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.1",
"version": "0.6.2",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
Expand Down
36 changes: 36 additions & 0 deletions src/components/ui/dev/service-logcat/ServiceLogcat.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,42 @@ export const Empty: Story = {
args: { logs: [] },
};

/** Seeds the text filter box on mount — e.g. landing pre-filtered from a deep link. */
export const WithInitialQuery: Story = {
args: { logs: SAMPLE_LOGS, initialQuery: "users" },
};

/** Every user-facing string overridden — e.g. for a non-English locale. */
export const Localized: Story = {
args: {
logs: SAMPLE_LOGS,
labels: {
title: "日誌",
live: "即時",
paused: "已暫停",
floorAll: "全部",
floorWarn: "警告以上",
floorError: "錯誤",
filterAriaLabel: "依層級篩選",
filterPlaceholder: "篩選紀錄",
clearFilterAriaLabel: "清除篩選",
copyAriaLabel: "複製紀錄",
noMatchingEntries: "沒有符合的紀錄。",
request: "請求",
response: "回應",
noRequestResponseBody: "沒有請求或回應內容。",
loadOlderEntries: "載入較舊的紀錄",
entriesLabel: (filteredCount, total, query) => {
const base =
filteredCount === total ? `共 ${total} 筆` : `${total} 筆中的 ${filteredCount} 筆`;
return query ? `${base} · "${query}"` : base;
},
tailingStatus: "即時追蹤中",
pausedStatus: "已暫停",
},
},
};

// Older pages are generated on demand, seq-keyed, and appended at the OLD end
// (the bottom). Scroll near the bottom or click the row to load a page.
const PAGE_SIZE = 20;
Expand Down
99 changes: 77 additions & 22 deletions src/components/ui/dev/service-logcat/ServiceLogcat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,53 @@ export const meta: ComponentMeta = {
"Service log console with severity-floor filter, text filter, live-tail toggle, copy, load-older paging, and an expandable request/response detail per line",
};

/** Every user-facing string ServiceLogcat renders, for callers that need it
* in a locale other than English — all optional, defaulting to the current
* English copy, so passing nothing is fully backward compatible. */
export interface ServiceLogcatLabels {
/** Default: "Logcat" */
title?: string;
/** Live-tail toggle when on. Default: "Live" */
live?: string;
/** Live-tail toggle when off. Default: "Paused" */
paused?: string;
/** Severity-floor option: no filter. Default: "All" */
floorAll?: string;
/** Severity-floor option: warn and above. Default: "Warn+" */
floorWarn?: string;
/** Severity-floor option: errors only. Default: "Errors" */
floorError?: string;
/** aria-label on the severity-floor segmented control. Default: "Filter by level" */
filterAriaLabel?: string;
/** Text-filter input label. Default: "Filter logs" */
filterPlaceholder?: string;
/** aria-label on the clear-filter button. Default: "Clear filter" */
clearFilterAriaLabel?: string;
/** aria-label on the copy-logs button. Default: "Copy logs" */
copyAriaLabel?: string;
/** Shown when the filter matches nothing. Default: "No matching log entries." */
noMatchingEntries?: string;
/** Expanded-row request section heading. Default: "Request" */
request?: string;
/** Expanded-row response section heading. Default: "Response" */
response?: string;
/** Expanded-row body when neither request nor response is present.
* Default: "No request or response body." */
noRequestResponseBody?: string;
/** Load-older-entries row label. Default: "Load older entries" */
loadOlderEntries?: string;
/** Footer entry-count line, e.g. "42 entries" or "5 of 42" plus the active
* query. Called with (filteredCount, total, query). */
entriesLabel?: (filteredCount: number, total: number, query: string) => string;
/** Footer status when tailing. Default: "tailing" */
tailingStatus?: string;
/** Footer status when paused. Default: "paused" */
pausedStatus?: string;
}

const defaultEntriesLabel = (filteredCount: number, total: number, query: string) =>
`${filteredCount === total ? `${total} entries` : `${filteredCount} of ${total}`}${query ? ` · "${query}"` : ""}`;

export interface ServiceLogcatProps {
/** Log entries, chronological (oldest first); the console reverses them so the newest line sits at the top. */
logs: LogEntry[];
Expand All @@ -32,6 +79,12 @@ export interface ServiceLogcatProps {
hasOlder?: boolean;
/** Older page currently loading — shows the row in a loading state and suppresses re-fire. */
loadingOlder?: boolean;
/** Seeds the text filter box on mount (e.g. a module slug, to land pre-filtered
* from a deep link) — after that, the input is normal uncontrolled state. */
initialQuery?: string;
/** Override any/all of the component's own strings for i18n. Unset fields
* fall back to the English default. */
labels?: ServiceLogcatLabels;
}

const LEVEL_BADGE: Record<LogLevel, BadgeVariant> = {
Expand All @@ -48,12 +101,6 @@ const LEVEL_TEXT: Record<LogLevel, string> = {
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 {
Expand Down Expand Up @@ -101,9 +148,16 @@ export function ServiceLogcat({
onLoadOlder,
hasOlder = false,
loadingOlder = false,
initialQuery,
labels,
}: ServiceLogcatProps) {
const floorOptions: readonly { value: LevelFloor; label: string }[] = [
{ value: "all", label: labels?.floorAll ?? "All" },
{ value: "warn", label: labels?.floorWarn ?? "Warn+" },
{ value: "error", label: labels?.floorError ?? "Errors" },
];
const { query, setQuery, floor, setFloor, tailing, setTailing, filtered, total } =
useLogcat(logs);
useLogcat(logs, initialQuery);
const rowKeys = useMemo(() => rowKeysFor(filtered), [filtered]);
const scrollRef = useRef<HTMLDivElement>(null);
const pinnedRef = useRef(true); // is the view currently pinned at the top?
Expand Down Expand Up @@ -190,7 +244,7 @@ export function ServiceLogcat({
return (
<div className="flex h-full min-h-0 flex-col">
<div className="flex items-center gap-2 mb-2">
<SectionLabel>Logcat</SectionLabel>
<SectionLabel>{labels?.title ?? "Logcat"}</SectionLabel>
<button
type="button"
onClick={() => {
Expand All @@ -212,22 +266,22 @@ export function ServiceLogcat({
tailing ? "bg-success animate-pulse" : "bg-on-surface-variant/40",
)}
/>
{tailing ? "Live" : "Paused"}
{tailing ? (labels?.live ?? "Live") : (labels?.paused ?? "Paused")}
</button>
</div>

<div className="flex items-center gap-2 mb-3">
<SegmentedButton
aria-label="Filter by level"
options={FLOOR_OPTIONS}
aria-label={labels?.filterAriaLabel ?? "Filter by level"}
options={floorOptions}
value={floor}
onChange={setFloor}
size="sm"
/>
<div className="flex-1">
<FloatingLabelInput
id="logcat-filter"
label="Filter logs"
label={labels?.filterPlaceholder ?? "Filter logs"}
size="sm"
hideLabel
value={query}
Expand All @@ -236,15 +290,15 @@ export function ServiceLogcat({
</div>
<IconButton
icon="close"
aria-label="Clear filter"
aria-label={labels?.clearFilterAriaLabel ?? "Clear filter"}
variant="filled"
size="md"
disabled={!query}
onClick={() => setQuery("")}
/>
<IconButton
icon="content_copy"
aria-label="Copy logs"
aria-label={labels?.copyAriaLabel ?? "Copy logs"}
variant="tonal"
size="md"
disabled={filtered.length === 0}
Expand All @@ -259,7 +313,7 @@ export function ServiceLogcat({
className="h-full overflow-y-auto p-3 font-mono text-xs"
>
{filtered.length === 0 ? (
<p className="text-on-surface-variant/50 py-6 text-center">No matching log entries.</p>
<p className="text-on-surface-variant/50 py-6 text-center">{labels?.noMatchingEntries ?? "No matching log entries."}</p>
) : (
<div className="space-y-0.5">
{filtered.map((l, i) => {
Expand Down Expand Up @@ -294,10 +348,12 @@ 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">
{l.req_body ? <LogDetail label="Request" body={l.req_body} /> : null}
{l.res_body ? <LogDetail label="Response" body={l.res_body} /> : null}
{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 ? (
<p className="text-on-surface-variant/40">No request or response body.</p>
<p className="text-on-surface-variant/40">
{labels?.noRequestResponseBody ?? "No request or response body."}
</p>
) : null}
</div>
)}
Expand All @@ -315,18 +371,17 @@ export function ServiceLogcat({
onClick={onLoadOlder}
className="mt-1"
>
Load older entries
{labels?.loadOlderEntries ?? "Load older entries"}
</Button>
)}
</div>
</Surface>

<div className="flex items-center justify-between mt-2 px-1 text-xs text-on-surface-variant/50">
<span>
{filtered.length === total ? `${total} entries` : `${filtered.length} of ${total}`}
{query && ` · “${query}”`}
{(labels?.entriesLabel ?? defaultEntriesLabel)(filtered.length, total, query)}
</span>
<span className={cn(tailing && "text-success/70")}>{tailing ? "tailing" : "paused"}</span>
<span className={cn(tailing && "text-success/70")}>{tailing ? (labels?.tailingStatus ?? "tailing") : (labels?.pausedStatus ?? "paused")}</span>
</div>
</div>
);
Expand Down
4 changes: 2 additions & 2 deletions src/components/ui/dev/service-logcat/useLogcat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,8 @@ export interface UseLogcatResult {
* 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("");
export function useLogcat(logs: LogEntry[], initialQuery = ""): UseLogcatResult {
const [query, setQuery] = useState(initialQuery);
const [floor, setFloor] = useState<LevelFloor>("all");
const [tailing, setTailing] = useState(true);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,18 @@ export const Disabled: Story = {
},
};

/** A small red count badge anchored to an option's corner (e.g. an open-items count) */
export const WithBadge: Story = {
args: {
options: [
{ value: "list", label: "List" },
{ value: "issues", label: "Issues", badge: 4 },
],
value: "list",
"aria-label": "Select view",
},
};

/** Boxed/connected track with an inset selected pill */
export const Boxed: Story = {
render: (args) => {
Expand Down
13 changes: 12 additions & 1 deletion src/components/ui/inputs/segmented-button/SegmentedButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export interface SegmentedButtonOption<T extends string = string> {
readonly icon?: string;
/** Visual tone for the unselected state. Defaults to "default". */
readonly tone?: SegmentedButtonOptionTone;
/** Small red count badge anchored to the option's top-right corner (e.g. an
* open-items count). Omit for no badge. */
readonly badge?: string | number;
}

export type SegmentedButtonSize = "sm" | "md";
Expand Down Expand Up @@ -139,7 +142,7 @@ export function SegmentedButton<T extends string = string>({
disabled={disabled}
aria-pressed={value === opt.value}
className={cn(
"font-medium transition-colors inline-flex items-center justify-center gap-1.5",
"relative font-medium transition-colors inline-flex items-center justify-center gap-1.5",
isBoxed ? boxedSizeStyles[size] : sizeStyles[size],
isBoxed
? boxedButtonStyle(value === opt.value, inverse)
Expand All @@ -151,6 +154,14 @@ export function SegmentedButton<T extends string = string>({
>
{opt.icon && <Icon name={opt.icon} size={size === "sm" ? 16 : 18} />}
{opt.label}
{opt.badge != null && (
<span
aria-hidden="true"
className="absolute -right-1.5 -top-1.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-error px-1 text-[10px] font-semibold leading-none text-on-error"
>
{opt.badge}
</span>
)}
</button>
))}
</div>
Expand Down
Loading