chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,123 @@
|
||||
import {
|
||||
type AlarmType,
|
||||
CloudWatchClient,
|
||||
DescribeAlarmHistoryCommand,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchDescribeAlarmHistoryContract } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarm-history'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeAlarmHistory')
|
||||
|
||||
/** AWS DescribeAlarmHistory caps `MaxRecords` at 100 items per page. */
|
||||
const ALARM_HISTORY_PAGE_SIZE = 100
|
||||
|
||||
/** Upper bound on pages drained to avoid unbounded loops on long-lived alarms. */
|
||||
const MAX_ALARM_HISTORY_PAGES = 20
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchDescribeAlarmHistoryContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info('Describing CloudWatch alarm history')
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const totalLimit = validatedData.limit
|
||||
const alarmHistoryItems: {
|
||||
alarmName: string | undefined
|
||||
alarmType: string | undefined
|
||||
timestamp: number | undefined
|
||||
historyItemType: string | undefined
|
||||
historySummary: string | undefined
|
||||
}[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
for (let page = 0; page < MAX_ALARM_HISTORY_PAGES; page++) {
|
||||
const pageLimit =
|
||||
totalLimit !== undefined
|
||||
? Math.min(ALARM_HISTORY_PAGE_SIZE, totalLimit - alarmHistoryItems.length)
|
||||
: ALARM_HISTORY_PAGE_SIZE
|
||||
|
||||
const command = new DescribeAlarmHistoryCommand({
|
||||
...(validatedData.alarmName && { AlarmName: validatedData.alarmName }),
|
||||
// AWS defaults AlarmTypes to MetricAlarm-only, so always request both kinds explicitly.
|
||||
AlarmTypes: ['MetricAlarm', 'CompositeAlarm'] as AlarmType[],
|
||||
...(validatedData.historyItemType && {
|
||||
HistoryItemType: validatedData.historyItemType,
|
||||
}),
|
||||
...(validatedData.startDate !== undefined && {
|
||||
StartDate: new Date(validatedData.startDate * 1000),
|
||||
}),
|
||||
...(validatedData.endDate !== undefined && {
|
||||
EndDate: new Date(validatedData.endDate * 1000),
|
||||
}),
|
||||
ScanBy: validatedData.scanBy ?? 'TimestampDescending',
|
||||
MaxRecords: pageLimit,
|
||||
...(nextToken && { NextToken: nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
for (const item of response.AlarmHistoryItems ?? []) {
|
||||
alarmHistoryItems.push({
|
||||
alarmName: item.AlarmName,
|
||||
alarmType: item.AlarmType,
|
||||
timestamp: item.Timestamp?.getTime(),
|
||||
historyItemType: item.HistoryItemType,
|
||||
historySummary: item.HistorySummary,
|
||||
})
|
||||
}
|
||||
|
||||
nextToken = response.NextToken
|
||||
if (!nextToken) break
|
||||
if (totalLimit !== undefined && alarmHistoryItems.length >= totalLimit) break
|
||||
|
||||
if (page === MAX_ALARM_HISTORY_PAGES - 1) {
|
||||
logger.warn(
|
||||
`DescribeAlarmHistory hit pagination cap of ${MAX_ALARM_HISTORY_PAGES} pages; history may be incomplete`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const cappedItems =
|
||||
totalLimit !== undefined ? alarmHistoryItems.slice(0, totalLimit) : alarmHistoryItems
|
||||
|
||||
logger.info(`Successfully described ${cappedItems.length} alarm history items`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { alarmHistoryItems: cappedItems },
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('DescribeAlarmHistory failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to describe CloudWatch alarm history: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
type AlarmType,
|
||||
CloudWatchClient,
|
||||
DescribeAlarmsCommand,
|
||||
type StateValue,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchDescribeAlarmsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-describe-alarms'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeAlarms')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchDescribeAlarmsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info('Describing CloudWatch alarms')
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const command = new DescribeAlarmsCommand({
|
||||
...(validatedData.alarmNamePrefix && { AlarmNamePrefix: validatedData.alarmNamePrefix }),
|
||||
...(validatedData.stateValue && { StateValue: validatedData.stateValue as StateValue }),
|
||||
AlarmTypes: validatedData.alarmType
|
||||
? [validatedData.alarmType as AlarmType]
|
||||
: (['MetricAlarm', 'CompositeAlarm'] as AlarmType[]),
|
||||
...(validatedData.limit !== undefined && { MaxRecords: validatedData.limit }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const metricAlarms = (response.MetricAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: a.MetricName,
|
||||
namespace: a.Namespace,
|
||||
comparisonOperator: a.ComparisonOperator,
|
||||
threshold: a.Threshold,
|
||||
evaluationPeriods: a.EvaluationPeriods,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
const compositeAlarms = (response.CompositeAlarms ?? []).map((a) => ({
|
||||
alarmName: a.AlarmName ?? '',
|
||||
alarmArn: a.AlarmArn ?? '',
|
||||
stateValue: a.StateValue ?? 'UNKNOWN',
|
||||
stateReason: a.StateReason ?? '',
|
||||
metricName: undefined,
|
||||
namespace: undefined,
|
||||
comparisonOperator: undefined,
|
||||
threshold: undefined,
|
||||
evaluationPeriods: undefined,
|
||||
stateUpdatedTimestamp: a.StateUpdatedTimestamp?.getTime(),
|
||||
}))
|
||||
|
||||
const alarms = [...metricAlarms, ...compositeAlarms]
|
||||
|
||||
logger.info(`Successfully described ${alarms.length} alarms`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { alarms },
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('DescribeAlarms failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to describe CloudWatch alarms: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,105 @@
|
||||
import { DescribeLogGroupsCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { cloudwatchLogGroupsSelectorContract } from '@/lib/api/contracts/selectors/cloudwatch'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogGroups')
|
||||
|
||||
/** AWS DescribeLogGroups caps `limit` at 50 items per page. */
|
||||
const LOG_GROUPS_PAGE_SIZE = 50
|
||||
|
||||
/** Upper bound on pages drained to avoid unbounded loops on very large accounts. */
|
||||
const MAX_LOG_GROUPS_PAGES = 20
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(cloudwatchLogGroupsSelectorContract, request, {
|
||||
errorFormat: 'firstError',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info('Describing CloudWatch log groups')
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const totalLimit = validatedData.limit
|
||||
const logGroups: {
|
||||
logGroupName: string
|
||||
arn: string
|
||||
storedBytes: number
|
||||
retentionInDays: number | undefined
|
||||
creationTime: number | undefined
|
||||
}[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
for (let page = 0; page < MAX_LOG_GROUPS_PAGES; page++) {
|
||||
const pageLimit =
|
||||
totalLimit !== undefined
|
||||
? Math.min(LOG_GROUPS_PAGE_SIZE, totalLimit - logGroups.length)
|
||||
: LOG_GROUPS_PAGE_SIZE
|
||||
|
||||
const command = new DescribeLogGroupsCommand({
|
||||
...(validatedData.prefix && { logGroupNamePrefix: validatedData.prefix }),
|
||||
limit: pageLimit,
|
||||
...(nextToken && { nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
for (const lg of response.logGroups ?? []) {
|
||||
logGroups.push({
|
||||
logGroupName: lg.logGroupName ?? '',
|
||||
arn: lg.arn ?? '',
|
||||
storedBytes: lg.storedBytes ?? 0,
|
||||
retentionInDays: lg.retentionInDays,
|
||||
creationTime: lg.creationTime,
|
||||
})
|
||||
}
|
||||
|
||||
nextToken = response.nextToken
|
||||
if (!nextToken) break
|
||||
if (totalLimit !== undefined && logGroups.length >= totalLimit) break
|
||||
|
||||
if (page === MAX_LOG_GROUPS_PAGES - 1) {
|
||||
logger.warn(
|
||||
`DescribeLogGroups hit pagination cap of ${MAX_LOG_GROUPS_PAGES} pages; log group list may be incomplete`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const cappedLogGroups = totalLimit !== undefined ? logGroups.slice(0, totalLimit) : logGroups
|
||||
|
||||
logger.info(`Successfully described ${cappedLogGroups.length} log groups`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logGroups: cappedLogGroups },
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('DescribeLogGroups failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to describe CloudWatch log groups: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { cloudwatchLogStreamsSelectorContract } from '@/lib/api/contracts/selectors/cloudwatch'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createCloudWatchLogsClient, describeLogStreams } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchDescribeLogStreams')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkSessionOrInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(cloudwatchLogStreamsSelectorContract, request, {
|
||||
errorFormat: 'firstError',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`Describing log streams for group: ${validatedData.logGroupName}`)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await describeLogStreams(client, validatedData.logGroupName, {
|
||||
prefix: validatedData.prefix,
|
||||
limit: validatedData.limit,
|
||||
})
|
||||
|
||||
logger.info(`Successfully described ${result.logStreams.length} log streams`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { logStreams: result.logStreams },
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('DescribeLogStreams failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to describe CloudWatch log streams: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchFilterLogEventsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-filter-log-events'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createCloudWatchLogsClient, filterLogEvents } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchFilterLogEvents')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchFilterLogEventsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`Filtering log events in ${validatedData.logGroupName}`)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await filterLogEvents(client, validatedData.logGroupName, {
|
||||
filterPattern: validatedData.filterPattern,
|
||||
logStreamNamePrefix: validatedData.logStreamNamePrefix,
|
||||
// CloudWatch Logs timestamps are epoch milliseconds; our params are epoch seconds.
|
||||
startTime:
|
||||
validatedData.startTime !== undefined ? validatedData.startTime * 1000 : undefined,
|
||||
endTime: validatedData.endTime !== undefined ? validatedData.endTime * 1000 : undefined,
|
||||
startFromHead: validatedData.startFromHead,
|
||||
limit: validatedData.limit,
|
||||
})
|
||||
|
||||
logger.info(`Successfully filtered ${result.events.length} log events`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { events: result.events },
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('FilterLogEvents failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to filter CloudWatch log events: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,64 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchGetLogEventsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-get-log-events'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createCloudWatchLogsClient, getLogEvents } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchGetLogEvents')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchGetLogEventsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`Getting log events from ${validatedData.logGroupName}/${validatedData.logStreamName}`
|
||||
)
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await getLogEvents(
|
||||
client,
|
||||
validatedData.logGroupName,
|
||||
validatedData.logStreamName,
|
||||
{
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
limit: validatedData.limit,
|
||||
}
|
||||
)
|
||||
|
||||
logger.info(`Successfully retrieved ${result.events.length} log events`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { events: result.events },
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('GetLogEvents failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to get CloudWatch log events: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
import { CloudWatchClient, GetMetricStatisticsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchGetMetricStatisticsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-get-metric-statistics'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CloudWatchGetMetricStatistics')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchGetMetricStatisticsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(
|
||||
`Getting metric statistics for ${validatedData.namespace}/${validatedData.metricName}`
|
||||
)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
let parsedDimensions: { Name: string; Value: string }[] | undefined
|
||||
if (validatedData.dimensions) {
|
||||
try {
|
||||
const dims = JSON.parse(validatedData.dimensions)
|
||||
if (Array.isArray(dims)) {
|
||||
parsedDimensions = dims.map((d: Record<string, string>) => ({
|
||||
Name: d.name,
|
||||
Value: d.value,
|
||||
}))
|
||||
} else if (typeof dims === 'object') {
|
||||
parsedDimensions = Object.entries(dims).map(([name, value]) => ({
|
||||
Name: name,
|
||||
Value: String(value),
|
||||
}))
|
||||
}
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Invalid dimensions JSON format' }, { status: 400 })
|
||||
}
|
||||
}
|
||||
|
||||
const command = new GetMetricStatisticsCommand({
|
||||
Namespace: validatedData.namespace,
|
||||
MetricName: validatedData.metricName,
|
||||
StartTime: new Date(validatedData.startTime * 1000),
|
||||
EndTime: new Date(validatedData.endTime * 1000),
|
||||
Period: validatedData.period,
|
||||
Statistics: validatedData.statistics,
|
||||
...(parsedDimensions && { Dimensions: parsedDimensions }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
const datapoints = (response.Datapoints ?? [])
|
||||
.sort((a, b) => (a.Timestamp?.getTime() ?? 0) - (b.Timestamp?.getTime() ?? 0))
|
||||
.map((dp) => ({
|
||||
timestamp: dp.Timestamp ? dp.Timestamp.getTime() : 0,
|
||||
average: dp.Average,
|
||||
sum: dp.Sum,
|
||||
minimum: dp.Minimum,
|
||||
maximum: dp.Maximum,
|
||||
sampleCount: dp.SampleCount,
|
||||
unit: dp.Unit,
|
||||
}))
|
||||
|
||||
logger.info(`Successfully retrieved ${datapoints.length} datapoints`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
label: response.Label ?? validatedData.metricName,
|
||||
datapoints,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('GetMetricStatistics failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to get CloudWatch metric statistics: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import { CloudWatchClient, ListMetricsCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchListMetricsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-list-metrics'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CloudWatchListMetrics')
|
||||
|
||||
/** AWS ListMetrics returns up to 500 results per page. */
|
||||
const METRICS_PAGE_SIZE = 500
|
||||
|
||||
/** Upper bound on pages drained to avoid unbounded loops on accounts with many metrics. */
|
||||
const MAX_METRICS_PAGES = 20
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchListMetricsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info('Listing CloudWatch metrics')
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const totalLimit = validatedData.limit ?? METRICS_PAGE_SIZE
|
||||
const metrics: {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: { name: string; value: string }[]
|
||||
}[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
for (let page = 0; page < MAX_METRICS_PAGES; page++) {
|
||||
const command = new ListMetricsCommand({
|
||||
...(validatedData.namespace && { Namespace: validatedData.namespace }),
|
||||
...(validatedData.metricName && { MetricName: validatedData.metricName }),
|
||||
...(validatedData.recentlyActive && { RecentlyActive: 'PT3H' }),
|
||||
...(nextToken && { NextToken: nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
for (const m of response.Metrics ?? []) {
|
||||
metrics.push({
|
||||
namespace: m.Namespace ?? '',
|
||||
metricName: m.MetricName ?? '',
|
||||
dimensions: (m.Dimensions ?? []).map((d) => ({
|
||||
name: d.Name ?? '',
|
||||
value: d.Value ?? '',
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
nextToken = response.NextToken
|
||||
if (!nextToken) break
|
||||
if (metrics.length >= totalLimit) break
|
||||
|
||||
if (page === MAX_METRICS_PAGES - 1) {
|
||||
logger.warn(
|
||||
`ListMetrics hit pagination cap of ${MAX_METRICS_PAGES} pages; metric list may be incomplete`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const cappedMetrics = metrics.slice(0, totalLimit)
|
||||
|
||||
logger.info(`Successfully listed ${cappedMetrics.length} metrics`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: { metrics: cappedMetrics },
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('ListMetrics failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to list CloudWatch metrics: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CloudWatchClient, PutAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchMuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-mute-alarm'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CloudWatchMuteAlarm')
|
||||
|
||||
function toAtExpression(date: Date): string {
|
||||
const yyyy = date.getUTCFullYear()
|
||||
const mm = String(date.getUTCMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getUTCDate()).padStart(2, '0')
|
||||
const hh = String(date.getUTCHours()).padStart(2, '0')
|
||||
const min = String(date.getUTCMinutes()).padStart(2, '0')
|
||||
return `at(${yyyy}-${mm}-${dd}T${hh}:${min})`
|
||||
}
|
||||
|
||||
function toIsoDuration(value: number, unit: 'minutes' | 'hours' | 'days'): string {
|
||||
switch (unit) {
|
||||
case 'minutes':
|
||||
return `PT${value}M`
|
||||
case 'hours':
|
||||
return `PT${value}H`
|
||||
case 'days':
|
||||
return `P${value}D`
|
||||
}
|
||||
}
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchMuteAlarmContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
const startDate =
|
||||
validatedData.startDate !== undefined ? new Date(validatedData.startDate * 1000) : new Date()
|
||||
const expression = toAtExpression(startDate)
|
||||
const duration = toIsoDuration(validatedData.durationValue, validatedData.durationUnit)
|
||||
|
||||
logger.info(
|
||||
`Creating CloudWatch alarm mute rule "${validatedData.muteRuleName}" for ${validatedData.alarmNames.length} alarm(s) (${expression}, duration ${duration})`
|
||||
)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const command = new PutAlarmMuteRuleCommand({
|
||||
Name: validatedData.muteRuleName,
|
||||
...(validatedData.description && { Description: validatedData.description }),
|
||||
Rule: {
|
||||
Schedule: {
|
||||
Expression: expression,
|
||||
Duration: duration,
|
||||
},
|
||||
},
|
||||
MuteTargets: { AlarmNames: validatedData.alarmNames },
|
||||
})
|
||||
|
||||
await client.send(command)
|
||||
|
||||
logger.info(`Successfully created mute rule "${validatedData.muteRuleName}"`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
muteRuleName: validatedData.muteRuleName,
|
||||
alarmNames: validatedData.alarmNames,
|
||||
expression,
|
||||
duration,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('MuteAlarm failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to create CloudWatch alarm mute rule: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,74 @@
|
||||
import {
|
||||
DeleteRetentionPolicyCommand,
|
||||
PutRetentionPolicyCommand,
|
||||
} from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchPutLogGroupRetentionContract } from '@/lib/api/contracts/tools/aws/cloudwatch-put-log-group-retention'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createCloudWatchLogsClient } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchPutLogGroupRetention')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchPutLogGroupRetentionContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
if (validatedData.retentionInDays !== undefined) {
|
||||
logger.info(
|
||||
`Setting retention for log group "${validatedData.logGroupName}" to ${validatedData.retentionInDays} days`
|
||||
)
|
||||
await client.send(
|
||||
new PutRetentionPolicyCommand({
|
||||
logGroupName: validatedData.logGroupName,
|
||||
retentionInDays: validatedData.retentionInDays,
|
||||
})
|
||||
)
|
||||
} else {
|
||||
logger.info(
|
||||
`Removing retention policy for log group "${validatedData.logGroupName}" (events never expire)`
|
||||
)
|
||||
await client.send(
|
||||
new DeleteRetentionPolicyCommand({ logGroupName: validatedData.logGroupName })
|
||||
)
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
logGroupName: validatedData.logGroupName,
|
||||
retentionInDays: validatedData.retentionInDays ?? null,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PutLogGroupRetention failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to set CloudWatch log group retention: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
CloudWatchClient,
|
||||
PutMetricDataCommand,
|
||||
type StandardUnit,
|
||||
} from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchPutMetricDataContract } from '@/lib/api/contracts/tools/aws/cloudwatch-put-metric-data'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CloudWatchPutMetricData')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchPutMetricDataContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`Publishing metric ${validatedData.namespace}/${validatedData.metricName}`)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const timestamp = new Date()
|
||||
|
||||
const dimensions: { Name: string; Value: string }[] = []
|
||||
if (validatedData.dimensions) {
|
||||
const parsed = JSON.parse(validatedData.dimensions)
|
||||
for (const [name, value] of Object.entries(parsed)) {
|
||||
dimensions.push({ Name: name, Value: String(value) })
|
||||
}
|
||||
}
|
||||
|
||||
const command = new PutMetricDataCommand({
|
||||
Namespace: validatedData.namespace,
|
||||
MetricData: [
|
||||
{
|
||||
MetricName: validatedData.metricName,
|
||||
Value: validatedData.value,
|
||||
Timestamp: timestamp,
|
||||
...(validatedData.unit && { Unit: validatedData.unit as StandardUnit }),
|
||||
...(dimensions.length > 0 && { Dimensions: dimensions }),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await client.send(command)
|
||||
|
||||
logger.info('Successfully published metric')
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
namespace: validatedData.namespace,
|
||||
metricName: validatedData.metricName,
|
||||
value: validatedData.value,
|
||||
unit: validatedData.unit ?? 'None',
|
||||
timestamp: timestamp.toISOString(),
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('PutMetricData failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to publish CloudWatch metric: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import { StartQueryCommand } from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchQueryLogsContract } from '@/lib/api/contracts/tools/aws/cloudwatch-query-logs'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
import { createCloudWatchLogsClient, pollQueryResults } from '@/app/api/tools/cloudwatch/utils'
|
||||
|
||||
const logger = createLogger('CloudWatchQueryLogs')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchQueryLogsContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info('Running CloudWatch Log Insights query')
|
||||
|
||||
const client = createCloudWatchLogsClient({
|
||||
region: validatedData.region,
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
})
|
||||
|
||||
try {
|
||||
const startQueryCommand = new StartQueryCommand({
|
||||
logGroupNames: validatedData.logGroupNames,
|
||||
queryString: validatedData.queryString,
|
||||
startTime: validatedData.startTime,
|
||||
endTime: validatedData.endTime,
|
||||
...(validatedData.limit !== undefined && { limit: validatedData.limit }),
|
||||
})
|
||||
|
||||
const startQueryResponse = await client.send(startQueryCommand)
|
||||
const queryId = startQueryResponse.queryId
|
||||
|
||||
if (!queryId) {
|
||||
throw new Error('Failed to start CloudWatch Log Insights query: no queryId returned')
|
||||
}
|
||||
|
||||
const result = await pollQueryResults(client, queryId)
|
||||
|
||||
logger.info(`Query completed with status: ${result.status}`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
results: result.results,
|
||||
statistics: result.statistics,
|
||||
status: result.status,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('QueryLogs failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `CloudWatch Log Insights query failed: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CloudWatchClient, DeleteAlarmMuteRuleCommand } from '@aws-sdk/client-cloudwatch'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { toError } from '@sim/utils/errors'
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { awsCloudwatchUnmuteAlarmContract } from '@/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm'
|
||||
import { parseToolRequest } from '@/lib/api/server'
|
||||
import { checkInternalAuth } from '@/lib/auth/hybrid'
|
||||
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
|
||||
|
||||
const logger = createLogger('CloudWatchUnmuteAlarm')
|
||||
|
||||
export const POST = withRouteHandler(async (request: NextRequest) => {
|
||||
try {
|
||||
const auth = await checkInternalAuth(request)
|
||||
if (!auth.success || !auth.userId) {
|
||||
return NextResponse.json({ error: auth.error || 'Unauthorized' }, { status: 401 })
|
||||
}
|
||||
|
||||
const parsed = await parseToolRequest(awsCloudwatchUnmuteAlarmContract, request, {
|
||||
errorFormat: 'details',
|
||||
logger,
|
||||
})
|
||||
if (!parsed.success) return parsed.response
|
||||
const validatedData = parsed.data.body
|
||||
|
||||
logger.info(`Deleting CloudWatch alarm mute rule "${validatedData.muteRuleName}"`)
|
||||
|
||||
const client = new CloudWatchClient({
|
||||
region: validatedData.region,
|
||||
credentials: {
|
||||
accessKeyId: validatedData.accessKeyId,
|
||||
secretAccessKey: validatedData.secretAccessKey,
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const command = new DeleteAlarmMuteRuleCommand({
|
||||
AlarmMuteRuleName: validatedData.muteRuleName,
|
||||
})
|
||||
|
||||
await client.send(command)
|
||||
|
||||
logger.info(`Successfully deleted mute rule "${validatedData.muteRuleName}"`)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
output: {
|
||||
success: true,
|
||||
muteRuleName: validatedData.muteRuleName,
|
||||
},
|
||||
})
|
||||
} finally {
|
||||
client.destroy()
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('UnmuteAlarm failed', { error: toError(error).message })
|
||||
return NextResponse.json(
|
||||
{ error: `Failed to delete CloudWatch alarm mute rule: ${toError(error).message}` },
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,291 @@
|
||||
import {
|
||||
CloudWatchLogsClient,
|
||||
DescribeLogStreamsCommand,
|
||||
FilterLogEventsCommand,
|
||||
GetLogEventsCommand,
|
||||
GetQueryResultsCommand,
|
||||
type ResultField,
|
||||
} from '@aws-sdk/client-cloudwatch-logs'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { sleep } from '@sim/utils/helpers'
|
||||
import { DEFAULT_EXECUTION_TIMEOUT_MS } from '@/lib/core/execution-limits'
|
||||
|
||||
interface AwsCredentials {
|
||||
region: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
}
|
||||
|
||||
export function createCloudWatchLogsClient(config: AwsCredentials): CloudWatchLogsClient {
|
||||
return new CloudWatchLogsClient({
|
||||
region: config.region,
|
||||
credentials: {
|
||||
accessKeyId: config.accessKeyId,
|
||||
secretAccessKey: config.secretAccessKey,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
interface PollOptions {
|
||||
maxWaitMs?: number
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
|
||||
interface PollResult {
|
||||
results: Record<string, string>[]
|
||||
statistics: {
|
||||
bytesScanned: number
|
||||
recordsMatched: number
|
||||
recordsScanned: number
|
||||
}
|
||||
status: string
|
||||
}
|
||||
|
||||
function parseResultFields(fields: ResultField[] | undefined): Record<string, string> {
|
||||
const record: Record<string, string> = {}
|
||||
if (!fields) return record
|
||||
for (const field of fields) {
|
||||
if (field.field && field.value !== undefined) {
|
||||
record[field.field] = field.value ?? ''
|
||||
}
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
export async function pollQueryResults(
|
||||
client: CloudWatchLogsClient,
|
||||
queryId: string,
|
||||
options: PollOptions = {}
|
||||
): Promise<PollResult> {
|
||||
const { maxWaitMs = DEFAULT_EXECUTION_TIMEOUT_MS, pollIntervalMs = 1_000 } = options
|
||||
const startTime = Date.now()
|
||||
|
||||
while (Date.now() - startTime < maxWaitMs) {
|
||||
const command = new GetQueryResultsCommand({ queryId })
|
||||
const response = await client.send(command)
|
||||
|
||||
const status = response.status ?? 'Unknown'
|
||||
|
||||
if (status === 'Complete') {
|
||||
return {
|
||||
results: (response.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: response.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: response.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: response.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status,
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 'Failed' || status === 'Cancelled') {
|
||||
throw new Error(`CloudWatch Log Insights query ${status.toLowerCase()}`)
|
||||
}
|
||||
|
||||
await sleep(pollIntervalMs)
|
||||
}
|
||||
|
||||
// Timeout -- fetch one last time for partial results
|
||||
const finalResponse = await client.send(new GetQueryResultsCommand({ queryId }))
|
||||
return {
|
||||
results: (finalResponse.results ?? []).map(parseResultFields),
|
||||
statistics: {
|
||||
bytesScanned: finalResponse.statistics?.bytesScanned ?? 0,
|
||||
recordsMatched: finalResponse.statistics?.recordsMatched ?? 0,
|
||||
recordsScanned: finalResponse.statistics?.recordsScanned ?? 0,
|
||||
},
|
||||
status: `Timeout (last status: ${finalResponse.status ?? 'Unknown'})`,
|
||||
}
|
||||
}
|
||||
|
||||
/** AWS DescribeLogStreams caps `limit` at 50 items per page. */
|
||||
const LOG_STREAMS_PAGE_SIZE = 50
|
||||
|
||||
/** Upper bound on pages drained to avoid unbounded loops on log groups with many streams. */
|
||||
const MAX_LOG_STREAMS_PAGES = 20
|
||||
|
||||
const logger = createLogger('CloudWatchUtils')
|
||||
|
||||
interface DescribedLogStream {
|
||||
logStreamName: string
|
||||
lastEventTimestamp: number | undefined
|
||||
firstEventTimestamp: number | undefined
|
||||
creationTime: number | undefined
|
||||
storedBytes: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Lists log streams for a log group, following `nextToken` so the complete set
|
||||
* is returned rather than just the first page. Bounded by
|
||||
* `MAX_LOG_STREAMS_PAGES`; logs a warning rather than silently dropping streams
|
||||
* when the cap is hit. Ordering/prefix inputs are preserved across all pages.
|
||||
*
|
||||
* When `limit` is provided it is treated as a total result cap: draining stops
|
||||
* once enough streams have been collected. When omitted, every page is drained.
|
||||
*/
|
||||
export async function describeLogStreams(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
options?: { prefix?: string; limit?: number }
|
||||
): Promise<{ logStreams: DescribedLogStream[] }> {
|
||||
const hasPrefix = Boolean(options?.prefix)
|
||||
const totalLimit = options?.limit
|
||||
const logStreams: DescribedLogStream[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
for (let page = 0; page < MAX_LOG_STREAMS_PAGES; page++) {
|
||||
const pageLimit =
|
||||
totalLimit !== undefined
|
||||
? Math.min(LOG_STREAMS_PAGE_SIZE, totalLimit - logStreams.length)
|
||||
: LOG_STREAMS_PAGE_SIZE
|
||||
|
||||
const command = new DescribeLogStreamsCommand({
|
||||
logGroupName,
|
||||
...(hasPrefix
|
||||
? { orderBy: 'LogStreamName', logStreamNamePrefix: options!.prefix }
|
||||
: { orderBy: 'LastEventTime', descending: true }),
|
||||
limit: pageLimit,
|
||||
...(nextToken && { nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
for (const ls of response.logStreams ?? []) {
|
||||
logStreams.push({
|
||||
logStreamName: ls.logStreamName ?? '',
|
||||
lastEventTimestamp: ls.lastEventTimestamp,
|
||||
firstEventTimestamp: ls.firstEventTimestamp,
|
||||
creationTime: ls.creationTime,
|
||||
storedBytes: ls.storedBytes ?? 0,
|
||||
})
|
||||
}
|
||||
|
||||
nextToken = response.nextToken
|
||||
if (!nextToken) break
|
||||
if (totalLimit !== undefined && logStreams.length >= totalLimit) break
|
||||
|
||||
if (page === MAX_LOG_STREAMS_PAGES - 1) {
|
||||
logger.warn(
|
||||
`DescribeLogStreams hit pagination cap of ${MAX_LOG_STREAMS_PAGES} pages; log stream list may be incomplete`,
|
||||
{ logGroupName }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
logStreams: totalLimit !== undefined ? logStreams.slice(0, totalLimit) : logStreams,
|
||||
}
|
||||
}
|
||||
|
||||
/** AWS FilterLogEvents caps `limit` at 10,000 events per page. */
|
||||
const FILTER_LOG_EVENTS_PAGE_SIZE = 10_000
|
||||
|
||||
/** Upper bound on pages drained to avoid unbounded loops on very active log groups. */
|
||||
const MAX_FILTER_LOG_EVENTS_PAGES = 20
|
||||
|
||||
interface FilteredLogEventResult {
|
||||
logStreamName: string | undefined
|
||||
timestamp: number | undefined
|
||||
message: string | undefined
|
||||
ingestionTime: number | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Searches log events across all streams (or a prefix-matched subset) in a log
|
||||
* group, following `nextToken` so the complete matching set is returned rather
|
||||
* than just the first page. Bounded by `MAX_FILTER_LOG_EVENTS_PAGES`.
|
||||
*
|
||||
* When `limit` is provided it is treated as a total result cap: draining stops
|
||||
* once enough events have been collected. When omitted, every page is drained.
|
||||
*/
|
||||
export async function filterLogEvents(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
options?: {
|
||||
filterPattern?: string
|
||||
logStreamNamePrefix?: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
startFromHead?: boolean
|
||||
limit?: number
|
||||
}
|
||||
): Promise<{ events: FilteredLogEventResult[] }> {
|
||||
const totalLimit = options?.limit
|
||||
const events: FilteredLogEventResult[] = []
|
||||
let nextToken: string | undefined
|
||||
|
||||
for (let page = 0; page < MAX_FILTER_LOG_EVENTS_PAGES; page++) {
|
||||
const pageLimit =
|
||||
totalLimit !== undefined
|
||||
? Math.min(FILTER_LOG_EVENTS_PAGE_SIZE, totalLimit - events.length)
|
||||
: FILTER_LOG_EVENTS_PAGE_SIZE
|
||||
|
||||
const command = new FilterLogEventsCommand({
|
||||
logGroupName,
|
||||
...(options?.filterPattern && { filterPattern: options.filterPattern }),
|
||||
...(options?.logStreamNamePrefix && { logStreamNamePrefix: options.logStreamNamePrefix }),
|
||||
...(options?.startTime !== undefined && { startTime: options.startTime }),
|
||||
...(options?.endTime !== undefined && { endTime: options.endTime }),
|
||||
...(options?.startFromHead !== undefined && { startFromHead: options.startFromHead }),
|
||||
limit: pageLimit,
|
||||
...(nextToken && { nextToken }),
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
|
||||
for (const e of response.events ?? []) {
|
||||
events.push({
|
||||
logStreamName: e.logStreamName,
|
||||
timestamp: e.timestamp,
|
||||
message: e.message,
|
||||
ingestionTime: e.ingestionTime,
|
||||
})
|
||||
}
|
||||
|
||||
nextToken = response.nextToken
|
||||
if (!nextToken) break
|
||||
if (totalLimit !== undefined && events.length >= totalLimit) break
|
||||
|
||||
if (page === MAX_FILTER_LOG_EVENTS_PAGES - 1) {
|
||||
logger.warn(
|
||||
`FilterLogEvents hit pagination cap of ${MAX_FILTER_LOG_EVENTS_PAGES} pages; event list may be incomplete`,
|
||||
{ logGroupName }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
events: totalLimit !== undefined ? events.slice(0, totalLimit) : events,
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLogEvents(
|
||||
client: CloudWatchLogsClient,
|
||||
logGroupName: string,
|
||||
logStreamName: string,
|
||||
options?: { startTime?: number; endTime?: number; limit?: number }
|
||||
): Promise<{
|
||||
events: {
|
||||
timestamp: number | undefined
|
||||
message: string | undefined
|
||||
ingestionTime: number | undefined
|
||||
}[]
|
||||
}> {
|
||||
const command = new GetLogEventsCommand({
|
||||
logGroupName,
|
||||
logStreamName,
|
||||
...(options?.startTime !== undefined && { startTime: options.startTime * 1000 }),
|
||||
...(options?.endTime !== undefined && { endTime: options.endTime * 1000 }),
|
||||
...(options?.limit !== undefined && { limit: options.limit }),
|
||||
startFromHead: true,
|
||||
})
|
||||
|
||||
const response = await client.send(command)
|
||||
return {
|
||||
events: (response.events ?? []).map((e) => ({
|
||||
timestamp: e.timestamp,
|
||||
message: e.message,
|
||||
ingestionTime: e.ingestionTime,
|
||||
})),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user