diff --git a/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts index b7d435a6e46..016b0e922bb 100644 --- a/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/mute-alarm/route.ts @@ -1,4 +1,4 @@ -import { CloudWatchClient, DisableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch' +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' @@ -9,6 +9,26 @@ 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) @@ -23,7 +43,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const validatedData = parsed.data.body - logger.info(`Muting ${validatedData.alarmNames.length} CloudWatch alarm(s)`) + 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, @@ -34,19 +61,30 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) try { - const command = new DisableAlarmActionsCommand({ - AlarmNames: validatedData.alarmNames, + 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 muted ${validatedData.alarmNames.length} alarm(s)`) + 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 { @@ -55,7 +93,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('MuteAlarm failed', { error: toError(error).message }) return NextResponse.json( - { error: `Failed to mute CloudWatch alarm: ${toError(error).message}` }, + { error: `Failed to create CloudWatch alarm mute rule: ${toError(error).message}` }, { status: 500 } ) } diff --git a/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts index 79357ae38f1..f950d9146af 100644 --- a/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts +++ b/apps/sim/app/api/tools/cloudwatch/unmute-alarm/route.ts @@ -1,4 +1,4 @@ -import { CloudWatchClient, EnableAlarmActionsCommand } from '@aws-sdk/client-cloudwatch' +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' @@ -23,7 +23,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const validatedData = parsed.data.body - logger.info(`Unmuting ${validatedData.alarmNames.length} CloudWatch alarm(s)`) + logger.info(`Deleting CloudWatch alarm mute rule "${validatedData.muteRuleName}"`) const client = new CloudWatchClient({ region: validatedData.region, @@ -34,19 +34,19 @@ export const POST = withRouteHandler(async (request: NextRequest) => { }) try { - const command = new EnableAlarmActionsCommand({ - AlarmNames: validatedData.alarmNames, + const command = new DeleteAlarmMuteRuleCommand({ + AlarmMuteRuleName: validatedData.muteRuleName, }) await client.send(command) - logger.info(`Successfully unmuted ${validatedData.alarmNames.length} alarm(s)`) + logger.info(`Successfully deleted mute rule "${validatedData.muteRuleName}"`) return NextResponse.json({ success: true, output: { success: true, - alarmNames: validatedData.alarmNames, + muteRuleName: validatedData.muteRuleName, }, }) } finally { @@ -55,7 +55,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { } catch (error) { logger.error('UnmuteAlarm failed', { error: toError(error).message }) return NextResponse.json( - { error: `Failed to unmute CloudWatch alarm: ${toError(error).message}` }, + { error: `Failed to delete CloudWatch alarm mute rule: ${toError(error).message}` }, { status: 500 } ) } diff --git a/apps/sim/blocks/blocks/cloudwatch.ts b/apps/sim/blocks/blocks/cloudwatch.ts index 585edb6a074..cbb303ceda8 100644 --- a/apps/sim/blocks/blocks/cloudwatch.ts +++ b/apps/sim/blocks/blocks/cloudwatch.ts @@ -366,13 +366,59 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, value: () => '', condition: { field: 'operation', value: 'describe_alarms' }, }, + { + id: 'muteRuleName', + title: 'Mute Rule Name', + type: 'short-input', + placeholder: 'my-mute-rule', + condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, + required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, + }, { id: 'alarmNames', title: 'Alarm Names', type: 'short-input', placeholder: 'my-alarm-1, my-alarm-2', - condition: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, - required: { field: 'operation', value: ['mute_alarm', 'unmute_alarm'] }, + condition: { field: 'operation', value: 'mute_alarm' }, + required: { field: 'operation', value: 'mute_alarm' }, + }, + { + id: 'durationValue', + title: 'Duration', + type: 'short-input', + placeholder: '1', + condition: { field: 'operation', value: 'mute_alarm' }, + required: { field: 'operation', value: 'mute_alarm' }, + }, + { + id: 'durationUnit', + title: 'Duration Unit', + type: 'dropdown', + options: [ + { label: 'Minutes', id: 'minutes' }, + { label: 'Hours', id: 'hours' }, + { label: 'Days', id: 'days' }, + ], + value: () => 'hours', + condition: { field: 'operation', value: 'mute_alarm' }, + required: { field: 'operation', value: 'mute_alarm' }, + }, + { + id: 'muteDescription', + title: 'Description', + type: 'short-input', + placeholder: 'Why these alarms are being muted', + condition: { field: 'operation', value: 'mute_alarm' }, + mode: 'advanced', + }, + { + id: 'muteStartDate', + title: 'Start Date', + type: 'short-input', + placeholder: 'e.g., 1711900800', + condition: { field: 'operation', value: 'mute_alarm' }, + mode: 'advanced', + description: 'Unix epoch seconds. Defaults to now (mute starts immediately).', }, { id: 'limit', @@ -633,8 +679,7 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, ...(parsedLimit !== undefined && { limit: parsedLimit }), } - case 'mute_alarm': - case 'unmute_alarm': { + case 'mute_alarm': { const alarmNames = rest.alarmNames if (!alarmNames) { throw new Error('Alarm names are required') @@ -652,11 +697,45 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, throw new Error('At least one alarm name is required') } + const durationValueRaw = rest.durationValue + const parsedDurationValue = + typeof durationValueRaw === 'number' + ? durationValueRaw + : Number.parseInt(String(durationValueRaw ?? ''), 10) + if (!Number.isFinite(parsedDurationValue) || parsedDurationValue < 1) { + throw new Error('Duration must be a positive integer') + } + + const startDateRaw = rest.muteStartDate + const parsedStartDate = + startDateRaw === undefined || startDateRaw === '' + ? undefined + : typeof startDateRaw === 'number' + ? startDateRaw + : Number.parseInt(String(startDateRaw), 10) + if (parsedStartDate !== undefined && !Number.isFinite(parsedStartDate)) { + throw new Error('Start date must be a Unix epoch in seconds') + } + return { awsRegion, awsAccessKeyId, awsSecretAccessKey, + muteRuleName: rest.muteRuleName, alarmNames: names, + durationValue: parsedDurationValue, + durationUnit: rest.durationUnit, + ...(rest.muteDescription && { description: rest.muteDescription }), + ...(parsedStartDate !== undefined && { startDate: parsedStartDate }), + } + } + + case 'unmute_alarm': { + return { + awsRegion, + awsAccessKeyId, + awsSecretAccessKey, + muteRuleName: rest.muteRuleName, } } @@ -700,7 +779,18 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, description: 'Alarm state filter (OK, ALARM, INSUFFICIENT_DATA)', }, alarmType: { type: 'string', description: 'Alarm type filter (MetricAlarm, CompositeAlarm)' }, - alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute or unmute' }, + muteRuleName: { type: 'string', description: 'Unique name for the alarm mute rule' }, + alarmNames: { type: 'string', description: 'Comma-separated alarm names to mute' }, + durationValue: { type: 'number', description: 'Length of the mute window' }, + durationUnit: { + type: 'string', + description: 'Unit for durationValue: minutes, hours, or days', + }, + muteDescription: { type: 'string', description: 'Description of the mute rule' }, + muteStartDate: { + type: 'number', + description: 'When the mute begins (Unix epoch seconds). Defaults to now.', + }, limit: { type: 'number', description: 'Maximum number of results' }, }, outputs: { @@ -746,7 +836,19 @@ Return ONLY the numeric timestamp - no explanations, no quotes, no extra text.`, }, alarmNames: { type: 'array', - description: 'Names of the alarms that were muted or unmuted', + description: 'Names of the alarms targeted by the mute rule', + }, + muteRuleName: { + type: 'string', + description: 'Name of the alarm mute rule that was created or deleted', + }, + expression: { + type: 'string', + description: 'Schedule expression used by the mute rule', + }, + duration: { + type: 'string', + description: 'ISO 8601 duration of the mute window', }, success: { type: 'boolean', diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts index cb95e59d9c3..30994189434 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-mute-alarm.ts @@ -7,26 +7,58 @@ import type { import { defineRouteContract } from '@/lib/api/contracts/types' import { validateAwsRegion } from '@/lib/core/security/input-validation' -const MuteAlarmSchema = z.object({ - region: z - .string() - .min(1, 'AWS region is required') - .refine((v) => validateAwsRegion(v).isValid, { - message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', - }), - accessKeyId: z.string().min(1, 'AWS access key ID is required'), - secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - alarmNames: z - .array(z.string().min(1, 'Alarm name cannot be empty')) - .min(1, 'At least one alarm name is required') - .max(100, 'At most 100 alarm names are allowed per request'), -}) +const MAX_MUTE_MINUTES = 15 * 24 * 60 + +const MuteAlarmSchema = z + .object({ + region: z + .string() + .min(1, 'AWS region is required') + .refine((v) => validateAwsRegion(v).isValid, { + message: 'Invalid AWS region format (e.g., us-east-1, eu-west-2)', + }), + accessKeyId: z.string().min(1, 'AWS access key ID is required'), + secretAccessKey: z.string().min(1, 'AWS secret access key is required'), + muteRuleName: z + .string() + .min(1, 'muteRuleName cannot be empty') + .max(255, 'muteRuleName must be at most 255 characters'), + alarmNames: z + .array(z.string().min(1, 'Alarm name cannot be empty').max(255)) + .min(1, 'At least one alarm name is required') + .max(100, 'At most 100 alarm names are allowed per mute rule'), + durationValue: z + .number() + .int('durationValue must be an integer') + .min(1, 'durationValue must be at least 1'), + durationUnit: z.enum(['minutes', 'hours', 'days']), + description: z.string().max(1024).optional(), + startDate: z + .number() + .int('startDate must be an integer') + .min(0, 'startDate must be a non-negative Unix epoch in seconds') + .optional(), + }) + .superRefine((data, ctx) => { + const minutesPerUnit = { minutes: 1, hours: 60, days: 1440 } as const + const totalMinutes = data.durationValue * minutesPerUnit[data.durationUnit] + if (totalMinutes > MAX_MUTE_MINUTES) { + ctx.addIssue({ + code: 'custom', + message: 'duration must be at most 15 days (CloudWatch mute rule limit)', + path: ['durationValue'], + }) + } + }) const MuteAlarmResponseSchema = z.object({ success: z.literal(true), output: z.object({ success: z.literal(true), + muteRuleName: z.string(), alarmNames: z.array(z.string()), + expression: z.string(), + duration: z.string(), }), }) diff --git a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts index f0fd1427a83..e98341cabf7 100644 --- a/apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts +++ b/apps/sim/lib/api/contracts/tools/aws/cloudwatch-unmute-alarm.ts @@ -16,17 +16,17 @@ const UnmuteAlarmSchema = z.object({ }), accessKeyId: z.string().min(1, 'AWS access key ID is required'), secretAccessKey: z.string().min(1, 'AWS secret access key is required'), - alarmNames: z - .array(z.string().min(1, 'Alarm name cannot be empty')) - .min(1, 'At least one alarm name is required') - .max(100, 'At most 100 alarm names are allowed per request'), + muteRuleName: z + .string() + .min(1, 'muteRuleName cannot be empty') + .max(255, 'muteRuleName must be at most 255 characters'), }) const UnmuteAlarmResponseSchema = z.object({ success: z.literal(true), output: z.object({ success: z.literal(true), - alarmNames: z.array(z.string()), + muteRuleName: z.string(), }), }) diff --git a/apps/sim/tools/cloudwatch/mute_alarm.ts b/apps/sim/tools/cloudwatch/mute_alarm.ts index 37591920dbe..720548be66e 100644 --- a/apps/sim/tools/cloudwatch/mute_alarm.ts +++ b/apps/sim/tools/cloudwatch/mute_alarm.ts @@ -7,7 +7,7 @@ import type { ToolConfig } from '@/tools/types' export const muteAlarmTool: ToolConfig = { id: 'cloudwatch_mute_alarm', name: 'CloudWatch Mute Alarm', - description: 'Disable notification actions on one or more CloudWatch alarms', + description: 'Create a CloudWatch alarm mute rule that suppresses alarms for a fixed duration', version: '1.0.0', params: { @@ -29,11 +29,42 @@ export const muteAlarmTool: ToolConfig = { id: 'cloudwatch_unmute_alarm', name: 'CloudWatch Unmute Alarm', - description: 'Re-enable notification actions on one or more CloudWatch alarms', + description: 'Delete a CloudWatch alarm mute rule, restoring alarm notifications', version: '1.0.0', params: { @@ -32,11 +32,11 @@ export const unmuteAlarmTool: ToolConfig< visibility: 'user-only', description: 'AWS secret access key', }, - alarmNames: { - type: 'array', + muteRuleName: { + type: 'string', required: true, visibility: 'user-or-llm', - description: 'Names of the CloudWatch alarms to unmute', + description: 'Name of the mute rule to delete', }, }, @@ -50,7 +50,7 @@ export const unmuteAlarmTool: ToolConfig< region: params.awsRegion, accessKeyId: params.awsAccessKeyId, secretAccessKey: params.awsSecretAccessKey, - alarmNames: params.alarmNames, + muteRuleName: params.muteRuleName, }), }, @@ -58,7 +58,7 @@ export const unmuteAlarmTool: ToolConfig< const data = await response.json() if (!response.ok) { - throw new Error(data.error || 'Failed to unmute CloudWatch alarm') + throw new Error(data.error || 'Failed to delete CloudWatch alarm mute rule') } return { @@ -68,11 +68,7 @@ export const unmuteAlarmTool: ToolConfig< }, outputs: { - success: { type: 'boolean', description: 'Whether the alarms were unmuted successfully' }, - alarmNames: { - type: 'array', - description: 'Names of the alarms that were unmuted', - items: { type: 'string' }, - }, + success: { type: 'boolean', description: 'Whether the mute rule was deleted successfully' }, + muteRuleName: { type: 'string', description: 'Name of the mute rule that was deleted' }, }, }