From 6dd33b8f656687e0e7fee78ebbf7e72a681e83c1 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sat, 24 Oct 2026 22:48:41 -0400 Subject: [PATCH] feat(api): add CloudWatch alarms and metric charts to the cloud explorer --- README.md | 1 + .../adapter-aws/AwsMonitoringAdapter.test.ts | 147 ++++++++ .../src/adapter-aws/AwsMonitoringAdapter.ts | 326 ++++++++++++++++++ .../api/src/cloud-spi/monitoringSchema.ts | 132 +++++++ packages/api/src/cloud-spi/serviceCatalog.ts | 7 + packages/api/src/cloud-spi/types.ts | 43 +++ packages/api/src/cloudProxy.test.ts | 20 +- packages/api/src/cloudProxy.ts | 2 + packages/api/src/routes/clouds.test.ts | 28 ++ packages/api/src/routes/clouds.ts | 44 +++ packages/api/src/service/CloudProxyService.ts | 35 ++ packages/frontend/src/api/api.ts | 12 + packages/frontend/src/api/cloudProxyClient.ts | 69 ++++ .../components/CloudWatchAlarmPanel.test.tsx | 74 ++++ .../src/components/CloudWatchAlarmPanel.tsx | 154 +++++++++ .../CloudWatchMetricsExplorer.test.tsx | 38 ++ .../components/CloudWatchMetricsExplorer.tsx | 86 +++++ .../src/components/DynamicResourceView.tsx | 8 + .../src/components/MetricChart.test.tsx | 35 ++ .../frontend/src/components/MetricChart.tsx | 145 ++++++++ .../frontend/src/components/serviceIcons.tsx | 4 + packages/frontend/src/index.css | 59 ++++ packages/frontend/src/lib/metricChart.test.ts | 12 + packages/frontend/src/lib/metricChart.ts | 34 ++ packages/frontend/src/types/resource.ts | 35 ++ packages/frontend/src/types/schema.ts | 2 + 26 files changed, 1551 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/adapter-aws/AwsMonitoringAdapter.test.ts create mode 100644 packages/api/src/adapter-aws/AwsMonitoringAdapter.ts create mode 100644 packages/api/src/cloud-spi/monitoringSchema.ts create mode 100644 packages/frontend/src/components/CloudWatchAlarmPanel.test.tsx create mode 100644 packages/frontend/src/components/CloudWatchAlarmPanel.tsx create mode 100644 packages/frontend/src/components/CloudWatchMetricsExplorer.test.tsx create mode 100644 packages/frontend/src/components/CloudWatchMetricsExplorer.tsx create mode 100644 packages/frontend/src/components/MetricChart.test.tsx create mode 100644 packages/frontend/src/components/MetricChart.tsx create mode 100644 packages/frontend/src/lib/metricChart.test.ts create mode 100644 packages/frontend/src/lib/metricChart.ts diff --git a/README.md b/README.md index ff7de98..556100f 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,7 @@ cd packages/api && bun run scripts/service-matrix.ts | Security | Secrets Manager / Key Vault / Secret Manager | Yes (list, create, inspect, delete) | Yes (list, create, delete, inspect) | Yes (list, create, inspect, delete) | | Security | KMS / Key Management | Yes (list, create, delete, inspect) | No | No | | Security | Parameter Store | Yes (list, create, delete, inspect) | No | No | +| Observability | CloudWatch / Monitoring | Yes (list, create, delete, inspect) | No | No | | Observability | CloudWatch Logs / Logs | Yes (list, create, delete, inspect) | No | No | Console Home is available for all three clouds. diff --git a/packages/api/src/adapter-aws/AwsMonitoringAdapter.test.ts b/packages/api/src/adapter-aws/AwsMonitoringAdapter.test.ts new file mode 100644 index 0000000..93825f0 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsMonitoringAdapter.test.ts @@ -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 + +function stubCloudWatch(handler: (command: object) => SendResult | Promise) { + 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/) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsMonitoringAdapter.ts b/packages/api/src/adapter-aws/AwsMonitoringAdapter.ts new file mode 100644 index 0000000..d432323 --- /dev/null +++ b/packages/api/src/adapter-aws/AwsMonitoringAdapter.ts @@ -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 = { + 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 { + 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 { + 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 { + 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 { + await this.cloudwatch.send(new DeleteAlarmsCommand({AlarmNames: [name]})) + } + + async listMetrics(query: {namespace?: string; search?: string} = {}): Promise { + 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 { + 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 { + 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 { + 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 { + 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(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) : '' +} diff --git a/packages/api/src/cloud-spi/monitoringSchema.ts b/packages/api/src/cloud-spi/monitoringSchema.ts new file mode 100644 index 0000000..56e61bd --- /dev/null +++ b/packages/api/src/cloud-spi/monitoringSchema.ts @@ -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 +} diff --git a/packages/api/src/cloud-spi/serviceCatalog.ts b/packages/api/src/cloud-spi/serviceCatalog.ts index ced0375..c297a41 100644 --- a/packages/api/src/cloud-spi/serviceCatalog.ts +++ b/packages/api/src/cloud-spi/serviceCatalog.ts @@ -281,6 +281,13 @@ export const SERVICE_CATALOG = { group: 'Integration', order: 20, }, + monitoring: { + displayName: 'Monitoring', + displayNameByCloud: {aws: 'CloudWatch'}, + iconKey: 'monitoring', + group: 'Observability', + order: 5, + }, logs: { displayName: 'Logs', displayNameByCloud: {aws: 'CloudWatch Logs'}, diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 8929d35..dc88433 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -181,6 +181,7 @@ export type UserPoolActionName = | 'describeClient' | 'updateClientOAuth' export type DistributionActionName = 'setEnabled' | 'createInvalidation' +export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export interface CapabilitySchema { @@ -226,6 +227,7 @@ export interface ServiceSchema { kubernetesActions?: CapabilitySchema[] userPoolActions?: CapabilitySchema[] distributionActions?: CapabilitySchema[] + monitoringActions?: CapabilitySchema[] // Child levels advertise separately from the resource level because a // store can be readable at the leaf and writable at the collection — // CloudWatch Logs creates streams but cannot delete an event. @@ -457,6 +459,42 @@ export interface UserPoolSignInTestInput { } /** Decoded claims only: the console shows what a sign-in yields without handing out tokens. */ +export interface MetricDimension { + name: string + value: string +} + +export interface MetricSummary { + namespace: string + metricName: string + dimensions: MetricDimension[] +} + +export interface MetricSeriesQuery { + namespace: string + metricName: string + dimensions?: MetricDimension[] + statistic?: string + periodSeconds?: number + /** How far back from now, in minutes. */ + rangeMinutes?: number +} + +export interface MetricSeries { + label: string + statistic: string + periodSeconds: number + points: {timestamp: string; value: number}[] +} + +export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA' + +export interface AlarmHistoryItem { + timestamp: string | null + type: string + summary: string +} + export interface DistributionInvalidation { id: string status: string @@ -1006,6 +1044,11 @@ export interface CloudServiceAdapter { testUserPoolSignIn?(poolId: string, input: UserPoolSignInTestInput): Promise describeUserPoolClient?(poolId: string, clientId: string): Promise setDistributionEnabled?(distributionId: string, enabled: boolean): Promise + listMetrics?(query: {namespace?: string; search?: string}): Promise + getMetricSeries?(query: MetricSeriesQuery): Promise + describeAlarmHistory?(alarmName: string): Promise + setAlarmState?(alarmName: string, state: AlarmStateValue, reason: string): Promise + setAlarmActionsEnabled?(alarmName: string, enabled: boolean): Promise createDistributionInvalidation?(distributionId: string, paths: string[]): Promise updateUserPoolClientOAuth?(poolId: string, clientId: string, input: UpdateUserPoolClientOAuthInput): Promise /** Clears the provider's locally captured email inbox, if it exposes one. */ diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 4cf6084..69c6cef 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test' import {createCloudAdapterRegistry} from './cloudProxy' import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {isServiceType} from './cloud-spi/serviceCatalog' -import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' /** * Guards the schema-to-implementation contract for every registered adapter. @@ -47,6 +47,14 @@ const METHOD_FOR_KUBERNETES_ACTION: Record = { + listMetrics: 'listMetrics', + getMetricData: 'getMetricSeries', + alarmHistory: 'describeAlarmHistory', + setAlarmState: 'setAlarmState', + setAlarmActions: 'setAlarmActionsEnabled', +} + const METHOD_FOR_DISTRIBUTION_ACTION: Record = { setEnabled: 'setDistributionEnabled', createInvalidation: 'createDistributionInvalidation', @@ -147,6 +155,15 @@ describe('registered adapters honour their schema', () => { ).toBe('function') } + const monitoringClaims = (adapter.schema().capabilities?.monitoringActions ?? []).filter((capability) => capability.status === 'available') + for (const capability of monitoringClaims) { + const method = METHOD_FOR_MONITORING_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } + const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available') for (const capability of distributionClaims) { const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name] @@ -169,6 +186,7 @@ describe('registered adapters honour their schema', () => { ...(adapter.schema().capabilities?.kubernetesActions ?? []), ...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.distributionActions ?? []), + ...(adapter.schema().capabilities?.monitoringActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []), ...(adapter.schema().capabilities?.itemActions ?? []), ] diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 9d3dd87..604434c 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -50,6 +50,7 @@ import {AwsLightsailAdapter} from './adapter-aws/AwsLightsailAdapter' import {AwsUserPoolsAdapter} from './adapter-aws/AwsUserPoolsAdapter' import {AwsDnsAdapter} from './adapter-aws/AwsDnsAdapter' import {AwsCdnAdapter} from './adapter-aws/AwsCdnAdapter' +import {AwsMonitoringAdapter} from './adapter-aws/AwsMonitoringAdapter' import {awsClientsForAccount, resolveAccountId} from './aws' import {createEc2Service} from './services/ec2' import {createEksService} from './services/eks' @@ -102,6 +103,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsUserPoolsAdapter(clients.cognito), new AwsDnsAdapter(clients.route53), new AwsCdnAdapter(clients.cloudfront, clients.s3), + new AwsMonitoringAdapter(clients.cloudwatch), new AzureStorageAdapter(), new AzureServiceBusAdapter(), new AzureDatabaseAdapter(), diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index a167240..0800aa6 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -18,6 +18,7 @@ import {awsStorageSchema, azureStorageSchema, gcpStorageSchema} from '../cloud-s import {awsSesEmailSchema} from '../cloud-spi/emailSchema' import {awsUserPoolsSchema} from '../cloud-spi/userPoolsSchema' import {awsCdnSchema} from '../cloud-spi/cdnSchema' +import {awsMonitoringSchema} from '../cloud-spi/monitoringSchema' import {awsAppConfigSchema} from '../cloud-spi/appConfigSchema' import {awsEventBridgeSchema} from '../cloud-spi/eventbridgeSchema' import {awsNotificationsSchema} from '../cloud-spi/notificationsSchema' @@ -770,6 +771,33 @@ describe('cloud schema routes', () => { expect(calls).toEqual([['us-east-1_x', 'c1', input]]) }) + test('routes CloudWatch metrics, history, alarm state and actions to the adapter', async () => { + const calls: unknown[] = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'monitoring', + schema: awsMonitoringSchema, + listMetrics: async (query) => { calls.push(['metrics', query]); return [{namespace: 'App', metricName: 'Errors', dimensions: []}] }, + getMetricSeries: async (query) => { calls.push(['series', query.metricName, query.rangeMinutes]); return {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: []} }, + describeAlarmHistory: async (name) => [{timestamp: null, type: 'StateUpdate', summary: `${name} changed`}], + setAlarmState: async (name, state, reason) => { calls.push(['state', name, state, reason]) }, + setAlarmActionsEnabled: async (name, enabled) => { calls.push(['actions', name, enabled]) }, + })]) + const json = (method: string, body: unknown) => ({method, headers: {'content-type': 'application/json'}, body: JSON.stringify(body)}) + const base = '/api/clouds/aws/services/monitoring' + + expect(await (await app.request(`${base}/metrics?namespace=App`)).json()).toEqual([{namespace: 'App', metricName: 'Errors', dimensions: []}]) + expect((await (await app.request(`${base}/metric-data`, json('POST', {namespace: 'App', metricName: 'Errors', rangeMinutes: 60}))).json()).label).toBe('Errors') + expect((await (await app.request(`${base}/resources/high-errors/history`)).json())[0].summary).toBe('high-errors changed') + expect((await app.request(`${base}/resources/high-errors/state`, json('PUT', {state: 'ALARM', reason: 'test'}))).status).toBe(200) + expect((await app.request(`${base}/resources/high-errors/actions`, json('PUT', {enabled: false}))).status).toBe(200) + expect(calls).toEqual([ + ['metrics', {namespace: 'App', search: undefined}], + ['series', 'Errors', 60], + ['state', 'high-errors', 'ALARM', 'test'], + ['actions', 'high-errors', false], + ]) + }) + test('enables a distribution and invalidates its paths through the CloudFront adapter', async () => { const calls: unknown[] = [] const app = appWithRoutes([mockAdapter('aws', { diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index dd3e123..79a023d 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -1,6 +1,7 @@ import {Hono} from 'hono' import type {Context} from 'hono' import type { + AlarmStateValue, CloudProvider, CloudServiceType, CreateDatabaseSnapshotInput, @@ -461,6 +462,49 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { return withRuntime(c, async () => c.json(await svc(c).updateUserPoolClientOAuth(cloud, c.req.param('id'), c.req.param('clientId'), await c.req.json()))) }) + app.get('/:cloud/services/monitoring/metrics', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => c.json(await svc(c).listMetrics(cloud, {namespace: c.req.query('namespace') || undefined, search: c.req.query('search') || undefined}))) + }) + + app.post('/:cloud/services/monitoring/metric-data', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => c.json(await svc(c).getMetricSeries(cloud, await c.req.json()))) + }) + + app.get('/:cloud/services/monitoring/resources/:id/history', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => c.json(await svc(c).describeAlarmHistory(cloud, c.req.param('id')))) + }) + + app.put('/:cloud/services/monitoring/resources/:id/state', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{state?: AlarmStateValue; reason?: string}>() + await svc(c).setAlarmState(cloud, c.req.param('id'), body.state as AlarmStateValue, body.reason ?? '') + return c.json({ok: true}) + }) + }) + + app.put('/:cloud/services/monitoring/resources/:id/actions', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) + + return withRuntime(c, async () => { + const body = await c.req.json<{enabled?: boolean}>() + await svc(c).setAlarmActionsEnabled(cloud, c.req.param('id'), body.enabled as boolean) + return c.json({ok: true}) + }) + }) + app.put('/:cloud/services/cdn/resources/:id/enabled', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (!isCloudProvider(cloud)) return c.json({error: 'Unknown cloud'}, 404) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index 58406af..b95fce0 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -83,6 +83,11 @@ import type { UserPoolSignInTestResult, UserPoolClientSettings, DistributionInvalidation, + AlarmHistoryItem, + AlarmStateValue, + MetricSeries, + MetricSeriesQuery, + MetricSummary, UpdateUserPoolClientOAuthInput, UserPoolUserAction, } from '../cloud-spi/types' @@ -834,6 +839,36 @@ export class CloudProxyService { return adapter.testUserPoolSignIn(poolId, input) } + async listMetrics(cloud: CloudProvider, query: {namespace?: string; search?: string}): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { const adapter = this.requireAdapter(cloud, 'cdn') if (!adapter.setDistributionEnabled) throw new NotSupportedError(`Enabling distributions is not supported for ${cloud}/cdn`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 64df429..02d5b75 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -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"}, 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: { enabled: "clouds.services.cdn.enabled", invalidations: "clouds.services.cdn.invalidations", @@ -890,6 +897,11 @@ export const endpointRegistry: EndpointRegistry = new Map([ 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, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 32468b1..1384bec 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -33,6 +33,11 @@ import type { UserPoolSignInTestResult, UserPoolClientSettings, DistributionInvalidation, + AlarmHistoryItem, + AlarmStateValue, + MetricSeries, + MetricSeriesQuery, + MetricSummary, UpdateUserPoolClientOAuthInput, UserPoolUserAction, LogsInsightsQueryInput, @@ -1477,6 +1482,70 @@ export async function testUserPoolSignIn( return res.data; } +export async function listMetrics( + cloud: CloudProvider, + query: { namespace?: string; search?: string } = {}, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + const res = await apiClient.call( + 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 { + await apiClient.call( + apiEndpointKeys.clouds.monitoring.state, + requestOptions(cloud, "monitoring", { body: { state, reason } }), + { cloud, id: alarmName }, + ); +} + +export async function setAlarmActionsEnabled( + cloud: CloudProvider, + alarmName: string, + enabled: boolean, +): Promise { + await apiClient.call( + apiEndpointKeys.clouds.monitoring.actions, + requestOptions(cloud, "monitoring", { body: { enabled } }), + { cloud, id: alarmName }, + ); +} + export async function setDistributionEnabled( cloud: CloudProvider, distributionId: string, diff --git a/packages/frontend/src/components/CloudWatchAlarmPanel.test.tsx b/packages/frontend/src/components/CloudWatchAlarmPanel.test.tsx new file mode 100644 index 0000000..2a5368a --- /dev/null +++ b/packages/frontend/src/components/CloudWatchAlarmPanel.test.tsx @@ -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() +} + +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() +}) + diff --git a/packages/frontend/src/components/CloudWatchAlarmPanel.tsx b/packages/frontend/src/components/CloudWatchAlarmPanel.tsx new file mode 100644 index 0000000..5c10385 --- /dev/null +++ b/packages/frontend/src/components/CloudWatchAlarmPanel.tsx @@ -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('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

Select an alarm

Choose an alarm to see its metric, test its actions and read its history.

+ } + + 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
+
+
+

Alarm

+ {state} {String(metadata.stateReason ?? '')} +
+ +
+
+
{isMetric ? 'Condition' : 'Rule'}
{String(metadata.condition ?? '—')}
+ {isMetric && <>
Metric
{String(metadata.namespace)} / {String(metadata.metricName)}{dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}
} +
State changed
{formatDateTime(typeof metadata.stateUpdatedAt === 'string' ? metadata.stateUpdatedAt : null) ?? '—'}
+
Notifies
{targets.length ? targets.join(', ') : 'No actions'}
+
+ + {isMetric && <> +
+ {RANGES.map((range) => ( + + ))} +
+
+

{String(metadata.metricName)} · {String(metadata.statistic ?? '')}

+ {seriesQuery.isLoading &&

Loading metric data

} + {seriesQuery.data && } +
+ } + +

Test the alarm

Sets the state now, which runs its actions. CloudWatch evaluates it again at the next period.
+
{ event.preventDefault(); setNotice(''); stateMutation.mutate() }}> + + +
+ + +
+
+ {notice &&

{notice}

} + {error instanceof Error &&

{error.message}

} + +

History

{history.length > 0 && Newest first.}
+ {historyUnsupported &&

TheCloud's runtime does not record alarm history yet. State changes still notify the alarm's actions.

} + {historyQuery.isSuccess && history.length === 0 &&

No history yet.

} + {history.length > 0 &&
{history.map((item, index) => ( + + ))}
WhenTypeWhat happened
{formatDateTime(item.timestamp) ?? '—'}{item.type}{item.summary}
} +
+} + +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') : [] +} diff --git a/packages/frontend/src/components/CloudWatchMetricsExplorer.test.tsx b/packages/frontend/src/components/CloudWatchMetricsExplorer.test.tsx new file mode 100644 index 0000000..87d8509 --- /dev/null +++ b/packages/frontend/src/components/CloudWatchMetricsExplorer.test.tsx @@ -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() +} + +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()) +}) diff --git a/packages/frontend/src/components/CloudWatchMetricsExplorer.tsx b/packages/frontend/src/components/CloudWatchMetricsExplorer.tsx new file mode 100644 index 0000000..362498f --- /dev/null +++ b/packages/frontend/src/components/CloudWatchMetricsExplorer.tsx @@ -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(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
+
+

Metrics

{metrics.length} {metrics.length === 1 ? 'metric' : 'metrics'} in {namespaces.length} {namespaces.length === 1 ? 'namespace' : 'namespaces'}. Pick one to chart it.
+ +
+
+ + +
+ + {selected &&
+
+

{selected.metricName} · {selected.namespace}{selected.dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}

+
+ + {RANGES.map((range) => ( + + ))} +
+
+ {seriesQuery.isLoading &&

Loading metric data

} + {seriesQuery.data && } +
} + + {error instanceof Error &&

{error.message}

} + {metricsQuery.isSuccess && metrics.length === 0 &&

No metrics yet

Metrics appear when services or your apps publish them, for example with PutMetricData.

} + {shown.length > 0 &&
{shown.map((metric) => { + const isSelected = selected !== null && metricKey(selected) === metricKey(metric) + return + + + + + + })}
MetricNamespaceDimensions
{metric.metricName}{metric.namespace}{metric.dimensions.length ? metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`).join(', ') : '—'}
} + {metricsQuery.isSuccess && metrics.length > 0 && shown.length === 0 &&

No metrics match.

} +
+} + +function metricKey(metric: MetricSummary): string { + return [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].join('|') +} diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index f3c1b04..9e67128 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -67,6 +67,8 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane import { CognitoUsersPanel } from "@/components/CognitoUsersPanel"; import { Route53RecordsPanel } from "@/components/Route53RecordsPanel"; import { CloudFrontPanel } from "@/components/CloudFrontPanel"; +import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel"; +import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer"; import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger"; import { CodeBuildPanel } from "@/components/CodeBuildPanel"; import { CodePipelinePanel } from "@/components/CodePipelinePanel"; @@ -987,6 +989,12 @@ export function DynamicResourceView({ {service === "cdn" && cloud === "aws" && ( )} + {service === "monitoring" && cloud === "aws" && ( + <> + + + + )} {service === "compute" && ( { + const {container} = render() + + 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() + 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() + expect(screen.getByText('No data points in this time range.')).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/MetricChart.tsx b/packages/frontend/src/components/MetricChart.tsx new file mode 100644 index 0000000..165f10a --- /dev/null +++ b/packages/frontend/src/components/MetricChart.tsx @@ -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(null) + const [width, setWidth] = useState(640) + const [active, setActive] = useState(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

No data points in this time range.

+ } + + 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) => setActive(nearest(event.clientX, event.currentTarget)) + const onKeyDown = (event: KeyboardEvent) => { + 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
+ setActive(null)} + onFocus={() => setActive(last)} + onBlur={() => setActive(null)} + onKeyDown={onKeyDown} + > + {summary} + {ticks.map((tick) => ( + + + {formatValue(tick)} + + ))} + {xTicks.map((tick) => ( + {formatTime(tick, spansDays)} + ))} + {threshold !== null && + + {`Threshold ${formatValue(threshold)}`} + } + + {points.length <= MARKER_LIMIT && points.map((point, index) => ( + + ))} + {formatValue(points[last].value)} + {shown !== null && + + + } + + {shown !== null &&
+ {formatValue(points[shown].value)} + + {formatTime(times[shown], true)} +
} +
+ Data table ({points.length} points) + {points.slice().reverse().map((point) => ( + + ))}
Time{series.statistic}
{new Date(point.timestamp).toLocaleString()}{formatValue(point.value)}
+
+
+} + +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'}) +} diff --git a/packages/frontend/src/components/serviceIcons.tsx b/packages/frontend/src/components/serviceIcons.tsx index a9be5b1..a5ce2cd 100644 --- a/packages/frontend/src/components/serviceIcons.tsx +++ b/packages/frontend/src/components/serviceIcons.tsx @@ -1,4 +1,5 @@ import { + Activity, Boxes, Circle, Clock, @@ -35,6 +36,7 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg' import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg' import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg' +import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg' import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg' import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg' import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg' @@ -93,6 +95,7 @@ const SERVICE_ICONS: Record = { userpools: UsersRound, dns: Globe, cdn: Earth, + monitoring: Activity, loadbalancing: Scale, parameters: SlidersHorizontal, configuration: SlidersHorizontal, @@ -139,6 +142,7 @@ const AWS_SERVICE_ICON_ASSETS: Record = { userpools: cognitoIcon, dns: route53Icon, cdn: cloudFrontIcon, + monitoring: cloudWatchIcon, workflows: stepFunctionsIcon, } diff --git a/packages/frontend/src/index.css b/packages/frontend/src/index.css index bf31d02..b9218a6 100644 --- a/packages/frontend/src/index.css +++ b/packages/frontend/src/index.css @@ -25,6 +25,7 @@ --status-error: #ff9a9a; --accent: #6cc4ff; + --chart-line: #2f9ce0; --accent-tint: rgba(108, 196, 255, 0.16); --accent-tint-faint: rgba(108, 196, 255, 0.07); --accent-tint-border: rgba(108, 196, 255, 0.35); @@ -148,6 +149,7 @@ --status-error: #bd3f30; --accent: var(--coral); + --chart-line: #d9573f; --accent-tint: rgba(237, 111, 92, 0.14); --accent-tint-faint: rgba(237, 111, 92, 0.07); --accent-tint-border: rgba(237, 111, 92, 0.3); @@ -244,6 +246,7 @@ --status-error: #ff8da1; --accent: #4eff96; + --chart-line: #2fae67; --accent-tint: rgba(78, 255, 150, 0.14); --accent-tint-faint: rgba(78, 255, 150, 0.07); --accent-tint-border: rgba(78, 255, 150, 0.34); @@ -6538,3 +6541,59 @@ tr[aria-current="true"] td { background: var(--hover-md); } .cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; } .cdn-viewer small { flex-basis: 100%; } + +/* CloudWatch: alarm states carry a dot in the status colour next to their label. */ +.badge.ok::before, .badge.alarm::before, .badge.insufficient-data::before { + content: ""; + width: 7px; + height: 7px; + border-radius: 50%; +} +.badge.ok::before { background: var(--status-ok); } +.badge.alarm::before { background: var(--status-error); } +.badge.insufficient-data::before { background: var(--text-3); } +.badge.alarm { border: 1px solid var(--status-error); } + +.cloudwatch-state-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; } +.cloudwatch-facts { padding: 0 16px 12px; } +.cloudwatch-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; padding: 0 16px 12px; } +.cloudwatch-range { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 16px 8px; } +.cloudwatch-range .input { width: auto; } +.cloudwatch-chart { padding: 4px 16px 12px; } +.cloudwatch-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; } +.cloudwatch-chart-head .cloudwatch-range { padding: 0; } +.cloudwatch-chart-title { margin: 0 0 6px; font-weight: 600; overflow-wrap: anywhere; } +.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; } +.cognito-row-actions-cell { text-align: right; } + +.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; } diff --git a/packages/frontend/src/lib/metricChart.test.ts b/packages/frontend/src/lib/metricChart.test.ts new file mode 100644 index 0000000..191b385 --- /dev/null +++ b/packages/frontend/src/lib/metricChart.test.ts @@ -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']) +}) diff --git a/packages/frontend/src/lib/metricChart.ts b/packages/frontend/src/lib/metricChart.ts new file mode 100644 index 0000000..30ea97f --- /dev/null +++ b/packages/frontend/src/lib/metricChart.ts @@ -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+$/, '') +} + diff --git a/packages/frontend/src/types/resource.ts b/packages/frontend/src/types/resource.ts index 1f4f88a..8ea9a61 100644 --- a/packages/frontend/src/types/resource.ts +++ b/packages/frontend/src/types/resource.ts @@ -302,6 +302,41 @@ export interface UserPoolSignInTestInput { password: string } +export interface MetricDimension { + name: string + value: string +} + +export interface MetricSummary { + namespace: string + metricName: string + dimensions: MetricDimension[] +} + +export interface MetricSeriesQuery { + namespace: string + metricName: string + dimensions?: MetricDimension[] + statistic?: string + periodSeconds?: number + rangeMinutes?: number +} + +export interface MetricSeries { + label: string + statistic: string + periodSeconds: number + points: Array<{timestamp: string; value: number}> +} + +export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA' + +export interface AlarmHistoryItem { + timestamp: string | null + type: string + summary: string +} + export interface DistributionInvalidation { id: string status: string diff --git a/packages/frontend/src/types/schema.ts b/packages/frontend/src/types/schema.ts index ceb187c..c0bffec 100644 --- a/packages/frontend/src/types/schema.ts +++ b/packages/frontend/src/types/schema.ts @@ -28,6 +28,7 @@ export type KubernetesActionName = | 'deleteFargateProfile' export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query' export type DistributionActionName = 'setEnabled' | 'createInvalidation' +export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' @@ -90,6 +91,7 @@ export interface ServiceSchema { itemActions?: Array | ItemActionName> userPoolActions?: Array> distributionActions?: Array> + monitoringActions?: Array> } filters: FieldSchema[] columns: TableColumnSchema[]