Add new components, typography, badge contrast, and screen layout refactors

New element components:
- ListToggle: expand/collapse button using react-icons (RiExpandVerticalLine/RiCollapseVerticalLine)
- LogLevelBadge: extracted from LogEntry, handles all 8 log levels with filled variant and dark/light text contrast

New group components:
- ViewHeader: shared header bar for ConnectedView and UnconnectedView with discriminated union on `connected` prop; MCP logo links to modelcontextprotocol.io
- ServerListControls: combines ListToggle and AddServerMenu, hides toggle when no servers
- LogStreamPanel: extracted from LoggingScreen with scroll containment, filtering by visible levels and search text
- HistoryControls: vertical sidebar with search, method filter, export, and clear
- HistoryListPanel: extracted from HistoryScreen with ListToggle compact mode, filtering by method and search text

Badge contrast improvements:
- AnnotationBadge: filled variant for both dark/light modes; black text in dark, white text in light
- LogLevelBadge: same contrast approach; bold weight for alert/emergency levels
- Badge theme default weight reduced from 700 to 600

Typography:
- Added Fredoka (300-700) as body and heading font
- Added Roboto Mono as monospace font
- Google Fonts loaded in index.html and Storybook previewHead
- Heading weight set to 600

Screen layout refactors:
- LoggingScreen: owns filterText and visibleLevels state; passes to LogControls and LogStreamPanel; removed controls prop in favor of flat props
- HistoryScreen: owns searchText and methodFilter state; passes to HistoryControls and HistoryListPanel for filtering
- Both screens use new ScreenLayout (Flex variant="screen") and Sidebar subcomponents
- Added Flex theme with "screen" variant (overflow: hidden)
- Added Paper theme "panel" variant (flex column, overflow hidden)

Component enhancements:
- ServerCard: added compact prop showing only header row
- HistoryEntry: renamed isExpanded to isListExpanded; internal useState syncs via useEffect; individual entries toggle independently
- LogControls: Select All / Deselect All toggle for level checkboxes; nowrap on level selector group
- ConnectionToggle: LoadingOverlay with rounded corners for connecting state
- ConnectedView: ThemeToggle ActionIcon matched to DisconnectButton height (36px) and border color (outline variant)
- Storybook preview: fullscreen layout wrapper with 100vh overflow hidden

New stories:
- UnconnectedView: Connecting and ManyServers (12 servers for scroll testing)
- ServerCard: CompactConnected and CompactDisconnected
- All new components have corresponding stories

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-03-28 20:38:04 -04:00
commit 961dc283fc
50 changed files with 1602 additions and 790 deletions

5
.claude/settings.json Normal file
View file

@ -0,0 +1,5 @@
{
"enabledPlugins": {
"playwright@claude-plugins-official": true
}
}

1
.gitignore vendored
View file

@ -4,3 +4,4 @@
node_modules node_modules
dist dist
.env .env
/.playwright-mcp/

View file

@ -12,6 +12,12 @@ const config: StorybookConfig = {
"@storybook/addon-docs", "@storybook/addon-docs",
"@storybook/addon-onboarding" "@storybook/addon-onboarding"
], ],
"framework": "@storybook/react-vite" "framework": "@storybook/react-vite",
previewHead: (head) => `
${head}
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300..700&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet" />
`,
}; };
export default config; export default config;

View file

@ -43,14 +43,23 @@ const preview: Preview = {
colorScheme: 'light', colorScheme: 'light',
}, },
decorators: [ decorators: [
(Story, context) => ( (Story, context) => {
<MantineProvider theme={theme} defaultColorScheme="light"> const isFullscreen = context.parameters?.layout === 'fullscreen'
<ColorSchemeWrapper colorScheme={context.globals.colorScheme ?? 'light'}> return (
<Notifications position="top-right" /> <MantineProvider theme={theme} defaultColorScheme="light">
<Story /> <ColorSchemeWrapper colorScheme={context.globals.colorScheme ?? 'light'}>
</ColorSchemeWrapper> <Notifications position="top-right" />
</MantineProvider> {isFullscreen ? (
), <div style={{ height: '100vh', overflow: 'hidden' }}>
<Story />
</div>
) : (
<Story />
)}
</ColorSchemeWrapper>
</MantineProvider>
)
},
], ],
parameters: { parameters: {
controls: { controls: {

View file

@ -4,6 +4,9 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fredoka:wght@300..700&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&display=swap" rel="stylesheet" />
<title>MCP Inspector</title> <title>MCP Inspector</title>
</head> </head>
<body> <body>

View file

@ -14,7 +14,8 @@
"@mantine/hooks": "^8.3.17", "@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17", "@mantine/notifications": "^8.3.17",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "^5.0.1",
@ -5138,6 +5139,15 @@
"react": "^19.2.4" "react": "^19.2.4"
} }
}, },
"node_modules/react-icons": {
"version": "5.6.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.6.0.tgz",
"integrity": "sha512-RH93p5ki6LfOiIt0UtDyNg/cee+HLVR6cHHtW3wALfo+eOHTp8RnU2kRkI6E+H19zMIs03DyxUG/GfZMOGvmiA==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": { "node_modules/react-is": {
"version": "16.13.1", "version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View file

@ -20,7 +20,8 @@
"@mantine/hooks": "^8.3.17", "@mantine/hooks": "^8.3.17",
"@mantine/notifications": "^8.3.17", "@mantine/notifications": "^8.3.17",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4" "react-dom": "^19.2.4",
"react-icons": "^5.6.0"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^5.0.1", "@chromatic-com/storybook": "^5.0.1",

View file

@ -13,16 +13,13 @@ export interface AnnotationBadgeProps {
variant?: AnnotationVariant; variant?: AnnotationVariant;
} }
const variantMap: Record< const colorMap: Record<AnnotationVariant, string> = {
AnnotationVariant, audience: "blue",
{ color: string; variant: string } readOnly: "green",
> = { destructive: "red",
audience: { color: "blue", variant: "outline" }, longRun: "yellow",
readOnly: { color: "green", variant: "filled" }, priority: "orange",
destructive: { color: "red", variant: "filled" }, default: "gray",
longRun: { color: "yellow", variant: "filled" },
priority: { color: "orange", variant: "filled" },
default: { color: "gray", variant: "light" },
}; };
export function AnnotationBadge({ export function AnnotationBadge({
@ -30,15 +27,10 @@ export function AnnotationBadge({
variant = "default", variant = "default",
}: AnnotationBadgeProps) { }: AnnotationBadgeProps) {
const colorScheme = useComputedColorScheme(); const colorScheme = useComputedColorScheme();
const { color, variant: badgeVariant } = variantMap[variant]; const color = colorMap[variant];
const textColor = const textColor = colorScheme === "dark" ? "black" : "white";
(variant === "priority" || variant === "destructive") &&
colorScheme === "dark"
? "black"
: undefined;
return ( return (
<Badge color={color} variant={badgeVariant} c={textColor}> <Badge color={color} variant="filled" fw={500} c={textColor}>
{label} {label}
</Badge> </Badge>
); );

View file

@ -0,0 +1,25 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { ListToggle } from "./ListToggle";
const meta: Meta<typeof ListToggle> = {
title: "Elements/ListToggle",
component: ListToggle,
};
export default meta;
type Story = StoryObj<typeof ListToggle>;
export const Expanded: Story = {
args: {
compact: false,
onToggle: fn(),
},
};
export const Collapsed: Story = {
args: {
compact: true,
onToggle: fn(),
},
};

View file

@ -0,0 +1,17 @@
import { Button } from "@mantine/core";
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
export interface ListToggleProps {
compact: boolean;
onToggle: () => void;
}
export function ListToggle({ compact, onToggle }: ListToggleProps) {
const Icon = compact ? RiExpandVerticalLine : RiCollapseVerticalLine;
return (
<Button size="sm" onClick={onToggle}>
<Icon size={20} />
</Button>
);
}

View file

@ -1,4 +1,5 @@
import { Badge, Group, Text } from "@mantine/core"; import { Group, Text } from "@mantine/core";
import { LogLevelBadge } from "../LogLevelBadge/LogLevelBadge";
export type LogLevel = export type LogLevel =
| "debug" | "debug"
@ -17,28 +18,6 @@ export interface LogEntryProps {
logger?: string; logger?: string;
} }
const levelBadgeColor: Record<LogLevel, string> = {
debug: "gray",
info: "blue",
notice: "teal",
warning: "yellow",
error: "red",
critical: "red",
alert: "red",
emergency: "red",
};
const levelBadgeVariant: Record<LogLevel, string> = {
debug: "light",
info: "light",
notice: "light",
warning: "light",
error: "light",
critical: "filled",
alert: "filled",
emergency: "filled",
};
const levelMessageColor: Record<LogLevel, string | undefined> = { const levelMessageColor: Record<LogLevel, string | undefined> = {
debug: "dimmed", debug: "dimmed",
info: "blue", info: "blue",
@ -50,14 +29,6 @@ const levelMessageColor: Record<LogLevel, string | undefined> = {
emergency: "red", emergency: "red",
}; };
function isBoldLevel(level: LogLevel): boolean {
return level === "alert" || level === "emergency";
}
function getBoldWeight(level: LogLevel): number | undefined {
return isBoldLevel(level) ? 700 : undefined;
}
function formatLogger(logger: string): string { function formatLogger(logger: string): string {
return `[${logger}]`; return `[${logger}]`;
} }
@ -70,6 +41,7 @@ const TimestampText = Text.withProps({
const LoggerText = Text.withProps({ const LoggerText = Text.withProps({
size: "xs", size: "xs",
ff: "monospace",
c: "dimmed", c: "dimmed",
}); });
@ -77,15 +49,9 @@ export function LogEntry({ timestamp, level, message, logger }: LogEntryProps) {
return ( return (
<Group gap="sm" wrap="nowrap"> <Group gap="sm" wrap="nowrap">
<TimestampText>{timestamp}</TimestampText> <TimestampText>{timestamp}</TimestampText>
<Badge <LogLevelBadge level={level} />
color={levelBadgeColor[level]}
variant={levelBadgeVariant[level]}
fw={getBoldWeight(level)}
>
{level}
</Badge>
{logger && <LoggerText>{formatLogger(logger)}</LoggerText>} {logger && <LoggerText>{formatLogger(logger)}</LoggerText>}
<Text size="sm" c={levelMessageColor[level]}> <Text size="sm" ff="monospace" c={levelMessageColor[level]}>
{message} {message}
</Text> </Text>
</Group> </Group>

View file

@ -0,0 +1,19 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { LogLevelBadge } from "./LogLevelBadge";
const meta: Meta<typeof LogLevelBadge> = {
title: "Elements/LogLevelBadge",
component: LogLevelBadge,
};
export default meta;
type Story = StoryObj<typeof LogLevelBadge>;
export const Debug: Story = { args: { level: "debug" } };
export const Info: Story = { args: { level: "info" } };
export const Notice: Story = { args: { level: "notice" } };
export const Warning: Story = { args: { level: "warning" } };
export const Error: Story = { args: { level: "error" } };
export const Critical: Story = { args: { level: "critical" } };
export const Alert: Story = { args: { level: "alert" } };
export const Emergency: Story = { args: { level: "emergency" } };

View file

@ -0,0 +1,31 @@
import { Badge, useComputedColorScheme } from "@mantine/core";
import type { LogLevel } from "../LogEntry/LogEntry";
export interface LogLevelBadgeProps {
level: LogLevel;
}
const levelColor: Record<LogLevel, string> = {
debug: "gray",
info: "blue",
notice: "teal",
warning: "yellow",
error: "red",
critical: "red",
alert: "red",
emergency: "red",
};
const boldLevels: Set<LogLevel> = new Set(["alert", "emergency"]);
export function LogLevelBadge({ level }: LogLevelBadgeProps) {
const colorScheme = useComputedColorScheme();
const textColor = colorScheme === "dark" ? "black" : "white";
const fw = boldLevels.has(level) ? 500 : undefined;
return (
<Badge color={levelColor[level]} variant="filled" fw={fw} c={textColor}>
{level}
</Badge>
);
}

View file

@ -0,0 +1,32 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { HistoryControls } from "./HistoryControls";
const meta: Meta<typeof HistoryControls> = {
title: "Groups/HistoryControls",
component: HistoryControls,
};
export default meta;
type Story = StoryObj<typeof HistoryControls>;
export const Default: Story = {
args: {
searchText: "",
onSearchChange: fn(),
onMethodFilterChange: fn(),
onClearAll: fn(),
onExport: fn(),
},
};
export const WithFilters: Story = {
args: {
searchText: "tools",
methodFilter: "tools/call",
onSearchChange: fn(),
onMethodFilterChange: fn(),
onClearAll: fn(),
onExport: fn(),
},
};

View file

@ -0,0 +1,62 @@
import { Button, Group, Select, Stack, TextInput, Title } from "@mantine/core";
const METHOD_OPTIONS = [
"tools/call",
"tools/list",
"resources/read",
"resources/list",
"prompts/get",
"prompts/list",
"sampling/createMessage",
"elicitation/create",
];
const ToolbarButton = Button.withProps({
variant: "light",
size: "sm",
});
export interface HistoryControlsProps {
searchText: string;
methodFilter?: string;
onSearchChange: (text: string) => void;
onMethodFilterChange: (method: string) => void;
onClearAll: () => void;
onExport: () => void;
}
export function HistoryControls({
searchText,
methodFilter,
onSearchChange,
onMethodFilterChange,
onClearAll,
onExport,
}: HistoryControlsProps) {
return (
<Stack gap="md">
<Title order={4}>History</Title>
<Title order={5}>Search</Title>
<TextInput
placeholder="Search..."
value={searchText}
onChange={(event) => onSearchChange(event.currentTarget.value)}
/>
<Title order={6}>Filter by Method</Title>
<Select
placeholder="All methods"
data={METHOD_OPTIONS}
value={methodFilter}
onChange={(value) => onMethodFilterChange(value ?? "")}
clearable
/>
<Group>
<ToolbarButton onClick={onExport}>Export JSON</ToolbarButton>
<ToolbarButton onClick={onClearAll}>Clear All</ToolbarButton>
</Group>
</Stack>
);
}

View file

@ -6,7 +6,6 @@ const meta: Meta<typeof HistoryEntry> = {
title: "Groups/HistoryEntry", title: "Groups/HistoryEntry",
component: HistoryEntry, component: HistoryEntry,
args: { args: {
onToggleExpand: fn(),
onReplay: fn(), onReplay: fn(),
onTogglePin: fn(), onTogglePin: fn(),
}, },
@ -23,7 +22,7 @@ export const SuccessCollapsed: Story = {
status: "success", status: "success",
durationMs: 142, durationMs: 142,
isPinned: false, isPinned: false,
isExpanded: false, isListExpanded: false,
}, },
}; };
@ -35,7 +34,7 @@ export const SuccessExpanded: Story = {
status: "success", status: "success",
durationMs: 142, durationMs: 142,
isPinned: false, isPinned: false,
isExpanded: true, isListExpanded: true,
parameters: { parameters: {
city: "San Francisco", city: "San Francisco",
units: "celsius", units: "celsius",
@ -56,7 +55,7 @@ export const Error: Story = {
status: "error", status: "error",
durationMs: 3200, durationMs: 3200,
isPinned: false, isPinned: false,
isExpanded: false, isListExpanded: false,
}, },
}; };
@ -68,7 +67,7 @@ export const WithChildren: Story = {
status: "success", status: "success",
durationMs: 1250, durationMs: 1250,
isPinned: false, isPinned: false,
isExpanded: true, isListExpanded: true,
parameters: { parameters: {
action: "process", action: "process",
}, },
@ -102,7 +101,6 @@ export const Pinned: Story = {
status: "success", status: "success",
durationMs: 45, durationMs: 45,
isPinned: true, isPinned: true,
pinLabel: "Baseline", isListExpanded: false,
isExpanded: false,
}, },
}; };

View file

@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { import {
Badge, Badge,
Button, Button,
Card, Card,
Collapse, Collapse,
Divider,
Group, Group,
Stack, Stack,
Text, Text,
@ -27,9 +29,7 @@ export interface HistoryEntryProps {
response?: Record<string, unknown>; response?: Record<string, unknown>;
childEntries?: HistoryChildEntry[]; childEntries?: HistoryChildEntry[];
isPinned: boolean; isPinned: boolean;
pinLabel?: string; isListExpanded: boolean;
isExpanded: boolean;
onToggleExpand: () => void;
onReplay: () => void; onReplay: () => void;
onTogglePin: () => void; onTogglePin: () => void;
} }
@ -82,9 +82,8 @@ function statusColor(status: "success" | "error"): string {
return status === "success" ? "green" : "red"; return status === "success" ? "green" : "red";
} }
function formatPinLabel(isPinned: boolean, pinLabel?: string): string { function formatPinLabel(isPinned: boolean): string {
const base = isPinned ? "Unpin" : "Pin"; return isPinned ? "Unpin" : "Pin";
return isPinned && pinLabel ? `${base} (${pinLabel})` : base;
} }
function serializeJson(value: Record<string, unknown>): string { function serializeJson(value: Record<string, unknown>): string {
@ -101,12 +100,15 @@ export function HistoryEntry({
response, response,
childEntries, childEntries,
isPinned, isPinned,
pinLabel, isListExpanded,
isExpanded,
onToggleExpand,
onReplay, onReplay,
onTogglePin, onTogglePin,
}: HistoryEntryProps) { }: HistoryEntryProps) {
const [isExpanded, setIsExpanded] = useState(isListExpanded);
useEffect(() => {
setIsExpanded(isListExpanded);
}, [isListExpanded]);
return ( return (
<EntryContainer> <EntryContainer>
<Stack gap="sm"> <Stack gap="sm">
@ -127,55 +129,60 @@ export function HistoryEntry({
<Group gap="xs"> <Group gap="xs">
<SubtleButton onClick={onReplay}>Replay</SubtleButton> <SubtleButton onClick={onReplay}>Replay</SubtleButton>
<SubtleButton onClick={onTogglePin}> <SubtleButton onClick={onTogglePin}>
{formatPinLabel(isPinned, pinLabel)} {formatPinLabel(isPinned)}
</SubtleButton> </SubtleButton>
<SubtleButton onClick={onToggleExpand} ml="auto"> <SubtleButton onClick={() => setIsExpanded((v) => !v)} ml="auto">
{isExpanded ? "Collapse" : "Expand"} {isExpanded ? "Collapse" : "Expand"}
</SubtleButton> </SubtleButton>
</Group> </Group>
<Collapse in={isExpanded}> {isExpanded && (
<Stack gap="sm"> <Collapse in={isExpanded}>
{parameters && ( <Stack gap="sm">
<Stack gap="xs"> {parameters && (
<Text size="sm">Parameters:</Text> <>
<ContentViewer <Divider />
type="json" <Stack gap="xs">
content={serializeJson(parameters)} <Text size="sm">Parameters:</Text>
copyable <ContentViewer
/> type="json"
</Stack> content={serializeJson(parameters)}
)} copyable
{response && ( />
<Stack gap="xs"> </Stack>
<Text size="sm">Response:</Text> </>
<ContentViewer )}
type="json" {response && (
content={serializeJson(response)} <Stack gap="xs">
copyable <Text size="sm">Response:</Text>
/> <ContentViewer
</Stack> type="json"
)} content={serializeJson(response)}
{childEntries && childEntries.length > 0 && ( copyable
<Stack gap="xs"> />
{childEntries.map((child, index) => ( </Stack>
<Group key={index} pl="lg" gap="sm"> )}
<TimestampText>+-- </TimestampText> {childEntries && childEntries.length > 0 && (
<TimestampText>{child.timestamp}</TimestampText> <Stack gap="xs">
<ChildMethodBadge>{child.method}</ChildMethodBadge> {childEntries.map((child, index) => (
{child.target && <Text size="sm">{child.target}</Text>} <Group key={index} pl="lg" gap="sm">
<Badge color={statusColor(child.status)} size="sm"> <TimestampText>+-- </TimestampText>
{formatStatusLabel(child.status)} <TimestampText>{child.timestamp}</TimestampText>
</Badge> <ChildMethodBadge>{child.method}</ChildMethodBadge>
<DurationText> {child.target && <Text size="sm">{child.target}</Text>}
{formatDuration(child.durationMs)} <Badge color={statusColor(child.status)} size="sm">
</DurationText> {formatStatusLabel(child.status)}
</Group> </Badge>
))} <DurationText>
</Stack> {formatDuration(child.durationMs)}
)} </DurationText>
</Stack> </Group>
</Collapse> ))}
</Stack>
)}
</Stack>
</Collapse>
)}
</Stack> </Stack>
</EntryContainer> </EntryContainer>
); );

View file

@ -0,0 +1,70 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { HistoryListPanel } from "./HistoryListPanel.js";
import type { HistoryEntryProps } from "../HistoryEntry/HistoryEntry";
const meta: Meta<typeof HistoryListPanel> = {
title: "Groups/HistoryListPanel",
component: HistoryListPanel,
args: {
searchText: "",
},
};
export default meta;
type Story = StoryObj<typeof HistoryListPanel>;
function makeEntry(overrides: Partial<HistoryEntryProps>): HistoryEntryProps {
return {
timestamp: "2026-03-17T10:00:00Z",
method: "tools/call",
target: "send_message",
status: "success",
durationMs: 120,
isPinned: false,
isListExpanded: false,
onReplay: fn(),
onTogglePin: fn(),
...overrides,
};
}
export const WithEntries: Story = {
args: {
entries: [
makeEntry({
timestamp: "2026-03-17T10:00:00Z",
method: "tools/call",
target: "send_message",
}),
makeEntry({
timestamp: "2026-03-17T10:01:00Z",
method: "resources/read",
target: "config.json",
durationMs: 45,
}),
makeEntry({
timestamp: "2026-03-17T10:02:00Z",
method: "tools/call",
target: "delete_records",
status: "error",
durationMs: 350,
}),
],
pinnedEntries: [
makeEntry({
timestamp: "2026-03-17T09:30:00Z",
method: "tools/list",
isPinned: true,
durationMs: 80,
}),
],
},
};
export const Empty: Story = {
args: {
entries: [],
pinnedEntries: [],
},
};

View file

@ -0,0 +1,133 @@
import { useMemo, useState } from "react";
import {
Group,
Paper,
ScrollArea,
Stack,
Text,
Title,
} from "@mantine/core";
import { HistoryEntry } from "../HistoryEntry/HistoryEntry";
import { ListToggle } from "../../elements/ListToggle/ListToggle";
import type { HistoryEntryProps } from "../HistoryEntry/HistoryEntry";
export interface HistoryRequestsPanelProps {
entries: HistoryEntryProps[];
pinnedEntries: HistoryEntryProps[];
searchText: string;
methodFilter?: string;
}
const PanelContainer = Paper.withProps({
withBorder: true,
p: "lg",
flex: 1,
variant: "panel",
});
const EmptyState = Text.withProps({
c: "dimmed",
ta: "center",
py: "xl",
});
function entryKey(entry: HistoryEntryProps): string {
return `${entry.timestamp}-${entry.method}`;
}
function formatPinnedTitle(count: number): string {
return `Pinned Requests (${count})`;
}
function formatHistoryTitle(count: number): string {
return `History (${count})`;
}
function matchesFilters(
entry: HistoryEntryProps,
searchText: string,
methodFilter?: string,
): boolean {
if (methodFilter && entry.method !== methodFilter) return false;
if (searchText) {
const term = searchText.toLowerCase();
const searchable =
`${entry.method} ${entry.target ?? ""} ${entry.timestamp}`.toLowerCase();
if (!searchable.includes(term)) return false;
}
return true;
}
export function HistoryListPanel({
entries,
pinnedEntries,
searchText,
methodFilter,
}: HistoryRequestsPanelProps) {
const [compact, setCompact] = useState(false);
const filteredEntries = useMemo(
() => entries.filter((e) => matchesFilters(e, searchText, methodFilter)),
[entries, searchText, methodFilter],
);
const filteredPinned = useMemo(
() =>
pinnedEntries.filter((e) => matchesFilters(e, searchText, methodFilter)),
[pinnedEntries, searchText, methodFilter],
);
const hasResults = filteredEntries.length > 0 || filteredPinned.length > 0;
return (
<PanelContainer>
<Group justify="space-between" mb="sm">
<Title order={4}>Requests</Title>
{hasResults && (
<ListToggle
compact={compact}
onToggle={() => setCompact((c) => !c)}
/>
)}
</Group>
{!hasResults ? (
<EmptyState>No request history</EmptyState>
) : (
<ScrollArea.Autosize mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)">
<Stack gap="md">
{filteredPinned.length > 0 && (
<>
<Title order={5}>
{formatPinnedTitle(filteredPinned.length)}
</Title>
{filteredPinned.map((entry) => (
<HistoryEntry
key={entryKey(entry)}
{...entry}
isListExpanded={!compact}
/>
))}
</>
)}
{filteredEntries.length > 0 && (
<>
<Title order={5}>
{formatHistoryTitle(filteredEntries.length)}
</Title>
{filteredEntries.map((entry) => (
<HistoryEntry
key={entryKey(entry)}
{...entry}
isListExpanded={!compact}
/>
))}
</>
)}
</Stack>
</ScrollArea.Autosize>
)}
</PanelContainer>
);
}

View file

@ -36,6 +36,11 @@ const ToolbarButton = Button.withProps({
size: "sm", size: "sm",
}); });
const SubtleButton = Button.withProps({
variant: "subtle",
size: "xs",
});
export interface LogControlsProps { export interface LogControlsProps {
currentLevel: string; currentLevel: string;
filterText: string; filterText: string;
@ -43,6 +48,7 @@ export interface LogControlsProps {
onSetLevel: (level: string) => void; onSetLevel: (level: string) => void;
onFilterChange: (text: string) => void; onFilterChange: (text: string) => void;
onToggleLevel: (level: string, visible: boolean) => void; onToggleLevel: (level: string, visible: boolean) => void;
onToggleAllLevels: () => void;
onClear: () => void; onClear: () => void;
onExport: () => void; onExport: () => void;
} }
@ -54,13 +60,16 @@ export function LogControls({
onSetLevel, onSetLevel,
onFilterChange, onFilterChange,
onToggleLevel, onToggleLevel,
onToggleAllLevels,
onClear, onClear,
onExport, onExport,
}: LogControlsProps) { }: LogControlsProps) {
return ( return (
<Stack gap="md"> <Stack gap="md">
<Title order={5}>Log Level</Title> <Title order={4}>Logging</Title>
<Group>
<Title order={5}>Active Level</Title>
<Group wrap="nowrap">
<Select <Select
data={LOG_LEVELS.map((level) => ({ value: level, label: level }))} data={LOG_LEVELS.map((level) => ({ value: level, label: level }))}
value={currentLevel} value={currentLevel}
@ -69,18 +78,25 @@ export function LogControls({
}} }}
/> />
<Button size="sm" onClick={() => onSetLevel(currentLevel)}> <Button size="sm" onClick={() => onSetLevel(currentLevel)}>
Set Level Set
</Button> </Button>
</Group> </Group>
<Title order={5}>Filter</Title> <Title order={5}>Search</Title>
<TextInput <TextInput
placeholder="Search logs..." placeholder="Search logs..."
value={filterText} value={filterText}
onChange={(e) => onFilterChange(e.currentTarget.value)} onChange={(e) => onFilterChange(e.currentTarget.value)}
/> />
<Title order={5}>Show Levels</Title> <Group justify="space-between">
<Title order={5}>Filter by Level</Title>
<SubtleButton onClick={onToggleAllLevels}>
{Object.values(visibleLevels).every(Boolean)
? "Deselect All"
: "Select All"}
</SubtleButton>
</Group>
{LOG_LEVELS.map((level) => { {LOG_LEVELS.map((level) => {
const style = LEVEL_COLORS[level]; const style = LEVEL_COLORS[level];
return ( return (

Some files were not shown because too many files have changed in this diff Show more