diff --git a/console/build.rs b/console/build.rs index c9d08b5b0..99ffe3cea 100644 --- a/console/build.rs +++ b/console/build.rs @@ -7,9 +7,9 @@ //! `pnpm install --frozen-lockfile && pnpm build` inside `web/` //! before the rest of the crate compiles. //! 3. Ensures the console's *own* injected UI assets exist: `src/ui.rs` -//! embeds `ui/dist/config-form.js` and `ui/dist/styles.css` via -//! `include_str!` (the state worker precedent). Set `SKIP_UI_BUILD=1` -//! to use the existing `ui/dist/` outputs as-is. +//! embeds `ui/dist/config-form.js`, `ui/dist/catalog-page.js`, and +//! `ui/dist/styles.css` via `include_str!` (the state worker precedent). +//! Set `SKIP_UI_BUILD=1` to use the existing `ui/dist/` outputs as-is. use std::path::{Path, PathBuf}; use std::process::Command; @@ -99,6 +99,7 @@ fn ensure_web_bundle() { /// CI escape hatch. fn ensure_ui_assets() { println!("cargo:rerun-if-changed=ui/config-form.tsx"); + println!("cargo:rerun-if-changed=ui/catalog-page.tsx"); println!("cargo:rerun-if-changed=ui/styles.css"); println!("cargo:rerun-if-changed=ui/src"); println!("cargo:rerun-if-changed=ui/build.mjs"); @@ -112,6 +113,7 @@ fn ensure_ui_assets() { let ui_dir = manifest_dir.join("ui"); let dist_assets = [ ui_dir.join("dist").join("config-form.js"), + ui_dir.join("dist").join("catalog-page.js"), ui_dir.join("dist").join("styles.css"), ]; @@ -174,6 +176,7 @@ fn ui_dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { let watched_files = [ ui_dir.join("config-form.tsx"), + ui_dir.join("catalog-page.tsx"), ui_dir.join("styles.css"), ui_dir.join("build.mjs"), ui_dir.join("package.json"), diff --git a/console/src/ui.rs b/console/src/ui.rs index c3e719084..66fab8c6f 100644 --- a/console/src/ui.rs +++ b/console/src/ui.rs @@ -2,11 +2,18 @@ //! `console:script` / `console:style` trigger types it hosts (the engine //! routes the registration straight back to this worker). //! -//! One contribution: a custom configuration form for the `console` entry -//! (`host.configForms`), replacing the schema-generated JSON editor with the -//! injectable-UI toggle board — one bordered card per worker (title + -//! description + switch) flipping `injectableUi.disabledWorkers`, which -//! [`crate::configuration::start_injectable_ui_sync`] applies live. +//! Two contributions: +//! +//! - a custom configuration form for the `console` entry +//! (`host.configForms`), replacing the schema-generated JSON editor with +//! the injectable-UI toggle board — one bordered card per worker (title + +//! description + switch) flipping `injectableUi.disabledWorkers`, which +//! [`crate::configuration::start_injectable_ui_sync`] applies live; +//! - the engine-catalogue pages (`host.pages`): functions and triggers, +//! reading `engine::functions::*` / `engine::triggers::*` / +//! `engine::registered-triggers::list`. They are engine-level views no +//! single worker owns, and they ship injected so the console SPA carries +//! no per-view code. //! //! Registration machinery comes from the shared `iii-console-ui` crate //! (workers/crates/console-ui); this module only names the assets and embeds @@ -23,6 +30,7 @@ use iii_console_ui::ConsoleUi; use iii_sdk::IIIClient; pub const CONFIG_FORM_PATH: &str = "console/config-form.js"; +pub const CATALOG_PAGE_PATH: &str = "console/catalog-page.js"; pub const STYLES_PATH: &str = "console/styles.css"; /// Built by `build.rs` (esbuild over `ui/`). @@ -30,11 +38,16 @@ const CONFIG_FORM_JS: &str = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/ui/dist/config-form.js" )); +const CATALOG_PAGE_JS: &str = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/ui/dist/catalog-page.js" +)); const STYLES_CSS: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/ui/dist/styles.css")); fn console_ui() -> ConsoleUi { ConsoleUi::new("console") .script(CONFIG_FORM_PATH, CONFIG_FORM_JS) + .script(CATALOG_PAGE_PATH, CATALOG_PAGE_JS) .style(STYLES_PATH, STYLES_CSS) } @@ -62,6 +75,42 @@ mod tests { ); } + #[test] + fn embedded_catalog_page_is_nonempty_esm() { + assert!( + CATALOG_PAGE_JS.contains("export"), + "built catalog-page.js looks wrong" + ); + } + + /// The pages are useless if their ids drift from the routes the nav and + /// deep links use (`#/ext/functions`, `#/ext/triggers`). + #[test] + fn embedded_catalog_page_registers_every_page() { + for id in ["functions", "triggers"] { + assert!( + CATALOG_PAGE_JS.contains(id), + "built catalog-page.js is missing the `{id}` page" + ); + } + } + + /// The pages are live over engine signals, not timers. A build that lost + /// the subscriptions would look fine and silently go stale. + #[test] + fn embedded_catalog_page_subscribes_to_engine_signals() { + for signal in [ + "engine::functions-available", + "engine::workers-available", + "trace", + ] { + assert!( + CATALOG_PAGE_JS.contains(signal), + "built catalog-page.js no longer subscribes to `{signal}`" + ); + } + } + #[test] fn embedded_styles_are_scoped() { // esbuild prints the attribute selector unquoted ([data-iii-ui=console]). diff --git a/console/ui/build.mjs b/console/ui/build.mjs index 862311156..9cd1c3885 100644 --- a/console/ui/build.mjs +++ b/console/ui/build.mjs @@ -1,8 +1,9 @@ /** - * Build the console worker's own two console assets: + * Build the console worker's own console assets: * - * config-form.tsx → dist/config-form.js (injected over `console:script`) - * styles.css → dist/styles.css (injected over `console:style`) + * config-form.tsx → dist/config-form.js (injected over `console:script`) + * catalog-page.tsx → dist/catalog-page.js (injected over `console:script`) + * styles.css → dist/styles.css (injected over `console:style`) * * The five shared specifiers stay EXTERNAL — they resolve at runtime * through the console's import map (a bundled React copy would surface as @@ -14,7 +15,7 @@ import esbuild from 'esbuild' const options = { - entryPoints: ['config-form.tsx', 'styles.css'], + entryPoints: ['config-form.tsx', 'catalog-page.tsx', 'styles.css'], bundle: true, format: 'esm', jsx: 'automatic', diff --git a/console/ui/catalog-page.tsx b/console/ui/catalog-page.tsx new file mode 100644 index 000000000..a80d0f54f --- /dev/null +++ b/console/ui/catalog-page.tsx @@ -0,0 +1,55 @@ +/** + * Entry for the console worker's engine-catalogue pages — compiled by + * esbuild (react + @iii-dev/console-ui external) into dist/catalog-page.js + * and served over the `console:script` trigger (see src/ui.rs). The + * stylesheet is its own asset: styles.css ships over `console:style` as + * console/styles.css. + * + * Two contributions, both reading engine-level data no single worker owns: + * + * - src/catalog/FunctionsPage — every registered function, its schemas, an + * invoke panel, and its live call feed + * (#/ext/functions) + * - src/catalog/TriggersPage — trigger types and their live bindings, each + * with its family's real fire path + * (#/ext/triggers) + * + * Both run off engine signals (`engine::functions-available`, + * `engine::workers-available`, `trace`) rather than timers, so they are live + * without polling. Per-worker drill-down lives on the console's own Workers + * page, which expands a row into that worker's functions and triggers. + * + * They ship as injected UI rather than console pages so the console SPA + * keeps no per-view code: this bundle can be rebuilt, hot-reloaded, and + * toggled off without touching the host. + */ + +import type { Host, PageRenderProps } from '@iii-dev/console-ui' +import { FunctionsPage } from './src/catalog/FunctionsPage' +import { TriggersPage } from './src/catalog/TriggersPage' + +export default function setup(host: Host) { + host.pages.register({ + id: 'functions', + title: 'functions', + render: ({ panelSide, onRequestClose }: PageRenderProps) => ( + + ), + }) + + host.pages.register({ + id: 'triggers', + title: 'triggers', + render: ({ panelSide, onRequestClose }: PageRenderProps) => ( + + ), + }) +} diff --git a/console/ui/src/catalog/ActivityFeed.tsx b/console/ui/src/catalog/ActivityFeed.tsx new file mode 100644 index 000000000..1e8257446 --- /dev/null +++ b/console/ui/src/catalog/ActivityFeed.tsx @@ -0,0 +1,206 @@ +/** + * Live calls of one function: who called it, how long it took, what went in + * and what came back — updating as the engine records spans. + * + * The old console could not show this. It exists here because the console + * worker already streams: the `trace` trigger is a coalesced "spans changed" + * tick, so the feed re-reads `engine::traces::list` filtered to this + * function's span name on each beat instead of polling a timer. + * + * Every row is replayable — the recorded input becomes the invoke editor's + * body, which turns "this call failed in production" into one click. + */ + +import { Button, type Host, JsonHighlight } from '@iii-dev/console-ui' +import { useCallback, useState } from 'react' +import { + type CallRecord, + listCalls, + useLiveSignals, + useResource, +} from './engine' +import { pretty } from './schema' +import { Note } from './widgets' + +function clockTime(ms: number): string { + return new Date(ms).toLocaleTimeString(undefined, { hour12: false }) +} + +/** + * Bus calls are routinely tens of microseconds, so a fixed `ms` scale prints + * a wall of `0.0ms` and hides the only number on the row that varies. Same + * adaptive scale the traces page uses. + */ +export function formatDuration(ms: number): string { + if (ms < 1) return `${Math.round(ms * 1000)}µs` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +/** + * Fields the ENGINE adds to a payload on its way through the bus, not fields + * the caller sent. Replaying them verbatim would put another worker's id on + * the call, so they are dropped and the editor opens on what a caller would + * actually type. The feed still displays the recorded input in full. + */ +function withoutInjected(input: unknown): unknown { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + return input + } + const copy: Record = {} + for (const [key, value] of Object.entries(input)) { + if (key === '_caller_worker_id') continue + copy[key] = value + } + return copy +} + +function ago(ms: number, now: number): string { + const seconds = Math.max(0, Math.round((now - ms) / 1000)) + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + return `${Math.round(minutes / 60)}h ago` +} + +export function ActivityFeed({ + host, + functionId, + onReplay, +}: { + host: Host + functionId: string + /** Push a recorded input back into the invoke editor. */ + onReplay: (input: unknown) => void +}) { + const load = useCallback( + () => listCalls(host, functionId), + [host, functionId], + ) + const calls = useResource(load) + const [open, setOpen] = useState(null) + + // Trace ticks are frequent under load, so this debounces harder than the + // catalogue subscriptions do. + useLiveSignals(host, ['trace'], calls.reload, { debounceMs: 1200 }) + + if (calls.error) { + return ( +
+ engine::traces::list failed — {calls.error} +
+ ) + } + if (calls.data === null) return reading recent calls… + if (calls.data.length === 0) { + return ( + + no recorded calls. This feed follows the trace stream, so a call made + from anywhere — the agent, another worker, the trigger tab — appears + here as it happens. + + ) + } + + const now = Date.now() + const failures = calls.data.filter((c) => !c.ok).length + const slowest = calls.data.reduce((max, c) => Math.max(max, c.durationMs), 0) + const median = medianDuration(calls.data) + + return ( +
+
+ {calls.data.length} recent calls + median {formatDuration(median)} + slowest {formatDuration(slowest)} + + {failures} failed + +
+ {calls.data.map((call, i) => { + // spanId can be empty or duplicated on some backends — the row id + // keys AND drives open state, so a collision would open every twin. + const rowId = call.spanId || `${call.traceId}:${call.startedAtMs}:${i}` + return ( + setOpen((prev) => (prev === rowId ? null : rowId))} + onReplay={onReplay} + /> + ) + })} +
+ ) +} + +function CallRow({ + call, + now, + open, + onToggle, + onReplay, +}: { + call: CallRecord + now: number + open: boolean + onToggle: () => void + onReplay: (input: unknown) => void +}) { + return ( +
+ + {open ? ( +
+
+ input + {call.input !== undefined ? ( + + ) : null} +
+ +
output
+ + trace {call.traceId} +
+ ) : null} +
+ ) +} + +function medianDuration(calls: CallRecord[]): number { + const sorted = calls.map((c) => c.durationMs).sort((a, b) => a - b) + const mid = Math.floor(sorted.length / 2) + if (sorted.length === 0) return 0 + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} diff --git a/console/ui/src/catalog/FunctionsPage.tsx b/console/ui/src/catalog/FunctionsPage.tsx new file mode 100644 index 000000000..473ec34e1 --- /dev/null +++ b/console/ui/src/catalog/FunctionsPage.tsx @@ -0,0 +1,705 @@ +/** + * The Functions page (`#/ext/functions`): a navigation sidebar of every + * function on the bus — each row led by the `ƒ` tile, grouped by the worker + * that registered it — and a workspace that is always present: a hero when + * nothing is selected, the function document (breadcrumb, identity head, + * overview/invoke/input/output/triggers/activity tabs) when one is. + * + * Live, never polled. `engine::functions-available` fires whenever functions + * are registered or unregistered, so a worker connecting or dying is visible + * here within a beat, and rows that arrived on the last tick flash once so + * the change is legible rather than silent. + * + * `engine::functions::list` is the catalogue (one cheap row per function); + * `engine::functions::info` is fetched per selection, because that is where + * the schemas live and the fleet has hundreds of functions. + * `engine::workers::list` rides along for each worker's runtime — the + * document's "language" fact. + * + * Internal functions are hidden by default: the console's own per-tab + * handlers and every worker's UI plumbing register as internal, and they + * would otherwise outnumber the functions an operator came to find. + */ + +import { + Badge, + Button, + EmptyState, + type Host, + JsonHighlight, + PageHeader, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ActivityFeed, formatDuration } from './ActivityFeed' +import { nextCronRun, untilLabel } from './cron' +import { + type FunctionDetail, + type FunctionSummary, + functionInfo, + listFunctions, + listWorkers, + type RegisteredTrigger, + type SpanEvent, + useLiveSignals, + useResource, +} from './engine' +import { InvokePanel } from './InvokePanel' +import { agoLabel, LastCallMeta, useLiveActivity } from './live' +import { SchemaTable } from './SchemaTable' +import { pretty } from './schema' +import { cronExpression, familyOf, summarize } from './trigger-kinds' +import { + CatalogListSkeleton, + CatalogRow, + CatalogShell, + CatalogWorkspace, + Chip, + ContextItem, + ContextPanel, + CopyButton, + Crumb, + ErrorNote, + Facts, + FamilyGlyph, + FnGlyph, + GroupHeader, + Hero, + IdentityHead, + LiveDot, + Note, + SearchField, + SideCount, + useGroupToggle, +} from './widgets' + +/** Worker groups always start expanded; there is no noisy bucket. */ +const alwaysOpen = () => true + +export function FunctionsPage({ + host, + side, + onRequestClose, +}: { + host: Host + side?: 'left' | 'right' + onRequestClose?: () => void +}) { + const [showInternal, setShowInternal] = useState(false) + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + const groupState = useGroupToggle(alwaysOpen) + + // Functions and workers together: the sidebar groups by worker and the + // document names each worker's runtime, so both loads share one beat. + const load = useCallback(async () => { + const [functions, workers] = await Promise.all([ + listFunctions(host, { includeInternal: showInternal }), + listWorkers(host), + ]) + return { functions, workers } + }, [host, showInternal]) + const catalog = useResource(load) + useLiveSignals( + host, + ['engine::functions-available', 'engine::workers-available'], + catalog.reload, + ) + const activity = useLiveActivity(host) + + const functions = catalog.data?.functions ?? null + + // Ids that appeared on the last tick, so an arrival is visible instead of + // silently changing the row count. The first load is not "new". + const [arrived, setArrived] = useState>(new Set()) + const seenRef = useRef | null>(null) + useEffect(() => { + if (!functions) return + const ids = new Set(functions.map((f) => f.function_id)) + const previous = seenRef.current + seenRef.current = ids + if (!previous) return + const fresh = new Set([...ids].filter((id) => !previous.has(id))) + if (fresh.size === 0) return + setArrived(fresh) + const timer = window.setTimeout(() => setArrived(new Set()), 2000) + return () => window.clearTimeout(timer) + }, [functions]) + + const runtimeOf = useMemo(() => { + const byName = new Map( + (catalog.data?.workers ?? []).map((w) => [w.name, w.runtime]), + ) + return (worker: string) => byName.get(worker) ?? undefined + }, [catalog.data]) + + const groups = useMemo(() => { + const needle = search.trim().toLowerCase() + // Ids and workers only. Description text matches surprised more than + // they helped: searching `config` surfaced harness::triggers::list + // because its description mentions config, which reads as broken. + const matched = (functions ?? []).filter((fn) => { + if (!needle) return true + return ( + fn.function_id.toLowerCase().includes(needle) || + fn.worker_name.toLowerCase().includes(needle) + ) + }) + const byWorker = new Map() + for (const fn of matched) { + const bucket = byWorker.get(fn.worker_name) + if (bucket) bucket.push(fn) + else byWorker.set(fn.worker_name, [fn]) + } + return [...byWorker.entries()] + .map(([label, items]) => ({ + label, + items: items.sort((a, b) => a.function_id.localeCompare(b.function_id)), + })) + .sort((a, b) => a.label.localeCompare(b.label)) + }, [functions, search]) + + const total = functions?.length ?? 0 + const shown = groups.reduce((n, g) => n + g.items.length, 0) + + // A live catalogue can remove the selected row while its document is open. + // Returning to the list is less surprising than leaving a stale document + // on screen for a function that no longer exists. + useEffect(() => { + if ( + selected && + catalog.data && + !catalog.data.functions.some((fn) => fn.function_id === selected) + ) { + setSelected(null) + } + }, [catalog.data, selected]) + + return ( + ƒ} + title="functions" + description={ + + registered functions, grouped by worker + + } + onClose={onRequestClose} + className="console-catalog-page-header" + actions={ + <> + + + + } + /> + } + sideTop={ +
+ + +
+ } + sideFooter={ + + {catalog.data === null + ? 'loading functions…' + : search.trim() + ? `showing ${shown} of ${total} functions` + : `${total} total function${total === 1 ? '' : 's'}`} + + } + list={ + catalog.error ? ( + + ) : catalog.data === null ? ( + + ) : shown === 0 ? ( + setSearch('') } + : undefined + } + /> + ) : ( + groups.map((group) => ( +
+ groupState.toggle(group.label)} + /> + {!groupState.isOpen(group.label) + ? null + : group.items.map((fn) => ( + } + primary={fn.function_id} + secondary={fn.description ?? undefined} + meta={ + + } + selected={selected === fn.function_id} + flash={ + arrived.has(fn.function_id) || + activity.pulsing.has(fn.function_id) + } + onClick={() => + setSelected((prev) => + prev === fn.function_id ? null : fn.function_id, + ) + } + /> + ))} +
+ )) + ) + } + main={ + selected ? ( + setSelected(null)} + /> + ) : ( + } + eyebrow="function catalog" + title="explore registered functions" + body="select a function from the sidebar to understand its contract, test it, and follow its recent calls. the catalog updates whenever workers connect or disconnect." + items={[ + { + label: 'overview', + value: 'ownership, runtime, triggers, and metadata', + }, + { + label: 'contracts', + value: 'input and output schemas in a readable field table', + }, + { + label: 'test', + value: + 'trigger with json, copy a cli command, or replay a call', + }, + ]} + /> + ) + } + /> + ) +} + +function FunctionDocument({ + host, + functionId, + runtimeOf, + lastCall, + onBack, +}: { + host: Host + functionId: string + /** Worker name → runtime, from the page-level workers list. */ + runtimeOf: (worker: string) => string | undefined + lastCall?: SpanEvent + onBack: () => void +}) { + const load = useCallback( + () => functionInfo(host, functionId), + [host, functionId], + ) + const detail = useResource(load) + const [tab, setTab] = useState('invoke') + const [prefill, setPrefill] = useState<{ value: unknown; nonce: number }>() + + useEffect(() => { + setTab('invoke') + setPrefill(undefined) + }, [functionId]) + + // Replaying from the activity feed hands the recorded input to the invoke + // editor and moves the operator there — the whole point of the button. + const replay = useCallback((value: unknown) => { + setPrefill({ value, nonce: Date.now() }) + setTab('invoke') + }, []) + + const language = detail.data ? runtimeOf(detail.data.worker_name) : undefined + + return ( + setTab('triggers')} + onShowActivity={() => setTab('activity')} + /> + ) : undefined + } + > +
+ + } + title={functionId} + status="executable function" + description={ + detail.data + ? detail.data.description || 'no description provided.' + : undefined + } + chips={ + detail.data ? ( + <> + + {language ? : null} + {detail.data.registered_triggers.length > 0 ? ( + + ) : null} + + ) : null + } + actions={ + <> + + + + } + /> + {detail.error ? ( + + ) : detail.data === null ? ( + loading detail… + ) : ( + + + overview + trigger + input + output + + triggers + {detail.data.registered_triggers.length > 0 ? ( + {detail.data.registered_triggers.length} + ) : null} + + activity + + + + + + + + + + + + + + + + + + + + + )} +
+
+ ) +} + +function FunctionContext({ + detail, + language, + lastCall, + onShowTriggers, + onShowActivity, +}: { + detail: FunctionDetail + language?: string + lastCall?: SpanEvent + onShowTriggers: () => void + onShowActivity: () => void +}) { + const now = new Date() + return ( + <> + 0 + ? { label: 'view all', onClick: onShowTriggers } + : undefined + } + wide + > + {detail.registered_triggers.length === 0 ? ( + + This function runs only when called directly. + + ) : ( + detail.registered_triggers.slice(0, 3).map((ref) => { + const binding: RegisteredTrigger = { + id: ref.id, + trigger_type: ref.trigger_type, + function_id: detail.function_id, + worker_name: detail.worker_name, + config: ref.config, + } + const spec = familyOf(ref.trigger_type) + const expression = cronExpression(binding) + const next = expression ? nextCronRun(expression, now) : null + return ( + } + title={summarize(binding)} + description={ref.trigger_type} + meta={next ? `next run ${untilLabel(next, now)}` : spec.label} + onClick={onShowTriggers} + /> + ) + }) + )} + + + + {detail.function_id} }, + { label: 'worker', value: detail.worker_name }, + ...(language ? [{ label: 'language', value: language }] : []), + { + label: 'input schema', + value: detail.request_schema !== undefined ? 'defined' : 'none', + }, + { + label: 'output schema', + value: detail.response_schema !== undefined ? 'defined' : 'none', + }, + ]} + /> + + + + {lastCall ? ( + + ) : ( + + No call observed since this page opened. + + )} + + + ) +} + +/** + * Function identity and contract facts already live in the contextual rail. + * The overview is reserved for extra metadata so it never repeats that same + * fact sheet in the main work surface. + */ +function FunctionOverview({ detail }: { detail: FunctionDetail }) { + const hasMetadata = + detail.metadata !== undefined && + detail.metadata !== null && + (typeof detail.metadata !== 'object' || + Array.isArray(detail.metadata) || + Object.keys(detail.metadata).length > 0) + + return hasMetadata ? ( +
+ metadata + +
+ ) : ( + this function registered no additional metadata. + ) +} + +/** + * What fires this function, one card per binding: the family tile, the + * binding in its family's words (`GET /users/:id`, `every 5 min`), and the + * raw config for the cases those words compress away. + */ +function FunctionTriggers({ detail }: { detail: FunctionDetail }) { + if (detail.registered_triggers.length === 0) { + return ( + + nothing is bound to this function — it runs only when something calls + it. + + ) + } + const now = new Date() + return ( +
+ {detail.registered_triggers.map((ref) => { + // The refs on a function detail carry no worker/summary fields; the + // function's own identity fills them so trigger-kinds can read the + // binding the same way the triggers page does. + const binding: RegisteredTrigger = { + id: ref.id, + trigger_type: ref.trigger_type, + function_id: detail.function_id, + worker_name: detail.worker_name, + config: ref.config, + } + const spec = familyOf(ref.trigger_type) + const expression = cronExpression(binding) + const next = expression ? nextCronRun(expression, now) : null + const config = pretty(ref.config ?? {}) + return ( +
+ +
+
+ {summarize(binding)} + + {spec.label} + +
+ + {ref.trigger_type} · {ref.id} + + {next ? ( + next run {untilLabel(next, now)} + ) : null} + {config !== '{}' ? ( + + ) : null} +
+
+ ) + })} +
+ ) +} diff --git a/console/ui/src/catalog/HttpTester.tsx b/console/ui/src/catalog/HttpTester.tsx new file mode 100644 index 000000000..b1b0f471e --- /dev/null +++ b/console/ui/src/catalog/HttpTester.tsx @@ -0,0 +1,346 @@ +/** + * Send a real request to an http binding's endpoint. + * + * This is the one fire path that does not go over the bus: an http trigger + * fires when the http worker receives a request, so the honest test is an + * actual request to the port that worker listens on. The base URL comes from + * the worker's own configuration entry (`configuration::get id=iii-http`), + * never a guess, and the panel says plainly when it cannot be read. + */ + +import { + Button, + CodeEditor, + type Host, + Input, + JsonHighlight, + Select, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useState } from 'react' +import { errorMessage, useResource } from './engine' +import { pretty } from './schema' +import type { HttpBinding } from './trigger-kinds' +import { Chip, ErrorNote, Note } from './widgets' + +const METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] as const +const BODY_METHODS = new Set(['POST', 'PUT', 'PATCH']) + +interface HttpEndpoint { + baseUrl: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Where the http worker listens. `host` is what the worker binds, which is + * `127.0.0.1` on a local rig; a tab opened over the LAN cannot reach that, so + * a loopback bind is rewritten to the hostname the console itself was + * loaded from. + */ +async function readEndpoint(host: Host): Promise { + // `http` is the current worker; `iii-http` is its deprecated predecessor. + // Whichever entry exists with a port wins, current name first. + let value: Record | null = null + for (const id of ['http', 'iii-http']) { + try { + const entry = await host.iii.trigger('configuration::get', { id }) + const candidate = + isRecord(entry) && isRecord(entry.value) ? entry.value : null + if (candidate && typeof candidate.port === 'number') { + value = candidate + break + } + } catch { + // Entry absent under this id; try the next. + } + } + if (!value) throw new Error('no http worker configuration with a port found') + const port = value.port + if (typeof port !== 'number') throw new Error('http config carries no port') + const bound = typeof value.host === 'string' ? value.host : '127.0.0.1' + const reachable = + bound === '0.0.0.0' || bound === '127.0.0.1' || bound === 'localhost' + ? window.location.hostname + : bound + return { baseUrl: `${window.location.protocol}//${reachable}:${port}` } +} + +interface QueryRow { + id: number + key: string + value: string +} + +let queryRowSeq = 0 + +interface Outcome { + ok: boolean + status: number | null + durationMs: number + body: string + error?: string +} + +export function HttpTester({ + host, + binding, +}: { + host: Host + binding: HttpBinding +}) { + const load = useCallback(() => readEndpoint(host), [host]) + const endpoint = useResource(load) + + const [method, setMethod] = useState(binding.method) + const [params, setParams] = useState>({}) + // Rows carry their own id: the key is empty until typed, so nothing else + // about a row is stable enough to key React on. + const [query, setQuery] = useState([]) + const [body, setBody] = useState('{}') + const [sending, setSending] = useState(false) + const [outcome, setOutcome] = useState(null) + const [invalid, setInvalid] = useState(null) + + // A new selection resets the whole form; a stale path parameter filled for + // a different endpoint is worse than an empty one. Keyed on the endpoint's + // VALUES, not the binding's identity — live catalog refreshes rebuild the + // object every tick, and resetting on identity would wipe the form + // mid-typing. + const paramKey = binding.params.join(',') + useEffect(() => { + setMethod(binding.method) + setParams(Object.fromEntries(binding.params.map((p) => [p, '']))) + setQuery([]) + setBody('{}') + setOutcome(null) + setInvalid(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [binding.method, binding.path, paramKey]) + + const filledPath = binding.params.reduce( + (path, name) => + path.replace(`:${name}`, encodeURIComponent(params[name] ?? `:${name}`)), + binding.path, + ) + const queryString = query + .filter((q) => q.key) + .map((q) => `${encodeURIComponent(q.key)}=${encodeURIComponent(q.value)}`) + .join('&') + const url = endpoint.data + ? `${endpoint.data.baseUrl}${filledPath}${queryString ? `?${queryString}` : ''}` + : null + + const send = async () => { + if (!url) return + const missing = binding.params.filter((name) => !params[name]) + if (missing.length > 0) { + setInvalid( + `fill the path parameter${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`, + ) + setOutcome(null) + return + } + const init: RequestInit = { method } + if (BODY_METHODS.has(method)) { + try { + JSON.parse(body) + } catch (err) { + setInvalid(err instanceof Error ? err.message : 'invalid JSON body') + setOutcome(null) + return + } + init.headers = { 'Content-Type': 'application/json' } + init.body = body + } + setInvalid(null) + setSending(true) + const started = performance.now() + try { + const response = await fetch(url, init) + const text = await response.text() + const contentType = response.headers.get('content-type') ?? '' + setOutcome({ + ok: response.ok, + status: response.status, + durationMs: performance.now() - started, + body: contentType.includes('json') ? pretty(safeJson(text)) : text, + }) + } catch (err) { + setOutcome({ + ok: false, + status: null, + durationMs: performance.now() - started, + body: '', + error: errorMessage(err), + }) + } finally { + setSending(false) + } + } + + if (endpoint.error) { + return + } + if (!endpoint.data) return reading the http worker's address… + + return ( +
+
+ {method} + {url} + +
+ +
+ + setParams((prev) => ({ ...prev, [name]: next })) + } + preserveCase + placeholder={name} + /> +
+ ))} +
+ ) : null} + +
+ + query parameters + + + {query.length === 0 ? ( + none + ) : ( + query.map((entry) => ( +
+ + setQuery((prev) => + prev.map((q) => + q.id === entry.id ? { ...q, key: next } : q, + ), + ) + } + preserveCase + placeholder="key" + aria-label="query parameter name" + /> + + setQuery((prev) => + prev.map((q) => + q.id === entry.id ? { ...q, value: next } : q, + ), + ) + } + preserveCase + placeholder="value" + aria-label="query parameter value" + /> + +
+ )) + )} +
+ + {BODY_METHODS.has(method) ? ( + + ) : null} + +
+ + {invalid ? ( + {invalid} + ) : null} + {outcome ? ( + + {outcome.status ?? 'failed'} · {Math.round(outcome.durationMs)}ms + + ) : null} +
+ + {outcome?.error ? ( +
{outcome.error}
+ ) : null} + {outcome && !outcome.error ? ( + + ) : null} + + + ) +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return text + } +} diff --git a/console/ui/src/catalog/InvokePanel.tsx b/console/ui/src/catalog/InvokePanel.tsx new file mode 100644 index 000000000..214b1d108 --- /dev/null +++ b/console/ui/src/catalog/InvokePanel.tsx @@ -0,0 +1,275 @@ +/** + * Call one function with a JSON body and show what came back. + * + * The body opens on a template generated from the function's registered + * `request_schema` (./schema.ts), and the editor is the console's Monaco + * `CodeEditor` fed the schema's field names as completions — the same editor + * the rest of the console uses, never a bundled second one. + * + * Two things the old console did not do: + * + * - required fields are checked against the schema BEFORE the call, so an + * obvious mistake reads as "scope is required" instead of a worker-side + * serialization error + * - every call this panel makes is kept for the session and can be replayed, + * so tuning a payload is a loop rather than a retype + */ + +import { + Button, + CodeEditor, + type Host, + JsonHighlight, +} from '@iii-dev/console-ui' +import { useEffect, useMemo, useState } from 'react' +import { type InvokeOutcome, invoke } from './engine' +import { schemaFieldNames } from './SchemaTable' +import { pretty, templateFromSchema } from './schema' +import { CopyButton } from './widgets' + +interface Attempt { + id: number + atMs: number + body: string + outcome: InvokeOutcome +} + +let attemptSeq = 0 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Required top-level fields the body is missing, by the schema's own list. */ +function missingRequired(schema: unknown, payload: unknown): string[] { + if (!isRecord(schema) || !isRecord(payload)) return [] + const required = Array.isArray(schema.required) + ? schema.required.filter((k): k is string => typeof k === 'string') + : [] + return required.filter( + (key) => payload[key] === undefined || payload[key] === '', + ) +} + +/** + * The same call as a CLI line. `iii trigger` takes `key=value` pairs, so a + * scalar body copies verbatim and anything nested copies as JSON — which is + * exactly the difference between a command that runs and one that does not. + */ +function asCliCommand(functionId: string, payload: unknown): string { + if (!isRecord(payload) || Object.keys(payload).length === 0) { + return `iii trigger ${functionId}` + } + const args = Object.entries(payload).map(([key, value]) => { + const literal = + typeof value === 'string' ? value : (JSON.stringify(value) ?? '') + if (!/[\s"']/.test(literal)) return `${key}=${literal}` + // A single quote cannot appear inside single quotes: close the segment, + // emit an escaped quote, reopen. + return `${key}='${literal.replaceAll("'", `'\\''`)}'` + }) + return `iii trigger ${functionId} ${args.join(' ')}` +} + +export function InvokePanel({ + host, + functionId, + requestSchema, + label = 'trigger', + runningLabel = 'triggering…', + hint, + prefill, +}: { + host: Host + functionId: string + requestSchema: unknown + /** Verb on the button — the triggers page fires a target function. */ + label?: string + runningLabel?: string + hint?: string + /** A recorded input pushed in from the activity feed; changes replace the body. */ + prefill?: { value: unknown; nonce: number } +}) { + const [body, setBody] = useState('{}') + const [running, setRunning] = useState(false) + const [attempts, setAttempts] = useState([]) + const [invalid, setInvalid] = useState(null) + + // A new selection resets the editor to that function's own template and + // drops the previous function's attempts with it. Keyed on the schema's + // CONTENT, not its identity — live catalog refreshes rebuild the object + // every tick, and resetting on identity would wipe a body mid-edit. + const schemaKey = JSON.stringify(requestSchema) ?? 'none' + useEffect(() => { + setBody(templateFromSchema(requestSchema)) + setAttempts([]) + setInvalid(null) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [functionId, schemaKey]) + + useEffect(() => { + if (!prefill) return + setBody(pretty(prefill.value) || '{}') + setInvalid(null) + }, [prefill]) + + const completions = useMemo( + () => schemaFieldNames(requestSchema), + [requestSchema], + ) + + const latest = attempts[0] ?? null + const outcome = latest?.outcome ?? null + + const reset = () => { + setBody(templateFromSchema(requestSchema)) + setInvalid(null) + } + + const run = async () => { + let payload: unknown + try { + payload = JSON.parse(body) + } catch (err) { + setInvalid(err instanceof Error ? err.message : 'invalid JSON') + return + } + if (!isRecord(payload)) { + setInvalid('the request body must be a JSON object') + return + } + const missing = missingRequired(requestSchema, payload) + if (missing.length > 0) { + setInvalid( + `${missing.join(', ')} ${missing.length > 1 ? 'are' : 'is'} required by the schema`, + ) + return + } + setInvalid(null) + setRunning(true) + const result = await invoke(host, functionId, payload) + setRunning(false) + attemptSeq += 1 + setAttempts((prev) => [ + { id: attemptSeq, atMs: Date.now(), body, outcome: result }, + ...prev.slice(0, 9), + ]) + } + + return ( +
+
+
+

trigger function

+

+ Provide the input payload and trigger this function. +

+
+ +
+ {hint ?
{hint}
: null} + input payload (json) + +
+ + + {invalid ? ( + {invalid} + ) : null} +
+ + {outcome ? ( +
+
+ + + {outcome.ok ? 'success' : 'error'} + + + {latest + ? new Date(latest.atMs).toLocaleTimeString(undefined, { + hour12: false, + }) + : null} + {formatMilliseconds(outcome.durationMs)} + +
+ {outcome.error ? ( +
{outcome.error}
+ ) : ( + + )} +
+ ) : null} + + {attempts.length > 1 ? ( +
+ this session + {attempts.slice(1).map((attempt) => ( + + ))} +
+ ) : null} +
+ ) +} + +function formatMilliseconds(ms: number): string { + if (ms < 1) return `${Math.round(ms * 1000)}µs` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function safeJson(text: string): unknown { + try { + return JSON.parse(text) + } catch { + return {} + } +} + +function oneLine(body: string): string { + const flat = body.replace(/\s+/g, ' ').trim() + return flat.length > 64 ? `${flat.slice(0, 61)}…` : flat +} diff --git a/console/ui/src/catalog/QueuePublish.tsx b/console/ui/src/catalog/QueuePublish.tsx new file mode 100644 index 000000000..14a24bee3 --- /dev/null +++ b/console/ui/src/catalog/QueuePublish.tsx @@ -0,0 +1,124 @@ +/** + * Publish a message onto a queue subscriber's topic. + * + * Unlike calling the subscriber's function directly, this is the real path: + * `iii::durable::publish` puts the message on the queue, the queue worker + * delivers it, and the binding's retry and DLQ behavior applies exactly as it + * would in production. That is also why it asks before sending. + */ + +import { + Button, + CodeEditor, + type Host, + JsonHighlight, +} from '@iii-dev/console-ui' +import { useEffect, useRef, useState } from 'react' +import { type InvokeOutcome, invoke } from './engine' +import { pretty } from './schema' +import { Chip } from './widgets' + +export function QueuePublish({ host, topic }: { host: Host; topic: string }) { + const [body, setBody] = useState('{}') + const [confirming, setConfirming] = useState(false) + const [sending, setSending] = useState(false) + const [outcome, setOutcome] = useState(null) + const [invalid, setInvalid] = useState(null) + + // Guards an in-flight publish across a topic switch: the stale result must + // not land on the newly selected topic's fresh form. + const activeTopic = useRef(topic) + activeTopic.current = topic + + useEffect(() => { + setBody('{}') + setOutcome(null) + setInvalid(null) + setConfirming(false) + setSending(false) + }, [topic]) + + const publish = async () => { + let data: unknown + try { + data = JSON.parse(body) + } catch (err) { + setInvalid(err instanceof Error ? err.message : 'invalid JSON') + return + } + setInvalid(null) + setConfirming(false) + setSending(true) + const sentTopic = topic + const result = await invoke(host, 'iii::durable::publish', { topic, data }) + if (activeTopic.current !== sentTopic) return + setOutcome(result) + setSending(false) + } + + return ( +
+
+ publishes to {topic} through the queue, so every consumer + of this topic receives it and the binding's retry and dead-letter + behavior applies. +
+ +
+ {confirming ? ( + <> + + + + ) : ( + + )} + {invalid ? ( + {invalid} + ) : null} + {outcome ? ( + + {outcome.ok ? 'published' : 'failed'} ·{' '} + {Math.round(outcome.durationMs)}ms + + ) : null} +
+ {outcome?.error ? ( +
{outcome.error}
+ ) : null} + {outcome?.ok && outcome.data !== null && outcome.data !== undefined ? ( + + ) : null} + +
+ ) +} diff --git a/console/ui/src/catalog/SchemaTable.tsx b/console/ui/src/catalog/SchemaTable.tsx new file mode 100644 index 000000000..e73cb2db4 --- /dev/null +++ b/console/ui/src/catalog/SchemaTable.tsx @@ -0,0 +1,181 @@ +/** + * A JSON Schema as a field table: name, type, required, default, and the + * schema's own description, nested objects indented under their parent. + * + * The raw schema stays one tab away for the cases this cannot express + * (`oneOf` unions, `$ref` chains, custom keywords). This view exists because + * an operator reading "what does this function take" should not have to parse + * draft-07 by eye — the same reason the registry site renders functions as + * docs rather than as JSON. + */ + +import { JsonHighlight } from '@iii-dev/console-ui' +import { Note } from './widgets' + +const MAX_DEPTH = 3 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** `type` may be a string, a nullable union, or implied by the keywords. */ +function typeName(schema: Record): string { + const t = schema.type + if (typeof t === 'string') return t + if (Array.isArray(t)) { + const named = t.filter((x) => typeof x === 'string' && x !== 'null') + const nullable = t.includes('null') + if (named.length > 0) { + return `${named.join(' | ')}${nullable ? '?' : ''}` + } + } + if (Array.isArray(schema.enum)) return 'enum' + if (isRecord(schema.properties)) return 'object' + if (schema.items !== undefined) return 'array' + if (Array.isArray(schema.oneOf) || Array.isArray(schema.anyOf)) return 'union' + if (typeof schema.$ref === 'string') + return schema.$ref.split('/').pop() ?? 'ref' + return 'any' +} + +interface Row { + path: string + name: string + type: string + required: boolean + description?: string + defaultValue?: string + enumValues?: string + depth: number +} + +function collect( + schema: unknown, + depth: number, + prefix: string, + out: Row[], +): void { + if (!isRecord(schema) || depth > MAX_DEPTH) return + const props = isRecord(schema.properties) ? schema.properties : null + if (!props) return + const required = new Set( + Array.isArray(schema.required) + ? schema.required.filter((k): k is string => typeof k === 'string') + : [], + ) + // Required fields first: they are what a caller must supply. + const keys = Object.keys(props) + const ordered = [ + ...keys.filter((k) => required.has(k)), + ...keys.filter((k) => !required.has(k)), + ] + for (const key of ordered) { + const field = props[key] + if (!isRecord(field)) continue + out.push({ + path: `${prefix}${key}`, + name: key, + type: typeName(field), + required: required.has(key), + description: + typeof field.description === 'string' ? field.description : undefined, + defaultValue: + field.default !== undefined ? JSON.stringify(field.default) : undefined, + enumValues: Array.isArray(field.enum) + ? field.enum.map((v) => JSON.stringify(v)).join(' · ') + : undefined, + depth, + }) + collect(field, depth + 1, `${prefix}${key}.`, out) + const items = field.items + if (isRecord(items)) collect(items, depth + 1, `${prefix}${key}[].`, out) + } +} + +/** Field names a caller can type, for the invoke editor's completions. */ +export function schemaFieldNames(schema: unknown): string[] { + const rows: Row[] = [] + collect(schema, 0, '', rows) + return [...new Set(rows.map((r) => r.name))] +} + +export function SchemaTable({ + schema, + empty, +}: { + schema: unknown + empty: string +}) { + if (schema === undefined || schema === null) return {empty} + + const rows: Row[] = [] + collect(schema, 0, '', rows) + + if (rows.length === 0) { + // A schema with no properties is still information: a scalar response, a + // free-form object. Show it rather than claiming there is nothing. + return ( + + ) + } + + const title = + isRecord(schema) && typeof schema.title === 'string' ? schema.title : null + const description = + isRecord(schema) && typeof schema.description === 'string' + ? schema.description + : null + + return ( +
+ {title || description ? ( +
+ {title ? {title} : null} + {description ? {description} : null} +
+ ) : null} + + + + + + + + + + {rows.map((row) => ( + + + + + + ))} + +
fieldtypenotes
+ + {row.name} + + {row.required ? required : null} + + {row.type} + + {row.description ? ( + {row.description} + ) : null} + {row.enumValues ? ( + one of {row.enumValues} + ) : null} + {row.defaultValue ? ( + default {row.defaultValue} + ) : null} +
+
+ ) +} diff --git a/console/ui/src/catalog/TriggersPage.tsx b/console/ui/src/catalog/TriggersPage.tsx new file mode 100644 index 000000000..61475e888 --- /dev/null +++ b/console/ui/src/catalog/TriggersPage.tsx @@ -0,0 +1,879 @@ +/** + * The Triggers page (`#/ext/triggers`): the same sidebar + workspace shell + * as the functions page. The sidebar lists every trigger type with its live + * bindings indented beneath it, each row led by the family's glyph (globe, + * clock, layers…); the workspace is always present — a hero when nothing is + * selected, the type or binding document when something is. + * + * Two lists make one view. `engine::triggers::list` is the catalogue of + * TYPES (what can fire); `engine::registered-triggers::list` is the set of + * live REGISTERED TRIGGERS (what will fire, and into which function). A type + * with none still lists — knowing a type exists is half of what the page is + * for — and a binding whose type is not in the catalogue lists under its own + * heading rather than disappearing. The type itself is selected by clicking + * its heading; only bindings get rows. + * + * A registered trigger is named by its family, not by its raw config (`trigger-kinds`): + * `GET /users/:id`, `every 5 min`, the queue topic. The detail pane then + * offers that family's REAL fire path where one exists — an actual request + * for http, a real publish for a queue subscriber — and falls back to calling + * the bound function directly, labelled as exactly that, where the engine has + * no way to synthesize a firing. + */ + +import { + Button, + EmptyState, + type Host, + JsonHighlight, + PageHeader, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { describeCron, nextCronRun, untilLabel } from './cron' +import { + type FunctionSummary, + listFunctions, + listRegisteredTriggers, + listTriggerTypes, + type RegisteredTrigger, + type TriggerTypeDetail, + type TriggerTypeSummary, + triggerTypeInfo, + useLiveSignals, + useResource, +} from './engine' +import { HttpTester } from './HttpTester' +import { InvokePanel } from './InvokePanel' +import { QueuePublish } from './QueuePublish' +import { SchemaTable } from './SchemaTable' +import { pretty } from './schema' +import { + configChips, + cronExpression, + type Family, + familyOf, + httpBinding, + isPlumbing, + queueTopic, + summarize, +} from './trigger-kinds' +import { + CatalogListSkeleton, + CatalogRow, + CatalogShell, + CatalogWorkspace, + Chip, + ContextItem, + ContextPanel, + CopyButton, + Crumb, + ErrorNote, + Facts, + FamilyGlyph, + FilterChips, + FnGlyph, + GroupHeader, + Hero, + IdentityHead, + LiveDot, + Note, + SearchField, + SideCount, + StatTile, + useGroupToggle, +} from './widgets' + +type Selection = + | { kind: 'type'; id: string } + | { kind: 'binding'; binding: RegisteredTrigger } + +interface TypeGroup { + type: TriggerTypeSummary + bindings: RegisteredTrigger[] +} + +function BoltIcon() { + return ( + + ) +} + +export function TriggersPage({ + host, + side, + onRequestClose, +}: { + host: Host + side?: 'left' | 'right' + onRequestClose?: () => void +}) { + const [showInternal, setShowInternal] = useState(false) + const [search, setSearch] = useState('') + const [family, setFamily] = useState(null) + const [selected, setSelected] = useState(null) + + // Three independent reads the page needs together, so it pays for one round + // trip. Functions come along to put each binding's target description on + // the row, the way the old console did. + const loadCatalog = useCallback(async () => { + const [types, bindings, functions] = await Promise.all([ + listTriggerTypes(host, { includeInternal: showInternal }), + listRegisteredTriggers(host, { includeInternal: showInternal }), + listFunctions(host, { includeInternal: true }), + ]) + return { types, bindings, functions } + }, [host, showInternal]) + const catalog = useResource(loadCatalog) + // A binding registers and unregisters with its worker's function surface, + // so the same two engine signals cover this page. + useLiveSignals( + host, + ['engine::functions-available', 'engine::workers-available'], + catalog.reload, + ) + + const describeFunction = useMemo(() => { + const byId = new Map( + (catalog.data?.functions ?? []).map((f) => [f.function_id, f]), + ) + return (id: string) => byId.get(id)?.description ?? undefined + }, [catalog.data]) + + const partitioned = useMemo(() => { + if (!catalog.data) + return { groups: [] as TypeGroup[], plumbing: [] as RegisteredTrigger[] } + // Plumbing (per-tab delivery handlers, injected-UI assets, config + // hot-reload hooks) is real but never what this page is opened FOR — + // it folds into one collapsed section at the bottom instead of putting + // `configuration` above everything alphabetically. + const plumbing: RegisteredTrigger[] = [] + const byType = new Map() + for (const binding of catalog.data.bindings) { + if (isPlumbing(binding)) { + plumbing.push(binding) + continue + } + const bucket = byType.get(binding.trigger_type) + if (bucket) bucket.push(binding) + else byType.set(binding.trigger_type, [binding]) + } + + const known = new Map(catalog.data.types.map((t) => [t.id, t])) + // A registration whose type the catalogue does not carry still needs a + // home: synthesize a heading for it rather than dropping the row. + for (const type of byType.keys()) { + if (!known.has(type)) { + known.set(type, { id: type, worker_name: 'unknown', description: null }) + } + } + + // Types whose only registrations are plumbing carry no operator-facing + // rows; drop the heading too unless the type itself is worth knowing. + for (const id of [...known.keys()]) { + if (id.startsWith('console:')) known.delete(id) + } + + const groups = [...known.values()].map((type) => ({ + type, + bindings: (byType.get(type.id) ?? []).sort((a, b) => + summarize(a).localeCompare(summarize(b)), + ), + })) + plumbing.sort((a, b) => a.function_id.localeCompare(b.function_id)) + return { groups, plumbing } + }, [catalog.data]) + const allGroups = partitioned.groups + + const familyCounts = useMemo(() => { + const counts = new Map() + for (const group of allGroups) { + const key = familyOf(group.type.id).family + counts.set(key, (counts.get(key) ?? 0) + 1) + } + return counts + }, [allGroups]) + + const groups = useMemo(() => { + const needle = search.trim().toLowerCase() + return allGroups + .filter((group) => { + if (family && familyOf(group.type.id).family !== family) return false + if (!needle) return true + if ( + group.type.id.toLowerCase().includes(needle) || + group.type.worker_name.toLowerCase().includes(needle) || + (group.type.description ?? '').toLowerCase().includes(needle) + ) { + return true + } + return group.bindings.some( + (b) => + b.function_id.toLowerCase().includes(needle) || + b.worker_name.toLowerCase().includes(needle) || + summarize(b).toLowerCase().includes(needle) || + (b.config_summary ?? '').toLowerCase().includes(needle), + ) + }) + .sort((a, b) => { + // Bound types stay easy to find without moving around as calls arrive. + // Live function spans cannot prove which trigger fired, so activity is + // deliberately not used as a sorting signal here. + const count = b.bindings.length - a.bindings.length + if (count !== 0) return count + return a.type.id.localeCompare(b.type.id) + }) + }, [allGroups, family, search]) + + const boundCount = groups.reduce((n, g) => n + g.bindings.length, 0) + const totalBindings = allGroups.reduce((n, g) => n + g.bindings.length, 0) + + // Most of the catalogue is unbound types; expanding all of them buries the + // ones that actually fire, so a type opens by default only when something + // is bound to it. + const bindingCounts = useMemo( + () => new Map(groups.map((g) => [g.type.id, g.bindings.length])), + [groups], + ) + const groupState = useGroupToggle((id) => (bindingCounts.get(id) ?? 0) > 0) + + const selectType = (id: string) => + setSelected((prev) => + prev?.kind === 'type' && prev.id === id ? null : { kind: 'type', id }, + ) + const selectBinding = (binding: RegisteredTrigger) => + setSelected((prev) => + prev?.kind === 'binding' && prev.binding.id === binding.id + ? null + : { kind: 'binding', binding }, + ) + + // Keep an open document tied to the live registry. Bindings may be updated + // in place or disappear when their worker disconnects. + useEffect(() => { + if (!catalog.data || !selected) return + if (selected.kind === 'type') { + const exists = + catalog.data.types.some((type) => type.id === selected.id) || + catalog.data.bindings.some( + (binding) => binding.trigger_type === selected.id, + ) + if (!exists) setSelected(null) + return + } + const current = catalog.data.bindings.find( + (binding) => binding.id === selected.binding.id, + ) + if (!current) setSelected(null) + else if (current !== selected.binding) { + setSelected({ kind: 'binding', binding: current }) + } + }, [catalog.data, selected]) + + return ( + } + title="triggers" + description={ + + trigger types and registered bindings + + } + onClose={onRequestClose} + className="console-catalog-page-header" + actions={ + <> + + + + } + /> + } + sideTop={ + <> +
+ + +
+ + + } + sideFooter={ + + {catalog.data === null + ? 'loading triggers…' + : search.trim() || family + ? `showing ${groups.length} of ${allGroups.length} types · ${boundCount} bindings` + : `${allGroups.length} types · ${totalBindings} bindings`} + + } + list={ + catalog.error ? ( + + ) : catalog.data === null ? ( + + ) : groups.length === 0 ? ( + { + setSearch('') + setFamily(null) + }, + } + : undefined + } + /> + ) : ( + <> + {groups.map((group) => { + const spec = familyOf(group.type.id) + return ( +
+ groupState.toggle(group.type.id)} + collapsible={group.bindings.length > 0} + onSelect={() => selectType(group.type.id)} + selected={ + selected?.kind === 'type' && selected.id === group.type.id + } + /> + {groupState.isOpen(group.type.id) + ? group.bindings.map((binding) => ( + + } + primary={summarize(binding)} + secondary={ + binding.function_id + ? `${binding.function_id}${ + describeFunction(binding.function_id) + ? ` — ${describeFunction(binding.function_id)}` + : '' + }` + : '(no target function)' + } + selected={ + selected?.kind === 'binding' && + selected.binding.id === binding.id + } + onClick={() => selectBinding(binding)} + /> + )) + : null} +
+ ) + })} + {partitioned.plumbing.length > 0 && !search.trim() && !family ? ( +
+ groupState.toggle('__plumbing')} + /> + {groupState.isOpen('__plumbing') + ? partitioned.plumbing.map((binding) => ( + + } + primary={summarize(binding)} + secondary={`${binding.trigger_type} → ${binding.function_id}`} + selected={ + selected?.kind === 'binding' && + selected.binding.id === binding.id + } + onClick={() => selectBinding(binding)} + /> + )) + : null} +
+ ) : null} + + ) + } + main={ + selected === null ? ( + } + eyebrow="trigger catalog" + title="understand what starts work" + body="select a type to inspect the contract it defines, or select a binding nested beneath it to see the live route, schedule, or subscription connected to a function." + items={[ + { + label: 'type', + value: 'the reusable trigger definition and its schemas', + }, + { + label: 'binding', + value: 'a registered route, schedule, topic, or hook', + }, + { + label: 'target', + value: 'the function that receives the trigger payload', + }, + ]} + /> + ) : selected.kind === 'type' ? ( + setSelected(null)} + /> + ) : ( + setSelected(null)} + /> + ) + } + /> + ) +} + +function TypeDocument({ + host, + typeId, + onBack, +}: { + host: Host + typeId: string + onBack: () => void +}) { + const load = useCallback(() => triggerTypeInfo(host, typeId), [host, typeId]) + const detail = useResource(load) + const spec = familyOf(typeId) + + return ( + : undefined} + > +
+ + + } + title={typeId} + status="trigger type" + description={ + detail.data + ? detail.data.description || 'no description provided.' + : undefined + } + chips={ + detail.data ? ( + <> + + + {detail.data.instance_count !== undefined ? ( + + ) : null} + + ) : null + } + actions={} + /> + {detail.error ? ( + + ) : detail.data === null ? ( + loading detail… + ) : ( + + + configuration + event payload + + + + + + + + + )} +
+
+ ) +} + +function TypeContext({ detail }: { detail: TriggerTypeDetail }) { + const spec = familyOf(detail.id) + return ( + <> + + {detail.id} }, + { label: 'family', value: spec.label }, + { label: 'worker', value: detail.worker_name }, + { + label: 'registered', + value: String(detail.instance_count ?? 0), + }, + ]} + /> + + + + + + ) +} + +/** The engine sends `config_summary` as a JSON STRING — parse it so the + * fallback renders as structured JSON, not one quoted escaped line. */ +function parsedSummary(raw: string | null | undefined): unknown { + if (!raw) return undefined + try { + return JSON.parse(raw) + } catch { + return raw + } +} + +function BindingDocument({ + host, + binding, + description, + onBack, +}: { + host: Host + binding: RegisteredTrigger + description?: string + onBack: () => void +}) { + // The payload schema belongs to the TYPE, so a direct call opens on the + // shape this binding's function actually receives when the trigger fires. + const load = useCallback( + () => triggerTypeInfo(host, binding.trigger_type), + [host, binding.trigger_type], + ) + const type = useResource(load) + + const spec = familyOf(binding.trigger_type) + const http = httpBinding(binding) + const topic = queueTopic(binding) + const chips = configChips(binding) + + // A title must read as a name. summarize() avoids raw JSON already, but if + // a config defeats it the type id is the honest fallback. + const title = (() => { + const s = summarize(binding) + return s.startsWith('{') ? binding.trigger_type : s + })() + + return ( + } + > +
+ + + } + title={title} + status="registered binding" + description={ + binding.function_id + ? `Delivers ${spec.label} events to ${binding.function_id}.` + : 'This binding has no target function.' + } + chips={ + <> + + + {chips.map((chip) => ( + + ))} + + } + actions={} + /> + +
+ {type.error ? ( + + ) : null} + + + + + {spec.family === 'http' + ? 'send request' + : spec.family === 'queue' + ? 'publish' + : spec.family === 'cron' + ? 'run now' + : 'trigger function'} + + config + + + + {http ? ( + + ) : topic ? ( + + ) : binding.function_id ? ( + + ) : ( + + this binding carries no target function — nothing to call. + + )} + + + + + + +
+
+
+ ) +} + +function BindingContext({ + binding, + description, +}: { + binding: RegisteredTrigger + description?: string +}) { + const spec = familyOf(binding.trigger_type) + const hasDeliveryFacts = Boolean( + cronExpression(binding) || httpBinding(binding) || queueTopic(binding), + ) + return ( + <> + + {binding.function_id ? ( + } + title={binding.function_id} + description={description || 'No function description provided.'} + meta={binding.worker_name} + /> + ) : ( + + No target function is registered. + + )} + + + + {binding.id} }, + { label: 'type', value: {binding.trigger_type} }, + { label: 'family', value: spec.label }, + { label: 'worker', value: binding.worker_name }, + ]} + /> + + + {hasDeliveryFacts ? ( + + + + ) : null} + + ) +} + +/** The stat tiles that only make sense for a given family. */ +function FamilyFacts({ binding }: { binding: RegisteredTrigger }) { + const expression = cronExpression(binding) + if (expression) { + const now = new Date() + const next = nextCronRun(expression, now) + return ( + <> +
+ + {describeCron(expression) ?? 'custom schedule'} + + {expression} +
+
+ + +
+ + ) + } + + const http = httpBinding(binding) + if (http) { + return ( +
+ + +
+ ) + } + + const topic = queueTopic(binding) + if (topic) { + return ( +
+ + +
+ ) + } + return null +} diff --git a/console/ui/src/catalog/cron.ts b/console/ui/src/catalog/cron.ts new file mode 100644 index 000000000..c22bae4ca --- /dev/null +++ b/console/ui/src/catalog/cron.ts @@ -0,0 +1,185 @@ +/** + * Cron expression reading: a plain-language description and, where it can be + * derived honestly, the next fire time. + * + * Both refuse to guess. `describeCron` returns null for anything past the + * common shapes (ranges, multi-field lists, `L`/`#` extensions) because a + * wrong translation is worse than the raw expression, and `nextRun` covers + * only the shapes whose next occurrence follows from the fields alone. + * + * The console SPA carries the same `describeCron` for chat rendering + * (`console/web/src/components/chat/engine/parsers.ts`). Injected UI cannot + * import across the two projects, and `@iii-dev/console-ui` is deliberately + * the only versioned contract, so this is a copy on purpose. + */ + +const MONTHS = [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', +] +const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +const num = (s: string, max: number): number | null => + /^\d+$/.test(s) && Number(s) <= max ? Number(s) : null + +const step = (s: string): number | null => { + const m = /^\*\/(\d+)$/.exec(s) + return m ? Number(m[1]) : null +} + +const pad = (n: number) => String(n).padStart(2, '0') + +/** Split into fields, normalizing the 5, 6 and 7-field dialects. */ +function fields(expression: string) { + const f = expression.trim().split(/\s+/) + if (f.length < 5 || f.length > 7) return null + const withSeconds = f.length >= 6 + const [min, hour, dom, mon, dow] = withSeconds ? f.slice(1) : f + return { + sec: withSeconds ? f[0] : '0', + min, + hour, + dom, + mon, + dow, + year: f.length === 7 ? f[6] : '*', + withSeconds, + } +} + +/** + * Humanize the common cron shapes: fixed time, minute steps, single + * day-of-month/month, weekday lists. `null` means "show the raw expression". + */ +export function describeCron(expression: string): string | null { + const f = fields(expression) + if (!f || f.year !== '*') return null + + const secondsWild = f.sec === '*' + const secNum = num(f.sec, 59) + if (!secondsWild && secNum == null) return null + + const h = num(f.hour, 23) + const m = num(f.min, 59) + let time: string | null = null + let daily = false + if (h != null && m != null) { + time = `at ${pad(h)}:${pad(m)}` + daily = true + } else if (f.hour === '*') { + const minStep = step(f.min) + if (minStep != null) time = `every ${minStep} min` + else if (f.min === '*') + time = f.sec === '*' ? 'every second' : 'every minute' + else if (m != null) time = `at :${pad(m)} every hour` + else return null + } else { + const hourStep = step(f.hour) + if (hourStep != null && m != null) time = `every ${hourStep}h at :${pad(m)}` + else return null + } + // Every description except "every second" speaks at minute granularity, so + // it is only honest when the schedule fires once per matching minute + // (seconds pinned to 0). `* 0 17 * * *` fires every second DURING 17:00 — + // saying "at 17:00" would hide sixty firings. + if (time !== 'every second' && (secondsWild || secNum !== 0)) return null + + const dn = num(f.dom, 31) + const mn = num(f.mon, 12) + if (f.dom !== '*' && (dn == null || dn < 1)) return null + if (f.mon !== '*' && (mn == null || mn < 1)) return null + let date: string | null = null + if (dn != null && mn != null) date = `on ${MONTHS[mn - 1]} ${dn}` + else if (dn != null) date = `on day ${dn} of every month` + else if (mn != null) date = `in ${MONTHS[mn - 1]}` + + let week: string | null = null + if (f.dow !== '*' && f.dow !== '?') { + const names = f.dow.split(',').map((d) => { + const n = num(d, 7) + if (n == null) return null + // Numeric weekday numbering differs by dialect: the seconds-first form + // is the Rust `cron` crate's (Quartz-style, 1=Sun..7=Sat); classic + // five-field cron is 0=Sun..6=Sat with 7 also Sunday. + if (f.withSeconds) return n >= 1 ? WEEKDAYS[n - 1] : null + return WEEKDAYS[n % 7] + }) + if (names.some((n) => n == null)) return null + week = `every ${names.join(', ')}` + } + + // A day-of-month AND a weekday restriction is OR semantics in cron, subtle + // enough that the raw expression is the honest rendering. + if (date && week) return null + if (week) return daily ? `${week} ${time}` : `${time} ${week}` + if (date) return `${time} ${date}` + return daily ? `every day ${time}` : (time as string) +} + +/** + * Next fire time for the unrestricted shapes only: a daily fixed time, a + * minute step, a fixed minute each hour. Anything with a date or weekday + * restriction returns null rather than a number the page cannot stand behind. + */ +export function nextCronRun(expression: string, now: Date): Date | null { + const f = fields(expression) + if (!f || f.year !== '*') return null + if (f.dom !== '*' || f.mon !== '*' || (f.dow !== '*' && f.dow !== '?')) { + return null + } + if (num(f.sec, 59) !== 0) return null + + const h = num(f.hour, 23) + const m = num(f.min, 59) + const next = new Date(now) + next.setSeconds(0, 0) + + if (h != null && m != null) { + next.setHours(h, m) + if (next <= now) next.setDate(next.getDate() + 1) + return next + } + if (f.hour === '*') { + const minStep = step(f.min) + if (minStep != null && minStep > 0) { + const minute = now.getMinutes() + const upcoming = Math.floor(minute / minStep) * minStep + minStep + next.setMinutes(upcoming) + return next + } + if (m != null) { + next.setMinutes(m) + if (next <= now) next.setHours(next.getHours() + 1) + return next + } + if (f.min === '*') { + next.setMinutes(now.getMinutes() + 1) + return next + } + } + return null +} + +/** "in 4 min", "in 2 h 10 min", "in 12 s" — the wait, not a wall-clock time. */ +export function untilLabel(target: Date, now: Date): string { + const seconds = Math.max( + 0, + Math.round((target.getTime() - now.getTime()) / 1000), + ) + if (seconds < 60) return `in ${seconds} s` + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `in ${minutes} min` + const hours = Math.floor(minutes / 60) + const rest = minutes % 60 + return rest ? `in ${hours} h ${rest} min` : `in ${hours} h` +} diff --git a/console/ui/src/catalog/engine.ts b/console/ui/src/catalog/engine.ts new file mode 100644 index 000000000..c104f6441 --- /dev/null +++ b/console/ui/src/catalog/engine.ts @@ -0,0 +1,561 @@ +/** + * The engine catalogue calls behind the Functions and Triggers pages, plus + * the narrow runtime guards that keep an unexpected wire shape from + * reaching React as `undefined.map`. + * + * Wire source: `iii/engine/src/workers/engine_fn/mod.rs`. Everything here is + * read-only except `invoke`, which is a plain function call over the tab's + * bus (`host.iii.trigger`) — the same privilege any worker on the bus has. + * + * Parsing is deliberately permissive: unknown fields pass through, absent + * optionals stay absent, and only the fields the pages actually render are + * required. A row the engine grew a field for still lists. + */ + +import type { Host } from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +export interface FunctionSummary { + function_id: string + worker_name: string + description?: string | null +} + +/** Inline trigger payload on `FunctionDetail` — raw `config`, not a summary. */ +export interface RegisteredTriggerRef { + id: string + trigger_type: string + config?: unknown +} + +export interface FunctionDetail extends FunctionSummary { + request_schema?: unknown + response_schema?: unknown + metadata?: unknown + registered_triggers: RegisteredTriggerRef[] +} + +/** A trigger TYPE (the catalogue entry a worker publishes). */ +export interface TriggerTypeSummary { + id: string + worker_name: string + description?: string | null +} + +export interface TriggerTypeDetail extends TriggerTypeSummary { + /** Live bindings of this type. */ + instance_count?: number + /** Per-binding `config` shape accepted by `engine::register_trigger`. */ + configuration_schema?: unknown + /** Payload shape delivered to the bound function when the trigger fires. */ + request_schema?: unknown +} + +/** A live binding of a trigger type to a function. */ +export interface RegisteredTrigger { + id: string + trigger_type: string + function_id: string + worker_name: string + /** The engine sends both the raw object and a stringified summary. */ + config?: unknown + config_summary?: string +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function str(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +/** `null` and absent both mean "no description"; anything else is dropped. */ +function description(value: unknown): string | null | undefined { + if (value === null) return null + return str(value) +} + +function rows(value: unknown, key: string): unknown[] { + if (!isRecord(value)) return [] + const list = value[key] + return Array.isArray(list) ? list : [] +} + +function functionSummary(row: unknown): FunctionSummary | null { + if (!isRecord(row)) return null + const function_id = str(row.function_id) + if (!function_id) return null + return { + function_id, + worker_name: str(row.worker_name) ?? 'unknown', + description: description(row.description), + } +} + +function triggerRef(row: unknown): RegisteredTriggerRef | null { + if (!isRecord(row)) return null + const id = str(row.id) + const trigger_type = str(row.trigger_type) + if (!id || !trigger_type) return null + return { id, trigger_type, config: row.config } +} + +export function errorMessage(err: unknown): string { + const seen = new Set() + const describe = (value: unknown): string => { + if (value === null || value === undefined) return 'unknown error' + if (value instanceof Error) return value.message || value.name + if (typeof value !== 'object') return String(value) + if (seen.has(value)) return 'unknown error' + seen.add(value) + + const record = value as Record + const code = typeof record.code === 'string' ? record.code : undefined + const candidates = [ + record.message, + record.error, + record.reason, + record.detail, + ] + for (const candidate of candidates) { + if (typeof candidate === 'string' && candidate.trim()) { + return code ? `${code}: ${candidate}` : candidate + } + if (candidate && typeof candidate === 'object') { + const nested = describe(candidate) + if (nested !== 'unknown error') { + return code ? `${code}: ${nested}` : nested + } + } + } + + try { + const serialized = JSON.stringify(value) + if (serialized && serialized !== '{}') return serialized + } catch { + // Cyclic or otherwise non-serializable errors fall through to a stable + // message instead of leaking the unhelpful default object coercion. + } + return code || 'unknown error' + } + return describe(err) +} + +export async function listFunctions( + host: Host, + options: { includeInternal: boolean }, +): Promise { + const out = await host.iii.trigger('engine::functions::list', { + include_internal: options.includeInternal, + }) + return rows(out, 'functions') + .map(functionSummary) + .filter((f): f is FunctionSummary => f !== null) +} + +export async function functionInfo( + host: Host, + functionId: string, +): Promise { + const out = await host.iii.trigger('engine::functions::info', { + function_id: functionId, + }) + const base = functionSummary(out) + if (!base) throw new Error(`engine::functions::info returned no detail`) + const detail = out as Record + return { + ...base, + request_schema: detail.request_schema, + response_schema: detail.response_schema, + metadata: detail.metadata, + registered_triggers: rows(out, 'registered_triggers') + .map(triggerRef) + .filter((t): t is RegisteredTriggerRef => t !== null), + } +} + +/** The slice of `engine::workers::list` the pages read: name → runtime. */ +export interface WorkerMeta { + name: string + /** Worker runtime as reported on the handshake (`node`, `python`, `rust`). */ + runtime?: string +} + +export async function listWorkers(host: Host): Promise { + const out = await host.iii.trigger('engine::workers::list', {}) + return rows(out, 'workers') + .map((row): WorkerMeta | null => { + if (!isRecord(row)) return null + const name = str(row.name) + if (!name) return null + return { name, runtime: str(row.runtime) } + }) + .filter((w): w is WorkerMeta => w !== null) +} + +export async function listTriggerTypes( + host: Host, + options: { includeInternal: boolean }, +): Promise { + const out = await host.iii.trigger('engine::triggers::list', { + include_internal: options.includeInternal, + }) + return rows(out, 'triggers') + .map((row): TriggerTypeSummary | null => { + if (!isRecord(row)) return null + const id = str(row.id) + if (!id) return null + return { + id, + worker_name: str(row.worker_name) ?? 'unknown', + description: description(row.description), + } + }) + .filter((t): t is TriggerTypeSummary => t !== null) +} + +export async function triggerTypeInfo( + host: Host, + id: string, +): Promise { + const out = await host.iii.trigger('engine::triggers::info', { id }) + if (!isRecord(out)) + throw new Error('engine::triggers::info returned no detail') + return { + id: str(out.id) ?? id, + worker_name: str(out.worker_name) ?? 'unknown', + description: description(out.description), + instance_count: + typeof out.instance_count === 'number' ? out.instance_count : undefined, + configuration_schema: out.configuration_schema, + request_schema: out.request_schema, + } +} + +export async function listRegisteredTriggers( + host: Host, + options: { includeInternal: boolean }, +): Promise { + const out = await host.iii.trigger('engine::registered-triggers::list', { + include_internal: options.includeInternal, + }) + return rows(out, 'registered_triggers') + .map((row): RegisteredTrigger | null => { + if (!isRecord(row)) return null + const id = str(row.id) + const trigger_type = str(row.trigger_type) + if (!id || !trigger_type) return null + return { + id, + trigger_type, + function_id: str(row.function_id) ?? '', + worker_name: str(row.worker_name) ?? 'unknown', + config: row.config, + config_summary: str(row.config_summary), + } + }) + .filter((t): t is RegisteredTrigger => t !== null) +} + +export interface InvokeOutcome { + ok: boolean + durationMs: number + data?: unknown + error?: string +} + +/** Call a function the way any bus client would; never throws. */ +export async function invoke( + host: Host, + functionId: string, + payload: Record, +): Promise { + const started = performance.now() + try { + const data = await host.iii.trigger(functionId, payload) + return { ok: true, durationMs: performance.now() - started, data } + } catch (err) { + return { + ok: false, + durationMs: performance.now() - started, + error: errorMessage(err), + } + } +} + +/** Unique per mount so two mounted pages never share a handler name. */ +let hubSeq = 0 + +/** + * The engine's own catalogue signals. Both are internal trigger types the + * engine publishes itself, which is why the pages never poll: + * + * - `engine::functions-available` fires when functions are registered or + * unregistered (a worker connecting registers its whole surface at once) + * - `engine::workers-available` fires when a worker connects or disconnects + * - `trace` is a coalesced "spans changed" tick carrying the affected trace + * ids; it is a refetch beat, not a span feed, so a live view re-reads + * `engine::traces::list` when it ticks + */ +export type LiveSignal = + | 'engine::functions-available' + | 'engine::workers-available' + | 'trace' + +/** + * Subscribe to engine signals for this component's lifetime and call `onTick` + * when any of them fires, debounced across bursts (a worker connecting emits + * one event per function). + * + * The binding is a per-tab handler under the `iii::` prefix, which keeps the + * per-event invocations span-suppressed and out of the trace feed — a live + * view of traces must not feed itself. It is GC'd with the tab like any + * Message-path trigger. A missing trigger type degrades to the page's manual + * refresh rather than breaking the page. + */ +export function useLiveSignals( + host: Host, + signals: readonly LiveSignal[], + onTick: () => void, + options: { debounceMs?: number } = {}, +) { + const tickRef = useRef(onTick) + tickRef.current = onTick + const debounceMs = options.debounceMs ?? 400 + const handlerId = useMemo(() => { + hubSeq += 1 + return `iii::console-catalog::live-${hubSeq}` + }, []) + const key = signals.join(',') + + useEffect(() => { + let timer: number | null = null + const schedule = () => { + if (timer !== null) window.clearTimeout(timer) + timer = window.setTimeout(() => { + timer = null + tickRef.current() + }, debounceMs) + } + + const offHandler = host.iii.on(handlerId, schedule) + const offTriggers = key + .split(',') + .map((type) => { + try { + return host.iii.registerTrigger({ + type, + function_id: `${handlerId}::${host.iii.browserId}`, + config: {}, + }) + } catch { + return null + } + }) + .filter((off): off is () => void => off !== null) + + return () => { + if (timer !== null) window.clearTimeout(timer) + for (const off of offTriggers) off() + offHandler() + } + }, [host, handlerId, key, debounceMs]) +} + +/** One recorded invocation of a function, read back from its span. */ +export interface CallRecord { + spanId: string + traceId: string + functionId: string + startedAtMs: number + durationMs: number + ok: boolean + input?: unknown + output?: unknown + worker: string +} + +function eventPayload(span: Record, name: string): unknown { + const events = Array.isArray(span.events) ? span.events : [] + for (const event of events) { + if (!isRecord(event) || event.name !== name) continue + const attrs = Array.isArray(event.attributes) ? event.attributes : [] + for (const attr of attrs) { + if (!Array.isArray(attr) || attr[0] !== 'iii.payload.json') continue + try { + return JSON.parse(String(attr[1])) + } catch { + return attr[1] + } + } + } + return undefined +} + +/** + * Recent calls of one function, newest first. + * + * Span names are `execute `, so the engine can filter server + * side instead of the page pulling the whole feed and discarding most of it. + */ +export async function listCalls( + host: Host, + functionId: string, + limit = 25, +): Promise { + const out = await host.iii.trigger('engine::traces::list', { + name: `execute ${functionId}`, + limit, + include_internal: true, + }) + return rows(out, 'spans') + .map((span): CallRecord | null => { + if (!isRecord(span)) return null + const start = Number(span.start_time_unix_nano) + const end = Number(span.end_time_unix_nano) + if (!Number.isFinite(start)) return null + return { + spanId: str(span.span_id) ?? '', + traceId: str(span.trace_id) ?? '', + functionId, + startedAtMs: start / 1e6, + // In-flight spans carry a null end (Number(null) === 0) — same guard + // as spansFromFrame, or the duration goes negative. + durationMs: + Number.isFinite(end) && end > start ? (end - start) / 1e6 : 0, + ok: str(span.status) !== 'error', + input: eventPayload(span, 'iii.invocation.input'), + output: eventPayload(span, 'iii.invocation.output'), + worker: str(span.service_name) ?? 'unknown', + } + }) + .filter((c): c is CallRecord => c !== null) + .sort((a, b) => b.startedAtMs - a.startedAtMs) +} + +export interface Resource { + data: T | null + error: string | null + loading: boolean + reload: () => void +} + +/** + * Load `work` and keep the result, with the staleness guard both pages need: + * a selection changed mid-flight discards the older answer instead of + * painting it over the newer one. `work` must be a stable callback (the + * caller's `useCallback`) — it is the dependency. + */ +export function useResource(work: () => Promise): Resource { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + const [nonce, setNonce] = useState(0) + const seq = useRef(0) + + useEffect(() => { + seq.current += 1 + const token = seq.current + setLoading(true) + work().then( + (value) => { + if (seq.current !== token) return + setData(value) + setError(null) + setLoading(false) + }, + (err: unknown) => { + if (seq.current !== token) return + setError(errorMessage(err)) + setLoading(false) + }, + ) + }, [work, nonce]) + + const reload = useCallback(() => setNonce((n) => n + 1), []) + return { data, error, loading, reload } +} + +/* ---------------- live span feed ---------------- */ + +/** One executed call, as the all-spans stream delivers it. */ +export interface SpanEvent { + functionId: string + worker: string + durationMs: number + ok: boolean + atMs: number +} + +/** + * The engine pushes every non-internal span onto the + * `iii:devtools:all-spans` stream each coalesce window — the same feed the + * traces masthead rides. Frame envelope: `{event:{event:{data:{spans}}}}` + * for the event variant, one level shallower for create/update + * (`console/web/src/lib/traces-stream.ts` is the reference reader). + */ +function spansFromFrame(frame: unknown): SpanEvent[] { + if (!isRecord(frame)) return [] + const outer = isRecord(frame.event) ? frame.event : null + if (!outer) return [] + const inner = isRecord(outer.event) ? outer.event : outer + const data = isRecord(inner.data) ? inner.data : null + const spans = data && Array.isArray(data.spans) ? data.spans : [] + const out: SpanEvent[] = [] + for (const span of spans) { + if (!isRecord(span)) continue + const name = str(span.name) ?? '' + // Execution spans are `execute `; caller-side `call …` + // spans would double-count the same invocation. + if (!name.startsWith('execute ')) continue + const start = Number(span.start_time_unix_nano) + // In-flight spans stream with a null end; Number(null) is 0, which would + // read as a negative duration. Only a real end after the start counts. + const end = Number(span.end_time_unix_nano) + if (!Number.isFinite(start) || start <= 0) continue + out.push({ + functionId: name.slice('execute '.length), + worker: str(span.service_name) ?? 'unknown', + durationMs: Number.isFinite(end) && end > start ? (end - start) / 1e6 : 0, + ok: str(span.status) !== 'error', + atMs: start / 1e6, + }) + } + return out +} + +/** + * Live feed of executed calls for this component's lifetime. Batches arrive + * as the engine coalesces them (sub-second under load); the binding is a + * per-tab stream subscription GC'd with the tab. + */ +export function useSpanFeed(host: Host, onSpans: (spans: SpanEvent[]) => void) { + const handlerRef = useRef(onSpans) + handlerRef.current = onSpans + const handlerId = useMemo(() => { + hubSeq += 1 + return `iii::console-catalog::spans-${hubSeq}` + }, []) + + useEffect(() => { + const offHandler = host.iii.on(handlerId, (frame: unknown) => { + const spans = spansFromFrame(frame) + if (spans.length > 0) handlerRef.current(spans) + }) + let offTrigger: (() => void) | undefined + try { + offTrigger = host.iii.registerTrigger({ + type: 'stream', + function_id: `${handlerId}::${host.iii.browserId}`, + config: { stream_name: 'iii:devtools:all-spans', group_id: 'all' }, + }) + } catch { + // No stream worker on this engine: pages degrade to manual refresh. + } + return () => { + offTrigger?.() + offHandler() + } + }, [host, handlerId]) +} diff --git a/console/ui/src/catalog/live.tsx b/console/ui/src/catalog/live.tsx new file mode 100644 index 000000000..ca4c04a6a --- /dev/null +++ b/console/ui/src/catalog/live.tsx @@ -0,0 +1,172 @@ +/** + * The page-level live layer: what is running RIGHT NOW, visible without + * selecting anything. + * + * `useLiveActivity` folds the all-spans stream into two things a page can + * render from directly: a rolling feed of the most recent calls (the + * now-strip) and a per-function "last call" map (row pulses and the live + * meta line). One subscription per page, shared by every row. + * + * The strip is the page's one bold element. Everything it shows is a real + * execution the engine just recorded — during a harness turn it reads as + * the agent thinking out loud. + */ + +import { type Host, StatusDot } from '@iii-dev/console-ui' +import { useCallback, useEffect, useRef, useState } from 'react' +import { formatDuration } from './ActivityFeed' +import { type SpanEvent, useSpanFeed } from './engine' + +const FEED_LENGTH = 18 +const PULSE_MS = 1400 + +export interface LiveActivity { + /** Newest first, capped at FEED_LENGTH. */ + feed: readonly SpanEvent[] + /** Latest call per function id. */ + lastCall: ReadonlyMap + /** Function ids whose pulse animation is currently running. */ + pulsing: ReadonlySet +} + +export function useLiveActivity(host: Host): LiveActivity { + const [feed, setFeed] = useState([]) + const [lastCall, setLastCall] = useState>( + new Map(), + ) + const [pulsing, setPulsing] = useState>(new Set()) + const timers = useRef>(new Map()) + + useSpanFeed( + host, + useCallback((spans: SpanEvent[]) => { + const newest = [...spans].sort((a, b) => b.atMs - a.atMs) + setFeed((prev) => { + // A span can arrive twice: in-flight (no end yet) and completed. + // Same identity, so the completed version replaces the running one + // instead of stacking next to it. + const merged = new Map() + for (const span of [...newest, ...prev]) { + const key = `${span.functionId}@${span.atMs}` + const held = merged.get(key) + if (!held || (held.durationMs === 0 && span.durationMs > 0)) { + merged.set(key, span) + } + } + return [...merged.values()] + .sort((a, b) => b.atMs - a.atMs) + .slice(0, FEED_LENGTH) + }) + setLastCall((prev) => { + const next = new Map(prev) + for (const span of spans) { + const held = next.get(span.functionId) + if (!held || span.atMs >= held.atMs) next.set(span.functionId, span) + } + return next + }) + setPulsing((prev) => { + const next = new Set(prev) + for (const span of spans) next.add(span.functionId) + return next + }) + for (const span of spans) { + const existing = timers.current.get(span.functionId) + if (existing !== undefined) window.clearTimeout(existing) + timers.current.set( + span.functionId, + window.setTimeout(() => { + timers.current.delete(span.functionId) + setPulsing((prev) => { + const next = new Set(prev) + next.delete(span.functionId) + return next + }) + }, PULSE_MS), + ) + } + }, []), + ) + + useEffect( + () => () => { + for (const timer of timers.current.values()) window.clearTimeout(timer) + }, + [], + ) + + return { feed, lastCall, pulsing } +} + +/** "3s ago" for the live meta line; empty under a second so fresh rows read as now. */ +export function agoLabel(atMs: number, nowMs: number): string { + const seconds = Math.floor((nowMs - atMs) / 1000) + if (seconds < 1) return 'now' + if (seconds < 60) return `${seconds}s ago` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + return `${Math.floor(minutes / 60)}h ago` +} + +/** + * The strip under the page head: recent function calls, newest on the left. + * Clicking one jumps to that function. + */ +export function NowStrip({ + activity, + onSelect, +}: { + activity: LiveActivity + onSelect?: (functionId: string) => void +}) { + // A ticking clock would re-render the whole page each second; the strip + // re-renders on arrival anyway, so relative times refresh with traffic. + const shown = activity.feed.slice(0, 6) + + if (shown.length === 0) { + return ( +
+ recent calls + no calls recorded since this page opened +
+ ) + } + + const now = Date.now() + return ( +
+ recent calls +
+ {shown.map((span) => ( + + ))} +
+
+ ) +} + +/** The quiet live meta a row shows once its function has been seen running. */ +export function LastCallMeta({ span }: { span: SpanEvent | undefined }) { + if (!span) return null + return ( + + {span.ok ? '' : 'failed · '} + {span.durationMs > 0 ? `${formatDuration(span.durationMs)} · ` : ''} + {agoLabel(span.atMs, Date.now())} + + ) +} diff --git a/console/ui/src/catalog/schema.ts b/console/ui/src/catalog/schema.ts new file mode 100644 index 000000000..e6537308f --- /dev/null +++ b/console/ui/src/catalog/schema.ts @@ -0,0 +1,89 @@ +/** + * JSON Schema → starting request body. The engine registers draft-07 + * schemas for most functions, so the invoke editor can open on the real + * field names instead of an empty object. + * + * Deliberately shallow-minded: it fills the shape, never plausible values. + * `default` and the first `enum` member are the only values it invents, + * because those are the schema's own words. Anything it cannot read becomes + * `{}` and the operator types the body themselves. + */ + +const MAX_DEPTH = 4 + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** `type` may be a string or a union array (`["string", "null"]`). */ +function primaryType(schema: Record): string | undefined { + const t = schema.type + if (typeof t === 'string') return t + if (Array.isArray(t)) { + const named = t.find((x) => typeof x === 'string' && x !== 'null') + if (typeof named === 'string') return named + } + // A bare `properties`/`items` with no `type` is still an object/array. + if (isRecord(schema.properties)) return 'object' + if (schema.items !== undefined) return 'array' + return undefined +} + +function sample(schema: unknown, depth: number): unknown { + if (!isRecord(schema) || depth > MAX_DEPTH) return null + if (schema.default !== undefined) return schema.default + if (Array.isArray(schema.enum) && schema.enum.length > 0) + return schema.enum[0] + + const composed = schema.oneOf ?? schema.anyOf ?? schema.allOf + if (Array.isArray(composed) && composed.length > 0) { + return sample(composed[0], depth + 1) + } + + switch (primaryType(schema)) { + case 'object': { + const props = isRecord(schema.properties) ? schema.properties : {} + const required = Array.isArray(schema.required) + ? schema.required.filter((k): k is string => typeof k === 'string') + : [] + const keys = Object.keys(props) + // Required fields first, then the rest in declaration order — the + // operator reads the fields they must fill without scrolling. + const ordered = [ + ...keys.filter((k) => required.includes(k)), + ...keys.filter((k) => !required.includes(k)), + ] + const out: Record = {} + for (const key of ordered) out[key] = sample(props[key], depth + 1) + return out + } + case 'array': + return [] + case 'string': + return '' + case 'number': + case 'integer': + return 0 + case 'boolean': + return false + default: + return null + } +} + +/** Pretty-printed starting body for the invoke editor. */ +export function templateFromSchema(schema: unknown): string { + const value = sample(schema, 0) + if (!isRecord(value) || Object.keys(value).length === 0) return '{}' + return JSON.stringify(value, null, 2) +} + +/** Pretty JSON for the read-only panes; non-JSON values degrade to text. */ +export function pretty(value: unknown): string { + if (value === undefined) return '' + try { + return JSON.stringify(value, null, 2) ?? String(value) + } catch { + return String(value) + } +} diff --git a/console/ui/src/catalog/trigger-kinds.ts b/console/ui/src/catalog/trigger-kinds.ts new file mode 100644 index 000000000..cb9e11b1d --- /dev/null +++ b/console/ui/src/catalog/trigger-kinds.ts @@ -0,0 +1,216 @@ +/** + * What a registered trigger IS, read from its type and config. + * + * A row is only useful if it says the thing the operator recognizes: an + * http registration is `GET /users/:id`, a cron registration is its + * schedule, a queue subscriber is its topic. This module owns that reading, one entry per + * family, so the page and the detail pane agree and an unknown type still + * gets a sane line instead of a blank. + * + * Families are matched on the type id the worker publishes today. Unknown + * ids fall through to the generic reading rather than being hidden: the type + * set is open, and a worker that ships a new type must still list. + */ + +import { describeCron } from './cron' +import type { RegisteredTrigger } from './engine' + +export type Family = + | 'http' + | 'cron' + | 'queue' + | 'state' + | 'stream' + | 'hook' + | 'asset' + | 'other' + +/** Tone drives the row chip color; it maps to the console's own tokens. */ +export type Tone = 'accent' | 'warn' | 'ok' | 'alert' | 'ink' + +export interface FamilySpec { + family: Family + label: string + tone: Tone +} + +const FAMILIES: { match: (typeId: string) => boolean; spec: FamilySpec }[] = [ + { + match: (t) => t === 'http', + spec: { family: 'http', label: 'http', tone: 'accent' }, + }, + { + match: (t) => t === 'cron' || t === 'timer', + spec: { family: 'cron', label: 'schedule', tone: 'warn' }, + }, + { + match: (t) => t === 'durable:subscriber' || t.startsWith('queue'), + spec: { family: 'queue', label: 'queue', tone: 'ok' }, + }, + { + match: (t) => t === 'state', + spec: { family: 'state', label: 'state', tone: 'ok' }, + }, + { + match: (t) => t === 'stream' || t.startsWith('stream:'), + spec: { family: 'stream', label: 'stream', tone: 'accent' }, + }, + { + match: (t) => t.startsWith('harness::hook::'), + spec: { family: 'hook', label: 'hook', tone: 'ink' }, + }, + { + match: (t) => t.startsWith('console:'), + spec: { family: 'asset', label: 'console asset', tone: 'ink' }, + }, +] + +export function familyOf(typeId: string): FamilySpec { + for (const entry of FAMILIES) { + if (entry.match(typeId)) return entry.spec + } + return { family: 'other', label: 'event', tone: 'ink' } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function text(config: unknown, key: string): string | undefined { + if (!isRecord(config)) return undefined + const value = config[key] + return typeof value === 'string' && value ? value : undefined +} + +export interface HttpBinding { + method: string + path: string + /** `:param` names in declaration order. */ + params: string[] +} + +/** The http family's config, or null when the binding is not http. */ +export function httpBinding(trigger: RegisteredTrigger): HttpBinding | null { + if (familyOf(trigger.trigger_type).family !== 'http') return null + const path = text(trigger.config, 'api_path') ?? '' + const params = [...path.matchAll(/:([a-zA-Z_][a-zA-Z0-9_]*)/g)].map( + (m) => m[1], + ) + return { + method: (text(trigger.config, 'http_method') ?? 'GET').toUpperCase(), + path: path.startsWith('/') ? path : `/${path}`, + params, + } +} + +/** The queue topic a subscriber consumes, under either config key. */ +export function queueTopic(trigger: RegisteredTrigger): string | undefined { + if (familyOf(trigger.trigger_type).family !== 'queue') return undefined + return text(trigger.config, 'queue') ?? text(trigger.config, 'topic') +} + +export function cronExpression(trigger: RegisteredTrigger): string | undefined { + if (familyOf(trigger.trigger_type).family !== 'cron') return undefined + return text(trigger.config, 'expression') +} + +/** + * The one line that names a registered trigger in the list: what it listens to, in the + * words of its family. Falls back to the compact config, then the type id, + * so a row is never blank. + */ +export function summarize(trigger: RegisteredTrigger): string { + const http = httpBinding(trigger) + if (http) return `${http.method} ${http.path}` + + const expression = cronExpression(trigger) + if (expression) return describeCron(expression) ?? expression + + const topic = queueTopic(trigger) + if (topic) return topic + + const family = familyOf(trigger.trigger_type).family + if (family === 'state') { + const scope = text(trigger.config, 'scope') + const key = text(trigger.config, 'key') + if (scope && key) return `${scope}/${key}` + if (scope) return `${scope}/*` + if (key) return `*/${key}` + return 'any state write' + } + if (family === 'stream') { + const stream = text(trigger.config, 'stream_name') + const group = text(trigger.config, 'group_id') + if (stream) return group ? `${stream} · ${group}` : stream + } + if (family === 'asset') { + const path = text(trigger.config, 'path') + if (path) return path + } + if (family === 'hook') { + return trigger.trigger_type.replace('harness::hook::', 'hook: ') + } + + // Session-scoped delivery (a console tab or sub-agent listening): say that, + // not the raw config JSON. + const sessionId = text(trigger.config, 'session_id') + if (sessionId) { + return `session ${sessionId.length > 18 ? `…${sessionId.slice(-12)}` : sessionId}` + } + + const summary = trigger.config_summary + // Raw JSON is a last resort for the row, never for a title: a `{"…"}` + // one-liner reads as a bug, not a name. + if (summary && summary !== '{}' && !summary.startsWith('{')) return summary + return trigger.trigger_type +} + +/** + * Console and engine plumbing: per-tab delivery handlers (`iii::` prefix), + * injected-UI assets, configuration hot-reload hooks, UI content functions. + * All real, none of them what an operator opens this page to see — they fold + * into one collapsed group at the bottom instead of burying the rest. + */ +export function isPlumbing(trigger: RegisteredTrigger): boolean { + if (trigger.function_id.startsWith('iii::')) return true + if (trigger.trigger_type.startsWith('console:')) return true + if (trigger.function_id.endsWith('::ui-content')) return true + if ( + trigger.trigger_type === 'configuration' && + /on[-_]config[-_]change/.test(trigger.function_id) + ) { + return true + } + return false +} + +/** + * Config fields worth a chip in the detail pane, in a stable order. Unknown + * fields are not listed here on purpose: they stay visible in the raw config + * block, which every binding shows. + */ +export function configChips( + trigger: RegisteredTrigger, +): { label: string; value: string }[] { + if (!isRecord(trigger.config)) return [] + const config = trigger.config + const chips: { label: string; value: string }[] = [] + const push = (key: string, label: string) => { + const value = config[key] + if (typeof value === 'string' && value) chips.push({ label, value }) + else if (typeof value === 'number') + chips.push({ label, value: String(value) }) + } + push('scope', 'scope') + push('key', 'key') + push('queue', 'queue') + push('topic', 'topic') + push('stream_name', 'stream') + push('group_id', 'group') + push('configuration_id', 'configuration') + push('max_retries', 'retries') + push('backoff_ms', 'backoff ms') + push('on_error', 'on error') + push('condition_function_id', 'if') + return chips +} diff --git a/console/ui/src/catalog/widgets.tsx b/console/ui/src/catalog/widgets.tsx new file mode 100644 index 000000000..62eaed2ef --- /dev/null +++ b/console/ui/src/catalog/widgets.tsx @@ -0,0 +1,825 @@ +/** + * The chrome both catalogue pages share, arranged like the directory page: + * a fixed navigation sidebar (search, filters, grouped rows) and a main + * workspace that is ALWAYS rendered — a hero empty state when nothing is + * selected, the document view (breadcrumb → identity head → tabs) when + * something is. + * + * Page chrome comes from `@iii-dev/console-ui` (PageShell/PageBody/ + * PageSidebar/PageMain — the console's own layout system, zero bytes in + * this bundle); everything else is a scoped class in ../../styles.css. + * + * Every list row leads with a glyph tile so the list reads as WHAT it is + * at a glance: `ƒ` for functions, a family icon (globe, clock, layers…) + * for triggers. The same tiles reappear scaled up in the identity head and + * the hero, so the sidebar and the workspace visibly describe one thing. + */ + +import { + Button, + Input, + PageBody, + PageMain, + PageShell, + PageSidebar, +} from '@iii-dev/console-ui' +import { Fragment, type ReactNode, useCallback, useState } from 'react' +import type { Family, Tone } from './trigger-kinds' + +/** + * Open/closed state for collapsible groups, stored as the set of ids whose + * state is FLIPPED from the default. Storing flips rather than open ids + * keeps a list that grows (a worker connects, a new group appears) honest: + * the newcomer follows the default instead of arriving collapsed. + * + * The default is a predicate because the pages disagree: function groups + * always open, trigger types open only when something is bound to them. + */ +export function useGroupToggle(defaultOpen: (id: string) => boolean) { + const [flipped, setFlipped] = useState>(new Set()) + const toggle = useCallback((id: string) => { + setFlipped((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }) + }, []) + const isOpen = (id: string) => + flipped.has(id) ? !defaultOpen(id) : defaultOpen(id) + return { isOpen, toggle } +} + +/** + * The catalogue page frame: header, the live now-strip, then sidebar | + * main. `hasSelection` rides on PageBody as a data attribute so the + * narrow-container styles can drill in (list OR document, never a squeeze) + * — the pane, not the viewport, decides. + */ +export function CatalogShell({ + header, + strip, + sideTop, + list, + sideFooter, + main, + side, + hasSelection, +}: { + header: ReactNode + /** The full-width live strip between header and columns. */ + strip?: ReactNode + /** Sidebar top block: search, filters, the count line. */ + sideTop: ReactNode + /** The scrolling group/row list (plumbing footer included). */ + list: ReactNode + /** A persistent catalogue summary below the scrolling list. */ + sideFooter?: ReactNode + /** The workspace: a hero when nothing is selected, else the document. */ + main: ReactNode + /** The hosting pane's side (`PageRenderProps.panelSide`). */ + side?: 'left' | 'right' + hasSelection: boolean +}) { + return ( + + {header} + {strip ?
{strip}
: null} + + +
{sideTop}
+
{list}
+ {sideFooter ? ( +
{sideFooter}
+ ) : null} +
+ {main} +
+
+ ) +} + +function SearchGlassIcon() { + return ( + + ) +} + +/** The sidebar search box: leading magnifier, esc/× to clear. */ +export function SearchField({ + value, + onChange, + placeholder, +}: { + value: string + onChange: (next: string) => void + placeholder: string +}) { + return ( +
+ + { + if (e.key === 'Escape' && value) { + e.stopPropagation() + onChange('') + } + }} + /> + {value ? ( + + ) : null} +
+ ) +} + +/** The quiet count line under the search box. */ +export function SideCount({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ) +} + +/** + * One chip per family that actually has entries, plus `all`. Families with + * nothing registered are not rendered: an empty filter is a dead control. + */ +export function FilterChips({ + counts, + selected, + onSelect, +}: { + counts: ReadonlyMap + selected: T | null + onSelect: (next: T | null) => void +}) { + const entries = [...counts.entries()].filter(([, n]) => n > 0).sort() + if (entries.length < 2) return null + const total = entries.reduce((n, [, count]) => n + count, 0) + return ( +
+ + {entries.map(([key, count]) => ( + + ))} +
+ ) +} + +/** A labelled fact in the detail pane: next run, method, topic, status. */ +export function StatTile({ + label, + value, + hint, + tone, +}: { + label: string + value: string + hint?: string + tone?: 'ok' | 'warn' | 'alert' +}) { + return ( +
+ {label} + + {value} + + {hint ? {hint} : null} +
+ ) +} + +/** + * The "this page is live" marker. Every catalogue page here is driven by an + * engine signal rather than a timer, and the operator deserves to know that + * without reading the source. + */ +export function LiveDot() { + return ( + + + live + + ) +} + +/** Copy-to-clipboard with the two-second confirmation the old console had. */ +export function CopyButton({ + value, + label = 'copy', + title, +}: { + value: string + label?: string + title?: string +}) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + +/** + * A collapsible group heading. The chevron is its own button so the label + * can be a second, independent target: the triggers page selects the TYPE + * by its heading (no phantom "type detail" row), the functions page just + * toggles. + */ +export function GroupHeader({ + label, + meta, + count, + countLabel, + open, + onToggle, + onSelect, + selected, + tone, + toneLabel, + collapsible = true, +}: { + label: string + meta?: string + count?: number + countLabel?: string + open: boolean + onToggle: () => void + /** Clicking the label selects the group's own detail (trigger types). */ + onSelect?: () => void + selected?: boolean + /** Family color for the leading tag, when the page groups by family. */ + tone?: string + toneLabel?: string + /** Empty trigger types have nothing to disclose. */ + collapsible?: boolean +}) { + const countText = count === undefined ? null : String(count) + const countTitle = + count === undefined + ? undefined + : `${count} ${countLabel ?? 'item'}${count === 1 ? '' : 's'}` + const content = ( + <> + + {toneLabel ? ( + + {toneLabel} + + ) : null} + + {label} + + {countText ? ( + + {countText} + + ) : null} + + {meta ? ( + + {meta} + + ) : null} + + ) + + return ( +
+ + {onSelect && collapsible ? ( + + ) : null} +
+ ) +} + +export function CatalogRow({ + icon, + primary, + secondary, + meta, + selected, + onClick, + flash, +}: { + /** The leading glyph tile — what KIND of thing this row is. */ + icon?: ReactNode + primary: ReactNode + secondary?: ReactNode + /** Right-aligned live annotation on the primary line (last call, ago). */ + meta?: ReactNode + selected: boolean + onClick: () => void + /** Highlight once: this row's function just ran (or the row just arrived). */ + flash?: boolean +}) { + return ( + + ) +} + +/* ── glyph tiles ────────────────────────────────────────────────────── */ + +/** The `ƒ` tile that marks a function everywhere: rows, head, hero. */ +export function FnGlyph({ size }: { size?: 'lg' | 'hero' }) { + return ( + + ƒ + + ) +} + +const FAMILY_ICONS: Record = { + http: ( + <> + + + + + ), + cron: ( + <> + + + + ), + queue: ( + <> + + + + + ), + state: ( + <> + + + + + ), + stream: ( + <> + + + + ), + hook: ( + <> + + + + + ), + asset: ( + <> + + + + ), + other: , +} + +/** The family tile that marks a trigger: globe, clock, layers, waves… */ +export function FamilyGlyph({ + family, + tone, + size, +}: { + family: Family + tone?: Tone + size?: 'lg' | 'hero' +}) { + return ( + + + + ) +} + +/* ── the main workspace pieces ──────────────────────────────────────── */ + +/** + * The workspace empty state, on the directory page's pattern: the page's + * glyph, one title, a short paragraph. It renders where the document will, + * so selecting something changes content, never layout. + */ +export function Hero({ + glyph, + eyebrow, + title, + body, + items, +}: { + glyph: ReactNode + eyebrow?: string + title: string + body: string + items?: readonly { label: string; value: string }[] +}) { + return ( +
+
+ {glyph} +
+ {eyebrow ? {eyebrow} : null} +

{title}

+

{body}

+
+ {items?.length ? ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) : null} +
+
+ ) +} + +/** + * A selected catalogue item has two jobs: the primary work surface and a + * compact reference rail. Keeping them in one component lets the rail stack + * below the document when the host pane is too narrow for three columns. + */ +export function CatalogWorkspace({ + children, + context, +}: { + children: ReactNode + context?: ReactNode +}) { + return ( +
+
{children}
+ {context ? ( + + ) : null} +
+ ) +} + +/** A titled group in the contextual rail. */ +export function ContextPanel({ + title, + description, + action, + children, + wide = false, +}: { + title: string + description?: string + action?: { label: string; onClick: () => void } + children: ReactNode + wide?: boolean +}) { + return ( +
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {action ? ( + + ) : null} +
+
{children}
+
+ ) +} + +/** A dense object summary used for related triggers and target functions. */ +export function ContextItem({ + glyph, + title, + description, + meta, + onClick, +}: { + glyph?: ReactNode + title: string + description?: ReactNode + meta?: ReactNode + onClick?: () => void +}) { + const content = ( + <> + {glyph ? {glyph} : null} + + {title} + {description ? ( + {description} + ) : null} + {meta ? {meta} : null} + + {onClick ? ( + + → + + ) : null} + + ) + + return onClick ? ( + + ) : ( +
{content}
+ ) +} + +/** + * The document's place line: `functions › harness › harness::state::list`. + * The back button only paints in narrow containers, where the document + * replaces the list and needs a way out. + */ +export function Crumb({ + trail, + onBack, +}: { + trail: readonly (string | undefined)[] + onBack: () => void +}) { + const segments = trail.filter((s): s is string => Boolean(s)) + return ( +
+ + {segments.map((segment, i) => ( + + {i > 0 ? ( + + › + + ) : null} + + {segment} + + + ))} +
+ ) +} + +/** The document masthead: big glyph, name, description, chips, actions. */ +export function IdentityHead({ + glyph, + title, + status, + description, + chips, + actions, +}: { + glyph: ReactNode + title: string + status?: string + description?: string | null + chips?: ReactNode + actions?: ReactNode +}) { + return ( +
+ {glyph} +
+
+

{title}

+ {status ? {status} : null} +
+ {description ?

{description}

: null} + {chips ?
{chips}
: null} +
+ {actions ?
{actions}
: null} +
+ ) +} + +/** The label/value fact sheet (the prototype's "function details" card). */ +export function Facts({ + items, +}: { + items: readonly { label: string; value: ReactNode }[] +}) { + if (items.length === 0) return null + return ( +
+ {items.map((item) => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+ ) +} + +export function Note({ children }: { children: ReactNode }) { + return
{children}
+} + +export function ErrorNote({ + title = 'request failed', + call, + message, + onRetry, +}: { + title?: string + call: string + message: string + onRetry?: () => void +}) { + return ( +
+
+ {title} + + {call} · {message} + +
+ {onRetry ? ( + + ) : null} +
+ ) +} + +/** A quiet, structural placeholder for the first catalogue load. */ +export function CatalogListSkeleton({ label }: { label: string }) { + return ( +
+ {[0, 1, 2].map((group) => ( +
+ + + +
+ ))} +
+ ) +} + +/** Key/value chips for a trigger config, ids, counts. */ +export function Chip({ + k, + v, + tone, +}: { + k: string + v: ReactNode + tone?: string +}) { + return ( + + {k} + {v} + + ) +} diff --git a/console/ui/styles.css b/console/ui/styles.css index a003e84d5..2d561d31e 100644 --- a/console/ui/styles.css +++ b/console/ui/styles.css @@ -2,7 +2,8 @@ * The console worker's own console stylesheet, shipped as its own * `console:style` asset (console/styles.css). Every rule is scoped under * `[data-iii-ui="console"]`, the wrapper the console mounts around every - * injected render — here, the injectable-UI toggle form on the Workers tab. + * injected render — the injectable-UI toggle form on the Workers tab and + * the two engine-catalogue pages (functions, triggers). * Styling uses the console's design tokens, so light/dark theming is free. */ @@ -52,9 +53,9 @@ width: 100%; text-align: left; padding: 12px 14px; - border: 1px solid var(--color-rule); + border: 0; border-radius: 8px; - background: var(--color-panel); + background: var(--color-surface, rgba(0, 0, 0, 0.05)); font: inherit; color: var(--color-ink); cursor: pointer; @@ -64,7 +65,7 @@ background-color 0.15s ease; } [data-iii-ui="console"] .console-ui-toggle-card:hover { - border-color: var(--color-ring); + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); } [data-iii-ui="console"] .console-ui-toggle-card:focus-visible { outline: 2px solid var(--color-ring); @@ -72,7 +73,6 @@ } /* active ⇒ accent border; inactive ⇒ dimmed card */ [data-iii-ui="console"] .console-ui-toggle-card[data-active="true"] { - border-color: var(--color-accent); } [data-iii-ui="console"] .console-ui-toggle-card[data-active="false"] { opacity: 0.55; @@ -160,7 +160,7 @@ font-size: 12px; line-height: 1.5; color: var(--color-ink-faint); - border: 1px dashed var(--color-rule); + background: var(--color-surface, rgba(0, 0, 0, 0.04)); border-radius: 8px; padding: 14px; max-width: 60ch; @@ -170,3 +170,1689 @@ line-height: 1.5; color: var(--color-alert); } + +/* ── the engine catalogue pages (functions, triggers) ─────────────────── + * + * Layout: PageShell > PageHeader > now-strip > PageBody (PageSidebar | + * PageMain). The sidebar owns search + grouped rows; the main column is + * always rendered — hero when nothing is selected, the document when + * something is. Container queries, not media queries: the pane width is + * what matters, and under NARROW the columns become a drill-in flow + * (list OR document) driven by `data-selected` on the body. */ + +[data-iii-ui="console"] .console-catalog { + container-type: inline-size; + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink); + background: var(--color-bg, var(--color-panel)); +} +[data-iii-ui="console"] .console-catalog *, +[data-iii-ui="console"] .console-catalog *::before, +[data-iii-ui="console"] .console-catalog *::after { + box-sizing: border-box; +} + +/* --- the live strip between header and columns ------------------------ */ +[data-iii-ui="console"] .console-catalog-strip { + flex: none; + padding: 5px 14px; + background: var(--color-panel-raised, var(--color-paper-2)); +} + +[data-iii-ui="console"] .console-catalog-body { + gap: 6px; + padding: 6px; + background: var(--color-bg, var(--color-panel)); +} +[data-iii-ui="console"] .console-catalog-side, +[data-iii-ui="console"] .console-catalog-main { + border-radius: 6px; + box-shadow: inset 0 0 0 1px var(--color-edge); +} + +/* --- sidebar ----------------------------------------------------------- */ +[data-iii-ui="console"] .console-catalog-side-top { + flex: none; + display: flex; + flex-direction: column; + gap: 9px; + padding: 14px 12px 10px; +} +[data-iii-ui="console"] .console-catalog-side-scroll { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 0 8px 12px; +} +[data-iii-ui="console"] .console-catalog-side-footer { + flex: none; + padding: 9px 12px; + background: var(--color-panel-raised, var(--color-paper-2)); +} +[data-iii-ui="console"] .console-catalog-count { + font-size: 11px; + color: var(--color-ink-ghost); + padding: 0 2px; + font-variant-numeric: tabular-nums; +} + +[data-iii-ui="console"] .console-catalog-search-row { + display: flex; + align-items: center; + gap: 6px; +} + +[data-iii-ui="console"] .console-catalog-search { + position: relative; +} +[data-iii-ui="console"] .console-catalog-search .icon { + position: absolute; + left: 9px; + top: 50%; + transform: translateY(-50%); + width: 13px; + height: 13px; + color: var(--color-ink-ghost); + pointer-events: none; +} +[data-iii-ui="console"] .console-catalog-search-input { + width: 100%; + padding-left: 28px; + padding-right: 28px; +} +[data-iii-ui="console"] .console-catalog-search .clear { + position: absolute; + right: 5px; + top: 50%; + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-ink-ghost); + font: inherit; + font-size: 14px; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-search .clear:hover { + color: var(--color-ink); + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="console"] .console-catalog-search-row .console-catalog-search { + flex: 1; + min-width: 0; +} + +/* --- list: collapsible group, then its rows -------------------------- */ +[data-iii-ui="console"] .console-catalog-section { + margin-bottom: 12px; +} +[data-iii-ui="console"] .console-catalog-group { + display: flex; + align-items: stretch; + gap: 2px; + width: 100%; + padding: 2px; + border-radius: 6px; +} +[data-iii-ui="console"] .console-catalog-group .twist { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-ink-ghost); + font: inherit; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-group .twist:hover { + color: var(--color-ink); + background: var(--color-surface-hover, var(--color-paper-2)); +} +[data-iii-ui="console"] .console-catalog-group .chevron { + flex: none; + color: var(--color-ink-ghost); + transition: transform 0.12s ease; +} +[data-iii-ui="console"] .console-catalog-group .chevron[data-open="true"] { + transform: rotate(90deg); +} +[data-iii-ui="console"] .console-catalog-group .pick { + flex: 1; + min-width: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 3px 8px; + padding: 7px 8px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--color-ink); + font: inherit; + text-align: left; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-group .pick:hover { + background: var(--color-surface-hover, var(--color-paper-2)); +} +[data-iii-ui="console"] .console-catalog-group[data-selected="true"] .pick { + background: var( + --color-surface-selected, + color-mix(in srgb, var(--color-accent) 10%, transparent) + ); +} +[data-iii-ui="console"] .console-catalog-group .group-label-line { + min-width: 0; + display: flex; + align-items: center; + gap: 7px; +} +[data-iii-ui="console"] .console-catalog-group .group-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--color-ink); + font-weight: 600; +} +[data-iii-ui="console"] .console-catalog-group .group-count { + flex: none; + min-width: 20px; + padding: 2px 5px; + border-radius: 4px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + color: var(--color-ink-faint); + font-size: 10px; + font-variant-numeric: tabular-nums; + text-align: center; +} +[data-iii-ui="console"] + .console-catalog-group[data-selected="true"] + .group-name { + color: var(--color-accent); +} +[data-iii-ui="console"] .console-catalog-group .detail { + grid-column: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 5px; + overflow: hidden; + font-size: 10.5px; + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-group .count { + flex: none; + font-variant-numeric: tabular-nums; +} +[data-iii-ui="console"] .console-catalog-group .meta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-group .pick > .chevron { + grid-column: 2; + grid-row: 1 / span 2; +} + +[data-iii-ui="console"] .console-catalog-row { + display: flex; + align-items: flex-start; + gap: 11px; + width: 100%; + padding: 10px; + text-align: left; + background: transparent; + border: 0; + border-radius: 6px; + color: var(--color-ink); + font: inherit; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-row:hover { + background: var(--color-surface-hover, var(--color-paper-2)); +} +[data-iii-ui="console"] .console-catalog-row:focus-visible { + outline: 2px solid var(--color-rule-focus, var(--color-ring)); + outline-offset: 1px; +} +[data-iii-ui="console"] .console-catalog-row[data-selected="true"] { + background: var( + --color-surface-selected, + color-mix(in srgb, var(--color-accent) 10%, transparent) + ); + box-shadow: inset 0 0 0 1px var(--color-accent-border); +} +[data-iii-ui="console"] .console-catalog-row .row-glyph { + flex: none; + margin-top: 1px; +} +[data-iii-ui="console"] .console-catalog-row .row-copy { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +[data-iii-ui="console"] .console-catalog-row .primary-line { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 10px; + min-width: 0; +} +[data-iii-ui="console"] .console-catalog-row .primary { + min-width: 0; + font-size: 12.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-row[data-selected="true"] .primary { + color: var(--color-accent); +} +[data-iii-ui="console"] .console-catalog-row .secondary { + font-size: 11px; + line-height: 1.45; + color: var(--color-ink-faint); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* --- glyph tiles: ƒ for functions, a family icon for triggers --------- */ + +[data-iii-ui="console"] .console-catalog-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + flex: none; + width: 34px; + height: 34px; + border-radius: 6px; + background: color-mix(in srgb, var(--color-accent) 12%, transparent); + color: var(--color-accent); + font-size: 17px; + font-style: italic; + font-weight: 600; +} +[data-iii-ui="console"] .console-catalog-glyph svg { + width: 16px; + height: 16px; +} +[data-iii-ui="console"] .console-catalog-glyph[data-size="lg"] { + width: 44px; + height: 44px; + border-radius: 6px; + font-size: 22px; +} +[data-iii-ui="console"] .console-catalog-glyph[data-size="lg"] svg { + width: 20px; + height: 20px; +} +[data-iii-ui="console"] .console-catalog-glyph[data-size="hero"] { + width: 40px; + height: 40px; + border-radius: 6px; + font-size: 20px; +} +[data-iii-ui="console"] .console-catalog-glyph[data-size="hero"] svg { + width: 20px; + height: 20px; +} +[data-iii-ui="console"] .console-catalog-glyph[data-tone="warn"] { + background: color-mix(in srgb, var(--color-warn) 12%, transparent); + color: var(--color-warn); +} +[data-iii-ui="console"] .console-catalog-glyph[data-tone="ok"] { + background: color-mix(in srgb, var(--color-ok) 12%, transparent); + color: var(--color-ok); +} +[data-iii-ui="console"] .console-catalog-glyph[data-tone="alert"] { + background: color-mix(in srgb, var(--color-alert) 12%, transparent); + color: var(--color-alert); +} +[data-iii-ui="console"] .console-catalog-glyph[data-tone="ink"] { + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + color: var(--color-ink-faint); +} + +/* --- the main workspace ------------------------------------------------ */ + +[data-iii-ui="console"] .console-catalog-workspace { + flex: 1; + min-width: 0; + min-height: 0; + display: grid; + grid-template-columns: minmax(0, 1fr) 304px; + gap: 1px; + overflow: hidden; + background: var(--color-edge); +} +[data-iii-ui="console"] .console-catalog-workspace-main { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + background: var(--color-panel); +} +[data-iii-ui="console"] .console-catalog-context { + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + gap: 8px; + overflow-y: auto; + padding: 8px; + background: var(--color-sidebar, var(--color-panel-raised)); +} +[data-iii-ui="console"] .console-catalog-context-panel { + flex: none; + overflow: hidden; + border-radius: 6px; + background: var(--color-panel); + box-shadow: inset 0 0 0 1px var(--color-edge); +} +[data-iii-ui="console"] .console-catalog-context-panel .context-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; + padding: 12px 12px 9px; +} +[data-iii-ui="console"] .console-catalog-context-panel .context-heading { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +[data-iii-ui="console"] .console-catalog-context-panel h3 { + margin: 0; + color: var(--color-ink); + font-size: 12px; + font-weight: 600; +} +[data-iii-ui="console"] .console-catalog-context-panel .context-heading p { + margin: 0; + color: var(--color-ink-faint); + font-size: 10.5px; + line-height: 1.45; +} +[data-iii-ui="console"] .console-catalog-context-panel .context-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 0 8px 8px; +} +[data-iii-ui="console"] .console-catalog-context-item { + width: 100%; + min-width: 0; + display: flex; + align-items: flex-start; + gap: 9px; + padding: 9px; + border: 0; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + color: var(--color-ink); + font: inherit; + text-align: left; +} +[data-iii-ui="console"] button.console-catalog-context-item { + cursor: pointer; +} +[data-iii-ui="console"] button.console-catalog-context-item:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="console"] .console-catalog-context-item .context-item-glyph { + flex: none; +} +[data-iii-ui="console"] .console-catalog-context-item .context-item-copy { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; +} +[data-iii-ui="console"] .console-catalog-context-item .context-item-title { + overflow: hidden; + color: var(--color-ink); + font-size: 11.5px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-context-item .context-item-description, +[data-iii-ui="console"] .console-catalog-context-item .context-item-meta { + color: var(--color-ink-faint); + font-size: 10.5px; + line-height: 1.4; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-context-item .context-item-meta { + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-context-item .context-item-arrow { + flex: none; + align-self: center; + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-context-empty { + padding: 4px; + color: var(--color-ink-ghost); + font-size: 11px; + line-height: 1.5; +} +[data-iii-ui="console"] .console-catalog-context-activity { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 9px; + border: 0; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + color: var(--color-ink); + font: inherit; + text-align: left; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-context-activity:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="console"] .console-catalog-context-activity .dot { + flex: none; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-ok); +} +[data-iii-ui="console"] + .console-catalog-context-activity + .dot[data-ok="false"] { + background: var(--color-alert); +} +[data-iii-ui="console"] .console-catalog-context-activity .activity-copy { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 2px; + font-size: 10.5px; +} +[data-iii-ui="console"] + .console-catalog-context-activity + .activity-copy + > :last-child { + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-context-activity .duration { + flex: none; + color: var(--color-ink-faint); + font-size: 10.5px; + font-variant-numeric: tabular-nums; +} + +[data-iii-ui="console"] .console-catalog-doc { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; +} +[data-iii-ui="console"] .console-catalog-hero { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: clamp(40px, 9cqw, 80px) clamp(24px, 8cqw, 72px); +} +[data-iii-ui="console"] .console-catalog-hero .inner { + width: min(100%, 560px); + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 18px; +} +[data-iii-ui="console"] .console-catalog-hero .copy { + display: flex; + flex-direction: column; + gap: 7px; +} +[data-iii-ui="console"] .console-catalog-hero .eyebrow { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-accent); +} +[data-iii-ui="console"] .console-catalog-hero .title { + margin: 0; + max-width: 28ch; + font-size: 18px; + font-weight: 600; + color: var(--color-ink); +} +[data-iii-ui="console"] .console-catalog-hero .body { + margin: 0; + max-width: 60ch; + font-size: 13px; + line-height: 1.65; + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-hero .guide { + width: 100%; + max-width: 540px; + margin: 2px 0 0; + padding: 4px; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); +} +[data-iii-ui="console"] .console-catalog-hero .guide-row { + display: grid; + grid-template-columns: minmax(88px, 1fr) minmax(0, 3fr); + gap: 16px; + padding: 9px 10px; +} +[data-iii-ui="console"] .console-catalog-hero .guide-term, +[data-iii-ui="console"] .console-catalog-hero .guide-description { + margin: 0; + font-size: 11.5px; + line-height: 1.5; +} +[data-iii-ui="console"] .console-catalog-hero .guide-term { + color: var(--color-ink); + font-weight: 600; +} +[data-iii-ui="console"] .console-catalog-hero .guide-description { + color: var(--color-ink-faint); +} + +[data-iii-ui="console"] .console-catalog-crumb { + flex: none; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 14px 20px 0; + font-size: 11px; + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-crumb .seg { + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-crumb .seg[data-last="true"] { + color: var(--color-ink-faint); +} +/* The way out of the document when the narrow flow hides the list. */ +[data-iii-ui="console"] .console-catalog-back { + display: none; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border: 0; + border-radius: 4px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + color: var(--color-ink-faint); + font: inherit; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-back:hover { + color: var(--color-ink); + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} + +[data-iii-ui="console"] .console-catalog-ident { + flex: none; + display: flex; + align-items: flex-start; + gap: 12px; + padding: 14px 20px 0; +} +[data-iii-ui="console"] .console-catalog-ident .ident-copy { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +[data-iii-ui="console"] .console-catalog-ident .ident-title-line { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[data-iii-ui="console"] .console-catalog-ident .ident-title { + margin: 0; + font-size: 17px; + font-weight: 600; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-ident .ident-status { + padding: 2px 6px; + border-radius: 4px; + background: var(--color-ok-muted, rgba(53, 111, 61, 0.12)); + color: var(--color-ok); + font-size: 9.5px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +[data-iii-ui="console"] .console-catalog-ident .ident-desc { + margin: 0; + max-width: 72ch; + font-size: 12.5px; + line-height: 1.55; + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-ident .ident-chips { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + margin-top: 2px; +} +[data-iii-ui="console"] .console-catalog-ident .ident-actions { + flex: none; + display: flex; + align-items: center; + gap: 6px; +} + +[data-iii-ui="console"] .console-catalog-desc { + font-size: 12px; + line-height: 1.5; + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-chip { + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + border-radius: 4px; + font-size: 11.5px; + padding: 3px 8px; + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-chip .k { + color: var(--color-ink-ghost); + margin-right: 6px; +} +[data-iii-ui="console"] .console-catalog-tabs { + padding: 14px 20px 24px; +} +[data-iii-ui="console"] .console-catalog-tabs [role="tablist"] { + max-width: 100%; + overflow-x: auto; + scrollbar-width: thin; +} + +/* --- overview: description read as documentation, then the facts ------ */ + +[data-iii-ui="console"] .console-catalog-overview { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 10px; +} +[data-iii-ui="console"] .console-catalog-doc-desc { + margin: 0; + max-width: 64ch; + font-size: 13px; + line-height: 1.6; +} + +[data-iii-ui="console"] .console-catalog-facts { + width: 100%; + max-width: 760px; + margin: 0; + display: flex; + flex-direction: column; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + border-radius: 6px; + padding: 2px 0; +} +[data-iii-ui="console"] .console-catalog-facts .fact { + display: flex; + gap: 12px; + padding: 7px 12px; + font-size: 12px; +} +[data-iii-ui="console"] .console-catalog-facts dt { + flex: none; + width: 150px; + color: var(--color-ink-ghost); + font-size: 11px; + padding-top: 1px; +} +[data-iii-ui="console"] .console-catalog-facts dd { + flex: 1; + min-width: 0; + margin: 0; + overflow-wrap: anywhere; +} + +/* --- trigger cards (function detail + related bindings) --------------- */ + +[data-iii-ui="console"] .console-catalog-trigcards { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; +} +[data-iii-ui="console"] .console-catalog-trigcard { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); +} +[data-iii-ui="console"] .console-catalog-trigcard .copy { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} +[data-iii-ui="console"] .console-catalog-trigcard .line1 { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[data-iii-ui="console"] .console-catalog-trigcard .name { + font-size: 12.5px; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-trigcard .type { + font-size: 11px; + color: var(--color-ink-ghost); + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-trigcard .fine { + font-size: 11px; + color: var(--color-warn); +} + +/* --- invoke panel ---------------------------------------------------- */ +[data-iii-ui="console"] .console-catalog-invoke { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 10px; + padding: 14px; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + box-shadow: inset 0 0 0 1px var(--color-edge); +} +[data-iii-ui="console"] .console-catalog-invoke-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +[data-iii-ui="console"] .console-catalog-invoke-head > div { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +[data-iii-ui="console"] .console-catalog-invoke-head h3 { + margin: 0; + color: var(--color-ink); + font-size: 13px; + font-weight: 600; +} +[data-iii-ui="console"] + .console-catalog-invoke + .console-catalog-invoke-head + .invoke-description { + margin: 0; + color: var(--color-ink-faint); + font-size: 11.5px; + line-height: 1.5; +} +[data-iii-ui="console"] .console-catalog-editor { + background: var(--color-panel); + border-radius: 6px; + min-height: 120px; + max-height: 320px; + overflow: auto; + box-shadow: inset 0 0 0 1px var(--color-edge); +} +[data-iii-ui="console"] .console-catalog-invoke-foot { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; +} +[data-iii-ui="console"] .console-catalog-ok { + color: var(--color-ok); +} +[data-iii-ui="console"] .console-catalog-invalid { + color: var(--color-alert); +} +[data-iii-ui="console"] .console-catalog-result, +[data-iii-ui="console"] .console-catalog-json { + display: block; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + border-radius: 6px; + padding: 10px 12px; + font-size: 12px; + line-height: 1.5; + max-height: 420px; + overflow: auto; +} +[data-iii-ui="console"] .console-catalog-result-shell { + overflow: hidden; + border-radius: 6px; + background: var(--color-panel); + box-shadow: inset 0 0 0 1px var(--color-edge); +} +[data-iii-ui="console"] .console-catalog-result-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 9px 11px; + background: var(--color-panel-raised, var(--color-paper-2)); + font-size: 11px; +} +[data-iii-ui="console"] .console-catalog-result-head > :first-child { + display: inline-flex; + align-items: center; + gap: 6px; +} +[data-iii-ui="console"] .console-catalog-result-head .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +[data-iii-ui="console"] .console-catalog-result-head .result-meta { + display: flex; + align-items: center; + gap: 12px; + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="console"] + .console-catalog-result-shell + > .console-catalog-result { + max-height: 360px; + border-radius: 0; + background: transparent; +} +/* --- notes, errors ----------------------------------------------------- */ +[data-iii-ui="console"] .console-catalog-note { + padding: 12px 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-error { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + background: var(--color-alert-muted, rgba(255, 0, 38, 0.08)); + color: var(--color-alert); + border-radius: 6px; + padding: 10px 12px; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-error .error-copy { + min-width: 0; + display: flex; + flex-direction: column; + gap: 3px; +} +[data-iii-ui="console"] .console-catalog-error .error-title { + color: var(--color-alert); + font-size: 12.5px; + font-weight: 600; +} +[data-iii-ui="console"] .console-catalog-error .error-detail { + color: var(--color-ink-faint); + font-size: 11px; + line-height: 1.5; +} +[data-iii-ui="console"] .console-catalog-result-shell > .console-catalog-error { + border-radius: 0; + background: var(--color-alert-muted, rgba(255, 0, 38, 0.08)); +} +/* Loading/error states sit directly in the document column, outside the + * padded tab region — give them the document's own gutters. */ +[data-iii-ui="console"] .console-catalog-doc > .console-catalog-note, +[data-iii-ui="console"] .console-catalog-doc > .console-catalog-error { + margin: 14px 20px; +} + +/* --- first-load skeleton --------------------------------------------- */ +[data-iii-ui="console"] .console-catalog-skeleton { + display: flex; + flex-direction: column; + gap: 14px; + padding: 4px 2px; +} +[data-iii-ui="console"] .console-catalog-skeleton .skeleton-group { + display: flex; + flex-direction: column; + gap: 7px; +} +[data-iii-ui="console"] .console-catalog-skeleton .skeleton-heading, +[data-iii-ui="console"] .console-catalog-skeleton .skeleton-row { + display: block; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + animation: console-catalog-skeleton 1.5s ease-in-out infinite; +} +[data-iii-ui="console"] .console-catalog-skeleton .skeleton-heading { + width: 54%; + height: 28px; +} +[data-iii-ui="console"] .console-catalog-skeleton .skeleton-row { + height: 44px; +} +[data-iii-ui="console"] .console-catalog-skeleton .skeleton-row.short { + width: 84%; +} +@keyframes console-catalog-skeleton { + 0%, + 100% { + opacity: 0.45; + } + 50% { + opacity: 0.85; + } +} + +/* --- family tags, filter chips, stat tiles --------------------------- */ + +/* One tone scale for every family marker: the group tag, the detail chip, + * and the tile value all read from these. */ +[data-iii-ui="console"] .console-catalog-tag, +[data-iii-ui="console"] .console-catalog-chip[data-tone], +[data-iii-ui="console"] .console-catalog-tile .value[data-tone] { + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-tag[data-tone="accent"], +[data-iii-ui="console"] .console-catalog-chip[data-tone="accent"], +[data-iii-ui="console"] .console-catalog-tile .value[data-tone="accent"] { + color: var(--color-accent); +} +[data-iii-ui="console"] .console-catalog-tag[data-tone="ok"], +[data-iii-ui="console"] .console-catalog-chip[data-tone="ok"], +[data-iii-ui="console"] .console-catalog-tile .value[data-tone="ok"] { + color: var(--color-ok); +} +[data-iii-ui="console"] .console-catalog-tag[data-tone="warn"], +[data-iii-ui="console"] .console-catalog-chip[data-tone="warn"], +[data-iii-ui="console"] .console-catalog-tile .value[data-tone="warn"] { + color: var(--color-warn); +} +[data-iii-ui="console"] .console-catalog-tag[data-tone="alert"], +[data-iii-ui="console"] .console-catalog-chip[data-tone="alert"], +[data-iii-ui="console"] .console-catalog-tile .value[data-tone="alert"] { + color: var(--color-alert); +} + +[data-iii-ui="console"] .console-catalog-tag { + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + border-radius: 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 1px 6px; + white-space: nowrap; +} + +[data-iii-ui="console"] .console-catalog-filters { + display: flex; + gap: 6px; + flex-wrap: wrap; +} +[data-iii-ui="console"] .console-catalog-filter { + border: 0; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + color: var(--color-ink-faint); + font: inherit; + font-size: 11px; + padding: 3px 10px; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-filter:hover { + color: var(--color-ink); + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="console"] .console-catalog-filter[data-selected="true"] { + color: var(--color-accent); + background: var(--color-accent-muted); +} +[data-iii-ui="console"] .console-catalog-filter .count { + font-variant-numeric: tabular-nums; + opacity: 0.7; + margin-left: 4px; +} + +[data-iii-ui="console"] .console-catalog-tiles { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin: 12px 0; +} +[data-iii-ui="console"] .console-catalog-tile { + flex: 1 1 140px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + border-radius: 6px; + padding: 8px 10px; +} +[data-iii-ui="console"] .console-catalog-tile .label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-tile .value { + font-size: 13px; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-tile .hint { + font-size: 11px; + color: var(--color-ink-ghost); +} + +/* --- schedule card, endpoint bar, form rows -------------------------- */ + +[data-iii-ui="console"] .console-catalog-schedule { + display: flex; + flex-direction: column; + gap: 4px; + background: var(--color-warn-muted, rgba(168, 122, 0, 0.12)); + border-radius: 6px; + padding: 10px 12px; + margin-top: 12px; +} +[data-iii-ui="console"] .console-catalog-schedule .readable { + font-size: 13px; + color: var(--color-warn); +} +[data-iii-ui="console"] .console-catalog-schedule code { + font-size: 11.5px; + color: var(--color-ink-faint); +} + +[data-iii-ui="console"] .console-catalog-context .console-catalog-facts { + max-width: none; + padding: 0; + background: transparent; +} +[data-iii-ui="console"] .console-catalog-context .console-catalog-facts .fact { + display: grid; + grid-template-columns: 92px minmax(0, 1fr); + gap: 8px; + padding: 6px 4px; + font-size: 10.5px; +} +[data-iii-ui="console"] .console-catalog-context .console-catalog-facts dt { + width: auto; + font-size: 10px; +} +[data-iii-ui="console"] .console-catalog-context .console-catalog-tiles { + margin: 0; +} +[data-iii-ui="console"] .console-catalog-context .console-catalog-tile { + flex-basis: 110px; +} +[data-iii-ui="console"] .console-catalog-context .console-catalog-schedule { + margin-top: 0; +} + +[data-iii-ui="console"] .console-catalog-endpoint { + display: flex; + align-items: center; + gap: 8px; + background: var(--color-accent-muted, rgba(184, 66, 15, 0.1)); + border-radius: 6px; + padding: 8px 10px; + overflow: hidden; +} +[data-iii-ui="console"] .console-catalog-endpoint .method { + font-size: 11px; + letter-spacing: 0.08em; + color: var(--color-accent); +} +[data-iii-ui="console"] .console-catalog-endpoint code { + flex: 1; + font-size: 12px; + overflow-wrap: anywhere; +} + +[data-iii-ui="console"] .console-catalog-fields { + display: flex; + flex-direction: column; + gap: 6px; +} +[data-iii-ui="console"] .console-catalog-field-label { + display: flex; + align-items: center; + gap: 8px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-field-row { + display: flex; + align-items: center; + gap: 8px; +} +[data-iii-ui="console"] .console-catalog-field-row input { + flex: 1; + min-width: 0; +} +[data-iii-ui="console"] .console-catalog-key { + font-size: 12px; + color: var(--color-warn); + min-width: 88px; +} +[data-iii-ui="console"] .console-catalog-method { + width: 104px; + flex: none; +} +[data-iii-ui="console"] .console-catalog-path { + font-size: 12px; + color: var(--color-ink-faint); + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-hint { + font-size: 11.5px; + color: var(--color-ink-ghost); +} + +[data-iii-ui="console"] .console-catalog-target { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + border-radius: 6px; + margin-bottom: 12px; +} +[data-iii-ui="console"] .console-catalog-target code { + font-size: 12.5px; + overflow-wrap: anywhere; +} + +/* --- live marker + arrival flash ------------------------------------- */ + +[data-iii-ui="console"] .console-catalog-live { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-live .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-ok); +} +[data-iii-ui="console"] .console-catalog-header-toggle[aria-pressed="true"] { + color: var(--color-accent); + background: var(--color-accent-muted); +} +[data-iii-ui="console"] .console-catalog-row.flash { + animation: console-catalog-flash 2s ease-out; +} +@keyframes console-catalog-flash { + from { + background: color-mix(in srgb, var(--color-ok) 22%, transparent); + } + to { + background: transparent; + } +} + +/* --- schema table ---------------------------------------------------- */ + +[data-iii-ui="console"] .console-catalog-schema { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 10px; +} +[data-iii-ui="console"] .console-catalog-schema-head { + display: flex; + flex-direction: column; + gap: 2px; +} +[data-iii-ui="console"] .console-catalog-schema-head .title { + font-size: 12px; + color: var(--color-ink); +} +[data-iii-ui="console"] .console-catalog-schema-head .desc, +[data-iii-ui="console"] .console-catalog-schema .desc { + font-size: 11.5px; + line-height: 1.45; + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-schema table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +[data-iii-ui="console"] .console-catalog-schema th { + text-align: left; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); + font-weight: 500; + padding: 0 8px 6px 0; +} +[data-iii-ui="console"] .console-catalog-schema td { + vertical-align: top; + padding: 7px 8px 7px 0; +} +[data-iii-ui="console"] .console-catalog-schema tbody tr:nth-child(odd) { + background: var(--color-surface, rgba(0, 0, 0, 0.03)); +} +[data-iii-ui="console"] .console-catalog-schema td:last-child { + display: flex; + flex-direction: column; + gap: 3px; +} +[data-iii-ui="console"] .console-catalog-schema .field { + display: inline-block; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-schema .req { + margin-left: 6px; + font-size: 9.5px; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-accent); +} +[data-iii-ui="console"] .console-catalog-schema .type { + color: var(--color-ink-faint); + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-schema .enum, +[data-iii-ui="console"] .console-catalog-schema .default { + font-size: 11px; + color: var(--color-ink-ghost); +} + +/* --- activity feed --------------------------------------------------- */ + +[data-iii-ui="console"] .console-catalog-activity { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 10px; +} +[data-iii-ui="console"] .console-catalog-activity-summary { + display: flex; + gap: 14px; + flex-wrap: wrap; + font-size: 11px; + color: var(--color-ink-ghost); + padding-bottom: 6px; +} +[data-iii-ui="console"] .console-catalog-call { + border-radius: 6px; +} +[data-iii-ui="console"] .console-catalog-call[data-open="true"] { + background: var(--color-surface, rgba(0, 0, 0, 0.04)); +} +[data-iii-ui="console"] .console-catalog-call-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 6px 8px; + background: transparent; + border: 0; + color: var(--color-ink); + font: inherit; + font-size: 12px; + text-align: left; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-call-head:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="console"] .console-catalog-call-head .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-ok); + flex: none; +} +[data-iii-ui="console"] .console-catalog-call-head .dot[data-ok="false"] { + background: var(--color-alert); +} +[data-iii-ui="console"] .console-catalog-call-head .ago, +[data-iii-ui="console"] .console-catalog-call-head .worker { + color: var(--color-ink-ghost); + font-size: 11px; +} +[data-iii-ui="console"] .console-catalog-call-head .duration { + margin-left: auto; + font-variant-numeric: tabular-nums; + color: var(--color-ink-faint); +} +[data-iii-ui="console"] .console-catalog-call-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 4px 8px 10px; +} + +/* --- invoke attempt history ------------------------------------------ */ + +[data-iii-ui="console"] .console-catalog-attempts { + display: flex; + flex-direction: column; + gap: 4px; +} +[data-iii-ui="console"] .console-catalog-attempt { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 5px 8px; + border: 0; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + color: var(--color-ink-faint); + font: inherit; + font-size: 11.5px; + text-align: left; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-attempt:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); + color: var(--color-ink); +} +[data-iii-ui="console"] .console-catalog-attempt .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--color-ok); + flex: none; +} +[data-iii-ui="console"] .console-catalog-attempt .dot[data-ok="false"] { + background: var(--color-alert); +} +[data-iii-ui="console"] .console-catalog-attempt .body { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-attempt .duration { + font-variant-numeric: tabular-nums; +} + +/* --- the now-strip + live row meta ----------------------------------- */ + +[data-iii-ui="console"] .console-catalog-nowstrip { + display: flex; + align-items: center; + gap: 10px; + min-height: 30px; + overflow: hidden; +} +[data-iii-ui="console"] .console-catalog-nowstrip-label { + flex: none; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--color-ok); +} +[data-iii-ui="console"] .console-catalog-nowstrip[data-empty="true"] .quiet { + font-size: 11.5px; + color: var(--color-ink-ghost); +} +[data-iii-ui="console"] .console-catalog-nowstrip-track { + display: flex; + gap: 6px; + overflow: hidden; + mask-image: linear-gradient(to right, black 85%, transparent); +} +[data-iii-ui="console"] .console-catalog-nowentry { + display: inline-flex; + align-items: center; + gap: 6px; + flex: none; + border: 0; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + color: var(--color-ink); + font: inherit; + font-size: 11.5px; + padding: 2px 10px 2px 7px; + cursor: pointer; + /* New entries land at the head of the strip: slide in, ease-out, brief. */ + animation: console-catalog-nowentry-in 220ms cubic-bezier(0.23, 1, 0.32, 1); +} +[data-iii-ui="console"] .console-catalog-nowentry:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="console"] .console-catalog-nowentry .fn { + max-width: 30ch; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-nowentry .dur { + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; +} +@keyframes console-catalog-nowentry-in { + from { + opacity: 0; + transform: translateX(-6px); + } + to { + opacity: 1; + transform: translateX(0); + } +} +@media (prefers-reduced-motion: reduce) { + [data-iii-ui="console"] .console-catalog-skeleton .skeleton-heading, + [data-iii-ui="console"] .console-catalog-skeleton .skeleton-row, + [data-iii-ui="console"] .console-catalog-nowentry { + animation: none; + } + [data-iii-ui="console"] .console-catalog-row.flash { + animation: none; + } +} + +[data-iii-ui="console"] .console-catalog-lastcall { + flex: none; + font-size: 10.5px; + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +[data-iii-ui="console"] .console-catalog-lastcall[data-ok="false"] { + color: var(--color-alert); +} + +/* --- plumbing section: present, quiet, out of the way ----------------- */ + +[data-iii-ui="console"] .console-catalog-plumbing { + margin-top: 24px; + opacity: 0.72; +} + +/* --- tab chrome: a count badge is a separate word, not a suffix -------- */ + +[data-iii-ui="console"] .console-catalog-tabs [role="tab"] { + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* Every catalogue-native control gets the same high-contrast keyboard ring. */ +[data-iii-ui="console"] + :is( + .console-catalog-search .clear, + .console-catalog-filter, + .console-catalog-group .pick, + .console-catalog-group .twist, + .console-catalog-back, + .console-catalog-nowentry, + .console-catalog-call-head, + .console-catalog-attempt + ):focus-visible { + outline: 2px solid var(--color-rule-focus, var(--color-ring)); + outline-offset: 1px; +} + +/* The contextual rail becomes a continuation of the document before either + * column gets too cramped to scan. The workspace owns the scroll in this + * mode, so the context remains reachable without introducing nested panes. */ +@container (max-width: 1180px) { + [data-iii-ui="console"] .console-catalog-workspace { + display: block; + overflow-y: auto; + background: var(--color-panel); + } + [data-iii-ui="console"] .console-catalog-workspace-main { + min-height: auto; + overflow: visible; + } + [data-iii-ui="console"] .console-catalog-workspace .console-catalog-doc { + flex: none; + min-height: auto; + overflow: visible; + } + [data-iii-ui="console"] .console-catalog-context { + min-height: auto; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + overflow: visible; + padding: 0 20px 20px; + background: var(--color-panel); + } + [data-iii-ui="console"] .console-catalog-context-panel[data-wide="true"] { + grid-column: 1 / -1; + } +} + +/* --- narrow containers: drill in, one column at a time ------------------ + * Below this the sidebar and the document are both too narrow to read side + * by side. `data-selected` on the body decides which one owns the width; + * the crumb's ← (hidden when wide) is the way back to the list. */ +@container (max-width: 700px) { + [data-iii-ui="console"] .console-catalog-header-desc { + display: none; + } + [data-iii-ui="console"] + .console-catalog-body[data-selected="true"] + .console-catalog-side { + display: none; + } + [data-iii-ui="console"] + .console-catalog-body[data-selected="false"] + .console-catalog-main { + display: none; + } + [data-iii-ui="console"] + .console-catalog-body[data-selected="false"] + .console-catalog-side { + /* biome-ignore lint/complexity/noImportantStyles: PageSidebar fixes its + width as an inline style; only !important can widen it from CSS. */ + width: 100% !important; + } + [data-iii-ui="console"] .console-catalog-back { + display: inline-flex; + } + [data-iii-ui="console"] .console-catalog-hero { + padding: 36px 24px; + } + [data-iii-ui="console"] .console-catalog-ident { + flex-wrap: wrap; + } + [data-iii-ui="console"] .console-catalog-ident .ident-actions { + width: 100%; + padding-left: 48px; + } + [data-iii-ui="console"] .console-catalog-schema { + overflow-x: auto; + } + [data-iii-ui="console"] .console-catalog-schema table { + min-width: 520px; + } + [data-iii-ui="console"] .console-catalog-context { + grid-template-columns: minmax(0, 1fr); + padding-inline: 14px; + } + [data-iii-ui="console"] .console-catalog-context-panel[data-wide="true"] { + grid-column: auto; + } +} + +@container (max-width: 480px) { + [data-iii-ui="console"] .console-catalog-live { + display: none; + } + [data-iii-ui="console"] .console-catalog-strip { + padding-inline: 12px; + } + [data-iii-ui="console"] .console-catalog-nowstrip[data-empty="true"] .quiet { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + [data-iii-ui="console"] .console-catalog-crumb, + [data-iii-ui="console"] .console-catalog-ident { + padding-inline: 14px; + } + [data-iii-ui="console"] .console-catalog-tabs { + padding: 12px 14px 20px; + } + [data-iii-ui="console"] .console-catalog-ident .ident-actions { + padding-left: 0; + } + [data-iii-ui="console"] .console-catalog-hero .guide-row { + grid-template-columns: 1fr; + gap: 2px; + } + [data-iii-ui="console"] .console-catalog-facts .fact { + flex-direction: column; + gap: 3px; + padding-block: 9px; + } + [data-iii-ui="console"] .console-catalog-facts dt { + width: auto; + } + [data-iii-ui="console"] .console-catalog-field-row { + align-items: stretch; + flex-direction: column; + } + [data-iii-ui="console"] .console-catalog-method, + [data-iii-ui="console"] .console-catalog-key { + width: 100%; + min-width: 0; + } +} diff --git a/console/web/src/pages/Workers/components/WorkerSurface.tsx b/console/web/src/pages/Workers/components/WorkerSurface.tsx new file mode 100644 index 000000000..dab2116c5 --- /dev/null +++ b/console/web/src/pages/Workers/components/WorkerSurface.tsx @@ -0,0 +1,129 @@ +/** + * What one worker brought to the bus: the functions it registered and the + * trigger types it publishes. + * + * Rendered inline under an expanded row on the Workers table, so "what can + * this worker actually do" is answered where the operator already is instead + * of on another page. One `engine::workers::info` call carries both lists, + * and it only runs when a row is actually opened. + * + * Registered triggers deliberately do NOT appear here. They are live + * bindings of a type to a function, they belong to the triggers view, and + * showing them per worker invited the question of whose they are — the + * registering worker's or the target's. Deeper reads stay on the dedicated + * pages: schemas, invoke and call history on `#/ext/functions`, registered + * triggers and their fire paths on `#/ext/triggers`. + */ + +import { useQuery } from '@tanstack/react-query' +import { AlertCircle, ChevronRight } from 'lucide-react' +import { Skeleton } from '@/components/ui/Skeleton' +import { fetchEngineWorkerInfo } from '../api/workers' + +interface WorkerSurfaceProps { + name: string +} + +export const workerSurfaceKeys = { + detail: (name: string) => ['workers', 'surface', name] as const, +} + +export function WorkerSurface({ name }: WorkerSurfaceProps) { + const query = useQuery({ + queryKey: workerSurfaceKeys.detail(name), + queryFn: () => fetchEngineWorkerInfo(name), + }) + + if (query.isLoading) { + return ( +
+ + + +
+ ) + } + + if (query.isError || !query.data) { + return ( +
+ + engine::workers::info returned nothing for {name} — the worker may have + disconnected. +
+ ) + } + + const { functions, trigger_types: triggerTypes } = query.data + + return ( +
+
+ {functions.length === 0 ? ( + this worker registered no functions. + ) : ( + functions.map((fn) => ( +
+
+ {fn.function_id} +
+ {fn.description ? ( +

+ {fn.description} +

+ ) : null} +
+ )) + )} +
+ +
+ {triggerTypes.length === 0 ? ( + this worker publishes no trigger types. + ) : ( + triggerTypes.map((type) => ( +
+
+ {type.id} +
+ {type.description ? ( +

+ {type.description} +

+ ) : null} +
+ )) + )} +
+
+ ) +} + +function Section({ + title, + count, + children, +}: { + title: string + count: number + children: React.ReactNode +}) { + return ( +
+

+ + {title} + {count} +

+
{children}
+
+ ) +} + +function Empty({ children }: { children: React.ReactNode }) { + return ( +

+ {children} +

+ ) +} diff --git a/console/web/src/pages/Workers/components/WorkersTable.stories.tsx b/console/web/src/pages/Workers/components/WorkersTable.stories.tsx index bb0bee607..b82886f22 100644 --- a/console/web/src/pages/Workers/components/WorkersTable.stories.tsx +++ b/console/web/src/pages/Workers/components/WorkersTable.stories.tsx @@ -1,15 +1,36 @@ import type { Meta, StoryObj } from '@storybook/react-vite' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useMemo, useState } from 'react' import { TooltipProvider } from '@/components/ui/Tooltip' import { + WORKER_SURFACE_FIXTURE, WORKERS_FIXTURE_EMPTY, WORKERS_FIXTURE_ROWS, } from '../fixtures/workers-fixtures' import type { WorkerRow } from '../types' import { filterWorkerRows, type WorkersFilterState } from '../types' +import { workerSurfaceKeys } from './WorkerSurface' import { WorkersFilters } from './WorkersFilters' import { WorkersTable } from './WorkersTable' +/** + * Expanding a row fetches `engine::workers::info` through react-query, so the + * stories seed that cache instead of reaching for an engine. Seeding also + * keeps the expanded row deterministic in the gallery. + */ +function storyQueryClient(): QueryClient { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, staleTime: Number.POSITIVE_INFINITY }, + }, + }) + client.setQueryData( + workerSurfaceKeys.detail('harness'), + WORKER_SURFACE_FIXTURE, + ) + return client +} + function WorkersHarness({ rows, isLoading, @@ -33,27 +54,33 @@ function WorkersHarness({ [rows, filters], ) + const [queryClient] = useState(storyQueryClient) + return ( - -
- {!isLoading && rows.length > 0 ? ( - - setFilters((cur) => ({ ...cur, ...next })) - } - onClear={() => setFilters({ search: '', tag: null, runtime: null })} + + +
+ {!isLoading && rows.length > 0 ? ( + + setFilters((cur) => ({ ...cur, ...next })) + } + onClear={() => + setFilters({ search: '', tag: null, runtime: null }) + } + /> + ) : null} + - ) : null} - -
-
+
+
+ ) } @@ -111,3 +138,21 @@ export const StandaloneNoStop: Story = { rows: WORKERS_FIXTURE_ROWS.filter((r) => r.managementKind === 'standalone'), }, } + +/** + * A connected row expanded into its surface: the functions it registered, + * the trigger types it publishes, and the bindings pointing into it. Click + * the `harness` row in the canvas to open it. + */ +export const ExpandedSurface: Story = { + args: { + rows: WORKERS_FIXTURE_ROWS.filter((r) => r.name === 'harness'), + onConfigure: () => undefined, + }, + play: async ({ canvasElement }) => { + const toggle = canvasElement.querySelector( + 'button[aria-expanded="false"]', + ) + toggle?.click() + }, +} diff --git a/console/web/src/pages/Workers/components/WorkersTable.tsx b/console/web/src/pages/Workers/components/WorkersTable.tsx index be9bc0edf..7570fffe8 100644 --- a/console/web/src/pages/Workers/components/WorkersTable.tsx +++ b/console/web/src/pages/Workers/components/WorkersTable.tsx @@ -1,4 +1,5 @@ -import { Settings, Square } from 'lucide-react' +import { ChevronRight, Settings, Square } from 'lucide-react' +import { useState } from 'react' import { Badge } from '@/components/ui/Badge' import { Button } from '@/components/ui/Button' import { EmptyState } from '@/components/ui/EmptyState' @@ -11,6 +12,7 @@ import { } from '@/components/ui/Tooltip' import { cn } from '@/lib/utils' import type { WorkerManagementKind, WorkerRow } from '../types' +import { WorkerSurface } from './WorkerSurface' interface WorkersTableProps { rows: WorkerRow[] @@ -73,6 +75,24 @@ export function WorkersTable({ ) } + return ( + + ) +} + +function WorkersTableBody({ + rows, + stoppingName, + onStop, + onConfigure, + className, +}: Omit) { + // Only connected workers have a surface to show: `engine::workers::info` + // answers for the live bus, not for a stopped supervisor entry. + const [expanded, setExpanded] = useState(null) + return (
+ setExpanded((prev) => (prev === row.name ? null : row.name)) + } onStop={onStop} onConfigure={onConfigure} /> @@ -130,6 +154,8 @@ export function WorkersTable({ interface WorkerTableRowProps { row: WorkerRow stopping: boolean + expanded: boolean + onToggle: () => void onStop?: (name: string) => void onConfigure?: (configurationId: string) => void } @@ -137,9 +163,12 @@ interface WorkerTableRowProps { function WorkerTableRow({ row, stopping, + expanded, + onToggle, onStop, onConfigure, }: WorkerTableRowProps) { + const expandable = row.status === 'connected' const configureButton = row.configurationId ? ( ) : ( - - - {stopButton} - - {row.stopDisabledReason} - +
{nameCell}
)} -
- - + + + {formatCell(row.runtime)} + + + {formatCell(row.ipAddress)} + + + {formatCell(row.version)} + + + {formatCell(row.pid)} + + + + {MANAGEMENT_LABEL[row.managementKind]} + + + + {formatCell(row.tag)} + + +
+ {configureButton} + {row.stopEnabled || !row.stopDisabledReason ? ( + stopButton + ) : ( + + + {stopButton} + + {row.stopDisabledReason} + + )} +
+ + + {expanded ? ( + + {/* The table wrapper is `whitespace-nowrap` so the columns never + wrap mid-row; the surface below is prose, so it opts back out. */} + + + + + ) : null} + ) } diff --git a/console/web/src/pages/Workers/fixtures/workers-fixtures.ts b/console/web/src/pages/Workers/fixtures/workers-fixtures.ts index e516f85f0..9398b37d5 100644 --- a/console/web/src/pages/Workers/fixtures/workers-fixtures.ts +++ b/console/web/src/pages/Workers/fixtures/workers-fixtures.ts @@ -79,3 +79,64 @@ export const WORKERS_FIXTURE_ROWS: WorkerRow[] = [ export const WORKERS_FIXTURE_EMPTY: WorkerRow[] = [] export const WORKERS_FIXTURE_LOADING: null = null + +/** + * One worker's registered surface, as `engine::workers::info` answers it. + * Backs the expanded-row story so the drill-down renders without an engine. + */ +export const WORKER_SURFACE_FIXTURE = { + worker: { + id: '7fa8e8a4-1c3d-44b2-9a5f-1234567890ab', + name: 'harness', + status: 'connected', + function_count: 3, + connected_at_ms: 1_785_930_000_000, + active_invocations: 1, + internal: false, + }, + functions: [ + { + function_id: 'harness::spawn', + worker_name: 'harness', + description: 'Spawn a sub-agent for a task and return its session id.', + }, + { + function_id: 'harness::triggers::list', + worker_name: 'harness', + description: 'List the triggers this session has registered.', + }, + { + function_id: 'harness::sweep-pending', + worker_name: 'harness', + description: null, + }, + ], + trigger_types: [ + { + id: 'harness::turn-completed', + worker_name: 'harness', + description: 'A turn finished, successfully or not.', + }, + { + id: 'harness::hook::pre-generate', + worker_name: 'harness', + description: 'Runs before every model generation.', + }, + ], + registered_triggers: [ + { + id: 'f8aa4183-1549-4dd2-a3e9-83719f5ee2cc', + trigger_type: 'cron', + function_id: 'harness::sweep-pending', + worker_name: 'harness', + config_summary: '{"expression":"0 0 0 * * *"}', + }, + { + id: '05238e9a-1a9f-4966-9f8b-3949fcf5dcc6', + trigger_type: 'harness::hook::pre-generate', + function_id: 'fp::inject-guidance', + worker_name: 'fp', + config_summary: '{"on_error":"fail_open"}', + }, + ], +}