feat(variables): introduce variable management system
- Added a new module for managing variables, including creation, retrieval, updating, and deletion functionalities. - Implemented a new API endpoint for variable operations, allowing users to interact with variables through HTTP requests. - Enhanced the configuration reference system to support variable references, replacing the previous KV reference system. - Updated existing workflows and scripts to utilize the new variable management features, improving flexibility in configuration handling. - Introduced smoke tests to validate the functionality of the variable system, ensuring reliability and robustness. This update significantly enhances the ability to manage dynamic variables within workflows, improving overall usability and maintainability.
This commit is contained in:
@@ -17,6 +17,7 @@ import { AuthProfilesPage } from "./pages/AuthProfilesPage.jsx";
|
||||
import { ResponsesPage } from "./pages/ResponsesPage.jsx";
|
||||
import { UsersPage } from "./pages/UsersPage.jsx";
|
||||
import { SecretsPage } from "./pages/SecretsPage.jsx";
|
||||
import { VariablesPage } from "./pages/VariablesPage.jsx";
|
||||
|
||||
export function App() {
|
||||
const qc = useQueryClient();
|
||||
@@ -61,6 +62,7 @@ export function App() {
|
||||
<Route path="/events" element={<EventsPage />} />
|
||||
<Route path="/events/:id" element={<EventDetailPage />} />
|
||||
<Route path="/kv" element={<KvPage />} />
|
||||
<Route path="/variables" element={<VariablesPage />} />
|
||||
<Route path="/auth" element={<AuthProfilesPage />} />
|
||||
<Route path="/responses" element={<ResponsesPage />} />
|
||||
{user.role === "admin" ? (
|
||||
|
||||
@@ -336,6 +336,33 @@ export function useDeleteSecret() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useVariables(owner) {
|
||||
return useQuery({
|
||||
queryKey: ["variables", owner ?? "all"],
|
||||
queryFn: async () => {
|
||||
const params = owner ? { owner } : {};
|
||||
return (await api.get("/variables", { params })).data.variables;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertVariable() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (body) => (await api.put("/variables", body)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteVariable() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: async (id) =>
|
||||
(await api.delete(`/variables/${encodeURIComponent(id)}`)).data,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ["variables"] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useKvNamespaces() {
|
||||
return useQuery({
|
||||
queryKey: ["kv", "namespaces"],
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
LuMoon,
|
||||
LuShield,
|
||||
LuSun,
|
||||
LuTags,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { useLogout } from "../api/hooks.js";
|
||||
@@ -24,6 +25,7 @@ const links = [
|
||||
{ to: "/workflows", label: "Workflows", icon: LuGitBranch },
|
||||
{ to: "/events", label: "Events", icon: LuActivity },
|
||||
{ to: "/kv", label: "KV", icon: LuDatabase },
|
||||
{ to: "/variables", label: "Variables", icon: LuTags },
|
||||
{ to: "/auth", label: "Auth", icon: LuShield },
|
||||
{ to: "/responses", label: "Responses", icon: LuFileText },
|
||||
];
|
||||
|
||||
@@ -9,7 +9,7 @@ const MULTILINE_KEYS = new Set(["expression", "jsonata"]);
|
||||
const CONFIG_REF_PREFIXES = [
|
||||
{ prefix: "$SECRET_", label: "secret" },
|
||||
{ prefix: "$CONTEXT_", label: "context" },
|
||||
{ prefix: "$KV_", label: "KV" },
|
||||
{ prefix: "$VAR_", label: "variable" },
|
||||
];
|
||||
|
||||
function describeConfigRef(value) {
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
import { useState } from "react";
|
||||
import { LuPencil, LuPlus, LuTrash2, LuX } from "react-icons/lu";
|
||||
import { errorMessage } from "../api/client.js";
|
||||
import {
|
||||
useDeleteVariable,
|
||||
useOwners,
|
||||
useUpsertVariable,
|
||||
useVariables,
|
||||
} from "../api/hooks.js";
|
||||
import { formatTime } from "../lib/format.jsx";
|
||||
|
||||
const TYPES = ["string", "number", "boolean"];
|
||||
|
||||
function defaultValue(type) {
|
||||
if (type === "boolean") return false;
|
||||
if (type === "number") return "";
|
||||
return "";
|
||||
}
|
||||
|
||||
function displayValue(value) {
|
||||
if (typeof value === "string") return value === "" ? '""' : value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function VariablesPage() {
|
||||
const { data: owners = [] } = useOwners();
|
||||
const [ownerFilter, setOwnerFilter] = useState("");
|
||||
const { data: variables = [], isLoading } = useVariables(ownerFilter || undefined);
|
||||
const upsert = useUpsertVariable();
|
||||
const del = useDeleteVariable();
|
||||
const [mode, setMode] = useState(null);
|
||||
const [form, setForm] = useState({
|
||||
owner: "",
|
||||
name: "",
|
||||
type: "string",
|
||||
value: "",
|
||||
});
|
||||
const [formError, setFormError] = useState(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(null);
|
||||
|
||||
function openAdd() {
|
||||
setMode("add");
|
||||
setFormError(null);
|
||||
setForm({
|
||||
owner: ownerFilter || owners[0] || "default",
|
||||
name: "",
|
||||
type: "string",
|
||||
value: "",
|
||||
});
|
||||
}
|
||||
|
||||
function openEdit(row) {
|
||||
setMode("edit");
|
||||
setFormError(null);
|
||||
setForm({
|
||||
owner: row.owner,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
value: row.type === "number" ? String(row.value) : row.value,
|
||||
});
|
||||
}
|
||||
|
||||
function closeForm() {
|
||||
setMode(null);
|
||||
setFormError(null);
|
||||
setForm({ owner: "", name: "", type: "string", value: "" });
|
||||
}
|
||||
|
||||
function onTypeChange(type) {
|
||||
setForm({ ...form, type, value: defaultValue(type) });
|
||||
}
|
||||
|
||||
function onSubmit(e) {
|
||||
e.preventDefault();
|
||||
let value = form.value;
|
||||
if (form.type === "number") {
|
||||
value = Number(form.value);
|
||||
if (!Number.isFinite(value)) {
|
||||
setFormError("value must be a finite number");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (form.type === "boolean") {
|
||||
value = form.value === true;
|
||||
}
|
||||
setFormError(null);
|
||||
upsert.mutate(
|
||||
{ owner: form.owner, name: form.name, type: form.type, value },
|
||||
{ onSuccess: closeForm },
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">Variables</h1>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="select select-sm"
|
||||
value={ownerFilter}
|
||||
onChange={(e) => setOwnerFilter(e.target.value)}
|
||||
>
|
||||
<option value="">all owners</option>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openAdd}>
|
||||
<LuPlus className="size-4" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<span className="loading loading-spinner" />
|
||||
) : variables.length === 0 ? (
|
||||
<p className="text-sm opacity-60">No variables yet.</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Owner</th>
|
||||
<th>Name</th>
|
||||
<th>Type</th>
|
||||
<th>Value</th>
|
||||
<th>Updated</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{variables.map((row) => (
|
||||
<tr key={row.id} className="hover">
|
||||
<td className="font-mono">{row.owner}</td>
|
||||
<td className="font-mono">{row.name}</td>
|
||||
<td className="font-mono text-xs">{row.type}</td>
|
||||
<td className="font-mono text-xs max-w-xs truncate" title={displayValue(row.value)}>
|
||||
{displayValue(row.value)}
|
||||
</td>
|
||||
<td className="whitespace-nowrap">{formatTime(row.updated_at)}</td>
|
||||
<td className="text-right whitespace-nowrap">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs"
|
||||
title="Edit"
|
||||
onClick={() => openEdit(row)}
|
||||
>
|
||||
<LuPencil className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-xs text-error"
|
||||
title="Delete"
|
||||
onClick={() => setConfirmDelete(row)}
|
||||
>
|
||||
<LuTrash2 className="size-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode ? (
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
className="fieldset bg-base-100 border-base-300 rounded-box max-w-md border p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<legend className="fieldset-legend">
|
||||
{mode === "add" ? "New variable" : `Edit ${form.owner}/${form.name}`}
|
||||
</legend>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost btn-sm btn-square"
|
||||
onClick={closeForm}
|
||||
aria-label="Close"
|
||||
>
|
||||
<LuX className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
{mode === "add" ? (
|
||||
<>
|
||||
<label className="label">Owner</label>
|
||||
{owners.length > 0 ? (
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
>
|
||||
{owners.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{o}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<input
|
||||
className="input w-full"
|
||||
value={form.owner}
|
||||
onChange={(e) => setForm({ ...form, owner: e.target.value })}
|
||||
required
|
||||
/>
|
||||
)}
|
||||
<label className="label">Name</label>
|
||||
<input
|
||||
className="input w-full font-mono"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
pattern="[A-Za-z0-9._-]+"
|
||||
title="Letters, numbers, dots, underscores, hyphens"
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
<label className="label">Type</label>
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.type}
|
||||
onChange={(e) => onTypeChange(e.target.value)}
|
||||
>
|
||||
{TYPES.map((t) => (
|
||||
<option key={t} value={t}>
|
||||
{t}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">Value</label>
|
||||
{form.type === "boolean" ? (
|
||||
<select
|
||||
className="select w-full"
|
||||
value={form.value === true ? "true" : "false"}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value === "true" })}
|
||||
>
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
) : form.type === "number" ? (
|
||||
<input
|
||||
type="number"
|
||||
className="input w-full font-mono"
|
||||
value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
required
|
||||
step="any"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
className="textarea w-full font-mono min-h-24"
|
||||
value={form.value}
|
||||
onChange={(e) => setForm({ ...form, value: e.target.value })}
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
<p className="text-xs opacity-60 mt-1">
|
||||
Stored in plaintext. Use Secrets for credentials. In workflows use{" "}
|
||||
<span className="font-mono">$VAR_name</span> as a whole field.
|
||||
</p>
|
||||
{formError ? <p className="text-error text-sm">{formError}</p> : null}
|
||||
{upsert.isError ? (
|
||||
<p className="text-error text-sm">{errorMessage(upsert.error)}</p>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary mt-2" disabled={upsert.isPending}>
|
||||
Save
|
||||
</button>
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{confirmDelete ? (
|
||||
<dialog className="modal modal-open">
|
||||
<div className="modal-box">
|
||||
<h3 className="font-bold">
|
||||
Delete {confirmDelete.owner}/{confirmDelete.name}?
|
||||
</h3>
|
||||
<p className="text-sm mt-2">
|
||||
This cannot be undone. Workflows that reference $VAR_{confirmDelete.name} will fail.
|
||||
</p>
|
||||
{del.isError ? (
|
||||
<p className="text-error text-sm mt-2">{errorMessage(del.error)}</p>
|
||||
) : null}
|
||||
<div className="modal-action">
|
||||
<button type="button" className="btn btn-ghost" onClick={() => setConfirmDelete(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-error"
|
||||
disabled={del.isPending}
|
||||
onClick={() =>
|
||||
del.mutate(confirmDelete.id, { onSuccess: () => setConfirmDelete(null) })
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button type="button" onClick={() => setConfirmDelete(null)}>
|
||||
close
|
||||
</button>
|
||||
</form>
|
||||
</dialog>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user