From f59fb6f2e83848a1bc16fed3348a365741624f43 Mon Sep 17 00:00:00 2001 From: Sheng Kun Chang Date: Wed, 1 Jul 2026 07:03:08 +0800 Subject: [PATCH] feat: add ServiceLogcat console component Promote the dev Logcat from web-applications into the kit as an idiomatic ui/dev component. Purely presentational: caller supplies logs: LogEntry[] (chronological, oldest first); the co-located useLogcat hook owns text/severity filters, the live-tail toggle, and the single-open accordion, and reverses entries to newest-first. Pins to the top while tailing, anchors the reading position on prepend, and expands an access-log line's request/response body (pretty-printed JSON) on click. Sibling-primitive imports use the kit-internal @/ alias (Badge, IconButton, SegmentedButton, FloatingLabelInput, SectionLabel, Surface, cn); LogEntry/ LogLevel + ServiceLogcatProps are exported from the public barrel. Ships a Storybook story (UI/Dev/ServiceLogcat) with a sample line carrying method/path/status/req_body/res_body so the expand is demoable. Bumps to 0.5.16 (pre-1.0 patch-only) with a CHANGELOG entry. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 13 + package.json | 2 +- .../service-logcat/ServiceLogcat.stories.tsx | 67 +++++ .../ui/dev/service-logcat/ServiceLogcat.tsx | 278 ++++++++++++++++++ src/components/ui/dev/service-logcat/types.ts | 25 ++ .../ui/dev/service-logcat/useLogcat.ts | 64 ++++ src/index.ts | 8 + 7 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx create mode 100644 src/components/ui/dev/service-logcat/ServiceLogcat.tsx create mode 100644 src/components/ui/dev/service-logcat/types.ts create mode 100644 src/components/ui/dev/service-logcat/useLogcat.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69a2a5fbc..ab2df3ce0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ Notable API additions and breaking changes. For the full commit log, see [GitHub Releases](https://github.com/mirrorstack-ai/web-ui-kit/releases). +## 0.5.16 + +- **New `ServiceLogcat` dev component.** A service log console (promoted from + `web-applications`) for streaming module/service logs. Purely presentational: + the caller supplies `logs: LogEntry[]` (chronological, oldest first) and the + component owns all internal state — text filter, severity-floor filter + (`All` / `Warn+` / `Errors`), live-tail toggle, and a single-open accordion. + It reverses entries to newest-first, pins to the top while tailing, anchors + the reading position when new lines are prepended, and expands an access-log + line's request/response body (pretty-printed JSON) on click. Exports + `ServiceLogcat`, `ServiceLogcatProps`, and the `LogEntry` / `LogLevel` types + consumers use to type the array they pass in. + ## 0.5.15 - **Graph physics auto-parks when settled.** The force-directed `Graph`'s diff --git a/package.json b/package.json index ee3a2d357..52d9c55cd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.5.15", + "version": "0.5.16", "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx new file mode 100644 index 000000000..3f41c7649 --- /dev/null +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx @@ -0,0 +1,67 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { ServiceLogcat } from "./ServiceLogcat"; +import type { LogEntry } from "./types"; + +// Chronological (oldest first) — the console reverses to newest-first. +const SAMPLE_LOGS: LogEntry[] = [ + { ts: "2026-06-30T16:23:40.118921000Z", level: "info", msg: "GET /api/modules 200 8ms", duration_ms: 8 }, + { ts: "2026-06-30T16:23:42.041204000Z", level: "info", msg: "POST /api/sessions 201 41ms", duration_ms: 41 }, + { ts: "2026-06-30T16:23:43.512876000Z", level: "warn", msg: "Rate limit approaching: 88% of quota" }, + { ts: "2026-06-30T16:23:44.220019000Z", level: "info", msg: "GET /api/items 200 22ms", duration_ms: 22 }, + { + ts: "2026-06-30T16:23:45.225373081Z", + level: "info", + msg: "POST /api/users 201 84ms", + method: "POST", + path: "/api/users", + status: 201, + duration_ms: 84, + req_body: '{"name":"Ada Lovelace","email":"ada@example.com","role":"admin"}', + res_body: '{"id":"usr_8f2c","name":"Ada Lovelace","email":"ada@example.com","role":"admin","created_at":"2026-06-30T16:23:45Z"}', + }, + { ts: "2026-06-30T16:23:46.882013000Z", level: "warn", msg: "Slow query: media.list took 412ms", duration_ms: 412 }, + { + ts: "2026-06-30T16:23:48.014662000Z", + level: "error", + msg: "GET /api/items 500 — db connection refused", + method: "GET", + path: "/api/items", + status: 500, + duration_ms: 5021, + res_body: '{"error":"db_connection_refused","detail":"dial tcp 10.0.1.4:5432: connect: connection refused"}', + }, + { ts: "2026-06-30T16:23:49.330114000Z", level: "info", msg: "GET /api/users 200 12ms", duration_ms: 12 }, +]; + +const meta: Meta = { + title: "UI/Dev/ServiceLogcat", + component: ServiceLogcat, + args: { + logs: SAMPLE_LOGS, + }, + parameters: { + layout: "padded", + }, +}; + +export default meta; +type Story = StoryObj; + +export const Playground: Story = {}; + +export const WithRequestResponseDetail: Story = { + args: { logs: SAMPLE_LOGS }, + render: (args) => ( +
+

+ Click a row with request/response detail (the POST /api/users or the + 500 error) to expand its body. +

+ +
+ ), +}; + +export const Empty: Story = { + args: { logs: [] }, +}; diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx new file mode 100644 index 000000000..a059e05a9 --- /dev/null +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx @@ -0,0 +1,278 @@ +import { useLayoutEffect, useRef, useState } from "react"; +import { Badge, type BadgeVariant } from "@/components/ui/feedback/badge/Badge"; +import { FloatingLabelInput } from "@/components/ui/inputs/floating-label-input/FloatingLabelInput"; +import { IconButton } from "@/components/ui/actions/icon-button/IconButton"; +import { SectionLabel } from "@/components/ui/display/section-label/SectionLabel"; +import { SegmentedButton } from "@/components/ui/inputs/segmented-button/SegmentedButton"; +import { Surface } from "@/components/ui/surfaces/surface/Surface"; +import { cn } from "@/utils/cn"; +import type { ComponentMeta } from "@/types/component-meta"; +import type { + LogEntry, + LogLevel, +} from "@/components/ui/dev/service-logcat/types"; +import { + useLogcat, + type LevelFloor, +} from "@/components/ui/dev/service-logcat/useLogcat"; + +export const meta: ComponentMeta = { + name: "ServiceLogcat", + description: + "Service log console with severity-floor filter, text filter, live-tail toggle, copy, and an expandable request/response detail per line", +}; + +export interface ServiceLogcatProps { + /** Log entries, chronological (oldest first); the console reverses them so the newest line sits at the top. */ + logs: LogEntry[]; +} + +const LEVEL_BADGE: Record = { + info: "default", + warn: "warning", + error: "error", +}; + +// Message text colour by level — warn/error lines read in their palette so a +// failure stands out, not just its badge. Info stays neutral. +const LEVEL_TEXT: Record = { + info: "text-on-surface", + warn: "text-warning", + error: "text-error", +}; + +const FLOOR_OPTIONS: readonly { value: LevelFloor; label: string }[] = [ + { value: "all", label: "All" }, + { value: "warn", label: "Warn+" }, + { value: "error", label: "Errors" }, +]; + +// Compact single-line timestamp: "2026-06-30T16:23:45.225373081Z" -> +// "2026-06-30 16:23:45.225" (drop the T, nanoseconds, and trailing Z). +function fmtTs(ts: string): string { + const m = ts.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})\.(\d{3})/); + return m ? `${m[1]} ${m[2]}.${m[3]}` : ts; +} + +/** + * ServiceLogcat — the module service log console. A severity-floor filter, a + * text filter, a live-tail toggle, copy, and a mono scroll surface that pins to + * the newest line while tailing. Purely presentational: the caller supplies the + * `logs` array (mock today; a live stream tomorrow) and the co-located + * `useLogcat` hook owns all internal state. + */ +export function ServiceLogcat({ logs }: ServiceLogcatProps) { + const { query, setQuery, floor, setFloor, tailing, setTailing, filtered, total } = + useLogcat(logs); + const scrollRef = useRef(null); + const pinnedRef = useRef(true); // is the view currently pinned at the top? + const programmaticRef = useRef(false); + + // Accordion: only one row open at a time. + const [openKey, setOpenKey] = useState(null); + const toggleRow = (key: string) => setOpenKey((cur) => (cur === key ? null : key)); + + const scrollToTop = () => { + const el = scrollRef.current; + if (!el) return; + programmaticRef.current = true; + el.scrollTop = 0; + pinnedRef.current = true; + }; + + // Track whether the view is at the top. A programmatic scroll-to-top is + // ignored (guarded by programmaticRef). No auto-pause: tailing stays on; we + // just stop following while the user is scrolled away from the top. + const onScroll = () => { + const el = scrollRef.current; + if (!el) return; + if (programmaticRef.current) { + programmaticRef.current = false; + return; + } + pinnedRef.current = el.scrollTop < 24; + }; + + // Newest sits at the TOP. When tailing + pinned, follow to the top. Otherwise, + // if new lines were prepended above while the user is reading lower down, they + // would shove the view down — so offset scrollTop by the added height to keep + // the user anchored to the line they're reading. openKey is a dep so the + // height baseline updates after an expand/collapse too (no false offset). + const prevLenRef = useRef(filtered.length); + const prevHeightRef = useRef(0); + useLayoutEffect(() => { + const el = scrollRef.current; + if (!el) return; + const grew = filtered.length > prevLenRef.current; + if (tailing && pinnedRef.current) { + scrollToTop(); + } else if (grew) { + const added = el.scrollHeight - prevHeightRef.current; + if (added > 0) { + programmaticRef.current = true; + el.scrollTop += added; + } + } + prevLenRef.current = filtered.length; + prevHeightRef.current = el.scrollHeight; + }, [tailing, filtered.length, openKey]); + + const copyAll = () => { + const text = filtered.map((l) => `${l.ts} ${l.level.toUpperCase()} ${l.msg}`).join("\n"); + void navigator.clipboard?.writeText(text); + }; + + return ( +
+
+ Logcat + +
+ +
+ +
+ setQuery(e.target.value)} + /> +
+ setQuery("")} + /> + +
+ + +
+ {filtered.length === 0 ? ( +

No matching log entries.

+ ) : ( +
+ {filtered.map((l) => { + const key = `${l.ts}-${l.msg}`; + const hasDetail = + l.method != null || l.req_body != null || l.res_body != null; + const open = openKey === key; + return ( +
+
toggleRow(key) : undefined} + > + + {fmtTs(l.ts)} + + + + {l.level} + + + {l.msg} + {l.duration_ms != null && ( + + {l.duration_ms}ms + + )} +
+ {open && ( +
+ {l.req_body ? : null} + {l.res_body ? : null} + {!l.req_body && !l.res_body ? ( +

No request or response body.

+ ) : null} +
+ )} +
+ ); + })} +
+ )} +
+
+ +
+ + {filtered.length === total ? `${total} entries` : `${filtered.length} of ${total}`} + {query && ` · “${query}”`} + + {tailing ? "tailing" : "paused"} +
+
+ ); +} + +function LogDetail({ label, body }: { label: string; body: string }) { + return ( +
+

+ {label} +

+
+        {prettyJson(body)}
+      
+
+ ); +} + +// Pretty-print JSON bodies; leave non-JSON text as-is. +function prettyJson(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} diff --git a/src/components/ui/dev/service-logcat/types.ts b/src/components/ui/dev/service-logcat/types.ts new file mode 100644 index 000000000..dbea9e18d --- /dev/null +++ b/src/components/ui/dev/service-logcat/types.ts @@ -0,0 +1,25 @@ +/** + * A single log line rendered by {@link ServiceLogcat}. + * + * Entries arrive chronological (oldest first); the console reverses them + * internally so the newest line sits at the top. The optional request/response + * detail fields are present on access-log lines (captured by the dev-proxy / + * dispatch ring) and absent on plain log lines. Detail keys are snake_case to + * match the dispatch ring Entry JSON. + */ +export type LogLevel = "info" | "warn" | "error"; + +export interface LogEntry { + ts: string; + level: LogLevel; + msg: string; + // Optional request/response detail — present on access-log lines captured by + // the dev-proxy, absent on plain log lines. Keys match the dispatch ring + // Entry JSON (snake_case). + method?: string; + path?: string; + status?: number; + req_body?: string; + res_body?: string; + duration_ms?: number; +} diff --git a/src/components/ui/dev/service-logcat/useLogcat.ts b/src/components/ui/dev/service-logcat/useLogcat.ts new file mode 100644 index 000000000..4761c6df7 --- /dev/null +++ b/src/components/ui/dev/service-logcat/useLogcat.ts @@ -0,0 +1,64 @@ +import { useMemo, useState } from "react"; +import type { + LogEntry, + LogLevel, +} from "@/components/ui/dev/service-logcat/types"; + +/** + * Severity floor for the Logcat filter — show this level "and above". + * `all` = info+warn+error, `warn` = warn+error, `error` = error only. + */ +export type LevelFloor = "all" | "warn" | "error"; + +const FLOOR_RANK: Record = { all: 0, warn: 1, error: 2 }; +const LEVEL_RANK: Record = { info: 0, warn: 1, error: 2 }; + +export interface UseLogcatResult { + query: string; + setQuery: (v: string) => void; + floor: LevelFloor; + setFloor: (v: LevelFloor) => void; + tailing: boolean; + setTailing: (v: boolean) => void; + /** Filtered entries newest-first (reversed), so the latest line sits at the top. */ + filtered: LogEntry[]; + total: number; +} + +/** + * State + derivation for the service Logcat console. The caller passes the raw + * entries (a mock array, or a live stream — this hook is the swap seam); the + * component stays presentational. Source entries arrive chronological (oldest + * first); the hook reverses them to newest-first so the console shows the + * latest line at the top. + */ +export function useLogcat(logs: LogEntry[]): UseLogcatResult { + const [query, setQuery] = useState(""); + const [floor, setFloor] = useState("all"); + const [tailing, setTailing] = useState(true); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + const minRank = FLOOR_RANK[floor]; + // Source arrives chronological (oldest first); reverse to newest-first so + // the latest line sits at the TOP and the console follows upward. + return logs + .filter((l) => { + if (LEVEL_RANK[l.level] < minRank) return false; + if (q && !l.msg.toLowerCase().includes(q) && !l.level.includes(q)) return false; + return true; + }) + .reverse(); + }, [logs, query, floor]); + + return { + query, + setQuery, + floor, + setFloor, + tailing, + setTailing, + filtered, + total: logs.length, + }; +} diff --git a/src/index.ts b/src/index.ts index 0d2c78005..8a7b1db1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,6 +137,14 @@ export { type DevToolbarProps, type DevToolbarItem, } from "./components/ui/dev/dev-toolbar/DevToolbar"; +export { + ServiceLogcat, + type ServiceLogcatProps, +} from "./components/ui/dev/service-logcat/ServiceLogcat"; +export type { + LogEntry, + LogLevel, +} from "./components/ui/dev/service-logcat/types"; // --- ui/display --- export {