87 lines
6.2 KiB
TypeScript
87 lines
6.2 KiB
TypeScript
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('|')
|
|
}
|