diff --git a/api/routes.ts b/api/routes.ts index 6a4eb8f..843d516 100644 --- a/api/routes.ts +++ b/api/routes.ts @@ -698,7 +698,26 @@ 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'), + 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 bb3430a..5d952e6 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 { @@ -29,8 +35,8 @@ import { SortMenu, toggleSort, } from '../components/Filtre.tsx' -import { effect, Signal, signal } from '@preact/signals' -import { api } 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' @@ -51,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') { @@ -59,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 ( @@ -81,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('') @@ -796,7 +800,9 @@ function SchemaPanel() { ) } -const TabButton = ({ tabName }: { tabName: 'tables' | 'queries' | 'logs' | 'metrics' }) => ( +const TabButton = ( + { tabName }: { tabName: 'tables' | 'queries' | 'logs' | 'metrics' }, +) => ( { } }) -function MetricsViewer() { - const filteredMetrics = metricsData.data || [] - const isPending = metricsData.pending +// ─── 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 / 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 ** 2) { + return { value: (bytes / 1024).toFixed(2), unit: 'KB' } + } + if (bytes < 1024 ** 3) { + return { value: (bytes / (1024 ** 2)).toFixed(2), unit: 'MB' } + } + return { value: (bytes / (1024 ** 3)).toFixed(2), unit: 'GB' } +} + +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[] = [] + 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 +} + +// ─── Metrics sub-components ───────────────────────────────────────────────── +function ExplainTreeNode( + { node, depth = 0, isLast = true, prefix = '' }: ExplainTreeNodeProps, +) { + const connector = depth === 0 ? '' : isLast ? '└─ ' : '├─ ' + const childPrefix = depth === 0 ? '' : prefix + (isLast ? ' ' : '│ ') return ( -
- {!!isPending && ( -
-
-
- )} -
-
+
+ {depth > 0 && ( + + {prefix} + {connector} + + )} + - - - - {filteredMetrics.map((metric, index) => ( - - - - - - - - ))} - -
-
{metric.query}
-
- {metric.count} - - {metric.duration} - -
{metric.explain}
-
-
+ {node.detail} +
- {filteredMetrics.length === 0 && !isPending && ( -
-
- -
-

- No metrics found -

+ {node.children.map((child, i) => ( + + ))} +
+ ) +} + +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 match = STATUS_LABELS[key as keyof typeof STATUS_LABELS] || + { label: key } + const { value, unit } = match.fmt?.(val) || { value: val } + return ( +
+
+ {match.label} +
+
+ {value} + {unit && ( + + {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 = sortedMetrics.value + 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 === 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 / stats.value.totalDuration) * 100 + + return ( +
+ + + ) +} + +function MetricsSummaryBar() { + const totalDuration = formatDuration(stats.value.totalDuration) + return ( +
+
+ + + {stats.value.totalCalls.toLocaleString()} + + total calls +
+
+
+ + + {totalDuration.value} {totalDuration.unit} + + total time +
+
+
+ + {stats.value.count} + unique queries +
+
+ ) +} + +function MetricsEmpty() { + return ( +
+
+ +
+
+

+ No metrics recorded +

+

+ Execute database queries to see performance data here. +

+
+
+ ) +} + +// ─── MetricsViewer ────────────────────────────────────────────────────────── + +function MetricsViewer() { + const isPending = metricsData.pending + const sorted = sortedMetrics.value + + return ( +
+ {!!isPending && ( +
+
+
)} + +
+ {sorted.map((metric, index) => ( + + ))} + {sorted.length === 0 && !isPending && } +
) }