From 4af37791ddecf0e383c9f5830a9a9c46575c2f54 Mon Sep 17 00:00:00 2001 From: Oleksii Date: Wed, 10 Jun 2026 00:12:16 -0300 Subject: [PATCH] feat(dashboard): usage cost display + create-sequence template picker Co-Authored-By: Claude Fable 5 --- dashboard/package.json | 4 +- dashboard/src/App.tsx | 2 + dashboard/src/api.ts | 52 +++ dashboard/src/components/Layout.tsx | 2 + dashboard/src/components/ui/Icons.tsx | 8 + dashboard/src/lib/fmt.ts | 10 + dashboard/src/lib/templates.ts | 507 ++++++++++++++++++++++++++ dashboard/src/pages/Sequences.tsx | 200 +++++++++- dashboard/src/pages/Usage.tsx | 235 ++++++++++++ dashboard/tests/fmt.test.ts | 52 ++- dashboard/tests/templates.test.ts | 133 +++++++ 11 files changed, 1200 insertions(+), 5 deletions(-) create mode 100644 dashboard/src/lib/templates.ts create mode 100644 dashboard/src/pages/Usage.tsx create mode 100644 dashboard/tests/templates.test.ts diff --git a/dashboard/package.json b/dashboard/package.json index 7dc3ea95..c5d275aa 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -7,7 +7,9 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "typecheck": "tsc -b", + "test": "node --test 'tests/*.test.ts'" }, "dependencies": { "clsx": "^2.1.1", diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 92fd1090..855275f5 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -17,6 +17,7 @@ import Credentials from "./pages/Credentials"; import Pools from "./pages/Pools"; import Settings from "./pages/Settings"; import MobileSync from "./pages/MobileSync"; +import Usage from "./pages/Usage"; export default function App() { return ( @@ -34,6 +35,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/dashboard/src/api.ts b/dashboard/src/api.ts index 7e0ef8e1..1cd89e6d 100644 --- a/dashboard/src/api.ts +++ b/dashboard/src/api.ts @@ -289,6 +289,58 @@ export async function listSequences( return Array.isArray(raw) ? raw : raw.items; } +export interface CreateSequenceResponse { + id: string; + warnings?: string[]; +} + +/** + * Register a new sequence definition. The body is the full sequence JSON + * (the server validates structure and returns lint warnings, if any). + */ +export function createSequence( + body: Record, + signal?: AbortSignal, +): Promise { + return mutate("/sequences", "POST", body, undefined, signal); +} + +// ─── Usage / cost ──────────────────────────────────────────────────────────── + +export interface UsageEntry { + kind: string; + model: string; + events: number; + input_tokens: number; + output_tokens: number; + /** Estimated list-price cost in USD; null when the model has no pricing-table entry. */ + cost_usd: number | null; +} + +export interface UsageResponse { + tenant: string; + start: string; + end: string; + usage: UsageEntry[]; + /** Window-wide total over the known-model entries. */ + total_cost_usd: number; + /** Always true — costs are computed from static list prices, not invoices. */ + cost_is_estimate: boolean; +} + +export interface GetUsageParams { + /** Honored only for unscoped/admin callers; scoped keys are locked to their tenant. */ + tenant?: string; + /** Window start (RFC 3339). Defaults server-side to 30 days before `end`. */ + start?: string; + /** Window end (RFC 3339). Defaults server-side to now. */ + end?: string; +} + +export function getUsage(params?: GetUsageParams, signal?: AbortSignal): Promise { + return request("/usage", params as Record, signal); +} + // ─── Operations: DLQ / circuit breakers / cluster ──────────────────────────── export interface ListDlqParams { diff --git a/dashboard/src/components/Layout.tsx b/dashboard/src/components/Layout.tsx index 63d475e5..7edd4b47 100644 --- a/dashboard/src/components/Layout.tsx +++ b/dashboard/src/components/Layout.tsx @@ -25,6 +25,7 @@ import { IconKey, IconDatabase, IconPhone, + IconCost, } from "./ui/Icons"; const CONN_TONE = { @@ -42,6 +43,7 @@ const NAV = [ { to: "/cron", label: "Cron", icon: IconClock }, { to: "/triggers", label: "Triggers", icon: IconZap }, { to: "/sessions", label: "Sessions", icon: IconSession }, + { to: "/usage", label: "Usage", icon: IconCost }, { to: "/plugins", label: "Plugins", icon: IconPlugin }, { to: "/credentials", label: "Credentials", icon: IconKey }, { to: "/pools", label: "Pools", icon: IconDatabase }, diff --git a/dashboard/src/components/ui/Icons.tsx b/dashboard/src/components/ui/Icons.tsx index c7e70ce2..59199b0e 100644 --- a/dashboard/src/components/ui/Icons.tsx +++ b/dashboard/src/components/ui/Icons.tsx @@ -191,6 +191,14 @@ export const IconDatabase = (p: P) => ( ); +export const IconCost = (p: P) => ( + + + + + +); + export const IconPhone = (p: P) => ( diff --git a/dashboard/src/lib/fmt.ts b/dashboard/src/lib/fmt.ts index b281d5fa..22ae6478 100644 --- a/dashboard/src/lib/fmt.ts +++ b/dashboard/src/lib/fmt.ts @@ -42,3 +42,13 @@ export function fmtDelta(n: number): string { const sign = n > 0 ? "+" : "−"; return `${sign}${fmtCount(Math.abs(n))}`; } + +/** + * USD amount with adaptive precision: 2 decimals at/above $1 ("$12.35"), + * 4 below ("$0.0123") so sub-cent LLM costs stay legible. `null`/`undefined` + * (unknown model — no entry in the pricing table) renders as an em-dash. + */ +export function fmtUsd(n: number | null | undefined): string { + if (n === null || n === undefined || !Number.isFinite(n)) return "—"; + return `$${n.toFixed(Math.abs(n) >= 1 ? 2 : 4)}`; +} diff --git a/dashboard/src/lib/templates.ts b/dashboard/src/lib/templates.ts new file mode 100644 index 00000000..27d0fbec --- /dev/null +++ b/dashboard/src/lib/templates.ts @@ -0,0 +1,507 @@ +/** + * Built-in sequence templates for the "Create from template" picker on the + * Sequences page. + * + * SINGLE SOURCE OF TRUTH — keep in sync manually: + * these mirror the CLI's embedded templates in `orch8-cli/src/templates.rs`, + * which `include_str!`s the agent-pattern JSONs from `docs/agent-patterns/*.json` + * (the `default` scaffold is defined inline in templates.rs). There is no API + * endpoint serving templates, so the dashboard inlines the same data. When a + * template is added or changed, update BOTH `docs/agent-patterns/` / + * `orch8-cli/src/templates.rs` AND this module. + * + * This module is imported by node:test unit tests, so it must stay free of + * imports from other dashboard modules (tests resolve bare specifiers via + * Node, not Vite). + */ + +export interface SequenceTemplate { + /** Kebab-case slug — matches `orch8 templates list`. */ + name: string; + /** One-line summary shown in the picker (same text as the CLI's). */ + description: string; + /** The template's sequence JSON, verbatim from its source file. */ + sequence: Record; +} + +/** + * The scaffold written by `orch8 init` when no `--template` is given: + * a minimal three-step hello-world sequence (DEFAULT_JSON in templates.rs). + */ +const DEFAULT_SEQUENCE: Record = { + tenant_id: "demo", + namespace: "default", + name: "hello-world", + version: 1, + blocks: [ + { + type: "step", + id: "greet", + handler: "greet_user", + params: { message: "Hello from Orch8!" }, + }, + { + type: "step", + id: "wait", + handler: "noop", + delay: { duration: 5000 }, + }, + { + type: "step", + id: "complete", + handler: "noop", + params: { message: "Workflow complete." }, + }, + ], +}; + +/** docs/agent-patterns/react-loop.json */ +const REACT_LOOP_SEQUENCE: Record = { + name: "ReAct Loop Agent", + description: + "Observe-Think-Act loop with tool calling. The agent reasons about the task, selects tools, observes results, and iterates until done or max iterations reached.", + blocks: [ + { + type: "loop", + id: "react_cycle", + condition: "context.data.agent_done != true", + max_iterations: 10, + body: [ + { + type: "step", + id: "observe", + handler: "llm_call", + params: { + provider: "openai", + model: "gpt-4o", + system: + 'You are a ReAct agent. Given the task and previous observations, decide the next action. Respond with JSON: {"thought": "...", "action": "tool_name", "action_input": {...}} or {"thought": "...", "action": "finish", "final_answer": "..."}', + messages: [ + { + role: "user", + content: + "Task: {{context.data.task}}\n\nAvailable tools: {{context.data.available_tools}}\n\nPrevious observations: {{context.data.observations}}", + }, + ], + tools: "{{context.data.tool_schemas}}", + }, + }, + { + type: "router", + id: "decide", + routes: [ + { + condition: "outputs.observe.content.action == finish", + blocks: [ + { + type: "step", + id: "finalize", + handler: "set_state", + params: { + key: "final_answer", + value: "{{outputs.observe.content.final_answer}}", + }, + }, + { + type: "step", + id: "mark_done", + handler: "transform", + params: { + expression: "true", + target: "context.data.agent_done", + }, + }, + ], + }, + ], + default: [ + { + type: "step", + id: "act", + handler: "tool_call", + params: { + tool_name: "{{outputs.observe.content.action}}", + arguments: "{{outputs.observe.content.action_input}}", + url: "{{context.data.tool_dispatch_url}}", + }, + }, + { + type: "step", + id: "record_observation", + handler: "transform", + params: { + expression: "outputs.act", + target: "context.data.observations", + }, + }, + ], + }, + ], + }, + ], +}; + +/** docs/agent-patterns/tool-calling-pipeline.json */ +const TOOL_CALLING_PIPELINE_SEQUENCE: Record = { + name: "Tool-Calling Pipeline", + description: + "LLM generates a plan, executes tools in sequence, then synthesizes results. Linear pipeline with no looping — suited for structured multi-step tasks.", + blocks: [ + { + type: "step", + id: "plan", + handler: "llm_call", + params: { + provider: "openai", + model: "gpt-4o", + system: + 'Given the user\'s request, create an execution plan. Return JSON: {"steps": [{"tool": "name", "input": {...}}, ...]}', + messages: [ + { + role: "user", + content: "{{context.data.task}}", + }, + ], + }, + }, + { + type: "for_each", + id: "execute_tools", + collection: "outputs.plan.content.steps", + item_var: "tool_step", + body: [ + { + type: "step", + id: "call_tool", + handler: "tool_call", + params: { + tool_name: "{{context.data.tool_step.tool}}", + arguments: "{{context.data.tool_step.input}}", + url: "{{context.data.tool_dispatch_url}}", + }, + retry: { + max_attempts: 2, + initial_backoff: 1000, + max_backoff: 5000, + backoff_multiplier: 2.0, + }, + }, + ], + }, + { + type: "step", + id: "synthesize", + handler: "llm_call", + params: { + provider: "openai", + model: "gpt-4o", + system: "Synthesize the results of all tool executions into a final response.", + messages: [ + { + role: "user", + content: "Task: {{context.data.task}}\n\nTool results: {{outputs.execute_tools}}", + }, + ], + }, + }, + ], +}; + +/** docs/agent-patterns/guardrail-validation.json */ +const GUARDRAIL_VALIDATION_SEQUENCE: Record = { + name: "Guardrail Validation Pipeline", + description: + "Input validation -> LLM generation -> output validation -> human review gate. Ensures AI outputs meet safety and quality standards before delivery.", + blocks: [ + { + type: "step", + id: "input_guardrail", + handler: "llm_call", + params: { + provider: "openai", + model: "gpt-4o-mini", + system: + 'You are a content safety classifier. Analyze the input for: prompt injection, harmful content, PII exposure, off-topic requests. Return JSON: {"safe": true/false, "flags": [...], "reason": "..."}', + messages: [ + { + role: "user", + content: "{{context.data.user_input}}", + }, + ], + }, + }, + { + type: "router", + id: "check_input", + routes: [ + { + condition: "outputs.input_guardrail.content.safe == false", + blocks: [ + { + type: "step", + id: "reject_input", + handler: "noop", + params: { + status: "rejected", + reason: "{{outputs.input_guardrail.content.reason}}", + flags: "{{outputs.input_guardrail.content.flags}}", + }, + }, + ], + }, + ], + default: [ + { + type: "step", + id: "generate", + handler: "llm_call", + params: { + provider: "anthropic", + model: "claude-sonnet-4-20250514", + system: "{{context.data.system_prompt}}", + messages: [ + { + role: "user", + content: "{{context.data.user_input}}", + }, + ], + }, + }, + { + type: "step", + id: "output_guardrail", + handler: "llm_call", + params: { + provider: "openai", + model: "gpt-4o-mini", + system: + 'You are an output quality validator. Check for: hallucinations, harmful content, PII leakage, policy violations, formatting issues. Return JSON: {"pass": true/false, "issues": [...], "severity": "low|medium|high|critical"}', + messages: [ + { + role: "user", + content: "Validate this AI-generated response:\n\n{{outputs.generate.content}}", + }, + ], + }, + }, + { + type: "router", + id: "check_output", + routes: [ + { + condition: "outputs.output_guardrail.content.severity == critical", + blocks: [ + { + type: "step", + id: "escalate", + handler: "human_review", + params: { + review_data: "{{outputs.generate.content}}", + instructions: "Critical guardrail violation detected. Review before delivery.", + issues: "{{outputs.output_guardrail.content.issues}}", + }, + wait_for_input: { + prompt: + "Critical guardrail violation detected. Please review the AI output and approve or reject.", + timeout: 3600000, + }, + }, + ], + }, + ], + default: [ + { + type: "step", + id: "deliver", + handler: "noop", + params: { + status: "approved", + response: "{{outputs.generate.content}}", + validation: "{{outputs.output_guardrail.content}}", + }, + }, + ], + }, + ], + }, + ], +}; + +/** docs/agent-patterns/multi-agent-delegation.json */ +const MULTI_AGENT_DELEGATION_SEQUENCE: Record = { + name: "Multi-Agent Delegation", + description: + "Orchestrator agent breaks a task into sub-tasks and delegates each to a specialized child agent instance. Results are collected and synthesized.", + blocks: [ + { + type: "step", + id: "decompose", + handler: "llm_call", + params: { + provider: "anthropic", + model: "claude-sonnet-4-20250514", + system: + "You are an orchestrator agent. Decompose complex tasks into sub-tasks for specialist agents: researcher (web search, data gathering), coder (code generation, debugging), analyst (data analysis, summarization).", + messages: [ + { + role: "user", + content: + 'Break this task into independent sub-tasks that can be delegated to specialist agents. Return JSON: {"sub_tasks": [{"agent": "researcher|coder|analyst", "task": "...", "context": {...}}, ...]}\n\nTask: {{context.data.task}}', + }, + ], + }, + }, + { + type: "parallel", + id: "delegate", + branches: [ + [ + { + type: "sub_sequence", + id: "agent_1", + sequence_name: "{{outputs.decompose.content.sub_tasks[0].agent}}_agent", + input: { + task: "{{outputs.decompose.content.sub_tasks[0].task}}", + context: "{{outputs.decompose.content.sub_tasks[0].context}}", + }, + }, + ], + [ + { + type: "sub_sequence", + id: "agent_2", + sequence_name: "{{outputs.decompose.content.sub_tasks[1].agent}}_agent", + input: { + task: "{{outputs.decompose.content.sub_tasks[1].task}}", + context: "{{outputs.decompose.content.sub_tasks[1].context}}", + }, + }, + ], + [ + { + type: "sub_sequence", + id: "agent_3", + sequence_name: "{{outputs.decompose.content.sub_tasks[2].agent}}_agent", + input: { + task: "{{outputs.decompose.content.sub_tasks[2].task}}", + context: "{{outputs.decompose.content.sub_tasks[2].context}}", + }, + }, + ], + ], + }, + { + type: "step", + id: "synthesize", + handler: "llm_call", + params: { + provider: "anthropic", + model: "claude-sonnet-4-20250514", + system: + "You are an orchestrator agent. Synthesize results from specialist agents into a coherent final answer.", + messages: [ + { + role: "user", + content: + "Combine these agent results into a final response.\n\nOriginal task: {{context.data.task}}\n\nAgent 1 result: {{outputs.agent_1}}\nAgent 2 result: {{outputs.agent_2}}\nAgent 3 result: {{outputs.agent_3}}", + }, + ], + }, + }, + ], + notes: + "In practice, use for_each + sub_sequence to dynamically delegate to N agents based on the decomposition output. The parallel block shown here is a simplified illustration for 3 sub-tasks.", +}; + +/** Registry of all built-in templates, in display order (mirrors the CLI's). */ +export const TEMPLATES: SequenceTemplate[] = [ + { + name: "default", + description: "Minimal hello-world sequence: greet, delayed wait, complete.", + sequence: DEFAULT_SEQUENCE, + }, + { + name: "react-loop", + description: "Observe-Think-Act agent loop with tool calling, iterating until done.", + sequence: REACT_LOOP_SEQUENCE, + }, + { + name: "tool-calling-pipeline", + description: "LLM plans, executes tools in sequence, then synthesizes the results.", + sequence: TOOL_CALLING_PIPELINE_SEQUENCE, + }, + { + name: "guardrail-validation", + description: "Input guardrail, LLM generation, output guardrail, human review gate.", + sequence: GUARDRAIL_VALIDATION_SEQUENCE, + }, + { + name: "multi-agent-delegation", + description: "Orchestrator decomposes a task and delegates sub-tasks to parallel agents.", + sequence: MULTI_AGENT_DELEGATION_SEQUENCE, + }, +]; + +/** Picker option that pre-fills nothing — same skeleton the editor opens with. */ +export const BLANK_TEMPLATE = "blank"; + +export interface TemplatePickerOption { + name: string; + description: string; +} + +/** All picker choices: "blank" first, then every built-in template. */ +export function templatePickerOptions(): TemplatePickerOption[] { + return [ + { + name: BLANK_TEMPLATE, + description: "Empty skeleton — write the sequence JSON yourself.", + }, + ...TEMPLATES.map(({ name, description }) => ({ name, description })), + ]; +} + +export interface TemplateContext { + tenantId: string; + namespace: string; +} + +/** + * Pretty-printed sequence JSON for the creation editor. + * + * `blank` (or any unknown name) yields the empty skeleton the editor opens + * with; a template name yields that template's blocks with `tenant_id` / + * `namespace` taken from the form and `version` reset to 1. The sequence + * `name` is the template slug, except `default` which keeps its canonical + * "hello-world" name (matching `orch8 init`). + */ +export function templateEditorContent( + templateName: string, + { tenantId, namespace }: TemplateContext, +): string { + const template = TEMPLATES.find((t) => t.name === templateName); + if (!template) { + return JSON.stringify( + { + tenant_id: tenantId, + namespace, + name: "my-sequence", + version: 1, + blocks: [], + }, + null, + 2, + ); + } + + const src = template.sequence; + const out: Record = { + tenant_id: tenantId, + namespace, + name: template.name === "default" ? (src["name"] as string) : template.name, + version: 1, + }; + if (typeof src["description"] === "string") out["description"] = src["description"]; + out["blocks"] = src["blocks"]; + return JSON.stringify(out, null, 2); +} diff --git a/dashboard/src/pages/Sequences.tsx b/dashboard/src/pages/Sequences.tsx index 05265d09..8e78d265 100644 --- a/dashboard/src/pages/Sequences.tsx +++ b/dashboard/src/pages/Sequences.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { listSequences, type SequenceDefinition } from "../api"; +import { listSequences, createSequence, type SequenceDefinition } from "../api"; import { usePolling } from "../hooks/usePolling"; import { usePageTitle } from "../hooks/usePageTitle"; import { PageHeader } from "../components/ui/PageHeader"; @@ -8,11 +8,18 @@ import { PageMeta } from "../components/ui/PageMeta"; import { Section } from "../components/ui/Section"; import { Glossary, type GlossaryItem } from "../components/ui/Glossary"; import { Badge } from "../components/ui/Badge"; -import { Input } from "../components/ui/Input"; +import { Button } from "../components/ui/Button"; +import { Input, FieldLabel } from "../components/ui/Input"; import { Table, THead, TH, TR, TD, Empty } from "../components/ui/Table"; import { Id } from "../components/ui/Mono"; import { Relative } from "../components/ui/Relative"; import { SkeletonTable } from "../components/ui/Skeleton"; +import { IconPlus } from "../components/ui/Icons"; +import { + BLANK_TEMPLATE, + templateEditorContent, + templatePickerOptions, +} from "../lib/templates"; // A group of versions sharing (tenant, namespace, name). Versions are sorted // newest-first so the first row of each group is the "head" version. @@ -62,6 +69,13 @@ export default function Sequences() { const [tenant, setTenant] = useState(""); const [namespace, setNamespace] = useState(""); const [nameFilter, setNameFilter] = useState(""); + const [showCreate, setShowCreate] = useState(false); + const [toast, setToast] = useState(null); + + const flash = (msg: string) => { + setToast(msg); + setTimeout(() => setToast(null), 4000); + }; const fetcher = useCallback( (signal?: AbortSignal) => @@ -124,11 +138,39 @@ export default function Sequences() { eyebrow="Operator" title="Sequences" description="Every workflow definition deployed to the engine. One row per unique (tenant, namespace, name) — click through to see all its versions and the block graph of the head." - actions={} + actions={ +
+ + +
+ } /> + {toast &&
{toast}
} + + {showCreate && ( + { + flash( + warnings.length > 0 + ? `Sequence created (${id.slice(0, 8)}…) with ${warnings.length} warning${warnings.length === 1 ? "" : "s"}: ${warnings[0]}` + : `Sequence created (${id.slice(0, 8)}…)`, + ); + setShowCreate(false); + refresh(); + }} + onError={(msg) => flash(msg)} + /> + )} +
); } + +function CreateSequenceForm({ + onCreated, + onError, +}: { + onCreated: (id: string, warnings: string[]) => void; + onError: (msg: string) => void; +}) { + const [tenantId, setTenantId] = useState("tenant-a"); + const [namespace, setNamespace] = useState("prod"); + const [template, setTemplate] = useState(BLANK_TEMPLATE); + const [json, setJson] = useState(() => + templateEditorContent(BLANK_TEMPLATE, { tenantId: "tenant-a", namespace: "prod" }), + ); + // Once the operator hand-edits the JSON we stop regenerating it on + // tenant/namespace changes — picking a template always overwrites. + const [dirty, setDirty] = useState(false); + const [busy, setBusy] = useState(false); + + const pickTemplate = (name: string) => { + setTemplate(name); + setJson(templateEditorContent(name, { tenantId, namespace })); + setDirty(false); + }; + + const changeTenant = (v: string) => { + setTenantId(v); + if (!dirty) setJson(templateEditorContent(template, { tenantId: v, namespace })); + }; + + const changeNamespace = (v: string) => { + setNamespace(v); + if (!dirty) setJson(templateEditorContent(template, { tenantId, namespace: v })); + }; + + const submit = async () => { + let parsed: unknown; + try { + parsed = JSON.parse(json); + } catch { + onError("Sequence JSON is not valid JSON"); + return; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + onError("Sequence JSON must be an object"); + return; + } + const body = parsed as Record; + // POST /sequences deserializes the full SequenceDefinition, which carries + // server-side identity fields authoring payloads usually omit — fill them + // here so the editor only needs the human-authored part. + if (!body["id"]) body["id"] = crypto.randomUUID(); + if (!body["created_at"]) body["created_at"] = new Date().toISOString(); + setBusy(true); + try { + const res = await createSequence(body); + onCreated(res.id, res.warnings ?? []); + } catch (e) { + onError(`Failed: ${e instanceof Error ? e.message : String(e)}`); + } finally { + setBusy(false); + } + }; + + return ( +
+ Pick a starting point — the agent patterns ship with the CLI + (orch8 init --template …) + and pre-fill the editor below. Blank{" "} + starts from an empty skeleton. You can edit the JSON freely before + deploying; the engine validates structure on submit. + + } + > +
+
+ {templatePickerOptions().map((opt) => ( + + ))} +
+ +
+
+ Tenant + changeTenant(e.target.value)} + className="w-full" + /> +

+ Isolation group. Executions never cross tenants. +

+
+
+ Namespace + changeNamespace(e.target.value)} + className="w-full" + /> +

+ Environment label — e.g. prod, staging, dev. +

+
+
+ +
+ Sequence definition (JSON) +

+ Exactly what gets POSTed to /sequences — id and created_at are + filled in automatically if you leave them out. +

+