LCOV - code coverage report
Current view: top level - dashql-app/src/connections/hyper - hyper_log_dashboard.tsx Coverage Total Hit
Test: DashQL 915828b Lines: 91.4 % 372 340
Test Date: 2026-11-06 19:30:32

            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 : };
        

Generated by: LCOV version 2.0-1