Merge remote-tracking branch 'origin/v2/main' into 1579-wave-1
# Conflicts: # clients/cli/package-lock.json # clients/cli/package.json # clients/web/src/App.tsx # clients/web/src/lib/environmentFactory.test.ts # clients/web/src/lib/environmentFactory.ts # clients/web/src/lib/remoteOAuthStorage.test.ts # clients/web/src/lib/remoteOAuthStorage.ts # clients/web/src/test/core/auth/connection-state.test.ts # clients/web/src/test/core/auth/providers.test.ts # clients/web/src/test/core/auth/storage-browser.test.ts # clients/web/src/test/core/auth/storage-remote.test.ts # clients/web/src/test/core/mcp/oauthManager.test.ts # clients/web/src/test/integration/auth/node/storage.test.ts # clients/web/src/test/integration/storage/adapters.test.ts # clients/web/src/utils/clearServerOAuthState.test.ts # clients/web/src/utils/clearServerOAuthState.ts # core/auth/connection-state.ts # core/auth/oauth-storage.ts # core/auth/providers.ts # core/auth/storage.ts # core/auth/store.ts # core/storage/adapters/remote-storage.ts
This commit is contained in:
commit
32a3610737
14 changed files with 5046 additions and 3271 deletions
|
|
@ -208,6 +208,54 @@ describe("HistoryEntry", () => {
|
|||
expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand"));
|
||||
});
|
||||
|
||||
it("renders the compact two-line layout with Replay as an icon when embedded", () => {
|
||||
renderWithMantine(
|
||||
<HistoryEntry {...baseProps} entry={successEntry} embedded />,
|
||||
);
|
||||
// Line 1 essentials plus the method are still shown. The timestamp is the
|
||||
// compact time-only form (not the full ISO) to fit the narrow line.
|
||||
expect(screen.getByText("10:30:00")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("2026-03-17T10:30:00.000Z"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("client → server")).toBeInTheDocument();
|
||||
expect(screen.getByText("142ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("OK")).toBeInTheDocument();
|
||||
expect(screen.getByText("tools/call")).toBeInTheDocument();
|
||||
// Replay is an icon button (aria-label), not the text button.
|
||||
expect(screen.getByRole("button", { name: "Replay" })).toBeInTheDocument();
|
||||
expect(screen.queryByText("Replay")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps action order Replay, Pin, Expand in the compact layout", () => {
|
||||
renderWithMantine(
|
||||
<HistoryEntry {...baseProps} entry={successEntry} embedded />,
|
||||
);
|
||||
const names = screen
|
||||
.getAllByRole("button")
|
||||
.map((b) => b.getAttribute("aria-label") ?? b.textContent);
|
||||
expect(names.indexOf("Replay")).toBeLessThan(names.indexOf("Pin"));
|
||||
expect(names.indexOf("Pin")).toBeLessThan(names.indexOf("Expand"));
|
||||
});
|
||||
|
||||
it("does not render a Replay icon for a non-replayable method when embedded", () => {
|
||||
// A server→client response isn't replayable.
|
||||
const responseEntry: MessageEntry = {
|
||||
id: "resp-1",
|
||||
timestamp: new Date("2026-03-17T10:30:00Z"),
|
||||
direction: "response",
|
||||
origin: "server",
|
||||
message: { jsonrpc: "2.0", id: 1, result: {} },
|
||||
};
|
||||
renderWithMantine(
|
||||
<HistoryEntry {...baseProps} entry={responseEntry} embedded />,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Replay" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Pin" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("invokes onTogglePin when Pin button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onTogglePin = vi.fn();
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
|||
import { MessageDirectionBadge } from "../../elements/MessageDirectionBadge/MessageDirectionBadge";
|
||||
import { ExpandToggle } from "../../elements/ExpandToggle/ExpandToggle";
|
||||
import { PinToggle } from "../../elements/PinToggle/PinToggle";
|
||||
import { ReplayButton } from "../../elements/ReplayButton/ReplayButton";
|
||||
import { extractMethod, isReplayableHistoryMethod } from "../historyUtils.js";
|
||||
|
||||
export interface HistoryEntryProps {
|
||||
|
|
@ -22,6 +23,12 @@ export interface HistoryEntryProps {
|
|||
isListExpanded: boolean;
|
||||
onReplay: () => void;
|
||||
onTogglePin: () => void;
|
||||
/**
|
||||
* Compact two-line header for the narrow monitoring column (#1616): line 1 is
|
||||
* time + direction + duration + status; line 2 is the method (and target) with
|
||||
* the controls — Replay as an icon — on the right.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const EntryContainer = Card.withProps({
|
||||
|
|
@ -34,6 +41,19 @@ const HeaderRow = Group.withProps({
|
|||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
// Left / right clusters within a compact header line. The left cluster shrinks
|
||||
// (`miw: 0`) so a long target can truncate rather than push the row wider.
|
||||
const HeaderCluster = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const ControlsCluster = Group.withProps({
|
||||
gap: "xs",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const TimestampText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
|
|
@ -63,6 +83,12 @@ function formatTimestamp(date: Date): string {
|
|||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO
|
||||
// string would eat most of the narrow line-1 width (#1616).
|
||||
function formatTimestampCompact(date: Date): string {
|
||||
return date.toISOString().slice(11, 19);
|
||||
}
|
||||
|
||||
function extractTarget(entry: MessageEntry): string | undefined {
|
||||
const msg = entry.message;
|
||||
if (!("params" in msg) || !msg.params) return undefined;
|
||||
|
|
@ -108,6 +134,7 @@ export function HistoryEntry({
|
|||
isListExpanded,
|
||||
onReplay,
|
||||
onTogglePin,
|
||||
embedded = false,
|
||||
}: HistoryEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
const method = extractMethod(entry);
|
||||
|
|
@ -119,44 +146,88 @@ export function HistoryEntry({
|
|||
setIsExpanded(isListExpanded);
|
||||
}, [isListExpanded]);
|
||||
|
||||
const directionBadge = entry.origin && (
|
||||
<MessageDirectionBadge
|
||||
direction={entry.origin === "client" ? "outgoing" : "incoming"}
|
||||
/>
|
||||
);
|
||||
const statusBadge = status !== "none" && (
|
||||
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
|
||||
);
|
||||
const durationText = entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntryContainer>
|
||||
<Stack gap="sm">
|
||||
<HeaderRow>
|
||||
<Group gap="sm">
|
||||
{entry.origin && (
|
||||
<MessageDirectionBadge
|
||||
direction={entry.origin === "client" ? "outgoing" : "incoming"}
|
||||
/>
|
||||
)}
|
||||
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
|
||||
<Badge color="dark">{method}</Badge>
|
||||
{target && <TargetText>{target}</TargetText>}
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{status !== "none" && (
|
||||
<Badge color={statusColor(status)}>{statusLabel(status)}</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
{embedded ? (
|
||||
// Compact two-line header for the narrow column.
|
||||
<Stack gap="xs">
|
||||
<HeaderRow>
|
||||
<HeaderCluster>
|
||||
<TimestampText>
|
||||
{formatTimestampCompact(entry.timestamp)}
|
||||
</TimestampText>
|
||||
{directionBadge}
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>
|
||||
{durationText}
|
||||
{statusBadge}
|
||||
</ControlsCluster>
|
||||
</HeaderRow>
|
||||
<HeaderRow>
|
||||
<HeaderCluster flex={1}>
|
||||
<Badge color="dark">{method}</Badge>
|
||||
{target && (
|
||||
<TargetText truncate="end" miw={0}>
|
||||
{target}
|
||||
</TargetText>
|
||||
)}
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>
|
||||
{canReplay && <ReplayButton onReplay={onReplay} />}
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</ControlsCluster>
|
||||
</HeaderRow>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<HeaderRow>
|
||||
<Group gap="sm">
|
||||
<TimestampText>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</TimestampText>
|
||||
{directionBadge}
|
||||
<Badge color="dark">{method}</Badge>
|
||||
{target && <TargetText>{target}</TargetText>}
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
{durationText}
|
||||
{statusBadge}
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Group gap="xs">
|
||||
{canReplay && (
|
||||
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
<Group gap="xs" justify="space-between">
|
||||
<Group gap="xs">
|
||||
{canReplay && (
|
||||
<SubtleButton onClick={onReplay}>Replay</SubtleButton>
|
||||
)}
|
||||
</Group>
|
||||
<Group gap="xs">
|
||||
<PinToggle pinned={isPinned} onToggle={onTogglePin} />
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</Group>
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap="sm">
|
||||
|
|
|
|||
|
|
@ -98,11 +98,14 @@ describe("HistoryListPanel", () => {
|
|||
expect(screen.getByText("No request history")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the History title with count for unpinned entries", () => {
|
||||
it("hides the History header when there are no pinned entries", () => {
|
||||
renderWithMantine(
|
||||
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
|
||||
);
|
||||
expect(screen.getByText("History (3)")).toBeInTheDocument();
|
||||
// With no pinned section to distinguish it from, the header is dropped...
|
||||
expect(screen.queryByText("History (3)")).toBeNull();
|
||||
// ...but the entries themselves are still shown.
|
||||
expect(screen.getByText("resources/read")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the Pinned title with count when entries are pinned", () => {
|
||||
|
|
@ -135,12 +138,13 @@ describe("HistoryListPanel", () => {
|
|||
expect(header).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
it("renders a lone section as a plain (non-collapsible) header with its entries shown", () => {
|
||||
// Only the unpinned section → no accordion toggle, entries always visible.
|
||||
it("renders a lone unpinned section headerless, with its entries shown", () => {
|
||||
// Only the unpinned section → no header at all, no accordion toggle,
|
||||
// entries always visible.
|
||||
renderWithMantine(
|
||||
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
|
||||
);
|
||||
expect(screen.getByText("History (3)")).toBeInTheDocument();
|
||||
expect(screen.queryByText("History (3)")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "History (3)" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
|
@ -246,8 +250,9 @@ describe("HistoryListPanel", () => {
|
|||
visibleDirections={{ client: true, server: false }}
|
||||
/>,
|
||||
);
|
||||
// The server-origin entry is filtered out, leaving one.
|
||||
expect(screen.getByText("History (1)")).toBeInTheDocument();
|
||||
// The server-origin entry is filtered out, leaving the client one.
|
||||
expect(screen.getByText("tools/call")).toBeInTheDocument();
|
||||
expect(screen.queryByText("resources/read")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters entries by searchText (case-insensitive)", () => {
|
||||
|
|
@ -258,7 +263,10 @@ describe("HistoryListPanel", () => {
|
|||
searchText="config.json"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("History (1)")).toBeInTheDocument();
|
||||
// Only the resources/read entry references config.json.
|
||||
expect(screen.getByText("resources/read")).toBeInTheDocument();
|
||||
expect(screen.queryByText("tools/call")).toBeNull();
|
||||
expect(screen.queryByText("tools/list")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters entries by methodFilter", () => {
|
||||
|
|
@ -269,7 +277,9 @@ describe("HistoryListPanel", () => {
|
|||
methodFilter="tools/list"
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("History (1)")).toBeInTheDocument();
|
||||
expect(screen.getByText("tools/list")).toBeInTheDocument();
|
||||
expect(screen.queryByText("tools/call")).toBeNull();
|
||||
expect(screen.queryByText("resources/read")).toBeNull();
|
||||
});
|
||||
|
||||
it("invokes onExport when Export is clicked", async () => {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import {
|
|||
Collapse,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
|
|
@ -21,6 +20,8 @@ import {
|
|||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { extractMethod } from "../historyUtils.js";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
|
|
@ -43,6 +44,10 @@ export interface HistoryListPanelProps {
|
|||
onSortChange: (next: SortDirection) => void;
|
||||
compact: boolean;
|
||||
onToggleCompact: () => void;
|
||||
/** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
|
||||
onPin?: () => void;
|
||||
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = Paper.withProps({
|
||||
|
|
@ -123,10 +128,14 @@ function SectionActions({
|
|||
// is a `listItem` toggle — with an optional actions slot on the right — over a
|
||||
// `Collapse` of the entries. When it's the only section, the accordion makes no
|
||||
// sense: the header is a plain title and the entries always show (so a stale
|
||||
// collapsed state from when both sections were present can't hide them).
|
||||
// collapsed state from when both sections were present can't hide them) —
|
||||
// unless `hideHeaderWhenAlone`, in which case the lone section drops its title
|
||||
// entirely (the "History" label is redundant when there's nothing to
|
||||
// distinguish it from).
|
||||
function CollapsibleSection({
|
||||
title,
|
||||
collapsible,
|
||||
hideHeaderWhenAlone = false,
|
||||
open,
|
||||
onToggle,
|
||||
actions,
|
||||
|
|
@ -134,6 +143,7 @@ function CollapsibleSection({
|
|||
}: {
|
||||
title: string;
|
||||
collapsible: boolean;
|
||||
hideHeaderWhenAlone?: boolean;
|
||||
open: boolean;
|
||||
onToggle: () => void;
|
||||
actions?: ReactNode;
|
||||
|
|
@ -142,7 +152,7 @@ function CollapsibleSection({
|
|||
if (!collapsible) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Title order={5}>{title}</Title>
|
||||
{hideHeaderWhenAlone ? null : <Title order={5}>{title}</Title>}
|
||||
<Stack gap="md">{children}</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
|
@ -168,13 +178,18 @@ function matchesFilters(
|
|||
entry: MessageEntry,
|
||||
searchText: string,
|
||||
visibleDirections: Record<MessageOrigin, boolean>,
|
||||
methodFilter?: MessageMethod,
|
||||
methodFilter: MessageMethod | undefined,
|
||||
// The embedded column exposes only the search box (no direction/method
|
||||
// controls), so it applies the text filter but skips those (#1616).
|
||||
ignoreDirectionAndMethod: boolean,
|
||||
): boolean {
|
||||
// Hide a direction when its toggle is off. Entries with no recorded origin
|
||||
// (legacy / pre-origin logs) are never filtered out by direction.
|
||||
if (entry.origin && !visibleDirections[entry.origin]) return false;
|
||||
const method = extractMethod(entry);
|
||||
if (methodFilter && method !== methodFilter) return false;
|
||||
if (!ignoreDirectionAndMethod) {
|
||||
// Hide a direction when its toggle is off. Entries with no recorded origin
|
||||
// (legacy / pre-origin logs) are never filtered out by direction.
|
||||
if (entry.origin && !visibleDirections[entry.origin]) return false;
|
||||
if (methodFilter && method !== methodFilter) return false;
|
||||
}
|
||||
if (searchText) {
|
||||
const term = searchText.toLowerCase();
|
||||
const responseText = entry.response ? JSON.stringify(entry.response) : "";
|
||||
|
|
@ -201,6 +216,8 @@ export function HistoryListPanel({
|
|||
onSortChange,
|
||||
compact,
|
||||
onToggleCompact,
|
||||
onPin,
|
||||
embedded = false,
|
||||
}: HistoryListPanelProps) {
|
||||
const viewportRef = useScrollMemory("history-list");
|
||||
// Per-section expand/collapse, like the LogControls level toggles. Both start
|
||||
|
|
@ -208,15 +225,30 @@ export function HistoryListPanel({
|
|||
const [pinnedOpen, setPinnedOpen] = useState(true);
|
||||
const [historyOpen, setHistoryOpen] = useState(true);
|
||||
const filteredEntries = useMemo(() => {
|
||||
// `.filter()` returns a fresh array, so sorting in-place is safe.
|
||||
// Embedded column filters by text only (its direction/method controls live
|
||||
// in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns
|
||||
// a fresh array, so sorting in-place is safe.
|
||||
const sorted = entries
|
||||
.filter((e) =>
|
||||
matchesFilters(e, searchText, visibleDirections, methodFilter),
|
||||
matchesFilters(
|
||||
e,
|
||||
searchText,
|
||||
visibleDirections,
|
||||
methodFilter,
|
||||
embedded,
|
||||
),
|
||||
)
|
||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
if (sortDirection === "newest-first") sorted.reverse();
|
||||
return sorted;
|
||||
}, [entries, searchText, visibleDirections, methodFilter, sortDirection]);
|
||||
}, [
|
||||
entries,
|
||||
searchText,
|
||||
visibleDirections,
|
||||
methodFilter,
|
||||
sortDirection,
|
||||
embedded,
|
||||
]);
|
||||
|
||||
const pinnedEntries = useMemo(
|
||||
() => filteredEntries.filter((e) => pinnedIds.has(e.id)),
|
||||
|
|
@ -238,9 +270,6 @@ export function HistoryListPanel({
|
|||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>Requests</Title>
|
||||
<Group gap="xs">
|
||||
{hasResults && (
|
||||
<ListToggle compact={compact} onToggle={onToggleCompact} />
|
||||
)}
|
||||
<SortToggle
|
||||
value={sortDirection}
|
||||
onChange={onSortChange}
|
||||
|
|
@ -256,18 +285,17 @@ export function HistoryListPanel({
|
|||
<Button variant="default" onClick={onExport} disabled={!hasResults}>
|
||||
Export
|
||||
</Button>
|
||||
{hasResults && (
|
||||
<ListToggle compact={compact} onToggle={onToggleCompact} />
|
||||
)}
|
||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasResults ? (
|
||||
<EmptyState>No request history</EmptyState>
|
||||
) : (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
|
||||
type="scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="md">
|
||||
{pinnedEntries.length > 0 && (
|
||||
<CollapsibleSection
|
||||
|
|
@ -290,6 +318,7 @@ export function HistoryListPanel({
|
|||
entry={entry}
|
||||
isPinned={true}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
|
|
@ -301,6 +330,9 @@ export function HistoryListPanel({
|
|||
<CollapsibleSection
|
||||
title={formatHistoryTitle(unpinnedEntries.length)}
|
||||
collapsible={bothSections}
|
||||
// With no pinned section to distinguish it from, the lone
|
||||
// "History (N)" header is redundant — drop it.
|
||||
hideHeaderWhenAlone
|
||||
open={historyOpen}
|
||||
onToggle={() => setHistoryOpen((v) => !v)}
|
||||
actions={
|
||||
|
|
@ -318,6 +350,7 @@ export function HistoryListPanel({
|
|||
entry={entry}
|
||||
isPinned={false}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
onReplay={() => onReplay(entry.id)}
|
||||
onTogglePin={() => onTogglePin(entry.id)}
|
||||
/>
|
||||
|
|
@ -325,7 +358,7 @@ export function HistoryListPanel({
|
|||
</CollapsibleSection>
|
||||
)}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</EmbeddableScrollArea>
|
||||
)}
|
||||
</PanelContainer>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { LoggingLevel } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { LogEntry } from "../../elements/LogEntry/LogEntry";
|
||||
import type { LogEntryData } from "../../elements/LogEntry/LogEntry";
|
||||
|
|
@ -15,6 +7,8 @@ import {
|
|||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
export interface LogStreamPanelProps {
|
||||
|
|
@ -25,6 +19,18 @@ export interface LogStreamPanelProps {
|
|||
onExport: () => void;
|
||||
sortDirection: SortDirection;
|
||||
onSortChange: (next: SortDirection) => void;
|
||||
/**
|
||||
* When set, renders a "pin as column" button in the toolbar that opens this
|
||||
* screen in the monitoring column (#1616). Omitted when the panel is already
|
||||
* embedded in that column (or when pinning isn't available).
|
||||
*/
|
||||
onPin?: () => void;
|
||||
/**
|
||||
* True when this panel is rendered inside the monitoring column. Switches the
|
||||
* scroll region from the viewport-height calc to filling its flex parent, so
|
||||
* it fits below the column's controls row without viewport math.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = Paper.withProps({
|
||||
|
|
@ -50,8 +56,11 @@ function matchesFilters(
|
|||
entry: LogEntryData,
|
||||
filterText: string,
|
||||
visibleLevels: Record<LoggingLevel, boolean>,
|
||||
// The embedded column exposes only the search box (no level toggles), so it
|
||||
// applies the text filter but skips the level filter (#1616).
|
||||
ignoreLevels: boolean,
|
||||
): boolean {
|
||||
if (!visibleLevels[entry.params.level]) return false;
|
||||
if (!ignoreLevels && !visibleLevels[entry.params.level]) return false;
|
||||
if (filterText) {
|
||||
const term = filterText.toLowerCase();
|
||||
const searchable =
|
||||
|
|
@ -69,16 +78,20 @@ export function LogStreamPanel({
|
|||
onExport,
|
||||
sortDirection,
|
||||
onSortChange,
|
||||
onPin,
|
||||
embedded = false,
|
||||
}: LogStreamPanelProps) {
|
||||
const viewportRef = useScrollMemory("logs-stream");
|
||||
const filteredEntries = useMemo(() => {
|
||||
// `.filter()` returns a fresh array, so sorting in-place is safe.
|
||||
// The embedded column has only the search box (its level toggles live in the
|
||||
// full-size sidebar), so it filters by text but ignores the level filter
|
||||
// (#1616). `.filter()` returns a fresh array, so sorting in-place is safe.
|
||||
const sorted = entries
|
||||
.filter((e) => matchesFilters(e, filterText, visibleLevels))
|
||||
.filter((e) => matchesFilters(e, filterText, visibleLevels, embedded))
|
||||
.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
|
||||
if (sortDirection === "newest-first") sorted.reverse();
|
||||
return sorted;
|
||||
}, [entries, filterText, visibleLevels, sortDirection]);
|
||||
}, [entries, filterText, visibleLevels, sortDirection, embedded]);
|
||||
|
||||
return (
|
||||
<PanelContainer>
|
||||
|
|
@ -104,21 +117,17 @@ export function LogStreamPanel({
|
|||
>
|
||||
Export
|
||||
</Button>
|
||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
||||
</Group>
|
||||
</Group>
|
||||
{filteredEntries.length > 0 ? (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
|
||||
type="scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="xs">
|
||||
{filteredEntries.map((entry, index) => (
|
||||
<LogEntry key={index} entry={entry} />
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</EmbeddableScrollArea>
|
||||
) : (
|
||||
<EmptyCenter>
|
||||
<Text c="dimmed">No log entries</Text>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { MonitoringControls } from "./MonitoringControls";
|
||||
|
||||
const meta: Meta<typeof MonitoringControls> = {
|
||||
title: "Groups/MonitoringControls",
|
||||
component: MonitoringControls,
|
||||
args: {
|
||||
tabs: ["Logs", "History", "Network"],
|
||||
value: "Logs",
|
||||
onChange: fn(),
|
||||
searchValue: "",
|
||||
onSearchChange: fn(),
|
||||
onClose: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MonitoringControls>;
|
||||
|
||||
export const AllTabs: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked();
|
||||
expect(
|
||||
canvas.getByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const StdioServer: Story = {
|
||||
args: { tabs: ["Logs", "History"], value: "History" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import {
|
||||
MonitoringControls,
|
||||
type MonitoringControlsProps,
|
||||
} from "./MonitoringControls";
|
||||
|
||||
const TABS = ["Logs", "History", "Network"];
|
||||
|
||||
function renderControls(overrides: Partial<MonitoringControlsProps> = {}) {
|
||||
const props: MonitoringControlsProps = {
|
||||
tabs: TABS,
|
||||
value: "Logs",
|
||||
onChange: vi.fn(),
|
||||
searchValue: "",
|
||||
onSearchChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
renderWithMantine(<MonitoringControls {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("MonitoringControls", () => {
|
||||
it("renders a radio option for each available tab", () => {
|
||||
renderControls();
|
||||
for (const tab of TABS) {
|
||||
expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
|
||||
it("renders only the tabs it is given", () => {
|
||||
renderControls({ tabs: ["Logs", "History"] });
|
||||
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
||||
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||
});
|
||||
|
||||
it("gives the tab switcher an accessible name", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<MonitoringControls
|
||||
tabs={TABS}
|
||||
value="Logs"
|
||||
onChange={vi.fn()}
|
||||
searchValue=""
|
||||
onSearchChange={vi.fn()}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
container.querySelector('[aria-label="Monitoring screen"]'),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
it("marks the active tab as selected", () => {
|
||||
renderControls({ value: "History" });
|
||||
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
|
||||
});
|
||||
|
||||
it("calls onChange when a different tab is chosen", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
renderControls({ onChange });
|
||||
await user.click(screen.getByRole("radio", { name: "Network" }));
|
||||
expect(onChange).toHaveBeenCalledWith("Network");
|
||||
});
|
||||
|
||||
it("renders the search box with the current value", () => {
|
||||
renderControls({ searchValue: "auth" });
|
||||
expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue("auth");
|
||||
});
|
||||
|
||||
it("calls onSearchChange as the user types", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderControls({ onSearchChange });
|
||||
await user.type(screen.getByRole("textbox", { name: "Search" }), "x");
|
||||
expect(onSearchChange).toHaveBeenCalledWith("x");
|
||||
});
|
||||
|
||||
it("clears the search via the clear button", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderControls({ searchValue: "term", onSearchChange });
|
||||
await user.click(screen.getByRole("button", { name: "Clear" }));
|
||||
expect(onSearchChange).toHaveBeenCalledWith("");
|
||||
});
|
||||
|
||||
it("shows no clear button when the search is empty", () => {
|
||||
renderControls({ searchValue: "" });
|
||||
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
|
||||
});
|
||||
|
||||
it("calls onClose when the close button is clicked", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderControls({ onClose });
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core";
|
||||
import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
|
||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||
|
||||
export interface MonitoringControlsProps {
|
||||
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */
|
||||
tabs: string[];
|
||||
/** The active monitor tab shown in the column below. */
|
||||
value: string;
|
||||
onChange: (tab: string) => void;
|
||||
/** Search text for the active screen (shares the screen's own filter state). */
|
||||
searchValue: string;
|
||||
onSearchChange: (next: string) => void;
|
||||
/** Close the monitoring column, returning its screens to the header menu. */
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
// The controls row at the top of the pinned monitoring column: a segmented tab
|
||||
// switcher on the left, a search box filling the middle, and a close button on
|
||||
// the right that unpins the whole column.
|
||||
const ControlsBar = Group.withProps({
|
||||
wrap: "nowrap",
|
||||
gap: "sm",
|
||||
p: "sm",
|
||||
});
|
||||
|
||||
// Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0`
|
||||
// so it fills the space between the tabs and the close button.
|
||||
const SearchInput = TextInput.withProps({
|
||||
placeholder: "Search...",
|
||||
size: "sm",
|
||||
flex: 1,
|
||||
miw: 0,
|
||||
"aria-label": "Search",
|
||||
});
|
||||
|
||||
/**
|
||||
* Tab row + search + close control for the pinned monitoring column (#1616).
|
||||
* Renders only the currently-available monitor tabs (the caller filters by
|
||||
* capability), so it never shows an empty switcher. Selecting a tab swaps the
|
||||
* screen below; the search box filters the shown screen (wired by the caller to
|
||||
* that screen's own filter state); the close button unpins the column so its
|
||||
* screens rejoin the header tab menu.
|
||||
*/
|
||||
export function MonitoringControls({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
onClose,
|
||||
}: MonitoringControlsProps) {
|
||||
return (
|
||||
<ControlsBar>
|
||||
<SegmentedControl
|
||||
size="sm"
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
data={tabs}
|
||||
aria-label="Monitoring screen"
|
||||
/>
|
||||
<SearchInput
|
||||
value={searchValue}
|
||||
onChange={(e) => onSearchChange(e.currentTarget.value)}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
searchValue ? (
|
||||
<ClearButton onClick={() => onSearchChange("")} />
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
size="lg"
|
||||
aria-label="Close monitoring column"
|
||||
onClick={onClose}
|
||||
>
|
||||
<TbLayoutSidebarRightCollapse size={20} />
|
||||
</ActionIcon>
|
||||
</ControlsBar>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
import { Box, Text } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, within } from "storybook/test";
|
||||
import { MonitoringScreen } from "./MonitoringScreen";
|
||||
|
||||
const demoScreens = {
|
||||
Logs: (
|
||||
<Box p="md">
|
||||
<Text>Log stream</Text>
|
||||
</Box>
|
||||
),
|
||||
History: (
|
||||
<Box p="md">
|
||||
<Text>Request history</Text>
|
||||
</Box>
|
||||
),
|
||||
Network: (
|
||||
<Box p="md">
|
||||
<Text>Network requests</Text>
|
||||
</Box>
|
||||
),
|
||||
};
|
||||
|
||||
const meta: Meta<typeof MonitoringScreen> = {
|
||||
title: "Groups/MonitoringScreen",
|
||||
component: MonitoringScreen,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<Box h={320}>
|
||||
<Story />
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
tabs: ["Logs", "History", "Network"],
|
||||
value: "Logs",
|
||||
onChange: fn(),
|
||||
searchValue: "",
|
||||
onSearchChange: fn(),
|
||||
onClose: fn(),
|
||||
screens: demoScreens,
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof MonitoringScreen>;
|
||||
|
||||
export const Logs: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Log stream")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const History: Story = {
|
||||
args: { value: "History" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
expect(canvas.getByText("Request history")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import {
|
||||
MonitoringScreen,
|
||||
type MonitoringScreenProps,
|
||||
} from "./MonitoringScreen";
|
||||
|
||||
const TABS = ["Logs", "History", "Network"];
|
||||
|
||||
function screens() {
|
||||
return {
|
||||
Logs: <div>logs-body</div>,
|
||||
History: <div>history-body</div>,
|
||||
Network: <div>network-body</div>,
|
||||
};
|
||||
}
|
||||
|
||||
function renderScreen(overrides: Partial<MonitoringScreenProps> = {}) {
|
||||
const props: MonitoringScreenProps = {
|
||||
tabs: TABS,
|
||||
value: "Logs",
|
||||
onChange: vi.fn(),
|
||||
searchValue: "",
|
||||
onSearchChange: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
screens: screens(),
|
||||
...overrides,
|
||||
};
|
||||
renderWithMantine(<MonitoringScreen {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe("MonitoringScreen", () => {
|
||||
it("renders the screen for the active tab", () => {
|
||||
renderScreen({ value: "History" });
|
||||
expect(screen.getByText("history-body")).toBeInTheDocument();
|
||||
expect(screen.queryByText("logs-body")).toBeNull();
|
||||
expect(screen.queryByText("network-body")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the controls tab row", () => {
|
||||
renderScreen();
|
||||
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("forwards tab changes and close from the controls", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
renderScreen({ onChange, onClose });
|
||||
await user.click(screen.getByRole("radio", { name: "Network" }));
|
||||
expect(onChange).toHaveBeenCalledWith("Network");
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards the search value and changes from the controls", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onSearchChange = vi.fn();
|
||||
renderScreen({ searchValue: "hi", onSearchChange });
|
||||
const box = screen.getByRole("textbox", { name: "Search" });
|
||||
expect(box).toHaveValue("hi");
|
||||
await user.type(box, "!");
|
||||
expect(onSearchChange).toHaveBeenCalledWith("hi!");
|
||||
});
|
||||
|
||||
it("renders nothing in the slot when the active tab has no screen", () => {
|
||||
renderScreen({ value: "Network", screens: { Logs: <div>logs-body</div> } });
|
||||
expect(screen.queryByText("logs-body")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Divider, Stack } from "@mantine/core";
|
||||
import { MonitoringControls } from "../MonitoringControls/MonitoringControls";
|
||||
|
||||
export interface MonitoringScreenProps {
|
||||
/** Available monitor tabs (Logs/History/Network, filtered by capability). */
|
||||
tabs: string[];
|
||||
/** Active monitor tab; keys into `screens` to pick what renders below. */
|
||||
value: string;
|
||||
onChange: (tab: string) => void;
|
||||
/** Search text for the active screen; wired by the caller to its filter state. */
|
||||
searchValue: string;
|
||||
onSearchChange: (next: string) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* The embedded screen node for each tab, keyed by tab label. The caller builds
|
||||
* these (with `embedded` set) so this component stays a pure layout shell.
|
||||
*/
|
||||
screens: Record<string, ReactNode>;
|
||||
}
|
||||
|
||||
// Fills the pinned column: a fixed controls row on top and the selected screen
|
||||
// filling the remaining height below (mih:0 lets the inner ScrollArea bound).
|
||||
const ColumnLayout = Stack.withProps({
|
||||
h: "100%",
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
const ScreenSlot = Stack.withProps({
|
||||
flex: 1,
|
||||
mih: 0,
|
||||
gap: 0,
|
||||
});
|
||||
|
||||
/**
|
||||
* The pinned monitoring column's content (#1616): a `MonitoringControls` tab row
|
||||
* over the currently-selected monitor screen. Layout-only — it renders whichever
|
||||
* embedded screen node the caller supplies for the active tab.
|
||||
*/
|
||||
export function MonitoringScreen({
|
||||
tabs,
|
||||
value,
|
||||
onChange,
|
||||
searchValue,
|
||||
onSearchChange,
|
||||
onClose,
|
||||
screens,
|
||||
}: MonitoringScreenProps) {
|
||||
return (
|
||||
<ColumnLayout>
|
||||
<MonitoringControls
|
||||
tabs={tabs}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={onSearchChange}
|
||||
onClose={onClose}
|
||||
/>
|
||||
<Divider />
|
||||
<ScreenSlot>{screens[value]}</ScreenSlot>
|
||||
</ColumnLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -31,6 +31,24 @@ describe("NetworkEntry", () => {
|
|||
expect(screen.getByText("transport")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the compact two-line layout (line 1 meta, line 2 URL) when embedded", () => {
|
||||
renderWithMantine(
|
||||
<NetworkEntry entry={baseEntry} isListExpanded={false} embedded />,
|
||||
);
|
||||
// Line 1: compact time-only timestamp, method, category, duration, status.
|
||||
expect(screen.getByText("10:00:00")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText("2026-03-17T10:00:00.000Z"),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText("POST")).toBeInTheDocument();
|
||||
expect(screen.getByText("transport")).toBeInTheDocument();
|
||||
expect(screen.getByText("45ms")).toBeInTheDocument();
|
||||
expect(screen.getByText("200 OK")).toBeInTheDocument();
|
||||
// Line 2: the URL (in its scroll area) and the expand toggle.
|
||||
expect(screen.getByText("https://example.com/mcp")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Expand" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows body / header detail when expanded", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Collapse,
|
||||
Divider,
|
||||
Group,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
|
|
@ -19,6 +20,12 @@ import { maskSecretsInBody } from "../../../utils/maskSecrets";
|
|||
export interface NetworkEntryProps {
|
||||
entry: FetchRequestEntry;
|
||||
isListExpanded: boolean;
|
||||
/**
|
||||
* Compact two-line header for the narrow monitoring column (#1616): line 1 is
|
||||
* time + method + category + duration + status; line 2 is the URL in a
|
||||
* horizontal scroll area with the expand toggle on the right.
|
||||
*/
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const EntryContainer = Card.withProps({
|
||||
|
|
@ -43,6 +50,26 @@ const UrlText = Text.withProps({
|
|||
truncate: "end",
|
||||
});
|
||||
|
||||
// Compact-header URL: never wraps, so a long URL scrolls horizontally inside its
|
||||
// ScrollArea instead of wrapping to many lines.
|
||||
const UrlScroll = Text.withProps({
|
||||
size: "sm",
|
||||
fw: 500,
|
||||
variant: "nowrap",
|
||||
});
|
||||
|
||||
// Left / right clusters for a compact header line (mirrors HistoryEntry).
|
||||
const HeaderCluster = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
miw: 0,
|
||||
});
|
||||
|
||||
const ControlsCluster = Group.withProps({
|
||||
gap: "sm",
|
||||
wrap: "nowrap",
|
||||
});
|
||||
|
||||
const DurationText = Text.withProps({
|
||||
size: "sm",
|
||||
c: "dimmed",
|
||||
|
|
@ -62,6 +89,12 @@ function formatTimestamp(date: Date): string {
|
|||
return date.toISOString();
|
||||
}
|
||||
|
||||
// Time-only (HH:MM:SS, UTC) for the compact column header, where the full ISO
|
||||
// string would eat most of the narrow line-1 width (#1616).
|
||||
function formatTimestampCompact(date: Date): string {
|
||||
return date.toISOString().slice(11, 19);
|
||||
}
|
||||
|
||||
function statusColor(entry: FetchRequestEntry): string {
|
||||
if (entry.error) return "red";
|
||||
const status = entry.responseStatus;
|
||||
|
|
@ -197,7 +230,11 @@ function BodyPreview({
|
|||
);
|
||||
}
|
||||
|
||||
export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
||||
export function NetworkEntry({
|
||||
entry,
|
||||
isListExpanded,
|
||||
embedded = false,
|
||||
}: NetworkEntryProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(isListExpanded);
|
||||
|
||||
// The list-level Expand/Collapse toggle is authoritative: each time the
|
||||
|
|
@ -210,33 +247,70 @@ export function NetworkEntry({ entry, isListExpanded }: NetworkEntryProps) {
|
|||
setIsExpanded(isListExpanded);
|
||||
}, [isListExpanded]);
|
||||
|
||||
const metaBadges = (
|
||||
<>
|
||||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
|
||||
<Badge color={statusColor(entry)}>{statusLabel(entry)}</Badge>
|
||||
</>
|
||||
);
|
||||
const expandToggle = (
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<EntryContainer>
|
||||
<Stack gap="sm">
|
||||
<HeaderRow>
|
||||
<Group gap="sm" wrap="nowrap" miw={0} flex={1}>
|
||||
<TimestampText>{formatTimestamp(entry.timestamp)}</TimestampText>
|
||||
<Badge color="dark">{entry.method}</Badge>
|
||||
<Badge color={categoryColor(entry.category)} variant="light">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
<UrlText>{entry.url}</UrlText>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{entry.duration != null && (
|
||||
<DurationText>{formatDuration(entry.duration)}</DurationText>
|
||||
)}
|
||||
{isLongLivedStream(entry) && <Badge color="orange">SSE</Badge>}
|
||||
<Badge color={statusColor(entry)}>{statusLabel(entry)}</Badge>
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
{embedded ? (
|
||||
// Compact two-line header for the narrow column.
|
||||
<Stack gap="xs">
|
||||
<HeaderRow>
|
||||
<HeaderCluster>
|
||||
<TimestampText>
|
||||
{formatTimestampCompact(entry.timestamp)}
|
||||
</TimestampText>
|
||||
<Badge color="dark">{entry.method}</Badge>
|
||||
<Badge color={categoryColor(entry.category)} variant="light">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
</HeaderCluster>
|
||||
<ControlsCluster>{metaBadges}</ControlsCluster>
|
||||
</HeaderRow>
|
||||
<Group gap="xs" wrap="nowrap" justify="space-between">
|
||||
<ScrollArea type="hover" scrollbarSize={6} flex={1} miw={0}>
|
||||
<UrlScroll>{entry.url}</UrlScroll>
|
||||
</ScrollArea>
|
||||
{expandToggle}
|
||||
</Group>
|
||||
</Stack>
|
||||
) : (
|
||||
<>
|
||||
<HeaderRow>
|
||||
<Group gap="sm" wrap="nowrap" miw={0} flex={1}>
|
||||
<TimestampText>
|
||||
{formatTimestamp(entry.timestamp)}
|
||||
</TimestampText>
|
||||
<Badge color="dark">{entry.method}</Badge>
|
||||
<Badge color={categoryColor(entry.category)} variant="light">
|
||||
{entry.category}
|
||||
</Badge>
|
||||
<UrlText>{entry.url}</UrlText>
|
||||
</Group>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
{metaBadges}
|
||||
</Group>
|
||||
</HeaderRow>
|
||||
|
||||
<Group gap="xs" justify="flex-end">
|
||||
<ExpandToggle
|
||||
expanded={isExpanded}
|
||||
onToggle={() => setIsExpanded((v) => !v)}
|
||||
/>
|
||||
</Group>
|
||||
<Group gap="xs" justify="flex-end">
|
||||
{expandToggle}
|
||||
</Group>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Collapse in={isExpanded}>
|
||||
<Stack gap="sm">
|
||||
|
|
|
|||
|
|
@ -1,13 +1,5 @@
|
|||
import { useMemo } from "react";
|
||||
import {
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { Button, Group, Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type {
|
||||
FetchRequestCategory,
|
||||
FetchRequestEntry,
|
||||
|
|
@ -18,6 +10,8 @@ import {
|
|||
SortToggle,
|
||||
type SortDirection,
|
||||
} from "../../elements/SortToggle/SortToggle";
|
||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||
|
||||
export interface NetworkStreamPanelProps {
|
||||
|
|
@ -30,6 +24,10 @@ export interface NetworkStreamPanelProps {
|
|||
onSortChange: (next: SortDirection) => void;
|
||||
compact: boolean;
|
||||
onToggleCompact: () => void;
|
||||
/** See LogStreamPanel: shows a "pin as column" button when set (#1616). */
|
||||
onPin?: () => void;
|
||||
/** See LogStreamPanel: fills the flex parent instead of the viewport calc. */
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = Paper.withProps({
|
||||
|
|
@ -60,8 +58,11 @@ function matchesFilters(
|
|||
entry: FetchRequestEntry,
|
||||
filterText: string,
|
||||
visibleCategories: Record<FetchRequestCategory, boolean>,
|
||||
// The embedded column exposes only the search box (no category toggles), so it
|
||||
// applies the text filter but skips the category filter (#1616).
|
||||
ignoreCategories: boolean,
|
||||
): boolean {
|
||||
if (!visibleCategories[entry.category]) return false;
|
||||
if (!ignoreCategories && !visibleCategories[entry.category]) return false;
|
||||
if (filterText) {
|
||||
const term = filterText.toLowerCase();
|
||||
const status =
|
||||
|
|
@ -95,16 +96,20 @@ export function NetworkStreamPanel({
|
|||
onSortChange,
|
||||
compact,
|
||||
onToggleCompact,
|
||||
onPin,
|
||||
embedded = false,
|
||||
}: NetworkStreamPanelProps) {
|
||||
const viewportRef = useScrollMemory("network-stream");
|
||||
const filteredEntries = useMemo(() => {
|
||||
// `.filter()` returns a fresh array, so sorting in-place is safe.
|
||||
// Embedded column filters by text only (its category toggles live in the
|
||||
// full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns a
|
||||
// fresh array, so sorting in-place is safe.
|
||||
const sorted = entries
|
||||
.filter((e) => matchesFilters(e, filterText, visibleCategories))
|
||||
.filter((e) => matchesFilters(e, filterText, visibleCategories, embedded))
|
||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||
if (sortDirection === "newest-first") sorted.reverse();
|
||||
return sorted;
|
||||
}, [entries, filterText, visibleCategories, sortDirection]);
|
||||
}, [entries, filterText, visibleCategories, sortDirection, embedded]);
|
||||
|
||||
const hasEntries = entries.length > 0;
|
||||
const hasResults = filteredEntries.length > 0;
|
||||
|
|
@ -114,9 +119,6 @@ export function NetworkStreamPanel({
|
|||
<Group justify="space-between" mb="sm">
|
||||
<Title order={4}>{formatTitle(filteredEntries.length)}</Title>
|
||||
<Group gap="xs">
|
||||
{hasResults && (
|
||||
<ListToggle compact={compact} onToggle={onToggleCompact} />
|
||||
)}
|
||||
<SortToggle
|
||||
value={sortDirection}
|
||||
onChange={onSortChange}
|
||||
|
|
@ -128,28 +130,28 @@ export function NetworkStreamPanel({
|
|||
<Button variant="default" onClick={onExport} disabled={!hasEntries}>
|
||||
Export
|
||||
</Button>
|
||||
{hasResults && (
|
||||
<ListToggle compact={compact} onToggle={onToggleCompact} />
|
||||
)}
|
||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{!hasResults ? (
|
||||
<EmptyState>No network requests</EmptyState>
|
||||
) : (
|
||||
<ScrollArea.Autosize
|
||||
viewportRef={viewportRef}
|
||||
mah="calc(100vh - var(--app-shell-header-height, 0px) - 150px)"
|
||||
type="scroll"
|
||||
offsetScrollbars
|
||||
>
|
||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||
<Stack gap="md">
|
||||
{filteredEntries.map((entry) => (
|
||||
<NetworkEntry
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
isListExpanded={!compact}
|
||||
embedded={embedded}
|
||||
/>
|
||||
))}
|
||||
</Stack>
|
||||
</ScrollArea.Autosize>
|
||||
</EmbeddableScrollArea>
|
||||
)}
|
||||
</PanelContainer>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue