diff --git a/package.json b/package.json index dfade9553..a36dcf44d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@mirrorstack-ai/web-ui-kit", "packageManager": "pnpm@10.29.3", - "version": "0.6.3", + "version": "0.6.4", "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 index b3b828d6c..64dd900de 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx @@ -32,6 +32,22 @@ const SAMPLE_LOGS: LogEntry[] = [ 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 }, + // Status-driven severity: logged at "info" but a structured 500 — the badge + + // row escalate to error, and the row is expandable (per-log copy demo). + { + ts: "2026-06-30T16:23:50.771244000Z", + level: "info", + msg: "POST /public/complete 500", + method: "POST", + path: "/public/complete", + status: 500, + duration_ms: 132, + req_body: '{"code":"authz_grant_9f","state":"xR7"}', + res_body: '{"error":"internal","detail":"token exchange failed: upstream 503"}', + }, + // Status-driven severity: "info" line whose only status signal is a trailing + // 4xx code in the message — escalates to warn. + { ts: "2026-06-30T16:23:51.402990000Z", level: "info", msg: "GET /api/items/nope 404", duration_ms: 6 }, ]; const meta: Meta = { @@ -87,6 +103,8 @@ export const Localized: Story = { filterPlaceholder: "篩選紀錄", clearFilterAriaLabel: "清除篩選", copyAriaLabel: "複製紀錄", + copyEntryAriaLabel: "複製此紀錄", + copiedAriaLabel: "已複製", noMatchingEntries: "沒有符合的紀錄。", request: "請求", response: "回應", diff --git a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx index e06b15d1f..77ba8a45e 100644 --- a/src/components/ui/dev/service-logcat/ServiceLogcat.tsx +++ b/src/components/ui/dev/service-logcat/ServiceLogcat.tsx @@ -8,9 +8,10 @@ import { SegmentedButton } from "@/components/ui/inputs/segmented-button/Segment import { Surface } from "@/components/ui/surfaces/surface/Surface"; import { cn } from "@/utils/cn"; import type { ComponentMeta } from "@/types/component-meta"; -import type { - LogEntry, - LogLevel, +import { + effectiveLevel, + type LogEntry, + type LogLevel, } from "@/components/ui/dev/service-logcat/types"; import { useLogcat, @@ -47,6 +48,10 @@ export interface ServiceLogcatLabels { clearFilterAriaLabel?: string; /** aria-label on the copy-logs button. Default: "Copy logs" */ copyAriaLabel?: string; + /** aria-label on an expanded row's copy-this-entry button. Default: "Copy this log entry" */ + copyEntryAriaLabel?: string; + /** aria-label on a copy-this-entry button in its just-copied state. Default: "Copied" */ + copiedAriaLabel?: string; /** Shown when the filter matches nothing. Default: "No matching log entries." */ noMatchingEntries?: string; /** Expanded-row request section heading. Default: "Request" */ @@ -108,6 +113,22 @@ function fmtTs(ts: string): string { return m ? `${m[1]} ${m[2]}.${m[3]}` : ts; } +// Header line shared by per-log copy and copy-all: timestamp + effective +// severity (not the raw level — an HTTP failure logged at "info" still reads +// as an error) + message. +function formatHeader(l: LogEntry): string { + return `${l.ts} ${effectiveLevel(l).toUpperCase()} ${l.msg}`; +} + +// Single-entry copy payload: header line plus any expanded request/response +// bodies, pretty-printed to match the detail view. +function formatEntry(l: LogEntry): string { + const parts = [formatHeader(l)]; + if (l.req_body) parts.push(`Request:\n${prettyJson(l.req_body)}`); + if (l.res_body) parts.push(`Response:\n${prettyJson(l.res_body)}`); + return parts.join("\n"); +} + // Auto-fire distance from the bottom (old end) for load-older, mirroring the // 24px pinned-at-top threshold with room for the load-older row itself. const LOAD_OLDER_NEAR_PX = 48; @@ -167,6 +188,27 @@ export function ServiceLogcat({ const [openKey, setOpenKey] = useState(null); const toggleRow = (key: string) => setOpenKey((cur) => (cur === key ? null : key)); + // Per-log copy: which row last copied (drives its check/text-success flash), + // cleared ~2s later. Mirrors the copy affordance in ReadOnlyField. + const [copiedKey, setCopiedKey] = useState(null); + const copyTimerRef = useRef>(null); + useEffect( + () => () => { + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + }, + [], + ); + const copyEntry = (key: string, l: LogEntry) => { + navigator.clipboard + ?.writeText(formatEntry(l)) + .then(() => { + setCopiedKey(key); + if (copyTimerRef.current) clearTimeout(copyTimerRef.current); + copyTimerRef.current = setTimeout(() => setCopiedKey(null), 2000); + }) + .catch(() => {}); + }; + const scrollToTop = () => { const el = scrollRef.current; if (!el) return; @@ -237,7 +279,7 @@ export function ServiceLogcat({ }, [tailing, filtered, rowKeys, openKey]); const copyAll = () => { - const text = filtered.map((l) => `${l.ts} ${l.level.toUpperCase()} ${l.msg}`).join("\n"); + const text = filtered.map(formatHeader).join("\n"); void navigator.clipboard?.writeText(text); }; @@ -310,6 +352,13 @@ export function ServiceLogcat({
{filtered.length === 0 ? ( @@ -321,6 +370,9 @@ export function ServiceLogcat({ const hasDetail = l.method != null || l.req_body != null || l.res_body != null; const open = openKey === key; + // Escalate badge + row colour by HTTP status (a 500 logged at + // "info" still reads as an error), falling back to entry.level. + const level = effectiveLevel(l); return (
- - {l.level} + + {level} - {l.msg} + {l.msg} {l.duration_ms != null && ( {l.duration_ms}ms @@ -348,6 +400,26 @@ export function ServiceLogcat({
{open && (
+ {/* Copy just this entry. Lives in the detail region — a + sibling of the toggle above, not inside it — so copying + never collapses the row. Global copy-all is unchanged. */} +
+ copyEntry(key, l)} + className={cn( + "shrink-0", + copiedKey === key ? "text-success" : "text-on-surface-variant", + )} + /> +
{l.req_body ? : null} {l.res_body ? : null} {!l.req_body && !l.res_body ? ( diff --git a/src/components/ui/dev/service-logcat/types.ts b/src/components/ui/dev/service-logcat/types.ts index f7df464e2..8a1a08114 100644 --- a/src/components/ui/dev/service-logcat/types.ts +++ b/src/components/ui/dev/service-logcat/types.ts @@ -25,3 +25,26 @@ export interface LogEntry { res_body?: string; duration_ms?: number; } + +// HTTP status for an entry: the structured `status` field when present, else a +// standalone 3-digit 1xx–5xx token parsed from the message ("POST /x 500" -> +// 500). The `\s|$` guard skips embedded numbers like durations ("412ms"). +export function statusOf(l: LogEntry): number | undefined { + if (l.status != null) return l.status; + const m = l.msg.match(/(?:^|\s)([1-5]\d{2})(?=\s|$)/); + return m ? Number(m[1]) : undefined; +} + +// Effective severity: an HTTP failure escalates the badge + colour (and the +// severity-floor filter) even when the line was recorded at "info" (a 500 is +// an error however it was logged). Status >=500 -> error, 400-499 -> warn, +// otherwise the entry's own level. Shared by ServiceLogcat (render) and +// useLogcat (filter) so both agree on an entry's severity. +export function effectiveLevel(l: LogEntry): LogLevel { + const status = statusOf(l); + if (status != null) { + if (status >= 500) return "error"; + if (status >= 400) return "warn"; + } + return l.level; +} diff --git a/src/components/ui/dev/service-logcat/useLogcat.ts b/src/components/ui/dev/service-logcat/useLogcat.ts index 5531ef832..84bd0636c 100644 --- a/src/components/ui/dev/service-logcat/useLogcat.ts +++ b/src/components/ui/dev/service-logcat/useLogcat.ts @@ -1,7 +1,8 @@ import { useMemo, useState } from "react"; -import type { - LogEntry, - LogLevel, +import { + effectiveLevel, + type LogEntry, + type LogLevel, } from "@/components/ui/dev/service-logcat/types"; /** @@ -44,8 +45,12 @@ export function useLogcat(logs: LogEntry[], initialQuery = ""): UseLogcatResult // 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; + // Rank and match on the EFFECTIVE severity (escalated by HTTP status), + // not the raw level — otherwise an "info" line carrying a 500 renders + // a red error badge yet vanishes under the "Errors" floor. + const level = effectiveLevel(l); + if (LEVEL_RANK[level] < minRank) return false; + if (q && !l.msg.toLowerCase().includes(q) && !level.includes(q)) return false; return true; }) .reverse();