Compare commits
14
Commits
88cea85701
...
16b1bc5668
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b1bc5668 | ||
|
|
453a6f2969 | ||
|
|
a2ee0b633d | ||
|
|
3850c529e4 | ||
|
|
5363dd50c3 | ||
|
|
c33b6db089 | ||
|
|
dc0dfadd44 | ||
|
|
f41e9dc23a | ||
|
|
2845971670 | ||
|
|
14f6331fce | ||
|
|
64f605b135 | ||
|
|
04c0d4b801 | ||
|
|
c668947c90 | ||
|
|
7474bb5c6d |
@@ -16,7 +16,7 @@ pnpm install
|
|||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- API: http://localhost:9000
|
- API: http://localhost:8700
|
||||||
- UI (dev): http://localhost:5173
|
- UI (dev): http://localhost:5173
|
||||||
|
|
||||||
The first account created becomes **admin**. Later accounts are created from Users.
|
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 + Vite together |
|
||||||
| `pnpm dev:server` | API/runner only |
|
| `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 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 |
|
| `pnpm migrate` | Apply SQLite migrations |
|
||||||
|
|
||||||
## Environment
|
## Environment
|
||||||
@@ -67,7 +67,7 @@ Optional `script.meta.reads = "ctx"` documents expression hosts. `meta.input` /
|
|||||||
| `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:5173` | Vite origin in dev |
|
| `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 |
|
| `NODE_ENV` | — | Set `production` for secure cookies |
|
||||||
|
|
||||||
## Production
|
## Production
|
||||||
|
|||||||
@@ -1,41 +1,34 @@
|
|||||||
### Manual trigger (default owner)
|
### Manual trigger (default owner)
|
||||||
POST http://localhost:9000/u/default/mt
|
POST http://localhost:8700/u/default/mt
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
0
|
0
|
||||||
|
|
||||||
###
|
###
|
||||||
POST http://localhost:9000/u/default/time-to-ntfy
|
POST http://localhost:8700/u/default/time-to-ntfy
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{}
|
{}
|
||||||
|
###
|
||||||
### Auth bootstrap
|
GET http://localhost:8700/api/auth/bootstrap
|
||||||
GET http://localhost:9000/api/auth/bootstrap
|
###
|
||||||
|
POST http://localhost:8700/api/auth/login
|
||||||
### Login
|
|
||||||
POST http://localhost:9000/api/auth/login
|
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"username": "admin",
|
"username": "admin",
|
||||||
"password": "changeme1"
|
"password": "changeme1"
|
||||||
}
|
}
|
||||||
|
###
|
||||||
### Dashboard
|
GET http://localhost:8700/api/dashboard
|
||||||
GET http://localhost:9000/api/dashboard
|
###
|
||||||
|
GET http://localhost:8700/api/runs?owner=default&limit=20
|
||||||
### Runs
|
###
|
||||||
GET http://localhost:9000/api/runs?owner=default&limit=20
|
POST http://localhost:8700/api/workflows/reregister
|
||||||
|
|
||||||
### Reregister
|
|
||||||
POST http://localhost:9000/api/workflows/reregister
|
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{}
|
{}
|
||||||
|
###
|
||||||
### Run workflow manually
|
POST http://localhost:8700/api/workflows/default/manual-trigger.yaml/run
|
||||||
POST http://localhost:9000/api/workflows/default/manual-trigger.yaml/run
|
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{}
|
{}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
"@fastify/jwt": "^9.1.0",
|
"@fastify/jwt": "^9.1.0",
|
||||||
"@fastify/static": "^8.2.0",
|
"@fastify/static": "^8.2.0",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"basic-ftp": "^6.2.0",
|
||||||
"bcryptjs": "^3.0.2",
|
"bcryptjs": "^3.0.2",
|
||||||
"better-sqlite3": "^13.0.3",
|
"better-sqlite3": "^13.0.3",
|
||||||
"fastify": "^5.12.0",
|
"fastify": "^5.12.0",
|
||||||
@@ -28,6 +29,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",
|
||||||
|
"ssh2-sftp-client": "^12.1.1",
|
||||||
"webdav": "^5.10.0",
|
"webdav": "^5.10.0",
|
||||||
"yaml": "^2.9.0"
|
"yaml": "^2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -690,7 +690,7 @@ export function createRegistry(server) {
|
|||||||
{
|
{
|
||||||
workflow: opts.key,
|
workflow: opts.key,
|
||||||
consecutiveFailures,
|
consecutiveFailures,
|
||||||
triggerWorkflow: failureConfig.workflowName,
|
onFailureWorkflow: failureConfig.workflowName,
|
||||||
destination: destKey,
|
destination: destKey,
|
||||||
},
|
},
|
||||||
"triggering failure alert workflow",
|
"triggering failure alert workflow",
|
||||||
|
|||||||
@@ -174,7 +174,7 @@ async function shutdown() {
|
|||||||
process.on("SIGINT", shutdown);
|
process.on("SIGINT", shutdown);
|
||||||
process.on("SIGTERM", shutdown);
|
process.on("SIGTERM", shutdown);
|
||||||
|
|
||||||
const port = Number(process.env.PORT ?? 9000);
|
const port = Number(process.env.PORT ?? 8700);
|
||||||
|
|
||||||
server
|
server
|
||||||
.listen({
|
.listen({
|
||||||
|
|||||||
@@ -18,11 +18,14 @@ const ALLOWED_MODULES = new Set([
|
|||||||
"@aws-sdk/client-s3",
|
"@aws-sdk/client-s3",
|
||||||
"@aws-sdk/s3-request-presigner",
|
"@aws-sdk/s3-request-presigner",
|
||||||
"axios",
|
"axios",
|
||||||
|
"basic-ftp",
|
||||||
"jsonata",
|
"jsonata",
|
||||||
"mustache",
|
"mustache",
|
||||||
"node-html-parser",
|
"node-html-parser",
|
||||||
|
"node:stream",
|
||||||
"nodemailer",
|
"nodemailer",
|
||||||
"rss-parser",
|
"rss-parser",
|
||||||
|
"ssh2-sftp-client",
|
||||||
"webdav",
|
"webdav",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { inspect } from "node:util";
|
import { inspect } from "node:util";
|
||||||
|
|
||||||
export const REDACTED = "[secret]";
|
export const REDACTED = "[secret]";
|
||||||
|
/** Short values are stored, but skipped in log redaction to avoid false positives. */
|
||||||
export const MIN_SECRET_LENGTH = 8;
|
export const MIN_SECRET_LENGTH = 8;
|
||||||
|
|
||||||
/** @type {Set<string>} */
|
/** @type {Set<string>} */
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
import { assertOwner } from "./fs-store.js";
|
import { assertOwner } from "./fs-store.js";
|
||||||
import { decryptSecret, encryptSecret } from "./secrets.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 MAX_NAME_LENGTH = 128;
|
||||||
const SECRET_NAME_RE = /^[A-Za-z0-9._-]+$/;
|
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
|
* @param {{ owner: string, name: string, value: string }} opts
|
||||||
*/
|
*/
|
||||||
export async function upsertSecret({ owner, name, value }) {
|
export async function upsertSecret({ owner, name, value }) {
|
||||||
if (typeof value !== "string" || value.length < MIN_SECRET_LENGTH) {
|
if (typeof value !== "string" || value.length === 0) {
|
||||||
const err = new Error(`value must be at least ${MIN_SECRET_LENGTH} characters`);
|
const err = new Error("value is required");
|
||||||
err.statusCode = 400;
|
err.statusCode = 400;
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,9 +63,10 @@ export default function dashboardPluginFactory(registry) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const [running, failed, recent] = await Promise.all([
|
const [running, streaks, failedEvents, recent] = await Promise.all([
|
||||||
store.listRuns({ status: "running", limit: 10 }),
|
store.listRuns({ status: "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 }),
|
store.listRuns({ limit: 10 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -76,9 +77,11 @@ export default function dashboardPluginFactory(registry) {
|
|||||||
brokenCount,
|
brokenCount,
|
||||||
running,
|
running,
|
||||||
needsAttention: {
|
needsAttention: {
|
||||||
failed,
|
consecutiveFailures: streaks.items,
|
||||||
|
consecutiveFailureCount: streaks.total,
|
||||||
brokenWorkflows,
|
brokenWorkflows,
|
||||||
},
|
},
|
||||||
|
failedEvents,
|
||||||
recent,
|
recent,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import pino from "pino";
|
import pino from "pino";
|
||||||
|
import { isBinary, summarizeBinary } from "../../json-preview.js";
|
||||||
import { redactString } from "../../secret-value.js";
|
import { redactString } from "../../secret-value.js";
|
||||||
|
|
||||||
const LEVEL_TO_NUM = {
|
const LEVEL_TO_NUM = {
|
||||||
@@ -64,7 +65,9 @@ export function safeSerialize(value) {
|
|||||||
try {
|
try {
|
||||||
return JSON.parse(
|
return JSON.parse(
|
||||||
redactString(
|
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 === "bigint") return v.toString();
|
||||||
if (typeof v === "object" && v !== null) {
|
if (typeof v === "object" && v !== null) {
|
||||||
if (seen.has(v)) return "[Circular]";
|
if (seen.has(v)) return "[Circular]";
|
||||||
|
|||||||
@@ -17,6 +17,15 @@ export default async function runsPlugin(fastify) {
|
|||||||
return { runs };
|
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) => {
|
fastify.get("/runs/:id", async (req, reply) => {
|
||||||
const { id } = /** @type {{ id: string }} */ (req.params);
|
const { id } = /** @type {{ id: string }} */ (req.params);
|
||||||
const run = await store.getRun(id);
|
const run = await store.getRun(id);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
listSecrets,
|
listSecrets,
|
||||||
upsertSecret,
|
upsertSecret,
|
||||||
} from "../../secrets-store.js";
|
} from "../../secrets-store.js";
|
||||||
import { MIN_SECRET_LENGTH } from "../../secret-value.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import("fastify").FastifyInstance} fastify
|
* @param {import("fastify").FastifyInstance} fastify
|
||||||
@@ -37,10 +36,8 @@ export default async function secretsPlugin(fastify) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const value = String(body.value ?? "");
|
const value = String(body.value ?? "");
|
||||||
if (value.length < MIN_SECRET_LENGTH) {
|
if (value.length === 0) {
|
||||||
return reply
|
return reply.code(400).send({ error: "value is required" });
|
||||||
.code(400)
|
|
||||||
.send({ error: `value must be at least ${MIN_SECRET_LENGTH} characters` });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ function triggerSummary(owner, workflow) {
|
|||||||
path: isHttp && t?.path != null ? namespacedPath(owner, t.path) : t?.path ?? null,
|
path: isHttp && t?.path != null ? namespacedPath(owner, t.path) : t?.path ?? null,
|
||||||
schedule: t?.schedule ?? null,
|
schedule: t?.schedule ?? null,
|
||||||
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
|
onConsecutiveFailures: t?.onConsecutiveFailures ?? null,
|
||||||
triggerWorkflow: t?.triggerWorkflow ?? null,
|
onFailureWorkflow: t?.onFailureWorkflow ?? null,
|
||||||
auth: isHttp ? authLabel(t?.auth) : null,
|
auth: isHttp ? authLabel(t?.auth) : null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -418,7 +418,7 @@ export default function workflowsPluginFactory(registry) {
|
|||||||
return {
|
return {
|
||||||
runId: result.runId,
|
runId: result.runId,
|
||||||
status: result.status,
|
status: result.status,
|
||||||
result: result.result,
|
result: store.toDisplayValue(result.result),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+108
-1
@@ -1,5 +1,6 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { db } from "./db.js";
|
import { db } from "./db.js";
|
||||||
|
import { jsonPreviewReplacer } from "./json-preview.js";
|
||||||
import { redactString } from "./secret-value.js";
|
import { redactString } from "./secret-value.js";
|
||||||
|
|
||||||
const MAX_JSON_BYTES = 64 * 1024;
|
const MAX_JSON_BYTES = 64 * 1024;
|
||||||
@@ -12,7 +13,7 @@ export function serialize(value) {
|
|||||||
if (value === undefined || value === null) return null;
|
if (value === undefined || value === null) return null;
|
||||||
let json;
|
let json;
|
||||||
try {
|
try {
|
||||||
json = JSON.stringify(value);
|
json = JSON.stringify(value, jsonPreviewReplacer);
|
||||||
} catch {
|
} catch {
|
||||||
json = JSON.stringify({ truncated: true, reason: "unserializable" });
|
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
|
* @param {string | null} value
|
||||||
* @returns {unknown}
|
* @returns {unknown}
|
||||||
@@ -278,6 +293,98 @@ export async function countConsecutiveFailures(workflow, triggerType, triggerDet
|
|||||||
return count;
|
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 }>>}
|
* @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");
|
||||||
@@ -46,8 +46,7 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
|
|||||||
if (!spec) continue;
|
if (!spec) continue;
|
||||||
|
|
||||||
const threshold = Number(spec.onConsecutiveFailures);
|
const threshold = Number(spec.onConsecutiveFailures);
|
||||||
const workflowName =
|
const workflowName = onFailureWorkflowName(spec);
|
||||||
typeof spec.triggerWorkflow === "string" ? spec.triggerWorkflow.trim() : "";
|
|
||||||
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) {
|
if (!Number.isFinite(threshold) || threshold < 1 || workflowName.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -59,6 +58,14 @@ export function resolveFailureTriggerConfig(workflow, owner, runtimeTrigger) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Record<string, unknown>} trigger
|
||||||
|
*/
|
||||||
|
function onFailureWorkflowName(trigger) {
|
||||||
|
const value = trigger?.onFailureWorkflow;
|
||||||
|
return typeof value === "string" ? value.trim() : "";
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {unknown} workflow
|
* @param {unknown} workflow
|
||||||
*/
|
*/
|
||||||
@@ -73,14 +80,13 @@ export async function validateWorkflowFailureTriggers(workflow) {
|
|||||||
|
|
||||||
const hasThreshold =
|
const hasThreshold =
|
||||||
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
|
trigger.onConsecutiveFailures != null && trigger.onConsecutiveFailures !== "";
|
||||||
const hasWorkflow =
|
const hasWorkflow = onFailureWorkflowName(trigger).length > 0;
|
||||||
typeof trigger.triggerWorkflow === "string" && trigger.triggerWorkflow.trim().length > 0;
|
|
||||||
|
|
||||||
if (!hasThreshold && !hasWorkflow) continue;
|
if (!hasThreshold && !hasWorkflow) continue;
|
||||||
|
|
||||||
if (!hasThreshold || !hasWorkflow) {
|
if (!hasThreshold || !hasWorkflow) {
|
||||||
const err = new Error(
|
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;
|
err.statusCode = 400;
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ scripts:
|
|||||||
config:
|
config:
|
||||||
url: https://example.com/
|
url: https://example.com/
|
||||||
outputVar: message
|
outputVar: message
|
||||||
transform: >
|
transform: |
|
||||||
data.hasChanges
|
data.hasChanges
|
||||||
? "example.com changed (fingerprint " & data.fingerprint & ")"
|
? "example.com changed (fingerprint " & data.fingerprint & ")"
|
||||||
: "example.com unchanged since " & data.fingerprintAt
|
: "example.com unchanged since " & data.fingerprintAt
|
||||||
@@ -16,3 +16,8 @@ triggers:
|
|||||||
- type: HTTP
|
- type: HTTP
|
||||||
method: POST
|
method: POST
|
||||||
path: /detect-example
|
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
|
- test-send-gmail.yaml
|
||||||
- track.yaml
|
- track.yaml
|
||||||
- rss-devto-to-ntfy.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
|
||||||
@@ -12,6 +12,7 @@ import { WorkflowsPage } from "./pages/WorkflowsPage.jsx";
|
|||||||
import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx";
|
import { WorkflowEditPage, WorkflowNewPage } from "./pages/WorkflowEditPage.jsx";
|
||||||
import { EventsPage } from "./pages/EventsPage.jsx";
|
import { EventsPage } from "./pages/EventsPage.jsx";
|
||||||
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
|
import { EventDetailPage } from "./pages/EventDetailPage.jsx";
|
||||||
|
import { FailuresPage } from "./pages/FailuresPage.jsx";
|
||||||
import { KvPage } from "./pages/KvPage.jsx";
|
import { KvPage } from "./pages/KvPage.jsx";
|
||||||
import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx";
|
import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx";
|
||||||
import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
||||||
@@ -61,6 +62,7 @@ export function App() {
|
|||||||
<Route path="/workflows/:owner/:file/edit" element={<WorkflowEditPage />} />
|
<Route path="/workflows/:owner/:file/edit" element={<WorkflowEditPage />} />
|
||||||
<Route path="/events" element={<EventsPage />} />
|
<Route path="/events" element={<EventsPage />} />
|
||||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||||
|
<Route path="/failures" element={<FailuresPage />} />
|
||||||
<Route path="/kv" element={<KvPage />} />
|
<Route path="/kv" element={<KvPage />} />
|
||||||
<Route path="/variables" element={<VariablesPage />} />
|
<Route path="/variables" element={<VariablesPage />} />
|
||||||
<Route path="/auth" element={<AuthProfilesPage />} />
|
<Route path="/auth" element={<AuthProfilesPage />} />
|
||||||
|
|||||||
@@ -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) {
|
export function useRun(id) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["runs", id],
|
queryKey: ["runs", id],
|
||||||
|
|||||||
@@ -117,15 +117,15 @@ export function TriggerCard({
|
|||||||
|
|
||||||
function triggerSummary(trigger, owner) {
|
function triggerSummary(trigger, owner) {
|
||||||
const type = trigger?.type;
|
const type = trigger?.type;
|
||||||
|
const failure =
|
||||||
|
trigger.onConsecutiveFailures && trigger.onFailureWorkflow
|
||||||
|
? ` · onFailure@${trigger.onFailureWorkflow}`
|
||||||
|
: "";
|
||||||
if (type === "HTTP") {
|
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") {
|
if (type === "cron") {
|
||||||
const extra =
|
return `${trigger.schedule || ""}${failure}`;
|
||||||
trigger.onConsecutiveFailures && trigger.triggerWorkflow
|
|
||||||
? ` · alert@${trigger.triggerWorkflow}`
|
|
||||||
: "";
|
|
||||||
return `${trigger.schedule || ""}${extra}`;
|
|
||||||
}
|
}
|
||||||
if (type === "workflow") return "callable";
|
if (type === "workflow") return "callable";
|
||||||
return "";
|
return "";
|
||||||
@@ -320,12 +320,12 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
|
|||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<label className="form-control">
|
<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
|
<select
|
||||||
className="select select-sm"
|
className="select select-sm"
|
||||||
value={trigger.triggerWorkflow ?? ""}
|
value={trigger.onFailureWorkflow ?? ""}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onChange={(e) => onChange({ ...trigger, triggerWorkflow: e.target.value })}
|
onChange={(e) => onChange({ ...trigger, onFailureWorkflow: e.target.value })}
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{alertDestinations.map((w) => (
|
{alertDestinations.map((w) => (
|
||||||
@@ -333,11 +333,11 @@ function FailureAlertFields({ trigger, disabled, onChange, alertDestinations })
|
|||||||
{w.name ?? w.file}
|
{w.name ?? w.file}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
{trigger.triggerWorkflow &&
|
{trigger.onFailureWorkflow &&
|
||||||
!alertDestinations.some(
|
!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}
|
) : null}
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ export function newHttpTrigger() {
|
|||||||
response: "",
|
response: "",
|
||||||
unauthorized: null,
|
unauthorized: null,
|
||||||
onConsecutiveFailures: "",
|
onConsecutiveFailures: "",
|
||||||
triggerWorkflow: "",
|
onFailureWorkflow: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +158,7 @@ export function newCronTrigger() {
|
|||||||
response: "",
|
response: "",
|
||||||
unauthorized: null,
|
unauthorized: null,
|
||||||
onConsecutiveFailures: "",
|
onConsecutiveFailures: "",
|
||||||
triggerWorkflow: "",
|
onFailureWorkflow: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,7 +173,7 @@ export function newWorkflowTrigger() {
|
|||||||
response: "",
|
response: "",
|
||||||
unauthorized: null,
|
unauthorized: null,
|
||||||
onConsecutiveFailures: "",
|
onConsecutiveFailures: "",
|
||||||
triggerWorkflow: "",
|
onFailureWorkflow: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +191,10 @@ export function triggerDestinations(workflows, { owner, excludeFile } = {}) {
|
|||||||
|
|
||||||
export { HTTP_METHODS };
|
export { HTTP_METHODS };
|
||||||
|
|
||||||
|
function readOnFailureWorkflow(raw) {
|
||||||
|
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeStep(step) {
|
function normalizeStep(step) {
|
||||||
const uiId = nextUiId("step");
|
const uiId = nextUiId("step");
|
||||||
if (typeof step === "string") {
|
if (typeof step === "string") {
|
||||||
@@ -266,10 +270,10 @@ function normalizeTrigger(raw) {
|
|||||||
"response",
|
"response",
|
||||||
"unauthorized",
|
"unauthorized",
|
||||||
"onConsecutiveFailures",
|
"onConsecutiveFailures",
|
||||||
"triggerWorkflow",
|
"onFailureWorkflow",
|
||||||
])
|
])
|
||||||
: type === "cron"
|
: type === "cron"
|
||||||
? new Set(["type", "schedule", "onConsecutiveFailures", "triggerWorkflow"])
|
? new Set(["type", "schedule", "onConsecutiveFailures", "onFailureWorkflow"])
|
||||||
: new Set(["type"]);
|
: new Set(["type"]);
|
||||||
/** @type {Record<string, unknown>} */
|
/** @type {Record<string, unknown>} */
|
||||||
const extra = {};
|
const extra = {};
|
||||||
@@ -286,8 +290,7 @@ function normalizeTrigger(raw) {
|
|||||||
raw.onConsecutiveFailures == null || raw.onConsecutiveFailures === ""
|
raw.onConsecutiveFailures == null || raw.onConsecutiveFailures === ""
|
||||||
? ""
|
? ""
|
||||||
: String(raw.onConsecutiveFailures),
|
: String(raw.onConsecutiveFailures),
|
||||||
triggerWorkflow:
|
onFailureWorkflow: readOnFailureWorkflow(raw),
|
||||||
typeof raw.triggerWorkflow === "string" ? raw.triggerWorkflow : "",
|
|
||||||
auth: raw.auth ?? null,
|
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,
|
||||||
@@ -328,8 +331,8 @@ function dumpFailureTriggerFields(t, out) {
|
|||||||
out.onConsecutiveFailures = Math.floor(threshold);
|
out.onConsecutiveFailures = Math.floor(threshold);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (typeof t.triggerWorkflow === "string" && t.triggerWorkflow.trim()) {
|
if (typeof t.onFailureWorkflow === "string" && t.onFailureWorkflow.trim()) {
|
||||||
out.triggerWorkflow = t.triggerWorkflow.trim();
|
out.onFailureWorkflow = t.onFailureWorkflow.trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export function EventsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="flex flex-col sm:flex-row gap-2">
|
||||||
<input
|
<input
|
||||||
className="input input-sm w-full sm:max-w-sm"
|
className="input input-sm w-full sm:max-w-sm"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,5 @@
|
|||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import {
|
import { LuCode, LuGitBranch, LuTriangleAlert } from "react-icons/lu";
|
||||||
LuActivity,
|
|
||||||
LuCode,
|
|
||||||
LuGitBranch,
|
|
||||||
LuTriangleAlert,
|
|
||||||
} from "react-icons/lu";
|
|
||||||
import { useDashboard } from "../api/hooks.js";
|
import { useDashboard } from "../api/hooks.js";
|
||||||
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
import { formatTime, StatusBadge } from "../lib/format.jsx";
|
||||||
|
|
||||||
@@ -18,8 +13,10 @@ export function HomePage() {
|
|||||||
return <p className="text-error">Failed to load dashboard</p>;
|
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 broken = data.needsAttention?.brokenWorkflows ?? [];
|
||||||
|
const failedEvents = data.failedEvents ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -39,52 +36,89 @@ export function HomePage() {
|
|||||||
<div className="stat-title">Scripts</div>
|
<div className="stat-title">Scripts</div>
|
||||||
<div className="stat-value text-2xl">{data.scriptCount}</div>
|
<div className="stat-value text-2xl">{data.scriptCount}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="stat">
|
|
||||||
<div className="stat-figure text-warning">
|
|
||||||
<LuActivity className="size-7" />
|
|
||||||
</div>
|
|
||||||
<div className="stat-title">Running</div>
|
|
||||||
<div className="stat-value text-2xl">{data.running?.length ?? 0}</div>
|
|
||||||
</div>
|
|
||||||
<div className="stat">
|
<div className="stat">
|
||||||
<div className="stat-figure text-error">
|
<div className="stat-figure text-error">
|
||||||
<LuTriangleAlert className="size-7" />
|
<LuTriangleAlert className="size-7" />
|
||||||
</div>
|
</div>
|
||||||
<div className="stat-title">Attention</div>
|
<div className="stat-title">Attention</div>
|
||||||
<div className="stat-value text-2xl">
|
<div className="stat-value text-2xl">{streakCount + broken.length}</div>
|
||||||
{failed.length + broken.length}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section>
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
<h2 className="text-lg font-semibold mb-2">Running</h2>
|
<section className="min-w-0">
|
||||||
<RunList runs={data.running} empty="None" />
|
<div className="mb-2 flex items-baseline justify-between gap-2">
|
||||||
</section>
|
<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>
|
<section className="min-w-0">
|
||||||
<h2 className="text-lg font-semibold mb-2">Needs attention</h2>
|
<div className="mb-2 flex items-baseline justify-between gap-2">
|
||||||
{broken.length > 0 ? (
|
<h2 className="text-lg font-semibold">Failed events</h2>
|
||||||
<ul className="mb-3 space-y-1">
|
<Link className="link link-hover text-sm" to="/events?status=failed">
|
||||||
{broken.map((w) => (
|
View all
|
||||||
<li key={w.key}>
|
</Link>
|
||||||
<Link
|
</div>
|
||||||
className="link link-error"
|
<RunList runs={failedEvents} empty="None" />
|
||||||
to={`/workflows/${encodeURIComponent(w.owner)}/${encodeURIComponent(w.file)}/edit`}
|
</section>
|
||||||
>
|
|
||||||
{w.key}: {w.loadError}
|
<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>
|
</Link>
|
||||||
</li>
|
</td>
|
||||||
))}
|
<td className="whitespace-nowrap">{formatTime(s.lastRun.started_at)}</td>
|
||||||
</ul>
|
</tr>
|
||||||
) : null}
|
))}
|
||||||
<RunList runs={failed} empty={broken.length ? "" : "None"} />
|
</tbody>
|
||||||
</section>
|
</table>
|
||||||
|
|
||||||
<section>
|
|
||||||
<h2 className="text-lg font-semibold mb-2">Recent</h2>
|
|
||||||
<RunList runs={data.recent} empty="None" />
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -180,11 +180,11 @@ export function SecretsPage() {
|
|||||||
value={form.value}
|
value={form.value}
|
||||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||||
required
|
required
|
||||||
minLength={8}
|
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
/>
|
/>
|
||||||
<p className="text-xs opacity-60 mt-1">
|
<p className="text-xs opacity-60 mt-1">
|
||||||
Values are encrypted at rest and never shown again after save.
|
Values are encrypted at rest and never shown again after save.
|
||||||
|
Values shorter than 8 characters are not redacted from logs.
|
||||||
</p>
|
</p>
|
||||||
{upsert.isError ? (
|
{upsert.isError ? (
|
||||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": { 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:9000", changeOrigin: true },
|
"/admin": { target: "http://127.0.0.1:8700", changeOrigin: true },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+118
@@ -35,6 +35,9 @@ importers:
|
|||||||
axios:
|
axios:
|
||||||
specifier: ^1.19.0
|
specifier: ^1.19.0
|
||||||
version: 1.19.0
|
version: 1.19.0
|
||||||
|
basic-ftp:
|
||||||
|
specifier: ^6.2.0
|
||||||
|
version: 6.2.0
|
||||||
bcryptjs:
|
bcryptjs:
|
||||||
specifier: ^3.0.2
|
specifier: ^3.0.2
|
||||||
version: 3.0.3
|
version: 3.0.3
|
||||||
@@ -68,6 +71,9 @@ importers:
|
|||||||
pino-roll:
|
pino-roll:
|
||||||
specifier: ^4.0.0
|
specifier: ^4.0.0
|
||||||
version: 4.0.0
|
version: 4.0.0
|
||||||
|
ssh2-sftp-client:
|
||||||
|
specifier: ^12.1.1
|
||||||
|
version: 12.1.1
|
||||||
webdav:
|
webdav:
|
||||||
specifier: ^5.10.0
|
specifier: ^5.10.0
|
||||||
version: 5.10.0
|
version: 5.10.0
|
||||||
@@ -985,6 +991,9 @@ packages:
|
|||||||
asn1.js@5.4.1:
|
asn1.js@5.4.1:
|
||||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
resolution: {integrity: sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==}
|
||||||
|
|
||||||
asynckit@0.4.0:
|
asynckit@0.4.0:
|
||||||
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
|
||||||
|
|
||||||
@@ -1013,6 +1022,13 @@ packages:
|
|||||||
engines: {node: '>=6.0.0'}
|
engines: {node: '>=6.0.0'}
|
||||||
hasBin: true
|
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:
|
bcryptjs@3.0.3:
|
||||||
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
resolution: {integrity: sha512-GlF5wPWnSa/X5LKM1o0wz0suXIINz1iHRLvTS+sLyi7XPbe5ycmYI3DlZqVGZZtDgl4DmasFg7gOB3JYbphV5g==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1042,6 +1058,13 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
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'}
|
||||||
|
|
||||||
byte-length@1.0.2:
|
byte-length@1.0.2:
|
||||||
resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==}
|
resolution: {integrity: sha512-ovBpjmsgd/teRmgcPh23d4gJvxDoXtAzEL9xTfMU8Yc2kqCDb7L9jAG0XHl1nzuGl+h3ebCIF1i62UFyA9V/2Q==}
|
||||||
|
|
||||||
@@ -1074,6 +1097,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==}
|
||||||
engines: {node: '>= 12'}
|
engines: {node: '>= 12'}
|
||||||
|
|
||||||
|
concat-stream@2.0.0:
|
||||||
|
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
|
||||||
|
engines: {'0': node >= 6.0}
|
||||||
|
|
||||||
content-disposition@0.5.4:
|
content-disposition@0.5.4:
|
||||||
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -1095,6 +1122,10 @@ packages:
|
|||||||
cose-base@2.2.0:
|
cose-base@2.2.0:
|
||||||
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
resolution: {integrity: sha512-AzlgcsCbUMymkADOJtQm3wO9S3ltPfYOFD5033keQn9NJzIbtnZj+UdBJe7DYml/8TdbtHJW3j58SOnKhWY/5g==}
|
||||||
|
|
||||||
|
cpu-features@0.0.10:
|
||||||
|
resolution: {integrity: sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA==}
|
||||||
|
engines: {node: '>=10.0.0'}
|
||||||
|
|
||||||
cronstrue@3.24.0:
|
cronstrue@3.24.0:
|
||||||
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
|
resolution: {integrity: sha512-t/Ji3Ur2c/pzhIAWNwC0ftl3JAE4dLfCjAdZoTZXmPDZwcispnS1PaMcMS4OmIIXyIVouAz+yw+mfQiE3hz5OQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -1829,6 +1860,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
nan@2.28.0:
|
||||||
|
resolution: {integrity: sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==}
|
||||||
|
|
||||||
nanoid@3.3.18:
|
nanoid@3.3.18:
|
||||||
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==}
|
||||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||||
@@ -1986,6 +2020,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
readable-stream@3.6.2:
|
||||||
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
real-require@0.2.0:
|
real-require@0.2.0:
|
||||||
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
|
||||||
engines: {node: '>= 12.13.0'}
|
engines: {node: '>= 12.13.0'}
|
||||||
@@ -2103,6 +2141,14 @@ packages:
|
|||||||
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
|
||||||
engines: {node: '>= 10.x'}
|
engines: {node: '>= 10.x'}
|
||||||
|
|
||||||
|
ssh2-sftp-client@12.1.1:
|
||||||
|
resolution: {integrity: sha512-wYVDgwkpcKG2iPGQQ+QR33xkWqLFIaVrYvA+uON4pmxTPaPuB81f1aooUEPN75e/9DCK6rrKYXb6zR6zP3+EtA==}
|
||||||
|
engines: {node: '>=18.20.4'}
|
||||||
|
|
||||||
|
ssh2@1.17.0:
|
||||||
|
resolution: {integrity: sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ==}
|
||||||
|
engines: {node: '>=10.16.0'}
|
||||||
|
|
||||||
state-local@1.0.7:
|
state-local@1.0.7:
|
||||||
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==}
|
||||||
|
|
||||||
@@ -2113,6 +2159,9 @@ packages:
|
|||||||
steed@1.1.3:
|
steed@1.1.3:
|
||||||
resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
|
resolution: {integrity: sha512-EUkci0FAUiE4IvGTSKcDJIQ/eRUP2JJb56+fvZ4sdnguLTqIdKjSxUe138poW8mkvKWXW2sFPrgTsxqoISnmoA==}
|
||||||
|
|
||||||
|
string_decoder@1.3.0:
|
||||||
|
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
|
||||||
|
|
||||||
strnum@2.4.2:
|
strnum@2.4.2:
|
||||||
resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
|
resolution: {integrity: sha512-rDG3Ah4TV0k1hWvLSzkZtMmLN9+eS+h3knq4MP6A42Y3Yh5qGNnOUs1jJkoSr8FG5dsL28c7KgkIBzSEykqtuw==}
|
||||||
|
|
||||||
@@ -2165,6 +2214,12 @@ packages:
|
|||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
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:
|
update-browserslist-db@1.3.1:
|
||||||
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
|
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -2178,6 +2233,9 @@ packages:
|
|||||||
url-parse@1.5.10:
|
url-parse@1.5.10:
|
||||||
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
resolution: {integrity: sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==}
|
||||||
|
|
||||||
|
util-deprecate@1.0.2:
|
||||||
|
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||||
|
|
||||||
uuid@14.0.1:
|
uuid@14.0.1:
|
||||||
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
|
resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@@ -3159,6 +3217,10 @@ snapshots:
|
|||||||
minimalistic-assert: 1.0.1
|
minimalistic-assert: 1.0.1
|
||||||
safer-buffer: 2.1.2
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
|
asn1@0.2.6:
|
||||||
|
dependencies:
|
||||||
|
safer-buffer: 2.1.2
|
||||||
|
|
||||||
asynckit@0.4.0: {}
|
asynckit@0.4.0: {}
|
||||||
|
|
||||||
atomic-sleep@1.0.0: {}
|
atomic-sleep@1.0.0: {}
|
||||||
@@ -3186,6 +3248,12 @@ snapshots:
|
|||||||
|
|
||||||
baseline-browser-mapping@2.11.14: {}
|
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: {}
|
bcryptjs@3.0.3: {}
|
||||||
|
|
||||||
better-sqlite3@13.0.3:
|
better-sqlite3@13.0.3:
|
||||||
@@ -3214,6 +3282,11 @@ snapshots:
|
|||||||
node-releases: 2.0.53
|
node-releases: 2.0.53
|
||||||
update-browserslist-db: 1.3.1(browserslist@4.28.8)
|
update-browserslist-db: 1.3.1(browserslist@4.28.8)
|
||||||
|
|
||||||
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
|
buildcheck@0.0.7:
|
||||||
|
optional: true
|
||||||
|
|
||||||
byte-length@1.0.2: {}
|
byte-length@1.0.2: {}
|
||||||
|
|
||||||
call-bind-apply-helpers@1.0.2:
|
call-bind-apply-helpers@1.0.2:
|
||||||
@@ -3237,6 +3310,13 @@ snapshots:
|
|||||||
|
|
||||||
commander@8.3.0: {}
|
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:
|
content-disposition@0.5.4:
|
||||||
dependencies:
|
dependencies:
|
||||||
safe-buffer: 5.2.1
|
safe-buffer: 5.2.1
|
||||||
@@ -3255,6 +3335,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
layout-base: 2.0.1
|
layout-base: 2.0.1
|
||||||
|
|
||||||
|
cpu-features@0.0.10:
|
||||||
|
dependencies:
|
||||||
|
buildcheck: 0.0.7
|
||||||
|
nan: 2.28.0
|
||||||
|
optional: true
|
||||||
|
|
||||||
cronstrue@3.24.0: {}
|
cronstrue@3.24.0: {}
|
||||||
|
|
||||||
cross-spawn@7.0.6:
|
cross-spawn@7.0.6:
|
||||||
@@ -3996,6 +4082,9 @@ snapshots:
|
|||||||
|
|
||||||
mustache@4.2.0: {}
|
mustache@4.2.0: {}
|
||||||
|
|
||||||
|
nan@2.28.0:
|
||||||
|
optional: true
|
||||||
|
|
||||||
nanoid@3.3.18: {}
|
nanoid@3.3.18: {}
|
||||||
|
|
||||||
nested-property@4.0.0: {}
|
nested-property@4.0.0: {}
|
||||||
@@ -4129,6 +4218,12 @@ snapshots:
|
|||||||
|
|
||||||
react@19.2.8: {}
|
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@0.2.0: {}
|
||||||
|
|
||||||
real-require@1.0.0: {}
|
real-require@1.0.0: {}
|
||||||
@@ -4244,6 +4339,19 @@ snapshots:
|
|||||||
|
|
||||||
split2@4.2.0: {}
|
split2@4.2.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: {}
|
state-local@1.0.7: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
@@ -4256,6 +4364,10 @@ snapshots:
|
|||||||
fastseries: 1.7.2
|
fastseries: 1.7.2
|
||||||
reusify: 1.1.0
|
reusify: 1.1.0
|
||||||
|
|
||||||
|
string_decoder@1.3.0:
|
||||||
|
dependencies:
|
||||||
|
safe-buffer: 5.2.1
|
||||||
|
|
||||||
strnum@2.4.2:
|
strnum@2.4.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
anynum: 1.0.1
|
anynum: 1.0.1
|
||||||
@@ -4291,6 +4403,10 @@ snapshots:
|
|||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
|
tweetnacl@0.14.5: {}
|
||||||
|
|
||||||
|
typedarray@0.0.6: {}
|
||||||
|
|
||||||
update-browserslist-db@1.3.1(browserslist@4.28.8):
|
update-browserslist-db@1.3.1(browserslist@4.28.8):
|
||||||
dependencies:
|
dependencies:
|
||||||
browserslist: 4.28.8
|
browserslist: 4.28.8
|
||||||
@@ -4304,6 +4420,8 @@ snapshots:
|
|||||||
querystringify: 2.2.0
|
querystringify: 2.2.0
|
||||||
requires-port: 1.0.0
|
requires-port: 1.0.0
|
||||||
|
|
||||||
|
util-deprecate@1.0.2: {}
|
||||||
|
|
||||||
uuid@14.0.1: {}
|
uuid@14.0.1: {}
|
||||||
|
|
||||||
vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0):
|
vite@7.3.6(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.9.0):
|
||||||
|
|||||||
Reference in New Issue
Block a user