feat(api): add CloudWatch alarms and metric charts to the cloud explorer
CI / checks (pull_request) Successful in 27m29s
CI / checks (pull_request) Successful in 27m29s
This commit is contained in:
@@ -130,6 +130,13 @@ export const apiEndpointKeys = {
|
||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
monitoring: {
|
||||
metrics: "clouds.services.monitoring.metrics",
|
||||
metricData: "clouds.services.monitoring.metric-data",
|
||||
history: "clouds.services.monitoring.history",
|
||||
state: "clouds.services.monitoring.state",
|
||||
actions: "clouds.services.monitoring.actions",
|
||||
},
|
||||
cdn: {
|
||||
enabled: "clouds.services.cdn.enabled",
|
||||
invalidations: "clouds.services.cdn.invalidations",
|
||||
@@ -890,6 +897,11 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.state, {path: "/clouds/:cloud/services/monitoring/resources/:id/state", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.monitoring.actions, {path: "/clouds/:cloud/services/monitoring/resources/:id/actions", method: "PUT"}],
|
||||
[
|
||||
apiEndpointKeys.clouds.cdn.enabled,
|
||||
{
|
||||
|
||||
@@ -33,6 +33,11 @@ import type {
|
||||
UserPoolSignInTestResult,
|
||||
UserPoolClientSettings,
|
||||
DistributionInvalidation,
|
||||
AlarmHistoryItem,
|
||||
AlarmStateValue,
|
||||
MetricSeries,
|
||||
MetricSeriesQuery,
|
||||
MetricSummary,
|
||||
UpdateUserPoolClientOAuthInput,
|
||||
UserPoolUserAction,
|
||||
LogsInsightsQueryInput,
|
||||
@@ -1477,6 +1482,70 @@ export async function testUserPoolSignIn(
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function listMetrics(
|
||||
cloud: CloudProvider,
|
||||
query: { namespace?: string; search?: string } = {},
|
||||
signal?: AbortSignal,
|
||||
): Promise<MetricSummary[]> {
|
||||
const res = await apiClient.call<MetricSummary[]>(
|
||||
apiEndpointKeys.clouds.monitoring.metrics,
|
||||
requestOptions(cloud, "monitoring", { signal, params: { namespace: query.namespace || undefined, search: query.search || undefined } }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function getMetricSeries(
|
||||
cloud: CloudProvider,
|
||||
query: MetricSeriesQuery,
|
||||
signal?: AbortSignal,
|
||||
): Promise<MetricSeries> {
|
||||
const res = await apiClient.call<MetricSeries, MetricSeriesQuery>(
|
||||
apiEndpointKeys.clouds.monitoring.metricData,
|
||||
requestOptions(cloud, "monitoring", { signal, body: query }),
|
||||
{ cloud },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function describeAlarmHistory(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AlarmHistoryItem[]> {
|
||||
const res = await apiClient.call<AlarmHistoryItem[]>(
|
||||
apiEndpointKeys.clouds.monitoring.history,
|
||||
requestOptions(cloud, "monitoring", { signal }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setAlarmState(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
state: AlarmStateValue,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { state: AlarmStateValue; reason: string }>(
|
||||
apiEndpointKeys.clouds.monitoring.state,
|
||||
requestOptions(cloud, "monitoring", { body: { state, reason } }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setAlarmActionsEnabled(
|
||||
cloud: CloudProvider,
|
||||
alarmName: string,
|
||||
enabled: boolean,
|
||||
): Promise<void> {
|
||||
await apiClient.call<void, { enabled: boolean }>(
|
||||
apiEndpointKeys.clouds.monitoring.actions,
|
||||
requestOptions(cloud, "monitoring", { body: { enabled } }),
|
||||
{ cloud, id: alarmName },
|
||||
);
|
||||
}
|
||||
|
||||
export async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {CloudWatchAlarmPanel} from './CloudWatchAlarmPanel'
|
||||
import {HttpError} from '@/api/HttpClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getCloudResource: vi.fn(),
|
||||
describeAlarmHistory: vi.fn(),
|
||||
getMetricSeries: vi.fn(),
|
||||
setAlarmState: vi.fn(),
|
||||
setAlarmActionsEnabled: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
const alarm: CloudResource = {
|
||||
id: 'high-errors', name: 'high-errors', cloud: 'aws', service: 'monitoring', type: 'alarm', region: 'us-east-1', createdAt: null, status: 'ALARM',
|
||||
metadata: {
|
||||
alarmType: 'metric', stateReason: 'Threshold crossed', actionsEnabled: true, alarmActions: ['arn:aws:sns:us-east-1:000000000000:alerts'], okActions: [],
|
||||
namespace: 'App', metricName: 'Errors', dimensions: [], statistic: 'Sum', threshold: 5, condition: 'Errors Sum > 5 for 1 × 5 min',
|
||||
},
|
||||
}
|
||||
|
||||
function renderPanel() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudWatchAlarmPanel resource={alarm} runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.getCloudResource.mockResolvedValue(alarm)
|
||||
api.describeAlarmHistory.mockResolvedValue([{timestamp: null, type: 'StateUpdate', summary: 'Alarm updated from OK to ALARM'}])
|
||||
api.getMetricSeries.mockResolvedValue({label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 7}]})
|
||||
api.setAlarmState.mockResolvedValue(undefined)
|
||||
api.setAlarmActionsEnabled.mockResolvedValue(undefined)
|
||||
})
|
||||
|
||||
test('shows the state, its metric with the threshold, the targets and the history', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(screen.getByText('Threshold crossed')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Threshold 5')).toBeInTheDocument()
|
||||
expect(screen.getByText('arn:aws:sns:us-east-1:000000000000:alerts')).toBeInTheDocument()
|
||||
expect(await screen.findByText('Alarm updated from OK to ALARM')).toBeInTheDocument()
|
||||
expect(api.getMetricSeries).toHaveBeenCalledWith('aws', expect.objectContaining({namespace: 'App', metricName: 'Errors', statistic: 'Sum', rangeMinutes: 180}), expect.anything())
|
||||
})
|
||||
|
||||
test('sets a state to test the actions, and turns the actions off', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderPanel()
|
||||
|
||||
const form = await screen.findByRole('form', {name: 'Set alarm state'})
|
||||
await user.selectOptions(within(form).getByLabelText('State'), 'OK')
|
||||
await user.type(within(form).getByLabelText('Reason'), 'Testing')
|
||||
await user.click(within(form).getByRole('button', {name: 'Set state'}))
|
||||
expect(api.setAlarmState).toHaveBeenCalledWith('aws', 'high-errors', 'OK', 'Testing')
|
||||
expect(await screen.findByText(/Set to OK/)).toBeInTheDocument()
|
||||
|
||||
await user.click(within(form).getByRole('button', {name: 'Turn actions off'}))
|
||||
expect(api.setAlarmActionsEnabled).toHaveBeenCalledWith('aws', 'high-errors', false)
|
||||
})
|
||||
|
||||
test('says when the runtime does not record alarm history, without an error', async () => {
|
||||
api.describeAlarmHistory.mockRejectedValue(new HttpError(501, 'Not Implemented', {code: 'operation_not_implemented', error: 'Operation is not implemented by the selected runtime'}, '/history'))
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText(/does not record alarm history yet/)).toBeInTheDocument()
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
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') : []
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {CloudWatchMetricsExplorer} from './CloudWatchMetricsExplorer'
|
||||
|
||||
const api = vi.hoisted(() => ({listMetrics: vi.fn(), getMetricSeries: vi.fn()}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
vi.mock('@/lib/accountStore', () => ({useAccountId: () => '000000000000'}))
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.listMetrics.mockResolvedValue([
|
||||
{namespace: 'App', metricName: 'Errors', dimensions: []},
|
||||
{namespace: 'AWS/Lambda', metricName: 'Invocations', dimensions: [{name: 'FunctionName', value: 'checkout'}]},
|
||||
])
|
||||
api.getMetricSeries.mockResolvedValue({label: 'Invocations', statistic: 'Sum', periodSeconds: 60, points: [{timestamp: '2026-10-25T10:00:00Z', value: 12}]})
|
||||
})
|
||||
|
||||
function renderExplorer() {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><CloudWatchMetricsExplorer runtimeReachable/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
test('filters metrics by namespace and charts the chosen one', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderExplorer()
|
||||
|
||||
expect(await screen.findByText('2 metrics in 2 namespaces. Pick one to chart it.')).toBeInTheDocument()
|
||||
await user.selectOptions(screen.getByLabelText('Namespace'), 'AWS/Lambda')
|
||||
expect(screen.queryByText('Errors')).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', {name: 'Chart Invocations for checkout'}))
|
||||
await user.selectOptions(screen.getByLabelText('Statistic'), 'Sum')
|
||||
expect(await screen.findByRole('img', {name: /Invocations \(Sum\)/})).toBeInTheDocument()
|
||||
expect(api.getMetricSeries).toHaveBeenLastCalledWith('aws', expect.objectContaining({namespace: 'AWS/Lambda', metricName: 'Invocations', statistic: 'Sum', dimensions: [{name: 'FunctionName', value: 'checkout'}]}), expect.anything())
|
||||
})
|
||||
@@ -0,0 +1,86 @@
|
||||
import {RefreshCw} from 'lucide-react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
import {useMemo, useState} from 'react'
|
||||
import {getMetricSeries, listMetrics} from '@/api/cloudProxyClient'
|
||||
import {MetricChart} from '@/components/MetricChart'
|
||||
import {RANGES} from '@/lib/metricChart'
|
||||
import {useAccountId} from '@/lib/accountStore'
|
||||
import type {MetricSummary} from '@/types/resource'
|
||||
|
||||
const STATISTICS = ['Average', 'Sum', 'Minimum', 'Maximum', 'SampleCount']
|
||||
|
||||
export function CloudWatchMetricsExplorer({runtimeReachable}: {runtimeReachable: boolean}) {
|
||||
const accountId = useAccountId()
|
||||
const [namespace, setNamespace] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [selected, setSelected] = useState<MetricSummary | null>(null)
|
||||
const [statistic, setStatistic] = useState('Average')
|
||||
const [rangeMinutes, setRangeMinutes] = useState(180)
|
||||
|
||||
const metricsQuery = useQuery({
|
||||
queryKey: ['cloudwatch-metrics', accountId],
|
||||
queryFn: ({signal}) => listMetrics('aws', {}, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const seriesQuery = useQuery({
|
||||
queryKey: ['cloudwatch-series', accountId, selected && metricKey(selected), statistic, rangeMinutes],
|
||||
queryFn: ({signal}) => getMetricSeries('aws', {...selected!, statistic, rangeMinutes}, signal),
|
||||
enabled: runtimeReachable && selected !== null,
|
||||
})
|
||||
|
||||
const metrics = useMemo(() => metricsQuery.data ?? [], [metricsQuery.data])
|
||||
const namespaces = useMemo(() => [...new Set(metrics.map((metric) => metric.namespace))].sort(), [metrics])
|
||||
const term = search.trim().toLowerCase()
|
||||
const shown = metrics.filter((metric) => (!namespace || metric.namespace === namespace)
|
||||
&& (!term || [metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].some((value) => value.toLowerCase().includes(term))))
|
||||
const error = metricsQuery.error ?? seriesQuery.error
|
||||
|
||||
return <section className="table-panel" style={{marginTop: 16}} aria-label="Metrics">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div><p className="eyebrow">Metrics</p><span className="muted">{metrics.length} {metrics.length === 1 ? 'metric' : 'metrics'} in {namespaces.length} {namespaces.length === 1 ? 'namespace' : 'namespaces'}. Pick one to chart it.</span></div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || metricsQuery.isFetching} onClick={() => { void metricsQuery.refetch(); if (selected) void seriesQuery.refetch() }}><RefreshCw size={14}/>{metricsQuery.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
<div className="cloudwatch-filters">
|
||||
<label className="dynamic-field"><span>Namespace</span>
|
||||
<select className="input" value={namespace} onChange={(event) => setNamespace(event.target.value)}>
|
||||
<option value="">All namespaces</option>
|
||||
{namespaces.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Search</span><input className="input" value={search} placeholder="Metric or dimension" onChange={(event) => setSearch(event.target.value)}/></label>
|
||||
</div>
|
||||
|
||||
{selected && <div className="cloudwatch-chart">
|
||||
<div className="cloudwatch-chart-head">
|
||||
<p className="cloudwatch-chart-title">{selected.metricName} <span className="muted">· {selected.namespace}{selected.dimensions.map((dimension) => ` · ${dimension.name}=${dimension.value}`).join('')}</span></p>
|
||||
<div className="cloudwatch-range" role="group" aria-label="Chart settings">
|
||||
<select className="input" aria-label="Statistic" value={statistic} onChange={(event) => setStatistic(event.target.value)}>
|
||||
{STATISTICS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
{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>
|
||||
{seriesQuery.isLoading && <p className="muted">Loading metric data</p>}
|
||||
{seriesQuery.data && <MetricChart series={seriesQuery.data}/>}
|
||||
</div>}
|
||||
|
||||
{error instanceof Error && <p className="error-text compact-text" role="alert">{error.message}</p>}
|
||||
{metricsQuery.isSuccess && metrics.length === 0 && <div className="empty compact"><h3>No metrics yet</h3><p>Metrics appear when services or your apps publish them, for example with PutMetricData.</p></div>}
|
||||
{shown.length > 0 && <div className="cognito-table"><table className="table"><thead><tr><th>Metric</th><th>Namespace</th><th>Dimensions</th><th/></tr></thead><tbody>{shown.map((metric) => {
|
||||
const isSelected = selected !== null && metricKey(selected) === metricKey(metric)
|
||||
return <tr key={metricKey(metric)} aria-current={isSelected ? 'true' : undefined}>
|
||||
<td className="mono">{metric.metricName}</td>
|
||||
<td className="mono">{metric.namespace}</td>
|
||||
<td className="mono">{metric.dimensions.length ? metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`).join(', ') : '—'}</td>
|
||||
<td className="cognito-row-actions-cell"><button className="button compact" type="button" aria-label={`Chart ${metric.metricName}${metric.dimensions.length ? ` for ${metric.dimensions.map((dimension) => dimension.value).join(', ')}` : ''}`} onClick={() => setSelected(isSelected ? null : metric)}>{isSelected ? 'Hide' : 'Chart'}</button></td>
|
||||
</tr>
|
||||
})}</tbody></table></div>}
|
||||
{metricsQuery.isSuccess && metrics.length > 0 && shown.length === 0 && <p className="muted padded">No metrics match.</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function metricKey(metric: MetricSummary): string {
|
||||
return [metric.namespace, metric.metricName, ...metric.dimensions.map((dimension) => `${dimension.name}=${dimension.value}`)].join('|')
|
||||
}
|
||||
@@ -67,6 +67,8 @@ import { LoadBalancerListenerPanel } from "@/components/LoadBalancerListenerPane
|
||||
import { CognitoUsersPanel } from "@/components/CognitoUsersPanel";
|
||||
import { Route53RecordsPanel } from "@/components/Route53RecordsPanel";
|
||||
import { CloudFrontPanel } from "@/components/CloudFrontPanel";
|
||||
import { CloudWatchAlarmPanel } from "@/components/CloudWatchAlarmPanel";
|
||||
import { CloudWatchMetricsExplorer } from "@/components/CloudWatchMetricsExplorer";
|
||||
import { WorkflowExecutionDebugger } from "@/components/WorkflowExecutionDebugger";
|
||||
import { CodeBuildPanel } from "@/components/CodeBuildPanel";
|
||||
import { CodePipelinePanel } from "@/components/CodePipelinePanel";
|
||||
@@ -987,6 +989,12 @@ export function DynamicResourceView({
|
||||
{service === "cdn" && cloud === "aws" && (
|
||||
<CloudFrontPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
)}
|
||||
{service === "monitoring" && cloud === "aws" && (
|
||||
<>
|
||||
<CloudWatchAlarmPanel resource={activeSelected} runtimeReachable={canUseRuntime} />
|
||||
<CloudWatchMetricsExplorer runtimeReachable={canUseRuntime} />
|
||||
</>
|
||||
)}
|
||||
{service === "compute" && (
|
||||
<ComputePanel
|
||||
cloud={cloud}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {fireEvent, render, screen} from '@testing-library/react'
|
||||
import {expect, test} from 'vitest'
|
||||
import {MetricChart} from './MetricChart'
|
||||
|
||||
const series = {label: 'Errors', statistic: 'Sum', periodSeconds: 60, points: [
|
||||
{timestamp: '2026-10-25T10:00:00Z', value: 3},
|
||||
{timestamp: '2026-10-25T10:01:00Z', value: 7},
|
||||
]}
|
||||
|
||||
test('draws the line, labels the latest value and the threshold, and tabulates the points', () => {
|
||||
const {container} = render(<MetricChart series={series} threshold={5}/>)
|
||||
|
||||
expect(container.querySelector('.metric-chart-line')?.getAttribute('d')).toMatch(/^M[\d.]+,[\d.]+ L[\d.]+,[\d.]+$/)
|
||||
expect(screen.getByText('Threshold 5')).toBeInTheDocument()
|
||||
expect(screen.getByRole('img', {name: /Errors \(Sum\), 2 points; latest 7 .*threshold 5/})).toBeInTheDocument()
|
||||
expect(screen.getByText('Data table (2 points)')).toBeInTheDocument()
|
||||
expect(container.querySelectorAll('.metric-chart-table tbody tr')).toHaveLength(2)
|
||||
})
|
||||
|
||||
test('shows the tooltip from the keyboard, value first', () => {
|
||||
render(<MetricChart series={series}/>)
|
||||
const chart = screen.getByRole('img')
|
||||
|
||||
fireEvent.focus(chart)
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/^7Errors · Sum/)
|
||||
fireEvent.keyDown(chart, {key: 'ArrowLeft'})
|
||||
expect(screen.getByRole('status')).toHaveTextContent(/^3/)
|
||||
fireEvent.keyDown(chart, {key: 'Escape'})
|
||||
expect(screen.queryByRole('status')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('says when there is no data', () => {
|
||||
render(<MetricChart series={{...series, points: []}}/>)
|
||||
expect(screen.getByText('No data points in this time range.')).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,145 @@
|
||||
import {useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type PointerEvent} from 'react'
|
||||
import {formatValue, niceTicks, timeTicks} from '@/lib/metricChart'
|
||||
import type {MetricSeries} from '@/types/resource'
|
||||
|
||||
type Props = {
|
||||
series: MetricSeries
|
||||
/** The alarm threshold, drawn as a dashed reference line. */
|
||||
threshold?: number | null
|
||||
height?: number
|
||||
}
|
||||
|
||||
const MARGIN = {top: 16, right: 56, bottom: 28, left: 48}
|
||||
/** Few points read as a scatter of values, so each gets a marker. */
|
||||
const MARKER_LIMIT = 24
|
||||
|
||||
/**
|
||||
* One metric over time: a 2px line, a solid hairline grid, the latest value labelled at the end,
|
||||
* an optional dashed threshold, a crosshair tooltip that snaps to the nearest point (keyboard too)
|
||||
* and a table view of the same values.
|
||||
*/
|
||||
export function MetricChart({series, threshold = null, height = 220}: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(640)
|
||||
const [active, setActive] = useState<number | null>(null)
|
||||
const titleId = useId()
|
||||
|
||||
useEffect(() => {
|
||||
const element = containerRef.current
|
||||
// Without ResizeObserver (old browsers, tests) the chart keeps its default width.
|
||||
if (!element || typeof ResizeObserver === 'undefined') return
|
||||
const observer = new ResizeObserver(([entry]) => setWidth(Math.max(280, Math.round(entry.contentRect.width))))
|
||||
observer.observe(element)
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const points = series.points
|
||||
const geometry = useMemo(() => {
|
||||
const innerWidth = width - MARGIN.left - MARGIN.right
|
||||
const innerHeight = height - MARGIN.top - MARGIN.bottom
|
||||
const times = points.map((point) => Date.parse(point.timestamp))
|
||||
const values = points.map((point) => point.value)
|
||||
const maxValue = Math.max(...values, threshold ?? -Infinity, 0)
|
||||
const minValue = Math.min(...values, threshold ?? Infinity, 0)
|
||||
const ticks = niceTicks(minValue, maxValue === minValue ? minValue + 1 : maxValue)
|
||||
const yMin = ticks[0]
|
||||
const yMax = ticks[ticks.length - 1]
|
||||
const tMin = times.length ? Math.min(...times) : 0
|
||||
const tMax = times.length ? Math.max(...times) : 1
|
||||
const x = (time: number) => MARGIN.left + (tMax === tMin ? innerWidth / 2 : ((time - tMin) / (tMax - tMin)) * innerWidth)
|
||||
const y = (value: number) => MARGIN.top + innerHeight - ((value - yMin) / (yMax - yMin)) * innerHeight
|
||||
return {innerWidth, innerHeight, times, ticks, x, y, tMin, tMax}
|
||||
}, [points, threshold, width, height])
|
||||
|
||||
if (points.length === 0) {
|
||||
return <div className="metric-chart metric-chart--empty" ref={containerRef}><p className="muted">No data points in this time range.</p></div>
|
||||
}
|
||||
|
||||
const {times, ticks, x, y, tMin, tMax} = geometry
|
||||
const path = points.map((point, index) => `${index ? 'L' : 'M'}${x(times[index]).toFixed(1)},${y(point.value).toFixed(1)}`).join(' ')
|
||||
const last = points.length - 1
|
||||
const spansDays = tMax - tMin > 24 * 3600_000
|
||||
const xTicks = timeTicks(tMin, tMax, Math.max(2, Math.floor(geometry.innerWidth / 120)))
|
||||
const shown = active ?? null
|
||||
|
||||
const nearest = (clientX: number, svg: SVGSVGElement) => {
|
||||
const box = svg.getBoundingClientRect()
|
||||
const px = ((clientX - box.left) / box.width) * width
|
||||
let best = 0
|
||||
for (let index = 1; index < times.length; index++) {
|
||||
if (Math.abs(x(times[index]) - px) < Math.abs(x(times[best]) - px)) best = index
|
||||
}
|
||||
return best
|
||||
}
|
||||
const onPointerMove = (event: PointerEvent<SVGSVGElement>) => setActive(nearest(event.clientX, event.currentTarget))
|
||||
const onKeyDown = (event: KeyboardEvent<SVGSVGElement>) => {
|
||||
if (event.key === 'ArrowLeft') { event.preventDefault(); setActive((index) => Math.max(0, (index ?? last) - 1)) }
|
||||
if (event.key === 'ArrowRight') { event.preventDefault(); setActive((index) => Math.min(last, (index ?? last) + 1)) }
|
||||
if (event.key === 'Home') { event.preventDefault(); setActive(0) }
|
||||
if (event.key === 'End') { event.preventDefault(); setActive(last) }
|
||||
if (event.key === 'Escape') setActive(null)
|
||||
}
|
||||
const summary = `${series.label} (${series.statistic}), ${points.length} points; latest ${formatValue(points[last].value)} at ${formatTime(times[last], spansDays)}${threshold === null ? '' : `; threshold ${formatValue(threshold)}`}`
|
||||
|
||||
return <div className="metric-chart" ref={containerRef}>
|
||||
<svg
|
||||
width={width}
|
||||
height={height}
|
||||
viewBox={`0 0 ${width} ${height}`}
|
||||
role="img"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={0}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerLeave={() => setActive(null)}
|
||||
onFocus={() => setActive(last)}
|
||||
onBlur={() => setActive(null)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<title id={titleId}>{summary}</title>
|
||||
{ticks.map((tick) => (
|
||||
<g key={tick}>
|
||||
<line className="metric-chart-grid" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(tick)} y2={y(tick)}/>
|
||||
<text className="metric-chart-axis" x={MARGIN.left - 8} y={y(tick)} dy="0.32em" textAnchor="end">{formatValue(tick)}</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks.map((tick) => (
|
||||
<text key={tick} className="metric-chart-axis" x={x(tick)} y={height - 8} textAnchor="middle">{formatTime(tick, spansDays)}</text>
|
||||
))}
|
||||
{threshold !== null && <g>
|
||||
<line className="metric-chart-threshold" x1={MARGIN.left} x2={width - MARGIN.right} y1={y(threshold)} y2={y(threshold)}/>
|
||||
<text className="metric-chart-label" x={width - MARGIN.right} y={y(threshold) - 6} textAnchor="end">{`Threshold ${formatValue(threshold)}`}</text>
|
||||
</g>}
|
||||
<path className="metric-chart-line" d={path}/>
|
||||
{points.length <= MARKER_LIMIT && points.map((point, index) => (
|
||||
<circle key={point.timestamp} className="metric-chart-marker" cx={x(times[index])} cy={y(point.value)} r={4}/>
|
||||
))}
|
||||
<text className="metric-chart-label metric-chart-label--strong" x={x(times[last]) + 8} y={y(points[last].value)} dy="0.32em">{formatValue(points[last].value)}</text>
|
||||
{shown !== null && <g pointerEvents="none">
|
||||
<line className="metric-chart-crosshair" x1={x(times[shown])} x2={x(times[shown])} y1={MARGIN.top} y2={height - MARGIN.bottom}/>
|
||||
<circle className="metric-chart-marker metric-chart-marker--active" cx={x(times[shown])} cy={y(points[shown].value)} r={5}/>
|
||||
</g>}
|
||||
</svg>
|
||||
{shown !== null && <div className="metric-chart-tooltip" role="status" style={tooltipPosition(x(times[shown]), width)}>
|
||||
<strong>{formatValue(points[shown].value)}</strong>
|
||||
<span><span className="metric-chart-key" aria-hidden="true"/>{series.label} · {series.statistic}</span>
|
||||
<span className="muted">{formatTime(times[shown], true)}</span>
|
||||
</div>}
|
||||
<details className="metric-chart-table">
|
||||
<summary>Data table ({points.length} points)</summary>
|
||||
<table className="table"><thead><tr><th>Time</th><th>{series.statistic}</th></tr></thead><tbody>{points.slice().reverse().map((point) => (
|
||||
<tr key={point.timestamp}><td>{new Date(point.timestamp).toLocaleString()}</td><td className="mono">{formatValue(point.value)}</td></tr>
|
||||
))}</tbody></table>
|
||||
</details>
|
||||
</div>
|
||||
}
|
||||
|
||||
function tooltipPosition(px: number, width: number) {
|
||||
return px > width * 0.6 ? {right: width - px + 12} : {left: px + 12}
|
||||
}
|
||||
|
||||
function formatTime(time: number, withDate: boolean): string {
|
||||
const date = new Date(time)
|
||||
return withDate
|
||||
? date.toLocaleString(undefined, {month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'})
|
||||
: date.toLocaleTimeString(undefined, {hour: '2-digit', minute: '2-digit'})
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
Activity,
|
||||
Boxes,
|
||||
Circle,
|
||||
Clock,
|
||||
@@ -35,6 +36,7 @@ import codeDeployIcon from '../../../../aws-icons-svg/Developer-Tools/CodeDeploy
|
||||
import cognitoIcon from '../../../../aws-icons-svg/Security-Identity-Compliance/Cognito.svg'
|
||||
import route53Icon from '../../../../aws-icons-svg/Networking-Content-Delivery/Route-53.svg'
|
||||
import cloudFrontIcon from '../../../../aws-icons-svg/Networking-Content-Delivery/CloudFront.svg'
|
||||
import cloudWatchIcon from '../../../../aws-icons-svg/Management-Governance/CloudWatch.svg'
|
||||
import cloudFormationIcon from '@/assets/aws-services/cloudformation.svg'
|
||||
import cloudWatchLogsIcon from '@/assets/aws-services/cloudwatch-logs.svg'
|
||||
import dynamoDbIcon from '@/assets/aws-services/dynamodb.svg'
|
||||
@@ -93,6 +95,7 @@ const SERVICE_ICONS: Record<string, LucideIcon> = {
|
||||
userpools: UsersRound,
|
||||
dns: Globe,
|
||||
cdn: Earth,
|
||||
monitoring: Activity,
|
||||
loadbalancing: Scale,
|
||||
parameters: SlidersHorizontal,
|
||||
configuration: SlidersHorizontal,
|
||||
@@ -139,6 +142,7 @@ const AWS_SERVICE_ICON_ASSETS: Record<string, string> = {
|
||||
userpools: cognitoIcon,
|
||||
dns: route53Icon,
|
||||
cdn: cloudFrontIcon,
|
||||
monitoring: cloudWatchIcon,
|
||||
workflows: stepFunctionsIcon,
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
--status-error: #ff9a9a;
|
||||
|
||||
--accent: #6cc4ff;
|
||||
--chart-line: #2f9ce0;
|
||||
--accent-tint: rgba(108, 196, 255, 0.16);
|
||||
--accent-tint-faint: rgba(108, 196, 255, 0.07);
|
||||
--accent-tint-border: rgba(108, 196, 255, 0.35);
|
||||
@@ -148,6 +149,7 @@
|
||||
--status-error: #bd3f30;
|
||||
|
||||
--accent: var(--coral);
|
||||
--chart-line: #d9573f;
|
||||
--accent-tint: rgba(237, 111, 92, 0.14);
|
||||
--accent-tint-faint: rgba(237, 111, 92, 0.07);
|
||||
--accent-tint-border: rgba(237, 111, 92, 0.3);
|
||||
@@ -244,6 +246,7 @@
|
||||
--status-error: #ff8da1;
|
||||
|
||||
--accent: #4eff96;
|
||||
--chart-line: #2fae67;
|
||||
--accent-tint: rgba(78, 255, 150, 0.14);
|
||||
--accent-tint-faint: rgba(78, 255, 150, 0.07);
|
||||
--accent-tint-border: rgba(78, 255, 150, 0.34);
|
||||
@@ -6538,3 +6541,59 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
|
||||
.cdn-viewer .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||
.cdn-viewer small { flex-basis: 100%; }
|
||||
|
||||
/* CloudWatch: alarm states carry a dot in the status colour next to their label. */
|
||||
.badge.ok::before, .badge.alarm::before, .badge.insufficient-data::before {
|
||||
content: "";
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
.badge.ok::before { background: var(--status-ok); }
|
||||
.badge.alarm::before { background: var(--status-error); }
|
||||
.badge.insufficient-data::before { background: var(--text-3); }
|
||||
.badge.alarm { border: 1px solid var(--status-error); }
|
||||
|
||||
.cloudwatch-state-line { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; }
|
||||
.cloudwatch-facts { padding: 0 16px 12px; }
|
||||
.cloudwatch-filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; padding: 0 16px 12px; }
|
||||
.cloudwatch-range { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 0 16px 8px; }
|
||||
.cloudwatch-range .input { width: auto; }
|
||||
.cloudwatch-chart { padding: 4px 16px 12px; }
|
||||
.cloudwatch-chart-head { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; }
|
||||
.cloudwatch-chart-head .cloudwatch-range { padding: 0; }
|
||||
.cloudwatch-chart-title { margin: 0 0 6px; font-weight: 600; overflow-wrap: anywhere; }
|
||||
.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
|
||||
.cognito-row-actions-cell { text-align: right; }
|
||||
|
||||
.metric-chart { position: relative; width: 100%; min-width: 0; }
|
||||
.metric-chart svg { display: block; max-width: 100%; height: auto; overflow: visible; }
|
||||
.metric-chart svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
.metric-chart--empty { padding: 24px 0; }
|
||||
.metric-chart-grid { stroke: var(--border-faint); stroke-width: 1; }
|
||||
.metric-chart-axis { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-line { fill: none; stroke: var(--chart-line); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
|
||||
.metric-chart-marker { fill: var(--chart-line); stroke: var(--surface); stroke-width: 2; }
|
||||
.metric-chart-marker--active { stroke-width: 2.5; }
|
||||
.metric-chart-threshold { stroke: var(--text-3); stroke-width: 1.5; stroke-dasharray: 5 4; }
|
||||
.metric-chart-crosshair { stroke: var(--text-3); stroke-width: 1; }
|
||||
.metric-chart-label { fill: var(--text-3); font-size: 11px; }
|
||||
.metric-chart-label--strong { fill: var(--text-bright, var(--text)); font-weight: 600; }
|
||||
.metric-chart-tooltip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--elevated);
|
||||
font-size: 12px;
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.metric-chart-tooltip strong { font-size: 14px; color: var(--text-bright, var(--text)); }
|
||||
.metric-chart-key { display: inline-block; width: 12px; height: 2px; margin: 0 6px 3px 0; background: var(--chart-line); vertical-align: middle; }
|
||||
.metric-chart-table { margin-top: 8px; }
|
||||
.metric-chart-table summary { cursor: pointer; color: var(--text-3); font-size: 12px; }
|
||||
.metric-chart-table table { margin-top: 8px; }
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import {expect, test} from 'vitest'
|
||||
import {formatValue, niceTicks} from './metricChart'
|
||||
|
||||
test('picks about five round ticks that include zero', () => {
|
||||
expect(niceTicks(0, 7)).toEqual([0, 2, 4, 6, 8])
|
||||
expect(niceTicks(0, 100)).toEqual([0, 25, 50, 75, 100])
|
||||
expect(niceTicks(-3, 3)).toEqual([-4, -2, 0, 2, 4])
|
||||
})
|
||||
|
||||
test('formats values compactly from a thousand up', () => {
|
||||
expect([formatValue(7), formatValue(0.25), formatValue(1500), formatValue(2_000_000), formatValue(12.5)]).toEqual(['7', '0.25', '1.5K', '2M', '12.5'])
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
/** Time ranges offered above CloudWatch charts. */
|
||||
export const RANGES = [{label: '1 hour', minutes: 60}, {label: '3 hours', minutes: 180}, {label: '24 hours', minutes: 1440}, {label: '7 days', minutes: 10080}]
|
||||
|
||||
/** About five round ticks covering [min, max], always including zero when the range does. */
|
||||
export function niceTicks(min: number, max: number, count = 5): number[] {
|
||||
const span = max - min
|
||||
const raw = span / (count - 1)
|
||||
const magnitude = 10 ** Math.floor(Math.log10(raw))
|
||||
const step = [1, 2, 2.5, 5, 10].map((factor) => factor * magnitude).find((candidate) => candidate >= raw) ?? 10 * magnitude
|
||||
const start = Math.floor(min / step) * step
|
||||
const end = Math.ceil(max / step) * step
|
||||
const ticks: number[] = []
|
||||
for (let value = start; value <= end + step / 2; value += step) ticks.push(Number(value.toPrecision(12)))
|
||||
return ticks
|
||||
}
|
||||
|
||||
export function timeTicks(start: number, end: number, count: number): number[] {
|
||||
if (end <= start) return [start]
|
||||
const step = (end - start) / count
|
||||
return Array.from({length: count + 1}, (_, index) => start + index * step)
|
||||
}
|
||||
|
||||
export function formatValue(value: number): string {
|
||||
const abs = Math.abs(value)
|
||||
if (abs >= 1e9) return `${trim(value / 1e9)}B`
|
||||
if (abs >= 1e6) return `${trim(value / 1e6)}M`
|
||||
if (abs >= 1e3) return `${trim(value / 1e3)}K`
|
||||
return trim(value)
|
||||
}
|
||||
|
||||
function trim(value: number): string {
|
||||
return Number.isInteger(value) ? String(value) : value.toFixed(Math.abs(value) < 10 ? 2 : 1).replace(/\.?0+$/, '')
|
||||
}
|
||||
|
||||
@@ -302,6 +302,41 @@ export interface UserPoolSignInTestInput {
|
||||
password: string
|
||||
}
|
||||
|
||||
export interface MetricDimension {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface MetricSummary {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions: MetricDimension[]
|
||||
}
|
||||
|
||||
export interface MetricSeriesQuery {
|
||||
namespace: string
|
||||
metricName: string
|
||||
dimensions?: MetricDimension[]
|
||||
statistic?: string
|
||||
periodSeconds?: number
|
||||
rangeMinutes?: number
|
||||
}
|
||||
|
||||
export interface MetricSeries {
|
||||
label: string
|
||||
statistic: string
|
||||
periodSeconds: number
|
||||
points: Array<{timestamp: string; value: number}>
|
||||
}
|
||||
|
||||
export type AlarmStateValue = 'OK' | 'ALARM' | 'INSUFFICIENT_DATA'
|
||||
|
||||
export interface AlarmHistoryItem {
|
||||
timestamp: string | null
|
||||
type: string
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface DistributionInvalidation {
|
||||
id: string
|
||||
status: string
|
||||
|
||||
@@ -28,6 +28,7 @@ export type KubernetesActionName =
|
||||
| 'deleteFargateProfile'
|
||||
export type ItemActionName = 'list' | 'get' | 'put' | 'delete' | 'query'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type UserPoolActionName = 'updateUser' | 'setUserGroups' | 'listGroups' | 'createGroup' | 'deleteGroup' | 'testSignIn' | 'describeClient' | 'updateClientOAuth'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
@@ -90,6 +91,7 @@ export interface ServiceSchema {
|
||||
itemActions?: Array<CapabilitySchema<ItemActionName> | ItemActionName>
|
||||
userPoolActions?: Array<CapabilitySchema<UserPoolActionName>>
|
||||
distributionActions?: Array<CapabilitySchema<DistributionActionName>>
|
||||
monitoringActions?: Array<CapabilitySchema<MonitoringActionName>>
|
||||
}
|
||||
filters: FieldSchema[]
|
||||
columns: TableColumnSchema[]
|
||||
|
||||
Reference in New Issue
Block a user