13 Commits
Author SHA1 Message Date
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
52 changed files with 1097 additions and 605 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 -2
View File
@@ -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: $SECRET_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
+39 -11
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
@@ -52,7 +58,7 @@ The first account created becomes **admin**. Later accounts are created from Use
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) | | **Live workflows** | Yes | `packages/server/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/`. - 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`.
@@ -99,8 +105,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 |
@@ -134,24 +141,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",
}, },
}, },
], ],
+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"
+21 -18
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) {
}) log.error({ err }, "failed to start control");
.catch((err) => { process.exit(1);
log.error({ err }, "failed to start control"); }
process.exit(1);
}); try {
await connectPm2();
} catch (err) {
log.error({ err }, "failed to connect to PM2 — is pm2 installed?");
process.exit(1);
}
try {
await applyDesiredState();
} catch (err) {
log.error({ err }, "failed to apply desired state");
process.exit(1);
}
+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()) {
+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",
});
}
+3 -1
View File
@@ -11,9 +11,11 @@
"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_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_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"
}, },
@@ -40,7 +42,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",
+45 -1
View File
@@ -164,13 +164,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",
+53 -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
@@ -553,6 +555,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 +630,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();
}
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+1 -1
View File
@@ -258,7 +258,7 @@ export default function scriptsPluginFactory(registry) {
req.body ?? {} req.body ?? {}
); );
let owner = "default"; let owner = "local";
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));
+1
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,
}; };
}); });
+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);
} }
+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);
}
+23 -5
View File
@@ -9,9 +9,10 @@ export const SET_STEP_SCRIPT = "set";
* kind: "script", * kind: "script",
* script: string, * script: string,
* profile: string | null, * profile: string | null,
* config: unknown | null, * config: unknown | null,
* expression?: undefined, * expression?: undefined,
* id: string | null, * name: string | null,
* id: string | null,
* needsKind: "none" | "list" | "map", * needsKind: "none" | "list" | "map",
* needs: NeedEdge[], * needs: NeedEdge[],
* when: string | null, * when: string | null,
@@ -21,8 +22,9 @@ export const SET_STEP_SCRIPT = "set";
* script: typeof SET_STEP_SCRIPT, * script: typeof SET_STEP_SCRIPT,
* profile: null, * profile: null,
* config: { expression: string }, * config: { expression: string },
* expression: string, * expression: string,
* id: string | null, * name: string | null,
* id: string | null,
* needsKind: "none" | "list" | "map", * needsKind: "none" | "list" | "map",
* needs: NeedEdge[], * needs: NeedEdge[],
* when: string | null, * when: string | null,
@@ -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}
+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",
@@ -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,49 +80,23 @@ 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">
<label className="form-control w-full"> <span className="label py-0 text-sm">Name</span>
<span className="label py-0 text-sm">Owner</span> <FormInput
{owners.length > 0 ? ( className="w-full font-mono"
<FormSelect value={form.name}
className="w-full" onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.owner} required
onChange={(e) => setForm({ ...form, owner: e.target.value })} pattern="[A-Za-z0-9._-]+"
required title="Letters, numbers, dots, underscores, hyphens. Cannot be changed later."
> />
{owners.map((o) => ( <span className="label-text-alt opacity-60">
<option key={o} value={o}> YAML id. Locked after create so live workflow refs stay valid.
{o} </span>
</option> </label>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens. Cannot be changed later."
/>
<span className="label-text-alt opacity-60">
YAML id. Locked after create so live workflow refs stay valid.
</span>
</label>
</>
) : ( ) : (
<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,52 +34,24 @@ export function SecretEditorModal({
); );
} }
const title = mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`; const title = mode === "add" ? "New secret" : `Replace ${form.name}`;
return ( 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">
<label className="form-control w-full"> <span className="label py-0 text-sm">Name</span>
<span className="label py-0 text-sm">Owner</span> <FormInput
{owners.length > 0 ? ( className="w-full font-mono"
<FormSelect value={form.name}
className="w-full" onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.owner} required
onChange={(e) => setForm({ ...form, owner: e.target.value })} pattern="[A-Za-z0-9._-]+"
required title="Letters, numbers, dots, underscores, hyphens"
disabled={lockOwner} />
> </label>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
/>
</label>
</>
) : 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>
@@ -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,45 +72,17 @@ 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">
<label className="form-control w-full"> <span className="label py-0 text-sm">Name</span>
<span className="label py-0 text-sm">Owner</span> <FormInput
{owners.length > 0 ? ( className="w-full font-mono"
<FormSelect value={form.name}
className="w-full" onChange={(e) => setForm({ ...form, name: e.target.value })}
value={form.owner} required
onChange={(e) => setForm({ ...form, owner: e.target.value })} pattern="[A-Za-z0-9._-]+"
required title="Letters, numbers, dots, underscores, hyphens"
disabled={lockOwner} />
> </label>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
className="w-full font-mono"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
required
pattern="[A-Za-z0-9._-]+"
title="Letters, numbers, dots, underscores, hyphens"
/>
</label>
</>
) : 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>
@@ -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,28 +38,42 @@ 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-center gap-1"> <div className="flex items-start gap-2 min-w-0">
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p> {data.kind !== "set" && iconScript ? (
{data.stepId ? ( <ScriptIcon
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span> name={iconScript}
) : null} hasIcon={data.hasIcon}
{data.when ? ( className="size-7 shrink-0"
<span className="badge badge-warning badge-xs" title={data.when}> />
when
</span>
) : null}
{data.needsMode === "map" ? (
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
map
</span>
) : null} ) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
{data.stepId ? (
<span className="badge badge-ghost badge-xs font-mono">{data.stepId}</span>
) : null}
{data.when ? (
<span className="badge badge-warning badge-xs" title={data.when}>
when
</span>
) : null}
{data.needsMode === "map" ? (
<span className="badge badge-info badge-xs" title="Named needs — edit in inspector">
map
</span>
) : null}
</div>
<p
className={`text-sm leading-tight truncate ${data.named ? "font-semibold" : "font-mono"}`}
title={data.label}
>
{data.label}
</p>
{data.missingNeeds?.length ? (
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
) : null}
</div>
</div> </div>
<p className="font-mono text-sm leading-tight truncate" title={data.label}>
{data.label}
</p>
{data.missingNeeds?.length ? (
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
) : null}
<Handle <Handle
type="source" type="source"
position={Position.Right} position={Position.Right}
+2
View File
@@ -0,0 +1,2 @@
/** Default owner folder for new resources (latent tenant id). */
export const DEFAULT_OWNER = "local";
+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}
+30 -77
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")); mode: "add",
}, initial: {
buildNewEditor: () => { owner: DEFAULT_OWNER,
const owner = params.get("owner") || ownerFilter || owners[0] || "default"; name: params.get("name") || "",
return { script: params.get("script") || "",
mode: "add", config: {},
initial: { description: "",
owner, },
name: params.get("name") || "", }),
script: params.get("script") || "",
config: {},
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,42 +103,14 @@ 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"> <button
<FormSelect type="button"
value={ownerFilter} className="btn btn-primary btn-sm"
onChange={(e) => { onClick={() => navigate("/profiles/new")}
setOwnerFilter(e.target.value); >
setHighlightName(""); <LuPlus className="size-4" />
navigate( Add
e.target.value </button>
? `/profiles?owner=${encodeURIComponent(e.target.value)}`
: "/profiles",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/profiles/new?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles/new",
)
}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div> </div>
<p className="text-sm opacity-70"> <p className="text-sm opacity-70">
@@ -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"
+31 -75
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")); mode: "add",
}, initial: {
buildNewEditor: () => { owner: DEFAULT_OWNER,
const owner = params.get("owner") || ownerFilter || owners[0] || "default"; name: params.get("name") || "",
return { },
mode: "add", }),
initial: { 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,42 +58,14 @@ 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"> <button
<FormSelect type="button"
value={ownerFilter} className="btn btn-primary btn-sm"
onChange={(e) => { onClick={() => navigate("/secrets/new")}
setOwnerFilter(e.target.value); >
setHighlightName(""); <LuPlus className="size-4" />
navigate( Add
e.target.value </button>
? `/secrets?owner=${encodeURIComponent(e.target.value)}`
: "/secrets",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/secrets/new?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets/new",
)
}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -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}
+29 -77
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")); mode: "add",
}, initial: {
buildNewEditor: () => { owner: DEFAULT_OWNER,
const owner = params.get("owner") || ownerFilter || owners[0] || "default"; name: params.get("name") || "",
return { type: "string",
mode: "add", value: "",
initial: { },
owner, }),
name: params.get("name") || "",
type: "string",
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,42 +84,14 @@ 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"> <button
<FormSelect type="button"
value={ownerFilter} className="btn btn-primary btn-sm"
onChange={(e) => { onClick={() => navigate("/variables/new")}
setOwnerFilter(e.target.value); >
setHighlightName(""); <LuPlus className="size-4" />
navigate( Add
e.target.value </button>
? `/variables?owner=${encodeURIComponent(e.target.value)}`
: "/variables",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/variables/new?owner=${encodeURIComponent(ownerFilter)}`
: "/variables/new",
)
}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div> </div>
{isLoading ? ( {isLoading ? (
@@ -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,7 +176,7 @@ 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 $VAR_${confirmDelete.name} will fail.`
+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>
+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);
});