diff --git a/README.md b/README.md index 6314094eb..ac53f4729 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,8 @@ It gives you two ways to work, from the same binary: - **A scriptable CLI** — every operation is a flag-driven subcommand that emits JSON (`--json`), so it can be used by codeing agents and can drop cleanly into scripts, CI, and automation. -- **An interactive TUI** — bare Harness, Runtime, Memory, and Identity branches - and leaves open their corresponding menus and selection flows. +- **An interactive TUI** — bare Harness, Runtime, Memory, Identity, and Gateway + branches and leaves open their corresponding menus and selection flows. ```bash agentcore # launch the interactive TUI @@ -26,8 +26,8 @@ responses. `agentcore` wraps all of that behind one ergonomic tool. ## Command surface -Commands with operation flags run headlessly. Bare Harness, Runtime, Memory, and -Identity branches and leaves open their interactive flows. +Commands with operation flags run headlessly. Bare Harness, Runtime, Memory, +Identity, and Gateway branches and leaves open their interactive flows. ``` agentcore # interactive TUI @@ -86,6 +86,9 @@ agentcore # interactive TUI │ ├── target │ │ ├── get # get a Target under a Gateway │ │ └── list # list Targets under a Gateway +│ ├── connector +│ │ ├── get # get a connector-backed Target +│ │ └── list # list connector-backed Targets │ └── rule │ ├── get # get a Rule under a Gateway │ └── list # list Rules under a Gateway @@ -158,6 +161,8 @@ agentcore gateway get --id agentcore gateway list --max-results 20 agentcore gateway target get --gateway-id --target-id agentcore gateway target list --gateway-id --max-results 20 +agentcore gateway connector get --gateway-id --id +agentcore gateway connector list --gateway-id --max-results 20 agentcore gateway rule get --gateway-id --rule-id agentcore gateway rule list --gateway-id --max-results 20 @@ -340,6 +345,23 @@ agentcore identity oauth2-credential-provider list agentcore identity oauth2-credential-provider get ``` +The Gateway TUI is read-only: bare Gateway, Target, Connector, and Rule +branches and their `get`/`list` leaves open command menus and scoped selection +flows. Connector is presented as a separate resource experience while using +Gateway Target operations internally. + +```bash +agentcore gateway +agentcore gateway list +agentcore gateway get +agentcore gateway target list +agentcore gateway target get +agentcore gateway connector list +agentcore gateway connector get +agentcore gateway rule list +agentcore gateway rule get +``` + --- # Architecture & patterns @@ -749,9 +771,8 @@ A Husky pre-commit hook runs Prettier (via lint-staged) on staged files automati - **Cover more AgentCore resources.** The harness surface (CRUD, versions, endpoints, invoke, exec) is fully implemented in both the CLI and the TUI; - the same patterns extend naturally to gateways, the remaining read-only - Memory data-plane operations, browser profiles, and the other AgentCore - resources. + the same patterns extend naturally to the remaining read-only Memory + data-plane operations, browser profiles, and the other AgentCore resources. - **Implement `config`.** The `config` command is currently a stub — it should read/write real global settings (telemetry, log level, ...) through an injected config accessor. diff --git a/src/components/GatewayPicker.tsx b/src/components/GatewayPicker.tsx new file mode 100644 index 000000000..312055667 --- /dev/null +++ b/src/components/GatewayPicker.tsx @@ -0,0 +1,81 @@ +import type { GatewaySummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate } from "react-router"; +import type { ScreenProps } from "../handlers/types"; +import { coreOptsFromCtx } from "../handlers/utils"; +import { formatTimestamp } from "./formatTimestamp"; +import { PaginatedTablePicker } from "./PaginatedTablePicker"; +import type { DataTableColumn } from "./ui/data-table"; + +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 interface GatewayPickerProps extends ScreenProps { + breadcrumb: string[]; + description?: string; + onSelect: (gatewayId: string) => void; +} + +export function GatewayPicker({ + ctx, + core, + breadcrumb, + description, + onSelect, +}: GatewayPickerProps) { + 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={onSelect} + onBack={() => navigate("/" + breadcrumb.slice(0, -1).join("/"))} + 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/components/Root.tsx b/src/components/Root.tsx index 39eb95534..102ae5069 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -61,6 +61,18 @@ import { Oauth2CredentialProviderGetScreen, Oauth2CredentialProviderGetJsonScreen, } from "../handlers/identity/oauth2-credential-provider/get/screen.tsx"; +import { GatewayScreen } from "../handlers/gateway/screen.tsx"; +import { GatewayGetJsonScreen, GatewayGetScreen } from "../handlers/gateway/get/screen.tsx"; +import { GatewayListScreen } from "../handlers/gateway/list/screen.tsx"; +import { GatewayTargetScreen } from "../handlers/gateway/target/screen.tsx"; +import { GatewayTargetListScreen } from "../handlers/gateway/target/list/screen.tsx"; +import { GatewayTargetGetScreen } from "../handlers/gateway/target/get/screen.tsx"; +import { GatewayConnectorScreen } from "../handlers/gateway/connector/screen.tsx"; +import { GatewayConnectorListScreen } from "../handlers/gateway/connector/list/screen.tsx"; +import { GatewayConnectorGetScreen } from "../handlers/gateway/connector/get/screen.tsx"; +import { GatewayRuleScreen } from "../handlers/gateway/rule/screen.tsx"; +import { GatewayRuleListScreen } from "../handlers/gateway/rule/list/screen.tsx"; +import { GatewayRuleGetScreen } from "../handlers/gateway/rule/get/screen.tsx"; import { RootScreen, HelpScreen } from "../handlers/screen.tsx"; import type { Context } from "../router"; @@ -317,6 +329,83 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/runtime/invoke/:runtimeId/:qualifier" element={} /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> createHandler({ @@ -33,7 +27,7 @@ export const createGetGatewayConnectorHandler = (core: Core) => flags.id, coreOptsFromCtx(ctx), ); - if (!isConnectorTarget(target.targetConfiguration)) { + if (!GatewayConnectorTarget.is(target.targetConfiguration)) { throw new InputValidationError(`Gateway Target "${flags.id}" is not connector-backed`); } diff --git a/src/handlers/gateway/connector/get/screen.tsx b/src/handlers/gateway/connector/get/screen.tsx new file mode 100644 index 000000000..7d83d752f --- /dev/null +++ b/src/handlers/gateway/connector/get/screen.tsx @@ -0,0 +1,34 @@ +import { useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import { JsonDetail } from "../../../../components/JsonDetail"; +import { InputValidationError } from "../../../../errors"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; +import { GatewayConnectorTarget } from "../gatewayConnectorTarget"; + +export function GatewayConnectorGetScreen(props: ScreenProps) { + const { gatewayId, targetId } = useParams(); + const opts = coreOptsFromCtx(props.ctx); + const detail = useQuery({ + queryKey: ["gateway-connector", opts.region, gatewayId, targetId], + queryFn: async () => { + const target = await props.core.gateway.getGatewayTarget(gatewayId!, targetId!, opts); + if (!GatewayConnectorTarget.is(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/index.tsx b/src/handlers/gateway/connector/index.tsx index c4eddc556..00b0b273f 100644 --- a/src/handlers/gateway/connector/index.tsx +++ b/src/handlers/gateway/connector/index.tsx @@ -1,13 +1,13 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; -import { createHelpDefault } from "../../help"; +import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createGetGatewayConnectorHandler } from "./get"; import { createListGatewayConnectorsHandler } from "./list"; export function createGatewayConnectorHandler(core: Core, io: AppIO): Router { return new Router("connector", "inspect connectors configured for an AgentCore Gateway") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetGatewayConnectorHandler(core)) .handler(createListGatewayConnectorsHandler(core)); } diff --git a/src/handlers/gateway/connector/list/screen.tsx b/src/handlers/gateway/connector/list/screen.tsx new file mode 100644 index 000000000..e1a44c761 --- /dev/null +++ b/src/handlers/gateway/connector/list/screen.tsx @@ -0,0 +1,84 @@ +import { TargetType, type TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { GatewayPicker } from "../../../../components/GatewayPicker"; +import { PaginatedTablePicker } from "../../../../components/PaginatedTablePicker"; +import type { DataTableColumn } from "../../../../components/ui/data-table"; +import type { ScreenProps } from "../../../types"; +import { coreOptsFromCtx } from "../../../utils"; + +interface ConnectorRow extends Record { + targetId: string; + name: string; + status: string; + updatedAt: string; +} + +const connectorColumns = [ + { key: "name", header: "name", flex: true }, + { key: "status", header: "status", width: 18 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toConnectorRow(target: TargetSummary): ConnectorRow { + return { + targetId: target.targetId ?? "", + name: target.name ?? target.targetId ?? "", + status: target.status ?? "-", + updatedAt: target.updatedAt?.toISOString() ?? "-", + }; +} + +export function GatewayConnectorListScreen({ ctx, core }: ScreenProps) { + const { gatewayId } = useParams(); + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + + if (!gatewayId) { + return ( + navigate(`/agentcore/gateway/connector/list/${encodeURIComponent(id)}`)} + /> + ); + } + + 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={toConnectorRow} + columns={connectorColumns} + getValue={(row) => row.targetId} + onSelect={(targetId) => + navigate( + `/agentcore/gateway/connector/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(targetId)}`, + ) + } + onBack={() => navigate(-1)} + loadingMessage={`Loading Connectors for Gateway ${gatewayId}…`} + errorMessage={(error) => + `Error loading Connectors for Gateway ${gatewayId}: ${error.message}` + } + emptyMessage="This Gateway has no Connectors." + emptyPageMessage="No Connectors on this page." + /> + ); +} diff --git a/src/handlers/gateway/connector/screen.tsx b/src/handlers/gateway/connector/screen.tsx new file mode 100644 index 000000000..9d87c9f92 --- /dev/null +++ b/src/handlers/gateway/connector/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function GatewayConnectorScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/gateway/gateway.screen.test.tsx b/src/handlers/gateway/gateway.screen.test.tsx new file mode 100644 index 000000000..8ff001fc8 --- /dev/null +++ b/src/handlers/gateway/gateway.screen.test.tsx @@ -0,0 +1,489 @@ +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 menu and list", () => { + test("renders the Gateway command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway"); + + await waitForText(screen.lastFrame, "inspect AgentCore Gateways"); + const frame = screen.lastFrame()!; + for (const command of ["get", "list", "target", "connector", "rule"]) { + expect(frame).toContain(command); + } + expect(screen.core.gateway.calls).toEqual([]); + }); + + test("renders Gateway identity and calls list with exact Core options", async () => { + const core = coreWithGateways([gateway()]); + const screen = renderScreen("/agentcore/gateway/list", { 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/list", { core: loadingCore }); + + await waitForText(loading.lastFrame, "Loading Gateways"); + await loading.press("escape"); + await waitForText(loading.lastFrame, "inspect AgentCore Gateways"); + loading.unmount(); + + const empty = renderScreen("/agentcore/gateway/list"); + 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/list", { 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/list", { 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 → get → ${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/get/${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"); + }); + + test("bare Gateway get redirects to the Gateway picker", async () => { + const core = coreWithGateways([gateway({ name: "redirected-gateway" })]); + const screen = renderScreen("/agentcore/gateway/get", { core }); + + await waitForText(screen.lastFrame, "redirected-gateway"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); + }); +}); + +describe("Gateway Target flow", () => { + test("renders the Target command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway/target"); + + await waitForText(screen.lastFrame, "inspect targets for an AgentCore Gateway"); + expect(screen.lastFrame()).toContain("get"); + expect(screen.lastFrame()).toContain("list"); + expect(screen.core.gateway.calls).toEqual([]); + }); + + test("selects a Gateway before listing Targets", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setListTargetsResponse({ + items: [target(TARGET_ID, "orders-target", TargetType.PASSTHROUGH)], + }); + const screen = renderScreen("/agentcore/gateway/target/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → gateway → target → list → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, "orders-target"); + expect(core.gateway.calls.find((call) => call.method === "listGatewayTargets")).toEqual({ + method: "listGatewayTargets", + args: [ + GATEWAY_ID, + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: undefined }, + ], + }); + }); + + test("opens the selected Target JSON with exact selectors", 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/target/list/${encodeURIComponent(GATEWAY_ID)}`, + { + 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, + `agentcore → gateway → target → get → ${GATEWAY_ID} → ${TARGET_ID}`, + ); + 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("bare Target get redirects to Gateway selection", async () => { + const core = coreWithGateways([gateway({ name: "target-parent" })]); + const screen = renderScreen("/agentcore/gateway/target/get", { core }); + + await waitForText(screen.lastFrame, "target-parent"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); + }); + + test("unwinds Target detail through the scoped list and Gateway picker", async () => { + const core = coreWithGateways([gateway()]); + core.gateway + .setListTargetsResponse({ + items: [target(TARGET_ID, "orders-target", TargetType.PASSTHROUGH)], + }) + .setGetTargetResponse(targetDetail(TARGET_ID)); + const screen = renderScreen("/agentcore/gateway/target/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, "orders-target"); + await screen.press("return"); + await waitForText(screen.lastFrame, '"targetId"'); + + await screen.press("escape"); + await waitForText(screen.lastFrame, `agentcore → gateway → target → list → ${GATEWAY_ID}`); + await screen.press("escape"); + await waitForText(screen.lastFrame, "choose a Gateway to list Targets for"); + await screen.press("escape"); + await waitForText(screen.lastFrame, "inspect targets for an AgentCore Gateway"); + }); +}); + +describe("Gateway Connector flow", () => { + test("renders the separate Connector command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway/connector"); + + await waitForText(screen.lastFrame, "inspect connectors configured for an AgentCore Gateway"); + expect(screen.lastFrame()).toContain("get"); + expect(screen.lastFrame()).toContain("list"); + expect(screen.core.gateway.calls).toEqual([]); + }); + + test("selects a Gateway before showing the dedicated Connector list", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setListTargetsResponse({ + items: [ + target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH), + target(CONNECTOR_ID, "search-connector", TargetType.CONNECTOR), + ], + }); + const screen = renderScreen("/agentcore/gateway/connector/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → gateway → connector → list → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, "search-connector"); + const frame = screen.lastFrame()!; + expect(frame).not.toContain("ordinary-target"); + expect(frame).toContain("updated UTC"); + expect(frame).not.toMatch(/\btype\b/); + }); + + 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/connector/list/${encodeURIComponent(GATEWAY_ID)}`, + { core }, + ); + + await waitForText(screen.lastFrame, "search-connector"); + expect(screen.lastFrame()).not.toContain("ordinary-target"); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → gateway → connector → get → ${GATEWAY_ID} → ${CONNECTOR_ID}`, + ); + await waitForText(screen.lastFrame, '"targetConfiguration"'); + expect(screen.lastFrame()).toContain('"web-search"'); + expect(core.gateway.calls.at(-1)).toEqual({ + method: "getGatewayTarget", + args: [GATEWAY_ID, CONNECTOR_ID, { region: "us-east-1", endpointUrl: undefined }], + }); + }); + + 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/connector/get/${encodeURIComponent(GATEWAY_ID)}/${encodeURIComponent(TARGET_ID)}`, + { core }, + ); + + await waitForText(screen.lastFrame, `Gateway Target "${TARGET_ID}" is not connector-backed`); + expect(screen.lastFrame()).toContain("[r] retry"); + }); + + test("preserves Target pagination when a filtered Connector page is empty", async () => { + const core = new TestCoreClient(); + core.gateway + .setListTargetsResponse({ + items: [target(TARGET_ID, "ordinary-target", TargetType.PASSTHROUGH)], + nextToken: "page-2", + }) + .setListTargetsResponse( + { + items: [target(CONNECTOR_ID, "page-two-connector", TargetType.CONNECTOR)], + }, + "page-2", + ); + const screen = renderScreen( + `/agentcore/gateway/connector/list/${encodeURIComponent(GATEWAY_ID)}`, + { core }, + ); + + await waitForText(screen.lastFrame, "No Connectors on this page."); + expect(screen.lastFrame()).toContain("page 1 · more →"); + await screen.write("l"); + await waitForText(screen.lastFrame, "page-two-connector"); + }); + + test("bare Connector get redirects to Gateway selection", async () => { + const core = coreWithGateways([gateway({ name: "connector-parent" })]); + const screen = renderScreen("/agentcore/gateway/connector/get", { core }); + + await waitForText(screen.lastFrame, "connector-parent"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); + }); +}); + +describe("Gateway Rule flow", () => { + test("renders the Rule command menu without calling Core", async () => { + const screen = renderScreen("/agentcore/gateway/rule"); + + await waitForText(screen.lastFrame, "inspect rules for an AgentCore Gateway"); + expect(screen.lastFrame()).toContain("get"); + expect(screen.lastFrame()).toContain("list"); + expect(screen.core.gateway.calls).toEqual([]); + }); + + test("selects a Gateway before listing Rules", async () => { + const core = coreWithGateways([gateway()]); + core.gateway.setListRulesResponse({ gatewayRules: [rule()] }); + const screen = renderScreen("/agentcore/gateway/rule/list", { core }); + + await waitForText(screen.lastFrame, "checkout-gateway"); + await screen.press("return"); + await waitForText(screen.lastFrame, `agentcore → gateway → rule → list → ${GATEWAY_ID}`); + await waitForText(screen.lastFrame, "Route orders"); + expect(core.gateway.calls.find((call) => call.method === "listGatewayRules")).toEqual({ + method: "listGatewayRules", + args: [ + GATEWAY_ID, + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: undefined }, + ], + }); + }); + + test("opens the selected Rule JSON with exact selectors", async () => { + const core = new TestCoreClient(); + core.gateway.setListRulesResponse({ gatewayRules: [rule()] }).setGetRuleResponse(ruleDetail()); + const screen = renderScreen(`/agentcore/gateway/rule/list/${encodeURIComponent(GATEWAY_ID)}`, { + core, + }); + + await waitForText(screen.lastFrame, "Route orders"); + await screen.press("return"); + await waitForText( + screen.lastFrame, + `agentcore → gateway → rule → get → ${GATEWAY_ID} → ${RULE_ID}`, + ); + 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 scoped lists empty and retryable", async () => { + const core = new TestCoreClient(); + core.gateway.setError(new Error("rules unavailable")); + const screen = renderScreen(`/agentcore/gateway/rule/list/${encodeURIComponent(GATEWAY_ID)}`, { + core, + }); + + await waitForText(screen.lastFrame, "rules unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + core.gateway.setError(undefined); + core.gateway.setListRulesResponse({ gatewayRules: [] }); + await screen.write("r"); + await waitForText(screen.lastFrame, "This Gateway has no Rules."); + }); + + test("bare Rule get redirects to Gateway selection", async () => { + const core = coreWithGateways([gateway({ name: "rule-parent" })]); + const screen = renderScreen("/agentcore/gateway/rule/get", { core }); + + await waitForText(screen.lastFrame, "rule-parent"); + expect(core.gateway.calls[0]?.method).toBe("listGateways"); + }); +}); diff --git a/src/handlers/gateway/gateway.test.tsx b/src/handlers/gateway/gateway.test.tsx index 1dc8fe0ae..dc9dd15c4 100644 --- a/src/handlers/gateway/gateway.test.tsx +++ b/src/handlers/gateway/gateway.test.tsx @@ -83,16 +83,24 @@ describe("gateway command hierarchy", () => { expect(rule?.children().map((child) => child.name())).toEqual(["get", "list"]); }); - test.each(["gateway", "gateway target", "gateway connector", "gateway rule"])( - "prints help for bare `%s` without a Core call", - async (command) => { - const { core, stdout } = await run(command.split(" ")); - - expect(stdout).toContain(`Usage: agentcore ${command}`); - expect(stdout).toContain("Commands:"); - expect(core.gateway.calls).toEqual([]); - }, - ); + test.each([ + ["Gateway", ["gateway"]], + ["Gateway get", ["gateway", "get"]], + ["Gateway list", ["gateway", "list"]], + ["Target", ["gateway", "target"]], + ["Target get", ["gateway", "target", "get"]], + ["Target list", ["gateway", "target", "list"]], + ["Connector", ["gateway", "connector"]], + ["Connector get", ["gateway", "connector", "get"]], + ["Connector list", ["gateway", "connector", "list"]], + ["Rule", ["gateway", "rule"]], + ["Rule get", ["gateway", "rule", "get"]], + ["Rule list", ["gateway", "rule", "list"]], + ] as const)("opens the TUI for a bare %s command", async (_label, args) => { + await expect(run([...args])).rejects.toThrow( + "interactive mode requires a TTY on stdin and stdout", + ); + }); }); describe("gateway reads", () => { @@ -340,16 +348,16 @@ describe("gateway reads", () => { describe("gateway validation and errors", () => { test.each([ - ["Gateway get", ["gateway", "get"], /--id/], - ["Target get parent", ["gateway", "target", "get"], /--gateway-id/], + ["Gateway get", ["gateway", "get", "--id", ""], /--id/], + ["Target get parent", ["gateway", "target", "get", "--target-id", TARGET_ID], /--gateway-id/], ["Target get child", ["gateway", "target", "get", "--gateway-id", GATEWAY_ID], /--target-id/], - ["Target list", ["gateway", "target", "list"], /--gateway-id/], - ["Connector get parent", ["gateway", "connector", "get"], /--gateway-id/], + ["Target list", ["gateway", "target", "list", "--max-results", "1"], /--gateway-id/], + ["Connector get parent", ["gateway", "connector", "get", "--id", TARGET_ID], /--gateway-id/], ["Connector get child", ["gateway", "connector", "get", "--gateway-id", GATEWAY_ID], /--id/], - ["Connector list", ["gateway", "connector", "list"], /--gateway-id/], - ["Rule get parent", ["gateway", "rule", "get"], /--gateway-id/], + ["Connector list", ["gateway", "connector", "list", "--max-results", "1"], /--gateway-id/], + ["Rule get parent", ["gateway", "rule", "get", "--rule-id", RULE_ID], /--gateway-id/], ["Rule get child", ["gateway", "rule", "get", "--gateway-id", GATEWAY_ID], /--rule-id/], - ["Rule list", ["gateway", "rule", "list"], /--gateway-id/], + ["Rule list", ["gateway", "rule", "list", "--max-results", "1"], /--gateway-id/], ] as const)( "rejects a missing selector for %s before calling Core", async (_name, args, error) => { diff --git a/src/handlers/gateway/get/screen.tsx b/src/handlers/gateway/get/screen.tsx new file mode 100644 index 000000000..1a456f462 --- /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 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 GatewayGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { gatewayId } = useParams(); + const detail = useGatewayDetail(props, gatewayId); + + return ( + + navigate(`/agentcore/gateway/get/${encodeURIComponent(gatewayId)}/json`), + }, + { + name: "targets", + description: "browse every Target", + onSelect: () => + navigate(`/agentcore/gateway/target/list/${encodeURIComponent(gatewayId)}`), + }, + { + name: "connectors", + description: "browse configured Connectors", + onSelect: () => + navigate(`/agentcore/gateway/connector/list/${encodeURIComponent(gatewayId)}`), + }, + { + name: "rules", + description: "browse routing Rules", + onSelect: () => + navigate(`/agentcore/gateway/rule/list/${encodeURIComponent(gatewayId)}`), + }, + ] + : [] + } + loadingLabel="Loading Gateway…" + onRetry={() => void detail.refetch()} + selectLabel="open" + /> + ); +} + +export function GatewayGetJsonScreen(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..6e4d9ae2b 100644 --- a/src/handlers/gateway/index.tsx +++ b/src/handlers/gateway/index.tsx @@ -1,6 +1,7 @@ +import { renderTui } from "../../tui"; import type { AppIO } from "../../io"; +import { withTuiOnEmptyFlagsAndArgs } from "../../middleware"; import { Router } from "../../router"; -import { createHelpDefault } from "../help"; import type { Core } from "../types"; import { createGatewayConnectorHandler } from "./connector"; import { createGetGatewayHandler } from "./get"; @@ -10,7 +11,8 @@ import { createGatewayTargetHandler } from "./target"; export function createGatewayHandler(core: Core, io: AppIO): Router { return new Router("gateway", "inspect AgentCore Gateways") - .default(createHelpDefault(io)) + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) .handler(createGetGatewayHandler(core)) .handler(createListGatewaysHandler(core)) .handler(createGatewayTargetHandler(core, io)) diff --git a/src/handlers/gateway/list/screen.tsx b/src/handlers/gateway/list/screen.tsx new file mode 100644 index 000000000..d80a5a596 --- /dev/null +++ b/src/handlers/gateway/list/screen.tsx @@ -0,0 +1,15 @@ +import { useNavigate } from "react-router"; +import { GatewayPicker } from "../../../components/GatewayPicker"; +import type { ScreenProps } from "../../types"; + +export function GatewayListScreen(props: ScreenProps) { + const navigate = useNavigate(); + + return ( + navigate(`/agentcore/gateway/get/${encodeURIComponent(gatewayId)}`)} + /> + ); +} diff --git a/src/handlers/gateway/rule/get/screen.tsx b/src/handlers/gateway/rule/get/screen.tsx new file mode 100644 index 000000000..27ce6fb24 --- /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 GatewayRuleGetScreen(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/index.tsx b/src/handlers/gateway/rule/index.tsx index 40ed75f68..3b97ad153 100644 --- a/src/handlers/gateway/rule/index.tsx +++ b/src/handlers/gateway/rule/index.tsx @@ -1,13 +1,13 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; -import { createHelpDefault } from "../../help"; +import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createGetGatewayRuleHandler } from "./get"; import { createListGatewayRulesHandler } from "./list"; export function createGatewayRuleHandler(core: Core, io: AppIO): Router { return new Router("rule", "inspect rules for an AgentCore Gateway") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetGatewayRuleHandler(core)) .handler(createListGatewayRulesHandler(core)); } diff --git a/src/handlers/gateway/rule/list/screen.tsx b/src/handlers/gateway/rule/list/screen.tsx new file mode 100644 index 000000000..54e287e58 --- /dev/null +++ b/src/handlers/gateway/rule/list/screen.tsx @@ -0,0 +1,80 @@ +import type { GatewayRuleDetail } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { GatewayPicker } from "../../../../components/GatewayPicker"; +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(); + + if (!gatewayId) { + return ( + navigate(`/agentcore/gateway/rule/list/${encodeURIComponent(id)}`)} + /> + ); + } + + 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/rule/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(ruleId)}`, + ) + } + onBack={() => navigate(-1)} + loadingMessage={`Loading Rules for Gateway ${gatewayId}…`} + errorMessage={(error) => `Error loading Rules for Gateway ${gatewayId}: ${error.message}`} + emptyMessage="This Gateway has no Rules." + emptyPageMessage={`No Rules on this page for Gateway ${gatewayId}.`} + /> + ); +} diff --git a/src/handlers/gateway/rule/screen.tsx b/src/handlers/gateway/rule/screen.tsx new file mode 100644 index 000000000..9055ab168 --- /dev/null +++ b/src/handlers/gateway/rule/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function GatewayRuleScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/gateway/screen.tsx b/src/handlers/gateway/screen.tsx new file mode 100644 index 000000000..74b3c65f0 --- /dev/null +++ b/src/handlers/gateway/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../components/RouterScreen"; +import type { ScreenProps } from "../types"; + +export function GatewayScreen(props: ScreenProps) { + return ; +} diff --git a/src/handlers/gateway/target/get/screen.tsx b/src/handlers/gateway/target/get/screen.tsx new file mode 100644 index 000000000..51b4fc764 --- /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 GatewayTargetGetScreen(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/index.tsx b/src/handlers/gateway/target/index.tsx index 96f05dee9..e4a121a34 100644 --- a/src/handlers/gateway/target/index.tsx +++ b/src/handlers/gateway/target/index.tsx @@ -1,13 +1,13 @@ import type { AppIO } from "../../../io"; import { Router } from "../../../router"; -import { createHelpDefault } from "../../help"; +import { renderTui } from "../../../tui"; import type { Core } from "../../types"; import { createGetGatewayTargetHandler } from "./get"; import { createListGatewayTargetsHandler } from "./list"; export function createGatewayTargetHandler(core: Core, io: AppIO): Router { return new Router("target", "inspect targets for an AgentCore Gateway") - .default(createHelpDefault(io)) + .default(renderTui(core, io)) .handler(createGetGatewayTargetHandler(core)) .handler(createListGatewayTargetsHandler(core)); } diff --git a/src/handlers/gateway/target/list/screen.tsx b/src/handlers/gateway/target/list/screen.tsx new file mode 100644 index 000000000..1c0289ebf --- /dev/null +++ b/src/handlers/gateway/target/list/screen.tsx @@ -0,0 +1,83 @@ +import type { TargetSummary } from "@aws-sdk/client-bedrock-agentcore-control"; +import { useNavigate, useParams } from "react-router"; +import { formatTimestamp } from "../../../../components/formatTimestamp"; +import { GatewayPicker } from "../../../../components/GatewayPicker"; +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(); + + if (!gatewayId) { + return ( + navigate(`/agentcore/gateway/target/list/${encodeURIComponent(id)}`)} + /> + ); + } + + 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/target/get/${encodeURIComponent(gatewayId)}/${encodeURIComponent(targetId)}`, + ) + } + onBack={() => navigate(-1)} + loadingMessage={`Loading Targets for Gateway ${gatewayId}…`} + errorMessage={(error) => `Error loading Targets for Gateway ${gatewayId}: ${error.message}`} + emptyMessage="This Gateway has no Targets." + emptyPageMessage={`No Targets on this page for Gateway ${gatewayId}.`} + /> + ); +} diff --git a/src/handlers/gateway/target/screen.tsx b/src/handlers/gateway/target/screen.tsx new file mode 100644 index 000000000..b923eafe1 --- /dev/null +++ b/src/handlers/gateway/target/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function GatewayTargetScreen(props: ScreenProps) { + return ; +}