Skip to content

Commit eaca4fb

Browse files
author
Theodore Li
committed
feat(block): add cloudwatch integration
1 parent 30377d7 commit eaca4fb

26 files changed

Lines changed: 2215 additions & 0 deletions

File tree

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import {
2+
type AlarmType,
3+
CloudWatchClient,
4+
DescribeAlarmsCommand,
5+
type StateValue,
6+
} from '@aws-sdk/client-cloudwatch'
7+
import { type NextRequest, NextResponse } from 'next/server'
8+
import { z } from 'zod'
9+
import { checkInternalAuth } from '@/lib/auth/hybrid'
10+
11+
const DescribeAlarmsSchema = z.object({
12+
region: z.string().min(1, 'AWS region is required'),
13+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
14+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
15+
alarmNamePrefix: z.string().optional(),
16+
stateValue: z.preprocess(
17+
(v) => (v === '' ? undefined : v),
18+
z.enum(['OK', 'ALARM', 'INSUFFICIENT_DATA']).optional()
19+
),
20+
alarmType: z.preprocess(
21+
(v) => (v === '' ? undefined : v),
22+
z.enum(['MetricAlarm', 'CompositeAlarm']).optional()
23+
),
24+
limit: z.preprocess(
25+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
26+
z.number({ coerce: true }).int().positive().optional()
27+
),
28+
})
29+
30+
export async function POST(request: NextRequest) {
31+
try {
32+
const auth = await checkInternalAuth(request)
33+
if (!auth.success || !auth.userId) {
34+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
35+
}
36+
37+
const body = await request.json()
38+
const validatedData = DescribeAlarmsSchema.parse(body)
39+
40+
const client = new CloudWatchClient({
41+
region: validatedData.region,
42+
credentials: {
43+
accessKeyId: validatedData.accessKeyId,
44+
secretAccessKey: validatedData.secretAccessKey,
45+
},
46+
})
47+
48+
const command = new DescribeAlarmsCommand({
49+
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
50+
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
51+
...(validatedData.alarmType && { AlarmTypes: [validatedData.alarmType as AlarmType] }),
52+
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
53+
})
54+
55+
const response = await client.send(command)
56+
57+
const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
58+
alarmName: a.AlarmName ?? '',
59+
alarmArn: a.AlarmArn ?? '',
60+
stateValue: a.StateValue ?? 'UNKNOWN',
61+
stateReason: a.StateReason ?? '',
62+
metricName: a.MetricName,
63+
namespace: a.Namespace,
64+
comparisonOperator: a.ComparisonOperator,
65+
threshold: a.Threshold,
66+
evaluationPeriods: a.EvaluationPeriods,
67+
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
68+
}))
69+
70+
const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
71+
alarmName: a.AlarmName ?? '',
72+
alarmArn: a.AlarmArn ?? '',
73+
stateValue: a.StateValue ?? 'UNKNOWN',
74+
stateReason: a.StateReason ?? '',
75+
metricName: undefined,
76+
namespace: undefined,
77+
comparisonOperator: undefined,
78+
threshold: undefined,
79+
evaluationPeriods: undefined,
80+
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
81+
}))
82+
83+
return NextResponse.json({
84+
success: true,
85+
output: { alarms: [...metricAlarms, ...compositeAlarms] },
86+
})
87+
} catch (error) {
88+
const errorMessage =
89+
error instanceof Error ? error.message : 'Failed to describe CloudWatch alarms'
90+
return NextResponse.json({ error: errorMessage }, { status: 500 })
91+
}
92+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import { CloudWatchLogsClient, DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
2+
import { createLogger } from '@sim/logger'
3+
import { type NextRequest, NextResponse } from 'next/server'
4+
import { z } from 'zod'
5+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
6+
7+
const logger = createLogger('CloudWatchDescribeLogGroups')
8+
9+
const DescribeLogGroupsSchema = z.object({
10+
region: z.string().min(1, 'AWS region is required'),
11+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
12+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
13+
prefix: z.string().optional(),
14+
limit: z.preprocess(
15+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
16+
z.number({ coerce: true }).int().positive().optional()
17+
),
18+
})
19+
20+
export async function POST(request: NextRequest) {
21+
try {
22+
const auth = await checkSessionOrInternalAuth(request)
23+
if (!auth.success || !auth.userId) {
24+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
25+
}
26+
27+
const body = await request.json()
28+
logger.info('Received request', {
29+
hasRegion: Boolean(body.region),
30+
hasAccessKeyId: Boolean(body.accessKeyId),
31+
hasSecretAccessKey: Boolean(body.secretAccessKey),
32+
region: body.region,
33+
accessKeyIdPrefix: body.accessKeyId?.slice(0, 8),
34+
})
35+
const validatedData = DescribeLogGroupsSchema.parse(body)
36+
37+
const client = new CloudWatchLogsClient({
38+
region: validatedData.region,
39+
credentials: {
40+
accessKeyId: validatedData.accessKeyId,
41+
secretAccessKey: validatedData.secretAccessKey,
42+
},
43+
})
44+
45+
logger.info('Sending DescribeLogGroupsCommand')
46+
const command = new DescribeLogGroupsCommand({
47+
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
48+
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
49+
})
50+
51+
const response = await client.send(command)
52+
logger.info('DescribeLogGroupsCommand returned', { count: response.logGroups?.length ?? 0 })
53+
54+
const logGroups = (response.logGroups ?? []).map((lg) => ({
55+
logGroupName: lg.logGroupName ?? '',
56+
arn: lg.arn ?? '',
57+
storedBytes: lg.storedBytes ?? 0,
58+
retentionInDays: lg.retentionInDays,
59+
creationTime: lg.creationTime,
60+
}))
61+
62+
return NextResponse.json({
63+
success: true,
64+
output: { logGroups },
65+
})
66+
} catch (error) {
67+
const errorMessage =
68+
error instanceof Error ? error.message : 'Failed to describe CloudWatch log groups'
69+
return NextResponse.json({ error: errorMessage }, { status: 500 })
70+
}
71+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
4+
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
5+
6+
const DescribeLogStreamsSchema = z.object({
7+
region: z.string().min(1, 'AWS region is required'),
8+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
9+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
10+
logGroupName: z.string().min(1, 'Log group name is required'),
11+
prefix: z.string().optional(),
12+
limit: z.preprocess(
13+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
14+
z.number({ coerce: true }).int().positive().optional()
15+
),
16+
})
17+
18+
export async function POST(request: NextRequest) {
19+
try {
20+
const auth = await checkSessionOrInternalAuth(request)
21+
if (!auth.success || !auth.userId) {
22+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
23+
}
24+
25+
const body = await request.json()
26+
const validatedData = DescribeLogStreamsSchema.parse(body)
27+
28+
const client = createCloudWatchLogsClient({
29+
region: validatedData.region,
30+
accessKeyId: validatedData.accessKeyId,
31+
secretAccessKey: validatedData.secretAccessKey,
32+
})
33+
34+
const result = await describeLogStreams(client, validatedData.logGroupName, {
35+
prefix: validatedData.prefix,
36+
limit: validatedData.limit,
37+
})
38+
39+
return NextResponse.json({
40+
success: true,
41+
output: { logStreams: result.logStreams },
42+
})
43+
} catch (error) {
44+
const errorMessage =
45+
error instanceof Error ? error.message : 'Failed to describe CloudWatch log streams'
46+
return NextResponse.json({ error: errorMessage }, { status: 500 })
47+
}
48+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { z } from 'zod'
3+
import { checkInternalAuth } from '@/lib/auth/hybrid'
4+
import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'
5+
6+
const GetLogEventsSchema = z.object({
7+
region: z.string().min(1, 'AWS region is required'),
8+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
9+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
10+
logGroupName: z.string().min(1, 'Log group name is required'),
11+
logStreamName: z.string().min(1, 'Log stream name is required'),
12+
startTime: z.number({ coerce: true }).int().optional(),
13+
endTime: z.number({ coerce: true }).int().optional(),
14+
limit: z.preprocess(
15+
(v) => (v === '' || v === undefined || v === null ? undefined : v),
16+
z.number({ coerce: true }).int().positive().optional()
17+
),
18+
})
19+
20+
export async function POST(request: NextRequest) {
21+
try {
22+
const auth = await checkInternalAuth(request)
23+
if (!auth.success || !auth.userId) {
24+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
25+
}
26+
27+
const body = await request.json()
28+
const validatedData = GetLogEventsSchema.parse(body)
29+
30+
const client = createCloudWatchLogsClient({
31+
region: validatedData.region,
32+
accessKeyId: validatedData.accessKeyId,
33+
secretAccessKey: validatedData.secretAccessKey,
34+
})
35+
36+
const result = await getLogEvents(
37+
client,
38+
validatedData.logGroupName,
39+
validatedData.logStreamName,
40+
{
41+
startTime: validatedData.startTime,
42+
endTime: validatedData.endTime,
43+
limit: validatedData.limit,
44+
}
45+
)
46+
47+
return NextResponse.json({
48+
success: true,
49+
output: { events: result.events },
50+
})
51+
} catch (error) {
52+
const errorMessage =
53+
error instanceof Error ? error.message : 'Failed to get CloudWatch log events'
54+
return NextResponse.json({ error: errorMessage }, { status: 500 })
55+
}
56+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import { z } from 'zod'
4+
import { checkInternalAuth } from '@/lib/auth/hybrid'
5+
6+
const GetMetricStatisticsSchema = z.object({
7+
region: z.string().min(1, 'AWS region is required'),
8+
accessKeyId: z.string().min(1, 'AWS access key ID is required'),
9+
secretAccessKey: z.string().min(1, 'AWS secret access key is required'),
10+
namespace: z.string().min(1, 'Namespace is required'),
11+
metricName: z.string().min(1, 'Metric name is required'),
12+
startTime: z.number({ coerce: true }).int(),
13+
endTime: z.number({ coerce: true }).int(),
14+
period: z.number({ coerce: true }).int().min(1),
15+
statistics: z.array(z.enum(['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount'])).min(1),
16+
dimensions: z.string().optional(),
17+
})
18+
19+
export async function POST(request: NextRequest) {
20+
try {
21+
const auth = await checkInternalAuth(request)
22+
if (!auth.success || !auth.userId) {
23+
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
24+
}
25+
26+
const body = await request.json()
27+
const validatedData = GetMetricStatisticsSchema.parse(body)
28+
29+
const client = new CloudWatchClient({
30+
region: validatedData.region,
31+
credentials: {
32+
accessKeyId: validatedData.accessKeyId,
33+
secretAccessKey: validatedData.secretAccessKey,
34+
},
35+
})
36+
37+
let parsedDimensions: { Name: string; Value: string }[] | undefined
38+
if (validatedData.dimensions) {
39+
try {
40+
const dims = JSON.parse(validatedData.dimensions)
41+
if (Array.isArray(dims)) {
42+
parsedDimensions = dims.map((d: { name: string; value: string }) => ({
43+
Name: d.name,
44+
Value: d.value,
45+
}))
46+
} else if (typeof dims === 'object') {
47+
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
48+
Name: name,
49+
Value: String(value),
50+
}))
51+
}
52+
} catch {
53+
throw new Error('Invalid dimensions JSON')
54+
}
55+
}
56+
57+
const command = new GetMetricStatisticsCommand({
58+
Namespace: validatedData.namespace,
59+
MetricName: validatedData.metricName,
60+
StartTime: new Date(validatedData.startTime * 1000),
61+
EndTime: new Date(validatedData.endTime * 1000),
62+
Period: validatedData.period,
63+
Statistics: validatedData.statistics,
64+
...(parsedDimensions && { Dimensions: parsedDimensions }),
65+
})
66+
67+
const response = await client.send(command)
68+
69+
const datapoints = (response.Datapoints ?? [])
70+
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
71+
.map((dp) => ({
72+
timestamp: dp.Timestamp ? Math.floor(dp.Timestamp.getTime() / 1000) : 0,
73+
average: dp.Average,
74+
sum: dp.Sum,
75+
minimum: dp.Minimum,
76+
maximum: dp.Maximum,
77+
sampleCount: dp.SampleCount,
78+
unit: dp.Unit,
79+
}))
80+
81+
return NextResponse.json({
82+
success: true,
83+
output: {
84+
label: response.Label ?? validatedData.metricName,
85+
datapoints,
86+
},
87+
})
88+
} catch (error) {
89+
const errorMessage =
90+
error instanceof Error ? error.message : 'Failed to get CloudWatch metric statistics'
91+
return NextResponse.json({ error: errorMessage }, { status: 500 })
92+
}
93+
}

0 commit comments

Comments
 (0)