Compare commits

..

3 Commits

Author SHA1 Message Date
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
26 changed files with 1551 additions and 1 deletions
+1
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 | 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 | KMS / Key Management | Yes (list, create, delete, inspect) | No | No |
| Security | Parameter Store | 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 | | Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No |
Console Home is available for all three clouds. Console Home is available for all three clouds.
@@ -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) : ''
}
@@ -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', group: 'Integration',
order: 20, order: 20,
}, },
monitoring: {
displayName: 'Monitoring',
displayNameByCloud: {aws: 'CloudWatch'},
iconKey: 'monitoring',
group: 'Observability',
order: 5,
},
logs: { logs: {
displayName: 'Logs', displayName: 'Logs',
displayNameByCloud: {aws: 'CloudWatch Logs'}, displayNameByCloud: {aws: 'CloudWatch Logs'},
+43
View File
@@ -181,6 +181,7 @@ export type UserPoolActionName =
| 'describeClient' | 'describeClient'
| 'updateClientOAuth' | 'updateClientOAuth'
export type DistributionActionName = 'setEnabled' | 'createInvalidation' export type DistributionActionName = 'setEnabled' | 'createInvalidation'
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
export interface CapabilitySchema<TAction extends string> { export interface CapabilitySchema<TAction extends string> {
@@ -226,6 +227,7 @@ export interface ServiceSchema {
kubernetesActions?: CapabilitySchema<KubernetesActionName>[] kubernetesActions?: CapabilitySchema<KubernetesActionName>[]
userPoolActions?: CapabilitySchema<UserPoolActionName>[] userPoolActions?: CapabilitySchema<UserPoolActionName>[]
distributionActions?: CapabilitySchema<DistributionActionName>[] distributionActions?: CapabilitySchema<DistributionActionName>[]
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
// Child levels advertise separately from the resource level because a // Child levels advertise separately from the resource level because a
// store can be readable at the leaf and writable at the collection — // store can be readable at the leaf and writable at the collection —
// CloudWatch Logs creates streams but cannot delete an event. // CloudWatch Logs creates streams but cannot delete an event.
@@ -457,6 +459,42 @@ export interface UserPoolSignInTestInput {
} }
/** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */ /** 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 { export interface DistributionInvalidation {
id: string id: string
status: string status: string
@@ -1006,6 +1044,11 @@ export interface CloudServiceAdapter {
testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult> testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise<UserPoolSignInTestResult>
describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings> describeUserPoolClient?(poolId: string, clientId: string): Promise<UserPoolClientSettings>
setDistributionEnabled?(distributionId: string, enabled: boolean): Promise<CloudResource> 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> createDistributionInvalidation?(distributionId: string, paths: string[]): Promise<DistributionInvalidation>
updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings> updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise<UserPoolClientSettings>
/** Clears the provider's locally captured email inbox, if it exposes one. */ /** Clears the provider's locally captured email inbox, if it exposes one. */
+19 -1
View File
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
import {createCloudAdapterRegistry} from './cloudProxy' import {createCloudAdapterRegistry} from './cloudProxy'
import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {checkChildCapabilities} from './cloud-spi/childCapabilities'
import {isServiceType} from './cloud-spi/serviceCatalog' 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, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
/** /**
* Guards the schema-to-implementation contract for every registered adapter. * Guards the schema-to-implementation contract for every registered adapter.
@@ -47,6 +47,14 @@ const METHOD_FOR_KUBERNETES_ACTION: Record<KubernetesActionName, keyof CloudServ
deleteFargateProfile: 'deleteKubernetesFargateProfile', deleteFargateProfile: 'deleteKubernetesFargateProfile',
} }
const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServiceAdapter> = {
listMetrics: 'listMetrics',
getMetricData: 'getMetricSeries',
alarmHistory: 'describeAlarmHistory',
setAlarmState: 'setAlarmState',
setAlarmActions: 'setAlarmActionsEnabled',
}
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = { const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
setEnabled: 'setDistributionEnabled', setEnabled: 'setDistributionEnabled',
createInvalidation: 'createDistributionInvalidation', createInvalidation: 'createDistributionInvalidation',
@@ -147,6 +155,15 @@ describe('registered adapters honour their schema', () => {
).toBe('function') ).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 distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available') const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
for (const capability of distributionClaims) { for (const capability of distributionClaims) {
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name] const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
@@ -169,6 +186,7 @@ describe('registered adapters honour their schema', () => {
...(adapter.schema().capabilities?.kubernetesActions ?? []), ...(adapter.schema().capabilities?.kubernetesActions ?? []),
...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.userPoolActions ?? []),
...(adapter.schema().capabilities?.distributionActions ?? []), ...(adapter.schema().capabilities?.distributionActions ?? []),
...(adapter.schema().capabilities?.monitoringActions ?? []),
...(adapter.schema().capabilities?.collectionActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []),
...(adapter.schema().capabilities?.itemActions ?? []), ...(adapter.schema().capabilities?.itemActions ?? []),
] ]
+2
View File
@@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter'
import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter'
import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter'
import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter' import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter'
import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter'
import {awsClientsForAccount, resolveAccountId} from './aws' import {awsClientsForAccount, resolveAccountId} from './aws'
import {createEc2Service} from './services/ec2' import {createEc2Service} from './services/ec2'
import {createEksService} from './services/eks' import {createEksService} from './services/eks'
@@ -102,6 +103,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
new AwsUserPoolsAdapter(clients.cognito), new AwsUserPoolsAdapter(clients.cognito),
new AwsDnsAdapter(clients.route53), new AwsDnsAdapter(clients.route53),
new AwsCdnAdapter(clients.cloudfront, clients.s3), new AwsCdnAdapter(clients.cloudfront, clients.s3),
new AwsMonitoringAdapter(clients.cloudwatch),
new AzureStorageAdapter(), new AzureStorageAdapter(),
new AzureServiceBusAdapter(), new AzureServiceBusAdapter(),
new AzureDatabaseAdapter(), new AzureDatabaseAdapter(),
+28
View File
@@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s
import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsSesEmailSchema} from '../cloud-spi/emailSchema'
import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema'
import {awsCdnSchema} from '../cloud-spi/cdnSchema' import {awsCdnSchema} from '../cloud-spi/cdnSchema'
import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema'
import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema'
import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema'
import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema' import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema'
@@ -770,6 +771,33 @@ describe('cloud schema routes', () => {
expect(calls).toEqual([['us-east-1_x', 'c1', input]]) 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 () => { test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => {
const calls: unknown[] = [] const calls: unknown[] = []
const app = appWithRoutes([mockAdapter('aws', { const app = appWithRoutes([mockAdapter('aws', {
+44
View File
@@ -1,6 +1,7 @@
import {Hono} from 'hono' import {Hono} from 'hono'
import type {Context} from 'hono' import type {Context} from 'hono'
import type { import type {
AlarmStateValue,
CloudProvider, CloudProvider,
CloudServiceType, CloudServiceType,
CreateDatabaseSnapshotInput, 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()))) 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) => { app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => {
const cloud = c.req.param('cloud') as CloudProvider const cloud = c.req.param('cloud') as CloudProvider
if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404)
@@ -83,6 +83,11 @@ import type {
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
UpdateUserPoolClientOAuthInput, UpdateUserPoolClientOAuthInput,
UserPoolUserAction, UserPoolUserAction,
} from '../cloud-spi/types' } from '../cloud-spi/types'
@@ -834,6 +839,36 @@ export class CloudProxyService {
return adapter.testUserPoolSignIn(poolId, input) 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> { async setDistributionEnabled(cloud: CloudProvider, distributionId: string, enabled: boolean): Promise<CloudResource> {
const adapter = this.requireAdapter(cloud, 'cdn') const adapter = this.requireAdapter(cloud, 'cdn')
if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`) if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`)
+12
View File
@@ -130,6 +130,13 @@ 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"}, 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", moveTasks: "clouds.services.messaging.move-tasks",
}, },
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: { cdn: {
enabled: "clouds.services.cdn.enabled", enabled: "clouds.services.cdn.enabled",
invalidations: "clouds.services.cdn.invalidations", invalidations: "clouds.services.cdn.invalidations",
@@ -890,6 +897,11 @@ export const endpointRegistry: EndpointRegistry = new Map([
method: "POST", 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, apiEndpointKeys.clouds.cdn.enabled,
{ {
@@ -33,6 +33,11 @@ import type {
UserPoolSignInTestResult, UserPoolSignInTestResult,
UserPoolClientSettings, UserPoolClientSettings,
DistributionInvalidation, DistributionInvalidation,
AlarmHistoryItem,
AlarmStateValue,
MetricSeries,
MetricSeriesQuery,
MetricSummary,
UpdateUserPoolClientOAuthInput, UpdateUserPoolClientOAuthInput,
UserPoolUserAction, UserPoolUserAction,
LogsInsightsQueryInput, LogsInsightsQueryInput,
@@ -1477,6 +1482,70 @@ export async function testUserPoolSignIn(
return res.data; 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 async function setDistributionEnabled( export async function setDistributionEnabled(
cloud: CloudProvider, cloud: CloudProvider,
distributionId: string, distributionId: string,
@@ -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('|')
}
@@ -67,6 +67,8 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel"; import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
import { CloudFrontPanel } from "@/components/CloudFrontPanel"; import { CloudFrontPanel } from "@/components/CloudFrontPanel";
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodeBuildPanel } from "@/components/CodeBuildPanel";
import { CodePipelinePanel } from "@/components/CodePipelinePanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel";
@@ -987,6 +989,12 @@ export function DynamicResourceView({
{service === "cdn" && cloud === "aws" && ( {service === "cdn" && cloud === "aws" && (
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} /> <CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
)} )}
{service === "monitoring" && cloud === "aws" && (
<>
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
</>
)}
{service === "compute" && ( {service === "compute" && (
<ComputePanel <ComputePanel
cloud={cloud} cloud={cloud}
@@ -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 { import {
Activity,
Boxes, Boxes,
Circle, Circle,
Clock, 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 cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.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 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 cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg' import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg' import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
@@ -93,6 +95,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
userpools: UsersRound, userpools: UsersRound,
dns: Globe, dns: Globe,
cdn: Earth, cdn: Earth,
monitoring: Activity,
loadbalancing: Scale, loadbalancing: Scale,
parameters: SlidersHorizontal, parameters: SlidersHorizontal,
configuration: SlidersHorizontal, configuration: SlidersHorizontal,
@@ -139,6 +142,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
userpools: cognitoIcon, userpools: cognitoIcon,
dns: route53Icon, dns: route53Icon,
cdn: cloudFrontIcon, cdn: cloudFrontIcon,
monitoring: cloudWatchIcon,
workflows: stepFunctionsIcon, workflows: stepFunctionsIcon,
} }
+59
View File
@@ -25,6 +25,7 @@
--status-error: #ff9a9a; --status-error: #ff9a9a;
--accent: #6cc4ff; --accent: #6cc4ff;
--chart-line: #2f9ce0;
--accent-tint: rgba(108, 196, 255, 0.16); --accent-tint: rgba(108, 196, 255, 0.16);
--accent-tint-faint: rgba(108, 196, 255, 0.07); --accent-tint-faint: rgba(108, 196, 255, 0.07);
--accent-tint-border: rgba(108, 196, 255, 0.35); --accent-tint-border: rgba(108, 196, 255, 0.35);
@@ -148,6 +149,7 @@
--status-error: #bd3f30; --status-error: #bd3f30;
--accent: var(--coral); --accent: var(--coral);
--chart-line: #d9573f;
--accent-tint: rgba(237, 111, 92, 0.14); --accent-tint: rgba(237, 111, 92, 0.14);
--accent-tint-faint: rgba(237, 111, 92, 0.07); --accent-tint-faint: rgba(237, 111, 92, 0.07);
--accent-tint-border: rgba(237, 111, 92, 0.3); --accent-tint-border: rgba(237, 111, 92, 0.3);
@@ -244,6 +246,7 @@
--status-error: #ff8da1; --status-error: #ff8da1;
--accent: #4eff96; --accent: #4eff96;
--chart-line: #2fae67;
--accent-tint: rgba(78, 255, 150, 0.14); --accent-tint: rgba(78, 255, 150, 0.14);
--accent-tint-faint: rgba(78, 255, 150, 0.07); --accent-tint-faint: rgba(78, 255, 150, 0.07);
--accent-tint-border: rgba(78, 255, 150, 0.34); --accent-tint-border: rgba(78, 255, 150, 0.34);
@@ -6538,3 +6541,59 @@ tr[aria-current="true"] td { background: var(--hover-md); }
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; } .cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
.cdn-viewer small { flex-basis: 100%; } .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; }
.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; }
@@ -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 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 { export interface DistributionInvalidation {
id: string id: string
status: string status: string
+2
View File
@@ -28,6 +28,7 @@ export type KubernetesActionName =
| 'deleteFargateProfile' | 'deleteFargateProfile'
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
export type DistributionActionName = 'setEnabled' | 'createInvalidation' 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 UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
@@ -90,6 +91,7 @@ export interface ServiceSchema {
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName> itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>> userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
distributionActions?: Array<CapabilitySchema<DistributionActionName>> distributionActions?: Array<CapabilitySchema<DistributionActionName>>
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
} }
filters: FieldSchema[] filters: FieldSchema[]
columns: TableColumnSchema[] columns: TableColumnSchema[]