Compare commits

...

4 Commits

Author SHA1 Message Date
Zakaria bb5ca85a88 feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page
CI / checks (pull_request) Successful in 26m49s
API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs
made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page
now lists all three kinds with a Type column, creates each from the same form,
and opens HTTP and WebSocket APIs in their own panel:

- Quick create: an HTTP API with a Lambda gets a catch-all route on an
  auto-deployed $default stage, plus the Lambda permission.
- Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes
  the integration and Lambda permission only it used.
- Stages with or without automatic deployment, and deploy on demand.
- JWT authorizers filled in from a Cognito pool's OpenID Connect discovery
  document and an app client, or from any issuer; protect routes with them.
- CORS configuration.
- A tester that sends a real request to a stage, and for WebSocket APIs one
  that connects, sends a message and collects the replies.

Capabilities are advertised under a new httpApiActions group, covered by the
cloudProxy guard test. Verified end to end against TheCloud's runtime.
2026-10-25 09:18:50 -04:00
Zakaria 6acd3656a1 Merge pull request 'feat(api): add CloudWatch alarms and metric charts to the cloud explorer' (#19) from feat/cloudwatch into main
CI / checks (push) Successful in 26m58s
Reviewed-on: #19
2026-10-25 03:08:02 +00:00
Zakaria 6dd33b8f65 feat(api): add CloudWatch alarms and metric charts to the cloud explorer
CI / checks (pull_request) Successful in 27m29s
2026-10-24 22:48:41 -04:00
Zakaria f4b3e45f3d Merge pull request 'chore: show TheCloud instead of Floci in console text and messages' (#18) from chore/rebrand-thecloud into main
CI / checks (push) Successful in 25m42s
Reviewed-on: #18
2026-10-24 18:32:47 +00:00
38 changed files with 3960 additions and 68 deletions
+9 -4
View File
@@ -97,6 +97,7 @@ cd packages/api && bun run scripts/service-matrix.ts
| Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) |
| Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
| Security | Parameter Store | Yes (list, create, delete, inspect) | No | No |
| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No |
| Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
Console Home is available for all three clouds.
@@ -258,14 +259,18 @@ Current gaps:
<details>
<summary><strong>API Gateway</strong></summary>
AWS only, through the generic apigateway service category.
AWS only, through the generic apigateway service category. REST APIs (v1) and HTTP and WebSocket APIs (v2) are listed together, with a Type column.
- List and inspect REST APIs.
- Create and delete REST APIs.
- REST APIs: create, delete and inspect; add Lambda proxy routes on a resource tree, enable CORS, test a method with TestInvokeMethod, and send a request to a deployed stage.
- HTTP APIs: quick-create with a Lambda (a catch-all route on an auto-deployed `$default` stage); add routes such as `GET /notes/{id}` to a Lambda or an HTTP backend; add stages with or without automatic deployment and deploy them; add JWT authorizers, filled in from a Cognito user pool and app client or from any OpenID Connect issuer, and protect routes with them; configure CORS; send a real request to a stage through its routes and authorizers.
- WebSocket APIs: create with a route selection expression and a stage; add `$connect`, `$disconnect`, `$default` and action routes to Lambda, optionally sending the function's response back; connect to a stage, send a message and read the replies.
Verified against TheCloud's runtime: quick create, Lambda and HTTP routes, a Cognito JWT authorizer (401 without a token, the token's claims reach the Lambda with one), CORS preflight, a stage deployed on demand, and a WebSocket round trip.
Current gaps:
- Resources, methods, deployments, and stages are not yet exposed.
- WebSocket APIs: Lambda (REQUEST) authorizers, `@connections` messages pushed from a backend, and route request and response models are not exposed.
- Custom domain names, API mappings, VPC links, usage plans and API keys are not exposed.
- No Azure or GCP API Gateway adapter yet.
</details>
+1
View File
@@ -11,6 +11,7 @@
},
"dependencies": {
"@aws-sdk/client-api-gateway": "^3.1076.0",
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
"@aws-sdk/client-appconfig": "^3.1076.0",
"@aws-sdk/client-cloudformation": "^3.1111.0",
"@aws-sdk/client-cloudfront": "^3.1146.0",
@@ -18,6 +18,7 @@ import {
type APIGatewayClient,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {GetApisCommand, type ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
@@ -32,6 +33,18 @@ function fakeLambda(handler: (command: unknown) => unknown): LambdaClient {
} as unknown as LambdaClient
}
/** An API Gateway v2 runtime with no HTTP or WebSocket APIs, for tests about REST APIs. */
const noHttpApis = {
send: async (command: unknown) => {
if (command instanceof GetApisCommand) return {Items: []}
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
},
} as unknown as ApiGatewayV2Client
function restOnlyAdapter(client: APIGatewayClient) {
return new AwsApiGatewayAdapter(client, fakeLambda(() => ({})), '000000000000', 'us-east-1', noHttpApis)
}
const orders = {
id: 'a1b2c3',
name: 'orders-api',
@@ -44,7 +57,7 @@ const orders = {
describe('AwsApiGatewayAdapter', () => {
test('lists every page and maps REST APIs to normalized resources', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
const adapter = restOnlyAdapter(fakeClient((command) => {
if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command')
if (!command.input.position) return {items: [orders], position: 'next'}
return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]}
@@ -70,7 +83,7 @@ describe('AwsApiGatewayAdapter', () => {
})
test('filters APIs by search term', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({
const adapter = restOnlyAdapter(fakeClient(() => ({
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
})))
@@ -92,7 +105,7 @@ describe('AwsApiGatewayAdapter', () => {
})
test('returns null when API Gateway reports a missing REST API', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
const adapter = restOnlyAdapter(fakeClient(() => {
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
}))
@@ -123,7 +136,7 @@ describe('AwsApiGatewayAdapter', () => {
})
test('deletes the requested REST API', async () => {
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
const adapter = restOnlyAdapter(fakeClient((command) => {
expect(command).toBeInstanceOf(DeleteRestApiCommand)
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
return {}
@@ -22,10 +22,13 @@ import {
type RestApi,
} from '@aws-sdk/client-api-gateway'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
import type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws'
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
import {ValidationError} from '../cloud-spi/errors'
import {awsEndpoint} from '../service/runtimeProbe'
import {AwsHttpApiOperations} from './AwsHttpApiOperations'
import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} from '../cloud-spi/types'
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
@@ -37,7 +40,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
private readonly lambda: LambdaClient = defaultLambda,
private readonly accountId = defaultAccountId(),
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
) {}
apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2,
) {
this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region)
}
/** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */
private readonly httpApis: AwsHttpApiOperations
schema(): ServiceSchema {
return awsApiGatewaySchema()
@@ -53,7 +62,17 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
position = res.position
} while (position)
return filterBySearch(apis.map(toResource), query.search)
return filterBySearch([...apis.map(toResource), ...await this.listHttpApis()], query.search)
}
/** A runtime without API Gateway v2 still lists its REST APIs. */
private async listHttpApis(): Promise<CloudResource[]> {
try {
return await this.httpApis.list()
} catch (error) {
if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return []
throw error
}
}
async get(id: string): Promise<CloudResource | null> {
@@ -61,12 +80,15 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
return toResource(res)
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
if (!hasHttpStatus(error, 404)) throw error
}
return this.httpApis.get(id)
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const protocol = stringValue(input.values.protocol) || 'REST'
if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol)
if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET')
const name = stringValue(input.values.name)
const description = stringValue(input.values.description)
if (!name) throw new ValidationError('name is required')
@@ -79,9 +101,54 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
}
async delete(id: string): Promise<void> {
if (await this.httpApis.get(id)) return this.httpApis.delete(id)
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
}
describeHttpApi(apiId: string): Promise<HttpApiOverview> {
return this.httpApis.describe(apiId)
}
createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
return this.httpApis.createRoute(apiId, input)
}
deleteHttpApiRoute(apiId: string, routeId: string): Promise<void> {
return this.httpApis.deleteRoute(apiId, routeId)
}
setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId)
}
createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
return this.httpApis.createStage(apiId, input)
}
deleteHttpApiStage(apiId: string, stageName: string): Promise<void> {
return this.httpApis.deleteStage(apiId, stageName)
}
deployHttpApi(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
return this.httpApis.deploy(apiId, stageName)
}
createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
return this.httpApis.createAuthorizer(apiId, input)
}
deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise<void> {
return this.httpApis.deleteAuthorizer(apiId, authorizerId)
}
updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
return this.httpApis.updateCors(apiId, cors)
}
testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
return this.httpApis.testWebSocket(apiId, input)
}
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
const method = input.method.toUpperCase()
const stage = stageName(input.stage)
@@ -470,7 +537,8 @@ function childPath(parentPath: string, pathPart: string): string {
function stageName(stage: string): string {
const value = stringValue(stage)
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
// $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API.
if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) {
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
}
return value
@@ -518,6 +586,7 @@ function toResource(api: RestApi): CloudResource {
metadata: {
provider: 'aws',
apigatewayService: 'rest',
protocol: 'REST',
description: api.description,
version: api.version,
endpointTypes: api.endpointConfiguration?.types,
@@ -0,0 +1,326 @@
import {afterEach, describe, expect, test} from 'bun:test'
import {
CreateApiCommand,
CreateAuthorizerCommand,
CreateDeploymentCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateRouteResponseCommand,
CreateStageCommand,
DeleteApiCommand,
DeleteCorsConfigurationCommand,
DeleteIntegrationCommand,
DeleteRouteCommand,
GetApiCommand,
GetApisCommand,
GetAuthorizersCommand,
GetIntegrationsCommand,
GetRoutesCommand,
GetStageCommand,
GetStagesCommand,
UpdateApiCommand,
UpdateRouteCommand,
type ApiGatewayV2Client,
} from '@aws-sdk/client-apigatewayv2'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations'
import {ValidationError} from '../cloud-spi/errors'
const CREATED = new Date('2026-10-25T10:00:00Z')
const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED}
const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED}
const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes'
interface Runtime {
apis?: Record<string, unknown>
routes?: unknown[]
integrations?: unknown[]
stages?: unknown[]
authorizers?: unknown[]
reply?: (command: object) => unknown
}
function stubRuntime(runtime: Runtime = {}) {
const sent: object[] = []
const lambdaSent: object[] = []
const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi}
const client = {
async send(command: object) {
sent.push(command)
const custom = runtime.reply?.(command)
if (custom !== undefined) return custom
if (command instanceof GetApisCommand) return {Items: Object.values(apis)}
if (command instanceof GetApiCommand) {
const api = apis[command.input.ApiId!]
if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
return api
}
if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []}
if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []}
if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []}
if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []}
if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input}
if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input}
if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input}
if (command instanceof CreateStageCommand) return {...command.input}
return {}
},
} as unknown as ApiGatewayV2Client
const lambda = {
async send(command: object) {
lambdaSent.push(command)
if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}}
return {}
},
} as unknown as LambdaClient
const discoveries: string[] = []
const fetchJson = async (url: string) => {
discoveries.push(url)
return {issuer: 'http://floci:4566/us-east-1_pool1'}
}
const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson)
const of = <T extends object>(type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[]
const lambdaOf = <T extends object>(type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[]
return {operations, sent, of, lambdaOf, discoveries}
}
describe('AwsHttpApiOperations APIs', () => {
test('lists HTTP and WebSocket APIs with their protocol', async () => {
const {operations} = stubRuntime()
const apis = await operations.list()
expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([
['h1', 'http-api', 'HTTP', 'http'],
['w1', 'websocket-api', 'WEBSOCKET', 'websocket'],
])
expect(await operations.get('rest-id')).toBeNull()
})
test('quick-creates an HTTP API that sends every request to a Lambda', async () => {
const {operations, of, lambdaOf} = stubRuntime()
await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP')
expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn})
expect(of(CreateStageCommand)).toHaveLength(0)
expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({
FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*',
})
})
test('gives an HTTP API without a target an auto-deployed $default stage', async () => {
const {operations, of} = stubRuntime()
await operations.create({name: 'empty'}, 'HTTP')
expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true})
})
test('creates a WebSocket API with its route selection expression and a stage', async () => {
const {operations, of} = stubRuntime()
await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET')
expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'})
expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true})
await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/)
})
test('deletes an API and the Lambda permissions it granted', async () => {
const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]})
await operations.delete('h1')
expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'})
expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'})
})
})
describe('AwsHttpApiOperations routes', () => {
test('describes routes with their integration, stages, authorizers and CORS', async () => {
const {operations} = stubRuntime({
apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}},
routes: [
{RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
{RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'},
],
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}],
stages: [{StageName: '$default', AutoDeploy: true}],
authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}],
})
const overview = await operations.describe('h1')
expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([
['GET /notes', 'notes', null],
['POST /notes', 'notes', 'a1'],
])
expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}])
expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']})
expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300})
})
test('adds a Lambda route, reusing the integration the function already has', async () => {
const {operations, of, lambdaOf} = stubRuntime({
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}],
})
const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'})
expect(of(CreateIntegrationCommand)).toHaveLength(0)
expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'})
expect(lambdaOf(AddPermissionCommand)).toHaveLength(1)
expect(route.integration?.functionName).toBe('notes')
})
test('adds an HTTP proxy route with a payload 1.0 integration', async () => {
const {operations, of} = stubRuntime()
await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'})
expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'})
})
test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => {
const {operations, of} = stubRuntime()
await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true})
expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`)
expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default')
expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'})
})
test('checks a route before creating anything', async () => {
const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]})
const before = sent.length
await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/)
await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/)
await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError)
await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/)
await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/)
expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false)
})
test('deleting a route removes the integration and permission only it used', async () => {
const integrations = [
{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn},
{IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'},
]
const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations})
await shared.operations.deleteRoute('h1', 'r1')
expect(shared.of(DeleteRouteCommand)).toHaveLength(1)
expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0)
const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations})
await last.operations.deleteRoute('h1', 'r1')
expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'})
expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes')
})
test('protects a route with an authorizer and removes it again', async () => {
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined})
expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1')
expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull()
expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined},
])
})
})
describe('AwsHttpApiOperations stages, authorizers and CORS', () => {
test('a stage without auto-deploy is deployed once when created', async () => {
const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined})
const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false})
expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'})
expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'})
await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/)
})
test('fills a JWT authorizer from a Cognito pool discovery document', async () => {
const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined})
const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'})
expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration')
expect(of(CreateAuthorizerCommand)[0].input).toEqual({
ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'],
JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']},
})
expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1')
await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/)
await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/)
await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/)
})
test('will not delete an authorizer that still protects a route', async () => {
const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]})
await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first')
})
test('sets and removes the CORS configuration', async () => {
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined})
const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true}
expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']})
expect(await operations.updateCors('h1', null)).toBeNull()
expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1)
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/)
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/)
await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/)
})
})
describe('AwsHttpApiOperations WebSocket test', () => {
const endpoint = process.env.FLOCI_ENDPOINT
afterEach(() => {
if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT
else process.env.FLOCI_ENDPOINT = endpoint
})
test('connects to the stage, sends the message and collects the replies', async () => {
const paths: string[] = []
const server = Bun.serve({
port: 0,
fetch(request, upgrade) {
paths.push(new URL(request.url).pathname + new URL(request.url).search)
return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400})
},
websocket: {message(socket, message) { socket.send(`echo ${message}`) }},
})
process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}`
try {
const {operations} = stubRuntime()
const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'})
expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null})
expect(paths).toEqual(['/ws/w1/dev?token=abc'])
} finally {
server.stop(true)
}
})
test('reports a refused connection', async () => {
process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1'
const {operations} = stubRuntime()
const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000)
expect(result.connected).toBe(false)
expect(result.error).toBeTruthy()
await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/)
})
})
describe('route keys', () => {
test('HTTP route keys are $default or a method and a path', () => {
expect(httpRouteKey('$default')).toBe('$default')
expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}')
expect(httpRouteKey('GET /')).toBe('GET /')
for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) {
expect(() => httpRouteKey(bad), bad).toThrow(ValidationError)
}
})
test('WebSocket route keys are the system routes or an action name', () => {
expect(webSocketRouteKey('$connect')).toBe('$connect')
expect(webSocketRouteKey('sendMessage')).toBe('sendMessage')
expect(() => webSocketRouteKey('$other')).toThrow(ValidationError)
expect(() => webSocketRouteKey('send message')).toThrow(ValidationError)
})
})
@@ -0,0 +1,592 @@
import {
CreateApiCommand,
CreateAuthorizerCommand,
CreateDeploymentCommand,
CreateIntegrationCommand,
CreateRouteCommand,
CreateRouteResponseCommand,
CreateStageCommand,
DeleteApiCommand,
DeleteAuthorizerCommand,
DeleteCorsConfigurationCommand,
DeleteIntegrationCommand,
DeleteRouteCommand,
DeleteStageCommand,
GetApiCommand,
GetApisCommand,
GetAuthorizersCommand,
GetIntegrationsCommand,
GetRoutesCommand,
GetStageCommand,
GetStagesCommand,
UpdateApiCommand,
UpdateRouteCommand,
type Api,
type ApiGatewayV2Client,
type Authorizer,
type Cors,
type Integration,
type Route,
type Stage,
} from '@aws-sdk/client-apigatewayv2'
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import {awsEndpoint} from '../service/runtimeProbe'
import type {
CloudResource,
HttpApiAuthorizerInput,
HttpApiAuthorizerOverview,
HttpApiCors,
HttpApiIntegrationOverview,
HttpApiOverview,
HttpApiProtocol,
HttpApiRouteInput,
HttpApiRouteOverview,
HttpApiStageInput,
HttpApiStageOverview,
HttpApiWebSocketTestInput,
HttpApiWebSocketTestResult,
} from '../cloud-spi/types'
export const HTTP_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ANY']
export const CORS_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*']
export const DEFAULT_ROUTE_SELECTION_EXPRESSION = '$request.body.action'
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
/**
* API Gateway v2: HTTP and WebSocket APIs. A separate AWS API from REST APIs, with its own
* routes, integrations, stages and JWT authorizers, served by the same API Gateway page.
*/
export class AwsHttpApiOperations {
constructor(
private readonly client: ApiGatewayV2Client,
private readonly lambda: LambdaClient,
private readonly accountId: string,
private readonly region: string,
private readonly fetchJson: (url: string) => Promise<unknown> = defaultFetchJson,
) {}
async list(): Promise<CloudResource[]> {
const apis: Api[] = []
let next: string | undefined
do {
const page = await this.client.send(new GetApisCommand({NextToken: next}))
apis.push(...(page.Items ?? []))
next = page.NextToken
} while (next)
return apis.map(toResource)
}
/** The API, or null when no HTTP or WebSocket API has this id (it may be a REST API). */
async get(apiId: string): Promise<CloudResource | null> {
const api = await this.findApi(apiId)
return api ? toResource(api) : null
}
async create(values: Record<string, unknown>, protocol: HttpApiProtocol): Promise<CloudResource> {
const name = stringValue(values.name)
if (!name) throw new ValidationError('name is required')
const description = stringValue(values.description) || undefined
if (protocol === 'WEBSOCKET') {
const routeSelectionExpression = stringValue(values.routeSelectionExpression) || DEFAULT_ROUTE_SELECTION_EXPRESSION
if (!/^\$request\.body\.[A-Za-z0-9_.]+$/.test(routeSelectionExpression)) {
throw new ValidationError('routeSelectionExpression must name a field of the message body, for example $request.body.action')
}
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'WEBSOCKET', RouteSelectionExpression: routeSelectionExpression}))
const stageName = stageNameValue(stringValue(values.stageName) || 'dev')
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: stageName, AutoDeploy: true}))
return toResource(api)
}
// Quick create: a Lambda target gives the API a catch-all route and an auto-deployed $default stage.
const functionName = stringValue(values.functionName)
if (functionName) {
const functionArn = await this.functionArn(functionName)
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP', Target: functionArn}))
await this.allowInvoke(api.ApiId!, functionName)
return toResource(api)
}
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP'}))
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: '$default', AutoDeploy: true}))
return toResource(api)
}
async delete(apiId: string): Promise<void> {
const {integrations} = await this.describeParts(apiId)
await this.client.send(new DeleteApiCommand({ApiId: apiId}))
for (const functionName of new Set(integrations.map(integrationFunctionName).filter((name): name is string => Boolean(name)))) {
await this.removeInvokePermission(apiId, functionName)
}
}
async describe(apiId: string): Promise<HttpApiOverview> {
const api = await this.requireApi(apiId)
const {routes, integrations, stages, authorizers} = await this.describeParts(apiId)
const protocol = protocolOf(api)
const integrationsById = new Map(integrations.map((integration) => [integration.IntegrationId, integration]))
return {
apiId,
protocol,
apiEndpoint: api.ApiEndpoint ?? null,
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression ?? null : null,
routes: routes.map((route) => toRouteOverview(route, integrationsById)).sort(compareRoutes),
stages: stages.map((stage) => toStageOverview(apiId, protocol, stage)).sort((left, right) => left.stageName.localeCompare(right.stageName)),
authorizers: authorizers.map(toAuthorizerOverview).sort((left, right) => left.name.localeCompare(right.name)),
cors: protocol === 'HTTP' ? fromCors(api.CorsConfiguration) : null,
}
}
async createRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
const api = await this.requireApi(apiId)
const protocol = protocolOf(api)
const routeKey = protocol === 'HTTP' ? httpRouteKey(input.routeKey) : webSocketRouteKey(input.routeKey)
const functionName = stringValue(input.functionName)
const httpUrl = stringValue(input.httpUrl)
if (Boolean(functionName) === Boolean(httpUrl)) throw new ValidationError('Give the route either a Lambda function or an HTTP URL')
if (httpUrl && protocol === 'WEBSOCKET') throw new ValidationError('TheCloud connects WebSocket routes to Lambda functions only')
if (httpUrl && !/^https?:\/\/[^\s/]+/.test(httpUrl)) throw new ValidationError('httpUrl must be an http:// or https:// URL')
const {routes, integrations} = await this.describeParts(apiId)
if (routes.some((route) => route.RouteKey === routeKey)) throw new ValidationError(`The API already has a ${routeKey} route`)
const authorizerId = stringValue(input.authorizerId)
if (authorizerId && protocol === 'WEBSOCKET') throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
let integration: Partial<Integration> | undefined
if (functionName) {
const functionArn = await this.functionArn(functionName)
const uri = protocol === 'HTTP' ? functionArn : lambdaInvocationUri(this.region, functionArn)
integration = integrations.find((candidate) => candidate.IntegrationType === 'AWS_PROXY' && candidate.IntegrationUri === uri)
?? await this.client.send(new CreateIntegrationCommand({
ApiId: apiId,
IntegrationType: 'AWS_PROXY',
IntegrationUri: uri,
IntegrationMethod: 'POST',
PayloadFormatVersion: protocol === 'HTTP' ? '2.0' : undefined,
}))
await this.allowInvoke(apiId, functionName)
} else {
const method = routeKey === '$default' ? 'ANY' : routeKey.split(' ')[0]
integration = integrations.find((candidate) => candidate.IntegrationType === 'HTTP_PROXY' && candidate.IntegrationUri === httpUrl && candidate.IntegrationMethod === method)
?? await this.client.send(new CreateIntegrationCommand({
ApiId: apiId,
IntegrationType: 'HTTP_PROXY',
IntegrationUri: httpUrl,
IntegrationMethod: method,
PayloadFormatVersion: '1.0',
}))
}
const route = await this.client.send(new CreateRouteCommand({
ApiId: apiId,
RouteKey: routeKey,
Target: `integrations/${integration.IntegrationId}`,
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
AuthorizerId: authorizerId || undefined,
RouteResponseSelectionExpression: protocol === 'WEBSOCKET' && input.returnsResponse ? '$default' : undefined,
}))
// A WebSocket route sends its integration's response back only once it has a route response.
if (protocol === 'WEBSOCKET' && input.returnsResponse) {
await this.client.send(new CreateRouteResponseCommand({ApiId: apiId, RouteId: route.RouteId, RouteResponseKey: '$default'}))
}
return toRouteOverview(route, new Map([[integration.IntegrationId, integration]]))
}
async deleteRoute(apiId: string, routeId: string): Promise<void> {
const {routes, integrations} = await this.describeParts(apiId)
const route = routes.find((candidate) => candidate.RouteId === routeId)
if (!route) throw new NotFoundError(`Route ${routeId} not found`)
await this.client.send(new DeleteRouteCommand({ApiId: apiId, RouteId: routeId}))
// Clean up what only this route used: its integration, then the Lambda's permission for this API.
const integrationId = targetIntegrationId(route.Target)
const stillUsed = routes.some((candidate) => candidate.RouteId !== routeId && targetIntegrationId(candidate.Target) === integrationId)
const integration = integrations.find((candidate) => candidate.IntegrationId === integrationId)
if (!integration || stillUsed) return
await this.client.send(new DeleteIntegrationCommand({ApiId: apiId, IntegrationId: integrationId}))
const functionName = integrationFunctionName(integration)
const functionStillUsed = integrations.some((candidate) => candidate.IntegrationId !== integrationId && integrationFunctionName(candidate) === functionName)
if (functionName && !functionStillUsed) await this.removeInvokePermission(apiId, functionName)
}
async setRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
const api = await this.requireApi(apiId)
if (protocolOf(api) === 'WEBSOCKET' && authorizerId) throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
const route = await this.client.send(new UpdateRouteCommand({
ApiId: apiId,
RouteId: routeId,
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
AuthorizerId: authorizerId ?? undefined,
}))
const {integrations} = await this.describeParts(apiId)
return toRouteOverview(route, new Map(integrations.map((integration) => [integration.IntegrationId, integration])))
}
async createStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
const api = await this.requireApi(apiId)
const stage = await this.client.send(new CreateStageCommand({ApiId: apiId, StageName: stageNameValue(input.stageName), AutoDeploy: Boolean(input.autoDeploy)}))
// A stage without auto-deploy serves nothing until its first deployment.
if (!input.autoDeploy) return this.deploy(apiId, stage.StageName!)
return toStageOverview(apiId, protocolOf(api), stage)
}
async deleteStage(apiId: string, stageName: string): Promise<void> {
await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)}))
}
async deploy(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
const api = await this.requireApi(apiId)
const name = stageNameValue(stageName)
await this.client.send(new CreateDeploymentCommand({ApiId: apiId, StageName: name, Description: 'Deployed from TheCloud'}))
const stage = await this.client.send(new GetStageCommand({ApiId: apiId, StageName: name}))
return toStageOverview(apiId, protocolOf(api), stage)
}
async createAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
const api = await this.requireApi(apiId)
if (protocolOf(api) === 'WEBSOCKET') throw new ValidationError('JWT authorizers are for HTTP APIs; WebSocket APIs use Lambda authorizers')
const name = stringValue(input.name)
if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) throw new ValidationError('name must be 1-128 letters, numbers, hyphens or underscores')
let issuer = stringValue(input.issuer)
let audience = (input.audience ?? []).map(stringValue).filter(Boolean)
const userPoolId = stringValue(input.userPoolId)
if (userPoolId) {
const clientId = stringValue(input.userPoolClientId)
if (!clientId) throw new ValidationError('Choose the app client whose tokens the authorizer accepts')
issuer = await this.userPoolIssuer(userPoolId)
audience = [clientId]
}
if (!/^https?:\/\/[^\s]+$/.test(issuer)) throw new ValidationError('issuer must be the token issuer URL, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc')
if (audience.length === 0) throw new ValidationError('audience needs at least one value, such as an app client id')
const authorizer = await this.client.send(new CreateAuthorizerCommand({
ApiId: apiId,
Name: name,
AuthorizerType: 'JWT',
IdentitySource: ['$request.header.Authorization'],
JwtConfiguration: {Issuer: issuer, Audience: audience},
}))
return toAuthorizerOverview(authorizer)
}
async deleteAuthorizer(apiId: string, authorizerId: string): Promise<void> {
const {routes} = await this.describeParts(apiId)
const users = routes.filter((route) => route.AuthorizationType !== 'NONE' && route.AuthorizerId === authorizerId).map((route) => route.RouteKey)
if (users.length > 0) throw new ValidationError(`Remove the authorizer from ${users.join(', ')} first`)
await this.client.send(new DeleteAuthorizerCommand({ApiId: apiId, AuthorizerId: authorizerId}))
}
async updateCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
const api = await this.requireApi(apiId)
if (protocolOf(api) !== 'HTTP') throw new ValidationError('CORS applies to HTTP APIs only')
if (!cors) {
await this.client.send(new DeleteCorsConfigurationCommand({ApiId: apiId}))
return null
}
const configuration = toCors(cors)
const updated = await this.client.send(new UpdateApiCommand({ApiId: apiId, CorsConfiguration: configuration}))
return fromCors(updated.CorsConfiguration)
}
/** Opens a real connection to the deployed stage, sends one message and collects the replies. */
async testWebSocket(apiId: string, input: HttpApiWebSocketTestInput, timeoutMs = 4000): Promise<HttpApiWebSocketTestResult> {
const api = await this.requireApi(apiId)
if (protocolOf(api) !== 'WEBSOCKET') throw new ValidationError('This is not a WebSocket API')
const stage = stageNameValue(input.stage)
const query = stringValue(input.query).replace(/^\?/, '')
const url = `${awsEndpoint().replace(/\/$/, '').replace(/^http/, 'ws')}/ws/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${query ? `?${query}` : ''}`
return exchangeWebSocketMessage(url, input.message, timeoutMs)
}
private async describeParts(apiId: string) {
const [routes, integrations, stages, authorizers] = await Promise.all([
this.pages((NextToken) => this.client.send(new GetRoutesCommand({ApiId: apiId, NextToken}))),
this.pages((NextToken) => this.client.send(new GetIntegrationsCommand({ApiId: apiId, NextToken}))),
this.pages((NextToken) => this.client.send(new GetStagesCommand({ApiId: apiId, NextToken}))),
this.pages((NextToken) => this.client.send(new GetAuthorizersCommand({ApiId: apiId, NextToken}))),
])
return {routes: routes as Route[], integrations: integrations as Integration[], stages: stages as Stage[], authorizers: authorizers as Authorizer[]}
}
private async pages<T>(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise<T[]> {
const items: T[] = []
let next: string | undefined
do {
const page = await fetchPage(next)
items.push(...(page.Items ?? []))
next = page.NextToken
} while (next)
return items
}
private async findApi(apiId: string): Promise<Partial<Api> | null> {
try {
return await this.client.send(new GetApiCommand({ApiId: apiId}))
} catch (error) {
if (hasHttpStatus(error, 404)) return null
throw error
}
}
private async requireApi(apiId: string): Promise<Partial<Api>> {
const api = await this.findApi(apiId)
if (!api) throw new NotFoundError(`HTTP or WebSocket API ${apiId} not found`)
return api
}
private async functionArn(functionName: string): Promise<string> {
const result = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
const arn = result.Configuration?.FunctionArn
if (!arn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
return arn
}
/** API Gateway may invoke the function for any route and stage of this API, as the AWS console grants it. */
private async allowInvoke(apiId: string, functionName: string) {
try {
await this.lambda.send(new AddPermissionCommand({
FunctionName: functionName,
StatementId: invokeStatementId(apiId),
Action: 'lambda:InvokeFunction',
Principal: 'apigateway.amazonaws.com',
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/*`,
}))
} catch (error) {
if (!hasHttpStatus(error, 409)) throw error
}
}
private async removeInvokePermission(apiId: string, functionName: string) {
try {
await this.lambda.send(new RemovePermissionCommand({FunctionName: functionName, StatementId: invokeStatementId(apiId)}))
} catch (error) {
if (!hasHttpStatus(error, 404)) throw error
}
}
/** The pool's token issuer, read from its OpenID Connect discovery document as any relying party would. */
private async userPoolIssuer(userPoolId: string): Promise<string> {
if (!/^[\w-]+_[0-9A-Za-z]+$/.test(userPoolId)) throw new ValidationError('userPoolId is not a Cognito user pool id')
const discovery = await this.fetchJson(`${awsEndpoint().replace(/\/$/, '')}/${userPoolId}/.well-known/openid-configuration`)
const issuer = (discovery as {issuer?: unknown} | null)?.issuer
if (typeof issuer !== 'string' || !issuer) throw new NotFoundError(`User pool ${userPoolId} has no OpenID Connect discovery document`)
return issuer
}
}
async function defaultFetchJson(url: string): Promise<unknown> {
const response = await globalThis.fetch(url)
if (!response.ok) throw new NotFoundError(`${url} answered ${response.status}`)
return response.json()
}
function exchangeWebSocketMessage(url: string, message: string, timeoutMs: number): Promise<HttpApiWebSocketTestResult> {
const startedAt = performance.now()
return new Promise((resolve) => {
const messages: string[] = []
let connected = false
let settled = false
let socket: WebSocket
const finish = (closeCode: number | null, error: string | null) => {
if (settled) return
settled = true
clearTimeout(timer)
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000)
resolve({connected, messages, closeCode, error, durationMs: Math.round(performance.now() - startedAt)})
}
// After the first reply, wait briefly for any more before hanging up.
let quiet: ReturnType<typeof setTimeout> | undefined
const timer = setTimeout(() => finish(null, connected ? null : 'Timed out connecting'), timeoutMs)
try {
socket = new WebSocket(url)
} catch (error) {
finish(null, error instanceof Error ? error.message : 'Could not connect')
return
}
socket.onopen = () => {
connected = true
if (message) socket.send(message)
else finish(null, null)
}
socket.onmessage = (event) => {
messages.push(typeof event.data === 'string' ? event.data : String(event.data))
clearTimeout(quiet)
quiet = setTimeout(() => finish(null, null), 500)
}
socket.onerror = () => finish(null, connected ? 'The connection failed' : 'The stage refused the connection')
socket.onclose = (event) => finish(event.code, connected ? null : `The stage refused the connection (${event.code})`)
})
}
function toResource(api: Partial<Api>): CloudResource {
const protocol = protocolOf(api)
return {
id: api.ApiId ?? '',
name: api.Name ?? '',
cloud: 'aws',
service: 'apigateway',
type: protocol === 'HTTP' ? 'http-api' : 'websocket-api',
region: null,
createdAt: api.CreatedDate?.toISOString() ?? null,
metadata: {
provider: 'aws',
apigatewayService: protocol === 'HTTP' ? 'http' : 'websocket',
protocol,
description: api.Description,
apiEndpoint: api.ApiEndpoint,
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression : undefined,
},
}
}
function protocolOf(api: Partial<Api>): HttpApiProtocol {
return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP'
}
function toRouteOverview(route: Partial<Route>, integrations: Map<string | undefined, Partial<Integration>>): HttpApiRouteOverview {
const integration = integrations.get(targetIntegrationId(route.Target))
return {
routeId: route.RouteId ?? '',
routeKey: route.RouteKey ?? '',
integration: integration ? toIntegrationOverview(integration) : null,
authorizationType: route.AuthorizationType ?? 'NONE',
// Real API Gateway keeps a route's last AuthorizerId after its authorization goes back to NONE.
authorizerId: route.AuthorizationType && route.AuthorizationType !== 'NONE' ? route.AuthorizerId || null : null,
authorizationScopes: route.AuthorizationScopes ?? [],
returnsResponse: Boolean(route.RouteResponseSelectionExpression),
}
}
function toIntegrationOverview(integration: Partial<Integration>): HttpApiIntegrationOverview {
return {
integrationId: integration.IntegrationId ?? '',
type: integration.IntegrationType ?? '',
uri: integration.IntegrationUri ?? null,
functionName: integrationFunctionName(integration),
payloadFormatVersion: integration.PayloadFormatVersion ?? null,
}
}
function toStageOverview(apiId: string, protocol: HttpApiProtocol, stage: Partial<Stage>): HttpApiStageOverview {
const stageName = stage.StageName ?? ''
return {
stageName,
autoDeploy: Boolean(stage.AutoDeploy),
deploymentId: stage.DeploymentId ?? null,
lastUpdatedAt: stage.LastUpdatedDate?.toISOString() ?? null,
invokePath: `${protocol === 'WEBSOCKET' ? '/ws' : '/execute-api'}/${apiId}/${stageName}`,
}
}
function toAuthorizerOverview(authorizer: Partial<Authorizer>): HttpApiAuthorizerOverview {
return {
authorizerId: authorizer.AuthorizerId ?? '',
name: authorizer.Name ?? '',
type: authorizer.AuthorizerType ?? '',
identitySource: authorizer.IdentitySource ?? [],
issuer: authorizer.JwtConfiguration?.Issuer ?? null,
audience: authorizer.JwtConfiguration?.Audience ?? [],
}
}
function fromCors(cors: Cors | undefined): HttpApiCors | null {
if (!cors) return null
return {
allowOrigins: cors.AllowOrigins ?? [],
allowMethods: cors.AllowMethods ?? [],
allowHeaders: cors.AllowHeaders ?? [],
exposeHeaders: cors.ExposeHeaders ?? [],
maxAgeSeconds: cors.MaxAge ?? null,
allowCredentials: Boolean(cors.AllowCredentials),
}
}
function toCors(cors: HttpApiCors): Cors {
const clean = (values: string[]) => [...new Set((values ?? []).map(stringValue).filter(Boolean))]
const allowOrigins = clean(cors.allowOrigins)
const allowMethods = clean(cors.allowMethods).map((method) => method.toUpperCase())
if (allowOrigins.length === 0) throw new ValidationError('allowOrigins needs at least one origin, or *')
if (allowOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^\s/]+$/.test(origin))) {
throw new ValidationError('Each allowed origin must be * or a scheme and host, such as https://app.example.com')
}
if (allowMethods.some((method) => !CORS_ALLOWED_METHODS.includes(method))) throw new ValidationError(`allowMethods may contain ${CORS_ALLOWED_METHODS.join(', ')}`)
if (cors.allowCredentials && allowOrigins.includes('*')) throw new ValidationError('Credentials cannot be allowed for every origin (*); list the origins instead')
const maxAge = cors.maxAgeSeconds
if (maxAge !== null && maxAge !== undefined && (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > 86_400)) {
throw new ValidationError('maxAgeSeconds must be a whole number from 0 to 86400')
}
return {
AllowOrigins: allowOrigins,
AllowMethods: allowMethods,
AllowHeaders: clean(cors.allowHeaders),
ExposeHeaders: clean(cors.exposeHeaders),
MaxAge: maxAge ?? undefined,
AllowCredentials: Boolean(cors.allowCredentials),
}
}
/** `$default`, or a method and a path such as `GET /orders/{id}` or `ANY /files/{proxy+}`. */
export function httpRouteKey(value: string): string {
const key = stringValue(value).replace(/\s+/g, ' ')
if (key === '$default') return key
const [method, path, ...rest] = key.split(' ')
if (rest.length > 0 || !HTTP_ROUTE_METHODS.includes(method?.toUpperCase() ?? '') || !path) {
throw new ValidationError('routeKey must be $default or a method and a path, for example GET /orders/{id}')
}
const segments = path === '/' ? [] : path.split('/').slice(1)
if (!path.startsWith('/') || segments.some((segment, index) => !/^(?:[A-Za-z0-9._~-]+|\{[A-Za-z0-9_]+\}|\{[A-Za-z0-9_]+\+\})$/.test(segment)
|| (segment.endsWith('+}') && index !== segments.length - 1))) {
throw new ValidationError('The path must start with / and use segments such as orders, {id}, or a final {proxy+}')
}
return `${method.toUpperCase()} ${path}`
}
/** `$connect`, `$disconnect`, `$default`, or a value of the route selection expression such as `sendMessage`. */
export function webSocketRouteKey(value: string): string {
const key = stringValue(value)
if (WEBSOCKET_SYSTEM_ROUTES.includes(key)) return key
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
throw new ValidationError('routeKey must be $connect, $disconnect, $default, or a message action such as sendMessage')
}
return key
}
function stageNameValue(value: string): string {
const name = stringValue(value)
if (!/^(?:\$default|[A-Za-z0-9_-]{1,128})$/.test(name)) {
throw new ValidationError('stage must be $default or letters, numbers, hyphens and underscores')
}
return name
}
function compareRoutes(left: HttpApiRouteOverview, right: HttpApiRouteOverview): number {
const path = (route: HttpApiRouteOverview) => route.routeKey.split(' ')[1] ?? route.routeKey
return path(left).localeCompare(path(right)) || left.routeKey.localeCompare(right.routeKey)
}
function lambdaInvocationUri(region: string, functionArn: string): string {
return `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`
}
function integrationFunctionName(integration: Partial<Integration>): string | null {
if (integration.IntegrationType !== 'AWS_PROXY') return null
return integration.IntegrationUri?.match(/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)?.[1] ?? null
}
function targetIntegrationId(target: string | undefined): string | undefined {
return target?.startsWith('integrations/') ? target.slice('integrations/'.length) : undefined
}
function invokeStatementId(apiId: string): string {
return `apigatewayv2-${apiId}`
}
function stringValue(value: unknown): string {
return typeof value === 'string' ? value.trim() : ''
}
function hasHttpStatus(error: unknown, status: number): boolean {
if (typeof error !== 'object' || error === null) return false
return (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === status
}
@@ -0,0 +1,147 @@
import {describe, expect, test} from 'bun:test'
import {
DeleteAlarmsCommand,
DescribeAlarmHistoryCommand,
DescribeAlarmsCommand,
DisableAlarmActionsCommand,
EnableAlarmActionsCommand,
GetMetricDataCommand,
ListMetricsCommand,
PutCompositeAlarmCommand,
PutMetricAlarmCommand,
SetAlarmStateCommand,
type CloudWatchClient,
} from '@aws-sdk/client-cloudwatch'
import {AwsMonitoringAdapter} from './AwsMonitoringAdapter'
import {ValidationError} from '../cloud-spi/errors'
type SendResult = Record<string, unknown>
function stubCloudWatch(handler: (command: object) => SendResult | Promise<SendResult>) {
const sent: object[] = []
const client = {
async send(command: object) {
sent.push(command)
return handler(command)
},
} as unknown as CloudWatchClient
return {adapter: new AwsMonitoringAdapter(client), sent}
}
const UPDATED = new Date('2026-10-25T10:00:00Z')
const metricAlarm = {
AlarmName: 'high-errors', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:high-errors',
StateValue: 'ALARM', StateReason: 'Threshold crossed', StateUpdatedTimestamp: UPDATED, AlarmConfigurationUpdatedTimestamp: UPDATED,
ActionsEnabled: true, AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: [],
Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
}
const compositeAlarm = {
AlarmName: 'app-unhealthy', AlarmArn: 'arn:aws:cloudwatch:us-east-1:000000000000:alarm:app-unhealthy',
StateValue: 'OK', AlarmRule: 'ALARM("high-errors")', ActionsEnabled: false, AlarmConfigurationUpdatedTimestamp: UPDATED,
}
describe('AwsMonitoringAdapter alarms', () => {
test('lists metric and composite alarms with a readable condition', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand
? {MetricAlarms: [metricAlarm], CompositeAlarms: [compositeAlarm]}
: {})
const alarms = await adapter.list()
expect((sent[0] as DescribeAlarmsCommand).input.AlarmTypes).toEqual(['MetricAlarm', 'CompositeAlarm'])
expect(alarms.map((alarm) => [alarm.name, alarm.status, alarm.metadata.alarmType])).toEqual([['app-unhealthy', 'OK', 'composite'], ['high-errors', 'ALARM', 'metric']])
expect(alarms[1].metadata).toMatchObject({condition: 'Errors Sum > 5 for 2 × 5 min', dimensions: [{name: 'FunctionName', value: 'checkout'}], threshold: 5, periodSeconds: 300})
expect(alarms[0].metadata).toMatchObject({condition: 'ALARM("high-errors")', actionsEnabled: false})
expect(await adapter.list({search: 'errors'})).toHaveLength(1)
})
test('creates a metric alarm that notifies an SNS topic both ways', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {MetricAlarms: [metricAlarm]} : {})
await adapter.create({values: {
alarmType: 'metric', name: 'high-errors', namespace: 'App', metricName: 'Errors', dimensions: 'FunctionName=checkout\n',
statistic: 'Sum', period: '300', comparison: 'GreaterThanThreshold', threshold: '5', evaluationPeriods: '2',
topicArn: 'arn:aws:sns:us-east-1:000000000000:alerts',
}})
expect((sent[0] as PutMetricAlarmCommand).input).toMatchObject({
AlarmName: 'high-errors', Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}],
Statistic: 'Sum', Period: 300, EvaluationPeriods: 2, Threshold: 5, ComparisonOperator: 'GreaterThanThreshold',
AlarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], OKActions: ['arn:aws:sns:us-east-1:000000000000:alerts'],
})
})
test('creates a composite alarm from its rule', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof DescribeAlarmsCommand ? {CompositeAlarms: [compositeAlarm]} : {})
const created = await adapter.create({values: {alarmType: 'composite', name: 'app-unhealthy', alarmRule: 'ALARM("high-errors")'}})
expect((sent[0] as PutCompositeAlarmCommand).input).toMatchObject({AlarmName: 'app-unhealthy', AlarmRule: 'ALARM("high-errors")', AlarmActions: []})
expect(created.metadata.alarmType).toBe('composite')
})
test('checks the alarm before creating it', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
const valid = {alarmType: 'metric', name: 'a', namespace: 'App', metricName: 'Errors', threshold: '5'}
await expect(adapter.create({values: {...valid, threshold: 'lots'}})).rejects.toThrow(/threshold/)
await expect(adapter.create({values: {...valid, metricName: ''}})).rejects.toThrow(/metricName/)
await expect(adapter.create({values: {...valid, dimensions: 'FunctionName'}})).rejects.toThrow(/Name=Value/)
await expect(adapter.create({values: {...valid, topicArn: 'my-topic'}})).rejects.toThrow(/SNS topic ARN/)
await expect(adapter.create({values: {...valid, period: '7'}})).rejects.toThrow(ValidationError)
await expect(adapter.create({values: {alarmType: 'composite', name: 'b', alarmRule: ' '}})).rejects.toThrow(/rule/)
expect(sent.some((command) => command instanceof PutMetricAlarmCommand || command instanceof PutCompositeAlarmCommand)).toBe(false)
})
test('deletes, sets the state and toggles the actions of an alarm', async () => {
const {adapter, sent} = stubCloudWatch(() => ({}))
await adapter.delete('high-errors')
await adapter.setAlarmState('high-errors', 'ALARM', '')
await adapter.setAlarmActionsEnabled('high-errors', false)
await adapter.setAlarmActionsEnabled('high-errors', true)
expect((sent[0] as DeleteAlarmsCommand).input).toEqual({AlarmNames: ['high-errors']})
expect((sent[1] as SetAlarmStateCommand).input).toMatchObject({AlarmName: 'high-errors', StateValue: 'ALARM'})
expect((sent[1] as SetAlarmStateCommand).input.StateReason).toContain('TheCloud')
expect(sent[2]).toBeInstanceOf(DisableAlarmActionsCommand)
expect(sent[3]).toBeInstanceOf(EnableAlarmActionsCommand)
await expect(adapter.setAlarmState('high-errors', 'BROKEN' as never, '')).rejects.toThrow(/state/)
})
test('reads the alarm history newest first', async () => {
const {adapter, sent} = stubCloudWatch(() => ({AlarmHistoryItems: [{Timestamp: UPDATED, HistoryItemType: 'StateUpdate', HistorySummary: 'Alarm updated from OK to ALARM'}]}))
expect(await adapter.describeAlarmHistory('high-errors')).toEqual([{timestamp: '2026-10-25T10:00:00.000Z', type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
expect((sent[0] as DescribeAlarmHistoryCommand).input).toMatchObject({AlarmName: 'high-errors', ScanBy: 'TimestampDescending'})
})
})
describe('AwsMonitoringAdapter metrics', () => {
test('lists metrics sorted by namespace and filters them', async () => {
const {adapter} = stubCloudWatch((command) => command instanceof ListMetricsCommand
? {Metrics: [
{Namespace: 'AWS/SQS', MetricName: 'NumberOfMessagesSent', Dimensions: [{Name: 'QueueName', Value: 'orders'}]},
{Namespace: 'App', MetricName: 'Errors', Dimensions: []},
]}
: {})
expect((await adapter.listMetrics()).map((metric) => `${metric.namespace}/${metric.metricName}`)).toEqual(['App/Errors', 'AWS/SQS/NumberOfMessagesSent'])
expect(await adapter.listMetrics({search: 'orders'})).toHaveLength(1)
})
test('charts a metric with a period that suits the range', async () => {
const {adapter, sent} = stubCloudWatch((command) => command instanceof GetMetricDataCommand
? {MetricDataResults: [{Id: 'm1', Label: 'Errors', Timestamps: [new Date('2026-10-25T10:01:00Z'), new Date('2026-10-25T10:00:00Z')], Values: [7, 3]}]}
: {})
const series = await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 60, dimensions: [{name: 'FunctionName', value: 'checkout'}]})
expect(series).toEqual({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00.000Z', value: 3}, {timestamp: '2026-10-25T10:01:00.000Z', value: 7}]})
const query = (sent[0] as GetMetricDataCommand).input.MetricDataQueries![0]
expect(query.MetricStat).toEqual({Metric: {Namespace: 'App', MetricName: 'Errors', Dimensions: [{Name: 'FunctionName', Value: 'checkout'}]}, Period: 60, Stat: 'Sum'})
await adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 24 * 60})
expect((sent[1] as GetMetricDataCommand).input.MetricDataQueries![0].MetricStat!.Period).toBe(300)
await expect(adapter.getMetricSeries({namespace: 'App', metricName: 'Errors', rangeMinutes: 1})).rejects.toThrow(/rangeMinutes/)
await expect(adapter.getMetricSeries({namespace: '', metricName: 'Errors'})).rejects.toThrow(/namespace/)
})
})
@@ -0,0 +1,326 @@
import {
DeleteAlarmsCommand,
DescribeAlarmHistoryCommand,
DescribeAlarmsCommand,
DisableAlarmActionsCommand,
EnableAlarmActionsCommand,
GetMetricDataCommand,
ListMetricsCommand,
PutCompositeAlarmCommand,
PutMetricAlarmCommand,
SetAlarmStateCommand,
type CloudWatchClient,
type ComparisonOperator,
type CompositeAlarm,
type MetricAlarm,
type Statistic,
} from '@aws-sdk/client-cloudwatch'
import {
ALARM_COMPARISONS,
ALARM_NAME_PATTERN,
ALARM_PERIODS,
ALARM_STATES,
ALARM_STATISTICS,
ALARM_TYPES,
awsMonitoringSchema,
} from '../cloud-spi/monitoringSchema'
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
import type {
AlarmHistoryItem,
AlarmStateValue,
CloudResource,
CloudServiceAdapter,
CreateResourceInput,
MetricDimension,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
ResourceQuery,
ServiceSchema,
} from '../cloud-spi/types'
/** CloudWatch's page and request limits. */
const ALARM_PAGE_LIMIT = 100
const METRIC_LIST_CAP = 500
const HISTORY_LIMIT = 25
const MAX_DIMENSIONS = 30
const MAX_RANGE_MINUTES = 14 * 24 * 60
const SYMBOL: Record<string, string> = {
GreaterThanThreshold: '>',
GreaterThanOrEqualToThreshold: '>=',
LessThanThreshold: '<',
LessThanOrEqualToThreshold: '<=',
}
/**
* Talks to CloudWatch metrics and alarms. Alarms, metric and composite, are the resources;
* metrics are browsed and charted through the monitoring actions.
*
* Verified against TheCloud's runtime: alarm actions publish to SNS on every state change,
* `SetAlarmState` changes the state until the next evaluation, and `PutMetricData` datums
* are returned by `GetMetricData` straight away.
*/
export class AwsMonitoringAdapter implements CloudServiceAdapter {
readonly cloud = 'aws' as const
readonly service = 'monitoring' as const
constructor(private readonly cloudwatch: CloudWatchClient) {}
schema(): ServiceSchema {
return awsMonitoringSchema()
}
async list(query: ResourceQuery = {}): Promise<CloudResource[]> {
const metricAlarms: MetricAlarm[] = []
const compositeAlarms: CompositeAlarm[] = []
let nextToken: string | undefined
do {
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({
AlarmTypes: ['MetricAlarm', 'CompositeAlarm'],
MaxRecords: ALARM_PAGE_LIMIT,
...(nextToken ? {NextToken: nextToken} : {}),
}))
metricAlarms.push(...(res.MetricAlarms ?? []))
compositeAlarms.push(...(res.CompositeAlarms ?? []))
nextToken = res.NextToken || undefined
} while (nextToken)
const search = query.search?.trim().toLowerCase()
return [...metricAlarms.map(metricAlarmResource), ...compositeAlarms.map(compositeAlarmResource)]
.filter((alarm) => !search || [alarm.name, String(alarm.metadata.metricName ?? ''), String(alarm.metadata.namespace ?? '')].some((value) => value.toLowerCase().includes(search)))
.sort((a, b) => a.name.localeCompare(b.name))
}
async get(name: string): Promise<CloudResource | null> {
const res = await this.cloudwatch.send(new DescribeAlarmsCommand({AlarmNames: [name], AlarmTypes: ['MetricAlarm', 'CompositeAlarm']}))
const metric = res.MetricAlarms?.[0]
if (metric) return metricAlarmResource(metric)
const composite = res.CompositeAlarms?.[0]
return composite ? compositeAlarmResource(composite) : null
}
async create(input: CreateResourceInput): Promise<CloudResource> {
const values = input.values
const type = oneOf(values.alarmType ?? 'metric', ALARM_TYPES, 'alarmType')
const name = text(values.name)
if (!new RegExp(ALARM_NAME_PATTERN).test(name)) throw new ValidationError('name must be 1 to 255 characters, without control characters')
const description = text(values.description) || undefined
const topicArn = text(values.topicArn)
if (topicArn && !/^arn:aws[a-z-]*:sns:[a-z0-9-]+:\d{12}:[A-Za-z0-9_-]{1,256}(\.fifo)?$/.test(topicArn)) {
throw new ValidationError('topicArn must be an SNS topic ARN, such as arn:aws:sns:us-east-1:000000000000:alerts')
}
const actions = topicArn ? [topicArn] : []
if (type === 'composite') {
const rule = text(values.alarmRule)
if (!rule) throw new ValidationError('Write the rule, such as ALARM("high-errors")')
if (rule.length > 10240) throw new ValidationError('The rule must be at most 10240 characters')
await this.cloudwatch.send(new PutCompositeAlarmCommand({
AlarmName: name,
AlarmDescription: description,
AlarmRule: rule,
AlarmActions: actions,
OKActions: actions,
ActionsEnabled: true,
}))
} else {
const namespace = text(values.namespace)
const metricName = text(values.metricName)
if (!namespace) throw new ValidationError('namespace is required, such as AWS/Lambda')
if (!metricName) throw new ValidationError('metricName is required, such as Errors')
const threshold = Number(text(values.threshold))
if (text(values.threshold) === '' || !Number.isFinite(threshold)) throw new ValidationError('threshold must be a number')
const evaluationPeriods = Number(text(values.evaluationPeriods) || '1')
if (!Number.isInteger(evaluationPeriods) || evaluationPeriods < 1) throw new ValidationError('evaluationPeriods must be a whole number from 1')
await this.cloudwatch.send(new PutMetricAlarmCommand({
AlarmName: name,
AlarmDescription: description,
Namespace: namespace,
MetricName: metricName,
Dimensions: parseDimensions(values.dimensions).map((dimension) => ({Name: dimension.name, Value: dimension.value})),
Statistic: oneOf(values.statistic || 'Average', ALARM_STATISTICS, 'statistic') as Statistic,
Period: Number(oneOf(String(values.period || '300'), ALARM_PERIODS, 'period')),
EvaluationPeriods: evaluationPeriods,
Threshold: threshold,
ComparisonOperator: oneOf(values.comparison || 'GreaterThanThreshold', ALARM_COMPARISONS, 'comparison') as ComparisonOperator,
TreatMissingData: 'missing',
AlarmActions: actions,
OKActions: actions,
ActionsEnabled: true,
}))
}
const created = await this.get(name)
if (!created) throw new NotFoundError(`CloudWatch did not return the alarm ${name}`)
return created
}
async delete(name: string): Promise<void> {
await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]}))
}
async listMetrics(query: {namespace?: string; search?: string} = {}): Promise<MetricSummary[]> {
const metrics: MetricSummary[] = []
let nextToken: string | undefined
do {
const res = await this.cloudwatch.send(new ListMetricsCommand({
...(query.namespace ? {Namespace: query.namespace} : {}),
...(nextToken ? {NextToken: nextToken} : {}),
}))
for (const metric of res.Metrics ?? []) {
metrics.push({
namespace: metric.Namespace ?? '',
metricName: metric.MetricName ?? '',
dimensions: (metric.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''})),
})
}
nextToken = res.NextToken || undefined
} while (nextToken && metrics.length < METRIC_LIST_CAP)
const search = query.search?.trim().toLowerCase()
return metrics.slice(0, METRIC_LIST_CAP)
.filter((metric) => !search || [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => dimension.value)].some((value) => value.toLowerCase().includes(search)))
.sort((a, b) => a.namespace.localeCompare(b.namespace) || a.metricName.localeCompare(b.metricName))
}
async getMetricSeries(query: MetricSeriesQuery): Promise<MetricSeries> {
const namespace = text(query?.namespace)
const metricName = text(query.metricName)
if (!namespace || !metricName) throw new ValidationError('namespace and metricName are required')
const statistic = oneOf(query.statistic || 'Average', ALARM_STATISTICS, 'statistic')
const rangeMinutes = Number(query.rangeMinutes ?? 60)
if (!Number.isFinite(rangeMinutes) || rangeMinutes < 5 || rangeMinutes > MAX_RANGE_MINUTES) throw new ValidationError('rangeMinutes must be between 5 and 20160')
// A point every minute for short ranges, coarser for long ones, staying near 60-300 points.
const periodSeconds = Number(query.periodSeconds) || (rangeMinutes <= 180 ? 60 : rangeMinutes <= 1440 ? 300 : 3600)
if (![60, 300, 900, 3600].includes(periodSeconds)) throw new ValidationError('periodSeconds must be 60, 300, 900 or 3600')
const dimensions = (query.dimensions ?? []).map((dimension) => ({Name: text(dimension?.name), Value: text(dimension?.value)}))
if (dimensions.some((dimension) => !dimension.Name)) throw new ValidationError('Every dimension needs a name')
const end = new Date()
const start = new Date(end.getTime() - rangeMinutes * 60_000)
const res = await this.cloudwatch.send(new GetMetricDataCommand({
StartTime: start,
EndTime: end,
ScanBy: 'TimestampAscending',
MetricDataQueries: [{
Id: 'm1',
ReturnData: true,
MetricStat: {Metric: {Namespace: namespace, MetricName: metricName, Dimensions: dimensions}, Period: periodSeconds, Stat: statistic},
}],
}))
const result = res.MetricDataResults?.[0]
const timestamps = result?.Timestamps ?? []
const points = timestamps.map((timestamp, index) => ({timestamp: timestamp.toISOString(), value: result?.Values?.[index] ?? 0}))
.sort((a, b) => a.timestamp.localeCompare(b.timestamp))
return {label: result?.Label || metricName, statistic, periodSeconds, points}
}
async describeAlarmHistory(name: string): Promise<AlarmHistoryItem[]> {
const res = await this.cloudwatch.send(new DescribeAlarmHistoryCommand({AlarmName: name, MaxRecords: HISTORY_LIMIT, ScanBy: 'TimestampDescending'}))
return (res.AlarmHistoryItems ?? []).map((item) => ({
timestamp: item.Timestamp?.toISOString() ?? null,
type: item.HistoryItemType ?? '',
summary: item.HistorySummary ?? '',
}))
}
/** Changes the state until the next evaluation, which is how alarm actions are tested on AWS. */
async setAlarmState(name: string, state: AlarmStateValue, reason: string): Promise<void> {
const value = oneOf(state, ALARM_STATES, 'state')
const why = text(reason) || `Set to ${value} from TheCloud to test the alarm's actions`
if (why.length > 1023) throw new ValidationError('reason must be at most 1023 characters')
await this.cloudwatch.send(new SetAlarmStateCommand({AlarmName: name, StateValue: value, StateReason: why}))
}
async setAlarmActionsEnabled(name: string, enabled: boolean): Promise<void> {
if (typeof enabled !== 'boolean') throw new ValidationError('enabled must be true or false')
await this.cloudwatch.send(enabled ? new EnableAlarmActionsCommand({AlarmNames: [name]}) : new DisableAlarmActionsCommand({AlarmNames: [name]}))
}
}
function metricAlarmResource(alarm: MetricAlarm): CloudResource {
const dimensions = (alarm.Dimensions ?? []).map((dimension) => ({name: dimension.Name ?? '', value: dimension.Value ?? ''}))
const period = alarm.Period ?? 0
return {
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
metadata: {
alarmType: 'metric',
arn: alarm.AlarmArn ?? null,
description: alarm.AlarmDescription ?? null,
stateReason: alarm.StateReason ?? null,
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
actionsEnabled: alarm.ActionsEnabled ?? true,
alarmActions: alarm.AlarmActions ?? [],
okActions: alarm.OKActions ?? [],
namespace: alarm.Namespace ?? null,
metricName: alarm.MetricName ?? null,
dimensions,
statistic: alarm.Statistic ?? alarm.ExtendedStatistic ?? null,
periodSeconds: period || null,
threshold: alarm.Threshold ?? null,
comparison: alarm.ComparisonOperator ?? null,
evaluationPeriods: alarm.EvaluationPeriods ?? null,
condition: alarm.MetricName
? `${alarm.MetricName} ${alarm.Statistic ?? ''} ${SYMBOL[alarm.ComparisonOperator ?? ''] ?? alarm.ComparisonOperator ?? ''} ${alarm.Threshold ?? ''} for ${alarm.EvaluationPeriods ?? 1} × ${periodLabel(period)}`.replace(/\s+/g, ' ').trim()
: 'Metric math',
},
}
}
function compositeAlarmResource(alarm: CompositeAlarm): CloudResource {
return {
...baseResource(alarm.AlarmName ?? '', alarm.AlarmArn, alarm.StateValue, alarm.AlarmConfigurationUpdatedTimestamp),
metadata: {
alarmType: 'composite',
arn: alarm.AlarmArn ?? null,
description: alarm.AlarmDescription ?? null,
stateReason: alarm.StateReason ?? null,
stateUpdatedAt: alarm.StateUpdatedTimestamp?.toISOString() ?? null,
actionsEnabled: alarm.ActionsEnabled ?? true,
alarmActions: alarm.AlarmActions ?? [],
okActions: alarm.OKActions ?? [],
alarmRule: alarm.AlarmRule ?? null,
condition: alarm.AlarmRule ?? null,
},
}
}
function baseResource(name: string, arn: string | undefined, state: string | undefined, updated: Date | undefined) {
return {
id: name,
name,
cloud: 'aws' as const,
service: 'monitoring' as const,
type: 'alarm',
region: arn?.split(':')[3] || null,
createdAt: updated?.toISOString() ?? null,
status: state ?? null,
}
}
/** One Name=Value per line, as the create form takes it. */
function parseDimensions(value: unknown): MetricDimension[] {
const lines = typeof value === 'string' ? value.split('\n').map((line) => line.trim()).filter(Boolean) : []
if (lines.length > MAX_DIMENSIONS) throw new ValidationError(`An alarm takes at most ${MAX_DIMENSIONS} dimensions`)
return lines.map((line) => {
const index = line.indexOf('=')
const name = index > 0 ? line.slice(0, index).trim() : ''
const dimensionValue = index > 0 ? line.slice(index + 1).trim() : ''
if (!name || !dimensionValue) throw new ValidationError(`Write each dimension as Name=Value (got "${line}")`)
return {name, value: dimensionValue}
})
}
function periodLabel(seconds: number): string {
if (seconds >= 3600 && seconds % 3600 === 0) return `${seconds / 3600} h`
if (seconds >= 60 && seconds % 60 === 0) return `${seconds / 60} min`
return `${seconds} s`
}
function oneOf<T extends string>(value: unknown, allowed: readonly T[], field: string): T {
if (typeof value === 'string' && (allowed as readonly string[]).includes(value)) return value as T
throw new ValidationError(`${field} must be one of ${allowed.join(', ')}`)
}
function text(value: unknown): string {
return typeof value === 'string' ? value.trim() : typeof value === 'number' ? String(value) : ''
}
+4
View File
@@ -15,6 +15,7 @@ import { SQSClient } from "@aws-sdk/client-sqs";
import { IAMClient } from "@aws-sdk/client-iam";
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
import { ApiGatewayV2Client } from "@aws-sdk/client-apigatewayv2";
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
import { AppConfigClient } from "@aws-sdk/client-appconfig";
import { SNSClient } from "@aws-sdk/client-sns";
@@ -79,6 +80,7 @@ export type AwsClients = {
iam: IAMClient;
dynamodb: DynamoDBClient;
apiGateway: APIGatewayClient;
apiGatewayV2: ApiGatewayV2Client;
cloudformation: CloudFormationClient;
appConfig: AppConfigClient;
sns: SNSClient;
@@ -127,6 +129,7 @@ function buildClients(accountId: string): AwsClients {
iam: new IAMClient(base),
dynamodb: new DynamoDBClient(base),
apiGateway: new APIGatewayClient(base),
apiGatewayV2: new ApiGatewayV2Client(base),
cloudformation: new CloudFormationClient(base),
appConfig: new AppConfigClient(base),
sns: new SNSClient(base),
@@ -181,6 +184,7 @@ export const sqs = awsClients.sqs;
export const iam = awsClients.iam;
export const dynamodb = awsClients.dynamodb;
export const apiGateway = awsClients.apiGateway;
export const apiGatewayV2 = awsClients.apiGatewayV2;
export const cloudformation = awsClients.cloudformation;
export const ecr = awsClients.ecr;
export const kinesis = awsClients.kinesis;
+62 -5
View File
@@ -1,4 +1,4 @@
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
import type {CapabilitySchema, HttpApiActionName, ResourceActionName, ServiceSchema} from './types'
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
@@ -7,12 +7,42 @@ const resourceActions: CapabilitySchema<ResourceActionName>[] = [
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
]
const httpApiActions: CapabilitySchema<HttpApiActionName>[] = [
{name: 'describe', label: 'Show routes, stages and authorizers', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createRoute', label: 'Add a route', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteRoute', label: 'Delete a route', enabled: true, status: 'available', runtimeRequired: true},
{name: 'setRouteAuthorizer', label: 'Protect a route with an authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createStage', label: 'Add a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteStage', label: 'Delete a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deploy', label: 'Deploy to a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'createAuthorizer', label: 'Add a JWT authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'deleteAuthorizer', label: 'Delete an authorizer', enabled: true, status: 'available', runtimeRequired: true},
{name: 'updateCors', label: 'Configure CORS', enabled: true, status: 'available', runtimeRequired: true},
{name: 'sendRequest', label: 'Send a request to a stage', enabled: true, status: 'available', runtimeRequired: true},
{name: 'testWebSocket', label: 'Connect and send a WebSocket message', enabled: true, status: 'available', runtimeRequired: true},
]
const httpOnly = {field: 'protocol', values: ['HTTP']}
const webSocketOnly = {field: 'protocol', values: ['WEBSOCKET']}
export function awsApiGatewaySchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'apigateway',
displayName: 'AWS API Gateway',
fields: [
{
name: 'protocol',
label: 'API type',
type: 'select',
required: true,
defaultValue: 'REST',
options: [
{label: 'REST API (resources, methods and stages)', value: 'REST'},
{label: 'HTTP API (routes to Lambda or HTTP backends, JWT authorizers)', value: 'HTTP'},
{label: 'WebSocket API (two-way messages routed to Lambda)', value: 'WEBSOCKET'},
],
},
{
name: 'name',
label: 'API Name',
@@ -30,20 +60,47 @@ export function awsApiGatewaySchema(): ServiceSchema {
label: 'Description',
type: 'text',
required: false,
description: 'Optional description for the REST API.',
description: 'Optional description for the API.',
validation: {
maxLength: 1024,
message: 'Description must be 1024 characters or fewer.',
},
},
{
name: 'functionName',
label: 'Lambda function',
type: 'text',
required: false,
visibleWhen: httpOnly,
description: 'Optional. Sends every request to this function through a catch-all route, on a $default stage that deploys automatically. Leave empty to add routes yourself.',
},
{
name: 'routeSelectionExpression',
label: 'Route selection expression',
type: 'text',
required: false,
defaultValue: '$request.body.action',
visibleWhen: webSocketOnly,
description: 'Which field of each message picks its route. With $request.body.action, {"action": "sendMessage"} goes to the sendMessage route.',
},
{
name: 'stageName',
label: 'Stage',
type: 'text',
required: false,
defaultValue: 'dev',
visibleWhen: webSocketOnly,
description: 'Created with automatic deployment, so route changes go live right away.',
validation: {pattern: '^[A-Za-z0-9_-]{1,128}$', message: 'Use letters, numbers, hyphens and underscores'},
},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {resourceActions},
capabilities: {resourceActions, httpApiActions},
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
columns: [
{name: 'name', label: 'API Name'},
{name: 'type', label: 'Type'},
{name: 'createdAt', label: 'Created At'},
{name: 'protocol', label: 'Type', path: 'metadata.protocol', format: 'badge'},
{name: 'createdAt', label: 'Created At', format: 'relative'},
],
}
}
@@ -0,0 +1,132 @@
import type {CloudProvider, FieldSchema, ServiceSchema, TableColumnSchema} from './types'
export const ALARM_TYPES = ['metric', 'composite'] as const
export const ALARM_STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'] as const
export const ALARM_COMPARISONS = [
'GreaterThanThreshold',
'GreaterThanOrEqualToThreshold',
'LessThanThreshold',
'LessThanOrEqualToThreshold',
] as const
export const ALARM_PERIODS = ['60', '300', '900', '3600'] as const
export const ALARM_STATES = ['OK', 'ALARM', 'INSUFFICIENT_DATA'] as const
/** CloudWatch's alarm name rule: 1-255 characters, no control characters. */
export const ALARM_NAME_PATTERN = '^[^\\x00-\\x1f\\x7f]{1,255}$'
const alarmColumns: TableColumnSchema[] = [
{name: 'name', label: 'Alarm'},
{name: 'status', label: 'State', format: 'badge'},
{name: 'alarmType', label: 'Type', path: 'metadata.alarmType'},
{name: 'condition', label: 'Condition', path: 'metadata.condition', format: 'code', emptyText: '—'},
{name: 'stateUpdatedAt', label: 'State changed', path: 'metadata.stateUpdatedAt', format: 'relative'},
{name: 'actionsEnabled', label: 'Actions', path: 'metadata.actionsEnabled', format: 'boolean'},
]
const alarmFilters: FieldSchema[] = [{name: 'search', label: 'Search', type: 'text', required: false}]
const metricOnly = {field: 'alarmType', values: ['metric']}
export function awsMonitoringSchema(): ServiceSchema {
return {
cloud: 'aws',
service: 'monitoring',
displayName: 'Amazon CloudWatch alarms',
fields: [
{
name: 'alarmType',
label: 'Alarm type',
type: 'select',
required: true,
defaultValue: 'metric',
options: [
{label: 'Metric alarm (a threshold on one metric)', value: 'metric'},
{label: 'Composite alarm (a rule over other alarms)', value: 'composite'},
],
},
{
name: 'name',
label: 'Alarm name',
type: 'text',
required: true,
validation: {pattern: ALARM_NAME_PATTERN, message: 'Use up to 255 characters, without control characters'},
},
{name: 'description', label: 'Description', type: 'text', required: false},
{name: 'namespace', label: 'Namespace', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example AWS/Lambda, AWS/SQS or your own, such as MyApp.'},
{name: 'metricName', label: 'Metric name', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, description: 'For example Errors or Duration.'},
{
name: 'dimensions',
label: 'Dimensions',
type: 'textarea',
required: false,
visibleWhen: metricOnly,
description: 'One Name=Value per line, for example FunctionName=checkout. Leave empty for the metric across all dimensions.',
},
{
name: 'statistic',
label: 'Statistic',
type: 'select',
required: false,
defaultValue: 'Average',
visibleWhen: metricOnly,
options: ALARM_STATISTICS.map((value) => ({label: value, value})),
},
{
name: 'period',
label: 'Period',
type: 'select',
required: false,
defaultValue: '300',
visibleWhen: metricOnly,
options: [{label: '1 minute', value: '60'}, {label: '5 minutes', value: '300'}, {label: '15 minutes', value: '900'}, {label: '1 hour', value: '3600'}],
},
{
name: 'comparison',
label: 'Condition',
type: 'select',
required: false,
defaultValue: 'GreaterThanThreshold',
visibleWhen: metricOnly,
options: [
{label: 'Greater than the threshold', value: 'GreaterThanThreshold'},
{label: 'Greater than or equal to the threshold', value: 'GreaterThanOrEqualToThreshold'},
{label: 'Less than the threshold', value: 'LessThanThreshold'},
{label: 'Less than or equal to the threshold', value: 'LessThanOrEqualToThreshold'},
],
},
{name: 'threshold', label: 'Threshold', type: 'text', required: false, requiredWhen: {field: 'alarmType', equals: 'metric'}, visibleWhen: metricOnly, validation: {pattern: '^-?\\d+(\\.\\d+)?$', message: 'Use a number'}},
{name: 'evaluationPeriods', label: 'Periods to breach', type: 'text', required: false, defaultValue: '1', visibleWhen: metricOnly, description: 'How many periods in a row must breach before the alarm fires.', validation: {pattern: '^[1-9]\\d{0,2}$', message: 'Use a whole number from 1'}},
{
name: 'alarmRule',
label: 'Rule',
type: 'textarea',
required: false,
requiredWhen: {field: 'alarmType', equals: 'composite'},
visibleWhen: {field: 'alarmType', values: ['composite']},
description: 'For example ALARM("high-errors") OR ALARM("slow-checkout"). AND, OR, NOT and parentheses work; OK() and INSUFFICIENT_DATA() too.',
},
{name: 'topicArn', label: 'Notify SNS topic', type: 'text', required: false, description: 'Optional. An SNS topic ARN told when the alarm fires and when it recovers.'},
],
actions: ['list', 'create', 'delete', 'inspect'],
capabilities: {
resourceActions: [
{name: 'list', label: 'List alarms', enabled: true, status: 'available', runtimeRequired: true},
{name: 'create', label: 'Create alarm', enabled: true, status: 'available', runtimeRequired: true},
{name: 'delete', label: 'Delete alarm', enabled: true, status: 'available', runtimeRequired: true},
{name: 'inspect', label: 'Inspect alarm', enabled: true, status: 'available', runtimeRequired: true},
],
monitoringActions: [
{name: 'listMetrics', label: 'Browse metrics', enabled: true, status: 'available', runtimeRequired: true},
{name: 'getMetricData', label: 'Chart a metric', enabled: true, status: 'available', runtimeRequired: true},
{name: 'alarmHistory', label: 'Show alarm history', enabled: false, status: 'blocked', reason: "TheCloud's runtime does not implement DescribeAlarmHistory yet.", runtimeRequired: true},
{name: 'setAlarmState', label: 'Set an alarm state to test its actions', enabled: true, status: 'available', runtimeRequired: true},
{name: 'setAlarmActions', label: 'Turn alarm actions on or off', enabled: true, status: 'available', runtimeRequired: true},
],
},
filters: alarmFilters,
columns: alarmColumns,
}
}
export function monitoringSchemaFor(cloud: CloudProvider): ServiceSchema | null {
return cloud === 'aws' ? awsMonitoringSchema() : null
}
@@ -281,6 +281,13 @@ export const SERVICE_CATALOG = {
group: 'Integration',
order: 20,
},
monitoring: {
displayName: 'Monitoring',
displayNameByCloud: {aws: 'CloudWatch'},
iconKey: 'monitoring',
group: 'Observability',
order: 5,
},
logs: {
displayName: 'Logs',
displayNameByCloud: {aws: 'CloudWatch Logs'},
+170
View File
@@ -181,6 +181,20 @@ export type UserPoolActionName =
| 'describeClient'
| 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type HttpApiActionName =
| 'describe'
| 'createRoute'
| 'deleteRoute'
| 'setRouteAuthorizer'
| 'createStage'
| 'deleteStage'
| 'deploy'
| 'createAuthorizer'
| 'deleteAuthorizer'
| 'updateCors'
| 'sendRequest'
| 'testWebSocket'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> {
@@ -226,6 +240,9 @@ export interface ServiceSchema {
kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
// Child levels advertise separately from the resource level because a
// store can be readable at the leaf and writable at the collection —
// CloudWatch Logs creates streams but cannot delete an event.
@@ -457,6 +474,42 @@ export interface UserPoolSignInTestInput {
}
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */
export interface MetricDimension {
name: string
value: string
}
export interface MetricSummary {
namespace: string
metricName: string
dimensions: MetricDimension[]
}
export interface MetricSeriesQuery {
namespace: string
metricName: string
dimensions?: MetricDimension[]
statistic?: string
periodSeconds?: number
/** How far back from now, in minutes. */
rangeMinutes?: number
}
export interface MetricSeries {
label: string
statistic: string
periodSeconds: number
points: {timestamp: string; value: number}[]
}
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
export interface AlarmHistoryItem {
timestamp: string | null
type: string
summary: string
}
export interface DistributionInvalidation {
id: string
status: string
@@ -731,6 +784,107 @@ export interface ApiGatewayCorsResult {
proxyMethods: string[]
}
export type HttpApiProtocol = 'HTTP' | 'WEBSOCKET'
export interface HttpApiIntegrationOverview {
integrationId: string
type: string
uri: string | null
/** The Lambda function a proxy integration invokes, when it is one. */
functionName: string | null
payloadFormatVersion: string | null
}
export interface HttpApiRouteOverview {
routeId: string
/** `GET /orders/{id}`, `$default`, or a WebSocket key such as `$connect` or `sendMessage`. */
routeKey: string
integration: HttpApiIntegrationOverview | null
authorizationType: string
authorizerId: string | null
authorizationScopes: string[]
/** WebSocket only: whether the integration's response is sent back to the client. */
returnsResponse: boolean
}
export interface HttpApiStageOverview {
stageName: string
autoDeploy: boolean
deploymentId: string | null
lastUpdatedAt: string | null
/** Where the stage is served on the runtime endpoint: /execute-api/{api}/{stage} or, for WebSocket, /ws/{api}/{stage}. */
invokePath: string
}
export interface HttpApiAuthorizerOverview {
authorizerId: string
name: string
type: string
identitySource: string[]
issuer: string | null
audience: string[]
}
export interface HttpApiCors {
allowOrigins: string[]
allowMethods: string[]
allowHeaders: string[]
exposeHeaders: string[]
maxAgeSeconds: number | null
allowCredentials: boolean
}
export interface HttpApiOverview {
apiId: string
protocol: HttpApiProtocol
apiEndpoint: string | null
routeSelectionExpression: string | null
routes: HttpApiRouteOverview[]
stages: HttpApiStageOverview[]
authorizers: HttpApiAuthorizerOverview[]
cors: HttpApiCors | null
}
export interface HttpApiRouteInput {
routeKey: string
/** A Lambda function name; exactly one of functionName and httpUrl. */
functionName?: string
/** An HTTP backend the route proxies to; HTTP APIs only. */
httpUrl?: string
authorizerId?: string
/** WebSocket only: send the integration's response back to the client. */
returnsResponse?: boolean
}
export interface HttpApiStageInput {
stageName: string
autoDeploy: boolean
}
export interface HttpApiAuthorizerInput {
name: string
/** Fill the issuer and audience from a Cognito user pool and one of its app clients. */
userPoolId?: string
userPoolClientId?: string
issuer?: string
audience?: string[]
}
export interface HttpApiWebSocketTestInput {
stage: string
message: string
/** Sent as query string parameters on the connection request, for a $connect authorizer. */
query?: string
}
export interface HttpApiWebSocketTestResult {
connected: boolean
messages: string[]
closeCode: number | null
error: string | null
durationMs: number
}
export interface S3LambdaTriggerInput {
functionName: string
events: string[]
@@ -925,6 +1079,17 @@ export interface CloudServiceAdapter {
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
describeHttpApi?(apiId: string): Promise<HttpApiOverview>
createHttpApiRoute?(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview>
deleteHttpApiRoute?(apiId: string, routeId: string): Promise<void>
setHttpApiRouteAuthorizer?(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview>
createHttpApiStage?(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview>
deleteHttpApiStage?(apiId: string, stageName: string): Promise<void>
deployHttpApi?(apiId: string, stageName: string): Promise<HttpApiStageOverview>
createHttpApiAuthorizer?(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview>
deleteHttpApiAuthorizer?(apiId: string, authorizerId: string): Promise<void>
updateHttpApiCors?(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null>
testWebSocketApi?(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult>
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
@@ -1006,6 +1171,11 @@ export interface CloudServiceAdapter {
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource>
listMetrics?(query: {namespace?: string; search?: string}): Promise<MetricSummary[]>
getMetricSeries?(query: MetricSeriesQuery): Promise<MetricSeries>
describeAlarmHistory?(alarmName: string): Promise<AlarmHistoryItem[]>
setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise<void>
setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise<void>
createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
/** Clears the provider's locally captured email inbox, if it exposes one. */
+44 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/**
* Guards the schema-to-implementation contract for every registered adapter.
@@ -47,6 +47,29 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
deleteFargateProfile: 'deleteKubernetesFargateProfile',
}
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
listMetrics: 'listMetrics',
getMetricData: 'getMetricSeries',
alarmHistory: 'describeAlarmHistory',
setAlarmState: 'setAlarmState',
setAlarmActions: 'setAlarmActionsEnabled',
}
const METHOD_FOR_HTTP_API_ACTION: Record<HttpApiActionName, keyof CloudServiceAdapter> = {
describe: 'describeHttpApi',
createRoute: 'createHttpApiRoute',
deleteRoute: 'deleteHttpApiRoute',
setRouteAuthorizer: 'setHttpApiRouteAuthorizer',
createStage: 'createHttpApiStage',
deleteStage: 'deleteHttpApiStage',
deploy: 'deployHttpApi',
createAuthorizer: 'createHttpApiAuthorizer',
deleteAuthorizer: 'deleteHttpApiAuthorizer',
updateCors: 'updateHttpApiCors',
sendRequest: 'testApiGatewayRoute',
testWebSocket: 'testWebSocketApi',
}
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
setEnabled: 'setDistributionEnabled',
createInvalidation: 'createDistributionInvalidation',
@@ -147,6 +170,24 @@ describe('registered adapters honour their schema', () => {
).toBe('function')
}
const monitoringClaims = (adapter.schema().capabilities?.monitoringActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of monitoringClaims) {
const method = METHOD_FOR_MONITORING_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const httpApiClaims = (adapter.schema().capabilities?.httpApiActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of httpApiClaims) {
const method = METHOD_FOR_HTTP_API_ACTION[capability.name]
expect(
typeof adapter[method],
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
).toBe('function')
}
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -169,6 +210,8 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.httpApiActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []),
...(adapter.schema().capabilities?.itemActions ?? []),
]
+3 -1
View File
@@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks'
@@ -84,7 +85,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
new AwsIamAdapter(clients.iam),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId), undefined, clients.apiGatewayV2),
new AwsCloudFormationAdapter(clients.cloudformation),
new AwsSecretsAdapter(clients.secretsManager),
new AwsSesAdapter(),
@@ -102,6 +103,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(),
new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(),
+28
View File
@@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
@@ -770,6 +771,33 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['us-east-1_x', 'c1', input]])
})
test('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
service: 'monitoring',
schema: awsMonitoringSchema,
listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] },
getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} },
describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}],
setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) },
setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) },
})])
const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)})
const base = '/api/clouds/aws/services/monitoring'
expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}])
expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors')
expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed')
expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200)
expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200)
expect(calls).toEqual([
['metrics', {namespace: 'App', search: undefined}],
['series', 'Errors', 60],
['state', 'high-errors', 'ALARM', 'test'],
['actions', 'high-errors', false],
])
})
test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', {
+173
View File
@@ -1,6 +1,7 @@
import {Hono} from 'hono'
import type {Context} from 'hono'
import type {
AlarmStateValue,
CloudProvider,
CloudServiceType,
CreateDatabaseSnapshotInput,
@@ -461,6 +462,49 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json())))
})
app.get('/:cloud/services/monitoring/metrics', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).listMetrics(cloud, {namespace: c.req.query('namespace') || undefined, search: c.req.query('search') || undefined})))
})
app.post('/:cloud/services/monitoring/metric-data', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).getMetricSeries(cloud, await c.req.json())))
})
app.get('/:cloud/services/monitoring/resources/:id/history', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => c.json(await svc(c).describeAlarmHistory(cloud, c.req.param('id'))))
})
app.put('/:cloud/services/monitoring/resources/:id/state', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{state?: AlarmStateValue; reason?: string}>()
await svc(c).setAlarmState(cloud, c.req.param('id'), body.state as AlarmStateValue, body.reason ?? '')
return c.json({ok: true})
})
})
app.put('/:cloud/services/monitoring/resources/:id/actions', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
return withRuntime(c, async () => {
const body = await c.req.json<{enabled?: boolean}>()
await svc(c).setAlarmActionsEnabled(cloud, c.req.param('id'), body.enabled as boolean)
return c.json({ok: true})
})
})
app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -759,6 +803,131 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
})
})
// HTTP and WebSocket APIs (API Gateway v2) live on the same page as REST APIs.
app.get('/:cloud/services/apigateway/resources/:id/http-api', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).describeHttpApi(cloud, c.req.param('id'))))
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/routes', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const route = await svc(c).createHttpApiRoute(cloud, c.req.param('id'), {
routeKey: typeof input.routeKey === 'string' ? input.routeKey : '',
functionName: typeof input.functionName === 'string' ? input.functionName : undefined,
httpUrl: typeof input.httpUrl === 'string' ? input.httpUrl : undefined,
authorizerId: typeof input.authorizerId === 'string' ? input.authorizerId : undefined,
returnsResponse: input.returnsResponse === true,
})
return c.json(route, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteHttpApiRoute(cloud, c.req.param('id'), c.req.param('routeId'))
return c.json({ok: true})
})
})
app.put('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<{authorizerId?: unknown}>()
const authorizerId = typeof input.authorizerId === 'string' && input.authorizerId ? input.authorizerId : null
return c.json(await svc(c).setHttpApiRouteAuthorizer(cloud, c.req.param('id'), c.req.param('routeId'), authorizerId))
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const stage = await svc(c).createHttpApiStage(cloud, c.req.param('id'), {
stageName: typeof input.stageName === 'string' ? input.stageName : '',
autoDeploy: input.autoDeploy === true,
})
return c.json(stage, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteHttpApiStage(cloud, c.req.param('id'), c.req.param('stageName'))
return c.json({ok: true})
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => c.json(await svc(c).deployHttpApi(cloud, c.req.param('id'), c.req.param('stageName')), 201))
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/authorizers', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
const authorizer = await svc(c).createHttpApiAuthorizer(cloud, c.req.param('id'), {
name: typeof input.name === 'string' ? input.name : '',
userPoolId: typeof input.userPoolId === 'string' ? input.userPoolId : undefined,
userPoolClientId: typeof input.userPoolClientId === 'string' ? input.userPoolClientId : undefined,
issuer: typeof input.issuer === 'string' ? input.issuer : undefined,
audience: Array.isArray(input.audience) ? input.audience.filter((value): value is string => typeof value === 'string') : undefined,
})
return c.json(authorizer, 201)
})
})
app.delete('/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
await svc(c).deleteHttpApiAuthorizer(cloud, c.req.param('id'), c.req.param('authorizerId'))
return c.json({ok: true})
})
})
app.put('/:cloud/services/apigateway/resources/:id/http-api/cors', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<{cors?: Record<string, unknown> | null}>()
const cors = input.cors ? {
allowOrigins: stringList(input.cors.allowOrigins),
allowMethods: stringList(input.cors.allowMethods),
allowHeaders: stringList(input.cors.allowHeaders),
exposeHeaders: stringList(input.cors.exposeHeaders),
maxAgeSeconds: typeof input.cors.maxAgeSeconds === 'number' ? input.cors.maxAgeSeconds : null,
allowCredentials: input.cors.allowCredentials === true,
} : null
return c.json({cors: await svc(c).updateHttpApiCors(cloud, c.req.param('id'), cors)})
})
})
app.post('/:cloud/services/apigateway/resources/:id/http-api/websocket-test', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
return withRuntime(c, async () => {
const input = await c.req.json<Record<string, unknown>>()
return c.json(await svc(c).testWebSocketApi(cloud, c.req.param('id'), {
stage: typeof input.stage === 'string' ? input.stage : '',
message: typeof input.message === 'string' ? input.message : '',
query: typeof input.query === 'string' ? input.query : undefined,
}))
})
})
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
@@ -1618,6 +1787,10 @@ function isStringRecord(value: unknown): value is Record<string, string> {
&& Object.values(value).every((item) => typeof item === 'string')
}
function stringList(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
}
function stringHeaders(value: unknown): Record<string, string> {
if (value === undefined) return {}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
@@ -30,6 +30,16 @@ import type {
ApiGatewayDeleteRouteInput,
ApiGatewayCorsInput,
ApiGatewayCorsResult,
HttpApiAuthorizerInput,
HttpApiAuthorizerOverview,
HttpApiCors,
HttpApiOverview,
HttpApiRouteInput,
HttpApiRouteOverview,
HttpApiStageInput,
HttpApiStageOverview,
HttpApiWebSocketTestInput,
HttpApiWebSocketTestResult,
S3LambdaTriggerInput,
S3LambdaTriggerOverview,
S3BucketWebsiteConfiguration,
@@ -83,6 +93,11 @@ import type {
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction,
} from '../cloud-spi/types'
@@ -325,6 +340,56 @@ export class CloudProxyService {
return adapter.enableApiGatewayCors(apiId, input)
}
async describeHttpApi(cloud: CloudProvider, apiId: string): Promise<HttpApiOverview> {
return this.httpApiAdapter(cloud, 'describeHttpApi').describeHttpApi!(apiId)
}
async createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiRoute').createHttpApiRoute!(apiId, input)
}
async deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiRoute').deleteHttpApiRoute!(apiId, routeId)
}
async setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
return this.httpApiAdapter(cloud, 'setHttpApiRouteAuthorizer').setHttpApiRouteAuthorizer!(apiId, routeId, authorizerId)
}
async createHttpApiStage(cloud: CloudProvider, apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiStage').createHttpApiStage!(apiId, input)
}
async deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiStage').deleteHttpApiStage!(apiId, stageName)
}
async deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
return this.httpApiAdapter(cloud, 'deployHttpApi').deployHttpApi!(apiId, stageName)
}
async createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
return this.httpApiAdapter(cloud, 'createHttpApiAuthorizer').createHttpApiAuthorizer!(apiId, input)
}
async deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
await this.httpApiAdapter(cloud, 'deleteHttpApiAuthorizer').deleteHttpApiAuthorizer!(apiId, authorizerId)
}
async updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
return this.httpApiAdapter(cloud, 'updateHttpApiCors').updateHttpApiCors!(apiId, cors)
}
async testWebSocketApi(cloud: CloudProvider, apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
return this.httpApiAdapter(cloud, 'testWebSocketApi').testWebSocketApi!(apiId, input)
}
private httpApiAdapter(cloud: CloudProvider, method: keyof CloudServiceAdapter): CloudServiceAdapter {
const adapter = this.requireAdapter(cloud, 'apigateway')
if (typeof adapter[method] !== 'function') throw new NotSupportedError(`HTTP and WebSocket APIs are not supported for ${cloud}`)
return adapter
}
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
const adapter = this.requireAdapter(cloud, 'storage')
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
@@ -834,6 +899,36 @@ export class CloudProxyService {
return adapter.testUserPoolSignIn(poolId, input)
}
async listMetrics(cloud: CloudProvider, query: {namespace?: string; search?: string}): Promise<MetricSummary[]> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.listMetrics) throw new NotSupportedError(`Metrics are not supported for ${cloud}/monitoring`)
return adapter.listMetrics(query)
}
async getMetricSeries(cloud: CloudProvider, query: MetricSeriesQuery): Promise<MetricSeries> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.getMetricSeries) throw new NotSupportedError(`Metric data is not supported for ${cloud}/monitoring`)
return adapter.getMetricSeries(query)
}
async describeAlarmHistory(cloud: CloudProvider, alarmName: string): Promise<AlarmHistoryItem[]> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.describeAlarmHistory) throw new NotSupportedError(`Alarm history is not supported for ${cloud}/monitoring`)
return adapter.describeAlarmHistory(alarmName)
}
async setAlarmState(cloud: CloudProvider, alarmName: string, state: AlarmStateValue, reason: string): Promise<void> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.setAlarmState) throw new NotSupportedError(`Setting alarm states is not supported for ${cloud}/monitoring`)
await adapter.setAlarmState(alarmName, state, reason)
}
async setAlarmActionsEnabled(cloud: CloudProvider, alarmName: string, enabled: boolean): Promise<void> {
const adapter = this.requireAdapter(cloud, 'monitoring')
if (!adapter.setAlarmActionsEnabled) throw new NotSupportedError(`Alarm actions are not supported for ${cloud}/monitoring`)
await adapter.setAlarmActionsEnabled(alarmName, enabled)
}
async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'cdn')
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
+36
View File
@@ -130,6 +130,26 @@ export const apiEndpointKeys = {
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
moveTasks: "clouds.services.messaging.move-tasks",
},
httpApi: {
describe: "clouds.services.apigateway.http-api.describe",
createRoute: "clouds.services.apigateway.http-api.routes.create",
deleteRoute: "clouds.services.apigateway.http-api.routes.delete",
setRouteAuthorizer: "clouds.services.apigateway.http-api.routes.authorizer",
createStage: "clouds.services.apigateway.http-api.stages.create",
deleteStage: "clouds.services.apigateway.http-api.stages.delete",
deploy: "clouds.services.apigateway.http-api.stages.deploy",
createAuthorizer: "clouds.services.apigateway.http-api.authorizers.create",
deleteAuthorizer: "clouds.services.apigateway.http-api.authorizers.delete",
cors: "clouds.services.apigateway.http-api.cors",
webSocketTest: "clouds.services.apigateway.http-api.websocket-test",
},
monitoring: {
metrics: "clouds.services.monitoring.metrics",
metricData: "clouds.services.monitoring.metric-data",
history: "clouds.services.monitoring.history",
state: "clouds.services.monitoring.state",
actions: "clouds.services.monitoring.actions",
},
cdn: {
enabled: "clouds.services.cdn.enabled",
invalidations: "clouds.services.cdn.invalidations",
@@ -890,6 +910,22 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST",
},
],
[apiEndpointKeys.clouds.httpApi.describe, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api", method: "GET"}],
[apiEndpointKeys.clouds.httpApi.createRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer", method: "PUT"}],
[apiEndpointKeys.clouds.httpApi.createStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.deploy, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.createAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers", method: "POST"}],
[apiEndpointKeys.clouds.httpApi.deleteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId", method: "DELETE"}],
[apiEndpointKeys.clouds.httpApi.cors, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/cors", method: "PUT"}],
[apiEndpointKeys.clouds.httpApi.webSocketTest, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/websocket-test", method: "POST"}],
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
[
apiEndpointKeys.clouds.cdn.enabled,
{
@@ -33,6 +33,11 @@ import type {
UserPoolSignInTestResult,
UserPoolClientSettings,
DistributionInvalidation,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
UpdateUserPoolClientOAuthInput,
UserPoolUserAction,
LogsInsightsQueryInput,
@@ -1477,6 +1482,203 @@ export async function testUserPoolSignIn(
return res.data;
}
export async function listMetrics(
cloud: CloudProvider,
query: { namespace?: string; search?: string } = {},
signal?: AbortSignal,
): Promise<MetricSummary[]> {
const res = await apiClient.call<MetricSummary[]>(
apiEndpointKeys.clouds.monitoring.metrics,
requestOptions(cloud, "monitoring", { signal, params: { namespace: query.namespace || undefined, search: query.search || undefined } }),
{ cloud },
);
return res.data;
}
export async function getMetricSeries(
cloud: CloudProvider,
query: MetricSeriesQuery,
signal?: AbortSignal,
): Promise<MetricSeries> {
const res = await apiClient.call<MetricSeries, MetricSeriesQuery>(
apiEndpointKeys.clouds.monitoring.metricData,
requestOptions(cloud, "monitoring", { signal, body: query }),
{ cloud },
);
return res.data;
}
export async function describeAlarmHistory(
cloud: CloudProvider,
alarmName: string,
signal?: AbortSignal,
): Promise<AlarmHistoryItem[]> {
const res = await apiClient.call<AlarmHistoryItem[]>(
apiEndpointKeys.clouds.monitoring.history,
requestOptions(cloud, "monitoring", { signal }),
{ cloud, id: alarmName },
);
return res.data;
}
export async function setAlarmState(
cloud: CloudProvider,
alarmName: string,
state: AlarmStateValue,
reason: string,
): Promise<void> {
await apiClient.call<void, { state: AlarmStateValue; reason: string }>(
apiEndpointKeys.clouds.monitoring.state,
requestOptions(cloud, "monitoring", { body: { state, reason } }),
{ cloud, id: alarmName },
);
}
export async function setAlarmActionsEnabled(
cloud: CloudProvider,
alarmName: string,
enabled: boolean,
): Promise<void> {
await apiClient.call<void, { enabled: boolean }>(
apiEndpointKeys.clouds.monitoring.actions,
requestOptions(cloud, "monitoring", { body: { enabled } }),
{ cloud, id: alarmName },
);
}
export type HttpApiProtocol = "HTTP" | "WEBSOCKET";
export interface HttpApiIntegrationOverview {
integrationId: string;
type: string;
uri: string | null;
functionName: string | null;
payloadFormatVersion: string | null;
}
export interface HttpApiRouteOverview {
routeId: string;
routeKey: string;
integration: HttpApiIntegrationOverview | null;
authorizationType: string;
authorizerId: string | null;
authorizationScopes: string[];
returnsResponse: boolean;
}
export interface HttpApiStageOverview {
stageName: string;
autoDeploy: boolean;
deploymentId: string | null;
lastUpdatedAt: string | null;
invokePath: string;
}
export interface HttpApiAuthorizerOverview {
authorizerId: string;
name: string;
type: string;
identitySource: string[];
issuer: string | null;
audience: string[];
}
export interface HttpApiCors {
allowOrigins: string[];
allowMethods: string[];
allowHeaders: string[];
exposeHeaders: string[];
maxAgeSeconds: number | null;
allowCredentials: boolean;
}
export interface HttpApiOverview {
apiId: string;
protocol: HttpApiProtocol;
apiEndpoint: string | null;
routeSelectionExpression: string | null;
routes: HttpApiRouteOverview[];
stages: HttpApiStageOverview[];
authorizers: HttpApiAuthorizerOverview[];
cors: HttpApiCors | null;
}
export interface HttpApiRouteInput {
routeKey: string;
functionName?: string;
httpUrl?: string;
authorizerId?: string;
returnsResponse?: boolean;
}
export interface HttpApiAuthorizerInput {
name: string;
userPoolId?: string;
userPoolClientId?: string;
issuer?: string;
audience?: string[];
}
export interface HttpApiWebSocketTestResult {
connected: boolean;
messages: string[];
closeCode: number | null;
error: string | null;
durationMs: number;
}
export async function describeHttpApi(cloud: CloudProvider, apiId: string, signal?: AbortSignal): Promise<HttpApiOverview> {
const res = await apiClient.call<HttpApiOverview>(apiEndpointKeys.clouds.httpApi.describe, requestOptions(cloud, "apigateway", { signal }), { cloud, id: apiId });
return res.data;
}
export async function createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
const res = await apiClient.call<HttpApiRouteOverview, HttpApiRouteInput>(apiEndpointKeys.clouds.httpApi.createRoute, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteRoute, requestOptions(cloud, "apigateway"), { cloud, id: apiId, routeId });
}
export async function setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
const res = await apiClient.call<HttpApiRouteOverview, { authorizerId: string | null }>(apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, requestOptions(cloud, "apigateway", { body: { authorizerId } }), { cloud, id: apiId, routeId });
return res.data;
}
export async function createHttpApiStage(cloud: CloudProvider, apiId: string, input: { stageName: string; autoDeploy: boolean }): Promise<HttpApiStageOverview> {
const res = await apiClient.call<HttpApiStageOverview, typeof input>(apiEndpointKeys.clouds.httpApi.createStage, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteStage, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
}
export async function deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
const res = await apiClient.call<HttpApiStageOverview>(apiEndpointKeys.clouds.httpApi.deploy, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
return res.data;
}
export async function createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
const res = await apiClient.call<HttpApiAuthorizerOverview, HttpApiAuthorizerInput>(apiEndpointKeys.clouds.httpApi.createAuthorizer, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteAuthorizer, requestOptions(cloud, "apigateway"), { cloud, id: apiId, authorizerId });
}
export async function updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
const res = await apiClient.call<{ cors: HttpApiCors | null }, { cors: HttpApiCors | null }>(apiEndpointKeys.clouds.httpApi.cors, requestOptions(cloud, "apigateway", { body: { cors } }), { cloud, id: apiId });
return res.data.cors;
}
export async function testWebSocketApi(cloud: CloudProvider, apiId: string, input: { stage: string; message: string; query?: string }): Promise<HttpApiWebSocketTestResult> {
const res = await apiClient.call<HttpApiWebSocketTestResult, typeof input>(apiEndpointKeys.clouds.httpApi.webSocketTest, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
return res.data;
}
export async function setDistributionEnabled(
cloud: CloudProvider,
distributionId: string,
@@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource'
import {CopyValueButton} from './CopyValueButton'
import {LogsQueryPanel} from './LogsQueryPanel'
import {ServiceIcon} from '@/components/serviceIcons'
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
@@ -138,8 +139,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select a REST API</h3>
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
<h3>Select an API</h3>
<p>Select an API to see its routes, or create one.</p>
</div>
</section>
)
@@ -513,49 +514,6 @@ function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestRes
</div>
}
function invokeEndpoint(runtimeEndpoint?: string): string | null {
if (!runtimeEndpoint) return null
try {
const endpoint = new URL(runtimeEndpoint)
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
endpoint.hostname = window.location.hostname
}
return endpoint.toString().replace(/\/$/, '')
} catch {
return runtimeEndpoint.replace(/\/$/, '')
}
}
function isDockerBridgeAddress(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
return octets.length === 4
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
&& octets[0] === 172
&& octets[1] >= 16
&& octets[1] <= 31
}
function parseHeaders(value: string): Record<string, string> {
let parsed: unknown
try {
parsed = JSON.parse(value || '{}')
} catch {
throw new Error('Request headers must be valid JSON.')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
throw new Error('Request headers must be a JSON object with string values.')
}
return parsed as Record<string, string>
}
function formatResponseBody(body: string): string {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch {
return body || '(empty response body)'
}
}
function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
let headers: Record<string, string> = {}
try {
@@ -0,0 +1,74 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {CloudWatchAlarmPanel} from './CloudWatchAlarmPanel'
import {HttpError} from '@/api/HttpClient'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
getCloudResource: vi.fn(),
describeAlarmHistory: vi.fn(),
getMetricSeries: vi.fn(),
setAlarmState: vi.fn(),
setAlarmActionsEnabled: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
const alarm: CloudResource = {
id: 'high-errors', name: 'high-errors', cloud: 'aws', service: 'monitoring', type: 'alarm', region: 'us-east-1', createdAt: null, status: 'ALARM',
metadata: {
alarmType: 'metric', stateReason: 'Threshold crossed', actionsEnabled: true, alarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], okActions: [],
namespace: 'App', metricName: 'Errors', dimensions: [], statistic: 'Sum', threshold: 5, condition: 'Errors Sum > 5 for 1 × 5 min',
},
}
function renderPanel() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CloudWatchAlarmPanel resource={alarm} runtimeReachable/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.getCloudResource.mockResolvedValue(alarm)
api.describeAlarmHistory.mockResolvedValue([{timestamp: null, type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
api.getMetricSeries.mockResolvedValue({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 7}]})
api.setAlarmState.mockResolvedValue(undefined)
api.setAlarmActionsEnabled.mockResolvedValue(undefined)
})
test('shows the state, its metric with the threshold, the targets and the history', async () => {
renderPanel()
expect(screen.getByText('Threshold crossed')).toBeInTheDocument()
expect(await screen.findByText('Threshold 5')).toBeInTheDocument()
expect(screen.getByText('arn:aws:sns:us-east-1:000000000000:alerts')).toBeInTheDocument()
expect(await screen.findByText('Alarm updated from OK to ALARM')).toBeInTheDocument()
expect(api.getMetricSeries).toHaveBeenCalledWith('aws', expect.objectContaining({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 180}), expect.anything())
})
test('sets a state to test the actions, and turns the actions off', async () => {
const user = userEvent.setup()
renderPanel()
const form = await screen.findByRole('form', {name: 'Set alarm state'})
await user.selectOptions(within(form).getByLabelText('State'), 'OK')
await user.type(within(form).getByLabelText('Reason'), 'Testing')
await user.click(within(form).getByRole('button', {name: 'Set state'}))
expect(api.setAlarmState).toHaveBeenCalledWith('aws', 'high-errors', 'OK', 'Testing')
expect(await screen.findByText(/Set to OK/)).toBeInTheDocument()
await user.click(within(form).getByRole('button', {name: 'Turn actions off'}))
expect(api.setAlarmActionsEnabled).toHaveBeenCalledWith('aws', 'high-errors', false)
})
test('says when the runtime does not record alarm history, without an error', async () => {
api.describeAlarmHistory.mockRejectedValue(new HttpError(501, 'Not Implemented', {code: 'operation_not_implemented', error: 'Operation is not implemented by the selected runtime'}, '/history'))
renderPanel()
expect(await screen.findByText(/does not record alarm history yet/)).toBeInTheDocument()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
@@ -0,0 +1,154 @@
import {RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {describeAlarmHistory, getCloudResource, getMetricSeries, setAlarmActionsEnabled, setAlarmState} from '@/api/cloudProxyClient'
import {HttpError} from '@/api/HttpClient'
import {MetricChart} from '@/components/MetricChart'
import {RANGES} from '@/lib/metricChart'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {AlarmStateValue, CloudResource, MetricDimension} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
const STATES: AlarmStateValue[] = ['ALARM', 'OK', 'INSUFFICIENT_DATA']
export function CloudWatchAlarmPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [rangeMinutes, setRangeMinutes] = useState(180)
const [nextState, setNextState] = useState<AlarmStateValue>('ALARM')
const [reason, setReason] = useState('')
const [notice, setNotice] = useState('')
const alarmKey = ['cloudwatch-alarm', accountId, resource?.id]
const historyKey = ['cloudwatch-alarm-history', accountId, resource?.id]
useEffect(() => {
setNotice('')
setReason('')
}, [resource?.id])
const alarmQuery = useQuery({
queryKey: alarmKey,
queryFn: ({signal}) => getCloudResource('aws', 'monitoring', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const historyQuery = useQuery({
queryKey: historyKey,
queryFn: ({signal}) => describeAlarmHistory('aws', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const alarm = alarmQuery.data ?? resource
const metadata = alarm?.metadata ?? {}
const isMetric = metadata.alarmType === 'metric' && typeof metadata.metricName === 'string'
const dimensions = Array.isArray(metadata.dimensions) ? metadata.dimensions as MetricDimension[] : []
const seriesQuery = useQuery({
queryKey: ['cloudwatch-alarm-series', accountId, resource?.id, rangeMinutes, metadata.metricName, metadata.statistic],
queryFn: ({signal}) => getMetricSeries('aws', {
namespace: String(metadata.namespace),
metricName: String(metadata.metricName),
dimensions,
statistic: String(metadata.statistic ?? 'Average'),
rangeMinutes,
}, signal),
enabled: Boolean(resource) && runtimeReachable && isMetric,
})
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: alarmKey}),
queryClient.invalidateQueries({queryKey: historyKey}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'monitoring']}),
])
const stateMutation = useMutation({
mutationFn: () => setAlarmState('aws', resource?.id ?? '', nextState, reason),
onSuccess: () => {
setNotice(`Set to ${nextState}. Its actions ran if they are on; CloudWatch puts it back at the next evaluation.`)
setReason('')
return refresh()
},
})
const actionsMutation = useMutation({
mutationFn: (enabled: boolean) => setAlarmActionsEnabled('aws', resource?.id ?? '', enabled),
onSuccess: (_result, enabled) => {
setNotice(enabled ? 'Actions on: the alarm notifies its targets when its state changes.' : 'Actions off: the alarm still changes state but notifies nobody.')
return refresh()
},
})
if (!resource || !alarm) {
return <section className="table-panel"><div className="empty compact"><h3>Select an alarm</h3><p>Choose an alarm to see its metric, test its actions and read its history.</p></div></section>
}
const state = alarm.status ?? 'INSUFFICIENT_DATA'
const actionsEnabled = metadata.actionsEnabled !== false
const targets = [...new Set([...list(metadata.alarmActions), ...list(metadata.okActions)])]
const disabled = !runtimeReachable || stateMutation.isPending || actionsMutation.isPending
// TheCloud's runtime does not implement DescribeAlarmHistory yet; say so rather than fail.
const historyUnsupported = isNotImplemented(historyQuery.error)
const error = alarmQuery.error ?? (historyUnsupported ? null : historyQuery.error) ?? seriesQuery.error ?? stateMutation.error ?? actionsMutation.error
const history = historyQuery.data ?? []
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Alarm ${alarm.name}`}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Alarm</p>
<span className="cloudwatch-state-line"><span className={`badge ${state.toLowerCase().replace(/_/g, '-')}`}>{state}</span> <span className="muted">{String(metadata.stateReason ?? '')}</span></span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || alarmQuery.isFetching} onClick={() => { void refresh(); void seriesQuery.refetch() }}><RefreshCw size={14}/>{alarmQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<dl className="cognito-facts cloudwatch-facts">
<dt>{isMetric ? 'Condition' : 'Rule'}</dt><dd className="mono">{String(metadata.condition ?? '—')}</dd>
{isMetric && <><dt>Metric</dt><dd className="mono">{String(metadata.namespace)} / {String(metadata.metricName)}{dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</dd></>}
<dt>State changed</dt><dd>{formatDateTime(typeof metadata.stateUpdatedAt === 'string' ? metadata.stateUpdatedAt : null) ?? '—'}</dd>
<dt>Notifies</dt><dd className="mono">{targets.length ? targets.join(', ') : 'No actions'}</dd>
</dl>
{isMetric && <>
<div className="cloudwatch-range" role="group" aria-label="Time range">
{RANGES.map((range) => (
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
))}
</div>
<div className="cloudwatch-chart">
<p className="cloudwatch-chart-title">{String(metadata.metricName)} <span className="muted">· {String(metadata.statistic ?? '')}</span></p>
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
{seriesQuery.data && <MetricChart series={seriesQuery.data} threshold={typeof metadata.threshold === 'number' ? metadata.threshold : null}/>}
</div>
</>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">Test the alarm</p><span className="muted">Sets the state now, which runs its actions. CloudWatch evaluates it again at the next period.</span></div></div>
<form className="dynamic-form" aria-label="Set alarm state" onSubmit={(event) => { event.preventDefault(); setNotice(''); stateMutation.mutate() }}>
<label className="dynamic-field"><span>State</span>
<select className="input" value={nextState} onChange={(event) => setNextState(event.target.value as AlarmStateValue)} disabled={disabled}>
{STATES.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Reason</span><input className="input" value={reason} placeholder="Testing the on-call notification" onChange={(event) => setReason(event.target.value)} disabled={disabled}/></label>
<div className="cloudwatch-form-actions">
<button className="button primary" type="submit" disabled={disabled}>{stateMutation.isPending ? 'Setting' : 'Set state'}</button>
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); actionsMutation.mutate(!actionsEnabled) }}>{actionsEnabled ? 'Turn actions off' : 'Turn actions on'}</button>
</div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">History</p>{history.length > 0 && <span className="muted">Newest first.</span>}</div></div>
{historyUnsupported && <p className="muted padded">TheCloud's runtime does not record alarm history yet. State changes still notify the alarm's actions.</p>}
{historyQuery.isSuccess && history.length === 0 && <p className="muted padded">No history yet.</p>}
{history.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>When</th><th>Type</th><th>What happened</th></tr></thead><tbody>{history.map((item, index) => (
<tr key={`${item.timestamp}-${index}`}><td>{formatDateTime(item.timestamp) ?? '—'}</td><td><code>{item.type}</code></td><td>{item.summary}</td></tr>
))}</tbody></table></div>}
</section>
}
function isNotImplemented(error: unknown): boolean {
return error instanceof HttpError && typeof error.body === 'object' && error.body !== null
&& (error.body as {code?: unknown}).code === 'operation_not_implemented'
}
function list(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}
@@ -0,0 +1,38 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {CloudWatchMetricsExplorer} from './CloudWatchMetricsExplorer'
const api = vi.hoisted(() => ({listMetrics: vi.fn(), getMetricSeries: vi.fn()}))
vi.mock('@/api/cloudProxyClient', () => api)
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
beforeEach(() => {
vi.clearAllMocks()
api.listMetrics.mockResolvedValue([
{namespace: 'App', metricName: 'Errors', dimensions: []},
{namespace: 'AWS/Lambda', metricName: 'Invocations', dimensions: [{name: 'FunctionName', value: 'checkout'}]},
])
api.getMetricSeries.mockResolvedValue({label: 'Invocations', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 12}]})
})
function renderExplorer() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><CloudWatchMetricsExplorer runtimeReachable/></QueryClientProvider>)
}
test('filters metrics by namespace and charts the chosen one', async () => {
const user = userEvent.setup()
renderExplorer()
expect(await screen.findByText('2 metrics in 2 namespaces. Pick one to chart it.')).toBeInTheDocument()
await user.selectOptions(screen.getByLabelText('Namespace'), 'AWS/Lambda')
expect(screen.queryByText('Errors')).not.toBeInTheDocument()
await user.click(screen.getByRole('button', {name: 'Chart Invocations for checkout'}))
await user.selectOptions(screen.getByLabelText('Statistic'), 'Sum')
expect(await screen.findByRole('img', {name: /Invocations \(Sum\)/})).toBeInTheDocument()
expect(api.getMetricSeries).toHaveBeenLastCalledWith('aws', expect.objectContaining({namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'Sum', dimensions: [{name: 'FunctionName', value: 'checkout'}]}), expect.anything())
})
@@ -0,0 +1,86 @@
import {RefreshCw} from 'lucide-react'
import {useQuery} from '@tanstack/react-query'
import {useMemo, useState} from 'react'
import {getMetricSeries, listMetrics} from '@/api/cloudProxyClient'
import {MetricChart} from '@/components/MetricChart'
import {RANGES} from '@/lib/metricChart'
import {useAccountId} from '@/lib/accountStore'
import type {MetricSummary} from '@/types/resource'
const STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']
export function CloudWatchMetricsExplorer({runtimeReachable}: {runtimeReachable: boolean}) {
const accountId = useAccountId()
const [namespace, setNamespace] = useState('')
const [search, setSearch] = useState('')
const [selected, setSelected] = useState<MetricSummary | null>(null)
const [statistic, setStatistic] = useState('Average')
const [rangeMinutes, setRangeMinutes] = useState(180)
const metricsQuery = useQuery({
queryKey: ['cloudwatch-metrics', accountId],
queryFn: ({signal}) => listMetrics('aws', {}, signal),
enabled: runtimeReachable,
})
const seriesQuery = useQuery({
queryKey: ['cloudwatch-series', accountId, selected && metricKey(selected), statistic, rangeMinutes],
queryFn: ({signal}) => getMetricSeries('aws', {...selected!, statistic, rangeMinutes}, signal),
enabled: runtimeReachable && selected !== null,
})
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data])
const namespaces = useMemo(() => [...new Set(metrics.map((metric) => metric.namespace))].sort(), [metrics])
const term = search.trim().toLowerCase()
const shown = metrics.filter((metric) => (!namespace || metric.namespace === namespace)
&& (!term || [metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].some((value) => value.toLowerCase().includes(term))))
const error = metricsQuery.error ?? seriesQuery.error
return <section className="table-panel" style={{marginTop: 16}} aria-label="Metrics">
<div className="input-row resource-table-bar">
<div><p className="eyebrow">Metrics</p><span className="muted">{metrics.length} {metrics.length === 1 ? 'metric' : 'metrics'} in {namespaces.length} {namespaces.length === 1 ? 'namespace' : 'namespaces'}. Pick one to chart it.</span></div>
<button className="button" type="button" disabled={!runtimeReachable || metricsQuery.isFetching} onClick={() => { void metricsQuery.refetch(); if (selected) void seriesQuery.refetch() }}><RefreshCw size={14}/>{metricsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<div className="cloudwatch-filters">
<label className="dynamic-field"><span>Namespace</span>
<select className="input" value={namespace} onChange={(event) => setNamespace(event.target.value)}>
<option value="">All namespaces</option>
{namespaces.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Search</span><input className="input" value={search} placeholder="Metric or dimension" onChange={(event) => setSearch(event.target.value)}/></label>
</div>
{selected && <div className="cloudwatch-chart">
<div className="cloudwatch-chart-head">
<p className="cloudwatch-chart-title">{selected.metricName} <span className="muted">· {selected.namespace}{selected.dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</span></p>
<div className="cloudwatch-range" role="group" aria-label="Chart settings">
<select className="input" aria-label="Statistic" value={statistic} onChange={(event) => setStatistic(event.target.value)}>
{STATISTICS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
{RANGES.map((range) => (
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
))}
</div>
</div>
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
{seriesQuery.data && <MetricChart series={seriesQuery.data}/>}
</div>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
{metricsQuery.isSuccess && metrics.length === 0 && <div className="empty compact"><h3>No metrics yet</h3><p>Metrics appear when services or your apps publish them, for example with PutMetricData.</p></div>}
{shown.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Metric</th><th>Namespace</th><th>Dimensions</th><th/></tr></thead><tbody>{shown.map((metric) => {
const isSelected = selected !== null && metricKey(selected) === metricKey(metric)
return <tr key={metricKey(metric)} aria-current={isSelected ? 'true' : undefined}>
<td className="mono">{metric.metricName}</td>
<td className="mono">{metric.namespace}</td>
<td className="mono">{metric.dimensions.length ? metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`).join(', ') : '—'}</td>
<td className="cognito-row-actions-cell"><button className="button compact" type="button" aria-label={`Chart ${metric.metricName}${metric.dimensions.length ? ` for ${metric.dimensions.map((dimension) => dimension.value).join(', ')}` : ''}`} onClick={() => setSelected(isSelected ? null : metric)}>{isSelected ? 'Hide' : 'Chart'}</button></td>
</tr>
})}</tbody></table></div>}
{metricsQuery.isSuccess && metrics.length > 0 && shown.length === 0 && <p className="muted padded">No metrics match.</p>}
</section>
}
function metricKey(metric: MetricSummary): string {
return [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].join('|')
}
@@ -48,6 +48,7 @@ import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
import { AppConfigPanel } from "@/components/AppConfigPanel";
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
import { HttpApiPanel } from "@/components/HttpApiPanel";
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
@@ -67,6 +68,8 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -987,6 +990,12 @@ export function DynamicResourceView({
{service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)}
{service === "monitoring" && cloud === "aws" && (
<>
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
</>
)}
{service === "compute" && (
<ComputePanel
cloud={cloud}
@@ -1029,14 +1038,21 @@ export function DynamicResourceView({
)}
</>
)}
{service === "apigateway" && (
{service === "apigateway" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? (
<HttpApiPanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
) : (
<ApiGatewayRoutePanel
cloud={cloud}
resource={activeSelected}
runtimeReachable={canUseRuntime}
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
/>
)}
))}
{service === "events" && (
<EventBridgeRulePanel
cloud={cloud}
@@ -0,0 +1,126 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
import {render, screen, within} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import {beforeEach, expect, test, vi} from 'vitest'
import {HttpApiPanel} from './HttpApiPanel'
import type {HttpApiOverview} from '@/api/cloudProxyClient'
import type {CloudResource} from '@/types/resource'
const api = vi.hoisted(() => ({
describeHttpApi: vi.fn(),
listCloudResources: vi.fn(),
createHttpApiRoute: vi.fn(),
deleteHttpApiRoute: vi.fn(),
setHttpApiRouteAuthorizer: vi.fn(),
createHttpApiStage: vi.fn(),
deleteHttpApiStage: vi.fn(),
deployHttpApi: vi.fn(),
createHttpApiAuthorizer: vi.fn(),
deleteHttpApiAuthorizer: vi.fn(),
updateHttpApiCors: vi.fn(),
testApiGatewayRoute: vi.fn(),
testWebSocketApi: vi.fn(),
}))
vi.mock('@/api/cloudProxyClient', () => api)
const httpResource: CloudResource = {
id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null,
metadata: {apigatewayService: 'http', protocol: 'HTTP'},
}
const httpOverview: HttpApiOverview = {
apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null,
routes: [
{routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false},
{routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false},
],
stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}],
authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}],
cors: null,
}
function renderPanel(resource: CloudResource = httpResource) {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
return render(<QueryClientProvider client={client}><HttpApiPanel cloud="aws" resource={resource} runtimeReachable runtimeEndpoint="http://localhost:4566"/></QueryClientProvider>)
}
beforeEach(() => {
vi.clearAllMocks()
api.describeHttpApi.mockResolvedValue(httpOverview)
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless'
? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}]
: [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}])
api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0])
api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0])
api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0])
api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false})
api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12})
})
test('shows the stage URL, routes with their targets and who may call them', async () => {
renderPanel()
expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument()
const routes = within(screen.getByRole('region', {name: 'Routes'}))
expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument()
expect(routes.getAllByText('Lambda: notes')).toHaveLength(2)
expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1')
expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('')
})
test('adds a Lambda route and protects an existing one', async () => {
renderPanel()
const routes = within(await screen.findByRole('region', {name: 'Routes'}))
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST')
await userEvent.clear(routes.getByRole('textbox', {name: 'Path'}))
await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes')
await routes.findByRole('option', {name: 'notes'})
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes')
await userEvent.click(routes.getByRole('button', {name: 'Add route'}))
expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined})
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1')
expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1')
})
test('creates a JWT authorizer from a Cognito pool and app client', async () => {
renderPanel()
const security = within(await screen.findByRole('region', {name: 'Security'}))
await security.findByRole('option', {name: 'notes-users'})
await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool')
await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1')
await userEvent.click(security.getByRole('button', {name: 'Add authorizer'}))
expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'})
})
test('sends a request to the stage and shows the response', async () => {
renderPanel()
const tester = within(await screen.findByRole('region', {name: 'Send a request'}))
expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1')
await userEvent.click(tester.getByRole('button', {name: 'Send request'}))
expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'}))
expect(await tester.findByText('200')).toBeInTheDocument()
})
test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => {
api.describeHttpApi.mockResolvedValue({
apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action',
routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}],
stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}],
authorizers: [], cors: null,
} satisfies HttpApiOverview)
api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40})
renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}})
expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument()
expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument()
const tester = within(screen.getByRole('region', {name: 'Send a message'}))
expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2))
await userEvent.click(tester.getByRole('button', {name: 'Connect and send'}))
expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'}))
expect(await tester.findByText('Connected')).toBeInTheDocument()
})
@@ -0,0 +1,610 @@
import {useEffect, useState} from 'react'
import {Loader2, Plus, RefreshCw, Rocket, Send, ShieldCheck, Trash2} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {
createHttpApiAuthorizer,
createHttpApiRoute,
createHttpApiStage,
deleteHttpApiAuthorizer,
deleteHttpApiRoute,
deleteHttpApiStage,
deployHttpApi,
describeHttpApi,
listCloudResources,
setHttpApiRouteAuthorizer,
testApiGatewayRoute,
testWebSocketApi,
updateHttpApiCors,
} from '@/api/cloudProxyClient'
import type {HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteOverview, HttpApiStageOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {formatDateTime} from '@/lib/format'
import {CopyValueButton} from './CopyValueButton'
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
interface HttpApiPanelProps {
cloud: CloudProvider
resource: CloudResource
runtimeReachable: boolean
runtimeEndpoint?: string
}
const ROUTE_METHODS = ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
const TEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*']
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
/** API Gateway v2: an HTTP or WebSocket API's routes, stages, security and a live tester. */
export function HttpApiPanel({cloud, resource, runtimeReachable, runtimeEndpoint}: HttpApiPanelProps) {
const queryKey = ['http-api', cloud, resource.id]
const query = useQuery({
queryKey,
queryFn: ({signal}) => describeHttpApi(cloud, resource.id, signal),
enabled: runtimeReachable,
})
const api = query.data
const endpoint = invokeEndpoint(runtimeEndpoint)
const isWebSocket = (api?.protocol ?? resource.metadata.protocol) === 'WEBSOCKET'
return <div className="http-api-workspace">
<section className="table-panel" aria-label={`${isWebSocket ? 'WebSocket' : 'HTTP'} API ${resource.name}`}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">{isWebSocket ? 'WebSocket API' : 'HTTP API'}</p>
<span className="muted">{isWebSocket
? <>Messages are routed by <code>{api?.routeSelectionExpression ?? String(resource.metadata.routeSelectionExpression ?? '')}</code>.</>
: 'Routes send requests to Lambda functions or HTTP backends.'}</span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={14}/>{query.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
{query.isLoading && <p className="muted padded">Loading the API</p>}
{query.error instanceof Error && <p className="error-text compact-text padded" role="alert">{query.error.message}</p>}
{api && <StageUrls api={api} endpoint={endpoint}/>}
</section>
{api && <>
<RoutesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
<StagesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
{api.protocol === 'HTTP' && <SecuritySection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>}
{api.protocol === 'HTTP'
? <HttpTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>
: <WebSocketTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>}
</>}
</div>
}
function StageUrls({api, endpoint}: {api: HttpApiOverview; endpoint: string | null}) {
if (api.stages.length === 0) return <p className="muted padded">No stages yet. Add one below to serve the API.</p>
return <div className="http-api-urls">
{api.stages.map((stage) => {
const url = stageUrl(api, stage, endpoint)
return <div className="dynamic-field" key={stage.stageName}>
<span>{stage.stageName} {stage.autoDeploy && <small className="muted">· deploys automatically</small>}</span>
{url ? <div className="copy-value"><code>{url}</code><CopyValueButton value={url} label={`${stage.stageName} URL`}/></div> : <code>{stage.invokePath}</code>}
</div>
})}
</div>
}
type SectionProps = {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean; queryKey: unknown[]}
function RoutesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const isWebSocket = api.protocol === 'WEBSOCKET'
const [method, setMethod] = useState('GET')
const [path, setPath] = useState('/hello')
const [catchAll, setCatchAll] = useState(false)
const [webSocketKey, setWebSocketKey] = useState('$default')
const [targetKind, setTargetKind] = useState<'lambda' | 'http'>('lambda')
const [functionName, setFunctionName] = useState('')
const [httpUrl, setHttpUrl] = useState('')
const [authorizerId, setAuthorizerId] = useState('')
const [returnsResponse, setReturnsResponse] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
useEffect(() => {
setConfirmDelete(null)
setAuthorizerId('')
}, [api.apiId])
const functionsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'serverless'],
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
enabled: runtimeReachable,
})
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
const refresh = () => queryClient.invalidateQueries({queryKey})
const routeKey = isWebSocket ? webSocketKey.trim() : catchAll ? '$default' : `${method} ${path.trim()}`
const createMutation = useMutation({
mutationFn: () => createHttpApiRoute(cloud, api.apiId, {
routeKey,
functionName: targetKind === 'lambda' ? functionName : undefined,
httpUrl: targetKind === 'http' ? httpUrl.trim() : undefined,
authorizerId: authorizerId || undefined,
returnsResponse: isWebSocket ? returnsResponse : undefined,
}),
onSuccess: refresh,
})
const deleteMutation = useMutation({
mutationFn: (routeId: string) => deleteHttpApiRoute(cloud, api.apiId, routeId),
onSuccess: () => {
setConfirmDelete(null)
return refresh()
},
})
const authorizerMutation = useMutation({
mutationFn: ({routeId, authorizer}: {routeId: string; authorizer: string | null}) => setHttpApiRouteAuthorizer(cloud, api.apiId, routeId, authorizer),
onSuccess: refresh,
})
const target = targetKind === 'lambda' ? functionName : httpUrl.trim()
const canCreate = runtimeReachable && Boolean(routeKey && target) && !createMutation.isPending
const authorizerName = (id: string | null) => api.authorizers.find((authorizer) => authorizer.authorizerId === id)?.name ?? id
const error = createMutation.error ?? deleteMutation.error ?? authorizerMutation.error
return <section className="table-panel" aria-label="Routes">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Routes</p>
<span className="muted">{isWebSocket
? '$connect and $disconnect run when a client connects and leaves; other messages go to the route their action names, or $default.'
: 'The most specific route wins; $default catches every request no other route matches.'}</span>
</div>
</div>
{api.routes.length === 0 && <p className="muted padded">No routes yet.</p>}
{api.routes.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Route</th><th>Target</th>{!isWebSocket && <th>Access</th>}<th aria-label="Actions"/></tr></thead>
<tbody>{api.routes.map((route) => <tr key={route.routeId}>
<td><code>{route.routeKey}</code>{isWebSocket && route.returnsResponse && <small className="muted"> · replies</small>}</td>
<td>{describeTarget(route)}</td>
{!isWebSocket && <td>
<select className="input http-api-access" aria-label={`Access for ${route.routeKey}`} value={route.authorizerId ?? ''} disabled={!runtimeReachable || authorizerMutation.isPending}
onChange={(event) => authorizerMutation.mutate({routeId: route.routeId, authorizer: event.target.value || null})}>
<option value="">Open to anyone</option>
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
</select>
{route.authorizerId && !api.authorizers.some((authorizer) => authorizer.authorizerId === route.authorizerId) && <small className="muted">Authorizer {authorizerName(route.authorizerId)}</small>}
</td>}
<td className="cognito-row-actions-cell">
{confirmDelete === route.routeId
? <span className="apigateway-route-confirm">
<span>Delete {route.routeKey}?</span>
<button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(route.routeId)}>Delete</button>
<button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(null)}>Cancel</button>
</span>
: <button className="icon-btn" type="button" aria-label={`Delete ${route.routeKey}`} disabled={!runtimeReachable} onClick={() => setConfirmDelete(route.routeId)}><Trash2 size={13}/></button>}
</td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a route" onSubmit={(event) => {
event.preventDefault()
if (canCreate) createMutation.mutate()
}}>
{isWebSocket ? <label className="dynamic-field">
<span>Route key</span>
<input className="input" list="websocket-route-keys" value={webSocketKey} onChange={(event) => setWebSocketKey(event.target.value)} placeholder="sendMessage"/>
<datalist id="websocket-route-keys">{WEBSOCKET_SYSTEM_ROUTES.map((key) => <option key={key} value={key}/>)}</datalist>
<small>$connect, $disconnect, $default, or an action such as sendMessage.</small>
</label> : <>
<label className="dynamic-field">
<span>Method</span>
<select className="input" value={method} disabled={catchAll} onChange={(event) => setMethod(event.target.value)}>
{ROUTE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field">
<span>Path</span>
<input className="input" aria-label="Path" value={path} disabled={catchAll} onChange={(event) => setPath(event.target.value)} placeholder="/notes/{id}"/>
<small>Use {'{id}'} for a path parameter and a final {'{proxy+}'} for everything below.</small>
</label>
<label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={catchAll} onChange={(event) => setCatchAll(event.target.checked)}/>
<span>Catch-all ($default)</span>
</label>
</>}
{!isWebSocket && <label className="dynamic-field">
<span>Target</span>
<select className="input" value={targetKind} onChange={(event) => setTargetKind(event.target.value as 'lambda' | 'http')}>
<option value="lambda">Lambda function</option>
<option value="http">HTTP backend</option>
</select>
</label>}
{targetKind === 'lambda' || isWebSocket
? <label className="dynamic-field">
<span>Lambda function</span>
<select className="input" aria-label="Lambda function" value={functionName} onChange={(event) => setFunctionName(event.target.value)}>
<option value="">Select a Lambda function</option>
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
</select>
<small>{functionsQuery.isLoading ? 'Loading functions' : functions.length === 0 ? 'Create a Lambda function first.' : isWebSocket ? 'Receives the message as a WebSocket event.' : 'Receives a version 2.0 proxy event.'}</small>
</label>
: <label className="dynamic-field">
<span>Backend URL</span>
<input className="input" value={httpUrl} onChange={(event) => setHttpUrl(event.target.value)} placeholder="https://backend.example.com/{proxy}"/>
<small>Requests are proxied here as they are.</small>
</label>}
{!isWebSocket && api.authorizers.length > 0 && <label className="dynamic-field">
<span>Access</span>
<select className="input" aria-label="Access for the new route" value={authorizerId} onChange={(event) => setAuthorizerId(event.target.value)}>
<option value="">Open to anyone</option>
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
</select>
</label>}
{isWebSocket && <label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={returnsResponse} onChange={(event) => setReturnsResponse(event.target.checked)}/>
<span>Send the function's response back to the client</span>
</label>}
<button className="button primary" type="submit" disabled={!canCreate}>
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Adding' : 'Add route'}
</button>
</form>
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</section>
}
function StagesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [stageName, setStageName] = useState('prod')
const [autoDeploy, setAutoDeploy] = useState(true)
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
const [notice, setNotice] = useState('')
const refresh = () => queryClient.invalidateQueries({queryKey})
useEffect(() => {
setConfirmDelete(null)
setNotice('')
}, [api.apiId])
const createMutation = useMutation({
mutationFn: () => createHttpApiStage(cloud, api.apiId, {stageName: stageName.trim(), autoDeploy}),
onSuccess: (stage) => {
setNotice(stage.autoDeploy ? `${stage.stageName} deploys every change automatically.` : `${stage.stageName} is deployed. Deploy it again after changing routes.`)
return refresh()
},
})
const deployMutation = useMutation({
mutationFn: (name: string) => deployHttpApi(cloud, api.apiId, name),
onSuccess: (stage) => {
setNotice(`Deployed the current routes to ${stage.stageName}.`)
return refresh()
},
})
const deleteMutation = useMutation({
mutationFn: (name: string) => deleteHttpApiStage(cloud, api.apiId, name),
onSuccess: () => {
setConfirmDelete(null)
return refresh()
},
})
const error = createMutation.error ?? deployMutation.error ?? deleteMutation.error
const busy = !runtimeReachable || createMutation.isPending || deployMutation.isPending || deleteMutation.isPending
return <section className="table-panel" aria-label="Stages">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Stages</p>
<span className="muted">A stage serves a deployment of the routes. With automatic deployment, every change is live right away.</span>
</div>
</div>
{api.stages.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Stage</th><th>Deployment</th><th>Updated</th><th aria-label="Actions"/></tr></thead>
<tbody>{api.stages.map((stage) => <tr key={stage.stageName}>
<td><code>{stage.stageName}</code></td>
<td>{stage.autoDeploy ? 'Automatic' : stage.deploymentId ? <code>{stage.deploymentId}</code> : 'Not deployed'}</td>
<td>{formatDateTime(stage.lastUpdatedAt) ?? '—'}</td>
<td className="cognito-row-actions-cell">
{!stage.autoDeploy && <button className="button compact" type="button" disabled={busy} onClick={() => deployMutation.mutate(stage.stageName)}><Rocket size={13}/>Deploy</button>}
{confirmDelete === stage.stageName
? <span className="apigateway-route-confirm">
<span>Delete {stage.stageName}?</span>
<button className="button danger compact" type="button" disabled={busy} onClick={() => deleteMutation.mutate(stage.stageName)}>Delete</button>
<button className="button compact" type="button" onClick={() => setConfirmDelete(null)}>Cancel</button>
</span>
: <button className="icon-btn" type="button" aria-label={`Delete stage ${stage.stageName}`} disabled={busy} onClick={() => setConfirmDelete(stage.stageName)}><Trash2 size={13}/></button>}
</td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a stage" onSubmit={(event) => {
event.preventDefault()
setNotice('')
if (stageName.trim()) createMutation.mutate()
}}>
<label className="dynamic-field"><span>Stage name</span><input className="input" value={stageName} onChange={(event) => setStageName(event.target.value)} placeholder="prod"/></label>
<label className="dynamic-field http-api-check-field">
<input type="checkbox" checked={autoDeploy} onChange={(event) => setAutoDeploy(event.target.checked)}/>
<span>Deploy changes automatically</span>
</label>
<button className="button primary" type="submit" disabled={busy || !stageName.trim()}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add stage'}</button>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</section>
}
function SecuritySection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
return <section className="table-panel" aria-label="Security">
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow"><ShieldCheck size={13}/> Security</p>
<span className="muted">A JWT authorizer checks each request's bearer token; protect a route by choosing it under Access above.</span>
</div>
</div>
<AuthorizersSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
<CorsSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
</section>
}
function AuthorizersSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [source, setSource] = useState<'cognito' | 'issuer'>('cognito')
const [name, setName] = useState('cognito')
const [userPoolId, setUserPoolId] = useState('')
const [clientId, setClientId] = useState('')
const [issuer, setIssuer] = useState('')
const [audience, setAudience] = useState('')
const refresh = () => queryClient.invalidateQueries({queryKey})
const poolsQuery = useQuery({
queryKey: ['cloud-resources', cloud, 'userpools'],
queryFn: ({signal}) => listCloudResources(cloud, 'userpools', undefined, signal),
enabled: runtimeReachable,
})
const pools = poolsQuery.data ?? []
const pool = pools.find((candidate) => candidate.id === userPoolId)
const clients = Array.isArray(pool?.metadata.appClients) ? pool.metadata.appClients as Array<{clientId: string; clientName: string}> : []
const createMutation = useMutation({
mutationFn: () => createHttpApiAuthorizer(cloud, api.apiId, source === 'cognito'
? {name: name.trim(), userPoolId, userPoolClientId: clientId}
: {name: name.trim(), issuer: issuer.trim(), audience: audience.split(',').map((value) => value.trim()).filter(Boolean)}),
onSuccess: refresh,
})
const deleteMutation = useMutation({
mutationFn: (authorizerId: string) => deleteHttpApiAuthorizer(cloud, api.apiId, authorizerId),
onSuccess: refresh,
})
const ready = source === 'cognito' ? Boolean(userPoolId && clientId) : Boolean(issuer.trim() && audience.trim())
const error = createMutation.error ?? deleteMutation.error
return <>
{api.authorizers.length > 0 && <div className="cognito-table"><table className="table">
<thead><tr><th>Authorizer</th><th>Issuer</th><th>Audience</th><th aria-label="Actions"/></tr></thead>
<tbody>{api.authorizers.map((authorizer: HttpApiAuthorizerOverview) => <tr key={authorizer.authorizerId}>
<td>{authorizer.name} <small className="muted">· {authorizer.type}</small></td>
<td className="mono http-api-wrap">{authorizer.issuer ?? '—'}</td>
<td className="mono http-api-wrap">{authorizer.audience.join(', ') || '—'}</td>
<td className="cognito-row-actions-cell"><button className="icon-btn" type="button" aria-label={`Delete authorizer ${authorizer.name}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(authorizer.authorizerId)}><Trash2 size={13}/></button></td>
</tr>)}</tbody>
</table></div>}
<form className="dynamic-form" aria-label="Add a JWT authorizer" onSubmit={(event) => {
event.preventDefault()
if (ready && name.trim()) createMutation.mutate()
}}>
<label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)}/></label>
<label className="dynamic-field">
<span>Tokens from</span>
<select className="input" value={source} onChange={(event) => setSource(event.target.value as 'cognito' | 'issuer')}>
<option value="cognito">A Cognito user pool</option>
<option value="issuer">Another OpenID Connect issuer</option>
</select>
</label>
{source === 'cognito' ? <>
<label className="dynamic-field">
<span>User pool</span>
<select className="input" aria-label="User pool" value={userPoolId} onChange={(event) => { setUserPoolId(event.target.value); setClientId('') }}>
<option value="">Select a user pool</option>
{pools.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name}</option>)}
</select>
{poolsQuery.isSuccess && pools.length === 0 && <small>Create a Cognito user pool first.</small>}
</label>
<label className="dynamic-field">
<span>App client</span>
<select className="input" aria-label="App client" value={clientId} disabled={!pool} onChange={(event) => setClientId(event.target.value)}>
<option value="">Select an app client</option>
{clients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}
</select>
<small>Only this client's tokens are accepted.</small>
</label>
</> : <>
<label className="dynamic-field"><span>Issuer URL</span><input className="input" value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="https://login.example.com/"/></label>
<label className="dynamic-field"><span>Audience</span><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="client-id, another-client-id"/></label>
</>}
<button className="button primary" type="submit" disabled={!runtimeReachable || !ready || !name.trim() || createMutation.isPending}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add authorizer'}</button>
</form>
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
</>
}
function CorsSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
const queryClient = useQueryClient()
const [origins, setOrigins] = useState('')
const [methods, setMethods] = useState<string[]>([])
const [headers, setHeaders] = useState('')
const [exposeHeaders, setExposeHeaders] = useState('')
const [maxAge, setMaxAge] = useState('')
const [credentials, setCredentials] = useState(false)
const [notice, setNotice] = useState('')
useEffect(() => {
const cors = api.cors
setOrigins(cors?.allowOrigins.join(', ') ?? '*')
setMethods(cors?.allowMethods ?? ['GET', 'POST', 'OPTIONS'])
setHeaders(cors?.allowHeaders.join(', ') ?? 'content-type, authorization')
setExposeHeaders(cors?.exposeHeaders.join(', ') ?? '')
setMaxAge(cors?.maxAgeSeconds === null || cors?.maxAgeSeconds === undefined ? '' : String(cors.maxAgeSeconds))
setCredentials(cors?.allowCredentials ?? false)
setNotice('')
}, [api.apiId, api.cors])
const mutation = useMutation({
mutationFn: (cors: HttpApiCors | null) => updateHttpApiCors(cloud, api.apiId, cors),
onSuccess: (cors) => {
setNotice(cors ? 'CORS saved. API Gateway now answers preflight requests itself.' : 'CORS removed.')
return queryClient.invalidateQueries({queryKey})
},
})
const split = (value: string) => value.split(',').map((item) => item.trim()).filter(Boolean)
return <>
<div className="input-row resource-table-bar"><div>
<p className="eyebrow">CORS</p>
<span className="muted">{api.cors ? 'On: browsers on the allowed origins can call this API.' : 'Off: browsers on other sites cannot call this API.'}</span>
</div></div>
<form className="dynamic-form" aria-label="CORS" onSubmit={(event) => {
event.preventDefault()
setNotice('')
mutation.mutate({allowOrigins: split(origins), allowMethods: methods, allowHeaders: split(headers), exposeHeaders: split(exposeHeaders), maxAgeSeconds: maxAge.trim() ? Number(maxAge) : null, allowCredentials: credentials})
}}>
<label className="dynamic-field"><span>Allowed origins</span><input className="input" value={origins} onChange={(event) => setOrigins(event.target.value)} placeholder="https://app.example.com"/><small>Comma separated, or * for any site.</small></label>
<div className="dynamic-field"><span>Allowed methods</span><div className="http-api-checks">{CORS_METHODS.map((value) => <label key={value}><input type="checkbox" checked={methods.includes(value)} onChange={() => setMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div></div>
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={headers} onChange={(event) => setHeaders(event.target.value)}/></label>
<label className="dynamic-field"><span>Exposed headers</span><input className="input" value={exposeHeaders} onChange={(event) => setExposeHeaders(event.target.value)} placeholder="x-request-id"/></label>
<label className="dynamic-field"><span>Max age (seconds)</span><input className="input" inputMode="numeric" value={maxAge} onChange={(event) => setMaxAge(event.target.value)} placeholder="300"/></label>
<label className="dynamic-field http-api-check-field"><input type="checkbox" checked={credentials} onChange={(event) => setCredentials(event.target.checked)}/><span>Allow credentials (cookies)</span></label>
<div className="cloudwatch-form-actions">
<button className="button primary" type="submit" disabled={!runtimeReachable || mutation.isPending}>{mutation.isPending ? 'Saving' : 'Save CORS'}</button>
{api.cors && <button className="button" type="button" disabled={!runtimeReachable || mutation.isPending} onClick={() => { setNotice(''); mutation.mutate(null) }}>Remove CORS</button>}
</div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{mutation.error instanceof Error && <p className="error-text compact-text padded" role="alert">{mutation.error.message}</p>}
</>
}
function HttpTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '$default')
const [method, setMethod] = useState('GET')
const [path, setPath] = useState(suggestedPath(api.routes))
const [query, setQuery] = useState('')
const [headers, setHeaders] = useState('{\n "content-type": "application/json"\n}')
const [body, setBody] = useState('')
useEffect(() => {
setStage(api.stages[0]?.stageName ?? '$default')
setPath(suggestedPath(api.routes))
// Only when another API is opened; keep what the user typed while this one refreshes.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api.apiId])
const mutation = useMutation({
mutationFn: () => testApiGatewayRoute(cloud, api.apiId, {path: path.trim(), method, stage, body, headers: parseHeaders(headers), query}),
})
const result = mutation.data
return <section className="table-panel apigateway-test-panel" aria-label="Send a request">
<div className="input-row resource-table-bar"><div>
<p className="eyebrow"><Send size={13}/> Send a request</p>
<span className="muted">Sends a real request to the stage, through its routes and authorizers. For a protected route, add an <code>authorization</code> header with <code>Bearer</code> and a token.</span>
</div></div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!mutation.isPending) mutation.mutate()
}}>
<label className="dynamic-field"><span>Stage</span>
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Method</span>
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>{TEST_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}</select>
</label>
<label className="dynamic-field"><span>Path</span><input className="input" aria-label="Request path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/notes/123"/></label>
<label className="dynamic-field"><span>Query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="page=1"/></label>
<label className="dynamic-field"><span>Headers (JSON)</span><textarea className="textarea code-textarea small" value={headers} onChange={(event) => setHeaders(event.target.value)} spellCheck={false}/></label>
{!['GET', 'HEAD'].includes(method) && <label className="dynamic-field"><span>Body</span><textarea className="textarea code-textarea" aria-label="Request body" value={body} onChange={(event) => setBody(event.target.value)} spellCheck={false}/></label>}
<button className="button primary" type="submit" disabled={!runtimeReachable || api.stages.length === 0 || mutation.isPending}>
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Sending' : 'Send request'}
</button>
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
</form>
<div className="apigateway-test-response">
{result ? <div className="apigateway-test-result">
<div className="apigateway-test-result-header">
<strong>Response <span className={result.statusCode < 400 ? 'status-success' : 'status-error'}>{result.statusCode}</span></strong>
<small>{result.durationMs} ms</small>
</div>
<details><summary>Response headers</summary><pre>{JSON.stringify(result.headers, null, 2)}</pre></details>
<pre>{formatResponseBody(result.body)}</pre>
</div> : <p className="muted compact-text">Send a request to see the response.</p>}
</div>
</div>
</section>
}
function WebSocketTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
const actionField = api.routeSelectionExpression?.match(/^\$request\.body\.(.+)$/)?.[1] ?? 'action'
const firstAction = api.routes.find((route) => !WEBSOCKET_SYSTEM_ROUTES.includes(route.routeKey))?.routeKey ?? 'sendMessage'
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '')
const [message, setMessage] = useState(JSON.stringify({[actionField]: firstAction, data: 'hello'}, null, 2))
const [query, setQuery] = useState('')
useEffect(() => {
setStage(api.stages[0]?.stageName ?? '')
// Only when another API is opened.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [api.apiId])
const mutation = useMutation({mutationFn: () => testWebSocketApi(cloud, api.apiId, {stage, message, query})})
const result = mutation.data
return <section className="table-panel apigateway-test-panel" aria-label="Send a message">
<div className="input-row resource-table-bar"><div>
<p className="eyebrow"><Send size={13}/> Send a message</p>
<span className="muted">Connects to the stage like a client would, sends one message, and shows what comes back. Replies need a route that sends the function's response back.</span>
</div></div>
<div className="apigateway-test-workspace">
<form className="apigateway-test-form" onSubmit={(event) => {
event.preventDefault()
if (!mutation.isPending && stage) mutation.mutate()
}}>
<label className="dynamic-field"><span>Stage</span>
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Connection query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="token=abc"/><small>Passed to $connect.</small></label>
<label className="dynamic-field"><span>Message</span><textarea className="textarea code-textarea" aria-label="Message" value={message} onChange={(event) => setMessage(event.target.value)} spellCheck={false}/></label>
<button className="button primary" type="submit" disabled={!runtimeReachable || !stage || mutation.isPending}>
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Connecting' : 'Connect and send'}
</button>
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
</form>
<div className="apigateway-test-response">
{result ? <div className="apigateway-test-result">
<div className="apigateway-test-result-header">
<strong>{result.connected ? <span className="status-success">Connected</span> : <span className="status-error">Not connected</span>}</strong>
<small>{result.durationMs} ms</small>
</div>
{result.error && <p className="error-text compact-text">{result.error}</p>}
{result.connected && result.messages.length === 0 && <p className="muted compact-text">No reply. Check the route sends the function's response back.</p>}
{result.messages.map((reply, index) => <pre key={index}>{formatResponseBody(reply)}</pre>)}
</div> : <p className="muted compact-text">Send a message to see the replies.</p>}
</div>
</div>
</section>
}
function describeTarget(route: HttpApiRouteOverview): string {
const integration = route.integration
if (!integration) return 'No target'
if (integration.functionName) return `Lambda: ${integration.functionName}`
if (integration.type === 'HTTP_PROXY') return `HTTP: ${integration.uri ?? ''}`
return integration.type
}
function stageUrl(api: HttpApiOverview, stage: HttpApiStageOverview, endpoint: string | null): string | null {
if (!endpoint) return null
return `${api.protocol === 'WEBSOCKET' ? endpoint.replace(/^http/, 'ws') : endpoint}${stage.invokePath}`
}
/** A path to try first: the first route's path with its parameters filled in. */
function suggestedPath(routes: HttpApiRouteOverview[]): string {
const path = routes.map((route) => route.routeKey.split(' ')[1]).find(Boolean)
if (!path || path === '/') return '/hello'
return path.replace(/\{[^}]+\+\}/g, 'anything').replace(/\{[^}]+\}/g, '1')
}
@@ -0,0 +1,35 @@
import {fireEvent, render, screen} from '@testing-library/react'
import {expect, test} from 'vitest'
import {MetricChart} from './MetricChart'
const series = {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [
{timestamp: '2026-10-25T10:00:00Z', value: 3},
{timestamp: '2026-10-25T10:01:00Z', value: 7},
]}
test('draws the line, labels the latest value and the threshold, and tabulates the points', () => {
const {container} = render(<MetricChart series={series} threshold={5}/>)
expect(container.querySelector('.metric-chart-line')?.getAttribute('d')).toMatch(/^M[\d.]+,[\d.]+ L[\d.]+,[\d.]+$/)
expect(screen.getByText('Threshold 5')).toBeInTheDocument()
expect(screen.getByRole('img', {name: /Errors \(Sum\), 2 points; latest 7 .*threshold 5/})).toBeInTheDocument()
expect(screen.getByText('Data table (2 points)')).toBeInTheDocument()
expect(container.querySelectorAll('.metric-chart-table tbody tr')).toHaveLength(2)
})
test('shows the tooltip from the keyboard, value first', () => {
render(<MetricChart series={series}/>)
const chart = screen.getByRole('img')
fireEvent.focus(chart)
expect(screen.getByRole('status')).toHaveTextContent(/^7Errors · Sum/)
fireEvent.keyDown(chart, {key: 'ArrowLeft'})
expect(screen.getByRole('status')).toHaveTextContent(/^3/)
fireEvent.keyDown(chart, {key: 'Escape'})
expect(screen.queryByRole('status')).not.toBeInTheDocument()
})
test('says when there is no data', () => {
render(<MetricChart series={{...series, points: []}}/>)
expect(screen.getByText('No data points in this time range.')).toBeInTheDocument()
})
@@ -0,0 +1,145 @@
import {useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent} from 'react'
import {formatValue, niceTicks, timeTicks} from '@/lib/metricChart'
import type {MetricSeries} from '@/types/resource'
type Props = {
series: MetricSeries
/** The alarm threshold, drawn as a dashed reference line. */
threshold?: number | null
height?: number
}
const MARGIN = {top: 16, right: 56, bottom: 28, left: 48}
/** Few points read as a scatter of values, so each gets a marker. */
const MARKER_LIMIT = 24
/**
* One metric over time: a 2px line, a solid hairline grid, the latest value labelled at the end,
* an optional dashed threshold, a crosshair tooltip that snaps to the nearest point (keyboard too)
* and a table view of the same values.
*/
export function MetricChart({series, threshold = null, height = 220}: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const [width, setWidth] = useState(640)
const [active, setActive] = useState<number | null>(null)
const titleId = useId()
useEffect(() => {
const element = containerRef.current
// Without ResizeObserver (old browsers, tests) the chart keeps its default width.
if (!element || typeof ResizeObserver === 'undefined') return
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, Math.round(entry.contentRect.width))))
observer.observe(element)
return () => observer.disconnect()
}, [])
const points = series.points
const geometry = useMemo(() => {
const innerWidth = width - MARGIN.left - MARGIN.right
const innerHeight = height - MARGIN.top - MARGIN.bottom
const times = points.map((point) => Date.parse(point.timestamp))
const values = points.map((point) => point.value)
const maxValue = Math.max(...values, threshold ?? -Infinity, 0)
const minValue = Math.min(...values, threshold ?? Infinity, 0)
const ticks = niceTicks(minValue, maxValue === minValue ? minValue + 1 : maxValue)
const yMin = ticks[0]
const yMax = ticks[ticks.length - 1]
const tMin = times.length ? Math.min(...times) : 0
const tMax = times.length ? Math.max(...times) : 1
const x = (time: number) => MARGIN.left + (tMax === tMin ? innerWidth / 2 : ((time - tMin) / (tMax - tMin)) * innerWidth)
const y = (value: number) => MARGIN.top + innerHeight - ((value - yMin) / (yMax - yMin)) * innerHeight
return {innerWidth, innerHeight, times, ticks, x, y, tMin, tMax}
}, [points, threshold, width, height])
if (points.length === 0) {
return <div className="metric-chart metric-chart--empty" ref={containerRef}><p className="muted">No data points in this time range.</p></div>
}
const {times, ticks, x, y, tMin, tMax} = geometry
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(times[index]).toFixed(1)},${y(point.value).toFixed(1)}`).join(' ')
const last = points.length - 1
const spansDays = tMax - tMin > 24 * 3600_000
const xTicks = timeTicks(tMin, tMax, Math.max(2, Math.floor(geometry.innerWidth / 120)))
const shown = active ?? null
const nearest = (clientX: number, svg: SVGSVGElement) => {
const box = svg.getBoundingClientRect()
const px = ((clientX - box.left) / box.width) * width
let best = 0
for (let index = 1; index < times.length; index++) {
if (Math.abs(x(times[index]) - px) < Math.abs(x(times[best]) - px)) best = index
}
return best
}
const onPointerMove = (event: PointerEvent<SVGSVGElement>) => setActive(nearest(event.clientX, event.currentTarget))
const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
if (event.key === 'ArrowLeft') { event.preventDefault(); setActive((index) => Math.max(0, (index ?? last) - 1)) }
if (event.key === 'ArrowRight') { event.preventDefault(); setActive((index) => Math.min(last, (index ?? last) + 1)) }
if (event.key === 'Home') { event.preventDefault(); setActive(0) }
if (event.key === 'End') { event.preventDefault(); setActive(last) }
if (event.key === 'Escape') setActive(null)
}
const summary = `${series.label} (${series.statistic}), ${points.length} points; latest ${formatValue(points[last].value)} at ${formatTime(times[last], spansDays)}${threshold === null ? '' : `; threshold ${formatValue(threshold)}`}`
return <div className="metric-chart" ref={containerRef}>
<svg
width={width}
height={height}
viewBox={`0 0 ${width} ${height}`}
role="img"
aria-labelledby={titleId}
tabIndex={0}
onPointerMove={onPointerMove}
onPointerLeave={() => setActive(null)}
onFocus={() => setActive(last)}
onBlur={() => setActive(null)}
onKeyDown={onKeyDown}
>
<title id={titleId}>{summary}</title>
{ticks.map((tick) => (
<g key={tick}>
<line className="metric-chart-grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(tick)} y2={y(tick)}/>
<text className="metric-chart-axis" x={MARGIN.left - 8} y={y(tick)} dy="0.32em" textAnchor="end">{formatValue(tick)}</text>
</g>
))}
{xTicks.map((tick) => (
<text key={tick} className="metric-chart-axis" x={x(tick)} y={height - 8} textAnchor="middle">{formatTime(tick, spansDays)}</text>
))}
{threshold !== null && <g>
<line className="metric-chart-threshold" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(threshold)} y2={y(threshold)}/>
<text className="metric-chart-label" x={width - MARGIN.right} y={y(threshold) - 6} textAnchor="end">{`Threshold ${formatValue(threshold)}`}</text>
</g>}
<path className="metric-chart-line" d={path}/>
{points.length <= MARKER_LIMIT && points.map((point, index) => (
<circle key={point.timestamp} className="metric-chart-marker" cx={x(times[index])} cy={y(point.value)} r={4}/>
))}
<text className="metric-chart-label metric-chart-label--strong" x={x(times[last]) + 8} y={y(points[last].value)} dy="0.32em">{formatValue(points[last].value)}</text>
{shown !== null && <g pointerEvents="none">
<line className="metric-chart-crosshair" x1={x(times[shown])} x2={x(times[shown])} y1={MARGIN.top} y2={height - MARGIN.bottom}/>
<circle className="metric-chart-marker metric-chart-marker--active" cx={x(times[shown])} cy={y(points[shown].value)} r={5}/>
</g>}
</svg>
{shown !== null && <div className="metric-chart-tooltip" role="status" style={tooltipPosition(x(times[shown]), width)}>
<strong>{formatValue(points[shown].value)}</strong>
<span><span className="metric-chart-key" aria-hidden="true"/>{series.label} · {series.statistic}</span>
<span className="muted">{formatTime(times[shown], true)}</span>
</div>}
<details className="metric-chart-table">
<summary>Data table ({points.length} points)</summary>
<table className="table"><thead><tr><th>Time</th><th>{series.statistic}</th></tr></thead><tbody>{points.slice().reverse().map((point) => (
<tr key={point.timestamp}><td>{new Date(point.timestamp).toLocaleString()}</td><td className="mono">{formatValue(point.value)}</td></tr>
))}</tbody></table>
</details>
</div>
}
function tooltipPosition(px: number, width: number) {
return px > width * 0.6 ? {right: width - px + 12} : {left: px + 12}
}
function formatTime(time: number, withDate: boolean): string {
const date = new Date(time)
return withDate
? date.toLocaleString(undefined, {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})
: date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'})
}
@@ -1,4 +1,5 @@
import {
Activity,
Boxes,
Circle,
Clock,
@@ -35,6 +36,7 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -93,6 +95,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
userpools: UsersRound,
dns: Globe,
cdn: Earth,
monitoring: Activity,
loadbalancing: Scale,
parameters: SlidersHorizontal,
configuration: SlidersHorizontal,
@@ -139,6 +142,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
userpools: cognitoIcon,
dns: route53Icon,
cdn: cloudFrontIcon,
monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon,
}
+72
View File
@@ -25,6 +25,7 @@
--status-error: #ff9a9a;
--accent: #6cc4ff;
--chart-line: #2f9ce0;
--accent-tint: rgba(108, 196, 255, 0.16);
--accent-tint-faint: rgba(108, 196, 255, 0.07);
--accent-tint-border: rgba(108, 196, 255, 0.35);
@@ -148,6 +149,7 @@
--status-error: #bd3f30;
--accent: var(--coral);
--chart-line: #d9573f;
--accent-tint: rgba(237, 111, 92, 0.14);
--accent-tint-faint: rgba(237, 111, 92, 0.07);
--accent-tint-border: rgba(237, 111, 92, 0.3);
@@ -244,6 +246,7 @@
--status-error: #ff8da1;
--accent: #4eff96;
--chart-line: #2fae67;
--accent-tint: rgba(78, 255, 150, 0.14);
--accent-tint-faint: rgba(78, 255, 150, 0.07);
--accent-tint-border: rgba(78, 255, 150, 0.34);
@@ -6538,3 +6541,72 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
.cdn-viewer small { flex-basis: 100%; }
/* CloudWatch: alarm states carry a dot in the status colour next to their label. */
.badge.ok::before, .badge.alarm::before, .badge.insufficient-data::before {
content: "";
width: 7px;
height: 7px;
border-radius: 50%;
}
.badge.ok::before { background: var(--status-ok); }
.badge.alarm::before { background: var(--status-error); }
.badge.insufficient-data::before { background: var(--text-3); }
.badge.alarm { border: 1px solid var(--status-error); }
.cloudwatch-state-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.cloudwatch-facts { padding: 0 16px 12px; }
.cloudwatch-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; padding: 0 16px 12px; }
.cloudwatch-range { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 16px 8px; }
.cloudwatch-range .input { width: auto; }
.cloudwatch-chart { padding: 4px 16px 12px; }
.cloudwatch-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
.cloudwatch-chart-head .cloudwatch-range { padding: 0; }
.cloudwatch-chart-title { margin: 0 0 6px; font-weight: 600; overflow-wrap: anywhere; }
.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
.cognito-row-actions-cell { text-align: right; }
/* API Gateway v2 (HTTP and WebSocket APIs): one column of sections, unlike the REST API's two. */
.http-api-workspace { display: grid; gap: 16px; min-width: 0; }
.http-api-workspace .dynamic-form { padding: 4px 16px 16px; }
.http-api-workspace .apigateway-test-workspace { padding: 0 16px 16px; }
.http-api-urls { display: grid; gap: 8px; padding: 0 16px 12px; }
.http-api-urls .copy-value { overflow-wrap: anywhere; min-width: 0; }
.http-api-check-field { display: flex; align-items: center; gap: 8px; align-self: end; min-height: 36px; }
.http-api-check-field span { text-transform: none; font-size: 13px; font-weight: 500; color: var(--text-1); }
.http-api-checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
.http-api-checks label { display: inline-flex; align-items: center; gap: 4px; }
.http-api-access { min-width: 0; max-width: 220px; }
.http-api-wrap { overflow-wrap: anywhere; }
.metric-chart { position: relative; width: 100%; min-width: 0; }
.metric-chart svg { display: block; max-width: 100%; height: auto; overflow: visible; }
.metric-chart svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
.metric-chart--empty { padding: 24px 0; }
.metric-chart-grid { stroke: var(--border-faint); stroke-width: 1; }
.metric-chart-axis { fill: var(--text-3); font-size: 11px; }
.metric-chart-line { fill: none; stroke: var(--chart-line); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.metric-chart-marker { fill: var(--chart-line); stroke: var(--surface); stroke-width: 2; }
.metric-chart-marker--active { stroke-width: 2.5; }
.metric-chart-threshold { stroke: var(--text-3); stroke-width: 1.5; stroke-dasharray: 5 4; }
.metric-chart-crosshair { stroke: var(--text-3); stroke-width: 1; }
.metric-chart-label { fill: var(--text-3); font-size: 11px; }
.metric-chart-label--strong { fill: var(--text-bright, var(--text)); font-weight: 600; }
.metric-chart-tooltip {
position: absolute;
top: 8px;
display: grid;
gap: 2px;
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--elevated);
font-size: 12px;
pointer-events: none;
white-space: nowrap;
}
.metric-chart-tooltip strong { font-size: 14px; color: var(--text-bright, var(--text)); }
.metric-chart-key { display: inline-block; width: 12px; height: 2px; margin: 0 6px 3px 0; background: var(--chart-line); vertical-align: middle; }
.metric-chart-table { margin-top: 8px; }
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
.metric-chart-table table { margin-top: 8px; }
+45
View File
@@ -0,0 +1,45 @@
// Shared by the REST API and HTTP/WebSocket API panels.
/** The runtime endpoint as the browser can reach it, for building invoke URLs. */
export function invokeEndpoint(runtimeEndpoint?: string): string | null {
if (!runtimeEndpoint) return null
try {
const endpoint = new URL(runtimeEndpoint)
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
endpoint.hostname = window.location.hostname
}
return endpoint.toString().replace(/\/$/, '')
} catch {
return runtimeEndpoint.replace(/\/$/, '')
}
}
function isDockerBridgeAddress(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
return octets.length === 4
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
&& octets[0] === 172
&& octets[1] >= 16
&& octets[1] <= 31
}
export function parseHeaders(value: string): Record<string, string> {
let parsed: unknown
try {
parsed = JSON.parse(value || '{}')
} catch {
throw new Error('Request headers must be valid JSON.')
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) {
throw new Error('Request headers must be a JSON object with string values.')
}
return parsed as Record<string, string>
}
export function formatResponseBody(body: string): string {
try {
return JSON.stringify(JSON.parse(body), null, 2)
} catch {
return body || '(empty response body)'
}
}
@@ -0,0 +1,12 @@
import {expect, test} from 'vitest'
import {formatValue, niceTicks} from './metricChart'
test('picks about five round ticks that include zero', () => {
expect(niceTicks(0, 7)).toEqual([0, 2, 4, 6, 8])
expect(niceTicks(0, 100)).toEqual([0, 25, 50, 75, 100])
expect(niceTicks(-3, 3)).toEqual([-4, -2, 0, 2, 4])
})
test('formats values compactly from a thousand up', () => {
expect([formatValue(7), formatValue(0.25), formatValue(1500), formatValue(2_000_000), formatValue(12.5)]).toEqual(['7', '0.25', '1.5K', '2M', '12.5'])
})
+34
View File
@@ -0,0 +1,34 @@
/** Time ranges offered above CloudWatch charts. */
export const RANGES = [{label: '1 hour', minutes: 60}, {label: '3 hours', minutes: 180}, {label: '24 hours', minutes: 1440}, {label: '7 days', minutes: 10080}]
/** About five round ticks covering [min, max], always including zero when the range does. */
export function niceTicks(min: number, max: number, count = 5): number[] {
const span = max - min
const raw = span / (count - 1)
const magnitude = 10 ** Math.floor(Math.log10(raw))
const step = [1, 2, 2.5, 5, 10].map((factor) => factor * magnitude).find((candidate) => candidate >= raw) ?? 10 * magnitude
const start = Math.floor(min / step) * step
const end = Math.ceil(max / step) * step
const ticks: number[] = []
for (let value = start; value <= end + step / 2; value += step) ticks.push(Number(value.toPrecision(12)))
return ticks
}
export function timeTicks(start: number, end: number, count: number): number[] {
if (end <= start) return [start]
const step = (end - start) / count
return Array.from({length: count + 1}, (_, index) => start + index * step)
}
export function formatValue(value: number): string {
const abs = Math.abs(value)
if (abs >= 1e9) return `${trim(value / 1e9)}B`
if (abs >= 1e6) return `${trim(value / 1e6)}M`
if (abs >= 1e3) return `${trim(value / 1e3)}K`
return trim(value)
}
function trim(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(Math.abs(value) < 10 ? 2 : 1).replace(/\.?0+$/, '')
}
+35
View File
@@ -302,6 +302,41 @@ export interface UserPoolSignInTestInput {
password: string
}
export interface MetricDimension {
name: string
value: string
}
export interface MetricSummary {
namespace: string
metricName: string
dimensions: MetricDimension[]
}
export interface MetricSeriesQuery {
namespace: string
metricName: string
dimensions?: MetricDimension[]
statistic?: string
periodSeconds?: number
rangeMinutes?: number
}
export interface MetricSeries {
label: string
statistic: string
periodSeconds: number
points: Array<{timestamp: string; value: number}>
}
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
export interface AlarmHistoryItem {
timestamp: string | null
type: string
summary: string
}
export interface DistributionInvalidation {
id: string
status: string
+2
View File
@@ -28,6 +28,7 @@ export type KubernetesActionName =
| 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
@@ -90,6 +91,7 @@ export interface ServiceSchema {
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
}
filters: FieldSchema[]
columns: TableColumnSchema[]
+18
View File
@@ -13,6 +13,9 @@ importers:
'@aws-sdk/client-api-gateway':
specifier: ^3.1076.0
version: 3.1096.0
'@aws-sdk/client-apigatewayv2':
specifier: ^3.1076.0
version: 3.1156.0
'@aws-sdk/client-appconfig':
specifier: ^3.1076.0
version: 3.1130.0
@@ -299,6 +302,10 @@ packages:
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-apigatewayv2@3.1156.0':
resolution: {integrity: sha512-+PbvEXc5ZbfDARcjf6hCeWneq0pQ/ToyuhujoA/2c5eNXnEzi4SSAvh9lMDyWMx/iXOSYrARvdBw0up0D8sFag==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-appconfig@3.1130.0':
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
engines: {node: '>=20.0.0'}
@@ -2901,6 +2908,17 @@ snapshots:
'@smithy/types': 4.16.1
tslib: 2.8.1
'@aws-sdk/client-apigatewayv2@3.1156.0':
dependencies:
'@aws-sdk/core': 3.978.5
'@aws-sdk/credential-provider-node': 3.972.88
'@aws-sdk/types': 3.975.0
'@smithy/core': 3.36.2
'@smithy/fetch-http-handler': 5.8.0
'@smithy/node-http-handler': 4.12.1
'@smithy/types': 4.19.0
tslib: 2.8.1
'@aws-sdk/client-appconfig@3.1130.0':
dependencies:
'@aws-sdk/core': 3.978.0