23 Commits
Author SHA1 Message Date
nsrb 707e2c0f1e feat(plugins): enhance SMS sending functionality with raw HTTP handling
- Added support for using the `net` module in the `send-sms` plugin to establish raw HTTP connections.
- Refactored the `ztePost` function to `zteHttpPost`, allowing for custom HTTP request formatting and improved error handling.
- Introduced a new utility function, `httpBodyFromRaw`, to process raw HTTP responses effectively.
- Updated the `script-sandbox.js` to allow the use of the `net` and `node:net` modules in the sandbox environment.
2026-09-08 22:58:52 +07:00
nsrb 6c82ff20eb fix(plugins): enhance plugin installation and script handling
- Updated the `pnpm install` command in `plugin-install.js` to include the `--ignore-workspace` flag, ensuring proper installation of plugins within the app tree.
- Improved the `instantiateScriptSource` function in `script-sandbox.js` to resolve `pluginDir` more effectively, allowing for better package management.
- Added a new smoke test in `plugins-smoke.js` to validate the ability to require additional packages from plugin directories, enhancing testing coverage for plugin functionality.
- Updated documentation in `AGENTS.md` to reflect changes in the installation command.
2026-09-08 22:00:26 +07:00
nsrb 67ed3eecca feat(prayer-times): add Jakarta prayer time plugins and enhance ntfy integration
- Introduced two new plugins: `jakarta-prayer-time-parser-for-ntfy` for formatting prayer times as ntfy Markdown, and `jakarta-prayer-time-today-and-tomorrow` for fetching prayer times from Kemenag for today and tomorrow.
- Enhanced `ntfy.js` to support improved Markdown header handling and added a utility function for Markdown configuration validation.
- Updated plugin metadata and descriptions for clarity on functionality and usage.
- Implemented robust error handling and logging for better debugging and user feedback.
2026-08-30 21:25:09 +07:00
nsrb 80e9c91b88 refactor(scripts): remove admin role checks from plugin API endpoints
- Eliminated admin role checks from the create, install, and delete plugin API endpoints to simplify access control.
- Updated the ScriptsPage component to remove unnecessary user role checks, streamlining the installation process for all users.
- Improved UI by consolidating the zip installation button, enhancing user experience during plugin management.
2026-08-30 20:39:25 +07:00
nsrb 88d3692116 feat(scripts): add zip file installation functionality for plugins
- Introduced the ability to install plugins from zip files, enhancing the script management interface.
- Implemented file selection and base64 encoding for zip files before installation.
- Added confirmation dialog for installing plugins from zip, including warnings for existing plugins.
- Integrated user role checks to restrict zip installation to admin users only.
- Improved UI feedback during the installation process with loading indicators.
2026-08-30 20:27:26 +07:00
nsrb a20b26b4c7 feat(plugins): add duplicate plugin functionality and related API endpoints
- Implemented a new `duplicatePlugin` function to allow copying of installed plugins with a new ID.
- Added API endpoint for duplicating plugins, including error handling for various edge cases.
- Introduced frontend hooks and UI components for duplicating plugins in the script management interface.
- Enhanced tests to validate the duplication process and ensure proper error handling.
2026-08-30 19:33:36 +07:00
nsrb f1cdb7ac68 chore(paths): refactor data directory structure and update references
- Updated paths for instance data, workflows, and logs to use a unified `data/` directory.
- Adjusted related documentation in AGENTS.md and README.md to reflect the new data structure.
- Refactored path handling in server files to ensure consistency and clarity in data management.
- Enhanced test scripts to align with the new directory structure for improved organization.
2026-08-30 15:51:33 +07:00
nsrb 3f0774813d refactor(config): remove legacy config reference handling and streamline YAML management
- Deleted legacy config reference handling functions and related migration scripts to simplify the codebase.
- Updated documentation to reflect the removal of legacy YAML paths and configurations.
- Adjusted existing YAML management processes to ensure compatibility with the new mustache-style syntax.
- Removed tests related to legacy config references, focusing on current functionality and ensuring clarity in the testing suite.
2026-08-30 15:37:58 +07:00
nsrb 5615d989fa feat(migration): migrate legacy owner data and update config reference handling
- Implemented a migration process to move resources from the legacy owner "default" to the new default owner "local", ensuring no data loss during the transition.
- Updated configuration reference handling to replace legacy `$VAR_`, `$SECRET_`, and `$CONTEXT_` prefixes with mustache-style `{{ vars.name }}`, `{{ secrets.name }}`, and `{{ context.name }}`.
- Enhanced YAML configuration files and scripts to reflect the new mustache syntax, improving consistency across the application.
- Added tests to validate the migration process and ensure proper handling of legacy references.
- Updated documentation to guide users on the new configuration reference format.
2026-08-30 05:32:48 +07:00
nsrbandCursor e2e70a5aad fix(workflows): unregister missing yaml on trash
Ghost register entries returned 404 on delete and stayed in the list. Unregister first so move-to-trash still removes them when the file is gone.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-23 09:17:10 +07:00
nsrb dd98421b08 fix(server): refine error handling and PM2 connection logic
Deploy to Raspberry Pi / deploy (push) Canceled after 1m16s
- Reduced the maximum restarts for PM2 processes from 20 to 8 and added a restart delay of 2000ms for better stability.
- Improved error handling during server startup and PM2 connection, ensuring clearer logging and graceful exits on failure.
- Introduced a utility function to filter out PM2 metadata from environment variables, preventing conflicts during process management.
2026-08-23 08:45:26 +07:00
nsrb 69312c9080 feat(pm2): update PM2 configuration and introduce new start script
Deploy to Raspberry Pi / deploy (push) Canceled after 37s
- Updated PM2 version to 6.0.14 in package.json and pnpm-lock.yaml for improved stability.
- Changed interpreter for PM2 processes to use `process.execPath` for consistency.
- Added a new `start:pm2` script in package.json to streamline PM2 process management.
- Updated README to reflect changes in PM2 usage and instructions for starting the application.
2026-08-23 07:25:25 +07:00
nsrb c8697532f9 feat(ecosystem): add exec_mode to PM2 configuration for control and web server
Deploy to Raspberry Pi / deploy (push) Failing after 10s
- Introduced `exec_mode: "fork"` for both the control plane and web server processes to enhance performance and resource management.
2026-08-22 23:11:48 +07:00
nsrb be8122f9e5 feat(ecosystem): update PM2 configuration for control plane and web server
Deploy to Raspberry Pi / deploy (push) Successful in 52s
- Renamed the control process to `jflow-control` and updated the script path to `control.js`.
- Introduced a new `jflow-web` process for the production UI, serving on port 8500.
- Enhanced environment variable handling for both processes, ensuring proper port assignments.
- Updated README to reflect new process architecture and usage instructions for starting the application.
2026-08-22 22:32:04 +07:00
nsrb 0a8463d8f4 Merge branch 'main' into dev
Deploy to Raspberry Pi / deploy (push) Successful in 4m3s
2026-08-22 21:48:51 +07:00
nsrb 41cca86e6e feat(workflows): enhance script handling and introduce custom step names
- Updated AGENTS.md to include a `name` field for scripts, allowing custom display titles in the editor and graph.
- Enhanced script icon resolution to support `.jpeg` file extensions.
- Modified workflow parsing to accommodate the new `name` property for steps, improving step identification.
- Added new utility functions for handling custom step names and display names.
- Introduced tests to validate the functionality of custom step names in workflow YAML parsing and step labeling.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 21:48:18 +07:00
nsrb 71cc705cd2 feat(server): introduce admin reset functionality and enhance user management
- Added a new `reset-admin` script to reset or create the admin username and password.
- Updated the README to include instructions for resetting the admin login.
- Enhanced user management by allowing username updates during admin reset.
- Defaulted new resources to the internal namespace `local` for better organization.
- Removed unused owner selection from various components to streamline the UI.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 19:08:52 +07:00
nsrb 721f15e900 Merge branch 'dev' of https://git.home.0dev.web.id/nsrb/jerapah-flow into dev
Deploy to Raspberry Pi / deploy (push) Successful in 1m44s
2026-08-21 10:34:34 +07:00
nsrb 877ea9e3f8 Merge branch 'main' into dev 2026-08-21 09:31:01 +07:00
nsrb 93b51dcaaa Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Successful in 1m43s
2026-08-20 18:56:34 -04:00
nsrb eb7c318c13 Update .gitea/workflows/deploy.yaml
Deploy to Raspberry Pi / deploy (push) Failing after 4s
2026-08-20 18:40:36 -04:00
nsrb 864427b45c feat(workflows): add auto-disable feature for workflows on consecutive failures
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
- Introduced `disableOnConsecutiveFailures` option in workflow triggers to automatically disable workflows after reaching a specified failure threshold.
- Updated related functions to handle the new feature, including persistence of the disabled state and reloading of registries.
- Enhanced UI components to support the new option, allowing users to toggle the auto-disable feature in the workflow configuration.
2026-08-21 05:37:24 +07:00
nsrb 46aa8ca327 add deploy.yml for branch dev pushes
Deploy to Raspberry Pi / deploy (push) Canceled after 0s
2026-08-20 18:09:14 -04:00
98 changed files with 2692 additions and 1108 deletions
+33
View File
@@ -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
View File
@@ -1,6 +1,8 @@
node_modules/ node_modules/
.pnpm-store/ .pnpm-store/
# Instance data (SQLite, live workflows, control-state, backups)
data/ data/
# Process logs
logs/ logs/
*.db *.db
*.db-* *.db-*
@@ -10,7 +12,7 @@ packages/web/dist
# Personal/local scripts and workflows (not for the repo) # Personal/local scripts and workflows (not for the repo)
debug-*.js debug-*.js
# Legacy live workflow tree (migrated to packages/server/data/workflows/) # Legacy live workflow trees
packages/server/workflows/ packages/server/workflows/
# Plugin install staging and per-plugin deps # Plugin install staging and per-plugin deps
+10 -9
View File
@@ -4,14 +4,14 @@ This file tells agents how to add a **user plugin**. Do not put personal or site
## Workflows (instance data) ## 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 | | 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) | | 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 ## 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: Add `dependencies` only if the script `require()`s extra npm packages. Then:
```bash ```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. `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.data` | Step input (trigger payload, previous `output`, or DAG `needs`) |
| `ctx.context` | Run clipboard (plain object) | | `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` | | `output` | Next step’s `data` |
| `context` | Next clipboard. Omit to keep incoming | | `context` | Next clipboard. Omit to keep incoming |
@@ -138,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
```yaml ```yaml
scripts: scripts:
- script: plugin/my-plugin - name: Notify to channel
script: plugin/my-plugin
config: config:
url: http://10.8.0.6:3030/notes 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 ## 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`, …). - Use an id that matches a core script file (`ntfy`, `jsonata`, …).
- Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled). - Mismatch folder name and `jerapah-plugin.json` `id` (plugin is disabled).
- Commit `plugins/.staging-*` or `plugins/*/node_modules/`. - 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 `{`).
+69 -18
View File
@@ -20,7 +20,13 @@ pnpm dev
- UI (dev): http://localhost:8500 - UI (dev): http://localhost:8500
- API: http://localhost:8700 - 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 ### Process modes
@@ -49,16 +55,16 @@ The first account created becomes **admin**. Later accounts are created from Use
| Kind | Loaded by runner? | Location | | 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 | | **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. - 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).
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save). - 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`. - Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
```bash ```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_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \ JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
node packages/server/test/plugins-smoke.js 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.data` | This step’s input (trigger payload, previous `output`, or DAG `needs`) |
| `ctx.context` | Run clipboard (plain object, default `{}`) | | `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` | | `output` | Becomes the **next** step’s `data` |
| `context` | Next snapshot of the bag. Omitted → keep incoming | | `context` | Next snapshot of the bag. Omitted → keep incoming |
| `skipRemaining` | Stop later steps. Sibling of `output`/`context`, not inside `output` | | `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. 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. 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. 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:server` | Monolith API/runner only |
| `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) | | `pnpm dev:web` | UI only (proxies `/api` → :8700, `/ops` → :8600) |
| `pnpm build` | Production UI build | | `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: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:api` | HTTP API + cron enqueue (`JFLOW_ROLE=api`) |
| `pnpm start:worker` | BullMQ worker only | | `pnpm start:worker` | BullMQ worker only |
| `pnpm migrate` | Apply SQLite migrations | | `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 | | Drain restart | Pause → wait active=0 → stop children → migrate → recreate → resume |
| Force restart | Same without waiting (interrupts active runs; orphans marked `worker_lost`) | | 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 ## 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_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_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_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_WORKFLOWS_DIR` | `packages/server/data/workflows` | Live workflow YAML (instance data). | | `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_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. | | `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. | | `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_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_CONFIG_GENERATION` | `1` | Set by control/PM2 so children report config generation in heartbeats. |
| `JFLOW_CONTROL_PORT` | `8600` | Control ops API port. | | `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_LOG_LEVEL` | `debug` | Pino level |
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune | | `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Vite origin in dev | | `JFLOW_CORS_ORIGIN` | `http://localhost:8500` | Browser origin (Vite in dev, UI server in prod) |
| `PORT` | `8700` | HTTP API port | | `PORT` | `8700` | HTTP API port (alias; prefer `JFLOW_HTTP_PORT` under control) |
| `NODE_ENV` | — | Set `production` for secure cookies | | `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. 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 ## 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 ```bash
pnpm install pnpm install
pnpm build pnpm build
# Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH) # Redis must be reachable at REDIS_URL (set REDIS_PASS if Redis requires AUTH)
# Recommended: run control (migrates + manages PM2 HTTP/workers) # Put secrets in .env (JFLOW_JWT_SECRET, JFLOW_SECRETS_KEY, REDIS_URL, …)
JFLOW_JWT_SECRET=... JFLOW_SECRETS_KEY=... REDIS_URL=redis://127.0.0.1:6379 REDIS_PASS=... NODE_ENV=production pnpm start:control # Use in-tree PM2 6.x (same module control.js requires). A global `pm2` 7.x
# Or monolith (dev-style): # against a 6.x daemon pegs CPU even when ls shows only 2 fork instances.
# ... pnpm start 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
View File
@@ -1,7 +1,12 @@
/** /**
* Production PM2 ecosystem (monolith runner). * Production PM2 ecosystem (control plane + UI).
* Use for deployed/single-process starts. For local multi-process Ops UI, use * Starts always-on processes only; HTTP (:8700) and workers are owned by
* `pnpm dev:pm2` → packages/server/ecosystem.dev.cjs / control.js instead. * 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 fs = require("fs");
const path = require("path"); const path = require("path");
@@ -28,20 +33,42 @@ function loadEnv(file) {
} }
const root = __dirname; const root = __dirname;
const env = {
NODE_ENV: "production",
...loadEnv(path.join(root, ".env")),
};
module.exports = { module.exports = {
apps: [ apps: [
{ {
name: "jerapah-flow", name: "jflow-control",
cwd: root, cwd: root,
script: "packages/server/runner.js", script: "packages/server/control.js",
interpreter: "node", interpreter: process.execPath,
instances: 1, 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, autorestart: true,
max_restarts: 20, max_restarts: 20,
env: { env: {
NODE_ENV: "production", ...env,
...loadEnv(path.join(root, ".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 name: Comic - monkeyuser to ntfy
description: | 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: scripts:
- script: fetch-html.js - script: fetch-html.js
config: config:
@@ -20,7 +20,7 @@ scripts:
- script: fetch-binary.js - script: fetch-binary.js
- script: ntfy.js - script: ntfy.js
config: config:
url: $VAR_ntfy_channel url: "{{ vars.ntfy_channel }}"
triggers: triggers:
- type: HTTP - type: HTTP
method: POST method: POST
+3
View File
@@ -14,6 +14,9 @@
"start:api": "pnpm --filter @jerapah-flow/server start:api", "start:api": "pnpm --filter @jerapah-flow/server start:api",
"start:worker": "pnpm --filter @jerapah-flow/server start:worker", "start:worker": "pnpm --filter @jerapah-flow/server start:worker",
"start:control": "pnpm --filter @jerapah-flow/server start:control", "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", "migrate": "pnpm --filter @jerapah-flow/server migrate",
"test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test", "test": "pnpm --filter @jerapah-flow/shared test && pnpm --filter @jerapah-flow/web test",
"lint": "pnpm --filter @jerapah-flow/web lint" "lint": "pnpm --filter @jerapah-flow/web lint"
+2 -2
View File
@@ -1,8 +1,8 @@
import fs from "fs"; import fs from "fs";
import path from "path"; 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. * JerapahFlow app version from the monorepo root package.json.
+162 -78
View File
@@ -3,36 +3,24 @@ import { assertSecretName, getSecretPlaintext } from "./secrets-store.js";
import { isSecret } from "./secret-value.js"; import { isSecret } from "./secret-value.js";
import { assertVariableName, getVariablePlain } from "./variables-store.js"; import { assertVariableName, getVariablePlain } from "./variables-store.js";
const PREFIXES = [ const FORBIDDEN_SEGMENTS = new Set(["__proto__", "constructor", "prototype"]);
{ kind: "context", prefix: "$CONTEXT_" }, const MUSTACHE_TOKEN_RE =
{ kind: "secret", prefix: "$SECRET_" }, /\{\{\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)*)\s*\}\}/g;
{ kind: "var", prefix: "$VAR_" }, 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 {{
* @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx * owner: string,
* workflowKey?: string,
* context?: unknown,
* data?: unknown,
* }} ConfigRefCtx
*/ */
/** /**
* Parse a whole-value config placeholder. Returns null for literals. * Walk config (objects/arrays) and interpolate `{{ path }}` strings.
* @param {unknown} value * Does not walk trigger data.
* @returns {ConfigRef | null}
*/
export function parseConfigRef(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
for (const { kind, prefix } of PREFIXES) {
if (trimmed.startsWith(prefix)) {
return { kind, name: trimmed.slice(prefix.length), raw: trimmed };
}
}
return null;
}
/**
* Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$CONTEXT_` / `$VAR_`
* strings. Does not walk trigger data.
* *
* @param {unknown} value * @param {unknown} value
* @param {ConfigRefCtx} ctx * @param {ConfigRefCtx} ctx
@@ -68,83 +56,179 @@ export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
/** /**
* @param {string} value * @param {string} value
* @param {ConfigRefCtx} ctx * @param {ConfigRefCtx} ctx
* @returns {Promise<string | number | boolean>} * @returns {Promise<unknown>}
*/ */
async function resolveStringRef(value, ctx) { async function resolveStringRef(value, ctx) {
const ref = parseConfigRef(value); const whole = WHOLE_MUSTACHE_RE.exec(value);
if (!ref) return value; if (whole && whole[0] === value) {
return resolvePath(whole[1], ctx, { raw: value, allowObject: true });
}
if (ref.kind === "secret") { if (!value.includes("{{")) {
return resolveSecretRef(ref, ctx); 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 * @param {ConfigRefCtx} ctx
* @returns {Promise<string>} * @param {{ raw: string, allowObject: boolean }} opts
*/ */
async function resolveSecretRef(ref, ctx) { async function resolvePath(pathExpr, ctx, opts) {
try { const segments = pathExpr.split(".");
assertSecretName(ref.name); if (segments.length === 0 || segments.some((s) => !s)) {
} catch { throw new Error(`config ref ${opts.raw}: empty path`);
throw new Error(`config ref ${ref.raw}: invalid secret name`);
} }
const plaintext = await getSecretPlaintext(ctx.owner, ref.name); for (const seg of segments) {
if (FORBIDDEN_SEGMENTS.has(seg)) {
throw new Error(`config ref ${opts.raw}: forbidden path segment "${seg}"`);
}
}
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 {"var" | "secret"} kind
* @param {string[]} rest
* @param {ConfigRefCtx} ctx
* @param {{ raw: string, allowObject: boolean }} opts
*/
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 {
if (kind === "secret") assertSecretName(name);
else assertVariableName(name);
} catch {
throw new Error(`config ref ${opts.raw}: invalid ${kind} name`);
}
if (kind === "secret") {
const plaintext = await getSecretPlaintext(ctx.owner, name);
if (plaintext == null) { if (plaintext == null) {
throw new Error(`config ref ${ref.raw}: secret "${ref.name}" not found`); throw new Error(`config ref ${opts.raw}: secret "${name}" not found`);
} }
return plaintext; return plaintext;
} }
/** const value = await getVariablePlain(ctx.owner, name);
* @param {ConfigRef} ref
* @param {ConfigRefCtx} ctx
* @returns {Promise<string | number | boolean>}
*/
async function resolveVarRef(ref, ctx) {
if (ref.name.length === 0) {
throw new Error(`config ref ${ref.raw}: empty variable name`);
}
try {
assertVariableName(ref.name);
} catch {
throw new Error(`config ref ${ref.raw}: invalid variable name`);
}
const value = await getVariablePlain(ctx.owner, ref.name);
if (value == null) { 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; return value;
} }
/** /**
* @param {ConfigRef} ref * @param {unknown} root
* @param {ConfigRefCtx} ctx * @param {string[]} rest
* @returns {string} * @param {{ raw: string, allowObject: boolean }} opts
*/ */
function resolveContextRef(ref, ctx) { function walkObject(root, rest, opts) {
if (ref.name.length === 0) { if (rest.length === 0) {
throw new Error(`config ref ${ref.raw}: empty context key`); return unwrapValue(root, opts);
} }
const bag =
ctx.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context) let cur = root;
? /** @type {Record<string, unknown>} */ (ctx.context) for (const seg of rest) {
: {}; if (cur == null || typeof cur !== "object") {
if (!Object.prototype.hasOwnProperty.call(bag, ref.name)) { throw new Error(`config ref ${opts.raw}: path not found`);
throw new Error(`config ref ${ref.raw}: context "${ref.name}" not found`);
} }
const raw = bag[ref.name]; if (Array.isArray(cur)) {
if (isSecret(raw)) { if (!/^\d+$/.test(seg)) {
return raw.reveal(); throw new Error(`config ref ${opts.raw}: path not found`);
} }
const coerced = coerceCredentialString(raw); const idx = Number(seg);
if (coerced == null) { if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) {
throw new Error(`config ref ${ref.raw}: context "${ref.name}" is not a scalar`); throw new Error(`config ref ${opts.raw}: path not found`);
} }
return coerced; 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];
}
return unwrapValue(cur, opts);
}
/**
* @param {unknown} value
* @param {{ raw: string, allowObject: boolean }} opts
*/
function unwrapValue(value, opts) {
if (isSecret(value)) {
return value.reveal();
}
if (!opts.allowObject && value != null && typeof value === "object") {
throw new Error(`config ref ${opts.raw}: value is not a scalar`);
}
// 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`);
} }
+18 -15
View File
@@ -64,13 +64,6 @@ try {
process.exit(1); 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() { async function applyDesiredState() {
const state = readControlState(); const state = readControlState();
await ensureHttp({ await ensureHttp({
@@ -98,8 +91,6 @@ async function applyDesiredState() {
); );
} }
await applyDesiredState();
const server = fastify({ loggerInstance: log }); const server = fastify({ loggerInstance: log });
await server.register(cookie); await server.register(cookie);
await server.register(jwt, { await server.register(jwt, {
@@ -484,12 +475,24 @@ process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown); process.on("SIGTERM", shutdown);
const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600); const port = Number(process.env.JFLOW_CONTROL_PORT ?? process.env.PORT ?? 8600);
server try {
.listen({ host: "0.0.0.0", port }) await server.listen({ host: "0.0.0.0", port });
.then(() => {
log.info(`Control is running on port ${port}`); log.info(`Control is running on port ${port}`);
}) } catch (err) {
.catch((err) => {
log.error({ err }, "failed to start control"); log.error({ err }, "failed to start control");
process.exit(1); 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);
}
+2 -1
View File
@@ -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} * @returns {{ filePath: string, contentType: string } | null}
*/ */
export function resolveScriptIcon(name) { export function resolveScriptIcon(name) {
@@ -59,6 +59,7 @@ export function resolveScriptIcon(name) {
for (const { ext, contentType } of [ for (const { ext, contentType } of [
{ ext: "png", contentType: "image/png" }, { ext: "png", contentType: "image/png" },
{ ext: "jpg", contentType: "image/jpeg" }, { ext: "jpg", contentType: "image/jpeg" },
{ ext: "jpeg", contentType: "image/jpeg" },
]) { ]) {
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`); const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) { 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");
});
}
@@ -17,6 +17,9 @@ export async function up(knex) {
t.text("output"); t.text("output");
t.text("error"); t.text("error");
t.text("parent_run_id").references("id").inTable("workflow_runs"); 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( await knex.schema.raw(
@@ -28,45 +31,11 @@ export async function up(knex) {
await knex.schema.raw( await knex.schema.raw(
"CREATE INDEX workflow_runs_status_started_at_idx ON workflow_runs (status, started_at DESC)", "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( 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( 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 * @param {import("knex").Knex} knex
*/ */
export async function down(knex) { export async function down(knex) {
await knex.schema.dropTableIfExists("logs");
await knex.schema.dropTableIfExists("step_runs");
await knex.schema.dropTableIfExists("workflow_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");
}
@@ -29,14 +29,19 @@ const DEFAULT_EMAIL_TEMPLATE = `<!DOCTYPE html>
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function up(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.text("kind").notNullable().defaultTo("response");
t.integer("system").notNullable().defaultTo(0); t.integer("system").notNullable().defaultTo(0);
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
const existing = await knex("http_pages").where({ name: "email-default" }).first();
if (!existing) {
await knex("http_pages").insert({ await knex("http_pages").insert({
id: "00000000-0000-4000-8000-000000000001", id: "00000000-0000-4000-8000-000000000001",
name: "email-default", name: "email-default",
@@ -48,16 +53,11 @@ export async function up(knex) {
created_at: now, created_at: now,
updated_at: now, updated_at: now,
}); });
}
} }
/** /**
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function down(knex) { export async function down(knex) {
await knex("http_pages").where({ name: "email-default", system: 1 }).del(); await knex.schema.dropTableIfExists("http_pages");
await knex.schema.alterTable("http_pages", (t) => {
t.dropColumn("kind");
t.dropColumn("system");
});
} }
@@ -2,21 +2,11 @@
* @param {import("knex").Knex} knex * @param {import("knex").Knex} knex
*/ */
export async function up(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) => { await knex.schema.createTable("http_auths", (t) => {
t.text("id").primary(); t.text("id").primary();
t.text("name").notNullable().unique(); t.text("name").notNullable().unique();
t.text("type").notNullable(); // bearer | basic | header t.text("type").notNullable();
t.text("config").notNullable(); // JSON t.text("config").notNullable();
t.integer("unauthorized_status").nullable(); t.integer("unauthorized_status").nullable();
t.text("unauthorized_response").nullable(); t.text("unauthorized_response").nullable();
t.text("created_at").notNullable(); t.text("created_at").notNullable();
@@ -29,5 +19,4 @@ export async function up(knex) {
*/ */
export async function down(knex) { export async function down(knex) {
await knex.schema.dropTableIfExists("http_auths"); await knex.schema.dropTableIfExists("http_auths");
await knex.schema.dropTableIfExists("http_pages");
} }
@@ -21,29 +21,11 @@ export async function up(knex) {
await knex.schema.raw( await knex.schema.raw(
"CREATE INDEX workflow_revisions_workflow_id_created_at_idx ON workflow_revisions (workflow_id, created_at DESC)", "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 * @param {import("knex").Knex} knex
*/ */
export async function down(knex) { export async function down(knex) {
await knex.schema.dropTableIfExists("workflow_trash");
await knex.schema.dropTableIfExists("workflow_revisions"); 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");
}
@@ -14,7 +14,9 @@ export async function up(knex) {
t.unique(["owner", "name"]); 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)",
);
} }
/** /**
+97
View File
@@ -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",
});
}
+7 -4
View File
@@ -11,11 +11,14 @@
"start:api": "node server.js", "start:api": "node server.js",
"start:worker": "node worker.js", "start:worker": "node worker.js",
"start:control": "node control.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)))\"", "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: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_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-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: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": { "dependencies": {
"@jerapah-flow/shared": "workspace:*", "@jerapah-flow/shared": "workspace:*",
@@ -40,7 +43,7 @@
"nodemailer": "^9.0.5", "nodemailer": "^9.0.5",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-roll": "^4.0.0", "pino-roll": "^4.0.0",
"pm2": "^6.0.13", "pm2": "6.0.14",
"rss-parser": "^3.13.0", "rss-parser": "^3.13.0",
"ssh2-sftp-client": "^12.1.1", "ssh2-sftp-client": "^12.1.1",
"webdav": "^5.10.0", "webdav": "^5.10.0",
+39 -17
View File
@@ -1,27 +1,49 @@
import fs from "fs";
import path from "path"; import path from "path";
import { fileURLToPath } from "url"; import { fileURLToPath } from "url";
export const SERVER_ROOT = path.dirname(fileURLToPath(import.meta.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 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. */ /** Live instance workflows (not shipped in git). Override for tests. */
export const WORKFLOWS_DIR = export const WORKFLOWS_DIR = path.resolve(
process.env.JFLOW_WORKFLOWS_DIR ?? path.join(DATA_DIR, "workflows"); 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");
/** User plugins (repo-root /plugins, outside the pnpm workspace). */ /** User plugins (repo-root /plugins, outside the pnpm workspace). */
export const PLUGINS_DIR = export const PLUGINS_DIR = path.resolve(
process.env.JFLOW_PLUGINS_DIR ?? process.env.JFLOW_PLUGINS_DIR ?? path.join(REPO_ROOT, "plugins"),
path.resolve(SERVER_ROOT, "../../plugins"); );
/** Example plugin sources shipped with the repo. */ /** Example plugin sources shipped with the repo. */
export const EXAMPLE_PLUGINS_DIR = path.resolve( export const EXAMPLE_PLUGINS_DIR = path.join(REPO_ROOT, "examples/plugins");
SERVER_ROOT,
"../../examples/plugins",
);
/** Example workflow YAML presets (not loaded by the runner). */ /** Example workflow YAML presets (not loaded by the runner). */
export const EXAMPLE_WORKFLOWS_DIR = path.resolve( export const EXAMPLE_WORKFLOWS_DIR = path.join(REPO_ROOT, "examples/workflows");
SERVER_ROOT, export const LOGS_DIR = path.resolve(
"../../examples/workflows", 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.join(REPO_ROOT, "packages/web/dist");
export const WEB_DIST = path.resolve(SERVER_ROOT, "../web/dist");
+10 -1
View File
@@ -51,9 +51,18 @@ export async function pnpmInstallPlugin(dir) {
} }
if (!hasDeps) return { skipped: true }; 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( await execFileAsync(
"pnpm", "pnpm",
["install", "--dir", dir, "--ignore-scripts", "--prefer-offline"], [
"install",
"--dir",
dir,
"--ignore-scripts",
"--prefer-offline",
"--ignore-workspace",
],
{ {
cwd: dir, cwd: dir,
env: { ...process.env, CI: "1" }, env: { ...process.env, CI: "1" },
+90
View File
@@ -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 * @param {string} pluginDirectory
* @returns {((id: string) => unknown) | null} * @returns {((id: string) => unknown) | null}
+46 -4
View File
@@ -1,8 +1,6 @@
import path from "path"; import path from "path";
import pm2 from "pm2"; import pm2 from "pm2";
import { SERVER_ROOT } from "./paths.js"; import { REPO_ROOT, SERVER_ROOT } from "./paths.js";
const REPO_ROOT = path.resolve(SERVER_ROOT, "../..");
export const PM2_HTTP_NAME = "jflow-http"; export const PM2_HTTP_NAME = "jflow-http";
export const PM2_WORKER_NAME = "jflow-worker"; export const PM2_WORKER_NAME = "jflow-worker";
@@ -164,13 +162,57 @@ export async function restartPm2Process(pmId) {
return { name: proc.name, pmId: id }; 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. * Shared env for child processes.
* @param {{ generation: number }} opts * @param {{ generation: number }} opts
*/ */
export function childEnv(opts) { export function childEnv(opts) {
return { return {
...process.env, ...withoutPm2Meta(process.env),
JFLOW_CONFIG_GENERATION: String(opts.generation), JFLOW_CONFIG_GENERATION: String(opts.generation),
JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500", JFLOW_CORS_ORIGIN: process.env.JFLOW_CORS_ORIGIN ?? "http://localhost:8500",
PORT: process.env.JFLOW_HTTP_PORT ?? "8700", PORT: process.env.JFLOW_HTTP_PORT ?? "8700",
+54 -1
View File
@@ -36,7 +36,9 @@ import {
resolveFailureTriggerConfig, resolveFailureTriggerConfig,
} from "./trigger-failure.js"; } from "./trigger-failure.js";
import { enqueueWorkflowJob } from "./workflow-queue.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 * @typedef {{ owner: string, file: string, workflow: any }} WorkflowEntry
@@ -509,6 +511,7 @@ export function createRegistry(server, opts = {}) {
owner, owner,
workflowKey: key, workflowKey: key,
context: incomingContext, context: incomingContext,
data: ctx.data,
}); });
const stepCtx = { const stepCtx = {
data: ctx.data, 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 {{ * @param {{
* key: string, * key: string,
@@ -589,6 +631,17 @@ export function createRegistry(server, opts = {}) {
return; 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 destKey = resolveWorkflowTriggerKey(opts.owner, failureConfig.workflowName);
const alertData = buildFailureAlertData({ const alertData = buildFailureAlertData({
sourceKey: opts.key, sourceKey: opts.key,
+105
View File
@@ -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();
}
+13 -3
View File
@@ -11,6 +11,7 @@ import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js"; import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
import { getSecretPlaintext } from "./secrets-store.js"; import { getSecretPlaintext } from "./secrets-store.js";
import { getVariablePlain } from "./variables-store.js"; import { getVariablePlain } from "./variables-store.js";
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
const hostRequire = createRequire(import.meta.url); const hostRequire = createRequire(import.meta.url);
@@ -21,7 +22,9 @@ const ALLOWED_MODULES = new Set([
"basic-ftp", "basic-ftp",
"jsonata", "jsonata",
"mustache", "mustache",
"net",
"node-html-parser", "node-html-parser",
"node:net",
"node:stream", "node:stream",
"nodemailer", "nodemailer",
"rss-parser", "rss-parser",
@@ -443,7 +446,7 @@ function createScriptSandbox({
log, log,
script, script,
workflowName, workflowName,
owner = "default", owner = DEFAULT_OWNER,
$workflows = $workflowsStub, $workflows = $workflowsStub,
pluginDir = null, pluginDir = null,
}) { }) {
@@ -556,16 +559,23 @@ function instantiateCompiled(
* $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> }, * $workflows?: { trigger: (name: string, data?: unknown) => Promise<unknown> },
* pluginDir?: string | null, * pluginDir?: string | null,
* }} [opts] * }} [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 = {}) { export function instantiateScriptSource(script, source, opts = {}) {
const compiled = compileScriptSource(source, script); const compiled = compileScriptSource(source, script);
const pluginDir =
"pluginDir" in opts
? opts.pluginDir ?? null
: (resolveScriptRef(script).pluginDir ?? null);
const fn = instantiateCompiled(compiled, { const fn = instantiateCompiled(compiled, {
log: opts.log ?? inspectLog, log: opts.log ?? inspectLog,
script, script,
workflowName: opts.workflowName ?? "inspect", workflowName: opts.workflowName ?? "inspect",
owner: opts.owner ?? "default", owner: opts.owner ?? DEFAULT_OWNER,
$workflows: opts.$workflows, $workflows: opts.$workflows,
pluginDir: opts.pluginDir ?? null, pluginDir,
}); });
return { fn, ...extractScriptMeta(fn) }; 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

+21
View File
@@ -15,9 +15,24 @@ function ntfyHeaders(ctx) {
headers.Title = ctx.data.title; 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; 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) { function resolveFingerprintKey(config) {
const fingerprint = config?.fingerprint; const fingerprint = config?.fingerprint;
if (fingerprint === true) return "fingerprint:ntfy"; if (fingerprint === true) return "fingerprint:ntfy";
@@ -149,6 +164,12 @@ ntfy.meta = {
default: "https://ntfy.sh/jerapah-flow", default: "https://ntfy.sh/jerapah-flow",
description: "ntfy topic URL", 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: { fingerprint: {
type: "string", type: "string",
required: false, required: false,
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+40 -5
View File
@@ -8,6 +8,7 @@ import {
import * as fsStore from "../../fs-store.js"; import * as fsStore from "../../fs-store.js";
import { import {
forkCoreScript, forkCoreScript,
duplicatePlugin,
listCoreScriptNames, listCoreScriptNames,
listInstalledPlugins, listInstalledPlugins,
resolveScriptRef, resolveScriptRef,
@@ -29,8 +30,9 @@ import { normalizeStepResult } from "../../step-result.js";
import { resolveConfigRefs } from "../../config-refs.js"; import { resolveConfigRefs } from "../../config-refs.js";
import { getAppVersion } from "../../app-version.js"; import { getAppVersion } from "../../app-version.js";
import { EXAMPLE_PLUGINS_DIR } from "../../paths.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 { evaluateJsonata, SET_STEP_SCRIPT } from "../../workflow-parse.js";
import { DEFAULT_OWNER } from "@jerapah-flow/shared";
/** /**
* @param {{ referencedScripts: () => Set<string> }} registry * @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) => { fastify.post("/scripts/:name/dry-run", async (req, reply) => {
const rawName = decodeURIComponent( const rawName = decodeURIComponent(
/** @type {{ name: string }} */ (req.params).name, /** @type {{ name: string }} */ (req.params).name,
@@ -258,7 +294,7 @@ export default function scriptsPluginFactory(registry) {
req.body ?? {} req.body ?? {}
); );
let owner = "default"; let owner = DEFAULT_OWNER;
if (body.owner != null && body.owner !== "") { if (body.owner != null && body.owner !== "") {
try { try {
owner = fsStore.assertOwner(String(body.owner)); owner = fsStore.assertOwner(String(body.owner));
@@ -304,6 +340,7 @@ export default function scriptsPluginFactory(registry) {
owner, owner,
workflowKey: "dry-run", workflowKey: "dry-run",
context: incomingContext, context: incomingContext,
data: incomingData,
}, },
); );
const ctx = { const ctx = {
@@ -365,6 +402,7 @@ export default function scriptsPluginFactory(registry) {
owner, owner,
workflowKey: "dry-run", workflowKey: "dry-run",
context: incomingContext, context: incomingContext,
data: incomingData,
}); });
const ctx = { const ctx = {
data: incomingData, data: incomingData,
@@ -428,7 +466,6 @@ export default function scriptsPluginFactory(registry) {
fastify.post( fastify.post(
"/plugins/create", "/plugins/create",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => { async (req, reply) => {
const body = /** @type {{ id?: string, content?: string, description?: string }} */ ( const body = /** @type {{ id?: string, content?: string, description?: string }} */ (
req.body ?? {} req.body ?? {}
@@ -460,7 +497,6 @@ export default function scriptsPluginFactory(registry) {
fastify.post( fastify.post(
"/plugins/install", "/plugins/install",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => { async (req, reply) => {
const body = /** @type {{ const body = /** @type {{
source?: string, source?: string,
@@ -535,7 +571,6 @@ export default function scriptsPluginFactory(registry) {
fastify.delete( fastify.delete(
"/plugins/:id", "/plugins/:id",
{ onRequest: [fastify.requireAdmin] },
async (req, reply) => { async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params); const { id } = /** @type {{ id: string }} */ (req.params);
try { try {
+3 -3
View File
@@ -76,6 +76,7 @@ function triggerSummary(owner, workflow, nameById) {
schedule: t?.schedule ?? null, schedule: t?.schedule ?? null,
onConsecutiveFailures: t?.onConsecutiveFailures ?? null, onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
onFailureWorkflow: t?.onFailureWorkflow ?? null, onFailureWorkflow: t?.onFailureWorkflow ?? null,
disableOnConsecutiveFailures: t?.disableOnConsecutiveFailures === true,
auth: isHttp ? authLabel(t?.auth, nameById) : null, auth: isHttp ? authLabel(t?.auth, nameById) : null,
}; };
}); });
@@ -705,16 +706,15 @@ export default function workflowsPluginFactory(registry) {
return reply.code(err.statusCode ?? 400).send({ error: err.message }); return reply.code(err.statusCode ?? 400).send({ error: err.message });
} }
const raw = fsStore.readWorkflowYaml(owner, file); const raw = fsStore.readWorkflowYaml(owner, file);
if (raw == null) {
return reply.code(404).send({ error: "workflow not found" });
}
let name = null; let name = null;
if (raw != null) {
try { try {
const parsed = yaml.parse(raw); const parsed = yaml.parse(raw);
name = parsed?.name ?? null; name = parsed?.name ?? null;
} catch { } catch {
// ignore // ignore
} }
}
try { try {
const item = await moveWorkflowToTrash({ const item = await moveWorkflowToTrash({
workflowId: workflowIdFromFile(file), workflowId: workflowIdFromFile(file),
-7
View File
@@ -31,7 +31,6 @@ import {
getRedisUrlForLog, getRedisUrlForLog,
} from "./workflow-queue.js"; } from "./workflow-queue.js";
import { purgeExpiredTrash } from "./workflow-trash.js"; import { purgeExpiredTrash } from "./workflow-trash.js";
import { migrateLegacyWorkflowsIfNeeded } from "./workflow-migrate.js";
import { import {
getConfigGeneration, getConfigGeneration,
startHeartbeatLoop, 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, { const registry = createRegistry(server, {
queue: workflowQueue, queue: workflowQueue,
// Cron + HTTP triggers enqueue jobs; only the API process may own them. // Cron + HTTP triggers enqueue jobs; only the API process may own them.
+2 -1
View File
@@ -582,12 +582,13 @@ export async function listUsers() {
/** /**
* @param {string} id * @param {string} id
* @param {{ passwordHash?: string, role?: string }} patch * @param {{ passwordHash?: string, role?: string, username?: string }} patch
*/ */
export async function updateUser(id, patch) { export async function updateUser(id, patch) {
const update = { updated_at: nowIso() }; const update = { updated_at: nowIso() };
if (patch.passwordHash) update.password_hash = patch.passwordHash; if (patch.passwordHash) update.password_hash = patch.passwordHash;
if (patch.role) update.role = patch.role; if (patch.role) update.role = patch.role;
if (patch.username) update.username = patch.username;
await db("users").where({ id }).update(update); await db("users").where({ id }).update(update);
return getUserById(id); return getUserById(id);
} }
+94 -94
View File
@@ -2,7 +2,7 @@ import { migrate, db } from "../db.js";
import { upsertSecret, deleteSecret } from "../secrets-store.js"; import { upsertSecret, deleteSecret } from "../secrets-store.js";
import { deleteVariable, upsertVariable } from "../variables-store.js"; import { deleteVariable, upsertVariable } from "../variables-store.js";
import { Secret } from "../secret-value.js"; import { Secret } from "../secret-value.js";
import { parseConfigRef, resolveConfigRefs } from "../config-refs.js"; import { resolveConfigRefs } from "../config-refs.js";
await migrate(); await migrate();
@@ -23,126 +23,138 @@ async function assertRejects(fn, match) {
throw new Error(`expected to reject (${match ?? "any error"})`); throw new Error(`expected to reject (${match ?? "any error"})`);
} }
const owner = "default"; const owner = "config_refs_smoke_owner";
const workflowKey = "default/config-refs-smoke.yaml"; const ctx = { owner, workflowKey: `${owner}/config-refs-smoke.yaml`, context: {}, data: {} };
const ctx = { owner, workflowKey, context: {} };
function assertParse(value, expected) {
const got = parseConfigRef(value);
if (expected == null) {
assert(got == null, `expected null parse for ${JSON.stringify(value)}, got ${JSON.stringify(got)}`);
return;
}
assert(got != null, `expected parse for ${JSON.stringify(value)}`);
assert(got.kind === expected.kind, `kind ${got.kind} !== ${expected.kind}`);
assert(got.name === expected.name, `name ${JSON.stringify(got.name)} !== ${JSON.stringify(expected.name)}`);
}
assertParse("password123", null);
assertParse("$FOO_bar", null);
assertParse("$SECRET", null);
assertParse(" password123 ", null);
assertParse("$SECRET_zte_modem_password", { kind: "secret", name: "zte_modem_password" });
assertParse(" $SECRET_zte_modem_password ", { kind: "secret", name: "zte_modem_password" });
assertParse("Bearer $SECRET_x", null);
assertParse("$KV_modem password", null);
assertParse("$VAR_ntfy_url", { kind: "var", name: "ntfy_url" });
assertParse("$CONTEXT_token", { kind: "context", name: "token" });
assertParse("$SECRET_", { kind: "secret", name: "" });
assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" });
{ {
const literal = await resolveConfigRefs("password123", ctx); const literal = await resolveConfigRefs("password123", ctx);
assert(literal === "password123", "literal passthrough"); assert(literal === "password123", "literal passthrough");
const unknown = await resolveConfigRefs("$FOO_bar", ctx); const unknown = await resolveConfigRefs("$FOO_bar", ctx);
assert(unknown === "$FOO_bar", "$FOO_bar stays literal"); 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); const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx);
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal"); assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
const number = await resolveConfigRefs(42, ctx); const number = await resolveConfigRefs(42, ctx);
assert(number === 42, "number passthrough"); assert(number === 42, "number passthrough");
} }
const secret = await upsertSecret({ const created = [];
try {
const secret = await upsertSecret({
owner, owner,
name: "config_refs_smoke_token", name: "config_refs_smoke_token",
value: "s3cret-ok", value: "s3cret-ok",
}); });
const varUrl = await upsertVariable({ created.push(["secret", secret.id]);
const varUrl = await upsertVariable({
owner, owner,
name: "config_refs_smoke_url", name: "config_refs_smoke_url",
type: "string", type: "string",
value: "https://example.test", value: "https://example.test",
}); });
const varRetry = await upsertVariable({ created.push(["var", varUrl.id]);
const varRetry = await upsertVariable({
owner, owner,
name: "config_refs_smoke_retry", name: "config_refs_smoke_retry",
type: "number", type: "number",
value: 3, value: 3,
}); });
const varDebug = await upsertVariable({ created.push(["var", varRetry.id]);
const varDebug = await upsertVariable({
owner, owner,
name: "config_refs_smoke_debug", name: "config_refs_smoke_debug",
type: "boolean", type: "boolean",
value: false, value: false,
}); });
created.push(["var", varDebug.id]);
try {
{ {
const resolved = await resolveConfigRefs("$SECRET_config_refs_smoke_token", ctx); const resolved = await resolveConfigRefs("{{ secrets.config_refs_smoke_token }}", ctx);
assert(resolved === "s3cret-ok", "secret resolve"); assert(resolved === "s3cret-ok", "secret whole-value");
} }
{ {
const resolved = await resolveConfigRefs(" $SECRET_config_refs_smoke_token ", ctx); const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_url }}", ctx);
assert(resolved === "s3cret-ok", "secret resolve trimmed");
}
{
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_url", ctx);
assert(resolved === "https://example.test", "var string"); assert(resolved === "https://example.test", "var string");
} }
{ {
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_retry", ctx); const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_retry }}", ctx);
assert(resolved === 3, "var number stays number"); assert(resolved === 3, "var number keeps type");
assert(typeof resolved === "number", "var number type");
} }
{ {
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_debug", ctx); const resolved = await resolveConfigRefs("{{ vars.config_refs_smoke_debug }}", ctx);
assert(resolved === false, "var boolean stays false"); assert(resolved === false, "var boolean keeps type");
assert(typeof resolved === "boolean", "var boolean type");
} }
{ {
const resolved = await resolveConfigRefs("$CONTEXT_token", { const resolved = await resolveConfigRefs("{{ context.token }}", {
...ctx, ...ctx,
context: { token: "ctx-token-ok" }, context: { token: "ctx-token-ok" },
}); });
assert(resolved === "ctx-token-ok", "context string"); assert(resolved === "ctx-token-ok", "context string");
} }
{ {
const resolved = await resolveConfigRefs("$CONTEXT_n", { const resolved = await resolveConfigRefs("{{ context.n }}", {
...ctx, ...ctx,
context: { n: 7 }, context: { n: 7 },
}); });
assert(resolved === "7", "context number stringify"); assert(resolved === 7, "context number keeps type");
} }
{ {
const wrapped = new Secret("wrapped-secret-ok"); const wrapped = new Secret("wrapped-secret-ok");
const resolved = await resolveConfigRefs("$CONTEXT_tok", { const resolved = await resolveConfigRefs("{{ context.tok }}", {
...ctx, ...ctx,
context: { tok: wrapped }, context: { tok: wrapped },
}); });
assert(resolved === "wrapped-secret-ok", "context Secret unwrap"); 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( const nested = await resolveConfigRefs(
{ {
url: "$VAR_config_refs_smoke_url", url: "{{ vars.config_refs_smoke_url }}",
retry: "$VAR_config_refs_smoke_retry", retry: "{{ vars.config_refs_smoke_retry }}",
debug: "$VAR_config_refs_smoke_debug", debug: "{{ vars.config_refs_smoke_debug }}",
password: "$SECRET_config_refs_smoke_token", password: "{{ secrets.config_refs_smoke_token }}",
headers: { Authorization: "$KV_modem_password" }, headers: { Authorization: "$KV_modem_password" },
extra: ["$CONTEXT_token", "plain"], extra: ["{{ context.token }}", "plain"],
}, },
{ ...ctx, context: { token: "ctx-token-ok" } }, { ...ctx, context: { token: "ctx-token-ok" } },
); );
@@ -155,59 +167,47 @@ try {
assert(nested.extra[1] === "plain", "nested array literal"); assert(nested.extra[1] === "plain", "nested array literal");
} }
const data = { password: "$SECRET_config_refs_smoke_token" }; const data = { password: "{{ secrets.config_refs_smoke_token }}" };
const config = { password: "$SECRET_config_refs_smoke_token" }; const config = { password: "{{ secrets.config_refs_smoke_token }}" };
const resolvedConfig = await resolveConfigRefs(config, ctx); const resolvedConfig = await resolveConfigRefs(config, ctx);
assert(resolvedConfig.password === "s3cret-ok", "config resolved"); assert(resolvedConfig.password === "s3cret-ok", "config resolved");
assert(data.password === "$SECRET_config_refs_smoke_token", "data not walked"); assert(data.password === "{{ secrets.config_refs_smoke_token }}", "data not walked");
assert(config.password === "$SECRET_config_refs_smoke_token", "input config not mutated"); assert(config.password === "{{ secrets.config_refs_smoke_token }}", "input config not mutated");
await assertRejects( await assertRejects(
() => resolveConfigRefs("$SECRET_does_not_exist_xyz", ctx), () => resolveConfigRefs("{{ secrets.does_not_exist_xyz }}", ctx),
'secret "does_not_exist_xyz" not found', 'secret "does_not_exist_xyz" not found',
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$SECRET_not valid", ctx), () => resolveConfigRefs("{{ context.missing }}", ctx),
"invalid secret name", "path not found",
);
await assertRejects(
() => resolveConfigRefs("$SECRET_", ctx),
"invalid secret name",
);
await assertRejects(
() => resolveConfigRefs("$CONTEXT_missing", ctx),
'context "missing" not found',
); );
await assertRejects( await assertRejects(
() => () =>
resolveConfigRefs("$CONTEXT_obj", { resolveConfigRefs("Bearer {{ context.obj }}", {
...ctx, ...ctx,
context: { obj: { a: 1 } }, context: { obj: { a: 1 } },
}), }),
'context "obj" is not a scalar', "not a scalar",
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$CONTEXT_", ctx), () => resolveConfigRefs("{{ vars }}", ctx),
"empty context key", "empty var name",
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx), () => resolveConfigRefs("{{ title }}", ctx),
'variable "does_not_exist_xyz" not found', "unknown root",
); );
await assertRejects( await assertRejects(
() => resolveConfigRefs("$VAR_not valid", ctx), () => resolveConfigRefs("{{ context.__proto__.x }}", ctx),
"invalid variable name", "forbidden path segment",
);
await assertRejects(
() => resolveConfigRefs("$VAR_", ctx),
"empty variable name",
); );
} finally { } finally {
await deleteSecret(secret.id); for (const [kind, id] of created.reverse()) {
await deleteVariable(varUrl.id); if (kind === "secret") await deleteSecret(id);
await deleteVariable(varRetry.id); else await deleteVariable(id);
await deleteVariable(varDebug.id); }
await db.destroy();
} }
console.log("config-refs smoke test passed"); console.log("config-refs smoke test passed");
await db.destroy();
+72
View File
@@ -2,20 +2,24 @@
* Smoke: core vs plugin scripts, fork, example install, resolve, run. * Smoke: core vs plugin scripts, fork, example install, resolve, run.
* *
* Run: * Run:
* JFLOW_DATA_DIR=packages/server/data \
* JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \ * JFLOW_PLUGINS_DIR=packages/server/data/plugins-smoke-test \
* JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \ * JFLOW_DB_PATH=packages/server/data/plugins-smoke.db \
* node packages/server/test/plugins-smoke.js * node packages/server/test/plugins-smoke.js
*/ */
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import fs from "fs"; import fs from "fs";
import path from "node:path";
import { migrate, db } from "../db.js"; import { migrate, db } from "../db.js";
import { getAppVersion, satisfiesRange } from "../app-version.js"; import { getAppVersion, satisfiesRange } from "../app-version.js";
import { import {
forkCoreScript, forkCoreScript,
duplicatePlugin,
resolveScriptRef, resolveScriptRef,
uninstallPlugin, uninstallPlugin,
listInstalledPlugins, listInstalledPlugins,
createBlankPlugin, createBlankPlugin,
pluginDir,
} from "../plugin-store.js"; } from "../plugin-store.js";
import { installExamplePlugin } from "../plugin-install.js"; import { installExamplePlugin } from "../plugin-install.js";
import { import {
@@ -82,6 +86,26 @@ async function main() {
); );
assert.equal(blankRun.output.ok, true); 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; let hit = false;
try { try {
forkCoreScript("ntfy.js", "ntfy"); forkCoreScript("ntfy.js", "ntfy");
@@ -97,10 +121,58 @@ async function main() {
); );
assert.ok(meta); 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")); assert.ok(listInstalledPlugins().some((p) => p.id === "get-current-time"));
uninstallPlugin("jsonata-smoke-fork"); uninstallPlugin("jsonata-smoke-fork");
uninstallPlugin("blank-smoke"); uninstallPlugin("blank-smoke");
uninstallPlugin("blank-smoke-copy");
uninstallPlugin("extra-require-smoke");
uninstallPlugin("get-current-time"); uninstallPlugin("get-current-time");
console.log("plugins-smoke: ok"); console.log("plugins-smoke: ok");
+2 -2
View File
@@ -71,11 +71,11 @@ const created = await upsertProfile({
owner, owner,
name, name,
script: "ntfy.js", script: "ntfy.js",
config: { url: "$VAR_ntfy_channel" }, config: { url: "{{ vars.ntfy_channel }}" },
description: "smoke", description: "smoke",
}); });
assert(created.name === name, "created"); 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"); assert(created.script === "ntfy.js", "script locked on profile");
const fetched = await getProfilePlain(owner, name); const fetched = await getProfilePlain(owner, name);
@@ -136,6 +136,18 @@ const warnings = collectWorkflowWarnings(
); );
assert.ok(warnings.warnings.some((w) => w.code === "unknown_script")); 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({ const trashed = await moveWorkflowToTrash({
workflowId, workflowId,
owner, owner,
+19 -5
View File
@@ -47,12 +47,18 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
const threshold = Number(spec.onConsecutiveFailures); const threshold = Number(spec.onConsecutiveFailures);
const workflowName = onFailureWorkflowName(spec); 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 null;
} }
return { return {
threshold: Math.floor(threshold), threshold: Math.floor(threshold),
workflowName, workflowName: workflowName.length > 0 ? workflowName : null,
disableOnConsecutiveFailures,
}; };
} }
return null; return null;
@@ -66,6 +72,13 @@ function onFailureWorkflowName(trigger) {
return typeof value === "string" ? value.trim() : ""; return typeof value === "string" ? value.trim() : "";
} }
/**
* @param {Record<string, unknown>} trigger
*/
export function isDisableOnConsecutiveFailures(trigger) {
return trigger?.disableOnConsecutiveFailures === true;
}
/** /**
* @param {unknown} workflow * @param {unknown} workflow
*/ */
@@ -81,12 +94,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
const hasThreshold = const hasThreshold =
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== ""; trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
const hasWorkflow = onFailureWorkflowName(trigger).length > 0; 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( const err = new Error(
"onConsecutiveFailures and onFailureWorkflow must both be set on a trigger", "onConsecutiveFailures requires onFailureWorkflow and/or disableOnConsecutiveFailures",
); );
err.statusCode = 400; err.statusCode = 400;
throw err; throw err;
+116
View File
@@ -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);
}
+9 -3
View File
@@ -1,4 +1,4 @@
import { HTTP_METHODS } from "@jerapah-flow/shared"; import { HTTP_METHODS, DEFAULT_OWNER } from "@jerapah-flow/shared";
import { import {
checkAnyHttpAuth, checkAnyHttpAuth,
resolveAuthMechanisms, resolveAuthMechanisms,
@@ -85,8 +85,14 @@ export function createHttpTriggerHandler({
return async function dispatchHttpTrigger(req, reply) { return async function dispatchHttpTrigger(req, reply) {
const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? ""; const wildcard = /** @type {{ "*": string }} */ (req.params)["*"] ?? "";
const url = `/u/${String(wildcard).replace(/^\/+/, "")}`; 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 method = String(req.method ?? "GET").toUpperCase();
const routeKey = `${method} ${url}`; const routeKey = `${method} ${compatUrl}`;
const mapped = httpRoutes.get(routeKey); const mapped = httpRoutes.get(routeKey);
if (!mapped) { if (!mapped) {
@@ -104,7 +110,7 @@ export function createHttpTriggerHandler({
if (t?.type !== "HTTP") return false; if (t?.type !== "HTTP") return false;
const m = String(t.method ?? "POST").toUpperCase(); const m = String(t.method ?? "POST").toUpperCase();
const p = namespacedPath(entry.owner, t.path); const p = namespacedPath(entry.owner, t.path);
return m === method && p === url; return m === method && p === compatUrl;
}) ?? mapped.trigger; }) ?? mapped.trigger;
if ( if (
-51
View File
@@ -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;
}
+18
View File
@@ -11,6 +11,7 @@ export const SET_STEP_SCRIPT = "set";
* profile: string | null, * profile: string | null,
* config: unknown | null, * config: unknown | null,
* expression?: undefined, * expression?: undefined,
* name: string | null,
* id: string | null, * id: string | null,
* needsKind: "none" | "list" | "map", * needsKind: "none" | "list" | "map",
* needs: NeedEdge[], * needs: NeedEdge[],
@@ -22,6 +23,7 @@ export const SET_STEP_SCRIPT = "set";
* profile: null, * profile: null,
* config: { expression: string }, * config: { expression: string },
* expression: string, * expression: string,
* name: string | null,
* id: string | null, * id: string | null,
* needsKind: "none" | "list" | "map", * needsKind: "none" | "list" | "map",
* needs: NeedEdge[], * needs: NeedEdge[],
@@ -80,6 +82,7 @@ export function parseScriptStep(step) {
script: step, script: step,
profile: null, profile: null,
config: null, config: null,
name: null,
id: null, id: null,
needsKind: "none", needsKind: "none",
needs: [], needs: [],
@@ -119,6 +122,7 @@ export function parseScriptStep(step) {
script: hasScript ? step.script : "", script: hasScript ? step.script : "",
profile: hasProfile ? step.profile : null, profile: hasProfile ? step.profile : null,
config: step.config ?? null, config: step.config ?? null,
name: parseOptionalName(step.name),
id: parseOptionalId(step.id), id: parseOptionalId(step.id),
needsKind, needsKind,
needs, needs,
@@ -271,6 +275,7 @@ function parseSetStep(step) {
profile: null, profile: null,
config: { expression }, config: { expression },
expression, expression,
name: parseOptionalName(step.name),
id: parseOptionalId(step.id), id: parseOptionalId(step.id),
needsKind, needsKind,
needs, needs,
@@ -291,6 +296,19 @@ function parseWhen(when) {
return 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 * @param {unknown} id
* @returns {string | null} * @returns {string | null}
+26 -6
View File
@@ -62,29 +62,49 @@ export async function isInTrash(owner, file) {
return Boolean(row); 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 {{ * @param {{
* workflowId: string, * workflowId: string,
* owner: string, * owner: string,
* file: string, * file: string,
* name?: string | null, * name?: string | null,
* }} opts * }} opts
* @returns {Promise<ReturnType<typeof rowToItem> | null>} trash item, or null if there was no file to keep
*/ */
export async function moveWorkflowToTrash(opts) { export async function moveWorkflowToTrash(opts) {
unregisterWorkflow(opts.owner, opts.file);
const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file); const sourcePath = path.join(WORKFLOWS_DIR, opts.owner, opts.file);
if (!fs.existsSync(sourcePath)) { if (!fs.existsSync(sourcePath)) {
const err = new Error("workflow not found"); const existing = await db("workflow_trash")
err.statusCode = 404; .where({ owner: opts.owner, file: opts.file })
throw err; .first();
return existing ? rowToItem(existing) : null;
} }
const trashPath = trashFilePath(opts.owner, opts.file); const trashPath = trashFilePath(opts.owner, opts.file);
fs.mkdirSync(path.dirname(trashPath), { recursive: true }); fs.mkdirSync(path.dirname(trashPath), { recursive: true });
fs.renameSync(sourcePath, trashPath); fs.renameSync(sourcePath, trashPath);
const registered = fsStore.readRegisters(opts.owner).filter((f) => f !== opts.file); const existing = await db("workflow_trash")
fsStore.writeRegisters(opts.owner, registered); .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 id = randomUUID();
const deleted_at = nowIso(); const deleted_at = nowIso();
+1
View File
@@ -2,3 +2,4 @@ export { isPlainObject } from "./is-plain-object.js";
export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js"; export { mergeProfileConfig, overlayFromMerged, configHasOverlay } from "./profile-config.js";
export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js"; export { ensureWorkflowFilename, suggestCopyFilename } from "./workflow-filename.js";
export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js"; export { HTTP_METHODS, namespacedPath, hasWorkflowTrigger } from "./workflow-path.js";
export { DEFAULT_OWNER } from "./tenant.js";
+2
View File
@@ -0,0 +1,2 @@
/** Default owner folder for new resources (latent tenant id). */
export const DEFAULT_OWNER = "local";
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"lint": "eslint src", "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": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
+17
View File
@@ -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() { export function useInstallPlugin() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
@@ -1,22 +1,19 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useDuplicateWorkflow, useOwners } from "../api/hooks.js"; import { useDuplicateWorkflow } from "../api/hooks.js";
import { useNotifications } from "../notifications.jsx"; import { useNotifications } from "../notifications.jsx";
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) { export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
const { notify } = useNotifications(); const { notify } = useNotifications();
const { data: owners = [] } = useOwners();
const duplicate = useDuplicateWorkflow(); const duplicate = useDuplicateWorkflow();
const [destOwner, setDestOwner] = useState(source.owner);
function onSubmit(e) { function onSubmit(e) {
e.preventDefault(); e.preventDefault();
if (destOwner === source.owner && duplicate.isPending) return; if (duplicate.isPending) return;
duplicate.mutate( duplicate.mutate(
{ {
owner: source.owner, owner: source.owner,
file: source.file, file: source.file,
destOwner, destOwner: source.owner,
}, },
{ {
onSuccess: (data) => { onSuccess: (data) => {
@@ -33,7 +30,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
<h3 className="font-bold">Duplicate {source.key}?</h3> <h3 className="font-bold">Duplicate {source.key}?</h3>
<p className="mt-2 text-sm opacity-70"> <p className="mt-2 text-sm opacity-70">
A new UUID filename is assigned automatically. The copy starts disabled. HTTP paths are 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> </p>
{warnUnsaved ? ( {warnUnsaved ? (
<p className="text-warning mt-2 text-sm"> <p className="text-warning mt-2 text-sm">
@@ -41,21 +38,6 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
</p> </p>
) : null} ) : null}
<form className="mt-4 space-y-3" onSubmit={onSubmit}> <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 ? ( {duplicate.isError ? (
<p className="text-error text-sm">{errorMessage(duplicate.error)}</p> <p className="text-error text-sm">{errorMessage(duplicate.error)}</p>
) : null} ) : null}
-2
View File
@@ -17,7 +17,6 @@ import {
LuShield, LuShield,
LuSun, LuSun,
LuTags, LuTags,
LuUsers,
} from "react-icons/lu"; } from "react-icons/lu";
import { useLogout, useOpsStatus } from "../api/hooks.js"; import { useLogout, useOpsStatus } from "../api/hooks.js";
import { brandMark } from "../theme/brand.js"; import { brandMark } from "../theme/brand.js";
@@ -52,7 +51,6 @@ const navSections = [
items: [ items: [
{ to: "/manage", label: "Manage", icon: LuServer }, { to: "/manage", label: "Manage", icon: LuServer },
{ to: "/backup", label: "Backup", icon: LuArchive }, { to: "/backup", label: "Backup", icon: LuArchive },
{ to: "/users", label: "Users", icon: LuUsers },
], ],
}, },
]; ];
@@ -1,8 +1,9 @@
import { useState } from "react"; import { useState } from "react";
import { errorMessage } from "../api/client.js"; 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 { FormInput, FormSelect } from "./FormControls.jsx";
import { ConfigFields } from "./workflow/ConfigFields.jsx"; import { ConfigFields } from "./workflow/ConfigFields.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/** /**
* @param {"add" | "edit"} mode * @param {"add" | "edit"} mode
@@ -16,11 +17,10 @@ import { ConfigFields } from "./workflow/ConfigFields.jsx";
* @param {number} [usageCount] * @param {number} [usageCount]
*/ */
export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) { export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) {
const { data: owners = [] } = useOwners();
const { data: scripts = [] } = useScripts(); const { data: scripts = [] } = useScripts();
const upsert = useUpsertProfile(); const upsert = useUpsertProfile();
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default", owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "", name: initial.name || "",
script: initial.script || "", script: initial.script || "",
config: config:
@@ -72,7 +72,7 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
submit(); submit();
} }
const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`; const title = mode === "add" ? "New profile" : `Edit ${form.name}`;
return ( return (
<dialog className="modal modal-open"> <dialog className="modal modal-open">
@@ -80,31 +80,6 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
<h3 className="font-bold">{title}</h3> <h3 className="font-bold">{title}</h3>
<form className="mt-3 space-y-2" onSubmit={onSubmit}> <form className="mt-3 space-y-2" onSubmit={onSubmit}>
{mode === "add" ? ( {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"> <label className="form-control w-full">
<span className="label py-0 text-sm">Name</span> <span className="label py-0 text-sm">Name</span>
<FormInput <FormInput
@@ -119,10 +94,9 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
YAML id. Locked after create so live workflow refs stay valid. YAML id. Locked after create so live workflow refs stay valid.
</span> </span>
</label> </label>
</>
) : ( ) : (
<p className="text-sm opacity-70"> <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> <span className="ml-2 opacity-60">(name cannot be changed)</span>
</p> </p>
)} )}
+1 -1
View File
@@ -5,7 +5,7 @@ export function scriptIconSrc(name) {
return `/api/scripts/${encodeURIComponent(name)}/icon`; 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" }) { export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -1,30 +1,22 @@
import { useState } from "react"; import { useState } from "react";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useOwners, useUpsertSecret } from "../api/hooks.js"; import { useUpsertSecret } from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx"; import { FormInput } from "./FormControls.jsx";
import { Modal } from "./Modal.jsx"; import { Modal } from "./Modal.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/** /**
* Add / replace an encrypted secret. * Add / replace an encrypted secret.
* Reusable: mount when open; parent supplies mode + initial fields.
* *
* @param {"add" | "replace"} mode * @param {"add" | "replace"} mode
* @param {{ owner: string, name?: string }} initial * @param {{ owner: string, name?: string }} initial
* @param {() => void} onClose * @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved] * @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner]
*/ */
export function SecretEditorModal({ export function SecretEditorModal({ mode, initial, onClose, onSaved }) {
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
const upsert = useUpsertSecret(); const upsert = useUpsertSecret();
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default", owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "", name: initial.name || "",
value: "", value: "",
})); }));
@@ -42,40 +34,13 @@ export function SecretEditorModal({
); );
} }
const title = mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`; const title = mode === "add" ? "New secret" : `Replace ${form.name}`;
return ( return (
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}> <Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
<h3 className="font-bold">{title}</h3> <h3 className="font-bold">{title}</h3>
<form className="mt-3 space-y-2" onSubmit={onSubmit}> <form className="mt-3 space-y-2" onSubmit={onSubmit}>
{mode === "add" ? ( {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"> <label className="form-control w-full">
<span className="label py-0 text-sm">Name</span> <span className="label py-0 text-sm">Name</span>
<FormInput <FormInput
@@ -87,7 +52,6 @@ export function SecretEditorModal({
title="Letters, numbers, dots, underscores, hyphens" title="Letters, numbers, dots, underscores, hyphens"
/> />
</label> </label>
</>
) : null} ) : null}
<label className="form-control w-full"> <label className="form-control w-full">
<span className="label py-0 text-sm">Value</span> <span className="label py-0 text-sm">Value</span>
@@ -102,7 +66,8 @@ export function SecretEditorModal({
</label> </label>
<p className="text-xs opacity-60"> <p className="text-xs opacity-60">
Values are encrypted at rest and never shown again after save. Values shorter than 8 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> </p>
{upsert.isError ? ( {upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p> <p className="text-error text-sm">{errorMessage(upsert.error)}</p>
@@ -1,7 +1,8 @@
import { useState } from "react"; import { useState } from "react";
import { errorMessage } from "../api/client.js"; 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 { FormInput, FormSelect, FormTextarea } from "./FormControls.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
const TYPES = ["string", "number", "boolean"]; const TYPES = ["string", "number", "boolean"];
@@ -13,25 +14,16 @@ function defaultValue(type) {
/** /**
* Add / edit a plaintext workflow variable. * Add / edit a plaintext workflow variable.
* Reusable: mount when open; parent supplies mode + initial fields.
* *
* @param {"add" | "edit"} mode * @param {"add" | "edit"} mode
* @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial * @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial
* @param {() => void} onClose * @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved] * @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner] When true, owner cannot be changed (add mode).
*/ */
export function VariableEditorModal({ export function VariableEditorModal({ mode, initial, onClose, onSaved }) {
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
const upsert = useUpsertVariable(); const upsert = useUpsertVariable();
const [form, setForm] = useState(() => ({ const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default", owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "", name: initial.name || "",
type: initial.type || "string", type: initial.type || "string",
value: 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 ( return (
<dialog className="modal modal-open"> <dialog className="modal modal-open">
@@ -80,33 +72,6 @@ export function VariableEditorModal({
<h3 className="font-bold">{title}</h3> <h3 className="font-bold">{title}</h3>
<form className="mt-3 space-y-2" onSubmit={onSubmit}> <form className="mt-3 space-y-2" onSubmit={onSubmit}>
{mode === "add" ? ( {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"> <label className="form-control w-full">
<span className="label py-0 text-sm">Name</span> <span className="label py-0 text-sm">Name</span>
<FormInput <FormInput
@@ -118,7 +83,6 @@ export function VariableEditorModal({
title="Letters, numbers, dots, underscores, hyphens" title="Letters, numbers, dots, underscores, hyphens"
/> />
</label> </label>
</>
) : null} ) : null}
<label className="form-control w-full"> <label className="form-control w-full">
<span className="label py-0 text-sm">Type</span> <span className="label py-0 text-sm">Type</span>
@@ -165,7 +129,9 @@ export function VariableEditorModal({
</label> </label>
<p className="text-xs opacity-60"> <p className="text-xs opacity-60">
Stored in plaintext. Use Secrets for credentials. In workflows use{" "} 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> </p>
{formError ? <p className="text-error text-sm">{formError}</p> : null} {formError ? <p className="text-error text-sm">{formError}</p> : null}
{upsert.isError ? ( {upsert.isError ? (
@@ -4,22 +4,27 @@ import { LuEye, LuEyeOff, LuExternalLink } from "react-icons/lu";
import { useVariables } from "../../api/hooks.js"; import { useVariables } from "../../api/hooks.js";
const VAR_PEEK_MAX = 48; 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" }, * @param {unknown} value
{ prefix: "$CONTEXT_", label: "context" }, * @returns {{
{ prefix: "$VAR_", label: "variable" }, * kind: "mustache",
]; * path?: string,
* root?: string,
* name?: string,
* } | null}
*/
function describeConfigRef(value) { function describeConfigRef(value) {
if (typeof value !== "string") return null; if (typeof value !== "string") return null;
const trimmed = value.trim(); const trimmed = value.trim();
for (const { prefix, label } of CONFIG_REF_PREFIXES) { MUSTACHE_RE.lastIndex = 0;
if (trimmed.startsWith(prefix) && trimmed.length > prefix.length) { const match = MUSTACHE_RE.exec(trimmed);
return { label, name: trimmed.slice(prefix.length), kind: prefix }; if (!match) return null;
} const path = match[1];
} const root = path.split(".")[0];
return null; return { kind: "mustache", path, root, name: path.slice(root.length + 1) };
} }
function formatVarDisplay(value) { function formatVarDisplay(value) {
@@ -33,7 +38,7 @@ function truncatePeek(text, maxLen = VAR_PEEK_MAX) {
return `${text.slice(0, Math.max(0, maxLen - 1))}…`; 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) { function lookupVariable(variables, owner, name) {
const list = Array.isArray(variables) ? variables : []; const list = Array.isArray(variables) ? variables : [];
const match = list.find((v) => v.owner === owner && v.name === name); 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 }) { export function ConfigRefHint({ value, owner }) {
const ref = describeConfigRef(value); 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 { data: variables = [], isPending } = useVariables(undefined, { enabled: isVar });
const [revealed, setRevealed] = useState(false); const [revealed, setRevealed] = useState(false);
@@ -73,9 +78,17 @@ export function ConfigRefHint({ value, owner }) {
if (!ref) return null; if (!ref) return null;
if (!isVar) { if (!isVar) {
const label =
ref.root === "secrets"
? "secret"
: ref.root === "context"
? "context"
: ref.root === "data"
? "data"
: "expression";
return ( return (
<p className="text-xs opacity-60"> <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> </p>
); );
} }
@@ -42,6 +42,20 @@ import "@xyflow/react/dist/style.css";
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode }; 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). */ /** Two Control zoom-out steps from React Flow's default zoom (1). */
const INITIAL_ZOOM = 1 / 1.2 ** 2; const INITIAL_ZOOM = 1 / 1.2 ** 2;
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 }; const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
@@ -87,28 +101,6 @@ function GraphTabInner({
const steps = doc?.scripts ?? []; const steps = doc?.scripts ?? [];
const triggers = doc?.triggers ?? []; 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 scriptsByName = useMemo(() => {
const map = new Map(); const map = new Map();
for (const s of scripts) { for (const s of scripts) {
@@ -126,6 +118,29 @@ function GraphTabInner({
return map; return map;
}, [profiles]); }, [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( const displayEdges = useMemo(
() => () =>
edges.map((e) => ({ edges.map((e) => ({
@@ -18,7 +18,7 @@ import { LogViewer } from "../LogViewer.jsx";
import { StatusBadge } from "../../lib/format"; import { StatusBadge } from "../../lib/format";
import { prettyJson } from "../../lib/script.js"; import { prettyJson } from "../../lib/script.js";
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.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 { import {
stepPredecessors, stepPredecessors,
stepSuccessors, stepSuccessors,
@@ -117,7 +117,9 @@ function ScriptCardView({
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId); step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey); const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
const previewFull = configValueText(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 titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
const setConfig = const setConfig =
step.kind === "set" ? { expression: step.expression ?? "" } : null; step.kind === "set" ? { expression: step.expression ?? "" } : null;
@@ -180,10 +182,18 @@ function ScriptCardView({
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
<h3 <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} title={titleFull}
> >
<span className="min-w-0 truncate">{baseName}</span> <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 ? ( {!expanded && preview ? (
<span <span
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal" className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
@@ -275,6 +285,15 @@ function ScriptCardView({
{expanded ? ( {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" ? ( {step.kind === "set" ? (
<div className="space-y-2"> <div className="space-y-2">
<FieldLabel <FieldLabel
@@ -469,9 +488,11 @@ function ConfigJsonEditor({ config, disabled, onChange }) {
function NeedsEditor({ step, otherSteps, disabled, onChange }) { function NeedsEditor({ step, otherSteps, disabled, onChange }) {
const mode = needsMode(step.needs); const mode = needsMode(step.needs);
const ids = otherSteps const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId);
.filter((s) => s.id && s.uiId !== step.uiId) const ids = others.map((s) => s.id);
.map((s) => s.id); const nameById = new Map(
others.map((s) => [s.id, stepCustomName(s) || null]),
);
function setMode(next) { function setMode(next) {
if (next === "none") onChange({ ...step, needs: null }); if (next === "none") onChange({ ...step, needs: null });
@@ -479,6 +500,11 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
else onChange({ ...step, needs: {}, when: "" }); else onChange({ ...step, needs: {}, when: "" });
} }
function idLabel(id) {
const named = nameById.get(id);
return named ? `${id} · ${named}` : id;
}
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<span className="label py-0 text-sm">needs</span> <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> </label>
); );
}) })
@@ -531,6 +557,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
<NeedsMap <NeedsMap
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}} needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
ids={ids} ids={ids}
idLabel={idLabel}
disabled={disabled} disabled={disabled}
onChange={(needs) => onChange({ ...step, needs })} 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 ?? {}); const entries = Object.entries(needs ?? {});
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@@ -564,11 +591,11 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })} onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
> >
{from && !ids.includes(from) ? ( {from && !ids.includes(from) ? (
<option value={from}>{from}</option> <option value={from}>{idLabel ? idLabel(from) : from}</option>
) : null} ) : null}
{ids.map((id) => ( {ids.map((id) => (
<option key={id} value={id}> <option key={id} value={id}>
{id} {idLabel ? idLabel(id) : id}
</option> </option>
))} ))}
</FormSelect> </FormSelect>
@@ -874,8 +901,20 @@ function ScriptTryDialog({
} }
> >
<div className="flex shrink-0 items-start gap-2"> <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"> <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"> <p className="text-sm opacity-70">
{isSet {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." ? "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) { function triggerSummary(trigger, owner) {
const type = trigger?.type; const type = trigger?.type;
const failure = /** @type {string[]} */
trigger.onConsecutiveFailures && trigger.onFailureWorkflow const failureParts = [];
? ` · onFailure@${trigger.onFailureWorkflow}` 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") { if (type === "HTTP") {
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`; return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`;
} }
@@ -383,6 +388,20 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
) : null} ) : null}
</FormSelect> </FormSelect>
</Field> </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> </div>
); );
} }
@@ -1,4 +1,5 @@
import { Handle, Position } from "@xyflow/react"; import { Handle, Position } from "@xyflow/react";
import { ScriptIcon } from "../../ScriptIcon.jsx";
export function TriggerGraphNode({ data, selected }) { export function TriggerGraphNode({ data, selected }) {
return ( return (
@@ -19,6 +20,7 @@ export function TriggerGraphNode({ data, selected }) {
export function StepGraphNode({ data, selected }) { export function StepGraphNode({ data, selected }) {
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script"; const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
const iconScript = data.iconScript || "";
return ( return (
<div <div
className={`relative min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${ className={`relative min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
@@ -36,6 +38,15 @@ export function StepGraphNode({ data, selected }) {
className="nodrag nopan !h-3.5 !w-3.5 !bg-primary" className="nodrag nopan !h-3.5 !w-3.5 !bg-primary"
isConnectable isConnectable
/> />
<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"> <div className="flex items-center gap-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p> <p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
{data.stepId ? ( {data.stepId ? (
@@ -52,12 +63,17 @@ export function StepGraphNode({ data, selected }) {
</span> </span>
) : null} ) : null}
</div> </div>
<p className="font-mono text-sm leading-tight truncate" title={data.label}> <p
className={`text-sm leading-tight truncate ${data.named ? "font-semibold" : "font-mono"}`}
title={data.label}
>
{data.label} {data.label}
</p> </p>
{data.missingNeeds?.length ? ( {data.missingNeeds?.length ? (
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p> <p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
) : null} ) : null}
</div>
</div>
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
+1
View File
@@ -0,0 +1 @@
export { DEFAULT_OWNER } from "@jerapah-flow/shared";
+6 -6
View File
@@ -4,7 +4,7 @@
*/ */
import { contextFromMeta } from "./script.js"; import { contextFromMeta } from "./script.js";
import { isDagDoc, isEmptyNeeds, needsMode } from "./workflow-doc.js"; import { isDagDoc, isEmptyNeeds, needsMode, stepDisplayName } from "./workflow-doc.js";
/** /**
* @typedef {{ * @typedef {{
@@ -89,16 +89,16 @@ export function pruneTrySession(session, steps) {
/** /**
* Human-readable step label for seed hints. * 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] * @param {number} [index]
*/ */
export function stepTryLabel(step, index) { export function stepTryLabel(step, index) {
const n = index != null ? index + 1 : (step.index ?? 0) + 1; const n = index != null ? index + 1 : (step.index ?? 0) + 1;
const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null; const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null;
const name = const name = stepDisplayName(
step.kind === "set" step,
? "set" step.kind === "set" ? "set" : step.profile || step.script || "step",
: step.profile || step.script || "step"; );
if (id) return `step ${n} / ${id} (${name})`; if (id) return `step ${n} / ${id} (${name})`;
return `step ${n} / ${name}`; return `step ${n} / ${name}`;
} }
+15
View File
@@ -5,6 +5,7 @@ import {
pruneTrySession, pruneTrySession,
recordTrySuccess, recordTrySuccess,
seedTryDialog, seedTryDialog,
stepTryLabel,
} from "./try-session.js"; } from "./try-session.js";
function step(uiId, extra = {}) { function step(uiId, extra = {}) {
@@ -166,3 +167,17 @@ test("pruneTrySession drops removed steps", () => {
assert.equal(pruned.byStep.b, undefined); assert.equal(pruned.byStep.b, undefined);
assert.equal(pruned.lastTriedUiId, null); 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)",
);
});
+47 -2
View File
@@ -105,6 +105,19 @@ export function isDagDoc(doc) {
return (doc?.scripts ?? []).some((s) => !isEmptyNeeds(s.needs)); 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 = {}) { export function newScriptStep(script, config = {}) {
return { return {
uiId: nextUiId("step"), uiId: nextUiId("step"),
@@ -112,6 +125,7 @@ export function newScriptStep(script, config = {}) {
script, script,
profile: "", profile: "",
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {}, config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
name: "",
id: "", id: "",
when: "", when: "",
needs: null, needs: null,
@@ -125,6 +139,7 @@ export function newProfileStep(profileName, script = "") {
script, script,
profile: profileName, profile: profileName,
config: {}, config: {},
name: "",
id: "", id: "",
when: "", when: "",
needs: null, needs: null,
@@ -137,6 +152,7 @@ export function newSetStep() {
kind: "set", kind: "set",
script: "set", script: "set",
expression: "", expression: "",
name: "",
id: "", id: "",
when: "", when: "",
needs: null, needs: null,
@@ -155,6 +171,7 @@ export function newHttpTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -170,6 +187,7 @@ export function newCronTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -185,6 +203,7 @@ export function newWorkflowTrigger() {
unauthorized: null, unauthorized: null,
onConsecutiveFailures: "", onConsecutiveFailures: "",
onFailureWorkflow: "", onFailureWorkflow: "",
disableOnConsecutiveFailures: false,
}; };
} }
@@ -200,6 +219,10 @@ function readOnFailureWorkflow(raw) {
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : ""; return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
} }
function readStepName(raw) {
return typeof raw === "string" ? raw : "";
}
function normalizeStep(step) { function normalizeStep(step) {
const uiId = nextUiId("step"); const uiId = nextUiId("step");
if (typeof step === "string") { if (typeof step === "string") {
@@ -209,6 +232,7 @@ function normalizeStep(step) {
script: step, script: step,
profile: "", profile: "",
config: {}, config: {},
name: "",
id: "", id: "",
when: "", when: "",
needs: null, needs: null,
@@ -221,6 +245,7 @@ function normalizeStep(step) {
script: "", script: "",
profile: "", profile: "",
config: {}, config: {},
name: "",
id: "", id: "",
when: "", when: "",
needs: null, needs: null,
@@ -233,12 +258,13 @@ function normalizeStep(step) {
kind: "set", kind: "set",
script: "set", script: "set",
expression: typeof spec.expression === "string" ? spec.expression : "", expression: typeof spec.expression === "string" ? spec.expression : "",
name: readStepName(step.name),
id: typeof step.id === "string" ? step.id : "", id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "", when: typeof step.when === "string" ? step.when : "",
needs: step.needs ?? null, 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>} */ /** @type {Record<string, unknown>} */
const extra = {}; const extra = {};
for (const [key, value] of Object.entries(step)) { for (const [key, value] of Object.entries(step)) {
@@ -254,6 +280,7 @@ function normalizeStep(step) {
script: typeof step.script === "string" ? step.script : "", script: typeof step.script === "string" ? step.script : "",
profile: typeof step.profile === "string" ? step.profile : "", profile: typeof step.profile === "string" ? step.profile : "",
config, config,
name: readStepName(step.name),
id: typeof step.id === "string" ? step.id : "", id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "", when: typeof step.when === "string" ? step.when : "",
needs: step.needs ?? null, needs: step.needs ?? null,
@@ -279,9 +306,16 @@ function normalizeTrigger(raw) {
"unauthorized", "unauthorized",
"onConsecutiveFailures", "onConsecutiveFailures",
"onFailureWorkflow", "onFailureWorkflow",
"disableOnConsecutiveFailures",
]) ])
: type === "cron" : type === "cron"
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"]) ? new Set([
"type",
"schedule",
"onConsecutiveFailures",
"onFailureWorkflow",
"disableOnConsecutiveFailures",
])
: new Set(["type"]); : new Set(["type"]);
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const extra = {}; const extra = {};
@@ -299,6 +333,7 @@ function normalizeTrigger(raw) {
? "" ? ""
: String(raw.onConsecutiveFailures), : String(raw.onConsecutiveFailures),
onFailureWorkflow: readOnFailureWorkflow(raw), onFailureWorkflow: readOnFailureWorkflow(raw),
disableOnConsecutiveFailures: raw.disableOnConsecutiveFailures === true,
auth: Array.isArray(raw.auth) ? raw.auth : null, auth: Array.isArray(raw.auth) ? raw.auth : null,
response: typeof raw.response === "string" ? raw.response : "", response: typeof raw.response === "string" ? raw.response : "",
unauthorized: raw.unauthorized ?? null, 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) { function dumpStep(step) {
if (step.kind === "set") { if (step.kind === "set") {
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const out = {}; const out = {};
dumpStepName(step, out);
if (step.id) out.id = step.id; if (step.id) out.id = step.id;
out.set = { out.set = {
expression: step.expression ?? "", expression: step.expression ?? "",
@@ -320,6 +361,7 @@ function dumpStep(step) {
} }
/** @type {Record<string, unknown>} */ /** @type {Record<string, unknown>} */
const out = {}; const out = {};
dumpStepName(step, out);
if (step.id) out.id = step.id; if (step.id) out.id = step.id;
if (step.profile) { if (step.profile) {
out.profile = step.profile; out.profile = step.profile;
@@ -346,6 +388,9 @@ function dumpFailureTriggerFields(t, out) {
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) { if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
out.onFailureWorkflow = t.onFailureWorkflow.trim(); out.onFailureWorkflow = t.onFailureWorkflow.trim();
} }
if (t.disableOnConsecutiveFailures === true) {
out.disableOnConsecutiveFailures = true;
}
} }
function dumpTrigger(t) { function dumpTrigger(t) {
+59
View File
@@ -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",
);
});
+4 -1
View File
@@ -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 STEP_NODE_PREFIX = "step:";
export const TRIGGER_NODE_PREFIX = "trig:"; export const TRIGGER_NODE_PREFIX = "trig:";
@@ -44,6 +44,8 @@ export function triggerSummary(trigger, owner) {
} }
export function stepLabel(step) { export function stepLabel(step) {
const custom = stepCustomName(step);
if (custom) return custom;
if (step?.kind === "set") return step.id ? `${step.id}: set` : "set"; if (step?.kind === "set") return step.id ? `${step.id}: set` : "set";
const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled"; const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled";
return step?.id ? `${step.id}: ${target}` : target; return step?.id ? `${step.id}: ${target}` : target;
@@ -254,6 +256,7 @@ export function buildGraphElements(doc, positions = {}) {
data: { data: {
layoutKey, layoutKey,
label: stepLabel(s), label: stepLabel(s),
named: Boolean(stepCustomName(s)),
kind: s.kind, kind: s.kind,
stepId: s.id || "", stepId: s.id || "",
when: s.when || "", when: s.when || "",
@@ -6,6 +6,7 @@ import {
canConnectSteps, canConnectSteps,
enteringDagWouldStripWhen, enteringDagWouldStripWhen,
removeStepEdge, removeStepEdge,
stepLabel,
stepPredecessors, stepPredecessors,
stepSuccessors, stepSuccessors,
wouldCreateCycle, wouldCreateCycle,
@@ -113,3 +114,15 @@ test("stepSuccessors / stepPredecessors DAG multi", () => {
assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]); assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]);
assert.deepEqual(stepPredecessors(scripts, "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 -16
View File
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from "react-router-dom"; 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"; import { formatTime, StatusBadge } from "../lib/format";
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
@@ -47,7 +47,6 @@ export function EventsPage() {
const [params, setParams] = useSearchParams(); const [params, setParams] = useSearchParams();
const workflow = params.get("workflow") || ""; const workflow = params.get("workflow") || "";
const status = params.get("status") || ""; const status = params.get("status") || "";
const owner = params.get("owner") || "";
const trigger = params.get("trigger") || ""; const trigger = params.get("trigger") || "";
const afterDate = params.get("after") || ""; const afterDate = params.get("after") || "";
const beforeDate = params.get("before") || ""; const beforeDate = params.get("before") || "";
@@ -57,11 +56,9 @@ export function EventsPage() {
const sort = params.get("sort") || "started_at"; const sort = params.get("sort") || "started_at";
const order = params.get("order") === "asc" ? "asc" : "desc"; const order = params.get("order") === "asc" ? "asc" : "desc";
const { data: owners = [] } = useOwners();
const { data, isLoading } = useRuns({ const { data, isLoading } = useRuns({
workflow: workflow || undefined, workflow: workflow || undefined,
status: status || undefined, status: status || undefined,
owner: owner || undefined,
trigger: trigger || undefined, trigger: trigger || undefined,
after: dateToAfterIso(afterDate), after: dateToAfterIso(afterDate),
before: dateToBeforeIso(beforeDate), before: dateToBeforeIso(beforeDate),
@@ -116,18 +113,6 @@ export function EventsPage() {
<option value="failed">failed</option> <option value="failed">failed</option>
<option value="skipped">skipped</option> <option value="skipped">skipped</option>
</select> </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 <select
className="select select-sm w-full sm:max-w-xs" className="select select-sm w-full sm:max-w-xs"
value={trigger} value={trigger}
+16 -63
View File
@@ -2,13 +2,13 @@ import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { useDeleteProfile, useOwners, useProfileUsage, useProfiles } from "../api/hooks.js"; import { useDeleteProfile, useProfileUsage, useProfiles } from "../api/hooks.js";
import { FormSelect } from "../components/FormControls.jsx";
import { Modal } from "../components/Modal.jsx"; import { Modal } from "../components/Modal.jsx";
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx"; import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx"; import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format"; import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
function previewConfig(config) { function previewConfig(config) {
if (!config || typeof config !== "object") return ""; if (!config || typeof config !== "object") return "";
@@ -37,50 +37,32 @@ export function ProfilesPage() {
const isNewRoute = /\/profiles\/new\/?$/.test(location.pathname); const isNewRoute = /\/profiles\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName); const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners(); const { data: profiles = [], isLoading } = useProfiles();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: profiles = [], isLoading } = useProfiles(ownerFilter || undefined);
const del = useDeleteProfile(); const del = useDeleteProfile();
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const listPath = ownerFilter const listPath = "/profiles";
? `/profiles?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles";
const { editor, closeEditor } = useRouteDrivenModal({ const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute, isNewRoute,
isEditRoute, isEditRoute,
listPath, listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`, newRouteKey: () => `new:${params.get("name") || ""}:${params.get("script") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0), canOpenNew: () => true,
onBeforeOpenNew: () => { buildNewEditor: () => ({
if (params.get("owner")) setOwnerFilter(params.get("owner"));
},
buildNewEditor: () => {
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
return {
mode: "add", mode: "add",
initial: { initial: {
owner, owner: DEFAULT_OWNER,
name: params.get("name") || "", name: params.get("name") || "",
script: params.get("script") || "", script: params.get("script") || "",
config: {}, config: {},
description: "", description: "",
}, },
}; }),
},
editRouteKey: () => `edit:${routeOwner}/${routeName}`, editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => { canOpenEdit: () => !isLoading,
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return !isLoading;
},
onOpenEdit: () => setHighlightName(routeName), onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => { buildEditEditor: () => {
const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName); const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName);
@@ -100,7 +82,7 @@ export function ProfilesPage() {
return { return {
mode: "add", mode: "add",
initial: { initial: {
owner: routeOwner, owner: routeOwner || DEFAULT_OWNER,
name: routeName, name: routeName,
script: "", script: "",
config: {}, config: {},
@@ -108,8 +90,8 @@ export function ProfilesPage() {
}, },
}; };
}, },
newDeps: [params, owners, ownerFilter], newDeps: [params],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, profiles], editDeps: [routeOwner, routeName, isLoading, profiles],
}); });
useEffect(() => { useEffect(() => {
@@ -121,43 +103,15 @@ export function ProfilesPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Profiles</h1> <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 <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => onClick={() => navigate("/profiles/new")}
navigate(
ownerFilter
? `/profiles/new?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles/new",
)
}
> >
<LuPlus className="size-4" /> <LuPlus className="size-4" />
Add Add
</button> </button>
</div> </div>
</div>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
A profile is saved config for one script. Workflow steps can use it as initial config; A profile is saved config for one script. Workflow steps can use it as initial config;
@@ -174,7 +128,7 @@ export function ProfilesPage() {
const highlighted = const highlighted =
highlightName && highlightName &&
row.name === highlightName && row.name === highlightName &&
(!ownerFilter || row.owner === ownerFilter); (!routeOwner || row.owner === routeOwner);
const preview = previewConfig(row.config); const preview = previewConfig(row.config);
return ( return (
<article <article
@@ -191,7 +145,6 @@ export function ProfilesPage() {
<h2 className="font-mono font-semibold truncate" title={row.name}> <h2 className="font-mono font-semibold truncate" title={row.name}>
{row.name} {row.name}
</h2> </h2>
<p className="text-xs opacity-60 font-mono truncate">{row.owner}</p>
</div> </div>
</div> </div>
<p className="text-xs font-mono opacity-80 truncate" title={row.script}> <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 usage = useProfileUsage(profile.id, true);
const usages = usage.data ?? []; const usages = usage.data ?? [];
const used = usages.length > 0; const used = usages.length > 0;
const title = `Delete ${profile.owner}/${profile.name}?`; const title = `Delete ${profile.name}?`;
return ( return (
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}> <Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
+2 -19
View File
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useDryRunScript, useDryRunScript,
useOwners,
useSaveScript, useSaveScript,
useScript, useScript,
} from "../api/hooks.js"; } from "../api/hooks.js";
@@ -19,6 +18,7 @@ import {
contextFromMeta, contextFromMeta,
prettyJson, prettyJson,
} from "../lib/script.js"; } from "../lib/script.js";
import { DEFAULT_OWNER } from "../lib/tenant.js";
import { useNotifications } from "../notifications.jsx"; import { useNotifications } from "../notifications.jsx";
export function ScriptDryRunPage() { export function ScriptDryRunPage() {
@@ -31,8 +31,6 @@ export function ScriptDryRunPage() {
const existing = useScript(name, stateContent == null); const existing = useScript(name, stateContent == null);
const dryRun = useDryRunScript(); const dryRun = useDryRunScript();
const save = useSaveScript(); const save = useSaveScript();
const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState("default");
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT); const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT);
@@ -126,7 +124,7 @@ export function ScriptDryRunPage() {
data: ctx.data, data: ctx.data,
context: ctx.context, context: ctx.context,
config: ctx.config, config: ctx.config,
owner, owner: DEFAULT_OWNER,
}); });
} }
@@ -151,21 +149,6 @@ export function ScriptDryRunPage() {
<span className="text-sm opacity-60">{lastRun.durationMs}ms</span> <span className="text-sm opacity-60">{lastRun.durationMs}ms</span>
) : null} ) : null}
<div className="flex-1" /> <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 <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
+38
View File
@@ -4,6 +4,7 @@ import { LuArrowLeft, LuCopy, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useCreatePlugin, useCreatePlugin,
useDuplicatePlugin,
useForkScript, useForkScript,
useSaveScript, useSaveScript,
useScript, useScript,
@@ -119,9 +120,11 @@ export function ScriptEditPage() {
const existing = useScript(name); const existing = useScript(name);
const save = useSaveScript(); const save = useSaveScript();
const fork = useForkScript(); const fork = useForkScript();
const duplicate = useDuplicatePlugin();
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const [contentReady, setContentReady] = useState(false); const [contentReady, setContentReady] = useState(false);
const [forkId, setForkId] = useState(""); const [forkId, setForkId] = useState("");
const [duplicateId, setDuplicateId] = useState("");
const isCore = existing.data?.kind === "core" || existing.data?.editable === false; 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) { if (existing.isLoading) {
return ( return (
<div className="flex min-h-[12rem] items-center justify-center"> <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> <span className="text-error text-sm">{errorMessage(fork.error)}</span>
) : null} ) : null}
</form> </form>
) : (
<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} ) : null}
</form>
)}
<form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1"> <form id="script-edit-form" onSubmit={onSave} className="min-h-0 flex-1">
<CodeEditor <CodeEditor
+176 -3
View File
@@ -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 { 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 { errorMessage } from "../api/client.js";
import { import {
useDeleteScript, useDeleteScript,
useDuplicatePlugin,
useForkScript, useForkScript,
useInstallPlugin, useInstallPlugin,
useScripts, useScripts,
@@ -14,6 +15,21 @@ import { TagBadge } from "../components/TagBadge.jsx";
import { scriptTags } from "../lib/script.js"; import { scriptTags } from "../lib/script.js";
import { useNotifications } from "../notifications.jsx"; 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() { export function ScriptsPage() {
const [params] = useSearchParams(); const [params] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
@@ -23,10 +39,17 @@ export function ScriptsPage() {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [forkFor, setForkFor] = useState(null); const [forkFor, setForkFor] = useState(null);
const [forkId, setForkId] = useState(""); const [forkId, setForkId] = useState("");
const [duplicateFor, setDuplicateFor] = useState(null);
const [duplicateId, setDuplicateId] = useState("");
const del = useDeleteScript(); const del = useDeleteScript();
const fork = useForkScript(); const fork = useForkScript();
const duplicate = useDuplicatePlugin();
const install = useInstallPlugin(); const install = useInstallPlugin();
const { notify } = useNotifications(); 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 visible = useMemo(() => {
const term = query.trim().toLowerCase(); const term = query.trim().toLowerCase();
@@ -45,6 +68,44 @@ export function ScriptsPage() {
}); });
}, [scripts, query]); }, [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) { if (editName) {
return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />; return <Navigate to={`/scripts/${encodeURIComponent(editName)}/edit`} replace />;
} }
@@ -67,7 +128,7 @@ export function ScriptsPage() {
<button <button
type="button" type="button"
className="btn btn-outline btn-sm" className="btn btn-outline btn-sm"
disabled={install.isPending} disabled={zipBusy}
onClick={() => onClick={() =>
install.mutate( install.mutate(
{ source: "example", exampleId: "get-current-time", overwrite: true }, { source: "example", exampleId: "get-current-time", overwrite: true },
@@ -80,6 +141,21 @@ export function ScriptsPage() {
> >
Install example Install example
</button> </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"> <Link to="/scripts/new" className="btn btn-primary btn-sm">
<LuPlus className="size-4" /> <LuPlus className="size-4" />
Add plugin Add plugin
@@ -182,6 +258,7 @@ export function ScriptsPage() {
<LuCopy className="size-4" /> <LuCopy className="size-4" />
</button> </button>
) : ( ) : (
<>
<Link <Link
to={`/scripts/${encodeURIComponent(name)}/edit`} to={`/scripts/${encodeURIComponent(name)}/edit`}
className="btn btn-ghost btn-xs" className="btn btn-ghost btn-xs"
@@ -190,6 +267,24 @@ export function ScriptsPage() {
> >
<LuPencil className="size-4" /> <LuPencil className="size-4" />
</Link> </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 ? ( {!isCore ? (
<button <button
@@ -264,6 +359,84 @@ export function ScriptsPage() {
</dialog> </dialog>
) : null} ) : 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/&lt;id&gt;</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 <ConfirmDialog
open={Boolean(confirmDelete)} open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete}?` : ""} title={confirmDelete ? `Delete ${confirmDelete}?` : ""}
+22 -66
View File
@@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; 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 { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { SecretEditorModal } from "../components/SecretEditorModal.jsx"; import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format"; import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
export function SecretsPage() { export function SecretsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -17,51 +17,36 @@ export function SecretsPage() {
const isNewRoute = /\/secrets\/new\/?$/.test(location.pathname); const isNewRoute = /\/secrets\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName); const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners(); const { data: secrets = [], isLoading } = useSecrets();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined);
const del = useDeleteSecret(); const del = useDeleteSecret();
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const listPath = ownerFilter const listPath = "/secrets";
? `/secrets?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets";
const { editor, closeEditor } = useRouteDrivenModal({ const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute, isNewRoute,
isEditRoute, isEditRoute,
listPath, listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`, newRouteKey: () => `new:${params.get("name") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0), canOpenNew: () => true,
onBeforeOpenNew: () => { buildNewEditor: () => ({
if (params.get("owner")) setOwnerFilter(params.get("owner"));
},
buildNewEditor: () => {
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
return {
mode: "add", mode: "add",
initial: { owner, name: params.get("name") || "" }, initial: {
}; owner: DEFAULT_OWNER,
name: params.get("name") || "",
}, },
}),
editRouteKey: () => `edit:${routeOwner}/${routeName}`, editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => { canOpenEdit: () => true,
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return true;
},
onOpenEdit: () => setHighlightName(routeName), onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => ({ buildEditEditor: () => ({
mode: "replace", mode: "replace",
initial: { owner: routeOwner, name: routeName }, initial: { owner: routeOwner, name: routeName },
}), }),
newDeps: [params, owners, ownerFilter], newDeps: [params],
editDeps: [routeOwner, routeName, ownerFilter], editDeps: [routeOwner, routeName],
}); });
useEffect(() => { useEffect(() => {
@@ -73,43 +58,15 @@ export function SecretsPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Secrets</h1> <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 <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => onClick={() => navigate("/secrets/new")}
navigate(
ownerFilter
? `/secrets/new?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets/new",
)
}
> >
<LuPlus className="size-4" /> <LuPlus className="size-4" />
Add Add
</button> </button>
</div> </div>
</div>
{isLoading ? ( {isLoading ? (
<span className="loading loading-spinner" /> <span className="loading loading-spinner" />
@@ -120,7 +77,6 @@ export function SecretsPage() {
<table className="table table-sm"> <table className="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Owner</th>
<th>Name</th> <th>Name</th>
<th>Updated</th> <th>Updated</th>
<th /> <th />
@@ -129,16 +85,16 @@ export function SecretsPage() {
<tbody> <tbody>
{secrets.map((s) => { {secrets.map((s) => {
const highlighted = const highlighted =
highlightName && highlightName && s.name === highlightName && s.owner === routeOwner;
s.name === highlightName && const highlightByNameOnly =
(!ownerFilter || s.owner === ownerFilter); highlightName && s.name === highlightName && !routeOwner;
const isHi = highlighted || highlightByNameOnly;
return ( return (
<tr <tr
key={s.id} key={s.id}
ref={highlighted ? highlightRef : undefined} ref={isHi ? highlightRef : undefined}
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`} 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="font-mono">{s.name}</td>
<td className="whitespace-nowrap">{formatTime(s.updated_at)}</td> <td className="whitespace-nowrap">{formatTime(s.updated_at)}</td>
<td className="text-right whitespace-nowrap"> <td className="text-right whitespace-nowrap">
@@ -186,7 +142,7 @@ export function SecretsPage() {
<ConfirmDialog <ConfirmDialog
open={Boolean(confirmDelete)} 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." message="This cannot be undone. Workflows that retrieve this name will fail."
error={del.isError ? errorMessage(del.error) : null} error={del.isError ? errorMessage(del.error) : null}
loading={del.isPending} loading={del.isPending}
+17 -65
View File
@@ -2,12 +2,12 @@ import { useEffect, useRef, useState } from "react";
import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom";
import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; 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 { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { VariableEditorModal } from "../components/VariableEditorModal.jsx"; import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js"; import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format"; import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
function displayValue(value) { function displayValue(value) {
if (typeof value === "string") return value === "" ? '""' : value; if (typeof value === "string") return value === "" ? '""' : value;
@@ -22,49 +22,31 @@ export function VariablesPage() {
const isNewRoute = /\/variables\/new\/?$/.test(location.pathname); const isNewRoute = /\/variables\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName); const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners(); const { data: variables = [], isLoading } = useVariables();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
const del = useDeleteVariable(); const del = useDeleteVariable();
const [confirmDelete, setConfirmDelete] = useState(null); const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || ""); const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null); const highlightRef = useRef(null);
const listPath = ownerFilter const listPath = "/variables";
? `/variables?owner=${encodeURIComponent(ownerFilter)}`
: "/variables";
const { editor, closeEditor } = useRouteDrivenModal({ const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute, isNewRoute,
isEditRoute, isEditRoute,
listPath, listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`, newRouteKey: () => `new:${params.get("name") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0), canOpenNew: () => true,
onBeforeOpenNew: () => { buildNewEditor: () => ({
if (params.get("owner")) setOwnerFilter(params.get("owner"));
},
buildNewEditor: () => {
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
return {
mode: "add", mode: "add",
initial: { initial: {
owner, owner: DEFAULT_OWNER,
name: params.get("name") || "", name: params.get("name") || "",
type: "string", type: "string",
value: "", value: "",
}, },
}; }),
},
editRouteKey: () => `edit:${routeOwner}/${routeName}`, editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => { canOpenEdit: () => !isLoading,
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return !isLoading;
},
onOpenEdit: () => setHighlightName(routeName), onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => { buildEditEditor: () => {
const row = variables.find((v) => v.owner === routeOwner && v.name === routeName); const row = variables.find((v) => v.owner === routeOwner && v.name === routeName);
@@ -82,15 +64,15 @@ export function VariablesPage() {
return { return {
mode: "add", mode: "add",
initial: { initial: {
owner: routeOwner, owner: routeOwner || DEFAULT_OWNER,
name: routeName, name: routeName,
type: "string", type: "string",
value: "", value: "",
}, },
}; };
}, },
newDeps: [params, owners, ownerFilter], newDeps: [params],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, variables], editDeps: [routeOwner, routeName, isLoading, variables],
}); });
useEffect(() => { useEffect(() => {
@@ -102,43 +84,15 @@ export function VariablesPage() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2"> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
<h1 className="text-xl font-semibold">Variables</h1> <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 <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
onClick={() => onClick={() => navigate("/variables/new")}
navigate(
ownerFilter
? `/variables/new?owner=${encodeURIComponent(ownerFilter)}`
: "/variables/new",
)
}
> >
<LuPlus className="size-4" /> <LuPlus className="size-4" />
Add Add
</button> </button>
</div> </div>
</div>
{isLoading ? ( {isLoading ? (
<span className="loading loading-spinner" /> <span className="loading loading-spinner" />
@@ -149,7 +103,6 @@ export function VariablesPage() {
<table className="table table-sm"> <table className="table table-sm">
<thead> <thead>
<tr> <tr>
<th>Owner</th>
<th>Name</th> <th>Name</th>
<th>Type</th> <th>Type</th>
<th>Value</th> <th>Value</th>
@@ -162,14 +115,13 @@ export function VariablesPage() {
const highlighted = const highlighted =
highlightName && highlightName &&
row.name === highlightName && row.name === highlightName &&
(!ownerFilter || row.owner === ownerFilter); (!routeOwner || row.owner === routeOwner);
return ( return (
<tr <tr
key={row.id} key={row.id}
ref={highlighted ? highlightRef : undefined} ref={highlighted ? highlightRef : undefined}
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`} 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">{row.name}</td>
<td className="font-mono text-xs">{row.type}</td> <td className="font-mono text-xs">{row.type}</td>
<td <td
@@ -224,10 +176,10 @@ export function VariablesPage() {
<ConfirmDialog <ConfirmDialog
open={Boolean(confirmDelete)} open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete.owner}/${confirmDelete.name}?` : ""} title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message={ message={
confirmDelete 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} error={del.isError ? errorMessage(del.error) : null}
+3 -31
View File
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js"; import { errorMessage } from "../api/client.js";
import { import {
useCreateWorkflow, useCreateWorkflow,
useOwners,
useSaveWorkflow, useSaveWorkflow,
useSetWorkflowEnabled, useSetWorkflowEnabled,
useWorkflow, useWorkflow,
@@ -24,6 +23,7 @@ import {
import { ConfirmDialog } from "../components/ConfirmDialog.jsx"; import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { WorkflowRevisionBanner } from "../components/workflow/WorkflowRevisionBanner.jsx"; import { WorkflowRevisionBanner } from "../components/workflow/WorkflowRevisionBanner.jsx";
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js"; import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
import { DEFAULT_OWNER } from "../lib/tenant.js";
import { useNotifications } from "../notifications.jsx"; import { useNotifications } from "../notifications.jsx";
function WorkflowEditorLayout({ function WorkflowEditorLayout({
@@ -100,26 +100,15 @@ function WorkflowEditorLayout({
export function WorkflowNewPage() { export function WorkflowNewPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { notify } = useNotifications(); const { notify } = useNotifications();
const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState("local");
const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset()); const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset());
const [content, setContent] = useState(NEW_WORKFLOW_YAML); const [content, setContent] = useState(NEW_WORKFLOW_YAML);
const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML); const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML);
const [saveWarnings, setSaveWarnings] = useState(null); const [saveWarnings, setSaveWarnings] = useState(null);
const create = useCreateWorkflow(); 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) { function onSave(saveAnyway = false) {
create.mutate( create.mutate(
{ owner, content, saveAnyway }, { owner: DEFAULT_OWNER, content, saveAnyway },
{ {
onSuccess: (data) => { onSuccess: (data) => {
setSaveWarnings(null); setSaveWarnings(null);
@@ -159,34 +148,17 @@ export function WorkflowNewPage() {
title="New workflow" title="New workflow"
onSave={() => onSave(false)} onSave={() => onSave(false)}
savePending={create.isPending} savePending={create.isPending}
saveDisabled={!owner}
saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null} saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null}
> >
<div className="flex min-h-0 flex-1 flex-col gap-3"> <div className="flex min-h-0 flex-1 flex-col gap-3">
<WorkflowVisualEditor <WorkflowVisualEditor
yaml={content} yaml={content}
onYamlChange={setContent} onYamlChange={setContent}
owner={owner} owner={DEFAULT_OWNER}
file="" file=""
savedYaml={savedYaml} savedYaml={savedYaml}
showTest={false} 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> </div>
</WorkflowEditorLayout> </WorkflowEditorLayout>
{saveWarnings ? ( {saveWarnings ? (
@@ -61,7 +61,6 @@ export function WorkflowTrashPage() {
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>File</th> <th>File</th>
<th>Owner</th>
<th>Deleted</th> <th>Deleted</th>
<th>Age</th> <th>Age</th>
<th>Purge in</th> <th>Purge in</th>
@@ -73,7 +72,6 @@ export function WorkflowTrashPage() {
<tr key={item.id} className="hover"> <tr key={item.id} className="hover">
<td>{item.name ?? "—"}</td> <td>{item.name ?? "—"}</td>
<td className="font-mono text-xs">{item.file}</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 className="whitespace-nowrap">{formatTime(item.deleted_at)}</td>
<td>{formatAge(item.age_ms)}</td> <td>{formatAge(item.age_ms)}</td>
<td> <td>
-9
View File
@@ -16,7 +16,6 @@ import { formatTime, WorkflowStatusBadge } from "../lib/format";
const SORT_COLUMNS = [ const SORT_COLUMNS = [
{ key: "name", label: "Name", defaultOrder: "asc" }, { key: "name", label: "Name", defaultOrder: "asc" },
{ key: "owner", label: "Owner", defaultOrder: "asc" },
{ key: "status", label: "Status", defaultOrder: "asc" }, { key: "status", label: "Status", defaultOrder: "asc" },
{ key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" }, { key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" },
{ key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" }, { key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" },
@@ -59,12 +58,6 @@ function compareName(a, b) {
function compareWorkflows(a, b, sort) { function compareWorkflows(a, b, sort) {
if (sort === "name") return compareName(a, b); 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") { if (sort === "status") {
const byStatus = statusSortKey(a).localeCompare(statusSortKey(b)); const byStatus = statusSortKey(a).localeCompare(statusSortKey(b));
return byStatus !== 0 ? byStatus : compareName(a, b); return byStatus !== 0 ? byStatus : compareName(a, b);
@@ -196,7 +189,6 @@ export function WorkflowsPage() {
<thead> <thead>
<tr> <tr>
<SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} /> <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} /> <SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} />
<th>Triggers</th> <th>Triggers</th>
<SortHeader <SortHeader
@@ -244,7 +236,6 @@ export function WorkflowsPage() {
</span> </span>
) : null} ) : null}
</td> </td>
<td className="font-mono text-xs">{w.owner}</td>
<td> <td>
<WorkflowStatusBadge workflow={w} /> <WorkflowStatusBadge workflow={w} />
</td> </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;
+2 -2
View File
@@ -198,7 +198,7 @@ joplinHttp.meta = {
token: { token: {
type: "string", type: "string",
required: true, required: true,
description: "Bearer token ($SECRET_); Bearer prefix is added if missing", description: "Bearer token ({{ secrets.* }}); Bearer prefix is added if missing",
}, },
timeoutMs: { timeoutMs: {
type: "number", type: "number",
@@ -237,7 +237,7 @@ joplinHttp.meta = {
config: { config: {
url: "http://10.8.0.6:3030/notes", url: "http://10.8.0.6:3030/notes",
method: "GET", method: "GET",
token: "$SECRET_joplin_api_token", token: "{{ secrets.joplin_api_token }}",
timeoutMs: 60000, timeoutMs: 60000,
}, },
}, },
+67 -28
View File
@@ -1,3 +1,5 @@
import net from "node:net";
function passContext(ctx) { function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) { if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context }; return { ...ctx.context };
@@ -65,38 +67,75 @@ function parseResult(text) {
} }
} }
function leftoverFromError(err) { function httpBodyFromRaw(raw) {
let current = err; const normalized = String(raw).replace(/\r\n/g, "\n");
for (let i = 0; i < 6 && current; i++) { const split = normalized.indexOf("\n\n");
if (typeof current.data === "string") return current.data; return split === -1 ? normalized : normalized.slice(split + 2);
current = current.cause; }
/**
* 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) { async function ztePost(url, fields) {
const body = new URLSearchParams(fields).toString(); return zteHttpPost(url, fields);
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;
}
} }
async function sendZteSms(ctx) { async function sendZteSms(ctx) {
+1 -1
View File
@@ -77,7 +77,7 @@ importers:
specifier: ^4.0.0 specifier: ^4.0.0
version: 4.0.0 version: 4.0.0
pm2: pm2:
specifier: ^6.0.13 specifier: 6.0.14
version: 6.0.14(supports-color@7.2.0) version: 6.0.14(supports-color@7.2.0)
rss-parser: rss-parser:
specifier: ^3.13.0 specifier: ^3.13.0
+48
View File
@@ -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);
});