From a8763d0cf9bc3de8d60ffe99e52aeab56a3de9c4 Mon Sep 17 00:00:00 2001 From: Loocor Date: Mon, 3 Aug 2026 11:14:35 +0800 Subject: [PATCH 01/14] fix(board): remove server list diagnostics - Route list load failures through the notification center. - Remove legacy inline inspect diagnostics and cover the regression. --- board/e2e/server-list-error.spec.ts | 61 ++++++++++++ board/src/pages/servers/i18n/index.ts | 39 -------- board/src/pages/servers/server-list-page.tsx | 98 ++++---------------- 3 files changed, 79 insertions(+), 119 deletions(-) create mode 100644 board/e2e/server-list-error.spec.ts diff --git a/board/e2e/server-list-error.spec.ts b/board/e2e/server-list-error.spec.ts new file mode 100644 index 00000000..21a0593b --- /dev/null +++ b/board/e2e/server-list-error.spec.ts @@ -0,0 +1,61 @@ +import { expect, test } from "@playwright/test"; + +test("Server list failures use notification center without inline diagnostics", async ({ + page, +}) => { + let serverListRequests = 0; + 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(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/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-list-page.tsx b/board/src/pages/servers/server-list-page.tsx index 26d18ffd..d198659b 100644 --- a/board/src/pages/servers/server-list-page.tsx +++ b/board/src/pages/servers/server-list-page.tsx @@ -1,11 +1,10 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertCircle, RefreshCw, Server } from "lucide-react"; +import { RefreshCw, Server } from "lucide-react"; import React, { useCallback, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { ConfirmDialog } from "../../components/confirm-dialog"; -import { ErrorDisplay } from "../../components/error-display"; import { ListGridContainer } from "../../components/list-grid-container"; import { EmptyState, @@ -22,7 +21,6 @@ import { Card, CardContent, CardHeader, - CardTitle, } from "../../components/ui/card"; // Dropdown removed in favor of a single combined add flow import { @@ -66,11 +64,11 @@ export function ServerListPage() { usePageTranslations("servers"); const { t, i18n } = useTranslation("servers"); const navigate = useNavigate(); - const [debugInfo, setDebugInfo] = useState(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); @@ -226,17 +224,12 @@ export function ServerListPage() { 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; } }, @@ -251,6 +244,22 @@ export function ServerListPage() { retry: 1, // Reduce retry count to show errors more quickly }); + React.useEffect(() => { + if (!isError || !error) { + hasNotifiedServerListErrorRef.current = false; + return; + } + if (hasNotifiedServerListErrorRef.current) { + return; + } + + hasNotifiedServerListErrorRef.current = true; + notifyError( + t("errors.loadFailed", { defaultValue: "Failed to load servers" }), + error.message, + ); + }, [error, i18n.language, isError, t]); + React.useEffect(() => { if (sortedServers.length === 0 && serverData?.servers) { const initialSorted = [...serverData.servers].sort((a, b) => { @@ -563,21 +572,6 @@ export function ServerListPage() { [navigate, openDebugInNewWindow], ); - // 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; @@ -735,17 +729,6 @@ export function ServerListPage() { // Action buttons const actions = (
- {isError && enableServerDebug && ( - - )} - )} - - {/* Display error information */} - {isError && ( - refetch()} - /> - )} - - {/* Display inspect information */} - {debugInfo && ( - - - - {t("debug.cardTitle", { - defaultValue: "Inspect Details", - })} - - - - -
-							{debugInfo}
-						
-
-
- )} -
Date: Mon, 3 Aug 2026 20:41:21 +0800 Subject: [PATCH 02/14] fix(board): refine server and client catalogs - Keep catalog state fresh after imports and transitions. - Add responsive pagination with persistent controls and page-size selection. - Remove the obsolete server list inspect action. --- board/e2e/catalog-pagination.spec.ts | 206 ++++++++++++++++++ .../servers/server-catalog-entry.test.tsx | 36 +++ .../servers/server-catalog-entry.tsx | 35 +-- board/src/components/ui/page-toolbar.tsx | 39 +++- .../use-responsive-catalog-pagination.test.ts | 40 ++++ .../use-responsive-catalog-pagination.ts | 195 +++++++++++++++++ board/src/lib/server-query-cache.test.ts | 26 +++ board/src/lib/server-query-cache.ts | 12 + .../src/pages/clients/client-detail-page.tsx | 2 + board/src/pages/clients/clients-page.tsx | 113 +++++++--- board/src/pages/servers/server-list-page.tsx | 181 +++++++-------- .../pages/servers/server-list-polling.test.ts | 33 +++ .../src/pages/servers/server-list-polling.ts | 30 +++ 13 files changed, 772 insertions(+), 176 deletions(-) create mode 100644 board/e2e/catalog-pagination.spec.ts create mode 100644 board/src/components/servers/server-catalog-entry.test.tsx create mode 100644 board/src/lib/hooks/use-responsive-catalog-pagination.test.ts create mode 100644 board/src/lib/hooks/use-responsive-catalog-pagination.ts create mode 100644 board/src/lib/server-query-cache.test.ts create mode 100644 board/src/lib/server-query-cache.ts create mode 100644 board/src/pages/servers/server-list-polling.test.ts create mode 100644 board/src/pages/servers/server-list-polling.ts diff --git a/board/e2e/catalog-pagination.spec.ts b/board/e2e/catalog-pagination.spec.ts new file mode 100644 index 00000000..964b8ece --- /dev/null +++ b/board/e2e/catalog-pagination.spec.ts @@ -0,0 +1,206 @@ +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).not.toHaveURL(/page=/); + await expect(page.getByText("Server 01", { exact: true })).toBeVisible(); + await expect(page.getByText("Server 04", { 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("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); + releaseReviewResponse?.(); + + await expect(page.getByText("Client 07", { exact: true })).toBeVisible(); + await expect(page).toHaveURL(/page=2/); +}); diff --git a/board/src/components/servers/server-catalog-entry.test.tsx b/board/src/components/servers/server-catalog-entry.test.tsx new file mode 100644 index 00000000..6efaa8c5 --- /dev/null +++ b/board/src/components/servers/server-catalog-entry.test.tsx @@ -0,0 +1,36 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import "../../lib/i18n/index"; +import { ServerCatalogEntry } from "./server-catalog-entry"; + +test("list entries keep the enable switch without the legacy inspect action", () => { + const legacyDebugProps = { + enableServerDebug: true, + onOpenDebug: () => undefined, + }; + const markup = renderToStaticMarkup( + undefined} + onToggle={() => undefined} + isToggleDisabled={false} + />, + ); + + expect(markup).toContain('role="switch"'); + expect(markup).not.toContain("Open inspect view"); +}); diff --git a/board/src/components/servers/server-catalog-entry.tsx b/board/src/components/servers/server-catalog-entry.tsx index 4a125b1d..dcd6ab9a 100644 --- a/board/src/components/servers/server-catalog-entry.tsx +++ b/board/src/components/servers/server-catalog-entry.tsx @@ -1,5 +1,5 @@ -import { Bug, Plug } from "lucide-react"; -import { memo, useCallback, useMemo, type MouseEvent } from "react"; +import { Plug } from "lucide-react"; +import { memo, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { resolveServerOAuthReadiness } from "../../lib/oauth-readiness"; @@ -18,7 +18,6 @@ import { EntityListItem } from "../entity-list-item"; import { ServerAuthBadge } from "../server-auth-badge"; import { StatusBadge } from "../status-badge"; import { Badge } from "../ui/badge"; -import { Button } from "../ui/button"; import { Switch } from "../ui/switch"; export type ServerCatalogStatsLabels = { @@ -34,8 +33,6 @@ type ServerCatalogEntryBaseProps = { onOpen: (serverId: string) => void; onToggle: (serverId: string, enabled: boolean) => void; isToggleDisabled: boolean; - enableServerDebug?: boolean; - onOpenDebug?: (serverId: string) => void; }; export type ServerCatalogListEntryProps = ServerCatalogEntryBaseProps & { @@ -94,8 +91,6 @@ function ServerCatalogEntryComponent(props: ServerCatalogEntryProps) { onOpen, onToggle, isToggleDisabled, - enableServerDebug = false, - onOpenDebug, } = props; const displayName = getServerDisplayName(server); const lifecycleLabels: CapabilityLifecycleLabels = { @@ -128,14 +123,6 @@ function ServerCatalogEntryComponent(props: ServerCatalogEntryProps) { [onToggle, server.id], ); - const handleOpenDebug = useCallback( - (event: MouseEvent) => { - event.stopPropagation(); - onOpenDebug?.(server.id); - }, - [onOpenDebug, server.id], - ); - function renderUnifyEligibilityTag() { if (!server.unify_direct_exposure_eligible) return null; return ( @@ -343,24 +330,6 @@ function ServerCatalogEntryComponent(props: ServerCatalogEntryProps) { onChange: handleToggle, disabled: isToggleDisabled, }} - actionButtons={ - enableServerDebug && onOpenDebug - ? [ - , - ] - : [] - } onClick={handleOpen} /> ); diff --git a/board/src/components/ui/page-toolbar.tsx b/board/src/components/ui/page-toolbar.tsx index e295f973..a5aa0f43 100644 --- a/board/src/components/ui/page-toolbar.tsx +++ b/board/src/components/ui/page-toolbar.tsx @@ -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; 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..b4403760 --- /dev/null +++ b/board/src/lib/hooks/use-responsive-catalog-pagination.ts @@ -0,0 +1,195 @@ +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 getGridColumnCount(): 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; +} + +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 [requestedPage, setRequestedPage] = useUrlState({ + paramName: "page", + defaultValue: 1, + validate: isValidPageParam, + deserialize: Number, + }); + const [gridColumnCount, setGridColumnCount] = useState(getGridColumnCount); + const [selectedPageSize, setSelectedPageSize] = useState(null); + + useEffect(() => { + const updateColumnCount = () => setGridColumnCount(getGridColumnCount()); + + window.addEventListener("resize", updateColumnCount); + return () => window.removeEventListener("resize", updateColumnCount); + }, []); + + 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); + const previousPageSize = useRef(pageSize); + const previousResponsivePageSize = useRef(responsivePageSize); + + useEffect(() => { + if (!isDataReady) { + return; + } + if (hasInvalidPageParam) { + setRequestedPage(1); + return; + } + + const shouldReset = + previousResetSignature.current !== resetSignature || + previousPageSize.current !== pageSize || + previousResponsivePageSize.current !== responsivePageSize; + + previousResetSignature.current = resetSignature; + previousPageSize.current = pageSize; + previousResponsivePageSize.current = responsivePageSize; + + if (shouldReset) { + setRequestedPage(1); + return; + } + + if (requestedPage !== currentPage) { + setRequestedPage(currentPage); + } + }, [ + currentPage, + hasInvalidPageParam, + isDataReady, + pageSize, + requestedPage, + resetSignature, + responsivePageSize, + 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..0005cc5f 100644 --- a/board/src/pages/clients/client-detail-page.tsx +++ b/board/src/pages/clients/client-detail-page.tsx @@ -93,6 +93,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 +1692,7 @@ export function ClientDetailPage() { ); } const imported = res.imported_count ?? 0; + void invalidateServerCatalogAfterImport(qc, imported); if (imported > 0) { notifySuccess( t("detail.notifications.imported.title", { diff --git a/board/src/pages/clients/clients-page.tsx b/board/src/pages/clients/clients-page.tsx index ea079d52..6848c26e 100644 --- a/board/src/pages/clients/clients-page.tsx +++ b/board/src/pages/clients/clients-page.tsx @@ -5,6 +5,7 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { EntityListItem } from "../../components/entity-list-item"; import { ListGridContainer } from "../../components/list-grid-container"; +import { Pagination } from "../../components/pagination"; import { EmptyState, FullHeightEmptyStateCard, @@ -26,6 +27,10 @@ import { SelectValue, } from "../../components/ui/select"; import { clientsApi, surfaceReviewsApi } from "../../lib/api"; +import { + CATALOG_PAGE_SIZE_OPTIONS, + useResponsiveCatalogPagination, +} from "../../lib/hooks/use-responsive-catalog-pagination"; import { usePageTranslations } from "../../lib/i18n/usePageTranslations"; import { useUrlFilter, useUrlView } from "../../lib/hooks/use-url-state"; import { notifyError, notifyInfo, notifySuccess } from "../../lib/notify"; @@ -106,6 +111,7 @@ export function ClientsPage() { const { data: pendingReviewItems = [], error: reviewItemsError, + isFetched: areReviewItemsFetched, } = useQuery({ queryKey: ["surfaceReviews", "pending"], queryFn: () => surfaceReviewsApi.list({ state: "pending" }), @@ -195,6 +201,17 @@ export function ClientsPage() { const [sortedClients, setSortedClients] = React.useState( filteredClientsAsEntities, ); + const [isCatalogDataReady, setIsCatalogDataReady] = useState(false); + const clientPagination = useResponsiveCatalogPagination( + sortedClients, + view as "grid" | "list", + isCatalogDataReady, + ); + const catalogScrollRef = React.useRef(null); + + React.useEffect(() => { + catalogScrollRef.current?.scrollTo({ top: 0 }); + }, [clientPagination.currentPage]); const governanceMutation = useMutation({ mutationFn: async ({ @@ -459,11 +476,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 +542,12 @@ export function ClientsPage() { enabled: true, }, }), - [filteredClientsAsEntities, i18n.language, t], + [ + filteredClientsAsEntities, + i18n.language, + isClientCatalogDataReady, + t, + ], ); // Toolbar state @@ -538,7 +564,10 @@ export function ClientsPage() { onViewModeChange: (mode: "grid" | "list") => { setDashboardSetting("defaultView", mode); }, - onSortedDataChange: (sortedData: ClientToolbarEntity[]) => setSortedClients(sortedData), + onSortedDataChange: (sortedData: ClientToolbarEntity[]) => { + setSortedClients(sortedData); + setIsCatalogDataReady(true); + }, onExpandedChange: setExpanded, }; @@ -633,35 +662,57 @@ 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 [isCatalogDataReady, setIsCatalogDataReady] = useState(false); const queryClient = useQueryClient(); @@ -100,12 +97,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, @@ -113,12 +104,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, ); @@ -235,10 +220,7 @@ export function ServerListPage() { }, 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 @@ -260,27 +242,6 @@ export function ServerListPage() { ); }, [error, i18n.language, isError, t]); - 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); - } - }, [serverData?.servers, sortedServers.length, sortState]); - // Enable/disable server const toggleServerAsync = useCallback( async (serverId: string, enable: boolean, sync?: boolean) => { @@ -558,24 +519,16 @@ 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 serverPagination = useResponsiveCatalogPagination( + sortedServers, + viewMode as "grid" | "list", + isCatalogDataReady, ); + const catalogScrollRef = useRef(null); - // 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(() => { @@ -664,7 +617,8 @@ export function ServerListPage() { // Toolbar config type ToolbarServer = ServerSummary & { [key: string]: unknown }; const toolbarConfig: PageToolbarConfig = { - data: (serverData?.servers || []) as ToolbarServer[], + data: (serverData?.servers ?? EMPTY_SERVERS) as ToolbarServer[], + isDataReady: serverData !== undefined, search: { placeholder: t("toolbar.search.placeholder", { defaultValue: "Search servers...", @@ -722,7 +676,10 @@ export function ServerListPage() { onViewModeChange: (mode: "grid" | "list") => { setDashboardSetting("defaultView", mode); }, - onSortedDataChange: (data) => setSortedServers(data as ServerSummary[]), + onSortedDataChange: (data) => { + setSortedServers(data as ServerSummary[]); + setIsCatalogDataReady(true); + }, onExpandedChange: setExpanded, }; @@ -794,41 +751,61 @@ export function ServerListPage() { } statsCards={} > -
- - {viewMode === "grid" - ? filteredAndSortedServers.map((server) => ( - - )) - : filteredAndSortedServers.map((server) => ( - - ))} - +
+
+ + {viewMode === "grid" + ? serverPagination.pageItems.map((server) => ( + + )) + : serverPagination.pageItems.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; +} From d0e72dce5a6dd5f1a74ffc8ce834ef364a3efbe2 Mon Sep 17 00:00:00 2001 From: Loocor Date: Tue, 4 Aug 2026 00:06:03 +0800 Subject: [PATCH 03/14] fix(board): polish pagination toolbar and page indicator - Render compact current/total page indicator with balanced slash spacing. - Remove focus ring glow from pagination icon and per-page controls. - Export catalogPaginationClassName for pinned catalog footers. --- board/src/components/pagination.test.tsx | 48 +++++++ board/src/components/pagination.tsx | 151 ++++++++++++++++------- 2 files changed, 157 insertions(+), 42 deletions(-) create mode 100644 board/src/components/pagination.test.tsx diff --git a/board/src/components/pagination.test.tsx b/board/src/components/pagination.test.tsx new file mode 100644 index 00000000..be7c605c --- /dev/null +++ b/board/src/components/pagination.test.tsx @@ -0,0 +1,48 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import "../lib/i18n/index"; +import { Pagination } from "./pagination"; + +test("renders the page indicator in compact current slash total form", () => { + const markup = renderToStaticMarkup( + undefined} + onPreviousPage={() => undefined} + onNextPage={() => undefined} + />, + ); + + expect(markup).toContain('aria-label="Go to page"'); + expect(markup).toContain('value="1"'); + expect(markup).toContain(">/"); + expect(markup).toContain('aria-hidden="true">2'); + expect(markup).toContain('class="sr-only">of 2'); + expect(markup).not.toContain(">Page"); +}); + +test("preserves the localized page context when total pages are unknown", () => { + const markup = renderToStaticMarkup( + undefined} + onPreviousPage={() => undefined} + onNextPage={() => undefined} + />, + ); + + expect(markup).toContain(">Page"); + expect(markup).toContain('aria-label="Go to page"'); + expect(markup).toContain('value="1"'); + expect(markup).not.toContain(">/"); +}); diff --git a/board/src/components/pagination.tsx b/board/src/components/pagination.tsx index bc647187..1cea9df4 100644 --- a/board/src/components/pagination.tsx +++ b/board/src/components/pagination.tsx @@ -97,6 +97,20 @@ interface PaginationProps { const ICON_BTN = "h-8 w-8 shrink-0"; +/** Pagination toolbar controls use border/hover only — no shadcn focus ring glow. */ +const PAGINATION_CONTROL_FOCUS_CLASS = + "focus:outline-none focus-visible:outline-none focus:ring-0 focus:ring-offset-0 focus-visible:ring-0 focus-visible:ring-offset-0"; + +const PER_PAGE_SELECT_TRIGGER_CLASS = cn( + "relative h-8 w-[4.25rem] shrink-0 justify-center px-2 text-xs", + PAGINATION_CONTROL_FOCUS_CLASS, + "[&>span]:line-clamp-none [&>span]:flex-1 [&>span]:text-center", + "[&>svg]:absolute [&>svg]:right-1 [&>svg]:h-3.5 [&>svg]:w-3.5 [&>svg]:opacity-50", +); + +/** Sticky catalog footer pagination; pair with a scrollable list using `flex-1` above it. */ +export const catalogPaginationClassName = "shrink-0 pt-3"; + function PaginationSummary(props: { totalItemCount?: number | null; summaryId: string; @@ -128,6 +142,9 @@ function PaginationSummary(props: { ); } +/** Horizontal padding on each side of "/" so gaps to page numbers match in px. */ +const PAGE_INDICATOR_SLASH_SIDE_PADDING_PX = 6; + function PageIndicator(props: { currentPage: number; totalPages?: number | null; @@ -182,9 +199,10 @@ function PageIndicator(props: { const pageWord = t("pagination.pageWord", { defaultValue: "Page" }); const pageSuffix = t("pagination.pageSuffix", { defaultValue: "" }); + const totalDigits = + typeof totalPages === "number" && totalPages > 0 ? String(totalPages).length : 0; + const widthCh = useMemo(() => { - const totalDigits = - typeof totalPages === "number" && totalPages > 0 ? String(totalPages).length : 0; const core = Math.max( 1, draft.length, @@ -193,45 +211,94 @@ function PageIndicator(props: { ); // Padding for caret and focus ring; scales with digit count return Math.min(24, core + 1.75); - }, [currentPage, draft, totalPages]); + }, [currentPage, draft, totalDigits]); - return ( -
- {pageWord.trim() ? {pageWord} : null} - {onGoToPage ? ( - setDraft(event.target.value)} - onFocus={() => setFocused(true)} - onBlur={handleBlur} - onKeyDown={handleKeyDown} - style={{ width: `${widthCh}ch`, maxWidth: "100%" }} - className={cn( - "h-8 min-h-8 box-border min-w-0 rounded-md border text-center text-sm tabular-nums font-medium text-foreground transition-[width,box-shadow,border-color,background-color]", - "disabled:cursor-not-allowed disabled:opacity-50", - focused - ? "border-input bg-background px-2 py-0 ring-2 ring-ring ring-offset-2 ring-offset-background" - : "border-transparent bg-transparent px-1 py-0 shadow-none ring-0 ring-offset-0 outline-none focus-visible:ring-0 focus-visible:ring-offset-0", - )} - /> - ) : ( - {currentPage} + const currentPageWidthCh = useMemo(() => { + const core = Math.max(1, draft.length, String(currentPage).length); + return Math.min(24, core + 1.75); + }, [currentPage, draft]); + + const pageInputClassName = cn( + "h-8 min-h-8 shrink-0 box-border rounded-md border text-sm tabular-nums font-medium text-foreground transition-[width,box-shadow,border-color,background-color]", + PAGINATION_CONTROL_FOCUS_CLASS, + showTotal ? "text-right" : "min-w-0 max-w-full text-center", + "disabled:cursor-not-allowed disabled:opacity-50", + focused + ? cn("border-input bg-background py-0", showTotal ? "px-1" : "px-2") + : "border-transparent bg-transparent py-0 shadow-none px-1", + ); + + const pageInput = onGoToPage ? ( + setDraft(event.target.value)} + onFocus={() => setFocused(true)} + onBlur={handleBlur} + onKeyDown={handleKeyDown} + style={{ + width: `${showTotal ? currentPageWidthCh : widthCh}ch`, + }} + className={pageInputClassName} + /> + ) : ( + {pageSuffix} + > + {currentPage} + + ); + + return ( +
+ {!showTotal && pageWord.trim() ? ( + {pageWord} ) : null} {showTotal ? ( - - {t("pagination.ofTotal", { total: totalPages, defaultValue: "of {{total}}" })} - - ) : null} +
+
{pageInput}
+ + / + + + {totalPages} + + + {t("pagination.ofTotal", { + total: totalPages, + defaultValue: "of {{total}}", + })} + +
+ ) : ( + <> + {pageInput} + {!showTotal && pageSuffix ? ( + {pageSuffix} + ) : null} + + )}
); } @@ -323,7 +390,7 @@ export function Pagination({ type="button" variant="outline" size="icon" - className={ICON_BTN} + className={cn(ICON_BTN, PAGINATION_CONTROL_FOCUS_CLASS)} onClick={onFirstPage} disabled={isFirstDisabled} aria-label={t("pagination.first", { defaultValue: "First" })} @@ -334,7 +401,7 @@ export function Pagination({ type="button" variant="outline" size="icon" - className={ICON_BTN} + className={cn(ICON_BTN, PAGINATION_CONTROL_FOCUS_CLASS)} onClick={onPreviousPage} disabled={isPreviousDisabled} aria-label={t("pagination.previous", { defaultValue: "Previous" })} @@ -349,7 +416,7 @@ export function Pagination({ disabled={isLoading} > @@ -368,7 +435,7 @@ export function Pagination({ type="button" variant="outline" size="icon" - className={ICON_BTN} + className={cn(ICON_BTN, PAGINATION_CONTROL_FOCUS_CLASS)} onClick={onNextPage} disabled={isNextDisabled} aria-label={t("pagination.next", { defaultValue: "Next" })} @@ -379,7 +446,7 @@ export function Pagination({ type="button" variant="outline" size="icon" - className={ICON_BTN} + className={cn(ICON_BTN, PAGINATION_CONTROL_FOCUS_CLASS)} onClick={onLastPage} disabled={isLastDisabled} aria-label={t("pagination.last", { defaultValue: "Last" })} From 5b48f715bfba777d29b8521ce8a60e65aab6f16e Mon Sep 17 00:00:00 2001 From: Loocor Date: Tue, 4 Aug 2026 00:06:04 +0800 Subject: [PATCH 04/14] feat(board): extract PageToolbarSelect for auto-width triggers - Add mirror-label grid so toolbar selects size to the selected option. - Reuse the component for toolbar sort and secrets lifecycle filter. --- .../components/ui/page-toolbar-select.test.ts | 22 ++++++ .../src/components/ui/page-toolbar-select.tsx | 76 +++++++++++++++++++ board/src/components/ui/page-toolbar.tsx | 38 +++++----- board/src/pages/secrets/secrets-page.tsx | 27 ++----- 4 files changed, 122 insertions(+), 41 deletions(-) create mode 100644 board/src/components/ui/page-toolbar-select.test.ts create mode 100644 board/src/components/ui/page-toolbar-select.tsx 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 a5aa0f43..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"; @@ -384,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/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>( From 173edbeba312ae828c46c37db2e46f1adcbfdd8e Mon Sep 17 00:00:00 2001 From: Loocor Date: Tue, 4 Aug 2026 00:06:04 +0800 Subject: [PATCH 05/14] feat(board): honor explicit view mode in ListGridContainer - Accept optional viewMode prop that overrides stored dashboard default. - Keep grid/list class selection aligned with toolbar URL state. --- .../components/list-grid-container.test.tsx | 18 ++++++++++++++++++ board/src/components/list-grid-container.tsx | 7 +++++-- 2 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 board/src/components/list-grid-container.test.tsx diff --git a/board/src/components/list-grid-container.test.tsx b/board/src/components/list-grid-container.test.tsx new file mode 100644 index 00000000..db422f86 --- /dev/null +++ b/board/src/components/list-grid-container.test.tsx @@ -0,0 +1,18 @@ +import { expect, test } from "bun:test"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { useAppStore } from "../lib/store"; +import { ListGridContainer } from "./list-grid-container"; + +test("explicit view mode overrides the stored default layout", () => { + useAppStore.getState().setDashboardSetting("defaultView", "grid"); + + const markup = renderToStaticMarkup( + +
List item
+
, + ); + + expect(markup).toContain("space-y-4"); + expect(markup).not.toContain("grid-cols"); +}); diff --git a/board/src/components/list-grid-container.tsx b/board/src/components/list-grid-container.tsx index 1bec73e3..db765df8 100644 --- a/board/src/components/list-grid-container.tsx +++ b/board/src/components/list-grid-container.tsx @@ -4,6 +4,7 @@ import { cn } from "../lib/utils"; export interface ListGridContainerProps { children: ReactNode; + viewMode?: "grid" | "list"; loading?: boolean; loadingSkeleton?: ReactNode; emptyState?: ReactNode; @@ -13,6 +14,7 @@ export interface ListGridContainerProps { export function ListGridContainer({ children, + viewMode, loading = false, loadingSkeleton, emptyState, @@ -22,12 +24,13 @@ export function ListGridContainer({ const defaultView = useAppStore( (state) => state.dashboardSettings.defaultView, ); + const resolvedView = viewMode ?? defaultView; if (loading) { return (
Date: Tue, 4 Aug 2026 00:06:04 +0800 Subject: [PATCH 06/14] refactor(board): share catalog layout tokens and grid hooks - Centralize catalog scroll shell and hover inset class names. - Export getCatalogGridColumnCount and useCatalogGridColumnCount. --- board/src/lib/catalog-layout.ts | 11 ++++++++ .../use-responsive-catalog-pagination.ts | 28 +++++++++++++------ 2 files changed, 30 insertions(+), 9 deletions(-) create mode 100644 board/src/lib/catalog-layout.ts 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.ts b/board/src/lib/hooks/use-responsive-catalog-pagination.ts index b4403760..928b0486 100644 --- a/board/src/lib/hooks/use-responsive-catalog-pagination.ts +++ b/board/src/lib/hooks/use-responsive-catalog-pagination.ts @@ -45,7 +45,7 @@ export function paginateCatalogItems( return items.slice(start, start + pageSize); } -function getGridColumnCount(): number { +function getCatalogGridColumnCount(): number { if (typeof window === "undefined") { return 1; } @@ -58,6 +58,23 @@ function getGridColumnCount(): number { 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; @@ -79,22 +96,15 @@ export function useResponsiveCatalogPagination( isDataReady = true, ): ResponsiveCatalogPagination { const [searchParams] = useSearchParams(); + const gridColumnCount = useCatalogGridColumnCount(); const [requestedPage, setRequestedPage] = useUrlState({ paramName: "page", defaultValue: 1, validate: isValidPageParam, deserialize: Number, }); - const [gridColumnCount, setGridColumnCount] = useState(getGridColumnCount); const [selectedPageSize, setSelectedPageSize] = useState(null); - useEffect(() => { - const updateColumnCount = () => setGridColumnCount(getGridColumnCount()); - - window.addEventListener("resize", updateColumnCount); - return () => window.removeEventListener("resize", updateColumnCount); - }, []); - const responsivePageSize = getCatalogPageSize(viewMode, gridColumnCount); const pageSize = selectedPageSize ?? responsivePageSize; const totalPages = getCatalogTotalPages(items.length, pageSize); From 72428f7e342cd3ed89fd373cdee86d24d6f4ea21 Mon Sep 17 00:00:00 2001 From: Loocor Date: Tue, 4 Aug 2026 00:06:09 +0800 Subject: [PATCH 07/14] fix(board): unify catalog scroll shell on server and client lists - Use shared catalog section/scroll/pagination layout tokens. - Pass explicit viewMode into ListGridContainer and PageToolbarSelect. - Add e2e coverage for clients grid/list toggle highlighting. --- board/e2e/catalog-pagination.spec.ts | 16 +++++ board/src/pages/clients/clients-page.tsx | 61 +++++++++----------- board/src/pages/servers/server-list-page.tsx | 18 ++++-- 3 files changed, 57 insertions(+), 38 deletions(-) diff --git a/board/e2e/catalog-pagination.spec.ts b/board/e2e/catalog-pagination.spec.ts index 964b8ece..354148f4 100644 --- a/board/e2e/catalog-pagination.spec.ts +++ b/board/e2e/catalog-pagination.spec.ts @@ -171,6 +171,22 @@ test("Clients paginate responsive grid results and reset after search", async ({ 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"); diff --git a/board/src/pages/clients/clients-page.tsx b/board/src/pages/clients/clients-page.tsx index 6848c26e..844cc859 100644 --- a/board/src/pages/clients/clients-page.tsx +++ b/board/src/pages/clients/clients-page.tsx @@ -5,7 +5,10 @@ import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import { EntityListItem } from "../../components/entity-list-item"; import { ListGridContainer } from "../../components/list-grid-container"; -import { Pagination } from "../../components/pagination"; +import { + catalogPaginationClassName, + Pagination, +} from "../../components/pagination"; import { EmptyState, FullHeightEmptyStateCard, @@ -17,16 +20,14 @@ import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card } from "../../components/ui/card"; import { PageToolbar } from "../../components/ui/page-toolbar"; +import { PageToolbarSelect } from "../../components/ui/page-toolbar-select"; import type { Entity } from "../../components/ui/page-toolbar"; import type { SegmentOption } from "../../components/ui/segment"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../../components/ui/select"; import { clientsApi, surfaceReviewsApi } from "../../lib/api"; +import { + catalogPageSectionClassName, + catalogScrollShellClassName, +} from "../../lib/catalog-layout"; import { CATALOG_PAGE_SIZE_OPTIONS, useResponsiveCatalogPagination, @@ -324,11 +325,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)}`)} /> ); @@ -546,6 +546,7 @@ export function ClientsPage() { filteredClientsAsEntities, i18n.language, isClientCatalogDataReady, + storedDefaultView, t, ], ); @@ -588,23 +589,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 @@ -662,9 +656,10 @@ export function ClientsPage() { })}
)} -
-
+
+
} > -
-
+
+
From d89c06fa58ff587de247ea3a1e8665457d5e1c4c Mon Sep 17 00:00:00 2001 From: Loocor Date: Tue, 4 Aug 2026 00:06:09 +0800 Subject: [PATCH 08/14] feat(board): add responsive pagination to profiles catalog - Wire useResponsiveCatalogPagination with review-filter toolbar data. - Match servers/clients scroll shell and pinned pagination footer. - Type toolbar entities and view mode for PageToolbar integration. --- board/src/pages/profile/profile-page.tsx | 197 +++++++++++++++-------- 1 file changed, 127 insertions(+), 70 deletions(-) diff --git a/board/src/pages/profile/profile-page.tsx b/board/src/pages/profile/profile-page.tsx index f8f6b042..f4fd2b15 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"; @@ -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 [isCatalogDataReady, setIsCatalogDataReady] = useState(false); 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,34 @@ 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 isProfileCatalogDataReady = + suitsResponse !== undefined && + (reviewFilter !== "needs_review" || areReviewItemsFetched); + + const arrangedSortedSuits = useMemo( + () => arrangeSuitsWithDefaultAnchor(sortedSuits), + [sortedSuits], + ); + + const profilePagination = useResponsiveCatalogPagination( + arrangedSortedSuits, + viewMode, + 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 +725,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 +830,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 +880,14 @@ export function ProfilePage() { urlPersistence: { enabled: true, }, - }; + }), + [ + isProfileCatalogDataReady, + reviewFilteredSuits, + storedDefaultView, + t, + ], + ); // 工具栏状态 const toolbarState = { @@ -865,11 +895,14 @@ export function ProfilePage() { }; // 工具栏回调 - const toolbarCallbacks = { + const toolbarCallbacks: PageToolbarCallbacks = { onViewModeChange: (mode: "grid" | "list") => { setDashboardSetting("defaultView", mode); }, - onSortedDataChange: setSortedSuits, + onSortedDataChange: (sortedData) => { + setSortedSuits(sortedData as ConfigSuit[]); + setIsCatalogDataReady(true); + }, onExpandedChange: setExpanded, }; @@ -901,28 +934,25 @@ export function ProfilePage() {
); const filterNode = ( -
- -
+ ); // Prepare empty state @@ -958,11 +988,12 @@ export function ProfilePage() { return ( + config={toolbarConfig} state={toolbarState} - callbacks={toolbarCallbacks as any} + callbacks={toolbarCallbacks} filters={filterNode} actions={actions} /> @@ -987,17 +1018,43 @@ export function ProfilePage() {
)} - - {viewMode === "grid" - ? filteredAndSortedSuits.map(renderSuitCard) - : filteredAndSortedSuits.map(renderSuitListItem)} - +
+
+ + {viewMode === "grid" + ? profilePagination.pageItems.map(renderSuitCard) + : profilePagination.pageItems.map(renderSuitListItem)} + +
+ +
{/* New Suit Drawer */} Date: Tue, 4 Aug 2026 00:06:09 +0800 Subject: [PATCH 09/14] fix(board): align market page sizes with grid columns - Derive per-page options as multiples of responsive column count. - Snap stored/URL page sizes when breakpoints change. - Pin market pagination footer with shared catalog class. --- .../src/pages/market/hooks/use-market-data.ts | 70 ++++++++++++++-- .../market-list-pagination-storage.test.ts | 41 +++++++++ .../market/market-list-pagination-storage.ts | 58 +++++++++++-- board/src/pages/market/market-page.tsx | 83 +++++++++---------- board/src/pages/market/types.ts | 2 + 5 files changed, 197 insertions(+), 57 deletions(-) create mode 100644 board/src/pages/market/market-list-pagination-storage.test.ts diff --git a/board/src/pages/market/hooks/use-market-data.ts b/board/src/pages/market/hooks/use-market-data.ts index 32843263..c38a6334 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,8 @@ export function useMarketData( (state) => state.dashboardSettings.marketBlacklist, ); const prevFiltersRef = useRef({ search, sort }); + const prevResponsivePageSizeRef = useRef(responsivePageSize); + const prevGridColumnCountRef = useRef(gridColumnCount); const pagination = useCursorPagination({ limit: itemsPerPage, @@ -300,6 +313,38 @@ export function useMarketData( resetToFirstPage(); }, [search, sort, resetToFirstPage]); + useEffect(() => { + const prevColumnCount = prevGridColumnCountRef.current; + const prevResponsive = prevResponsivePageSizeRef.current; + prevGridColumnCountRef.current = gridColumnCount; + prevResponsivePageSizeRef.current = responsivePageSize; + + if (prevColumnCount === gridColumnCount) { + if ( + selectedPageSize === null && + prevResponsive !== responsivePageSize + ) { + resetToFirstPage(); + } + return; + } + + const effectiveSize = selectedPageSize ?? responsivePageSize; + if (!pageSizeOptions.includes(effectiveSize)) { + const snapped = snapMarketPageSize(effectiveSize, gridColumnCount); + setSelectedPageSize( + snapped === responsivePageSize ? null : snapped, + ); + } + resetToFirstPage(); + }, [ + gridColumnCount, + pageSizeOptions, + resetToFirstPage, + responsivePageSize, + selectedPageSize, + ]); + useEffect(() => { if (isRestoringPagination) { return; @@ -312,7 +357,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 +366,13 @@ export function useMarketData( }, { replace: true }, ); - }, [currentPage, itemsPerPage, isRestoringPagination, setSearchParams]); + }, [ + currentPage, + itemsPerPage, + isRestoringPagination, + responsivePageSize, + setSearchParams, + ]); useEffect(() => { if (isRestoringPagination) { @@ -464,10 +515,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 +620,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; From fdd6557a1d87e6680dca8e3940cf614ac33875f3 Mon Sep 17 00:00:00 2001 From: Loocor Date: Tue, 4 Aug 2026 00:06:14 +0800 Subject: [PATCH 10/14] fix(board): let detail overview logs fill remaining height - Add fillHeight mode to AuditLogsPanel with internal table scroll. - Pin metadata/stats above logs on server, client, and profile detail. - Tighten main layout footer spacing for full-height catalog pages. --- board/src/components/audit-logs-panel.tsx | 47 +- .../components/detail-tab-content-class.ts | 7 + board/src/components/layout/layout.tsx | 12 +- .../src/pages/clients/client-detail-page.tsx | 18 +- .../src/pages/profile/profile-detail-page.tsx | 28 +- .../src/pages/servers/server-detail-page.tsx | 710 +++++++++--------- 6 files changed, 436 insertions(+), 386 deletions(-) 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..079c37a7 100644 --- a/board/src/components/detail-tab-content-class.ts +++ b/board/src/components/detail-tab-content-class.ts @@ -4,3 +4,10 @@ */ 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"; 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/pages/clients/client-detail-page.tsx b/board/src/pages/clients/client-detail-page.tsx index 0005cc5f..89651d73 100644 --- a/board/src/pages/clients/client-detail-page.tsx +++ b/board/src/pages/clients/client-detail-page.tsx @@ -40,7 +40,11 @@ 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_STACK_CLASS, +} from "../../components/detail-tab-content-class"; import { useUrlTab } from "../../lib/hooks/use-url-state"; import { Badge } from "../../components/ui/badge"; @@ -2027,12 +2031,9 @@ export function ClientDetailPage() { {renderOverviewActionButtons()}
- -
- + +
+ {loadingConfig ? (
@@ -2387,7 +2388,7 @@ export function ClientDetailPage() { )} - +
@@ -2440,6 +2441,7 @@ export function ClientDetailPage() { {showClientLiveLogs ? (
- -
- + +
+ {!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/servers/server-detail-page.tsx b/board/src/pages/servers/server-detail-page.tsx index c32b829f..540e1b6b 100644 --- a/board/src/pages/servers/server-detail-page.tsx +++ b/board/src/pages/servers/server-detail-page.tsx @@ -27,7 +27,11 @@ 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_STACK_CLASS, +} from "../../components/detail-tab-content-class"; import { AuditLogsPanel } from "../../components/audit-logs-panel"; import { CapsuleStripeList, @@ -762,351 +766,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) + } + /> + + +
)} Date: Tue, 4 Aug 2026 00:06:14 +0800 Subject: [PATCH 11/14] fix(board): position profile card token badge in header corner - Add topRightBadgePosition corner mode with absolute placement. - Use corner badge on profile suit grid cards to protect title width. --- board/src/components/entity-card.tsx | 56 +++++++++++++------ .../components/profile-suit-grid-card.tsx | 1 + 2 files changed, 41 insertions(+), 16 deletions(-) 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/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, }} From f8543e747fe1bfae5488e0b3a6f045b80af15f1a Mon Sep 17 00:00:00 2001 From: Loocor Date: Tue, 4 Aug 2026 00:06:14 +0800 Subject: [PATCH 12/14] refactor(board): unify settings layout and drop preview labels - Centralize settings row/grid/control class tokens in settings-layout. - Remove sidebar Beta and About WIP labels; update About copy in EN/zh/JP. - Add bun test src script and tighten preset/test fixture typing. --- board/package.json | 1 + board/src/components/layout/sidebar.tsx | 5 +- board/src/pages/clients/i18n/index.test.ts | 7 +- .../src/pages/profile/profile-preset-page.tsx | 3 +- .../pages/settings/about-licenses-section.tsx | 7 +- board/src/pages/settings/i18n/index.ts | 6 +- .../src/pages/settings/providers-settings.tsx | 8 +- board/src/pages/settings/settings-layout.ts | 59 +++ board/src/pages/settings/settings-page.tsx | 435 +++++++++--------- 9 files changed, 299 insertions(+), 232 deletions(-) create mode 100644 board/src/pages/settings/settings-layout.ts 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/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" })}
- + {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.", })}

-
+