Merge branch 'main' into cursor/bullmq-queue-phase-a-3038

This commit is contained in:
2026-08-19 15:53:07 +07:00
35 changed files with 2017 additions and 120 deletions
+4 -4
View File
@@ -16,7 +16,7 @@ pnpm install
pnpm dev
```
- API: http://localhost:9000
- API: http://localhost:8700
- UI (dev): http://localhost:5173
The first account created becomes **admin**. Later accounts are created from Users.
@@ -52,9 +52,9 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|---|---|
| `pnpm dev` | Server + Vite together |
| `pnpm dev:server` | API/runner only |
| `pnpm dev:web` | UI only (proxies `/api` to :9000) |
| `pnpm dev:web` | UI only (proxies `/api` to :8700) |
| `pnpm build` | Production UI build |
| `pnpm start` | Serve API and built UI from :9000 |
| `pnpm start` | Serve API and built UI from :8700 |
| `pnpm migrate` | Apply SQLite migrations |
## Environment
@@ -71,7 +71,7 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
| `JFLOW_LOG_LEVEL` | `debug` | Pino level |
| `JFLOW_RETENTION_DAYS` | `30` | Run history prune |
| `JFLOW_CORS_ORIGIN` | `http://localhost:5173` | Vite origin in dev |
| `PORT` | `9000` | HTTP port |
| `PORT` | `8700` | HTTP port |
| `NODE_ENV` | — | Set `production` for secure cookies |
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.
+15 -22
View File
@@ -1,41 +1,34 @@
### Manual trigger (default owner)
POST http://localhost:9000/u/default/mt
POST http://localhost:8700/u/default/mt
Content-Type: application/json
0
###
POST http://localhost:9000/u/default/time-to-ntfy
POST http://localhost:8700/u/default/time-to-ntfy
Content-Type: application/json
{}
### Auth bootstrap
GET http://localhost:9000/api/auth/bootstrap
### Login
POST http://localhost:9000/api/auth/login
###
GET http://localhost:8700/api/auth/bootstrap
###
POST http://localhost:8700/api/auth/login
Content-Type: application/json
{
"username": "admin",
"password": "changeme1"
}
### Dashboard
GET http://localhost:9000/api/dashboard
### Runs
GET http://localhost:9000/api/runs?owner=default&limit=20
### Reregister
POST http://localhost:9000/api/workflows/reregister
###
GET http://localhost:8700/api/dashboard
###
GET http://localhost:8700/api/runs?owner=default&limit=20
###
POST http://localhost:8700/api/workflows/reregister
Content-Type: application/json
{}
### Run workflow manually
POST http://localhost:9000/api/workflows/default/manual-trigger.yaml/run
###
POST http://localhost:8700/api/workflows/default/manual-trigger.yaml/run
Content-Type: application/json
{}
{}
+43
View File
@@ -0,0 +1,43 @@
const BUFFER_PREVIEW_BYTES = 16;
/**
* @param {unknown} value
*/
export function isBinary(value) {
return (
Buffer.isBuffer(value) ||
ArrayBuffer.isView(value) ||
value instanceof ArrayBuffer
);
}
/**
* Compact stand-in for JSON (Buffer.toJSON dumps every byte as a number).
* @param {Buffer | ArrayBufferView | ArrayBuffer} value
*/
export function summarizeBinary(value) {
const buf = Buffer.isBuffer(value)
? value
: value instanceof ArrayBuffer
? Buffer.from(value)
: Buffer.from(value.buffer, value.byteOffset, value.byteLength);
const take = Math.min(buf.length, BUFFER_PREVIEW_BYTES);
return {
type: "Buffer",
length: buf.length,
preview: buf.subarray(0, take).toString("hex"),
truncated: buf.length > take,
};
}
/**
* JSON.stringify replacer. Must be a real function so `this` is the holder:
* Buffer#toJSON already ran on `value`, but `this[key]` is still the Buffer.
* @param {string} key
* @param {unknown} value
*/
export function jsonPreviewReplacer(key, value) {
const raw = this[key];
if (isBinary(raw)) return summarizeBinary(raw);
return value;
}
+4
View File
@@ -10,11 +10,14 @@
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\""
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1111.0",
"@aws-sdk/s3-request-presigner": "^3.1111.0",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.1.0",
"@fastify/jwt": "^9.1.0",
"@fastify/static": "^8.2.0",
"axios": "^1.19.0",
"basic-ftp": "^6.2.0",
"bcryptjs": "^3.0.2",
"better-sqlite3": "^13.0.3",
"bullmq": "^6.1.2",
@@ -28,6 +31,7 @@
"nodemailer": "^9.0.5",
"pino": "^10.3.1",
"pino-roll": "^4.0.0",
"ssh2-sftp-client": "^12.1.1",
"webdav": "^5.10.0",
"yaml": "^2.9.0"
}
+1 -1
View File
@@ -693,7 +693,7 @@ export function createRegistry(server, opts = {}) {
{
workflow: opts.key,
consecutiveFailures,
triggerWorkflow: failureConfig.workflowName,
onFailureWorkflow: failureConfig.workflowName,
destination: destKey,
},
"triggering failure alert workflow",
+1 -1
View File
@@ -225,7 +225,7 @@ async function shutdown() {
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
const port = Number(process.env.PORT ?? 9000);
const port = Number(process.env.PORT ?? 8700);
if (runApi) {
server
+5
View File
@@ -15,12 +15,17 @@ import { getVariablePlain } from "./variables-store.js";
const hostRequire = createRequire(import.meta.url);
const ALLOWED_MODULES = new Set([
"@aws-sdk/client-s3",
"@aws-sdk/s3-request-presigner",
"axios",
"basic-ftp",
"jsonata",
"mustache",
"node-html-parser",
"node:stream",
"nodemailer",
"rss-parser",
"ssh2-sftp-client",
"webdav",
]);
+521
View File
@@ -0,0 +1,521 @@
import SftpClient from "ssh2-sftp-client";
import { Client } from "basic-ftp";
import { Readable, Writable } from "node:stream";
const PROTOCOLS = new Set(["ftp", "sftp"]);
const ACTIONS = new Set(["list", "read", "write", "delete", "stat", "mkdir", "rename"]);
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
}
function resolveProtocol(ctx) {
const raw = ctx.config?.protocol ?? ctx.data?.protocol ?? "sftp";
if (typeof raw !== "string" || raw.length === 0) {
throw new Error("remote-fs: protocol must be a non-empty string");
}
const protocol = raw.toLowerCase();
if (!PROTOCOLS.has(protocol)) {
throw new Error(`remote-fs: unsupported protocol "${raw}" (use ftp or sftp)`);
}
return protocol;
}
function resolveAction(ctx) {
const raw = ctx.config?.action ?? ctx.data?.action ?? "list";
if (typeof raw !== "string" || raw.length === 0) {
throw new Error("remote-fs: action must be a non-empty string");
}
const action = raw.toLowerCase();
if (!ACTIONS.has(action)) {
throw new Error(`remote-fs: unsupported action "${raw}"`);
}
return action;
}
function resolvePath(ctx, { required = false, label = "path" } = {}) {
const path = ctx.config?.path ?? ctx.data?.path;
if (path == null || path === "") {
if (required) throw new Error(`remote-fs: ${label} is required`);
return ".";
}
if (typeof path !== "string") {
throw new Error(`remote-fs: ${label} must be a string`);
}
return path;
}
function resolveHost(ctx) {
const host = ctx.config?.host ?? ctx.data?.host;
if (typeof host !== "string" || host.length === 0) {
throw new Error("remote-fs: host is required (ctx.config.host or ctx.data.host)");
}
return host;
}
function resolvePort(ctx, protocol) {
const raw = ctx.config?.port ?? ctx.data?.port;
if (raw == null || raw === "") {
return protocol === "sftp" ? 22 : 21;
}
const port = Number(raw);
if (!Number.isFinite(port) || port <= 0) {
throw new Error("remote-fs: port must be a positive number");
}
return port;
}
async function resolveSecretValue(secretName, label) {
if (typeof secretName !== "string" || secretName.length === 0) {
throw new Error(`remote-fs: ${label} is required`);
}
return $secrets.reveal(await $secrets.get(secretName));
}
async function resolveAuth(ctx) {
const username =
typeof ctx.config?.usernameSecret === "string" && ctx.config.usernameSecret.length > 0
? await resolveSecretValue(ctx.config.usernameSecret, "usernameSecret")
: (ctx.config?.username ?? ctx.data?.username);
if (typeof username !== "string" || username.length === 0) {
throw new Error("remote-fs: username is required");
}
let password;
if (typeof ctx.config?.passwordSecret === "string" && ctx.config.passwordSecret.length > 0) {
password = await resolveSecretValue(ctx.config.passwordSecret, "passwordSecret");
} else if (typeof ctx.config?.password === "string") {
password = ctx.config.password;
} else if (typeof ctx.data?.password === "string") {
password = ctx.data.password;
}
let privateKey;
if (typeof ctx.config?.privateKeySecret === "string" && ctx.config.privateKeySecret.length > 0) {
privateKey = await resolveSecretValue(ctx.config.privateKeySecret, "privateKeySecret");
} else if (typeof ctx.config?.privateKey === "string") {
privateKey = ctx.config.privateKey;
}
let passphrase;
if (typeof ctx.config?.passphraseSecret === "string" && ctx.config.passphraseSecret.length > 0) {
passphrase = await resolveSecretValue(ctx.config.passphraseSecret, "passphraseSecret");
} else if (typeof ctx.config?.passphrase === "string") {
passphrase = ctx.config.passphrase;
}
if (!password && !privateKey) {
throw new Error(
"remote-fs: password or private key is required (passwordSecret/privateKeySecret or inline values)",
);
}
return { username, password, privateKey, passphrase };
}
function toIsoDate(value) {
if (value == null) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function joinRemotePath(parentPath, name) {
if (!parentPath || parentPath === ".") return name;
if (parentPath.endsWith("/")) return `${parentPath}${name}`;
return `${parentPath}/${name}`;
}
function normalizeSftpEntry(entry, parentPath) {
const name = entry.name;
const type = entry.type === "d" ? "directory" : "file";
return {
name,
path: joinRemotePath(parentPath, name),
type,
size: type === "directory" ? null : typeof entry.size === "number" ? entry.size : null,
modified: toIsoDate(entry.modifyTime),
};
}
function normalizeFtpEntry(entry, parentPath) {
const type = entry.type === 2 ? "directory" : "file";
return {
name: entry.name,
path: joinRemotePath(parentPath, entry.name),
type,
size: type === "directory" ? null : typeof entry.size === "number" ? entry.size : null,
modified: toIsoDate(entry.modifiedAt ?? entry.rawModifiedAt),
};
}
function bufferFromWritable(writeFn) {
return new Promise((resolve, reject) => {
/** @type {Buffer[]} */
const chunks = [];
const writable = new Writable({
write(chunk, _encoding, callback) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
callback();
},
});
writable.on("finish", () => resolve(Buffer.concat(chunks)));
writable.on("error", reject);
Promise.resolve(writeFn(writable)).catch(reject);
});
}
function resolveWriteBody(ctx) {
if (ctx.config != null && typeof ctx.config === "object" && "body" in ctx.config) {
const body = ctx.config.body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) return Buffer.from(body);
if (typeof body === "string") return Buffer.from(body, "utf8");
return Buffer.from(JSON.stringify(body), "utf8");
}
if (ctx.data?.file != null) {
const file = ctx.data.file;
if (Buffer.isBuffer(file) || file instanceof Uint8Array) return Buffer.from(file);
}
if (ctx.data?.body != null) {
const body = ctx.data.body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) return Buffer.from(body);
if (typeof body === "string") return Buffer.from(body, "utf8");
return Buffer.from(JSON.stringify(body), "utf8");
}
throw new Error("remote-fs: write requires ctx.config.body, ctx.data.body, or ctx.data.file");
}
async function withSftp(ctx, protocol, fn) {
const auth = await resolveAuth(ctx);
const client = new SftpClient();
/** @type {Record<string, unknown>} */
const connectOptions = {
host: resolveHost(ctx),
port: resolvePort(ctx, protocol),
username: auth.username,
};
if (auth.privateKey) {
connectOptions.privateKey = auth.privateKey;
if (auth.passphrase) connectOptions.passphrase = auth.passphrase;
} else {
connectOptions.password = auth.password;
}
if (ctx.config?.readyTimeout != null) {
connectOptions.readyTimeout = Number(ctx.config.readyTimeout);
}
await client.connect(connectOptions);
try {
return await fn(client);
} finally {
await client.end();
}
}
async function withFtp(ctx, protocol, fn) {
const auth = await resolveAuth(ctx);
const client = new Client(
typeof ctx.config?.timeout === "number" ? ctx.config.timeout : 30000,
);
const secure = ctx.config?.secure === true || ctx.config?.secure === "implicit";
await client.access({
host: resolveHost(ctx),
port: resolvePort(ctx, protocol),
user: auth.username,
password: auth.password ?? "",
secure,
});
if (ctx.config?.passive === false) {
client.ftp.passive = false;
}
try {
return await fn(client);
} finally {
client.close();
}
}
async function runAction(protocol, ctx, action) {
const path = resolvePath(ctx, { required: action !== "list", label: "path" });
if (protocol === "sftp") {
return withSftp(ctx, protocol, async (client) => {
switch (action) {
case "list": {
const listPath = resolvePath(ctx);
const items = await client.list(listPath);
const entries = items.map((item) => normalizeSftpEntry(item, listPath));
return { path: listPath, entries, count: entries.length };
}
case "read": {
const outputVar =
typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0
? ctx.config.outputVar
: "file";
const file = await client.get(path);
const buffer = Buffer.isBuffer(file) ? file : Buffer.from(file);
const encoding = ctx.config?.encoding ?? ctx.data?.encoding;
/** @type {Record<string, unknown>} */
const out = {
path,
[outputVar]: buffer,
contentLength: buffer.length,
};
if (encoding === "utf8" || encoding === "text") out.text = buffer.toString("utf8");
if (encoding === "base64") out.base64 = buffer.toString("base64");
return out;
}
case "write": {
const body = resolveWriteBody(ctx);
await client.put(body, path);
return { path, written: true, contentLength: body.length };
}
case "delete": {
await client.delete(path);
return { path, deleted: true };
}
case "stat": {
const stat = await client.stat(path);
return {
path,
type: stat.isDirectory ? "directory" : "file",
size: typeof stat.size === "number" ? stat.size : null,
modified: toIsoDate(stat.modifyTime),
accessed: toIsoDate(stat.accessTime),
};
}
case "mkdir": {
const recursive = ctx.config?.recursive !== false;
await client.mkdir(path, recursive);
return { path, created: true, recursive };
}
case "rename": {
const destination = ctx.config?.destination ?? ctx.data?.destination;
if (typeof destination !== "string" || destination.length === 0) {
throw new Error("remote-fs: rename requires destination");
}
await client.rename(path, destination);
return { path, destination, renamed: true };
}
default:
throw new Error(`remote-fs: unsupported action "${action}"`);
}
});
}
return withFtp(ctx, protocol, async (client) => {
switch (action) {
case "list": {
const listPath = resolvePath(ctx);
const items = await client.list(listPath === "." ? undefined : listPath);
const entries = items.map((item) => normalizeFtpEntry(item, listPath));
return { path: listPath, entries, count: entries.length };
}
case "read": {
const outputVar =
typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0
? ctx.config.outputVar
: "file";
const buffer = await bufferFromWritable((writable) => client.downloadTo(writable, path));
const encoding = ctx.config?.encoding ?? ctx.data?.encoding;
/** @type {Record<string, unknown>} */
const out = {
path,
[outputVar]: buffer,
contentLength: buffer.length,
};
if (encoding === "utf8" || encoding === "text") out.text = buffer.toString("utf8");
if (encoding === "base64") out.base64 = buffer.toString("base64");
return out;
}
case "write": {
const body = resolveWriteBody(ctx);
const stream = Readable.from(body);
await client.uploadFrom(stream, path);
return { path, written: true, contentLength: body.length };
}
case "delete": {
await client.remove(path);
return { path, deleted: true };
}
case "stat": {
const size = await client.size(path);
const modified = await client.lastMod(path);
return {
path,
type: "file",
size: typeof size === "number" ? size : null,
modified: toIsoDate(modified),
};
}
case "mkdir": {
await client.ensureDir(path);
return { path, created: true, recursive: true };
}
case "rename": {
const destination = ctx.config?.destination ?? ctx.data?.destination;
if (typeof destination !== "string" || destination.length === 0) {
throw new Error("remote-fs: rename requires destination");
}
await client.rename(path, destination);
return { path, destination, renamed: true };
}
default:
throw new Error(`remote-fs: unsupported action "${action}"`);
}
});
}
async function remoteFs(ctx) {
const protocol = resolveProtocol(ctx);
const action = resolveAction(ctx);
const host = resolveHost(ctx);
const port = resolvePort(ctx, protocol);
log.info({ protocol, action, host, port }, "remote-fs: starting");
const result = await runAction(protocol, ctx, action);
const output = {
protocol,
action,
host,
...result,
};
log.info(
{ protocol, action, path: output.path ?? null, count: output.count ?? null },
"remote-fs: complete",
);
return {
output: { ...mergeData(ctx.data), ...output },
context: { ...passContext(ctx), ...output },
};
}
remoteFs.meta = {
description: "Access remote files over SFTP or FTP/FTPS",
previewConfigKey: "protocol",
tags: ["SFTP", "FTP", "storage"],
config: {
protocol: {
type: "string",
default: "sftp",
enum: ["sftp", "ftp"],
description: "Transfer protocol",
},
action: {
type: "string",
default: "list",
enum: ["list", "read", "write", "delete", "stat", "mkdir", "rename"],
description: "Operation to perform",
},
host: { type: "string", required: true, description: "Server hostname" },
port: { type: "number", required: false, description: "Port (default 22 for SFTP, 21 for FTP)" },
path: {
type: "string",
required: false,
description: "Remote directory for list, or file path for other actions",
},
username: { type: "string", required: false, description: "Login username" },
usernameSecret: { type: "string", required: false, description: "Named secret for username" },
password: { type: "string", required: false, description: "Login password" },
passwordSecret: { type: "string", required: false, description: "Named secret for password" },
privateKeySecret: {
type: "string",
required: false,
description: "Named secret holding an SFTP private key (PEM)",
},
privateKey: { type: "string", required: false, description: "Inline SFTP private key (PEM)" },
passphraseSecret: {
type: "string",
required: false,
description: "Named secret for encrypted private key passphrase",
},
passphrase: { type: "string", required: false, description: "Private key passphrase" },
secure: {
type: "boolean",
default: false,
description: "Use FTPS for FTP protocol",
},
passive: {
type: "boolean",
default: true,
description: "Use passive FTP mode",
},
recursive: {
type: "boolean",
default: true,
description: "Create parent directories for mkdir",
},
destination: {
type: "string",
required: false,
description: "Destination path for rename",
},
body: { type: "any", required: false, description: "Write payload" },
outputVar: {
type: "string",
default: "file",
description: "Output key for read action bytes",
},
encoding: {
type: "string",
required: false,
enum: ["utf8", "text", "base64"],
description: "Optional read decoding helper",
},
timeout: { type: "number", required: false, description: "FTP client timeout in ms" },
readyTimeout: { type: "number", required: false, description: "SFTP ready timeout in ms" },
},
input: {
protocol: { type: "string", required: false },
action: { type: "string", required: false },
host: { type: "string", required: false },
path: { type: "string", required: false },
username: { type: "string", required: false },
password: { type: "string", required: false },
body: { type: "any", required: false },
file: { type: "buffer", required: false },
destination: { type: "string", required: false },
},
output: {
protocol: { type: "string" },
action: { type: "string" },
host: { type: "string" },
entries: { type: "array", required: false, description: "list results" },
file: { type: "buffer", required: false, description: "read bytes (or outputVar)" },
written: { type: "boolean", required: false },
deleted: { type: "boolean", required: false },
renamed: { type: "boolean", required: false },
created: { type: "boolean", required: false },
},
context: {
protocol: { type: "string" },
action: { type: "string" },
host: { type: "string" },
},
example: {
data: {},
config: {
protocol: "sftp",
action: "list",
host: "sftp.example.com",
path: "/incoming",
username: "deploy",
passwordSecret: "sftp_password",
},
},
};
export default remoteFs;
+508
View File
@@ -0,0 +1,508 @@
import {
CopyObjectCommand,
DeleteObjectCommand,
GetObjectCommand,
HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const ACTIONS = new Set(["list", "read", "write", "delete", "stat", "presign", "copy"]);
function passContext(ctx) {
if (ctx?.context != null && typeof ctx.context === "object" && !Array.isArray(ctx.context)) {
return { ...ctx.context };
}
return {};
}
function mergeData(data) {
if (data != null && typeof data === "object" && !Array.isArray(data)) {
return { ...data };
}
return {};
}
function resolveAction(ctx) {
const raw = ctx.config?.action ?? ctx.data?.action ?? "list";
if (typeof raw !== "string" || raw.length === 0) {
throw new Error("s3: action must be a non-empty string");
}
const action = raw.toLowerCase();
if (!ACTIONS.has(action)) {
throw new Error(`s3: unsupported action "${raw}"`);
}
return action;
}
function resolveBucket(ctx) {
const bucket = ctx.config?.bucket ?? ctx.data?.bucket;
if (typeof bucket !== "string" || bucket.length === 0) {
throw new Error("s3: bucket is required (ctx.config.bucket or ctx.data.bucket)");
}
return bucket;
}
function resolveKey(ctx, { required = false } = {}) {
const key = ctx.config?.key ?? ctx.data?.key;
if (key == null || key === "") {
if (required) throw new Error("s3: key is required for this action");
return undefined;
}
if (typeof key !== "string") {
throw new Error("s3: key must be a string");
}
return key;
}
async function resolveSecretValue(secretName, label) {
if (typeof secretName !== "string" || secretName.length === 0) {
throw new Error(`s3: ${label} is required`);
}
return $secrets.reveal(await $secrets.get(secretName));
}
async function resolveCredentials(ctx) {
const accessKeyIdSecret = ctx.config?.accessKeyIdSecret;
const secretAccessKeySecret = ctx.config?.secretAccessKeySecret;
if (
typeof accessKeyIdSecret === "string" &&
accessKeyIdSecret.length > 0 &&
typeof secretAccessKeySecret === "string" &&
secretAccessKeySecret.length > 0
) {
return {
accessKeyId: await resolveSecretValue(accessKeyIdSecret, "accessKeyIdSecret"),
secretAccessKey: await resolveSecretValue(secretAccessKeySecret, "secretAccessKeySecret"),
};
}
const accessKeyId = ctx.config?.accessKeyId ?? ctx.data?.accessKeyId;
const secretAccessKey = ctx.config?.secretAccessKey ?? ctx.data?.secretAccessKey;
if (typeof accessKeyId === "string" && typeof secretAccessKey === "string") {
return { accessKeyId, secretAccessKey };
}
throw new Error(
"s3: credentials are required (accessKeyIdSecret + secretAccessKeySecret, or accessKeyId + secretAccessKey)",
);
}
function createS3Client(ctx, credentials) {
const endpoint = ctx.config?.endpoint ?? ctx.data?.endpoint;
const region =
typeof ctx.config?.region === "string" && ctx.config.region.length > 0
? ctx.config.region
: typeof ctx.data?.region === "string" && ctx.data.region.length > 0
? ctx.data.region
: "us-east-1";
/** @type {import("@aws-sdk/client-s3").S3ClientConfig} */
const options = {
region,
credentials,
};
if (typeof endpoint === "string" && endpoint.length > 0) {
options.endpoint = endpoint;
}
if (ctx.config?.forcePathStyle === true || ctx.data?.forcePathStyle === true) {
options.forcePathStyle = true;
}
return new S3Client(options);
}
function toIsoDate(value) {
if (value == null) return null;
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
}
function normalizeListedObject(item) {
return {
key: item.Key ?? null,
size: typeof item.Size === "number" ? item.Size : null,
modified: toIsoDate(item.LastModified),
etag: item.ETag ?? null,
storageClass: item.StorageClass ?? null,
};
}
async function readObjectBody(body) {
if (body == null) return Buffer.alloc(0);
if (typeof body.transformToByteArray === "function") {
return Buffer.from(await body.transformToByteArray());
}
/** @type {Buffer[]} */
const chunks = [];
for await (const chunk of body) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
function resolveWriteBody(ctx) {
if (ctx.config != null && typeof ctx.config === "object" && "body" in ctx.config) {
return ctx.config.body;
}
if (ctx.data?.file != null) {
const file = ctx.data.file;
if (Buffer.isBuffer(file) || file instanceof Uint8Array) {
return Buffer.from(file);
}
}
if (ctx.data?.body != null) {
const body = ctx.data.body;
if (Buffer.isBuffer(body) || body instanceof Uint8Array) {
return Buffer.from(body);
}
if (typeof body === "string") {
return Buffer.from(body, "utf8");
}
return Buffer.from(JSON.stringify(body), "utf8");
}
throw new Error("s3: write requires ctx.config.body, ctx.data.body, or ctx.data.file");
}
async function s3(ctx) {
const action = resolveAction(ctx);
const bucket = resolveBucket(ctx);
const credentials = await resolveCredentials(ctx);
const client = createS3Client(ctx, credentials);
log.info({ action, bucket }, "s3: starting action");
/** @type {Record<string, unknown>} */
let result = { action, bucket };
switch (action) {
case "list": {
const prefix = ctx.config?.prefix ?? ctx.data?.prefix ?? "";
const delimiter = ctx.config?.delimiter ?? ctx.data?.delimiter;
const maxKeys = Number(ctx.config?.maxKeys ?? ctx.data?.maxKeys ?? 1000);
const response = await client.send(
new ListObjectsV2Command({
Bucket: bucket,
Prefix: typeof prefix === "string" ? prefix : "",
Delimiter: typeof delimiter === "string" && delimiter.length > 0 ? delimiter : undefined,
MaxKeys: Number.isFinite(maxKeys) && maxKeys > 0 ? maxKeys : 1000,
}),
);
const objects = (response.Contents ?? []).map(normalizeListedObject);
const prefixes = (response.CommonPrefixes ?? [])
.map((entry) => entry.Prefix)
.filter((value) => typeof value === "string");
result = {
...result,
prefix: typeof prefix === "string" ? prefix : "",
objects,
prefixes,
count: objects.length,
isTruncated: response.IsTruncated === true,
nextContinuationToken: response.NextContinuationToken ?? null,
};
break;
}
case "read": {
const key = resolveKey(ctx, { required: true });
const outputVar =
typeof ctx.config?.outputVar === "string" && ctx.config.outputVar.length > 0
? ctx.config.outputVar
: "file";
const response = await client.send(
new GetObjectCommand({
Bucket: bucket,
Key: key,
}),
);
const file = await readObjectBody(response.Body);
const contentType = response.ContentType ?? "application/octet-stream";
const encoding = ctx.config?.encoding ?? ctx.data?.encoding;
result = {
...result,
key,
[outputVar]: file,
contentType,
contentLength: file.length,
etag: response.ETag ?? null,
lastModified: toIsoDate(response.LastModified),
};
if (encoding === "utf8" || encoding === "text") {
result.text = file.toString("utf8");
} else if (encoding === "base64") {
result.base64 = file.toString("base64");
}
break;
}
case "write": {
const key = resolveKey(ctx, { required: true });
const body = resolveWriteBody(ctx);
const contentType =
ctx.config?.contentType ??
ctx.data?.contentType ??
(typeof ctx.data?.body === "string" ? "text/plain; charset=utf-8" : "application/octet-stream");
const response = await client.send(
new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: body,
ContentType: typeof contentType === "string" ? contentType : undefined,
}),
);
result = {
...result,
key,
etag: response.ETag ?? null,
contentLength: body.length,
written: true,
};
break;
}
case "delete": {
const key = resolveKey(ctx, { required: true });
await client.send(
new DeleteObjectCommand({
Bucket: bucket,
Key: key,
}),
);
result = {
...result,
key,
deleted: true,
};
break;
}
case "stat": {
const key = resolveKey(ctx, { required: true });
const response = await client.send(
new HeadObjectCommand({
Bucket: bucket,
Key: key,
}),
);
result = {
...result,
key,
contentType: response.ContentType ?? null,
contentLength: typeof response.ContentLength === "number" ? response.ContentLength : null,
etag: response.ETag ?? null,
lastModified: toIsoDate(response.LastModified),
metadata: response.Metadata ?? {},
};
break;
}
case "presign": {
const key = resolveKey(ctx, { required: true });
const method = String(ctx.config?.presignMethod ?? ctx.data?.presignMethod ?? "get").toLowerCase();
const expiresIn = Number(ctx.config?.expiresIn ?? ctx.data?.expiresIn ?? 3600);
const command =
method === "put"
? new PutObjectCommand({ Bucket: bucket, Key: key })
: new GetObjectCommand({ Bucket: bucket, Key: key });
const url = await getSignedUrl(client, command, {
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600,
});
result = {
...result,
key,
method,
expiresIn: Number.isFinite(expiresIn) && expiresIn > 0 ? expiresIn : 3600,
url,
};
break;
}
case "copy": {
const key = resolveKey(ctx, { required: true });
const sourceKey = ctx.config?.sourceKey ?? ctx.data?.sourceKey;
const sourceBucket = ctx.config?.sourceBucket ?? ctx.data?.sourceBucket ?? bucket;
if (typeof sourceKey !== "string" || sourceKey.length === 0) {
throw new Error("s3: copy requires sourceKey (ctx.config.sourceKey or ctx.data.sourceKey)");
}
const response = await client.send(
new CopyObjectCommand({
Bucket: bucket,
Key: key,
CopySource: `${sourceBucket}/${sourceKey}`,
}),
);
result = {
...result,
key,
sourceBucket,
sourceKey,
etag: response.CopyObjectResult?.ETag ?? null,
copied: true,
};
break;
}
default:
throw new Error(`s3: unsupported action "${action}"`);
}
log.info({ action, bucket, key: result.key ?? null }, "s3: action complete");
return {
output: { ...mergeData(ctx.data), ...result },
context: { ...passContext(ctx), ...result },
};
}
s3.meta = {
description: "Access S3-compatible object storage (AWS S3, MinIO, Cloudflare R2, etc.)",
previewConfigKey: "action",
tags: ["S3", "storage"],
config: {
action: {
type: "string",
default: "list",
enum: ["list", "read", "write", "delete", "stat", "presign", "copy"],
description: "Operation to perform",
},
endpoint: {
type: "string",
required: false,
description: "Custom S3 endpoint URL (required for MinIO and most non-AWS providers)",
},
region: {
type: "string",
default: "us-east-1",
description: "AWS region (still required by many S3-compatible APIs)",
},
bucket: {
type: "string",
required: true,
description: "Bucket name",
},
key: {
type: "string",
required: false,
description: "Object key (required for read, write, delete, stat, presign, copy)",
},
prefix: {
type: "string",
required: false,
description: "List only keys under this prefix",
},
delimiter: {
type: "string",
required: false,
description: "List folder delimiter, usually /",
},
maxKeys: {
type: "number",
default: 1000,
description: "Maximum objects returned by list",
},
forcePathStyle: {
type: "boolean",
default: false,
description: "Use path-style URLs (often required for MinIO)",
},
accessKeyIdSecret: {
type: "string",
required: false,
description: "Named secret for the access key id",
},
secretAccessKeySecret: {
type: "string",
required: false,
description: "Named secret for the secret access key",
},
accessKeyId: {
type: "string",
required: false,
description: "Plain access key id (prefer secrets in production)",
},
secretAccessKey: {
type: "string",
required: false,
description: "Plain secret access key (prefer secrets in production)",
},
contentType: {
type: "string",
required: false,
description: "Content-Type for write",
},
body: {
type: "any",
required: false,
description: "Body for write when not passed via data",
},
outputVar: {
type: "string",
default: "file",
description: "Output key for read action bytes",
},
encoding: {
type: "string",
required: false,
enum: ["utf8", "text", "base64"],
description: "Optional read decoding helper (adds text or base64 field)",
},
expiresIn: {
type: "number",
default: 3600,
description: "Presigned URL lifetime in seconds",
},
presignMethod: {
type: "string",
default: "get",
enum: ["get", "put"],
description: "Presign a download (get) or upload (put) URL",
},
sourceBucket: {
type: "string",
required: false,
description: "Source bucket for copy (defaults to bucket)",
},
sourceKey: {
type: "string",
required: false,
description: "Source key for copy",
},
},
input: {
action: { type: "string", required: false },
bucket: { type: "string", required: false },
key: { type: "string", required: false },
prefix: { type: "string", required: false },
body: { type: "any", required: false, description: "Write payload" },
file: { type: "buffer", required: false, description: "Binary write payload" },
sourceKey: { type: "string", required: false },
sourceBucket: { type: "string", required: false },
},
output: {
action: { type: "string" },
bucket: { type: "string" },
objects: { type: "array", required: false, description: "list results" },
file: { type: "buffer", required: false, description: "read bytes (or outputVar)" },
url: { type: "string", required: false, description: "presigned URL" },
written: { type: "boolean", required: false },
deleted: { type: "boolean", required: false },
copied: { type: "boolean", required: false },
},
context: {
action: { type: "string" },
bucket: { type: "string" },
},
example: {
data: {},
config: {
action: "list",
endpoint: "https://minio.example.com",
region: "us-east-1",
bucket: "backups",
prefix: "daily/",
forcePathStyle: true,
accessKeyIdSecret: "minio_access_key",
secretAccessKeySecret: "minio_secret_key",
},
},
};
export default s3;
+1
View File
@@ -1,6 +1,7 @@
import { inspect } from "node:util";
export const REDACTED = "[secret]";
/** Short values are stored, but skipped in log redaction to avoid false positives. */
export const MIN_SECRET_LENGTH = 8;
/** @type {Set<string>} */
+3 -3
View File
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { assertOwner } from "./fs-store.js";
import { decryptSecret, encryptSecret } from "./secrets.js";
import { MIN_SECRET_LENGTH, registerPlaintext } from "./secret-value.js";
import { registerPlaintext } from "./secret-value.js";
const MAX_NAME_LENGTH = 128;
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
@@ -68,8 +68,8 @@ export async function getSecretById(id) {
* @param {{ owner: string, name: string, value: string }} opts
*/
export async function upsertSecret({ owner, name, value }) {
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
if (typeof value !== "string" || value.length === 0) {
const err = new Error("value is required");
err.statusCode = 400;
throw err;
}
+6 -3
View File
@@ -63,9 +63,10 @@ export default function dashboardPluginFactory(registry) {
}
}
const [active, failed, recent] = await Promise.all([
const [active, streaks, failedEvents, recent] = await Promise.all([
store.listRuns({ status: ["queued", "running"], limit: 10 }),
store.listRuns({ status: "failed", limit: 20 }),
store.listConsecutiveFailureStreaks({ minCount: 4, limit: 10 }),
store.listRuns({ status: "failed", limit: 5 }),
store.listRuns({ limit: 10 }),
]);
@@ -76,9 +77,11 @@ export default function dashboardPluginFactory(registry) {
brokenCount,
running: active,
needsAttention: {
failed,
consecutiveFailures: streaks.items,
consecutiveFailureCount: streaks.total,
brokenWorkflows,
},
failedEvents,
recent,
};
});
+4 -1
View File
@@ -1,4 +1,5 @@
import pino from "pino";
import { isBinary, summarizeBinary } from "../../json-preview.js";
import { redactString } from "../../secret-value.js";
const LEVEL_TO_NUM = {
@@ -64,7 +65,9 @@ export function safeSerialize(value) {
try {
return JSON.parse(
redactString(
JSON.stringify(value, (_key, v) => {
JSON.stringify(value, function (key, v) {
const raw = this[key];
if (isBinary(raw)) return summarizeBinary(raw);
if (typeof v === "bigint") return v.toString();
if (typeof v === "object" && v !== null) {
if (seen.has(v)) return "[Circular]";
+9
View File
@@ -17,6 +17,15 @@ export default async function runsPlugin(fastify) {
return { runs };
});
fastify.get("/consecutive-failures", async (req) => {
const q = /** @type {Record<string, string | undefined>} */ (req.query ?? {});
const limit = q.limit ? Number(q.limit) : undefined;
return store.listConsecutiveFailureStreaks({
minCount: 4,
limit: Number.isFinite(limit) ? limit : 200,
});
});
fastify.get("/runs/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const run = await store.getRun(id);
+2 -5
View File
@@ -6,7 +6,6 @@ import {
listSecrets,
upsertSecret,
} from "../../secrets-store.js";
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
/**
* @param {import("fastify").FastifyInstance} fastify
@@ -37,10 +36,8 @@ export default async function secretsPlugin(fastify) {
}
const value = String(body.value ?? "");
if (value.length < MIN_SECRET_LENGTH) {
return reply
.code(400)
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
if (value.length === 0) {
return reply.code(400).send({ error: "value is required" });
}
try {
+1 -1
View File
@@ -28,7 +28,7 @@ function triggerSummary(owner, workflow) {
path: isHttp && t?.path != null ? namespacedPath(owner, t.path) : t?.path ?? null,
schedule: t?.schedule ?? null,
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
triggerWorkflow: t?.triggerWorkflow ?? null,
onFailureWorkflow: t?.onFailureWorkflow ?? null,
auth: isHttp ? authLabel(t?.auth) : null,
};
});
+108 -1
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { jsonPreviewReplacer } from "./json-preview.js";
import { redactString } from "./secret-value.js";
const MAX_JSON_BYTES = 64 * 1024;
@@ -12,7 +13,7 @@ export function serialize(value) {
if (value === undefined || value === null) return null;
let json;
try {
json = JSON.stringify(value);
json = JSON.stringify(value, jsonPreviewReplacer);
} catch {
json = JSON.stringify({ truncated: true, reason: "unserializable" });
}
@@ -24,6 +25,20 @@ export function serialize(value) {
});
}
/**
* Parsed JSON-safe copy for API / UI (buffers summarized, size-capped).
* @param {unknown} value
*/
export function toDisplayValue(value) {
const json = serialize(value);
if (json == null) return null;
try {
return JSON.parse(json);
} catch {
return null;
}
}
/**
* @param {string | null} value
* @returns {unknown}
@@ -311,6 +326,98 @@ export async function countConsecutiveFailures(workflow, triggerType, triggerDet
return count;
}
const CONSECUTIVE_FAILURE_WINDOW = 5000;
const STREAK_LAST_RUN_FIELDS = [
"id",
"owner",
"workflow",
"workflow_name",
"trigger_type",
"trigger_detail",
"status",
"started_at",
"finished_at",
"duration_ms",
"error",
];
/**
* Workflow+trigger groups currently in a trailing failure streak.
*
* @param {{
* minCount?: number,
* limit?: number,
* }} [opts]
* @returns {Promise<{
* items: Array<{
* consecutiveFailures: number,
* workflow: string,
* workflow_name: string | null,
* owner: string,
* trigger_type: string,
* trigger_detail: string | null,
* lastRun: {
* id: string,
* owner: string,
* workflow: string,
* workflow_name: string | null,
* trigger_type: string,
* trigger_detail: string | null,
* status: string,
* started_at: string,
* finished_at: string | null,
* duration_ms: number | null,
* error: string | null,
* },
* }>,
* total: number,
* }>}
*/
export async function listConsecutiveFailureStreaks(opts = {}) {
const minCount = Math.max(opts.minCount ?? 4, 1);
const limit = Math.min(Math.max(opts.limit ?? 100, 1), 200);
const rows = await db("workflow_runs")
.select(STREAK_LAST_RUN_FIELDS)
.whereIn("status", ["success", "failed"])
.orderBy("started_at", "desc")
.limit(CONSECUTIVE_FAILURE_WINDOW);
/** @type {Map<string, { count: number, done: boolean, lastRun: (typeof rows)[number] }>} */
const groups = new Map();
for (const row of rows) {
const key = `${row.workflow}\0${row.trigger_type}\0${row.trigger_detail ?? ""}`;
let group = groups.get(key);
if (!group) {
group = { count: 0, done: false, lastRun: row };
groups.set(key, group);
}
if (group.done) continue;
if (row.status === "failed") group.count += 1;
else group.done = true;
}
const streaks = [...groups.values()]
.filter((g) => g.lastRun.status === "failed" && g.count >= minCount)
.sort((a, b) => {
if (b.count !== a.count) return b.count - a.count;
return String(b.lastRun.started_at).localeCompare(String(a.lastRun.started_at));
});
return {
total: streaks.length,
items: streaks.slice(0, limit).map((g) => ({
consecutiveFailures: g.count,
workflow: g.lastRun.workflow,
workflow_name: g.lastRun.workflow_name,
owner: g.lastRun.owner,
trigger_type: g.lastRun.trigger_type,
trigger_detail: g.lastRun.trigger_detail,
lastRun: g.lastRun,
})),
};
}
/**
* @returns {Promise<Record<string, { invocationCount: number, lastInvokedAt: string | null, lastStatus: string | null }>>}
*/
@@ -0,0 +1,56 @@
import { jsonPreviewReplacer, summarizeBinary } from "../json-preview.js";
import { serialize, toDisplayValue } from "../store.js";
import { safeSerialize } from "../src/api/dry-run-logger.js";
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3]);
const summary = summarizeBinary(png);
if (summary.length !== png.length || summary.preview !== "89504e470d0a1a0a010203" || summary.truncated !== false) {
throw new Error(`summarizeBinary: ${JSON.stringify(summary)}`);
}
const long = Buffer.alloc(32, 0xff);
const longSummary = summarizeBinary(long);
if (longSummary.length !== 32 || longSummary.preview.length !== 32 || longSummary.truncated !== true) {
throw new Error(`long summarizeBinary: ${JSON.stringify(longSummary)}`);
}
const dumped = JSON.stringify({ file: png });
if (!dumped.includes('"data":[')) {
throw new Error("expected default Buffer JSON to include data array");
}
const previewed = JSON.stringify({ file: png }, jsonPreviewReplacer);
if (previewed.includes('"data":[')) {
throw new Error(`replacer still dumped bytes: ${previewed}`);
}
if (!previewed.includes('"preview":"89504e470d0a1a0a010203"')) {
throw new Error(`replacer missing hex preview: ${previewed}`);
}
const stored = serialize({ output: { file: png, filename: "test.png" } });
if (stored.includes('"data":[')) {
throw new Error(`serialize dumped bytes: ${stored.slice(0, 200)}`);
}
const display = toDisplayValue({
output: { file: png },
context: { file: png },
});
if (display.output.file.length !== png.length || display.context.file.truncated !== false) {
throw new Error(`toDisplayValue: ${JSON.stringify(display)}`);
}
if (Array.isArray(display.output.file.data)) {
throw new Error("toDisplayValue should not keep Buffer.data");
}
const dry = safeSerialize({ file: png, n: 1n });
if (dry.n !== "1" || Array.isArray(dry.file.data)) {
throw new Error(`safeSerialize: ${JSON.stringify(dry)}`);
}
const typed = safeSerialize({ file: new Uint8Array(png) });
if (typed.file.length !== png.length || typed.file.type !== "Buffer") {
throw new Error(`Uint8Array: ${JSON.stringify(typed)}`);
}
console.log("json-preview-smoke: ok");
+11 -5
View File
@@ -46,8 +46,7 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
if (!spec) continue;
const threshold = Number(spec.onConsecutiveFailures);
const workflowName =
typeof spec.triggerWorkflow === "string" ? spec.triggerWorkflow.trim() : "";
const workflowName = onFailureWorkflowName(spec);
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) {
return null;
}
@@ -59,6 +58,14 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
return null;
}
/**
* @param {Record<string, unknown>} trigger
*/
function onFailureWorkflowName(trigger) {
const value = trigger?.onFailureWorkflow;
return typeof value === "string" ? value.trim() : "";
}
/**
* @param {unknown} workflow
*/
@@ -73,14 +80,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
const hasThreshold =
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
const hasWorkflow =
typeof trigger.triggerWorkflow === "string" && trigger.triggerWorkflow.trim().length > 0;
const hasWorkflow = onFailureWorkflowName(trigger).length > 0;
if (!hasThreshold && !hasWorkflow) continue;
if (!hasThreshold || !hasWorkflow) {
const err = new Error(
"onConsecutiveFailures and triggerWorkflow must both be set on a trigger",
"onConsecutiveFailures and onFailureWorkflow must both be set on a trigger",
);
err.statusCode = 400;
throw err;
@@ -8,7 +8,7 @@ scripts:
config:
url: https://example.com/
outputVar: message
transform: >
transform: |
data.hasChanges
? "example.com changed (fingerprint " & data.fingerprint & ")"
: "example.com unchanged since " & data.fingerprintAt
@@ -16,3 +16,8 @@ triggers:
- type: HTTP
method: POST
path: /detect-example
- type: cron
schedule: "* * * * *"
onConsecutiveFailures: 3
onFailureWorkflow: dev-zte-sms
enabled: false
@@ -0,0 +1,50 @@
name: Jadwal Solat Jakarta ntfy
scripts:
- id: fetch
script: fetch-http.js
config:
url: https://kemenag.go.id/api/prayer-times/1301
method: GET
headers:
Content-Type: application/json
Accept: application/json
- id: transform
script: jsonata.js
config:
expression: |-
{
"title": data.httpResponse.data.date,
"message": "Imsak:" & data.httpResponse.data.imsak & "\n" &
"Subuh:" & data.httpResponse.data.subuh & "\n" &
"Dzuhur:" & data.httpResponse.data.dzuhur & "\n" &
"Ashar:" & data.httpResponse.data.ashar & "\n" &
"Maghrib:" & data.httpResponse.data.maghrib & "\n" &
"Isya:" & data.httpResponse.data.isya
}
needs:
- fetch
- id: ntfy
script: ntfy.js
config:
url: $VAR_ntfy_channel
fingerprint: true
needs:
- transform
- id: slack
script: ntfy.js
config:
url: $VAR_ntfy_channel2
fingerprint: fingerprint:ntfy2
needs:
- transform
- script: slack-webhook.js
config:
webhookUrlSecret: slack_deploy_webhook
fingerprint: true
fingerprintMaxAge: 1h
text: $INPUT_message
needs:
- transform
triggers:
- type: cron
schedule: 0 5 * * *
@@ -10,3 +10,11 @@ scripts:
- test-send-gmail.yaml
- track.yaml
- rss-devto-to-ntfy.yaml
- test-minio.yaml
- dev-joplin-sync.yaml
- dev-joplin-daily.yaml
- dev-joplin-get-note.yaml
- web-dave.yaml
- jadwal-sholat-jakart.yaml
- detect-example-changes.yaml
- test-sftp.yaml
@@ -0,0 +1,20 @@
name: Test MinIO
scripts:
- script: fetch-binary.js
config:
outputVar: file
url: https://nsrb:error403@dav.0dev.web.id/ntfy/IyM9784UdG4S
filename: test.png
- script: s3.js
config:
action: write
endpoint: http://localhost:9000
bucket: default
forcePathStyle: true
accessKeyIdSecret: minio_user
secretAccessKeySecret: minio_pass
key: file.png
triggers:
- type: HTTP
method: POST
path: /new
@@ -0,0 +1,22 @@
name: SFTP Test
scripts:
- script: remote-fs.js
config:
protocol: sftp
action: list
host: localhost
path: /Users/nsrb/
username: nsrb
port: 22
password: JKLjkl
- script: jsonata.js
config:
expression: '{"message": $join(data.entries.name, "\n")}'
- script: ntfy.js
config:
url: $VAR_ntfy_channel
fingerprint: "false"
triggers:
- type: HTTP
method: POST
path: /new
@@ -0,0 +1,14 @@
name: Dab 0dev
scripts:
- script: list-webdav.js
config:
url: https://dav.0dev.web.id/books
path: /
includeDirectories: true
recursive: false
username: nsrb
passwordSecret: dav_0dev_password
triggers:
- type: HTTP
method: POST
path: /new
+2
View File
@@ -12,6 +12,7 @@ import { WorkflowsPage } from "./pages/WorkflowsPage.jsx";
import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx";
import { EventsPage } from "./pages/EventsPage.jsx";
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
import { FailuresPage } from "./pages/FailuresPage.jsx";
import { KvPage } from "./pages/KvPage.jsx";
import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx";
import { ResponsesPage } from "./pages/ResponsesPage.jsx";
@@ -61,6 +62,7 @@ export function App() {
<Route path="/workflows/:owner/:file/edit" element={<WorkflowEditPage />} />
<Route path="/events" element={<EventsPage />} />
<Route path="/events/:id" element={<EventDetailPage />} />
<Route path="/failures" element={<FailuresPage />} />
<Route path="/kv" element={<KvPage />} />
<Route path="/variables" element={<VariablesPage />} />
<Route path="/auth" element={<AuthProfilesPage />} />
+11
View File
@@ -266,6 +266,17 @@ export function useRuns(filters = {}) {
});
}
export function useConsecutiveFailures(limit) {
return useQuery({
queryKey: ["consecutive-failures", limit ?? "all"],
queryFn: async () => {
const params = {};
if (limit) params.limit = limit;
return (await api.get("/consecutive-failures", { params })).data;
},
});
}
export function useRun(id) {
return useQuery({
queryKey: ["runs", id],
@@ -117,15 +117,15 @@ export function TriggerCard({
function triggerSummary(trigger, owner) {
const type = trigger?.type;
const failure =
trigger.onConsecutiveFailures && trigger.onFailureWorkflow
? ` · onFailure@${trigger.onFailureWorkflow}`
: "";
if (type === "HTTP") {
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}`;
return `${trigger.method || "POST"} ${namespacedPath(owner || "owner", trigger.path || "/")}${failure}`;
}
if (type === "cron") {
const extra =
trigger.onConsecutiveFailures && trigger.triggerWorkflow
? ` · alert@${trigger.triggerWorkflow}`
: "";
return `${trigger.schedule || ""}${extra}`;
return `${trigger.schedule || ""}${failure}`;
}
if (type === "workflow") return "callable";
return "";
@@ -320,12 +320,12 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
/>
</label>
<label className="form-control">
<span className="label py-0 text-sm">Trigger workflow</span>
<span className="label py-0 text-sm">On failure, start</span>
<select
className="select select-sm"
value={trigger.triggerWorkflow ?? ""}
value={trigger.onFailureWorkflow ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...trigger, triggerWorkflow: e.target.value })}
onChange={(e) => onChange({ ...trigger, onFailureWorkflow: e.target.value })}
>
<option value="">None</option>
{alertDestinations.map((w) => (
@@ -333,11 +333,11 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
{w.name ?? w.file}
</option>
))}
{trigger.triggerWorkflow &&
{trigger.onFailureWorkflow &&
!alertDestinations.some(
(w) => (w.name ?? w.file) === trigger.triggerWorkflow,
(w) => (w.name ?? w.file) === trigger.onFailureWorkflow,
) ? (
<option value={trigger.triggerWorkflow}>{trigger.triggerWorkflow}</option>
<option value={trigger.onFailureWorkflow}>{trigger.onFailureWorkflow}</option>
) : null}
</select>
</label>
+12 -9
View File
@@ -143,7 +143,7 @@ export function newHttpTrigger() {
response: "",
unauthorized: null,
onConsecutiveFailures: "",
triggerWorkflow: "",
onFailureWorkflow: "",
};
}
@@ -158,7 +158,7 @@ export function newCronTrigger() {
response: "",
unauthorized: null,
onConsecutiveFailures: "",
triggerWorkflow: "",
onFailureWorkflow: "",
};
}
@@ -173,7 +173,7 @@ export function newWorkflowTrigger() {
response: "",
unauthorized: null,
onConsecutiveFailures: "",
triggerWorkflow: "",
onFailureWorkflow: "",
};
}
@@ -191,6 +191,10 @@ export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
export { HTTP_METHODS };
function readOnFailureWorkflow(raw) {
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
}
function normalizeStep(step) {
const uiId = nextUiId("step");
if (typeof step === "string") {
@@ -266,10 +270,10 @@ function normalizeTrigger(raw) {
"response",
"unauthorized",
"onConsecutiveFailures",
"triggerWorkflow",
"onFailureWorkflow",
])
: type === "cron"
? new Set(["type", "schedule", "onConsecutiveFailures", "triggerWorkflow"])
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"])
: new Set(["type"]);
/** @type {Record<string, unknown>} */
const extra = {};
@@ -286,8 +290,7 @@ function normalizeTrigger(raw) {
raw.onConsecutiveFailures == null || raw.onConsecutiveFailures === ""
? ""
: String(raw.onConsecutiveFailures),
triggerWorkflow:
typeof raw.triggerWorkflow === "string" ? raw.triggerWorkflow : "",
onFailureWorkflow: readOnFailureWorkflow(raw),
auth: raw.auth ?? null,
response: typeof raw.response === "string" ? raw.response : "",
unauthorized: raw.unauthorized ?? null,
@@ -328,8 +331,8 @@ function dumpFailureTriggerFields(t, out) {
out.onConsecutiveFailures = Math.floor(threshold);
}
}
if (typeof t.triggerWorkflow === "string" && t.triggerWorkflow.trim()) {
out.triggerWorkflow = t.triggerWorkflow.trim();
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
out.onFailureWorkflow = t.onFailureWorkflow.trim();
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ export function EventsPage() {
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">Events</h1>
<h1 className="text-xl font-semibold">{status === "failed" ? "Failed events" : "Events"}</h1>
<div className="flex flex-col sm:flex-row gap-2">
<input
className="input input-sm w-full sm:max-w-sm"
+63
View File
@@ -0,0 +1,63 @@
import { Link } from "react-router-dom";
import { useConsecutiveFailures } from "../api/hooks.js";
import { formatTime } from "../lib/format.jsx";
export function FailuresPage() {
const { data, isLoading, error } = useConsecutiveFailures();
const items = data?.items ?? [];
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold">Failures</h1>
<p className="opacity-70 text-sm">
Workflows that have failed 4 or more times in a row for the same trigger.
</p>
{isLoading ? (
<span className="loading loading-spinner" />
) : error ? (
<p className="text-error">Failed to load consecutive failures</p>
) : items.length === 0 ? (
<p className="opacity-60 text-sm">None</p>
) : (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Streak</th>
<th>Workflow</th>
<th>Trigger</th>
<th>Last error</th>
<th>Last failed</th>
</tr>
</thead>
<tbody>
{items.map((s) => (
<tr
key={`${s.workflow}\0${s.trigger_type}\0${s.trigger_detail ?? ""}`}
className="hover"
>
<td>
<span className="badge badge-error badge-sm">{s.consecutiveFailures}</span>
</td>
<td>
<Link className="link" to={`/events/${s.lastRun.id}`}>
{s.workflow_name || s.workflow}
</Link>
</td>
<td className="text-xs">
{s.trigger_type}
{s.trigger_detail ? ` · ${s.trigger_detail}` : ""}
</td>
<td className="max-w-md truncate text-xs" title={s.lastRun.error ?? ""}>
{s.lastRun.error || "—"}
</td>
<td className="whitespace-nowrap">{formatTime(s.lastRun.started_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}
+77 -26
View File
@@ -18,8 +18,10 @@ export function HomePage() {
return <p className="text-error">Failed to load dashboard</p>;
}
const failed = data.needsAttention?.failed ?? [];
const streaks = data.needsAttention?.consecutiveFailures ?? [];
const streakCount = data.needsAttention?.consecutiveFailureCount ?? streaks.length;
const broken = data.needsAttention?.brokenWorkflows ?? [];
const failedEvents = data.failedEvents ?? [];
return (
<div className="space-y-6">
@@ -51,9 +53,7 @@ export function HomePage() {
<LuTriangleAlert className="size-7" />
</div>
<div className="stat-title">Attention</div>
<div className="stat-value text-2xl">
{failed.length + broken.length}
</div>
<div className="stat-value text-2xl">{streakCount + broken.length}</div>
</div>
</div>
@@ -62,29 +62,80 @@ export function HomePage() {
<RunList runs={data.running} empty="None" />
</section>
<section>
<h2 className="text-lg font-semibold mb-2">Needs attention</h2>
{broken.length > 0 ? (
<ul className="mb-3 space-y-1">
{broken.map((w) => (
<li key={w.key}>
<Link
className="link link-error"
to={`/workflows/${encodeURIComponent(w.owner)}/${encodeURIComponent(w.file)}/edit`}
>
{w.key}: {w.loadError}
</Link>
</li>
))}
</ul>
) : null}
<RunList runs={failed} empty={broken.length ? "" : "None"} />
</section>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<section className="min-w-0">
<div className="mb-2 flex items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Needs attention</h2>
<Link className="link link-hover text-sm" to="/failures">
Show all
</Link>
</div>
{broken.length > 0 ? (
<ul className="mb-3 space-y-1">
{broken.map((w) => (
<li key={w.key}>
<Link
className="link link-error"
to={`/workflows/${encodeURIComponent(w.owner)}/${encodeURIComponent(w.file)}/edit`}
>
{w.key}: {w.loadError}
</Link>
</li>
))}
</ul>
) : null}
<StreakList streaks={streaks} empty={broken.length ? "" : "None"} />
</section>
<section>
<h2 className="text-lg font-semibold mb-2">Recent</h2>
<RunList runs={data.recent} empty="None" />
</section>
<section className="min-w-0">
<div className="mb-2 flex items-baseline justify-between gap-2">
<h2 className="text-lg font-semibold">Failed events</h2>
<Link className="link link-hover text-sm" to="/events?status=failed">
View all
</Link>
</div>
<RunList runs={failedEvents} empty="None" />
</section>
<section className="min-w-0">
<h2 className="text-lg font-semibold mb-2">Recent</h2>
<RunList runs={data.recent} empty="None" />
</section>
</div>
</div>
);
}
function StreakList({ streaks, empty }) {
if (!streaks?.length) {
return empty ? <p className="opacity-60 text-sm">{empty}</p> : null;
}
return (
<div className="overflow-x-auto">
<table className="table table-sm">
<thead>
<tr>
<th>Streak</th>
<th>Workflow</th>
<th>Last failed</th>
</tr>
</thead>
<tbody>
{streaks.map((s) => (
<tr key={`${s.workflow}\0${s.trigger_type}\0${s.trigger_detail ?? ""}`} className="hover">
<td>
<span className="badge badge-error badge-sm">{s.consecutiveFailures}</span>
</td>
<td>
<Link className="link" to={`/events/${s.lastRun.id}`}>
{s.workflow_name || s.workflow}
</Link>
</td>
<td className="whitespace-nowrap">{formatTime(s.lastRun.started_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
+1 -1
View File
@@ -180,11 +180,11 @@ export function SecretsPage() {
value={form.value}
onChange={(e) => setForm({ ...form, value: e.target.value })}
required
minLength={8}
autoComplete="new-password"
/>
<p className="text-xs opacity-60 mt-1">
Values are encrypted at rest and never shown again after save.
Values shorter than 8 characters are not redacted from logs.
</p>
{upsert.isError ? (
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
+2 -2
View File
@@ -7,8 +7,8 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
"/api": { target: "http://127.0.0.1:9000", changeOrigin: true },
"/admin": { target: "http://127.0.0.1:9000", changeOrigin: true },
"/api": { target: "http://127.0.0.1:8700", changeOrigin: true },
"/admin": { target: "http://127.0.0.1:8700", changeOrigin: true },
},
},
});
+413 -21
View File
@@ -14,6 +14,12 @@ importers:
packages/server:
dependencies:
'@aws-sdk/client-s3':
specifier: ^3.1111.0
version: 3.1113.0
'@aws-sdk/s3-request-presigner':
specifier: ^3.1111.0
version: 3.1113.0
'@fastify/cookie':
specifier: ^11.0.2
version: 11.1.2
@@ -29,6 +35,9 @@ importers:
axios:
specifier: ^1.19.0
version: 1.19.0
basic-ftp:
specifier: ^6.2.0
version: 6.2.0
bcryptjs:
specifier: ^3.0.2
version: 3.0.3
@@ -68,6 +77,9 @@ importers:
pino-roll:
specifier: ^4.0.0
version: 4.0.0
ssh2-sftp-client:
specifier: ^12.1.1
version: 12.1.1
webdav:
specifier: ^5.10.0
version: 5.10.0
@@ -141,6 +153,82 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@aws-sdk/checksums@3.1000.28':
resolution: {integrity: sha512-VCpnmyHQ1IH49ni3LXnQj7DPr7rmcJmzYeiCkYdCcfgNtkvOj38cdcL9lapBWoItZWFACJPFJlymqC7/gem3Gw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-s3@3.1113.0':
resolution: {integrity: sha512-NRqdtohoMRyWkEeeznfG1KPN08dclCbl+HFuLPB2v8qPcgoNmTFlLKl9ELiR6hsGXQ4Ur3qvRnoJVEk8ND74pg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.8':
resolution: {integrity: sha512-7+Kcrkvrk9lM/m7jRhHpT4jCdvzGHsuaSRbF8TdzzkY1mRzp/Ogwf9c7H29k4gGhey0BBWhCWr16+t0J61gwmg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-env@3.972.69':
resolution: {integrity: sha512-AreCFzcB4kH2HF9031Ot0jSJr3KXvRg6e8uDeub20JEVdZU3Bv0sTq1plc7VsT3KiqutlzH7l0j50UcCWHUioA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-http@3.972.71':
resolution: {integrity: sha512-A8ObcqVmDMnk4F9NozZ7JwmUu9Q4xyBJkmyq1C5U+wNM9ht9J7+EuuyabsLWXZnOoTqFaJuYBYTKf5CTipkEjA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-ini@3.973.14':
resolution: {integrity: sha512-7c+Wti2LsERNWMfm7ySz3/6RPopFW3Nmn7s63Xpcq6R/tRuY5hpvkHA2xVgi5ukJbvok9l0IDtVEvqTtg+X7dw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-login@3.972.76':
resolution: {integrity: sha512-LVixwOnEJfrrfKHeZjBA8pIMTZjNDq8ak8VpcoWUuCJDrSnBNU8POJksULMgvN089P0MXtQYH2Zs627/MK1K0g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-node@3.972.80':
resolution: {integrity: sha512-bE2qh8ww4iClO1jHsBXdOE8FUgzDbdxbyorNjSCoPSkQd51k3jODItuPZfuwcLHZqDXsH+bI4AMHhqtuyR7mSg==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-process@3.972.69':
resolution: {integrity: sha512-9kpTNdZTrcqXTfhxM7fgl9Z68ek3Fu5oe3Yf+A/pJGibEqpgZxz2tSY7SinmyCIU2PJ+ygY4FPoBBnLpocMtrQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-sso@3.973.13':
resolution: {integrity: sha512-Oc81qauMPzUoTnAS2YKpNwY6sY/LUyQTEeaf6yP197WMxkEBQfcKLR1MFpD7+pNTubXnfkH6gwpji+Gc7iyD2Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/credential-provider-web-identity@3.972.75':
resolution: {integrity: sha512-YPN6uoGDgjjjeVFZrcOeCJqmB6zpXoeeNgIjqe+DexJaWqdjVfCCe+VAZwli9Z2h8KhFW8oxkO39emQ1tyz/Mw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/middleware-sdk-s3@3.972.74':
resolution: {integrity: sha512-2lzoV2z2QO5KJZYGOCnIZ1WVQgzMECvwuzr1xb034a++8QW4U4eGrmC2u4yg1xvNv4TLL/Uv5DLyuAiw0b9z7Q==}
engines: {node: '>=20.0.0'}
'@aws-sdk/nested-clients@3.997.43':
resolution: {integrity: sha512-bit+VpqWNyi3wHxFoTsTliNXimCSL2r2OeDTm7ZrG+YsTZ2D7ofDJ6r/t9PVBn80i6/v0X2h9Tgw6QP2MAKfPw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/s3-request-presigner@3.1113.0':
resolution: {integrity: sha512-FdbHboJSscXRHnqOGRLN+MvtcYNlxw1+XWMKcKi72nBPxpSTGQA+/zmxEBTlkCvTdIPtl/g383nNY0RiKYPmWQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/signature-v4-multi-region@3.996.45':
resolution: {integrity: sha512-bBuyztukzXq6plzFGHAWiQt0QXo+HL8b8lX5cFTzkez/74PtS1c0qPFCIVuHkyoT+miH2qOjAcm1/yoro2ESPA==}
engines: {node: '>=20.0.0'}
'@aws-sdk/token-providers@3.1111.0':
resolution: {integrity: sha512-JfljgoVtl+s3Qy21n9a7Z48uCQaOXcN74KJ3TEQfPoB293GrXFSt6HSQJF1sTZ8c/5QedEvd3NjJQMO4u9qa5A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/types@3.974.4':
resolution: {integrity: sha512-dSFDNG00MEz0/xl5gxL62giLd1iYyJsTxZ1I1DOj6lC+bbgLB4TRsYClJg3b62dhXT1uATzsTNXPnC+33EJV3A==}
engines: {node: '>=20.0.0'}
'@aws-sdk/xml-builder@3.972.39':
resolution: {integrity: sha512-FTti8DS5MMWXNUWiRwXAJeYS+0GHHiMy0+7XOhcwk63ILHmfS2UFy2z/HNpZCSOJJ3P3dnWY6hfYNW3DF0nXUA==}
engines: {node: '>=20.0.0'}
'@aws/lambda-invoke-store@0.3.0':
resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==}
engines: {node: '>=18.0.0'}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@@ -666,6 +754,30 @@ packages:
cpu: [x64]
os: [win32]
'@smithy/core@3.33.2':
resolution: {integrity: sha512-CUGXpnPkVdjUCbix+83sWLW9VFgQOm44MDOx/ihITJMAnOZKvL8YYIc7DR9pP/tZ8CIRvMiON/TucvygqbHO3w==}
engines: {node: '>=18.0.0'}
'@smithy/credential-provider-imds@4.5.2':
resolution: {integrity: sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==}
engines: {node: '>=18.0.0'}
'@smithy/fetch-http-handler@5.7.2':
resolution: {integrity: sha512-nZyWTmSpJEXl6VtWVMBJve/7x12DZu6sIX1z1a+ZMaHlQQRs9Zpu6NbTe/gmxYXVRpkjxyDYpZ5gx2IM6f/Wkw==}
engines: {node: '>=18.0.0'}
'@smithy/node-http-handler@4.11.2':
resolution: {integrity: sha512-avwAh9HM3h2lcfjvP3zYIZGf+XVgLQ91wOJ2qoFbNpW1UZeZb33aGlhTZvtkANHfcGhJroRY64525OjfgOg30g==}
engines: {node: '>=18.0.0'}
'@smithy/signature-v4@5.7.2':
resolution: {integrity: sha512-P7Ki6px6OOrxVtx8K7nLmyx4SlXUW/uTKDdMG44UHefmPGSRMBKe2v+TM59WdLcpUIrBrnuCsIqiM2MbsZjmhw==}
engines: {node: '>=18.0.0'}
'@smithy/types@4.17.2':
resolution: {integrity: sha512-FOKpVZob9MPTn2znRzGrnsMHv7BOsKVw3XiP/cOyYLDVZ9qKp4nifIiSCuUU/fIj5Vu0UOAxCFr+qRAtG0NUkA==}
engines: {node: '>=18.0.0'}
'@tailwindcss/node@4.3.3':
resolution: {integrity: sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==}
@@ -918,6 +1030,9 @@ packages:
asn1.js@5.4.1:
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
asn1@0.2.6:
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
asynckit@0.4.0:
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
@@ -946,6 +1061,13 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
basic-ftp@6.2.0:
resolution: {integrity: sha512-H8eLjhoYPbOI717FLP8fGE7XInkMI7Ucj/ft0fp1avABtSiV5Bb2kYzScnOqlvJZs/KJZypr9Mp5zJmhaPFAEg==}
engines: {node: '>=10.0.0'}
bcrypt-pbkdf@1.0.2:
resolution: {integrity: sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==}
bcryptjs@3.0.3:
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
hasBin: true
@@ -960,6 +1082,9 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
bowser@2.14.1:
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
brace-expansion@2.1.4:
resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==}
@@ -972,6 +1097,13 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
buffer-from@1.1.2:
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
buildcheck@0.0.7:
resolution: {integrity: sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA==}
engines: {node: '>=10.0.0'}
bullmq@6.1.2:
resolution: {integrity: sha512-GSX8JfWN8CElAGDyt7Zmq59n1FfWZ0L7IjsThNUQq7X8mvVVDb9I5i2F+nFg4A00UbysJ5yejN0oWzmbUJPDFg==}
engines: {node: '>=14.17.0'}
@@ -1026,6 +1158,10 @@ packages:
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
engines: {node: '>= 12'}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
content-disposition@0.5.4:
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
engines: {node: '>= 0.6'}
@@ -1047,10 +1183,6 @@ packages:
cose-base@2.2.0:
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
cron-parser@5.10.0:
resolution: {integrity: sha512-izNAxJyRWUP8ljBoDSub5WyrVOUlT4SLGShswE7eoRBpp6QUsSycYxLBMJlbshgPBMcPT/nrfgjNY2918ayv2A==}
engines: {node: '>=18'}
cronstrue@3.24.0:
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
hasBin: true
@@ -1804,6 +1936,9 @@ packages:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
nan@2.28.0:
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
nanoid@3.3.18:
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1968,6 +2103,10 @@ packages:
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
engines: {node: '>=0.10.0'}
readable-stream@3.6.2:
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
engines: {node: '>= 6'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
@@ -2089,9 +2228,6 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
standard-as-callback@2.1.0:
resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==}
state-local@1.0.7:
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
@@ -2102,6 +2238,9 @@ packages:
steed@1.1.3:
resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
strnum@2.4.2:
resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
@@ -2154,6 +2293,12 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
tweetnacl@0.14.5:
resolution: {integrity: sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==}
typedarray@0.0.6:
resolution: {integrity: sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==}
update-browserslist-db@1.3.1:
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
hasBin: true
@@ -2167,6 +2312,9 @@ packages:
url-parse@1.5.10:
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
util-deprecate@1.0.2:
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
uuid@14.0.1:
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
hasBin: true
@@ -2255,6 +2403,180 @@ snapshots:
package-manager-detector: 1.8.0
tinyexec: 1.3.0
'@aws-sdk/checksums@3.1000.28':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/client-s3@3.1113.0':
dependencies:
'@aws-sdk/checksums': 3.1000.28
'@aws-sdk/core': 3.977.8
'@aws-sdk/credential-provider-node': 3.972.80
'@aws-sdk/middleware-sdk-s3': 3.972.74
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/core@3.977.8':
dependencies:
'@aws-sdk/types': 3.974.4
'@aws-sdk/xml-builder': 3.972.39
'@aws/lambda-invoke-store': 0.3.0
'@smithy/core': 3.33.2
'@smithy/signature-v4': 5.7.2
'@smithy/types': 4.17.2
bowser: 2.14.1
tslib: 2.8.1
'@aws-sdk/credential-provider-env@3.972.69':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-http@3.972.71':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-ini@3.973.14':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/credential-provider-env': 3.972.69
'@aws-sdk/credential-provider-http': 3.972.71
'@aws-sdk/credential-provider-login': 3.972.76
'@aws-sdk/credential-provider-process': 3.972.69
'@aws-sdk/credential-provider-sso': 3.973.13
'@aws-sdk/credential-provider-web-identity': 3.972.75
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-login@3.972.76':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-node@3.972.80':
dependencies:
'@aws-sdk/credential-provider-env': 3.972.69
'@aws-sdk/credential-provider-http': 3.972.71
'@aws-sdk/credential-provider-ini': 3.973.14
'@aws-sdk/credential-provider-process': 3.972.69
'@aws-sdk/credential-provider-sso': 3.973.13
'@aws-sdk/credential-provider-web-identity': 3.972.75
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/credential-provider-imds': 4.5.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-process@3.972.69':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-sso@3.973.13':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/token-providers': 3.1111.0
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/credential-provider-web-identity@3.972.75':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/middleware-sdk-s3@3.972.74':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/nested-clients@3.997.43':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/fetch-http-handler': 5.7.2
'@smithy/node-http-handler': 4.11.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/s3-request-presigner@3.1113.0':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/signature-v4-multi-region': 3.996.45
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/signature-v4-multi-region@3.996.45':
dependencies:
'@aws-sdk/types': 3.974.4
'@smithy/signature-v4': 5.7.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/token-providers@3.1111.0':
dependencies:
'@aws-sdk/core': 3.977.8
'@aws-sdk/nested-clients': 3.997.43
'@aws-sdk/types': 3.974.4
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/types@3.974.4':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws-sdk/xml-builder@3.972.39':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@aws/lambda-invoke-store@0.3.0': {}
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@@ -2690,6 +3012,39 @@ snapshots:
'@rollup/rollup-win32-x64-msvc@4.62.4':
optional: true
'@smithy/core@3.33.2':
dependencies:
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/credential-provider-imds@4.5.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/fetch-http-handler@5.7.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/node-http-handler@4.11.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/signature-v4@5.7.2':
dependencies:
'@smithy/core': 3.33.2
'@smithy/types': 4.17.2
tslib: 2.8.1
'@smithy/types@4.17.2':
dependencies:
tslib: 2.8.1
'@tailwindcss/node@4.3.3':
dependencies:
'@jridgewell/remapping': 2.3.5
@@ -2961,6 +3316,10 @@ snapshots:
minimalistic-assert: 1.0.1
safer-buffer: 2.1.2
asn1@0.2.6:
dependencies:
safer-buffer: 2.1.2
asynckit@0.4.0: {}
atomic-sleep@1.0.0: {}
@@ -2988,6 +3347,12 @@ snapshots:
baseline-browser-mapping@2.11.14: {}
basic-ftp@6.2.0: {}
bcrypt-pbkdf@1.0.2:
dependencies:
tweetnacl: 0.14.5
bcryptjs@3.0.3: {}
better-sqlite3@13.0.3:
@@ -2998,6 +3363,8 @@ snapshots:
boolbase@1.0.0: {}
bowser@2.14.1: {}
brace-expansion@2.1.4:
dependencies:
balanced-match: 1.0.2
@@ -3014,16 +3381,6 @@ snapshots:
node-releases: 2.0.53
update-browserslist-db: 1.3.1(browserslist@4.28.8)
bullmq@6.1.2(ioredis@6.0.0):
dependencies:
cron-parser: 5.10.0
msgpackr: 2.0.5
node-abort-controller: 3.1.1
semver: 7.8.5
tslib: 2.8.1
optionalDependencies:
ioredis: 6.0.0
byte-length@1.0.2: {}
call-bind-apply-helpers@1.0.2:
@@ -3049,6 +3406,13 @@ snapshots:
commander@8.3.0: {}
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
inherits: 2.0.4
readable-stream: 3.6.2
typedarray: 0.0.6
content-disposition@0.5.4:
dependencies:
safe-buffer: 5.2.1
@@ -3067,10 +3431,6 @@ snapshots:
dependencies:
layout-base: 2.0.1
cron-parser@5.10.0:
dependencies:
luxon: 3.7.2
cronstrue@3.24.0: {}
cross-spawn@7.0.6:
@@ -3843,6 +4203,9 @@ snapshots:
mustache@4.2.0: {}
nan@2.28.0:
optional: true
nanoid@3.3.18: {}
nested-property@4.0.0: {}
@@ -3983,6 +4346,12 @@ snapshots:
react@19.2.8: {}
readable-stream@3.6.2:
dependencies:
inherits: 2.0.4
string_decoder: 1.3.0
util-deprecate: 1.0.2
real-require@0.2.0: {}
real-require@1.0.0: {}
@@ -4102,6 +4471,19 @@ snapshots:
standard-as-callback@2.1.0: {}
ssh2-sftp-client@12.1.1:
dependencies:
concat-stream: 2.0.0
ssh2: 1.17.0
ssh2@1.17.0:
dependencies:
asn1: 0.2.6
bcrypt-pbkdf: 1.0.2
optionalDependencies:
cpu-features: 0.0.10
nan: 2.28.0
state-local@1.0.7: {}
statuses@2.0.2: {}
@@ -4114,6 +4496,10 @@ snapshots:
fastseries: 1.7.2
reusify: 1.1.0
string_decoder@1.3.0:
dependencies:
safe-buffer: 5.2.1
strnum@2.4.2:
dependencies:
anynum: 1.0.1
@@ -4149,6 +4535,10 @@ snapshots:
tslib@2.8.1: {}
tweetnacl@0.14.5: {}
typedarray@0.0.6: {}
update-browserslist-db@1.3.1(browserslist@4.28.8):
dependencies:
browserslist: 4.28.8
@@ -4162,6 +4552,8 @@ snapshots:
querystringify: 2.2.0
requires-port: 1.0.0
util-deprecate@1.0.2: {}
uuid@14.0.1: {}
vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0):