From e84432a49237b128c8e7e495a753965045e6fdc4 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 21 Aug 2026 06:12:49 +0000 Subject: [PATCH] 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 ? ( + + ) : ( + + )} +
+
+
+ +
+
+ ); +}