From 20654b068236d27764a4c80b4638719c4688aa97 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 20 Aug 2026 23:28:35 +0000 Subject: [PATCH 01/16] feat(events): add pagination, filters, sort, and page size Extend GET /api/runs with offset/total, date and trigger filters, and configurable column sort. Rebuild Events page with URL-driven state matching the KV page pagination pattern. Co-authored-by: Nasyarobby Putra --- packages/server/src/api/run-query.js | 28 +++ packages/server/src/api/runs.js | 12 +- packages/server/start-app.js | 13 +- packages/server/store.js | 115 ++++++++++-- packages/web/src/api/hooks.js | 14 +- packages/web/src/pages/EventsPage.jsx | 240 +++++++++++++++++++++----- 6 files changed, 343 insertions(+), 79 deletions(-) create mode 100644 packages/server/src/api/run-query.js diff --git a/packages/server/src/api/run-query.js b/packages/server/src/api/run-query.js new file mode 100644 index 0000000..6b50237 --- /dev/null +++ b/packages/server/src/api/run-query.js @@ -0,0 +1,28 @@ +import * as store from "../../store.js"; + +/** + * @param {Record} q + */ +export function parseRunQueryParams(q) { + const limit = q.limit != null ? Number(q.limit) : undefined; + const offset = q.offset != null ? Number(q.offset) : undefined; + return { + owner: q.owner || undefined, + workflow: q.workflow || undefined, + status: q.status || undefined, + trigger_type: q.trigger || undefined, + after: q.after || undefined, + before: q.before || undefined, + limit: Number.isFinite(limit) ? limit : undefined, + offset: Number.isFinite(offset) ? offset : undefined, + sort: q.sort || undefined, + order: q.order || undefined, + }; +} + +/** + * @param {Record} q + */ +export async function queryRunsFromRequest(q) { + return store.queryRuns(parseRunQueryParams(q)); +} diff --git a/packages/server/src/api/runs.js b/packages/server/src/api/runs.js index 1aeb8ba..0b59107 100644 --- a/packages/server/src/api/runs.js +++ b/packages/server/src/api/runs.js @@ -1,20 +1,12 @@ import * as store from "../../store.js"; +import { queryRunsFromRequest } from "./run-query.js"; /** * @param {import("fastify").FastifyInstance} fastify */ export default async function runsPlugin(fastify) { fastify.get("/runs", async (req) => { - const q = /** @type {Record} */ (req.query ?? {}); - const limit = q.limit ? Number(q.limit) : undefined; - const runs = await store.listRuns({ - owner: q.owner, - workflow: q.workflow, - status: q.status, - limit: Number.isFinite(limit) ? limit : undefined, - before: q.before, - }); - return { runs }; + return queryRunsFromRequest(/** @type {Record} */ (req.query ?? {})); }); fastify.get("/consecutive-failures", async (req) => { diff --git a/packages/server/start-app.js b/packages/server/start-app.js index 39f0299..ee58947 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -14,6 +14,7 @@ import usersPlugin from "./src/api/users.js"; import scriptsPluginFactory from "./src/api/scripts.js"; import workflowsPluginFactory from "./src/api/workflows.js"; import runsPlugin from "./src/api/runs.js"; +import { queryRunsFromRequest } from "./src/api/run-query.js"; import dashboardPluginFactory from "./src/api/dashboard.js"; import secretsPlugin from "./src/api/secrets.js"; import kvPlugin from "./src/api/kv.js"; @@ -198,16 +199,8 @@ export async function startApp(opts = {}) { "/admin/runs", { onRequest: [server.authenticate] }, async (req, reply) => { - const q = /** @type {Record} */ (req.query); - const limit = q.limit ? Number(q.limit) : undefined; - const runs = await store.listRuns({ - owner: q.owner, - workflow: q.workflow, - status: q.status, - limit: Number.isFinite(limit) ? limit : undefined, - before: q.before, - }); - return reply.send({ runs }); + const q = /** @type {Record} */ (req.query ?? {}); + return reply.send(await queryRunsFromRequest(q)); }, ); diff --git a/packages/server/store.js b/packages/server/store.js index c680529..a23a1cd 100644 --- a/packages/server/store.js +++ b/packages/server/store.js @@ -222,19 +222,29 @@ export async function insertLogs(rows) { ); } +/** @type {Record} */ +const RUN_SORT_COLUMNS = { + status: "status", + workflow: "workflow_name", + revision: "workflow_revision", + trigger: "trigger_type", + started_at: "started_at", + duration: "duration_ms", +}; + /** + * @param {import("knex").Knex.QueryBuilder} q * @param {{ * owner?: string, * workflow?: string, * status?: string | string[], - * limit?: number, + * trigger_type?: string, + * after?: string, * before?: string, - * }} [filters] + * }} filters */ -export async function listRuns(filters = {}) { - const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); - let q = db("workflow_runs").select("*").orderBy("started_at", "desc"); - if (filters.owner) q = q.where("owner", filters.owner); +function applyRunFilters(q, filters) { + if (filters.owner) q.where("owner", filters.owner); if (filters.workflow) { const key = String(filters.workflow); if (key.includes("*")) { @@ -243,25 +253,102 @@ export async function listRuns(filters = {}) { .replaceAll("%", "\\%") .replaceAll("_", "\\_") .replaceAll("*", "%"); - q = q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]); + q.whereRaw("workflow LIKE ? ESCAPE '\\'", [pattern]); } else { - q = q.where("workflow", key); + q.where("workflow", key); } } if (filters.status) { if (Array.isArray(filters.status)) { - q = q.whereIn("status", filters.status); + q.whereIn("status", filters.status); } else { - q = q.where("status", filters.status); + q.where("status", filters.status); } } - if (filters.before) q = q.where("started_at", "<", filters.before); - const rows = await q.limit(limit); - return rows.map((row) => ({ + if (filters.trigger_type) q.where("trigger_type", filters.trigger_type); + if (filters.after) q.where("started_at", ">=", filters.after); + if (filters.before) q.where("started_at", "<", filters.before); + return q; +} + +/** + * @param {import("knex").Knex.QueryBuilder} q + * @param {string | undefined} sort + * @param {string | undefined} order + */ +function applyRunSort(q, sort, order) { + const column = RUN_SORT_COLUMNS[sort ?? ""] ?? "started_at"; + const direction = order === "asc" ? "asc" : "desc"; + q.orderBy(column, direction); + if (column !== "started_at") q.orderBy("started_at", "desc"); + return q; +} + +/** + * @param {Record} row + */ +function mapRunRow(row) { + return { ...row, input: deserialize(row.input), output: deserialize(row.output), - })); + }; +} + +/** + * @param {{ + * owner?: string, + * workflow?: string, + * status?: string | string[], + * trigger_type?: string, + * after?: string, + * before?: string, + * limit?: number, + * offset?: number, + * sort?: string, + * order?: string, + * }} [filters] + */ +export async function queryRuns(filters = {}) { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + const offset = Math.max(Number(filters.offset) || 0, 0); + + let q = db("workflow_runs"); + q = applyRunFilters(q, filters); + + const countRow = await q.clone().count({ count: "*" }).first(); + const total = Number(countRow?.count ?? 0); + + let rowsQ = q.clone().select("*"); + rowsQ = applyRunSort(rowsQ, filters.sort, filters.order); + const rows = await rowsQ.limit(limit).offset(offset); + + return { + runs: rows.map(mapRunRow), + total, + limit, + offset, + }; +} + +/** + * @param {{ + * owner?: string, + * workflow?: string, + * status?: string | string[], + * trigger_type?: string, + * after?: string, + * before?: string, + * limit?: number, + * }} [filters] + */ +export async function listRuns(filters = {}) { + const limit = Math.min(Math.max(filters.limit ?? 50, 1), 200); + let q = db("workflow_runs").select("*"); + q = applyRunFilters(q, filters); + q = applyRunSort(q, "started_at", "desc"); + const rows = await q.limit(limit); + return rows.map(mapRunRow); } /** diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index cd07e4b..91b9ccd 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -298,16 +298,22 @@ export function useReregisterWorkflows() { } export function useRuns(filters = {}) { - const { owner, workflow, status, limit } = filters; + const { owner, workflow, status, trigger, after, before, limit, offset, sort, order } = filters; return useQuery({ - queryKey: ["runs", { owner, workflow, status, limit }], + queryKey: ["runs", { owner, workflow, status, trigger, after, before, limit, offset, sort, order }], queryFn: async () => { const params = {}; if (owner) params.owner = owner; if (workflow) params.workflow = workflow; if (status) params.status = status; - if (limit) params.limit = limit; - return (await api.get("/runs", { params })).data.runs; + if (trigger) params.trigger = trigger; + if (after) params.after = after; + if (before) params.before = before; + if (limit != null) params.limit = limit; + if (offset != null) params.offset = offset; + if (sort) params.sort = sort; + if (order) params.order = order; + return (await api.get("/runs", { params })).data; }, }); } diff --git a/packages/web/src/pages/EventsPage.jsx b/packages/web/src/pages/EventsPage.jsx index 6e645af..f1ec475 100644 --- a/packages/web/src/pages/EventsPage.jsx +++ b/packages/web/src/pages/EventsPage.jsx @@ -1,28 +1,103 @@ import { Link, useSearchParams } from "react-router-dom"; -import { useRuns } from "../api/hooks.js"; +import { useOwners, useRuns } from "../api/hooks.js"; import { formatTime, StatusBadge } from "../lib/format.jsx"; +const PAGE_SIZES = [25, 50, 100]; +const DEFAULT_LIMIT = 50; +const TRIGGER_TYPES = ["HTTP", "cron", "manual"]; + +const SORT_COLUMNS = [ + { key: "status", label: "Status" }, + { key: "workflow", label: "Workflow" }, + { key: "revision", label: "Revision" }, + { key: "trigger", label: "Trigger" }, + { key: "started_at", label: "Started" }, + { key: "duration", label: "Duration" }, +]; + +function dateToAfterIso(date) { + if (!date) return undefined; + return `${date}T00:00:00.000Z`; +} + +function dateToBeforeIso(date) { + if (!date) return undefined; + const d = new Date(`${date}T00:00:00.000Z`); + d.setUTCDate(d.getUTCDate() + 1); + return d.toISOString(); +} + +function SortHeader({ column, label, sort, order, onSort }) { + const active = sort === column; + return ( + + + + ); +} + export function EventsPage() { const [params, setParams] = useSearchParams(); const workflow = params.get("workflow") || ""; const status = params.get("status") || ""; - const { data: runs = [], isLoading } = useRuns({ + const owner = params.get("owner") || ""; + const trigger = params.get("trigger") || ""; + const afterDate = params.get("after") || ""; + const beforeDate = params.get("before") || ""; + const offset = Math.max(Number(params.get("offset")) || 0, 0); + const limitParam = Number(params.get("limit")); + const limit = PAGE_SIZES.includes(limitParam) ? limitParam : DEFAULT_LIMIT; + const sort = params.get("sort") || "started_at"; + const order = params.get("order") === "asc" ? "asc" : "desc"; + + const { data: owners = [] } = useOwners(); + const { data, isLoading } = useRuns({ workflow: workflow || undefined, status: status || undefined, - limit: 100, + owner: owner || undefined, + trigger: trigger || undefined, + after: dateToAfterIso(afterDate), + before: dateToBeforeIso(beforeDate), + limit, + offset, + sort, + order, }); - function update(key, value) { + const runs = data?.runs ?? []; + const total = data?.total ?? 0; + + function update(key, value, resetOffset = true) { const next = new URLSearchParams(params); if (value) next.set(key, value); else next.delete(key); + if (resetOffset && key !== "offset") next.delete("offset"); + setParams(next); + } + + function setSort(column) { + const next = new URLSearchParams(params); + if (sort === column) { + next.set("order", order === "asc" ? "desc" : "asc"); + } else { + next.set("sort", column); + next.set("order", "desc"); + } + next.delete("offset"); setParams(next); } return (

{status === "failed" ? "Failed events" : "Events"}

-
+
update("status", e.target.value)} > + + + + + +
{isLoading ? ( + ) : runs.length === 0 ? ( +

No events match these filters.

) : ( -
- - - - - - - - - - - - - {runs.map((r) => ( - - - - - - - + <> +
+
StatusWorkflowRevisionTriggerStartedDuration
- - - - {r.workflow_name || r.workflow} - - - {r.workflow_revision != null ? `#${r.workflow_revision}` : "unknown"} - - {r.trigger_type} - {r.trigger_detail ? ` · ${r.trigger_detail}` : ""} - {formatTime(r.started_at)}{r.duration_ms != null ? `${r.duration_ms}ms` : "—"}
+ + + {SORT_COLUMNS.map(({ key, label }) => ( + + ))} - ))} - -
-
+ + + {runs.map((r) => ( + + + + + + + {r.workflow_name || r.workflow} + + + + {r.workflow_revision != null ? `#${r.workflow_revision}` : "unknown"} + + + {r.trigger_type} + {r.trigger_detail ? ` · ${r.trigger_detail}` : ""} + + {formatTime(r.started_at)} + {r.duration_ms != null ? `${r.duration_ms}ms` : "—"} + + ))} + + +
+
+ + + + {total === 0 ? "0" : `${offset + 1}–${offset + runs.length}`} of {total} + +
+ )}
); From e84432a49237b128c8e7e495a753965045e6fdc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:12:49 +0000 Subject: [PATCH 02/16] feat(profiles): add typed live step config profiles Profiles store script + default config per owner. Workflow steps reference them with profile:, overlay keys win, and the UI marks overrides. Profile name is immutable after create so YAML refs stay stable. Co-authored-by: Nasyarobby Putra --- .../migrations/20260821060000_profiles.js | 25 ++ packages/server/package.json | 3 +- packages/server/profile-config.js | 31 ++ packages/server/profiles-store.js | 244 ++++++++++++ packages/server/registry.js | 24 +- packages/server/src/api/profiles.js | 82 +++++ packages/server/src/api/workflows.js | 66 +++- packages/server/start-app.js | 2 + packages/server/test/profiles-smoke.js | 101 +++++ packages/server/workflow-parse.js | 47 ++- packages/server/workflow-validate-warnings.js | 1 + packages/web/src/App.jsx | 4 + packages/web/src/api/hooks.js | 41 +++ packages/web/src/components/Layout.jsx | 2 + .../web/src/components/ProfileEditorModal.jsx | 201 ++++++++++ .../components/workflow/AddScriptDialog.jsx | 161 +++++--- .../src/components/workflow/ConfigFields.jsx | 106 ++++-- .../src/components/workflow/ScriptCard.jsx | 58 ++- .../src/components/workflow/ScriptsTab.jsx | 13 +- .../workflow/WorkflowVisualEditor.jsx | 16 +- packages/web/src/lib/profile.js | 16 + packages/web/src/lib/script.js | 7 +- packages/web/src/lib/workflow-doc.js | 25 +- packages/web/src/lib/workflow-mermaid.js | 13 +- packages/web/src/pages/ProfilesPage.jsx | 346 ++++++++++++++++++ 25 files changed, 1512 insertions(+), 123 deletions(-) create mode 100644 packages/server/migrations/20260821060000_profiles.js create mode 100644 packages/server/profile-config.js create mode 100644 packages/server/profiles-store.js create mode 100644 packages/server/src/api/profiles.js create mode 100644 packages/server/test/profiles-smoke.js create mode 100644 packages/web/src/components/ProfileEditorModal.jsx create mode 100644 packages/web/src/lib/profile.js create mode 100644 packages/web/src/pages/ProfilesPage.jsx diff --git a/packages/server/migrations/20260821060000_profiles.js b/packages/server/migrations/20260821060000_profiles.js new file mode 100644 index 0000000..a9552ca --- /dev/null +++ b/packages/server/migrations/20260821060000_profiles.js @@ -0,0 +1,25 @@ +/** + * @param {import("knex").Knex} knex + */ +export async function up(knex) { + await knex.schema.createTable("profiles", (t) => { + t.text("id").primary(); + t.text("owner").notNullable(); + t.text("name").notNullable(); + t.text("script").notNullable(); + t.text("config").notNullable(); + t.text("description").notNullable().defaultTo(""); + t.text("created_at").notNullable(); + t.text("updated_at").notNullable(); + t.unique(["owner", "name"]); + }); + + await knex.schema.raw("CREATE INDEX profiles_owner_name_idx ON profiles (owner, name)"); +} + +/** + * @param {import("knex").Knex} knex + */ +export async function down(knex) { + await knex.schema.dropTableIfExists("profiles"); +} diff --git a/packages/server/package.json b/packages/server/package.json index 345b80d..45af17b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -13,7 +13,8 @@ "start:control": "node control.js", "migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"", "test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js", - "test:workflow-history": "node test/workflow-history-smoke.js" + "test:workflow-history": "node test/workflow-history-smoke.js", + "test:profiles": "node test/profiles-smoke.js" }, "dependencies": { "@aws-sdk/client-s3": "^3.1111.0", diff --git a/packages/server/profile-config.js b/packages/server/profile-config.js new file mode 100644 index 0000000..1be3c9d --- /dev/null +++ b/packages/server/profile-config.js @@ -0,0 +1,31 @@ +/** + * Shallow merge: step overlay keys replace profile defaults (including ""). + * Nested objects/arrays are replaced, not deep-merged. + * + * @param {unknown} profileConfig + * @param {unknown} stepConfig + * @returns {Record} + */ +export function mergeProfileConfig(profileConfig, stepConfig) { + const base = + profileConfig != null && typeof profileConfig === "object" && !Array.isArray(profileConfig) + ? { ...profileConfig } + : {}; + if (stepConfig == null || typeof stepConfig !== "object" || Array.isArray(stepConfig)) { + return base; + } + return { ...base, ...stepConfig }; +} + +/** + * @param {unknown} config + * @returns {boolean} + */ +export function configHasOverlay(config) { + return ( + config != null && + typeof config === "object" && + !Array.isArray(config) && + Object.keys(config).length > 0 + ); +} diff --git a/packages/server/profiles-store.js b/packages/server/profiles-store.js new file mode 100644 index 0000000..da6d981 --- /dev/null +++ b/packages/server/profiles-store.js @@ -0,0 +1,244 @@ +import { randomUUID } from "node:crypto"; +import yaml from "yaml"; +import { db } from "./db.js"; +import { assertOwner, listOwnerYamlFiles, readWorkflowYaml } from "./fs-store.js"; + +const MAX_NAME_LENGTH = 128; +const MAX_DESCRIPTION_LENGTH = 500; +const MAX_CONFIG_BYTES = 64 * 1024; +const PROFILE_NAME_RE = /^[A-Za-z0-9._-]+$/; + +function nowIso() { + return new Date().toISOString(); +} + +function httpError(message, statusCode = 400) { + const err = new Error(message); + err.statusCode = statusCode; + return err; +} + +/** + * @param {unknown} name + * @returns {string} + */ +export function assertProfileName(name) { + if (typeof name !== "string" || !PROFILE_NAME_RE.test(name)) { + throw httpError("invalid profile name"); + } + if (name.length > MAX_NAME_LENGTH) { + throw httpError(`profile name must be at most ${MAX_NAME_LENGTH} characters`); + } + return name; +} + +/** + * @param {unknown} script + * @returns {string} + */ +export function assertProfileScript(script) { + if (typeof script !== "string" || script.trim().length === 0) { + throw httpError("script is required"); + } + const trimmed = script.trim(); + if (trimmed.length > 256) { + throw httpError("script name is too long"); + } + return trimmed; +} + +/** + * @param {unknown} description + * @returns {string} + */ +export function assertProfileDescription(description) { + if (description == null) return ""; + if (typeof description !== "string") { + throw httpError("description must be a string"); + } + if (description.length > MAX_DESCRIPTION_LENGTH) { + throw httpError(`description must be at most ${MAX_DESCRIPTION_LENGTH} characters`); + } + return description; +} + +/** + * @param {unknown} config + * @returns {string} + */ +export function encodeProfileConfig(config) { + if (config == null) return "{}"; + if (typeof config !== "object" || Array.isArray(config)) { + throw httpError("config must be an object"); + } + let encoded; + try { + encoded = JSON.stringify(config); + } catch { + throw httpError("config must be JSON-serializable"); + } + if (Buffer.byteLength(encoded, "utf8") > MAX_CONFIG_BYTES) { + throw httpError(`config exceeds ${MAX_CONFIG_BYTES} byte limit`); + } + return encoded; +} + +/** + * @param {string} stored + * @returns {Record} + */ +export function decodeProfileConfig(stored) { + if (stored == null || stored === "") return {}; + try { + const parsed = JSON.parse(stored); + if (parsed != null && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed; + } + } catch { + throw new Error(`corrupt profile config: ${JSON.stringify(stored).slice(0, 80)}`); + } + throw new Error("corrupt profile config: not an object"); +} + +/** + * @param {Record} row + */ +function publicProfile(row) { + return { + id: row.id, + owner: row.owner, + name: row.name, + script: row.script, + config: decodeProfileConfig(String(row.config ?? "{}")), + description: row.description == null ? "" : String(row.description), + created_at: row.created_at, + updated_at: row.updated_at, + }; +} + +/** + * @param {{ owner?: string }} [filters] + */ +export async function listProfiles(filters = {}) { + let q = db("profiles") + .select("id", "owner", "name", "script", "config", "description", "created_at", "updated_at") + .orderBy("owner", "asc") + .orderBy("name", "asc"); + if (filters.owner) { + q = q.where("owner", assertOwner(filters.owner)); + } + const rows = await q; + return rows.map((row) => publicProfile(row)); +} + +/** + * @param {string} id + */ +export async function getProfileById(id) { + const row = await db("profiles").where({ id }).first(); + return row ? publicProfile(row) : null; +} + +/** + * @param {string} owner + * @param {string} name + */ +export async function getProfilePlain(owner, name) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + const row = await db("profiles").where({ owner: ownerName, name: profileName }).first(); + return row ? publicProfile(row) : null; +} + +/** + * @param {{ + * owner: string, + * name: string, + * script: unknown, + * config?: unknown, + * description?: unknown, + * }} opts + */ +export async function upsertProfile({ owner, name, script, config, description }) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + const scriptName = assertProfileScript(script); + const encoded = encodeProfileConfig(config ?? {}); + const desc = assertProfileDescription(description); + const now = nowIso(); + const existing = await db("profiles").where({ owner: ownerName, name: profileName }).first(); + + if (existing) { + await db("profiles") + .where({ id: existing.id }) + .update({ + script: scriptName, + config: encoded, + description: desc, + updated_at: now, + }); + return getProfileById(existing.id); + } + + const id = randomUUID(); + await db("profiles").insert({ + id, + owner: ownerName, + name: profileName, + script: scriptName, + config: encoded, + description: desc, + created_at: now, + updated_at: now, + }); + return getProfileById(id); +} + +/** + * @param {string} id + * @returns {Promise} + */ +export async function deleteProfile(id) { + const n = await db("profiles").where({ id }).del(); + return n > 0; +} + +/** + * Workflows (same owner) whose YAML steps reference this profile name. + * @param {string} owner + * @param {string} name + * @returns {{ file: string, name: string, steps: number }[]} + */ +export function listProfileUsages(owner, name) { + const ownerName = assertOwner(owner); + const profileName = assertProfileName(name); + /** @type {{ file: string, name: string, steps: number }[]} */ + const usages = []; + for (const file of listOwnerYamlFiles(ownerName)) { + const content = readWorkflowYaml(ownerName, file); + if (content == null) continue; + let parsed; + try { + parsed = yaml.parse(content); + } catch { + continue; + } + if (parsed == null || typeof parsed !== "object" || Array.isArray(parsed)) continue; + const scripts = parsed.scripts; + if (!Array.isArray(scripts)) continue; + let steps = 0; + for (const step of scripts) { + if (step != null && typeof step === "object" && !Array.isArray(step) && step.profile === profileName) { + steps += 1; + } + } + if (steps > 0) { + usages.push({ + file, + name: typeof parsed.name === "string" && parsed.name ? parsed.name : file, + steps, + }); + } + } + return usages; +} diff --git a/packages/server/registry.js b/packages/server/registry.js index b4bb4e2..670332c 100644 --- a/packages/server/registry.js +++ b/packages/server/registry.js @@ -31,6 +31,8 @@ import { sendSuccessPage, } from "./http-trigger-auth.js"; import { resolveConfigRefs } from "./config-refs.js"; +import { mergeProfileConfig } from "./profile-config.js"; +import { getProfilePlain } from "./profiles-store.js"; import { buildFailureAlertData, resolveFailureTriggerConfig, @@ -593,8 +595,21 @@ export function createRegistry(server, opts = {}) { owner, depth, ) { - const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script; - const unresolvedConfig = parsed.config; + let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script; + let unresolvedConfig = parsed.config; + if (parsed.kind === "script" && parsed.profile) { + const profile = await getProfilePlain(owner, parsed.profile); + if (!profile) { + throw new Error(`profile "${parsed.profile}" not found`); + } + if (parsed.script && parsed.script !== profile.script) { + throw new Error( + `step script "${parsed.script}" does not match profile "${parsed.profile}" script "${profile.script}"`, + ); + } + script = profile.script; + unresolvedConfig = mergeProfileConfig(profile.config, parsed.config); + } const incomingContext = normalizeContext(ctx.context); const step = await store.startStep({ runId, @@ -904,7 +919,10 @@ export function createRegistry(server, opts = {}) { for (const raw of workflow.scripts ?? []) { try { const parsed = parseScriptStep(raw); - if (parsed.kind === "script") refs.add(parsed.script); + if (parsed.kind === "script") { + if (parsed.script) refs.add(parsed.script); + if (parsed.profile) refs.add(`profile:${parsed.profile}`); + } } catch { // skip invalid steps } diff --git a/packages/server/src/api/profiles.js b/packages/server/src/api/profiles.js new file mode 100644 index 0000000..2a5c1ce --- /dev/null +++ b/packages/server/src/api/profiles.js @@ -0,0 +1,82 @@ +import * as fsStore from "../../fs-store.js"; +import { + assertProfileName, + deleteProfile, + getProfileById, + getProfilePlain, + listProfileUsages, + listProfiles, + upsertProfile, +} from "../../profiles-store.js"; + +/** + * @param {import("fastify").FastifyInstance} fastify + */ +export default async function profilesPlugin(fastify) { + fastify.get("/profiles", async (req, reply) => { + const q = /** @type {{ owner?: string }} */ (req.query ?? {}); + try { + const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined; + const profiles = await listProfiles({ owner }); + const withUsage = profiles.map((profile) => ({ + ...profile, + usageCount: listProfileUsages(profile.owner, profile.name).length, + })); + return { profiles: withUsage }; + } catch (err) { + return reply.code(err.statusCode ?? 500).send({ error: err.message }); + } + }); + + fastify.get("/profiles/:id/usage", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + const existing = await getProfileById(id); + if (!existing) { + return reply.code(404).send({ error: "profile not found" }); + } + return { usages: listProfileUsages(existing.owner, existing.name) }; + }); + + fastify.put("/profiles", async (req, reply) => { + const body = /** @type {{ + owner?: string, + name?: string, + script?: unknown, + config?: unknown, + description?: unknown, + }} */ (req.body ?? {}); + try { + fsStore.assertOwner(String(body.owner ?? "")); + assertProfileName(String(body.name ?? "")); + const profile = await upsertProfile({ + owner: String(body.owner), + name: String(body.name), + script: body.script, + config: body.config, + description: body.description, + }); + return reply.send({ profile }); + } catch (err) { + return reply.code(err.statusCode ?? 400).send({ error: err.message }); + } + }); + + fastify.delete("/profiles/:id", async (req, reply) => { + const { id } = /** @type {{ id: string }} */ (req.params); + const q = /** @type {{ force?: string }} */ (req.query ?? {}); + const existing = await getProfileById(id); + if (!existing) { + return reply.code(404).send({ error: "profile not found" }); + } + const usages = listProfileUsages(existing.owner, existing.name); + const force = q.force === "1" || q.force === "true"; + if (usages.length > 0 && !force) { + return reply.code(409).send({ + error: "profile is used by workflows", + usages, + }); + } + await deleteProfile(id); + return { ok: true, forced: force && usages.length > 0, usages }; + }); +} diff --git a/packages/server/src/api/workflows.js b/packages/server/src/api/workflows.js index eed98e7..a417091 100644 --- a/packages/server/src/api/workflows.js +++ b/packages/server/src/api/workflows.js @@ -26,6 +26,8 @@ import { collectWorkflowWarnings, parseWorkflowDocument, } from "../../workflow-validate-warnings.js"; +import { getProfilePlain } from "../../profiles-store.js"; +import { resolveScriptRef } from "../../plugin-store.js"; import { recordRevision, listRevisions, @@ -80,7 +82,9 @@ function scriptNames(workflow) { for (const raw of workflow.scripts ?? []) { try { const parsed = parseScriptStep(raw); - names.push(parsed.kind === "set" ? "set" : parsed.script); + if (parsed.kind === "set") names.push("set"); + else if (parsed.profile) names.push(`profile:${parsed.profile}`); + else names.push(parsed.script); } catch { names.push(null); } @@ -88,6 +92,59 @@ function scriptNames(workflow) { return names; } +/** + * @param {unknown} parsed + * @param {string} owner + */ +async function collectProfileWarnings(parsed, owner) { + /** @type {Array<{ code: string, message: string, path?: string }>} */ + const warnings = []; + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || !owner) { + return warnings; + } + for (const [i, raw] of (parsed.scripts ?? []).entries()) { + if (raw == null || typeof raw !== "object" || Array.isArray(raw)) continue; + const profileName = raw.profile; + if (typeof profileName !== "string" || !profileName) continue; + const pathKey = `scripts[${i}]`; + let profile; + try { + profile = await getProfilePlain(owner, profileName); + } catch { + warnings.push({ + code: "unknown_profile", + message: `Profile "${profileName}" is not a valid name`, + path: pathKey, + }); + continue; + } + if (!profile) { + warnings.push({ + code: "unknown_profile", + message: `Profile "${profileName}" not found`, + path: pathKey, + }); + continue; + } + if (typeof raw.script === "string" && raw.script && raw.script !== profile.script) { + warnings.push({ + code: "profile_script_mismatch", + message: `Step script "${raw.script}" does not match profile "${profileName}" (${profile.script})`, + path: pathKey, + }); + } + const resolved = resolveScriptRef(profile.script); + if (resolved.error) { + warnings.push({ + code: "unknown_script", + message: resolved.error, + path: `${pathKey}.profile`, + }); + } + } + return warnings; +} + /** * @param {unknown} parsed */ @@ -109,8 +166,11 @@ async function validateStrictWorkflow(parsed) { * }} opts */ async function saveWorkflowContent(opts) { - const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content); - const saveAnyway = Boolean(opts.saveAnyway); + const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content); + if (parsed) { + warnings.push(...(await collectProfileWarnings(parsed, opts.owner))); + } + const saveAnyway = Boolean(opts.saveAnyway); if (!saveAnyway) { if (parseError) { diff --git a/packages/server/start-app.js b/packages/server/start-app.js index ee58947..623ac9e 100644 --- a/packages/server/start-app.js +++ b/packages/server/start-app.js @@ -19,6 +19,7 @@ import dashboardPluginFactory from "./src/api/dashboard.js"; import secretsPlugin from "./src/api/secrets.js"; import kvPlugin from "./src/api/kv.js"; import variablesPlugin from "./src/api/variables.js"; +import profilesPlugin from "./src/api/profiles.js"; import httpPagesPlugin from "./src/api/http-pages.js"; import httpAuthsPlugin from "./src/api/http-auths.js"; import { WEB_DIST } from "./paths.js"; @@ -169,6 +170,7 @@ export async function startApp(opts = {}) { await api.register(usersPlugin); await api.register(secretsPlugin); await api.register(variablesPlugin); + await api.register(profilesPlugin); await api.register(kvPlugin); await api.register(httpPagesPlugin); await api.register(httpAuthsPlugin); diff --git a/packages/server/test/profiles-smoke.js b/packages/server/test/profiles-smoke.js new file mode 100644 index 0000000..6462715 --- /dev/null +++ b/packages/server/test/profiles-smoke.js @@ -0,0 +1,101 @@ +import { migrate, db } from "../db.js"; +import { + assertProfileName, + deleteProfile, + encodeProfileConfig, + getProfilePlain, + listProfileUsages, + upsertProfile, +} from "../profiles-store.js"; +import { mergeProfileConfig } from "../profile-config.js"; +import { parseScriptStep } from "../workflow-parse.js"; + +await migrate(); + +function assert(cond, msg) { + if (!cond) throw new Error(msg); +} + +async function assertThrows(fn, match) { + try { + await fn(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + if (match && !message.includes(match)) { + throw new Error(`threw "${message}", expected to include "${match}"`); + } + return; + } + throw new Error(`expected to throw (${match ?? "any error"})`); +} + +const merged = mergeProfileConfig( + { url: "https://n.example/ops", fingerprint: true }, + { fingerprint: "comic-rss" }, +); +assert(merged.url === "https://n.example/ops", "profile url kept"); +assert(merged.fingerprint === "comic-rss", "overlay wins"); + +const emptyOverlay = mergeProfileConfig({ url: "https://n.example/ops" }, {}); +assert(emptyOverlay.url === "https://n.example/ops", "empty overlay"); + +const emptyWins = mergeProfileConfig({ url: "https://n.example/ops" }, { url: "" }); +assert(emptyWins.url === "", "empty string overlay wins"); + +const profileOnly = parseScriptStep({ + profile: "ops-ntfy", + config: { fingerprint: "x" }, +}); +assert(profileOnly.kind === "script", "profile step kind"); +assert(profileOnly.script === "", "script supplied by profile at runtime"); +assert(profileOnly.profile === "ops-ntfy", "profile name"); + +const both = parseScriptStep({ + script: "ntfy.js", + profile: "ops-ntfy", +}); +assert(both.script === "ntfy.js" && both.profile === "ops-ntfy", "script + profile"); + +await assertThrows( + () => parseScriptStep({ profile: "ops", set: { expression: "1" } }), + "profile and set", +); + +assert(assertProfileName("ops-ntfy") === "ops-ntfy", "valid name"); +await assertThrows(() => assertProfileName("ops ntfy"), "invalid profile name"); +await assertThrows(() => encodeProfileConfig([]), "config must be an object"); + +const owner = "default"; +const name = `profiles_smoke_${Date.now()}`; +const created = await upsertProfile({ + owner, + name, + script: "ntfy.js", + config: { url: "$VAR_ntfy_channel" }, + description: "smoke", +}); +assert(created.name === name, "created"); +assert(created.config.url === "$VAR_ntfy_channel", "config roundtrip"); +assert(created.script === "ntfy.js", "script locked on profile"); + +const fetched = await getProfilePlain(owner, name); +assert(fetched?.id === created.id, "get by owner/name"); + +const updated = await upsertProfile({ + owner, + name, + script: "send-email.js", + config: { service: "Gmail" }, + description: "now mail", +}); +assert(updated.id === created.id, "upsert same row"); +assert(updated.script === "send-email.js", "script may change"); + +const usages = listProfileUsages(owner, name); +assert(Array.isArray(usages) && usages.length === 0, "unused profile"); + +await deleteProfile(created.id); +assert((await getProfilePlain(owner, name)) == null, "deleted"); + +await db.destroy(); +console.log("profiles-smoke: ok"); diff --git a/packages/server/workflow-parse.js b/packages/server/workflow-parse.js index 059b707..2cb0fc9 100644 --- a/packages/server/workflow-parse.js +++ b/packages/server/workflow-parse.js @@ -6,20 +6,22 @@ export const SET_STEP_SCRIPT = "set"; * @typedef {{ alias: string, from: string }} NeedEdge * @typedef {{ * kind: "script", - * script: string, - * config: unknown | null, - * expression?: undefined, - * id: string | null, - * needsKind: "none" | "list" | "map", - * needs: NeedEdge[], - * when: string | null, - * }} ParsedScriptStep + * script: string, + * profile: string | null, + * config: unknown | null, + * expression?: undefined, + * id: string | null, + * needsKind: "none" | "list" | "map", + * needs: NeedEdge[], + * when: string | null, + * }} ParsedScriptStep * @typedef {{ * kind: "set", - * script: typeof SET_STEP_SCRIPT, - * config: { expression: string }, - * expression: string, - * id: string | null, + * script: typeof SET_STEP_SCRIPT, + * profile: null, + * config: { expression: string }, + * expression: string, + * id: string | null, * needsKind: "none" | "list" | "map", * needs: NeedEdge[], * when: string | null, @@ -85,6 +87,7 @@ export function parseScriptStep(step) { return { kind: "script", script: step, + profile: null, config: null, id: null, needsKind: "none", @@ -97,24 +100,33 @@ export function parseScriptStep(step) { } const hasScript = step.script != null && step.script !== ""; + const hasProfile = step.profile != null && step.profile !== ""; const hasSet = step.set != null; if (hasScript && hasSet) { throw new Error("Step cannot have both script and set"); } + if (hasProfile && hasSet) { + throw new Error("Step cannot have both profile and set"); + } if (hasSet) { return parseSetStep(step); } - if (hasScript) { - if (typeof step.script !== "string") { - throw new Error(`Invalid script step: ${JSON.stringify(step)}`); - } + if (hasProfile && typeof step.profile !== "string") { + throw new Error(`Invalid profile: ${JSON.stringify(step.profile)}`); + } + if (hasScript && typeof step.script !== "string") { + throw new Error(`Invalid script step: ${JSON.stringify(step)}`); + } + + if (hasScript || hasProfile) { const { needsKind, needs } = parseNeeds(step.needs); return { kind: "script", - script: step.script, + script: hasScript ? step.script : "", + profile: hasProfile ? step.profile : null, config: step.config ?? null, id: parseOptionalId(step.id), needsKind, @@ -265,6 +277,7 @@ function parseSetStep(step) { return { kind: "set", script: SET_STEP_SCRIPT, + profile: null, config: { expression }, expression, id: parseOptionalId(step.id), diff --git a/packages/server/workflow-validate-warnings.js b/packages/server/workflow-validate-warnings.js index 91aad42..2f81dd4 100644 --- a/packages/server/workflow-validate-warnings.js +++ b/packages/server/workflow-validate-warnings.js @@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) { try { const step = parseScriptStep(raw); if (step.kind === "set") continue; + if (step.profile && !step.script) continue; const resolved = resolveScriptRef(step.script); if (resolved.error) { warnings.push({ diff --git a/packages/web/src/App.jsx b/packages/web/src/App.jsx index 351f59f..b0c3343 100644 --- a/packages/web/src/App.jsx +++ b/packages/web/src/App.jsx @@ -20,6 +20,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx"; import { UsersPage } from "./pages/UsersPage.jsx"; import { SecretsPage } from "./pages/SecretsPage.jsx"; import { VariablesPage } from "./pages/VariablesPage.jsx"; +import { ProfilesPage } from "./pages/ProfilesPage.jsx"; import { OpsPage } from "./pages/OpsPage.jsx"; import { BackupPage } from "./pages/BackupPage.jsx"; @@ -71,6 +72,9 @@ export function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index 91b9ccd..2324d0d 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -429,6 +429,47 @@ export function useDeleteVariable() { }); } +export function useProfiles(owner, options = {}) { + return useQuery({ + queryKey: ["profiles", owner ?? "all"], + queryFn: async () => { + const params = owner ? { owner } : {}; + return (await api.get("/profiles", { params })).data.profiles; + }, + ...options, + }); +} + +export function useProfileUsage(id, enabled = true) { + return useQuery({ + queryKey: ["profiles", "usage", id], + queryFn: async () => + (await api.get(`/profiles/${encodeURIComponent(id)}/usage`)).data.usages, + enabled: Boolean(id) && enabled, + }); +} + +export function useUpsertProfile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (body) => (await api.put("/profiles", body)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), + }); +} + +export function useDeleteProfile() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, force }) => + ( + await api.delete(`/profiles/${encodeURIComponent(id)}`, { + params: force ? { force: "1" } : {}, + }) + ).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ["profiles"] }), + }); +} + export function useKvNamespaces() { return useQuery({ queryKey: ["kv", "namespaces"], diff --git a/packages/web/src/components/Layout.jsx b/packages/web/src/components/Layout.jsx index 45fd436..662d4df 100644 --- a/packages/web/src/components/Layout.jsx +++ b/packages/web/src/components/Layout.jsx @@ -9,6 +9,7 @@ import { LuGitBranch, LuHouse, LuKey, + LuLayers, LuLogOut, LuMenu, LuMoon, @@ -31,6 +32,7 @@ const navSections = [ items: [ { to: "/workflows", label: "Workflows", icon: LuGitBranch }, { to: "/scripts", label: "Scripts", icon: LuCode }, + { to: "/profiles", label: "Profiles", icon: LuLayers }, { to: "/events", label: "Events", icon: LuActivity }, ], }, diff --git a/packages/web/src/components/ProfileEditorModal.jsx b/packages/web/src/components/ProfileEditorModal.jsx new file mode 100644 index 0000000..84c40cf --- /dev/null +++ b/packages/web/src/components/ProfileEditorModal.jsx @@ -0,0 +1,201 @@ +import { useState } from "react"; +import { errorMessage } from "../api/client.js"; +import { useOwners, useScripts, useUpsertProfile } from "../api/hooks.js"; +import { ConfigFields } from "./workflow/ConfigFields.jsx"; + +/** + * @param {"add" | "edit"} mode + * @param {{ + * owner: string, + * name?: string, + * script?: string, + * config?: Record, + * description?: string, + * }} initial + * @param {number} [usageCount] + */ +export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount = 0 }) { + const { data: owners = [] } = useOwners(); + const { data: scripts = [] } = useScripts(); + const upsert = useUpsertProfile(); + const [form, setForm] = useState(() => ({ + owner: initial.owner || owners[0] || "default", + name: initial.name || "", + script: initial.script || "", + config: + initial.config && typeof initial.config === "object" && !Array.isArray(initial.config) + ? { ...initial.config } + : {}, + description: initial.description || "", + })); + const [formError, setFormError] = useState(null); + const [confirmScript, setConfirmScript] = useState(false); + + const scriptChanged = mode === "edit" && form.script !== (initial.script || ""); + const listed = scripts.find((s) => (typeof s === "string" ? s : s.name) === form.script); + const meta = listed && typeof listed === "object" ? listed.meta : null; + + function submit() { + setFormError(null); + if (!form.name.trim()) { + setFormError("name is required"); + return; + } + if (!form.script.trim()) { + setFormError("script is required"); + return; + } + upsert.mutate( + { + owner: form.owner, + name: form.name, + script: form.script, + config: form.config, + description: form.description, + }, + { + onSuccess: (data) => { + onSaved?.(data?.profile ?? data); + onClose(); + }, + }, + ); + } + + function onSubmit(e) { + e.preventDefault(); + if (scriptChanged && usageCount > 0 && !confirmScript) { + setConfirmScript(true); + return; + } + submit(); + } + + const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`; + + return ( + +
+

{title}

+
+ {mode === "add" ? ( + <> + + + + ) : ( +

+ {form.owner}/{form.name} + (name cannot be changed) +

+ )} + + {scriptChanged && usageCount > 0 ? ( +

+ Changing the script updates {usageCount} workflow + {usageCount === 1 ? "" : "s"} that use this profile + {confirmScript ? ". Save to apply." : "."} +

+ ) : null} + +
+

Config (defaults for every step that uses this profile)

+ setForm({ ...form, config })} + /> +
+ {formError ?

{formError}

: null} + {upsert.isError ? ( +

{errorMessage(upsert.error)}

+ ) : null} +
+ + +
+
+
+
+ +
+
+ ); +} diff --git a/packages/web/src/components/workflow/AddScriptDialog.jsx b/packages/web/src/components/workflow/AddScriptDialog.jsx index 8718f97..f91c31e 100644 --- a/packages/web/src/components/workflow/AddScriptDialog.jsx +++ b/packages/web/src/components/workflow/AddScriptDialog.jsx @@ -1,13 +1,18 @@ import { useMemo, useState } from "react"; -import { useScripts } from "../../api/hooks.js"; +import { useProfiles, useScripts } from "../../api/hooks.js"; import { ScriptIcon } from "../ScriptIcon.jsx"; import { scriptTags } from "../../lib/script.js"; -export function AddScriptDialog({ open, onClose, onPick }) { +export function AddScriptDialog({ open, onClose, onPick, owner }) { const { data: scripts = [], isLoading } = useScripts(); + const { data: profiles = [], isLoading: profilesLoading } = useProfiles( + owner || undefined, + { enabled: open && Boolean(owner) }, + ); const [q, setQ] = useState(""); + const [tab, setTab] = useState("scripts"); - const filtered = useMemo(() => { + const filteredScripts = useMemo(() => { const term = q.trim().toLowerCase(); const list = scripts.map((s) => ({ name: typeof s === "string" ? s : s.name, @@ -26,60 +31,128 @@ export function AddScriptDialog({ open, onClose, onPick }) { ); }, [scripts, q]); + const filteredProfiles = useMemo(() => { + const term = q.trim().toLowerCase(); + if (!term) return profiles; + return profiles.filter( + (p) => + p.name.toLowerCase().includes(term) || + String(p.script ?? "").toLowerCase().includes(term) || + String(p.description ?? "").toLowerCase().includes(term), + ); + }, [profiles, q]); + if (!open) return null; return (
-

Add script

+

Add step

+
+ + +
setQ(e.target.value)} autoFocus /> -
    -
  • - -
  • - {isLoading ? ( -
  • - + {tab === "scripts" ? ( +
      +
    • +
    • - ) : ( - filtered.map((s) => ( -
    • - + {isLoading ? ( +
    • +
    • - )) - )} -
    + ) : ( + filteredScripts.map((s) => ( +
  • + +
  • + )) + )} +
+ ) : ( +
    + {!owner ? ( +
  • Save the workflow with an owner first.
  • + ) : profilesLoading ? ( +
  • + +
  • + ) : filteredProfiles.length === 0 ? ( +
  • No profiles for this owner.
  • + ) : ( + filteredProfiles.map((p) => ( +
  • + +
  • + )) + )} +
+ )}
+ ) : null} + + setField(key, v)} + spec={extra ? { type: displayValue != null && typeof displayValue === "object" ? "object" : "string" } : spec} + script={script} + fieldKey={key} + workflows={workflows} + owner={owner} + excludeFile={excludeFile} + disabled={disabled} + /> + +
+ ); + } + return (
- {metaKeys.map((key) => { - const spec = fieldSpec(meta, key); - return ( -
- - setField(key, v)} - spec={spec} - script={script} - fieldKey={key} - workflows={workflows} - owner={owner} - excludeFile={excludeFile} - disabled={disabled} - /> - -
- ); - })} + {metaKeys.map((key) => renderField(key))} {extraKeys.map((key) => (
@@ -552,16 +601,18 @@ export function ConfigFields({ value={key} onCommit={(nk) => renameExtra(key, nk)} className="font-mono text-sm" + disabled={disabled} /> + {inherited ? overridden : null} + {metaKeys.length > 0 ? ( + not in schema + ) : null} @@ -580,6 +631,7 @@ export function ConfigFields({
))} + {inheritedExtraKeys.map((key) => renderField(key, { extra: true, inheritedOnly: true }))} - {step.kind === "script" && step.script ? ( - + {step.kind === "script" && scriptName ? ( + ) : null}
+ {expanded && step.kind === "script" && missingProfile ? ( +

+ Profile {step.profile} not found for owner {owner || "?"} +

+ ) : null} {expanded && step.kind === "script" && meta?.description ? (

{meta.description}

) : null} @@ -104,10 +133,10 @@ export function ScriptCard({ ) : null} {!expanded ? ( - + ) : null}
- {step.kind === "script" && step.script ? ( + {step.kind === "script" && scriptName ? (
{steps.length === 0 ? ( @@ -88,6 +93,7 @@ export function ScriptsTab({ index={index} otherSteps={steps} scriptsByName={scriptsByName} + profilesByName={profilesByName} disabled={disabled} workflows={workflows} owner={owner} @@ -106,10 +112,13 @@ export function ScriptsTab({ )} setAddOpen(false)} onPick={(picked) => { if (picked.kind === "set") { patchSteps([...steps, newSetStep()]); + } else if (picked.kind === "profile") { + patchSteps([...steps, newProfileStep(picked.name, picked.script)]); } else { patchSteps([ ...steps, diff --git a/packages/web/src/components/workflow/WorkflowVisualEditor.jsx b/packages/web/src/components/workflow/WorkflowVisualEditor.jsx index b11112f..8ffd6e0 100644 --- a/packages/web/src/components/workflow/WorkflowVisualEditor.jsx +++ b/packages/web/src/components/workflow/WorkflowVisualEditor.jsx @@ -1,6 +1,6 @@ import { useMemo, useState } from "react"; import { parse as parseYaml } from "yaml"; -import { useHttpAuths, useHttpPages, useScripts, useWorkflows } from "../../api/hooks.js"; +import { useHttpAuths, useHttpPages, useProfiles, useScripts, useWorkflows } from "../../api/hooks.js"; import { dataFromInputMeta, firstInputMeta } from "../../lib/script.js"; import { workflowToFlowchart } from "../../lib/workflow-mermaid.js"; import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js"; @@ -49,6 +49,7 @@ export function WorkflowVisualEditor({ const visualDisabled = !displayDoc; const { data: scripts = [] } = useScripts(); + const { data: profiles = [] } = useProfiles(owner || undefined, { enabled: Boolean(owner) }); const { data: workflows = [] } = useWorkflows(owner || undefined); const { data: auths = [] } = useHttpAuths(); const { data: allPages = [] } = useHttpPages(); @@ -90,9 +91,17 @@ export function WorkflowVisualEditor({ return map; }, [scripts]); + const profilesByName = useMemo(() => { + const map = new Map(); + for (const p of profiles) { + if (p?.name) map.set(p.name, p); + } + return map; + }, [profiles]); + const inputMeta = useMemo( - () => firstInputMeta(displayDoc?.scripts, scriptsByName), - [displayDoc?.scripts, scriptsByName], + () => firstInputMeta(displayDoc?.scripts, scriptsByName, profilesByName), + [displayDoc?.scripts, scriptsByName, profilesByName], ); const defaultData = useMemo(() => { @@ -175,6 +184,7 @@ export function WorkflowVisualEditor({ onPatch={patchDoc} disabled={visualDisabled} scripts={scripts} + profiles={profiles} workflows={workflows} owner={owner} excludeFile={file} diff --git a/packages/web/src/lib/profile.js b/packages/web/src/lib/profile.js new file mode 100644 index 0000000..28a1bb9 --- /dev/null +++ b/packages/web/src/lib/profile.js @@ -0,0 +1,16 @@ +export function mergeProfileConfig(base, overlay) { + const a = base && typeof base === "object" && !Array.isArray(base) ? { ...base } : {}; + if (overlay == null || typeof overlay !== "object" || Array.isArray(overlay)) { + return a; + } + return { ...a, ...overlay }; +} + +export function configHasOverlay(config) { + return ( + config != null && + typeof config === "object" && + !Array.isArray(config) && + Object.keys(config).length > 0 + ); +} diff --git a/packages/web/src/lib/script.js b/packages/web/src/lib/script.js index 8f9ce03..b1731e4 100644 --- a/packages/web/src/lib/script.js +++ b/packages/web/src/lib/script.js @@ -121,11 +121,14 @@ export function inputHasFields(meta) { } /** First script step with a non-empty `meta.input`; else first script meta. */ -export function firstInputMeta(steps, scriptsByName) { +export function firstInputMeta(steps, scriptsByName, profilesByName) { let fallback = null; for (const step of steps ?? []) { if (step?.kind === "set") continue; - const name = typeof step === "string" ? step : step?.script; + let name = typeof step === "string" ? step : step?.script; + if (!name && step?.profile && profilesByName) { + name = profilesByName.get(step.profile)?.script; + } if (!name) continue; const listed = scriptsByName?.get(name); const meta = listed && typeof listed === "object" ? listed.meta ?? null : null; diff --git a/packages/web/src/lib/workflow-doc.js b/packages/web/src/lib/workflow-doc.js index 6f9e9b9..fb3c5a0 100644 --- a/packages/web/src/lib/workflow-doc.js +++ b/packages/web/src/lib/workflow-doc.js @@ -113,6 +113,7 @@ export function newScriptStep(script, config = {}) { uiId: nextUiId("step"), kind: "script", script, + profile: "", config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {}, id: "", when: "", @@ -120,6 +121,19 @@ export function newScriptStep(script, config = {}) { }; } +export function newProfileStep(profileName, script = "") { + return { + uiId: nextUiId("step"), + kind: "script", + script, + profile: profileName, + config: {}, + id: "", + when: "", + needs: null, + }; +} + export function newSetStep() { return { uiId: nextUiId("step"), @@ -202,6 +216,7 @@ function normalizeStep(step) { uiId, kind: "script", script: step, + profile: "", config: {}, id: "", when: "", @@ -213,6 +228,7 @@ function normalizeStep(step) { uiId, kind: "script", script: "", + profile: "", config: {}, id: "", when: "", @@ -231,7 +247,7 @@ function normalizeStep(step) { needs: step.needs ?? null, }; } - const known = new Set(["script", "config", "id", "when", "needs", "set"]); + const known = new Set(["script", "profile", "config", "id", "when", "needs", "set"]); /** @type {Record} */ const extra = {}; for (const [key, value] of Object.entries(step)) { @@ -245,6 +261,7 @@ function normalizeStep(step) { uiId, kind: "script", script: typeof step.script === "string" ? step.script : "", + profile: typeof step.profile === "string" ? step.profile : "", config, id: typeof step.id === "string" ? step.id : "", when: typeof step.when === "string" ? step.when : "", @@ -313,7 +330,11 @@ function dumpStep(step) { /** @type {Record} */ const out = {}; if (step.id) out.id = step.id; - out.script = step.script ?? ""; + if (step.profile) { + out.profile = step.profile; + } else { + out.script = step.script ?? ""; + } const config = step.config; if (config && typeof config === "object" && !Array.isArray(config) && Object.keys(config).length) { out.config = config; diff --git a/packages/web/src/lib/workflow-mermaid.js b/packages/web/src/lib/workflow-mermaid.js index 740fcc0..9fa9edf 100644 --- a/packages/web/src/lib/workflow-mermaid.js +++ b/packages/web/src/lib/workflow-mermaid.js @@ -1,20 +1,22 @@ function parseStep(step) { if (typeof step === "string") { - return { kind: "script", script: step, id: null, needs: null, when: null }; + return { kind: "script", script: step, profile: null, id: null, needs: null, when: null }; } if (step?.set) { return { kind: "set", script: null, + profile: null, id: typeof step.id === "string" && step.id ? step.id : null, needs: step.needs ?? null, when: typeof step.when === "string" && step.when ? step.when : null, }; } - if (step?.script) { + if (step?.script || step?.profile) { return { kind: "script", - script: step.script, + script: typeof step.script === "string" && step.script ? step.script : null, + profile: typeof step.profile === "string" && step.profile ? step.profile : null, id: typeof step.id === "string" && step.id ? step.id : null, needs: step.needs ?? null, when: typeof step.when === "string" && step.when ? step.when : null, @@ -55,7 +57,8 @@ function stepLabel(s) { const base = s.id ? `${s.id}: set` : "set"; return s.when ? `${base} when: ${s.when}` : base; } - const base = s.id ? `${s.id}: ${s.script}` : s.script; + const target = s.profile ? `profile ${s.profile}` : s.script; + const base = s.id ? `${s.id}: ${target}` : target; return s.when ? `${base} when: ${s.when}` : base; } @@ -112,7 +115,7 @@ export function workflowToFlowchart(parsed) { if (s.kind === "set") { lines.push(` ${s.mermaidId}(["${label}"])`); } else { - scriptIds[s.mermaidId] = s.script; + scriptIds[s.mermaidId] = s.script || (s.profile ? `profile:${s.profile}` : ""); lines.push(` ${s.mermaidId}["${label}"]`); } } diff --git a/packages/web/src/pages/ProfilesPage.jsx b/packages/web/src/pages/ProfilesPage.jsx new file mode 100644 index 0000000..01b1d00 --- /dev/null +++ b/packages/web/src/pages/ProfilesPage.jsx @@ -0,0 +1,346 @@ +import { useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate, useParams, useSearchParams } from "react-router-dom"; +import { LuPencil, LuPlus, LuTrash2 } from "react-icons/lu"; +import { errorMessage } from "../api/client.js"; +import { useDeleteProfile, useOwners, useProfileUsage, useProfiles } from "../api/hooks.js"; +import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx"; +import { ScriptIcon } from "../components/ScriptIcon.jsx"; +import { formatTime } from "../lib/format.jsx"; + +function previewConfig(config) { + if (!config || typeof config !== "object") return ""; + if (typeof config.url === "string" && config.url) return config.url; + const first = Object.entries(config).find( + ([, v]) => v != null && (typeof v === "string" || typeof v === "number" || typeof v === "boolean"), + ); + if (!first) return ""; + return `${first[0]}=${String(first[1])}`; +} + +function formatUsage(usages) { + const names = (usages ?? []).map((u) => u.name); + if (names.length === 0) return ""; + if (names.length === 1) return `${names[0]} uses this.`; + if (names.length === 2) return `${names[0]} and ${names[1]} use this.`; + const rest = names.length - 2; + return `${names[0]}, ${names[1]}, and ${rest} other workflow${rest === 1 ? "" : "s"} use this.`; +} + +export function ProfilesPage() { + const navigate = useNavigate(); + const location = useLocation(); + const { owner: routeOwner, name: routeName } = useParams(); + const [params] = useSearchParams(); + const isNewRoute = /\/profiles\/new\/?$/.test(location.pathname); + const isEditRoute = Boolean(routeOwner && routeName); + + const { data: owners = [] } = useOwners(); + const [ownerFilter, setOwnerFilter] = useState( + () => routeOwner || params.get("owner") || "", + ); + const { data: profiles = [], isLoading } = useProfiles(ownerFilter || undefined); + const del = useDeleteProfile(); + const [editor, setEditor] = useState(null); + const [confirmDelete, setConfirmDelete] = useState(null); + const [highlightName, setHighlightName] = useState(() => routeName || ""); + const highlightRef = useRef(null); + const openedRouteKey = useRef(null); + + const listPath = ownerFilter + ? `/profiles?owner=${encodeURIComponent(ownerFilter)}` + : "/profiles"; + + function closeEditor() { + setEditor(null); + openedRouteKey.current = null; + if (isNewRoute || isEditRoute) { + navigate(listPath, { replace: true }); + } + } + + useEffect(() => { + if (!isNewRoute) return; + const key = `new:${params.get("owner") || ""}:${params.get("name") || ""}`; + if (openedRouteKey.current === key) return; + if (!params.get("owner") && !ownerFilter && owners.length === 0) return; + const owner = params.get("owner") || ownerFilter || owners[0] || "default"; + if (params.get("owner")) setOwnerFilter(params.get("owner")); + openedRouteKey.current = key; + setEditor({ + mode: "add", + initial: { + owner, + name: params.get("name") || "", + script: params.get("script") || "", + config: {}, + description: "", + }, + }); + }, [isNewRoute, params, owners, ownerFilter]); + + useEffect(() => { + if (!isEditRoute) { + if (!isNewRoute) openedRouteKey.current = null; + return; + } + if (ownerFilter !== routeOwner) { + setOwnerFilter(routeOwner); + return; + } + if (isLoading) return; + const key = `edit:${routeOwner}/${routeName}`; + if (openedRouteKey.current === key) return; + openedRouteKey.current = key; + setHighlightName(routeName); + const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName); + if (row) { + setEditor({ + mode: "edit", + initial: { + owner: row.owner, + name: row.name, + script: row.script, + config: row.config, + description: row.description, + }, + usageCount: row.usageCount ?? 0, + }); + return; + } + setEditor({ + mode: "add", + initial: { + owner: routeOwner, + name: routeName, + script: "", + config: {}, + description: "", + }, + }); + }, [isEditRoute, isNewRoute, routeOwner, routeName, ownerFilter, isLoading, profiles]); + + useEffect(() => { + if (!highlightName || isLoading) return; + highlightRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth" }); + }, [highlightName, isLoading, profiles]); + + return ( +
+
+

Profiles

+
+ + +
+
+ +

+ A profile is saved config for one script. Workflow steps can use it as initial config; + local keys override. Changing a profile updates every workflow that references it. +

+ + {isLoading ? ( + + ) : profiles.length === 0 ? ( +

No profiles yet.

+ ) : ( +
+ {profiles.map((row) => { + const highlighted = + highlightName && + row.name === highlightName && + (!ownerFilter || row.owner === ownerFilter); + const preview = previewConfig(row.config); + return ( +
+
+
+ +
+

+ {row.name} +

+

{row.owner}

+
+
+

+ {row.script} +

+ {row.description ? ( +

{row.description}

+ ) : null} + {preview ? ( +

+ {preview} +

+ ) : null} +

+ {row.usageCount + ? `${row.usageCount} workflow${row.usageCount === 1 ? "" : "s"}` + : "unused"} + {row.updated_at ? ` · ${formatTime(row.updated_at)}` : ""} +

+
+ + +
+
+
+ ); + })} +
+ )} + + {editor ? ( + { + setHighlightName(saved?.name || editor.initial.name); + }} + /> + ) : null} + + {confirmDelete ? ( + { + del.reset(); + setConfirmDelete(null); + }} + /> + ) : null} +
+ ); +} + +function DeleteProfileDialog({ profile, del, onClose }) { + const [force, setForce] = useState(false); + const usage = useProfileUsage(profile.id, true); + const usages = usage.data ?? []; + const used = usages.length > 0; + + return ( + +
+

+ Delete {profile.owner}/{profile.name}? +

+ {usage.isLoading ? ( +

Checking workflow usage…

+ ) : used ? ( + <> +

{formatUsage(usages)}

+
    + {usages.map((u) => ( +
  • + {u.name} + {u.file !== u.name ? ` (${u.file})` : ""} +
  • + ))} +
+ {force ? ( +

+ Workflows that still reference this profile will fail until you fix them. +

+ ) : null} + + ) : ( +

This cannot be undone.

+ )} + {del.isError ? ( +

{errorMessage(del.error)}

+ ) : null} +
+ + {used && !force ? ( + + ) : ( + + )} +
+
+
+ +
+
+ ); +} From 9985ff344033b7b6c6de5c9f4dacf5d1905ec1f0 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Fri, 21 Aug 2026 21:48:24 +0700 Subject: [PATCH 03/16] chore(deps): update pnpm version and clean up package.json; remove unused dependencies and scripts - Updated package manager to pnpm@11.22.0 in package.json. - Removed unused dependencies and scripts from package.json and pnpm-lock.yaml. - Cleaned up fs-store.js and other files by removing commented-out code and unused imports. - Updated workflow configurations to use environment variables for URLs. --- package.json | 11 +------ packages/server/app-version.js | 3 -- packages/server/fs-store.js | 31 ------------------- packages/server/package.json | 1 + packages/server/plugin-install.js | 3 -- packages/server/src/api/scripts.js | 3 -- .../default/comic-monkeyuser-to-ntfy.yaml | 2 +- .../default/time-to-ntfy-example.yaml | 4 +-- packages/web/src/api/hooks.js | 9 ------ packages/web/src/lib/script.js | 6 ---- packages/web/src/lib/workflow-doc.js | 29 ----------------- pnpm-lock.yaml | 9 +++--- pnpm-workspace.yaml | 4 +++ 13 files changed, 12 insertions(+), 103 deletions(-) diff --git a/package.json b/package.json index 8cc5674..b0ee1b8 100644 --- a/package.json +++ b/package.json @@ -16,14 +16,5 @@ "start:control": "pnpm --filter @jerapah-flow/server start:control", "migrate": "pnpm --filter @jerapah-flow/server migrate" }, - "packageManager": "pnpm@10.25.0", - "pnpm": { - "onlyBuiltDependencies": [ - "better-sqlite3", - "esbuild" - ] - }, - "dependencies": { - "rss-parser": "^3.13.0" - } + "packageManager": "pnpm@11.22.0" } diff --git a/packages/server/app-version.js b/packages/server/app-version.js index c81629f..ae46a37 100644 --- a/packages/server/app-version.js +++ b/packages/server/app-version.js @@ -1,6 +1,5 @@ import fs from "fs"; import path from "path"; -import { fileURLToPath } from "url"; import { SERVER_ROOT } from "./paths.js"; const ROOT_PKG = path.resolve(SERVER_ROOT, "../../package.json"); @@ -75,5 +74,3 @@ export function satisfiesRange(version, range) { } return true; } - -void fileURLToPath; diff --git a/packages/server/fs-store.js b/packages/server/fs-store.js index 2f2c842..72a419b 100644 --- a/packages/server/fs-store.js +++ b/packages/server/fs-store.js @@ -49,12 +49,6 @@ export function readScript(name) { return fs.readFileSync(filePath, "utf8"); } -export function writeScript(name, content) { - assertScriptName(name); - fs.mkdirSync(SCRIPTS_DIR, { recursive: true }); - fs.writeFileSync(path.join(SCRIPTS_DIR, name), content, "utf8"); -} - /** * Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`. * @returns {{ filePath: string, contentType: string } | null} @@ -78,22 +72,6 @@ export function scriptHasIcon(name) { return resolveScriptIcon(name) != null; } -export function deleteScript(name) { - assertScriptName(name); - const filePath = path.join(SCRIPTS_DIR, name); - if (!fs.existsSync(filePath)) return false; - const icon = resolveScriptIcon(name); - fs.unlinkSync(filePath); - if (icon) { - try { - fs.unlinkSync(icon.filePath); - } catch { - // ignore missing icon - } - } - return true; -} - export function listOwners() { if (!fs.existsSync(WORKFLOWS_DIR)) return []; return fs @@ -140,15 +118,6 @@ export function writeWorkflowYaml(owner, file, content) { fs.writeFileSync(path.join(ownerDir, file), content, "utf8"); } -export function deleteWorkflowYaml(owner, file) { - assertOwner(owner); - assertWorkflowFile(file); - const filePath = path.join(WORKFLOWS_DIR, owner, file); - if (!fs.existsSync(filePath)) return false; - fs.unlinkSync(filePath); - return true; -} - export function listOwnerYamlFiles(owner) { const ownerDir = path.join(WORKFLOWS_DIR, owner); if (!fs.existsSync(ownerDir)) return []; diff --git a/packages/server/package.json b/packages/server/package.json index 45af17b..289b7c2 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -39,6 +39,7 @@ "pino": "^10.3.1", "pino-roll": "^4.0.0", "pm2": "^6.0.13", + "rss-parser": "^3.13.0", "ssh2-sftp-client": "^12.1.1", "webdav": "^5.10.0", "yaml": "^2.9.0" diff --git a/packages/server/plugin-install.js b/packages/server/plugin-install.js index 06c1f05..2a49484 100644 --- a/packages/server/plugin-install.js +++ b/packages/server/plugin-install.js @@ -8,7 +8,6 @@ import { installPluginFromDirectory, pluginDir, } from "./plugin-store.js"; -import { readManifestFile } from "./plugin-manifest.js"; const execFileAsync = promisify(execFile); @@ -214,5 +213,3 @@ export function ensureExampleInstalled(exampleId) { }); return { ...installed, already: false }; } - -void readManifestFile; diff --git a/packages/server/src/api/scripts.js b/packages/server/src/api/scripts.js index 8858255..af1e017 100644 --- a/packages/server/src/api/scripts.js +++ b/packages/server/src/api/scripts.js @@ -8,7 +8,6 @@ import { import * as fsStore from "../../fs-store.js"; import { forkCoreScript, - getInstalledPlugin, listCoreScriptNames, listInstalledPlugins, resolveScriptRef, @@ -475,7 +474,5 @@ export default function scriptsPluginFactory(registry) { } }, ); - - void getInstalledPlugin; }; } diff --git a/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml index 8e8690b..a5aebbd 100644 --- a/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml +++ b/packages/server/workflows/default/comic-monkeyuser-to-ntfy.yaml @@ -18,7 +18,7 @@ scripts: - script: fetch-binary.js - script: ntfy.js config: - url: https://ntfy.sh/jerapah-flow + url: $VAR_ntfy_channel triggers: - type: HTTP method: POST diff --git a/packages/server/workflows/default/time-to-ntfy-example.yaml b/packages/server/workflows/default/time-to-ntfy-example.yaml index 67dcdf3..148ecad 100644 --- a/packages/server/workflows/default/time-to-ntfy-example.yaml +++ b/packages/server/workflows/default/time-to-ntfy-example.yaml @@ -6,9 +6,7 @@ scripts: - script: plugin/get-current-time config: key: "" - - script: ntfy.js - config: - url: https://n.0dev.web.id/system + - profile: ntfy_default triggers: - type: HTTP method: POST diff --git a/packages/web/src/api/hooks.js b/packages/web/src/api/hooks.js index 2324d0d..cce8f82 100644 --- a/packages/web/src/api/hooks.js +++ b/packages/web/src/api/hooks.js @@ -625,15 +625,6 @@ export function useOpsProcessRestart() { }); } -export function useOpsBumpGeneration() { - const qc = useQueryClient(); - return useMutation({ - mutationFn: async (reason) => - (await opsApi.post("/generation/bump", { reason })).data, - onSuccess: () => qc.invalidateQueries({ queryKey: ["ops-status"] }), - }); -} - export function useWorkflowTrash() { return useQuery({ queryKey: ["workflows", "trash"], diff --git a/packages/web/src/lib/script.js b/packages/web/src/lib/script.js index b1731e4..34fd33e 100644 --- a/packages/web/src/lib/script.js +++ b/packages/web/src/lib/script.js @@ -27,12 +27,6 @@ export const DEFAULT_INPUT_CONTEXT = `{ } `; -export function normalizeScriptName(name) { - const trimmed = name.trim(); - if (!trimmed) return ""; - return trimmed.endsWith(".js") ? trimmed : `${trimmed}.js`; -} - export function scriptTags(meta) { if (!Array.isArray(meta?.tags)) return []; return meta.tags.map((t) => String(t).trim()).filter(Boolean); diff --git a/packages/web/src/lib/workflow-doc.js b/packages/web/src/lib/workflow-doc.js index fb3c5a0..300f4f4 100644 --- a/packages/web/src/lib/workflow-doc.js +++ b/packages/web/src/lib/workflow-doc.js @@ -12,35 +12,6 @@ triggers: path: /new `; -export function ensureWorkflowFilename(file) { - const trimmed = String(file ?? "").trim(); - if (!trimmed) return ""; - return /\.ya?ml$/i.test(trimmed) ? trimmed : `${trimmed}.yaml`; -} - -/** - * Next unused copy filename: `track.yaml` → `track-copy.yaml`, - * `track-copy.yaml` → `track-copy-2.yaml`. - * @param {string} file - * @param {string[]} existingFiles - */ -export function suggestCopyFilename(file, existingFiles = []) { - const name = ensureWorkflowFilename(file) || "workflow.yaml"; - const match = name.match(/^(.*?)(\.ya?ml)$/i); - const base = match ? match[1] : name; - const ext = match ? match[2] : ".yaml"; - const existing = new Set(existingFiles); - - const copyMatch = base.match(/^(.*)-copy(?:-(\d+))?$/); - const root = copyMatch ? copyMatch[1] : base; - const candidate = (i) => - i <= 1 ? `${root}-copy${ext}` : `${root}-copy-${i}${ext}`; - - let n = copyMatch ? Number(copyMatch[2] || 1) + 1 : 1; - while (existing.has(candidate(n))) n += 1; - return candidate(n); -} - let uidSeq = 0; export function nextUiId(prefix = "ui") { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4755284..b7d815a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6,11 +6,7 @@ settings: importers: - .: - dependencies: - rss-parser: - specifier: ^3.13.0 - version: 3.13.0 + .: {} packages/server: dependencies: @@ -80,6 +76,9 @@ importers: pm2: specifier: ^6.0.13 version: 6.0.14 + rss-parser: + specifier: ^3.13.0 + version: 3.13.0 ssh2-sftp-client: specifier: ^12.1.1 version: 12.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 101f05b..ba78ceb 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,4 +2,8 @@ packages: - packages/* allowBuilds: better-sqlite3: true + cpu-features: true esbuild: true + msgpackr-extract: true + ssh2: true + rss-parser: true From 3d25d8a614223042aac2d17306bee1ae67669933 Mon Sep 17 00:00:00 2001 From: Nasyarobby Putra Date: Fri, 21 Aug 2026 22:08:23 +0700 Subject: [PATCH 04/16] refactor(web): shared ConfirmDialog, FormControls, and Modal a11y Extract reusable confirm/modal shells and bordered form controls, adopt them across list pages and editors, and improve icon-button labels plus KvPage keyboard rows. Co-authored-by: Cursor --- .../web/src/components/AuthEditorModal.jsx | 47 +++--- packages/web/src/components/ConfirmDialog.jsx | 55 +++++++ packages/web/src/components/FormControls.jsx | 27 ++++ packages/web/src/components/Modal.jsx | 85 ++++++++++ .../web/src/components/ProfileEditorModal.jsx | 25 +-- .../web/src/components/SecretEditorModal.jsx | 149 +++++++++--------- .../web/src/components/UserEditorModal.jsx | 15 +- .../src/components/VariableEditorModal.jsx | 35 ++-- .../components/workflow/AddScriptDialog.jsx | 5 +- .../src/components/workflow/ConfigFields.jsx | 33 ++-- .../src/components/workflow/ScriptCard.jsx | 28 ++-- .../src/components/workflow/TriggerCard.jsx | 44 +++--- .../workflow/WorkflowHistoryPanel.jsx | 4 +- .../workflow/WorkflowRevisionBanner.jsx | 44 +----- .../workflow/WorkflowVisualEditor.jsx | 9 +- packages/web/src/pages/AuthProfilesPage.jsx | 49 ++---- packages/web/src/pages/BackupPage.jsx | 50 ++---- packages/web/src/pages/KvPage.jsx | 16 +- packages/web/src/pages/ProfilesPage.jsx | 117 +++++++------- packages/web/src/pages/ResponsesPage.jsx | 48 ++---- packages/web/src/pages/ScriptsPage.jsx | 55 +++---- packages/web/src/pages/SecretsPage.jsx | 55 ++----- packages/web/src/pages/UsersPage.jsx | 43 ++--- packages/web/src/pages/VariablesPage.jsx | 59 +++---- packages/web/src/pages/WorkflowEditPage.jsx | 7 +- packages/web/src/pages/WorkflowTrashPage.jsx | 63 +++----- packages/web/src/pages/WorkflowsPage.jsx | 65 ++++---- 27 files changed, 606 insertions(+), 626 deletions(-) create mode 100644 packages/web/src/components/ConfirmDialog.jsx create mode 100644 packages/web/src/components/FormControls.jsx create mode 100644 packages/web/src/components/Modal.jsx diff --git a/packages/web/src/components/AuthEditorModal.jsx b/packages/web/src/components/AuthEditorModal.jsx index 8b01e7d..67977c0 100644 --- a/packages/web/src/components/AuthEditorModal.jsx +++ b/packages/web/src/components/AuthEditorModal.jsx @@ -6,6 +6,7 @@ import { useHttpPages, useUpsertHttpAuth, } from "../api/hooks.js"; +import { FormInput, FormSelect } from "./FormControls.jsx"; function emptyCred(source = "literal") { return { source, value: "", kv: "", namespace: "", secret: "" }; @@ -120,15 +121,15 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) {

{label}

- + {cred.source === "literal" ? (
- onChange({ ...cred, value: e.target.value, keep: false })} placeholder={ @@ -168,16 +169,16 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) { {cred.source === "kv" ? ( <> - onChange({ ...cred, namespace: e.target.value })} /> - onChange({ ...cred, kv: e.target.value })} @@ -191,8 +192,8 @@ function CredentialFields({ label, cred, onChange, allowEmpty, masked }) { label="Secret name" hint="Encrypted secret — value is never shown here. Manage it on the Secrets page." > - onChange({ ...cred, secret: e.target.value })} @@ -297,8 +298,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { ) : (
- setForm({ ...form, name: e.target.value })} required @@ -308,15 +309,15 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { - + {form.type === "bearer" ? ( @@ -345,8 +346,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { {form.type === "header" ? ( <> - setForm({ ...form, header: e.target.value })} required @@ -362,9 +363,9 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { ) : null} - setForm({ ...form, unauthorized_status: e.target.value })} min={100} @@ -374,8 +375,8 @@ export function AuthEditorModal({ mode, auth, onClose, onSaved }) { - + {upsert.isError ? ( diff --git a/packages/web/src/components/ConfirmDialog.jsx b/packages/web/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..6a24543 --- /dev/null +++ b/packages/web/src/components/ConfirmDialog.jsx @@ -0,0 +1,55 @@ +import { Modal } from "./Modal.jsx"; + +/** + * Shared confirm / delete dialog. + * + * @param {object} props + * @param {boolean} props.open + * @param {string} props.title + * @param {import("react").ReactNode} [props.message] + * @param {string} [props.confirmLabel] + * @param {string} [props.confirmClass] + * @param {boolean} [props.loading] + * @param {boolean} [props.pending] alias for loading + * @param {boolean} [props.confirmDisabled] + * @param {import("react").ReactNode} [props.error] + * @param {() => void} props.onCancel + * @param {() => void} props.onConfirm + */ +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = "Delete", + confirmClass = "btn-error", + loading, + pending, + confirmDisabled = false, + error, + onCancel, + onConfirm, +}) { + const busy = Boolean(loading ?? pending); + + return ( + +

{title}

+ {message ?
{message}
: null} + {error ?

{error}

: null} +
+ + +
+
+ ); +} diff --git a/packages/web/src/components/FormControls.jsx b/packages/web/src/components/FormControls.jsx new file mode 100644 index 0000000..d971e8e --- /dev/null +++ b/packages/web/src/components/FormControls.jsx @@ -0,0 +1,27 @@ +/** + * Shared DaisyUI form controls — always bordered + sm. + */ + +import { forwardRef } from "react"; + +export const FormInput = forwardRef(function FormInput({ className = "", ...props }, ref) { + return ; +}); + +export const FormSelect = forwardRef(function FormSelect({ className = "", children, ...props }, ref) { + return ( + + ); +}); + +export const FormTextarea = forwardRef(function FormTextarea({ className = "", ...props }, ref) { + return ( +