2 Commits
Author SHA1 Message Date
nsrb 41cca86e6e feat(workflows): enhance script handling and introduce custom step names
- Updated AGENTS.md to include a `name` field for scripts, allowing custom display titles in the editor and graph.
- Enhanced script icon resolution to support `.jpeg` file extensions.
- Modified workflow parsing to accommodate the new `name` property for steps, improving step identification.
- Added new utility functions for handling custom step names and display names.
- Introduced tests to validate the functionality of custom step names in workflow YAML parsing and step labeling.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 21:48:18 +07:00
nsrb 71cc705cd2 feat(server): introduce admin reset functionality and enhance user management
- Added a new `reset-admin` script to reset or create the admin username and password.
- Updated the README to include instructions for resetting the admin login.
- Enhanced user management by allowing username updates during admin reset.
- Defaulted new resources to the internal namespace `local` for better organization.
- Removed unused owner selection from various components to streamline the UI.

Co-authored-by: Nasyarobby Putra <nasyarobby@gmail.com>
2026-08-22 19:08:52 +07:00
39 changed files with 553 additions and 555 deletions
+3 -2
View File
@@ -138,13 +138,14 @@ Injected: `log` (pino), `console`, `fetch`, `require`, `$axios`, `$kv`, `$finger
```yaml
scripts:
- script: plugin/my-plugin
- name: Notify to channel
script: plugin/my-plugin
config:
url: http://10.8.0.6:3030/notes
token: $SECRET_joplin_api_token
```
Canonical ref is `plugin/<id>` (`.js` suffix is optional).
Optional `name` is the display title in the editor and graph (falls back to the script filename). Canonical ref is `plugin/<id>` (`.js` suffix is optional).
## Do not
+8 -2
View File
@@ -20,7 +20,13 @@ pnpm dev
- UI (dev): http://localhost:8500
- API: http://localhost:8700
The first account created becomes **admin**. Later accounts are created from Users.
The first account created becomes **admin**. JerapahFlow is a **single-machine, single-user** automation app: the Users page is not linked in the nav (still available at `/users` if typed). New workflows, secrets, variables, and profiles default to the internal namespace `local`.
Reset or create the admin login from the host:
```bash
pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
```
### Process modes
@@ -52,7 +58,7 @@ The first account created becomes **admin**. Later accounts are created from Use
| **Live workflows** | Yes | `packages/server/data/workflows/<owner>/` (gitignored) |
| **Example presets** | No | `examples/workflows/*.yaml` — offered when creating a new workflow |
- Live YAML is **instance data**, same as SQLite and secrets — not product source. Prefer owner `local` for personal workflows.
- Live YAML is **instance data**, same as SQLite and secrets — not product source. New resources use owner `local` (owner remains in storage/URLs for a possible future multi-tenant mode; the UI hides it).
- On first start, if the instance store is empty and a legacy `packages/server/workflows/` tree still exists, it is copied into `data/workflows/`.
- New workflow editor starts empty; optional presets copy example YAML into the editor (nothing is saved until Save).
- Override the live store in tests with `JFLOW_WORKFLOWS_DIR`.
+2 -1
View File
@@ -50,7 +50,7 @@ export function readScript(name) {
}
/**
* Icon next to the script: `fetch-html.js` → `fetch-html.png` or `.jpg`.
* Icon next to the script: `fetch-html.js` → `fetch-html.png`, `.jpg`, or `.jpeg`.
* @returns {{ filePath: string, contentType: string } | null}
*/
export function resolveScriptIcon(name) {
@@ -59,6 +59,7 @@ export function resolveScriptIcon(name) {
for (const { ext, contentType } of [
{ ext: "png", contentType: "image/png" },
{ ext: "jpg", contentType: "image/jpeg" },
{ ext: "jpeg", contentType: "image/jpeg" },
]) {
const filePath = path.join(SCRIPTS_DIR, `${base}.${ext}`);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
+1
View File
@@ -14,6 +14,7 @@
"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": "JFLOW_WORKFLOWS_DIR=./data/workflow-history-smoke JFLOW_DB_PATH=./data/workflow-history-smoke.db node test/workflow-history-smoke.js",
"reset-admin": "node reset-admin.js",
"test:profiles": "node test/profiles-smoke.js",
"test:set-dry-run": "node test/set-dry-run-smoke.js"
},
+105
View File
@@ -0,0 +1,105 @@
/**
* Reset (or create) the admin username and password.
*
* Usage:
* pnpm --dir packages/server reset-admin -- --username admin --password 'your-password'
*
* Uses JFLOW_DB_PATH like the app. Never prints the password.
*/
import bcrypt from "bcryptjs";
import { db, migrate } from "./db.js";
import * as store from "./store.js";
import { validateCredentials } from "./src/api/auth.js";
function parseArgs(argv) {
/** @type {{ username?: string, password?: string }} */
const out = {};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--username" || arg === "-u") {
out.username = argv[++i];
continue;
}
if (arg === "--password" || arg === "-p") {
out.password = argv[++i];
continue;
}
if (arg === "--help" || arg === "-h") {
out.help = true;
}
}
return out;
}
function usage() {
console.log(`Usage:
pnpm --dir packages/server reset-admin -- --username <name> --password <secret>
Creates an admin if none exist; otherwise updates the oldest admin's
username and password. Credentials must match login rules
(username 3-32 [A-Za-z0-9_], password at least 8 characters).`);
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (args.help) {
usage();
process.exit(0);
}
const username = typeof args.username === "string" ? args.username.trim() : "";
const password = typeof args.password === "string" ? args.password : "";
if (!username || !password) {
usage();
process.exit(1);
}
const credErr = validateCredentials(username, password);
if (credErr) {
console.error(credErr);
process.exit(1);
}
await migrate();
const passwordHash = await bcrypt.hash(password, 10);
const admins = await db("users")
.where({ role: "admin" })
.orderBy("created_at", "asc")
.select("id", "username");
if (admins.length === 0) {
const user = await store.createUser({
username,
passwordHash,
role: "admin",
});
console.log(`Created admin user "${user.username}" (${user.id})`);
return;
}
const admin = admins[0];
const taken = await store.getUserAuthByUsername(username);
if (taken && taken.id !== admin.id) {
console.error(`username "${username}" is already taken by another user`);
process.exit(1);
}
const updated = await store.updateUser(admin.id, {
username,
passwordHash,
role: "admin",
});
console.log(
`Updated admin "${admin.username}" → "${updated.username}" (${updated.id})`,
);
}
try {
await main();
} catch (err) {
console.error(err instanceof Error ? err.message : String(err));
process.exitCode = 1;
} finally {
await db.destroy();
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

+1 -1
View File
@@ -258,7 +258,7 @@ export default function scriptsPluginFactory(registry) {
req.body ?? {}
);
let owner = "default";
let owner = "local";
if (body.owner != null && body.owner !== "") {
try {
owner = fsStore.assertOwner(String(body.owner));
+2 -1
View File
@@ -582,12 +582,13 @@ export async function listUsers() {
/**
* @param {string} id
* @param {{ passwordHash?: string, role?: string }} patch
* @param {{ passwordHash?: string, role?: string, username?: string }} patch
*/
export async function updateUser(id, patch) {
const update = { updated_at: nowIso() };
if (patch.passwordHash) update.password_hash = patch.passwordHash;
if (patch.role) update.role = patch.role;
if (patch.username) update.username = patch.username;
await db("users").where({ id }).update(update);
return getUserById(id);
}
+18
View File
@@ -11,6 +11,7 @@ export const SET_STEP_SCRIPT = "set";
* profile: string | null,
* config: unknown | null,
* expression?: undefined,
* name: string | null,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
@@ -22,6 +23,7 @@ export const SET_STEP_SCRIPT = "set";
* profile: null,
* config: { expression: string },
* expression: string,
* name: string | null,
* id: string | null,
* needsKind: "none" | "list" | "map",
* needs: NeedEdge[],
@@ -80,6 +82,7 @@ export function parseScriptStep(step) {
script: step,
profile: null,
config: null,
name: null,
id: null,
needsKind: "none",
needs: [],
@@ -119,6 +122,7 @@ export function parseScriptStep(step) {
script: hasScript ? step.script : "",
profile: hasProfile ? step.profile : null,
config: step.config ?? null,
name: parseOptionalName(step.name),
id: parseOptionalId(step.id),
needsKind,
needs,
@@ -271,6 +275,7 @@ function parseSetStep(step) {
profile: null,
config: { expression },
expression,
name: parseOptionalName(step.name),
id: parseOptionalId(step.id),
needsKind,
needs,
@@ -291,6 +296,19 @@ function parseWhen(when) {
return when;
}
/**
* @param {unknown} name
* @returns {string | null}
*/
function parseOptionalName(name) {
if (name == null || name === "") return null;
if (typeof name !== "string") {
throw new Error(`Invalid step name: ${JSON.stringify(name)}`);
}
const trimmed = name.trim();
return trimmed || null;
}
/**
* @param {unknown} id
* @returns {string | null}
+1 -1
View File
@@ -8,7 +8,7 @@
"build": "vite build",
"preview": "vite preview",
"lint": "eslint src",
"test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js"
"test": "node --test src/lib/workflow-graph.test.js src/lib/try-session.test.js src/lib/workflow-doc.test.js"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -1,22 +1,19 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useDuplicateWorkflow, useOwners } from "../api/hooks.js";
import { useDuplicateWorkflow } from "../api/hooks.js";
import { useNotifications } from "../notifications.jsx";
export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplicated }) {
const { notify } = useNotifications();
const { data: owners = [] } = useOwners();
const duplicate = useDuplicateWorkflow();
const [destOwner, setDestOwner] = useState(source.owner);
function onSubmit(e) {
e.preventDefault();
if (destOwner === source.owner && duplicate.isPending) return;
if (duplicate.isPending) return;
duplicate.mutate(
{
owner: source.owner,
file: source.file,
destOwner,
destOwner: source.owner,
},
{
onSuccess: (data) => {
@@ -33,7 +30,7 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
<h3 className="font-bold">Duplicate {source.key}?</h3>
<p className="mt-2 text-sm opacity-70">
A new UUID filename is assigned automatically. The copy starts disabled. HTTP paths are
rewritten when staying under the same owner so triggers do not collide.
rewritten so triggers do not collide.
</p>
{warnUnsaved ? (
<p className="text-warning mt-2 text-sm">
@@ -41,21 +38,6 @@ export function DuplicateWorkflowDialog({ source, warnUnsaved, onClose, onDuplic
</p>
) : null}
<form className="mt-4 space-y-3" onSubmit={onSubmit}>
<label className="form-control w-full">
<span className="label py-0 text-sm">Owner</span>
<input
className="input input-sm w-full"
list="duplicate-workflow-owners"
value={destOwner}
onChange={(e) => setDestOwner(e.target.value)}
required
/>
<datalist id="duplicate-workflow-owners">
{owners.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</label>
{duplicate.isError ? (
<p className="text-error text-sm">{errorMessage(duplicate.error)}</p>
) : null}
-2
View File
@@ -17,7 +17,6 @@ import {
LuShield,
LuSun,
LuTags,
LuUsers,
} from "react-icons/lu";
import { useLogout, useOpsStatus } from "../api/hooks.js";
import { brandMark } from "../theme/brand.js";
@@ -52,7 +51,6 @@ const navSections = [
items: [
{ to: "/manage", label: "Manage", icon: LuServer },
{ to: "/backup", label: "Backup", icon: LuArchive },
{ to: "/users", label: "Users", icon: LuUsers },
],
},
];
@@ -1,8 +1,9 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useOwners, useScripts, useUpsertProfile } from "../api/hooks.js";
import { useScripts, useUpsertProfile } from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx";
import { ConfigFields } from "./workflow/ConfigFields.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/**
* @param {"add" | "edit"} mode
@@ -16,11 +17,10 @@ import { ConfigFields } from "./workflow/ConfigFields.jsx";
* @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",
owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "",
script: initial.script || "",
config:
@@ -72,7 +72,7 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
submit();
}
const title = mode === "add" ? "New profile" : `Edit ${form.owner}/${form.name}`;
const title = mode === "add" ? "New profile" : `Edit ${form.name}`;
return (
<dialog className="modal modal-open">
@@ -80,31 +80,6 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
<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 ? (
<FormSelect
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
) : (
<FormInput
className="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>
<FormInput
@@ -119,10 +94,9 @@ export function ProfileEditorModal({ mode, initial, onClose, onSaved, usageCount
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="font-mono">{form.name}</span>
<span className="ml-2 opacity-60">(name cannot be changed)</span>
</p>
)}
+1 -1
View File
@@ -5,7 +5,7 @@ export function scriptIconSrc(name) {
return `/api/scripts/${encodeURIComponent(name)}/icon`;
}
/** Same-name PNG/JPG next to the script, or a JS document icon. */
/** Same-name PNG/JPG/JPEG next to the script, or a JS document icon. */
export function ScriptIcon({ name, hasIcon, className = "size-10" }) {
const [failed, setFailed] = useState(false);
@@ -1,30 +1,22 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useOwners, useUpsertSecret } from "../api/hooks.js";
import { FormInput, FormSelect } from "./FormControls.jsx";
import { useUpsertSecret } from "../api/hooks.js";
import { FormInput } from "./FormControls.jsx";
import { Modal } from "./Modal.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
/**
* Add / replace an encrypted secret.
* Reusable: mount when open; parent supplies mode + initial fields.
*
* @param {"add" | "replace"} mode
* @param {{ owner: string, name?: string }} initial
* @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner]
*/
export function SecretEditorModal({
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
export function SecretEditorModal({ mode, initial, onClose, onSaved }) {
const upsert = useUpsertSecret();
const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default",
owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "",
value: "",
}));
@@ -42,40 +34,13 @@ export function SecretEditorModal({
);
}
const title = mode === "add" ? "New secret" : `Replace ${form.owner}/${form.name}`;
const title = mode === "add" ? "New secret" : `Replace ${form.name}`;
return (
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
<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 ? (
<FormSelect
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
@@ -87,7 +52,6 @@ export function SecretEditorModal({
title="Letters, numbers, dots, underscores, hyphens"
/>
</label>
</>
) : null}
<label className="form-control w-full">
<span className="label py-0 text-sm">Value</span>
@@ -1,7 +1,8 @@
import { useState } from "react";
import { errorMessage } from "../api/client.js";
import { useOwners, useUpsertVariable } from "../api/hooks.js";
import { useUpsertVariable } from "../api/hooks.js";
import { FormInput, FormSelect, FormTextarea } from "./FormControls.jsx";
import { DEFAULT_OWNER } from "../lib/tenant.js";
const TYPES = ["string", "number", "boolean"];
@@ -13,25 +14,16 @@ function defaultValue(type) {
/**
* Add / edit a plaintext workflow variable.
* Reusable: mount when open; parent supplies mode + initial fields.
*
* @param {"add" | "edit"} mode
* @param {{ owner: string, name?: string, type?: string, value?: string | number | boolean }} initial
* @param {() => void} onClose
* @param {(saved: unknown) => void} [onSaved]
* @param {boolean} [lockOwner] When true, owner cannot be changed (add mode).
*/
export function VariableEditorModal({
mode,
initial,
onClose,
onSaved,
lockOwner = false,
}) {
const { data: owners = [] } = useOwners();
export function VariableEditorModal({ mode, initial, onClose, onSaved }) {
const upsert = useUpsertVariable();
const [form, setForm] = useState(() => ({
owner: initial.owner || owners[0] || "default",
owner: initial.owner || DEFAULT_OWNER,
name: initial.name || "",
type: initial.type || "string",
value:
@@ -72,7 +64,7 @@ export function VariableEditorModal({
);
}
const title = mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`;
const title = mode === "add" ? "New variable" : `Edit ${form.name}`;
return (
<dialog className="modal modal-open">
@@ -80,33 +72,6 @@ export function VariableEditorModal({
<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 ? (
<FormSelect
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
) : (
<FormInput
className="w-full"
value={form.owner}
onChange={(e) => setForm({ ...form, owner: e.target.value })}
required
disabled={lockOwner}
/>
)}
</label>
<label className="form-control w-full">
<span className="label py-0 text-sm">Name</span>
<FormInput
@@ -118,7 +83,6 @@ export function VariableEditorModal({
title="Letters, numbers, dots, underscores, hyphens"
/>
</label>
</>
) : null}
<label className="form-control w-full">
<span className="label py-0 text-sm">Type</span>
@@ -42,6 +42,20 @@ import "@xyflow/react/dist/style.css";
const nodeTypes = { trigger: TriggerGraphNode, step: StepGraphNode };
function listedHasIcon(listed) {
if (listed == null || typeof listed === "string") return undefined;
return listed.hasIcon;
}
function stepIconData(node, scriptsByName, profilesByName) {
if (node.type !== "step" || node.data?.kind === "set") {
return { iconScript: "", hasIcon: false };
}
const profile = node.data?.profile ? profilesByName.get(node.data.profile) : null;
const iconScript = profile?.script || node.data?.script || "";
return { iconScript, hasIcon: listedHasIcon(scriptsByName.get(iconScript)) };
}
/** Two Control zoom-out steps from React Flow's default zoom (1). */
const INITIAL_ZOOM = 1 / 1.2 ** 2;
const FIT_VIEW = { padding: 0.2, maxZoom: INITIAL_ZOOM, duration: 200 };
@@ -87,28 +101,6 @@ function GraphTabInner({
const steps = doc?.scripts ?? [];
const triggers = doc?.triggers ?? [];
const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]);
useEffect(() => {
const built = buildGraphElements(doc, positions);
setNodes((prev) => {
const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id));
return built.nodes.map((n) => ({
...n,
selected: selected.has(n.id),
deletable: !disabled,
draggable: !disabled,
}));
});
}, [doc, disabled, positions]);
useEffect(() => {
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
return () => cancelAnimationFrame(t);
}, [owner, file, fitView]);
const selected = parseGraphNodeId(selectedId);
const scriptsByName = useMemo(() => {
const map = new Map();
for (const s of scripts) {
@@ -126,6 +118,29 @@ function GraphTabInner({
return map;
}, [profiles]);
const { edges } = useMemo(() => buildGraphElements(doc, positions), [doc, positions]);
useEffect(() => {
const built = buildGraphElements(doc, positions);
setNodes((prev) => {
const selected = new Set(prev.filter((n) => n.selected).map((n) => n.id));
return built.nodes.map((n) => ({
...n,
data: { ...n.data, ...stepIconData(n, scriptsByName, profilesByName) },
selected: selected.has(n.id),
deletable: !disabled,
draggable: !disabled,
}));
});
}, [doc, disabled, positions, scriptsByName, profilesByName]);
useEffect(() => {
const t = requestAnimationFrame(() => fitView(FIT_VIEW));
return () => cancelAnimationFrame(t);
}, [owner, file, fitView]);
const selected = parseGraphNodeId(selectedId);
const displayEdges = useMemo(
() =>
edges.map((e) => ({
@@ -18,7 +18,7 @@ import { LogViewer } from "../LogViewer.jsx";
import { StatusBadge } from "../../lib/format";
import { prettyJson } from "../../lib/script.js";
import { seedTryDialog, stepTryLabel } from "../../lib/try-session.js";
import { needsMode } from "../../lib/workflow-doc.js";
import { needsMode, stepCustomName, stepDisplayName } from "../../lib/workflow-doc.js";
import {
stepPredecessors,
stepSuccessors,
@@ -117,7 +117,9 @@ function ScriptCardView({
step.id && otherSteps.some((s) => s.id === step.id && s.uiId !== step.uiId);
const preview = previewConfigValue(mergedConfig, meta?.previewConfigKey);
const previewFull = configValueText(mergedConfig, meta?.previewConfigKey);
const baseName = step.kind === "set" ? "set" : scriptName || "untitled";
const scriptFallback = step.kind === "set" ? "set" : scriptName || "untitled";
const customName = stepCustomName(step);
const baseName = stepDisplayName(step, scriptFallback);
const titleFull = previewFull ? `${baseName} (${previewFull})` : baseName;
const setConfig =
step.kind === "set" ? { expression: step.expression ?? "" } : null;
@@ -180,10 +182,18 @@ function ScriptCardView({
>
<div className="flex min-w-0 items-center gap-2">
<h3
className="card-title min-w-0 flex-1 text-base font-mono"
className={`card-title min-w-0 flex-1 text-base ${customName ? "" : "font-mono"}`}
title={titleFull}
>
<span className="min-w-0 truncate">{baseName}</span>
{customName && scriptFallback && scriptFallback !== customName ? (
<span
className="badge badge-ghost badge-sm shrink-0 truncate font-mono font-normal"
title={scriptFallback}
>
{scriptFallback}
</span>
) : null}
{!expanded && preview ? (
<span
className="badge badge-secondary badge-sm shrink-0 truncate font-mono font-normal"
@@ -275,6 +285,15 @@ function ScriptCardView({
{expanded ? (
<>
<label className="form-control">
<span className="label py-0 text-sm">Name</span>
<FormInput
value={step.name ?? ""}
disabled={disabled}
onChange={(e) => onChange({ ...step, name: e.target.value })}
placeholder={scriptFallback}
/>
</label>
{step.kind === "set" ? (
<div className="space-y-2">
<FieldLabel
@@ -469,9 +488,11 @@ function ConfigJsonEditor({ config, disabled, onChange }) {
function NeedsEditor({ step, otherSteps, disabled, onChange }) {
const mode = needsMode(step.needs);
const ids = otherSteps
.filter((s) => s.id && s.uiId !== step.uiId)
.map((s) => s.id);
const others = otherSteps.filter((s) => s.id && s.uiId !== step.uiId);
const ids = others.map((s) => s.id);
const nameById = new Map(
others.map((s) => [s.id, stepCustomName(s) || null]),
);
function setMode(next) {
if (next === "none") onChange({ ...step, needs: null });
@@ -479,6 +500,11 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
else onChange({ ...step, needs: {}, when: "" });
}
function idLabel(id) {
const named = nameById.get(id);
return named ? `${id} · ${named}` : id;
}
return (
<div className="space-y-1">
<span className="label py-0 text-sm">needs</span>
@@ -520,7 +546,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
});
}}
/>
<span className="font-mono text-xs">{id}</span>
<span className="font-mono text-xs">{idLabel(id)}</span>
</label>
);
})
@@ -531,6 +557,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
<NeedsMap
needs={step.needs && !Array.isArray(step.needs) ? step.needs : {}}
ids={ids}
idLabel={idLabel}
disabled={disabled}
onChange={(needs) => onChange({ ...step, needs })}
/>
@@ -539,7 +566,7 @@ function NeedsEditor({ step, otherSteps, disabled, onChange }) {
);
}
function NeedsMap({ needs, ids, disabled, onChange }) {
function NeedsMap({ needs, ids, idLabel, disabled, onChange }) {
const entries = Object.entries(needs ?? {});
return (
<div className="space-y-1">
@@ -564,11 +591,11 @@ function NeedsMap({ needs, ids, disabled, onChange }) {
onChange={(e) => onChange({ ...needs, [alias]: e.target.value })}
>
{from && !ids.includes(from) ? (
<option value={from}>{from}</option>
<option value={from}>{idLabel ? idLabel(from) : from}</option>
) : null}
{ids.map((id) => (
<option key={id} value={id}>
{id}
{idLabel ? idLabel(id) : id}
</option>
))}
</FormSelect>
@@ -874,8 +901,20 @@ function ScriptTryDialog({
}
>
<div className="flex shrink-0 items-start gap-2">
{!isSet && script ? (
<ScriptIcon
name={script}
hasIcon={existing.data?.hasIcon}
className="size-8 shrink-0"
/>
) : null}
<div className="min-w-0 flex-1">
<h3 className="font-bold font-mono">{isSet ? "set" : script}</h3>
<h3 className={stepCustomName(step) ? "font-bold" : "font-bold font-mono"}>
{stepDisplayName(step, isSet ? "set" : script)}
</h3>
{stepCustomName(step) && script ? (
<p className="text-xs font-mono opacity-60">{isSet ? "set" : script}</p>
) : null}
<p className="text-sm opacity-70">
{isSet
? "Dry-run this set with editable data, context, and expression. Does not create an event. Use Apply to card to write the expression back to the step."
@@ -1,4 +1,5 @@
import { Handle, Position } from "@xyflow/react";
import { ScriptIcon } from "../../ScriptIcon.jsx";
export function TriggerGraphNode({ data, selected }) {
return (
@@ -19,6 +20,7 @@ export function TriggerGraphNode({ data, selected }) {
export function StepGraphNode({ data, selected }) {
const kindLabel = data.kind === "set" ? "set" : data.profile ? "profile" : "script";
const iconScript = data.iconScript || "";
return (
<div
className={`relative min-w-48 max-w-60 rounded-box border bg-base-100 px-3 py-2 shadow-sm ${
@@ -36,6 +38,15 @@ export function StepGraphNode({ data, selected }) {
className="nodrag nopan !h-3.5 !w-3.5 !bg-primary"
isConnectable
/>
<div className="flex items-start gap-2 min-w-0">
{data.kind !== "set" && iconScript ? (
<ScriptIcon
name={iconScript}
hasIcon={data.hasIcon}
className="size-7 shrink-0"
/>
) : null}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1">
<p className="text-[10px] uppercase tracking-wide opacity-60">{kindLabel}</p>
{data.stepId ? (
@@ -52,12 +63,17 @@ export function StepGraphNode({ data, selected }) {
</span>
) : null}
</div>
<p className="font-mono text-sm leading-tight truncate" title={data.label}>
<p
className={`text-sm leading-tight truncate ${data.named ? "font-semibold" : "font-mono"}`}
title={data.label}
>
{data.label}
</p>
{data.missingNeeds?.length ? (
<p className="text-error text-[11px] mt-0.5">Unknown needs: {data.missingNeeds.join(", ")}</p>
) : null}
</div>
</div>
<Handle
type="source"
position={Position.Right}
+2
View File
@@ -0,0 +1,2 @@
/** Default owner folder for new resources (latent tenant id). */
export const DEFAULT_OWNER = "local";
+6 -6
View File
@@ -4,7 +4,7 @@
*/
import { contextFromMeta } from "./script.js";
import { isDagDoc, isEmptyNeeds, needsMode } from "./workflow-doc.js";
import { isDagDoc, isEmptyNeeds, needsMode, stepDisplayName } from "./workflow-doc.js";
/**
* @typedef {{
@@ -89,16 +89,16 @@ export function pruneTrySession(session, steps) {
/**
* Human-readable step label for seed hints.
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string }} step
* @param {{ index?: number, kind?: string, script?: string, profile?: string, id?: string, name?: string }} step
* @param {number} [index]
*/
export function stepTryLabel(step, index) {
const n = index != null ? index + 1 : (step.index ?? 0) + 1;
const id = typeof step.id === "string" && step.id.trim() ? step.id.trim() : null;
const name =
step.kind === "set"
? "set"
: step.profile || step.script || "step";
const name = stepDisplayName(
step,
step.kind === "set" ? "set" : step.profile || step.script || "step",
);
if (id) return `step ${n} / ${id} (${name})`;
return `step ${n} / ${name}`;
}
+15
View File
@@ -5,6 +5,7 @@ import {
pruneTrySession,
recordTrySuccess,
seedTryDialog,
stepTryLabel,
} from "./try-session.js";
function step(uiId, extra = {}) {
@@ -166,3 +167,17 @@ test("pruneTrySession drops removed steps", () => {
assert.equal(pruned.byStep.b, undefined);
assert.equal(pruned.lastTriedUiId, null);
});
test("stepTryLabel uses custom name", () => {
assert.equal(
stepTryLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }, 0),
"step 1 / Notify to channel",
);
assert.equal(
stepTryLabel(
{ kind: "script", script: "ntfy.js", id: "notify", name: "Notify to channel" },
2,
),
"step 3 / notify (Notify to channel)",
);
});
+32 -1
View File
@@ -105,6 +105,19 @@ export function isDagDoc(doc) {
return (doc?.scripts ?? []).some((s) => !isEmptyNeeds(s.needs));
}
export function stepCustomName(step) {
return typeof step?.name === "string" ? step.name.trim() : "";
}
export function stepDisplayName(step, fallback) {
const custom = stepCustomName(step);
if (custom) return custom;
if (fallback) return fallback;
if (step?.kind === "set") return "set";
if (step?.profile) return `profile ${step.profile}`;
return step?.script || "untitled";
}
export function newScriptStep(script, config = {}) {
return {
uiId: nextUiId("step"),
@@ -112,6 +125,7 @@ export function newScriptStep(script, config = {}) {
script,
profile: "",
config: config && typeof config === "object" && !Array.isArray(config) ? { ...config } : {},
name: "",
id: "",
when: "",
needs: null,
@@ -125,6 +139,7 @@ export function newProfileStep(profileName, script = "") {
script,
profile: profileName,
config: {},
name: "",
id: "",
when: "",
needs: null,
@@ -137,6 +152,7 @@ export function newSetStep() {
kind: "set",
script: "set",
expression: "",
name: "",
id: "",
when: "",
needs: null,
@@ -200,6 +216,10 @@ function readOnFailureWorkflow(raw) {
return typeof raw?.onFailureWorkflow === "string" ? raw.onFailureWorkflow : "";
}
function readStepName(raw) {
return typeof raw === "string" ? raw : "";
}
function normalizeStep(step) {
const uiId = nextUiId("step");
if (typeof step === "string") {
@@ -209,6 +229,7 @@ function normalizeStep(step) {
script: step,
profile: "",
config: {},
name: "",
id: "",
when: "",
needs: null,
@@ -221,6 +242,7 @@ function normalizeStep(step) {
script: "",
profile: "",
config: {},
name: "",
id: "",
when: "",
needs: null,
@@ -233,12 +255,13 @@ function normalizeStep(step) {
kind: "set",
script: "set",
expression: typeof spec.expression === "string" ? spec.expression : "",
name: readStepName(step.name),
id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "",
needs: step.needs ?? null,
};
}
const known = new Set(["script", "profile", "config", "id", "when", "needs", "set"]);
const known = new Set(["script", "profile", "config", "name", "id", "when", "needs", "set"]);
/** @type {Record<string, unknown>} */
const extra = {};
for (const [key, value] of Object.entries(step)) {
@@ -254,6 +277,7 @@ function normalizeStep(step) {
script: typeof step.script === "string" ? step.script : "",
profile: typeof step.profile === "string" ? step.profile : "",
config,
name: readStepName(step.name),
id: typeof step.id === "string" ? step.id : "",
when: typeof step.when === "string" ? step.when : "",
needs: step.needs ?? null,
@@ -306,10 +330,16 @@ function normalizeTrigger(raw) {
};
}
function dumpStepName(step, out) {
const name = stepCustomName(step);
if (name) out.name = name;
}
function dumpStep(step) {
if (step.kind === "set") {
/** @type {Record<string, unknown>} */
const out = {};
dumpStepName(step, out);
if (step.id) out.id = step.id;
out.set = {
expression: step.expression ?? "",
@@ -320,6 +350,7 @@ function dumpStep(step) {
}
/** @type {Record<string, unknown>} */
const out = {};
dumpStepName(step, out);
if (step.id) out.id = step.id;
if (step.profile) {
out.profile = step.profile;
+59
View File
@@ -0,0 +1,59 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
parseWorkflowYaml,
stringifyWorkflowDoc,
stepCustomName,
stepDisplayName,
} from "./workflow-doc.js";
test("step name round-trips in yaml", () => {
const { doc, parseError } = parseWorkflowYaml(`
name: demo
scripts:
- name: Notify to channel
script: ntfy.js
config:
url: https://ntfy.sh/demo
- name: Transform
set:
expression: data
`);
assert.equal(parseError, null);
assert.equal(doc.scripts[0].name, "Notify to channel");
assert.equal(doc.scripts[0].script, "ntfy.js");
assert.equal(doc.scripts[0].extra, undefined);
assert.equal(doc.scripts[1].name, "Transform");
assert.equal(doc.scripts[1].kind, "set");
const yaml = stringifyWorkflowDoc(doc);
assert.match(yaml, /name: Notify to channel/);
assert.match(yaml, /script: ntfy.js/);
assert.match(yaml, /name: Transform/);
});
test("empty step name is omitted from yaml", () => {
const { doc } = parseWorkflowYaml(`
scripts:
- name: " "
script: ntfy.js
`);
assert.equal(doc.scripts[0].name, " ");
assert.equal(stepCustomName(doc.scripts[0]), "");
const yaml = stringifyWorkflowDoc(doc);
assert.doesNotMatch(yaml, /^\s*name:/m);
assert.match(yaml, /script: ntfy.js/);
});
test("stepDisplayName prefers custom name", () => {
assert.equal(
stepDisplayName({ kind: "script", script: "ntfy.js", name: "Notify to channel" }),
"Notify to channel",
);
assert.equal(stepDisplayName({ kind: "script", script: "ntfy.js" }), "ntfy.js");
assert.equal(stepDisplayName({ kind: "set" }), "set");
assert.equal(
stepDisplayName({ kind: "script", script: "ntfy.js" }, "fallback.js"),
"fallback.js",
);
});
+4 -1
View File
@@ -1,4 +1,4 @@
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId } from "./workflow-doc.js";
import { isDagDoc, isEmptyNeeds, needsMode, nextStepId, stepCustomName } from "./workflow-doc.js";
export const STEP_NODE_PREFIX = "step:";
export const TRIGGER_NODE_PREFIX = "trig:";
@@ -44,6 +44,8 @@ export function triggerSummary(trigger, owner) {
}
export function stepLabel(step) {
const custom = stepCustomName(step);
if (custom) return custom;
if (step?.kind === "set") return step.id ? `${step.id}: set` : "set";
const target = step?.profile ? `profile ${step.profile}` : step?.script || "untitled";
return step?.id ? `${step.id}: ${target}` : target;
@@ -254,6 +256,7 @@ export function buildGraphElements(doc, positions = {}) {
data: {
layoutKey,
label: stepLabel(s),
named: Boolean(stepCustomName(s)),
kind: s.kind,
stepId: s.id || "",
when: s.when || "",
@@ -6,6 +6,7 @@ import {
canConnectSteps,
enteringDagWouldStripWhen,
removeStepEdge,
stepLabel,
stepPredecessors,
stepSuccessors,
wouldCreateCycle,
@@ -113,3 +114,15 @@ test("stepSuccessors / stepPredecessors DAG multi", () => {
assert.deepEqual(stepPredecessors(scripts, "d"), ["a"]);
assert.deepEqual(stepPredecessors(scripts, "a"), []);
});
test("stepLabel uses custom name", () => {
assert.equal(
stepLabel({ kind: "script", script: "ntfy.js", name: "Notify to channel" }),
"Notify to channel",
);
assert.equal(stepLabel({ kind: "script", script: "ntfy.js", id: "n" }), "n: ntfy.js");
assert.equal(
stepLabel({ kind: "set", id: "xform", name: "Shape payload" }),
"Shape payload",
);
});
+1 -16
View File
@@ -1,5 +1,5 @@
import { Link, useSearchParams } from "react-router-dom";
import { useOwners, useRuns } from "../api/hooks.js";
import { useRuns } from "../api/hooks.js";
import { formatTime, StatusBadge } from "../lib/format";
const PAGE_SIZES = [25, 50, 100];
@@ -47,7 +47,6 @@ export function EventsPage() {
const [params, setParams] = useSearchParams();
const workflow = params.get("workflow") || "";
const status = params.get("status") || "";
const owner = params.get("owner") || "";
const trigger = params.get("trigger") || "";
const afterDate = params.get("after") || "";
const beforeDate = params.get("before") || "";
@@ -57,11 +56,9 @@ export function EventsPage() {
const sort = params.get("sort") || "started_at";
const order = params.get("order") === "asc" ? "asc" : "desc";
const { data: owners = [] } = useOwners();
const { data, isLoading } = useRuns({
workflow: workflow || undefined,
status: status || undefined,
owner: owner || undefined,
trigger: trigger || undefined,
after: dateToAfterIso(afterDate),
before: dateToBeforeIso(beforeDate),
@@ -116,18 +113,6 @@ export function EventsPage() {
<option value="failed">failed</option>
<option value="skipped">skipped</option>
</select>
<select
className="select select-sm w-full sm:max-w-xs"
value={owner}
onChange={(e) => update("owner", e.target.value)}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
<select
className="select select-sm w-full sm:max-w-xs"
value={trigger}
+16 -63
View File
@@ -2,13 +2,13 @@ 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 { FormSelect } from "../components/FormControls.jsx";
import { useDeleteProfile, useProfileUsage, useProfiles } from "../api/hooks.js";
import { Modal } from "../components/Modal.jsx";
import { ProfileEditorModal } from "../components/ProfileEditorModal.jsx";
import { ScriptIcon } from "../components/ScriptIcon.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
function previewConfig(config) {
if (!config || typeof config !== "object") return "";
@@ -37,50 +37,32 @@ export function ProfilesPage() {
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 { data: profiles = [], isLoading } = useProfiles();
const del = useDeleteProfile();
const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null);
const listPath = ownerFilter
? `/profiles?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles";
const listPath = "/profiles";
const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute,
isEditRoute,
listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
onBeforeOpenNew: () => {
if (params.get("owner")) setOwnerFilter(params.get("owner"));
},
buildNewEditor: () => {
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
return {
newRouteKey: () => `new:${params.get("name") || ""}:${params.get("script") || ""}`,
canOpenNew: () => true,
buildNewEditor: () => ({
mode: "add",
initial: {
owner,
owner: DEFAULT_OWNER,
name: params.get("name") || "",
script: params.get("script") || "",
config: {},
description: "",
},
};
},
}),
editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => {
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return !isLoading;
},
canOpenEdit: () => !isLoading,
onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => {
const row = profiles.find((p) => p.owner === routeOwner && p.name === routeName);
@@ -100,7 +82,7 @@ export function ProfilesPage() {
return {
mode: "add",
initial: {
owner: routeOwner,
owner: routeOwner || DEFAULT_OWNER,
name: routeName,
script: "",
config: {},
@@ -108,8 +90,8 @@ export function ProfilesPage() {
},
};
},
newDeps: [params, owners, ownerFilter],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, profiles],
newDeps: [params],
editDeps: [routeOwner, routeName, isLoading, profiles],
});
useEffect(() => {
@@ -121,43 +103,15 @@ export function ProfilesPage() {
<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">
<FormSelect
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>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/profiles/new?owner=${encodeURIComponent(ownerFilter)}`
: "/profiles/new",
)
}
onClick={() => navigate("/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;
@@ -174,7 +128,7 @@ export function ProfilesPage() {
const highlighted =
highlightName &&
row.name === highlightName &&
(!ownerFilter || row.owner === ownerFilter);
(!routeOwner || row.owner === routeOwner);
const preview = previewConfig(row.config);
return (
<article
@@ -191,7 +145,6 @@ export function ProfilesPage() {
<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}>
@@ -275,7 +228,7 @@ function DeleteProfileDialog({ profile, del, onClose }) {
const usage = useProfileUsage(profile.id, true);
const usages = usage.data ?? [];
const used = usages.length > 0;
const title = `Delete ${profile.owner}/${profile.name}?`;
const title = `Delete ${profile.name}?`;
return (
<Modal open onClose={onClose} boxClassName="max-w-md" aria-label={title}>
+2 -19
View File
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useDryRunScript,
useOwners,
useSaveScript,
useScript,
} from "../api/hooks.js";
@@ -19,6 +18,7 @@ import {
contextFromMeta,
prettyJson,
} from "../lib/script.js";
import { DEFAULT_OWNER } from "../lib/tenant.js";
import { useNotifications } from "../notifications.jsx";
export function ScriptDryRunPage() {
@@ -31,8 +31,6 @@ export function ScriptDryRunPage() {
const existing = useScript(name, stateContent == null);
const dryRun = useDryRunScript();
const save = useSaveScript();
const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState("default");
const [content, setContent] = useState("");
const [inputJson, setInputJson] = useState(DEFAULT_INPUT_CONTEXT);
@@ -126,7 +124,7 @@ export function ScriptDryRunPage() {
data: ctx.data,
context: ctx.context,
config: ctx.config,
owner,
owner: DEFAULT_OWNER,
});
}
@@ -151,21 +149,6 @@ export function ScriptDryRunPage() {
<span className="text-sm opacity-60">{lastRun.durationMs}ms</span>
) : null}
<div className="flex-1" />
<label className="flex items-center gap-1 text-sm">
<span className="opacity-60 hidden sm:inline">Owner</span>
<select
className="select select-sm"
value={owner}
onChange={(e) => setOwner(e.target.value)}
>
{owners.includes(owner) ? null : <option value={owner}>{owner}</option>}
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
</label>
<button
type="button"
className="btn btn-primary btn-sm"
+22 -66
View File
@@ -2,12 +2,12 @@ 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 { useDeleteSecret, useOwners, useSecrets } from "../api/hooks.js";
import { useDeleteSecret, useSecrets } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { SecretEditorModal } from "../components/SecretEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
export function SecretsPage() {
const navigate = useNavigate();
@@ -17,51 +17,36 @@ export function SecretsPage() {
const isNewRoute = /\/secrets\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: secrets = [], isLoading } = useSecrets(ownerFilter || undefined);
const { data: secrets = [], isLoading } = useSecrets();
const del = useDeleteSecret();
const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null);
const listPath = ownerFilter
? `/secrets?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets";
const listPath = "/secrets";
const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute,
isEditRoute,
listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
onBeforeOpenNew: () => {
if (params.get("owner")) setOwnerFilter(params.get("owner"));
},
buildNewEditor: () => {
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
return {
newRouteKey: () => `new:${params.get("name") || ""}`,
canOpenNew: () => true,
buildNewEditor: () => ({
mode: "add",
initial: { owner, name: params.get("name") || "" },
};
initial: {
owner: DEFAULT_OWNER,
name: params.get("name") || "",
},
}),
editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => {
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return true;
},
canOpenEdit: () => true,
onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => ({
mode: "replace",
initial: { owner: routeOwner, name: routeName },
}),
newDeps: [params, owners, ownerFilter],
editDeps: [routeOwner, routeName, ownerFilter],
newDeps: [params],
editDeps: [routeOwner, routeName],
});
useEffect(() => {
@@ -73,43 +58,15 @@ export function SecretsPage() {
<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">Secrets</h1>
<div className="flex gap-2">
<FormSelect
value={ownerFilter}
onChange={(e) => {
setOwnerFilter(e.target.value);
setHighlightName("");
navigate(
e.target.value
? `/secrets?owner=${encodeURIComponent(e.target.value)}`
: "/secrets",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/secrets/new?owner=${encodeURIComponent(ownerFilter)}`
: "/secrets/new",
)
}
onClick={() => navigate("/secrets/new")}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div>
{isLoading ? (
<span className="loading loading-spinner" />
@@ -120,7 +77,6 @@ export function SecretsPage() {
<table className="table table-sm">
<thead>
<tr>
<th>Owner</th>
<th>Name</th>
<th>Updated</th>
<th />
@@ -129,16 +85,16 @@ export function SecretsPage() {
<tbody>
{secrets.map((s) => {
const highlighted =
highlightName &&
s.name === highlightName &&
(!ownerFilter || s.owner === ownerFilter);
highlightName && s.name === highlightName && s.owner === routeOwner;
const highlightByNameOnly =
highlightName && s.name === highlightName && !routeOwner;
const isHi = highlighted || highlightByNameOnly;
return (
<tr
key={s.id}
ref={highlighted ? highlightRef : undefined}
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
ref={isHi ? highlightRef : undefined}
className={`hover ${isHi ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
>
<td className="font-mono">{s.owner}</td>
<td className="font-mono">{s.name}</td>
<td className="whitespace-nowrap">{formatTime(s.updated_at)}</td>
<td className="text-right whitespace-nowrap">
@@ -186,7 +142,7 @@ export function SecretsPage() {
<ConfirmDialog
open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete.owner}/${confirmDelete.name}?` : ""}
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message="This cannot be undone. Workflows that retrieve this name will fail."
error={del.isError ? errorMessage(del.error) : null}
loading={del.isPending}
+16 -64
View File
@@ -2,12 +2,12 @@ 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 { useDeleteVariable, useOwners, useVariables } from "../api/hooks.js";
import { useDeleteVariable, useVariables } from "../api/hooks.js";
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { FormSelect } from "../components/FormControls.jsx";
import { VariableEditorModal } from "../components/VariableEditorModal.jsx";
import { useRouteDrivenModal } from "../hooks/useRouteDrivenModal.js";
import { formatTime } from "../lib/format";
import { DEFAULT_OWNER } from "../lib/tenant.js";
function displayValue(value) {
if (typeof value === "string") return value === "" ? '""' : value;
@@ -22,49 +22,31 @@ export function VariablesPage() {
const isNewRoute = /\/variables\/new\/?$/.test(location.pathname);
const isEditRoute = Boolean(routeOwner && routeName);
const { data: owners = [] } = useOwners();
const [ownerFilter, setOwnerFilter] = useState(
() => routeOwner || params.get("owner") || "",
);
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
const { data: variables = [], isLoading } = useVariables();
const del = useDeleteVariable();
const [confirmDelete, setConfirmDelete] = useState(null);
const [highlightName, setHighlightName] = useState(() => routeName || "");
const highlightRef = useRef(null);
const listPath = ownerFilter
? `/variables?owner=${encodeURIComponent(ownerFilter)}`
: "/variables";
const listPath = "/variables";
const { editor, closeEditor } = useRouteDrivenModal({
isNewRoute,
isEditRoute,
listPath,
newRouteKey: () => `new:${params.get("owner") || ""}:${params.get("name") || ""}`,
canOpenNew: () => Boolean(params.get("owner") || ownerFilter || owners.length > 0),
onBeforeOpenNew: () => {
if (params.get("owner")) setOwnerFilter(params.get("owner"));
},
buildNewEditor: () => {
const owner = params.get("owner") || ownerFilter || owners[0] || "default";
return {
newRouteKey: () => `new:${params.get("name") || ""}`,
canOpenNew: () => true,
buildNewEditor: () => ({
mode: "add",
initial: {
owner,
owner: DEFAULT_OWNER,
name: params.get("name") || "",
type: "string",
value: "",
},
};
},
}),
editRouteKey: () => `edit:${routeOwner}/${routeName}`,
canOpenEdit: () => {
if (ownerFilter !== routeOwner) {
setOwnerFilter(routeOwner);
return false;
}
return !isLoading;
},
canOpenEdit: () => !isLoading,
onOpenEdit: () => setHighlightName(routeName),
buildEditEditor: () => {
const row = variables.find((v) => v.owner === routeOwner && v.name === routeName);
@@ -82,15 +64,15 @@ export function VariablesPage() {
return {
mode: "add",
initial: {
owner: routeOwner,
owner: routeOwner || DEFAULT_OWNER,
name: routeName,
type: "string",
value: "",
},
};
},
newDeps: [params, owners, ownerFilter],
editDeps: [routeOwner, routeName, ownerFilter, isLoading, variables],
newDeps: [params],
editDeps: [routeOwner, routeName, isLoading, variables],
});
useEffect(() => {
@@ -102,43 +84,15 @@ export function VariablesPage() {
<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">Variables</h1>
<div className="flex gap-2">
<FormSelect
value={ownerFilter}
onChange={(e) => {
setOwnerFilter(e.target.value);
setHighlightName("");
navigate(
e.target.value
? `/variables?owner=${encodeURIComponent(e.target.value)}`
: "/variables",
{ replace: true },
);
}}
>
<option value="">all owners</option>
{owners.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</FormSelect>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() =>
navigate(
ownerFilter
? `/variables/new?owner=${encodeURIComponent(ownerFilter)}`
: "/variables/new",
)
}
onClick={() => navigate("/variables/new")}
>
<LuPlus className="size-4" />
Add
</button>
</div>
</div>
{isLoading ? (
<span className="loading loading-spinner" />
@@ -149,7 +103,6 @@ export function VariablesPage() {
<table className="table table-sm">
<thead>
<tr>
<th>Owner</th>
<th>Name</th>
<th>Type</th>
<th>Value</th>
@@ -162,14 +115,13 @@ export function VariablesPage() {
const highlighted =
highlightName &&
row.name === highlightName &&
(!ownerFilter || row.owner === ownerFilter);
(!routeOwner || row.owner === routeOwner);
return (
<tr
key={row.id}
ref={highlighted ? highlightRef : undefined}
className={`hover ${highlighted ? "bg-primary/10 outline outline-1 outline-primary/40" : ""}`}
>
<td className="font-mono">{row.owner}</td>
<td className="font-mono">{row.name}</td>
<td className="font-mono text-xs">{row.type}</td>
<td
@@ -224,7 +176,7 @@ export function VariablesPage() {
<ConfirmDialog
open={Boolean(confirmDelete)}
title={confirmDelete ? `Delete ${confirmDelete.owner}/${confirmDelete.name}?` : ""}
title={confirmDelete ? `Delete ${confirmDelete.name}?` : ""}
message={
confirmDelete
? `This cannot be undone. Workflows that reference $VAR_${confirmDelete.name} will fail.`
+3 -31
View File
@@ -4,7 +4,6 @@ import { LuArrowLeft, LuCopy, LuPause, LuPlay, LuSave } from "react-icons/lu";
import { errorMessage } from "../api/client.js";
import {
useCreateWorkflow,
useOwners,
useSaveWorkflow,
useSetWorkflowEnabled,
useWorkflow,
@@ -24,6 +23,7 @@ import {
import { ConfirmDialog } from "../components/ConfirmDialog.jsx";
import { WorkflowRevisionBanner } from "../components/workflow/WorkflowRevisionBanner.jsx";
import { NEW_WORKFLOW_YAML, parseWorkflowYaml } from "../lib/workflow-doc.js";
import { DEFAULT_OWNER } from "../lib/tenant.js";
import { useNotifications } from "../notifications.jsx";
function WorkflowEditorLayout({
@@ -100,26 +100,15 @@ function WorkflowEditorLayout({
export function WorkflowNewPage() {
const navigate = useNavigate();
const { notify } = useNotifications();
const { data: owners = [] } = useOwners();
const [owner, setOwner] = useState("local");
const [ready, setReady] = useState(() => shouldSkipNewWorkflowPreset());
const [content, setContent] = useState(NEW_WORKFLOW_YAML);
const [savedYaml, setSavedYaml] = useState(NEW_WORKFLOW_YAML);
const [saveWarnings, setSaveWarnings] = useState(null);
const create = useCreateWorkflow();
useEffect(() => {
// Prefer local when it exists; otherwise first owner. Do not clobber a typed value.
setOwner((prev) => {
if (prev && prev !== "local") return prev;
if (owners.includes("local") || owners.length === 0) return "local";
return owners[0];
});
}, [owners]);
function onSave(saveAnyway = false) {
create.mutate(
{ owner, content, saveAnyway },
{ owner: DEFAULT_OWNER, content, saveAnyway },
{
onSuccess: (data) => {
setSaveWarnings(null);
@@ -159,34 +148,17 @@ export function WorkflowNewPage() {
title="New workflow"
onSave={() => onSave(false)}
savePending={create.isPending}
saveDisabled={!owner}
saveError={create.isError && !saveWarnings ? errorMessage(create.error) : null}
>
<div className="flex min-h-0 flex-1 flex-col gap-3">
<WorkflowVisualEditor
yaml={content}
onYamlChange={setContent}
owner={owner}
owner={DEFAULT_OWNER}
file=""
savedYaml={savedYaml}
showTest={false}
extraChrome={
<input
className="input input-sm w-full sm:max-w-xs"
placeholder="owner"
value={owner}
onChange={(e) => setOwner(e.target.value)}
list="new-workflow-owners"
required
/>
}
/>
<datalist id="new-workflow-owners">
{owners.includes("local") ? null : <option value="local" />}
{owners.map((o) => (
<option key={o} value={o} />
))}
</datalist>
</div>
</WorkflowEditorLayout>
{saveWarnings ? (
@@ -61,7 +61,6 @@ export function WorkflowTrashPage() {
<tr>
<th>Name</th>
<th>File</th>
<th>Owner</th>
<th>Deleted</th>
<th>Age</th>
<th>Purge in</th>
@@ -73,7 +72,6 @@ export function WorkflowTrashPage() {
<tr key={item.id} className="hover">
<td>{item.name ?? "—"}</td>
<td className="font-mono text-xs">{item.file}</td>
<td className="font-mono text-xs">{item.owner}</td>
<td className="whitespace-nowrap">{formatTime(item.deleted_at)}</td>
<td>{formatAge(item.age_ms)}</td>
<td>
-9
View File
@@ -16,7 +16,6 @@ import { formatTime, WorkflowStatusBadge } from "../lib/format";
const SORT_COLUMNS = [
{ key: "name", label: "Name", defaultOrder: "asc" },
{ key: "owner", label: "Owner", defaultOrder: "asc" },
{ key: "status", label: "Status", defaultOrder: "asc" },
{ key: "lastModifiedAt", label: "Last modified", defaultOrder: "desc" },
{ key: "lastInvokedAt", label: "Last run", defaultOrder: "desc" },
@@ -59,12 +58,6 @@ function compareName(a, b) {
function compareWorkflows(a, b, sort) {
if (sort === "name") return compareName(a, b);
if (sort === "owner") {
const byOwner = String(a.owner ?? "").localeCompare(String(b.owner ?? ""), undefined, {
sensitivity: "base",
});
return byOwner !== 0 ? byOwner : compareName(a, b);
}
if (sort === "status") {
const byStatus = statusSortKey(a).localeCompare(statusSortKey(b));
return byStatus !== 0 ? byStatus : compareName(a, b);
@@ -196,7 +189,6 @@ export function WorkflowsPage() {
<thead>
<tr>
<SortHeader column="name" label="Name" sort={sort} order={order} onSort={setSort} />
<SortHeader column="owner" label="Owner" sort={sort} order={order} onSort={setSort} />
<SortHeader column="status" label="Status" sort={sort} order={order} onSort={setSort} />
<th>Triggers</th>
<SortHeader
@@ -244,7 +236,6 @@ export function WorkflowsPage() {
</span>
) : null}
</td>
<td className="font-mono text-xs">{w.owner}</td>
<td>
<WorkflowStatusBadge workflow={w} />
</td>