Line data Source code
1 : import type * as arrow from 'apache-arrow';
2 : import * as React from 'react';
3 : import type { TopLevelSpec } from 'vega-lite';
4 :
5 : import type { EmbeddedClientContext } from '../../platform/database/embedded_database.js';
6 : import { stringifyError } from '../../platform/logger/logger.js';
7 : import { useLogger } from '../../platform/logger/logger_provider.js';
8 : import { ButtonSize, ButtonVariant, IconButton } from '../../ui/foundations/button.js';
9 : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
10 : import { SyncIcon } from '../../ui/foundations/symbol_icon.js';
11 : import { Tooltip } from '../../ui/foundations/tooltip.js';
12 : import { VegaLiteChart, type VegaSignals } from '../../ui/vegalite/vegalite_chart.js';
13 : import type { ChartColors } from '../../ui/vegalite/vegalite_theme.js';
14 : import type { HyperLogError, HyperQueryEnd, HyperResourceSample } from './hyper_log_metrics.js';
15 : import { overviewClientContext, queryEndsQuery } from './hyper_log_metrics.js';
16 : import { useHyperLogMetrics, useHyperLogRows, useHyperLogState } from './hyper_log_metrics_provider.js';
17 : import {
18 : bucketQueries,
19 : bucketSamples,
20 : computeQueryMetrics,
21 : computeResourceMetrics,
22 : formatClock,
23 : formatCount,
24 : formatDuration,
25 : formatLoad,
26 : formatMemory,
27 : METRICS_WINDOWS,
28 : QUERY_SCOPES,
29 : QueryScope,
30 : queriesInScope,
31 : type MetricsWindow,
32 : type QueryBucket,
33 : type SampleAggregate,
34 : } from './hyper_log_stats.js';
35 : import {
36 : bucketChartSpec,
37 : ROWS_DATASET,
38 : seriesSpec,
39 : WINDOW_END_SIGNAL,
40 : WINDOW_START_SIGNAL,
41 : type ChartSeries,
42 : type SeriesKind,
43 : } from './hyper_log_chart_specs.js';
44 : import { recentQueriesSql, queryShapesSql } from './hyper_log_query_sql.js';
45 : import { QueryTable, type QueryTableProps } from '../connection_query_table.js';
46 : import * as styles from './hyper_log_dashboard.module.css';
47 :
48 : /// The labels of the query scopes
49 1 : const QUERY_SCOPE_LABELS: Record<QueryScope, string> = {
50 1 : [QueryScope.User]: 'User',
51 1 : [QueryScope.App]: 'App',
52 1 : [QueryScope.All]: 'All',
53 1 : };
54 :
55 : /// The specs of the cards, their identity keeps the charts embedded
56 1 : const COUNT_SPEC = bucketChartSpec('count', 'count', [
57 1 : { field: 'count', title: 'Queries' },
58 1 : { field: 'failed', title: 'Failed' },
59 1 : ]);
60 1 : const LATENCY_SPEC = bucketChartSpec('elapsedP95', 'duration', [
61 1 : { field: 'elapsedP95Text', title: 'p95 elapsed' },
62 1 : { field: 'p95Window', title: 'p95 of' },
63 1 : ]);
64 1 : const ROWS_SPEC = bucketChartSpec('processedRows', 'count', [
65 1 : { field: 'count', title: 'Queries' },
66 1 : { field: 'processedRowsText', title: 'Rows processed' },
67 1 : ]);
68 1 : const CPU_TIME_SPEC = bucketChartSpec('meanCpuTime', 'duration', [
69 1 : { field: 'count', title: 'Queries' },
70 1 : { field: 'meanCpuTimeText', title: 'Mean CPU time' },
71 1 : ]);
72 1 : const WAIT_TIME_SPEC = bucketChartSpec('meanWaitTime', 'duration', [
73 1 : { field: 'count', title: 'Queries' },
74 1 : { field: 'meanWaitTimeText', title: 'Mean wait time' },
75 1 : ]);
76 1 : const PEAK_MEMORY_SPEC = bucketChartSpec('peakMemoryMb', 'memory', [
77 1 : { field: 'count', title: 'Queries' },
78 1 : { field: 'peakMemoryText', title: 'Peak memory' },
79 1 : ]);
80 :
81 1 : const optionalValue = (value: number | null, format: (value: number) => string) => value == null ? '–' : format(value);
82 :
83 : /// The time of day of a bucket, for the tooltips
84 1 : const bucketClock = (bucket: { start: number; end: number }) => `${formatClock(bucket.start)} – ${formatClock(bucket.end)}`;
85 :
86 : /// Are two buckets equal in every value?
87 1 : function sameBucket<B extends object>(l: B, r: B): boolean {
88 1 : return (Object.keys(l) as (keyof B)[]).every(key => l[key] === r[key]);
89 1 : }
90 :
91 : /// The rows of buckets, a row keeps its identity while its bucket is unchanged.
92 : /// A chart then only receives the rows of the buckets that changed, e.g. of the newest one.
93 1 : function useBucketRows<B extends { start: number }, R>(buckets: readonly B[], toRow: (bucket: B) => R): R[] {
94 1 : const previous = React.useRef(new Map<number, { bucket: B; row: R }>());
95 1 : return React.useMemo(() => {
96 1 : const rows = new Map<number, { bucket: B; row: R }>();
97 1 : for (const bucket of buckets) {
98 1 : const known = previous.current.get(bucket.start);
99 1 : rows.set(bucket.start, known != null && sameBucket(known.bucket, bucket) ? known : { bucket, row: toRow(bucket) });
100 1 : }
101 1 : previous.current = rows;
102 1 : return [...rows.values()].map(entry => entry.row);
103 1 : }, [buckets]);
104 1 : }
105 :
106 : /// The queries of a bucket as a row of the query charts, with the formatted values of the tooltips
107 1 : function queryBucketRow(bucket: QueryBucket, window: MetricsWindow) {
108 1 : return {
109 1 : t0: bucket.start,
110 1 : t1: bucket.end,
111 1 : clock: bucketClock(bucket),
112 1 : count: bucket.count,
113 1 : failed: bucket.failed,
114 1 : elapsedP95: bucket.elapsedP95,
115 1 : elapsedP95Text: optionalValue(bucket.elapsedP95, formatDuration),
116 1 : p95Window: `the last ${window.p95WindowMs / 60_000} min`,
117 1 : processedRows: bucket.processedRows,
118 1 : processedRowsText: formatCount(bucket.processedRows),
119 1 : meanCpuTime: bucket.meanCpuTime,
120 1 : meanCpuTimeText: formatDuration(bucket.meanCpuTime),
121 1 : meanWaitTime: bucket.meanWaitTime,
122 1 : meanWaitTimeText: formatDuration(bucket.meanWaitTime),
123 1 : peakMemoryMb: bucket.peakMemoryMb,
124 1 : peakMemoryText: optionalValue(bucket.peakMemoryMb, formatMemory),
125 1 : };
126 1 : }
127 :
128 : /// The fields of the query-end events that the query metrics read, see hyperLogQueryEndsTable
129 1 : const QUERY_SOURCES = {
130 1 : queries: 'query-end: one per finished query',
131 1 : latency: 'query-end: elapsed',
132 1 : rows: 'query-end: processed-rows.total of pre-execution + execution',
133 1 : cpuTime: 'query-end: threads.cpu-time of pre-execution + execution',
134 1 : peakMemory: 'query-end: peak-transaction-memory-mb, the larger of pre-execution and execution',
135 1 : waitTime: 'query-end: threads.wait-time of pre-execution + execution',
136 1 : };
137 :
138 : /// A metric of the engine that Hyper samples, with a card of its own
139 : interface EngineMetric {
140 : /// The name of the metric, also the series of its rows
141 : title: string;
142 : /// The test id of the card
143 : testId: string;
144 : /// The field of the resource-metrics events that the metric reads
145 : source: string;
146 : /// The value of a sample, null if the sample did not record it
147 : value: (sample: HyperResourceSample) => number | null;
148 : /// How a bucket of the chart combines the values of its samples
149 : aggregate: SampleAggregate;
150 : /// Format a value
151 : format: (value: number) => string;
152 : /// The spec of the chart
153 : spec: (colors: ChartColors) => TopLevelSpec;
154 : }
155 :
156 1 : function engineMetric(
157 1 : title: string,
158 1 : testId: string,
159 1 : source: string,
160 1 : kind: SeriesKind,
161 1 : value: (sample: HyperResourceSample) => number | null,
162 1 : aggregate: SampleAggregate,
163 1 : format: (value: number) => string,
164 1 : ): EngineMetric {
165 1 : const series: ChartSeries[] = [{ name: title, color: colors => colors.primary, area: true }];
166 1 : return { title, testId, source: `resource-metrics: ${source}`, value, aggregate, format, spec: colors => seriesSpec(colors, series, kind) };
167 1 : }
168 :
169 : /// The memory peaks of a bucket and its mean load
170 1 : const ENGINE_METRICS: EngineMetric[] = [
171 1 : engineMetric('Process memory', 'metric-process-memory', 'memory.process_physical_memory_mb', 'memory', sample => sample.processMemoryMb, 'max', formatMemory),
172 1 : engineMetric('Tracked memory', 'metric-tracked-memory', 'mem-trackers.memory_tracked_current_usage_mb.global', 'memory', sample => sample.trackedMemoryMb, 'max', formatMemory),
173 1 : engineMetric('Query memory', 'metric-query-memory', 'mem-trackers.memory_tracked_current_usage_mb.global_transactions', 'memory', sample => sample.transactionMemoryMb, 'max', formatMemory),
174 1 : engineMetric('Load', 'metric-load', 'load.overall_load', 'load', sample => sample.overallLoad, 'mean', formatLoad),
175 1 : ];
176 :
177 : interface EngineMetricCardProps {
178 : /// The metric
179 : metric: EngineMetric;
180 : /// The resource samples of the window, ordered by time
181 : samples: readonly HyperResourceSample[];
182 : /// The newest sample of the window
183 : latest: HyperResourceSample | null;
184 : /// The duration of a bucket of the chart
185 : bucketMs: number;
186 : /// The bounds of the window
187 : signals: VegaSignals;
188 : }
189 :
190 : /// The card of a metric of the engine, its chart draws a point per bucket at the middle of the bucket
191 1 : const EngineMetricCard: React.FC<EngineMetricCardProps> = ({ metric, samples, latest, bucketMs, signals }) => {
192 1 : const buckets = React.useMemo(
193 1 : () => bucketSamples(samples, bucketMs, metric.value, metric.aggregate),
194 1 : [samples, bucketMs, metric],
195 : );
196 1 : const rows = useBucketRows(buckets, bucket => ({ t: (bucket.start + bucket.end) / 2, series: metric.title, value: bucket.value }));
197 1 : const datasets = React.useMemo(() => ({ [ROWS_DATASET]: rows }), [rows]);
198 1 : return (
199 1 : <MetricCard
200 1 : title={metric.title}
201 1 : source={metric.source}
202 1 : value={optionalValue(latest == null ? null : metric.value(latest), metric.format)}
203 1 : data-testid={metric.testId}
204 : >
205 1 : <VegaLiteChart className={styles.chart} spec={metric.spec} datasets={datasets} signals={signals} />
206 1 : </MetricCard>
207 : );
208 1 : };
209 :
210 : /// The current time, advanced once per period
211 1 : function useNow(periodMs: number): number {
212 1 : const [now, setNow] = React.useState(() => Date.now());
213 1 : React.useEffect(() => {
214 1 : const timer = setInterval(() => setNow(Date.now()), periodMs);
215 1 : return () => clearInterval(timer);
216 1 : }, [periodMs]);
217 1 : return now;
218 1 : }
219 :
220 :
221 : /// The aggregate of the summary of a card, shown in front of it
222 : interface CardAggregate {
223 : /// The symbol of the aggregate
224 : symbol: string;
225 : /// The name of the aggregate, e.g. for screen readers
226 : label: string;
227 : /// The class that sizes the symbol so that all aggregates weigh the same
228 : sizeClass?: string;
229 : }
230 :
231 1 : const MEAN: CardAggregate = { symbol: '⌀', label: 'mean' };
232 1 : const P95: CardAggregate = { symbol: 'p95', label: '95th percentile', sizeClass: styles.card_aggregate_word };
233 1 : const MAX: CardAggregate = { symbol: 'max', label: 'maximum', sizeClass: styles.card_aggregate_word };
234 1 : const TOTAL: CardAggregate = { symbol: 'Σ', label: 'total', sizeClass: styles.card_aggregate_capital };
235 :
236 : interface MetricCardProps {
237 : /// The name of the metric
238 : title: string;
239 : /// The Hyper log field that the metric reads, shown on hover of the name
240 : source: string;
241 : /// The summary of the metric in the window, e.g. its total
242 : value: string;
243 : /// The aggregate of the summary, e.g. MEAN
244 : aggregate?: CardAggregate;
245 : /// The test id of the card
246 : 'data-testid': string;
247 : /// The chart
248 : children: React.ReactElement;
249 : }
250 :
251 : /// A metric with its summary in the window next to its name and a small plot of it
252 1 : const MetricCard: React.FC<MetricCardProps> = props => (
253 1 : <section className={styles.card} aria-label={props.title} data-testid={props['data-testid']}>
254 1 : <header className={styles.card_header}>
255 1 : <Tooltip type="description" direction="n" text={props.source}>
256 1 : <h4 className={styles.card_title} data-testid="metric-source">{props.title}</h4>
257 1 : </Tooltip>
258 1 : <span className={styles.card_value}>
259 1 : {props.aggregate != null && <span className={props.aggregate.sizeClass != null ? `${styles.card_aggregate} ${props.aggregate.sizeClass}` : styles.card_aggregate} aria-label={props.aggregate.label}>{props.aggregate.symbol}</span>}
260 1 : <span data-testid="metric-value">{props.value}</span>
261 1 : </span>
262 1 : </header>
263 1 : {props.children}
264 1 : </section>
265 : );
266 :
267 : /// The views of the queries of the activity
268 1 : enum ActivityView {
269 : /// The queries, newest first
270 1 : Recent = 'recent',
271 : /// The queries grouped by their shape, most elapsed time first
272 1 : Top = 'top',
273 1 : }
274 :
275 1 : const ACTIVITY_VIEWS: readonly { view: ActivityView; label: string }[] = [
276 1 : { view: ActivityView.Recent, label: 'Recent' },
277 1 : { view: ActivityView.Top, label: 'Top' },
278 1 : ];
279 :
280 : /// The queries, the previous array while the queries are the same
281 1 : function useStableQueries(queries: readonly HyperQueryEnd[]): readonly HyperQueryEnd[] {
282 1 : const previous = React.useRef(queries);
283 1 : const prev = previous.current;
284 1 : if (prev !== queries && (prev.length !== queries.length || prev.some((query, i) => query !== queries[i]))) {
285 1 : previous.current = queries;
286 : }
287 1 : return previous.current;
288 1 : }
289 :
290 : /// The kinds of the queries that the overview and the log monitor run themselves, every poll adds some
291 1 : const OWN_QUERY_TYPES = new Set(['overview', 'monitor']);
292 : /// The interval in which the table of the queries follows the own queries of the overview
293 1 : const OWN_QUERIES_REFRESH_MS = 15_000;
294 :
295 : /// A key that changes when the table of the queries is outdated: with the other queries, and every
296 : /// OWN_QUERIES_REFRESH_MS with the own queries of the overview. The table then does not refresh for its own reads.
297 1 : function refreshKey(queries: readonly HyperQueryEnd[]): string {
298 1 : let others = 0;
299 1 : let newestOther = 0;
300 1 : let newestOwn = 0;
301 1 : for (const query of queries) {
302 1 : if (OWN_QUERY_TYPES.has(query.queryType ?? '')) {
303 1 : newestOwn = Math.max(newestOwn, query.endedAt);
304 1 : } else {
305 1 : ++others;
306 1 : newestOther = Math.max(newestOther, query.endedAt);
307 1 : }
308 : }
309 1 : return `${others}:${newestOther}:${Math.floor(newestOwn / OWN_QUERIES_REFRESH_MS)}`;
310 1 : }
311 :
312 : interface QueryActivityProps {
313 : /// The finished queries of the window and the scope
314 : queries: readonly HyperQueryEnd[];
315 : /// The finished queries of the connection in the extract of the Hyper log, see queryEndsQuery()
316 : queryEnds: string | null;
317 : /// Run a query over the extract
318 : runQuery: (sql: string) => Promise<arrow.Table>;
319 : /// The scope of the queries
320 : scope: QueryScope;
321 : /// The start of the window in milliseconds since the epoch
322 : windowStart: number;
323 : /// The client context of the queries of the table, e.g. to attribute them to the connection
324 : clientContext: EmbeddedClientContext | null;
325 : /// Read the events of the Hyper log that were logged since the last poll, before the table is queried again
326 : refresh?: () => Promise<void>;
327 : /// The table of the queries
328 : QueryTable: React.FC<QueryTableProps>;
329 : }
330 :
331 : /// The finished queries of the window, grouped by their shape or newest first
332 1 : const QueryActivity: React.FC<QueryActivityProps> = props => {
333 1 : const [view, setView] = React.useState(ActivityView.Recent);
334 1 : const [refreshes, setRefreshes] = React.useState(0);
335 1 : const [refreshing, setRefreshing] = React.useState(false);
336 1 : const logger = useLogger();
337 1 : const { refresh } = props;
338 : // Query the table again even when the poll fails, e.g. for the events of the previous polls
339 1 : const onRefresh = React.useCallback(async () => {
340 1 : setRefreshing(true);
341 1 : try {
342 1 : await refresh?.();
343 1 : } catch (e) {
344 0 : logger.warn('Failed to refresh the queries of the connection overview', { error: stringifyError(e) }, 'connection_overview');
345 0 : }
346 1 : setRefreshes(n => n + 1);
347 1 : setRefreshing(false);
348 1 : }, [refresh, logger]);
349 1 : const queries = useStableQueries(props.queries);
350 : // The window slides every second, but the query of the table only changes when it is outdated.
351 : // Until then, the earlier start selects the same queries.
352 1 : const key = React.useMemo(() => refreshKey(queries), [queries]);
353 1 : const sql = React.useMemo(() => {
354 1 : if (props.queryEnds == null) return null;
355 1 : const query = view === ActivityView.Top ? queryShapesSql : recentQueriesSql;
356 1 : return query(props.queryEnds, props.scope, props.windowStart);
357 1 : }, [key, view, props.queryEnds, props.scope]);
358 1 : return (
359 1 : <section className={styles.panel} aria-label="Queries" data-testid="recent-queries">
360 1 : <header className={styles.panel_header}>
361 1 : <h3 className={styles.panel_title}>Queries</h3>
362 1 : <SegmentedControl
363 1 : aria-label="View"
364 1 : data-testid="recent-queries-view"
365 1 : size={SegmentedControlSize.XSmall}
366 1 : onChange={index => setView(ACTIVITY_VIEWS[index].view)}
367 : >
368 1 : {ACTIVITY_VIEWS.map(v => (
369 1 : <SegmentedControl.Button key={v.view} selected={v.view === view} data-view={v.view}>{v.label}</SegmentedControl.Button>
370 1 : ))}
371 1 : </SegmentedControl>
372 1 : <IconButton
373 1 : variant={ButtonVariant.Invisible}
374 1 : size={ButtonSize.Small}
375 1 : onClick={() => void onRefresh()}
376 1 : disabled={refreshing}
377 1 : aria-label="Refresh"
378 1 : data-testid="recent-queries-refresh"
379 : >
380 1 : <SyncIcon size={14} />
381 1 : </IconButton>
382 1 : </header>
383 1 : {queries.length === 0
384 1 : ? <p className={styles.panel_empty} data-testid="connection-metrics-empty">No finished queries in this window</p>
385 1 : : <div className={styles.panel_table}>{sql != null && <props.QueryTable sql={sql} clientContext={props.clientContext} runQuery={props.runQuery} refreshes={refreshes} />}</div>}
386 1 : </section>
387 : );
388 1 : };
389 :
390 : /// The reason why the Hyper log of a connection cannot be read, in place of its queries
391 1 : const LogUnavailablePanel: React.FC<{ error: HyperLogError }> = ({ error }) => (
392 1 : <section className={styles.panel} aria-label="Queries" data-testid="connection-metrics-log-unavailable">
393 1 : <header className={styles.panel_header}>
394 1 : <h3 className={styles.panel_title}>Queries</h3>
395 1 : </header>
396 1 : <div className={styles.panel_notice}>
397 1 : <p>
398 1 : {error.unreadable
399 1 : ? <>This Hyper does not allow reading its log. The metrics need <code>hyper_log()</code>, which Hyper enables with <code>experimental_hyper_introspection_functions</code>.</>
400 0 : : 'Reading the Hyper log failed. The metrics resume when the connection is set up again.'}
401 1 : </p>
402 1 : <p className={styles.panel_notice_detail} data-testid="connection-metrics-log-error">{error.message}</p>
403 1 : </div>
404 1 : </section>
405 : );
406 :
407 : /// The components of the view, replaced in tests
408 : export interface HyperLogDashboardViewDependencies {
409 : /// The table of the queries
410 : QueryTable: React.FC<QueryTableProps>;
411 : }
412 :
413 1 : const DEFAULT_DEPENDENCIES: HyperLogDashboardViewDependencies = { QueryTable };
414 :
415 : interface Props {
416 : /// The id of the connection
417 : connectionId: string;
418 : /// The facts about the Hyper of the connection, shown in the header of the engine
419 : engine?: React.ReactNode;
420 : }
421 :
422 : /// The activity and the metrics of a Hyper connection and of its Hyper, from the extract of the Hyper log
423 1 : export const HyperLogDashboard: React.FC<Props> = (props: Props) => {
424 0 : const logMetrics = useHyperLogMetrics();
425 0 : const { queries, samples } = useHyperLogRows(props.connectionId);
426 0 : const logState = useHyperLogState(props.connectionId);
427 0 : const { queryEnds: queryEndsTable, clockOffsetMs } = logState;
428 0 : const queryEnds = React.useMemo(
429 0 : () => queryEndsTable == null || clockOffsetMs == null ? null : queryEndsQuery(queryEndsTable, clockOffsetMs),
430 0 : [queryEndsTable, clockOffsetMs],
431 0 : );
432 0 : const clientContext = React.useMemo(() => overviewClientContext(props.connectionId), [props.connectionId]);
433 0 : const runQuery = React.useCallback(async (sql: string) => {
434 0 : if (logMetrics == null) throw new Error('the Hyper log is not monitored');
435 0 : return await logMetrics.query(props.connectionId, sql);
436 0 : }, [logMetrics, props.connectionId]);
437 0 : const refresh = React.useCallback(async () => {
438 0 : await logMetrics?.poll(props.connectionId);
439 0 : }, [logMetrics, props.connectionId]);
440 0 : return (
441 0 : <HyperLogDashboardView
442 0 : queries={queries}
443 0 : queryEnds={queryEnds}
444 0 : runQuery={runQuery}
445 0 : refresh={refresh}
446 0 : clientContext={clientContext}
447 0 : samples={samples}
448 0 : logError={logState.error}
449 0 : resourceUsageLogged={logState.resourceUsageLogged}
450 0 : engine={props.engine}
451 0 : />
452 : );
453 0 : };
454 :
455 : interface ViewProps {
456 : /// The finished queries, ordered by their end
457 : queries: readonly HyperQueryEnd[];
458 : /// The finished queries in the extract of the Hyper log, see queryEndsQuery(), null without the extract
459 : queryEnds: string | null;
460 : /// Run a query over the extract
461 : runQuery: (sql: string) => Promise<arrow.Table>;
462 : /// Read the events of the Hyper log that were logged since the last poll
463 : refresh?: () => Promise<void>;
464 : /// The client context of the queries of the view, e.g. to attribute them to the connection
465 : clientContext?: EmbeddedClientContext | null;
466 : /// The resource samples of the Hyper process, ordered by time
467 : samples: readonly HyperResourceSample[];
468 : /// Why the Hyper log cannot be read, the view then only shows the error
469 : logError?: HyperLogError | null;
470 : /// Does the Hyper log its resource usage? Null while unknown, see HyperLogState.resourceUsageLogged
471 : resourceUsageLogged?: boolean | null;
472 : /// The facts about the Hyper of the connection, shown in the header of the engine
473 : engine?: React.ReactNode;
474 : /// The current time, the clock when not set
475 : now?: number;
476 : /// The components of the view
477 : dependencies?: HyperLogDashboardViewDependencies;
478 : }
479 :
480 : /// The query metrics of a connection and the resource metrics of the engine in a sliding time window
481 1 : export const HyperLogDashboardView: React.FC<ViewProps> = ({ queries, queryEnds, runQuery, refresh, clientContext = null, samples, logError = null, resourceUsageLogged = null, engine, now, dependencies = DEFAULT_DEPENDENCIES }) => {
482 1 : const [windowIndex, setWindowIndex] = React.useState(0);
483 1 : const window = METRICS_WINDOWS[windowIndex];
484 1 : const [scope, setScope] = React.useState(QueryScope.All);
485 : // The window slides every second
486 1 : const clock = useNow(1000);
487 1 : const end = now ?? clock;
488 1 : const scopedQueries = React.useMemo(() => queriesInScope(queries, scope), [queries, scope]);
489 1 : const metrics = React.useMemo(() => computeQueryMetrics(scopedQueries, window, end), [scopedQueries, window, end]);
490 1 : const resources = React.useMemo(() => computeResourceMetrics(samples, window, end), [samples, window, end]);
491 1 : const latest = resources.latest;
492 : // The p95 windows of the scope, they keep their p95 while their queries are unchanged
493 1 : const p95Cache = React.useMemo(() => new Map<string, number | null>(), [scope]);
494 1 : const buckets = React.useMemo(
495 1 : () => bucketQueries(metrics.queries, window, scopedQueries, p95Cache),
496 1 : [metrics.queries, window, scopedQueries, p95Cache],
497 : );
498 1 : const bucketRows = useBucketRows(buckets, bucket => queryBucketRow(bucket, window));
499 :
500 1 : const signals = React.useMemo(
501 1 : () => ({ [WINDOW_START_SIGNAL]: metrics.start, [WINDOW_END_SIGNAL]: metrics.end }),
502 1 : [metrics.start, metrics.end],
503 1 : );
504 : // The query charts draw the same buckets
505 1 : const datasets = React.useMemo(() => ({ [ROWS_DATASET]: bucketRows }), [bucketRows]);
506 :
507 : // Without the log, there are no metrics to draw
508 1 : if (logError != null) {
509 1 : return (
510 1 : <div className={styles.overview} data-testid="connection-metrics">
511 1 : <LogUnavailablePanel error={logError} />
512 1 : </div>
513 : );
514 : }
515 1 : return (
516 1 : <div className={styles.overview} data-testid="connection-metrics">
517 1 : <section className={styles.section} aria-label="Activity" data-testid="connection-metrics-queries">
518 1 : <header className={styles.section_header}>
519 1 : <h3 className={styles.section_label}>Activity</h3>
520 1 : <span className={styles.section_spacer} />
521 1 : <SegmentedControl
522 1 : aria-label="Scope"
523 1 : data-testid="connection-metrics-scope"
524 1 : size={SegmentedControlSize.Small}
525 1 : onChange={index => setScope(QUERY_SCOPES[index])}
526 : >
527 1 : {QUERY_SCOPES.map(s => (
528 1 : <SegmentedControl.Button key={s} selected={s === scope} data-scope={s}>
529 1 : {QUERY_SCOPE_LABELS[s]}
530 1 : </SegmentedControl.Button>
531 1 : ))}
532 1 : </SegmentedControl>
533 1 : <SegmentedControl
534 1 : aria-label="Time window"
535 1 : data-testid="connection-metrics-window"
536 1 : size={SegmentedControlSize.Small}
537 1 : onChange={setWindowIndex}
538 : >
539 1 : {METRICS_WINDOWS.map((w, i) => (
540 1 : <SegmentedControl.Button key={w.key} selected={i === windowIndex} data-window={w.key}>
541 1 : {w.key}
542 1 : </SegmentedControl.Button>
543 1 : ))}
544 1 : </SegmentedControl>
545 1 : </header>
546 1 : <div className={`${styles.grid} ${styles.query_cards}`}>
547 1 : <MetricCard title="Queries" source={QUERY_SOURCES.queries} aggregate={TOTAL} value={formatCount(metrics.queries.length)} data-testid="metric-queries">
548 1 : <VegaLiteChart className={styles.chart} spec={COUNT_SPEC} datasets={datasets} signals={signals} />
549 1 : </MetricCard>
550 1 : <MetricCard
551 1 : title="Latency"
552 1 : source={QUERY_SOURCES.latency}
553 1 : aggregate={P95}
554 1 : value={optionalValue(metrics.elapsedP95, formatDuration)}
555 1 : data-testid="metric-latency"
556 : >
557 1 : <VegaLiteChart className={styles.chart} spec={LATENCY_SPEC} datasets={datasets} signals={signals} />
558 1 : </MetricCard>
559 1 : <MetricCard title="Rows processed" source={QUERY_SOURCES.rows} aggregate={TOTAL} value={formatCount(metrics.processedRows)} data-testid="metric-rows">
560 1 : <VegaLiteChart className={styles.chart} spec={ROWS_SPEC} datasets={datasets} signals={signals} />
561 1 : </MetricCard>
562 1 : <MetricCard title="CPU time" source={QUERY_SOURCES.cpuTime} aggregate={MEAN} value={optionalValue(metrics.meanCpuTime, formatDuration)} data-testid="metric-cpu-time">
563 1 : <VegaLiteChart className={styles.chart} spec={CPU_TIME_SPEC} datasets={datasets} signals={signals} />
564 1 : </MetricCard>
565 1 : <MetricCard title="Peak memory" source={QUERY_SOURCES.peakMemory} aggregate={MAX} value={optionalValue(metrics.peakMemoryMb, formatMemory)} data-testid="metric-peak-memory">
566 1 : <VegaLiteChart className={styles.chart} spec={PEAK_MEMORY_SPEC} datasets={datasets} signals={signals} />
567 1 : </MetricCard>
568 1 : <MetricCard title="Wait time" source={QUERY_SOURCES.waitTime} aggregate={MEAN} value={optionalValue(metrics.meanWaitTime, formatDuration)} data-testid="metric-wait-time">
569 1 : <VegaLiteChart className={styles.chart} spec={WAIT_TIME_SPEC} datasets={datasets} signals={signals} />
570 1 : </MetricCard>
571 1 : </div>
572 1 : <QueryActivity
573 1 : queries={metrics.queries}
574 1 : queryEnds={queryEnds}
575 1 : runQuery={runQuery}
576 1 : scope={scope}
577 1 : windowStart={metrics.start}
578 1 : clientContext={clientContext}
579 1 : refresh={refresh}
580 1 : QueryTable={dependencies.QueryTable}
581 1 : />
582 1 : </section>
583 1 : <section className={styles.section} aria-label="Engine" data-testid="connection-metrics-engine">
584 1 : <header className={styles.section_header}>
585 1 : <h3 className={styles.section_label}>Engine</h3>
586 1 : {engine != null && <span className={styles.section_detail}>{engine}</span>}
587 1 : </header>
588 1 : {resourceUsageLogged === false && samples.length === 0
589 : ? (
590 1 : <p className={styles.section_empty} data-testid="connection-metrics-no-resource-usage">
591 1 : This Hyper does not log its resource usage. Hyper logs it every <code>log_resource_usage_interval</code>.
592 1 : </p>
593 : )
594 : : (
595 1 : <div className={`${styles.grid} ${styles.engine_cards}`}>
596 1 : {ENGINE_METRICS.map(metric => (
597 1 : <EngineMetricCard
598 1 : key={metric.testId}
599 1 : metric={metric}
600 1 : samples={resources.samples}
601 1 : latest={latest}
602 1 : bucketMs={window.bucketMs}
603 1 : signals={signals}
604 1 : />
605 1 : ))}
606 1 : </div>
607 : )}
608 1 : </section>
609 1 : </div>
610 : );
611 1 : };
|