diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ef806e312..360da4d81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,6 +369,22 @@ importers: specifier: ^5.9.2 version: 5.9.3 + queue/ui: + dependencies: + '@iii-dev/console-ui': + specifier: workspace:* + version: link:../../packages/console-ui + devDependencies: + '@types/react': + specifier: ^19.2.14 + version: 19.2.17 + esbuild: + specifier: ^0.25.0 + version: 0.25.12 + typescript: + specifier: ^5.9.2 + version: 5.9.3 + sandbox-code-runner/ui: dependencies: '@iii-dev/console-ui': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 41164c993..f911ad3c3 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,6 +18,7 @@ packages: - harness/ui - memory/ui - pdf/ui + - queue/ui - state/ui - iii-directory/ui - shell/ui diff --git a/queue/Cargo.lock b/queue/Cargo.lock index e020ec87f..ce395e0cb 100644 --- a/queue/Cargo.lock +++ b/queue/Cargo.lock @@ -1215,6 +1215,18 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "iii-console-ui" +version = "0.1.0" +dependencies = [ + "iii-sdk", + "schemars", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "iii-helpers" version = "0.21.6" @@ -1244,6 +1256,7 @@ dependencies = [ "async-trait", "clap", "futures", + "iii-console-ui", "iii-helpers", "iii-queue", "iii-sdk", diff --git a/queue/Cargo.toml b/queue/Cargo.toml index 718fe9865..8374cb5f4 100644 --- a/queue/Cargo.toml +++ b/queue/Cargo.toml @@ -41,6 +41,7 @@ tracing-subscriber = { version = "0.3", features = ["fmt", "env-filter"] } clap = { version = "4", features = ["derive", "env"] } schemars = "0.8" uuid = { version = "1", features = ["v4"] } +iii-console-ui = { path = "../crates/console-ui" } futures = "0.3" redis = { version = "1.0.1", features = ["tokio-comp", "connection-manager"] } lapin = { version = "3", optional = true } diff --git a/queue/build.rs b/queue/build.rs new file mode 100644 index 000000000..e7bf39adc --- /dev/null +++ b/queue/build.rs @@ -0,0 +1,189 @@ +//! Build script for the `queue` worker. +//! +//! 1. Forwards the build-time target triple to the binary as `env!("TARGET")` +//! (used by `manifest.rs` for the registry `supported_targets` field). +//! 2. Ensures the injected console UI assets exist: `src/ui.rs` embeds +//! `ui/dist/page.js` and `ui/dist/styles.css` via `include_str!`, so if +//! either is missing or stale we run `pnpm install && pnpm build` inside +//! `ui/` first (the console worker's `web/` precedent). Set +//! `SKIP_UI_BUILD=1` to use the existing `ui/dist/` outputs as-is. + +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::time::SystemTime; + +fn main() { + println!( + "cargo:rustc-env=TARGET={}", + std::env::var("TARGET").unwrap() + ); + + // `dist/` itself is not listed: include_str! reads it directly, and + // listing it would rebuild-loop on our own output. + println!("cargo:rerun-if-changed=ui/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"); + println!("cargo:rerun-if-changed=ui/package.json"); + // The lockfile lives at the workers-repo root (pnpm workspace: the ui + // project links @iii-dev/console-ui from packages/console-ui). + println!("cargo:rerun-if-changed=../pnpm-lock.yaml"); + println!("cargo:rerun-if-changed=ui/tsconfig.json"); + println!("cargo:rerun-if-env-changed=SKIP_UI_BUILD"); + println!("cargo:rerun-if-env-changed=PNPM"); + + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let ui_dir = manifest_dir.join("ui"); + let dist_assets = [ + ui_dir.join("dist").join("page.js"), + ui_dir.join("dist").join("styles.css"), + ]; + + if dist_assets + .iter() + .all(|a| a.exists() && dist_is_fresh(a, &ui_dir)) + { + return; + } + + if std::env::var_os("SKIP_UI_BUILD").is_some() { + for asset in &dist_assets { + if !asset.exists() { + panic!( + "SKIP_UI_BUILD set but {} is missing — build the UI manually \ + (cd ui && pnpm install && pnpm build) or unset the env var", + asset.display() + ); + } + } + return; + } + + let pnpm = locate_pnpm(); + + let status = Command::new(&pnpm) + .args(["install"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| { + panic!( + "failed to spawn `pnpm install` in {}: {e}", + ui_dir.display() + ) + }); + if !status.success() { + panic!("`pnpm install` exited with {status} — see logs above"); + } + + let status = Command::new(&pnpm) + .args(["build"]) + .current_dir(&ui_dir) + .status() + .unwrap_or_else(|e| panic!("failed to spawn `pnpm build` in {}: {e}", ui_dir.display())); + if !status.success() { + panic!("`pnpm build` exited with {status} — see logs above"); + } + + for asset in &dist_assets { + if !asset.exists() { + panic!( + "`pnpm build` finished but {} is still missing — check the esbuild \ + output above", + asset.display() + ); + } + } +} + +/// `true` when the built asset is at least as new as every source that +/// contributes to it. Conservative: any I/O failure forces a rebuild. +fn dist_is_fresh(dist_asset: &Path, ui_dir: &Path) -> bool { + let Ok(dist_mtime) = dist_asset.metadata().and_then(|m| m.modified()) else { + return false; + }; + + let watched_files = [ + ui_dir.join("page.tsx"), + ui_dir.join("styles.css"), + ui_dir.join("build.mjs"), + ui_dir.join("package.json"), + ui_dir.join("../../pnpm-lock.yaml"), + ui_dir.join("tsconfig.json"), + ]; + for f in watched_files.iter() { + if !f.exists() { + continue; + } + let Ok(m) = f.metadata().and_then(|m| m.modified()) else { + return false; + }; + if m > dist_mtime { + return false; + } + } + + for dir in [ui_dir.join("src")] { + if dir.exists() && !subtree_older_than(&dir, dist_mtime) { + return false; + } + } + + true +} + +fn subtree_older_than(root: &Path, ceiling: SystemTime) -> bool { + // The directory's own mtime catches deletions: removing a file bumps the + // parent while every surviving entry stays old. + let Ok(root_modified) = root.metadata().and_then(|m| m.modified()) else { + return false; + }; + if root_modified > ceiling { + return false; + } + let Ok(read) = std::fs::read_dir(root) else { + return false; + }; + for entry in read.flatten() { + let path = entry.path(); + let Ok(meta) = entry.metadata() else { + return false; + }; + if meta.is_dir() { + if !subtree_older_than(&path, ceiling) { + return false; + } + } else { + let Ok(m) = meta.modified() else { + return false; + }; + if m > ceiling { + return false; + } + } + } + true +} + +fn locate_pnpm() -> PathBuf { + if let Ok(explicit) = std::env::var("PNPM") { + return PathBuf::from(explicit); + } + let candidates = if cfg!(windows) { + ["pnpm.cmd", "pnpm.exe", "pnpm"].as_slice() + } else { + ["pnpm"].as_slice() + }; + let path = std::env::var_os("PATH").unwrap_or_default(); + for dir in std::env::split_paths(&path) { + for name in candidates { + let candidate = dir.join(name); + if candidate.is_file() { + return candidate; + } + } + } + panic!( + "pnpm not found on PATH — install Node + pnpm, or set SKIP_UI_BUILD=1 \ + after building the UI manually with `cd ui && pnpm install && pnpm build`" + ); +} diff --git a/queue/src/boot.rs b/queue/src/boot.rs index 05b9210f0..1a95d4cc4 100644 --- a/queue/src/boot.rs +++ b/queue/src/boot.rs @@ -67,6 +67,7 @@ pub async fn start(iii: Arc, config: QueueConfig) -> anyhow::Result ConsoleUi { + ConsoleUi::new("queue") + .script(PAGE_PATH, PAGE_JS) + .style(STYLES_PATH, STYLES_CSS) +} + +/// Register the queue worker's console assets. Ordering never matters: if +/// the console is not up yet the engine parks the registration and delivers +/// it when the console arrives. +pub fn register(iii: &Arc) { + queue_ui().register(iii); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ui_builder_accepts_the_assets() { + // The builder panics on any path/kind the console would reject. + let _ = queue_ui(); + } + + #[test] + fn embedded_page_is_nonempty_esm() { + assert!(PAGE_JS.contains("export"), "built page.js looks wrong"); + } + + /// The page is useless if its id drifts from the route deep links use + /// (`#/ext/queues`), or if it loses the queue functions it drives. + #[test] + fn embedded_page_registers_the_queues_page() { + assert!(PAGE_JS.contains("queues")); + for fn_id in [ + "engine::queue::list_topics", + "engine::queue::dlq_messages", + "iii::queue::redrive", + "iii::durable::publish", + ] { + assert!( + PAGE_JS.contains(fn_id), + "built page.js no longer calls `{fn_id}`" + ); + } + } + + #[test] + fn embedded_styles_are_scoped() { + // esbuild prints the attribute selector unquoted ([data-iii-ui=queue]). + assert!( + STYLES_CSS.contains(r#"[data-iii-ui="queue"]"#) + || STYLES_CSS.contains("[data-iii-ui=queue]"), + "built styles.css must be scoped under the queue worker's data-iii-ui attribute" + ); + } +} diff --git a/queue/ui/build.mjs b/queue/ui/build.mjs new file mode 100644 index 000000000..619ed56b8 --- /dev/null +++ b/queue/ui/build.mjs @@ -0,0 +1,37 @@ +/** + * Build the worker's two console assets: + * + * page.tsx → dist/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 + * a cryptic "Invalid hook call"). Everything else the page needs gets + * bundled in. `--watch` pairs with the worker's III_STATE_UI_WATCH poller + * for the hot-reload dev loop. + */ + +import esbuild from 'esbuild' + +const options = { + entryPoints: ['page.tsx', 'styles.css'], + bundle: true, + format: 'esm', + jsx: 'automatic', + outdir: 'dist', + external: [ + 'react', + 'react-dom', + 'react-dom/client', + 'react/jsx-runtime', + '@iii-dev/console-ui', + ], + logLevel: 'info', +} + +if (process.argv.includes('--watch')) { + const ctx = await esbuild.context(options) + await ctx.watch() +} else { + await esbuild.build(options) +} diff --git a/queue/ui/package.json b/queue/ui/package.json new file mode 100644 index 000000000..860e1d5e0 --- /dev/null +++ b/queue/ui/package.json @@ -0,0 +1,18 @@ +{ + "name": "@iii-workers/queue-ui", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "build": "tsc --noEmit && node build.mjs", + "watch": "node build.mjs --watch" + }, + "dependencies": { + "@iii-dev/console-ui": "workspace:*" + }, + "devDependencies": { + "@types/react": "^19.2.14", + "esbuild": "^0.25.0", + "typescript": "^5.9.2" + } +} diff --git a/queue/ui/page.tsx b/queue/ui/page.tsx new file mode 100644 index 000000000..aecd7c7b3 --- /dev/null +++ b/queue/ui/page.tsx @@ -0,0 +1,28 @@ +/** + * Entry for the queue worker's injected console UI — compiled by esbuild + * (react + @iii-dev/console-ui external) into dist/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 queue/styles.css. + * + * One contribution: the Queues page (#/ext/queues) — topics, stats, + * publish, and the dead-letter queue with redrive/discard. It ships FROM + * the queue worker because queues are queue-worker data: the page appears + * when the worker connects and leaves with it. + */ + +import type { Host, PageRenderProps } from '@iii-dev/console-ui' +import { QueuesPage } from './src/page' + +export default function setup(host: Host) { + host.pages.register({ + id: 'queues', + title: 'queues', + render: ({ panelSide, onRequestClose }: PageRenderProps) => ( + + ), + }) +} diff --git a/queue/ui/src/page/api.ts b/queue/ui/src/page/api.ts new file mode 100644 index 000000000..1010160d9 --- /dev/null +++ b/queue/ui/src/page/api.ts @@ -0,0 +1,465 @@ +/** + * The queue worker's own read/write surface, as the console page consumes + * it. Everything here already exists on the bus (`queue/src/functions.rs`); + * this file only names the wire shapes and narrows unknown JSON. + * + * Two engine quirks worth the comment: `list_topics` and `dlq_topics` + * answer with BARE ARRAYS, not `{topics: […]}` envelopes, and the write + * functions live under `iii::queue::*` / `iii::durable::*` rather than + * `queue::*` because they predate the namespace convention. + */ + +import type { Host } from '@iii-dev/console-ui' + +export interface TopicInfo { + name: string + brokerType: string + subscriberCount: number +} + +export interface TopicStats { + depth?: number + delivered?: number + failed?: number + consumerCount?: number + dlqDepth?: number + config?: unknown +} + +export interface DlqTopicInfo { + topic: string + messageCount: number +} + +export interface DlqMessage { + id: string + error?: string + failedAtMs?: number + retries?: number + sizeBytes?: number + payload?: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +const num = (v: unknown): number | undefined => + typeof v === 'number' && Number.isFinite(v) ? v : undefined + +export async function listTopics(host: Host): Promise { + const out = await host.iii.trigger('engine::queue::list_topics', {}) + if (!Array.isArray(out)) return [] + return out + .map((row): TopicInfo | null => { + if (!isRecord(row) || typeof row.name !== 'string') return null + return { + name: row.name, + brokerType: typeof row.broker_type === 'string' ? row.broker_type : '', + subscriberCount: num(row.subscriber_count) ?? 0, + } + }) + .filter((t): t is TopicInfo => t !== null) + .sort((a, b) => a.name.localeCompare(b.name)) +} + +export async function topicStats( + host: Host, + topic: string, +): Promise { + const out = await host.iii.trigger('engine::queue::topic_stats', { topic }) + if (!isRecord(out)) return {} + return { + depth: num(out.depth), + delivered: num(out.delivered), + failed: num(out.failed), + consumerCount: num(out.consumer_count), + dlqDepth: num(out.dlq_depth), + config: out.config, + } +} + +export async function dlqTopics(host: Host): Promise { + const out = await host.iii.trigger('engine::queue::dlq_topics', {}) + if (!Array.isArray(out)) return [] + return out + .map((row): DlqTopicInfo | null => { + if (!isRecord(row) || typeof row.topic !== 'string') return null + return { topic: row.topic, messageCount: num(row.message_count) ?? 0 } + }) + .filter((t): t is DlqTopicInfo => t !== null) +} + +export async function dlqMessages( + host: Host, + topic: string, + offset: number, + limit: number, +): Promise { + const out = await host.iii.trigger('engine::queue::dlq_messages', { + topic, + offset, + limit, + }) + const rows = Array.isArray(out) + ? out + : isRecord(out) && Array.isArray(out.messages) + ? out.messages + : [] + return rows + .map((row): DlqMessage | null => { + if (!isRecord(row) || typeof row.id !== 'string') return null + return { + id: row.id, + error: typeof row.error === 'string' ? row.error : undefined, + failedAtMs: num(row.failed_at), + retries: num(row.retries), + sizeBytes: num(row.size_bytes), + payload: row.payload, + } + }) + .filter((m): m is DlqMessage => m !== null) +} + +export async function publish( + host: Host, + topic: string, + data: unknown, +): Promise { + await host.iii.trigger('iii::durable::publish', { topic, data }) +} + +/** Returns how many messages went back onto the main queue. */ +export async function redriveAll(host: Host, queue: string): Promise { + const out = await host.iii.trigger('iii::queue::redrive', { queue }) + return isRecord(out) ? (num(out.redriven) ?? 0) : 0 +} + +export async function redriveOne( + host: Host, + queue: string, + messageId: string, +): Promise { + await host.iii.trigger('iii::queue::redrive_message', { + queue, + message_id: messageId, + }) +} + +export async function discardOne( + host: Host, + queue: string, + messageId: string, +): Promise { + await host.iii.trigger('iii::queue::discard_message', { + queue, + message_id: messageId, + }) +} + +/** Per-topic delivery policy, from the worker's `queue_configs` entry. */ +export interface TopicPolicy { + type?: string + messageGroupField?: string + concurrency?: number + maxRetries?: number + backoffMs?: number + timeoutMs?: number + pollIntervalMs?: number + redeliverOnEngineRestart?: boolean +} + +export interface QueueSetup { + /** Transport: builtin | rabbitmq | redis. */ + adapter: string + /** builtin only: file_based survives restarts, in_memory does not. */ + storeMethod?: string + policies: Map +} + +/** + * The worker's own configuration entry: which transport runs, whether the + * builtin store survives restarts, and the per-topic delivery policies — + * the substance behind the counters (fifo ordering keys, retry budgets, + * redeliver-on-restart). + */ +export async function queueSetup(host: Host): Promise { + const setup: QueueSetup = { adapter: 'builtin', policies: new Map() } + try { + const out = await host.iii.trigger('configuration::get', { id: 'queue' }) + const value = isRecord(out) ? out.value : null + const adapter = isRecord(value) ? value.adapter : null + if (isRecord(adapter) && typeof adapter.name === 'string') { + setup.adapter = adapter.name + } + const adapterConfig = isRecord(adapter) ? adapter.config : null + if ( + isRecord(adapterConfig) && + typeof adapterConfig.store_method === 'string' + ) { + setup.storeMethod = adapterConfig.store_method + } + const configs = isRecord(value) ? value.queue_configs : null + if (isRecord(configs)) { + for (const [topic, raw] of Object.entries(configs)) { + if (!isRecord(raw)) continue + setup.policies.set(topic, { + type: typeof raw.type === 'string' ? raw.type : undefined, + messageGroupField: + typeof raw.message_group_field === 'string' + ? raw.message_group_field + : undefined, + concurrency: num(raw.concurrency), + maxRetries: num(raw.max_retries), + backoffMs: num(raw.backoff_ms), + timeoutMs: num(raw.timeout_ms), + pollIntervalMs: num(raw.poll_interval_ms), + redeliverOnEngineRestart: + typeof raw.redeliver_on_engine_restart === 'boolean' + ? raw.redeliver_on_engine_restart + : undefined, + }) + } + } + } catch { + // Configuration worker absent: defaults stand. + } + return setup +} + +export const DLQ_CAPABLE = new Set(['builtin', 'rabbitmq']) + +/** + * Stats for every topic in one bounded sweep — the list renders real + * numbers per row instead of a name and a subscriber count. A handful of + * topics is the normal case; the concurrency cap keeps a large fleet from + * stampeding the worker. + */ +export async function statsForAll( + host: Host, + topics: readonly TopicInfo[], +): Promise> { + const out = new Map() + const queue = [...topics] + const workers = Array.from( + { length: Math.min(4, queue.length) }, + async () => { + for (;;) { + const topic = queue.shift() + if (!topic) return + try { + out.set(topic.name, await topicStats(host, topic.name)) + } catch { + // A single failing topic must not blank the whole table. + } + } + }, + ) + await Promise.all(workers) + return out +} + +/** One live consumer of a topic: who runs, under what retry budget. */ +export interface Subscriber { + functionId: string + worker: string + maxRetries?: number + backoffMs?: number + conditionFunctionId?: string +} + +/** + * Live `durable:subscriber` registrations for one topic. This is the answer + * to "who consumes this" — the list the topic row's `N subs` count summarizes. + */ +async function durableRegistrations(host: Host): Promise { + const out = await host.iii.trigger('engine::registered-triggers::list', { + trigger_type: 'durable:subscriber', + include_internal: true, + }) + return isRecord(out) && Array.isArray(out.registered_triggers) + ? out.registered_triggers + : [] +} + +function registrationTopic(row: unknown): string | null { + if (!isRecord(row)) return null + const config = isRecord(row.config) ? row.config : {} + if (typeof config.queue === 'string') return config.queue + if (typeof config.topic === 'string') return config.topic + return null +} + +/** + * Subscriber registrations per topic. The engine's own + * `list_topics.subscriber_count` reports connected CONSUMERS and reads 0 for + * idle durable subscribers — registrations are what an operator means by + * "does anything consume this topic". + */ +export async function subscriberCounts( + host: Host, +): Promise> { + const counts = new Map() + for (const row of await durableRegistrations(host)) { + const topic = registrationTopic(row) + if (topic !== null) counts.set(topic, (counts.get(topic) ?? 0) + 1) + } + return counts +} + +export async function subscribersFor( + host: Host, + topic: string, +): Promise { + const rows = await durableRegistrations(host) + const subs: Subscriber[] = [] + for (const row of rows) { + if (!isRecord(row)) continue + const config = isRecord(row.config) ? row.config : {} + if (config.queue !== topic && config.topic !== topic) continue + if (typeof row.function_id !== 'string') continue + subs.push({ + functionId: row.function_id, + worker: typeof row.worker_name === 'string' ? row.worker_name : 'unknown', + maxRetries: num(config.max_retries), + backoffMs: num(config.backoff_ms), + conditionFunctionId: + typeof config.condition_function_id === 'string' + ? config.condition_function_id + : undefined, + }) + } + return subs.sort((a, b) => a.functionId.localeCompare(b.functionId)) +} + +/** One movement on the topic: a publish in, or a delivery to a consumer. */ +export interface QueueEvent { + kind: 'publish' | 'delivery' + functionId: string + worker: string + atMs: number + durationMs: number + ok: boolean +} + +/** + * Span names carried by one all-spans stream frame. The envelope nests as + * `{event: {event: {data: {spans}}}}`; reading the names directly beats + * JSON.stringify'ing the whole frame just to substring-match it. + */ +export function frameSpanNames(frame: unknown): string[] { + if (!isRecord(frame)) return [] + const outer = isRecord(frame.event) ? frame.event : undefined + const inner = outer && isRecord(outer.event) ? outer.event : undefined + const data = inner && isRecord(inner.data) ? inner.data : undefined + const spans = data && Array.isArray(data.spans) ? data.spans : [] + const names: string[] = [] + for (const span of spans) { + if (isRecord(span) && typeof span.name === 'string') names.push(span.name) + } + return names +} + +function spanEvents( + out: unknown, + kind: QueueEvent['kind'], + topicFilter?: string, + lenient = false, +): QueueEvent[] { + const spans = isRecord(out) && Array.isArray(out.spans) ? out.spans : [] + const events: QueueEvent[] = [] + for (const span of spans) { + if (!isRecord(span)) continue + if (topicFilter !== undefined) { + // Publishes carry the topic in the recorded input payload event, so + // they filter exactly. Delivery inputs are the bare message data — the + // envelope is gone — so a subscriber bound to several topics is only + // filterable when the payload happens to carry a `topic` field: + // lenient keeps a span unless that field names a DIFFERENT topic. + const eventsAttr = Array.isArray(span.events) ? span.events : [] + let matches = lenient + for (const entry of eventsAttr) { + if (!isRecord(entry) || entry.name !== 'iii.invocation.input') continue + for (const attr of Array.isArray(entry.attributes) + ? entry.attributes + : []) { + if (!Array.isArray(attr) || attr[0] !== 'iii.payload.json') continue + try { + const payload = JSON.parse(String(attr[1])) + if (!isRecord(payload)) continue + if (payload.topic === topicFilter) { + matches = true + } else if (lenient && typeof payload.topic === 'string') { + matches = false + } + } catch { + // Unparseable payload: not a match. + } + } + } + if (!matches) continue + } + const start = Number(span.start_time_unix_nano) + const end = Number(span.end_time_unix_nano) + if (!Number.isFinite(start) || start <= 0) continue + const name = typeof span.name === 'string' ? span.name : '' + events.push({ + kind, + functionId: name.startsWith('execute ') ? name.slice(8) : name, + worker: + typeof span.service_name === 'string' ? span.service_name : 'unknown', + atMs: start / 1e6, + durationMs: Number.isFinite(end) && end > start ? (end - start) / 1e6 : 0, + ok: span.status !== 'error', + }) + } + return events +} + +/** + * Recent movement on one topic, from the trace store: publishes onto it + * (filtered by the recorded payload's `topic`) and deliveries into each of + * its subscribers (their execution spans). Newest first. + */ +export async function recentActivity( + host: Host, + topic: string, + subscribers: readonly Subscriber[], +): Promise { + const publishRead = host.iii + .trigger('engine::traces::list', { + name: 'execute iii::durable::publish', + limit: 60, + include_internal: true, + }) + .then((out) => spanEvents(out, 'publish', topic)) + .catch((): QueueEvent[] => []) + + // Bounded like statsForAll — a topic with many subscribers must not + // stampede the engine with one traces call each, all at once. + const deliveries: QueueEvent[] = [] + const pending = [...subscribers] + const pool = Array.from( + { length: Math.min(4, pending.length) }, + async () => { + for (;;) { + const sub = pending.shift() + if (!sub) return + try { + const out = await host.iii.trigger('engine::traces::list', { + name: `execute ${sub.functionId}`, + limit: 25, + include_internal: true, + }) + deliveries.push(...spanEvents(out, 'delivery', topic, true)) + } catch { + // One unreadable subscriber must not blank the feed. + } + } + }, + ) + await Promise.all(pool) + return [...(await publishRead), ...deliveries] + .sort((a, b) => b.atMs - a.atMs) + .slice(0, 40) +} diff --git a/queue/ui/src/page/index.tsx b/queue/ui/src/page/index.tsx new file mode 100644 index 000000000..8aadb8df7 --- /dev/null +++ b/queue/ui/src/page/index.tsx @@ -0,0 +1,967 @@ +/** + * The Queues page (`#/ext/queues`): what each topic IS, who consumes it, + * what is moving through it right now, and its failures — with the levers + * (publish, redrive, discard) behind confirm steps because all of them + * touch production traffic. + * + * The counters are the least of it. A topic's identity is its delivery + * policy (standard vs fifo, the fifo ordering key — `harness-turn` is fifo + * grouped by `session_id`, which is how agent turns queue — retry budget, + * concurrency, redeliver-on-restart), its live subscribers with their own + * retry configs, and the movement: publishes in, deliveries out, failures + * into the DLQ. All of that is on the bus already; this page just refuses + * to summarize it down to five numbers. + * + * Live without polling: a `stream` subscription on the all-spans feed + * reloads whenever a queue function or a subscriber of the selected topic + * executes anywhere — a publish from chat, a redrive from the CLI, a + * consumer failing. Where nothing fires, the refresh button stands in. + */ + +import { + Badge, + Button, + CodeEditor, + EmptyState, + type Host, + JsonHighlight, + PageBody, + PageHeader, + PageShell, + StatusDot, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@iii-dev/console-ui' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + DLQ_CAPABLE, + type DlqMessage, + type DlqTopicInfo, + discardOne, + dlqMessages, + dlqTopics, + frameSpanNames, + listTopics, + publish, + type QueueEvent, + type QueueSetup, + queueSetup, + recentActivity, + redriveAll, + redriveOne, + type Subscriber, + statsForAll, + subscriberCounts, + subscribersFor, + type TopicInfo, + type TopicPolicy, + type TopicStats, + topicStats, +} from './api' + +const DLQ_PAGE_SIZE = 25 + +/* ---------------- helpers ---------------- */ + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B` + const kb = bytes / 1024 + return kb < 1024 ? `${kb.toFixed(1)} KB` : `${(kb / 1024).toFixed(2)} MB` +} + +function formatDuration(ms: number): string { + if (ms <= 0) return 'running' + if (ms < 1) return `${Math.round(ms * 1000)}µs` + if (ms < 1000) return `${ms.toFixed(1)}ms` + return `${(ms / 1000).toFixed(2)}s` +} + +function formatMs(ms: number | undefined): string { + if (ms === undefined) return '—' + if (ms >= 60_000) return `${Math.round(ms / 60_000)}m` + if (ms >= 1000) return `${Math.round(ms / 1000)}s` + return `${ms}ms` +} + +function ago(atMs: number, now: number): string { + const s = Math.max(0, Math.floor((now - atMs) / 1000)) + if (s < 1) return 'now' + if (s < 60) return `${s}s ago` + const m = Math.floor(s / 60) + if (m < 60) return `${m}m ago` + return `${Math.floor(m / 60)}h ago` +} + +let feedSeq = 0 + +/** + * Reload when queue traffic happens anywhere on the bus, including the + * selected topic's subscribers executing. Debounced across a busy turn. + */ +function useQueueTraffic( + host: Host, + reload: () => void, + watchedFns: readonly string[] = [], +) { + const reloadRef = useRef(reload) + reloadRef.current = reload + const watchedRef = useRef(watchedFns) + watchedRef.current = watchedFns + const handlerId = useMemo(() => { + feedSeq += 1 + return `iii::queue-ui::traffic-${feedSeq}` + }, []) + + useEffect(() => { + let timer: number | null = null + // Trailing debounce with a ceiling: continuous traffic keeps extending a + // plain debounce forever, and the page would never reload exactly when + // the most is happening. + let deadline = 0 + const offHandler = host.iii.on(handlerId, (frame: unknown) => { + const touches = frameSpanNames(frame).some( + (name) => + name.startsWith('execute engine::queue::') || + name === 'execute iii::durable::publish' || + name.startsWith('execute iii::queue::') || + watchedRef.current.some((fn) => name === `execute ${fn}`), + ) + if (!touches) return + const now = Date.now() + if (timer === null) deadline = now + 2500 + else window.clearTimeout(timer) + timer = window.setTimeout( + () => { + timer = null + reloadRef.current() + }, + Math.min(600, Math.max(0, deadline - now)), + ) + }) + let offTrigger: (() => void) | undefined + try { + offTrigger = host.iii.registerTrigger({ + type: 'stream', + function_id: `${handlerId}::${host.iii.browserId}`, + config: { stream_name: 'iii:devtools:all-spans', group_id: 'all' }, + }) + } catch { + // No stream worker: manual refresh only. + } + return () => { + if (timer !== null) window.clearTimeout(timer) + offTrigger?.() + offHandler() + } + }, [host, handlerId]) +} + +/* ---------------- the page ---------------- */ + +export function QueuesPage({ + host, + side, + onRequestClose, +}: { + host: Host + side?: 'left' | 'right' + onRequestClose?: () => void +}) { + const [topics, setTopics] = useState(null) + const [dlq, setDlq] = useState([]) + const [setup, setSetup] = useState({ + adapter: 'builtin', + policies: new Map(), + }) + const [error, setError] = useState(null) + const [filter, setFilter] = useState('') + const [selected, setSelected] = useState(null) + const [statsByTopic, setStatsByTopic] = useState>( + new Map(), + ) + const [regCounts, setRegCounts] = useState>(new Map()) + const pickedRef = useRef(false) + + const load = useCallback(() => { + Promise.all([ + listTopics(host), + dlqTopics(host), + queueSetup(host), + subscriberCounts(host).catch(() => new Map()), + ]).then( + async ([topicRows, dlqRows, setupValue, counts]) => { + setTopics(topicRows) + setDlq(dlqRows) + setSetup(setupValue) + setRegCounts(counts) + setError(null) + // A page that opens onto nothing selected is a page that opens + // mostly empty. Pick the topic with dead letters, else the first — + // on the FIRST populated load only: later traffic reloads must not + // override a deliberately cleared selection. + if (!pickedRef.current && topicRows.length > 0) { + pickedRef.current = true + const dead = dlqRows[0]?.topic + setSelected((prev) => prev ?? dead ?? topicRows[0]?.name ?? null) + } + setStatsByTopic(await statsForAll(host, topicRows)) + }, + (err: unknown) => setError(errorMessage(err)), + ) + }, [host]) + useEffect(load, [load]) + useQueueTraffic(host, load) + + const dlqByTopic = useMemo( + () => new Map(dlq.map((d) => [d.topic, d.messageCount])), + [dlq], + ) + + const shown = useMemo(() => { + const needle = filter.trim().toLowerCase() + return (topics ?? []).filter( + (t) => !needle || t.name.toLowerCase().includes(needle), + ) + }, [topics, filter]) + + const dlqTotal = dlq.reduce((n, d) => n + d.messageCount, 0) + const volatile = + setup.adapter === 'builtin' && setup.storeMethod !== 'file_based' + + return ( + + 0 ? ` · ${dlqTotal} dead` : '' + } · ${setup.adapter}${setup.storeMethod ? ` (${setup.storeMethod})` : ''}`} + onClose={onRequestClose} + actions={ + <> + {volatile ? ( + + volatile store + + ) : null} + + + } + /> + setFilter(e.target.value)} + placeholder="filter topics…" + aria-label="filter topics" + /> + + {error ? ( +
+ engine::queue::list_topics failed — {error} +
+ ) : topics === null ? ( +
loading topics…
+ ) : shown.length === 0 ? ( + + ) : ( + +
+
+ topic + depth + delivered + failed + dead + subs +
+ {shown.map((topic) => { + const dead = dlqByTopic.get(topic.name) ?? 0 + const policy = setup.policies.get(topic.name) + const stats = statsByTopic.get(topic.name) + return ( + + ) + })} +
+ {selected ? ( +
+ setSelected(null)} + /> +
+ ) : null} +
+ )} +
+ ) +} + +/* ---------------- topic detail ---------------- */ + +function TopicDetail({ + host, + topic, + deadCount, + policy, + dlqCapable, + adapter, + onChanged, + onClose, +}: { + host: Host + topic: string + deadCount: number + policy?: TopicPolicy + dlqCapable: boolean + adapter: string + onChanged: () => void + onClose: () => void +}) { + const [stats, setStats] = useState + > | null>(null) + const [subscribers, setSubscribers] = useState([]) + const [statsError, setStatsError] = useState(null) + + const loadDetail = useCallback(() => { + Promise.all([topicStats(host, topic), subscribersFor(host, topic)]).then( + ([statsValue, subs]) => { + setStats(statsValue) + setSubscribers(subs) + setStatsError(null) + }, + (err: unknown) => setStatsError(errorMessage(err)), + ) + }, [host, topic]) + useEffect(loadDetail, [loadDetail]) + const watched = useMemo( + () => subscribers.map((s) => s.functionId), + [subscribers], + ) + useQueueTraffic(host, loadDetail, watched) + + return ( + <> +
+ {topic} + {policy?.type ? ( + + {policy.type} + {policy.messageGroupField ? ` by ${policy.messageGroupField}` : ''} + + ) : null} + + +
+ + {statsError ? ( +
+ engine::queue::topic_stats failed — {statsError} +
+ ) : stats ? ( +
+ + + 0} + /> + + 0} + /> +
+ ) : ( +
loading stats…
+ )} + + 0 ? 'dead' : 'overview'} + className="queue-ui-tabs" + > + + overview + activity + publish + + dead letters + {deadCount > 0 ? {deadCount} : null} + + + + + + + + + + + + + {dlqCapable ? ( + + ) : ( +
+ the {adapter} adapter is pub/sub only — it keeps no dead-letter + queue, so failed deliveries are not retained. Switch to the + builtin or rabbitmq adapter for retry and dead-lettering. +
+ )} +
+
+ + ) +} + +function Tile({ + label, + value, + alert, +}: { + label: string + value: number | undefined + alert?: boolean +}) { + return ( +
0}> + {label} + {value ?? '—'} +
+ ) +} + +/* ---------------- overview: policy + subscribers ---------------- */ + +function OverviewPanel({ + policy, + subscribers, +}: { + policy?: TopicPolicy + subscribers: readonly Subscriber[] +}) { + return ( +
+ delivery policy + {policy ? ( +
+ + + + + +
+ ) : ( +
+ no explicit policy — the topic runs on the adapter's defaults. +
+ )} + + + subscribers + {subscribers.length} + + {subscribers.length === 0 ? ( +
+ nothing consumes this topic right now. Published messages queue until + a durable subscriber registers for it. +
+ ) : ( + subscribers.map((sub) => ( +
+ {sub.functionId} + + {sub.worker} + {sub.maxRetries !== undefined + ? ` · ${sub.maxRetries} retries` + : ''} + {sub.backoffMs !== undefined + ? ` · ${formatMs(sub.backoffMs)} backoff` + : ''} + {sub.conditionFunctionId + ? ` · if ${sub.conditionFunctionId}` + : ''} + +
+ )) + )} +
+ ) +} + +function PolicyFact({ + label, + value, + warn, +}: { + label: string + value: string + warn?: boolean +}) { + return ( +
+ {label} + {value} +
+ ) +} + +/* ---------------- activity: publishes + deliveries ---------------- */ + +function ActivityPanel({ + host, + topic, + subscribers, +}: { + host: Host + topic: string + subscribers: readonly Subscriber[] +}) { + const [events, setEvents] = useState(null) + const [error, setError] = useState(null) + + const load = useCallback(() => { + recentActivity(host, topic, subscribers).then( + (rows) => { + setEvents(rows) + setError(null) + }, + (err: unknown) => setError(errorMessage(err)), + ) + }, [host, topic, subscribers]) + useEffect(load, [load]) + const watched = useMemo( + () => subscribers.map((s) => s.functionId), + [subscribers], + ) + useQueueTraffic(host, load, watched) + + if (error) { + return ( +
+ engine::traces::list failed — {error} +
+ ) + } + if (events === null) + return
reading recent movement…
+ if (events.length === 0) { + return ( +
+ no recorded movement. Publishes onto {topic} and deliveries + into its subscribers appear here as they happen. +
+ ) + } + + const now = Date.now() + return ( +
+ {events.map((event, i) => ( +
+ + + {event.kind === 'publish' ? '→ in' : 'out →'} + + + {event.kind === 'publish' ? 'publish' : event.functionId} + + {event.worker} + + {formatDuration(event.durationMs)} · {ago(event.atMs, now)} + +
+ ))} +
+ ) +} + +/* ---------------- publish ---------------- */ + +function PublishPanel({ + host, + topic, + onPublished, +}: { + host: Host + topic: string + onPublished: () => void +}) { + const [body, setBody] = useState('{\n "test": true\n}') + const [confirming, setConfirming] = useState(false) + const [busy, setBusy] = useState(false) + const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null) + + const send = async () => { + let data: unknown + try { + data = JSON.parse(body) + } catch (err) { + setNote({ ok: false, text: errorMessage(err) }) + return + } + setConfirming(false) + setBusy(true) + try { + await publish(host, topic, data) + setNote({ ok: true, text: `published to ${topic}` }) + onPublished() + } catch (err) { + setNote({ ok: false, text: errorMessage(err) }) + } finally { + setBusy(false) + } + } + + return ( +
+
+ goes through the real queue: every subscriber of {topic}{' '} + receives it, and retry / dead-letter rules apply. +
+ +
+ {confirming ? ( + <> + + + + ) : ( + + )} + {note ? ( + + {note.text} + + ) : null} +
+
+ ) +} + +/* ---------------- dead letters ---------------- */ + +function DlqPanel({ + host, + topic, + onChanged, +}: { + host: Host + topic: string + onChanged: () => void +}) { + const [rows, setRows] = useState(null) + const [page, setPage] = useState(0) + const [open, setOpen] = useState(null) + const [busyId, setBusyId] = useState(null) + const [confirmingAll, setConfirmingAll] = useState(false) + const [note, setNote] = useState(null) + const [error, setError] = useState(null) + + const load = useCallback(() => { + dlqMessages(host, topic, page * DLQ_PAGE_SIZE, DLQ_PAGE_SIZE).then( + (messages) => { + setRows(messages) + setError(null) + }, + (err: unknown) => setError(errorMessage(err)), + ) + }, [host, topic, page]) + useEffect(load, [load]) + useQueueTraffic(host, load) + + // The same failure repeated 40 times is one fact, not 40: lead with the + // grouped error lines so the operator reads causes before instances. + const grouped = useMemo(() => { + const byError = new Map() + for (const row of rows ?? []) { + const key = row.error ?? '(no error text)' + byError.set(key, (byError.get(key) ?? 0) + 1) + } + return [...byError.entries()].sort((a, b) => b[1] - a[1]) + }, [rows]) + + const act = async ( + label: string, + fn: () => Promise, + id: string | null, + ) => { + setBusyId(id ?? '__all') + try { + await fn() + setNote(label) + load() + onChanged() + } catch (err) { + setNote(errorMessage(err)) + } finally { + setBusyId(null) + setConfirmingAll(false) + } + } + + if (error) { + return ( +
+ engine::queue::dlq_messages failed — {error} +
+ ) + } + if (rows === null) + return
loading dead letters…
+ if (rows.length === 0 && page === 0) { + return ( +
+ no dead letters on {topic} — failed deliveries land here + after their retries are spent. +
+ ) + } + + return ( +
+ {grouped.length > 1 ? ( +
+ {grouped.map(([message, count]) => ( +
+ {count}× + {message} +
+ ))} +
+ ) : null} + +
+ {confirmingAll ? ( + <> + + + + ) : ( + + )} + {note ? {note} : null} +
+ + {rows.map((message) => ( +
+ + {open === message.id ? ( +
+ +
+ + + {message.id} +
+
+ ) : null} +
+ ))} + +
+ + + page {page + 1} +
+
+ ) +} diff --git a/queue/ui/styles.css b/queue/ui/styles.css new file mode 100644 index 000000000..89f732b1b --- /dev/null +++ b/queue/ui/styles.css @@ -0,0 +1,469 @@ +/* + * The queue worker's console stylesheet, shipped as its own `console:style` + * asset (queue/styles.css). Every rule is scoped under + * `[data-iii-ui="queue"]`, the wrapper the console mounts around every + * injected render. Colors come from the console's design tokens, so + * light/dark theming is free. + */ + +[data-iii-ui="queue"] .queue-ui { + display: flex; + flex-direction: column; + height: 100%; + padding: 0; + box-sizing: border-box; + font-family: var(--font-mono, ui-monospace, monospace); + color: var(--color-ink); + container-type: inline-size; +} +[data-iii-ui="queue"] .queue-ui *, +[data-iii-ui="queue"] .queue-ui *::before, +[data-iii-ui="queue"] .queue-ui *::after { + box-sizing: border-box; +} + +[data-iii-ui="queue"] .queue-ui-filter { + width: calc(100% - 32px); + margin: 10px 16px 12px; + border: 1px solid transparent; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + color: var(--color-ink); + font-family: inherit; + font-size: 13px; + padding: 8px 12px; +} +[data-iii-ui="queue"] .queue-ui-filter:focus { + outline: none; + border-color: var(--color-rule-focus, var(--color-accent)); +} + +[data-iii-ui="queue"] .queue-ui-body { + flex: 1; + min-height: 0; + display: flex; + gap: 0; +} +[data-iii-ui="queue"] .queue-ui-list { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 0 8px 16px; +} +[data-iii-ui="queue"] .queue-ui-detail { + flex: none; + width: 48%; + min-width: 340px; + overflow-y: auto; + background: var(--color-panel-raised, var(--color-panel)); + border-radius: 6px; + padding: 0 16px 20px; +} +@container (max-width: 760px) { + [data-iii-ui="queue"] .queue-ui-body { + flex-direction: column; + } + [data-iii-ui="queue"] .queue-ui-detail { + width: 100%; + min-width: 0; + padding: 0 8px 20px; + } +} + +[data-iii-ui="queue"] .queue-ui-thead, +[data-iii-ui="queue"] .queue-ui-row { + display: grid; + grid-template-columns: minmax(0, 1fr) 64px 76px 64px 56px 52px; + align-items: baseline; + gap: 8px; + width: 100%; + padding: 8px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--color-ink); + font: inherit; + font-size: 13px; + text-align: left; + cursor: pointer; +} +[data-iii-ui="queue"] .queue-ui-thead { + cursor: default; + padding-bottom: 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="queue"] .queue-ui-thead .c-n, +[data-iii-ui="queue"] .queue-ui-row .c-n { + text-align: right; + font-variant-numeric: tabular-nums; +} +[data-iii-ui="queue"] .queue-ui-row .c-n { + color: var(--color-ink-faint); +} +[data-iii-ui="queue"] .queue-ui-row .c-n[data-alert="true"] { + color: var(--color-alert); +} +[data-iii-ui="queue"] .queue-ui-row .c-name { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; + overflow: hidden; +} +[data-iii-ui="queue"] .queue-ui-row:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="queue"] .queue-ui-row[data-selected="true"] { + background: var( + --color-surface-selected, + color-mix(in srgb, var(--color-accent) 10%, transparent) + ); +} +[data-iii-ui="queue"] .queue-ui-row .meta { + font-size: 11.5px; + color: var(--color-ink-ghost); + white-space: nowrap; +} +[data-iii-ui="queue"] .queue-ui-row .dead { + color: var(--color-alert); +} + +[data-iii-ui="queue"] .queue-ui-detail-head { + position: sticky; + top: 0; + display: flex; + align-items: center; + gap: 10px; + padding: 14px 0 10px; + background: var(--color-panel-raised, var(--color-panel)); + margin-bottom: 12px; +} +[data-iii-ui="queue"] .queue-ui-detail-title { + font-size: 13px; + font-weight: 600; + overflow-wrap: anywhere; +} + +[data-iii-ui="queue"] .queue-ui-tiles { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 12px; +} +[data-iii-ui="queue"] .queue-ui-tile { + flex: 1 1 96px; + display: flex; + flex-direction: column; + gap: 2px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + border-radius: 6px; + padding: 8px 10px; +} +[data-iii-ui="queue"] .queue-ui-tile .label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="queue"] .queue-ui-tile .value { + font-size: 15px; + font-variant-numeric: tabular-nums; +} +[data-iii-ui="queue"] .queue-ui-tile[data-alert="true"] { + background: var(--color-alert-muted, rgba(255, 0, 38, 0.08)); +} +[data-iii-ui="queue"] .queue-ui-tile[data-alert="true"] .value { + color: var(--color-alert); +} + +[data-iii-ui="queue"] .queue-ui-publish, +[data-iii-ui="queue"] .queue-ui-dlq { + display: flex; + flex-direction: column; + gap: 10px; + padding-top: 10px; +} +[data-iii-ui="queue"] .queue-ui-editor { + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + border-radius: 6px; + min-height: 110px; + max-height: 280px; + overflow: auto; +} +[data-iii-ui="queue"] .queue-ui-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + font-size: 12px; +} +[data-iii-ui="queue"] .queue-ui-ok { + color: var(--color-ok); +} +[data-iii-ui="queue"] .queue-ui-bad { + color: var(--color-alert); +} + +[data-iii-ui="queue"] .queue-ui-dead { + border-radius: 6px; +} +[data-iii-ui="queue"] .queue-ui-dead[data-open="true"] { + background: var(--color-surface, rgba(0, 0, 0, 0.04)); +} +[data-iii-ui="queue"] .queue-ui-dead-head { + display: flex; + align-items: center; + gap: 10px; + width: 100%; + padding: 7px 8px; + background: transparent; + border: 0; + color: var(--color-ink); + font: inherit; + font-size: 12.5px; + text-align: left; + cursor: pointer; +} +[data-iii-ui="queue"] .queue-ui-dead-head:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.08)); +} +[data-iii-ui="queue"] .queue-ui-dead-head .err { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-alert); +} +[data-iii-ui="queue"] .queue-ui-dead-head .meta { + flex: none; + font-size: 11px; + color: var(--color-ink-ghost); + font-variant-numeric: tabular-nums; +} +[data-iii-ui="queue"] .queue-ui-dead-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 4px 8px 10px; +} +[data-iii-ui="queue"] .queue-ui-json { + display: block; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); + padding: 10px 12px; + font-size: 12px; + max-height: 300px; + overflow: auto; +} +[data-iii-ui="queue"] .queue-ui-id { + font-size: 10.5px; + color: var(--color-ink-ghost); + overflow-wrap: anywhere; +} + +[data-iii-ui="queue"] .queue-ui-note { + padding: 10px 0; + font-size: 12.5px; + line-height: 1.5; + color: var(--color-ink-faint); +} +[data-iii-ui="queue"] .queue-ui-note-inline { + font-size: 11.5px; + color: var(--color-ink-ghost); +} +[data-iii-ui="queue"] .queue-ui-error { + background: var(--color-alert-muted, rgba(255, 0, 38, 0.08)); + border-radius: 6px; + color: var(--color-alert); + padding: 10px 12px; + font-size: 12.5px; + overflow-wrap: anywhere; +} +[data-iii-ui="queue"] .queue-ui-tabs { + margin-top: 4px; +} + +/* --- dark-theme safety: explicit ink on every text node ---------------- */ +/* The name span was invisible in dark: it inherited through PageBody's + * sidebar step. Every reading surface names its color. */ + +[data-iii-ui="queue"] .queue-ui-row .name { + color: var(--color-ink); + font-weight: 500; + overflow-wrap: anywhere; + text-align: left; +} +[data-iii-ui="queue"] .queue-ui-row .fifo { + display: inline-block; + margin-right: 6px; + padding: 0 5px; + border-radius: 4px; + background: var(--color-accent-muted, rgba(184, 66, 15, 0.1)); + color: var(--color-accent); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.08em; +} +[data-iii-ui="queue"] .queue-ui-detail-title { + color: var(--color-ink); +} +[data-iii-ui="queue"] .queue-ui-tile .value { + color: var(--color-ink); +} + +/* --- overview: policy facts + subscribers ------------------------------ */ + +[data-iii-ui="queue"] .queue-ui-overview { + display: flex; + flex-direction: column; + gap: 8px; + padding-top: 10px; +} +[data-iii-ui="queue"] .queue-ui-label { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--color-ink-ghost); +} +[data-iii-ui="queue"] .queue-ui-label .count { + font-variant-numeric: tabular-nums; +} +[data-iii-ui="queue"] .queue-ui-policy { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 8px; +} +[data-iii-ui="queue"] .queue-ui-fact { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--color-surface, rgba(0, 0, 0, 0.05)); + border-radius: 6px; + padding: 8px 10px; +} +[data-iii-ui="queue"] .queue-ui-fact .label { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--color-ink-ghost); +} +[data-iii-ui="queue"] .queue-ui-fact .value { + font-size: 12.5px; + color: var(--color-ink); + overflow-wrap: anywhere; +} +[data-iii-ui="queue"] .queue-ui-fact[data-warn="true"] { + background: var(--color-warn-muted, rgba(168, 122, 0, 0.12)); +} +[data-iii-ui="queue"] .queue-ui-fact[data-warn="true"] .value { + color: var(--color-warn); +} + +[data-iii-ui="queue"] .queue-ui-subscriber { + display: flex; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + border-radius: 6px; + background: var(--color-surface, rgba(0, 0, 0, 0.04)); +} +[data-iii-ui="queue"] .queue-ui-subscriber .fn { + font-size: 12.5px; + color: var(--color-ink); + overflow-wrap: anywhere; +} +[data-iii-ui="queue"] .queue-ui-subscriber .who { + font-size: 11.5px; + color: var(--color-ink-ghost); +} + +/* --- activity feed ----------------------------------------------------- */ + +[data-iii-ui="queue"] .queue-ui-activity { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: 10px; +} +[data-iii-ui="queue"] .queue-ui-event { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 6px; + font-size: 12px; +} +[data-iii-ui="queue"] .queue-ui-event:hover { + background: var(--color-surface-hover, rgba(0, 0, 0, 0.06)); +} +[data-iii-ui="queue"] .queue-ui-event .kind { + flex: none; + width: 44px; + font-size: 10.5px; + letter-spacing: 0.04em; + color: var(--color-ink-ghost); +} +[data-iii-ui="queue"] .queue-ui-event .kind[data-kind="publish"] { + color: var(--color-accent); +} +[data-iii-ui="queue"] .queue-ui-event .fn { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--color-ink); +} +[data-iii-ui="queue"] .queue-ui-event .who { + flex: none; + font-size: 11px; + color: var(--color-ink-ghost); +} +[data-iii-ui="queue"] .queue-ui-event .when { + flex: none; + font-size: 11px; + font-variant-numeric: tabular-nums; + color: var(--color-ink-faint); +} + +/* --- grouped DLQ errors ------------------------------------------------ */ + +[data-iii-ui="queue"] .queue-ui-grouped { + display: flex; + flex-direction: column; + gap: 4px; + background: var(--color-alert-muted, rgba(255, 0, 38, 0.06)); + border-radius: 6px; + padding: 8px 10px; +} +[data-iii-ui="queue"] .queue-ui-grouped-row { + display: flex; + gap: 8px; + font-size: 12px; +} +[data-iii-ui="queue"] .queue-ui-grouped-row .count { + flex: none; + font-variant-numeric: tabular-nums; + color: var(--color-alert); +} +[data-iii-ui="queue"] .queue-ui-grouped-row .err { + min-width: 0; + overflow-wrap: anywhere; + color: var(--color-ink); +} + +/* --- tab chrome: a count badge is a separate word, not a suffix -------- */ + +[data-iii-ui="queue"] .queue-ui-tabs [role="tab"] { + display: inline-flex; + align-items: center; + gap: 6px; +} diff --git a/queue/ui/tsconfig.json b/queue/ui/tsconfig.json new file mode 100644 index 000000000..e5ac60540 --- /dev/null +++ b/queue/ui/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "types": [] + }, + "include": ["page.tsx", "src"] +}