* feat(web): single header MonitoringToggle + Prompts-style Tools results (#1661) Consolidate the monitoring column's open/close affordances into one header toggle, and align the Tools/Prompts result panes and the sidebar log layout. - Add a single `MonitoringToggle` in the header, to the right of the theme icon. It opens/closes the monitoring column and only shows when a server is connected OR a connect attempt failed (wide viewport + a monitor tab available) — the same availability rule the column uses. - Remove the scattered pin/close icons: delete `PinColumnButton`; drop `onPin` from the Logs/Protocol/Network/Console screens and their stream panels; drop `onOpenMonitor` from the server list; remove the close button from `MonitoringControls`/`MonitoringScreen`. - Tools screen: remove the "Results will appear here" placeholder pane. Results now replace the input form in a single content pane (the Prompts pattern), with a top-left X (ToolResultPanel) that dismisses them back to the form; form values are preserved for a re-run. - Prompts screen: the argument form fills the content pane width (drop the 40% cap), matching the Tools input. - LogEntry: add a `compact` two-line layout (time/level/logger on line one, message wrapping below) used by the embedded monitoring-column Logs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 * polish(web): tooltips on icon buttons + rename "monitoring column" → "monitoring sidebar" (#1661) - Add Mantine `Tooltip`s to the icon-only controls: Client settings, Theme (dynamic light/dark label), the monitoring-sidebar toggle, the list-level and per-entry Expand/Collapse toggles, the Pin/Unpin toggle, and the Replay button. Labels mirror each control's existing aria-label. - Rename the feature term "monitoring column" → "monitoring sidebar" across the user-facing labels (Open/Close/Resize), comments, and tests, so the UI calls it a sidebar consistently. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 * review: consistent sidebar wording + reuse MonitoringToggleProps (#1661) Address the two optional notes from the PR #1662 review: - Make the MonitoringToggle JSDoc voice consistent ("sidebar" throughout, no lingering "column"). - Drop ViewHeader's duplicate `MonitorToggleProps` and reuse the element's exported `MonitoringToggleProps` as the single source of truth. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 * feat(web): slide-in/out animation for the monitoring-sidebar toggle (#1661) The header MonitoringToggle now animates in/out via a Mantine Transition whose in/out styles drive width + margin-left (plus opacity/translateX): as its slot grows from 0 → the 36px icon, the flex-end header row reflows and pushes the theme + client-settings buttons one slot to the left; the exit reverses and they slide back over. A negative margin-left while collapsed cancels the row gap so the theme icon stays flush-right when the toggle is absent. Only appearance/ disappearance (connect / disconnect / narrow viewport) animates — opening or closing the sidebar leaves the toggle in place. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sMW1YZXkM7fDkdPtHpE95 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4e8e772aff
commit
dbda70e83b
50 changed files with 613 additions and 440 deletions
|
|
@ -8,7 +8,7 @@ const FULLSIZE_MAH =
|
||||||
|
|
||||||
export interface EmbeddableScrollAreaProps {
|
export interface EmbeddableScrollAreaProps {
|
||||||
/**
|
/**
|
||||||
* True when rendered inside the pinned monitoring column (#1616): the scroll
|
* True when rendered inside the pinned monitoring sidebar (#1616): the scroll
|
||||||
* region fills its flex parent instead of using the viewport calc. A
|
* region fills its flex parent instead of using the viewport calc. A
|
||||||
* `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining
|
* `flex:1 / mih:0` wrapper caps the inner `ScrollArea` at the space remaining
|
||||||
* below the column's controls (via `mah:100%`), so no viewport math is needed
|
* below the column's controls (via `mah:100%`), so no viewport math is needed
|
||||||
|
|
@ -36,7 +36,7 @@ const CONSTRAIN_CONTENT_STYLES = { content: { minWidth: 0 } } as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The scroll region shared by the Logs / Protocol / Network stream panels, which
|
* The scroll region shared by the Logs / Protocol / Network stream panels, which
|
||||||
* render both full-size (their own tab) and embedded (the monitoring column).
|
* render both full-size (their own tab) and embedded (the monitoring sidebar).
|
||||||
* Centralizes the one layout difference between those two hosts.
|
* Centralizes the one layout difference between those two hosts.
|
||||||
*/
|
*/
|
||||||
export function EmbeddableScrollArea({
|
export function EmbeddableScrollArea({
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { RiCollapseVerticalLine, RiExpandVerticalLine } from "react-icons/ri";
|
import { RiCollapseVerticalLine, RiExpandVerticalLine } from "react-icons/ri";
|
||||||
|
|
||||||
export interface ExpandToggleProps {
|
export interface ExpandToggleProps {
|
||||||
|
|
@ -18,14 +18,16 @@ export function ExpandToggle({ expanded, onToggle }: ExpandToggleProps) {
|
||||||
const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine;
|
const Icon = expanded ? RiCollapseVerticalLine : RiExpandVerticalLine;
|
||||||
const label = expanded ? "Collapse" : "Expand";
|
const label = expanded ? "Collapse" : "Expand";
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<Tooltip label={label}>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="md"
|
color="gray"
|
||||||
aria-label={label}
|
size="md"
|
||||||
onClick={onToggle}
|
aria-label={label}
|
||||||
>
|
onClick={onToggle}
|
||||||
<Icon size={16} />
|
>
|
||||||
</ActionIcon>
|
<Icon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
|
import { RiExpandVerticalLine, RiCollapseVerticalLine } from "react-icons/ri";
|
||||||
|
|
||||||
export interface ListToggleProps {
|
export interface ListToggleProps {
|
||||||
|
|
@ -17,28 +17,32 @@ export function ListToggle({
|
||||||
|
|
||||||
if (variant === "subtle") {
|
if (variant === "subtle") {
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<Tooltip label={label}>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="md"
|
color="gray"
|
||||||
aria-label={label}
|
size="md"
|
||||||
onClick={onToggle}
|
aria-label={label}
|
||||||
>
|
onClick={onToggle}
|
||||||
<Icon size={16} />
|
>
|
||||||
</ActionIcon>
|
<Icon size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `size={36}` matches the header's theme / client-settings ActionIcons so the
|
// `size={36}` matches the header's theme / client-settings ActionIcons so the
|
||||||
// toolbar's toggle reads as the same size icon button.
|
// toolbar's toggle reads as the same size icon button.
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<Tooltip label={label}>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
size={36}
|
variant="subtle"
|
||||||
aria-label={label}
|
size={36}
|
||||||
onClick={onToggle}
|
aria-label={label}
|
||||||
>
|
onClick={onToggle}
|
||||||
<Icon size={20} />
|
>
|
||||||
</ActionIcon>
|
<Icon size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { Box } from "@mantine/core";
|
||||||
import { LogEntry } from "./LogEntry";
|
import { LogEntry } from "./LogEntry";
|
||||||
|
|
||||||
const meta: Meta<typeof LogEntry> = {
|
const meta: Meta<typeof LogEntry> = {
|
||||||
|
|
@ -78,3 +79,27 @@ export const WithJsonData: Story = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The narrow monitoring-column layout (#1661): time/level/logger on the first
|
||||||
|
// line, the (potentially long) message wrapping on the line below. Rendered in a
|
||||||
|
// column-width box so the wrapping is visible.
|
||||||
|
export const Compact: Story = {
|
||||||
|
args: {
|
||||||
|
compact: true,
|
||||||
|
entry: {
|
||||||
|
receivedAt: new Date("2026-03-29T20:18:26.000Z"),
|
||||||
|
params: {
|
||||||
|
level: "warning",
|
||||||
|
data: "Connection pool nearing capacity — 92 of 100 connections in use; consider raising the limit",
|
||||||
|
logger: "pool",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<Box maw={360}>
|
||||||
|
<Story />
|
||||||
|
</Box>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -36,4 +36,21 @@ describe("LogEntry", () => {
|
||||||
);
|
);
|
||||||
expect(container.textContent).toContain("info");
|
expect(container.textContent).toContain("info");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the compact two-line layout with time, level, logger, and message", () => {
|
||||||
|
renderWithMantine(
|
||||||
|
<LogEntry
|
||||||
|
entry={baseEntry({
|
||||||
|
data: "compact message",
|
||||||
|
level: "warning",
|
||||||
|
logger: "transport",
|
||||||
|
})}
|
||||||
|
compact
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// All four pieces still render in the compact variant.
|
||||||
|
expect(screen.getByText("warning")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("[transport]")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("compact message")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Group, Text } from "@mantine/core";
|
import { Group, Stack, Text } from "@mantine/core";
|
||||||
import type {
|
import type {
|
||||||
LoggingLevel,
|
LoggingLevel,
|
||||||
LoggingMessageNotification,
|
LoggingMessageNotification,
|
||||||
|
|
@ -12,6 +12,13 @@ export interface LogEntryData {
|
||||||
|
|
||||||
export interface LogEntryProps {
|
export interface LogEntryProps {
|
||||||
entry: LogEntryData;
|
entry: LogEntryData;
|
||||||
|
/**
|
||||||
|
* Compact two-line layout for the narrow monitoring sidebar (#1661): the
|
||||||
|
* timestamp, level, and logger sit on the first line and the message wraps
|
||||||
|
* onto the line below, so a long message isn't clipped by the column width.
|
||||||
|
* The default (false) is the single-line row used on the full Logs screen.
|
||||||
|
*/
|
||||||
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelMessageColor: Record<LoggingLevel, string | undefined> = {
|
const levelMessageColor: Record<LoggingLevel, string | undefined> = {
|
||||||
|
|
@ -51,18 +58,56 @@ const LoggerText = Text.withProps({
|
||||||
c: "dimmed",
|
c: "dimmed",
|
||||||
});
|
});
|
||||||
|
|
||||||
export function LogEntry({ entry }: LogEntryProps) {
|
// Single-line message (full Logs screen): sits inline with the meta on one row.
|
||||||
|
const MessageText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
ff: "monospace",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compact message (monitoring sidebar): wraps over-long lines within the narrow
|
||||||
|
// column via the `consoleLine` variant instead of overflowing its width.
|
||||||
|
const CompactMessageText = Text.withProps({
|
||||||
|
size: "sm",
|
||||||
|
ff: "monospace",
|
||||||
|
variant: "consoleLine",
|
||||||
|
});
|
||||||
|
|
||||||
|
// The compact meta row: timestamp + level + logger on one line above the
|
||||||
|
// message. `wrap: nowrap` keeps them on a single line; the message wraps below.
|
||||||
|
const MetaRow = Group.withProps({
|
||||||
|
gap: "xs",
|
||||||
|
wrap: "nowrap",
|
||||||
|
align: "center",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function LogEntry({ entry, compact = false }: LogEntryProps) {
|
||||||
const { receivedAt, params } = entry;
|
const { receivedAt, params } = entry;
|
||||||
const message = formatData(params.data);
|
const message = formatData(params.data);
|
||||||
|
const logger = params.logger ? (
|
||||||
|
<LoggerText>{formatLogger(params.logger)}</LoggerText>
|
||||||
|
) : null;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Stack gap={2}>
|
||||||
|
<MetaRow>
|
||||||
|
<TimestampText>{formatTimestamp(receivedAt)}</TimestampText>
|
||||||
|
<LogLevelBadge level={params.level} />
|
||||||
|
{logger}
|
||||||
|
</MetaRow>
|
||||||
|
<CompactMessageText c={levelMessageColor[params.level]}>
|
||||||
|
{message}
|
||||||
|
</CompactMessageText>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<TimestampText>{formatTimestamp(receivedAt)}</TimestampText>
|
<TimestampText>{formatTimestamp(receivedAt)}</TimestampText>
|
||||||
<LogLevelBadge level={params.level} />
|
<LogLevelBadge level={params.level} />
|
||||||
{params.logger && <LoggerText>{formatLogger(params.logger)}</LoggerText>}
|
{logger}
|
||||||
<Text size="sm" ff="monospace" c={levelMessageColor[params.level]}>
|
<MessageText c={levelMessageColor[params.level]}>{message}</MessageText>
|
||||||
{message}
|
|
||||||
</Text>
|
|
||||||
</Group>
|
</Group>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||||
|
import { expect, fn, within } from "storybook/test";
|
||||||
|
import { MonitoringToggle } from "./MonitoringToggle";
|
||||||
|
|
||||||
|
const meta: Meta<typeof MonitoringToggle> = {
|
||||||
|
title: "Elements/MonitoringToggle",
|
||||||
|
component: MonitoringToggle,
|
||||||
|
args: { open: false, onToggle: fn() },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof MonitoringToggle>;
|
||||||
|
|
||||||
|
export const Closed: Story = {
|
||||||
|
args: { open: false },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(
|
||||||
|
canvas.getByRole("button", { name: "Open monitoring sidebar" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Open: Story = {
|
||||||
|
args: { open: true },
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const canvas = within(canvasElement);
|
||||||
|
expect(
|
||||||
|
canvas.getByRole("button", { name: "Close monitoring sidebar" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
import { describe, it, expect, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||||
|
import { MonitoringToggle } from "./MonitoringToggle";
|
||||||
|
|
||||||
|
describe("MonitoringToggle", () => {
|
||||||
|
it("renders an open-column button when closed", () => {
|
||||||
|
renderWithMantine(<MonitoringToggle open={false} onToggle={vi.fn()} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Open monitoring sidebar" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Close monitoring sidebar" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a close-column button when open", () => {
|
||||||
|
renderWithMantine(<MonitoringToggle open onToggle={vi.fn()} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: "Close monitoring sidebar" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: "Open monitoring sidebar" }),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invokes onToggle when clicked", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onToggle = vi.fn();
|
||||||
|
renderWithMantine(<MonitoringToggle open={false} onToggle={onToggle} />);
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", { name: "Open monitoring sidebar" }),
|
||||||
|
);
|
||||||
|
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
TbLayoutSidebarRightExpand,
|
||||||
|
TbLayoutSidebarRightCollapse,
|
||||||
|
} from "react-icons/tb";
|
||||||
|
|
||||||
|
export interface MonitoringToggleProps {
|
||||||
|
/** Whether the monitoring sidebar is currently open. */
|
||||||
|
open: boolean;
|
||||||
|
/** Open the sidebar when closed, close it when open. */
|
||||||
|
onToggle: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single header affordance for the monitoring sidebar (#1661). It replaces
|
||||||
|
* the per-screen pin buttons, the server-list open-sidebar button, and the
|
||||||
|
* sidebar's own close button — one toggle, placed in the header to the right of
|
||||||
|
* the theme icon, that opens or closes the sidebar on demand. The glyph and
|
||||||
|
* label reflect the current state (expand when closed, collapse when open). The
|
||||||
|
* caller only mounts it when the sidebar is available (connected, or a failed
|
||||||
|
* connect attempt, on a wide viewport), so it never appears with nothing to
|
||||||
|
* toggle. `size={36}` matches the header's theme / client-settings ActionIcons.
|
||||||
|
*/
|
||||||
|
export function MonitoringToggle({ open, onToggle }: MonitoringToggleProps) {
|
||||||
|
const Icon = open ? TbLayoutSidebarRightCollapse : TbLayoutSidebarRightExpand;
|
||||||
|
const label = open ? "Close monitoring sidebar" : "Open monitoring sidebar";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={label}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size={36}
|
||||||
|
aria-label={label}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
||||||
import { expect, fn, within } from "storybook/test";
|
|
||||||
import { PinColumnButton } from "./PinColumnButton";
|
|
||||||
|
|
||||||
const meta: Meta<typeof PinColumnButton> = {
|
|
||||||
title: "Elements/PinColumnButton",
|
|
||||||
component: PinColumnButton,
|
|
||||||
args: { onPin: fn() },
|
|
||||||
};
|
|
||||||
|
|
||||||
export default meta;
|
|
||||||
type Story = StoryObj<typeof PinColumnButton>;
|
|
||||||
|
|
||||||
export const Default: Story = {
|
|
||||||
play: async ({ canvasElement }) => {
|
|
||||||
const canvas = within(canvasElement);
|
|
||||||
expect(
|
|
||||||
canvas.getByRole("button", { name: "Pin as column" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
@ -1,31 +0,0 @@
|
||||||
import { describe, it, expect, vi } from "vitest";
|
|
||||||
import userEvent from "@testing-library/user-event";
|
|
||||||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
|
||||||
import { PinColumnButton } from "./PinColumnButton";
|
|
||||||
|
|
||||||
describe("PinColumnButton", () => {
|
|
||||||
it("renders a labelled pin-as-column button", () => {
|
|
||||||
renderWithMantine(<PinColumnButton onPin={vi.fn()} />);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Pin as column" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("invokes onPin when clicked", async () => {
|
|
||||||
const user = userEvent.setup();
|
|
||||||
const onPin = vi.fn();
|
|
||||||
renderWithMantine(<PinColumnButton onPin={onPin} />);
|
|
||||||
await user.click(screen.getByRole("button", { name: "Pin as column" }));
|
|
||||||
expect(onPin).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses a custom accessible label when provided", () => {
|
|
||||||
renderWithMantine(
|
|
||||||
<PinColumnButton onPin={vi.fn()} label="Open monitoring column" />,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: "Open monitoring column" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(screen.queryByRole("button", { name: "Pin as column" })).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { ActionIcon } from "@mantine/core";
|
|
||||||
import { TbLayoutSidebarRightExpand } from "react-icons/tb";
|
|
||||||
|
|
||||||
export interface PinColumnButtonProps {
|
|
||||||
/** Pin this screen into the monitoring column to the right. */
|
|
||||||
onPin: () => void;
|
|
||||||
/** Accessible label. Defaults to "Pin as column" (its monitor-screen use). */
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Toolbar button that opens the resizable monitoring column on the right of the
|
|
||||||
* InspectorView (#1616). On a monitor screen (Logs / Protocol / Network) it pins
|
|
||||||
* that screen in as a column; on the server list it just opens the column (a
|
|
||||||
* different `label`). Distinct from `PinToggle` (which pins individual history
|
|
||||||
* entries) — this one opens a side column, so it uses a right-sidebar glyph.
|
|
||||||
* `size={36}` matches the header's theme / client-settings ActionIcons and the
|
|
||||||
* toolbar's `ListToggle`, so all these icon buttons share one width.
|
|
||||||
*/
|
|
||||||
export function PinColumnButton({
|
|
||||||
onPin,
|
|
||||||
label = "Pin as column",
|
|
||||||
}: PinColumnButtonProps) {
|
|
||||||
return (
|
|
||||||
<ActionIcon variant="subtle" size={36} aria-label={label} onClick={onPin}>
|
|
||||||
<TbLayoutSidebarRightExpand size={20} />
|
|
||||||
</ActionIcon>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { TiPin, TiPinOutline } from "react-icons/ti";
|
import { TiPin, TiPinOutline } from "react-icons/ti";
|
||||||
|
|
||||||
export interface PinToggleProps {
|
export interface PinToggleProps {
|
||||||
|
|
@ -16,14 +16,16 @@ export function PinToggle({ pinned, onToggle }: PinToggleProps) {
|
||||||
const Icon = pinned ? TiPin : TiPinOutline;
|
const Icon = pinned ? TiPin : TiPinOutline;
|
||||||
const label = pinned ? "Unpin" : "Pin";
|
const label = pinned ? "Unpin" : "Pin";
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<Tooltip label={label}>
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="md"
|
color="gray"
|
||||||
aria-label={label}
|
size="md"
|
||||||
onClick={onToggle}
|
aria-label={label}
|
||||||
>
|
onClick={onToggle}
|
||||||
<Icon size={18} />
|
>
|
||||||
</ActionIcon>
|
<Icon size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { ActionIcon } from "@mantine/core";
|
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||||
import { MdReplay } from "react-icons/md";
|
import { MdReplay } from "react-icons/md";
|
||||||
|
|
||||||
export interface ReplayButtonProps {
|
export interface ReplayButtonProps {
|
||||||
|
|
@ -13,14 +13,16 @@ export interface ReplayButtonProps {
|
||||||
*/
|
*/
|
||||||
export function ReplayButton({ onReplay }: ReplayButtonProps) {
|
export function ReplayButton({ onReplay }: ReplayButtonProps) {
|
||||||
return (
|
return (
|
||||||
<ActionIcon
|
<Tooltip label="Replay">
|
||||||
variant="subtle"
|
<ActionIcon
|
||||||
color="gray"
|
variant="subtle"
|
||||||
size="md"
|
color="gray"
|
||||||
aria-label="Replay"
|
size="md"
|
||||||
onClick={onReplay}
|
aria-label="Replay"
|
||||||
>
|
onClick={onReplay}
|
||||||
<MdReplay size={18} />
|
>
|
||||||
</ActionIcon>
|
<MdReplay size={18} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,9 +39,9 @@ describe("ResizeHandle", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("accepts a custom aria-label", () => {
|
it("accepts a custom aria-label", () => {
|
||||||
setup({ "aria-label": "Resize monitoring column" });
|
setup({ "aria-label": "Resize monitoring sidebar" });
|
||||||
expect(
|
expect(
|
||||||
screen.getByRole("separator", { name: "Resize monitoring column" }),
|
screen.getByRole("separator", { name: "Resize monitoring sidebar" }),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ export interface ScreenStageProps {
|
||||||
/**
|
/**
|
||||||
* Stretch the stage to fill its relative-positioned parent (adds `bottom: 0`).
|
* Stretch the stage to fill its relative-positioned parent (adds `bottom: 0`).
|
||||||
* Needed where the screen relies on the parent for height (e.g. an inner
|
* Needed where the screen relies on the parent for height (e.g. an inner
|
||||||
* ScrollArea in the monitoring column). Off by default so callers whose
|
* ScrollArea in the monitoring sidebar). Off by default so callers whose
|
||||||
* screens size themselves keep the top/left/right anchoring.
|
* screens size themselves keep the top/left/right anchoring.
|
||||||
*/
|
*/
|
||||||
fill?: boolean;
|
fill?: boolean;
|
||||||
|
|
@ -25,7 +25,7 @@ export interface ScreenStageProps {
|
||||||
* (`keepMounted={false}`) the outgoing screen unmounts after its exit animation,
|
* (`keepMounted={false}`) the outgoing screen unmounts after its exit animation,
|
||||||
* resetting any local screen state (search filters, scroll, expanded sections).
|
* resetting any local screen state (search filters, scroll, expanded sections).
|
||||||
*
|
*
|
||||||
* Shared by the primary InspectorView pane and the pinned monitoring column so
|
* Shared by the primary InspectorView pane and the pinned monitoring sidebar so
|
||||||
* both use identical enter/exit motion (#1639-follow-up). Must be rendered
|
* both use identical enter/exit motion (#1639-follow-up). Must be rendered
|
||||||
* inside a `position: relative` container.
|
* inside a `position: relative` container.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import {
|
||||||
SortToggle,
|
SortToggle,
|
||||||
type SortDirection,
|
type SortDirection,
|
||||||
} from "../../elements/SortToggle/SortToggle";
|
} from "../../elements/SortToggle/SortToggle";
|
||||||
import { PinColumnButton } from "../../elements/PinColumnButton/PinColumnButton";
|
|
||||||
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
import { EmbeddableScrollArea } from "../../elements/EmbeddableScrollArea/EmbeddableScrollArea";
|
||||||
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
import { useScrollMemory } from "../../../hooks/useScrollMemory";
|
||||||
|
|
||||||
|
|
@ -20,13 +19,7 @@ export interface LogStreamPanelProps {
|
||||||
sortDirection: SortDirection;
|
sortDirection: SortDirection;
|
||||||
onSortChange: (next: SortDirection) => void;
|
onSortChange: (next: SortDirection) => void;
|
||||||
/**
|
/**
|
||||||
* When set, renders a "pin as column" button in the toolbar that opens this
|
* True when this panel is rendered inside the monitoring sidebar. Switches the
|
||||||
* 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
|
* scroll region from the viewport-height calc to filling its flex parent, so
|
||||||
* it fits below the column's controls row without viewport math.
|
* it fits below the column's controls row without viewport math.
|
||||||
*/
|
*/
|
||||||
|
|
@ -78,7 +71,6 @@ export function LogStreamPanel({
|
||||||
onExport,
|
onExport,
|
||||||
sortDirection,
|
sortDirection,
|
||||||
onSortChange,
|
onSortChange,
|
||||||
onPin,
|
|
||||||
embedded = false,
|
embedded = false,
|
||||||
}: LogStreamPanelProps) {
|
}: LogStreamPanelProps) {
|
||||||
const viewportRef = useScrollMemory("logs-stream");
|
const viewportRef = useScrollMemory("logs-stream");
|
||||||
|
|
@ -117,14 +109,15 @@ export function LogStreamPanel({
|
||||||
>
|
>
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
{onPin ? <PinColumnButton onPin={onPin} /> : null}
|
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
{filteredEntries.length > 0 ? (
|
{filteredEntries.length > 0 ? (
|
||||||
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
<EmbeddableScrollArea embedded={embedded} viewportRef={viewportRef}>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
{filteredEntries.map((entry, index) => (
|
{filteredEntries.map((entry, index) => (
|
||||||
<LogEntry key={index} entry={entry} />
|
// Compact (two-line) layout inside the narrow monitoring sidebar;
|
||||||
|
// the full single-line row on the standalone Logs screen. (#1661)
|
||||||
|
<LogEntry key={index} entry={entry} compact={embedded} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</EmbeddableScrollArea>
|
</EmbeddableScrollArea>
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ const meta: Meta<typeof MonitoringControls> = {
|
||||||
onChange: fn(),
|
onChange: fn(),
|
||||||
searchValue: "",
|
searchValue: "",
|
||||||
onSearchChange: fn(),
|
onSearchChange: fn(),
|
||||||
onClose: fn(),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -22,9 +21,6 @@ export const AllTabs: Story = {
|
||||||
play: async ({ canvasElement }) => {
|
play: async ({ canvasElement }) => {
|
||||||
const canvas = within(canvasElement);
|
const canvas = within(canvasElement);
|
||||||
expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked();
|
expect(canvas.getByRole("radio", { name: "Logs" })).toBeChecked();
|
||||||
expect(
|
|
||||||
canvas.getByRole("button", { name: "Close monitoring column" }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ function renderControls(overrides: Partial<MonitoringControlsProps> = {}) {
|
||||||
onChange: vi.fn(),
|
onChange: vi.fn(),
|
||||||
searchValue: "",
|
searchValue: "",
|
||||||
onSearchChange: vi.fn(),
|
onSearchChange: vi.fn(),
|
||||||
onClose: vi.fn(),
|
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
renderWithMantine(<MonitoringControls {...props} />);
|
renderWithMantine(<MonitoringControls {...props} />);
|
||||||
|
|
@ -44,7 +43,6 @@ describe("MonitoringControls", () => {
|
||||||
onChange={vi.fn()}
|
onChange={vi.fn()}
|
||||||
searchValue=""
|
searchValue=""
|
||||||
onSearchChange={vi.fn()}
|
onSearchChange={vi.fn()}
|
||||||
onClose={vi.fn()}
|
|
||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -90,14 +88,4 @@ describe("MonitoringControls", () => {
|
||||||
renderControls({ searchValue: "" });
|
renderControls({ searchValue: "" });
|
||||||
expect(screen.queryByRole("button", { name: "Clear" })).toBeNull();
|
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);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import { ActionIcon, Group, SegmentedControl, TextInput } from "@mantine/core";
|
import { Group, SegmentedControl, TextInput } from "@mantine/core";
|
||||||
import { TbLayoutSidebarRightCollapse } from "react-icons/tb";
|
|
||||||
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||||
|
|
||||||
export interface MonitoringControlsProps {
|
export interface MonitoringControlsProps {
|
||||||
|
|
@ -11,13 +10,12 @@ export interface MonitoringControlsProps {
|
||||||
/** Search text for the active screen (shares the screen's own filter state). */
|
/** Search text for the active screen (shares the screen's own filter state). */
|
||||||
searchValue: string;
|
searchValue: string;
|
||||||
onSearchChange: (next: string) => void;
|
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
|
// The controls row at the top of the pinned monitoring sidebar: a segmented tab
|
||||||
// switcher on the left, a search box filling the middle, and a close button on
|
// switcher on the left and a search box filling the rest. The column is closed
|
||||||
// the right that unpins the whole column.
|
// from the single header MonitoringToggle (#1661), so there is no close button
|
||||||
|
// here.
|
||||||
const ControlsBar = Group.withProps({
|
const ControlsBar = Group.withProps({
|
||||||
wrap: "nowrap",
|
wrap: "nowrap",
|
||||||
gap: "sm",
|
gap: "sm",
|
||||||
|
|
@ -25,7 +23,7 @@ const ControlsBar = Group.withProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
// Search box, styled to match the `*Controls` search fields; `flex:1`/`miw:0`
|
// 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.
|
// so it fills the space between the tabs and the row's edge.
|
||||||
const SearchInput = TextInput.withProps({
|
const SearchInput = TextInput.withProps({
|
||||||
placeholder: "Search...",
|
placeholder: "Search...",
|
||||||
size: "sm",
|
size: "sm",
|
||||||
|
|
@ -35,12 +33,12 @@ const SearchInput = TextInput.withProps({
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tab row + search + close control for the pinned monitoring column (#1616).
|
* Tab row + search for the pinned monitoring sidebar (#1616). Renders only the
|
||||||
* Renders only the currently-available monitor tabs (the caller filters by
|
* currently-available monitor tabs (the caller filters by capability), so it
|
||||||
* capability), so it never shows an empty switcher. Selecting a tab swaps the
|
* never shows an empty switcher. Selecting a tab swaps the screen below; the
|
||||||
* screen below; the search box filters the shown screen (wired by the caller to
|
* search box filters the shown screen (wired by the caller to that screen's own
|
||||||
* that screen's own filter state); the close button unpins the column so its
|
* filter state). Opening/closing the column is handled by the single header
|
||||||
* screens rejoin the header tab menu.
|
* MonitoringToggle (#1661).
|
||||||
*/
|
*/
|
||||||
export function MonitoringControls({
|
export function MonitoringControls({
|
||||||
tabs,
|
tabs,
|
||||||
|
|
@ -48,7 +46,6 @@ export function MonitoringControls({
|
||||||
onChange,
|
onChange,
|
||||||
searchValue,
|
searchValue,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
onClose,
|
|
||||||
}: MonitoringControlsProps) {
|
}: MonitoringControlsProps) {
|
||||||
return (
|
return (
|
||||||
<ControlsBar>
|
<ControlsBar>
|
||||||
|
|
@ -69,14 +66,6 @@ export function MonitoringControls({
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<ActionIcon
|
|
||||||
variant="subtle"
|
|
||||||
size={36}
|
|
||||||
aria-label="Close monitoring column"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<TbLayoutSidebarRightCollapse size={20} />
|
|
||||||
</ActionIcon>
|
|
||||||
</ControlsBar>
|
</ControlsBar>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue