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:
2026-08-16 22:23:49 +07:00
parent fd0865063b
commit 6c13e45213
16 changed files with 855 additions and 47 deletions
+18 -18
View File
@@ -1,17 +1,16 @@
import { coerceCredentialString } from "./http-trigger-auth.js";
import { kvGet } from "./kv-store.js";
import { assertSecretName, getSecretPlaintext } from "./secrets-store.js";
import { isSecret } from "./secret-value.js";
import { assertVariableName, getVariablePlain } from "./variables-store.js";
/** Longest prefix first so `$CONTEXT_` is not confused with `$KV_`. */
const PREFIXES = [
{ kind: "context", prefix: "$CONTEXT_" },
{ kind: "secret", prefix: "$SECRET_" },
{ kind: "kv", prefix: "$KV_" },
{ kind: "var", prefix: "$VAR_" },
];
/**
* @typedef {{ kind: "secret" | "kv" | "context", name: string, raw: string }} ConfigRef
* @typedef {{ kind: "secret" | "context" | "var", name: string, raw: string }} ConfigRef
* @typedef {{ owner: string, workflowKey: string, context?: unknown }} ConfigRefCtx
*/
@@ -32,7 +31,7 @@ export function parseConfigRef(value) {
}
/**
* Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$KV_` / `$CONTEXT_`
* Walk config (objects/arrays) and replace whole-value `$SECRET_` / `$CONTEXT_` / `$VAR_`
* strings. Does not walk trigger data.
*
* @param {unknown} value
@@ -69,7 +68,7 @@ export async function resolveConfigRefs(value, ctx, seen = new WeakSet()) {
/**
* @param {string} value
* @param {ConfigRefCtx} ctx
* @returns {Promise<string>}
* @returns {Promise<string | number | boolean>}
*/
async function resolveStringRef(value, ctx) {
const ref = parseConfigRef(value);
@@ -78,8 +77,8 @@ async function resolveStringRef(value, ctx) {
if (ref.kind === "secret") {
return resolveSecretRef(ref, ctx);
}
if (ref.kind === "kv") {
return resolveKvRef(ref, ctx);
if (ref.kind === "var") {
return resolveVarRef(ref, ctx);
}
return resolveContextRef(ref, ctx);
}
@@ -105,21 +104,22 @@ async function resolveSecretRef(ref, ctx) {
/**
* @param {ConfigRef} ref
* @param {ConfigRefCtx} ctx
* @returns {Promise<string>}
* @returns {Promise<string | number | boolean>}
*/
async function resolveKvRef(ref, ctx) {
async function resolveVarRef(ref, ctx) {
if (ref.name.length === 0) {
throw new Error(`config ref ${ref.raw}: empty KV key`);
throw new Error(`config ref ${ref.raw}: empty variable name`);
}
const raw = await kvGet(ctx.workflowKey, ref.name);
if (raw == null) {
throw new Error(`config ref ${ref.raw}: KV "${ref.name}" not found`);
try {
assertVariableName(ref.name);
} catch {
throw new Error(`config ref ${ref.raw}: invalid variable name`);
}
const coerced = coerceCredentialString(raw);
if (coerced == null) {
throw new Error(`config ref ${ref.raw}: KV "${ref.name}" is not a scalar`);
const value = await getVariablePlain(ctx.owner, ref.name);
if (value == null) {
throw new Error(`config ref ${ref.raw}: variable "${ref.name}" not found`);
}
return coerced;
return value;
}
/**
@@ -0,0 +1,26 @@
/**
* @param {import("knex").Knex} knex
*/
export async function up(knex) {
await knex.schema.createTable("variables", (t) => {
t.text("id").primary();
t.text("owner").notNullable();
t.text("name").notNullable();
t.text("type").notNullable();
t.text("value").notNullable();
t.text("created_at").notNullable();
t.text("updated_at").notNullable();
t.unique(["owner", "name"]);
});
await knex.schema.raw(
"CREATE INDEX variables_owner_name_idx ON variables (owner, name)",
);
}
/**
* @param {import("knex").Knex} knex
*/
export async function down(knex) {
await knex.schema.dropTableIfExists("variables");
}
+2
View File
@@ -17,6 +17,7 @@ import runsPlugin from "./src/api/runs.js";
import dashboardPluginFactory from "./src/api/dashboard.js";
import secretsPlugin from "./src/api/secrets.js";
import kvPlugin from "./src/api/kv.js";
import variablesPlugin from "./src/api/variables.js";
import httpPagesPlugin from "./src/api/http-pages.js";
import httpAuthsPlugin from "./src/api/http-auths.js";
import { WEB_DIST } from "./paths.js";
@@ -92,6 +93,7 @@ await server.register(
await api.register(authPlugin);
await api.register(usersPlugin);
await api.register(secretsPlugin);
await api.register(variablesPlugin);
await api.register(kvPlugin);
await api.register(httpPagesPlugin);
await api.register(httpAuthsPlugin);
+21
View File
@@ -10,6 +10,7 @@ import { SCRIPTS_DIR } from "./paths.js";
import { isSecret, Secret, unwrapSecretsDeep } from "./secret-value.js";
import { getHttpPageByName, getHttpTemplateByName } from "./http-pages-store.js";
import { getSecretPlaintext } from "./secrets-store.js";
import { getVariablePlain } from "./variables-store.js";
const hostRequire = createRequire(import.meta.url);
@@ -301,6 +302,24 @@ function createRestrictedRequire(screenedAxios) {
};
}
/**
* @param {string} owner
*/
function createVarsApi(owner) {
return {
/**
* @param {string} name
*/
async get(name) {
const value = await getVariablePlain(owner, name);
if (value == null) {
throw new Error(`variable "${name}" not found`);
}
return value;
},
};
}
/**
* @param {string} owner
*/
@@ -380,6 +399,7 @@ function createScriptSandbox({
const $kv = createKvApi(workflowName);
const $fingerprint = createFingerprintApi($kv);
const $secrets = createSecretsApi(owner);
const $vars = createVarsApi(owner);
const $responses = createResponsesApi();
const sandbox = {
...pickBuiltins(),
@@ -389,6 +409,7 @@ function createScriptSandbox({
$kv,
$fingerprint,
$secrets,
$vars,
$responses,
$workflows,
require: createRestrictedRequire($axios),
+55
View File
@@ -0,0 +1,55 @@
import * as fsStore from "../../fs-store.js";
import {
assertVariableName,
assertVariableType,
deleteVariable,
getVariableById,
listVariables,
upsertVariable,
} from "../../variables-store.js";
/**
* @param {import("fastify").FastifyInstance} fastify
*/
export default async function variablesPlugin(fastify) {
fastify.get("/variables", async (req, reply) => {
const q = /** @type {{ owner?: string }} */ (req.query ?? {});
try {
const owner = q.owner ? fsStore.assertOwner(q.owner) : undefined;
const variables = await listVariables({ owner });
return { variables };
} catch (err) {
return reply.code(err.statusCode ?? 500).send({ error: err.message });
}
});
fastify.put("/variables", async (req, reply) => {
const body = /** @type {{ owner?: string, name?: string, type?: unknown, value?: unknown }} */ (
req.body ?? {}
);
try {
fsStore.assertOwner(String(body.owner ?? ""));
assertVariableName(String(body.name ?? ""));
assertVariableType(body.type);
const variable = await upsertVariable({
owner: String(body.owner),
name: String(body.name),
type: body.type,
value: body.value,
});
return reply.send({ variable });
} catch (err) {
return reply.code(err.statusCode ?? 400).send({ error: err.message });
}
});
fastify.delete("/variables/:id", async (req, reply) => {
const { id } = /** @type {{ id: string }} */ (req.params);
const existing = await getVariableById(id);
if (!existing) {
return reply.code(404).send({ error: "variable not found" });
}
await deleteVariable(id);
return { ok: true };
});
}
+55 -27
View File
@@ -1,6 +1,6 @@
import { migrate, db } from "../db.js";
import { kvSet, kvDelete } from "../kv-store.js";
import { upsertSecret, deleteSecret } from "../secrets-store.js";
import { deleteVariable, upsertVariable } from "../variables-store.js";
import { Secret } from "../secret-value.js";
import { parseConfigRef, resolveConfigRefs } from "../config-refs.js";
@@ -45,7 +45,8 @@ assertParse(" password123 ", null);
assertParse("$SECRET_zte_modem_password", { kind: "secret", name: "zte_modem_password" });
assertParse(" $SECRET_zte_modem_password ", { kind: "secret", name: "zte_modem_password" });
assertParse("Bearer $SECRET_x", null);
assertParse("$KV_modem password", { kind: "kv", name: "modem password" });
assertParse("$KV_modem password", null);
assertParse("$VAR_ntfy_url", { kind: "var", name: "ntfy_url" });
assertParse("$CONTEXT_token", { kind: "context", name: "token" });
assertParse("$SECRET_", { kind: "secret", name: "" });
assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" });
@@ -55,6 +56,8 @@ assertParse("$CONTEXT_SECRET_foo", { kind: "context", name: "SECRET_foo" });
assert(literal === "password123", "literal passthrough");
const unknown = await resolveConfigRefs("$FOO_bar", ctx);
assert(unknown === "$FOO_bar", "$FOO_bar stays literal");
const kvLiteral = await resolveConfigRefs("$KV_modem_password", ctx);
assert(kvLiteral === "$KV_modem_password", "$KV_ stays literal");
const embedded = await resolveConfigRefs("Bearer $SECRET_x", ctx);
assert(embedded === "Bearer $SECRET_x", "mid-string stays literal");
const number = await resolveConfigRefs(42, ctx);
@@ -66,9 +69,24 @@ const secret = await upsertSecret({
name: "config_refs_smoke_token",
value: "s3cret-ok",
});
await kvSet(workflowKey, "modem_password", "kv-pass-ok");
await kvSet(workflowKey, "modem password", "kv-spaced-ok");
await kvSet(workflowKey, "obj-key", { nested: true });
const varUrl = await upsertVariable({
owner,
name: "config_refs_smoke_url",
type: "string",
value: "https://example.test",
});
const varRetry = await upsertVariable({
owner,
name: "config_refs_smoke_retry",
type: "number",
value: 3,
});
const varDebug = await upsertVariable({
owner,
name: "config_refs_smoke_debug",
type: "boolean",
value: false,
});
try {
{
@@ -80,12 +98,18 @@ try {
assert(resolved === "s3cret-ok", "secret resolve trimmed");
}
{
const resolved = await resolveConfigRefs("$KV_modem_password", ctx);
assert(resolved === "kv-pass-ok", "kv resolve");
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_url", ctx);
assert(resolved === "https://example.test", "var string");
}
{
const resolved = await resolveConfigRefs("$KV_modem password", ctx);
assert(resolved === "kv-spaced-ok", "kv spaced key");
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_retry", ctx);
assert(resolved === 3, "var number stays number");
assert(typeof resolved === "number", "var number type");
}
{
const resolved = await resolveConfigRefs("$VAR_config_refs_smoke_debug", ctx);
assert(resolved === false, "var boolean stays false");
assert(typeof resolved === "boolean", "var boolean type");
}
{
const resolved = await resolveConfigRefs("$CONTEXT_token", {
@@ -113,16 +137,20 @@ try {
{
const nested = await resolveConfigRefs(
{
url: "http://example.test",
url: "$VAR_config_refs_smoke_url",
retry: "$VAR_config_refs_smoke_retry",
debug: "$VAR_config_refs_smoke_debug",
password: "$SECRET_config_refs_smoke_token",
headers: { Authorization: "$KV_modem_password" },
extra: ["$CONTEXT_token", "plain"],
},
{ ...ctx, context: { token: "ctx-token-ok" } },
);
assert(nested.url === "http://example.test", "nested literal");
assert(nested.url === "https://example.test", "nested var string");
assert(nested.retry === 3, "nested var number");
assert(nested.debug === false, "nested var boolean");
assert(nested.password === "s3cret-ok", "nested secret");
assert(nested.headers.Authorization === "kv-pass-ok", "nested kv");
assert(nested.headers.Authorization === "$KV_modem_password", "nested $KV_ stays literal");
assert(nested.extra[0] === "ctx-token-ok", "nested array context");
assert(nested.extra[1] === "plain", "nested array literal");
}
@@ -146,18 +174,6 @@ try {
() => resolveConfigRefs("$SECRET_", ctx),
"invalid secret name",
);
await assertRejects(
() => resolveConfigRefs("$KV_missing-key", ctx),
'KV "missing-key" not found',
);
await assertRejects(
() => resolveConfigRefs("$KV_obj-key", ctx),
'KV "obj-key" is not a scalar',
);
await assertRejects(
() => resolveConfigRefs("$KV_", ctx),
"empty KV key",
);
await assertRejects(
() => resolveConfigRefs("$CONTEXT_missing", ctx),
'context "missing" not found',
@@ -174,11 +190,23 @@ try {
() => resolveConfigRefs("$CONTEXT_", ctx),
"empty context key",
);
await assertRejects(
() => resolveConfigRefs("$VAR_does_not_exist_xyz", ctx),
'variable "does_not_exist_xyz" not found',
);
await assertRejects(
() => resolveConfigRefs("$VAR_not valid", ctx),
"invalid variable name",
);
await assertRejects(
() => resolveConfigRefs("$VAR_", ctx),
"empty variable name",
);
} finally {
await deleteSecret(secret.id);
await kvDelete(workflowKey, "modem_password");
await kvDelete(workflowKey, "modem password");
await kvDelete(workflowKey, "obj-key");
await deleteVariable(varUrl.id);
await deleteVariable(varRetry.id);
await deleteVariable(varDebug.id);
}
console.log("config-refs smoke test passed");
+136
View File
@@ -0,0 +1,136 @@
import { migrate, db } from "../db.js";
import { runScriptSource } from "../script-sandbox.js";
import { log } from "../logger.js";
import {
deleteVariable,
encodeVariableValue,
getVariablePlain,
upsertVariable,
} from "../variables-store.js";
await migrate();
function assert(cond, msg) {
if (!cond) throw new Error(msg);
}
async function assertThrows(fn, match) {
try {
await fn();
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
if (match && !message.includes(match)) {
throw new Error(`threw "${message}", expected to include "${match}"`);
}
return;
}
throw new Error(`expected to throw (${match ?? "any error"})`);
}
const owner = "default";
await assertThrows(
() => encodeVariableValue("number", "3"),
"finite number",
);
await assertThrows(
() => encodeVariableValue("number", NaN),
"finite number",
);
await assertThrows(
() => encodeVariableValue("boolean", "true"),
"boolean",
);
await assertThrows(
() => encodeVariableValue("string", 3),
"string",
);
const created = [];
try {
const str = await upsertVariable({
owner,
name: "variables_smoke_url",
type: "string",
value: "https://n.0dev.web.id/system",
});
created.push(str.id);
assert(str.value === "https://n.0dev.web.id/system", "string stored");
assert(
(await getVariablePlain(owner, "variables_smoke_url")) === str.value,
"string plain",
);
const num = await upsertVariable({
owner,
name: "variables_smoke_retry",
type: "number",
value: 3,
});
created.push(num.id);
assert(num.value === 3 && typeof num.value === "number", "number stored");
const flag = await upsertVariable({
owner,
name: "variables_smoke_debug",
type: "boolean",
value: false,
});
created.push(flag.id);
assert(flag.value === false, "boolean false stored");
const updated = await upsertVariable({
owner,
name: "variables_smoke_retry",
type: "number",
value: 9,
});
assert(updated.id === num.id, "upsert same id");
assert(updated.value === 9, "upsert number");
await assertThrows(
() =>
upsertVariable({
owner,
name: "variables_smoke_bad",
type: "number",
value: "abc",
}),
"finite number",
);
const scriptOut = await runScriptSource(
"variables-smoke.js",
`export default async function () {
const url = await $vars.get("variables_smoke_url");
const retry = await $vars.get("variables_smoke_retry");
const debug = await $vars.get("variables_smoke_debug");
return { url, retry, debug, retryType: typeof retry, debugType: typeof debug };
}`,
{ data: {} },
{ log, workflowName: "default/variables-smoke.yaml", owner },
);
assert(scriptOut.url === "https://n.0dev.web.id/system", "script $vars string");
assert(scriptOut.retry === 9 && scriptOut.retryType === "number", "script $vars number");
assert(scriptOut.debug === false && scriptOut.debugType === "boolean", "script $vars boolean");
await assertThrows(
() =>
runScriptSource(
"variables-smoke.js",
`export default async function () {
return $vars.get("does_not_exist_xyz");
}`,
{ data: {} },
{ log, workflowName: "default/variables-smoke.yaml", owner },
),
'variable "does_not_exist_xyz" not found',
);
} finally {
for (const id of created) {
await deleteVariable(id);
}
}
console.log("variables smoke test passed");
await db.destroy();
+190
View File
@@ -0,0 +1,190 @@
import { randomUUID } from "node:crypto";
import { db } from "./db.js";
import { assertOwner } from "./fs-store.js";
const MAX_NAME_LENGTH = 128;
const MAX_STRING_BYTES = 64 * 1024;
const VARIABLE_NAME_RE = /^[A-Za-z0-9._-]+$/;
export const VARIABLE_TYPES = /** @type {const} */ (["string", "number", "boolean"]);
function nowIso() {
return new Date().toISOString();
}
function httpError(message, statusCode = 400) {
const err = new Error(message);
err.statusCode = statusCode;
return err;
}
/**
* @param {unknown} name
* @returns {string}
*/
export function assertVariableName(name) {
if (typeof name !== "string" || !VARIABLE_NAME_RE.test(name)) {
throw httpError("invalid variable name");
}
if (name.length > MAX_NAME_LENGTH) {
throw httpError(`variable name must be at most ${MAX_NAME_LENGTH} characters`);
}
return name;
}
/**
* @param {unknown} type
* @returns {"string" | "number" | "boolean"}
*/
export function assertVariableType(type) {
if (type !== "string" && type !== "number" && type !== "boolean") {
throw httpError("type must be string, number, or boolean");
}
return type;
}
/**
* @param {"string" | "number" | "boolean"} type
* @param {unknown} value
* @returns {string}
*/
export function encodeVariableValue(type, value) {
if (type === "string") {
if (typeof value !== "string") {
throw httpError("value must be a string");
}
if (Buffer.byteLength(value, "utf8") > MAX_STRING_BYTES) {
throw httpError(`value exceeds ${MAX_STRING_BYTES} byte limit`);
}
return value;
}
if (type === "number") {
if (typeof value !== "number" || !Number.isFinite(value)) {
throw httpError("value must be a finite number");
}
return String(value);
}
if (typeof value !== "boolean") {
throw httpError("value must be a boolean");
}
return value ? "true" : "false";
}
/**
* @param {"string" | "number" | "boolean"} type
* @param {string} stored
* @returns {string | number | boolean}
*/
export function decodeVariableValue(type, stored) {
if (type === "string") return stored;
if (type === "number") {
const n = Number(stored);
if (!Number.isFinite(n)) {
throw new Error(`corrupt number variable: ${JSON.stringify(stored)}`);
}
return n;
}
if (stored === "true") return true;
if (stored === "false") return false;
throw new Error(`corrupt boolean variable: ${JSON.stringify(stored)}`);
}
/**
* @param {Record<string, unknown>} row
*/
function publicVariable(row) {
const type = assertVariableType(row.type);
return {
id: row.id,
owner: row.owner,
name: row.name,
type,
value: decodeVariableValue(type, String(row.value ?? "")),
created_at: row.created_at,
updated_at: row.updated_at,
};
}
/**
* @param {{ owner?: string }} [filters]
*/
export async function listVariables(filters = {}) {
let q = db("variables")
.select("id", "owner", "name", "type", "value", "created_at", "updated_at")
.orderBy("owner", "asc")
.orderBy("name", "asc");
if (filters.owner) {
q = q.where("owner", assertOwner(filters.owner));
}
const rows = await q;
return rows.map((row) => publicVariable(row));
}
/**
* @param {string} id
*/
export async function getVariableById(id) {
const row = await db("variables").where({ id }).first();
return row ? publicVariable(row) : null;
}
/**
* @param {{ owner: string, name: string, type: unknown, value: unknown }} opts
*/
export async function upsertVariable({ owner, name, type, value }) {
const ownerName = assertOwner(owner);
const variableName = assertVariableName(name);
const variableType = assertVariableType(type);
const encoded = encodeVariableValue(variableType, value);
const now = nowIso();
const existing = await db("variables")
.where({ owner: ownerName, name: variableName })
.first();
if (existing) {
await db("variables")
.where({ id: existing.id })
.update({
type: variableType,
value: encoded,
updated_at: now,
});
return getVariableById(existing.id);
}
const id = randomUUID();
await db("variables").insert({
id,
owner: ownerName,
name: variableName,
type: variableType,
value: encoded,
created_at: now,
updated_at: now,
});
return getVariableById(id);
}
/**
* @param {string} id
* @returns {Promise<boolean>}
*/
export async function deleteVariable(id) {
const n = await db("variables").where({ id }).del();
return n > 0;
}
/**
* Typed primitive for an owner/name. Returns null if missing.
* @param {string} owner
* @param {string} name
* @returns {Promise<string | number | boolean | null>}
*/
export async function getVariablePlain(owner, name) {
const ownerName = assertOwner(owner);
const variableName = assertVariableName(name);
const row = await db("variables")
.where({ owner: ownerName, name: variableName })
.first();
if (!row) return null;
return decodeVariableValue(assertVariableType(row.type), String(row.value ?? ""));
}
@@ -2,9 +2,13 @@ name: cron example
description: |
this workflow triggered by cron
scripts:
- script: get-current-time.js
- set:
expression: '{"message": context.datetime}'
- script: ntfy.js
config:
url: https://ntfy.sh/jerapah-flow
url: $VAR_ntfy_channel
title: $DATA
triggers:
- type: cron
schedule: "*/20 * * * *"
@@ -36,3 +36,4 @@ triggers:
- type: HTTP
method: POST
path: /send-gmail
auth: basic-auth
@@ -3,6 +3,8 @@ description: |
this workflow will send a message to ntfy with the current time
scripts:
- script: get-current-time.js
config:
key: ""
- script: ntfy.js
config:
url: https://n.0dev.web.id/system
+2
View File
@@ -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" ? (
+27
View File
@@ -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"],
+2
View File
@@ -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) {
+312
View File
@@ -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>
);
}