diff --git a/src/api/servers.test.ts b/src/api/servers.test.ts index 09f1cca9..9fb0b150 100644 --- a/src/api/servers.test.ts +++ b/src/api/servers.test.ts @@ -499,6 +499,61 @@ describe("serversApi", () => { }); }); + describe("getOAuthStatus", () => { + const statusResponse = (body: unknown) => + new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + + const idsFrom = (call: unknown[]) => + new URL(String(call[0]), "http://localhost").searchParams.getAll("gateway_ids"); + + it("sends one request and returns its statuses", async () => { + const body = { "srv-1": { user_token_status: { status: "missing" } } }; + mockFetch.mockResolvedValueOnce(statusResponse(body)); + + const result = await serversApi.getOAuthStatus(["srv-1"]); + + expect(result).toEqual(body); + expect(mockFetch).toHaveBeenCalledTimes(1); + expect(idsFrom(mockFetch.mock.calls[0])).toEqual(["srv-1"]); + }); + + it("splits over 100 ids across requests and merges the responses", async () => { + const ids = Array.from({ length: 101 }, (_, index) => `srv-${index}`); + mockFetch + .mockResolvedValueOnce( + statusResponse({ "srv-0": { user_token_status: { status: "valid" } } }), + ) + .mockResolvedValueOnce( + statusResponse({ "srv-100": { user_token_status: { status: "expired" } } }), + ); + + const result = await serversApi.getOAuthStatus(ids); + + expect(mockFetch).toHaveBeenCalledTimes(2); + expect(idsFrom(mockFetch.mock.calls[0])).toHaveLength(100); + expect(idsFrom(mockFetch.mock.calls[1])).toEqual(["srv-100"]); + expect(result).toEqual({ + "srv-0": { user_token_status: { status: "valid" } }, + "srv-100": { user_token_status: { status: "expired" } }, + }); + }); + + it("makes no request for an empty id list", async () => { + await expect(serversApi.getOAuthStatus([])).resolves.toEqual({}); + expect(mockFetch).not.toHaveBeenCalled(); + }); + + it("rejects a malformed id before any request", async () => { + await expect(serversApi.getOAuthStatus(["../etc"])).rejects.toThrow( + "Invalid server ID format", + ); + expect(mockFetch).not.toHaveBeenCalled(); + }); + }); + describe("testConnection", () => { it("POSTs /v1/mcp-servers/:id/test and returns the result", async () => { mockFetch.mockResolvedValueOnce( diff --git a/src/api/servers.ts b/src/api/servers.ts index 078eadee..7e1271d5 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, @@ -17,6 +17,17 @@ import type { const serverByIdRequestCache = new Map>(); +/** Mirrors OAUTH_STATUS_BATCH_MAX_IDS on the backend's /oauth/status route. */ +const OAUTH_STATUS_MAX_IDS = 100; + +/** The user closed the OAuth popup. Typed so callers can stay quiet about it. */ +export class OAuthCancelledError extends Error { + constructor() { + super("OAuth authorization was cancelled"); + this.name = "OAuthCancelledError"; + } +} + /** * Validates server ID to prevent path traversal and injection attacks * @param id - The server ID to validate @@ -203,6 +214,31 @@ 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. A paged-through list can exceed the + * backend's id cap, so requests are split and the responses merged. + */ + getOAuthStatus: async (ids: string[]): Promise> => { + const validIds = ids.map(validateServerId); + const batches: string[][] = []; + for (let start = 0; start < validIds.length; start += OAUTH_STATUS_MAX_IDS) { + batches.push(validIds.slice(start, start + OAUTH_STATUS_MAX_IDS)); + } + + const responses = await Promise.all( + batches.map((batch) => { + const params = new URLSearchParams(); + batch.forEach((id) => params.append("gateway_ids", id)); + return api.get>(`/oauth/status?${params.toString()}`); + }), + ); + + return Object.assign({}, ...responses) as Record; + }, + /** * Open blank OAuth popup during an active user gesture. * @@ -278,7 +314,7 @@ export const serversApi = { if (!settled) { settled = true; cleanup(); - reject(new Error("OAuth authorization was cancelled")); + reject(new OAuthCancelledError()); } } }, 1000); 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/ServerStatusDetail.tsx b/src/components/servers/ServerStatusDetail.tsx new file mode 100644 index 00000000..6164d3d6 --- /dev/null +++ b/src/components/servers/ServerStatusDetail.tsx @@ -0,0 +1,53 @@ +import { useIntl } from "react-intl"; + +import { getAvailabilityPresentation, type ServerAvailability } from "@/lib/serverStatus"; +import { formatLocalDateTime } from "@/utils/formatDate"; + +interface ServerStatusDetailProps { + availability: ServerAvailability; + enabled: boolean; + lastSeen?: string | null; + lastError?: string | null; +} + +/** + * What a status means, plus the last response and last error where the server + * has them. This is the only place either value is surfaced in the UI. + * + * Disabled servers withhold the error: the health loop clears `last_error` only + * on enabled servers, so theirs is left over from an outage before they were + * turned off and reads as a current failure. The test is `enabled` rather than + * the `inactive` state, because `auth` outranks `inactive`, so a disabled + * server whose token has also expired is classified `auth` and would otherwise + * slip past the guard. + */ +export function ServerStatusDetail({ + availability, + enabled, + lastSeen, + lastError, +}: ServerStatusDetailProps) { + const intl = useIntl(); + const showLastError = Boolean(lastError) && enabled; + + return ( +
+

+ {intl.formatMessage({ id: getAvailabilityPresentation(availability).detailId })} +

+ {lastSeen && ( +

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

+ )} + {showLastError && ( +

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

+ )} +
+ ); +} diff --git a/src/components/servers/ServerStatusIndicator.test.tsx b/src/components/servers/ServerStatusIndicator.test.tsx new file mode 100644 index 00000000..48eaebfa --- /dev/null +++ b/src/components/servers/ServerStatusIndicator.test.tsx @@ -0,0 +1,152 @@ +import { describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; + +import { renderWithProviders, screen } from "@/test/test-utils"; +import { ServerStatusIndicator } from "./ServerStatusIndicator"; + +const server = { name: "github-notify", enabled: true, reachable: true }; + +describe("ServerStatusIndicator", () => { + it("explains a state with nothing to resolve, in a popover", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: /status: Offline/i })); + + expect(await screen.findByText(/This server is offline/)).toBeInTheDocument(); + expect(screen.getByText(/connection refused/)).toBeInTheDocument(); + expect(screen.getByText(/Last response:/)).toBeInTheDocument(); + }); + + it("omits last response and last error when the server has neither", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /status: Connecting/i })); + + expect(await screen.findByText(/not active yet/)).toBeInTheDocument(); + expect(screen.queryByText(/Last error:/)).not.toBeInTheDocument(); + expect(screen.queryByText(/Last response:/)).not.toBeInTheDocument(); + }); + + it("withholds the stale last error an inactive server kept from its last outage", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: /status: Inactive/i })); + + expect(await screen.findByText(/are inactive/)).toBeInTheDocument(); + expect(screen.getByText(/Last response:/)).toBeInTheDocument(); + expect(screen.queryByText(/certificate has expired/)).not.toBeInTheDocument(); + }); + + it("withholds it from a disabled server whose token has also expired", async () => { + const user = userEvent.setup(); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: /status: Authorization/i })); + + expect(await screen.findByText(/You have not authorized this server/)).toBeInTheDocument(); + expect(screen.queryByText(/certificate has expired/)).not.toBeInTheDocument(); + }); + + it("hands authorization straight to the OAuth flow", async () => { + const user = userEvent.setup(); + const onAuthorize = vi.fn().mockResolvedValue(undefined); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: "Authorize github-notify" })); + + expect(onAuthorize).toHaveBeenCalledOnce(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); + + it("explains the auth state where the caller cannot authorize", async () => { + const user = userEvent.setup(); + renderWithProviders(); + + await user.click(screen.getByRole("button", { name: /status: Authorization/i })); + + expect(await screen.findByText(/You have not authorized this server/)).toBeInTheDocument(); + }); + + it("holds the pending label while the flow is open", async () => { + const user = userEvent.setup(); + let release: (() => void) | undefined; + const onAuthorize = vi.fn().mockReturnValue( + new Promise((resolve) => { + release = resolve; + }), + ); + renderWithProviders( + , + ); + + await user.click(screen.getByRole("button", { name: "Authorize github-notify" })); + + const trigger = screen.getByRole("button", { name: "Authorize github-notify" }); + expect(trigger).toBeDisabled(); + expect(trigger).toHaveTextContent("Authorizing..."); + expect(screen.getByText("Authorization")).toHaveAttribute("aria-hidden", "true"); + + release?.(); + }); + + it("announces the full status word where the label is abbreviated", () => { + renderWithProviders( + , + ); + + expect(screen.getByText("Auth")).toHaveAttribute("aria-hidden", "true"); + expect( + screen.getByRole("button", { name: "github-notify status: Authorization. Show details" }), + ).toBeInTheDocument(); + }); + + it("renders as plain text where a button cannot nest", () => { + renderWithProviders(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.getByText("Active")).toBeInTheDocument(); + }); +}); diff --git a/src/components/servers/ServerStatusIndicator.tsx b/src/components/servers/ServerStatusIndicator.tsx new file mode 100644 index 00000000..99cebf06 --- /dev/null +++ b/src/components/servers/ServerStatusIndicator.tsx @@ -0,0 +1,143 @@ +import { useState } from "react"; +import { useIntl } from "react-intl"; + +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + getAvailabilityPresentation, + getServerAvailability, + type OAuthTokenStatus, + type ServerAvailabilityInput, +} from "@/lib/serverStatus"; +import { ServerStatusDetail } from "./ServerStatusDetail"; + +interface ServerStatusIndicatorProps { + server: ServerAvailabilityInput & { + name: string; + lastError?: string | null; + }; + oauthTokenStatus?: OAuthTokenStatus; + /** Use the short label, for narrow columns. Screen readers still get the full one. */ + compact?: boolean; + /** Render as plain text rather than a button. Required inside another button. */ + interactive?: boolean; + /** + * Starts the OAuth authorization flow. Given only where the caller can run + * it; without it the `auth` state explains itself like every other state. + */ + onAuthorize?: () => Promise; + className?: string; +} + +/** + * Server status icon and label. + * + * States with nothing to resolve open a popover explaining themselves, the way + * visibility does. `auth` is the exception: it hands off to the OAuth flow + * rather than describing it, since authorizing is the whole point of the state. + * + * That button stacks its two labels so the wider one sets the width, keeping + * the row still while the flow is open. + */ +export function ServerStatusIndicator({ + server, + oauthTokenStatus, + compact = false, + interactive = true, + onAuthorize, + className, +}: ServerStatusIndicatorProps) { + const intl = useIntl(); + const [isAuthorizing, setIsAuthorizing] = useState(false); + const availability = getServerAvailability(server, oauthTokenStatus); + const presentation = getAvailabilityPresentation(availability); + const StatusIcon = presentation.Icon; + const authorize = availability === "auth" ? onAuthorize : undefined; + const statusLabel = intl.formatMessage({ + id: compact ? presentation.shortLabelId : presentation.labelId, + }); + const authorizingLabel = intl.formatMessage({ id: "mcpServer.status.action.authorizing" }); + const label = isAuthorizing ? authorizingLabel : statusLabel; + const fullLabel = intl.formatMessage({ id: presentation.labelId }); + const isAbbreviated = compact && presentation.shortLabelId !== presentation.labelId; + + const icon = ( +