feat(web): monitoring column search, close-to-fullscreen, and open/close animation (#1616)
- Add a search box to MonitoringControls (right of the tabs). It's a single shared search kept across column tab switches, filtering whichever screen is shown. Embedded panels apply this text but still ignore the sidebar-only filters (levels/categories/directions/method), which have no control there. - Close button now uses the "collapse sidebar" icon (TbLayoutSidebarRightCollapse), the mirror of the pin button's expand icon. - Closing the column carries its selection to the primary view: the screen the user was watching becomes the active header tab (rather than snapping back to the pre-pin selection). - Animate the column open/close: the handle + column slide in from the right and fade (reversing on close) via a Mantine Transition. - HistoryListPanel: drop the redundant "History (N)" header when there are no pinned requests (lone unpinned section renders headerless). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9b76272ad1
commit
c5e064ff71
15 changed files with 448 additions and 169 deletions
|
|
@ -98,11 +98,14 @@ describe("HistoryListPanel", () => {
|
||||||
expect(screen.getByText("No request history")).toBeInTheDocument();
|
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(
|
renderWithMantine(
|
||||||
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
|
<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", () => {
|
it("renders the Pinned title with count when entries are pinned", () => {
|
||||||
|
|
@ -135,12 +138,13 @@ describe("HistoryListPanel", () => {
|
||||||
expect(header).toHaveAttribute("aria-expanded", "true");
|
expect(header).toHaveAttribute("aria-expanded", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders a lone section as a plain (non-collapsible) header with its entries shown", () => {
|
it("renders a lone unpinned section headerless, with its entries shown", () => {
|
||||||
// Only the unpinned section → no accordion toggle, entries always visible.
|
// Only the unpinned section → no header at all, no accordion toggle,
|
||||||
|
// entries always visible.
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
|
<HistoryListPanel {...baseProps} entries={sampleEntries} />,
|
||||||
);
|
);
|
||||||
expect(screen.getByText("History (3)")).toBeInTheDocument();
|
expect(screen.queryByText("History (3)")).toBeNull();
|
||||||
expect(
|
expect(
|
||||||
screen.queryByRole("button", { name: "History (3)" }),
|
screen.queryByRole("button", { name: "History (3)" }),
|
||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
|
|
@ -246,8 +250,9 @@ describe("HistoryListPanel", () => {
|
||||||
visibleDirections={{ client: true, server: false }}
|
visibleDirections={{ client: true, server: false }}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// The server-origin entry is filtered out, leaving one.
|
// The server-origin entry is filtered out, leaving the client one.
|
||||||
expect(screen.getByText("History (1)")).toBeInTheDocument();
|
expect(screen.getByText("tools/call")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("resources/read")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("filters entries by searchText (case-insensitive)", () => {
|
it("filters entries by searchText (case-insensitive)", () => {
|
||||||
|
|
@ -258,7 +263,10 @@ describe("HistoryListPanel", () => {
|
||||||
searchText="config.json"
|
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", () => {
|
it("filters entries by methodFilter", () => {
|
||||||
|
|
@ -269,7 +277,9 @@ describe("HistoryListPanel", () => {
|
||||||
methodFilter="tools/list"
|
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 () => {
|
it("invokes onExport when Export is clicked", async () => {
|
||||||
|
|
|
||||||
|
|
@ -128,10 +128,14 @@ function SectionActions({
|
||||||
// is a `listItem` toggle — with an optional actions slot on the right — over a
|
// 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
|
// `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
|
// 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({
|
function CollapsibleSection({
|
||||||
title,
|
title,
|
||||||
collapsible,
|
collapsible,
|
||||||
|
hideHeaderWhenAlone = false,
|
||||||
open,
|
open,
|
||||||
onToggle,
|
onToggle,
|
||||||
actions,
|
actions,
|
||||||
|
|
@ -139,6 +143,7 @@ function CollapsibleSection({
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
collapsible: boolean;
|
collapsible: boolean;
|
||||||
|
hideHeaderWhenAlone?: boolean;
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
actions?: ReactNode;
|
actions?: ReactNode;
|
||||||
|
|
@ -147,7 +152,7 @@ function CollapsibleSection({
|
||||||
if (!collapsible) {
|
if (!collapsible) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Title order={5}>{title}</Title>
|
{hideHeaderWhenAlone ? null : <Title order={5}>{title}</Title>}
|
||||||
<Stack gap="md">{children}</Stack>
|
<Stack gap="md">{children}</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|
@ -173,13 +178,18 @@ function matchesFilters(
|
||||||
entry: MessageEntry,
|
entry: MessageEntry,
|
||||||
searchText: string,
|
searchText: string,
|
||||||
visibleDirections: Record<MessageOrigin, boolean>,
|
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 {
|
): 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);
|
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) {
|
if (searchText) {
|
||||||
const term = searchText.toLowerCase();
|
const term = searchText.toLowerCase();
|
||||||
const responseText = entry.response ? JSON.stringify(entry.response) : "";
|
const responseText = entry.response ? JSON.stringify(entry.response) : "";
|
||||||
|
|
@ -215,14 +225,18 @@ export function HistoryListPanel({
|
||||||
const [pinnedOpen, setPinnedOpen] = useState(true);
|
const [pinnedOpen, setPinnedOpen] = useState(true);
|
||||||
const [historyOpen, setHistoryOpen] = useState(true);
|
const [historyOpen, setHistoryOpen] = useState(true);
|
||||||
const filteredEntries = useMemo(() => {
|
const filteredEntries = useMemo(() => {
|
||||||
// Embedded column: show the full stream (no filter sidebar to explain a
|
// Embedded column filters by text only (its direction/method controls live
|
||||||
// mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh
|
// in the full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns
|
||||||
// array, so sorting in-place is safe.
|
// a fresh array, so sorting in-place is safe.
|
||||||
const sorted = entries
|
const sorted = entries
|
||||||
.filter(
|
.filter((e) =>
|
||||||
(e) =>
|
matchesFilters(
|
||||||
embedded ||
|
e,
|
||||||
matchesFilters(e, searchText, visibleDirections, methodFilter),
|
searchText,
|
||||||
|
visibleDirections,
|
||||||
|
methodFilter,
|
||||||
|
embedded,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||||
if (sortDirection === "newest-first") sorted.reverse();
|
if (sortDirection === "newest-first") sorted.reverse();
|
||||||
|
|
@ -315,6 +329,9 @@ export function HistoryListPanel({
|
||||||
<CollapsibleSection
|
<CollapsibleSection
|
||||||
title={formatHistoryTitle(unpinnedEntries.length)}
|
title={formatHistoryTitle(unpinnedEntries.length)}
|
||||||
collapsible={bothSections}
|
collapsible={bothSections}
|
||||||
|
// With no pinned section to distinguish it from, the lone
|
||||||
|
// "History (N)" header is redundant — drop it.
|
||||||
|
hideHeaderWhenAlone
|
||||||
open={historyOpen}
|
open={historyOpen}
|
||||||
onToggle={() => setHistoryOpen((v) => !v)}
|
onToggle={() => setHistoryOpen((v) => !v)}
|
||||||
actions={
|
actions={
|
||||||
|
|
|
||||||
|
|
@ -56,8 +56,11 @@ function matchesFilters(
|
||||||
entry: LogEntryData,
|
entry: LogEntryData,
|
||||||
filterText: string,
|
filterText: string,
|
||||||
visibleLevels: Record<LoggingLevel, boolean>,
|
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 {
|
): boolean {
|
||||||
if (!visibleLevels[entry.params.level]) return false;
|
if (!ignoreLevels && !visibleLevels[entry.params.level]) return false;
|
||||||
if (filterText) {
|
if (filterText) {
|
||||||
const term = filterText.toLowerCase();
|
const term = filterText.toLowerCase();
|
||||||
const searchable =
|
const searchable =
|
||||||
|
|
@ -80,12 +83,11 @@ export function LogStreamPanel({
|
||||||
}: LogStreamPanelProps) {
|
}: LogStreamPanelProps) {
|
||||||
const viewportRef = useScrollMemory("logs-stream");
|
const viewportRef = useScrollMemory("logs-stream");
|
||||||
const filteredEntries = useMemo(() => {
|
const filteredEntries = useMemo(() => {
|
||||||
// The embedded column has no filter sidebar, so it shows the full stream
|
// The embedded column has only the search box (its level toggles live in the
|
||||||
// rather than mirroring the full-size screen's live filter with no visible
|
// full-size sidebar), so it filters by text but ignores the level filter
|
||||||
// control to explain it (#1616). `.filter()` returns a fresh array, so
|
// (#1616). `.filter()` returns a fresh array, so sorting in-place is safe.
|
||||||
// sorting in-place is safe.
|
|
||||||
const sorted = entries
|
const sorted = entries
|
||||||
.filter((e) => embedded || matchesFilters(e, filterText, visibleLevels))
|
.filter((e) => matchesFilters(e, filterText, visibleLevels, embedded))
|
||||||
.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
|
.sort((a, b) => a.receivedAt.getTime() - b.receivedAt.getTime());
|
||||||
if (sortDirection === "newest-first") sorted.reverse();
|
if (sortDirection === "newest-first") sorted.reverse();
|
||||||
return sorted;
|
return sorted;
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@ const meta: Meta<typeof MonitoringControls> = {
|
||||||
tabs: ["Logs", "History", "Network"],
|
tabs: ["Logs", "History", "Network"],
|
||||||
value: "Logs",
|
value: "Logs",
|
||||||
onChange: fn(),
|
onChange: fn(),
|
||||||
|
searchValue: "",
|
||||||
|
onSearchChange: fn(),
|
||||||
onClose: fn(),
|
onClose: fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,37 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
import { MonitoringControls } from "./MonitoringControls";
|
import {
|
||||||
|
MonitoringControls,
|
||||||
|
type MonitoringControlsProps,
|
||||||
|
} from "./MonitoringControls";
|
||||||
|
|
||||||
const TABS = ["Logs", "History", "Network"];
|
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", () => {
|
describe("MonitoringControls", () => {
|
||||||
it("renders a radio option for each available tab", () => {
|
it("renders a radio option for each available tab", () => {
|
||||||
renderWithMantine(
|
renderControls();
|
||||||
<MonitoringControls
|
|
||||||
tabs={TABS}
|
|
||||||
value="Logs"
|
|
||||||
onChange={vi.fn()}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
for (const tab of TABS) {
|
for (const tab of TABS) {
|
||||||
expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: tab })).toBeInTheDocument();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders only the tabs it is given", () => {
|
it("renders only the tabs it is given", () => {
|
||||||
renderWithMantine(
|
renderControls({ tabs: ["Logs", "History"] });
|
||||||
<MonitoringControls
|
|
||||||
tabs={["Logs", "History"]}
|
|
||||||
value="Logs"
|
|
||||||
onChange={vi.fn()}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
expect(screen.getAllByRole("radio")).toHaveLength(2);
|
||||||
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
expect(screen.queryByRole("radio", { name: "Network" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -39,6 +42,8 @@ describe("MonitoringControls", () => {
|
||||||
tabs={TABS}
|
tabs={TABS}
|
||||||
value="Logs"
|
value="Logs"
|
||||||
onChange={vi.fn()}
|
onChange={vi.fn()}
|
||||||
|
searchValue=""
|
||||||
|
onSearchChange={vi.fn()}
|
||||||
onClose={vi.fn()}
|
onClose={vi.fn()}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
@ -48,43 +53,48 @@ describe("MonitoringControls", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("marks the active tab as selected", () => {
|
it("marks the active tab as selected", () => {
|
||||||
renderWithMantine(
|
renderControls({ value: "History" });
|
||||||
<MonitoringControls
|
|
||||||
tabs={TABS}
|
|
||||||
value="History"
|
|
||||||
onChange={vi.fn()}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
|
expect(screen.getByRole("radio", { name: "History" })).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onChange when a different tab is chosen", async () => {
|
it("calls onChange when a different tab is chosen", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
renderWithMantine(
|
renderControls({ onChange });
|
||||||
<MonitoringControls
|
|
||||||
tabs={TABS}
|
|
||||||
value="Logs"
|
|
||||||
onChange={onChange}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("radio", { name: "Network" }));
|
await user.click(screen.getByRole("radio", { name: "Network" }));
|
||||||
expect(onChange).toHaveBeenCalledWith("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 () => {
|
it("calls onClose when the close button is clicked", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderWithMantine(
|
renderControls({ onClose });
|
||||||
<MonitoringControls
|
|
||||||
tabs={TABS}
|
|
||||||
value="Logs"
|
|
||||||
onChange={vi.fn()}
|
|
||||||
onClose={onClose}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await user.click(
|
await user.click(
|
||||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import { CloseButton, Group, SegmentedControl } from "@mantine/core";
|
import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core";
|
||||||
|
import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
|
||||||
|
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||||
|
|
||||||
export interface MonitoringControlsProps {
|
export interface MonitoringControlsProps {
|
||||||
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */
|
/** The monitor screens currently available to pin (e.g. Logs/History/Network). */
|
||||||
|
|
@ -6,30 +8,46 @@ export interface MonitoringControlsProps {
|
||||||
/** The active monitor tab shown in the column below. */
|
/** The active monitor tab shown in the column below. */
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (tab: string) => void;
|
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. */
|
/** Close the monitoring column, returning its screens to the header menu. */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The controls row that sits at the top of the pinned monitoring column: a
|
// The controls row at the top of the pinned monitoring column: a segmented tab
|
||||||
// segmented tab switcher on the left, a close button on the right that unpins
|
// switcher on the left, a search box filling the middle, and a close button on
|
||||||
// the whole column.
|
// the right that unpins the whole column.
|
||||||
const ControlsBar = Group.withProps({
|
const ControlsBar = Group.withProps({
|
||||||
justify: "space-between",
|
|
||||||
wrap: "nowrap",
|
wrap: "nowrap",
|
||||||
gap: "sm",
|
gap: "sm",
|
||||||
p: "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 + close control for the pinned monitoring column (#1616). Renders only
|
* Tab row + search + close control for the pinned monitoring column (#1616).
|
||||||
* the currently-available monitor tabs (the caller filters by capability), so it
|
* Renders only the currently-available monitor tabs (the caller filters by
|
||||||
* never shows an empty switcher. Selecting a tab swaps the screen below; the
|
* capability), so it never shows an empty switcher. Selecting a tab swaps the
|
||||||
* close button unpins the column so its screens rejoin the header tab menu.
|
* 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({
|
export function MonitoringControls({
|
||||||
tabs,
|
tabs,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
onClose,
|
onClose,
|
||||||
}: MonitoringControlsProps) {
|
}: MonitoringControlsProps) {
|
||||||
return (
|
return (
|
||||||
|
|
@ -41,7 +59,25 @@ export function MonitoringControls({
|
||||||
data={tabs}
|
data={tabs}
|
||||||
aria-label="Monitoring screen"
|
aria-label="Monitoring screen"
|
||||||
/>
|
/>
|
||||||
<CloseButton aria-label="Close monitoring column" onClick={onClose} />
|
<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>
|
</ControlsBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,8 @@ const meta: Meta<typeof MonitoringScreen> = {
|
||||||
tabs: ["Logs", "History", "Network"],
|
tabs: ["Logs", "History", "Network"],
|
||||||
value: "Logs",
|
value: "Logs",
|
||||||
onChange: fn(),
|
onChange: fn(),
|
||||||
|
searchValue: "",
|
||||||
|
onSearchChange: fn(),
|
||||||
onClose: fn(),
|
onClose: fn(),
|
||||||
screens: demoScreens,
|
screens: demoScreens,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
import { MonitoringScreen } from "./MonitoringScreen";
|
import {
|
||||||
|
MonitoringScreen,
|
||||||
|
type MonitoringScreenProps,
|
||||||
|
} from "./MonitoringScreen";
|
||||||
|
|
||||||
const TABS = ["Logs", "History", "Network"];
|
const TABS = ["Logs", "History", "Network"];
|
||||||
|
|
||||||
|
|
@ -13,32 +16,31 @@ function screens() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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", () => {
|
describe("MonitoringScreen", () => {
|
||||||
it("renders the screen for the active tab", () => {
|
it("renders the screen for the active tab", () => {
|
||||||
renderWithMantine(
|
renderScreen({ value: "History" });
|
||||||
<MonitoringScreen
|
|
||||||
tabs={TABS}
|
|
||||||
value="History"
|
|
||||||
onChange={vi.fn()}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
screens={screens()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByText("history-body")).toBeInTheDocument();
|
expect(screen.getByText("history-body")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("logs-body")).toBeNull();
|
expect(screen.queryByText("logs-body")).toBeNull();
|
||||||
expect(screen.queryByText("network-body")).toBeNull();
|
expect(screen.queryByText("network-body")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders the controls tab row", () => {
|
it("renders the controls tab row", () => {
|
||||||
renderWithMantine(
|
renderScreen();
|
||||||
<MonitoringScreen
|
|
||||||
tabs={TABS}
|
|
||||||
value="Logs"
|
|
||||||
onChange={vi.fn()}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
screens={screens()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
|
expect(screen.getByRole("radio", { name: "Logs" })).toBeInTheDocument();
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("button", { name: "Close monitoring column" }),
|
screen.getByRole("button", { name: "Close monitoring column" }),
|
||||||
|
|
@ -49,15 +51,7 @@ describe("MonitoringScreen", () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onChange = vi.fn();
|
const onChange = vi.fn();
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
renderWithMantine(
|
renderScreen({ onChange, onClose });
|
||||||
<MonitoringScreen
|
|
||||||
tabs={TABS}
|
|
||||||
value="Logs"
|
|
||||||
onChange={onChange}
|
|
||||||
onClose={onClose}
|
|
||||||
screens={screens()}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
await user.click(screen.getByRole("radio", { name: "Network" }));
|
await user.click(screen.getByRole("radio", { name: "Network" }));
|
||||||
expect(onChange).toHaveBeenCalledWith("Network");
|
expect(onChange).toHaveBeenCalledWith("Network");
|
||||||
await user.click(
|
await user.click(
|
||||||
|
|
@ -66,16 +60,18 @@ describe("MonitoringScreen", () => {
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
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", () => {
|
it("renders nothing in the slot when the active tab has no screen", () => {
|
||||||
renderWithMantine(
|
renderScreen({ value: "Network", screens: { Logs: <div>logs-body</div> } });
|
||||||
<MonitoringScreen
|
|
||||||
tabs={TABS}
|
|
||||||
value="Network"
|
|
||||||
onChange={vi.fn()}
|
|
||||||
onClose={vi.fn()}
|
|
||||||
screens={{ Logs: <div>logs-body</div> }}
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
expect(screen.queryByText("logs-body")).toBeNull();
|
expect(screen.queryByText("logs-body")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export interface MonitoringScreenProps {
|
||||||
/** Active monitor tab; keys into `screens` to pick what renders below. */
|
/** Active monitor tab; keys into `screens` to pick what renders below. */
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (tab: string) => void;
|
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;
|
onClose: () => void;
|
||||||
/**
|
/**
|
||||||
* The embedded screen node for each tab, keyed by tab label. The caller builds
|
* The embedded screen node for each tab, keyed by tab label. The caller builds
|
||||||
|
|
@ -38,6 +41,8 @@ export function MonitoringScreen({
|
||||||
tabs,
|
tabs,
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
|
searchValue,
|
||||||
|
onSearchChange,
|
||||||
onClose,
|
onClose,
|
||||||
screens,
|
screens,
|
||||||
}: MonitoringScreenProps) {
|
}: MonitoringScreenProps) {
|
||||||
|
|
@ -47,6 +52,8 @@ export function MonitoringScreen({
|
||||||
tabs={tabs}
|
tabs={tabs}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
|
searchValue={searchValue}
|
||||||
|
onSearchChange={onSearchChange}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
|
|
|
||||||
|
|
@ -58,8 +58,11 @@ function matchesFilters(
|
||||||
entry: FetchRequestEntry,
|
entry: FetchRequestEntry,
|
||||||
filterText: string,
|
filterText: string,
|
||||||
visibleCategories: Record<FetchRequestCategory, boolean>,
|
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 {
|
): boolean {
|
||||||
if (!visibleCategories[entry.category]) return false;
|
if (!ignoreCategories && !visibleCategories[entry.category]) return false;
|
||||||
if (filterText) {
|
if (filterText) {
|
||||||
const term = filterText.toLowerCase();
|
const term = filterText.toLowerCase();
|
||||||
const status =
|
const status =
|
||||||
|
|
@ -98,13 +101,11 @@ export function NetworkStreamPanel({
|
||||||
}: NetworkStreamPanelProps) {
|
}: NetworkStreamPanelProps) {
|
||||||
const viewportRef = useScrollMemory("network-stream");
|
const viewportRef = useScrollMemory("network-stream");
|
||||||
const filteredEntries = useMemo(() => {
|
const filteredEntries = useMemo(() => {
|
||||||
// Embedded column: show the full stream (no filter sidebar to explain a
|
// Embedded column filters by text only (its category toggles live in the
|
||||||
// mirrored filter). See LogStreamPanel (#1616). `.filter()` returns a fresh
|
// full-size sidebar). See LogStreamPanel (#1616). `.filter()` returns a
|
||||||
// array, so sorting in-place is safe.
|
// fresh array, so sorting in-place is safe.
|
||||||
const sorted = entries
|
const sorted = entries
|
||||||
.filter(
|
.filter((e) => matchesFilters(e, filterText, visibleCategories, embedded))
|
||||||
(e) => embedded || matchesFilters(e, filterText, visibleCategories),
|
|
||||||
)
|
|
||||||
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
|
||||||
if (sortDirection === "newest-first") sorted.reverse();
|
if (sortDirection === "newest-first") sorted.reverse();
|
||||||
return sorted;
|
return sorted;
|
||||||
|
|
|
||||||
|
|
@ -127,8 +127,25 @@ describe("HistoryScreen", () => {
|
||||||
expect(screen.queryByPlaceholderText("Search...")).toBeNull();
|
expect(screen.queryByPlaceholderText("Search...")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the full stream when embedded, ignoring the live filter", () => {
|
it("applies the search text but ignores the method filter when embedded", () => {
|
||||||
// A search that matches nothing would empty the full-size screen...
|
renderWithMantine(
|
||||||
|
<HistoryScreen
|
||||||
|
{...baseProps}
|
||||||
|
ui={{
|
||||||
|
...EMPTY_HISTORY_UI,
|
||||||
|
// Method filter would exclude the tools/list entries on the full-size
|
||||||
|
// screen...
|
||||||
|
methodFilter: "resources/list",
|
||||||
|
// ...but the column search matches them.
|
||||||
|
search: "tools",
|
||||||
|
}}
|
||||||
|
embedded
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("No request history")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides entries not matching the column search when embedded", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<HistoryScreen
|
<HistoryScreen
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
|
|
@ -136,7 +153,6 @@ describe("HistoryScreen", () => {
|
||||||
embedded
|
embedded
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// ...but the embedded column shows the full stream (not the empty state).
|
expect(screen.getByText("No request history")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("No request history")).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -147,14 +147,38 @@ describe("LoggingScreen", () => {
|
||||||
expect(screen.queryByRole("button", { name: "Set" })).toBeNull();
|
expect(screen.queryByRole("button", { name: "Set" })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the full stream when embedded, ignoring the live filter", () => {
|
it("applies the search text but ignores the level filter when embedded", () => {
|
||||||
|
const entries = [
|
||||||
|
{
|
||||||
|
receivedAt: new Date(),
|
||||||
|
params: { level: "info" as const, data: "hello" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
renderWithMantine(
|
||||||
|
<LoggingScreen
|
||||||
|
{...baseProps}
|
||||||
|
entries={entries}
|
||||||
|
ui={{
|
||||||
|
...EMPTY_LOGS_UI,
|
||||||
|
// The level filter would hide this entry on the full-size screen...
|
||||||
|
visibleLevels: { ...EMPTY_LOGS_UI.visibleLevels, info: false },
|
||||||
|
// ...but the column search matches it.
|
||||||
|
filterText: "hello",
|
||||||
|
}}
|
||||||
|
embedded
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Search matches + level filter is ignored → the entry shows.
|
||||||
|
expect(screen.getByText("hello")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides entries not matching the column search when embedded", () => {
|
||||||
const entries = [
|
const entries = [
|
||||||
{
|
{
|
||||||
receivedAt: new Date(),
|
receivedAt: new Date(),
|
||||||
params: { level: "info" as const, data: "hello" },
|
params: { level: "info" as const, data: "hello" },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
// A filter that would hide the entry on the full-size screen...
|
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<LoggingScreen
|
<LoggingScreen
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
|
|
@ -163,7 +187,6 @@ describe("LoggingScreen", () => {
|
||||||
embedded
|
embedded
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// ...is ignored in the embedded, sidebar-less column.
|
expect(screen.queryByText("hello")).toBeNull();
|
||||||
expect(screen.getByText("hello")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -180,8 +180,25 @@ describe("NetworkScreen", () => {
|
||||||
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Export" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows the full stream when embedded, ignoring the live filter", () => {
|
it("applies the search text but ignores the category filter when embedded", () => {
|
||||||
// A filter that matches nothing would empty the full-size screen...
|
renderWithMantine(
|
||||||
|
<NetworkScreen
|
||||||
|
{...baseProps}
|
||||||
|
ui={{
|
||||||
|
...EMPTY_NETWORK_UI,
|
||||||
|
// Category filter would hide the transport entry on the full-size
|
||||||
|
// screen...
|
||||||
|
visibleCategories: { auth: true, transport: false },
|
||||||
|
// ...but the column search matches it.
|
||||||
|
filterText: "example.com",
|
||||||
|
}}
|
||||||
|
embedded
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("No network requests")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides entries not matching the column search when embedded", () => {
|
||||||
renderWithMantine(
|
renderWithMantine(
|
||||||
<NetworkScreen
|
<NetworkScreen
|
||||||
{...baseProps}
|
{...baseProps}
|
||||||
|
|
@ -189,7 +206,6 @@ describe("NetworkScreen", () => {
|
||||||
embedded
|
embedded
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
// ...but the embedded column shows the full stream (not the empty state).
|
expect(screen.getByText("No network requests")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("No network requests")).toBeNull();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1388,15 +1388,38 @@ describe("InspectorView", () => {
|
||||||
await screen.findByRole("button", { name: "Close monitoring column" }),
|
await screen.findByRole("button", { name: "Close monitoring column" }),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
// The column plays its slide-out animation before unmounting.
|
||||||
screen.queryByRole("button", { name: "Close monitoring column" }),
|
await waitFor(() =>
|
||||||
).toBeNull();
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Close monitoring column" }),
|
||||||
|
).toBeNull(),
|
||||||
|
);
|
||||||
const header = screen.getByRole("banner");
|
const header = screen.getByRole("banner");
|
||||||
expect(
|
expect(
|
||||||
within(header).getByRole("radio", { name: "Logs" }),
|
within(header).getByRole("radio", { name: "Logs" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("carries the column's selection to the primary view on close", async () => {
|
||||||
|
monitorWide.value = true;
|
||||||
|
renderWithMantine(<StatefulInspectorViewHost {...connectedHttp()} />);
|
||||||
|
const user = await gotoTab("Logs");
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "Pin as column" }),
|
||||||
|
);
|
||||||
|
// Switch the column to History, then close it.
|
||||||
|
await user.click(await screen.findByRole("radio", { name: "History" }));
|
||||||
|
await user.click(
|
||||||
|
await screen.findByRole("button", { name: "Close monitoring column" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The primary header now has History selected (not the pre-pin Logs).
|
||||||
|
const header = screen.getByRole("banner");
|
||||||
|
expect(
|
||||||
|
within(header).getByRole("radio", { name: "History" }),
|
||||||
|
).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
it("persists the pin preference and reopens the column when wide", () => {
|
it("persists the pin preference and reopens the column when wide", () => {
|
||||||
// Stored preference from a prior wide session.
|
// Stored preference from a prior wide session.
|
||||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||||
|
|
@ -1431,7 +1454,7 @@ describe("InspectorView", () => {
|
||||||
).toBeNull();
|
).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hides the column on disconnect but keeps the pin preference", () => {
|
it("hides the column on disconnect but keeps the pin preference", async () => {
|
||||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||||
monitorWide.value = true;
|
monitorWide.value = true;
|
||||||
const { rerender } = renderWithMantine(
|
const { rerender } = renderWithMantine(
|
||||||
|
|
@ -1450,9 +1473,12 @@ describe("InspectorView", () => {
|
||||||
})}
|
})}
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
// The column plays its slide-out animation before unmounting.
|
||||||
screen.queryByRole("button", { name: "Close monitoring column" }),
|
await waitFor(() =>
|
||||||
).toBeNull();
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Close monitoring column" }),
|
||||||
|
).toBeNull(),
|
||||||
|
);
|
||||||
// Preference is untouched — only the column's close button clears it.
|
// Preference is untouched — only the column's close button clears it.
|
||||||
expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe(
|
expect(window.localStorage.getItem("inspector.monitor.pinned")).toBe(
|
||||||
"true",
|
"true",
|
||||||
|
|
@ -1539,6 +1565,46 @@ describe("InspectorView", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps the column search across tabs and filters each screen", async () => {
|
||||||
|
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||||
|
monitorWide.value = true;
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithMantine(
|
||||||
|
<StatefulInspectorViewHost
|
||||||
|
{...connectedHttp({
|
||||||
|
logs: [
|
||||||
|
{
|
||||||
|
receivedAt: new Date(),
|
||||||
|
params: { level: "info", data: "loghello" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
history: [
|
||||||
|
{
|
||||||
|
id: "h1",
|
||||||
|
timestamp: new Date(),
|
||||||
|
direction: "request",
|
||||||
|
message: { jsonrpc: "2.0", id: 1, method: "tools/list" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Column defaults to Logs; the log entry shows.
|
||||||
|
expect(await screen.findByText("loghello")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// A search matching nothing filters the Logs stream...
|
||||||
|
const searchBox = screen.getByRole("textbox", { name: "Search" });
|
||||||
|
await user.type(searchBox, "zzz");
|
||||||
|
expect(screen.queryByText("loghello")).toBeNull();
|
||||||
|
|
||||||
|
// ...and the same term carries over to History (still filtered → empty).
|
||||||
|
await user.click(screen.getByRole("radio", { name: "History" }));
|
||||||
|
expect(screen.getByText("No request history")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("textbox", { name: "Search" })).toHaveValue(
|
||||||
|
"zzz",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("resizes and persists the column width from the keyboard", async () => {
|
it("resizes and persists the column width from the keyboard", async () => {
|
||||||
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
window.localStorage.setItem("inspector.monitor.pinned", "true");
|
||||||
window.localStorage.setItem("inspector.monitor.width", "420");
|
window.localStorage.setItem("inspector.monitor.width", "420");
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,13 @@
|
||||||
import { useMemo, useState, type ReactNode, type Ref } from "react";
|
import { useMemo, useState, type ReactNode, type Ref } from "react";
|
||||||
import { AppShell, Box, Flex, Stack, Transition } from "@mantine/core";
|
import {
|
||||||
|
AppShell,
|
||||||
|
Box,
|
||||||
|
Flex,
|
||||||
|
Group,
|
||||||
|
Stack,
|
||||||
|
Transition,
|
||||||
|
type MantineTransition,
|
||||||
|
} from "@mantine/core";
|
||||||
import { useLocalStorage, useMediaQuery } from "@mantine/hooks";
|
import { useLocalStorage, useMediaQuery } from "@mantine/hooks";
|
||||||
import type {
|
import type {
|
||||||
InitializeResult,
|
InitializeResult,
|
||||||
|
|
@ -244,6 +252,27 @@ const MonitoringColumn = Stack.withProps({
|
||||||
miw: 0,
|
miw: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Column open/close animation (#1616): the handle + column slide in from the
|
||||||
|
// right edge and fade as they mount, reversing on close, so pinning reads as the
|
||||||
|
// screen moving into a side column rather than snapping in. `AppShell.Main`'s
|
||||||
|
// `overflow: hidden` clips the off-screen portion during the slide.
|
||||||
|
const COLUMN_ANIM_MS = 300;
|
||||||
|
const slideInFromRight: MantineTransition = {
|
||||||
|
in: { opacity: 1, transform: "translateX(0)" },
|
||||||
|
out: { opacity: 0, transform: "translateX(100%)" },
|
||||||
|
common: { transformOrigin: "right center" },
|
||||||
|
transitionProperty: "transform, opacity",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flex-row wrapper holding the resize handle + column so the whole unit animates
|
||||||
|
// as one (the Transition interpolation is applied to it via `style`).
|
||||||
|
const MonitorColumnGroup = Group.withProps({
|
||||||
|
wrap: "nowrap",
|
||||||
|
gap: 0,
|
||||||
|
h: "100%",
|
||||||
|
flex: "0 0 auto",
|
||||||
|
});
|
||||||
|
|
||||||
// Wraps each screen in a Mantine Transition. With Transition's default
|
// Wraps each screen in a Mantine Transition. With Transition's default
|
||||||
// (`keepMounted={false}`), the outgoing screen unmounts after its exit
|
// (`keepMounted={false}`), the outgoing screen unmounts after its exit
|
||||||
// animation — any local screen state (search filters, scroll position,
|
// animation — any local screen state (search filters, scroll position,
|
||||||
|
|
@ -744,11 +773,25 @@ export function InspectorView({
|
||||||
/* v8 ignore next */
|
/* v8 ignore next */
|
||||||
if (isMonitorTab(tab)) setMonitorTab(tab);
|
if (isMonitorTab(tab)) setMonitorTab(tab);
|
||||||
}
|
}
|
||||||
|
// Closing the column carries its selection back to the primary area: the
|
||||||
|
// screen the user was watching in the column becomes the active header tab,
|
||||||
|
// rather than snapping back to whatever was selected before they pinned.
|
||||||
|
function closeMonitorColumn() {
|
||||||
|
onActiveTabChange(effectiveMonitorTab);
|
||||||
|
setMonitorPinned(false);
|
||||||
|
}
|
||||||
function commitMonitorWidth(next: number) {
|
function commitMonitorWidth(next: number) {
|
||||||
setMonitorWidth(next);
|
setMonitorWidth(next);
|
||||||
setDragWidth(null);
|
setDragWidth(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A single search shared across the column's tabs (kept as you move between
|
||||||
|
// Logs/History/Network so it filters each one), distinct from the full-size
|
||||||
|
// screens' own searches. The embedded panels apply only this text; their other
|
||||||
|
// filters (levels / categories / directions / method) have no control in the
|
||||||
|
// column and are bypassed there.
|
||||||
|
const [monitorSearch, setMonitorSearch] = useState("");
|
||||||
|
|
||||||
// Merge the parent's `serversInput` (static config) with the runtime
|
// Merge the parent's `serversInput` (static config) with the runtime
|
||||||
// connection state owned by the parent — only the active server reflects
|
// connection state owned by the parent — only the active server reflects
|
||||||
// the live status; the rest render as `disconnected`. Handshake errors
|
// the live status; the rest render as `disconnected`. Handshake errors
|
||||||
|
|
@ -840,11 +883,31 @@ export function InspectorView({
|
||||||
};
|
};
|
||||||
|
|
||||||
// Embedded instances for the pinned column, keyed by tab. MonitoringScreen
|
// Embedded instances for the pinned column, keyed by tab. MonitoringScreen
|
||||||
// renders only the active one; the rest are unmounted element values.
|
// renders only the active one; the rest are unmounted element values. Each
|
||||||
|
// screen's search field is overridden with the shared column search so it
|
||||||
|
// filters whichever tab is showing, and carries over as tabs change.
|
||||||
const monitorScreens: Record<string, ReactNode> = {
|
const monitorScreens: Record<string, ReactNode> = {
|
||||||
[LOGS_TAB]: <LoggingScreen {...loggingScreenProps} embedded />,
|
[LOGS_TAB]: (
|
||||||
[HISTORY_TAB]: <HistoryScreen {...historyScreenProps} embedded />,
|
<LoggingScreen
|
||||||
[NETWORK_TAB]: <NetworkScreen {...networkScreenProps} embedded />,
|
{...loggingScreenProps}
|
||||||
|
ui={{ ...logsUi, filterText: monitorSearch }}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[HISTORY_TAB]: (
|
||||||
|
<HistoryScreen
|
||||||
|
{...historyScreenProps}
|
||||||
|
ui={{ ...historyUi, search: monitorSearch }}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
[NETWORK_TAB]: (
|
||||||
|
<NetworkScreen
|
||||||
|
{...networkScreenProps}
|
||||||
|
ui={{ ...networkUi, filterText: monitorSearch }}
|
||||||
|
embedded
|
||||||
|
/>
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -997,28 +1060,40 @@ export function InspectorView({
|
||||||
/>
|
/>
|
||||||
</ScreenStage>
|
</ScreenStage>
|
||||||
</ScreenStageContainer>
|
</ScreenStageContainer>
|
||||||
{effectivePinned ? (
|
<Transition
|
||||||
<>
|
mounted={effectivePinned}
|
||||||
<ResizeHandle
|
transition={slideInFromRight}
|
||||||
value={columnWidth}
|
duration={COLUMN_ANIM_MS}
|
||||||
min={MONITOR_WIDTH_MIN}
|
exitDuration={COLUMN_ANIM_MS}
|
||||||
max={MONITOR_WIDTH_MAX}
|
timingFunction="ease"
|
||||||
step={MONITOR_WIDTH_STEP}
|
>
|
||||||
onChange={setDragWidth}
|
{(styles) => (
|
||||||
onCommit={commitMonitorWidth}
|
// `style={styles}` is Mantine's runtime Transition interpolation,
|
||||||
aria-label="Resize monitoring column"
|
// not static styling — same pattern as ScreenStage above.
|
||||||
/>
|
<MonitorColumnGroup style={styles}>
|
||||||
<MonitoringColumn w={columnWidth}>
|
<ResizeHandle
|
||||||
<MonitoringScreen
|
value={columnWidth}
|
||||||
tabs={monitorAvailable}
|
min={MONITOR_WIDTH_MIN}
|
||||||
value={effectiveMonitorTab}
|
max={MONITOR_WIDTH_MAX}
|
||||||
onChange={handleMonitorTabChange}
|
step={MONITOR_WIDTH_STEP}
|
||||||
onClose={() => setMonitorPinned(false)}
|
onChange={setDragWidth}
|
||||||
screens={monitorScreens}
|
onCommit={commitMonitorWidth}
|
||||||
|
aria-label="Resize monitoring column"
|
||||||
/>
|
/>
|
||||||
</MonitoringColumn>
|
<MonitoringColumn w={columnWidth}>
|
||||||
</>
|
<MonitoringScreen
|
||||||
) : null}
|
tabs={monitorAvailable}
|
||||||
|
value={effectiveMonitorTab}
|
||||||
|
onChange={handleMonitorTabChange}
|
||||||
|
searchValue={monitorSearch}
|
||||||
|
onSearchChange={setMonitorSearch}
|
||||||
|
onClose={closeMonitorColumn}
|
||||||
|
screens={monitorScreens}
|
||||||
|
/>
|
||||||
|
</MonitoringColumn>
|
||||||
|
</MonitorColumnGroup>
|
||||||
|
)}
|
||||||
|
</Transition>
|
||||||
</SplitRow>
|
</SplitRow>
|
||||||
</AppShell.Main>
|
</AppShell.Main>
|
||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue