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>
This commit is contained in:
@@ -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`.
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,49 +80,23 @@ 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
|
||||
className="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>
|
||||
</>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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="font-mono">{form.name}</span>
|
||||
<span className="ml-2 opacity-60">(name cannot be changed)</span>
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -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,52 +34,24 @@ 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
|
||||
className="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"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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"
|
||||
/>
|
||||
</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,45 +72,17 @@ 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
|
||||
className="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"
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Name</span>
|
||||
<FormInput
|
||||
className="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"
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="form-control w-full">
|
||||
<span className="label py-0 text-sm">Type</span>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
/** Default owner folder for new resources (latent tenant id). */
|
||||
export const DEFAULT_OWNER = "local";
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner,
|
||||
name: params.get("name") || "",
|
||||
script: params.get("script") || "",
|
||||
config: {},
|
||||
description: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
newRouteKey: () => `new:${params.get("name") || ""}:${params.get("script") || ""}`,
|
||||
canOpenNew: () => true,
|
||||
buildNewEditor: () => ({
|
||||
mode: "add",
|
||||
initial: {
|
||||
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,42 +103,14 @@ 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",
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate("/profiles/new")}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-sm opacity-70">
|
||||
@@ -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}>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
mode: "add",
|
||||
initial: { owner, name: params.get("name") || "" },
|
||||
};
|
||||
},
|
||||
newRouteKey: () => `new:${params.get("name") || ""}`,
|
||||
canOpenNew: () => true,
|
||||
buildNewEditor: () => ({
|
||||
mode: "add",
|
||||
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,42 +58,14 @@ 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",
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate("/secrets/new")}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -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}
|
||||
|
||||
@@ -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 {
|
||||
mode: "add",
|
||||
initial: {
|
||||
owner,
|
||||
name: params.get("name") || "",
|
||||
type: "string",
|
||||
value: "",
|
||||
},
|
||||
};
|
||||
},
|
||||
newRouteKey: () => `new:${params.get("name") || ""}`,
|
||||
canOpenNew: () => true,
|
||||
buildNewEditor: () => ({
|
||||
mode: "add",
|
||||
initial: {
|
||||
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,42 +84,14 @@ 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",
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => navigate("/variables/new")}
|
||||
>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
@@ -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.`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user