Files
floci-ui/packages/frontend/src/components/CloudWatchAlarmPanel.tsx
T
2026-10-24 22:48:41 -04:00

155 lines
9.5 KiB
TypeScript

import {RefreshCw} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {useEffect, useState} from 'react'
import {describeAlarmHistory, getCloudResource, getMetricSeries, setAlarmActionsEnabled, setAlarmState} from '@/api/cloudProxyClient'
import {HttpError} from '@/api/HttpClient'
import {MetricChart} from '@/components/MetricChart'
import {RANGES} from '@/lib/metricChart'
import {useAccountId} from '@/lib/accountStore'
import {formatDateTime} from '@/lib/format'
import type {AlarmStateValue, CloudResource, MetricDimension} from '@/types/resource'
type Props = {
resource?: CloudResource
runtimeReachable: boolean
}
const STATES: AlarmStateValue[] = ['ALARM', 'OK', 'INSUFFICIENT_DATA']
export function CloudWatchAlarmPanel({resource, runtimeReachable}: Props) {
const accountId = useAccountId()
const queryClient = useQueryClient()
const [rangeMinutes, setRangeMinutes] = useState(180)
const [nextState, setNextState] = useState<AlarmStateValue>('ALARM')
const [reason, setReason] = useState('')
const [notice, setNotice] = useState('')
const alarmKey = ['cloudwatch-alarm', accountId, resource?.id]
const historyKey = ['cloudwatch-alarm-history', accountId, resource?.id]
useEffect(() => {
setNotice('')
setReason('')
}, [resource?.id])
const alarmQuery = useQuery({
queryKey: alarmKey,
queryFn: ({signal}) => getCloudResource('aws', 'monitoring', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const historyQuery = useQuery({
queryKey: historyKey,
queryFn: ({signal}) => describeAlarmHistory('aws', resource?.id ?? '', signal),
enabled: Boolean(resource) && runtimeReachable,
})
const alarm = alarmQuery.data ?? resource
const metadata = alarm?.metadata ?? {}
const isMetric = metadata.alarmType === 'metric' && typeof metadata.metricName === 'string'
const dimensions = Array.isArray(metadata.dimensions) ? metadata.dimensions as MetricDimension[] : []
const seriesQuery = useQuery({
queryKey: ['cloudwatch-alarm-series', accountId, resource?.id, rangeMinutes, metadata.metricName, metadata.statistic],
queryFn: ({signal}) => getMetricSeries('aws', {
namespace: String(metadata.namespace),
metricName: String(metadata.metricName),
dimensions,
statistic: String(metadata.statistic ?? 'Average'),
rangeMinutes,
}, signal),
enabled: Boolean(resource) && runtimeReachable && isMetric,
})
const refresh = () => Promise.all([
queryClient.invalidateQueries({queryKey: alarmKey}),
queryClient.invalidateQueries({queryKey: historyKey}),
queryClient.invalidateQueries({queryKey: ['cloud-resources', 'aws', 'monitoring']}),
])
const stateMutation = useMutation({
mutationFn: () => setAlarmState('aws', resource?.id ?? '', nextState, reason),
onSuccess: () => {
setNotice(`Set to ${nextState}. Its actions ran if they are on; CloudWatch puts it back at the next evaluation.`)
setReason('')
return refresh()
},
})
const actionsMutation = useMutation({
mutationFn: (enabled: boolean) => setAlarmActionsEnabled('aws', resource?.id ?? '', enabled),
onSuccess: (_result, enabled) => {
setNotice(enabled ? 'Actions on: the alarm notifies its targets when its state changes.' : 'Actions off: the alarm still changes state but notifies nobody.')
return refresh()
},
})
if (!resource || !alarm) {
return <section className="table-panel"><div className="empty compact"><h3>Select an alarm</h3><p>Choose an alarm to see its metric, test its actions and read its history.</p></div></section>
}
const state = alarm.status ?? 'INSUFFICIENT_DATA'
const actionsEnabled = metadata.actionsEnabled !== false
const targets = [...new Set([...list(metadata.alarmActions), ...list(metadata.okActions)])]
const disabled = !runtimeReachable || stateMutation.isPending || actionsMutation.isPending
// TheCloud's runtime does not implement DescribeAlarmHistory yet; say so rather than fail.
const historyUnsupported = isNotImplemented(historyQuery.error)
const error = alarmQuery.error ?? (historyUnsupported ? null : historyQuery.error) ?? seriesQuery.error ?? stateMutation.error ?? actionsMutation.error
const history = historyQuery.data ?? []
return <section className="table-panel" style={{marginTop: 16}} aria-label={`Alarm ${alarm.name}`}>
<div className="input-row resource-table-bar">
<div>
<p className="eyebrow">Alarm</p>
<span className="cloudwatch-state-line"><span className={`badge ${state.toLowerCase().replace(/_/g, '-')}`}>{state}</span> <span className="muted">{String(metadata.stateReason ?? '')}</span></span>
</div>
<button className="button" type="button" disabled={!runtimeReachable || alarmQuery.isFetching} onClick={() => { void refresh(); void seriesQuery.refetch() }}><RefreshCw size={14}/>{alarmQuery.isFetching ? 'Loading' : 'Refresh'}</button>
</div>
<dl className="cognito-facts cloudwatch-facts">
<dt>{isMetric ? 'Condition' : 'Rule'}</dt><dd className="mono">{String(metadata.condition ?? '—')}</dd>
{isMetric && <><dt>Metric</dt><dd className="mono">{String(metadata.namespace)} / {String(metadata.metricName)}{dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</dd></>}
<dt>State changed</dt><dd>{formatDateTime(typeof metadata.stateUpdatedAt === 'string' ? metadata.stateUpdatedAt : null) ?? '—'}</dd>
<dt>Notifies</dt><dd className="mono">{targets.length ? targets.join(', ') : 'No actions'}</dd>
</dl>
{isMetric && <>
<div className="cloudwatch-range" role="group" aria-label="Time range">
{RANGES.map((range) => (
<button key={range.minutes} type="button" className={`button compact${rangeMinutes === range.minutes ? ' primary' : ''}`} aria-pressed={rangeMinutes === range.minutes} onClick={() => setRangeMinutes(range.minutes)}>{range.label}</button>
))}
</div>
<div className="cloudwatch-chart">
<p className="cloudwatch-chart-title">{String(metadata.metricName)} <span className="muted">· {String(metadata.statistic ?? '')}</span></p>
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
{seriesQuery.data && <MetricChart series={seriesQuery.data} threshold={typeof metadata.threshold === 'number' ? metadata.threshold : null}/>}
</div>
</>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">Test the alarm</p><span className="muted">Sets the state now, which runs its actions. CloudWatch evaluates it again at the next period.</span></div></div>
<form className="dynamic-form" aria-label="Set alarm state" onSubmit={(event) => { event.preventDefault(); setNotice(''); stateMutation.mutate() }}>
<label className="dynamic-field"><span>State</span>
<select className="input" value={nextState} onChange={(event) => setNextState(event.target.value as AlarmStateValue)} disabled={disabled}>
{STATES.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
</label>
<label className="dynamic-field"><span>Reason</span><input className="input" value={reason} placeholder="Testing the on-call notification" onChange={(event) => setReason(event.target.value)} disabled={disabled}/></label>
<div className="cloudwatch-form-actions">
<button className="button primary" type="submit" disabled={disabled}>{stateMutation.isPending ? 'Setting' : 'Set state'}</button>
<button className="button" type="button" disabled={disabled} onClick={() => { setNotice(''); actionsMutation.mutate(!actionsEnabled) }}>{actionsEnabled ? 'Turn actions off' : 'Turn actions on'}</button>
</div>
</form>
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
<div className="input-row resource-table-bar"><div><p className="eyebrow">History</p>{history.length > 0 && <span className="muted">Newest first.</span>}</div></div>
{historyUnsupported && <p className="muted padded">TheCloud's runtime does not record alarm history yet. State changes still notify the alarm's actions.</p>}
{historyQuery.isSuccess && history.length === 0 && <p className="muted padded">No history yet.</p>}
{history.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>When</th><th>Type</th><th>What happened</th></tr></thead><tbody>{history.map((item, index) => (
<tr key={`${item.timestamp}-${index}`}><td>{formatDateTime(item.timestamp) ?? '—'}</td><td><code>{item.type}</code></td><td>{item.summary}</td></tr>
))}</tbody></table></div>}
</section>
}
function isNotImplemented(error: unknown): boolean {
return error instanceof HttpError && typeof error.body === 'object' && error.body !== null
&& (error.body as {code?: unknown}).code === 'operation_not_implemented'
}
function list(value: unknown): string[] {
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === 'string') : []
}