Skip to content

Commit 723a7c1

Browse files
committed
feat: display gateway target sync status after deploy
Query ListGatewayTargets after successful deployment and display sync status for each target: - READY: ✓ synced - SYNCHRONIZING: ⟳ syncing... - FAILED: ✗ failed Integrated into both CLI and TUI deploy paths. TUI uses React state for proper rendering. API errors are non-blocking — deploy succeeds regardless of status query result.
1 parent 6af88f9 commit 723a7c1

5 files changed

Lines changed: 163 additions & 1 deletion

File tree

src/cli/commands/deploy/actions.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
synthesizeCdk,
1919
validateProject,
2020
} from '../../operations/deploy';
21+
import { formatTargetStatus, getGatewayTargetStatuses } from '../../operations/deploy/gateway-status';
2122
import type { DeployResult } from './types';
2223

2324
export interface ValidatedDeployOptions {
@@ -316,12 +317,20 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
316317
);
317318
await configIO.writeDeployedState(deployedState);
318319

319-
// Show gateway URLs if any were deployed
320+
// Show gateway URLs and target sync status
320321
if (Object.keys(gateways).length > 0) {
321322
const gatewayUrls = Object.entries(gateways)
322323
.map(([name, gateway]) => `${name}: ${gateway.gatewayArn}`)
323324
.join(', ');
324325
logger.log(`Gateway URLs: ${gatewayUrls}`);
326+
327+
// Query target sync statuses (non-blocking)
328+
for (const [, gateway] of Object.entries(gateways)) {
329+
const statuses = await getGatewayTargetStatuses(gateway.gatewayId, target.region);
330+
for (const targetStatus of statuses) {
331+
logger.log(` ${targetStatus.name}: ${formatTargetStatus(targetStatus.status)}`);
332+
}
333+
}
325334
}
326335

327336
endStep('success');
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import { formatTargetStatus, getGatewayTargetStatuses } from '../gateway-status.js';
2+
import { afterEach, describe, expect, it, vi } from 'vitest';
3+
4+
const { mockSend } = vi.hoisted(() => ({
5+
mockSend: vi.fn(),
6+
}));
7+
8+
vi.mock('@aws-sdk/client-bedrock-agentcore-control', () => ({
9+
BedrockAgentCoreControlClient: class {
10+
send = mockSend;
11+
},
12+
ListGatewayTargetsCommand: class {
13+
constructor(public input: unknown) {}
14+
},
15+
}));
16+
17+
describe('getGatewayTargetStatuses', () => {
18+
afterEach(() => vi.clearAllMocks());
19+
20+
it('returns statuses for all targets', async () => {
21+
mockSend.mockResolvedValue({
22+
items: [
23+
{ name: 'target-1', status: 'READY' },
24+
{ name: 'target-2', status: 'SYNCHRONIZING' },
25+
{ name: 'target-3', status: 'READY' },
26+
],
27+
});
28+
29+
const result = await getGatewayTargetStatuses('gw-123', 'us-east-1');
30+
31+
expect(result).toEqual([
32+
{ name: 'target-1', status: 'READY' },
33+
{ name: 'target-2', status: 'SYNCHRONIZING' },
34+
{ name: 'target-3', status: 'READY' },
35+
]);
36+
});
37+
38+
it('returns empty array on API error', async () => {
39+
mockSend.mockRejectedValue(new Error('Access denied'));
40+
41+
const result = await getGatewayTargetStatuses('gw-123', 'us-east-1');
42+
43+
expect(result).toEqual([]);
44+
});
45+
46+
it('returns empty array when no targets', async () => {
47+
mockSend.mockResolvedValue({ items: [] });
48+
49+
const result = await getGatewayTargetStatuses('gw-123', 'us-east-1');
50+
51+
expect(result).toEqual([]);
52+
});
53+
54+
it('handles undefined items', async () => {
55+
mockSend.mockResolvedValue({});
56+
57+
const result = await getGatewayTargetStatuses('gw-123', 'us-east-1');
58+
59+
expect(result).toEqual([]);
60+
});
61+
});
62+
63+
describe('formatTargetStatus', () => {
64+
it('formats READY', () => {
65+
expect(formatTargetStatus('READY')).toBe('✓ synced');
66+
});
67+
68+
it('formats SYNCHRONIZING', () => {
69+
expect(formatTargetStatus('SYNCHRONIZING')).toBe('⟳ syncing...');
70+
});
71+
72+
it('formats SYNCHRONIZE_UNSUCCESSFUL', () => {
73+
expect(formatTargetStatus('SYNCHRONIZE_UNSUCCESSFUL')).toBe('⚠ sync failed');
74+
});
75+
76+
it('formats FAILED', () => {
77+
expect(formatTargetStatus('FAILED')).toBe('✗ failed');
78+
});
79+
80+
it('returns raw status for unknown values', () => {
81+
expect(formatTargetStatus('UNKNOWN_STATUS')).toBe('UNKNOWN_STATUS');
82+
});
83+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* Query gateway target sync statuses after deployment.
3+
*/
4+
import { BedrockAgentCoreControlClient, ListGatewayTargetsCommand } from '@aws-sdk/client-bedrock-agentcore-control';
5+
6+
export interface TargetSyncStatus {
7+
name: string;
8+
status: string;
9+
}
10+
11+
const STATUS_DISPLAY: Record<string, string> = {
12+
READY: '✓ synced',
13+
SYNCHRONIZING: '⟳ syncing...',
14+
SYNCHRONIZE_UNSUCCESSFUL: '⚠ sync failed',
15+
CREATING: '⟳ creating...',
16+
UPDATING: '⟳ updating...',
17+
UPDATE_UNSUCCESSFUL: '⚠ update failed',
18+
FAILED: '✗ failed',
19+
DELETING: '⟳ deleting...',
20+
};
21+
22+
export function formatTargetStatus(status: string): string {
23+
return STATUS_DISPLAY[status] ?? status;
24+
}
25+
26+
/**
27+
* Get sync statuses for all targets in a gateway.
28+
* Returns empty array on error (non-blocking).
29+
*/
30+
export async function getGatewayTargetStatuses(gatewayId: string, region: string): Promise<TargetSyncStatus[]> {
31+
try {
32+
const client = new BedrockAgentCoreControlClient({ region });
33+
const response = await client.send(new ListGatewayTargetsCommand({ gatewayIdentifier: gatewayId, maxResults: 100 }));
34+
35+
return (response.items ?? []).map(target => ({
36+
name: target.name ?? 'unknown',
37+
status: target.status ?? 'UNKNOWN',
38+
}));
39+
} catch {
40+
return [];
41+
}
42+
}

src/cli/tui/screens/deploy/DeployScreen.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ConfigIO } from '../../../../lib';
22
import type { AgentCoreMcpSpec } from '../../../../schema';
3+
import { formatTargetStatus } from '../../../operations/deploy/gateway-status';
34
import {
45
AwsTargetConfigUI,
56
ConfirmPrompt,
@@ -58,6 +59,7 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p
5859
isComplete,
5960
hasStartedCfn,
6061
logFilePath,
62+
targetStatuses,
6163
missingCredentials,
6264
startDeploy,
6365
confirmTeardown,
@@ -279,6 +281,18 @@ export function DeployScreen({ isInteractive, onExit, autoConfirm, onNavigate, p
279281
</Box>
280282
)}
281283

284+
{allSuccess && targetStatuses.length > 0 && (
285+
<Box flexDirection="column" marginTop={1}>
286+
<Text bold>Gateway Targets:</Text>
287+
{targetStatuses.map(t => (
288+
<Text key={t.name}>
289+
{' '}
290+
{t.name}: {formatTargetStatus(t.status)}
291+
</Text>
292+
))}
293+
</Box>
294+
)}
295+
282296
{logFilePath && (
283297
<Box marginTop={1}>
284298
<LogLink filePath={logFilePath} />

src/cli/tui/screens/deploy/useDeployFlow.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { buildDeployedState, getStackOutputs, parseAgentOutputs, parseGatewayOut
44
import { getErrorMessage, isChangesetInProgressError, isExpiredTokenError } from '../../../errors';
55
import { ExecLogger } from '../../../logging';
66
import { performStackTeardown } from '../../../operations/deploy';
7+
import { getGatewayTargetStatuses } from '../../../operations/deploy/gateway-status';
78
import { type Step, areStepsComplete, hasStepError } from '../../components';
89
import { type MissingCredential, type PreflightContext, useCdkPreflight } from '../../hooks';
910
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
@@ -45,6 +46,7 @@ interface DeployFlowState {
4546
deployOutput: string | null;
4647
deployMessages: DeployMessage[];
4748
stackOutputs: Record<string, string>;
49+
targetStatuses: { name: string; status: string }[];
4850
hasError: boolean;
4951
/** True if the error is specifically due to expired/invalid AWS credentials */
5052
hasTokenExpiredError: boolean;
@@ -96,6 +98,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState
9698
const [deployOutput, setDeployOutput] = useState<string | null>(null);
9799
const [deployMessages, setDeployMessages] = useState<DeployMessage[]>([]);
98100
const [stackOutputs, setStackOutputs] = useState<Record<string, string>>({});
101+
const [targetStatuses, setTargetStatuses] = useState<{ name: string; status: string }[]>([]);
99102
const [shouldStartDeploy, setShouldStartDeploy] = useState(false);
100103
const [hasTokenExpiredError, setHasTokenExpiredError] = useState(false);
101104
// Track if CloudFormation has started (received first resource event)
@@ -196,6 +199,16 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState
196199
Object.keys(oauthCredentials).length > 0 ? oauthCredentials : undefined
197200
);
198201
await configIO.writeDeployedState(deployedState);
202+
203+
// Query gateway target sync statuses (non-blocking)
204+
const allStatuses: { name: string; status: string }[] = [];
205+
for (const [, gateway] of Object.entries(gateways)) {
206+
const statuses = await getGatewayTargetStatuses(gateway.gatewayId, target.region);
207+
allStatuses.push(...statuses);
208+
}
209+
if (allStatuses.length > 0) {
210+
setTargetStatuses(allStatuses);
211+
}
199212
}, [context, stackNames, logger, identityKmsKeyArn, oauthCredentials]);
200213

201214
// Start deploy when preflight completes OR when shouldStartDeploy is set
@@ -400,6 +413,7 @@ export function useDeployFlow(options: DeployFlowOptions = {}): DeployFlowState
400413
deployOutput,
401414
deployMessages,
402415
stackOutputs,
416+
targetStatuses,
403417
hasError,
404418
hasTokenExpiredError: combinedTokenExpiredError,
405419
hasCredentialsError: preflight.hasCredentialsError,

0 commit comments

Comments
 (0)