diff --git a/board/e2e/catalog-pagination.spec.ts b/board/e2e/catalog-pagination.spec.ts new file mode 100644 index 00000000..c677fb18 --- /dev/null +++ b/board/e2e/catalog-pagination.spec.ts @@ -0,0 +1,239 @@ +import { expect, test, type Page } from "@playwright/test"; + +function ok(data: unknown) { + return { + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data }), + }; +} + +async function expectPaginationControlsDisabled(page: Page): Promise { + for (const name of ["First", "Previous", "Next", "Last"]) { + await expect( + page.getByRole("button", { name, exact: true }), + ).toBeDisabled(); + } +} + +const servers = Array.from({ length: 10 }, (_, index) => { + const number = String(index + 1).padStart(2, "0"); + return { + id: `server-${number}`, + name: `Server ${number}`, + status: "Ready", + server_type: "stdio", + enabled: true, + instances: [{ id: `instance-${number}`, name: "default", status: "Ready" }], + }; +}); + +const clients = Array.from({ length: 10 }, (_, index) => { + const number = String(index + 1).padStart(2, "0"); + return { + identifier: `client-${number}`, + display_name: `Client ${number}`, + description: `Client fixture ${number}`, + detected: true, + approval_status: "allowed", + }; +}); +const reviewItems = clients.map((client, index) => ({ + review_item_id: `review-${index + 1}`, + owners: [ + { + owner_type: "consumer_direct_exposure", + owner_id: client.identifier, + }, + ], +})); + +let serverFixtures = servers; +let clientFixtures = clients; +let reviewFixtures: typeof reviewItems = []; +let holdReviewResponse = false; +let releaseReviewResponse: (() => void) | null = null; + +test.beforeEach(async ({ page }) => { + serverFixtures = servers; + clientFixtures = clients; + reviewFixtures = []; + holdReviewResponse = false; + releaseReviewResponse = null; + await page.setViewportSize({ width: 1024, height: 720 }); + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + switch (url.pathname) { + case "/api/system/readiness": + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ type: "ready", status: "ok" }), + }); + case "/api/mcp/servers/list": + return route.fulfill(ok({ servers: serverFixtures })); + case "/api/client/list": + return route.fulfill( + ok({ + total: clientFixtures.length, + last_updated: "2026-08-03T00:00:00Z", + client: clientFixtures, + }), + ); + case "/api/client/surface/reviews": + if (holdReviewResponse) { + await new Promise((resolve) => { + releaseReviewResponse = resolve; + }); + } + return route.fulfill(ok({ items: reviewFixtures })); + default: + return route.fulfill(ok({})); + } + }); +}); + +test("Servers paginate responsive grid results and reset after search", async ({ + page, +}) => { + await page.goto("/servers?view=grid&page=2"); + await expect(page).toHaveURL(/page=2/); + await expect(page.getByText("Server 07", { exact: true })).toBeVisible(); + + await page.goto("/servers?view=grid"); + + await expect(page.getByText("Server 01", { exact: true })).toBeVisible(); + await expect(page.getByText("Server 06", { exact: true })).toBeVisible(); + await expect(page.getByText("Server 07", { exact: true })).toHaveCount(0); + + await page.getByRole("button", { name: "Next" }).click(); + await expect(page).toHaveURL(/page=2/); + await expect(page.getByText("Server 07", { exact: true })).toBeVisible(); + await expect(page.getByText("Server 01", { exact: true })).toHaveCount(0); + + await page.getByPlaceholder("Search servers...").fill("Server"); + await expect(page).not.toHaveURL(/page=/); + await expect(page.getByText("Server 01", { exact: true })).toBeVisible(); + await expect(page.getByText("Server 07", { exact: true })).toHaveCount(0); + + await page.getByRole("button", { name: "Next" }).click(); + await expect(page).toHaveURL(/page=2/); + await page.getByRole("combobox", { name: "Per page", exact: true }).click(); + await page.getByRole("option", { name: "3", exact: true }).click(); + await expect(page).not.toHaveURL(/page=/); + await page.getByRole("button", { name: "Next", exact: true }).click(); + await expect(page).toHaveURL(/page=2/); + await page.setViewportSize({ width: 1280, height: 720 }); + await expect + .poll(() => + page.evaluate(() => ({ + innerWidth: window.innerWidth, + matches: window.matchMedia("(min-width: 1280px)").matches, + })), + ) + .toEqual({ innerWidth: 1280, matches: true }); + await page.evaluate(() => window.dispatchEvent(new Event("resize"))); + await expect(page).toHaveURL(/page=2/); + await expect(page.getByText("Server 04", { exact: true })).toBeVisible(); + await expect(page.getByText("Server 01", { exact: true })).toHaveCount(0); + + await page.goto("/servers?view=list"); + await expect(page.getByText("Server 01", { exact: true })).toBeVisible(); + await expect( + page.getByRole("button", { name: "Open inspect view" }), + ).toHaveCount(0); + + await page.goto("/servers?view=grid&page=invalid"); + await expect(page).not.toHaveURL(/page=/); + await expect(page.getByText("Server 01", { exact: true })).toBeVisible(); +}); + +test("Clients paginate responsive grid results and reset after search", async ({ + page, +}) => { + await page.goto("/clients?view=grid&page=2"); + await expect(page).toHaveURL(/page=2/); + await expect(page.getByText("Client 07", { exact: true })).toBeVisible(); + + await page.goto("/clients?view=grid"); + + await expect(page.getByText("Client 01", { exact: true })).toBeVisible(); + await expect(page.getByText("Client 06", { exact: true })).toBeVisible(); + await expect(page.getByText("Client 07", { exact: true })).toHaveCount(0); + + await page.getByRole("button", { name: "Next" }).click(); + await expect(page).toHaveURL(/page=2/); + await expect(page.getByText("Client 07", { exact: true })).toBeVisible(); + + await page.getByPlaceholder("Search clients...").fill("Client"); + await expect(page).not.toHaveURL(/page=/); + await expect(page.getByText("Client 01", { exact: true })).toBeVisible(); + await expect(page.getByText("Client 07", { exact: true })).toHaveCount(0); +}); + +test("Clients keep the selected view button aligned with the catalog layout", async ({ + page, +}) => { + await page.goto("/clients?expanded=true"); + + const gridButton = page.locator("main button:has(svg.lucide-grid3x3)"); + const listButton = page.locator("main button:has(svg.lucide-list)"); + + await expect(gridButton).toHaveCount(1); + await expect(listButton).toHaveCount(1); + await listButton.click(); + + await expect(listButton).toHaveClass(/bg-primary/); + await expect(gridButton).not.toHaveClass(/bg-primary/); +}); + +test("empty and zero-match catalogs normalize stale pages", async ({ page }) => { + serverFixtures = []; + await page.goto("/servers?view=grid&page=2"); + await expect(page).not.toHaveURL(/page=/); + await expect(page.getByText("No servers found", { exact: true })).toBeVisible(); + + serverFixtures = servers; + await page.goto("/servers?view=grid&q=NoMatch&page=2"); + await expect(page).not.toHaveURL(/page=/); + await expect(page.getByText("No servers found", { exact: true })).toBeVisible(); + + clientFixtures = []; + await page.goto("/clients?view=grid&page=2"); + await expect(page).not.toHaveURL(/page=/); + await expect(page.getByText("No clients found", { exact: true })).toBeVisible(); + await expectPaginationControlsDisabled(page); +}); + +test("needs-review deep links wait for review data before clamping", async ({ + page, +}) => { + reviewFixtures = reviewItems; + holdReviewResponse = true; + await page.goto("/clients?view=grid&filter=needs_review&page=2"); + + await expect.poll(() => releaseReviewResponse !== null).toBe(true); + await expect(page).toHaveURL(/page=2/); + await expectPaginationControlsDisabled(page); + await expect(page.getByText("No clients found", { exact: true })).toHaveCount(0); + releaseReviewResponse?.(); + + await expect(page.getByText("Client 07", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(/page=2/); +}); + +test("needs-review transitions do not flash stale empty states", async ({ page }) => { + reviewFixtures = reviewItems; + holdReviewResponse = true; + await page.goto("/clients?view=grid&expanded=true"); + + await expect.poll(() => releaseReviewResponse !== null).toBe(true); + await expect(page.getByText("Client 01", { exact: true })).toBeVisible(); + await page.getByRole("combobox", { name: "Filter", exact: true }).click(); + await page.getByRole("option", { name: "Needs review", exact: true }).click(); + await expect(page.getByText("No clients found", { exact: true })).toHaveCount(0); + await expect(page.getByText("Client 01", { exact: true })).toHaveCount(0); + releaseReviewResponse?.(); + + await expect(page.getByText("Client 01", { exact: true })).toBeVisible(); +}); diff --git a/board/e2e/detail-overview-overflow.spec.ts b/board/e2e/detail-overview-overflow.spec.ts new file mode 100644 index 00000000..927224ca --- /dev/null +++ b/board/e2e/detail-overview-overflow.spec.ts @@ -0,0 +1,108 @@ +import { expect, test, type Page } from "@playwright/test"; + +function ok(data: unknown) { + return { + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data }), + }; +} + +function serverEntries(count: number) { + return Array.from({ length: count }, (_, index) => ({ + name: `Server ${String(index + 1).padStart(2, "0")}`, + transport: "stdio", + args: [], + env: {}, + headers: {}, + })); +} + +async function currentServersScrollMetrics(page: Page) { + const currentServersCard = page + .getByText("Current Servers", { exact: true }) + .locator("xpath=ancestor::div[contains(@class, 'rounded-xl')][1]"); + const scrollRegion = currentServersCard.locator(".overflow-y-auto"); + await expect(scrollRegion).toHaveCount(1); + return scrollRegion.evaluate((element) => ({ + clientHeight: element.clientHeight, + scrollHeight: element.scrollHeight, + })); +} + +let configuredServerEntries = serverEntries(1); + +test.beforeEach(async ({ page }) => { + configuredServerEntries = serverEntries(1); + await page.setViewportSize({ width: 1280, height: 720 }); + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + switch (url.pathname) { + case "/api/system/readiness": + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ type: "ready", status: "ok" }), + }); + case "/api/client/list": + return route.fulfill( + ok({ + total: 1, + last_updated: "2026-08-04T00:00:00Z", + client: [ + { + identifier: "client-overflow", + display_name: "Overflow Client", + detected: true, + approval_status: "approved", + writable_config: false, + attachment_state: "not_applicable", + }, + ], + }), + ); + case "/api/client/config/details": + return route.fulfill( + ok({ + config_exists: true, + config_path: "/tmp/client.json", + content: {}, + has_mcp_config: true, + configured_server_entries: configuredServerEntries, + mcp_servers_count: configuredServerEntries.length, + approval_status: "approved", + attachment_state: "not_applicable", + writable_config: false, + template_merge_strategy: "deep_merge", + effective_merge_strategy: "deep_merge", + merge_strategy_source: "template", + supported_merge_strategies: ["deep_merge"], + template: {}, + }), + ); + case "/api/mcp/servers/list": + return route.fulfill(ok({ servers: [] })); + default: + return route.fulfill(ok({})); + } + }); +}); + +test("keeps a short current-servers list at its natural height", async ({ page }) => { + await page.goto("/clients/client-overflow"); + await expect(page.getByText("Server 01", { exact: true })).toBeVisible(); + + const metrics = await currentServersScrollMetrics(page); + expect(metrics.scrollHeight).toBe(metrics.clientHeight); +}); + +test("scrolls a long current-servers list inside its overview card", async ({ + page, +}) => { + configuredServerEntries = serverEntries(24); + await page.goto("/clients/client-overflow"); + await expect(page.getByText("Server 24", { exact: true })).toBeAttached(); + + const metrics = await currentServersScrollMetrics(page); + expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight); +}); diff --git a/board/e2e/server-list-error.spec.ts b/board/e2e/server-list-error.spec.ts new file mode 100644 index 00000000..7f6ecb8b --- /dev/null +++ b/board/e2e/server-list-error.spec.ts @@ -0,0 +1,73 @@ +import { expect, test } from "@playwright/test"; + +test("Server list failures use notification center without inline diagnostics", async ({ + page, +}) => { + let serverListRequests = 0; + const serverDiagnostics: string[] = []; + page.on("console", (message) => { + const text = message.text(); + if ( + text.includes("Fetching servers") || + text.includes("Servers fetched") || + text.includes("Error fetching servers") + ) { + serverDiagnostics.push(text); + } + }); + await page.addInitScript(() => { + window.localStorage.removeItem("mcp_notifications"); + }); + + await page.route("**/api/**", async (route) => { + const url = new URL(route.request().url()); + if (url.pathname === "/api/system/readiness") { + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ type: "ready", status: "ok" }), + }); + } + + if (url.pathname === "/api/mcp/servers/list") { + serverListRequests += 1; + return route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ message: "server list unavailable" }), + }); + } + + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ success: true, data: {} }), + }); + }); + + await page.goto("/servers"); + await expect.poll(() => serverListRequests).toBeGreaterThan(1); + await expect.poll(() => serverDiagnostics.length).toBe(0); + + await expect(page.getByRole("alert")).toHaveCount(0); + const notificationMenu = page.getByRole("menu", { name: "Notifications" }); + const loadFailureNotification = notificationMenu.getByText( + "Failed to load servers", + { exact: true }, + ); + await expect(loadFailureNotification).toBeVisible(); + await expect( + notificationMenu.getByText("server list unavailable", { exact: true }), + ).toBeVisible(); + await expect(page.getByText("Inspect Details", { exact: true })).toHaveCount(0); + await expect( + page.getByRole("button", { name: /^(Inspect|Hide Inspect)$/ }), + ).toHaveCount(0); + + await page.keyboard.press("Escape"); + const requestsBeforeRefresh = serverListRequests; + await page.getByRole("button", { name: "Refresh" }).click(); + await expect.poll(() => serverListRequests).toBeGreaterThan(requestsBeforeRefresh); + await page.getByRole("button", { name: "Notifications" }).click(); + await expect(loadFailureNotification).toHaveCount(1); +}); diff --git a/board/package.json b/board/package.json index ee60d020..0b924efb 100644 --- a/board/package.json +++ b/board/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "vite build", "lint": "eslint .", + "test": "bun test src", "biome": "bunx @biomejs/biome check .", "preview": "vite preview", "e2e": "node ./scripts/run-playwright.mjs test", diff --git a/board/src/components/audit-logs-panel.tsx b/board/src/components/audit-logs-panel.tsx index a9f38ff2..3d5d70bf 100644 --- a/board/src/components/audit-logs-panel.tsx +++ b/board/src/components/audit-logs-panel.tsx @@ -1,5 +1,6 @@ import type { AuditEventRecord } from "../lib/types"; import { useState } from "react"; +import { cn } from "../lib/utils"; import { Pagination } from "./pagination"; import { Button } from "./ui/button"; import { @@ -50,6 +51,8 @@ interface AuditLogsPanelProps { collapseLabel: string; defaultExpanded?: boolean; collapsedPreviewRows?: number; + /** Grow to fill remaining vertical space in a flex column parent; table body scrolls internally. */ + fillHeight?: boolean; } export function AuditLogsPanel({ @@ -83,13 +86,23 @@ export function AuditLogsPanel({ collapseLabel, defaultExpanded = false, collapsedPreviewRows = 5, + fillHeight = false, }: AuditLogsPanelProps) { const [expanded, setExpanded] = useState(defaultExpanded); const visibleRows = expanded ? rows : rows.slice(0, collapsedPreviewRows); return ( - - + +
{title} {description} @@ -121,13 +134,28 @@ export function AuditLogsPanel({ ) : null}
- + {isLoading ? ( -
+
{loadingLabel}
) : rows.length ? ( -
+
@@ -159,7 +187,12 @@ export function AuditLogsPanel({
) : ( -
+
{emptyLabel}
)} @@ -181,7 +214,7 @@ export function AuditLogsPanel({ onNextPage={onNextPage} onLastPage={onLastPage} pageSizeOptions={[10, 20, 50, 100]} - className="mt-4" + className={cn("mt-4", fillHeight && "shrink-0")} /> ) : null} diff --git a/board/src/components/detail-tab-content-class.ts b/board/src/components/detail-tab-content-class.ts index d3b1e3b3..c09f4ada 100644 --- a/board/src/components/detail-tab-content-class.ts +++ b/board/src/components/detail-tab-content-class.ts @@ -4,3 +4,14 @@ */ export const DETAIL_TAB_CONTENT_CLASS = "mt-0 flex min-h-0 flex-1 flex-col overflow-hidden data-[state=inactive]:hidden"; + +/** Overview tab column: metadata/stats pinned at top, logs panel fills remaining height. */ +export const DETAIL_OVERVIEW_STACK_CLASS = + "flex h-full min-h-0 flex-1 flex-col gap-4"; + +/** Sections above the fill-height logs card (metadata, stats, instance lists). */ +export const DETAIL_OVERVIEW_PINNED_SECTION_CLASS = "shrink-0"; + +/** Allow unbounded overview lists to scroll without changing short-list height. */ +export const DETAIL_OVERVIEW_SCROLLABLE_LIST_CLASS = + "max-h-[min(16rem,35vh)] overflow-y-auto"; diff --git a/board/src/components/entity-card.tsx b/board/src/components/entity-card.tsx index 2a6da724..535880d2 100644 --- a/board/src/components/entity-card.tsx +++ b/board/src/components/entity-card.tsx @@ -8,6 +8,7 @@ import { CardHeader, CardTitle, } from "../components/ui/card"; +import { cn } from "../lib/utils"; export interface EntityCardProps { id: string; @@ -21,6 +22,8 @@ export interface EntityCardProps { avatarShape?: "circle" | "rounded" | "square"; topRightBadge?: ReactNode; + /** Pin `topRightBadge` to the header top-right without shrinking the title row. */ + topRightBadgePosition?: "inline" | "corner"; // 统计信息 (4x2 网格) stats?: Array<{ @@ -51,6 +54,7 @@ export function EntityCard({ avatar, avatarShape = "circle", topRightBadge, + topRightBadgePosition = "inline", stats = [], bottomLeft, bottomRight, @@ -76,15 +80,36 @@ export function EntityCard({ onKeyDown?.(e); }; + const isCornerBadge = + topRightBadgePosition === "corner" && topRightBadge != null; + const titleClassNames = cn( + "min-w-0 truncate text-lg font-semibold leading-tight", + !isCornerBadge && "flex-1", + titleClassName, + ); + const titleNode = ( + + {title} + + ); + return ( - + + {isCornerBadge ? ( +
+ {topRightBadge} +
+ ) : null}
-
-
- - {title} - - {topRightBadge ? ( -
- {topRightBadge} -
- ) : null} -
+
+ {isCornerBadge ? ( + titleNode + ) : ( +
+ {titleNode} + {topRightBadge ? ( +
+ {topRightBadge} +
+ ) : null} +
+ )}
{typeof description === "string" || description === undefined ? ( diff --git a/board/src/components/layout/layout.tsx b/board/src/components/layout/layout.tsx index e49f6470..0c2e21b5 100644 --- a/board/src/components/layout/layout.tsx +++ b/board/src/components/layout/layout.tsx @@ -319,11 +319,11 @@ export function Layout() { }`} > {/* Viewport-height column: outlet fills space above footer; pages can use h-full + inner scroll */} -
+
-
diff --git a/board/src/components/layout/sidebar.tsx b/board/src/components/layout/sidebar.tsx index d083a583..f2baf85f 100644 --- a/board/src/components/layout/sidebar.tsx +++ b/board/src/components/layout/sidebar.tsx @@ -87,10 +87,7 @@ export function Sidebar() { )} /> - {t("layout.brand", { defaultValue: "MCPMate" })}{" "} - - {t("layout.alpha", { defaultValue: "Beta" })} - + {t("layout.brand", { defaultValue: "MCPMate" })} , - ] - : [] - } onClick={handleOpen} /> ); diff --git a/board/src/components/ui/page-toolbar-select.test.ts b/board/src/components/ui/page-toolbar-select.test.ts new file mode 100644 index 00000000..c6c70cda --- /dev/null +++ b/board/src/components/ui/page-toolbar-select.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; + +import { + getPageToolbarSelectLabel, + type PageToolbarSelectOption, +} from "./page-toolbar-select"; + +describe("getPageToolbarSelectLabel", () => { + const options: PageToolbarSelectOption[] = [ + { value: "name", label: "Name" }, + { value: "needs_review", label: "Needs review" }, + ]; + + it("uses the selected option label for width mirroring", () => { + expect(getPageToolbarSelectLabel("name", options)).toBe("Name"); + expect(getPageToolbarSelectLabel("needs_review", options)).toBe("Needs review"); + }); + + it("falls back to placeholder when value is missing", () => { + expect(getPageToolbarSelectLabel("missing", options, "Filter")).toBe("Filter"); + }); +}); diff --git a/board/src/components/ui/page-toolbar-select.tsx b/board/src/components/ui/page-toolbar-select.tsx new file mode 100644 index 00000000..c1a5d0fd --- /dev/null +++ b/board/src/components/ui/page-toolbar-select.tsx @@ -0,0 +1,76 @@ +import React from "react"; + +import { cn } from "../../lib/utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "./select"; + +export interface PageToolbarSelectOption { + value: string; + label: string; +} + +export interface PageToolbarSelectProps { + value: string; + onValueChange: (value: string) => void; + options: PageToolbarSelectOption[]; + placeholder?: string; + "aria-label"?: string; + className?: string; + triggerClassName?: string; +} + +export function getPageToolbarSelectLabel( + value: string, + options: PageToolbarSelectOption[], + placeholder?: string, +): string { + return options.find((option) => option.value === value)?.label ?? placeholder ?? ""; +} + +const mirrorClassName = + "invisible col-start-1 row-start-1 flex h-9 items-center whitespace-pre border border-transparent px-3 pr-8 text-sm leading-none"; + +export function PageToolbarSelect({ + value, + onValueChange, + options, + placeholder, + "aria-label": ariaLabel, + className, + triggerClassName, +}: PageToolbarSelectProps) { + const selectedLabel = getPageToolbarSelectLabel(value, options, placeholder); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/board/src/components/ui/page-toolbar.tsx b/board/src/components/ui/page-toolbar.tsx index e295f973..ae17d1f6 100644 --- a/board/src/components/ui/page-toolbar.tsx +++ b/board/src/components/ui/page-toolbar.tsx @@ -30,13 +30,13 @@ export interface Entity { } import { Button } from "./button"; import { Input } from "./input"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "./select"; +import { PageToolbarSelect } from "./page-toolbar-select"; + +export { PageToolbarSelect } from "./page-toolbar-select"; +export type { + PageToolbarSelectOption, + PageToolbarSelectProps, +} from "./page-toolbar-select"; export const toolbarSearchInputClassName = "h-9 w-full rounded-md border border-slate-200 bg-white px-4 py-2 text-sm placeholder:text-slate-500 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-offset-0 focus:ring-slate-300 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-offset-0 focus-visible:ring-slate-300 dark:border-slate-700 dark:bg-slate-900 dark:placeholder:text-slate-400 dark:focus:ring-slate-600 dark:focus-visible:ring-slate-600"; @@ -45,6 +45,7 @@ export const toolbarSearchInputClassName = export interface PageToolbarConfig { // 数据源 data?: T[]; + isDataReady?: boolean; // 搜索配置 search?: { @@ -124,6 +125,7 @@ export function PageToolbar({ }: PageToolbarProps) { const { data = [], + isDataReady = true, search: searchConfig, viewMode: viewModeConfig, sort: sortConfig, @@ -258,23 +260,40 @@ export function PageToolbar({ }, [filteredData, sortState, sortConfig, getNestedValue]); // 通知排序后的数据变化(避免无限循环) - const lastSortedRef = React.useRef(null); + const lastSortedNotificationRef = React.useRef<{ + items: T[]; + data: T[]; + search: string; + sortField: string; + sortDirection: SortState["direction"]; + } | null>(null); React.useEffect(() => { - if (!callbacks?.onSortedDataChange) return; + if (!isDataReady || !callbacks?.onSortedDataChange) return; - const previous = lastSortedRef.current; - const isSameAsPrevious = + const previous = lastSortedNotificationRef.current; + const hasSameItems = previous !== null && - previous.length === sortedData.length && - previous.every((item, index) => item === sortedData[index]); - - if (isSameAsPrevious) { + previous.items.length === sortedData.length && + previous.items.every((item, index) => item === sortedData[index]); + const hasSameInput = + previous?.data === data && + previous.search === search && + previous.sortField === sortState.field && + previous.sortDirection === sortState.direction; + + if (hasSameItems && hasSameInput) { return; } - lastSortedRef.current = sortedData; + lastSortedNotificationRef.current = { + items: sortedData, + data, + search, + sortField: sortState.field, + sortDirection: sortState.direction, + }; callbacks.onSortedDataChange(sortedData); - }, [sortedData, callbacks]); + }, [callbacks, data, isDataReady, search, sortedData, sortState.direction, sortState.field]); // 是否启用精简模式 const isCompact = compactConfig?.enabled !== false; @@ -365,8 +384,6 @@ export function PageToolbar({ handleSortChange({ field: sortState.field, direction: newDirection }); }; - const currentLabel = currentOption?.label || "Sort"; - return (
- + ({ + value: option.value, + label: option.label, + }))} + placeholder="Sort" + triggerClassName="border-l-0 rounded-l-none" + />
); }; diff --git a/board/src/lib/catalog-layout.ts b/board/src/lib/catalog-layout.ts new file mode 100644 index 00000000..9eb19148 --- /dev/null +++ b/board/src/lib/catalog-layout.ts @@ -0,0 +1,11 @@ +/** + * Catalog list hover uses `-translate-y-0.5` (2px). Top inset prevents border clipping + * inside `overflow-y-auto` shells; matching negative margin keeps stats-to-list rhythm. + */ +export const catalogPageSectionClassName = "flex min-h-0 flex-1 flex-col -mt-0.5"; + +export const catalogScrollShellClassName = + "min-h-0 flex-1 overflow-y-auto pt-0.5"; + +/** Catalog surface without a dedicated inner scroll shell (legacy partial inset). */ +export const catalogSurfaceClassName = "-mt-0.5 pt-0.5"; diff --git a/board/src/lib/hooks/use-responsive-catalog-pagination.test.ts b/board/src/lib/hooks/use-responsive-catalog-pagination.test.ts new file mode 100644 index 00000000..5d749b7d --- /dev/null +++ b/board/src/lib/hooks/use-responsive-catalog-pagination.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test"; + +import { + clampCatalogPage, + getCatalogPageSize, + getCatalogTotalPages, + paginateCatalogItems, +} from "./use-responsive-catalog-pagination"; + +describe("responsive catalog pagination", () => { + test("uses three grid rows at every responsive column count", () => { + expect(getCatalogPageSize("grid", 1)).toBe(3); + expect(getCatalogPageSize("grid", 2)).toBe(6); + expect(getCatalogPageSize("grid", 3)).toBe(9); + }); + + test("uses six items in list view regardless of grid columns", () => { + expect(getCatalogPageSize("list", 1)).toBe(6); + expect(getCatalogPageSize("list", 3)).toBe(6); + }); + + test("calculates at least one page and rounds partial pages up", () => { + expect(getCatalogTotalPages(0, 6)).toBe(1); + expect(getCatalogTotalPages(7, 6)).toBe(2); + }); + + test("clamps invalid and stale page numbers", () => { + expect(clampCatalogPage(0, 4)).toBe(1); + expect(clampCatalogPage(8, 4)).toBe(4); + expect(clampCatalogPage(2, 4)).toBe(2); + }); + + test("returns only the requested page slice", () => { + expect(paginateCatalogItems([1, 2, 3, 4, 5, 6, 7], 2, 3)).toEqual([ + 4, + 5, + 6, + ]); + }); +}); diff --git a/board/src/lib/hooks/use-responsive-catalog-pagination.ts b/board/src/lib/hooks/use-responsive-catalog-pagination.ts new file mode 100644 index 00000000..708796c6 --- /dev/null +++ b/board/src/lib/hooks/use-responsive-catalog-pagination.ts @@ -0,0 +1,196 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useSearchParams } from "react-router-dom"; + +import { useUrlState } from "./use-url-state"; + +export type CatalogViewMode = "grid" | "list"; + +const GRID_ROWS_PER_PAGE = 3; +const LIST_ITEMS_PER_PAGE = 6; +export const CATALOG_PAGE_SIZE_OPTIONS = [3, 6, 9, 12] as const; + +function isValidPageParam(value: string): boolean { + return /^[1-9]\d*$/.test(value); +} + +export function getCatalogPageSize( + viewMode: CatalogViewMode, + gridColumnCount: number, +): number { + if (viewMode === "list") { + return LIST_ITEMS_PER_PAGE; + } + + const columnCount = Math.min(3, Math.max(1, gridColumnCount)); + return GRID_ROWS_PER_PAGE * columnCount; +} + +export function getCatalogTotalPages( + itemCount: number, + pageSize: number, +): number { + return Math.max(1, Math.ceil(itemCount / pageSize)); +} + +export function clampCatalogPage(page: number, totalPages: number): number { + return Math.min(Math.max(1, page), Math.max(1, totalPages)); +} + +export function paginateCatalogItems( + items: readonly T[], + page: number, + pageSize: number, +): T[] { + const start = (page - 1) * pageSize; + return items.slice(start, start + pageSize); +} + +function getCatalogGridColumnCount(): number { + if (typeof window === "undefined") { + return 1; + } + if (window.matchMedia("(min-width: 1280px)").matches) { + return 3; + } + if (window.matchMedia("(min-width: 768px)").matches) { + return 2; + } + return 1; +} + +/** Responsive grid column count for catalog and market card grids (`md:2`, `xl:3`). */ +export { getCatalogGridColumnCount }; + +export function useCatalogGridColumnCount(): number { + const [gridColumnCount, setGridColumnCount] = useState(getCatalogGridColumnCount); + + useEffect(() => { + const updateColumnCount = () => + setGridColumnCount(getCatalogGridColumnCount()); + + window.addEventListener("resize", updateColumnCount); + return () => window.removeEventListener("resize", updateColumnCount); + }, []); + + return gridColumnCount; +} + +interface ResponsiveCatalogPagination { + currentPage: number; + pageSize: number; + totalPages: number; + pageItems: T[]; + hasPreviousPage: boolean; + hasNextPage: boolean; + goToPage: (page: number) => void; + goToFirstPage: () => void; + goToPreviousPage: () => void; + goToNextPage: () => void; + goToLastPage: () => void; + onItemsPerPageChange: (pageSize: number) => void; +} + +export function useResponsiveCatalogPagination( + items: readonly T[], + viewMode: CatalogViewMode, + isDataReady = true, +): ResponsiveCatalogPagination { + const [searchParams] = useSearchParams(); + const gridColumnCount = useCatalogGridColumnCount(); + const [requestedPage, setRequestedPage] = useUrlState({ + paramName: "page", + defaultValue: 1, + validate: isValidPageParam, + deserialize: Number, + }); + const [selectedPageSize, setSelectedPageSize] = useState(null); + + const responsivePageSize = getCatalogPageSize(viewMode, gridColumnCount); + const pageSize = selectedPageSize ?? responsivePageSize; + const totalPages = getCatalogTotalPages(items.length, pageSize); + const currentPage = isDataReady + ? clampCatalogPage(requestedPage, totalPages) + : requestedPage; + const pageParam = searchParams.get("page"); + const hasInvalidPageParam = + pageParam !== null && !isValidPageParam(pageParam); + const resetSignature = ["q", "filter", "sort", "view"] + .map((key) => `${key}=${searchParams.get(key) ?? ""}`) + .concat(`mode=${viewMode}`) + .join("&"); + const previousResetSignature = useRef(resetSignature); + + useEffect(() => { + if (!isDataReady) { + return; + } + if (hasInvalidPageParam) { + setRequestedPage(1); + return; + } + + const shouldReset = previousResetSignature.current !== resetSignature; + + previousResetSignature.current = resetSignature; + + if (shouldReset) { + setRequestedPage(1); + return; + } + + if (requestedPage !== currentPage) { + setRequestedPage(currentPage); + } + }, [ + currentPage, + hasInvalidPageParam, + isDataReady, + requestedPage, + resetSignature, + setRequestedPage, + ]); + + const pageItems = useMemo( + () => paginateCatalogItems(items, currentPage, pageSize), + [items, currentPage, pageSize], + ); + const goToPage = useCallback( + (page: number) => setRequestedPage(clampCatalogPage(page, totalPages)), + [setRequestedPage, totalPages], + ); + const goToFirstPage = useCallback(() => goToPage(1), [goToPage]); + const goToPreviousPage = useCallback( + () => goToPage(currentPage - 1), + [currentPage, goToPage], + ); + const goToNextPage = useCallback( + () => goToPage(currentPage + 1), + [currentPage, goToPage], + ); + const goToLastPage = useCallback( + () => goToPage(totalPages), + [goToPage, totalPages], + ); + const onItemsPerPageChange = useCallback( + (nextPageSize: number) => { + setSelectedPageSize(nextPageSize); + setRequestedPage(1); + }, + [setRequestedPage], + ); + + return { + currentPage, + pageSize, + totalPages, + pageItems, + hasPreviousPage: currentPage > 1, + hasNextPage: currentPage < totalPages, + goToPage, + goToFirstPage, + goToPreviousPage, + goToNextPage, + goToLastPage, + onItemsPerPageChange, + }; +} diff --git a/board/src/lib/server-query-cache.test.ts b/board/src/lib/server-query-cache.test.ts new file mode 100644 index 00000000..541a2e40 --- /dev/null +++ b/board/src/lib/server-query-cache.test.ts @@ -0,0 +1,26 @@ +import { QueryClient } from "@tanstack/react-query"; +import { describe, expect, test } from "bun:test"; + +import { invalidateServerCatalogAfterImport } from "./server-query-cache"; + +describe("invalidateServerCatalogAfterImport", () => { + test("invalidates the server catalog after importing servers", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["servers"], { servers: [] }); + queryClient.setQueryData(["clients"], { clients: [] }); + + await invalidateServerCatalogAfterImport(queryClient, 2); + + expect(queryClient.getQueryState(["servers"])?.isInvalidated).toBe(true); + expect(queryClient.getQueryState(["clients"])?.isInvalidated).toBe(false); + }); + + test("keeps the server catalog fresh when nothing was imported", async () => { + const queryClient = new QueryClient(); + queryClient.setQueryData(["servers"], { servers: [] }); + + await invalidateServerCatalogAfterImport(queryClient, 0); + + expect(queryClient.getQueryState(["servers"])?.isInvalidated).toBe(false); + }); +}); diff --git a/board/src/lib/server-query-cache.ts b/board/src/lib/server-query-cache.ts new file mode 100644 index 00000000..3b888a1d --- /dev/null +++ b/board/src/lib/server-query-cache.ts @@ -0,0 +1,12 @@ +import type { QueryClient } from "@tanstack/react-query"; + +export function invalidateServerCatalogAfterImport( + queryClient: QueryClient, + importedCount: number, +): Promise { + if (importedCount <= 0) { + return Promise.resolve(); + } + + return queryClient.invalidateQueries({ queryKey: ["servers"] }); +} diff --git a/board/src/pages/clients/client-detail-page.tsx b/board/src/pages/clients/client-detail-page.tsx index 90372145..6ad2ea4f 100644 --- a/board/src/pages/clients/client-detail-page.tsx +++ b/board/src/pages/clients/client-detail-page.tsx @@ -40,7 +40,12 @@ import { import { ClientFormDrawer } from "../../components/client-form-drawer"; import { ConfirmDialog } from "../../components/confirm-dialog"; import { SurfaceReviewDialog } from "../../components/surface-review-dialog"; -import { DETAIL_TAB_CONTENT_CLASS } from "../../components/detail-tab-content-class"; +import { + DETAIL_TAB_CONTENT_CLASS, + DETAIL_OVERVIEW_PINNED_SECTION_CLASS, + DETAIL_OVERVIEW_SCROLLABLE_LIST_CLASS, + DETAIL_OVERVIEW_STACK_CLASS, +} from "../../components/detail-tab-content-class"; import { useUrlTab } from "../../lib/hooks/use-url-state"; import { Badge } from "../../components/ui/badge"; @@ -93,6 +98,7 @@ import { } from "../../lib/api"; import { resolveAutoAddTargetProfileId } from "../../lib/default-profile"; import { buildClientServersImportRequest } from "../../lib/server-import-payload"; +import { invalidateServerCatalogAfterImport } from "../../lib/server-query-cache"; import { mapDashboardSettingsToClientBackupPolicy } from "../../lib/client-backup-policy"; import { applyClientConfigWithResolvedSelection, @@ -1691,6 +1697,7 @@ export function ClientDetailPage() { ); } const imported = res.imported_count ?? 0; + void invalidateServerCatalogAfterImport(qc, imported); if (imported > 0) { notifySuccess( t("detail.notifications.imported.title", { @@ -2025,12 +2032,9 @@ export function ClientDetailPage() { {renderOverviewActionButtons()}
- -
- + +
+ {loadingConfig ? (
@@ -2385,7 +2389,7 @@ export function ClientDetailPage() { )} - +
@@ -2408,7 +2412,7 @@ export function ClientDetailPage() {
- + {loadingConfig ? (
{[1, 2, 3].map((i) => ( @@ -2438,6 +2442,7 @@ export function ClientDetailPage() { {showClientLiveLogs ? ( surfaceReviewsApi.list({ state: "pending" }), @@ -195,6 +202,21 @@ export function ClientsPage() { const [sortedClients, setSortedClients] = React.useState( filteredClientsAsEntities, ); + const [sortedClientSource, setSortedClientSource] = + useState(null); + const isCatalogDataReady = sortedClientSource === filteredClientsAsEntities; + const clientPagination = useResponsiveCatalogPagination( + sortedClients, + view as "grid" | "list", + isCatalogDataReady, + ); + const isCatalogLoading = + isLoading || (clientData !== undefined && !isCatalogDataReady); + const catalogScrollRef = React.useRef(null); + + React.useEffect(() => { + catalogScrollRef.current?.scrollTo({ top: 0 }); + }, [clientPagination.currentPage]); const governanceMutation = useMutation({ mutationFn: async ({ @@ -307,11 +329,10 @@ export function ClientsPage() { governanceMutation.mutate({ identifier, approved: checked }), disabled: governanceMutation.isPending || governanceStatus === "pending", }} - className={`${governanceStatus === "pending" ? "opacity-75" : ""} ${attentionClasses.cardClassName} ${ - reviewCount > 0 - ? "border-amber-400 bg-amber-50/50 dark:border-amber-700 dark:bg-amber-950/20" - : "" - }`.trim()} + className={`${governanceStatus === "pending" ? "opacity-75" : ""} ${attentionClasses.cardClassName} ${reviewCount > 0 + ? "border-amber-400 bg-amber-50/50 dark:border-amber-700 dark:bg-amber-950/20" + : "" + }`.trim()} onClick={() => navigate(`/clients/${encodeURIComponent(identifier)}`)} /> ); @@ -459,11 +480,15 @@ export function ClientsPage() { // Toolbar expansion state const [expanded, setExpanded] = useState(false); + const isClientCatalogDataReady = + clientData !== undefined && + (filter !== "needs_review" || areReviewItemsFetched); // Toolbar configuration const toolbarConfig = React.useMemo( () => ({ data: filteredClientsAsEntities, + isDataReady: isClientCatalogDataReady, search: { placeholder: t("toolbar.search.placeholder", { defaultValue: "Search clients...", @@ -521,7 +546,13 @@ export function ClientsPage() { enabled: true, }, }), - [filteredClientsAsEntities, i18n.language, t], + [ + filteredClientsAsEntities, + i18n.language, + isClientCatalogDataReady, + storedDefaultView, + t, + ], ); // Toolbar state @@ -538,7 +569,10 @@ export function ClientsPage() { onViewModeChange: (mode: "grid" | "list") => { setDashboardSetting("defaultView", mode); }, - onSortedDataChange: (sortedData: ClientToolbarEntity[]) => setSortedClients(sortedData), + onSortedDataChange: (sortedData: ClientToolbarEntity[]) => { + setSortedClients(sortedData); + setSortedClientSource(filteredClientsAsEntities); + }, onExpandedChange: setExpanded, }; @@ -559,23 +593,16 @@ export function ClientsPage() { ); const filterNode = ( -
- -
+ setFilter(value as ClientPageFilter)} + options={filterOptions.map((opt) => ({ + value: String(opt.value), + label: String(opt.label), + }))} + aria-label={t("toolbar.filters.title", { defaultValue: "Filter" })} + placeholder={t("toolbar.filters.title", { defaultValue: "Filter" })} + /> ); // Toolbar actions @@ -633,35 +660,62 @@ export function ClientsPage() { })}
)} -
- - {view === "grid" - ? sortedClients.map((client) => { - const sourceClient = clientsByIdentifier.get(client.identifier); - return sourceClient ? ( - navigate(`/clients/${encodeURIComponent(identifier)}`)} - onGovernanceChange={(identifier, approved) => governanceMutation.mutate({ identifier, approved })} - isGovernancePending={governanceMutation.isPending} - reviewCount={getClientReviewCount( - pendingReviewItems, - sourceClient, - )} - /> - ) : null; - }) - : sortedClients.map((client) => { - const sourceClient = clientsByIdentifier.get(client.identifier); - return sourceClient ? renderClientListItem(sourceClient) : null; - })} - +
+
+ + {view === "grid" + ? clientPagination.pageItems.map((client) => { + const sourceClient = clientsByIdentifier.get(client.identifier); + return sourceClient ? ( + navigate(`/clients/${encodeURIComponent(identifier)}`)} + onGovernanceChange={(identifier, approved) => governanceMutation.mutate({ identifier, approved })} + isGovernancePending={governanceMutation.isPending} + reviewCount={getClientReviewCount( + pendingReviewItems, + sourceClient, + )} + /> + ) : null; + }) + : clientPagination.pageItems.map((client) => { + const sourceClient = clientsByIdentifier.get(client.identifier); + return sourceClient ? renderClientListItem(sourceClient) : null; + })} + +
+
{ const values = { identifier: "claude_desktop", displayName: "Claude Desktop", - configFileChoice: "without_config_file", + configFileChoice: "without_config_file" as const, + mergeStrategySelection: "deep_merge" as const, supportedTransports: [], - configFileParseFormat: "json", - configFileParseContainerType: "standard", + configFileParseFormat: "json" as const, + configFileParseContainerType: "standard" as const, }; const createResult = createClientFormSchema(echoT, "create").safeParse(values); diff --git a/board/src/pages/market/hooks/use-market-data.ts b/board/src/pages/market/hooks/use-market-data.ts index 32843263..a41c791a 100644 --- a/board/src/pages/market/hooks/use-market-data.ts +++ b/board/src/pages/market/hooks/use-market-data.ts @@ -7,17 +7,21 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useSearchParams } from "react-router-dom"; import { useCursorPagination } from "../../../hooks/use-cursor-pagination"; +import { useCatalogGridColumnCount } from "../../../lib/hooks/use-responsive-catalog-pagination"; import { useCatalogProvider } from "../../../lib/market"; import { getOfficialMeta, getCanonicalRegistryServerId } from "../../../lib/registry"; import { useAppStore } from "../../../lib/store"; import type { RegistryServerEntry } from "../../../lib/types"; import type { UseMarketDataReturn } from "../types"; import { - type MarketPageSize, buildMarketPaginationStorageKey, + getDefaultMarketPageSize, + getMarketPageSizeOptions, parseMarketListPageParam, parseMarketListPerPageParam, + readMarketListSelectedPageSize, readStoredMarketPagination, + snapMarketPageSize, writeStoredMarketPagination, } from "../market-list-pagination-storage"; import { @@ -108,10 +112,16 @@ export function useMarketData( const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const providerId = provider.meta.id; + const gridColumnCount = useCatalogGridColumnCount(); + const responsivePageSize = getDefaultMarketPageSize(gridColumnCount); + const pageSizeOptions = getMarketPageSizeOptions(gridColumnCount); const restoredPaginationRef = useRef(null); if (restoredPaginationRef.current === null) { const urlPage = parseMarketListPageParam(searchParams.get("page")); - const urlPerPage = parseMarketListPerPageParam(searchParams.get("perPage")); + const urlPerPage = parseMarketListPerPageParam( + searchParams.get("perPage"), + gridColumnCount, + ); const urlSearch = searchParams.get("q") ?? ""; restoredPaginationRef.current = restoreMarketPagination( providerId, @@ -121,9 +131,10 @@ export function useMarketData( ); } - const [itemsPerPage, setItemsPerPage] = useState(() => - parseMarketListPerPageParam(searchParams.get("perPage")), + const [selectedPageSize, setSelectedPageSize] = useState(() => + readMarketListSelectedPageSize(searchParams.get("perPage"), gridColumnCount), ); + const itemsPerPage = selectedPageSize ?? responsivePageSize; const [isPaginationActionLoading, setIsPaginationActionLoading] = useState(false); const [isRestoringPagination, setIsRestoringPagination] = useState( () => restoredPaginationRef.current?.needsRebuild ?? false, @@ -132,6 +143,7 @@ export function useMarketData( (state) => state.dashboardSettings.marketBlacklist, ); const prevFiltersRef = useRef({ search, sort }); + const prevGridColumnCountRef = useRef(gridColumnCount); const pagination = useCursorPagination({ limit: itemsPerPage, @@ -300,6 +312,34 @@ export function useMarketData( resetToFirstPage(); }, [search, sort, resetToFirstPage]); + useEffect(() => { + const prevColumnCount = prevGridColumnCountRef.current; + prevGridColumnCountRef.current = gridColumnCount; + + if (prevColumnCount === gridColumnCount) { + return; + } + + if (selectedPageSize === null) { + resetToFirstPage(); + return; + } + + if (pageSizeOptions.includes(selectedPageSize)) { + return; + } + + const snapped = snapMarketPageSize(selectedPageSize, gridColumnCount); + setSelectedPageSize(snapped === responsivePageSize ? null : snapped); + resetToFirstPage(); + }, [ + gridColumnCount, + pageSizeOptions, + resetToFirstPage, + responsivePageSize, + selectedPageSize, + ]); + useEffect(() => { if (isRestoringPagination) { return; @@ -312,7 +352,7 @@ export function useMarketData( } else { next.delete("page"); } - if (itemsPerPage !== 9) { + if (itemsPerPage !== responsivePageSize) { next.set("perPage", String(itemsPerPage)); } else { next.delete("perPage"); @@ -321,7 +361,13 @@ export function useMarketData( }, { replace: true }, ); - }, [currentPage, itemsPerPage, isRestoringPagination, setSearchParams]); + }, [ + currentPage, + itemsPerPage, + isRestoringPagination, + responsivePageSize, + setSearchParams, + ]); useEffect(() => { if (isRestoringPagination) { @@ -464,10 +510,12 @@ export function useMarketData( if (nextItemsPerPage === itemsPerPage) { return; } - setItemsPerPage(nextItemsPerPage as MarketPageSize); + setSelectedPageSize( + nextItemsPerPage === responsivePageSize ? null : nextItemsPerPage, + ); resetToFirstPage(); }, - [itemsPerPage, resetToFirstPage], + [itemsPerPage, resetToFirstPage, responsivePageSize], ); const handleLastPage = useCallback(async () => { @@ -567,6 +615,7 @@ export function useMarketData( hasNextPage, itemsPerPage, totalPages, + pageSizeOptions, }, onNextPage: handleNextPage, onPreviousPage: handlePreviousPage, diff --git a/board/src/pages/market/market-list-pagination-storage.test.ts b/board/src/pages/market/market-list-pagination-storage.test.ts new file mode 100644 index 00000000..bbe8b0e8 --- /dev/null +++ b/board/src/pages/market/market-list-pagination-storage.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; + +import { + getDefaultMarketPageSize, + getMarketPageSizeOptions, + parseMarketListPerPageParam, + readMarketListSelectedPageSize, + snapMarketPageSize, +} from "./market-list-pagination-storage"; + +describe("market list pagination storage", () => { + test("page size options are multiples of the responsive grid column count", () => { + expect(getMarketPageSizeOptions(1)).toEqual([3, 9, 18, 24]); + expect(getMarketPageSizeOptions(2)).toEqual([6, 18, 36, 48]); + expect(getMarketPageSizeOptions(3)).toEqual([9, 27, 54, 72]); + }); + + test("default page size matches three grid rows at the current column count", () => { + expect(getDefaultMarketPageSize(1)).toBe(3); + expect(getDefaultMarketPageSize(2)).toBe(6); + expect(getDefaultMarketPageSize(3)).toBe(9); + }); + + test("parseMarketListPerPageParam snaps invalid values to the responsive default", () => { + expect(parseMarketListPerPageParam("9", 2)).toBe(6); + expect(parseMarketListPerPageParam("27", 3)).toBe(27); + expect(parseMarketListPerPageParam(null, 2)).toBe(6); + }); + + test("snapMarketPageSize picks the nearest valid option", () => { + expect(snapMarketPageSize(9, 2)).toBe(6); + expect(snapMarketPageSize(27, 2)).toBe(18); + expect(snapMarketPageSize(72, 3)).toBe(72); + }); + + test("readMarketListSelectedPageSize returns null for the responsive default", () => { + expect(readMarketListSelectedPageSize(null, 2)).toBe(null); + expect(readMarketListSelectedPageSize("18", 2)).toBe(18); + expect(readMarketListSelectedPageSize("6", 2)).toBe(null); + }); +}); diff --git a/board/src/pages/market/market-list-pagination-storage.ts b/board/src/pages/market/market-list-pagination-storage.ts index 258978bb..3ada3575 100644 --- a/board/src/pages/market/market-list-pagination-storage.ts +++ b/board/src/pages/market/market-list-pagination-storage.ts @@ -1,6 +1,46 @@ -export const MARKET_PAGE_SIZE_OPTIONS = [9, 27, 54, 72] as const; +import { + getCatalogGridColumnCount, + getCatalogPageSize, +} from "../../lib/hooks/use-responsive-catalog-pagination"; -export type MarketPageSize = (typeof MARKET_PAGE_SIZE_OPTIONS)[number]; +/** Page-size tiers as multiples of three grid rows at the current column count. */ +const MARKET_PAGE_SIZE_TIERS = [1, 3, 6, 8] as const; + +export function getMarketPageSizeOptions(gridColumnCount: number): number[] { + const base = getCatalogPageSize("grid", gridColumnCount); + return MARKET_PAGE_SIZE_TIERS.map((tier) => base * tier); +} + +export function getDefaultMarketPageSize( + gridColumnCount = getCatalogGridColumnCount(), +): number { + return getCatalogPageSize("grid", gridColumnCount); +} + +export function snapMarketPageSize( + size: number, + gridColumnCount: number, +): number { + const options = getMarketPageSizeOptions(gridColumnCount); + if (options.includes(size)) { + return size; + } + return options.reduce( + (best, option) => + Math.abs(option - size) < Math.abs(best - size) ? option : best, + options[0], + ); +} + +/** `null` when the URL matches the responsive default (no explicit user override). */ +export function readMarketListSelectedPageSize( + perPageParam: string | null, + gridColumnCount: number, +): number | null { + const responsive = getDefaultMarketPageSize(gridColumnCount); + const parsed = parseMarketListPerPageParam(perPageParam, gridColumnCount); + return parsed === responsive ? null : parsed; +} const STORAGE_PREFIX = "mcpmate.market.pagination"; @@ -65,12 +105,16 @@ export function parseMarketListPageParam(value: string | null): number { return parsed; } -export function parseMarketListPerPageParam(value: string | null): MarketPageSize { - const parsed = Number.parseInt(value ?? "9", 10); - if (MARKET_PAGE_SIZE_OPTIONS.includes(parsed as MarketPageSize)) { - return parsed as MarketPageSize; +export function parseMarketListPerPageParam( + value: string | null, + gridColumnCount = getCatalogGridColumnCount(), +): number { + const options = getMarketPageSizeOptions(gridColumnCount); + const parsed = Number.parseInt(value ?? "", 10); + if (options.includes(parsed)) { + return parsed; } - return 9; + return getDefaultMarketPageSize(gridColumnCount); } export const MARKET_LIST_RETURN_SEARCH_KEY = "mcpmate.market.listReturnSearch"; diff --git a/board/src/pages/market/market-page.tsx b/board/src/pages/market/market-page.tsx index f10ff0b3..d61f8666 100644 --- a/board/src/pages/market/market-page.tsx +++ b/board/src/pages/market/market-page.tsx @@ -2,7 +2,7 @@ import { useCallback, useMemo } from "react"; import { useQuery } from "@tanstack/react-query"; import { useTranslation } from "react-i18next"; import { useLocation, useNavigate } from "react-router-dom"; -import { Pagination } from "../../components/pagination"; +import { Pagination, catalogPaginationClassName } from "../../components/pagination"; import { serversApi } from "../../lib/api"; import { usePageTranslations } from "../../lib/i18n/usePageTranslations"; import { notifyInfo } from "../../lib/notify"; @@ -11,10 +11,7 @@ import { useAppStore } from "../../lib/store"; import type { RegistryServerEntry } from "../../lib/types"; import { useUrlSearch, useUrlState } from "../../lib/hooks/use-url-state"; import { useMarketData } from "./hooks/use-market-data"; -import { - MARKET_PAGE_SIZE_OPTIONS, - rememberMarketListReturnSearch, -} from "./market-list-pagination-storage"; +import { rememberMarketListReturnSearch } from "./market-list-pagination-storage"; import { MarketSearch } from "./market-search"; import { ServerGrid } from "./server-grid"; import type { SortOption } from "./types"; @@ -108,7 +105,7 @@ export function MarketPage() { !isEmpty); return ( -
+

@@ -126,43 +123,45 @@ export function MarketPage() {

- 0} - pagination={pagination} - onRetry={onRefresh} - onClearSearch={handleClearSearch} - onServerPreview={handleOpenDetailPage} - onServerInstall={handleOpenDetailPage} - onServerHide={handleHideServer} - enableBlacklist={enableMarketBlacklist} - /> - - {showPagination ? ( - + 0} + pagination={pagination} + onRetry={onRefresh} + onClearSearch={handleClearSearch} + onServerPreview={handleOpenDetailPage} + onServerInstall={handleOpenDetailPage} + onServerHide={handleHideServer} + enableBlacklist={enableMarketBlacklist} /> - ) : null} + + {showPagination ? ( + + ) : null} +
); } diff --git a/board/src/pages/market/types.ts b/board/src/pages/market/types.ts index 115adbd7..b627e094 100644 --- a/board/src/pages/market/types.ts +++ b/board/src/pages/market/types.ts @@ -49,6 +49,7 @@ export interface ServerGridProps { hasNextPage: boolean; itemsPerPage: number; totalPages: number | null; + pageSizeOptions: number[]; }; onRetry: () => void; onClearSearch: () => void; @@ -80,6 +81,7 @@ export interface UseMarketDataReturn { hasNextPage: boolean; itemsPerPage: number; totalPages: number | null; + pageSizeOptions: number[]; }; onNextPage: () => void; onPreviousPage: () => void; diff --git a/board/src/pages/profile/components/profile-suit-grid-card.tsx b/board/src/pages/profile/components/profile-suit-grid-card.tsx index 7c5330b3..2b7b47d8 100644 --- a/board/src/pages/profile/components/profile-suit-grid-card.tsx +++ b/board/src/pages/profile/components/profile-suit-grid-card.tsx @@ -46,6 +46,7 @@ export function ProfileSuitGridCard({ id={suit.id} title={displayName} description={suit.description} + topRightBadgePosition="corner" avatar={{ fallback: avatarInitial, }} diff --git a/board/src/pages/profile/profile-detail-page.tsx b/board/src/pages/profile/profile-detail-page.tsx index 07adbfcd..92380010 100644 --- a/board/src/pages/profile/profile-detail-page.tsx +++ b/board/src/pages/profile/profile-detail-page.tsx @@ -47,7 +47,11 @@ import { import { CapsuleStripeRowBody } from "../../components/capsule-stripe-row"; import { ProfileFormDrawer } from "../../components/profile-form-drawer"; import { SurfaceReviewDialog } from "../../components/surface-review-dialog"; -import { DETAIL_TAB_CONTENT_CLASS } from "../../components/detail-tab-content-class"; +import { + DETAIL_TAB_CONTENT_CLASS, + DETAIL_OVERVIEW_PINNED_SECTION_CLASS, + DETAIL_OVERVIEW_STACK_CLASS, +} from "../../components/detail-tab-content-class"; import { ProfileTokenUsageChart } from "./components/profile-token-usage-chart"; import { AlertDialog, @@ -1630,12 +1634,9 @@ export function ProfileDetailPage() {
- -
- + +
+ {!isHostApp && !isCustomMode && (
@@ -1670,8 +1671,8 @@ export function ProfileDetailPage() { {suit.is_active @@ -1754,7 +1755,9 @@ export function ProfileDetailPage() { -
+
{showProfileLiveLogs ? ( setSelectedCapabilityServerId( diff --git a/board/src/pages/profile/profile-page.tsx b/board/src/pages/profile/profile-page.tsx index f8f6b042..95dcc1b8 100644 --- a/board/src/pages/profile/profile-page.tsx +++ b/board/src/pages/profile/profile-page.tsx @@ -12,12 +12,16 @@ import { Settings, Wrench, } from "lucide-react"; -import React, { useMemo, useState } from "react"; +import React, { useMemo, useRef, useState } from "react"; import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { useNavigate, useSearchParams } from "react-router-dom"; import { EntityListItem } from "../../components/entity-list-item"; import { ListGridContainer } from "../../components/list-grid-container"; +import { + Pagination, + catalogPaginationClassName, +} from "../../components/pagination"; import { EmptyState, PageLayout } from "../../components/page-layout"; import { ProfileFormDrawer } from "../../components/profile-form-drawer"; import { StatsCards } from "../../components/stats-cards"; @@ -29,14 +33,12 @@ import { CardFooter, CardHeader, } from "../../components/ui/card"; -import { PageToolbar } from "../../components/ui/page-toolbar"; import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../components/ui/select"; + PageToolbar, + type PageToolbarCallbacks, + type PageToolbarConfig, +} from "../../components/ui/page-toolbar"; +import { PageToolbarSelect } from "../../components/ui/page-toolbar-select"; import { ProfileSuitGridCard } from "./components/profile-suit-grid-card"; import { configSuitsApi, @@ -44,8 +46,17 @@ import { serversApi, surfaceReviewsApi, } from "../../lib/api"; +import { + catalogPageSectionClassName, + catalogScrollShellClassName, +} from "../../lib/catalog-layout"; import { DEFAULT_ANCHOR_ROLE } from "../../lib/default-profile"; import { usePageTranslations } from "../../lib/i18n/usePageTranslations"; +import { + CATALOG_PAGE_SIZE_OPTIONS, + type CatalogViewMode, + useResponsiveCatalogPagination, +} from "../../lib/hooks/use-responsive-catalog-pagination"; import { useUrlFilter, useUrlView } from "../../lib/hooks/use-url-state"; import { notifyError, notifySuccess } from "../../lib/notify"; import { useAppStore } from "../../lib/store"; @@ -133,7 +144,7 @@ function formatSuitDisplayName( } export function ProfilePage() { - const { t } = useTranslation(); + const { t, i18n } = useTranslation(); usePageTranslations("profiles"); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -150,7 +161,7 @@ export function ProfilePage() { defaultView: storedDefaultView, validViews: ["grid", "list"], }); - const viewMode = view; + const viewMode = view as CatalogViewMode; const { filter: reviewFilter, setFilter: setReviewFilter } = useUrlFilter({ paramName: "filter", defaultValue: "all", @@ -159,8 +170,8 @@ export function ProfilePage() { const [expanded, setExpanded] = useState(false); - // 排序后的数据状态 const [sortedSuits, setSortedSuits] = React.useState([]); + const [sortedSuitSource, setSortedSuitSource] = useState(null); const { data: suitsResponse, @@ -177,6 +188,7 @@ export function ProfilePage() { const { data: pendingReviewItems = [], error: reviewItemsError, + isFetched: areReviewItemsFetched, } = useQuery({ queryKey: ["surfaceReviews", "pending"], queryFn: () => surfaceReviewsApi.list({ state: "pending" }), @@ -196,11 +208,37 @@ export function ProfilePage() { ); const activeSuits = suits.filter((suit) => suit.is_active); - React.useEffect(() => { - if (sortedSuits.length === 0) { - setSortedSuits(suits); + const reviewFilteredSuits = useMemo(() => { + if (reviewFilter !== "needs_review") { + return suits; } - }, [suits, sortedSuits.length]); + return suits.filter( + (suit) => getProfileReviewCount(pendingReviewItems, suit.id) > 0, + ); + }, [pendingReviewItems, reviewFilter, suits]); + const isCatalogDataReady = sortedSuitSource === reviewFilteredSuits; + + const isProfileCatalogDataReady = + suitsResponse !== undefined && + (reviewFilter !== "needs_review" || areReviewItemsFetched); + + const arrangedSortedSuits = useMemo( + () => arrangeSuitsWithDefaultAnchor(sortedSuits), + [sortedSuits], + ); + + const profilePagination = useResponsiveCatalogPagination( + arrangedSortedSuits, + viewMode, + isCatalogDataReady, + ); + const isCatalogLoading = + isLoadingSuits || (suitsResponse !== undefined && !isCatalogDataReady); + const catalogScrollRef = useRef(null); + + React.useEffect(() => { + catalogScrollRef.current?.scrollTo({ top: 0 }); + }, [profilePagination.currentPage]); // Get active suit IDs for query keys const activeSuitIds = activeSuits.map((suit) => suit.id); @@ -690,20 +728,6 @@ export function ProfilePage() { ); }; - // 使用排序后的数据,保持默认套件在前的顺序 - const filteredAndSortedSuits = useMemo( - () => { - const arranged = arrangeSuitsWithDefaultAnchor(sortedSuits); - if (reviewFilter !== "needs_review") { - return arranged; - } - return arranged.filter( - (suit) => getProfileReviewCount(pendingReviewItems, suit.id) > 0, - ); - }, - [pendingReviewItems, reviewFilter, sortedSuits], - ); - // Prepare stats cards data const statsCards = [ { @@ -809,9 +833,11 @@ export function ProfilePage() { ); }); - // 工具栏配置 - const toolbarConfig = { - data: suits, + type ProfileToolbarSuit = ConfigSuit & { [key: string]: unknown }; + + const toolbarConfig = useMemo((): PageToolbarConfig => ({ + data: reviewFilteredSuits as ProfileToolbarSuit[], + isDataReady: isProfileCatalogDataReady, search: { placeholder: t("profiles:searchPlaceholder", { defaultValue: "Search profiles...", @@ -857,7 +883,15 @@ export function ProfilePage() { urlPersistence: { enabled: true, }, - }; + }), + [ + isProfileCatalogDataReady, + i18n.language, + reviewFilteredSuits, + storedDefaultView, + t, + ], + ); // 工具栏状态 const toolbarState = { @@ -865,11 +899,14 @@ export function ProfilePage() { }; // 工具栏回调 - const toolbarCallbacks = { + const toolbarCallbacks: PageToolbarCallbacks = { onViewModeChange: (mode: "grid" | "list") => { setDashboardSetting("defaultView", mode); }, - onSortedDataChange: setSortedSuits, + onSortedDataChange: (sortedData) => { + setSortedSuits(sortedData as ConfigSuit[]); + setSortedSuitSource(reviewFilteredSuits); + }, onExpandedChange: setExpanded, }; @@ -901,28 +938,25 @@ export function ProfilePage() {
); const filterNode = ( -
- -
+ ); // Prepare empty state @@ -958,11 +992,12 @@ export function ProfilePage() { return ( + config={toolbarConfig} state={toolbarState} - callbacks={toolbarCallbacks as any} + callbacks={toolbarCallbacks} filters={filterNode} actions={actions} /> @@ -987,17 +1022,45 @@ export function ProfilePage() {
)} - - {viewMode === "grid" - ? filteredAndSortedSuits.map(renderSuitCard) - : filteredAndSortedSuits.map(renderSuitListItem)} - +
+
+ + {viewMode === "grid" + ? profilePagination.pageItems.map(renderSuitCard) + : profilePagination.pageItems.map(renderSuitListItem)} + +
+ +
{/* New Suit Drawer */} (); @@ -32,7 +33,7 @@ export function ProfilePresetPage() { }); const updateMutation = useMutation({ - mutationFn: ({ id, preset }: { id: string; preset: any }) => + mutationFn: ({ id, preset }: { id: string; preset: Partial }) => configApi.updatePreset(id, preset), onSuccess: () => { queryClient.invalidateQueries({ diff --git a/board/src/pages/secrets/secrets-page.tsx b/board/src/pages/secrets/secrets-page.tsx index 197bb03e..1b74cfe2 100644 --- a/board/src/pages/secrets/secrets-page.tsx +++ b/board/src/pages/secrets/secrets-page.tsx @@ -31,13 +31,7 @@ import { import { Button } from "../../components/ui/button"; import { Card, CardContent } from "../../components/ui/card"; import { PageToolbar } from "../../components/ui/page-toolbar"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../components/ui/select"; +import { PageToolbarSelect } from "../../components/ui/page-toolbar-select"; import type { Entity, PageToolbarCallbacks, @@ -620,23 +614,16 @@ export function SecretsPage() { ]); const filters = ( - + options={SECRET_LIFECYCLE_FILTERS.map((filter) => ({ + value: filter, + label: lifecycleLabel(filter), + }))} + /> ); const toolbarConfig = useMemo>( diff --git a/board/src/pages/servers/i18n/index.ts b/board/src/pages/servers/i18n/index.ts index 23630147..14e69646 100644 --- a/board/src/pages/servers/i18n/index.ts +++ b/board/src/pages/servers/i18n/index.ts @@ -25,9 +25,6 @@ export const serversTranslations = { }, actions: { debug: { - title: "Inspect", - show: "Inspect", - hide: "Hide Inspect", open: "Open inspect view", }, refresh: { @@ -115,16 +112,6 @@ export const serversTranslations = { errors: { loadFailed: "Failed to load servers", }, - debug: { - cardTitle: "Inspect Details", - close: "Close", - info: { - baseUrl: "API Base URL", - currentTime: "Current Time", - error: "Error", - data: "Servers Data", - }, - }, entity: { tags: { unifyEligible: "Direct Exposure", @@ -1067,9 +1054,6 @@ export const serversTranslations = { }, actions: { debug: { - title: "检视", - show: "检视", - hide: "隐藏检视", open: "打开检视视图", }, refresh: { @@ -1153,16 +1137,6 @@ export const serversTranslations = { errors: { loadFailed: "加载服务器失败", }, - debug: { - cardTitle: "检视详情", - close: "关闭", - info: { - baseUrl: "API 基础地址", - currentTime: "当前时间", - error: "错误", - data: "服务器数据", - }, - }, entity: { tags: { unifyEligible: "直达暴露", @@ -2045,9 +2019,6 @@ export const serversTranslations = { }, actions: { debug: { - title: "検査", - show: "検査", - hide: "検査を隠す", open: "検査ビューを開く", }, refresh: { @@ -2137,16 +2108,6 @@ export const serversTranslations = { errors: { loadFailed: "サーバーの読み込みに失敗しました", }, - debug: { - cardTitle: "検査情報", - close: "閉じる", - info: { - baseUrl: "API ベース URL", - currentTime: "現在時刻", - error: "エラー", - data: "サーバーデータ", - }, - }, entity: { tags: { unifyEligible: "直接公開", diff --git a/board/src/pages/servers/server-detail-page.tsx b/board/src/pages/servers/server-detail-page.tsx index c32b829f..2ab306cd 100644 --- a/board/src/pages/servers/server-detail-page.tsx +++ b/board/src/pages/servers/server-detail-page.tsx @@ -27,7 +27,12 @@ import { type CapabilityPreviewFlatItem, type CapabilityPreviewKind, } from "../../components/capability-preview-list"; -import { DETAIL_TAB_CONTENT_CLASS } from "../../components/detail-tab-content-class"; +import { + DETAIL_TAB_CONTENT_CLASS, + DETAIL_OVERVIEW_PINNED_SECTION_CLASS, + DETAIL_OVERVIEW_SCROLLABLE_LIST_CLASS, + DETAIL_OVERVIEW_STACK_CLASS, +} from "../../components/detail-tab-content-class"; import { AuditLogsPanel } from "../../components/audit-logs-panel"; import { CapsuleStripeList, @@ -762,351 +767,351 @@ export function ServerDetailPage() { )} {server && ( - -
- - - - - -
+ + + +
- - {isLoading ? ( - - -
- - - ) : ( -
- + + {isLoading ? ( + -
-
-
- -
- - {server.server_info?.name?.trim() || "—"} - - - {server.namespace_issue ? ( - - ) : ( - server.name - )} - - + + + ) : ( +
+ + +
+
+
+ +
- {server.server_type} - - {server.auth_mode || isRemoteHttpServer ? ( - + {server.server_info?.name?.trim() || "—"} - ) : null} - {protocolVersion ? ( - {protocolVersion} + {server.namespace_issue ? ( + + ) : ( + server.name + )} - ) : null} - {serverVersion ? ( - {serverVersion} + {server.server_type} - ) : null} - {serverCategory ? ( - - - {serverCategory} - - - ) : null} - {serverScenario ? ( - - - {serverScenario} - - - ) : null} - {server.command ? ( + {server.auth_mode || isRemoteHttpServer ? ( + + + + ) : null} + {protocolVersion ? ( + + {protocolVersion} + + ) : null} + {serverVersion ? ( + + {serverVersion} + + ) : null} + {serverCategory ? ( + + + {serverCategory} + + + ) : null} + {serverScenario ? ( + + + {serverScenario} + + + ) : null} + {server.command ? ( + + + {server.command} + + + ) : null} - - {server.command} - + — - ) : null} - - — - +
+ + + +
- - - -
-
- - + + - - - - {t("detail.instances.title", { - count: server.instances?.length || 0, - defaultValue: "Instances ({{count}})", - })} - - - - {server.instances?.length ? ( - - {server.instances.map((i) => ( - - navigate( - `/servers/${encodeURIComponent(serverId)}/instances/${encodeURIComponent(i.id)}`, - ) - } - > -
{i.id}
- -
- ))} -
- ) : ( -
- {t("detail.instances.empty", { - defaultValue: "No instances.", + + + + {t("detail.instances.title", { + count: server.instances?.length || 0, + defaultValue: "Instances ({{count}})", })} -
- )} -
-
- {showServerLevelLogs ? ( - void serverLogsQuery.refetch()} - rows={filteredServerLogs} - isLoading={serverLogsQuery.isLoading} - isFetching={serverLogsQuery.isFetching} - isPaginationActionLoading={isLogPaginationActionLoading} - currentPage={logCurrentPageIndex + 1} - hasPreviousPage={logCurrentPageIndex > 0} - hasNextPage={Boolean(serverLogsQuery.data?.next_cursor)} - itemsPerPage={logPageSize} - onItemsPerPageChange={setLogPageSize} - onPreviousPage={handleServerLogsPrevPage} - onFirstPage={handleServerLogsFirstPage} - onNextPage={handleServerLogsNextPage} - onLastPage={() => void handleServerLogsLastPage()} - expandLabel={t("detail.logs.expand", { - defaultValue: "Expand Logs", - })} - collapseLabel={t("detail.logs.collapse", { - defaultValue: "Collapse Logs", - })} - /> - ) : null} -
- )} - + + + + {server.instances?.length ? ( + + {server.instances.map((i) => ( + + navigate( + `/servers/${encodeURIComponent(serverId)}/instances/${encodeURIComponent(i.id)}`, + ) + } + > +
{i.id}
+ +
+ ))} +
+ ) : ( +
+ {t("detail.instances.empty", { + defaultValue: "No instances.", + })} +
+ )} +
+ + {showServerLevelLogs ? ( + void serverLogsQuery.refetch()} + rows={filteredServerLogs} + isLoading={serverLogsQuery.isLoading} + isFetching={serverLogsQuery.isFetching} + isPaginationActionLoading={isLogPaginationActionLoading} + currentPage={logCurrentPageIndex + 1} + hasPreviousPage={logCurrentPageIndex > 0} + hasNextPage={Boolean(serverLogsQuery.data?.next_cursor)} + itemsPerPage={logPageSize} + onItemsPerPageChange={setLogPageSize} + onPreviousPage={handleServerLogsPrevPage} + onFirstPage={handleServerLogsFirstPage} + onNextPage={handleServerLogsNextPage} + onLastPage={() => void handleServerLogsLastPage()} + expandLabel={t("detail.logs.expand", { + defaultValue: "Expand Logs", + })} + collapseLabel={t("detail.logs.collapse", { + defaultValue: "Collapse Logs", + })} + /> + ) : null} +
+ )} + - - - navigate(`/audit?server_id=${encodeURIComponent(serverId)}`) - } - onInspect={(kind, item, capabilityOptions) => - handleInspect(kind, item, capabilityOptions) - } - /> - - + + + navigate(`/audit?server_id=${encodeURIComponent(serverId)}`) + } + onInspect={(kind, item, capabilityOptions) => + handleInspect(kind, item, capabilityOptions) + } + /> + + +
)} (null); const [manualOpen, setManualOpen] = useState(false); const [pendingIngestPayload, setPendingIngestPayload] = useState(null); const manualRef = useRef(null); + const hasNotifiedServerListErrorRef = useRef(false); const [editingServer, setEditingServer] = useState(null); const [deletingServer, setDeletingServer] = useState(null); const [isDeleteConfirmOpen, setIsDeleteConfirmOpen] = useState(false); @@ -83,6 +84,8 @@ export function ServerListPage() { // Sorted data state const [sortedServers, setSortedServers] = React.useState([]); + const [sortedServerSource, setSortedServerSource] = + useState(null); const queryClient = useQueryClient(); @@ -102,12 +105,6 @@ export function ServerListPage() { validViews: ["grid", "list"], }); const viewMode = view; - const { sortState } = useUrlSort({ - paramName: "sort", - defaultField: "name", - defaultDirection: "asc", - validFields: ["name", "enabled"], - }); const pendingServerDeepLinkImport = useAppStore( (state) => state.pendingServerDeepLinkImport, @@ -115,12 +112,6 @@ export function ServerListPage() { const setPendingServerDeepLinkImport = useAppStore( (state) => state.setPendingServerDeepLinkImport, ); - const enableServerDebug = useAppStore( - (state) => state.dashboardSettings.enableServerDebug, - ); - const openDebugInNewWindow = useAppStore( - (state) => state.dashboardSettings.openDebugInNewWindow, - ); const syncServerStateToClients = useAppStore( (state) => state.dashboardSettings.syncServerStateToClients, ); @@ -224,53 +215,30 @@ export function ServerListPage() { isError, } = useQuery({ queryKey: ["servers"], - queryFn: async () => { - try { - // Append inspect information - console.log("Fetching servers..."); - const result = await serversApi.getAll(); - console.log("Servers fetched:", result); - return result; - } catch (err) { - console.error("Error fetching servers:", err); - // Capture error information for display - setDebugInfo( - err instanceof Error ? `${err.message}\n\n${err.stack}` : String(err), - ); - throw err; - } - }, + queryFn: () => serversApi.getAll(), refetchInterval: (query) => { const servers = query.state.data?.servers ?? []; - const hasTransitionalServer = servers.some((server) => - isTransitionalServerStatus(server.status), - ); - return hasTransitionalServer ? 5000 : 30000; + return getServerListRefetchInterval(servers); }, refetchIntervalInBackground: true, retry: 1, // Reduce retry count to show errors more quickly }); React.useEffect(() => { - if (sortedServers.length === 0 && serverData?.servers) { - const initialSorted = [...serverData.servers].sort((a, b) => { - const aValue = a[sortState.field as keyof ServerSummary]; - const bValue = b[sortState.field as keyof ServerSummary]; - - let comparison = 0; - if (typeof aValue === "string" && typeof bValue === "string") { - comparison = aValue.localeCompare(bValue); - } else if (typeof aValue === "boolean" && typeof bValue === "boolean") { - comparison = Number(aValue) - Number(bValue); - } else { - comparison = String(aValue).localeCompare(String(bValue)); - } - - return sortState.direction === "desc" ? -comparison : comparison; - }); - setSortedServers(initialSorted); + if (!isError || !error) { + hasNotifiedServerListErrorRef.current = false; + return; + } + if (hasNotifiedServerListErrorRef.current) { + return; } - }, [serverData?.servers, sortedServers.length, sortState]); + + hasNotifiedServerListErrorRef.current = true; + notifyError( + t("errors.loadFailed", { defaultValue: "Failed to load servers" }), + error.message, + ); + }, [error, i18n.language, isError, t]); // Enable/disable server const toggleServerAsync = useCallback( @@ -354,7 +322,7 @@ export function ServerListPage() { if ( requestedEligibility !== undefined && requestedEligibility !== - currentServer?.unify_direct_exposure_eligible + currentServer?.unify_direct_exposure_eligible ) { if (!currentServer?.source_revision_set) { throw new Error( @@ -398,20 +366,14 @@ export function ServerListPage() { // Handle update server const handleUpdateServer = async (config: Partial) => { - if (editingServer) { - console.log("Updating server:", editingServer.id, "with config:", config); - try { - await updateServerMutation.mutateAsync({ - serverId: editingServer.id, - config, - }); - console.log("Server update successful"); - setEditingServer(null); - } catch (error) { - console.error("Server update failed:", error); - throw error; // Re-throw to let the mutation handle it - } + if (!editingServer) { + return; } + await updateServerMutation.mutateAsync({ + serverId: editingServer.id, + config, + }); + setEditingServer(null); }; // Handle delete server @@ -549,39 +511,21 @@ export function ServerListPage() { [syncServerStateToClients, toggleServerAsync], ); - const handleCatalogDebugOpen = useCallback( - (serverId: string) => { - const url = `/servers/${encodeURIComponent(serverId)}?view=debug&channel=native`; - if (openDebugInNewWindow) { - if (typeof window !== "undefined") { - window.open(url, "_blank", "noopener,noreferrer"); - } - return; - } - navigate(url); - }, - [navigate, openDebugInNewWindow], + const serverSource = serverData?.servers ?? EMPTY_SERVERS; + const isCatalogDataReady = + serverData !== undefined && sortedServerSource === serverSource; + const serverPagination = useResponsiveCatalogPagination( + sortedServers, + viewMode as "grid" | "list", + isCatalogDataReady, ); + const isCatalogLoading = + isLoading || (serverData !== undefined && !isCatalogDataReady); + const catalogScrollRef = useRef(null); - // Add inspect button handler - const toggleDebugInfo = () => { - if (debugInfo) { - setDebugInfo(null); - } else { - const debugLines = [ - `${t("debug.info.baseUrl", { defaultValue: "API Base URL" })}: ${window.location.origin}`, - `${t("debug.info.currentTime", { defaultValue: "Current Time" })}: ${new Date().toLocaleString()}`, - `${t("debug.info.error", { defaultValue: "Error" })}: ${error instanceof Error ? error.message : String(error)}`, - `${t("debug.info.data", { defaultValue: "Servers Data" })}: ${JSON.stringify(serverData, null, 2)}`, - ]; - setDebugInfo(debugLines.join("\n")); - } - }; - - // Use sorted data - const filteredAndSortedServers = useMemo(() => { - return sortedServers; - }, [sortedServers]); + React.useEffect(() => { + catalogScrollRef.current?.scrollTo({ top: 0 }); + }, [serverPagination.currentPage]); const hasNoServerRecords = serverData?.servers?.length === 0; const statsCards = useMemo(() => { @@ -670,7 +614,8 @@ export function ServerListPage() { // Toolbar config type ToolbarServer = ServerSummary & { [key: string]: unknown }; const toolbarConfig: PageToolbarConfig = { - data: (serverData?.servers || []) as ToolbarServer[], + data: serverSource as ToolbarServer[], + isDataReady: serverData !== undefined, search: { placeholder: t("toolbar.search.placeholder", { defaultValue: "Search servers...", @@ -728,24 +673,16 @@ export function ServerListPage() { onViewModeChange: (mode: "grid" | "list") => { setDashboardSetting("defaultView", mode); }, - onSortedDataChange: (data) => setSortedServers(data as ServerSummary[]), + onSortedDataChange: (data) => { + setSortedServers(data as ServerSummary[]); + setSortedServerSource(serverSource); + }, onExpandedChange: setExpanded, }; // Action buttons const actions = (
- {isError && enableServerDebug && ( - - )} - )} - - {/* Display error information */} - {isError && ( - refetch()} +
+
+ + {viewMode === "grid" + ? serverPagination.pageItems.map((server) => ( + + )) + : serverPagination.pageItems.map((server) => ( + + ))} + +
+ - )} - - {/* Display inspect information */} - {debugInfo && ( - - - - {t("debug.cardTitle", { - defaultValue: "Inspect Details", - })} - - - - -
-							{debugInfo}
-						
-
-
- )} - -
- - {viewMode === "grid" - ? filteredAndSortedServers.map((server) => ( - - )) - : filteredAndSortedServers.map((server) => ( - - ))} -
{/* Server install pipeline */} diff --git a/board/src/pages/servers/server-list-polling.test.ts b/board/src/pages/servers/server-list-polling.test.ts new file mode 100644 index 00000000..5c0f3c36 --- /dev/null +++ b/board/src/pages/servers/server-list-polling.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, test } from "bun:test"; + +import { getServerListRefetchInterval } from "./server-list-polling"; + +describe("getServerListRefetchInterval", () => { + test("polls quickly when an instance is transitioning without a server status", () => { + expect( + getServerListRefetchInterval([ + { + status: undefined, + instances: [{ status: "Initializing" }], + }, + ]), + ).toBe(5000); + }); + + test("polls quickly when the server status is transitioning", () => { + expect( + getServerListRefetchInterval([ + { status: "starting", instances: [{ status: "Idle" }] }, + ]), + ).toBe(5000); + }); + + test("uses the normal interval when every status is stable", () => { + expect( + getServerListRefetchInterval([ + { status: undefined, instances: [{ status: "Ready" }] }, + { status: "idle", instances: [] }, + ]), + ).toBe(30000); + }); +}); diff --git a/board/src/pages/servers/server-list-polling.ts b/board/src/pages/servers/server-list-polling.ts new file mode 100644 index 00000000..47863f44 --- /dev/null +++ b/board/src/pages/servers/server-list-polling.ts @@ -0,0 +1,30 @@ +interface ServerPollingState { + status?: string; + instances?: Array<{ status: string }>; +} + +const TRANSITIONAL_SERVER_STATUSES = new Set([ + "initializing", + "starting", + "connecting", + "busy", + "stopping", +]); + +function isTransitionalStatus(status: string | undefined): boolean { + return TRANSITIONAL_SERVER_STATUSES.has(status?.toLowerCase() ?? ""); +} + +export function getServerListRefetchInterval( + servers: ServerPollingState[], +): number { + const hasTransitionalServer = servers.some( + (server) => + isTransitionalStatus(server.status) || + server.instances?.some((instance) => + isTransitionalStatus(instance.status), + ), + ); + + return hasTransitionalServer ? 5000 : 30000; +} diff --git a/board/src/pages/settings/about-licenses-section.tsx b/board/src/pages/settings/about-licenses-section.tsx index 7bd6085a..d3c69a54 100644 --- a/board/src/pages/settings/about-licenses-section.tsx +++ b/board/src/pages/settings/about-licenses-section.tsx @@ -133,15 +133,12 @@ export function AboutLicensesSection({ document }: AboutLicensesSectionProps) { - {t("settings:about.title", { defaultValue: "About MCPMate" })}{" "} - - {t("wip", { defaultValue: "WIP" })} - + {t("settings:about.title", { defaultValue: "About MCPMate" })} {t("settings:about.description", { defaultValue: - "Open-source acknowledgements for the MCPMate preview build.", + "Open-source acknowledgements for MCPMate.", })} {generatedAtDisplay && ( diff --git a/board/src/pages/settings/i18n/index.ts b/board/src/pages/settings/i18n/index.ts index 57fa76ea..dcfbc39b 100644 --- a/board/src/pages/settings/i18n/index.ts +++ b/board/src/pages/settings/i18n/index.ts @@ -425,7 +425,7 @@ export const settingsTranslations = { about: { title: "About MCPMate", description: - "Open-source acknowledgements for the MCPMate preview build.", + "Open-source acknowledgements for MCPMate.", lastUpdated: "Last updated: {{date}}", backendTitle: "Backend (Rust workspace)", desktopShellTitle: "Desktop Shell (Tauri)", @@ -895,7 +895,7 @@ export const settingsTranslations = { }, about: { title: "关于 MCPMate", - description: "MCPMate 预览版本的开源致谢信息。", + description: "MCPMate 的开源致谢。", lastUpdated: "最后更新:{{date}}", backendTitle: "后端 (Rust 工作区)", desktopShellTitle: "桌面外壳 (Tauri)", @@ -1401,7 +1401,7 @@ export const settingsTranslations = { }, about: { title: "MCPMate について", - description: "MCPMate プレビュービルドのオープンソース謝辞。", + description: "MCPMate のオープンソース謝辞。", lastUpdated: "最終更新:{{date}}", backendTitle: "バックエンド (Rust ワークスペース)", desktopShellTitle: "デスクトップシェル (Tauri)", diff --git a/board/src/pages/settings/providers-settings.tsx b/board/src/pages/settings/providers-settings.tsx index 73c848f8..e754e9f2 100644 --- a/board/src/pages/settings/providers-settings.tsx +++ b/board/src/pages/settings/providers-settings.tsx @@ -15,6 +15,10 @@ import { } from "lucide-react"; import { llmApi } from "../../lib/api"; +import { + SETTINGS_CARD_CONTENT_CLASS, + SETTINGS_ITEM_DESCRIPTION_CLASS, +} from "./settings-layout"; import type { LlmConnectivityResult, LlmProviderConfig, @@ -241,7 +245,7 @@ export function ProvidersSettings() {
- + {isLoading ? (
@@ -249,7 +253,7 @@ export function ProvidersSettings() { ) : providers.length === 0 ? (
-

+

{t( "settings:providers.empty", "No providers configured yet. Add an LLM provider to enable LLM-powered workflows.", diff --git a/board/src/pages/settings/settings-layout.ts b/board/src/pages/settings/settings-layout.ts new file mode 100644 index 00000000..887e4991 --- /dev/null +++ b/board/src/pages/settings/settings-layout.ts @@ -0,0 +1,59 @@ +/** + * Shared layout tokens for Settings tab panels and setting field rows. + * Import these instead of repeating Tailwind strings across settings pages. + */ + +export const SETTINGS_TAB_TRIGGER_CLASS = + "w-full justify-center gap-2 px-2 py-2 text-left text-sm font-medium text-slate-600 data-[state=active]:text-emerald-700 md:justify-start md:px-3 dark:text-slate-300"; + +export const SETTINGS_CARD_CONTENT_CLASS = "space-y-5"; + +export const SETTINGS_CARD_CONTENT_STACK_CLASS = "flex h-full flex-col gap-5"; + +export const SETTINGS_SECTION_CLASS = "space-y-5"; + +export const SETTINGS_ITEM_TITLE_CLASS = "text-base font-medium"; + +export const SETTINGS_ITEM_DESCRIPTION_CLASS = "text-sm text-muted-foreground"; + +/** Title + description column in a setting row. */ +export const SETTINGS_LABEL_CLASS = "min-w-0 space-y-0.5"; + +export const SETTINGS_ROW_CLASS = + "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4"; + +export const SETTINGS_CONTROL_CLASS = "w-full shrink-0 sm:w-72"; + +export const SETTINGS_CLIENTS_ROW_CLASS = + "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6"; + +export const SETTINGS_CLIENTS_LABEL_CLASS = + "min-w-0 max-w-full flex-1 space-y-0.5 sm:pr-2 lg:max-w-lg xl:max-w-xl"; + +export const SETTINGS_CLIENTS_CONTROL_CLASS = + "w-full shrink-0 sm:w-auto sm:min-w-[16rem] md:min-w-[20rem] lg:min-w-[24rem]"; + +export const SETTINGS_SWITCH_ROW_CLASS = + "flex items-center justify-between gap-4"; + +export const SETTINGS_GRID_ROW_CLASS = + "grid grid-cols-1 gap-2 sm:grid-cols-2 sm:items-center"; + +export const SETTINGS_GRID_CONTROL_CLASS = "flex sm:justify-end"; + +export const SETTINGS_SELECT_TRIGGER_WIDE_CLASS = "w-full sm:w-72"; + +export const SETTINGS_SELECT_TRIGGER_CLASS = "w-full sm:w-64"; + +export const SETTINGS_INPUT_CLASS = "w-full sm:w-64"; + +export const SETTINGS_INPUT_WIDE_CLASS = "w-full sm:w-80"; + +export const SETTINGS_SECURITY_GROUP_CLASS = "space-y-3"; + +export const SETTINGS_SECURITY_DIVIDER_CLASS = "space-y-3 border-t pt-6"; + +export const SETTINGS_LABEL_FLEX_CLASS = "min-w-0 space-y-0.5 md:flex-1"; + +export const SETTINGS_MUTED_HINT_CLASS = + "text-xs text-muted-foreground sm:text-right"; diff --git a/board/src/pages/settings/settings-page.tsx b/board/src/pages/settings/settings-page.tsx index be349829..a8b186c3 100644 --- a/board/src/pages/settings/settings-page.tsx +++ b/board/src/pages/settings/settings-page.tsx @@ -121,6 +121,29 @@ import { } from "../../lib/store"; import type { OpenSourceDocument } from "../../types/open-source"; import { AboutLicensesSection } from "./about-licenses-section"; +import { + SETTINGS_CARD_CONTENT_CLASS, + SETTINGS_CARD_CONTENT_STACK_CLASS, + SETTINGS_CLIENTS_CONTROL_CLASS, + SETTINGS_CLIENTS_LABEL_CLASS, + SETTINGS_CLIENTS_ROW_CLASS, + SETTINGS_CONTROL_CLASS, + SETTINGS_GRID_CONTROL_CLASS, + SETTINGS_GRID_ROW_CLASS, SETTINGS_INPUT_WIDE_CLASS, + SETTINGS_ITEM_DESCRIPTION_CLASS, + SETTINGS_ITEM_TITLE_CLASS, + SETTINGS_LABEL_CLASS, + SETTINGS_LABEL_FLEX_CLASS, + SETTINGS_MUTED_HINT_CLASS, + SETTINGS_ROW_CLASS, + SETTINGS_SECTION_CLASS, + SETTINGS_SECURITY_DIVIDER_CLASS, + SETTINGS_SECURITY_GROUP_CLASS, + SETTINGS_SELECT_TRIGGER_CLASS, + SETTINGS_SELECT_TRIGGER_WIDE_CLASS, + SETTINGS_SWITCH_ROW_CLASS, + SETTINGS_TAB_TRIGGER_CLASS +} from "./settings-layout"; import { buildClientWritebackDefaultUpdate, removeClientWritebackDecisionCache, @@ -1440,22 +1463,6 @@ export function SettingsPage() { wireDashboardToCoreSource, ]); - const tabTriggerClass = - "w-full justify-center gap-2 px-2 py-2 text-left text-sm font-medium text-slate-600 data-[state=active]:text-emerald-700 md:justify-start md:px-3 dark:text-slate-300"; - const settingItemTitleClass = "text-base font-medium"; - const settingItemDescriptionClass = "text-sm text-muted-foreground"; - const generalSettingsRowClass = - "flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between sm:gap-4"; - const generalSettingsLabelClass = "min-w-0 space-y-0.5"; - const generalSettingsControlClass = "w-full shrink-0 sm:w-72"; - /** Clients tab: left column wraps without stealing flex space from controls; right keeps a floor width so Segments are not squeezed. */ - const clientsSettingsRowClass = - "flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between sm:gap-6"; - const clientsSettingsLabelClass = - "min-w-0 max-w-full flex-1 space-y-0.5 sm:pr-2 lg:max-w-lg xl:max-w-xl"; - const clientsSettingsControlClass = - "w-full shrink-0 sm:w-auto sm:min-w-[16rem] md:min-w-[20rem] lg:min-w-[24rem]"; - const themeOptions = useMemo( () => THEME_CONFIG.map(({ value, icon: Icon, labelKey, fallback }) => ({ @@ -1839,13 +1846,13 @@ export function SettingsPage() { } > - + {t("settings:tabs.general", { defaultValue: "General" })} - + {t("settings:tabs.serverControls", { @@ -1853,7 +1860,7 @@ export function SettingsPage() { })} - + {t("settings:tabs.clientDefaults", { @@ -1861,50 +1868,50 @@ export function SettingsPage() { })} - + {t("settings:tabs.profile", { defaultValue: "Profile" })} - + {t("settings:tabs.market", { defaultValue: "Market" })} - + {t("settings:tabs.audit", { defaultValue: "Logs" })} - + {t("settings:tabs.developer", { defaultValue: "Developer" })} - + {t("settings:tabs.security", { defaultValue: "Security" })} - + {t("settings:tabs.providers", { defaultValue: "Providers" })} - + {t("settings:tabs.system", { defaultValue: "System" })} {showLicenseTab && ( - + {t("settings:tabs.about", { defaultValue: "About" })} @@ -1933,23 +1940,23 @@ export function SettingsPage() { })} - + {/* Default View */} -

-
-

+
+
+

{t("settings:general.defaultView", { defaultValue: "Default View", })}

-

+

{t("settings:general.defaultViewDescription", { defaultValue: "Choose the default layout for displaying items.", })}

-
+
{/* Theme */} -
-
-

+
+
+

{t("settings:general.themeTitle", { defaultValue: "Theme", })}

-

+

{t("settings:general.themeDescription", { defaultValue: "Switch between light, dark, and system theme.", })}

-
+
{/* Language Selection */} -
-
-

+
+
+

{t("settings:general.language", { defaultValue: "Language", })}

-

+

{t("settings:general.languageDescription", { defaultValue: "Select the dashboard language.", })}

-
+
@@ -2075,14 +2082,14 @@ export function SettingsPage() {
-
-
-

+
+
+

{t("settings:general.dockTitle", { defaultValue: "Dock / Taskbar Icon", })}

-

+

{t("settings:general.dockDescription", { defaultValue: "Show MCPMate in the Dock (macOS), taskbar (Windows/Linux), or run from the tray or menu bar only.", @@ -2126,15 +2133,15 @@ export function SettingsPage() { })} - -

-
-

+ +
+
+

{t("settings:servers.syncTitle", { defaultValue: "Sync Global Start/Stop", })}

-

+

{t("settings:servers.syncDescription", { defaultValue: "Push global enable state to managed clients instantly.", @@ -2149,14 +2156,14 @@ export function SettingsPage() { />

-
-
-

+
+
+

{t("settings:servers.autoAddTitle", { defaultValue: "Auto Add To Default Profile", })}

-

+

{t("settings:servers.autoAddDescription", { defaultValue: "Include new servers in the default profile automatically.", @@ -2173,14 +2180,14 @@ export function SettingsPage() { } />

-
-
-

+
+
+

{t("settings:servers.liveLogsTitle", { defaultValue: "Server Detail Logs", })}

-

+

{t("settings:servers.liveLogsDescription", { defaultValue: "Show paginated live logs on the Server detail page.", @@ -2213,22 +2220,22 @@ export function SettingsPage() { })} - -

-
-

+ +
+
+

{t("settings:clients.modeTitle", { defaultValue: "Client Application Mode", })}

-

+

{t("settings:clients.modeDescription", { defaultValue: "Choose how client applications should operate by default.", })}

-
+
-
-
-

+
+
+

{t("settings:clients.firstContactTitle", { defaultValue: "First-contact Behavior", })}

-

+

{t("settings:clients.firstContactDescription", { defaultValue: "Control how new, unknown clients are handled when they first request an MCP connection.", })}

-
+
-
-
-

+
+
+

{t("settings:clients.defaultVisibilityTitle", { defaultValue: "Default Client Visibility", })}

-

+

{t("settings:clients.defaultVisibilityDescription", { defaultValue: "Choose which client statuses are shown by default on the Clients page.", })}

-
+
-
-
-

+
+
+

{t("settings:clients.writebackDefaultTitle", { defaultValue: "Config Writeback Default", })}

-

+

{t("settings:clients.writebackDefaultDescription", { defaultValue: "Choose the default config writeback behavior. You can also set it per client.", })}

-
+
-
-
-

+
+
+

{t("settings:clients.backupStrategyTitle", { defaultValue: "Client Backup Strategy", })}

-

+

{t("settings:clients.backupStrategyDescription", { defaultValue: "Define how client configurations should be backed up.", })}

-
+
-
-
-

+
+
+

{t("settings:clients.backupLimitTitle", { defaultValue: "Maximum Backup Copies", })}

-

+

{t("settings:clients.backupLimitDescription", { defaultValue: "Set the maximum number of backup copies to keep. Applied when the strategy is set to Keep N. Values below 1 are rounded up.", @@ -2375,17 +2382,17 @@ export function SettingsPage() { disabled={ dashboardSettings.clientBackupStrategy !== "keep_n" } - className={clientsSettingsControlClass} + className={SETTINGS_CLIENTS_CONTROL_CLASS} />

-
-

+
+

{t("settings:clients.liveLogsTitle", { defaultValue: "Client Detail Logs", })}

-

+

{t("settings:clients.liveLogsDescription", { defaultValue: "Show paginated live logs on the Client detail page.", @@ -2418,9 +2425,9 @@ export function SettingsPage() { })} - + {storeStatusQuery.isLoading ? ( -

+

{t("settings:security.loading", { defaultValue: "Checking store status..." })}

) : storeStatusQuery.isError ? ( @@ -2443,12 +2450,12 @@ export function SettingsPage() { /> ) : storeStatusQuery.data ? ( <> -
+
{/* Password Protection */} -
-
+
+
-

+

{t("settings:security.passwordProtection", { defaultValue: "Password Protection" })}

-

+

{t("settings:security.passwordProtectionDescription", { defaultValue: "Require a login password before accessing MCPMate or Settings.", })}

-
+
{effectiveProtectionLevel !== "off" ? ( -
-
-

+
+
+

{t("settings:security.loginPasswordRow", { defaultValue: "Password" })}

-

+

{t("settings:security.loginPasswordRowDescription", { defaultValue: "Login password used when protection is enabled.", })}

-
+