From a2fb7e99ab128460d408661c730a01ead12d8329 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 7 Aug 2026 20:59:59 +0000 Subject: [PATCH 1/3] feat: add read-only Gateway TUI --- src/components/Root.tsx | 41 +++ src/handlers/gateway/connector/get/screen.tsx | 40 +++ .../gateway/connector/list/screen.tsx | 42 +++ src/handlers/gateway/gateway.screen.test.tsx | 280 ++++++++++++++++++ src/handlers/gateway/gateway.test.tsx | 22 +- src/handlers/gateway/get/screen.tsx | 86 ++++++ src/handlers/gateway/index.tsx | 4 +- src/handlers/gateway/rule/get/screen.tsx | 26 ++ src/handlers/gateway/rule/list/screen.tsx | 68 +++++ src/handlers/gateway/screen.tsx | 71 +++++ src/handlers/gateway/target/get/screen.tsx | 26 ++ src/handlers/gateway/target/list/screen.tsx | 71 +++++ 12 files changed, 774 insertions(+), 3 deletions(-) create mode 100644 src/handlers/gateway/connector/get/screen.tsx create mode 100644 src/handlers/gateway/connector/list/screen.tsx create mode 100644 src/handlers/gateway/gateway.screen.test.tsx create mode 100644 src/handlers/gateway/get/screen.tsx create mode 100644 src/handlers/gateway/rule/get/screen.tsx create mode 100644 src/handlers/gateway/rule/list/screen.tsx create mode 100644 src/handlers/gateway/screen.tsx create mode 100644 src/handlers/gateway/target/get/screen.tsx create mode 100644 src/handlers/gateway/target/list/screen.tsx diff --git a/src/components/Root.tsx b/src/components/Root.tsx index 39eb95534..cd67e28b9 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -61,6 +61,14 @@ import { Oauth2CredentialProviderGetScreen, Oauth2CredentialProviderGetJsonScreen, } from "../handlers/identity/oauth2-credential-provider/get/screen.tsx"; +import { GatewayBrowseScreen } from "../handlers/gateway/screen.tsx"; +import { GatewayDetailScreen, GatewayJsonScreen } from "../handlers/gateway/get/screen.tsx"; +import { GatewayTargetListScreen } from "../handlers/gateway/target/list/screen.tsx"; +import { GatewayTargetJsonScreen } from "../handlers/gateway/target/get/screen.tsx"; +import { GatewayConnectorListScreen } from "../handlers/gateway/connector/list/screen.tsx"; +import { GatewayConnectorJsonScreen } from "../handlers/gateway/connector/get/screen.tsx"; +import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; +import { GatewayRuleJsonScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -317,6 +325,39 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/runtime/invoke/:runtimeId/:qualifier" element={} /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> { + const target = await props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts); + if (!isConnectorTarget(target.targetConfiguration)) { + throw new InputValidationError(`Gateway Target "${targetId}" is not connector-backed`); + } + return target; + }, + enabled: gatewayId !== undefined && targetId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/connector/list/screen.tsx b/src/handlers/gateway/connector/list/screen.tsx new file mode 100644 index 000000000..2968c2215 --- /dev/null +++ b/src/handlers/gateway/connector/list/screen.tsx @@ -0,0 +1,42 @@ +import { TargetType } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import { targetColumns, targetRow } from "../../target/list/screen"; + +export function GatewayConnectorListScreen({ ctx, core }: ScreenProps) { + const { gatewayId } = useParams(); + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const encodedGatewayId = encodeURIComponent(gatewayId ?? ""); + + return ( + { + const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); + return { + items: (response.items ?? []).filter( + (target) => target.targetType === TargetType.CONNECTOR, + ), + nextToken: response.nextToken, + }; + }} + toRow={targetRow} + columns={targetColumns} + getValue={(row) => row.targetId} + onSelect={(targetId) => + navigate( + `/agentcore/gateway/browse/${encodedGatewayId}/connectors/${encodeURIComponent(targetId)}`, + ) + } + onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} + loadingMessage="Loading Gateway Connectors…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This Gateway has no Connectors." + emptyPageMessage="No Connectors on this page." + /> + ); +} diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx new file mode 100644 index 000000000..9144c4346 --- /dev/null +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -0,0 +1,280 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + TargetType, + type GatewayRuleDetail, + type GatewaySummary, + type GetGatewayResponse, + type GetGatewayRuleResponse, + type GetGatewayTargetResponse, + type ListGatewaysResponse, + type TargetSummary, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { cleanupScreens, renderScreen, TestCoreClient, waitForText } from "../../testing"; + +afterEach(cleanupScreens); + +const ENDPOINT = "https://agentcore.example.test"; +const GATEWAY_ID = "gateway/blue one"; +const TARGET_ID = "target/blue one"; +const CONNECTOR_ID = "connector/blue one"; +const RULE_ID = "rule/blue one"; + +function gateway(overrides: Partial = {}): GatewaySummary { + return { + gatewayId: GATEWAY_ID, + name: "checkout-gateway", + status: "READY", + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + authorizerType: "AWS_IAM", + protocolType: "MCP", + ...overrides, + }; +} + +function gatewayDetail(overrides: Partial = {}): GetGatewayResponse { + return { + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/checkout", + gatewayId: GATEWAY_ID, + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + status: "READY", + name: "checkout-gateway", + authorizerType: "AWS_IAM", + gatewayUrl: "https://checkout.gateway.example.test", + roleArn: "arn:aws:iam::123456789012:role/gateway", + protocolType: "MCP", + ...overrides, + } as GetGatewayResponse; +} + +function target(targetId: string, name: string, targetType: TargetType): TargetSummary { + return { + targetId, + name, + targetType, + status: "READY", + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + }; +} + +function targetDetail(targetId: string, connector = false): GetGatewayTargetResponse { + return { + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/checkout", + targetId, + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + status: "READY", + name: connector ? "search-connector" : "orders-target", + targetConfiguration: connector + ? { mcp: { connector: { source: { connectorId: "web-search" } } } } + : { + http: { + passthrough: { + endpoint: "https://orders.example.test", + protocolType: "CUSTOM", + }, + }, + }, + credentialProviderConfigurations: [], + } as GetGatewayTargetResponse; +} + +function rule(): GatewayRuleDetail { + return { + ruleId: RULE_ID, + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/checkout", + priority: 10, + actions: [{ routeToTarget: { staticRoute: { targetName: "orders-target" } } }], + createdAt: new Date("2026-08-01T01:02:03.000Z"), + updatedAt: new Date("2026-08-02T03:04:05.000Z"), + status: "ACTIVE", + description: "Route orders", + }; +} + +function ruleDetail(): GetGatewayRuleResponse { + return rule() as GetGatewayRuleResponse; +} + +function coreWithGateways(items: GatewaySummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.gateway.setListResponse({ items }); + return core; +} + +describe("Gateway browse", () => { + test("renders Gateway identity and calls list with exact Core options", async () => { + const core = coreWithGateways([gateway()]); + const screen = renderScreen("/agentcore/gateway", { core, endpointUrl: ENDPOINT }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + const frame = screen.lastFrame()!; + expect(frame).toContain("status"); + expect(frame).toContain("protocol"); + expect(frame).toContain("authorizer"); + expect(frame).toContain("2026-08-02 03:04"); + expect(frame).toContain("[/] filter"); + expect(core.gateway.calls).toEqual([ + { + method: "listGateways", + args: [undefined, expect.any(Number), { region: "us-east-1", endpointUrl: ENDPOINT }], + }, + ]); + }); + + test("shows loading, empty, and retryable error states", async () => { + const loadingCore = new TestCoreClient(); + const pending = Promise.withResolvers(); + loadingCore.gateway.listGateways = async () => pending.promise; + const loading = renderScreen("/agentcore/gateway", { core: loadingCore }); + + await waitForText(loading.lastFrame, "Loading Gateways"); + await loading.press("escape"); + await waitForText(loading.lastFrame, "the platform for production AI agents"); + loading.unmount(); + + const empty = renderScreen("/agentcore/gateway"); + await waitForText(empty.lastFrame, "No Gateways found in this Region."); + empty.unmount(); + + const errorCore = new TestCoreClient(); + errorCore.gateway.setError(new Error("gateway unavailable")); + const error = renderScreen("/agentcore/gateway", { core: errorCore }); + + await waitForText(error.lastFrame, "gateway unavailable"); + expect(error.lastFrame()).toContain("[r] retry"); + errorCore.gateway.setError(undefined); + errorCore.gateway.setListResponse({ items: [gateway({ name: "recovered-gateway" })] }); + await error.write("r"); + await waitForText(error.lastFrame, "recovered-gateway"); + }); + + test("selects a Gateway and renders only read-only hub actions", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen("/agentcore/gateway", { core, endpointUrl: ENDPOINT }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + expect(screen.lastFrame()).toContain(`agentcore → gateway → ${GATEWAY_ID}`); + const frame = screen.lastFrame()!; + for (const action of ["detail", "targets", "connectors", "rules"]) { + expect(frame).toContain(action); + } + for (const excluded of ["create", "update", "delete", "invoke"]) { + expect(frame).not.toMatch(new RegExp(`\\b${excluded}\\b`)); + } + expect(core.gateway.calls.find((call) => call.method === "getGateway")).toEqual({ + method: "getGateway", + args: [GATEWAY_ID, { region: "us-east-1", endpointUrl: ENDPOINT }], + }); + }); + + test("opens complete Gateway JSON from the detail action", async () => { + const core = new TestCoreClient(); + core.gateway.setGetResponse(gatewayDetail()); + const screen = renderScreen(`/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}`, { + core, + }); + + await waitForText(screen.lastFrame, "show the full JSON definition"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"gatewayId"'); + expect(screen.lastFrame()).toContain('"roleArn"'); + await screen.press("escape"); + await waitForText(screen.lastFrame, "show the full JSON definition"); + }); +}); + +describe("Gateway nested browse", () => { + test("browses Targets and opens the selected Target JSON", async () => { + const core = new TestCoreClient(); + core.gateway + .setListTargetsResponse({ + items: [target(TARGET_ID, "orders-target", TargetType.PASSTHROUGH)], + }) + .setGetTargetResponse(targetDetail(TARGET_ID)); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/targets`, + { core, endpointUrl: ENDPOINT }, + ); + + await waitForText(screen.lastFrame, "orders-target"); + expect(core.gateway.calls[0]).toEqual({ + method: "listGatewayTargets", + args: [ + GATEWAY_ID, + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: ENDPOINT }, + ], + }); + await screen.press("return"); + await waitForText(screen.lastFrame, '"targetId"'); + expect(core.gateway.calls.at(-1)).toEqual({ + method: "getGatewayTarget", + args: [GATEWAY_ID, TARGET_ID, { region: "us-east-1", endpointUrl: ENDPOINT }], + }); + }); + + test("filters Connectors and opens the selected Connector JSON", async () => { + const core = new TestCoreClient(); + core.gateway + .setListTargetsResponse({ + items: [ + target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH), + target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR), + ], + }) + .setGetTargetResponse(targetDetail(CONNECTOR_ID, true)); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/connectors`, + { core }, + ); + + await waitForText(screen.lastFrame, "search-connector"); + expect(screen.lastFrame()).not.toContain("ordinary-target"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"targetConfiguration"'); + expect(screen.lastFrame()).toContain('"web-search"'); + }); + + test("browses Rules and opens the selected Rule JSON", async () => { + const core = new TestCoreClient(); + core.gateway.setListRulesResponse({ gatewayRules: [rule()] }).setGetRuleResponse(ruleDetail()); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/rules`, + { core }, + ); + + await waitForText(screen.lastFrame, "Route orders"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"ruleId"'); + expect(core.gateway.calls.at(-1)).toEqual({ + method: "getGatewayRule", + args: [GATEWAY_ID, RULE_ID, { region: "us-east-1", endpointUrl: undefined }], + }); + }); + + test("keeps nested lists empty and retryable without exposing write actions", async () => { + const core = new TestCoreClient(); + core.gateway.setError(new Error("targets unavailable")); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/targets`, + { core }, + ); + + await waitForText(screen.lastFrame, "targets unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + for (const excluded of ["create", "update", "delete"]) { + expect(screen.lastFrame()).not.toMatch(new RegExp(`\\b${excluded}\\b`)); + } + core.gateway.setError(undefined); + core.gateway.setListTargetsResponse({ items: [] }); + await screen.write("r"); + await waitForText(screen.lastFrame, "This Gateway has no Targets."); + }); +}); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 1dc8fe0ae..55af7e900 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -83,7 +83,27 @@ describe("gateway command hierarchy", () => { expect(rule?.children().map((child) => child.name())).toEqual(["get", "list"]); }); - test.each(["gateway", "gateway target", "gateway connector", "gateway rule"])( + test("opens only the Gateway root in the TUI and keeps read leaves headless", async () => { + await expect(run(["gateway"])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + await expect(run(["gateway", "get"])).rejects.toThrow(/--id/); + + const core = new TestCoreClient(); + core.gateway.setListResponse({ items: [] }); + await run(["gateway", "list"], core); + expect(core.gateway.calls).toEqual([ + { + method: "listGateways", + args: [undefined, undefined, { region: REGION }], + }, + ]); + + const target = await run(["gateway", "target"]); + expect(target.stdout).toContain("Usage: agentcore gateway target"); + }); + + test.each(["gateway target", "gateway connector", "gateway rule"])( "prints help for bare `%s` without a Core call", async (command) => { const { core, stdout } = await run(command.split(" ")); diff --git a/src/handlers/gateway/get/screen.tsx b/src/handlers/gateway/get/screen.tsx new file mode 100644 index 000000000..9fd391870 --- /dev/null +++ b/src/handlers/gateway/get/screen.tsx @@ -0,0 +1,86 @@ +import { useQuery } from "@tanstack/react-query"; +import { useNavigate, useParams } from "react-router"; +import { JsonDetail } from "../../../components/JsonDetail"; +import { ResourceDetailScreen } from "../../../components/ResourceDetailScreen"; +import type { ScreenProps } from "../../types"; +import { coreOptsFromCtx } from "../../utils"; + +function gatewayPath(gatewayId: string): string { + return `/agentcore/gateway/browse/${encodeURIComponent(gatewayId)}`; +} + +function useGatewayDetail({ ctx, core }: ScreenProps, gatewayId: string | undefined) { + const opts = coreOptsFromCtx(ctx); + return useQuery({ + queryKey: ["gateway", opts.region, gatewayId], + queryFn: () => core.gateway.getGateway(gatewayId!, opts), + enabled: gatewayId !== undefined, + }); +} + +export function GatewayDetailScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { gatewayId } = useParams(); + const detail = useGatewayDetail(props, gatewayId); + + return ( + navigate(`${gatewayPath(gatewayId)}/json`), + }, + { + name: "targets", + description: "browse every Target", + onSelect: () => navigate(`${gatewayPath(gatewayId)}/targets`), + }, + { + name: "connectors", + description: "browse connector-backed Targets", + onSelect: () => navigate(`${gatewayPath(gatewayId)}/connectors`), + }, + { + name: "rules", + description: "browse routing Rules", + onSelect: () => navigate(`${gatewayPath(gatewayId)}/rules`), + }, + ] + : [] + } + loadingLabel="Loading Gateway…" + onRetry={() => void detail.refetch()} + selectLabel="open" + /> + ); +} + +export function GatewayJsonScreen(props: ScreenProps) { + const { gatewayId } = useParams(); + const detail = useGatewayDetail(props, gatewayId); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/index.tsx b/src/handlers/gateway/index.tsx index 698e3719e..4cb7bbd60 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -1,6 +1,6 @@ +import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; import { Router } from "../../router"; -import { createHelpDefault } from "../help"; import type { Core } from "../types"; import { createGatewayConnectorHandler } from "./connector"; import { createGetGatewayHandler } from "./get"; @@ -10,7 +10,7 @@ import { createGatewayTargetHandler } from "./target"; export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) .handler(createGatewayTargetHandler(core, io)) diff --git a/src/handlers/gateway/rule/get/screen.tsx b/src/handlers/gateway/rule/get/screen.tsx new file mode 100644 index 000000000..668c8cc45 --- /dev/null +++ b/src/handlers/gateway/rule/get/screen.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function GatewayRuleJsonScreen(props: ScreenProps) { + const { gatewayId, ruleId } = useParams(); + const opts = coreOptsFromCtx(props.ctx); + const detail = useQuery({ + queryKey: ["gateway-rule", opts.region, gatewayId, ruleId], + queryFn: () => props.core.gateway.getGatewayRule(gatewayId!, ruleId!, opts), + enabled: gatewayId !== undefined && ruleId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/rule/list/screen.tsx b/src/handlers/gateway/rule/list/screen.tsx new file mode 100644 index 000000000..de1cf67eb --- /dev/null +++ b/src/handlers/gateway/rule/list/screen.tsx @@ -0,0 +1,68 @@ +import type { GatewayRuleDetail } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +interface RuleRow extends Record { + ruleId: string; + priority: string; + status: string; + description: string; +} + +const ruleColumns = [ + { key: "priority", header: "priority", width: 10 }, + { key: "status", header: "status", width: 13 }, + { key: "description", header: "description", flex: true }, + { + key: "ruleId", + header: "id suffix", + width: 10, + render: (value: unknown) => String(value ?? "").slice(-8), + }, +] satisfies DataTableColumn[]; + +function toRow(rule: GatewayRuleDetail): RuleRow { + return { + ruleId: rule.ruleId ?? "", + priority: rule.priority?.toString() ?? "-", + status: rule.status ?? "-", + description: rule.description ?? "-", + }; +} + +export function GatewayRuleListScreen({ ctx, core }: ScreenProps) { + const { gatewayId } = useParams(); + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const encodedGatewayId = encodeURIComponent(gatewayId ?? ""); + + return ( + { + const response = await core.gateway.listGatewayRules(gatewayId!, token, pageSize, opts); + return { + items: response.gatewayRules ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={ruleColumns} + getValue={(row) => row.ruleId} + onSelect={(ruleId) => + navigate( + `/agentcore/gateway/browse/${encodedGatewayId}/rules/${encodeURIComponent(ruleId)}`, + ) + } + onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} + loadingMessage="Loading Gateway Rules…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This Gateway has no Rules." + emptyPageMessage="No Rules on this page." + /> + ); +} diff --git a/src/handlers/gateway/screen.tsx b/src/handlers/gateway/screen.tsx new file mode 100644 index 000000000..13be7a720 --- /dev/null +++ b/src/handlers/gateway/screen.tsx @@ -0,0 +1,71 @@ +import type { GatewaySummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import { formatTimestamp } from "../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../components/ui/data-table"; +import type { ScreenProps } from "../types"; +import { coreOptsFromCtx } from "../utils"; + +interface GatewayRow extends Record { + gatewayId: string; + name: string; + status: string; + protocol: string; + authorizer: string; + updatedAt: string; +} + +export const gatewayColumns = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", width: 16 }, + { key: "protocol", header: "protocol", width: 12 }, + { key: "authorizer", header: "authorizer", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(gateway: GatewaySummary): GatewayRow { + return { + gatewayId: gateway.gatewayId ?? "", + name: gateway.name ?? gateway.gatewayId ?? "", + status: gateway.status ?? "-", + protocol: gateway.protocolType ?? "unrestricted", + authorizer: gateway.authorizerType ?? "-", + updatedAt: gateway.updatedAt?.toISOString() ?? "-", + }; +} + +export function GatewayBrowseScreen({ ctx, core }: ScreenProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + return ( + { + const response = await core.gateway.listGateways(token, pageSize, opts); + return { + items: response.items ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={gatewayColumns} + getValue={(row) => row.gatewayId} + onSelect={(gatewayId) => + navigate(`/agentcore/gateway/browse/${encodeURIComponent(gatewayId)}`) + } + onBack={() => navigate("/agentcore")} + loadingMessage="Loading Gateways…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No Gateways found in this Region." + emptyPageMessage="No Gateways on this page." + /> + ); +} diff --git a/src/handlers/gateway/target/get/screen.tsx b/src/handlers/gateway/target/get/screen.tsx new file mode 100644 index 000000000..c071e4e45 --- /dev/null +++ b/src/handlers/gateway/target/get/screen.tsx @@ -0,0 +1,26 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +export function GatewayTargetJsonScreen(props: ScreenProps) { + const { gatewayId, targetId } = useParams(); + const opts = coreOptsFromCtx(props.ctx); + const detail = useQuery({ + queryKey: ["gateway-target", opts.region, gatewayId, targetId], + queryFn: () => props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts), + enabled: gatewayId !== undefined && targetId !== undefined, + }); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/gateway/target/list/screen.tsx b/src/handlers/gateway/target/list/screen.tsx new file mode 100644 index 000000000..eed8e00d6 --- /dev/null +++ b/src/handlers/gateway/target/list/screen.tsx @@ -0,0 +1,71 @@ +import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +interface TargetRow extends Record { + targetId: string; + name: string; + type: string; + status: string; + updatedAt: string; +} + +export const targetColumns = [ + { key: "name", header: "name", flex: true }, + { key: "type", header: "type", width: 18 }, + { key: "status", header: "status", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +export function targetRow(target: TargetSummary): TargetRow { + return { + targetId: target.targetId ?? "", + name: target.name ?? target.targetId ?? "", + type: target.targetType ?? "-", + status: target.status ?? "-", + updatedAt: target.updatedAt?.toISOString() ?? "-", + }; +} + +export function GatewayTargetListScreen({ ctx, core }: ScreenProps) { + const { gatewayId } = useParams(); + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const encodedGatewayId = encodeURIComponent(gatewayId ?? ""); + + return ( + { + const response = await core.gateway.listGatewayTargets(gatewayId!, token, pageSize, opts); + return { + items: response.items ?? [], + nextToken: response.nextToken, + }; + }} + toRow={targetRow} + columns={targetColumns} + getValue={(row) => row.targetId} + onSelect={(targetId) => + navigate( + `/agentcore/gateway/browse/${encodedGatewayId}/targets/${encodeURIComponent(targetId)}`, + ) + } + onBack={() => navigate(`/agentcore/gateway/browse/${encodedGatewayId}`)} + loadingMessage="Loading Gateway Targets…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="This Gateway has no Targets." + emptyPageMessage="No Targets on this page." + /> + ); +} From 56f5936e0a846e131f38d4ecc53499ee7eb335ec Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 7 Aug 2026 21:03:40 +0000 Subject: [PATCH 2/3] fix: correct Memory column test import --- src/components/ui/data-table/columnWidths.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ui/data-table/columnWidths.test.ts b/src/components/ui/data-table/columnWidths.test.ts index 8ed0bd624..7c06cc7b0 100644 --- a/src/components/ui/data-table/columnWidths.test.ts +++ b/src/components/ui/data-table/columnWidths.test.ts @@ -3,10 +3,10 @@ import stringWidth from "string-width"; import { harnessEndpointColumns } from "../../HarnessEndpointPicker"; import { harnessColumns } from "../../HarnessPicker"; import { harnessVersionColumns } from "../../HarnessVersionPicker"; +import { memoryColumns } from "../../MemoryPicker"; import { runtimeEndpointColumns } from "../../RuntimeEndpointPicker"; import { runtimeColumns } from "../../RuntimePicker"; import { runtimeVersionColumns } from "../../RuntimeVersionPicker"; -import { memoryColumns } from "../../../handlers/memory/list/screen"; import { computeColumnWidths, FLEX_MIN_WIDTH, From fbf99b8bfd4f6d5edfd8ed7c703bbeea56f18175 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 7 Aug 2026 21:15:54 +0000 Subject: [PATCH 3/3] test: cover non-Connector Gateway route --- src/handlers/gateway/gateway.screen.test.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx index 9144c4346..4abbfd019 100644 --- a/src/handlers/gateway/gateway.screen.test.tsx +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -242,6 +242,18 @@ describe("Gateway nested browse", () => { expect(screen.lastFrame()).toContain('"web-search"'); }); + test("rejects a non-Connector Target opened through the Connector route", async () => { + const core = new TestCoreClient(); + core.gateway.setGetTargetResponse(targetDetail(TARGET_ID)); + const screen = renderScreen( + `/agentcore/gateway/browse/${encodeURIComponent(GATEWAY_ID)}/connectors/${encodeURIComponent(TARGET_ID)}`, + { core }, + ); + + await waitForText(screen.lastFrame, `Gateway Target "${TARGET_ID}" is not connector-backed`); + expect(screen.lastFrame()).toContain("[r] retry"); + }); + test("browses Rules and opens the selected Rule JSON", async () => { const core = new TestCoreClient(); core.gateway.setListRulesResponse({ gatewayRules: [rule()] }).setGetRuleResponse(ruleDetail());