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={ + + setShowCreate((v) => !v)} + > + {showCreate ? "Close" : "New sequence"} + + + + } /> + {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) => ( + pickTemplate(opt.name)} + aria-pressed={template === opt.name} + className={`text-left border rounded-sm p-4 transition-colors ${ + template === opt.name + ? "border-signal bg-signal-weak" + : "border-hairline bg-surface hover:border-hairline-strong" + }`} + > + {opt.name} + + {opt.description} + + + ))} + + + + + 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. + + { + setJson(e.target.value); + setDirty(true); + }} + rows={18} + spellCheck={false} + className="w-full bg-sunken border border-rule px-2.5 py-2 text-[12px] font-mono text-ink placeholder:text-faint focus:border-signal focus:outline-none" + /> + + + + + Deploy sequence + + + + + ); +} diff --git a/dashboard/src/pages/Usage.tsx b/dashboard/src/pages/Usage.tsx new file mode 100644 index 00000000..a7ab2b77 --- /dev/null +++ b/dashboard/src/pages/Usage.tsx @@ -0,0 +1,235 @@ +import { useCallback, useMemo, useState } from "react"; +import { getUsage, type UsageResponse } from "../api"; +import { usePolling } from "../hooks/usePolling"; +import { usePageTitle } from "../hooks/usePageTitle"; +import { useDebounce } from "../hooks/useDebounce"; +import { PageHeader } from "../components/ui/PageHeader"; +import { PageMeta } from "../components/ui/PageMeta"; +import { Section } from "../components/ui/Section"; +import { Glossary, type GlossaryItem } from "../components/ui/Glossary"; +import { Metric, MetricRow } from "../components/ui/Metric"; +import { Table, THead, TH, TR, TD, Empty } from "../components/ui/Table"; +import { Input } from "../components/ui/Input"; +import { SkeletonTable } from "../components/ui/Skeleton"; +import { fmtCount, fmtUsd } from "../lib/fmt"; + +const EST_TOOLTIP = "Estimated from list prices"; + +const PAGE_GLOSSARY: GlossaryItem[] = [ + { + term: "Usage event", + definition: + "One token-consuming LLM call captured by the engine (llm_call / agent blocks). Each row below aggregates every event for one (kind, model) pair inside the window.", + }, + { + term: "Kind", + definition: + "What produced the tokens — the block/handler kind that made the call (e.g. llm_call, agent).", + }, + { + term: "Tokens", + definition: + "Input tokens are what you sent (prompt + context); output tokens are what the model generated. Providers bill the two at different rates.", + }, + { + term: "Est. cost", + definition: + "input_tokens × list input price + output_tokens × list output price, from a static per-model pricing table. An estimate — negotiated rates, caching discounts, and provider-side rounding are not reflected. Models missing from the table show — and are excluded from the total.", + }, + { + term: "Window", + definition: + "The reporting period. Defaults to the last 30 days, ending now.", + }, + { + term: "Tenant", + definition: + "Usage is tenant-scoped. A tenant-scoped API key always reports on its own tenant; an unscoped (admin) key picks one here.", + }, +]; + +export default function Usage() { + usePageTitle("Usage"); + const [tenant, setTenant] = useState("tenant-a"); + const debouncedTenant = useDebounce(tenant, 400); + + const fetcher = useCallback( + (signal?: AbortSignal) => + getUsage({ tenant: debouncedTenant || undefined }, signal), + [debouncedTenant], + ); + const { data, loading, error, updatedAt, refresh } = usePolling( + fetcher, + 10000, + ); + + const totals = useMemo(() => { + const entries = data?.usage ?? []; + return { + events: entries.reduce((s, u) => s + u.events, 0), + input: entries.reduce((s, u) => s + u.input_tokens, 0), + output: entries.reduce((s, u) => s + u.output_tokens, 0), + }; + }, [data]); + + // Biggest spender first; unknown-cost models sink to the bottom. + const rows = useMemo( + () => + [...(data?.usage ?? [])].sort( + (a, b) => (b.cost_usd ?? -1) - (a.cost_usd ?? -1), + ), + [data], + ); + + return ( + + } + /> + + + + + + + Tenant ID + setTenant(e.target.value)} + /> + + + + + {error && {error.message}} + + {loading && !data && } + + {data && ( + <> + + Spend is an estimate from list + prices — negotiated rates and caching discounts are + not reflected. Models missing from the pricing table are + excluded from the total. + > + } + meta={ + + WINDOW{" "} + + {new Date(data.start).toLocaleDateString()} –{" "} + {new Date(data.end).toLocaleDateString()} + + + } + > + + + + + + + + + + One row per (kind, model) aggregate, biggest estimated spend + first. A — cost means the + model has no entry in the pricing table, so it can't be + priced and is excluded from the total. + > + } + meta={ + + ROWS{" "} + {rows.length} + + } + > + + + Kind + Model + Events + Input tokens + Output tokens + Est. cost + + + {rows.map((u) => ( + + {u.kind} + {u.model} + {fmtCount(u.events)} + {fmtCount(u.input_tokens)} + {fmtCount(u.output_tokens)} + + {u.cost_usd === null ? ( + + — + + ) : ( + + {fmtUsd(u.cost_usd)} + est. + + )} + + + ))} + {rows.length === 0 && ( + + No usage events in this window. Run a sequence with an + llm_call or agent block and token usage shows up here. + + )} + + + + > + )} + + ); +} diff --git a/dashboard/tests/fmt.test.ts b/dashboard/tests/fmt.test.ts index 2c53fd60..f2cfa3ef 100644 --- a/dashboard/tests/fmt.test.ts +++ b/dashboard/tests/fmt.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import * as assert from "node:assert/strict"; -import { fmtCount, fmtRate, fmtPct, fmtDelta } from "../src/lib/fmt.ts"; +import { fmtCount, fmtRate, fmtPct, fmtDelta, fmtUsd } from "../src/lib/fmt.ts"; // --------------------------------------------------------------------------- // fmtCount @@ -244,3 +244,53 @@ test("fmtDelta(-150000) returns '−150.0K'", () => { test("fmtDelta(1000000) returns '+1.0M'", () => { assert.equal(fmtDelta(1_000_000), "+1.0M"); }); + +// --------------------------------------------------------------------------- +// fmtUsd — estimated LLM costs (GET /usage cost_usd / total_cost_usd) +// --------------------------------------------------------------------------- + +test("fmtUsd(null) returns em-dash (unknown model)", () => { + assert.equal(fmtUsd(null), "—"); +}); + +test("fmtUsd(undefined) returns em-dash", () => { + assert.equal(fmtUsd(undefined), "—"); +}); + +test("fmtUsd(NaN) returns em-dash", () => { + assert.equal(fmtUsd(NaN), "—"); +}); + +test("fmtUsd(Infinity) returns em-dash", () => { + assert.equal(fmtUsd(Infinity), "—"); +}); + +test("fmtUsd(0) returns '$0.0000' (4 decimals below $1)", () => { + assert.equal(fmtUsd(0), "$0.0000"); +}); + +test("fmtUsd(0.0123) returns '$0.0123' (4 decimals below $1)", () => { + assert.equal(fmtUsd(0.0123), "$0.0123"); +}); + +test("fmtUsd(0.5) returns '$0.5000' (just below the $1 boundary)", () => { + assert.equal(fmtUsd(0.5), "$0.5000"); +}); + +test("fmtUsd(1) returns '$1.00' (2 decimals at $1)", () => { + assert.equal(fmtUsd(1), "$1.00"); +}); + +test("fmtUsd(12.3456) returns '$12.35' (2 decimals above $1)", () => { + assert.equal(fmtUsd(12.3456), "$12.35"); +}); + +test("fmtUsd(15.5) formats a window total as '$15.50'", () => { + // total_cost_usd from GET /usage is a plain number — the headline stat + // renders it through the same helper as the per-row costs. + assert.equal(fmtUsd(15.5), "$15.50"); +}); + +test("fmtUsd(1234.5) returns '$1234.50'", () => { + assert.equal(fmtUsd(1234.5), "$1234.50"); +}); diff --git a/dashboard/tests/templates.test.ts b/dashboard/tests/templates.test.ts new file mode 100644 index 00000000..23c034fb --- /dev/null +++ b/dashboard/tests/templates.test.ts @@ -0,0 +1,133 @@ +import { test } from "node:test"; +import * as assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { + BLANK_TEMPLATE, + TEMPLATES, + templateEditorContent, + templatePickerOptions, +} from "../src/lib/templates.ts"; + +// --------------------------------------------------------------------------- +// Registry shape — mirrors orch8-cli/src/templates.rs +// --------------------------------------------------------------------------- + +const EXPECTED_NAMES = [ + "default", + "react-loop", + "tool-calling-pipeline", + "guardrail-validation", + "multi-agent-delegation", +]; + +test("TEMPLATES contains exactly the 5 CLI templates, in display order", () => { + assert.deepEqual( + TEMPLATES.map((t) => t.name), + EXPECTED_NAMES, + ); +}); + +test("every template has a non-empty description", () => { + for (const t of TEMPLATES) { + assert.ok(t.description.length > 0, `template ${t.name} has empty description`); + } +}); + +test("every template sequence has a non-empty blocks array", () => { + for (const t of TEMPLATES) { + const blocks = t.sequence["blocks"]; + assert.ok( + Array.isArray(blocks) && blocks.length > 0, + `template ${t.name} is missing a non-empty blocks array`, + ); + } +}); + +test("template names are unique kebab-case", () => { + const seen = new Set(); + for (const t of TEMPLATES) { + assert.ok(!seen.has(t.name), `duplicate template name ${t.name}`); + seen.add(t.name); + assert.match(t.name, /^[a-z0-9-]+$/, `template name ${t.name} is not kebab-case`); + } +}); + +// --------------------------------------------------------------------------- +// Picker options — the list the UI renders +// --------------------------------------------------------------------------- + +test("templatePickerOptions lists blank first, then all 5 templates", () => { + const opts = templatePickerOptions(); + assert.equal(opts.length, 6); + assert.equal(opts[0]!.name, BLANK_TEMPLATE); + assert.deepEqual(opts.slice(1).map((o) => o.name), EXPECTED_NAMES); + for (const o of opts) { + assert.ok(o.description.length > 0, `picker option ${o.name} has empty description`); + } +}); + +// --------------------------------------------------------------------------- +// Editor content — what selecting a picker option fills the editor with +// --------------------------------------------------------------------------- + +test("selecting blank fills the editor with an empty skeleton", () => { + const content = templateEditorContent(BLANK_TEMPLATE, { + tenantId: "acme", + namespace: "staging", + }); + const seq = JSON.parse(content) as Record; + assert.equal(seq["tenant_id"], "acme"); + assert.equal(seq["namespace"], "staging"); + assert.equal(seq["version"], 1); + assert.deepEqual(seq["blocks"], []); +}); + +test("selecting a template fills the editor with its blocks and the form's tenant/namespace", () => { + for (const t of TEMPLATES) { + const content = templateEditorContent(t.name, { + tenantId: "acme", + namespace: "prod", + }); + const seq = JSON.parse(content) as Record; + assert.equal(seq["tenant_id"], "acme", `${t.name}: tenant_id not adapted`); + assert.equal(seq["namespace"], "prod", `${t.name}: namespace not adapted`); + assert.equal(seq["version"], 1, `${t.name}: version not reset to 1`); + assert.deepEqual(seq["blocks"], t.sequence["blocks"], `${t.name}: blocks differ`); + } +}); + +test("the default template keeps its hello-world sequence name", () => { + const seq = JSON.parse( + templateEditorContent("default", { tenantId: "demo", namespace: "default" }), + ) as Record; + assert.equal(seq["name"], "hello-world"); + assert.equal((seq["blocks"] as unknown[]).length, 3); +}); + +test("agent-pattern templates use their slug as the sequence name", () => { + const seq = JSON.parse( + templateEditorContent("react-loop", { tenantId: "t", namespace: "n" }), + ) as Record; + assert.equal(seq["name"], "react-loop"); +}); + +test("an unknown template name behaves like blank", () => { + const ctx = { tenantId: "t", namespace: "n" }; + assert.equal(templateEditorContent("nope", ctx), templateEditorContent(BLANK_TEMPLATE, ctx)); +}); + +// --------------------------------------------------------------------------- +// Drift guard — the embedded pattern JSONs must equal docs/agent-patterns/*.json +// (the source of truth that orch8-cli/src/templates.rs include_str!s). +// --------------------------------------------------------------------------- + +const PATTERNS_DIR = join(import.meta.dirname, "..", "..", "docs", "agent-patterns"); + +for (const name of EXPECTED_NAMES.filter((n) => n !== "default")) { + test(`embedded "${name}" template matches docs/agent-patterns/${name}.json`, () => { + const file = JSON.parse(readFileSync(join(PATTERNS_DIR, `${name}.json`), "utf8")); + const embedded = TEMPLATES.find((t) => t.name === name)!.sequence; + assert.deepEqual(embedded, file); + }); +}
orch8 init --template …
+ Isolation group. Executions never cross tenants. +
+ Environment label — e.g. prod, staging, dev. +
+ Exactly what gets POSTed to /sequences — id and created_at are + filled in automatically if you leave them out. +