155 lines
9.5 KiB
TypeScript
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') : []
|
|
}
|