Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
18 changes: 18 additions & 0 deletions src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof ServiceLogcat> = {
Expand Down Expand Up @@ -87,6 +103,8 @@ export const Localized: Story = {
filterPlaceholder: "篩選紀錄",
clearFilterAriaLabel: "清除篩選",
copyAriaLabel: "複製紀錄",
copyEntryAriaLabel: "複製此紀錄",
copiedAriaLabel: "已複製",
noMatchingEntries: "沒有符合的紀錄。",
request: "請求",
response: "回應",
Expand Down
86 changes: 79 additions & 7 deletions src/components/ui/dev/service-logcat/ServiceLogcat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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" */
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -167,6 +188,27 @@ export function ServiceLogcat({
const [openKey, setOpenKey] = useState<string | null>(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<string | null>(null);
const copyTimerRef = useRef<ReturnType<typeof setTimeout>>(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;
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -310,6 +352,13 @@ export function ServiceLogcat({
<div
ref={scrollRef}
onScroll={onScroll}
// overflow-anchor:none — the browser's own scroll anchoring would also
// shift scrollTop when new lines prepend at the TOP, double-counting the
// manual compensation in the layout effect below and jumping the view;
// we own that offset. Load-older pages append at the BOTTOM (below the
// viewport, where anchoring never adjusts), so paging keeps the read
// position untouched either way.
style={{ overflowAnchor: "none" }}
className="h-full overflow-y-auto p-3 font-mono text-xs"
>
{filtered.length === 0 ? (
Expand All @@ -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 (
<div key={key}>
<div
Expand All @@ -335,11 +387,11 @@ export function ServiceLogcat({
{fmtTs(l.ts)}
</span>
<span className="shrink-0">
<Badge variant={LEVEL_BADGE[l.level]} size="sm">
{l.level}
<Badge variant={LEVEL_BADGE[level]} size="sm">
{level}
</Badge>
</span>
<span className={cn("flex-1 break-all", LEVEL_TEXT[l.level])}>{l.msg}</span>
<span className={cn("flex-1 break-all", LEVEL_TEXT[level])}>{l.msg}</span>
{l.duration_ms != null && (
<span className="text-on-surface-variant/40 shrink-0 tabular-nums">
{l.duration_ms}ms
Expand All @@ -348,6 +400,26 @@ export function ServiceLogcat({
</div>
{open && (
<div className="mb-1 mt-0.5 ml-4 space-y-1.5 border-l border-outline-variant/40 pl-3">
{/* 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. */}
<div className="flex justify-end">
<IconButton
icon={copiedKey === key ? "check" : "content_copy"}
variant="text"
size="sm"
aria-label={
copiedKey === key
? (labels?.copiedAriaLabel ?? "Copied")
: (labels?.copyEntryAriaLabel ?? "Copy this log entry")
}
onClick={() => copyEntry(key, l)}
className={cn(
"shrink-0",
copiedKey === key ? "text-success" : "text-on-surface-variant",
)}
/>
</div>
{l.req_body ? <LogDetail label={labels?.request ?? "Request"} body={l.req_body} /> : null}
{l.res_body ? <LogDetail label={labels?.response ?? "Response"} body={l.res_body} /> : null}
{!l.req_body && !l.res_body ? (
Expand Down
23 changes: 23 additions & 0 deletions src/components/ui/dev/service-logcat/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
15 changes: 10 additions & 5 deletions src/components/ui/dev/service-logcat/useLogcat.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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();
Expand Down
Loading