Compare commits
13
Commits
811890443b
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd98421b08 | ||
|
|
69312c9080 | ||
|
|
c8697532f9 | ||
|
|
be8122f9e5 | ||
|
|
0a8463d8f4 | ||
|
|
41cca86e6e | ||
|
|
71cc705cd2 | ||
|
|
721f15e900 | ||
|
|
877ea9e3f8 | ||
|
|
93b51dcaaa | ||
|
|
eb7c318c13 | ||
|
|
864427b45c | ||
|
|
46aa8ca327 |
@@ -0,0 +1,33 @@
|
|||||||
|
name: Deploy to Raspberry Pi
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [dev]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: home # must match a label on your act_runner
|
||||||
|
steps:
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# act_runner uses bash --noprofile --norc; load nvm/pnpm explicitly
|
||||||
|
export NVM_DIR="/home/nsrb/.nvm"
|
||||||
|
export PNPM_HOME="/home/nsrb/.local/share/pnpm"
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
|
||||||
|
export PATH="$PNPM_HOME/bin:$PATH"
|
||||||
|
|
||||||
|
APP_DIR=/home/nsrb/apps/jerapah-flow
|
||||||
|
cd "$APP_DIR"
|
||||||
|
|
||||||
|
git fetch origin dev
|
||||||
|
git checkout dev
|
||||||
|
git pull --ff-only origin dev
|
||||||
|
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||||
|
pm2 save
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()) {
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
const HOP_BY_HOP = new Set([
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"proxy-authenticate",
|
||||||
|
"proxy-authorization",
|
||||||
|
"te",
|
||||||
|
"trailer",
|
||||||
|
"transfer-encoding",
|
||||||
|
"upgrade",
|
||||||
|
"host",
|
||||||
|
"content-length",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const REPLY_SKIP = new Set([
|
||||||
|
"connection",
|
||||||
|
"keep-alive",
|
||||||
|
"transfer-encoding",
|
||||||
|
"content-encoding",
|
||||||
|
"content-length",
|
||||||
|
]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Control origin used when the UI or HTTP process proxies to control.
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function controlOrigin() {
|
||||||
|
const explicit = process.env.JFLOW_CONTROL_URL?.trim();
|
||||||
|
if (explicit) return explicit.replace(/\/$/, "");
|
||||||
|
const port = Number(process.env.JFLOW_CONTROL_PORT ?? 8600);
|
||||||
|
return `http://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP API origin (workflow triggers + REST).
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function httpOrigin() {
|
||||||
|
const explicit = process.env.JFLOW_HTTP_URL?.trim();
|
||||||
|
if (explicit) return explicit.replace(/\/$/, "");
|
||||||
|
const port = Number(process.env.JFLOW_HTTP_PORT ?? process.env.PORT ?? 8700);
|
||||||
|
return `http://127.0.0.1:${port}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward the incoming request to `origin`, preserving path + query.
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
* @param {string} origin
|
||||||
|
* @param {{ unreachableMessage?: string }} [opts]
|
||||||
|
*/
|
||||||
|
export async function proxyToOrigin(req, reply, origin, opts = {}) {
|
||||||
|
const target = `${origin.replace(/\/$/, "")}${req.raw.url ?? "/"}`;
|
||||||
|
const headers = {};
|
||||||
|
for (const [key, value] of Object.entries(req.headers)) {
|
||||||
|
if (value == null || HOP_BY_HOP.has(key.toLowerCase())) continue;
|
||||||
|
headers[key] = Array.isArray(value) ? value.join(", ") : String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = req.method.toUpperCase();
|
||||||
|
const hasBody = method !== "GET" && method !== "HEAD";
|
||||||
|
let body;
|
||||||
|
if (hasBody) {
|
||||||
|
if (Buffer.isBuffer(req.body)) body = req.body;
|
||||||
|
else if (typeof req.body === "string") body = req.body;
|
||||||
|
else if (req.body != null) {
|
||||||
|
body = JSON.stringify(req.body);
|
||||||
|
if (!headers["content-type"]) headers["content-type"] = "application/json";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await fetch(target, { method, headers, body });
|
||||||
|
} catch (err) {
|
||||||
|
const message = opts.unreachableMessage ?? "upstream unreachable";
|
||||||
|
req.log.warn({ err, target }, `proxy: ${message}`);
|
||||||
|
return reply.code(502).send({ error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(res.status);
|
||||||
|
res.headers.forEach((value, key) => {
|
||||||
|
if (REPLY_SKIP.has(key.toLowerCase())) return;
|
||||||
|
reply.header(key, value);
|
||||||
|
});
|
||||||
|
return reply.send(Buffer.from(await res.arrayBuffer()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forward `/ops/*` to the control plane (same-origin UI in production).
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
export async function proxyOpsToControl(req, reply) {
|
||||||
|
return proxyToOrigin(req, reply, controlOrigin(), {
|
||||||
|
unreachableMessage: "control plane unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -11,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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 |
@@ -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));
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* Production UI server (:8500).
|
||||||
|
* Serves packages/web/dist and proxies /api, /ops, /admin, /u like Vite in dev.
|
||||||
|
* Always-on — survives Ops stop of jflow-http.
|
||||||
|
*/
|
||||||
|
import fs from "fs";
|
||||||
|
import fastify from "fastify";
|
||||||
|
import fastifyStatic from "@fastify/static";
|
||||||
|
import { log } from "./logger.js";
|
||||||
|
import { WEB_DIST } from "./paths.js";
|
||||||
|
import {
|
||||||
|
controlOrigin,
|
||||||
|
httpOrigin,
|
||||||
|
proxyToOrigin,
|
||||||
|
} from "./ops-proxy.js";
|
||||||
|
|
||||||
|
if (!fs.existsSync(WEB_DIST)) {
|
||||||
|
log.error(
|
||||||
|
{ WEB_DIST },
|
||||||
|
"web dist missing — run `pnpm build` before starting the UI server",
|
||||||
|
);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = Number(process.env.JFLOW_UI_PORT ?? 8500);
|
||||||
|
const control = controlOrigin();
|
||||||
|
const http = httpOrigin();
|
||||||
|
|
||||||
|
const server = fastify({ loggerInstance: log });
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
async function proxyApi(req, reply) {
|
||||||
|
const url = req.raw.url ?? "";
|
||||||
|
// Match Vite: /api/auth → control (login works when HTTP is stopped).
|
||||||
|
if (url === "/api/auth" || url.startsWith("/api/auth/") || url.startsWith("/api/auth?")) {
|
||||||
|
return proxyToOrigin(req, reply, control, {
|
||||||
|
unreachableMessage: "control plane unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return proxyToOrigin(req, reply, http, {
|
||||||
|
unreachableMessage: "HTTP API unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
async function proxyOps(req, reply) {
|
||||||
|
return proxyToOrigin(req, reply, control, {
|
||||||
|
unreachableMessage: "control plane unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {import("fastify").FastifyRequest} req
|
||||||
|
* @param {import("fastify").FastifyReply} reply
|
||||||
|
*/
|
||||||
|
async function proxyHttp(req, reply) {
|
||||||
|
return proxyToOrigin(req, reply, http, {
|
||||||
|
unreachableMessage: "HTTP API unreachable",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
server.all("/api", proxyApi);
|
||||||
|
server.all("/api/*", proxyApi);
|
||||||
|
server.all("/ops", proxyOps);
|
||||||
|
server.all("/ops/*", proxyOps);
|
||||||
|
server.all("/admin", proxyHttp);
|
||||||
|
server.all("/admin/*", proxyHttp);
|
||||||
|
server.all("/u", proxyHttp);
|
||||||
|
server.all("/u/*", proxyHttp);
|
||||||
|
|
||||||
|
await server.register(fastifyStatic, {
|
||||||
|
root: WEB_DIST,
|
||||||
|
wildcard: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
server.setNotFoundHandler((req, reply) => {
|
||||||
|
const url = req.raw.url ?? "";
|
||||||
|
if (
|
||||||
|
url.startsWith("/api") ||
|
||||||
|
url.startsWith("/u/") ||
|
||||||
|
url.startsWith("/admin") ||
|
||||||
|
url.startsWith("/ops")
|
||||||
|
) {
|
||||||
|
return reply.code(404).send({ error: "not found" });
|
||||||
|
}
|
||||||
|
return reply.sendFile("index.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
async function shutdown() {
|
||||||
|
try {
|
||||||
|
await server.close();
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "web-server shutdown error");
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", shutdown);
|
||||||
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await server.listen({ host: "0.0.0.0", port });
|
||||||
|
log.info(
|
||||||
|
{ port, WEB_DIST, control, http },
|
||||||
|
"UI server listening (static + proxy)",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, "failed to start UI server");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
@@ -9,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}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
/** Default owner folder for new resources (latent tenant id). */
|
||||||
|
export const DEFAULT_OWNER = "local";
|
||||||
@@ -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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import {
|
||||||
|
parseWorkflowYaml,
|
||||||
|
stringifyWorkflowDoc,
|
||||||
|
stepCustomName,
|
||||||
|
stepDisplayName,
|
||||||
|
} from "./workflow-doc.js";
|
||||||
|
|
||||||
|
test("step name round-trips in yaml", () => {
|
||||||
|
const { doc, parseError } = parseWorkflowYaml(`
|
||||||
|
name: demo
|
||||||
|
scripts:
|
||||||
|
- name: Notify to channel
|
||||||
|
script: ntfy.js
|
||||||
|
config:
|
||||||
|
url: https://ntfy.sh/demo
|
||||||
|
- name: Transform
|
||||||
|
set:
|
||||||
|
expression: data
|
||||||
|
`);
|
||||||
|
assert.equal(parseError, null);
|
||||||
|
assert.equal(doc.scripts[0].name, "Notify to channel");
|
||||||
|
assert.equal(doc.scripts[0].script, "ntfy.js");
|
||||||
|
assert.equal(doc.scripts[0].extra, undefined);
|
||||||
|
assert.equal(doc.scripts[1].name, "Transform");
|
||||||
|
assert.equal(doc.scripts[1].kind, "set");
|
||||||
|
|
||||||
|
const yaml = stringifyWorkflowDoc(doc);
|
||||||
|
assert.match(yaml, /name: Notify to channel/);
|
||||||
|
assert.match(yaml, /script: ntfy.js/);
|
||||||
|
assert.match(yaml, /name: Transform/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty step name is omitted from yaml", () => {
|
||||||
|
const { doc } = parseWorkflowYaml(`
|
||||||
|
scripts:
|
||||||
|
- name: " "
|
||||||
|
script: ntfy.js
|
||||||
|
`);
|
||||||
|
assert.equal(doc.scripts[0].name, " ");
|
||||||
|
assert.equal(stepCustomName(doc.scripts[0]), "");
|
||||||
|
const yaml = stringifyWorkflowDoc(doc);
|
||||||
|
assert.doesNotMatch(yaml, /^\s*name:/m);
|
||||||
|
assert.match(yaml, /script: ntfy.js/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("stepDisplayName prefers custom name", () => {
|
||||||
|
assert.equal(
|
||||||
|
stepDisplayName({ kind: "script", script: "ntfy.js", name: "Notify to channel" }),
|
||||||
|
"Notify to channel",
|
||||||
|
);
|
||||||
|
assert.equal(stepDisplayName({ kind: "script", script: "ntfy.js" }), "ntfy.js");
|
||||||
|
assert.equal(stepDisplayName({ kind: "set" }), "set");
|
||||||
|
assert.equal(
|
||||||
|
stepDisplayName({ kind: "script", script: "ntfy.js" }, "fallback.js"),
|
||||||
|
"fallback.js",
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId } from "./workflow-doc.js";
|
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId, stepCustomName } from "./workflow-doc.js";
|
||||||
|
|
||||||
export const STEP_NODE_PREFIX = "step:";
|
export const 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,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}
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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.`
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Generated
+1
-1
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Run the same PM2 binary control.js `require("pm2")` uses.
|
||||||
|
* A global `pm2` 7.x talking to an in-memory 6.x daemon pegs CPU on start.
|
||||||
|
*/
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import { createRequire } from "node:module";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const require = createRequire(
|
||||||
|
path.join(root, "packages/server/package.json"),
|
||||||
|
);
|
||||||
|
const pm2Root = path.dirname(require.resolve("pm2/package.json"));
|
||||||
|
const pm2Bin = path.join(pm2Root, "bin/pm2");
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
|
||||||
|
function run(pm2Args, opts = {}) {
|
||||||
|
return spawnSync(process.execPath, [pm2Bin, ...pm2Args], {
|
||||||
|
cwd: root,
|
||||||
|
encoding: "utf8",
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmd = args[0];
|
||||||
|
if (cmd === "start" || cmd === "restart" || cmd === "reload") {
|
||||||
|
const probe = run(["ls"], { stdio: ["ignore", "pipe", "pipe"] });
|
||||||
|
const text = `${probe.stdout ?? ""}${probe.stderr ?? ""}`;
|
||||||
|
const mem = text.match(/In memory PM2 version:\s*(\S+)/);
|
||||||
|
const loc = text.match(/Local PM2 version:\s*(\S+)/);
|
||||||
|
if (mem && loc && mem[1] !== loc[1]) {
|
||||||
|
console.error(
|
||||||
|
`[jflow] PM2 daemon ${mem[1]} != CLI ${loc[1]}. Killing the daemon so control.js and the CLI share one version.`,
|
||||||
|
);
|
||||||
|
run(["kill"], { stdio: "inherit" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const child = spawn(process.execPath, [pm2Bin, ...args], {
|
||||||
|
cwd: root,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
child.on("exit", (code, signal) => {
|
||||||
|
if (signal) process.kill(process.pid, signal);
|
||||||
|
process.exit(code ?? 1);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user