From e4b79f5fa0030b3369d0d000c1b13ba9aa600cde Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Tue, 7 Apr 2026 16:28:02 +0000 Subject: [PATCH 1/3] feat: redesign metrics viewer with tree query plan and smart formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace flat card layout with clean divider-based list - Add tree rendering for EXPLAIN QUERY PLAN using parent/child ids - Smart time formatting: μs / ms / s with 2 decimal places - Smart byte formatting: B / KB / MB / GB for memory counters - Fixed-width stat columns (Calls/Avg/Max/Total) with tabular-nums for alignment --- api/routes.ts | 20 +- web/pages/DeploymentPage.tsx | 396 ++++++++++++++++++++++++++++++----- 2 files changed, 363 insertions(+), 53 deletions(-) diff --git a/api/routes.ts b/api/routes.ts index 6a4eb8f..048e78c 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -698,7 +698,25 @@ const defs = { query: STR('The SQL query text'), count: NUM('How many times the query has run'), duration: NUM('Total time spent running the query in milliseconds'), - explain: STR('The SQL explain text'), + max: NUM('Longest single query execution in milliseconds'), + explain: ARR( + OBJ({ + id: NUM('Query plan node id'), + parent: NUM('Parent query plan node id'), + detail: STR('Human-readable query plan detail'), + }), + 'SQLite EXPLAIN QUERY PLAN rows', + ), + status: OBJ({ + fullscanStep: NUM('Number of full table scan steps'), + sort: NUM('Number of sort operations'), + autoindex: NUM('Rows inserted into transient auto-indices'), + vmStep: NUM('Number of virtual machine operations'), + reprepare: NUM('Number of automatic statement reprepares'), + run: NUM('Number of statement runs'), + filterHit: NUM('Bloom filter bypass hits'), + filterMiss: NUM('Bloom filter misses'), + }, 'SQLite sqlite3_stmt_status counters'), }), 'Collected query metrics', ), diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index bb3430a..232b862 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -1,14 +1,19 @@ import { A, navigate, url } from '@01edu/signal-router' import { + Activity, AlertCircle, AlertTriangle, ArrowDown, ArrowUp, + BarChart, + BarChart2, Bug, ChevronDown, ChevronRight, Clock, Columns, + Cpu, + Database, Download, FileText, Hash, @@ -20,6 +25,7 @@ import { Save, Search, Table, + Timer, XCircle, } from 'lucide-preact' import { @@ -796,7 +802,9 @@ function SchemaPanel() { ) } -const TabButton = ({ tabName }: { tabName: 'tables' | 'queries' | 'logs' | 'metrics' }) => ( +const TabButton = ( + { tabName }: { tabName: 'tables' | 'queries' | 'logs' | 'metrics' }, +) => ( { } }) +const expandedMetric = signal(null) + +function formatDuration(ms: number): { value: string; unit: string } { + if (ms < 1) return { value: (ms * 1000).toFixed(2), unit: 'μs' } + if (ms >= 1000) return { value: (ms / 1000).toFixed(2), unit: 's' } + return { value: ms.toFixed(2), unit: 'ms' } +} + +function formatBytes(bytes: number): { value: string; unit: string } { + if (bytes < 1024) return { value: bytes.toFixed(2), unit: 'B' } + if (bytes < 1024 * 1024) { + return { value: (bytes / 1024).toFixed(2), unit: 'KB' } + } + if (bytes < 1024 * 1024 * 1024) { + return { value: (bytes / (1024 * 1024)).toFixed(2), unit: 'MB' } + } + return { value: (bytes / (1024 * 1024 * 1024)).toFixed(2), unit: 'GB' } +} + +const MEMORY_STATUS_KEYS = new Set(['memused']) + +function formatStatusValue( + key: string, + val: number, +): { value: string; unit: string } { + if (MEMORY_STATUS_KEYS.has(key)) return formatBytes(val) + return { value: String(val), unit: '' } +} + +type ExplainNode = { + id: number + parent: number + detail: string + children: ExplainNode[] +} + +function buildExplainTree( + rows: { id: number; parent: number; detail: string }[], +): ExplainNode[] { + const map = new Map() + const roots: ExplainNode[] = [] + for (const row of rows) map.set(row.id, { ...row, children: [] }) + for (const node of map.values()) { + const parent = map.get(node.parent) + if (parent) parent.children.push(node) + else roots.push(node) + } + return roots +} + +function ExplainTreeNode( + { node, depth = 0, isLast = true, prefix = '' }: { + node: ExplainNode + depth?: number + isLast?: boolean + prefix?: string + }, +) { + const connector = depth === 0 ? '' : isLast ? '└─ ' : '├─ ' + const childPrefix = depth === 0 ? '' : prefix + (isLast ? ' ' : '│ ') + return ( +
+
+ {depth > 0 && ( + + {prefix} + {connector} + + )} + + {node.detail} + +
+ {node.children.map((child, i) => ( + + ))} +
+ ) +} + +const STATUS_LABELS: Record = { + fullscanStep: { + name: 'Full Scan Steps', + title: + 'Number of steps that performed a full scan of a table or index, which is a common cause of slow queries.', + }, + sort: { + name: 'Sorts', + title: 'Number of steps that performed a sort operation.', + }, + autoindex: { + name: 'Auto Index', + title: 'Number of steps that created an index automatically.', + }, + vmStep: { + name: 'VM Steps', + title: 'Number of steps that involved virtual machine operations.', + }, + reprepare: { + name: 'Reprepares', + title: 'Number of steps that reprepared a query plan.', + }, + run: { + name: 'Runs', + title: 'Number of times a query was executed.', + }, + filterHit: { + name: 'Filter Hits', + title: 'Number of times a filter condition was satisfied.', + }, + filterMiss: { + name: 'Filter Miss', + title: 'Number of times a filter condition was not satisfied.', + }, +} + function MetricsViewer() { - const filteredMetrics = metricsData.data || [] + const metrics = metricsData.data || [] const isPending = metricsData.pending + const totalCalls = metrics.reduce((acc, m) => acc + (m.count || 0), 0) + const totalDuration = metrics.reduce((acc, m) => acc + (m.duration || 0), 0) + const maxDuration = Math.max(...metrics.map((m) => m.duration || 0), 1) + const sorted = [...metrics].sort((a, b) => b.duration - a.duration) + return ( -
+
{!!isPending && (
-
+
)} -
-
- - - - {filteredMetrics.map((metric, index) => ( - - - - - - - - ))} - -
-
{metric.query}
-
- {metric.count} - - {metric.duration} - -
{metric.explain}
-
+ + {/* Summary bar */} +
+
+ + + {totalCalls.toLocaleString()} + + total calls +
+
+
+ + + {(() => { + const f = formatDuration(totalDuration) + return `${f.value} ${f.unit}` + })()} + + total time +
+
+
+ + {sorted.length} + unique queries
- {filteredMetrics.length === 0 && !isPending && ( -
-
- + + {/* Query list */} +
+ {sorted.map((metric, index) => { + const isExpanded = expandedMetric.value === index + const avgMs = metric.count > 0 ? metric.duration / metric.count : 0 + const avg = formatDuration(avgMs) + const maxFmt = metric.max != null ? formatDuration(metric.max) : null + const totalFmt = formatDuration(metric.duration) + const pct = (metric.duration / maxDuration) * 100 + const explainTree = metric.explain?.length + ? buildExplainTree(metric.explain) + : [] + + return ( +
+ {/* Row */} +
expandedMetric.value = isExpanded ? null : index} + > +
+ {/* Query text */} +
+
+ {metric.query} +
+ {/* Duration bar */} +
+
66 + ? 'bg-error/70' + : pct > 33 + ? 'bg-warning/70' + : 'bg-success/70' + }`} + style={{ width: `${Math.max(2, pct)}%` }} + /> +
+
+ + {/* Key stats */} +
+
+
+ Calls +
+
+ {metric.count} +
+
+
+
+ Avg +
+
+ {avg.value} + + {avg.unit} + +
+
+
+
+ Max +
+
+ {maxFmt ? maxFmt.value : '—'} + + {maxFmt?.unit} + +
+
+
+
+ Total +
+
+ {totalFmt.value} + + {totalFmt.unit} + +
+
+
+ {isExpanded + ? + : } +
+
+
+
+ + {/* Expanded detail */} + {isExpanded && ( +
+ {/* Full query */} +
+
+ Query +
+
{metric.query}
+
+ +
+ {/* Status counters */} + {metric.status && ( +
+
+ Execution Counters +
+
+ {Object.entries(metric.status).map(([key, val]) => { + const fmt = formatStatusValue(key, val as number) + return ( +
+
+ {STATUS_LABELS[key]?.name ?? key} +
+
+ {fmt.value} + {fmt.unit && ( + + {fmt.unit} + + )} +
+
+ ) + })} +
+
+ )} + + {/* Explain tree */} + {explainTree.length > 0 && ( +
+
+ Query Plan +
+
+ {explainTree.map((node) => ( + + ))} +
+
+ )} +
+
+ )} +
+ ) + })} + + {sorted.length === 0 && !isPending && ( +
+
+ +
-

- No metrics found +

+ No metrics recorded

+

+ Execute database queries to see performance data here. +

-
- )} + )} +
) } From 6797c614e3561881cd7ad8e24f3918a3350aa084 Mon Sep 17 00:00:00 2001 From: Abdou TOP Date: Tue, 7 Apr 2026 17:28:00 +0000 Subject: [PATCH 2/3] feat: add peak memory usage metric to metrics collection --- api/routes.ts | 1 + web/pages/DeploymentPage.tsx | 545 ++++++++++++++++++----------------- 2 files changed, 278 insertions(+), 268 deletions(-) diff --git a/api/routes.ts b/api/routes.ts index 048e78c..843d516 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -716,6 +716,7 @@ const defs = { run: NUM('Number of statement runs'), filterHit: NUM('Bloom filter bypass hits'), filterMiss: NUM('Bloom filter misses'), + memused: NUM('Peak memory usage in bytes'), }, 'SQLite sqlite3_stmt_status counters'), }), 'Collected query metrics', diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index 232b862..544139f 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -36,7 +36,7 @@ import { toggleSort, } from '../components/Filtre.tsx' import { effect, Signal, signal } from '@preact/signals' -import { api } from '../lib/api.ts' +import { api, ApiOutput } from '../lib/api.ts' import { QueryHistory } from '../components/QueryHistory.tsx' import { JSX } from 'preact' @@ -1360,7 +1360,40 @@ effect(() => { } }) -const expandedMetric = signal(null) +// ─── Metrics types ─────────────────────────────────────────────────────────── + +type Metric = ApiOutput['GET/api/deployment/metrics-sql'][number] +type MetricStatus = NonNullable +type MetricExplain = NonNullable + +type ExplainNode = { + id: number + parent: number + detail: string + children: ExplainNode[] +} + +type ExplainTreeNodeProps = { + node: ExplainNode + depth?: number + isLast?: boolean + prefix?: string +} + +type StatCellProps = { + label: string + value: string | number + unit?: string + valueClass?: string + unitClass?: string + width?: string +} + +type MetricRowProps = { metric: Metric; index: number } +type StatusCountersProps = { status: MetricStatus } +type QueryPlanProps = { explain: MetricExplain } + +// ─── Metrics helpers ──────────────────────────────────────────────────────── function formatDuration(ms: number): { value: string; unit: string } { if (ms < 1) return { value: (ms * 1000).toFixed(2), unit: 'μs' } @@ -1381,24 +1414,19 @@ function formatBytes(bytes: number): { value: string; unit: string } { const MEMORY_STATUS_KEYS = new Set(['memused']) -function formatStatusValue( - key: string, - val: number, -): { value: string; unit: string } { - if (MEMORY_STATUS_KEYS.has(key)) return formatBytes(val) - return { value: String(val), unit: '' } +const STATUS_LABELS: Record = { + fullscanStep: 'Full Scan Steps', + sort: 'Sorts', + autoindex: 'Autoindex Steps', + vmStep: 'VM Steps', + reprepare: 'Reprepares', + run: 'Runs', + filterHit: 'Filter Hit', + filterMiss: 'Filter Miss', + memused: 'Memory Used', } -type ExplainNode = { - id: number - parent: number - detail: string - children: ExplainNode[] -} - -function buildExplainTree( - rows: { id: number; parent: number; detail: string }[], -): ExplainNode[] { +function buildExplainTree(rows: MetricExplain): ExplainNode[] { const map = new Map() const roots: ExplainNode[] = [] for (const row of rows) map.set(row.id, { ...row, children: [] }) @@ -1410,19 +1438,16 @@ function buildExplainTree( return roots } +// ─── Metrics sub-components ───────────────────────────────────────────────── + function ExplainTreeNode( - { node, depth = 0, isLast = true, prefix = '' }: { - node: ExplainNode - depth?: number - isLast?: boolean - prefix?: string - }, + { node, depth = 0, isLast = true, prefix = '' }: ExplainTreeNodeProps, ) { const connector = depth === 0 ? '' : isLast ? '└─ ' : '├─ ' const childPrefix = depth === 0 ? '' : prefix + (isLast ? ' ' : '│ ') return (
-
+
{depth > 0 && ( {prefix} @@ -1450,49 +1475,233 @@ function ExplainTreeNode( ) } -const STATUS_LABELS: Record = { - fullscanStep: { - name: 'Full Scan Steps', - title: - 'Number of steps that performed a full scan of a table or index, which is a common cause of slow queries.', - }, - sort: { - name: 'Sorts', - title: 'Number of steps that performed a sort operation.', - }, - autoindex: { - name: 'Auto Index', - title: 'Number of steps that created an index automatically.', - }, - vmStep: { - name: 'VM Steps', - title: 'Number of steps that involved virtual machine operations.', - }, - reprepare: { - name: 'Reprepares', - title: 'Number of steps that reprepared a query plan.', - }, - run: { - name: 'Runs', - title: 'Number of times a query was executed.', - }, - filterHit: { - name: 'Filter Hits', - title: 'Number of times a filter condition was satisfied.', - }, - filterMiss: { - name: 'Filter Miss', - title: 'Number of times a filter condition was not satisfied.', - }, +function QueryPlan({ explain }: QueryPlanProps) { + return ( +
+
+ Query Plan +
+
+ {buildExplainTree(explain).map((node) => ( + + ))} +
+
+ ) +} + +function StatusCounters({ status }: StatusCountersProps) { + return ( +
+
+ Execution Counters +
+
+ {Object.entries(status).map(([key, val]) => { + const fmt = MEMORY_STATUS_KEYS.has(key) + ? formatBytes(val) + : { value: String(val), unit: '' } + return ( +
+
+ {STATUS_LABELS[key] ?? key} +
+
+ {fmt.value} + {fmt.unit && ( + + {fmt.unit} + + )} +
+
+ ) + })} +
+
+ ) +} + +function StatCell( + { + label, + value, + unit, + valueClass = 'text-base-content/80', + unitClass = 'text-base-content/30', + width = 'w-24', + }: StatCellProps, +) { + return ( +
+
+ {label} +
+
+ {value} + {unit && {unit}} +
+
+ ) +} + +function MetricDetail() { + const expandedIndex = Number(url.params.expanded) + const sorted = [...(metricsData.data || [])].sort((a, b) => + b.duration - a.duration + ) + const metric = sorted[expandedIndex] + if (!metric) return null + return ( +
+
+
+ Query +
+
{metric.query}
+
+
+ {metric.status && } + {metric.explain?.length > 0 && } +
+
+ ) } +function MetricRow({ metric, index }: MetricRowProps) { + const isExpanded = url.params.expanded === index.toString() + const maxDuration = Math.max( + ...(metricsData.data?.map((m) => m.duration || 0) ?? []), + 1, + ) + const avg = formatDuration( + metric.count > 0 ? metric.duration / metric.count : 0, + ) + const maxFmt = metric.max != null ? formatDuration(metric.max) : null + const totalFmt = formatDuration(metric.duration) + const pct = (metric.duration / maxDuration) * 100 + + return ( +
+ + + ) +} + +function MetricsSummaryBar() { + const metrics = metricsData.data || [] + const totalCalls = metrics.reduce((acc, m) => acc + (m.count || 0), 0) + const totalDuration = formatDuration( + metrics.reduce((acc, m) => acc + (m.duration || 0), 0), + ) + return ( +
+
+ + + {totalCalls.toLocaleString()} + + total calls +
+
+
+ + + {totalDuration.value} {totalDuration.unit} + + total time +
+
+
+ + {metrics.length} + unique queries +
+
+ ) +} + +function MetricsEmpty() { + return ( +
+
+ +
+
+

+ No metrics recorded +

+

+ Execute database queries to see performance data here. +

+
+
+ ) +} + +// ─── MetricsViewer ────────────────────────────────────────────────────────── + function MetricsViewer() { const metrics = metricsData.data || [] const isPending = metricsData.pending - - const totalCalls = metrics.reduce((acc, m) => acc + (m.count || 0), 0) - const totalDuration = metrics.reduce((acc, m) => acc + (m.duration || 0), 0) - const maxDuration = Math.max(...metrics.map((m) => m.duration || 0), 1) const sorted = [...metrics].sort((a, b) => b.duration - a.duration) return ( @@ -1502,212 +1711,12 @@ function MetricsViewer() {
)} - - {/* Summary bar */} -
-
- - - {totalCalls.toLocaleString()} - - total calls -
-
-
- - - {(() => { - const f = formatDuration(totalDuration) - return `${f.value} ${f.unit}` - })()} - - total time -
-
-
- - {sorted.length} - unique queries -
-
- - {/* Query list */} +
- {sorted.map((metric, index) => { - const isExpanded = expandedMetric.value === index - const avgMs = metric.count > 0 ? metric.duration / metric.count : 0 - const avg = formatDuration(avgMs) - const maxFmt = metric.max != null ? formatDuration(metric.max) : null - const totalFmt = formatDuration(metric.duration) - const pct = (metric.duration / maxDuration) * 100 - const explainTree = metric.explain?.length - ? buildExplainTree(metric.explain) - : [] - - return ( -
- {/* Row */} -
expandedMetric.value = isExpanded ? null : index} - > -
- {/* Query text */} -
-
- {metric.query} -
- {/* Duration bar */} -
-
66 - ? 'bg-error/70' - : pct > 33 - ? 'bg-warning/70' - : 'bg-success/70' - }`} - style={{ width: `${Math.max(2, pct)}%` }} - /> -
-
- - {/* Key stats */} -
-
-
- Calls -
-
- {metric.count} -
-
-
-
- Avg -
-
- {avg.value} - - {avg.unit} - -
-
-
-
- Max -
-
- {maxFmt ? maxFmt.value : '—'} - - {maxFmt?.unit} - -
-
-
-
- Total -
-
- {totalFmt.value} - - {totalFmt.unit} - -
-
-
- {isExpanded - ? - : } -
-
-
-
- - {/* Expanded detail */} - {isExpanded && ( -
- {/* Full query */} -
-
- Query -
-
{metric.query}
-
- -
- {/* Status counters */} - {metric.status && ( -
-
- Execution Counters -
-
- {Object.entries(metric.status).map(([key, val]) => { - const fmt = formatStatusValue(key, val as number) - return ( -
-
- {STATUS_LABELS[key]?.name ?? key} -
-
- {fmt.value} - {fmt.unit && ( - - {fmt.unit} - - )} -
-
- ) - })} -
-
- )} - - {/* Explain tree */} - {explainTree.length > 0 && ( -
-
- Query Plan -
-
- {explainTree.map((node) => ( - - ))} -
-
- )} -
-
- )} -
- ) - })} - - {sorted.length === 0 && !isPending && ( -
-
- -
-
-

- No metrics recorded -

-

- Execute database queries to see performance data here. -

-
-
- )} + {sorted.map((metric, index) => ( + + ))} + {sorted.length === 0 && !isPending && }
) From 3249b4976b20920f642ef53c474022050cd97ad8 Mon Sep 17 00:00:00 2001 From: Clement Denis Date: Tue, 7 Apr 2026 23:15:03 +0200 Subject: [PATCH 3/3] refactor: rework signal logic --- web/pages/DeploymentPage.tsx | 162 ++++++++++++++++++++--------------- 1 file changed, 94 insertions(+), 68 deletions(-) diff --git a/web/pages/DeploymentPage.tsx b/web/pages/DeploymentPage.tsx index 544139f..5d952e6 100644 --- a/web/pages/DeploymentPage.tsx +++ b/web/pages/DeploymentPage.tsx @@ -35,8 +35,8 @@ import { SortMenu, toggleSort, } from '../components/Filtre.tsx' -import { effect, Signal, signal } from '@preact/signals' -import { api, ApiOutput } from '../lib/api.ts' +import { computed, effect, Signal } from '@preact/signals' +import { api, type ApiOutput } from '../lib/api.ts' import { QueryHistory } from '../components/QueryHistory.tsx' import { JSX } from 'preact' @@ -57,7 +57,9 @@ export const rowDetailsData = api['POST/api/deployment/table/data'].signal() export const logDetailsData = api['POST/api/deployment/logs'].signal() export const metricsData = api['GET/api/deployment/metrics-sql'].signal() -const toastSignal = signal<{ message: string; type: 'info' | 'error' } | null>( +const toastSignal = new Signal< + { message: string; type: 'info' | 'error' } | null +>( null, ) function toast(message: string, type: 'info' | 'error' = 'info') { @@ -65,13 +67,6 @@ function toast(message: string, type: 'info' | 'error' = 'info') { setTimeout(() => (toastSignal.value = null), 3000) } -effect(() => { - const dep = url.params.dep - if (dep) { - schema.fetch({ url: dep }) - } -}) - const Toast = () => { if (!toastSignal.value) return null return ( @@ -87,9 +82,12 @@ const Toast = () => { // Effect to fetch schema when deployment URL changes effect(() => { const dep = url.params.dep - if (dep) { - schema.fetch({ url: dep }) - } + dep && schema.fetch({ url: dep }) +}) + +effect(() => { + url.params.tab // clear expanded when params.tab change + navigate({ params: { expanded: null }, replace: true }) }) const queryHash = new Signal('') @@ -1396,36 +1394,79 @@ type QueryPlanProps = { explain: MetricExplain } // ─── Metrics helpers ──────────────────────────────────────────────────────── function formatDuration(ms: number): { value: string; unit: string } { + if (ms < (1 / 1000)) return { value: (ms * 1000 ** 2).toFixed(2), unit: 'ns' } if (ms < 1) return { value: (ms * 1000).toFixed(2), unit: 'μs' } + if (ms >= 1000 * 60) { + return { value: (ms / (1000 * 60)).toFixed(2), unit: 'm' } + } if (ms >= 1000) return { value: (ms / 1000).toFixed(2), unit: 's' } return { value: ms.toFixed(2), unit: 'ms' } } function formatBytes(bytes: number): { value: string; unit: string } { if (bytes < 1024) return { value: bytes.toFixed(2), unit: 'B' } - if (bytes < 1024 * 1024) { + if (bytes < 1024 ** 2) { return { value: (bytes / 1024).toFixed(2), unit: 'KB' } } - if (bytes < 1024 * 1024 * 1024) { - return { value: (bytes / (1024 * 1024)).toFixed(2), unit: 'MB' } + if (bytes < 1024 ** 3) { + return { value: (bytes / (1024 ** 2)).toFixed(2), unit: 'MB' } } - return { value: (bytes / (1024 * 1024 * 1024)).toFixed(2), unit: 'GB' } + return { value: (bytes / (1024 ** 3)).toFixed(2), unit: 'GB' } } -const MEMORY_STATUS_KEYS = new Set(['memused']) - -const STATUS_LABELS: Record = { - fullscanStep: 'Full Scan Steps', - sort: 'Sorts', - autoindex: 'Autoindex Steps', - vmStep: 'VM Steps', - reprepare: 'Reprepares', - run: 'Runs', - filterHit: 'Filter Hit', - filterMiss: 'Filter Miss', - memused: 'Memory Used', +type StatusProperty = { label: string; title: string; fmt?: typeof formatBytes } +const STATUS_LABELS: Record = { + fullscanStep: { + label: 'Full Scan Steps', + title: + 'Number of steps that performed a full scan of a table or index, which is a common cause of slow queries.', + }, + sort: { + label: 'Sorts', + title: 'Number of steps that performed a sort operation.', + }, + autoindex: { + label: 'Autoindex Steps', + title: 'Number of steps that created an index automatically.', + }, + vmStep: { + label: 'VM Steps', + title: 'Number of steps that involved virtual machine operations.', + }, + reprepare: { + label: 'Reprepares', + title: 'Number of steps that reprepared a query plan.', + }, + run: { label: 'Runs', title: 'Number of times a query was executed.' }, + filterHit: { + label: 'Filter Hit', + title: 'Number of times a filter condition was satisfied.', + }, + filterMiss: { + label: 'Filter Miss', + title: 'Number of times a filter condition was not satisfied.', + }, + memused: { + label: 'Memory Used', + title: 'The approximate number of bytes of heap memory used.', + fmt: formatBytes, + }, } +const sortedMetrics = computed( + () => (metricsData.data || []).toSorted((a, b) => b.duration - a.duration), +) + +const stats = computed(() => { + const metrics = sortedMetrics.value + return { + count: metrics.length, + maxDuration: metrics[0]?.duration || 0, + totalCalls: metrics.reduce((acc, m) => acc + (m.count || 0), 0), + totalDuration: metrics.reduce((acc, m) => acc + (m.duration || 0), 0), + } +}) + function buildExplainTree(rows: MetricExplain): ExplainNode[] { const map = new Map() const roots: ExplainNode[] = [] @@ -1498,9 +1539,9 @@ function StatusCounters({ status }: StatusCountersProps) {
{Object.entries(status).map(([key, val]) => { - const fmt = MEMORY_STATUS_KEYS.has(key) - ? formatBytes(val) - : { value: String(val), unit: '' } + const match = STATUS_LABELS[key as keyof typeof STATUS_LABELS] || + { label: key } + const { value, unit } = match.fmt?.(val) || { value: val } return (
- {STATUS_LABELS[key] ?? key} + {match.label}
- {fmt.value} - {fmt.unit && ( + {value} + {unit && ( - {fmt.unit} + {unit} )}
@@ -1528,16 +1569,14 @@ function StatusCounters({ status }: StatusCountersProps) { ) } -function StatCell( - { - label, - value, - unit, - valueClass = 'text-base-content/80', - unitClass = 'text-base-content/30', - width = 'w-24', - }: StatCellProps, -) { +function StatCell({ + label, + value, + unit, + valueClass = 'text-base-content/80', + unitClass = 'text-base-content/30', + width = 'w-24', +}: StatCellProps) { return (
@@ -1553,9 +1592,7 @@ function StatCell( function MetricDetail() { const expandedIndex = Number(url.params.expanded) - const sorted = [...(metricsData.data || [])].sort((a, b) => - b.duration - a.duration - ) + const sorted = sortedMetrics.value const metric = sorted[expandedIndex] if (!metric) return null return ( @@ -1575,17 +1612,11 @@ function MetricDetail() { } function MetricRow({ metric, index }: MetricRowProps) { - const isExpanded = url.params.expanded === index.toString() - const maxDuration = Math.max( - ...(metricsData.data?.map((m) => m.duration || 0) ?? []), - 1, - ) - const avg = formatDuration( - metric.count > 0 ? metric.duration / metric.count : 0, - ) + const isExpanded = url.params.expanded === String(index) + const avg = formatDuration(metric.count && (metric.duration / metric.count)) const maxFmt = metric.max != null ? formatDuration(metric.max) : null const totalFmt = formatDuration(metric.duration) - const pct = (metric.duration / maxDuration) * 100 + const pct = (metric.duration / stats.value.totalDuration) * 100 return (
@@ -1647,17 +1678,13 @@ function MetricRow({ metric, index }: MetricRowProps) { } function MetricsSummaryBar() { - const metrics = metricsData.data || [] - const totalCalls = metrics.reduce((acc, m) => acc + (m.count || 0), 0) - const totalDuration = formatDuration( - metrics.reduce((acc, m) => acc + (m.duration || 0), 0), - ) + const totalDuration = formatDuration(stats.value.totalDuration) return (
- {totalCalls.toLocaleString()} + {stats.value.totalCalls.toLocaleString()} total calls
@@ -1672,7 +1699,7 @@ function MetricsSummaryBar() {
- {metrics.length} + {stats.value.count} unique queries
@@ -1700,9 +1727,8 @@ function MetricsEmpty() { // ─── MetricsViewer ────────────────────────────────────────────────────────── function MetricsViewer() { - const metrics = metricsData.data || [] const isPending = metricsData.pending - const sorted = [...metrics].sort((a, b) => b.duration - a.duration) + const sorted = sortedMetrics.value return (