From b8eaa3dce9801b3fa96a279ab0ea6b303a741366 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 5 Aug 2026 12:43:01 +0100 Subject: [PATCH 01/10] (MOT-4354) feat(console): functions and triggers pages as injected UI The last two views that existed only in the old iii console now ship from the console worker's own injectable UI: functions at #/ext/functions and triggers at #/ext/triggers. Injected rather than first-party SPA pages, so the console stays a thin host and both pages rebuild, hot-reload, and toggle with the rest of the injectable UI. Functions lists engine::functions::list grouped by namespace, with search, an internal-functions toggle (off by default, or the console's own per-tab handlers bury everything else), and a detail pane that fetches engine::functions::info per selection for the request and response schemas, the triggers bound to that function, and an invoke panel whose body opens on a template generated from the request schema. Triggers keeps two lists distinct that the old view conflated: trigger TYPES from engine::triggers::list (what can fire) and live BINDINGS from engine::registered-triggers::list (what will fire, into which function). Types with no bindings still list but collapse by default; a binding whose type is missing from the catalogue gets its own heading rather than vanishing. Selecting a binding offers a call panel templated from the type's payload schema, labelled for what it actually does: it calls the bound function directly, so the trigger's own config filters do not apply. Neither page polls. Both read on mount, refresh on worker lifecycle events, and expose an explicit refresh control, since the engine publishes no function-registration event to subscribe to. --- console/build.rs | 9 +- console/src/ui.rs | 39 ++- console/ui/build.mjs | 9 +- console/ui/catalog-page.tsx | 35 ++ console/ui/src/catalog/FunctionsPage.tsx | 284 +++++++++++++++++ console/ui/src/catalog/InvokePanel.tsx | 121 +++++++ console/ui/src/catalog/TriggersPage.tsx | 386 +++++++++++++++++++++++ console/ui/src/catalog/engine.ts | 332 +++++++++++++++++++ console/ui/src/catalog/schema.ts | 105 ++++++ console/ui/src/catalog/widgets.tsx | 203 ++++++++++++ console/ui/styles.css | 275 +++++++++++++++- 11 files changed, 1785 insertions(+), 13 deletions(-) create mode 100644 console/ui/catalog-page.tsx create mode 100644 console/ui/src/catalog/FunctionsPage.tsx create mode 100644 console/ui/src/catalog/InvokePanel.tsx create mode 100644 console/ui/src/catalog/TriggersPage.tsx create mode 100644 console/ui/src/catalog/engine.ts create mode 100644 console/ui/src/catalog/schema.ts create mode 100644 console/ui/src/catalog/widgets.tsx 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..3f42a589f 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,22 @@ 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_both_pages() { + assert!(CATALOG_PAGE_JS.contains("functions")); + assert!(CATALOG_PAGE_JS.contains("triggers")); + } + #[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..838d745ae --- /dev/null +++ b/console/ui/catalog-page.tsx @@ -0,0 +1,35 @@ +/** + * 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 (#/ext/functions) + * - src/catalog/TriggersPage — trigger types and their live bindings + * (#/ext/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 } 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: () => , + }) + + host.pages.register({ + id: 'triggers', + title: 'triggers', + render: () => , + }) +} diff --git a/console/ui/src/catalog/FunctionsPage.tsx b/console/ui/src/catalog/FunctionsPage.tsx new file mode 100644 index 000000000..c164254ac --- /dev/null +++ b/console/ui/src/catalog/FunctionsPage.tsx @@ -0,0 +1,284 @@ +/** + * The Functions page (`#/ext/functions`): every function registered on the + * bus, grouped by namespace, with the detail and invoke panes on the right. + * + * Two calls back the page. `engine::functions::list` is the catalogue + * (cheap, one row per function); `engine::functions::info` is fetched only + * for the selected row, because that is where the schemas live and the + * fleet has hundreds of functions. + * + * 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, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@iii-dev/console-ui' +import { useCallback, useMemo, useState } from 'react' +import { + type FunctionSummary, + functionInfo, + listFunctions, + useFleetChanges, + useResource, +} from './engine' +import { InvokePanel } from './InvokePanel' +import { compareGroups, namespaceOf, pretty } from './schema' +import { + CatalogHead, + CatalogRow, + CatalogShell, + Chip, + DetailHead, + ErrorNote, + GroupHeader, + Note, + useGroupToggle, +} from './widgets' + +/** Function namespaces always start expanded; there is no noisy bucket. */ +const alwaysOpen = () => true + +export function FunctionsPage({ host }: { host: Host }) { + const [showInternal, setShowInternal] = useState(false) + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + const groupState = useGroupToggle(alwaysOpen) + + const load = useCallback( + () => listFunctions(host, { includeInternal: showInternal }), + [host, showInternal], + ) + const functions = useResource(load) + useFleetChanges(host, functions.reload) + + const groups = useMemo(() => { + const needle = search.trim().toLowerCase() + const matched = (functions.data ?? []).filter((fn) => { + if (!needle) return true + return ( + fn.function_id.toLowerCase().includes(needle) || + fn.worker_name.toLowerCase().includes(needle) || + (fn.description ?? '').toLowerCase().includes(needle) + ) + }) + const byGroup = new Map() + for (const fn of matched) { + const group = namespaceOf(fn.function_id) + const bucket = byGroup.get(group) + if (bucket) bucket.push(fn) + else byGroup.set(group, [fn]) + } + return [...byGroup.entries()] + .map(([label, items]) => ({ + label, + items: items.sort((a, b) => a.function_id.localeCompare(b.function_id)), + })) + .sort((a, b) => compareGroups(a.label, b.label)) + }, [functions.data, search]) + + const shown = groups.reduce((n, g) => n + g.items.length, 0) + + return ( + + + + } + list={ + functions.error ? ( + + ) : functions.data === null ? ( + loading functions… + ) : shown === 0 ? ( + + ) : ( + groups.map((group) => ( +
+ groupState.toggle(group.label)} + /> + {!groupState.isOpen(group.label) + ? null + : group.items.map((fn) => ( + + setSelected((prev) => + prev === fn.function_id ? null : fn.function_id, + ) + } + /> + ))} +
+ )) + ) + } + detail={ + selected ? ( + setSelected(null)} + /> + ) : null + } + /> + ) +} + +function FunctionDetailPane({ + host, + functionId, + onClose, +}: { + host: Host + functionId: string + onClose: () => void +}) { + const load = useCallback( + () => functionInfo(host, functionId), + [host, functionId], + ) + const detail = useResource(load) + + return ( + <> + + + {detail.data.registered_triggers.length > 0 ? ( + + ) : null} + {detail.data.description ? ( + + {detail.data.description} + + ) : null} + + ) : null + } + onClose={onClose} + /> + {detail.error ? ( + + ) : detail.data === null ? ( + loading detail… + ) : ( + + + invoke + request + response + + triggers + {detail.data.registered_triggers.length > 0 ? ( + {detail.data.registered_triggers.length} + ) : null} + + + + + + + + + + + + + {detail.data.registered_triggers.length === 0 ? ( + + nothing is bound to this function — it runs only when something + calls it. + + ) : ( + detail.data.registered_triggers.map((trigger) => ( +
+
+ + {trigger.id} +
+ +
+ )) + )} +
+
+ )} + + ) +} + +function SchemaPane({ schema, empty }: { schema: unknown; empty: string }) { + if (schema === undefined || schema === null) return {empty} + return ( + + ) +} diff --git a/console/ui/src/catalog/InvokePanel.tsx b/console/ui/src/catalog/InvokePanel.tsx new file mode 100644 index 000000000..9159cfadb --- /dev/null +++ b/console/ui/src/catalog/InvokePanel.tsx @@ -0,0 +1,121 @@ +/** + * 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` (../catalog/schema.ts) so the operator edits real field + * names rather than typing the shape from memory. Editing is the console's + * Monaco `CodeEditor` — the one editor, never a bundled second one. + * + * A call goes out as a plain `host.iii.trigger`, which is exactly what any + * bus client can already do; there is no separate privilege here. Failures + * render as the worker's own error text, never a swallowed empty result. + */ + +import { + Button, + CodeEditor, + type Host, + JsonHighlight, +} from '@iii-dev/console-ui' +import { useEffect, useState } from 'react' +import { type InvokeOutcome, invoke } from './engine' +import { pretty, templateFromSchema } from './schema' + +export function InvokePanel({ + host, + functionId, + requestSchema, + label = 'invoke', + runningLabel = 'invoking…', + hint, +}: { + host: Host + functionId: string + requestSchema: unknown + /** Verb on the button — the triggers page fires a target function. */ + label?: string + runningLabel?: string + hint?: string +}) { + const [body, setBody] = useState('{}') + const [running, setRunning] = useState(false) + const [outcome, setOutcome] = useState(null) + const [invalid, setInvalid] = useState(null) + + // A new selection resets the editor to that function's own template; an + // in-flight result from the previous selection is dropped with it. + useEffect(() => { + setBody(templateFromSchema(requestSchema)) + setOutcome(null) + setInvalid(null) + }, [functionId, requestSchema]) + + const run = async () => { + let payload: unknown + try { + payload = JSON.parse(body) + } catch (err) { + setInvalid(err instanceof Error ? err.message : 'invalid JSON') + setOutcome(null) + return + } + if ( + payload === null || + typeof payload !== 'object' || + Array.isArray(payload) + ) { + setInvalid('the request body must be a JSON object') + setOutcome(null) + return + } + setInvalid(null) + setRunning(true) + const result = await invoke( + host, + functionId, + payload as Record, + ) + setRunning(false) + setOutcome(result) + } + + return ( +
+ {hint ?
{hint}
: null} + +
+ + {invalid ? ( + {invalid} + ) : null} + {outcome ? ( + + {outcome.ok ? 'ok' : 'error'} · {Math.round(outcome.durationMs)}ms + + ) : null} +
+ {outcome?.error ? ( +
{outcome.error}
+ ) : null} + {outcome?.ok ? ( + + ) : null} +
+ ) +} diff --git a/console/ui/src/catalog/TriggersPage.tsx b/console/ui/src/catalog/TriggersPage.tsx new file mode 100644 index 000000000..36cd0e35f --- /dev/null +++ b/console/ui/src/catalog/TriggersPage.tsx @@ -0,0 +1,386 @@ +/** + * The Triggers page (`#/ext/triggers`): every trigger type published on the + * bus, each with its live bindings underneath. + * + * 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 BINDINGS (what will fire, and into which function). A type with no + * bindings 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. + * + * Selecting a type shows its config and payload schemas + * (`engine::triggers::info`, fetched per selection). Selecting a binding + * shows its config and lets the operator call the bound function with a + * payload shaped like the one the trigger delivers. There is no engine call + * that synthesizes a firing, so the page says what it is doing: it invokes + * the target function directly. + */ + +import { + Badge, + Button, + EmptyState, + type Host, + JsonHighlight, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@iii-dev/console-ui' +import { useCallback, useMemo, useState } from 'react' +import { + listRegisteredTriggers, + listTriggerTypes, + type RegisteredTrigger, + type TriggerTypeSummary, + triggerTypeInfo, + useFleetChanges, + useResource, +} from './engine' +import { InvokePanel } from './InvokePanel' +import { pretty } from './schema' +import { + CatalogHead, + CatalogRow, + CatalogShell, + Chip, + DetailHead, + ErrorNote, + GroupHeader, + Note, + useGroupToggle, +} from './widgets' + +type Selection = + | { kind: 'type'; id: string } + | { kind: 'binding'; binding: RegisteredTrigger } + +interface TypeGroup { + type: TriggerTypeSummary + bindings: RegisteredTrigger[] +} + +export function TriggersPage({ host }: { host: Host }) { + const [showInternal, setShowInternal] = useState(false) + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + + // Both lists are independent reads; the page needs them together, so it + // pays for one round trip, not two. + const loadCatalog = useCallback(async () => { + const [types, bindings] = await Promise.all([ + listTriggerTypes(host, { includeInternal: showInternal }), + listRegisteredTriggers(host, { includeInternal: showInternal }), + ]) + return { types, bindings } + }, [host, showInternal]) + const catalog = useResource(loadCatalog) + useFleetChanges(host, catalog.reload) + + const groups = useMemo(() => { + if (!catalog.data) return [] + const byType = new Map() + for (const binding of catalog.data.bindings) { + 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 binding 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 }) + } + } + + const needle = search.trim().toLowerCase() + const matches = (group: TypeGroup) => { + 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) || + (b.config_summary ?? '').toLowerCase().includes(needle), + ) + } + + return [...known.values()] + .map((type) => ({ + type, + bindings: (byType.get(type.id) ?? []).sort((a, b) => + a.function_id.localeCompare(b.function_id), + ), + })) + .filter(matches) + .sort((a, b) => a.type.id.localeCompare(b.type.id)) + }, [catalog.data, search]) + + const boundCount = groups.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) + + return ( + + + + } + list={ + catalog.error ? ( + + ) : catalog.data === null ? ( + loading triggers… + ) : groups.length === 0 ? ( + + ) : ( + groups.map((group) => ( +
+ groupState.toggle(group.type.id)} + /> + {groupState.isOpen(group.type.id) ? ( + <> + + setSelected((prev) => + prev?.kind === 'type' && prev.id === group.type.id + ? null + : { kind: 'type', id: group.type.id }, + ) + } + /> + {group.bindings.map((binding) => ( + + setSelected((prev) => + prev?.kind === 'binding' && + prev.binding.id === binding.id + ? null + : { kind: 'binding', binding }, + ) + } + /> + ))} + + ) : null} +
+ )) + ) + } + detail={ + selected === null ? null : selected.kind === 'type' ? ( + setSelected(null)} + /> + ) : ( + setSelected(null)} + /> + ) + } + /> + ) +} + +function TypeDetailPane({ + host, + typeId, + onClose, +}: { + host: Host + typeId: string + onClose: () => void +}) { + const load = useCallback(() => triggerTypeInfo(host, typeId), [host, typeId]) + const detail = useResource(load) + + return ( + <> + + + {detail.data.instance_count !== undefined ? ( + + ) : null} + {detail.data.description ? ( + + {detail.data.description} + + ) : null} + + ) : null + } + onClose={onClose} + /> + {detail.error ? ( + + ) : detail.data === null ? ( + loading detail… + ) : ( + + + config schema + payload schema + + + {detail.data.configuration_schema === undefined ? ( + + this type takes no config — bindings register with an empty + object. + + ) : ( + + )} + + + {detail.data.request_schema === undefined ? ( + this type publishes no payload schema. + ) : ( + + )} + + + )} + + ) +} + +function BindingDetailPane({ + host, + binding, + onClose, +}: { + host: Host + binding: RegisteredTrigger + onClose: () => void +}) { + // The payload schema belongs to the TYPE, so the call panel 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) + + return ( + <> + + + + {binding.id} + + } + onClose={onClose} + /> + + + config + + call target + {binding.function_id ? null : n/a} + + + + + + + {binding.function_id ? ( + + ) : ( + + this binding carries no target function — nothing to call. + + )} + + + + ) +} diff --git a/console/ui/src/catalog/engine.ts b/console/ui/src/catalog/engine.ts new file mode 100644 index 000000000..702e100a4 --- /dev/null +++ b/console/ui/src/catalog/engine.ts @@ -0,0 +1,332 @@ +/** + * 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 { + return err instanceof Error ? err.message : String(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), + } +} + +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 + +/** + * Reload when the worker fleet changes, instead of polling. + * + * The engine publishes no "function registered" event, so the closest true + * signal is the `worker` trigger type (worker manager add/remove, every + * lifecycle stage) — the case where the catalogue actually changes under an + * open tab. Bursts are debounced; everything else is the page's refresh + * control. The binding filters on BOTH `operations` and `stages`: omitting + * either matches no events. + */ +export function useFleetChanges(host: Host, reload: () => void) { + const reloadRef = useRef(reload) + reloadRef.current = reload + const handlerId = useMemo(() => { + hubSeq += 1 + return `iii::console-catalog::fleet-${hubSeq}` + }, []) + + useEffect(() => { + let timer: number | null = null + const schedule = () => { + if (timer !== null) window.clearTimeout(timer) + timer = window.setTimeout(() => { + timer = null + reloadRef.current() + }, 400) + } + + let offHandler: (() => void) | undefined + let offTrigger: (() => void) | undefined + try { + offHandler = host.iii.on(handlerId, schedule) + offTrigger = host.iii.registerTrigger({ + type: 'worker', + function_id: `${handlerId}::${host.iii.browserId}`, + config: { + operations: ['add', 'remove'], + stages: ['started', 'downloading', 'downloaded', 'done', 'failed'], + }, + }) + } catch { + // No `worker` trigger type on this engine: the refresh control stands + // in, the page still works. + offTrigger?.() + offHandler?.() + offTrigger = undefined + offHandler = undefined + } + + return () => { + if (timer !== null) window.clearTimeout(timer) + offTrigger?.() + offHandler?.() + } + }, [host, handlerId]) +} + +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 } +} diff --git a/console/ui/src/catalog/schema.ts b/console/ui/src/catalog/schema.ts new file mode 100644 index 000000000..0243421ec --- /dev/null +++ b/console/ui/src/catalog/schema.ts @@ -0,0 +1,105 @@ +/** + * 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) + } +} + +/** + * The namespace a function id belongs to: everything before the first `::`. + * Ids without a namespace group under `other` so no row goes missing. + */ +export function namespaceOf(functionId: string): string { + const cut = functionId.indexOf('::') + return cut > 0 ? functionId.slice(0, cut) : 'other' +} + +/** Group label ordering: alphabetical, with the `other` bucket last. */ +export function compareGroups(a: string, b: string): number { + if (a === 'other') return 1 + if (b === 'other') return -1 + return a.localeCompare(b) +} diff --git a/console/ui/src/catalog/widgets.tsx b/console/ui/src/catalog/widgets.tsx new file mode 100644 index 000000000..d05e5f87e --- /dev/null +++ b/console/ui/src/catalog/widgets.tsx @@ -0,0 +1,203 @@ +/** + * The chrome both catalogue pages share: the two-pane shell, the head row + * with search and toggles, collapsible group headers, list rows, and the + * detail pane header. Components come from `@iii-dev/console-ui` (the + * console's own, zero bytes in this bundle); everything else is a scoped + * class in ../../styles.css. + */ + +import { Badge, Button, Input } from '@iii-dev/console-ui' +import { type ReactNode, useCallback, useState } from 'react' + +/** + * 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: functions 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 } +} + +export function CatalogShell({ + head, + list, + detail, +}: { + head: ReactNode + list: ReactNode + detail: ReactNode | null +}) { + return ( +
+ {head} +
+
{list}
+ {detail ?
{detail}
: null} +
+
+ ) +} + +export function CatalogHead({ + title, + count, + search, + onSearch, + searchPlaceholder, + onRefresh, + loading, + children, +}: { + title: string + count: ReactNode + search: string + onSearch: (next: string) => void + searchPlaceholder: string + onRefresh: () => void + loading: boolean + children?: ReactNode +}) { + return ( +
+
+ {title} + {count} + + {children} + +
+ +
+ ) +} + +export function GroupHeader({ + label, + meta, + open, + onToggle, +}: { + label: string + meta: string + open: boolean + onToggle: () => void +}) { + return ( + + ) +} + +export function CatalogRow({ + primary, + secondary, + selected, + onClick, +}: { + primary: ReactNode + secondary?: ReactNode + selected: boolean + onClick: () => void +}) { + return ( + + ) +} + +export function DetailHead({ + title, + subtitle, + onClose, + children, +}: { + title: string + subtitle?: ReactNode + onClose: () => void + children?: ReactNode +}) { + return ( +
+
+ {title} + + {children} + +
+ {subtitle ? ( +
{subtitle}
+ ) : null} +
+ ) +} + +export function Note({ children }: { children: ReactNode }) { + return
{children}
+} + +export function ErrorNote({ + call, + message, +}: { + call: string + message: string +}) { + return ( +
+ {call} failed — {message} +
+ ) +} + +/** Key/value chips for a trigger config, ids, counts. */ +export function Chip({ k, v }: { k: string; v: ReactNode }) { + return ( + + {k} + {v} + + ) +} diff --git a/console/ui/styles.css b/console/ui/styles.css index a003e84d5..4e726be50 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. */ @@ -170,3 +171,275 @@ line-height: 1.5; color: var(--color-alert); } + +/* ── the engine catalogue pages (functions, triggers) ───────────────── */ + +/* The host pane is `flex-1 min-h-0 overflow-y-auto`, so the page owns its + * own height and scrolls each column separately. Container queries, not + * media queries: the pane width is what matters, not the viewport. */ +[data-iii-ui="console"] .console-catalog { + display: flex; + flex-direction: column; + height: 100%; + container-type: inline-size; + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink); +} +[data-iii-ui="console"] .console-catalog *, +[data-iii-ui="console"] .console-catalog *::before, +[data-iii-ui="console"] .console-catalog *::after { + box-sizing: border-box; +} + +[data-iii-ui="console"] .console-catalog-head { + flex: none; + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px 20px; + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="console"] .console-catalog-head-row { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +[data-iii-ui="console"] .console-catalog-title { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.16em; + color: var(--color-ink-faint); + font-weight: 600; +} +[data-iii-ui="console"] .console-catalog-search { + width: 100%; +} + +[data-iii-ui="console"] .console-catalog-body { + flex: 1; + min-height: 0; + display: flex; +} +[data-iii-ui="console"] .console-catalog-list { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 12px 20px 24px; +} +[data-iii-ui="console"] .console-catalog-detail { + flex: none; + width: 46%; + min-width: 340px; + overflow-y: auto; + border-left: 1px solid var(--color-rule); + background: var(--color-panel); +} +/* Below this the two panes are both too narrow to read, so they stack. + * The console page pane is roughly 790px with the chat dock open. */ +@container (max-width: 760px) { + [data-iii-ui="console"] .console-catalog-body { + flex-direction: column; + } + [data-iii-ui="console"] .console-catalog-detail { + width: 100%; + min-width: 0; + border-left: 0; + border-top: 1px solid var(--color-rule); + } +} + +/* --- list: collapsible group, then its rows -------------------------- */ +[data-iii-ui="console"] .console-catalog-section { + margin-bottom: 14px; +} +[data-iii-ui="console"] .console-catalog-group { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 0; + background: transparent; + border: 0; + color: var(--color-ink); + font: inherit; + text-align: left; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-group .chevron { + 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 .label { + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-ink-faint); + font-weight: 600; +} +[data-iii-ui="console"] .console-catalog-group .meta { + font-size: 11px; + color: var(--color-ink-ghost); +} + +[data-iii-ui="console"] .console-catalog-row { + display: flex; + flex-direction: column; + gap: 2px; + width: 100%; + padding: 8px 10px; + text-align: left; + background: transparent; + border: 1px solid transparent; + border-radius: 6px; + color: var(--color-ink); + font: inherit; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-row:hover { + background: var(--color-paper-2); +} +[data-iii-ui="console"] .console-catalog-row:focus-visible { + outline: 2px solid var(--color-ring); + outline-offset: 1px; +} +[data-iii-ui="console"] .console-catalog-row[data-selected="true"] { + border-color: var(--color-accent); + background: color-mix(in srgb, var(--color-accent) 8%, transparent); +} +[data-iii-ui="console"] .console-catalog-row .primary { + font-size: 13px; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-row .secondary { + font-size: 11.5px; + color: var(--color-ink-faint); + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +/* --- detail pane ----------------------------------------------------- */ +[data-iii-ui="console"] .console-catalog-detail-head { + position: sticky; + top: 0; + z-index: 1; + display: flex; + flex-direction: column; + gap: 8px; + padding: 14px 16px; + background: var(--color-panel); + border-bottom: 1px solid var(--color-rule); +} +[data-iii-ui="console"] .console-catalog-detail-title { + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-detail-sub { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[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 { + border: 1px solid var(--color-rule); + border-radius: 4px; + font-size: 11.5px; + padding: 1px 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-id { + font-size: 11px; + color: var(--color-ink-ghost); + overflow-wrap: anywhere; +} +[data-iii-ui="console"] .console-catalog-tabs { + padding: 12px 16px 20px; +} + +/* --- invoke panel ---------------------------------------------------- */ +[data-iii-ui="console"] .console-catalog-invoke { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 10px; +} +[data-iii-ui="console"] .console-catalog-editor { + border: 1px solid var(--color-rule); + border-radius: 6px; + min-height: 120px; + max-height: 320px; + overflow: auto; +} +[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; + border: 1px solid var(--color-rule); + border-radius: 6px; + background: var(--color-bg); + padding: 10px 12px; + font-size: 12px; + line-height: 1.5; + max-height: 420px; + overflow: auto; +} + +/* --- bindings, notes, errors ----------------------------------------- */ +[data-iii-ui="console"] .console-catalog-binding { + display: flex; + flex-direction: column; + gap: 6px; + padding: 10px 0; + border-bottom: 1px solid var(--color-rule-2); +} +[data-iii-ui="console"] .console-catalog-binding:last-child { + border-bottom: 0; +} +[data-iii-ui="console"] .console-catalog-binding-head { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +[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 { + border: 1px solid var(--color-alert); + border-radius: 6px; + color: var(--color-alert); + padding: 10px 12px; + font-size: 12.5px; + line-height: 1.5; + overflow-wrap: anywhere; +} From ba9c7814b950f2d1d70b8e0768f969871a576ba0 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 5 Aug 2026 13:52:03 +0100 Subject: [PATCH 02/10] (MOT-4356) feat(console): read triggers by family, with each family's real fire path The first cut listed bindings generically: function id as the title, raw config as the subtitle, one call-the-function panel for everything. That loses what the old console got right, which was that a trigger is only legible in the words of its own family. Bindings are now named by what they listen to. An http binding reads `POST /memory/list`, a cron binding reads its schedule in words, a queue subscriber reads its topic, a state binding reads scope/key. The target function moves to the subtitle with its description, so a row says both what fires and what runs. Filter chips across the top count the families actually present (http, cron, queue, state, stream, hook, console asset, event) and narrow the list to one of them, and the group header carries the family tag in that family's tone. The detail pane then offers that family's REAL fire path rather than a uniform function call: - http sends an actual request to the endpoint, with the method picker, path parameter inputs parsed from the `:param` template, add/remove query rows, a JSON body for the methods that take one, and the status and duration of the response. The base URL is read from the http worker's own configuration entry, never guessed. - a queue subscriber publishes to its topic through `iii::durable::publish`, behind a confirm step, so retry and dead-letter behavior applies exactly as it would in production. - cron shows the schedule in words with the raw expression, a next-run estimate for the shapes where that follows from the fields alone, and a run-now that calls the target with a cron-shaped payload, saying so. - everything else keeps the direct call, still labelled as a direct call. Schedule reading lives in cron.ts, family reading in trigger-kinds.ts, so the list and the detail pane cannot disagree about what a binding is. --- console/ui/src/catalog/FunctionsPage.tsx | 5 +- console/ui/src/catalog/HttpTester.tsx | 330 ++++++++++++++++++++ console/ui/src/catalog/QueuePublish.tsx | 115 +++++++ console/ui/src/catalog/TriggersPage.tsx | 377 ++++++++++++++++------- console/ui/src/catalog/cron.ts | 185 +++++++++++ console/ui/src/catalog/trigger-kinds.ts | 188 +++++++++++ console/ui/src/catalog/widgets.tsx | 115 ++++++- console/ui/styles.css | 200 ++++++++++++ 8 files changed, 1404 insertions(+), 111 deletions(-) create mode 100644 console/ui/src/catalog/HttpTester.tsx create mode 100644 console/ui/src/catalog/QueuePublish.tsx create mode 100644 console/ui/src/catalog/cron.ts create mode 100644 console/ui/src/catalog/trigger-kinds.ts diff --git a/console/ui/src/catalog/FunctionsPage.tsx b/console/ui/src/catalog/FunctionsPage.tsx index c164254ac..4400da4d8 100644 --- a/console/ui/src/catalog/FunctionsPage.tsx +++ b/console/ui/src/catalog/FunctionsPage.tsx @@ -38,6 +38,7 @@ import { CatalogRow, CatalogShell, Chip, + CopyButton, DetailHead, ErrorNote, GroupHeader, @@ -207,7 +208,9 @@ function FunctionDetailPane({ ) : null } onClose={onClose} - /> + > + +
{detail.error ? ( ) : detail.data === null ? ( diff --git a/console/ui/src/catalog/HttpTester.tsx b/console/ui/src/catalog/HttpTester.tsx new file mode 100644 index 000000000..14216d1d2 --- /dev/null +++ b/console/ui/src/catalog/HttpTester.tsx @@ -0,0 +1,330 @@ +/** + * 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 { + const entry = await host.iii.trigger('configuration::get', { id: 'iii-http' }) + const value = isRecord(entry) ? entry.value : null + if (!isRecord(value)) throw new Error('iii-http has no configuration value') + const port = value.port + if (typeof port !== 'number') + throw new Error('iii-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. + useEffect(() => { + setMethod(binding.method) + setParams(Object.fromEntries(binding.params.map((p) => [p, '']))) + setQuery([]) + setBody('{}') + setOutcome(null) + setInvalid(null) + }, [binding]) + + 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/QueuePublish.tsx b/console/ui/src/catalog/QueuePublish.tsx new file mode 100644 index 000000000..c6f5eec23 --- /dev/null +++ b/console/ui/src/catalog/QueuePublish.tsx @@ -0,0 +1,115 @@ +/** + * 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, 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) + + useEffect(() => { + setBody('{}') + setOutcome(null) + setInvalid(null) + setConfirming(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) + setOutcome(await invoke(host, 'iii::durable::publish', { topic, data })) + 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/TriggersPage.tsx b/console/ui/src/catalog/TriggersPage.tsx index 36cd0e35f..1327a2b43 100644 --- a/console/ui/src/catalog/TriggersPage.tsx +++ b/console/ui/src/catalog/TriggersPage.tsx @@ -9,16 +9,15 @@ * for — and a binding whose type is not in the catalogue lists under its own * heading rather than disappearing. * - * Selecting a type shows its config and payload schemas - * (`engine::triggers::info`, fetched per selection). Selecting a binding - * shows its config and lets the operator call the bound function with a - * payload shaped like the one the trigger delivers. There is no engine call - * that synthesizes a firing, so the page says what it is doing: it invokes - * the target function directly. + * A binding 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 { - Badge, Button, EmptyState, type Host, @@ -29,7 +28,10 @@ import { TabsTrigger, } from '@iii-dev/console-ui' import { useCallback, useMemo, useState } from 'react' +import { describeCron, nextCronRun, untilLabel } from './cron' import { + type FunctionSummary, + listFunctions, listRegisteredTriggers, listTriggerTypes, type RegisteredTrigger, @@ -38,17 +40,31 @@ import { useFleetChanges, useResource, } from './engine' +import { HttpTester } from './HttpTester' import { InvokePanel } from './InvokePanel' +import { QueuePublish } from './QueuePublish' import { pretty } from './schema' +import { + configChips, + cronExpression, + type Family, + familyOf, + httpBinding, + queueTopic, + summarize, +} from './trigger-kinds' import { CatalogHead, CatalogRow, CatalogShell, Chip, + CopyButton, DetailHead, ErrorNote, + FilterChips, GroupHeader, Note, + StatTile, useGroupToggle, } from './widgets' @@ -64,21 +80,31 @@ interface TypeGroup { export function TriggersPage({ host }: { host: Host }) { const [showInternal, setShowInternal] = useState(false) const [search, setSearch] = useState('') + const [family, setFamily] = useState(null) const [selected, setSelected] = useState(null) - // Both lists are independent reads; the page needs them together, so it - // pays for one round trip, not two. + // 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] = await Promise.all([ + const [types, bindings, functions] = await Promise.all([ listTriggerTypes(host, { includeInternal: showInternal }), listRegisteredTriggers(host, { includeInternal: showInternal }), + listFunctions(host, { includeInternal: true }), ]) - return { types, bindings } + return { types, bindings, functions } }, [host, showInternal]) const catalog = useResource(loadCatalog) useFleetChanges(host, catalog.reload) - const groups = useMemo(() => { + 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 allGroups = useMemo(() => { if (!catalog.data) return [] const byType = new Map() for (const binding of catalog.data.bindings) { @@ -96,8 +122,32 @@ export function TriggersPage({ host }: { host: Host }) { } } + return [...known.values()] + .map((type) => ({ + type, + bindings: (byType.get(type.id) ?? []).sort((a, b) => + summarize(a).localeCompare(summarize(b)), + ), + })) + .sort((a, b) => a.type.id.localeCompare(b.type.id)) + }, [catalog.data]) + + 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) + Math.max(1, group.bindings.length), + ) + } + return counts + }, [allGroups]) + + const groups = useMemo(() => { const needle = search.trim().toLowerCase() - const matches = (group: TypeGroup) => { + 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) || @@ -110,20 +160,11 @@ export function TriggersPage({ host }: { host: Host }) { (b) => b.function_id.toLowerCase().includes(needle) || b.worker_name.toLowerCase().includes(needle) || + summarize(b).toLowerCase().includes(needle) || (b.config_summary ?? '').toLowerCase().includes(needle), ) - } - - return [...known.values()] - .map((type) => ({ - type, - bindings: (byType.get(type.id) ?? []).sort((a, b) => - a.function_id.localeCompare(b.function_id), - ), - })) - .filter(matches) - .sort((a, b) => a.type.id.localeCompare(b.type.id)) - }, [catalog.data, search]) + }) + }, [allGroups, family, search]) const boundCount = groups.reduce((n, g) => n + g.bindings.length, 0) @@ -144,9 +185,16 @@ export function TriggersPage({ host }: { host: Host }) { count={`${groups.length} types · ${boundCount} bound`} search={search} onSearch={setSearch} - searchPlaceholder="search types, functions, config…" + searchPlaceholder="search types, functions, paths, topics, schedules…" onRefresh={catalog.reload} loading={catalog.loading} + below={ + + } > + {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} +
+ ) +} + +/** Copy-to-clipboard with the two-second confirmation the old console had. */ +export function CopyButton({ + value, + label = 'copy', +}: { + value: string + label?: string +}) { + const [copied, setCopied] = useState(false) + return ( + + ) +} + export function GroupHeader({ label, meta, open, onToggle, + tone, + toneLabel, }: { label: string meta: string open: boolean onToggle: () => void + /** Family color for the leading tag, when the page groups by family. */ + tone?: string + toneLabel?: string }) { return ( @@ -155,6 +257,7 @@ export function DetailHead({ title: string subtitle?: ReactNode onClose: () => void + /** Actions left of `close` — copy buttons, mostly. */ children?: ReactNode }) { return ( @@ -193,9 +296,17 @@ export function ErrorNote({ } /** Key/value chips for a trigger config, ids, counts. */ -export function Chip({ k, v }: { k: string; v: ReactNode }) { +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 4e726be50..29f1c990d 100644 --- a/console/ui/styles.css +++ b/console/ui/styles.css @@ -443,3 +443,203 @@ line-height: 1.5; overflow-wrap: anywhere; } + +/* --- 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); + border-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); + border-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); + border-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); + border-color: var(--color-alert); +} + +[data-iii-ui="console"] .console-catalog-tag { + border: 1px solid var(--color-rule); + border-radius: 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; + padding: 0 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: 1px solid var(--color-rule); + border-radius: 999px; + background: transparent; + color: var(--color-ink-faint); + font: inherit; + font-size: 11px; + padding: 2px 10px; + cursor: pointer; +} +[data-iii-ui="console"] .console-catalog-filter:hover { + color: var(--color-ink); + border-color: var(--color-ring); +} +[data-iii-ui="console"] .console-catalog-filter[data-selected="true"] { + color: var(--color-accent-fg); + background: var(--color-accent); + border-color: var(--color-accent); +} +[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; + border: 1px solid var(--color-rule); + 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; + border: 1px solid var(--color-warn); + 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-endpoint { + display: flex; + align-items: center; + gap: 8px; + border: 1px solid var(--color-accent); + 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 0; + border-top: 1px solid var(--color-rule-2); + border-bottom: 1px solid var(--color-rule-2); + margin-bottom: 12px; +} +[data-iii-ui="console"] .console-catalog-target code { + font-size: 12.5px; + overflow-wrap: anywhere; +} From ad142f46612a146069c49e277bab293f28978b33 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Wed, 5 Aug 2026 14:28:54 +0100 Subject: [PATCH 03/10] =?UTF-8?q?(MOT-4354)=20feat(console):=20live=20cata?= =?UTF-8?q?logue=20=E2=80=94=20fleet=20page,=20per-function=20activity,=20?= =?UTF-8?q?schema=20tables?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pages now read the engine the way the rest of the console does: over its own signals, never a timer. `engine::functions-available` and `engine::workers-available` are internal engine trigger types that fire when functions register or unregister and when a worker connects or drops. Every catalogue page subscribes to them, so a worker starting fills its rows in within a beat and rows that arrived on the last tick flash once. A `live` marker in each header says so, and the refresh control stays for the impatient. New page, fleet (#/ext/fleet): the connected workers with runtime, version, uptime, function count and what is running right now, and a detail pane that answers in one place what the console used to split across three — this worker's functions with their descriptions, the trigger types it publishes, the live bindings pointing into it, and its reported heap, rss, cpu and event-loop lag. It registers as `fleet` rather than `workers` so it does not collide with the SPA's native tab while both exist. Functions gains two panes: - activity, a live feed of that function's calls read from the trace stream (`trace` trigger tick, then `engine::traces::list` filtered to the span name). Each row carries time, worker, and duration on an adaptive scale, because bus calls are routinely tens of microseconds and a fixed ms scale prints a wall of zeroes. Expanding a row shows the recorded input and output; replaying one drops that input into the invoke editor, minus the engine-injected caller id, which is not something a caller would send. - request and response schemas as field tables — name, type, required, default, description, nested fields indented — instead of raw draft-07. The raw schema still renders for the shapes a table cannot express. The invoke panel now feeds the schema's field names to Monaco as completions, checks the schema's own required list before spending a call, keeps this session's attempts for one-click reuse, and copies the call as an `iii trigger` command line. --- console/src/ui.rs | 28 +- console/ui/catalog-page.tsx | 27 +- console/ui/src/catalog/ActivityFeed.tsx | 203 +++++++++++++ console/ui/src/catalog/FunctionsPage.tsx | 84 ++++-- console/ui/src/catalog/InvokePanel.tsx | 153 ++++++++-- console/ui/src/catalog/SchemaTable.tsx | 181 ++++++++++++ console/ui/src/catalog/TriggersPage.tsx | 10 +- console/ui/src/catalog/WorkersPage.tsx | 360 +++++++++++++++++++++++ console/ui/src/catalog/engine.ts | 272 ++++++++++++++--- console/ui/src/catalog/widgets.tsx | 19 +- console/ui/styles.css | 233 +++++++++++++++ 11 files changed, 1478 insertions(+), 92 deletions(-) create mode 100644 console/ui/src/catalog/ActivityFeed.tsx create mode 100644 console/ui/src/catalog/SchemaTable.tsx create mode 100644 console/ui/src/catalog/WorkersPage.tsx diff --git a/console/src/ui.rs b/console/src/ui.rs index 3f42a589f..090588eb2 100644 --- a/console/src/ui.rs +++ b/console/src/ui.rs @@ -84,11 +84,31 @@ mod tests { } /// The pages are useless if their ids drift from the routes the nav and - /// deep links use (`#/ext/functions`, `#/ext/triggers`). + /// deep links use (`#/ext/functions`, `#/ext/triggers`, `#/ext/fleet`). #[test] - fn embedded_catalog_page_registers_both_pages() { - assert!(CATALOG_PAGE_JS.contains("functions")); - assert!(CATALOG_PAGE_JS.contains("triggers")); + fn embedded_catalog_page_registers_every_page() { + for id in ["functions", "triggers", "fleet"] { + 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] diff --git a/console/ui/catalog-page.tsx b/console/ui/catalog-page.tsx index 838d745ae..7e7959301 100644 --- a/console/ui/catalog-page.tsx +++ b/console/ui/catalog-page.tsx @@ -5,11 +5,21 @@ * 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: + * Three contributions, all reading engine-level data no single worker owns: * - * - src/catalog/FunctionsPage — every registered function (#/ext/functions) - * - src/catalog/TriggersPage — trigger types and their live bindings + * - 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) + * - src/catalog/WorkersPage — the connected fleet, with each worker's + * functions, trigger types, bindings, and + * process metrics (#/ext/fleet) + * + * All three run off engine signals (`engine::functions-available`, + * `engine::workers-available`, `trace`) rather than timers, so they are live + * without polling. * * 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 @@ -19,6 +29,7 @@ import type { Host } from '@iii-dev/console-ui' import { FunctionsPage } from './src/catalog/FunctionsPage' import { TriggersPage } from './src/catalog/TriggersPage' +import { WorkersPage } from './src/catalog/WorkersPage' export default function setup(host: Host) { host.pages.register({ @@ -32,4 +43,14 @@ export default function setup(host: Host) { title: 'triggers', render: () => , }) + + // `fleet`, not `workers`: the console SPA still owns a native Workers tab, + // and two nav entries with the same label would be a coin flip for the + // operator. This page is the deeper read, and the native one can retire + // once it is. + host.pages.register({ + id: 'fleet', + title: 'fleet', + render: () => , + }) } diff --git a/console/ui/src/catalog/ActivityFeed.tsx b/console/ui/src/catalog/ActivityFeed.tsx new file mode 100644 index 000000000..6644103b5 --- /dev/null +++ b/console/ui/src/catalog/ActivityFeed.tsx @@ -0,0 +1,203 @@ +/** + * 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 invoke 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) => ( + + setOpen((prev) => (prev === call.spanId ? null : call.spanId)) + } + 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 index 4400da4d8..1381978ac 100644 --- a/console/ui/src/catalog/FunctionsPage.tsx +++ b/console/ui/src/catalog/FunctionsPage.tsx @@ -1,11 +1,16 @@ /** * The Functions page (`#/ext/functions`): every function registered on the - * bus, grouped by namespace, with the detail and invoke panes on the right. + * bus, grouped by namespace, with the detail, schema, invoke and live + * activity panes on the right. * - * Two calls back the page. `engine::functions::list` is the catalogue - * (cheap, one row per function); `engine::functions::info` is fetched only - * for the selected row, because that is where the schemas live and the - * fleet has hundreds of functions. + * 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. * * Internal functions are hidden by default: the console's own per-tab * handlers and every worker's UI plumbing register as internal, and they @@ -23,15 +28,17 @@ import { TabsList, TabsTrigger, } from '@iii-dev/console-ui' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { ActivityFeed } from './ActivityFeed' import { type FunctionSummary, functionInfo, listFunctions, - useFleetChanges, + useLiveSignals, useResource, } from './engine' import { InvokePanel } from './InvokePanel' +import { SchemaTable } from './SchemaTable' import { compareGroups, namespaceOf, pretty } from './schema' import { CatalogHead, @@ -42,6 +49,7 @@ import { DetailHead, ErrorNote, GroupHeader, + LiveDot, Note, useGroupToggle, } from './widgets' @@ -60,7 +68,24 @@ export function FunctionsPage({ host }: { host: Host }) { [host, showInternal], ) const functions = useResource(load) - useFleetChanges(host, functions.reload) + useLiveSignals(host, ['engine::functions-available'], functions.reload) + + // 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.data) return + const ids = new Set(functions.data.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.data]) const groups = useMemo(() => { const needle = search.trim().toLowerCase() @@ -103,6 +128,7 @@ export function FunctionsPage({ host }: { host: Host }) { onRefresh={functions.reload} loading={functions.loading} > + + {invalid ? ( {invalid} ) : null} @@ -106,6 +174,7 @@ export function InvokePanel({
) : null} + {outcome?.error ? (
{outcome.error}
) : null} @@ -116,6 +185,42 @@ export function InvokePanel({ wrap /> ) : null} + + {attempts.length > 1 ? ( +
+ this session + {attempts.slice(1).map((attempt) => ( + + ))} +
+ ) : null} + + ) } + +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/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 index 1327a2b43..ee0ec4cdc 100644 --- a/console/ui/src/catalog/TriggersPage.tsx +++ b/console/ui/src/catalog/TriggersPage.tsx @@ -37,7 +37,7 @@ import { type RegisteredTrigger, type TriggerTypeSummary, triggerTypeInfo, - useFleetChanges, + useLiveSignals, useResource, } from './engine' import { HttpTester } from './HttpTester' @@ -95,7 +95,13 @@ export function TriggersPage({ host }: { host: Host }) { return { types, bindings, functions } }, [host, showInternal]) const catalog = useResource(loadCatalog) - useFleetChanges(host, catalog.reload) + // 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( diff --git a/console/ui/src/catalog/WorkersPage.tsx b/console/ui/src/catalog/WorkersPage.tsx new file mode 100644 index 000000000..b0e9fe7c2 --- /dev/null +++ b/console/ui/src/catalog/WorkersPage.tsx @@ -0,0 +1,360 @@ +/** + * The Workers page (`#/ext/fleet`): who is connected, what each one brought + * with it, and how it is holding up. + * + * Live on `engine::workers-available` (connect/disconnect) and + * `engine::functions-available` (a worker's surface changing under it), so a + * worker that dies goes grey here without a refresh and one that reconnects + * flashes back in. + * + * The detail pane answers the question the console has been splitting across + * three pages: this worker's functions, the trigger types it publishes, and + * the live bindings pointing into it, all from one `engine::workers::info` + * call, with its reported process metrics on top. + */ + +import { EmptyState, type Host } from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + listWorkers, + useLiveSignals, + useResource, + type WorkerRow, + workerInfo, +} from './engine' +import { summarize } from './trigger-kinds' +import { + CatalogHead, + CatalogRow, + CatalogShell, + Chip, + CopyButton, + DetailHead, + ErrorNote, + GroupHeader, + LiveDot, + Note, + StatTile, + useGroupToggle, +} from './widgets' + +const DEAD = new Set(['disconnected', 'stopped', 'failed', 'error']) + +function uptime(sinceMs: number, now: number): string { + if (!sinceMs) return 'unknown' + const seconds = Math.max(0, Math.round((now - sinceMs) / 1000)) + if (seconds < 60) return `${seconds}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m` + const hours = Math.floor(minutes / 60) + return hours < 48 + ? `${hours}h ${minutes % 60}m` + : `${Math.floor(hours / 24)}d` +} + +function bytes(value: unknown): string | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null + const mb = value / (1024 * 1024) + return mb >= 1024 ? `${(mb / 1024).toFixed(2)} GB` : `${mb.toFixed(1)} MB` +} + +export function WorkersPage({ host }: { host: Host }) { + const [search, setSearch] = useState('') + const [selected, setSelected] = useState(null) + const groupState = useGroupToggle(() => true) + + const load = useCallback(() => listWorkers(host), [host]) + const workers = useResource(load) + useLiveSignals( + host, + ['engine::workers-available', 'engine::functions-available'], + workers.reload, + ) + + // Workers that connected on the last tick flash, the same signal the + // functions list uses for new registrations. + const [arrived, setArrived] = useState>(new Set()) + const seenRef = useRef | null>(null) + useEffect(() => { + if (!workers.data) return + const ids = new Set(workers.data.map((w) => w.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) + }, [workers.data]) + + const groups = useMemo(() => { + const needle = search.trim().toLowerCase() + const matched = (workers.data ?? []).filter( + (w) => + !needle || + w.name.toLowerCase().includes(needle) || + (w.runtime ?? '').toLowerCase().includes(needle) || + w.status.toLowerCase().includes(needle), + ) + const live = matched.filter((w) => !DEAD.has(w.status.toLowerCase())) + const gone = matched.filter((w) => DEAD.has(w.status.toLowerCase())) + const byName = (a: WorkerRow, b: WorkerRow) => a.name.localeCompare(b.name) + return [ + { label: 'connected', items: live.sort(byName) }, + { label: 'gone', items: gone.sort(byName) }, + ].filter((g) => g.items.length > 0) + }, [workers.data, search]) + + const now = Date.now() + const total = groups.reduce((n, g) => n + g.items.length, 0) + const functionTotal = (workers.data ?? []).reduce( + (n, w) => n + w.functionCount, + 0, + ) + const busy = (workers.data ?? []).reduce((n, w) => n + w.activeInvocations, 0) + + return ( + + + + } + list={ + workers.error ? ( + + ) : workers.data === null ? ( + loading workers… + ) : total === 0 ? ( + + ) : ( + groups.map((group) => ( +
+ groupState.toggle(group.label)} + /> + {groupState.isOpen(group.label) + ? group.items.map((worker) => ( + + setSelected((prev) => + prev === worker.name ? null : worker.name, + ) + } + /> + )) + : null} +
+ )) + ) + } + detail={ + selected ? ( + setSelected(null)} + /> + ) : null + } + /> + ) +} + +function WorkerDetailPane({ + host, + name, + onClose, +}: { + host: Host + name: string + onClose: () => void +}) { + const load = useCallback(() => workerInfo(host, name), [host, name]) + const detail = useResource(load) + useLiveSignals( + host, + ['engine::workers-available', 'engine::functions-available'], + detail.reload, + ) + + if (detail.error) { + return ( + <> + + + + ) + } + if (!detail.data) { + return ( + <> + + loading worker… + + ) + } + + const { worker, metrics, functions, triggerTypes, bindings } = detail.data + const heap = bytes(metrics?.memory_heap_used) + const rss = bytes(metrics?.memory_rss) + const cpu = + typeof metrics?.cpu_percent === 'number' + ? `${metrics.cpu_percent.toFixed(1)}%` + : null + const lag = + typeof metrics?.event_loop_lag_ms === 'number' + ? `${metrics.event_loop_lag_ms.toFixed(1)}ms` + : null + + return ( + <> + + + {worker.runtime ? : null} + {worker.version ? : null} + {worker.tag ? : null} + + } + onClose={onClose} + > + + + +
+
+ + + 0 ? 'ok' : undefined} + /> + {heap ? ( + + ) : null} + {cpu ? : null} + {lag ? : null} +
+ +
+ {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} +
+ )) + )} +
+ +
+ {bindings.length === 0 ? ( + nothing is bound to this worker's functions. + ) : ( + bindings.map((binding) => ( +
+
+ +
+ + {binding.function_id} + +
+ )) + )} +
+
+ + ) +} + +function Section({ + title, + children, +}: { + title: string + children: React.ReactNode +}) { + return ( +
+ {title} + {children} +
+ ) +} diff --git a/console/ui/src/catalog/engine.ts b/console/ui/src/catalog/engine.ts index 702e100a4..c9f77134b 100644 --- a/console/ui/src/catalog/engine.ts +++ b/console/ui/src/catalog/engine.ts @@ -232,22 +232,46 @@ export async function invoke( let hubSeq = 0 /** - * Reload when the worker fleet changes, instead of polling. + * The engine's own catalogue signals. Both are internal trigger types the + * engine publishes itself, which is why the pages never poll: * - * The engine publishes no "function registered" event, so the closest true - * signal is the `worker` trigger type (worker manager add/remove, every - * lifecycle stage) — the case where the catalogue actually changes under an - * open tab. Bursts are debounced; everything else is the page's refresh - * control. The binding filters on BOTH `operations` and `stages`: omitting - * either matches no events. + * - `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 function useFleetChanges(host: Host, reload: () => void) { - const reloadRef = useRef(reload) - reloadRef.current = reload +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::fleet-${hubSeq}` + return `iii::console-catalog::live-${hubSeq}` }, []) + const key = signals.join(',') useEffect(() => { let timer: number | null = null @@ -255,37 +279,215 @@ export function useFleetChanges(host: Host, reload: () => void) { if (timer !== null) window.clearTimeout(timer) timer = window.setTimeout(() => { timer = null - reloadRef.current() - }, 400) + tickRef.current() + }, debounceMs) } - let offHandler: (() => void) | undefined - let offTrigger: (() => void) | undefined - try { - offHandler = host.iii.on(handlerId, schedule) - offTrigger = host.iii.registerTrigger({ - type: 'worker', - function_id: `${handlerId}::${host.iii.browserId}`, - config: { - operations: ['add', 'remove'], - stages: ['started', 'downloading', 'downloaded', 'done', 'failed'], - }, + 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 + } }) - } catch { - // No `worker` trigger type on this engine: the refresh control stands - // in, the page still works. - offTrigger?.() - offHandler?.() - offTrigger = undefined - offHandler = undefined - } + .filter((off): off is () => void => off !== null) return () => { if (timer !== null) window.clearTimeout(timer) - offTrigger?.() - offHandler?.() + 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] + } } - }, [host, handlerId]) + } + 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, + durationMs: Number.isFinite(end) ? (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 WorkerRow { + id: string + name: string + status: string + runtime?: string | null + version?: string | null + functionCount: number + activeInvocations: number + connectedAtMs: number + tag?: string | null +} + +export async function listWorkers(host: Host): Promise { + const out = await host.iii.trigger('engine::workers::list', {}) + return rows(out, 'workers') + .map((row): WorkerRow | null => { + if (!isRecord(row)) return null + const id = str(row.id) + if (!id) return null + return { + id, + name: str(row.name) ?? id.slice(0, 8), + status: str(row.status) ?? 'unknown', + runtime: str(row.runtime), + version: str(row.version), + functionCount: + typeof row.function_count === 'number' ? row.function_count : 0, + activeInvocations: + typeof row.active_invocations === 'number' + ? row.active_invocations + : 0, + connectedAtMs: + typeof row.connected_at_ms === 'number' ? row.connected_at_ms : 0, + tag: str(row.tag), + } + }) + .filter((w): w is WorkerRow => w !== null) +} + +export interface WorkerDetail { + worker: WorkerRow + metrics: Record | null + functions: FunctionSummary[] + triggerTypes: TriggerTypeSummary[] + bindings: RegisteredTrigger[] +} + +/** Everything the engine knows about one worker, in a single call. */ +export async function workerInfo( + host: Host, + name: string, +): Promise { + const out = await host.iii.trigger('engine::workers::info', { name }) + if (!isRecord(out)) + throw new Error('engine::workers::info returned no detail') + const workerRow = isRecord(out.worker) ? out.worker : {} + const metrics = isRecord(workerRow.latest_metrics) + ? workerRow.latest_metrics + : null + return { + worker: { + id: str(workerRow.id) ?? name, + name: str(workerRow.name) ?? name, + status: str(workerRow.status) ?? 'unknown', + runtime: str(workerRow.runtime), + version: str(workerRow.version), + functionCount: + typeof workerRow.function_count === 'number' + ? workerRow.function_count + : 0, + activeInvocations: + typeof workerRow.active_invocations === 'number' + ? workerRow.active_invocations + : 0, + connectedAtMs: + typeof workerRow.connected_at_ms === 'number' + ? workerRow.connected_at_ms + : 0, + tag: str(workerRow.tag), + }, + metrics, + functions: rows(out, 'functions') + .map(functionSummary) + .filter((f): f is FunctionSummary => f !== null), + triggerTypes: rows(out, 'trigger_types') + .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) ?? name, + description: description(row.description), + } + }) + .filter((t): t is TriggerTypeSummary => t !== null), + bindings: 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) ?? name, + config: row.config, + config_summary: str(row.config_summary), + } + }) + .filter((t): t is RegisteredTrigger => t !== null), + } } export interface Resource { diff --git a/console/ui/src/catalog/widgets.tsx b/console/ui/src/catalog/widgets.tsx index ca420902e..d28807373 100644 --- a/console/ui/src/catalog/widgets.tsx +++ b/console/ui/src/catalog/widgets.tsx @@ -163,6 +163,20 @@ export function StatTile({ ) } +/** + * 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, @@ -229,16 +243,19 @@ export function CatalogRow({ secondary, selected, onClick, + flash, }: { primary: ReactNode secondary?: ReactNode selected: boolean onClick: () => void + /** Highlight once: this row arrived on the last live tick. */ + flash?: boolean }) { return ( ) : ( - - - {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 ? ( + + + + + + ) : 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"}', + }, + ], +} From eeb6e34bc22a96e33dcb53e0fe8d2e6f042f98f1 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 6 Aug 2026 15:42:49 +0100 Subject: [PATCH 05/10] (MOT-3677) fix(console): let the expanded worker surface wrap The workers table wrapper is `whitespace-nowrap` so the columns never wrap mid-row, and the expanded cell inherited it: every function description ran straight over the next column. The surface is prose, so it opts back out. Bindings also stack the trigger type above the function id now. Sharing one line squeezed two long strings into a column narrow enough to break them mid-word (`harness::on-s / ession-delete / d`), and `break-words` replaces `break-all` so ids only break when they genuinely have to. Columns go two-up on medium and three-up on extra-large rather than jumping straight to three, which is what made them narrow enough to notice. --- .../Workers/components/WorkerSurface.tsx | 21 +++++++++++-------- .../pages/Workers/components/WorkersTable.tsx | 4 +++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/console/web/src/pages/Workers/components/WorkerSurface.tsx b/console/web/src/pages/Workers/components/WorkerSurface.tsx index 98fc7bf07..71e610f92 100644 --- a/console/web/src/pages/Workers/components/WorkerSurface.tsx +++ b/console/web/src/pages/Workers/components/WorkerSurface.tsx @@ -56,14 +56,14 @@ export function WorkerSurface({ name }: WorkerSurfaceProps) { const bindings = query.data.registered_triggers return ( -
+
{functions.length === 0 ? ( this worker registered no functions. ) : ( functions.map((fn) => (
-
+
{fn.function_id}
{fn.description ? ( @@ -82,7 +82,7 @@ export function WorkerSurface({ name }: WorkerSurfaceProps) { ) : ( triggerTypes.map((type) => (
-
+
{type.id}
{type.description ? ( @@ -100,15 +100,18 @@ export function WorkerSurface({ name }: WorkerSurfaceProps) { nothing is bound to this worker's functions. ) : ( bindings.map((binding) => ( -
-
+ // Stacked, not side by side: a trigger type and a function id are + // both long, and sharing a line squeezes both into mid-word breaks + // in a column this narrow. +
+
{binding.trigger_type} - - {binding.function_id} - +
+
+ {binding.function_id}
{binding.config_summary && binding.config_summary !== '{}' ? ( -

+

{binding.config_summary}

) : null} diff --git a/console/web/src/pages/Workers/components/WorkersTable.tsx b/console/web/src/pages/Workers/components/WorkersTable.tsx index 55a084b61..f71520ef3 100644 --- a/console/web/src/pages/Workers/components/WorkersTable.tsx +++ b/console/web/src/pages/Workers/components/WorkersTable.tsx @@ -272,7 +272,9 @@ function WorkerTableRow({ {expanded ? ( - + {/* The table wrapper is `whitespace-nowrap` so the columns never + wrap mid-row; the surface below is prose, so it opts back out. */} + From c9e9f8b4e6eb3e69fa2ac69440cb66341649e75b Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 6 Aug 2026 16:15:48 +0100 Subject: [PATCH 06/10] (MOT-4356) feat(console): page-level live activity and an operator-first triggers view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pages were live in the narrow sense — one selected function's feed — while the page itself sat still during a harness turn. Now both pages ride the `iii:devtools:all-spans` stream (the traces masthead's own feed, one subscription per page): - a NOW strip under the head shows the last few executions as they happen, newest sliding in ease-out, `running` until the completed span replaces the in-flight one by identity. Clicking an entry selects that function. - the exact row whose function ran pulses, and carries a quiet right-aligned `8.0ms · 11s ago` that updates with traffic. - trigger groups order by last-fired, then registration count, then name — during a turn the page reads as what the agent is doing, not as an alphabetical index that opens on `configuration`. The triggers view also stops burying the operator. Per-tab delivery handlers (`iii::` prefixed), injected-UI assets, `::ui-content` functions, and configuration hot-reload hooks are real registrations but never what the page is opened FOR — they fold into one collapsed `plumbing` section at the bottom (16 of the old top-of-page rows were exactly these). Session deliveries summarize as `session …` instead of raw config JSON, and a detail title can no longer be a `{…}` one-liner. In-flight spans stream with a null end; Number(null) is 0, which read as a negative duration. Only an end after the start counts, and until then the entry says `running`. --- console/ui/src/catalog/FunctionsPage.tsx | 18 ++- console/ui/src/catalog/TriggersPage.tsx | 185 +++++++++++++++++------ console/ui/src/catalog/engine.ts | 83 ++++++++++ console/ui/src/catalog/live.tsx | 175 +++++++++++++++++++++ console/ui/src/catalog/trigger-kinds.ts | 40 ++++- console/ui/src/catalog/widgets.tsx | 18 ++- console/ui/styles.css | 101 +++++++++++++ 7 files changed, 567 insertions(+), 53 deletions(-) create mode 100644 console/ui/src/catalog/live.tsx diff --git a/console/ui/src/catalog/FunctionsPage.tsx b/console/ui/src/catalog/FunctionsPage.tsx index 1381978ac..13a011fbc 100644 --- a/console/ui/src/catalog/FunctionsPage.tsx +++ b/console/ui/src/catalog/FunctionsPage.tsx @@ -38,6 +38,7 @@ import { useResource, } from './engine' import { InvokePanel } from './InvokePanel' +import { LastCallMeta, NowStrip, useLiveActivity } from './live' import { SchemaTable } from './SchemaTable' import { compareGroups, namespaceOf, pretty } from './schema' import { @@ -69,6 +70,7 @@ export function FunctionsPage({ host }: { host: Host }) { ) const functions = useResource(load) useLiveSignals(host, ['engine::functions-available'], functions.reload) + const activity = useLiveActivity(host) // 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". @@ -127,6 +129,12 @@ export function FunctionsPage({ host }: { host: Host }) { searchPlaceholder="search functions, workers, descriptions…" onRefresh={functions.reload} loading={functions.loading} + below={ + setSelected(functionId)} + /> + } > + ))} +
+
+ ) +} + +/** 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/trigger-kinds.ts b/console/ui/src/catalog/trigger-kinds.ts index 55c7fbb63..cb9e11b1d 100644 --- a/console/ui/src/catalog/trigger-kinds.ts +++ b/console/ui/src/catalog/trigger-kinds.ts @@ -1,9 +1,9 @@ /** - * What a binding IS, read from its trigger type and config. + * What a registered trigger IS, read from its type and config. * - * A trigger row is only useful if it says the thing the operator recognizes: - * an http binding is `GET /users/:id`, a cron binding is its schedule, a - * queue subscriber is its topic. This module owns that reading, one entry per + * 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. * @@ -115,7 +115,7 @@ export function cronExpression(trigger: RegisteredTrigger): string | undefined { } /** - * The one line that names a binding in the list: what it listens to, in the + * 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. */ @@ -151,11 +151,39 @@ export function summarize(trigger: RegisteredTrigger): string { 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 - if (summary && summary !== '{}') return 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 diff --git a/console/ui/src/catalog/widgets.tsx b/console/ui/src/catalog/widgets.tsx index d28807373..7a214500c 100644 --- a/console/ui/src/catalog/widgets.tsx +++ b/console/ui/src/catalog/widgets.tsx @@ -36,17 +36,23 @@ export function useGroupToggle(defaultOpen: (id: string) => boolean) { export function CatalogShell({ head, list, + footer, detail, }: { head: ReactNode list: ReactNode + /** Rendered after the list inside the same scroll pane (plumbing section). */ + footer?: ReactNode detail: ReactNode | null }) { return (
{head}
-
{list}
+
+ {list} + {footer} +
{detail ?
{detail}
: null}
@@ -241,15 +247,18 @@ export function GroupHeader({ export function CatalogRow({ primary, secondary, + meta, selected, onClick, flash, }: { 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 arrived on the last live tick. */ + /** Highlight once: this row's function just ran (or the row just arrived). */ flash?: boolean }) { return ( @@ -259,7 +268,10 @@ export function CatalogRow({ data-selected={selected} onClick={onClick} > - {primary} + + {primary} + {meta} + {secondary ? {secondary} : null} ) diff --git a/console/ui/styles.css b/console/ui/styles.css index 9ab662487..ec80a95d8 100644 --- a/console/ui/styles.css +++ b/console/ui/styles.css @@ -864,3 +864,104 @@ 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: 1px solid var(--color-rule); + border-radius: 999px; + background: transparent; + 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 { + border-color: var(--color-ring); +} +[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-nowentry { + animation: none; + } + [data-iii-ui="console"] .console-catalog-row.flash { + animation: none; + } +} + +[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-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: 20px; + padding-top: 10px; + border-top: 1px dashed var(--color-rule); + opacity: 0.75; +} From df576c8ae1032fc9f0e259a9cf72196ef043c9d2 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Thu, 6 Aug 2026 21:27:01 +0100 Subject: [PATCH 07/10] (MOT-4355) fix(console): id-only search, clearer cli copy label, http worker config fallback Searching config surfaced harness::triggers::list because its DESCRIPTION mentions config; search now matches function/type ids and worker names only. The invoke panel's copy button says copy iii command and explains itself. The http tester resolves its base URL from the http worker's configuration entry first, falling back to the deprecated iii-http name. --- console/ui/src/catalog/FunctionsPage.tsx | 8 ++-- console/ui/src/catalog/HttpTester.tsx | 25 +++++++--- console/ui/src/catalog/InvokePanel.tsx | 3 +- console/ui/src/catalog/widgets.tsx | 3 ++ .../Workers/components/WorkerSurface.tsx | 46 +++++-------------- 5 files changed, 39 insertions(+), 46 deletions(-) diff --git a/console/ui/src/catalog/FunctionsPage.tsx b/console/ui/src/catalog/FunctionsPage.tsx index 13a011fbc..33d7648ea 100644 --- a/console/ui/src/catalog/FunctionsPage.tsx +++ b/console/ui/src/catalog/FunctionsPage.tsx @@ -91,12 +91,14 @@ export function FunctionsPage({ host }: { host: Host }) { 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.data ?? []).filter((fn) => { if (!needle) return true return ( fn.function_id.toLowerCase().includes(needle) || - fn.worker_name.toLowerCase().includes(needle) || - (fn.description ?? '').toLowerCase().includes(needle) + fn.worker_name.toLowerCase().includes(needle) ) }) const byGroup = new Map() @@ -126,7 +128,7 @@ export function FunctionsPage({ host }: { host: Host }) { } search={search} onSearch={setSearch} - searchPlaceholder="search functions, workers, descriptions…" + searchPlaceholder="search function ids or workers…" onRefresh={functions.reload} loading={functions.loading} below={ diff --git a/console/ui/src/catalog/HttpTester.tsx b/console/ui/src/catalog/HttpTester.tsx index 14216d1d2..cefcd891e 100644 --- a/console/ui/src/catalog/HttpTester.tsx +++ b/console/ui/src/catalog/HttpTester.tsx @@ -40,12 +40,25 @@ function isRecord(value: unknown): value is Record { * loaded from. */ async function readEndpoint(host: Host): Promise { - const entry = await host.iii.trigger('configuration::get', { id: 'iii-http' }) - const value = isRecord(entry) ? entry.value : null - if (!isRecord(value)) throw new Error('iii-http has no configuration value') + // `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('iii-http config carries no port') + 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' @@ -163,9 +176,7 @@ export function HttpTester({ } if (endpoint.error) { - return ( - - ) + return } if (!endpoint.data) return reading the http worker's address… diff --git a/console/ui/src/catalog/InvokePanel.tsx b/console/ui/src/catalog/InvokePanel.tsx index 673f1c6b3..35732d7ee 100644 --- a/console/ui/src/catalog/InvokePanel.tsx +++ b/console/ui/src/catalog/InvokePanel.tsx @@ -159,7 +159,8 @@ export function InvokePanel({ {invalid ? ( {invalid} diff --git a/console/ui/src/catalog/widgets.tsx b/console/ui/src/catalog/widgets.tsx index 7a214500c..1ef841ef1 100644 --- a/console/ui/src/catalog/widgets.tsx +++ b/console/ui/src/catalog/widgets.tsx @@ -187,15 +187,18 @@ export function LiveDot() { export function CopyButton({ value, label = 'copy', + title, }: { value: string label?: string + title?: string }) { const [copied, setCopied] = useState(false) return ( -
- + + {children} + + + } /> - {below} -
+
+ + {below} +
+ ) } diff --git a/console/ui/styles.css b/console/ui/styles.css index ec80a95d8..2f8f6eb23 100644 --- a/console/ui/styles.css +++ b/console/ui/styles.css @@ -53,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; @@ -65,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); @@ -73,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; @@ -161,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; @@ -196,8 +195,7 @@ display: flex; flex-direction: column; gap: 10px; - padding: 16px 20px; - border-bottom: 1px solid var(--color-rule); + padding: 12px 16px; } [data-iii-ui="console"] .console-catalog-head-row { display: flex; @@ -232,8 +230,8 @@ width: 46%; min-width: 340px; overflow-y: auto; - border-left: 1px solid var(--color-rule); - background: var(--color-panel); + background: var(--color-panel-raised, var(--color-panel)); + border-radius: 6px; } /* Below this the two panes are both too narrow to read, so they stack. * The console page pane is roughly 790px with the chat dock open. */ @@ -244,8 +242,6 @@ [data-iii-ui="console"] .console-catalog-detail { width: 100%; min-width: 0; - border-left: 0; - border-top: 1px solid var(--color-rule); } } @@ -293,22 +289,24 @@ padding: 8px 10px; text-align: left; background: transparent; - border: 1px solid 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-paper-2); + background: var(--color-surface-hover, var(--color-paper-2)); } [data-iii-ui="console"] .console-catalog-row:focus-visible { - outline: 2px solid var(--color-ring); + outline: 2px solid var(--color-rule-focus, var(--color-ring)); outline-offset: 1px; } [data-iii-ui="console"] .console-catalog-row[data-selected="true"] { - border-color: var(--color-accent); - background: color-mix(in srgb, var(--color-accent) 8%, transparent); + background: var( + --color-surface-selected, + color-mix(in srgb, var(--color-accent) 10%, transparent) + ); } [data-iii-ui="console"] .console-catalog-row .primary { font-size: 13px; @@ -332,8 +330,7 @@ flex-direction: column; gap: 8px; padding: 14px 16px; - background: var(--color-panel); - border-bottom: 1px solid var(--color-rule); + background: var(--color-panel-raised, var(--color-panel)); } [data-iii-ui="console"] .console-catalog-detail-title { font-size: 13px; @@ -352,10 +349,10 @@ color: var(--color-ink-faint); } [data-iii-ui="console"] .console-catalog-chip { - border: 1px solid var(--color-rule); + background: var(--color-surface, rgba(0, 0, 0, 0.05)); border-radius: 4px; font-size: 11.5px; - padding: 1px 8px; + padding: 2px 8px; white-space: nowrap; } [data-iii-ui="console"] .console-catalog-chip .k { @@ -379,7 +376,7 @@ padding-top: 10px; } [data-iii-ui="console"] .console-catalog-editor { - border: 1px solid var(--color-rule); + background: var(--color-surface, rgba(0, 0, 0, 0.04)); border-radius: 6px; min-height: 120px; max-height: 320px; @@ -401,9 +398,8 @@ [data-iii-ui="console"] .console-catalog-result, [data-iii-ui="console"] .console-catalog-json { display: block; - border: 1px solid var(--color-rule); + background: var(--color-surface, rgba(0, 0, 0, 0.04)); border-radius: 6px; - background: var(--color-bg); padding: 10px 12px; font-size: 12px; line-height: 1.5; @@ -416,11 +412,11 @@ display: flex; flex-direction: column; gap: 6px; - padding: 10px 0; - border-bottom: 1px solid var(--color-rule-2); + padding: 8px 10px; + border-radius: 6px; } -[data-iii-ui="console"] .console-catalog-binding:last-child { - border-bottom: 0; +[data-iii-ui="console"] .console-catalog-binding:nth-child(odd) { + background: var(--color-surface, rgba(0, 0, 0, 0.03)); } [data-iii-ui="console"] .console-catalog-binding-head { display: flex; @@ -435,7 +431,7 @@ color: var(--color-ink-faint); } [data-iii-ui="console"] .console-catalog-error { - border: 1px solid var(--color-alert); + background: var(--color-alert-muted, rgba(255, 0, 38, 0.08)); border-radius: 6px; color: var(--color-alert); padding: 10px 12px; @@ -457,34 +453,30 @@ [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); - border-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); - border-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); - border-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); - border-color: var(--color-alert); } [data-iii-ui="console"] .console-catalog-tag { - border: 1px solid var(--color-rule); + background: var(--color-surface, rgba(0, 0, 0, 0.05)); border-radius: 4px; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; - padding: 0 6px; + padding: 1px 6px; white-space: nowrap; } @@ -494,23 +486,22 @@ flex-wrap: wrap; } [data-iii-ui="console"] .console-catalog-filter { - border: 1px solid var(--color-rule); + border: 0; border-radius: 999px; - background: transparent; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); color: var(--color-ink-faint); font: inherit; font-size: 11px; - padding: 2px 10px; + padding: 3px 10px; cursor: pointer; } [data-iii-ui="console"] .console-catalog-filter:hover { color: var(--color-ink); - border-color: var(--color-ring); + 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-fg); background: var(--color-accent); - border-color: var(--color-accent); } [data-iii-ui="console"] .console-catalog-filter .count { font-variant-numeric: tabular-nums; @@ -529,7 +520,7 @@ display: flex; flex-direction: column; gap: 2px; - border: 1px solid var(--color-rule); + background: var(--color-surface, rgba(0, 0, 0, 0.05)); border-radius: 6px; padding: 8px 10px; } @@ -554,7 +545,7 @@ display: flex; flex-direction: column; gap: 4px; - border: 1px solid var(--color-warn); + background: var(--color-warn-muted, rgba(168, 122, 0, 0.12)); border-radius: 6px; padding: 10px 12px; margin-top: 12px; @@ -572,7 +563,7 @@ display: flex; align-items: center; gap: 8px; - border: 1px solid var(--color-accent); + background: var(--color-accent-muted, rgba(184, 66, 15, 0.1)); border-radius: 6px; padding: 8px 10px; overflow: hidden; @@ -634,9 +625,9 @@ display: flex; flex-direction: column; gap: 4px; - padding: 10px 0; - border-top: 1px solid var(--color-rule-2); - border-bottom: 1px solid var(--color-rule-2); + 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 { @@ -717,12 +708,13 @@ color: var(--color-ink-ghost); font-weight: 500; padding: 0 8px 6px 0; - border-bottom: 1px solid var(--color-rule); } [data-iii-ui="console"] .console-catalog-schema td { vertical-align: top; padding: 7px 8px 7px 0; - border-bottom: 1px solid var(--color-rule-2); +} +[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; @@ -765,14 +757,12 @@ font-size: 11px; color: var(--color-ink-ghost); padding-bottom: 6px; - border-bottom: 1px solid var(--color-rule); } [data-iii-ui="console"] .console-catalog-call { - border: 1px solid transparent; border-radius: 6px; } [data-iii-ui="console"] .console-catalog-call[data-open="true"] { - border-color: var(--color-rule); + background: var(--color-surface, rgba(0, 0, 0, 0.04)); } [data-iii-ui="console"] .console-catalog-call-head { display: flex; @@ -789,7 +779,7 @@ cursor: pointer; } [data-iii-ui="console"] .console-catalog-call-head:hover { - background: var(--color-paper-2); + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); } [data-iii-ui="console"] .console-catalog-call-head .dot { width: 6px; @@ -831,9 +821,9 @@ gap: 10px; width: 100%; padding: 5px 8px; - border: 1px solid var(--color-rule-2); + border: 0; border-radius: 6px; - background: transparent; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); color: var(--color-ink-faint); font: inherit; font-size: 11.5px; @@ -841,7 +831,7 @@ cursor: pointer; } [data-iii-ui="console"] .console-catalog-attempt:hover { - border-color: var(--color-ring); + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); color: var(--color-ink); } [data-iii-ui="console"] .console-catalog-attempt .dot { @@ -896,9 +886,9 @@ align-items: center; gap: 6px; flex: none; - border: 1px solid var(--color-rule); + border: 0; border-radius: 999px; - background: transparent; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); color: var(--color-ink); font: inherit; font-size: 11.5px; @@ -908,7 +898,7 @@ animation: console-catalog-nowentry-in 220ms cubic-bezier(0.23, 1, 0.32, 1); } [data-iii-ui="console"] .console-catalog-nowentry:hover { - border-color: var(--color-ring); + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); } [data-iii-ui="console"] .console-catalog-nowentry .fn { max-width: 30ch; @@ -960,8 +950,14 @@ /* --- plumbing section: present, quiet, out of the way ----------------- */ [data-iii-ui="console"] .console-catalog-plumbing { - margin-top: 20px; - padding-top: 10px; - border-top: 1px dashed var(--color-rule); - opacity: 0.75; + 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; } From ad70b4a0a75ec212e3767030b4d076177e526cb1 Mon Sep 17 00:00:00 2001 From: Rohit Ghumare Date: Fri, 7 Aug 2026 13:47:49 +0100 Subject: [PATCH 09/10] (MOT-4354) fix(console): review follow-ups across the catalog pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ten findings from review, all verified against the code first. The two that mattered: listCalls still divided a null end_time into a negative duration for in-flight spans — the same guard spansFromFrame already has (`end > start`, else 0) now applies there too. And two form components reset themselves on OBJECT IDENTITY (HttpTester on the binding, InvokePanel on the request schema); live catalog refreshes rebuild those objects every tick, so an open form wiped itself mid-typing. Both now key their reset effects on the values that name the selection (method/path/params, function id + schema content). The rest: QueuePublish discards an in-flight publish result if the topic changed under it (and clears `sending` on switch); the CLI copy string shell-escapes embedded single quotes; CopyButton only reports "copied" after the clipboard write resolves; the registered-trigger config tab parses `config_summary` (a JSON string on the wire) so the fallback renders structured, not one quoted line; activity rows key and open on a collision-proof row id instead of a possibly-empty span id; the workers-table name cell is a span (a div is invalid inside the expand button) and the toggle points at its detail row via aria-controls; the functions empty-state no longer claims descriptions are searched — that search was removed. --- console/ui/src/catalog/ActivityFeed.tsx | 27 ++++++++++--------- console/ui/src/catalog/FunctionsPage.tsx | 2 +- console/ui/src/catalog/HttpTester.tsx | 9 +++++-- console/ui/src/catalog/InvokePanel.tsx | 13 ++++++--- console/ui/src/catalog/QueuePublish.tsx | 13 +++++++-- console/ui/src/catalog/TriggersPage.tsx | 15 ++++++++++- console/ui/src/catalog/engine.ts | 5 +++- console/ui/src/catalog/widgets.tsx | 8 ++++-- .../pages/Workers/components/WorkersTable.tsx | 10 ++++--- 9 files changed, 75 insertions(+), 27 deletions(-) diff --git a/console/ui/src/catalog/ActivityFeed.tsx b/console/ui/src/catalog/ActivityFeed.tsx index 6644103b5..14b88648c 100644 --- a/console/ui/src/catalog/ActivityFeed.tsx +++ b/console/ui/src/catalog/ActivityFeed.tsx @@ -117,18 +117,21 @@ export function ActivityFeed({ {failures} failed
- {calls.data.map((call) => ( - - setOpen((prev) => (prev === call.spanId ? null : call.spanId)) - } - onReplay={onReplay} - /> - ))} + {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} + /> + ) + })}
) } diff --git a/console/ui/src/catalog/FunctionsPage.tsx b/console/ui/src/catalog/FunctionsPage.tsx index 390f2c5af..351127d7a 100644 --- a/console/ui/src/catalog/FunctionsPage.tsx +++ b/console/ui/src/catalog/FunctionsPage.tsx @@ -171,7 +171,7 @@ export function FunctionsPage({ } description={ search.trim() - ? 'no function id, worker, or description contains that text.' + ? 'no function id or worker name contains that text.' : 'workers register their functions on connect — start one and it appears here live.' } /> diff --git a/console/ui/src/catalog/HttpTester.tsx b/console/ui/src/catalog/HttpTester.tsx index 55b61920b..b1b0f471e 100644 --- a/console/ui/src/catalog/HttpTester.tsx +++ b/console/ui/src/catalog/HttpTester.tsx @@ -104,7 +104,11 @@ export function HttpTester({ 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. + // 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, '']))) @@ -112,7 +116,8 @@ export function HttpTester({ setBody('{}') setOutcome(null) setInvalid(null) - }, [binding]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [binding.method, binding.path, paramKey]) const filledPath = binding.params.reduce( (path, name) => diff --git a/console/ui/src/catalog/InvokePanel.tsx b/console/ui/src/catalog/InvokePanel.tsx index 35732d7ee..891c934a4 100644 --- a/console/ui/src/catalog/InvokePanel.tsx +++ b/console/ui/src/catalog/InvokePanel.tsx @@ -62,7 +62,10 @@ function asCliCommand(functionId: string, payload: unknown): string { const args = Object.entries(payload).map(([key, value]) => { const literal = typeof value === 'string' ? value : (JSON.stringify(value) ?? '') - return /[\s"']/.test(literal) ? `${key}='${literal}'` : `${key}=${literal}` + 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(' ')}` } @@ -92,12 +95,16 @@ export function InvokePanel({ 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. + // 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) - }, [functionId, requestSchema]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [functionId, schemaKey]) useEffect(() => { if (!prefill) return diff --git a/console/ui/src/catalog/QueuePublish.tsx b/console/ui/src/catalog/QueuePublish.tsx index c6f5eec23..14a24bee3 100644 --- a/console/ui/src/catalog/QueuePublish.tsx +++ b/console/ui/src/catalog/QueuePublish.tsx @@ -13,7 +13,7 @@ import { type Host, JsonHighlight, } from '@iii-dev/console-ui' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { type InvokeOutcome, invoke } from './engine' import { pretty } from './schema' import { Chip } from './widgets' @@ -25,11 +25,17 @@ export function QueuePublish({ host, topic }: { host: Host; topic: string }) { 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 () => { @@ -43,7 +49,10 @@ export function QueuePublish({ host, topic }: { host: Host; topic: string }) { setInvalid(null) setConfirming(false) setSending(true) - setOutcome(await invoke(host, 'iii::durable::publish', { topic, data })) + const sentTopic = topic + const result = await invoke(host, 'iii::durable::publish', { topic, data }) + if (activeTopic.current !== sentTopic) return + setOutcome(result) setSending(false) } diff --git a/console/ui/src/catalog/TriggersPage.tsx b/console/ui/src/catalog/TriggersPage.tsx index 93269ad09..d07f01585 100644 --- a/console/ui/src/catalog/TriggersPage.tsx +++ b/console/ui/src/catalog/TriggersPage.tsx @@ -494,6 +494,17 @@ function TypeDetailPane({ ) } +/** 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 BindingDetailPane({ host, binding, @@ -595,7 +606,9 @@ function BindingDetailPane({ diff --git a/console/ui/src/catalog/engine.ts b/console/ui/src/catalog/engine.ts index 9aee27108..e8084e8a3 100644 --- a/console/ui/src/catalog/engine.ts +++ b/console/ui/src/catalog/engine.ts @@ -364,7 +364,10 @@ export async function listCalls( traceId: str(span.trace_id) ?? '', functionId, startedAtMs: start / 1e6, - durationMs: Number.isFinite(end) ? (end - start) / 1e6 : 0, + // 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'), diff --git a/console/ui/src/catalog/widgets.tsx b/console/ui/src/catalog/widgets.tsx index 7da08a0f5..7838a5838 100644 --- a/console/ui/src/catalog/widgets.tsx +++ b/console/ui/src/catalog/widgets.tsx @@ -228,8 +228,12 @@ export function CopyButton({ variant="pill" size="sm" title={title} - onClick={() => { - void navigator.clipboard.writeText(value) + onClick={async () => { + try { + await navigator.clipboard.writeText(value) + } catch { + return + } setCopied(true) window.setTimeout(() => setCopied(false), 2000) }} diff --git a/console/web/src/pages/Workers/components/WorkersTable.tsx b/console/web/src/pages/Workers/components/WorkersTable.tsx index f71520ef3..7570fffe8 100644 --- a/console/web/src/pages/Workers/components/WorkersTable.tsx +++ b/console/web/src/pages/Workers/components/WorkersTable.tsx @@ -197,8 +197,10 @@ function WorkerTableRow({ ) + // A span, not a div: this nests inside the expand + } - > - + /> + } + sideTop={ +
+ - +
+ } + sideFooter={ + + {catalog.data === null + ? 'loading functions…' + : search.trim() + ? `showing ${shown} of ${total} functions` + : `${total} total function${total === 1 ? '' : 's'}`} + } list={ - functions.error ? ( - - ) : functions.data === null ? ( - loading functions… + catalog.error ? ( + + ) : catalog.data === null ? ( + ) : shown === 0 ? ( setSearch('') } + : undefined + } /> ) : ( groups.map((group) => (
groupState.toggle(group.label)} /> @@ -189,6 +280,7 @@ export function FunctionsPage({ : group.items.map((fn) => ( } primary={fn.function_id} secondary={fn.description ?? undefined} meta={ @@ -212,27 +304,56 @@ export function FunctionsPage({ )) ) } - detail={ + main={ selected ? ( - setSelected(null)} + runtimeOf={runtimeOf} + lastCall={activity.lastCall.get(selected)} + onBack={() => 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', + }, + ]} /> - ) : null + ) } /> ) } -function FunctionDetailPane({ +function FunctionDocument({ host, functionId, - onClose, + runtimeOf, + lastCall, + onBack, }: { host: Host functionId: string - onClose: () => void + /** Worker name → runtime, from the page-level workers list. */ + runtimeOf: (worker: string) => string | undefined + lastCall?: SpanEvent + onBack: () => void }) { const load = useCallback( () => functionInfo(host, functionId), @@ -242,6 +363,11 @@ function FunctionDetailPane({ 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) => { @@ -249,105 +375,331 @@ function FunctionDetailPane({ 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.data.registered_triggers.length > 0 ? ( - - ) : null} - {detail.data.description ? ( - - {detail.data.description} - - ) : null} + + - ) : null + } + /> + {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 } - onClose={onClose} + wide > - -
- {detail.error ? ( - - ) : detail.data === null ? ( - loading detail… - ) : ( - - - invoke - activity - request - response - - triggers - {detail.data.registered_triggers.length > 0 ? ( - {detail.data.registered_triggers.length} - ) : null} - - - - - - - - - - - - - - - - {detail.data.registered_triggers.length === 0 ? ( - - nothing is bound to this function — it runs only when something - calls it. - - ) : ( - detail.data.registered_triggers.map((trigger) => ( -
-
- - {trigger.id} -
- -
- )) - )} -
-
- )} + {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/InvokePanel.tsx b/console/ui/src/catalog/InvokePanel.tsx index 891c934a4..214b1d108 100644 --- a/console/ui/src/catalog/InvokePanel.tsx +++ b/console/ui/src/catalog/InvokePanel.tsx @@ -25,10 +25,11 @@ import { useEffect, useMemo, useState } from 'react' import { type InvokeOutcome, invoke } from './engine' import { schemaFieldNames } from './SchemaTable' import { pretty, templateFromSchema } from './schema' -import { Chip, CopyButton } from './widgets' +import { CopyButton } from './widgets' interface Attempt { id: number + atMs: number body: string outcome: InvokeOutcome } @@ -74,8 +75,8 @@ export function InvokePanel({ host, functionId, requestSchema, - label = 'invoke', - runningLabel = 'invoking…', + label = 'trigger', + runningLabel = 'triggering…', hint, prefill, }: { @@ -117,7 +118,13 @@ export function InvokePanel({ [requestSchema], ) - const outcome = attempts[0]?.outcome ?? null + const latest = attempts[0] ?? null + const outcome = latest?.outcome ?? null + + const reset = () => { + setBody(templateFromSchema(requestSchema)) + setInvalid(null) + } const run = async () => { let payload: unknown @@ -144,14 +151,32 @@ export function InvokePanel({ setRunning(false) attemptSeq += 1 setAttempts((prev) => [ - { id: attemptSeq, body, outcome: result }, + { 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} ) : null} - {outcome ? ( - - {outcome.ok ? 'ok' : 'error'} · {Math.round(outcome.durationMs)}ms - - ) : null}
- {outcome?.error ? ( -
{outcome.error}
- ) : null} - {outcome?.ok ? ( - + {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 ? ( @@ -214,12 +251,16 @@ export function InvokePanel({ ))}
) : 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) diff --git a/console/ui/src/catalog/TriggersPage.tsx b/console/ui/src/catalog/TriggersPage.tsx index d07f01585..61475e888 100644 --- a/console/ui/src/catalog/TriggersPage.tsx +++ b/console/ui/src/catalog/TriggersPage.tsx @@ -1,13 +1,17 @@ /** - * The Triggers page (`#/ext/triggers`): every trigger type published on the - * bus, each with its live bindings underneath. + * 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. + * 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 @@ -22,12 +26,13 @@ import { EmptyState, type Host, JsonHighlight, + PageHeader, Tabs, TabsContent, TabsList, TabsTrigger, } from '@iii-dev/console-ui' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { describeCron, nextCronRun, untilLabel } from './cron' import { type FunctionSummary, @@ -35,6 +40,7 @@ import { listRegisteredTriggers, listTriggerTypes, type RegisteredTrigger, + type TriggerTypeDetail, type TriggerTypeSummary, triggerTypeInfo, useLiveSignals, @@ -42,8 +48,8 @@ import { } from './engine' import { HttpTester } from './HttpTester' import { InvokePanel } from './InvokePanel' -import { LastCallMeta, NowStrip, useLiveActivity } from './live' import { QueuePublish } from './QueuePublish' +import { SchemaTable } from './SchemaTable' import { pretty } from './schema' import { configChips, @@ -56,16 +62,27 @@ import { summarize, } from './trigger-kinds' import { - CatalogHead, + CatalogListSkeleton, CatalogRow, CatalogShell, + CatalogWorkspace, Chip, + ContextItem, + ContextPanel, CopyButton, - DetailHead, + Crumb, ErrorNote, + Facts, + FamilyGlyph, FilterChips, + FnGlyph, GroupHeader, + Hero, + IdentityHead, + LiveDot, Note, + SearchField, + SideCount, StatTile, useGroupToggle, } from './widgets' @@ -79,6 +96,21 @@ interface TypeGroup { bindings: RegisteredTrigger[] } +function BoltIcon() { + return ( + + ) +} + export function TriggersPage({ host, side, @@ -120,8 +152,6 @@ export function TriggersPage({ return (id: string) => byId.get(id)?.description ?? undefined }, [catalog.data]) - const activity = useLiveActivity(host) - const partitioned = useMemo(() => { if (!catalog.data) return { groups: [] as TypeGroup[], plumbing: [] as RegisteredTrigger[] } @@ -167,27 +197,11 @@ export function TriggersPage({ }, [catalog.data]) const allGroups = partitioned.groups - // Groups that just fired float to the top: during a harness turn the page - // reads as what the agent is doing, not an alphabetical index. - const lastFiredOf = useMemo(() => { - return (group: TypeGroup): number => { - let latest = 0 - for (const binding of group.bindings) { - const span = activity.lastCall.get(binding.function_id) - if (span && span.atMs > latest) latest = span.atMs - } - return latest - } - }, [activity.lastCall]) - 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) + Math.max(1, group.bindings.length), - ) + counts.set(key, (counts.get(key) ?? 0) + 1) } return counts }, [allGroups]) @@ -214,15 +228,17 @@ export function TriggersPage({ ) }) .sort((a, b) => { - const fired = lastFiredOf(b) - lastFiredOf(a) - if (fired !== 0) return fired + // 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, lastFiredOf]) + }, [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 @@ -233,53 +249,116 @@ export function TriggersPage({ ) 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" - count={`${groups.length} types · ${boundCount} registered`} - search={search} - onSearch={setSearch} - searchPlaceholder="search types, functions, paths, topics, schedules…" - onRefresh={catalog.reload} - loading={catalog.loading} - onRequestClose={onRequestClose} - below={ + description={ + + trigger types and registered bindings + + } + onClose={onRequestClose} + className="console-catalog-page-header" + actions={ <> - - { - const hit = allGroups - .flatMap((g) => g.bindings) - .find((b) => b.function_id === functionId) - if (hit) setSelected({ kind: 'binding', binding: hit }) - }} - /> + + } - > - - + /> + } + 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 ? ( - loading triggers… + ) : groups.length === 0 ? ( { + setSearch('') + setFamily(null) + }, + } + : undefined + } /> ) : ( - groups.map((group) => { - const spec = familyOf(group.type.id) - return ( -
+ <> + {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(group.type.id)} + label="plumbing" + meta="console + configuration internals" + count={partitioned.plumbing.length} + countLabel="binding" + open={groupState.isOpen('__plumbing')} + onToggle={() => groupState.toggle('__plumbing')} /> - {groupState.isOpen(group.type.id) ? ( - <> - - setSelected((prev) => - prev?.kind === 'type' && prev.id === group.type.id - ? null - : { kind: 'type', id: group.type.id }, - ) - } - /> - {group.bindings.map((binding) => ( + {groupState.isOpen('__plumbing') + ? partitioned.plumbing.map((binding) => ( } - secondary={ - binding.function_id - ? `${binding.function_id}${ - describeFunction(binding.function_id) - ? ` — ${describeFunction(binding.function_id)}` - : '' - }` - : '(no target function)' - } + primary={summarize(binding)} + secondary={`${binding.trigger_type} → ${binding.function_id}`} selected={ selected?.kind === 'binding' && selected.binding.id === binding.id } - flash={activity.pulsing.has(binding.function_id)} - onClick={() => - setSelected((prev) => - prev?.kind === 'binding' && - prev.binding.id === binding.id - ? null - : { kind: 'binding', binding }, - ) - } + onClick={() => selectBinding(binding)} /> - ))} - - ) : null} + )) + : null}
- ) - }) + ) : null} + ) } - footer={ - partitioned.plumbing.length > 0 && !search.trim() && !family ? ( -
- groupState.toggle('__plumbing')} - /> - {groupState.isOpen('__plumbing') - ? partitioned.plumbing.map((binding) => ( - - setSelected((prev) => - prev?.kind === 'binding' && - prev.binding.id === binding.id - ? null - : { kind: 'binding', binding }, - ) - } - /> - )) - : null} -
- ) : null - } - detail={ - selected === null ? null : selected.kind === 'type' ? ( - } + 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)} + onBack={() => setSelected(null)} /> ) : ( - setSelected(null)} + onBack={() => setSelected(null)} /> ) } @@ -418,78 +509,121 @@ export function TriggersPage({ ) } -function TypeDetailPane({ +function TypeDocument({ host, typeId, - onClose, + onBack, }: { host: Host typeId: string - onClose: () => void + onBack: () => void }) { const load = useCallback(() => triggerTypeInfo(host, typeId), [host, typeId]) const detail = useResource(load) + const spec = familyOf(typeId) return ( - <> - - - {detail.data.instance_count !== undefined ? ( - - ) : null} - {detail.data.description ? ( - - {detail.data.description} - - ) : null} - - ) : null - } - onClose={onClose} - > - - - {detail.error ? ( - - ) : detail.data === null ? ( - loading detail… - ) : ( - - - config schema - payload schema - - - {detail.data.configuration_schema === undefined ? ( - - this type takes no config — bindings register with an empty - object. - - ) : ( - : 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 + + + - )} - - - {detail.data.request_schema === undefined ? ( - this type publishes no payload schema. - ) : ( - + + - )} - - - )} + + + )} +
+ + ) +} + +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), + }, + ]} + /> + + + + ) } @@ -505,16 +639,16 @@ function parsedSummary(raw: string | null | undefined): unknown { } } -function BindingDetailPane({ +function BindingDocument({ host, binding, description, - onClose, + onBack, }: { host: Host binding: RegisteredTrigger description?: string - onClose: () => void + 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. @@ -537,84 +671,154 @@ function BindingDetailPane({ })() return ( - <> - - - - {chips.map((chip) => ( - - ))} - - } - onClose={onClose} - > - - - -
- + } + > +
+ + + } + 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={} + /> -
- target function - {binding.function_id || '(none)'} - {description ? ( - {description} +
+ {type.error ? ( + ) : null} -
- - - - {spec.family === 'http' - ? 'send request' - : spec.family === 'queue' - ? 'publish' - : spec.family === 'cron' - ? 'run now' - : 'call target'} - - config - - - - {http ? ( - - ) : topic ? ( - - ) : binding.function_id ? ( - - ) : ( - - this binding carries no target function — nothing to call. - - )} - - - - + + + {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. + )} - className="console-catalog-json" - wrap - /> - - + + + + + + +
+
+ ) +} + +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} ) } diff --git a/console/ui/src/catalog/engine.ts b/console/ui/src/catalog/engine.ts index e8084e8a3..c104f6441 100644 --- a/console/ui/src/catalog/engine.ts +++ b/console/ui/src/catalog/engine.ts @@ -102,7 +102,44 @@ function triggerRef(row: unknown): RegisteredTriggerRef | null { } export function errorMessage(err: unknown): string { - return err instanceof Error ? err.message : String(err) + 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( @@ -138,6 +175,25 @@ export async function functionInfo( } } +/** 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 }, diff --git a/console/ui/src/catalog/live.tsx b/console/ui/src/catalog/live.tsx index 5988ac42b..ca4c04a6a 100644 --- a/console/ui/src/catalog/live.tsx +++ b/console/ui/src/catalog/live.tsx @@ -109,8 +109,8 @@ export function agoLabel(atMs: number, nowMs: number): string { } /** - * The strip under the page head: the last few executions, newest on the - * left, sliding in as they happen. Clicking one jumps to that function. + * The strip under the page head: recent function calls, newest on the left. + * Clicking one jumps to that function. */ export function NowStrip({ activity, @@ -126,11 +126,8 @@ export function NowStrip({ if (shown.length === 0) { return (
- now - - waiting for calls — anything the agent or a worker runs appears here - live - + recent calls + no calls recorded since this page opened
) } @@ -138,7 +135,7 @@ export function NowStrip({ const now = Date.now() return (
- now + recent calls
{shown.map((span) => ( - - } +
+ + { + if (e.key === 'Escape' && value) { + e.stopPropagation() + onChange('') + } + }} /> -
- - {below} -
- + {value ? ( + + ) : null} +
+ ) +} + +/** The quiet count line under the search box. */ +export function SideCount({ children }: { children: ReactNode }) { + return ( +
+ {children} +
) } @@ -156,6 +190,7 @@ export function FilterChips({ type="button" className="console-catalog-filter" data-selected={selected === null} + aria-pressed={selected === null} onClick={() => onSelect(null)} > all {total} @@ -166,6 +201,7 @@ export function FilterChips({ type="button" className="console-catalog-filter" data-selected={selected === key} + aria-pressed={selected === key} onClick={() => onSelect(selected === key ? null : key)} > {key} {count} @@ -205,7 +241,12 @@ export function StatTile({ */ export function LiveDot() { return ( - + live @@ -227,6 +268,7 @@ export function CopyButton({ + + ) + + return ( +
+ + {onSelect && collapsible ? ( + + ) : null} +
) } export function CatalogRow({ + icon, primary, secondary, meta, @@ -288,6 +391,8 @@ export function CatalogRow({ 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). */ @@ -302,60 +407,401 @@ export function CatalogRow({ type="button" className={`console-catalog-row${flash ? ' flash' : ''}`} data-selected={selected} + aria-pressed={selected} onClick={onClick} + title={typeof primary === 'string' ? primary : undefined} > - - {primary} - {meta} + {icon ? {icon} : null} + + + {primary} + {meta} + + {secondary ? {secondary} : null} - {secondary ? {secondary} : null} ) } -export function DetailHead({ +/* ── 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, - subtitle, - onClose, + description, + action, children, + wide = false, }: { title: string - subtitle?: ReactNode - onClose: () => void - /** Actions left of `close` — copy buttons, mostly. */ - children?: ReactNode + description?: string + action?: { label: string; onClick: () => void } + children: ReactNode + wide?: boolean }) { return ( -
-
- {title} - - {children} - +
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {action ? ( + + ) : null}
- {subtitle ? ( -
{subtitle}
+
{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 ( -
- {call} failed — {message} +
+
+ {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) => ( +
+ + + +
+ ))}
) } diff --git a/console/ui/styles.css b/console/ui/styles.css index 2f8f6eb23..2d561d31e 100644 --- a/console/ui/styles.css +++ b/console/ui/styles.css @@ -171,18 +171,20 @@ color: var(--color-alert); } -/* ── the engine catalogue pages (functions, triggers) ───────────────── */ +/* ── 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. */ -/* The host pane is `flex-1 min-h-0 overflow-y-auto`, so the page owns its - * own height and scrolls each column separately. Container queries, not - * media queries: the pane width is what matters, not the viewport. */ [data-iii-ui="console"] .console-catalog { - display: flex; - flex-direction: column; - height: 100%; 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, @@ -190,103 +192,225 @@ box-sizing: border-box; } -[data-iii-ui="console"] .console-catalog-head { +/* --- 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: 10px; - padding: 12px 16px; + gap: 9px; + padding: 14px 12px 10px; } -[data-iii-ui="console"] .console-catalog-head-row { - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; +[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-title { +[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; - text-transform: uppercase; - letter-spacing: 0.16em; - color: var(--color-ink-faint); - font-weight: 600; + 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-body { - flex: 1; - min-height: 0; - display: flex; +[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-list { +[data-iii-ui="console"] .console-catalog-search-row .console-catalog-search { flex: 1; min-width: 0; - overflow-y: auto; - padding: 12px 20px 24px; -} -[data-iii-ui="console"] .console-catalog-detail { - flex: none; - width: 46%; - min-width: 340px; - overflow-y: auto; - background: var(--color-panel-raised, var(--color-panel)); - border-radius: 6px; -} -/* Below this the two panes are both too narrow to read, so they stack. - * The console page pane is roughly 790px with the chat dock open. */ -@container (max-width: 760px) { - [data-iii-ui="console"] .console-catalog-body { - flex-direction: column; - } - [data-iii-ui="console"] .console-catalog-detail { - width: 100%; - min-width: 0; - } } /* --- list: collapsible group, then its rows -------------------------- */ [data-iii-ui="console"] .console-catalog-section { - margin-bottom: 14px; + margin-bottom: 12px; } [data-iii-ui="console"] .console-catalog-group { display: flex; - align-items: center; - gap: 8px; + align-items: stretch; + gap: 2px; width: 100%; - padding: 6px 0; - background: transparent; + 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; - color: var(--color-ink); + border-radius: 4px; + background: transparent; + color: var(--color-ink-ghost); font: inherit; - text-align: left; 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 .label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.12em; - color: var(--color-ink-faint); +[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 .meta { - font-size: 11px; +[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; - flex-direction: column; - gap: 2px; + align-items: flex-start; + gap: 11px; width: 100%; - padding: 8px 10px; + padding: 10px; text-align: left; background: transparent; border: 0; @@ -307,42 +431,444 @@ --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 { - font-size: 13px; - overflow-wrap: anywhere; + 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: 11.5px; + 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; } - -/* --- detail pane ----------------------------------------------------- */ -[data-iii-ui="console"] .console-catalog-detail-head { - position: sticky; - top: 0; - z-index: 1; + +/* --- 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; - padding: 14px 16px; - background: var(--color-panel-raised, var(--color-panel)); + flex-wrap: wrap; } -[data-iii-ui="console"] .console-catalog-detail-title { - font-size: 13px; +[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-detail-sub { +[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: 8px; + 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; @@ -352,35 +878,155 @@ background: var(--color-surface, rgba(0, 0, 0, 0.05)); border-radius: 4px; font-size: 11.5px; - padding: 2px 8px; + 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-id { +[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-tabs { - padding: 12px 16px 20px; +[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: 10px; - padding-top: 10px; + 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-surface, rgba(0, 0, 0, 0.04)); + 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; @@ -406,24 +1052,47 @@ max-height: 420px; overflow: auto; } - -/* --- bindings, notes, errors ----------------------------------------- */ -[data-iii-ui="console"] .console-catalog-binding { +[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; - flex-direction: column; + 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; - padding: 8px 10px; - border-radius: 6px; } -[data-iii-ui="console"] .console-catalog-binding:nth-child(odd) { - background: var(--color-surface, rgba(0, 0, 0, 0.03)); +[data-iii-ui="console"] .console-catalog-result-head .dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; } -[data-iii-ui="console"] .console-catalog-binding-head { +[data-iii-ui="console"] .console-catalog-result-head .result-meta { display: flex; align-items: center; - gap: 8px; - flex-wrap: wrap; + 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; @@ -431,13 +1100,80 @@ 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)); - border-radius: 6px; 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; - overflow-wrap: anywhere; +} +[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 --------------------------- */ @@ -487,7 +1223,7 @@ } [data-iii-ui="console"] .console-catalog-filter { border: 0; - border-radius: 999px; + border-radius: 6px; background: var(--color-surface, rgba(0, 0, 0, 0.05)); color: var(--color-ink-faint); font: inherit; @@ -500,8 +1236,8 @@ 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-fg); - background: var(--color-accent); + color: var(--color-accent); + background: var(--color-accent-muted); } [data-iii-ui="console"] .console-catalog-filter .count { font-variant-numeric: tabular-nums; @@ -559,6 +1295,32 @@ 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; @@ -649,16 +1411,10 @@ height: 6px; border-radius: 50%; background: var(--color-ok); - animation: console-catalog-pulse 2.4s ease-in-out infinite; } -@keyframes console-catalog-pulse { - 0%, - 100% { - opacity: 1; - } - 50% { - opacity: 0.3; - } +[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; @@ -854,7 +1610,6 @@ font-variant-numeric: tabular-nums; } - /* --- the now-strip + live row meta ----------------------------------- */ [data-iii-ui="console"] .console-catalog-nowstrip { @@ -887,7 +1642,7 @@ gap: 6px; flex: none; border: 0; - border-radius: 999px; + border-radius: 6px; background: var(--color-surface, rgba(0, 0, 0, 0.05)); color: var(--color-ink); font: inherit; @@ -921,6 +1676,8 @@ } } @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; } @@ -929,13 +1686,6 @@ } } -[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-lastcall { flex: none; font-size: 10.5px; @@ -961,3 +1711,148 @@ 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; + } +}