diff --git a/src/components/OnlineEvalPicker.tsx b/src/components/OnlineEvalPicker.tsx index df8141c15..d83711ac7 100644 --- a/src/components/OnlineEvalPicker.tsx +++ b/src/components/OnlineEvalPicker.tsx @@ -9,8 +9,8 @@ import type { DataTableColumn } from "./ui/data-table"; // OnlineEvalRow is the flat, display-ready shape the table renders. It also // satisfies DataTable's `T extends Record` constraint, which the // SDK's OnlineEvaluationConfigSummary interface does not. The list API returns -// only summary fields (name/status/executionStatus/timestamps); richer detail -// like sampling rate and evaluators comes from GetOnlineEvaluationConfig. +// only summary fields (name/status/executionStatus/timestamps); richer +// detail like sampling rate and evaluators comes from GetOnlineEvaluationConfig. interface OnlineEvalRow extends Record { configId: string; configName: string; diff --git a/src/components/OnlineInsightPicker.tsx b/src/components/OnlineInsightPicker.tsx new file mode 100644 index 000000000..e14fe9208 --- /dev/null +++ b/src/components/OnlineInsightPicker.tsx @@ -0,0 +1,93 @@ +import type { OnlineEvaluationConfigSummary } 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"; + +// OnlineInsightRow is the flat, display-ready shape the table renders. It also +// satisfies DataTable's `T extends Record` constraint, which the +// SDK's OnlineEvaluationConfigSummary interface does not. ListOnlineInsights +// returns the same summary type as the online-eval list; richer detail like +// sampling rate and clustering comes from GetOnlineInsight. +interface OnlineInsightRow extends Record { + configId: string; + configName: string; + status: string; + executionStatus: string; + updatedAt: string; +} + +const onlineInsightColumns = [ + { key: "configName", header: "name", flex: true }, + { key: "status", header: "status", width: 12 }, + { key: "executionStatus", header: "execution", width: 11 }, + { + key: "updatedAt", + header: "updated UTC", + width: 16, + render: formatTimestamp, + }, +] satisfies DataTableColumn[]; + +function toRow(config: OnlineEvaluationConfigSummary): OnlineInsightRow { + const id = config.onlineEvaluationConfigId ?? ""; + return { + configId: id, + configName: config.onlineEvaluationConfigName ?? id, + status: config.status ?? "-", + executionStatus: config.executionStatus ?? "-", + updatedAt: config.updatedAt?.toISOString() ?? "-", + }; +} + +export interface OnlineInsightPickerProps extends ScreenProps { + breadcrumb: string[]; + description?: string; + onSelect: (configId: string) => void; + onEscape?: () => void; +} + +/** + * Fetches the caller's online insight configs and renders them as a navigable + * table. The shared body of every "pick an insight config" screen. Esc returns to + * the parent menu derived from the breadcrumb unless a host supplies its own + * onEscape. + */ +export function OnlineInsightPicker({ + ctx, + core, + breadcrumb, + description, + onSelect, + onEscape, +}: OnlineInsightPickerProps) { + const opts = coreOptsFromCtx(ctx); + const navigate = useNavigate(); + const goBack = onEscape ?? (() => navigate("/" + breadcrumb.slice(0, -1).join("/"))); + + return ( + { + const response = await core.eval.listOnlineInsights(token, pageSize, opts); + return { + items: response.onlineEvaluationConfigs ?? [], + nextToken: response.nextToken, + }; + }} + toRow={toRow} + columns={onlineInsightColumns} + getValue={(row) => row.configId} + onSelect={onSelect} + onBack={goBack} + loadingMessage="Loading online insight configs…" + errorMessage={(error) => `Error: ${error.message}`} + emptyMessage="No online insight configs found in this Region." + emptyPageMessage="No online insight configs on this page." + /> + ); +} diff --git a/src/components/Root.tsx b/src/components/Root.tsx index da3ce473f..2c87399d9 100644 --- a/src/components/Root.tsx +++ b/src/components/Root.tsx @@ -48,6 +48,12 @@ import { OnlineEvalGetScreen, OnlineEvalGetJsonScreen, } from "../handlers/eval/online-eval/get/screen.tsx"; +import { OnlineInsightScreen } from "../handlers/eval/online-insight/screen.tsx"; +import { OnlineInsightListScreen } from "../handlers/eval/online-insight/list/screen.tsx"; +import { + OnlineInsightGetScreen, + OnlineInsightGetJsonScreen, +} from "../handlers/eval/online-insight/get/screen.tsx"; import { BatchEvaluationScreen } from "../handlers/eval/batch-evaluation/screen.tsx"; import { BatchEvaluationListScreen } from "../handlers/eval/batch-evaluation/list/screen.tsx"; import { BatchEvaluationGetJsonScreen } from "../handlers/eval/batch-evaluation/get/screen.tsx"; @@ -488,6 +494,26 @@ export function Root({ path, ctx, core, queryClient }: RootProps) { path="agentcore/eval/online-eval/get/:configId/json" element={} /> + } + /> + } + /> + } + /> + } + /> + } + /> } /> core.eval.getOnlineInsight(configId!, opts), + enabled: configId !== undefined, + }); +} + +export function OnlineInsightGetScreen(props: ScreenProps) { + const navigate = useNavigate(); + const { configId } = useParams(); + const detail = useOnlineInsightDetail(props, configId); + const config = detail.data; + const samplingPercentage = config?.rule?.samplingConfig?.samplingPercentage; + const insightCount = config?.insights?.length ?? 0; + const frequencies = config?.clusteringConfig?.frequencies ?? []; + + return ( + 0 ? insightCount.toString() : "-", + clustering: frequencies.length > 0 ? frequencies.join(", ") : "-", + ...(config?.failureReason ? { failureReason: config.failureReason } : {}), + role: config?.evaluationExecutionRoleArn ?? "-", + }} + actions={ + configId && config + ? [ + { + name: "detail", + description: "show the full JSON (insights, clustering, filters, data source)", + onSelect: () => + navigate( + `/agentcore/eval/online-insight/get/${encodeURIComponent(configId)}/json`, + ), + }, + ] + : [] + } + loadingLabel="Loading online insight config…" + onRetry={() => void detail.refetch()} + selectLabel="open detail" + /> + ); +} + +export function OnlineInsightGetJsonScreen(props: ScreenProps) { + const { configId } = useParams(); + const detail = useOnlineInsightDetail(props, configId); + + return ( + void detail.refetch()} + /> + ); +} diff --git a/src/handlers/eval/online-insight/index.tsx b/src/handlers/eval/online-insight/index.tsx index e41f52aad..b54329b49 100644 --- a/src/handlers/eval/online-insight/index.tsx +++ b/src/handlers/eval/online-insight/index.tsx @@ -1,4 +1,6 @@ import { Router } from "../../../router"; +import { renderTui } from "../../../tui"; +import { withTuiOnEmptyFlagsAndArgs } from "../../../middleware"; import type { AppIO } from "../../../io"; import type { Core } from "../../types"; import { createCreateOnlineInsightHandler } from "./create"; @@ -11,7 +13,9 @@ import { createDeleteOnlineInsightHandler } from "./delete"; export function createOnlineInsightHandler(core: Core, io: AppIO): Router { return new Router("online-insight", "manage AgentCore online insight configs") - .supportedTuiCommands() + .use(withTuiOnEmptyFlagsAndArgs(core, io)) + .default(renderTui(core, io)) + .supportedTuiCommands("get", "list") .handler(createCreateOnlineInsightHandler(core, io)) .handler(createGetOnlineInsightHandler(core)) .handler(createListOnlineInsightHandler(core)) @@ -20,3 +24,5 @@ export function createOnlineInsightHandler(core: Core, io: AppIO): Router { .handler(createResumeOnlineInsightHandler(core)) .handler(createDeleteOnlineInsightHandler(core)); } + +export { OnlineInsightScreen } from "./screen.tsx"; diff --git a/src/handlers/eval/online-insight/list/screen.tsx b/src/handlers/eval/online-insight/list/screen.tsx new file mode 100644 index 000000000..b65e370d2 --- /dev/null +++ b/src/handlers/eval/online-insight/list/screen.tsx @@ -0,0 +1,17 @@ +import { useNavigate } from "react-router"; +import { OnlineInsightPicker } from "../../../../components/OnlineInsightPicker"; +import type { ScreenProps } from "../../../types"; + +export function OnlineInsightListScreen(props: ScreenProps) { + const navigate = useNavigate(); + + return ( + + navigate(`/agentcore/eval/online-insight/get/${encodeURIComponent(configId)}`) + } + /> + ); +} diff --git a/src/handlers/eval/online-insight/online-insight.screen.test.tsx b/src/handlers/eval/online-insight/online-insight.screen.test.tsx new file mode 100644 index 000000000..50865ae12 --- /dev/null +++ b/src/handlers/eval/online-insight/online-insight.screen.test.tsx @@ -0,0 +1,194 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { + GetOnlineEvaluationConfigResponse, + OnlineEvaluationConfigSummary, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import { + cleanupScreens, + renderScreen, + TestCoreClient, + waitFor, + waitForText, +} from "../../../testing"; + +afterEach(cleanupScreens); + +const evalEndpointUrl = "https://eval.test"; + +function configSummary( + overrides: Partial = {}, +): OnlineEvaluationConfigSummary { + return { + onlineEvaluationConfigArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/oic-1", + onlineEvaluationConfigId: "oic-1", + onlineEvaluationConfigName: "prod_failure_insights", + status: "ACTIVE", + executionStatus: "ENABLED", + insights: [{ insightId: "ins-failure" }], + createdAt: new Date("2026-07-19T01:02:03.000Z"), + updatedAt: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function getConfigResponse( + overrides: Partial = {}, +): GetOnlineEvaluationConfigResponse { + return { + onlineEvaluationConfigArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:online-evaluation-config/oic-1", + onlineEvaluationConfigId: "oic-1", + onlineEvaluationConfigName: "prod_failure_insights", + status: "ACTIVE", + executionStatus: "ENABLED", + rule: { samplingConfig: { samplingPercentage: 5 } }, + dataSourceConfig: { + cloudWatchLogs: { logGroupNames: ["/aws/bedrock-agentcore/runtime/x"], serviceNames: [] }, + }, + insights: [{ insightId: "ins-failure" }, { insightId: "ins-intent" }], + clusteringConfig: { frequencies: ["DAILY", "WEEKLY"] }, + evaluationExecutionRoleArn: "arn:aws:iam::123456789012:role/online-insight-role", + createdAt: new Date("2026-07-19T01:02:03.000Z"), + updatedAt: new Date("2026-07-20T12:34:56.000Z"), + ...overrides, + }; +} + +function coreWithConfigs(configs: OnlineEvaluationConfigSummary[]): TestCoreClient { + const core = new TestCoreClient(); + core.eval.setOnlineEvalListResponse({ onlineEvaluationConfigs: configs }); + return core; +} + +describe("online-insight menu", () => { + test("offers only the read-only commands", async () => { + const screen = renderScreen("/agentcore/eval/online-insight"); + + await waitForText(screen.lastFrame, "get an online insight config by id"); + const frame = screen.lastFrame()!; + expect(frame).toContain("list"); + expect(frame).not.toContain("create"); + expect(frame).not.toContain("update"); + expect(frame).not.toContain("pause"); + expect(frame).not.toContain("resume"); + expect(frame).not.toContain("delete"); + }); +}); + +describe("online-insight picker", () => { + test("renders name, execution status, and update time", async () => { + const core = coreWithConfigs([ + configSummary({ + onlineEvaluationConfigName: "staging_intent_insights", + executionStatus: "DISABLED", + updatedAt: new Date("2026-07-21T02:03:04.000Z"), + }), + ]); + const screen = renderScreen("/agentcore/eval/online-insight/list", { core }); + + await waitForText(screen.lastFrame, "staging_intent_insights"); + const frame = screen.lastFrame()!; + expect(frame).toContain("DISABLED"); + expect(frame).toContain("2026-07-21 02:03"); + }); + + test("calls listOnlineInsights with exact Core options", async () => { + const core = coreWithConfigs([configSummary()]); + renderScreen("/agentcore/eval/online-insight/list", { core, endpointUrl: evalEndpointUrl }); + + await waitFor(() => core.eval.calls.some((call) => call.method === "listOnlineInsights")); + expect(core.eval.calls.filter((call) => call.method === "listOnlineInsights")).toEqual([ + { + method: "listOnlineInsights", + args: [ + undefined, + expect.any(Number), + { region: "us-east-1", endpointUrl: evalEndpointUrl }, + ], + }, + ]); + }); + + test("bare online-insight get redirects to the picker", async () => { + const core = coreWithConfigs([ + configSummary({ + onlineEvaluationConfigId: "redirected-oic", + onlineEvaluationConfigName: "redirected_insight", + }), + ]); + const screen = renderScreen("/agentcore/eval/online-insight/get", { core }); + + await waitForText(screen.lastFrame, "redirected_insight"); + expect(core.eval.calls[0]?.method).toBe("listOnlineInsights"); + }); + + test("selection opens the matching config detail", async () => { + const core = coreWithConfigs([configSummary({ onlineEvaluationConfigId: "oic-1" })]); + core.eval.setOnlineEvalGetResponse(getConfigResponse({ onlineEvaluationConfigId: "oic-1" })); + const screen = renderScreen("/agentcore/eval/online-insight/list", { core }); + + await waitForText(screen.lastFrame, "prod_failure_insights"); + await screen.press("return"); + await waitForText(screen.lastFrame, "agentcore → eval → online-insight → get → oic-1"); + await waitFor(() => + core.eval.calls.some( + (call) => call.method === "getOnlineInsight" && call.args[0] === "oic-1", + ), + ); + }); + + test("shows the empty state", async () => { + const empty = renderScreen("/agentcore/eval/online-insight/list"); + await waitForText(empty.lastFrame, "No online insight configs found in this Region."); + }); +}); + +describe("online-insight detail", () => { + test("renders sampling, execution status, insight count, and clustering frequencies", async () => { + const core = new TestCoreClient(); + core.eval.setOnlineEvalGetResponse(getConfigResponse()); + const screen = renderScreen("/agentcore/eval/online-insight/get/oic-1", { + core, + endpointUrl: evalEndpointUrl, + }); + + await waitForText(screen.lastFrame, "show the full JSON"); + const frame = screen.lastFrame()!; + expect(frame).toContain("prod_failure_insights"); + expect(frame).toMatch(/sampling\s+5%/); + expect(frame).toMatch(/execution\s+ENABLED/); + expect(frame).toMatch(/insights\s+2/); + expect(frame).toContain("DAILY, WEEKLY"); + expect(frame).not.toContain("evaluators"); + expect(core.eval.calls.find((call) => call.method === "getOnlineInsight")).toEqual({ + method: "getOnlineInsight", + args: ["oic-1", { region: "us-east-1", endpointUrl: evalEndpointUrl }], + }); + }); + + test("opens the complete config JSON", async () => { + const core = new TestCoreClient(); + core.eval.setOnlineEvalGetResponse(getConfigResponse()); + const screen = renderScreen("/agentcore/eval/online-insight/get/oic-1", { core }); + + await waitForText(screen.lastFrame, "show the full JSON"); + await screen.press("return"); + await waitForText(screen.lastFrame, "agentcore → eval → online-insight → get → oic-1 → json"); + expect(screen.lastFrame()).toContain('"clusteringConfig"'); + }); + + test("retries a failed detail query", async () => { + const core = new TestCoreClient(); + core.eval.setError(new Error("insight unavailable")); + const screen = renderScreen("/agentcore/eval/online-insight/get/oic-1", { core }); + + await waitForText(screen.lastFrame, "insight unavailable"); + expect(screen.lastFrame()).toContain("[r] retry"); + + core.eval.setError(undefined); + core.eval.setOnlineEvalGetResponse(getConfigResponse()); + await screen.write("r"); + await waitForText(screen.lastFrame, "show the full JSON"); + }); +}); diff --git a/src/handlers/eval/online-insight/screen.tsx b/src/handlers/eval/online-insight/screen.tsx new file mode 100644 index 000000000..68277b04e --- /dev/null +++ b/src/handlers/eval/online-insight/screen.tsx @@ -0,0 +1,6 @@ +import { RouterScreen } from "../../../components/RouterScreen"; +import type { ScreenProps } from "../../types"; + +export function OnlineInsightScreen(props: ScreenProps) { + return ; +}