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 <nasyarobby@gmail.com>
This commit is contained in:
@@ -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");
|
||||||
|
}
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"start:control": "node control.js",
|
"start:control": "node control.js",
|
||||||
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
"migrate": "node -e \"import('./db.js').then((m) => m.migrate().then(() => process.exit(0)))\"",
|
||||||
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
"test:plugins": "JFLOW_PLUGINS_DIR=./data/plugins-smoke-test JFLOW_DB_PATH=./data/plugins-smoke.db node test/plugins-smoke.js",
|
||||||
"test:workflow-history": "node test/workflow-history-smoke.js"
|
"test:workflow-history": "node test/workflow-history-smoke.js",
|
||||||
|
"test:profiles": "node test/profiles-smoke.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1111.0",
|
"@aws-sdk/client-s3": "^3.1111.0",
|
||||||
|
|||||||
@@ -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<string, unknown>}
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, unknown>}
|
||||||
|
*/
|
||||||
|
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<string, unknown>} 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<boolean>}
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
sendSuccessPage,
|
sendSuccessPage,
|
||||||
} from "./http-trigger-auth.js";
|
} from "./http-trigger-auth.js";
|
||||||
import { resolveConfigRefs } from "./config-refs.js";
|
import { resolveConfigRefs } from "./config-refs.js";
|
||||||
|
import { mergeProfileConfig } from "./profile-config.js";
|
||||||
|
import { getProfilePlain } from "./profiles-store.js";
|
||||||
import {
|
import {
|
||||||
buildFailureAlertData,
|
buildFailureAlertData,
|
||||||
resolveFailureTriggerConfig,
|
resolveFailureTriggerConfig,
|
||||||
@@ -593,8 +595,21 @@ export function createRegistry(server, opts = {}) {
|
|||||||
owner,
|
owner,
|
||||||
depth,
|
depth,
|
||||||
) {
|
) {
|
||||||
const script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
let script = parsed.kind === "set" ? SET_STEP_SCRIPT : parsed.script;
|
||||||
const unresolvedConfig = parsed.config;
|
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 incomingContext = normalizeContext(ctx.context);
|
||||||
const step = await store.startStep({
|
const step = await store.startStep({
|
||||||
runId,
|
runId,
|
||||||
@@ -904,7 +919,10 @@ export function createRegistry(server, opts = {}) {
|
|||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
const parsed = parseScriptStep(raw);
|
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 {
|
} catch {
|
||||||
// skip invalid steps
|
// skip invalid steps
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -26,6 +26,8 @@ import {
|
|||||||
collectWorkflowWarnings,
|
collectWorkflowWarnings,
|
||||||
parseWorkflowDocument,
|
parseWorkflowDocument,
|
||||||
} from "../../workflow-validate-warnings.js";
|
} from "../../workflow-validate-warnings.js";
|
||||||
|
import { getProfilePlain } from "../../profiles-store.js";
|
||||||
|
import { resolveScriptRef } from "../../plugin-store.js";
|
||||||
import {
|
import {
|
||||||
recordRevision,
|
recordRevision,
|
||||||
listRevisions,
|
listRevisions,
|
||||||
@@ -80,7 +82,9 @@ function scriptNames(workflow) {
|
|||||||
for (const raw of workflow.scripts ?? []) {
|
for (const raw of workflow.scripts ?? []) {
|
||||||
try {
|
try {
|
||||||
const parsed = parseScriptStep(raw);
|
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 {
|
} catch {
|
||||||
names.push(null);
|
names.push(null);
|
||||||
}
|
}
|
||||||
@@ -88,6 +92,59 @@ function scriptNames(workflow) {
|
|||||||
return names;
|
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
|
* @param {unknown} parsed
|
||||||
*/
|
*/
|
||||||
@@ -110,6 +167,9 @@ async function validateStrictWorkflow(parsed) {
|
|||||||
*/
|
*/
|
||||||
async function saveWorkflowContent(opts) {
|
async function saveWorkflowContent(opts) {
|
||||||
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
const { warnings, parsed, parseError } = collectWorkflowWarnings(opts.content);
|
||||||
|
if (parsed) {
|
||||||
|
warnings.push(...(await collectProfileWarnings(parsed, opts.owner)));
|
||||||
|
}
|
||||||
const saveAnyway = Boolean(opts.saveAnyway);
|
const saveAnyway = Boolean(opts.saveAnyway);
|
||||||
|
|
||||||
if (!saveAnyway) {
|
if (!saveAnyway) {
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import dashboardPluginFactory from "./src/api/dashboard.js";
|
|||||||
import secretsPlugin from "./src/api/secrets.js";
|
import secretsPlugin from "./src/api/secrets.js";
|
||||||
import kvPlugin from "./src/api/kv.js";
|
import kvPlugin from "./src/api/kv.js";
|
||||||
import variablesPlugin from "./src/api/variables.js";
|
import variablesPlugin from "./src/api/variables.js";
|
||||||
|
import profilesPlugin from "./src/api/profiles.js";
|
||||||
import httpPagesPlugin from "./src/api/http-pages.js";
|
import httpPagesPlugin from "./src/api/http-pages.js";
|
||||||
import httpAuthsPlugin from "./src/api/http-auths.js";
|
import httpAuthsPlugin from "./src/api/http-auths.js";
|
||||||
import { WEB_DIST } from "./paths.js";
|
import { WEB_DIST } from "./paths.js";
|
||||||
@@ -169,6 +170,7 @@ export async function startApp(opts = {}) {
|
|||||||
await api.register(usersPlugin);
|
await api.register(usersPlugin);
|
||||||
await api.register(secretsPlugin);
|
await api.register(secretsPlugin);
|
||||||
await api.register(variablesPlugin);
|
await api.register(variablesPlugin);
|
||||||
|
await api.register(profilesPlugin);
|
||||||
await api.register(kvPlugin);
|
await api.register(kvPlugin);
|
||||||
await api.register(httpPagesPlugin);
|
await api.register(httpPagesPlugin);
|
||||||
await api.register(httpAuthsPlugin);
|
await api.register(httpAuthsPlugin);
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -7,6 +7,7 @@ export const SET_STEP_SCRIPT = "set";
|
|||||||
* @typedef {{
|
* @typedef {{
|
||||||
* kind: "script",
|
* kind: "script",
|
||||||
* script: string,
|
* script: string,
|
||||||
|
* profile: string | null,
|
||||||
* config: unknown | null,
|
* config: unknown | null,
|
||||||
* expression?: undefined,
|
* expression?: undefined,
|
||||||
* id: string | null,
|
* id: string | null,
|
||||||
@@ -17,6 +18,7 @@ export const SET_STEP_SCRIPT = "set";
|
|||||||
* @typedef {{
|
* @typedef {{
|
||||||
* kind: "set",
|
* kind: "set",
|
||||||
* script: typeof SET_STEP_SCRIPT,
|
* script: typeof SET_STEP_SCRIPT,
|
||||||
|
* profile: null,
|
||||||
* config: { expression: string },
|
* config: { expression: string },
|
||||||
* expression: string,
|
* expression: string,
|
||||||
* id: string | null,
|
* id: string | null,
|
||||||
@@ -85,6 +87,7 @@ export function parseScriptStep(step) {
|
|||||||
return {
|
return {
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: step,
|
script: step,
|
||||||
|
profile: null,
|
||||||
config: null,
|
config: null,
|
||||||
id: null,
|
id: null,
|
||||||
needsKind: "none",
|
needsKind: "none",
|
||||||
@@ -97,24 +100,33 @@ export function parseScriptStep(step) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hasScript = step.script != null && step.script !== "";
|
const hasScript = step.script != null && step.script !== "";
|
||||||
|
const hasProfile = step.profile != null && step.profile !== "";
|
||||||
const hasSet = step.set != null;
|
const hasSet = step.set != null;
|
||||||
|
|
||||||
if (hasScript && hasSet) {
|
if (hasScript && hasSet) {
|
||||||
throw new Error("Step cannot have both script and set");
|
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) {
|
if (hasSet) {
|
||||||
return parseSetStep(step);
|
return parseSetStep(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasScript) {
|
if (hasProfile && typeof step.profile !== "string") {
|
||||||
if (typeof step.script !== "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)}`);
|
throw new Error(`Invalid script step: ${JSON.stringify(step)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (hasScript || hasProfile) {
|
||||||
const { needsKind, needs } = parseNeeds(step.needs);
|
const { needsKind, needs } = parseNeeds(step.needs);
|
||||||
return {
|
return {
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: step.script,
|
script: hasScript ? step.script : "",
|
||||||
|
profile: hasProfile ? step.profile : null,
|
||||||
config: step.config ?? null,
|
config: step.config ?? null,
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
needsKind,
|
needsKind,
|
||||||
@@ -265,6 +277,7 @@ function parseSetStep(step) {
|
|||||||
return {
|
return {
|
||||||
kind: "set",
|
kind: "set",
|
||||||
script: SET_STEP_SCRIPT,
|
script: SET_STEP_SCRIPT,
|
||||||
|
profile: null,
|
||||||
config: { expression },
|
config: { expression },
|
||||||
expression,
|
expression,
|
||||||
id: parseOptionalId(step.id),
|
id: parseOptionalId(step.id),
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ export function collectWorkflowWarnings(content) {
|
|||||||
try {
|
try {
|
||||||
const step = parseScriptStep(raw);
|
const step = parseScriptStep(raw);
|
||||||
if (step.kind === "set") continue;
|
if (step.kind === "set") continue;
|
||||||
|
if (step.profile && !step.script) continue;
|
||||||
const resolved = resolveScriptRef(step.script);
|
const resolved = resolveScriptRef(step.script);
|
||||||
if (resolved.error) {
|
if (resolved.error) {
|
||||||
warnings.push({
|
warnings.push({
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
|||||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||||
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
||||||
|
import { ProfilesPage } from "./pages/ProfilesPage.jsx";
|
||||||
import { OpsPage } from "./pages/OpsPage.jsx";
|
import { OpsPage } from "./pages/OpsPage.jsx";
|
||||||
import { BackupPage } from "./pages/BackupPage.jsx";
|
import { BackupPage } from "./pages/BackupPage.jsx";
|
||||||
|
|
||||||
@@ -71,6 +72,9 @@ export function App() {
|
|||||||
<Route path="/variables" element={<VariablesPage />} />
|
<Route path="/variables" element={<VariablesPage />} />
|
||||||
<Route path="/variables/new" element={<VariablesPage />} />
|
<Route path="/variables/new" element={<VariablesPage />} />
|
||||||
<Route path="/variables/:owner/:name/edit" element={<VariablesPage />} />
|
<Route path="/variables/:owner/:name/edit" element={<VariablesPage />} />
|
||||||
|
<Route path="/profiles" element={<ProfilesPage />} />
|
||||||
|
<Route path="/profiles/new" element={<ProfilesPage />} />
|
||||||
|
<Route path="/profiles/:owner/:name/edit" element={<ProfilesPage />} />
|
||||||
<Route path="/auth" element={<AuthProfilesPage />} />
|
<Route path="/auth" element={<AuthProfilesPage />} />
|
||||||
<Route path="/auth/new" element={<AuthProfilesPage />} />
|
<Route path="/auth/new" element={<AuthProfilesPage />} />
|
||||||
<Route path="/auth/:name/edit" element={<AuthProfilesPage />} />
|
<Route path="/auth/:name/edit" element={<AuthProfilesPage />} />
|
||||||
|
|||||||
@@ -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() {
|
export function useKvNamespaces() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["kv", "namespaces"],
|
queryKey: ["kv", "namespaces"],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LuGitBranch,
|
LuGitBranch,
|
||||||
LuHouse,
|
LuHouse,
|
||||||
LuKey,
|
LuKey,
|
||||||
|
LuLayers,
|
||||||
LuLogOut,
|
LuLogOut,
|
||||||
LuMenu,
|
LuMenu,
|
||||||
LuMoon,
|
LuMoon,
|
||||||
@@ -31,6 +32,7 @@ const navSections = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
|
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
|
||||||
{ to: "/scripts", label: "Scripts", icon: LuCode },
|
{ to: "/scripts", label: "Scripts", icon: LuCode },
|
||||||
|
{ to: "/profiles", label: "Profiles", icon: LuLayers },
|
||||||
{ to: "/events", label: "Events", icon: LuActivity },
|
{ to: "/events", label: "Events", icon: LuActivity },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<string, unknown>,
|
||||||
|
* 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 (
|
||||||
|
<dialog className="modal modal-open">
|
||||||
|
<div className="modal-box max-w-lg">
|
||||||
|
<h3 className="font-bold">{title}</h3>
|
||||||
|
<form className="mt-3 space-y-2" onSubmit={onSubmit}>
|
||||||
|
{mode === "add" ? (
|
||||||
|
<>
|
||||||
|
<label className="form-control w-full">
|
||||||
|
<span className="label py-0 text-sm">Owner</span>
|
||||||
|
{owners.length > 0 ? (
|
||||||
|
<select
|
||||||
|
className="select w-full"
|
||||||
|
value={form.owner}
|
||||||
|
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
{owners.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
value={form.owner}
|
||||||
|
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
<label className="form-control w-full">
|
||||||
|
<span className="label py-0 text-sm">Name</span>
|
||||||
|
<input
|
||||||
|
className="input w-full font-mono"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
required
|
||||||
|
pattern="[A-Za-z0-9._-]+"
|
||||||
|
title="Letters, numbers, dots, underscores, hyphens. Cannot be changed later."
|
||||||
|
/>
|
||||||
|
<span className="label-text-alt opacity-60">
|
||||||
|
YAML id. Locked after create so live workflow refs stay valid.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm opacity-70">
|
||||||
|
<span className="font-mono">{form.owner}/{form.name}</span>
|
||||||
|
<span className="ml-2 opacity-60">(name cannot be changed)</span>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<label className="form-control w-full">
|
||||||
|
<span className="label py-0 text-sm">Script</span>
|
||||||
|
<select
|
||||||
|
className="select w-full font-mono"
|
||||||
|
value={form.script}
|
||||||
|
onChange={(e) => {
|
||||||
|
setForm({ ...form, script: e.target.value });
|
||||||
|
setConfirmScript(false);
|
||||||
|
}}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select script</option>
|
||||||
|
{scripts.map((s) => {
|
||||||
|
const name = typeof s === "string" ? s : s.name;
|
||||||
|
return (
|
||||||
|
<option key={name} value={name}>
|
||||||
|
{name}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{form.script && !scripts.some((s) => (typeof s === "string" ? s : s.name) === form.script) ? (
|
||||||
|
<option value={form.script}>{form.script}</option>
|
||||||
|
) : null}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{scriptChanged && usageCount > 0 ? (
|
||||||
|
<p className="text-warning text-sm">
|
||||||
|
Changing the script updates {usageCount} workflow
|
||||||
|
{usageCount === 1 ? "" : "s"} that use this profile
|
||||||
|
{confirmScript ? ". Save to apply." : "."}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<label className="form-control w-full">
|
||||||
|
<span className="label py-0 text-sm">Description</span>
|
||||||
|
<input
|
||||||
|
className="input w-full"
|
||||||
|
value={form.description}
|
||||||
|
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm mb-1">Config (defaults for every step that uses this profile)</p>
|
||||||
|
<ConfigFields
|
||||||
|
script={form.script}
|
||||||
|
config={form.config}
|
||||||
|
meta={meta}
|
||||||
|
owner={form.owner}
|
||||||
|
onChange={(config) => setForm({ ...form, config })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{formError ? <p className="text-error text-sm">{formError}</p> : null}
|
||||||
|
{upsert.isError ? (
|
||||||
|
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="modal-action">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={upsert.isPending}>
|
||||||
|
{upsert.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
|
||||||
|
{scriptChanged && usageCount > 0 && !confirmScript ? "Confirm script change" : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useScripts } from "../../api/hooks.js";
|
import { useProfiles, useScripts } from "../../api/hooks.js";
|
||||||
import { ScriptIcon } from "../ScriptIcon.jsx";
|
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||||
import { scriptTags } from "../../lib/script.js";
|
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: scripts = [], isLoading } = useScripts();
|
||||||
|
const { data: profiles = [], isLoading: profilesLoading } = useProfiles(
|
||||||
|
owner || undefined,
|
||||||
|
{ enabled: open && Boolean(owner) },
|
||||||
|
);
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
|
const [tab, setTab] = useState("scripts");
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
const filteredScripts = useMemo(() => {
|
||||||
const term = q.trim().toLowerCase();
|
const term = q.trim().toLowerCase();
|
||||||
const list = scripts.map((s) => ({
|
const list = scripts.map((s) => ({
|
||||||
name: typeof s === "string" ? s : s.name,
|
name: typeof s === "string" ? s : s.name,
|
||||||
@@ -26,19 +31,49 @@ export function AddScriptDialog({ open, onClose, onPick }) {
|
|||||||
);
|
);
|
||||||
}, [scripts, q]);
|
}, [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;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<dialog className="modal modal-open">
|
<dialog className="modal modal-open">
|
||||||
<div className="modal-box max-w-lg">
|
<div className="modal-box max-w-lg">
|
||||||
<h3 className="font-bold text-lg">Add script</h3>
|
<h3 className="font-bold text-lg">Add step</h3>
|
||||||
|
<div role="tablist" className="tabs tabs-box mt-3 w-full">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${tab === "scripts" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => setTab("scripts")}
|
||||||
|
>
|
||||||
|
Scripts
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
className={`tab ${tab === "profiles" ? "tab-active" : ""}`}
|
||||||
|
onClick={() => setTab("profiles")}
|
||||||
|
>
|
||||||
|
Profiles
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
className="input input-sm w-full mt-3"
|
className="input input-sm w-full mt-3"
|
||||||
placeholder="Search scripts"
|
placeholder={tab === "profiles" ? "Search profiles" : "Search scripts"}
|
||||||
value={q}
|
value={q}
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
|
{tab === "scripts" ? (
|
||||||
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
|
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-base-300 p-0">
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
@@ -59,7 +94,7 @@ export function AddScriptDialog({ open, onClose, onPick }) {
|
|||||||
<span className="loading loading-spinner loading-sm" />
|
<span className="loading loading-spinner loading-sm" />
|
||||||
</li>
|
</li>
|
||||||
) : (
|
) : (
|
||||||
filtered.map((s) => (
|
filteredScripts.map((s) => (
|
||||||
<li key={s.name}>
|
<li key={s.name}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -80,6 +115,44 @@ export function AddScriptDialog({ open, onClose, onPick }) {
|
|||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
) : (
|
||||||
|
<ul className="menu mt-3 max-h-80 overflow-auto rounded-box border border-accent/40 p-0">
|
||||||
|
{!owner ? (
|
||||||
|
<li className="p-4 text-sm opacity-60">Save the workflow with an owner first.</li>
|
||||||
|
) : profilesLoading ? (
|
||||||
|
<li className="p-4">
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
</li>
|
||||||
|
) : filteredProfiles.length === 0 ? (
|
||||||
|
<li className="p-4 text-sm opacity-60">No profiles for this owner.</li>
|
||||||
|
) : (
|
||||||
|
filteredProfiles.map((p) => (
|
||||||
|
<li key={p.id}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
onPick({
|
||||||
|
kind: "profile",
|
||||||
|
name: p.name,
|
||||||
|
script: p.script,
|
||||||
|
});
|
||||||
|
setQ("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ScriptIcon name={p.script} className="size-8 shrink-0" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="font-mono">{p.name}</span>
|
||||||
|
<span className="text-xs opacity-70 line-clamp-2 block">
|
||||||
|
{p.script}
|
||||||
|
{p.description ? ` · ${p.description}` : ""}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
<div className="modal-action">
|
<div className="modal-action">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -487,15 +487,23 @@ export function ConfigFields({
|
|||||||
owner,
|
owner,
|
||||||
excludeFile,
|
excludeFile,
|
||||||
disabled,
|
disabled,
|
||||||
|
inheritedConfig = null,
|
||||||
}) {
|
}) {
|
||||||
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
const cfg = config && typeof config === "object" && !Array.isArray(config) ? config : {};
|
||||||
|
const inherited =
|
||||||
|
inheritedConfig && typeof inheritedConfig === "object" && !Array.isArray(inheritedConfig)
|
||||||
|
? inheritedConfig
|
||||||
|
: null;
|
||||||
const metaKeys = Object.keys(meta?.config ?? {});
|
const metaKeys = Object.keys(meta?.config ?? {});
|
||||||
const extraKeys = Object.keys(cfg).filter((k) => !metaKeys.includes(k));
|
const extraKeys = Object.keys(cfg).filter((k) => !metaKeys.includes(k));
|
||||||
|
const inheritedExtraKeys = inherited
|
||||||
|
? Object.keys(inherited).filter((k) => !metaKeys.includes(k) && !(k in cfg))
|
||||||
|
: [];
|
||||||
|
|
||||||
function setField(key, value) {
|
function setField(key, value) {
|
||||||
const spec = fieldSpec(meta, key);
|
const spec = fieldSpec(meta, key);
|
||||||
const next = { ...cfg };
|
const next = { ...cfg };
|
||||||
const required = Boolean(spec.required);
|
const required = Boolean(spec.required) && !inherited;
|
||||||
if (value === undefined || (value === "" && !required)) {
|
if (value === undefined || (value === "" && !required)) {
|
||||||
delete next[key];
|
delete next[key];
|
||||||
} else {
|
} else {
|
||||||
@@ -504,6 +512,12 @@ export function ConfigFields({
|
|||||||
onChange(next);
|
onChange(next);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resetField(key) {
|
||||||
|
const next = { ...cfg };
|
||||||
|
delete next[key];
|
||||||
|
onChange(next);
|
||||||
|
}
|
||||||
|
|
||||||
function renameExtra(oldKey, nextKey) {
|
function renameExtra(oldKey, nextKey) {
|
||||||
if (!nextKey || nextKey === oldKey) return;
|
if (!nextKey || nextKey === oldKey) return;
|
||||||
const next = {};
|
const next = {};
|
||||||
@@ -516,24 +530,55 @@ export function ConfigFields({
|
|||||||
function addExtra() {
|
function addExtra() {
|
||||||
let key = "key";
|
let key = "key";
|
||||||
let i = 1;
|
let i = 1;
|
||||||
while (key in cfg) {
|
while (key in cfg || (inherited && key in inherited)) {
|
||||||
key = `key${i}`;
|
key = `key${i}`;
|
||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
onChange({ ...cfg, [key]: "" });
|
onChange({ ...cfg, [key]: "" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderField(key, { extra = false, inheritedOnly = false } = {}) {
|
||||||
|
const spec = extra ? { type: "string" } : fieldSpec(meta, key);
|
||||||
|
const overridden = Boolean(inherited && Object.prototype.hasOwnProperty.call(cfg, key));
|
||||||
|
const displayValue = inheritedOnly
|
||||||
|
? inherited?.[key]
|
||||||
|
: overridden || !inherited
|
||||||
|
? cfg[key]
|
||||||
|
: inherited[key];
|
||||||
|
const unknown =
|
||||||
|
extra && metaKeys.length > 0 && !Object.prototype.hasOwnProperty.call(meta?.config ?? {}, key);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div
|
||||||
{metaKeys.map((key) => {
|
key={key}
|
||||||
const spec = fieldSpec(meta, key);
|
className={`space-y-1 ${
|
||||||
return (
|
overridden ? "rounded-box border border-warning/50 bg-warning/5 p-2" : ""
|
||||||
<div key={key} className="space-y-1">
|
}`}
|
||||||
<FieldLabel name={key} required={Boolean(spec.required)} description={spec.description} />
|
>
|
||||||
|
<FieldLabel name={key} required={Boolean(spec.required) && !inherited} description={spec.description}>
|
||||||
|
{overridden ? (
|
||||||
|
<span className="badge badge-warning badge-xs">overridden</span>
|
||||||
|
) : inherited && !inheritedOnly ? (
|
||||||
|
<span className="badge badge-ghost badge-xs">from profile</span>
|
||||||
|
) : null}
|
||||||
|
{unknown ? (
|
||||||
|
<span className="badge badge-error badge-xs">not in schema</span>
|
||||||
|
) : null}
|
||||||
|
{overridden ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => resetField(key)}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</FieldLabel>
|
||||||
<ValueEditor
|
<ValueEditor
|
||||||
value={cfg[key]}
|
value={displayValue}
|
||||||
onChange={(v) => setField(key, v)}
|
onChange={(v) => setField(key, v)}
|
||||||
spec={spec}
|
spec={extra ? { type: displayValue != null && typeof displayValue === "object" ? "object" : "string" } : spec}
|
||||||
script={script}
|
script={script}
|
||||||
fieldKey={key}
|
fieldKey={key}
|
||||||
workflows={workflows}
|
workflows={workflows}
|
||||||
@@ -541,10 +586,14 @@ export function ConfigFields({
|
|||||||
excludeFile={excludeFile}
|
excludeFile={excludeFile}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<ConfigRefHint value={cfg[key]} owner={owner} />
|
<ConfigRefHint value={displayValue} owner={owner} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{metaKeys.map((key) => renderField(key))}
|
||||||
{extraKeys.map((key) => (
|
{extraKeys.map((key) => (
|
||||||
<div key={key} className="space-y-1">
|
<div key={key} className="space-y-1">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -552,16 +601,18 @@ export function ConfigFields({
|
|||||||
value={key}
|
value={key}
|
||||||
onCommit={(nk) => renameExtra(key, nk)}
|
onCommit={(nk) => renameExtra(key, nk)}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
{inherited ? <span className="badge badge-warning badge-xs">overridden</span> : null}
|
||||||
|
{metaKeys.length > 0 ? (
|
||||||
|
<span className="badge badge-error badge-xs">not in schema</span>
|
||||||
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs btn-square text-error"
|
className="btn btn-ghost btn-xs btn-square text-error"
|
||||||
aria-label={`Remove ${key}`}
|
aria-label={`Remove ${key}`}
|
||||||
onClick={() => {
|
disabled={disabled}
|
||||||
const next = { ...cfg };
|
onClick={() => resetField(key)}
|
||||||
delete next[key];
|
|
||||||
onChange(next);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<LuTrash2 className="size-3.5" />
|
<LuTrash2 className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -580,6 +631,7 @@ export function ConfigFields({
|
|||||||
<ConfigRefHint value={cfg[key]} owner={owner} />
|
<ConfigRefHint value={cfg[key]} owner={owner} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
{inheritedExtraKeys.map((key) => renderField(key, { extra: true, inheritedOnly: true }))}
|
||||||
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
<button type="button" className="btn btn-ghost btn-xs" disabled={disabled} onClick={addExtra}>
|
||||||
<LuPlus className="size-3.5" />
|
<LuPlus className="size-3.5" />
|
||||||
Add field
|
Add field
|
||||||
|
|||||||
@@ -11,12 +11,14 @@ import { contextFromMeta, prettyJson } from "../../lib/script.js";
|
|||||||
import { ConfigFields } from "./ConfigFields.jsx";
|
import { ConfigFields } from "./ConfigFields.jsx";
|
||||||
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
import { ConfigTooltip, configValueText, FieldLabel, previewConfigValue, SchemaTooltip } from "./FieldHelp.jsx";
|
||||||
import { ScriptIcon } from "../ScriptIcon.jsx";
|
import { ScriptIcon } from "../ScriptIcon.jsx";
|
||||||
|
import { configHasOverlay, mergeProfileConfig } from "../../lib/profile.js";
|
||||||
|
|
||||||
export function ScriptCard({
|
export function ScriptCard({
|
||||||
step,
|
step,
|
||||||
index,
|
index,
|
||||||
otherSteps,
|
otherSteps,
|
||||||
scriptsByName,
|
scriptsByName,
|
||||||
|
profilesByName,
|
||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
disabled,
|
disabled,
|
||||||
@@ -35,25 +37,33 @@ export function ScriptCard({
|
|||||||
};
|
};
|
||||||
const [tryOpen, setTryOpen] = useState(false);
|
const [tryOpen, setTryOpen] = useState(false);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
const listed = scriptsByName?.get(step.script);
|
const profile = step.profile && profilesByName?.get(step.profile);
|
||||||
|
const scriptName = profile?.script || step.script;
|
||||||
|
const listed = scriptsByName?.get(scriptName);
|
||||||
const meta = listed?.meta ?? null;
|
const meta = listed?.meta ?? null;
|
||||||
|
const inheritedConfig = profile?.config ?? null;
|
||||||
|
const mergedConfig = profile
|
||||||
|
? mergeProfileConfig(profile.config, step.config)
|
||||||
|
: step.config;
|
||||||
|
const overridden = Boolean(step.profile && configHasOverlay(step.config));
|
||||||
|
|
||||||
const duplicateId =
|
const duplicateId =
|
||||||
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
|
||||||
const preview = previewConfigValue(step.config, meta?.previewConfigKey);
|
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
|
||||||
const previewFull = configValueText(step.config, meta?.previewConfigKey);
|
const previewFull = configValueText(mergedConfig, meta?.previewConfigKey);
|
||||||
const baseName = step.kind === "set" ? "set" : step.script || "untitled";
|
const baseName = step.kind === "set" ? "set" : scriptName || "untitled";
|
||||||
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
|
||||||
const setConfig =
|
const setConfig =
|
||||||
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
step.kind === "set" ? { expression: step.expression ?? "" } : null;
|
||||||
|
const missingProfile = Boolean(step.profile) && !profile;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article
|
<article
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`card bg-base-100 border border-primary ${
|
className={`card bg-base-100 border ${
|
||||||
isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"
|
step.profile ? "border-accent" : "border-primary"
|
||||||
}`}
|
} ${isDragging ? "z-40" : "z-0 hover:z-30 focus-within:z-30"}`}
|
||||||
>
|
>
|
||||||
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
<div className={`card-body gap-3 ${expanded ? "p-4" : "p-3"}`}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -67,8 +77,8 @@ export function ScriptCard({
|
|||||||
>
|
>
|
||||||
<LuGripVertical className="size-4 opacity-60" />
|
<LuGripVertical className="size-4 opacity-60" />
|
||||||
</button>
|
</button>
|
||||||
{step.kind === "script" && step.script ? (
|
{step.kind === "script" && scriptName ? (
|
||||||
<ScriptIcon name={step.script} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
<ScriptIcon name={scriptName} hasIcon={listed?.hasIcon} className="size-8 shrink-0" />
|
||||||
) : null}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -87,10 +97,29 @@ export function ScriptCard({
|
|||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</h3>
|
</h3>
|
||||||
|
{step.profile ? (
|
||||||
|
<span
|
||||||
|
className={`badge badge-sm shrink-0 font-mono font-normal ${
|
||||||
|
overridden ? "badge-warning" : "badge-accent"
|
||||||
|
}`}
|
||||||
|
title={
|
||||||
|
overridden
|
||||||
|
? `profile ${step.profile} + overridden`
|
||||||
|
: `profile ${step.profile}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{overridden ? `profile ${step.profile} + overridden` : `profile ${step.profile}`}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
{step.id ? (
|
{step.id ? (
|
||||||
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
|
<span className="badge badge-ghost badge-sm font-mono shrink-0">{step.id}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
{expanded && step.kind === "script" && missingProfile ? (
|
||||||
|
<p className="text-sm text-error mt-1">
|
||||||
|
Profile {step.profile} not found for owner {owner || "?"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
{expanded && step.kind === "script" && meta?.description ? (
|
{expanded && step.kind === "script" && meta?.description ? (
|
||||||
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
|
<p className="text-sm opacity-70 mt-1">{meta.description}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -104,10 +133,10 @@ export function ScriptCard({
|
|||||||
<SchemaTooltip label="Input" fields={meta?.input} />
|
<SchemaTooltip label="Input" fields={meta?.input} />
|
||||||
) : null}
|
) : null}
|
||||||
{!expanded ? (
|
{!expanded ? (
|
||||||
<ConfigTooltip config={step.kind === "set" ? setConfig : (step.config ?? {})} />
|
<ConfigTooltip config={step.kind === "set" ? setConfig : (mergedConfig ?? {})} />
|
||||||
) : null}
|
) : null}
|
||||||
<div className="card-actions shrink-0">
|
<div className="card-actions shrink-0">
|
||||||
{step.kind === "script" && step.script ? (
|
{step.kind === "script" && scriptName ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-ghost btn-xs"
|
className="btn btn-ghost btn-xs"
|
||||||
@@ -158,8 +187,9 @@ export function ScriptCard({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<ConfigFields
|
<ConfigFields
|
||||||
script={step.script}
|
script={scriptName}
|
||||||
config={step.config}
|
config={step.config}
|
||||||
|
inheritedConfig={inheritedConfig}
|
||||||
meta={meta}
|
meta={meta}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
workflows={workflows}
|
workflows={workflows}
|
||||||
@@ -211,8 +241,8 @@ export function ScriptCard({
|
|||||||
</div>
|
</div>
|
||||||
{tryOpen ? (
|
{tryOpen ? (
|
||||||
<ScriptTryDialog
|
<ScriptTryDialog
|
||||||
script={step.script}
|
script={scriptName}
|
||||||
config={step.config}
|
config={mergedConfig}
|
||||||
meta={meta}
|
meta={meta}
|
||||||
owner={owner}
|
owner={owner}
|
||||||
onClose={() => setTryOpen(false)}
|
onClose={() => setTryOpen(false)}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { LuPlus } from "react-icons/lu";
|
import { LuPlus } from "react-icons/lu";
|
||||||
import { defaultConfigFromMeta } from "../../lib/script.js";
|
import { defaultConfigFromMeta } from "../../lib/script.js";
|
||||||
import { newScriptStep, newSetStep } from "../../lib/workflow-doc.js";
|
import { newProfileStep, newScriptStep, newSetStep } from "../../lib/workflow-doc.js";
|
||||||
import { AddScriptDialog } from "./AddScriptDialog.jsx";
|
import { AddScriptDialog } from "./AddScriptDialog.jsx";
|
||||||
import { ScriptCard } from "./ScriptCard.jsx";
|
import { ScriptCard } from "./ScriptCard.jsx";
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ export function ScriptsTab({
|
|||||||
onPatch,
|
onPatch,
|
||||||
disabled,
|
disabled,
|
||||||
scripts = [],
|
scripts = [],
|
||||||
|
profiles = [],
|
||||||
workflows = [],
|
workflows = [],
|
||||||
owner,
|
owner,
|
||||||
excludeFile,
|
excludeFile,
|
||||||
@@ -42,6 +43,10 @@ export function ScriptsTab({
|
|||||||
const name = typeof s === "string" ? s : s.name;
|
const name = typeof s === "string" ? s : s.name;
|
||||||
scriptsByName.set(name, s);
|
scriptsByName.set(name, s);
|
||||||
}
|
}
|
||||||
|
const profilesByName = new Map();
|
||||||
|
for (const p of profiles) {
|
||||||
|
if (p?.name) profilesByName.set(p.name, p);
|
||||||
|
}
|
||||||
|
|
||||||
function patchSteps(next) {
|
function patchSteps(next) {
|
||||||
onPatch((d) => {
|
onPatch((d) => {
|
||||||
@@ -72,7 +77,7 @@ export function ScriptsTab({
|
|||||||
onClick={() => setAddOpen(true)}
|
onClick={() => setAddOpen(true)}
|
||||||
>
|
>
|
||||||
<LuPlus className="size-4" />
|
<LuPlus className="size-4" />
|
||||||
Add script
|
Add step
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{steps.length === 0 ? (
|
{steps.length === 0 ? (
|
||||||
@@ -88,6 +93,7 @@ export function ScriptsTab({
|
|||||||
index={index}
|
index={index}
|
||||||
otherSteps={steps}
|
otherSteps={steps}
|
||||||
scriptsByName={scriptsByName}
|
scriptsByName={scriptsByName}
|
||||||
|
profilesByName={profilesByName}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
workflows={workflows}
|
workflows={workflows}
|
||||||
owner={owner}
|
owner={owner}
|
||||||
@@ -106,10 +112,13 @@ export function ScriptsTab({
|
|||||||
)}
|
)}
|
||||||
<AddScriptDialog
|
<AddScriptDialog
|
||||||
open={addOpen}
|
open={addOpen}
|
||||||
|
owner={owner}
|
||||||
onClose={() => setAddOpen(false)}
|
onClose={() => setAddOpen(false)}
|
||||||
onPick={(picked) => {
|
onPick={(picked) => {
|
||||||
if (picked.kind === "set") {
|
if (picked.kind === "set") {
|
||||||
patchSteps([...steps, newSetStep()]);
|
patchSteps([...steps, newSetStep()]);
|
||||||
|
} else if (picked.kind === "profile") {
|
||||||
|
patchSteps([...steps, newProfileStep(picked.name, picked.script)]);
|
||||||
} else {
|
} else {
|
||||||
patchSteps([
|
patchSteps([
|
||||||
...steps,
|
...steps,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { parse as parseYaml } from "yaml";
|
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 { dataFromInputMeta, firstInputMeta } from "../../lib/script.js";
|
||||||
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
|
import { workflowToFlowchart } from "../../lib/workflow-mermaid.js";
|
||||||
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
import { parseWorkflowYaml, stringifyWorkflowDoc } from "../../lib/workflow-doc.js";
|
||||||
@@ -49,6 +49,7 @@ export function WorkflowVisualEditor({
|
|||||||
const visualDisabled = !displayDoc;
|
const visualDisabled = !displayDoc;
|
||||||
|
|
||||||
const { data: scripts = [] } = useScripts();
|
const { data: scripts = [] } = useScripts();
|
||||||
|
const { data: profiles = [] } = useProfiles(owner || undefined, { enabled: Boolean(owner) });
|
||||||
const { data: workflows = [] } = useWorkflows(owner || undefined);
|
const { data: workflows = [] } = useWorkflows(owner || undefined);
|
||||||
const { data: auths = [] } = useHttpAuths();
|
const { data: auths = [] } = useHttpAuths();
|
||||||
const { data: allPages = [] } = useHttpPages();
|
const { data: allPages = [] } = useHttpPages();
|
||||||
@@ -90,9 +91,17 @@ export function WorkflowVisualEditor({
|
|||||||
return map;
|
return map;
|
||||||
}, [scripts]);
|
}, [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(
|
const inputMeta = useMemo(
|
||||||
() => firstInputMeta(displayDoc?.scripts, scriptsByName),
|
() => firstInputMeta(displayDoc?.scripts, scriptsByName, profilesByName),
|
||||||
[displayDoc?.scripts, scriptsByName],
|
[displayDoc?.scripts, scriptsByName, profilesByName],
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultData = useMemo(() => {
|
const defaultData = useMemo(() => {
|
||||||
@@ -175,6 +184,7 @@ export function WorkflowVisualEditor({
|
|||||||
onPatch={patchDoc}
|
onPatch={patchDoc}
|
||||||
disabled={visualDisabled}
|
disabled={visualDisabled}
|
||||||
scripts={scripts}
|
scripts={scripts}
|
||||||
|
profiles={profiles}
|
||||||
workflows={workflows}
|
workflows={workflows}
|
||||||
owner={owner}
|
owner={owner}
|
||||||
excludeFile={file}
|
excludeFile={file}
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,11 +121,14 @@ export function inputHasFields(meta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** First script step with a non-empty `meta.input`; else first script 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;
|
let fallback = null;
|
||||||
for (const step of steps ?? []) {
|
for (const step of steps ?? []) {
|
||||||
if (step?.kind === "set") continue;
|
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;
|
if (!name) continue;
|
||||||
const listed = scriptsByName?.get(name);
|
const listed = scriptsByName?.get(name);
|
||||||
const meta = listed && typeof listed === "object" ? listed.meta ?? null : null;
|
const meta = listed && typeof listed === "object" ? listed.meta ?? null : null;
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function newScriptStep(script, config = {}) {
|
|||||||
uiId: nextUiId("step"),
|
uiId: nextUiId("step"),
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script,
|
script,
|
||||||
|
profile: "",
|
||||||
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
|
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
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() {
|
export function newSetStep() {
|
||||||
return {
|
return {
|
||||||
uiId: nextUiId("step"),
|
uiId: nextUiId("step"),
|
||||||
@@ -202,6 +216,7 @@ function normalizeStep(step) {
|
|||||||
uiId,
|
uiId,
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: step,
|
script: step,
|
||||||
|
profile: "",
|
||||||
config: {},
|
config: {},
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
when: "",
|
||||||
@@ -213,6 +228,7 @@ function normalizeStep(step) {
|
|||||||
uiId,
|
uiId,
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: "",
|
script: "",
|
||||||
|
profile: "",
|
||||||
config: {},
|
config: {},
|
||||||
id: "",
|
id: "",
|
||||||
when: "",
|
when: "",
|
||||||
@@ -231,7 +247,7 @@ function normalizeStep(step) {
|
|||||||
needs: step.needs ?? null,
|
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<string, unknown>} */
|
/** @type {Record<string, unknown>} */
|
||||||
const extra = {};
|
const extra = {};
|
||||||
for (const [key, value] of Object.entries(step)) {
|
for (const [key, value] of Object.entries(step)) {
|
||||||
@@ -245,6 +261,7 @@ function normalizeStep(step) {
|
|||||||
uiId,
|
uiId,
|
||||||
kind: "script",
|
kind: "script",
|
||||||
script: typeof step.script === "string" ? step.script : "",
|
script: typeof step.script === "string" ? step.script : "",
|
||||||
|
profile: typeof step.profile === "string" ? step.profile : "",
|
||||||
config,
|
config,
|
||||||
id: typeof step.id === "string" ? step.id : "",
|
id: typeof step.id === "string" ? step.id : "",
|
||||||
when: typeof step.when === "string" ? step.when : "",
|
when: typeof step.when === "string" ? step.when : "",
|
||||||
@@ -313,7 +330,11 @@ function dumpStep(step) {
|
|||||||
/** @type {Record<string, unknown>} */
|
/** @type {Record<string, unknown>} */
|
||||||
const out = {};
|
const out = {};
|
||||||
if (step.id) out.id = step.id;
|
if (step.id) out.id = step.id;
|
||||||
|
if (step.profile) {
|
||||||
|
out.profile = step.profile;
|
||||||
|
} else {
|
||||||
out.script = step.script ?? "";
|
out.script = step.script ?? "";
|
||||||
|
}
|
||||||
const config = step.config;
|
const config = step.config;
|
||||||
if (config && typeof config === "object" && !Array.isArray(config) && Object.keys(config).length) {
|
if (config && typeof config === "object" && !Array.isArray(config) && Object.keys(config).length) {
|
||||||
out.config = config;
|
out.config = config;
|
||||||
|
|||||||
@@ -1,20 +1,22 @@
|
|||||||
function parseStep(step) {
|
function parseStep(step) {
|
||||||
if (typeof step === "string") {
|
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) {
|
if (step?.set) {
|
||||||
return {
|
return {
|
||||||
kind: "set",
|
kind: "set",
|
||||||
script: null,
|
script: null,
|
||||||
|
profile: null,
|
||||||
id: typeof step.id === "string" && step.id ? step.id : null,
|
id: typeof step.id === "string" && step.id ? step.id : null,
|
||||||
needs: step.needs ?? null,
|
needs: step.needs ?? null,
|
||||||
when: typeof step.when === "string" && step.when ? step.when : null,
|
when: typeof step.when === "string" && step.when ? step.when : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (step?.script) {
|
if (step?.script || step?.profile) {
|
||||||
return {
|
return {
|
||||||
kind: "script",
|
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,
|
id: typeof step.id === "string" && step.id ? step.id : null,
|
||||||
needs: step.needs ?? null,
|
needs: step.needs ?? null,
|
||||||
when: typeof step.when === "string" && step.when ? step.when : 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";
|
const base = s.id ? `${s.id}: set` : "set";
|
||||||
return s.when ? `${base} when: ${s.when}` : base;
|
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;
|
return s.when ? `${base} when: ${s.when}` : base;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +115,7 @@ export function workflowToFlowchart(parsed) {
|
|||||||
if (s.kind === "set") {
|
if (s.kind === "set") {
|
||||||
lines.push(` ${s.mermaidId}(["${label}"])`);
|
lines.push(` ${s.mermaidId}(["${label}"])`);
|
||||||
} else {
|
} else {
|
||||||
scriptIds[s.mermaidId] = s.script;
|
scriptIds[s.mermaidId] = s.script || (s.profile ? `profile:${s.profile}` : "");
|
||||||
lines.push(` ${s.mermaidId}["${label}"]`);
|
lines.push(` ${s.mermaidId}["${label}"]`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||||
|
<h1 className="text-xl font-semibold">Profiles</h1>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
className="select select-sm"
|
||||||
|
value={ownerFilter}
|
||||||
|
onChange={(e) => {
|
||||||
|
setOwnerFilter(e.target.value);
|
||||||
|
setHighlightName("");
|
||||||
|
navigate(
|
||||||
|
e.target.value
|
||||||
|
? `/profiles?owner=${encodeURIComponent(e.target.value)}`
|
||||||
|
: "/profiles",
|
||||||
|
{ replace: true },
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">all owners</option>
|
||||||
|
{owners.map((o) => (
|
||||||
|
<option key={o} value={o}>
|
||||||
|
{o}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
ownerFilter
|
||||||
|
? `/profiles/new?owner=${encodeURIComponent(ownerFilter)}`
|
||||||
|
: "/profiles/new",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LuPlus className="size-4" />
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm opacity-70">
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<span className="loading loading-spinner" />
|
||||||
|
) : profiles.length === 0 ? (
|
||||||
|
<p className="text-sm opacity-60">No profiles yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||||
|
{profiles.map((row) => {
|
||||||
|
const highlighted =
|
||||||
|
highlightName &&
|
||||||
|
row.name === highlightName &&
|
||||||
|
(!ownerFilter || row.owner === ownerFilter);
|
||||||
|
const preview = previewConfig(row.config);
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={row.id}
|
||||||
|
ref={highlighted ? highlightRef : undefined}
|
||||||
|
className={`card bg-base-100 border border-accent ${
|
||||||
|
highlighted ? "outline outline-2 outline-accent" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="card-body p-4 gap-2">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<ScriptIcon name={row.script} className="size-10 shrink-0" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="font-mono font-semibold truncate" title={row.name}>
|
||||||
|
{row.name}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs opacity-60 font-mono truncate">{row.owner}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs font-mono opacity-80 truncate" title={row.script}>
|
||||||
|
{row.script}
|
||||||
|
</p>
|
||||||
|
{row.description ? (
|
||||||
|
<p className="text-sm opacity-80 line-clamp-2">{row.description}</p>
|
||||||
|
) : null}
|
||||||
|
{preview ? (
|
||||||
|
<p className="text-xs font-mono opacity-60 truncate" title={preview}>
|
||||||
|
{preview}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<p className="text-xs opacity-50">
|
||||||
|
{row.usageCount
|
||||||
|
? `${row.usageCount} workflow${row.usageCount === 1 ? "" : "s"}`
|
||||||
|
: "unused"}
|
||||||
|
{row.updated_at ? ` · ${formatTime(row.updated_at)}` : ""}
|
||||||
|
</p>
|
||||||
|
<div className="card-actions justify-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs"
|
||||||
|
title="Edit"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(
|
||||||
|
`/profiles/${encodeURIComponent(row.owner)}/${encodeURIComponent(row.name)}/edit`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<LuPencil className="size-4" />
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-ghost btn-xs text-error"
|
||||||
|
title="Delete"
|
||||||
|
onClick={() => setConfirmDelete({ ...row, force: false })}
|
||||||
|
>
|
||||||
|
<LuTrash2 className="size-4" />
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editor ? (
|
||||||
|
<ProfileEditorModal
|
||||||
|
mode={editor.mode}
|
||||||
|
initial={editor.initial}
|
||||||
|
usageCount={editor.usageCount ?? 0}
|
||||||
|
onClose={closeEditor}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
setHighlightName(saved?.name || editor.initial.name);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{confirmDelete ? (
|
||||||
|
<DeleteProfileDialog
|
||||||
|
profile={confirmDelete}
|
||||||
|
del={del}
|
||||||
|
onClose={() => {
|
||||||
|
del.reset();
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<dialog className="modal modal-open">
|
||||||
|
<div className="modal-box">
|
||||||
|
<h3 className="font-bold">
|
||||||
|
Delete {profile.owner}/{profile.name}?
|
||||||
|
</h3>
|
||||||
|
{usage.isLoading ? (
|
||||||
|
<p className="text-sm mt-2 opacity-70">Checking workflow usage…</p>
|
||||||
|
) : used ? (
|
||||||
|
<>
|
||||||
|
<p className="text-sm mt-2">{formatUsage(usages)}</p>
|
||||||
|
<ul className="mt-2 max-h-40 overflow-auto text-sm font-mono">
|
||||||
|
{usages.map((u) => (
|
||||||
|
<li key={u.file}>
|
||||||
|
{u.name}
|
||||||
|
{u.file !== u.name ? ` (${u.file})` : ""}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{force ? (
|
||||||
|
<p className="text-warning text-sm mt-2">
|
||||||
|
Workflows that still reference this profile will fail until you fix them.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm mt-2">This cannot be undone.</p>
|
||||||
|
)}
|
||||||
|
{del.isError ? (
|
||||||
|
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
|
||||||
|
) : null}
|
||||||
|
<div className="modal-action">
|
||||||
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
{used && !force ? (
|
||||||
|
<button type="button" className="btn btn-warning" onClick={() => setForce(true)}>
|
||||||
|
Force delete
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-error"
|
||||||
|
disabled={del.isPending || usage.isLoading}
|
||||||
|
onClick={() =>
|
||||||
|
del.mutate({ id: profile.id, force: used }, { onSuccess: () => onClose() })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{del.isPending ? <span className="loading loading-spinner loading-xs" /> : null}
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" className="modal-backdrop">
|
||||||
|
<button type="button" onClick={onClose}>
|
||||||
|
close
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user