From 123716aa119414bb7aa3ad5078ad1493a4cb385a Mon Sep 17 00:00:00 2001 From: Anna Effort Date: Tue, 15 Sep 2026 15:43:33 -0700 Subject: [PATCH 1/6] feat: add a shared MCP server status indicator and detail dialog Adds one availability derivation and one indicator for MCP server health, starting with the servers page. The "Authorization needed" state is new. OAuth tokens are held per user per gateway, so an authorization_code server that a colleague authorized is still unusable for everyone else. The health check treats the resulting 401 as healthy on purpose, so those rows read Active with zero components today. GET /oauth/status reports the caller's own token state, and the indicator maps a missing or expired token to that state. Retires the "warning" status. Two conflicting definitions existed: unreachable but seen before, and reachable but stale over five minutes. The first is Offline by another name; the second fires on any server the health loop has not reached yet. Unreachable now splits into Offline and Connecting on whether the server has ever responded. Deletes ServerStatusBadge, which nothing rendered. Closes #6464 Closes #6465 Signed-off-by: Anna Effort --- src/api/servers.ts | 14 +- .../servers/ServerStatusBadge.test.tsx | 50 ------- src/components/servers/ServerStatusBadge.tsx | 85 ----------- .../servers/ServerStatusDialog.test.tsx | 85 +++++++++++ src/components/servers/ServerStatusDialog.tsx | 133 ++++++++++++++++++ .../servers/ServerStatusIndicator.tsx | 92 ++++++++++++ src/components/servers/ServersTable.test.tsx | 45 +++++- src/components/servers/ServersTable.tsx | 68 ++------- src/i18n/locales/en-US/mcpServer.json | 17 +++ src/i18n/locales/es-ES/mcpServer.json | 17 +++ src/i18n/locales/pt-BR/mcpServer.json | 17 +++ src/lib/serverStatus.test.ts | 56 ++++++++ src/lib/serverStatus.ts | 89 ++++++++++++ src/pages/Servers.test.tsx | 10 +- src/pages/Servers.tsx | 53 +++++++ src/types/server.ts | 21 +++ 16 files changed, 652 insertions(+), 200 deletions(-) delete mode 100644 src/components/servers/ServerStatusBadge.test.tsx delete mode 100644 src/components/servers/ServerStatusBadge.tsx create mode 100644 src/components/servers/ServerStatusDialog.test.tsx create mode 100644 src/components/servers/ServerStatusDialog.tsx create mode 100644 src/components/servers/ServerStatusIndicator.tsx create mode 100644 src/lib/serverStatus.test.ts create mode 100644 src/lib/serverStatus.ts diff --git a/src/api/servers.ts b/src/api/servers.ts index a5606e1c..44da617c 100644 --- a/src/api/servers.ts +++ b/src/api/servers.ts @@ -6,7 +6,7 @@ */ import { api } from "./client"; -import type { ServersResponse, MCPServer } from "../types/server"; +import type { ServersResponse, MCPServer, GatewayOAuthStatus } from "../types/server"; import type { GatewayHandshakeRequest, GatewayHandshakeResponse, @@ -183,6 +183,18 @@ export const serversApi = { return api.post(`/oauth/fetch-tools/${validId}`); }, + /** + * The caller's own OAuth state for each gateway, batched. + * + * Keys stay snake_case, unlike the gateway endpoints. Ids that are missing or + * not visible to the caller are omitted; the backend rejects over 100 ids. + */ + getOAuthStatus: (ids: string[]): Promise> => { + const params = new URLSearchParams(); + ids.map(validateServerId).forEach((id) => params.append("gateway_ids", id)); + return api.get(`/oauth/status?${params.toString()}`); + }, + /** * Trigger OAuth authorization flow for a gateway via a popup window. * diff --git a/src/components/servers/ServerStatusBadge.test.tsx b/src/components/servers/ServerStatusBadge.test.tsx deleted file mode 100644 index 8a6e636d..00000000 --- a/src/components/servers/ServerStatusBadge.test.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { ServerStatusBadge } from "./ServerStatusBadge"; -import type { MCPServer } from "../../types/server"; - -describe("ServerStatusBadge", () => { - const baseServer = { - id: "test-id", - name: "test-server", - enabled: true, - reachable: true, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } as MCPServer; - - it("renders Draft when not enabled", () => { - render(); - expect(screen.getByText("Draft")).toBeInTheDocument(); - }); - - it("renders Offline when enabled but not reachable", () => { - render(); - expect(screen.getByText("Offline")).toBeInTheDocument(); - }); - - it("renders Warning when last_seen is older than threshold", () => { - const oldDate = new Date(Date.now() - 6 * 60 * 1000).toISOString(); - render( - , - ); - expect(screen.getByText("Warning")).toBeInTheDocument(); - }); - - it("renders Active when reachable and recently seen", () => { - const recentDate = new Date(Date.now() - 1 * 60 * 1000).toISOString(); - render( - , - ); - expect(screen.getByText("Active")).toBeInTheDocument(); - }); - - it("renders Active when reachable and last_seen is undefined", () => { - render(); - expect(screen.getByText("Active")).toBeInTheDocument(); - }); -}); diff --git a/src/components/servers/ServerStatusBadge.tsx b/src/components/servers/ServerStatusBadge.tsx deleted file mode 100644 index d4c4394b..00000000 --- a/src/components/servers/ServerStatusBadge.tsx +++ /dev/null @@ -1,85 +0,0 @@ -import { memo } from "react"; -import { FileText } from "lucide-react"; -import { Badge } from "../ui/badge"; -import { STATUS_ICON } from "@/lib/status"; -import type { MCPServer, ServerStatus } from "../../types/server"; - -// Warning threshold: 5 minutes in milliseconds -const WARNING_THRESHOLD_MS = 5 * 60 * 1000; - -function getServerStatus(server: MCPServer): ServerStatus { - if (!server.enabled) return "draft"; - if (!server.reachable) return "offline"; - - // Warning: if lastSeen is older than the threshold - if (server.lastSeen) { - const lastSeenDate = new Date(server.lastSeen); - const thresholdDate = new Date(Date.now() - WARNING_THRESHOLD_MS); - if (lastSeenDate < thresholdDate) return "warning"; - } - - return "active"; -} - -enum BadgeVariant { - Draft = "draft", - Success = "success", - Destructive = "destructive", - Warning = "warning", -} - -interface StatusConfig { - label: string; - icon: typeof FileText; - variant: BadgeVariant; -} - -const STATUS_CONFIG: Record = { - draft: { - label: "Draft", - icon: FileText, - variant: BadgeVariant.Draft, - }, - active: { - label: "Active", - icon: STATUS_ICON.success, - variant: BadgeVariant.Success, - }, - offline: { - label: "Offline", - icon: STATUS_ICON.error, - variant: BadgeVariant.Destructive, - }, - warning: { - label: "Warning", - icon: STATUS_ICON.warning, - variant: BadgeVariant.Warning, - }, -}; - -interface ServerStatusBadgeProps { - server: MCPServer; -} - -export const ServerStatusBadge = memo(function ServerStatusBadge({ - server, -}: ServerStatusBadgeProps) { - const status = getServerStatus(server); - const config = STATUS_CONFIG[status]; - const Icon = config.icon; - - return ( - - - ); -}); - -ServerStatusBadge.displayName = "ServerStatusBadge"; diff --git a/src/components/servers/ServerStatusDialog.test.tsx b/src/components/servers/ServerStatusDialog.test.tsx new file mode 100644 index 00000000..21a6f0e5 --- /dev/null +++ b/src/components/servers/ServerStatusDialog.test.tsx @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders, screen, waitFor } from "@/test/test-utils"; +import { ServerStatusDialog } from "./ServerStatusDialog"; + +function renderDialog(props: Partial[0]> = {}) { + return renderWithProviders( + , + ); +} + +describe("ServerStatusDialog", () => { + it("names the state and the server", () => { + renderDialog(); + expect(screen.getByRole("heading", { name: "Authorization needed" })).toBeInTheDocument(); + expect(screen.getByText("github-notify")).toBeInTheDocument(); + }); + + it("shows the last error and last response only when present", () => { + const { unmount } = renderDialog({ + availability: "unreachable", + lastSeen: "2026-04-16T13:23:12Z", + lastError: "connection refused", + }); + expect(screen.getByText(/connection refused/)).toBeInTheDocument(); + expect(screen.getByText(/Last response:/)).toBeInTheDocument(); + unmount(); + + renderDialog({ availability: "unreachable" }); + expect(screen.queryByText(/Last error:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Last response:/)).not.toBeInTheDocument(); + }); + + it("offers each state only the action that clears it", () => { + const handlers = { onAuthorize: vi.fn(), onEnable: vi.fn() }; + const { unmount } = renderDialog({ availability: "auth", ...handlers }); + expect(screen.getByRole("button", { name: "Authorize" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Turn on" })).not.toBeInTheDocument(); + unmount(); + + renderDialog({ availability: "inactive", ...handlers }); + expect(screen.getByRole("button", { name: "Turn on" })).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Authorize" })).not.toBeInTheDocument(); + }); + + it("offers no action when the caller cannot act", () => { + renderDialog({ availability: "auth" }); + expect(screen.queryByRole("button", { name: "Authorize" })).not.toBeInTheDocument(); + }); + + it("explains an unreachable server without offering a fix", () => { + renderDialog({ availability: "unreachable", onAuthorize: vi.fn(), onEnable: vi.fn() }); + expect(screen.queryByRole("button", { name: "Authorize" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Turn on" })).not.toBeInTheDocument(); + }); + + it("closes once the action succeeds", async () => { + const onOpenChange = vi.fn(); + renderDialog({ onAuthorize: vi.fn().mockResolvedValue(undefined), onOpenChange }); + + await userEvent.click(screen.getByRole("button", { name: "Authorize" })); + + await waitFor(() => expect(onOpenChange).toHaveBeenCalledWith(false)); + }); + + it("keeps the dialog open and reports a failed action", async () => { + const onOpenChange = vi.fn(); + renderDialog({ + onAuthorize: vi.fn().mockRejectedValue(new Error("popup blocked")), + onOpenChange, + }); + + await userEvent.click(screen.getByRole("button", { name: "Authorize" })); + + expect(await screen.findByRole("alert")).toHaveTextContent("popup blocked"); + expect(onOpenChange).not.toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/servers/ServerStatusDialog.tsx b/src/components/servers/ServerStatusDialog.tsx new file mode 100644 index 00000000..4fe2a8e5 --- /dev/null +++ b/src/components/servers/ServerStatusDialog.tsx @@ -0,0 +1,133 @@ +import { useState } from "react"; +import { useIntl } from "react-intl"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { parseApiError } from "@/lib/errorUtils"; +import { getAvailabilityPresentation, type ServerAvailability } from "@/lib/serverStatus"; +import { formatLocalDateTime } from "@/utils/formatDate"; + +interface ServerStatusDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + serverName: string; + availability: ServerAvailability; + lastSeen?: string | null; + lastError?: string | null; + /** Offered for `auth`. Omit where the caller cannot act. */ + onAuthorize?: () => Promise; + /** Offered for `inactive`. */ + onEnable?: () => Promise; +} + +/** Detail for a server status: what the state means, and the action that clears it. */ +export function ServerStatusDialog({ + open, + onOpenChange, + serverName, + availability, + lastSeen, + lastError, + onAuthorize, + onEnable, +}: ServerStatusDialogProps) { + const intl = useIntl(); + const [isPending, setIsPending] = useState(false); + const [actionError, setActionError] = useState(null); + const presentation = getAvailabilityPresentation(availability); + const StatusIcon = presentation.Icon; + + const action = + availability === "auth" && onAuthorize + ? { + run: onAuthorize, + labelId: "mcpServer.status.action.authorize", + pendingId: "mcpServer.status.action.authorizing", + } + : availability === "inactive" && onEnable + ? { + run: onEnable, + labelId: "mcpServer.status.action.enable", + pendingId: "mcpServer.status.action.enabling", + } + : null; + + const runAction = async () => { + if (!action) return; + setIsPending(true); + setActionError(null); + try { + await action.run(); + onOpenChange(false); + } catch (error) { + setActionError( + parseApiError( + error, + error instanceof Error ? error.message : intl.formatMessage({ id: "common.error" }), + ), + ); + } finally { + setIsPending(false); + } + }; + + return ( + + + +
+
+ {serverName} +
+ +
+

{intl.formatMessage({ id: presentation.detailId })}

+ {lastSeen && ( +

+ {intl.formatMessage( + { id: "mcpServer.status.detail.lastSeen" }, + { timestamp: formatLocalDateTime(lastSeen, "") }, + )} +

+ )} + {lastError && ( +

+ {intl.formatMessage( + { id: "mcpServer.status.detail.lastError" }, + { error: lastError }, + )} +

+ )} + {actionError && ( +

+ {intl.formatMessage({ id: "mcpServer.status.action.error" }, { error: actionError })} +

+ )} +
+ + + + {action && ( + + )} + +
+
+ ); +} diff --git a/src/components/servers/ServerStatusIndicator.tsx b/src/components/servers/ServerStatusIndicator.tsx new file mode 100644 index 00000000..3cfb5bc4 --- /dev/null +++ b/src/components/servers/ServerStatusIndicator.tsx @@ -0,0 +1,92 @@ +import { useState } from "react"; +import { useIntl } from "react-intl"; + +import { cn } from "@/lib/utils"; +import { + getAvailabilityPresentation, + getServerAvailability, + type OAuthTokenStatus, + type ServerAvailabilityInput, +} from "@/lib/serverStatus"; +import { ServerStatusDialog } from "./ServerStatusDialog"; + +interface ServerStatusIndicatorProps { + server: ServerAvailabilityInput & { + name: string; + lastError?: string | null; + }; + oauthTokenStatus?: OAuthTokenStatus; + /** Use the short label, for narrow columns. */ + compact?: boolean; + /** Render as plain text rather than a button. Required inside another button. */ + interactive?: boolean; + onAuthorize?: () => Promise; + onEnable?: () => Promise; + className?: string; +} + +/** Server status icon and label, opening the detail dialog on click. */ +export function ServerStatusIndicator({ + server, + oauthTokenStatus, + compact = false, + interactive = true, + onAuthorize, + onEnable, + className, +}: ServerStatusIndicatorProps) { + const intl = useIntl(); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const availability = getServerAvailability(server, oauthTokenStatus); + const presentation = getAvailabilityPresentation(availability); + const StatusIcon = presentation.Icon; + const label = intl.formatMessage({ + id: compact ? presentation.shortLabelId : presentation.labelId, + }); + + const content = ( + <> +