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:
parent
dd46acba87
commit
961dc283fc
50 changed files with 1602 additions and 790 deletions
5
.claude/settings.json
Normal file
5
.claude/settings.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"enabledPlugins": {
|
||||||
|
"playwright@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -4,3 +4,4 @@
|
||||||
node_modules
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
/.playwright-mcp/
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
12
clients/web/package-lock.json
generated
12
clients/web/package-lock.json
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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" } };
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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
Loading…
Reference in a new issue