@tabularis/explain 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.js ADDED
@@ -0,0 +1,1617 @@
1
+ import { memo, useState, useEffect, useCallback, useMemo } from 'react';
2
+ import { Handle, Position, ReactFlowProvider, useReactFlow, useNodesState, useEdgesState, ReactFlow, Background, Controls, MiniMap } from '@xyflow/react';
3
+ import '@xyflow/react/dist/style.css';
4
+ import dagre from 'dagre';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { Network, BarChart3, TableProperties, PieChart, FileText, Sparkles, CircleSlash, DatabaseZap, Repeat, Users, Layers, ScanSearch, Filter, HardDrive, TrendingDown, TrendingUp, Flame, Info, AlertTriangle, ChevronDown, ChevronRight, Layers2, Clock3, Database, LayoutDashboard, BookOpenText } from 'lucide-react';
7
+ import clsx from 'clsx';
8
+ import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
9
+
10
+ // src/components/ExplainGraph.tsx
11
+
12
+ // src/metrics.ts
13
+ var EXPLAIN_METRIC_KINDS = [
14
+ "time",
15
+ "rows",
16
+ "cost",
17
+ "buffers"
18
+ ];
19
+ function loopCount(node) {
20
+ return node.actual_loops != null && node.actual_loops > 0 ? node.actual_loops : 1;
21
+ }
22
+ function isSubplanChild(node) {
23
+ const relationship = node.extra["Parent Relationship"];
24
+ return relationship === "InitPlan" || relationship === "SubPlan";
25
+ }
26
+ function bufferCount(node) {
27
+ if (node.buffers_hit == null && node.buffers_read == null) {
28
+ return null;
29
+ }
30
+ return (node.buffers_hit ?? 0) + (node.buffers_read ?? 0);
31
+ }
32
+ function selfValue(total, childSum) {
33
+ if (total == null) {
34
+ return null;
35
+ }
36
+ return Math.max(0, total - childSum);
37
+ }
38
+ function computeExplainMetrics(plan) {
39
+ const byId = /* @__PURE__ */ new Map();
40
+ const order = [];
41
+ let nextIndex = 1;
42
+ function walk(node, depth) {
43
+ const metrics = {
44
+ nodeId: node.id,
45
+ index: nextIndex,
46
+ depth,
47
+ inclusiveTimeMs: null,
48
+ exclusiveTimeMs: null,
49
+ totalCost: node.total_cost,
50
+ exclusiveCost: null,
51
+ totalRows: null,
52
+ inclusiveBuffers: bufferCount(node),
53
+ exclusiveBuffers: null,
54
+ timeShare: null,
55
+ neverExecuted: node.actual_loops === 0
56
+ };
57
+ nextIndex += 1;
58
+ byId.set(node.id, metrics);
59
+ order.push(metrics);
60
+ let childTimeMs = 0;
61
+ let childCost = 0;
62
+ let childBuffers = 0;
63
+ for (const child of node.children) {
64
+ const contribution = walk(child, depth + 1);
65
+ childCost += contribution.cost;
66
+ childBuffers += contribution.buffers;
67
+ if (!isSubplanChild(child)) {
68
+ childTimeMs += contribution.timeMs;
69
+ }
70
+ }
71
+ const loops = loopCount(node);
72
+ metrics.inclusiveTimeMs = node.actual_time_ms != null ? node.actual_time_ms * loops : null;
73
+ metrics.exclusiveTimeMs = selfValue(metrics.inclusiveTimeMs, childTimeMs);
74
+ metrics.exclusiveCost = selfValue(metrics.totalCost, childCost);
75
+ metrics.exclusiveBuffers = selfValue(metrics.inclusiveBuffers, childBuffers);
76
+ metrics.totalRows = node.actual_rows != null ? node.actual_rows * loops : null;
77
+ return {
78
+ timeMs: metrics.inclusiveTimeMs ?? 0,
79
+ cost: metrics.totalCost ?? 0,
80
+ buffers: metrics.inclusiveBuffers ?? 0
81
+ };
82
+ }
83
+ walk(plan.root, 0);
84
+ let maxExclusiveTimeMs = 0;
85
+ let maxExclusiveCost = 0;
86
+ let maxTotalRows = 0;
87
+ let maxExclusiveBuffers = 0;
88
+ let totalExclusiveTimeMs = 0;
89
+ for (const metrics of order) {
90
+ maxExclusiveTimeMs = Math.max(
91
+ maxExclusiveTimeMs,
92
+ metrics.exclusiveTimeMs ?? 0
93
+ );
94
+ maxExclusiveCost = Math.max(maxExclusiveCost, metrics.exclusiveCost ?? 0);
95
+ maxTotalRows = Math.max(maxTotalRows, metrics.totalRows ?? 0);
96
+ maxExclusiveBuffers = Math.max(
97
+ maxExclusiveBuffers,
98
+ metrics.exclusiveBuffers ?? 0
99
+ );
100
+ totalExclusiveTimeMs += metrics.exclusiveTimeMs ?? 0;
101
+ }
102
+ if (totalExclusiveTimeMs > 0) {
103
+ for (const metrics of order) {
104
+ if (metrics.exclusiveTimeMs != null) {
105
+ metrics.timeShare = metrics.exclusiveTimeMs / totalExclusiveTimeMs;
106
+ }
107
+ }
108
+ }
109
+ return {
110
+ byId,
111
+ order,
112
+ maxExclusiveTimeMs,
113
+ maxExclusiveCost,
114
+ maxTotalRows,
115
+ maxExclusiveBuffers,
116
+ totalExclusiveTimeMs
117
+ };
118
+ }
119
+ function getMetricValue(metrics, kind) {
120
+ switch (kind) {
121
+ case "time":
122
+ return metrics.exclusiveTimeMs;
123
+ case "rows":
124
+ return metrics.totalRows;
125
+ case "cost":
126
+ return metrics.exclusiveCost;
127
+ case "buffers":
128
+ return metrics.exclusiveBuffers;
129
+ }
130
+ }
131
+ function getMetricMax(metrics, kind) {
132
+ switch (kind) {
133
+ case "time":
134
+ return metrics.maxExclusiveTimeMs;
135
+ case "rows":
136
+ return metrics.maxTotalRows;
137
+ case "cost":
138
+ return metrics.maxExclusiveCost;
139
+ case "buffers":
140
+ return metrics.maxExclusiveBuffers;
141
+ }
142
+ }
143
+ function isMetricAvailable(metrics, kind) {
144
+ return metrics.order.some((node) => getMetricValue(node, kind) != null);
145
+ }
146
+ function getAvailableMetricKinds(metrics) {
147
+ return EXPLAIN_METRIC_KINDS.filter((kind) => isMetricAvailable(metrics, kind));
148
+ }
149
+ function getDefaultMetricKind(metrics) {
150
+ if (metrics.maxExclusiveTimeMs > 0) {
151
+ return "time";
152
+ }
153
+ const available = getAvailableMetricKinds(metrics);
154
+ return available.includes("cost") ? "cost" : available[0] ?? null;
155
+ }
156
+
157
+ // src/flow.ts
158
+ function explainPlanToFlow(plan, selectedNodeId, metrics, diagnostics) {
159
+ const planMetrics = metrics ?? computeExplainMetrics(plan);
160
+ const rawNodes = [];
161
+ const edges = [];
162
+ function walk(node) {
163
+ const nodeMetrics = planMetrics.byId.get(node.id);
164
+ const data = {
165
+ node,
166
+ metrics: nodeMetrics ?? null,
167
+ maxExclusiveCost: planMetrics.maxExclusiveCost,
168
+ maxExclusiveTimeMs: planMetrics.maxExclusiveTimeMs,
169
+ diagnostics: diagnostics?.get(node.id) ?? [],
170
+ hasAnalyzeData: plan.has_analyze_data,
171
+ isSelected: selectedNodeId === node.id
172
+ };
173
+ rawNodes.push({
174
+ id: node.id,
175
+ type: "explainPlan",
176
+ position: { x: 0, y: 0 },
177
+ data
178
+ });
179
+ for (const child of node.children) {
180
+ edges.push({
181
+ id: `${node.id}-${child.id}`,
182
+ source: node.id,
183
+ target: child.id,
184
+ animated: true,
185
+ style: { stroke: "#6366f1" }
186
+ });
187
+ walk(child);
188
+ }
189
+ }
190
+ walk(plan.root);
191
+ return layoutExplainNodes(rawNodes, edges);
192
+ }
193
+ function layoutExplainNodes(nodes, edges) {
194
+ const g = new dagre.graphlib.Graph();
195
+ g.setDefaultEdgeLabel(() => ({}));
196
+ g.setGraph({ rankdir: "TB", ranksep: 80, nodesep: 40 });
197
+ const NODE_WIDTH = 280;
198
+ for (const node of nodes) {
199
+ const data = node.data;
200
+ const lines = 3 + (data.hasAnalyzeData ? 2 : 0) + (data.node.filter ? 1 : 0) + (data.diagnostics.length > 0 ? 1 : 0);
201
+ const height = 28 + lines * 22;
202
+ g.setNode(node.id, { width: NODE_WIDTH, height });
203
+ }
204
+ for (const edge of edges) {
205
+ g.setEdge(edge.source, edge.target);
206
+ }
207
+ dagre.layout(g);
208
+ const layoutedNodes = nodes.map((node) => {
209
+ const pos = g.node(node.id);
210
+ return {
211
+ ...node,
212
+ position: {
213
+ x: pos.x - NODE_WIDTH / 2,
214
+ y: pos.y - pos.height / 2
215
+ }
216
+ };
217
+ });
218
+ return { nodes: layoutedNodes, edges };
219
+ }
220
+
221
+ // src/plan.ts
222
+ function getNodeCostStyle(cost, maxCost) {
223
+ if (maxCost <= 0) return { border: "border-l-green-500", headerBg: "bg-green-950/30" };
224
+ const ratio = cost / maxCost;
225
+ if (ratio < 0.2) return { border: "border-l-green-500", headerBg: "bg-green-950/30" };
226
+ if (ratio < 0.6) return { border: "border-l-yellow-500", headerBg: "bg-yellow-950/30" };
227
+ return { border: "border-l-red-500", headerBg: "bg-red-950/30" };
228
+ }
229
+ function getHeatBarClass(value, max) {
230
+ if (max <= 0) return "bg-green-500/70";
231
+ const ratio = value / max;
232
+ if (ratio < 0.2) return "bg-green-500/70";
233
+ if (ratio < 0.6) return "bg-yellow-500/70";
234
+ return "bg-red-500/70";
235
+ }
236
+ function formatCost(n) {
237
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
238
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
239
+ if (n >= 100) return n.toFixed(0);
240
+ if (n >= 1) return n.toFixed(1);
241
+ return n.toFixed(2);
242
+ }
243
+ function formatTime(ms) {
244
+ if (ms >= 1e3) return `${(ms / 1e3).toFixed(2)} s`;
245
+ if (ms >= 1) return `${ms.toFixed(2)} ms`;
246
+ return `${(ms * 1e3).toFixed(0)} us`;
247
+ }
248
+ function formatRows(n) {
249
+ if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
250
+ if (n >= 1e3) return `${(n / 1e3).toFixed(1)}K`;
251
+ return n.toFixed(0);
252
+ }
253
+ function formatRatio(n) {
254
+ if (n >= 100) return `${n.toFixed(0)}x`;
255
+ if (n >= 10) return `${n.toFixed(1)}x`;
256
+ return `${n.toFixed(2)}x`;
257
+ }
258
+ function getMaxCost(node) {
259
+ let max = node.total_cost ?? 0;
260
+ for (const child of node.children) {
261
+ const childMax = getMaxCost(child);
262
+ if (childMax > max) max = childMax;
263
+ }
264
+ return max;
265
+ }
266
+ function flattenExplainNodes(root) {
267
+ const nodes = [];
268
+ function walk(node) {
269
+ nodes.push(node);
270
+ for (const child of node.children) {
271
+ walk(child);
272
+ }
273
+ }
274
+ walk(root);
275
+ return nodes;
276
+ }
277
+ function findExplainNode(root, nodeId) {
278
+ if (!nodeId) {
279
+ return null;
280
+ }
281
+ if (root.id === nodeId) {
282
+ return root;
283
+ }
284
+ for (const child of root.children) {
285
+ const found = findExplainNode(child, nodeId);
286
+ if (found) {
287
+ return found;
288
+ }
289
+ }
290
+ return null;
291
+ }
292
+ function getRowEstimateRatio(node) {
293
+ if (node.plan_rows == null || node.actual_rows == null || node.plan_rows <= 0 || node.actual_rows <= 0) {
294
+ return null;
295
+ }
296
+ return node.actual_rows / node.plan_rows;
297
+ }
298
+ function getMismatchMagnitude(node) {
299
+ const ratio = getRowEstimateRatio(node);
300
+ if (ratio == null) {
301
+ return null;
302
+ }
303
+ return ratio >= 1 ? ratio : 1 / ratio;
304
+ }
305
+ function isSequentialScan(node) {
306
+ const normalizedType = node.node_type.toLowerCase();
307
+ const accessType = typeof node.extra.access_type === "string" ? node.extra.access_type.toLowerCase() : "";
308
+ return normalizedType.includes("seq scan") || normalizedType.includes("table scan") || normalizedType.includes("full scan") || accessType === "all";
309
+ }
310
+ function isTempOperation(node) {
311
+ const normalizedType = node.node_type.toLowerCase();
312
+ const extraText = Object.values(node.extra).filter((value) => typeof value === "string").join(" ").toLowerCase();
313
+ return normalizedType.includes("sort") || normalizedType.includes("filesort") || normalizedType.includes("temporary") || extraText.includes("using temporary") || extraText.includes("using filesort");
314
+ }
315
+ function getExplainPlanSummary(plan, metrics) {
316
+ const nodes = flattenExplainNodes(plan.root);
317
+ const planMetrics = metrics ?? computeExplainMetrics(plan);
318
+ let highestCostNode = null;
319
+ let slowestNode = null;
320
+ let largestRowMismatchNode = null;
321
+ let sequentialScans = 0;
322
+ let tempOperations = 0;
323
+ for (const node of nodes) {
324
+ const nodeMetrics = planMetrics.byId.get(node.id);
325
+ const exclusiveCost = nodeMetrics?.exclusiveCost ?? null;
326
+ const exclusiveTimeMs = nodeMetrics?.exclusiveTimeMs ?? null;
327
+ if (exclusiveCost != null && (highestCostNode == null || exclusiveCost > highestCostNode.value)) {
328
+ highestCostNode = {
329
+ nodeId: node.id,
330
+ nodeType: node.node_type,
331
+ relation: node.relation,
332
+ value: exclusiveCost
333
+ };
334
+ }
335
+ if (exclusiveTimeMs != null && (slowestNode == null || exclusiveTimeMs > slowestNode.value)) {
336
+ slowestNode = {
337
+ nodeId: node.id,
338
+ nodeType: node.node_type,
339
+ relation: node.relation,
340
+ value: exclusiveTimeMs
341
+ };
342
+ }
343
+ const ratio = getRowEstimateRatio(node);
344
+ const magnitude = getMismatchMagnitude(node);
345
+ if (ratio != null && magnitude != null && (largestRowMismatchNode == null || magnitude > largestRowMismatchNode.value)) {
346
+ largestRowMismatchNode = {
347
+ nodeId: node.id,
348
+ nodeType: node.node_type,
349
+ relation: node.relation,
350
+ value: magnitude,
351
+ ratio
352
+ };
353
+ }
354
+ if (isSequentialScan(node)) {
355
+ sequentialScans += 1;
356
+ }
357
+ if (isTempOperation(node)) {
358
+ tempOperations += 1;
359
+ }
360
+ }
361
+ return {
362
+ highestCostNode,
363
+ slowestNode,
364
+ largestRowMismatchNode,
365
+ sequentialScans,
366
+ tempOperations
367
+ };
368
+ }
369
+ function getExplainDriverLegend(plan) {
370
+ switch (plan.driver) {
371
+ case "postgres":
372
+ return plan.has_analyze_data ? [
373
+ "editor.visualExplain.postgresAnalyzeLegend1",
374
+ "editor.visualExplain.postgresAnalyzeLegend2"
375
+ ] : [
376
+ "editor.visualExplain.postgresEstimateLegend1",
377
+ "editor.visualExplain.postgresEstimateLegend2"
378
+ ];
379
+ case "mysql":
380
+ return plan.has_analyze_data ? [
381
+ "editor.visualExplain.mysqlAnalyzeLegend1",
382
+ "editor.visualExplain.mysqlAnalyzeLegend2"
383
+ ] : [
384
+ "editor.visualExplain.mysqlEstimateLegend1",
385
+ "editor.visualExplain.mysqlEstimateLegend2"
386
+ ];
387
+ case "sqlite":
388
+ return [
389
+ "editor.visualExplain.sqliteLegend1",
390
+ "editor.visualExplain.sqliteLegend2"
391
+ ];
392
+ default:
393
+ return [];
394
+ }
395
+ }
396
+ var KIND_ICONS = {
397
+ hotspot: Flame,
398
+ "over-estimate": TrendingUp,
399
+ "under-estimate": TrendingDown,
400
+ "disk-sort": HardDrive,
401
+ "filter-loss": Filter,
402
+ "large-seq-scan": ScanSearch,
403
+ "heap-fetches": Layers,
404
+ "workers-underused": Users,
405
+ "high-loops": Repeat,
406
+ "cache-miss": DatabaseZap,
407
+ "never-executed": CircleSlash
408
+ };
409
+ var SEVERITY_ICONS = {
410
+ critical: AlertTriangle,
411
+ warning: AlertTriangle,
412
+ info: Info
413
+ };
414
+ function severityChipClass(severity) {
415
+ switch (severity) {
416
+ case "critical":
417
+ return "border-red-500/40 bg-red-950/30 text-red-200";
418
+ case "warning":
419
+ return "border-amber-500/40 bg-amber-950/30 text-amber-200";
420
+ case "info":
421
+ return "border-default bg-surface-secondary/40 text-secondary";
422
+ }
423
+ }
424
+ function severityTextClass(severity) {
425
+ switch (severity) {
426
+ case "critical":
427
+ return "text-red-300";
428
+ case "warning":
429
+ return "text-amber-300";
430
+ case "info":
431
+ return "text-secondary";
432
+ }
433
+ }
434
+ function ExplainDiagnosticChips({
435
+ diagnostics,
436
+ iconsOnly = false,
437
+ className
438
+ }) {
439
+ const { t } = useTranslation();
440
+ if (diagnostics.length === 0) {
441
+ return null;
442
+ }
443
+ return /* @__PURE__ */ jsx("div", { className: clsx("flex flex-wrap items-center gap-1", className), children: diagnostics.map((diagnostic) => {
444
+ const Icon = KIND_ICONS[diagnostic.kind];
445
+ const label = t(diagnostic.labelKey);
446
+ return /* @__PURE__ */ jsxs(
447
+ "span",
448
+ {
449
+ title: `${label}${diagnostic.value ? ` (${diagnostic.value})` : ""} \u2014 ${t(diagnostic.descriptionKey)}`,
450
+ className: clsx(
451
+ "inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] leading-none",
452
+ severityChipClass(diagnostic.severity)
453
+ ),
454
+ children: [
455
+ /* @__PURE__ */ jsx(Icon, { size: 10, className: "shrink-0" }),
456
+ !iconsOnly && /* @__PURE__ */ jsx("span", { className: "font-medium", children: label }),
457
+ diagnostic.value && /* @__PURE__ */ jsx("span", { className: "font-mono opacity-90", children: diagnostic.value })
458
+ ]
459
+ },
460
+ diagnostic.kind
461
+ );
462
+ }) });
463
+ }
464
+ function ExplainDiagnosticList({
465
+ diagnostics
466
+ }) {
467
+ const { t } = useTranslation();
468
+ if (diagnostics.length === 0) {
469
+ return null;
470
+ }
471
+ return /* @__PURE__ */ jsx("div", { className: "divide-y divide-default/40", children: diagnostics.map((diagnostic) => {
472
+ const Icon = SEVERITY_ICONS[diagnostic.severity];
473
+ return /* @__PURE__ */ jsxs("div", { className: "flex gap-2 px-4 py-2.5", children: [
474
+ /* @__PURE__ */ jsx(
475
+ Icon,
476
+ {
477
+ size: 13,
478
+ className: clsx("mt-0.5 shrink-0", severityTextClass(diagnostic.severity))
479
+ }
480
+ ),
481
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
482
+ /* @__PURE__ */ jsxs("div", { className: "flex items-baseline gap-2", children: [
483
+ /* @__PURE__ */ jsx(
484
+ "span",
485
+ {
486
+ className: clsx(
487
+ "text-[11px] font-semibold",
488
+ severityTextClass(diagnostic.severity)
489
+ ),
490
+ children: t(diagnostic.labelKey)
491
+ }
492
+ ),
493
+ diagnostic.value && /* @__PURE__ */ jsx("span", { className: "font-mono text-[11px] text-secondary", children: diagnostic.value })
494
+ ] }),
495
+ /* @__PURE__ */ jsx("div", { className: "mt-1 text-[11px] leading-relaxed text-muted", children: t(diagnostic.descriptionKey) })
496
+ ] })
497
+ ] }, diagnostic.kind);
498
+ }) });
499
+ }
500
+ var ExplainPlanNodeComponent = memo(
501
+ ({ data }) => {
502
+ const { t } = useTranslation();
503
+ const {
504
+ node,
505
+ metrics,
506
+ maxExclusiveCost,
507
+ maxExclusiveTimeMs,
508
+ diagnostics,
509
+ hasAnalyzeData,
510
+ isSelected
511
+ } = data;
512
+ const useTimeHeat = hasAnalyzeData && maxExclusiveTimeMs > 0;
513
+ const costStyle = useTimeHeat ? getNodeCostStyle(metrics?.exclusiveTimeMs ?? 0, maxExclusiveTimeMs) : getNodeCostStyle(metrics?.exclusiveCost ?? 0, maxExclusiveCost);
514
+ const rowRatio = getRowEstimateRatio(node);
515
+ const mismatch = rowRatio != null && (rowRatio >= 4 || rowRatio <= 0.25) ? rowRatio >= 1 ? {
516
+ value: formatRatio(rowRatio),
517
+ label: t("editor.visualExplain.overEstimate")
518
+ } : {
519
+ value: formatRatio(1 / rowRatio),
520
+ label: t("editor.visualExplain.underEstimate")
521
+ } : null;
522
+ return /* @__PURE__ */ jsxs(
523
+ "div",
524
+ {
525
+ className: clsx(
526
+ "bg-elevated border border-strong rounded shadow-xl min-w-[260px] max-w-[300px] overflow-hidden transition-all",
527
+ "border-l-4",
528
+ costStyle.border,
529
+ isSelected && "ring-2 ring-blue-400/70 border-blue-400/70"
530
+ ),
531
+ children: [
532
+ /* @__PURE__ */ jsxs("div", { className: clsx("px-3 py-2 border-b border-default", costStyle.headerBg), children: [
533
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
534
+ metrics && /* @__PURE__ */ jsxs("span", { className: "shrink-0 rounded bg-surface-secondary/70 px-1.5 py-0.5 font-mono text-[10px] text-muted", children: [
535
+ "#",
536
+ metrics.index
537
+ ] }),
538
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-bold text-primary", children: node.node_type })
539
+ ] }),
540
+ node.relation && /* @__PURE__ */ jsxs("div", { className: "text-xs text-muted mt-0.5", children: [
541
+ t("editor.visualExplain.relation"),
542
+ ": ",
543
+ node.relation
544
+ ] })
545
+ ] }),
546
+ /* @__PURE__ */ jsxs("div", { className: "px-3 py-2 space-y-1", children: [
547
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
548
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.estRows") }),
549
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: node.plan_rows != null ? formatRows(node.plan_rows) : "-" })
550
+ ] }),
551
+ metrics?.exclusiveCost != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
552
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.selfCost") }),
553
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: formatCost(metrics.exclusiveCost) })
554
+ ] }),
555
+ mismatch && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
556
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.largestEstimateGap") }),
557
+ /* @__PURE__ */ jsx("span", { className: "text-amber-300 font-mono font-semibold", children: mismatch.value })
558
+ ] }),
559
+ hasAnalyzeData && node.actual_rows != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
560
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.actualRows") }),
561
+ /* @__PURE__ */ jsx("span", { className: "text-primary font-mono font-semibold", children: formatRows(node.actual_rows) })
562
+ ] }),
563
+ hasAnalyzeData && metrics?.exclusiveTimeMs != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
564
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.selfTime") }),
565
+ /* @__PURE__ */ jsxs("span", { className: "text-primary font-mono font-semibold", children: [
566
+ formatTime(metrics.exclusiveTimeMs),
567
+ metrics.timeShare != null && /* @__PURE__ */ jsxs("span", { className: "ml-1 text-muted font-normal", children: [
568
+ "(",
569
+ Math.round(metrics.timeShare * 100),
570
+ "%)"
571
+ ] })
572
+ ] })
573
+ ] }),
574
+ hasAnalyzeData && metrics?.inclusiveTimeMs != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
575
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.totalTime") }),
576
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: formatTime(metrics.inclusiveTimeMs) })
577
+ ] }),
578
+ hasAnalyzeData && node.actual_loops != null && node.actual_loops > 1 && /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between text-xs", children: [
579
+ /* @__PURE__ */ jsx("span", { className: "text-muted", children: t("editor.visualExplain.loops") }),
580
+ /* @__PURE__ */ jsx("span", { className: "text-secondary font-mono", children: node.actual_loops })
581
+ ] }),
582
+ diagnostics.length > 0 && /* @__PURE__ */ jsx(
583
+ ExplainDiagnosticChips,
584
+ {
585
+ diagnostics,
586
+ className: "border-t border-default/50 pt-1.5"
587
+ }
588
+ ),
589
+ node.filter && /* @__PURE__ */ jsxs("div", { className: "text-[10px] text-muted mt-1 font-mono truncate border-t border-default/50 pt-1", children: [
590
+ t("editor.visualExplain.filter"),
591
+ ": ",
592
+ node.filter
593
+ ] }),
594
+ node.index_condition && /* @__PURE__ */ jsxs("div", { className: "text-[10px] text-muted font-mono truncate", children: [
595
+ t("editor.visualExplain.indexCondition"),
596
+ ": ",
597
+ node.index_condition
598
+ ] })
599
+ ] }),
600
+ /* @__PURE__ */ jsx(
601
+ Handle,
602
+ {
603
+ type: "target",
604
+ position: Position.Top,
605
+ className: "!w-2 !h-2 !bg-indigo-500 !border-strong"
606
+ }
607
+ ),
608
+ /* @__PURE__ */ jsx(
609
+ Handle,
610
+ {
611
+ type: "source",
612
+ position: Position.Bottom,
613
+ className: "!w-2 !h-2 !bg-indigo-500 !border-strong"
614
+ }
615
+ )
616
+ ]
617
+ }
618
+ );
619
+ }
620
+ );
621
+ var nodeTypes = {
622
+ explainPlan: ExplainPlanNodeComponent
623
+ };
624
+ function ExplainGraphInner({
625
+ plan,
626
+ metrics,
627
+ diagnostics,
628
+ selectedNodeId,
629
+ onSelectNode
630
+ }) {
631
+ const { fitView } = useReactFlow();
632
+ const { nodes: initialNodes, edges: initialEdges } = useMemo(
633
+ () => explainPlanToFlow(plan, selectedNodeId, metrics, diagnostics),
634
+ [plan, selectedNodeId, metrics, diagnostics]
635
+ );
636
+ const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
637
+ const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
638
+ useEffect(() => {
639
+ setNodes(initialNodes);
640
+ setEdges(initialEdges);
641
+ }, [initialNodes, initialEdges, setNodes, setEdges]);
642
+ const handleInit = useCallback(() => {
643
+ setTimeout(() => fitView({ padding: 0.2 }), 50);
644
+ }, [fitView]);
645
+ return /* @__PURE__ */ jsxs(
646
+ ReactFlow,
647
+ {
648
+ nodes,
649
+ edges,
650
+ onNodesChange,
651
+ onEdgesChange,
652
+ nodeTypes,
653
+ onInit: handleInit,
654
+ onNodeClick: (_, node) => onSelectNode(node.id),
655
+ fitView: true,
656
+ proOptions: { hideAttribution: true },
657
+ minZoom: 0.1,
658
+ maxZoom: 2,
659
+ children: [
660
+ /* @__PURE__ */ jsx(Background, {}),
661
+ /* @__PURE__ */ jsx(Controls, {}),
662
+ nodes.length > 10 && /* @__PURE__ */ jsx(
663
+ MiniMap,
664
+ {
665
+ nodeStrokeWidth: 3,
666
+ className: "!bg-base !border-default"
667
+ }
668
+ )
669
+ ]
670
+ }
671
+ );
672
+ }
673
+ var ExplainGraph = ({
674
+ plan,
675
+ metrics,
676
+ diagnostics,
677
+ selectedNodeId,
678
+ onSelectNode
679
+ }) => {
680
+ return /* @__PURE__ */ jsx("div", { className: "h-full w-full", children: /* @__PURE__ */ jsx(ReactFlowProvider, { children: /* @__PURE__ */ jsx(
681
+ ExplainGraphInner,
682
+ {
683
+ plan,
684
+ metrics,
685
+ diagnostics,
686
+ selectedNodeId,
687
+ onSelectNode
688
+ }
689
+ ) }) });
690
+ };
691
+ function ExplainNodeDetails({
692
+ node,
693
+ hasAnalyzeData,
694
+ metrics = null,
695
+ diagnostics = []
696
+ }) {
697
+ const { t } = useTranslation();
698
+ if (!node) {
699
+ return /* @__PURE__ */ jsx("div", { className: "p-4 text-xs text-muted", children: t("editor.visualExplain.selectNode") });
700
+ }
701
+ const generalEntries = [
702
+ [t("editor.visualExplain.nodeType"), node.node_type],
703
+ ...node.relation ? [[t("editor.visualExplain.relation"), node.relation]] : [],
704
+ ...node.startup_cost != null && node.total_cost != null ? [
705
+ [
706
+ t("editor.visualExplain.cost"),
707
+ `${formatCost(node.startup_cost)} - ${formatCost(node.total_cost)}`
708
+ ]
709
+ ] : node.total_cost != null ? [[t("editor.visualExplain.cost"), formatCost(node.total_cost)]] : [],
710
+ ...metrics?.exclusiveCost != null ? [
711
+ [
712
+ t("editor.visualExplain.selfCost"),
713
+ formatCost(metrics.exclusiveCost)
714
+ ]
715
+ ] : [],
716
+ ...node.plan_rows != null ? [[t("editor.visualExplain.estRows"), formatRows(node.plan_rows)]] : [],
717
+ ...node.filter ? [[t("editor.visualExplain.filter"), node.filter]] : [],
718
+ ...node.index_condition ? [[t("editor.visualExplain.indexCondition"), node.index_condition]] : [],
719
+ ...node.join_type ? [[t("editor.visualExplain.joinType"), node.join_type]] : [],
720
+ ...node.hash_condition ? [[t("editor.visualExplain.hashCondition"), node.hash_condition]] : []
721
+ ];
722
+ const analyzeEntries = hasAnalyzeData ? [
723
+ ...node.actual_rows != null ? [[t("editor.visualExplain.actualRows"), formatRows(node.actual_rows)]] : [],
724
+ ...metrics?.totalRows != null && node.actual_loops != null && node.actual_loops > 1 ? [
725
+ [
726
+ t("editor.visualExplain.rowsAllLoops"),
727
+ formatRows(metrics.totalRows)
728
+ ]
729
+ ] : [],
730
+ ...metrics?.exclusiveTimeMs != null ? [
731
+ [
732
+ t("editor.visualExplain.selfTime"),
733
+ metrics.timeShare != null ? `${formatTime(metrics.exclusiveTimeMs)} (${Math.round(metrics.timeShare * 100)}%)` : formatTime(metrics.exclusiveTimeMs)
734
+ ]
735
+ ] : [],
736
+ ...metrics?.inclusiveTimeMs != null ? [
737
+ [
738
+ t("editor.visualExplain.totalTime"),
739
+ formatTime(metrics.inclusiveTimeMs)
740
+ ]
741
+ ] : [],
742
+ ...node.actual_time_ms != null ? [
743
+ [
744
+ t("editor.visualExplain.timePerLoop"),
745
+ formatTime(node.actual_time_ms)
746
+ ]
747
+ ] : [],
748
+ ...node.actual_loops != null ? [[t("editor.visualExplain.loops"), String(node.actual_loops)]] : [],
749
+ ...node.buffers_hit != null ? [[t("editor.visualExplain.buffersHit"), String(node.buffers_hit)]] : [],
750
+ ...node.buffers_read != null ? [[t("editor.visualExplain.buffersRead"), String(node.buffers_read)]] : []
751
+ ] : [];
752
+ const extraEntries = Object.entries(node.extra).map(
753
+ ([key, value]) => [key, typeof value === "string" ? value : JSON.stringify(value)]
754
+ );
755
+ return /* @__PURE__ */ jsxs("div", { className: "text-xs", children: [
756
+ diagnostics.length > 0 && /* @__PURE__ */ jsxs("div", { className: "border-b border-default/60", children: [
757
+ /* @__PURE__ */ jsx("div", { className: "bg-base/60 px-4 py-3 text-[11px] font-semibold uppercase tracking-wide text-muted", children: t("editor.visualExplain.diagnostics.title") }),
758
+ /* @__PURE__ */ jsx(ExplainDiagnosticList, { diagnostics })
759
+ ] }),
760
+ /* @__PURE__ */ jsx(
761
+ DetailSection,
762
+ {
763
+ title: t("editor.visualExplain.general"),
764
+ entries: generalEntries
765
+ }
766
+ ),
767
+ analyzeEntries.length > 0 && /* @__PURE__ */ jsx(
768
+ DetailSection,
769
+ {
770
+ title: t("editor.visualExplain.analyzeData"),
771
+ entries: analyzeEntries
772
+ }
773
+ ),
774
+ extraEntries.length > 0 && /* @__PURE__ */ jsx(
775
+ DetailSection,
776
+ {
777
+ title: t("editor.visualExplain.extraDetails"),
778
+ entries: extraEntries
779
+ }
780
+ )
781
+ ] });
782
+ }
783
+ function DetailSection({ title, entries }) {
784
+ if (entries.length === 0) {
785
+ return null;
786
+ }
787
+ return /* @__PURE__ */ jsxs("div", { className: "border-b border-default/60 last:border-b-0", children: [
788
+ /* @__PURE__ */ jsx("div", { className: "px-4 py-3 text-[11px] uppercase tracking-wide text-muted font-semibold bg-base/60", children: title }),
789
+ /* @__PURE__ */ jsx("div", { className: "divide-y divide-default/40", children: entries.map(([label, value]) => /* @__PURE__ */ jsxs("div", { className: "px-4 py-2.5", children: [
790
+ /* @__PURE__ */ jsx("div", { className: "text-[11px] text-muted mb-1", children: label }),
791
+ /* @__PURE__ */ jsx("div", { className: "text-secondary break-words font-mono leading-relaxed", children: value })
792
+ ] }, label)) })
793
+ ] });
794
+ }
795
+ function ExplainTableView({
796
+ plan,
797
+ metrics,
798
+ diagnostics,
799
+ selectedId,
800
+ onSelect
801
+ }) {
802
+ const { t } = useTranslation();
803
+ const [expandedIds, setExpandedIds] = useState(
804
+ () => collectExpandedIds(plan.root)
805
+ );
806
+ useEffect(() => {
807
+ setExpandedIds(collectExpandedIds(plan.root));
808
+ }, [plan]);
809
+ const toggleExpand = useCallback((id) => {
810
+ setExpandedIds((prev) => {
811
+ const next = new Set(prev);
812
+ if (next.has(id)) next.delete(id);
813
+ else next.add(id);
814
+ return next;
815
+ });
816
+ }, []);
817
+ const selectedNode = findExplainNode(plan.root, selectedId);
818
+ return /* @__PURE__ */ jsxs("div", { className: "flex h-full", children: [
819
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto border-r border-default min-w-0", children: /* @__PURE__ */ jsxs("table", { className: "w-full text-xs", children: [
820
+ /* @__PURE__ */ jsx("thead", { className: "sticky top-0 z-10 bg-base border-b border-default", children: /* @__PURE__ */ jsxs("tr", { children: [
821
+ /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: "#" }),
822
+ /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.nodeType") }),
823
+ /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.relation") }),
824
+ /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.cost") }),
825
+ /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.estRows") }),
826
+ plan.has_analyze_data && /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.actualRows") }),
827
+ /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.selfTime") }),
828
+ /* @__PURE__ */ jsx("th", { className: "text-right px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.largestEstimateGap") }),
829
+ /* @__PURE__ */ jsx("th", { className: "text-left px-3 py-2 text-muted font-semibold whitespace-nowrap", children: t("editor.visualExplain.filter") })
830
+ ] }) }),
831
+ /* @__PURE__ */ jsx("tbody", { children: /* @__PURE__ */ jsx(
832
+ TreeRows,
833
+ {
834
+ node: plan.root,
835
+ depth: 0,
836
+ expandedIds,
837
+ selectedId,
838
+ onToggle: toggleExpand,
839
+ onSelect,
840
+ hasAnalyzeData: plan.has_analyze_data,
841
+ metrics,
842
+ diagnostics
843
+ }
844
+ ) })
845
+ ] }) }),
846
+ /* @__PURE__ */ jsx("div", { className: "w-[320px] shrink-0 overflow-y-auto bg-base/50", children: /* @__PURE__ */ jsx(
847
+ ExplainNodeDetails,
848
+ {
849
+ node: selectedNode,
850
+ hasAnalyzeData: plan.has_analyze_data,
851
+ metrics: selectedNode ? metrics.byId.get(selectedNode.id) ?? null : null,
852
+ diagnostics: selectedNode ? diagnostics.get(selectedNode.id) ?? [] : []
853
+ }
854
+ ) })
855
+ ] });
856
+ }
857
+ function TreeRows({
858
+ node,
859
+ depth,
860
+ expandedIds,
861
+ selectedId,
862
+ onToggle,
863
+ onSelect,
864
+ hasAnalyzeData,
865
+ metrics,
866
+ diagnostics
867
+ }) {
868
+ const isExpanded = expandedIds.has(node.id);
869
+ const hasChildren = node.children.length > 0;
870
+ const isSelected = selectedId === node.id;
871
+ const nodeMetrics = metrics.byId.get(node.id);
872
+ const nodeDiagnostics = diagnostics.get(node.id) ?? [];
873
+ const costStr = node.startup_cost != null && node.total_cost != null ? `${formatCost(node.startup_cost)} - ${formatCost(node.total_cost)}` : node.total_cost != null ? formatCost(node.total_cost) : "-";
874
+ const timeStr = hasAnalyzeData && nodeMetrics?.exclusiveTimeMs != null ? formatTime(nodeMetrics.exclusiveTimeMs) : "-";
875
+ const rowsStr = node.plan_rows != null ? formatRows(node.plan_rows) : "-";
876
+ const actualRowsStr = hasAnalyzeData && node.actual_rows != null ? formatRows(node.actual_rows) : "-";
877
+ const rowRatio = getRowEstimateRatio(node);
878
+ const ratioStr = rowRatio != null ? rowRatio >= 1 ? formatRatio(rowRatio) : formatRatio(1 / rowRatio) : "-";
879
+ return /* @__PURE__ */ jsxs(Fragment, { children: [
880
+ /* @__PURE__ */ jsxs(
881
+ "tr",
882
+ {
883
+ className: clsx(
884
+ "cursor-pointer transition-colors border-b border-default/30",
885
+ isSelected ? "bg-blue-900/30" : "hover:bg-surface-hover"
886
+ ),
887
+ onClick: () => onSelect(node.id),
888
+ children: [
889
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 font-mono text-[10px] text-muted whitespace-nowrap", children: nodeMetrics ? `#${nodeMetrics.index}` : "" }),
890
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 whitespace-nowrap", children: /* @__PURE__ */ jsxs(
891
+ "div",
892
+ {
893
+ className: "flex items-center gap-1",
894
+ style: { paddingLeft: `${depth * 20}px` },
895
+ children: [
896
+ hasChildren ? /* @__PURE__ */ jsx(
897
+ "button",
898
+ {
899
+ onClick: (e) => {
900
+ e.stopPropagation();
901
+ onToggle(node.id);
902
+ },
903
+ className: "p-0.5 text-muted hover:text-primary",
904
+ children: isExpanded ? /* @__PURE__ */ jsx(ChevronDown, { size: 12 }) : /* @__PURE__ */ jsx(ChevronRight, { size: 12 })
905
+ }
906
+ ) : /* @__PURE__ */ jsx("span", { className: "w-4" }),
907
+ /* @__PURE__ */ jsx("span", { className: "text-primary font-medium", children: node.node_type }),
908
+ nodeDiagnostics.length > 0 && /* @__PURE__ */ jsx(ExplainDiagnosticChips, { diagnostics: nodeDiagnostics, iconsOnly: true })
909
+ ]
910
+ }
911
+ ) }),
912
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-secondary whitespace-nowrap", children: node.relation ?? "" }),
913
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: costStr }),
914
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: rowsStr }),
915
+ hasAnalyzeData && /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: actualRowsStr }),
916
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right text-secondary font-mono whitespace-nowrap", children: timeStr }),
917
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right whitespace-nowrap", children: /* @__PURE__ */ jsx(
918
+ "span",
919
+ {
920
+ className: clsx(
921
+ "font-mono",
922
+ rowRatio == null ? "text-secondary" : rowRatio >= 4 || rowRatio <= 0.25 ? "text-amber-300" : "text-secondary"
923
+ ),
924
+ children: ratioStr
925
+ }
926
+ ) }),
927
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-muted truncate max-w-[200px]", children: node.filter ?? "" })
928
+ ]
929
+ }
930
+ ),
931
+ isExpanded && node.children.map((child) => /* @__PURE__ */ jsx(
932
+ TreeRows,
933
+ {
934
+ node: child,
935
+ depth: depth + 1,
936
+ expandedIds,
937
+ selectedId,
938
+ onToggle,
939
+ onSelect,
940
+ hasAnalyzeData,
941
+ metrics,
942
+ diagnostics
943
+ },
944
+ child.id
945
+ ))
946
+ ] });
947
+ }
948
+ function collectExpandedIds(root) {
949
+ const ids = /* @__PURE__ */ new Set();
950
+ function walk(node) {
951
+ ids.add(node.id);
952
+ for (const child of node.children) {
953
+ walk(child);
954
+ }
955
+ }
956
+ walk(root);
957
+ return ids;
958
+ }
959
+ var METRIC_LABEL_KEYS = {
960
+ time: "editor.visualExplain.selfTime",
961
+ rows: "editor.visualExplain.actualRows",
962
+ cost: "editor.visualExplain.selfCost",
963
+ buffers: "editor.visualExplain.buffers"
964
+ };
965
+ function formatMetricValue(value, kind) {
966
+ switch (kind) {
967
+ case "time":
968
+ return formatTime(value);
969
+ case "cost":
970
+ return formatCost(value);
971
+ case "rows":
972
+ case "buffers":
973
+ return formatRows(value);
974
+ }
975
+ }
976
+ function ExplainDiagramView({
977
+ plan,
978
+ metrics,
979
+ diagnostics,
980
+ selectedId,
981
+ onSelect
982
+ }) {
983
+ const { t } = useTranslation();
984
+ const availableKinds = useMemo(
985
+ () => getAvailableMetricKinds(metrics),
986
+ [metrics]
987
+ );
988
+ const defaultKind = useMemo(() => getDefaultMetricKind(metrics), [metrics]);
989
+ const [requestedKind, setRequestedKind] = useState(
990
+ null
991
+ );
992
+ const metricKind = requestedKind != null && availableKinds.includes(requestedKind) ? requestedKind : defaultKind;
993
+ const rows = useMemo(() => {
994
+ const nodesById = new Map(
995
+ flattenExplainNodes(plan.root).map((node) => [node.id, node])
996
+ );
997
+ return metrics.order.flatMap((nodeMetrics) => {
998
+ const node = nodesById.get(nodeMetrics.nodeId);
999
+ return node ? [{ node, metrics: nodeMetrics }] : [];
1000
+ });
1001
+ }, [plan, metrics]);
1002
+ const max = metricKind != null ? getMetricMax(metrics, metricKind) : 0;
1003
+ const selectedNode = findExplainNode(plan.root, selectedId);
1004
+ const handleSelect = useCallback(
1005
+ (id) => {
1006
+ onSelect(id);
1007
+ },
1008
+ [onSelect]
1009
+ );
1010
+ return /* @__PURE__ */ jsxs("div", { className: "flex h-full", children: [
1011
+ /* @__PURE__ */ jsxs("div", { className: "flex-1 min-w-0 flex flex-col border-r border-default", children: [
1012
+ availableKinds.length > 0 && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 border-b border-default bg-base/50 px-4 py-2", children: [
1013
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] uppercase tracking-wide text-muted font-semibold", children: t("editor.visualExplain.metric") }),
1014
+ /* @__PURE__ */ jsx("div", { className: "flex items-center gap-1 rounded-lg bg-surface-secondary p-0.5", children: availableKinds.map((kind) => /* @__PURE__ */ jsx(
1015
+ "button",
1016
+ {
1017
+ onClick: () => setRequestedKind(kind),
1018
+ className: clsx(
1019
+ "rounded px-2 py-1 text-xs transition-colors",
1020
+ metricKind === kind ? "bg-blue-900/40 text-blue-300" : "text-muted hover:text-primary"
1021
+ ),
1022
+ children: t(METRIC_LABEL_KEYS[kind])
1023
+ },
1024
+ kind
1025
+ )) })
1026
+ ] }),
1027
+ /* @__PURE__ */ jsx("div", { className: "flex-1 overflow-auto", children: rows.length === 0 || metricKind == null ? /* @__PURE__ */ jsx("div", { className: "p-4 text-xs text-muted", children: t("editor.visualExplain.noMetricData") }) : /* @__PURE__ */ jsx("table", { className: "w-full text-xs", children: /* @__PURE__ */ jsx("tbody", { children: rows.map(({ node, metrics: nodeMetrics }) => {
1028
+ const value = getMetricValue(nodeMetrics, metricKind);
1029
+ const ratio = value != null && max > 0 ? value / max : 0;
1030
+ const nodeDiagnostics = diagnostics.get(node.id) ?? [];
1031
+ const isSelected = selectedId === node.id;
1032
+ return /* @__PURE__ */ jsxs(
1033
+ "tr",
1034
+ {
1035
+ onClick: () => handleSelect(node.id),
1036
+ className: clsx(
1037
+ "cursor-pointer border-b border-default/30 transition-colors",
1038
+ isSelected ? "bg-blue-900/30" : "hover:bg-surface-hover"
1039
+ ),
1040
+ children: [
1041
+ /* @__PURE__ */ jsxs("td", { className: "w-10 px-3 py-1.5 align-top font-mono text-[10px] text-muted", children: [
1042
+ "#",
1043
+ nodeMetrics.index
1044
+ ] }),
1045
+ /* @__PURE__ */ jsx("td", { className: "px-1 py-1.5 align-top", children: /* @__PURE__ */ jsxs(
1046
+ "div",
1047
+ {
1048
+ style: { paddingLeft: `${nodeMetrics.depth * 12}px` },
1049
+ className: "min-w-0",
1050
+ children: [
1051
+ /* @__PURE__ */ jsx("div", { className: "truncate font-medium text-primary", children: node.node_type }),
1052
+ node.relation && /* @__PURE__ */ jsx("div", { className: "truncate text-[11px] text-muted", children: node.relation }),
1053
+ nodeDiagnostics.length > 0 && /* @__PURE__ */ jsx(
1054
+ ExplainDiagnosticChips,
1055
+ {
1056
+ diagnostics: nodeDiagnostics,
1057
+ iconsOnly: true,
1058
+ className: "mt-1"
1059
+ }
1060
+ )
1061
+ ]
1062
+ }
1063
+ ) }),
1064
+ /* @__PURE__ */ jsx("td", { className: "w-1/2 px-3 py-1.5 align-middle", children: /* @__PURE__ */ jsx("div", { className: "h-2.5 w-full overflow-hidden rounded bg-surface-secondary/60", children: /* @__PURE__ */ jsx(
1065
+ "div",
1066
+ {
1067
+ className: clsx(
1068
+ "h-full rounded transition-all",
1069
+ getHeatBarClass(value ?? 0, max)
1070
+ ),
1071
+ style: {
1072
+ width: `${Math.min(100, Math.max(ratio * 100, value != null && value > 0 ? 1 : 0))}%`
1073
+ }
1074
+ }
1075
+ ) }) }),
1076
+ /* @__PURE__ */ jsx("td", { className: "w-24 whitespace-nowrap px-3 py-1.5 text-right align-middle font-mono text-secondary", children: value != null ? formatMetricValue(value, metricKind) : "-" })
1077
+ ]
1078
+ },
1079
+ node.id
1080
+ );
1081
+ }) }) }) })
1082
+ ] }),
1083
+ /* @__PURE__ */ jsx("div", { className: "w-[320px] shrink-0 overflow-y-auto bg-base/50", children: /* @__PURE__ */ jsx(
1084
+ ExplainNodeDetails,
1085
+ {
1086
+ node: selectedNode,
1087
+ hasAnalyzeData: plan.has_analyze_data,
1088
+ metrics: selectedNode ? metrics.byId.get(selectedNode.id) ?? null : null,
1089
+ diagnostics: selectedNode ? diagnostics.get(selectedNode.id) ?? [] : []
1090
+ }
1091
+ ) })
1092
+ ] });
1093
+ }
1094
+
1095
+ // src/stats.ts
1096
+ function addTo(sum, value) {
1097
+ if (value != null) {
1098
+ sum.total += value;
1099
+ sum.hasData = true;
1100
+ }
1101
+ }
1102
+ function resolve(sum) {
1103
+ return sum.hasData ? sum.total : null;
1104
+ }
1105
+ function newSum() {
1106
+ return { total: 0, hasData: false };
1107
+ }
1108
+ function indexNameOf(node) {
1109
+ const value = node.extra["Index Name"];
1110
+ return typeof value === "string" && value.length > 0 ? value : null;
1111
+ }
1112
+ function getExplainPlanStats(plan, metrics) {
1113
+ const nodes = flattenExplainNodes(plan.root);
1114
+ const byNodeType = /* @__PURE__ */ new Map();
1115
+ const byRelation = /* @__PURE__ */ new Map();
1116
+ const byIndex = /* @__PURE__ */ new Map();
1117
+ let maxDepth = 0;
1118
+ let neverExecutedCount = 0;
1119
+ const totalTime = newSum();
1120
+ for (const node of nodes) {
1121
+ const nodeMetrics = metrics.byId.get(node.id);
1122
+ const exclusiveTimeMs = nodeMetrics?.exclusiveTimeMs ?? null;
1123
+ maxDepth = Math.max(maxDepth, (nodeMetrics?.depth ?? 0) + 1);
1124
+ if (nodeMetrics?.neverExecuted) {
1125
+ neverExecutedCount += 1;
1126
+ }
1127
+ addTo(totalTime, exclusiveTimeMs);
1128
+ const typeStat = byNodeType.get(node.node_type) ?? {
1129
+ count: 0,
1130
+ time: newSum()
1131
+ };
1132
+ typeStat.count += 1;
1133
+ addTo(typeStat.time, exclusiveTimeMs);
1134
+ byNodeType.set(node.node_type, typeStat);
1135
+ if (node.relation) {
1136
+ const relationStat = byRelation.get(node.relation) ?? {
1137
+ accessCount: 0,
1138
+ nodeTypes: [],
1139
+ time: newSum(),
1140
+ rows: newSum()
1141
+ };
1142
+ relationStat.accessCount += 1;
1143
+ if (!relationStat.nodeTypes.includes(node.node_type)) {
1144
+ relationStat.nodeTypes.push(node.node_type);
1145
+ }
1146
+ addTo(relationStat.time, exclusiveTimeMs);
1147
+ addTo(relationStat.rows, nodeMetrics?.totalRows ?? null);
1148
+ byRelation.set(node.relation, relationStat);
1149
+ }
1150
+ const indexName = indexNameOf(node);
1151
+ if (indexName) {
1152
+ const indexStat = byIndex.get(indexName) ?? {
1153
+ relation: node.relation,
1154
+ scanCount: 0,
1155
+ time: newSum()
1156
+ };
1157
+ indexStat.scanCount += 1;
1158
+ indexStat.relation = indexStat.relation ?? node.relation;
1159
+ addTo(indexStat.time, exclusiveTimeMs);
1160
+ byIndex.set(indexName, indexStat);
1161
+ }
1162
+ }
1163
+ const planTotalTime = resolve(totalTime);
1164
+ const nodeTypes2 = Array.from(byNodeType.entries()).map(([nodeType, stat]) => {
1165
+ const exclusiveTimeMs = resolve(stat.time);
1166
+ return {
1167
+ nodeType,
1168
+ count: stat.count,
1169
+ exclusiveTimeMs,
1170
+ timeShare: exclusiveTimeMs != null && planTotalTime != null && planTotalTime > 0 ? exclusiveTimeMs / planTotalTime : null
1171
+ };
1172
+ }).sort(compareByTimeThenCount);
1173
+ const relations = Array.from(byRelation.entries()).map(([relation, stat]) => ({
1174
+ relation,
1175
+ accessCount: stat.accessCount,
1176
+ nodeTypes: stat.nodeTypes,
1177
+ exclusiveTimeMs: resolve(stat.time),
1178
+ totalRows: resolve(stat.rows)
1179
+ })).sort(
1180
+ (a, b) => (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.accessCount - a.accessCount || a.relation.localeCompare(b.relation)
1181
+ );
1182
+ const indexes = Array.from(byIndex.entries()).map(([indexName, stat]) => ({
1183
+ indexName,
1184
+ relation: stat.relation,
1185
+ scanCount: stat.scanCount,
1186
+ exclusiveTimeMs: resolve(stat.time)
1187
+ })).sort(
1188
+ (a, b) => (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.scanCount - a.scanCount || a.indexName.localeCompare(b.indexName)
1189
+ );
1190
+ return {
1191
+ nodeCount: nodes.length,
1192
+ maxDepth,
1193
+ neverExecutedCount,
1194
+ totalExclusiveTimeMs: planTotalTime,
1195
+ nodeTypes: nodeTypes2,
1196
+ relations,
1197
+ indexes
1198
+ };
1199
+ }
1200
+ function compareByTimeThenCount(a, b) {
1201
+ return (b.exclusiveTimeMs ?? 0) - (a.exclusiveTimeMs ?? 0) || b.count - a.count || a.nodeType.localeCompare(b.nodeType);
1202
+ }
1203
+ function ExplainStatsView({ plan, metrics }) {
1204
+ const { t } = useTranslation();
1205
+ const stats = useMemo(
1206
+ () => getExplainPlanStats(plan, metrics),
1207
+ [plan, metrics]
1208
+ );
1209
+ return /* @__PURE__ */ jsxs("div", { className: "h-full overflow-y-auto px-4 py-3 text-xs", children: [
1210
+ /* @__PURE__ */ jsxs("div", { className: "mb-4 flex flex-wrap gap-2", children: [
1211
+ /* @__PURE__ */ jsx(
1212
+ StatTile,
1213
+ {
1214
+ label: t("editor.visualExplain.stats.nodeCount"),
1215
+ value: String(stats.nodeCount)
1216
+ }
1217
+ ),
1218
+ /* @__PURE__ */ jsx(
1219
+ StatTile,
1220
+ {
1221
+ label: t("editor.visualExplain.stats.maxDepth"),
1222
+ value: String(stats.maxDepth)
1223
+ }
1224
+ ),
1225
+ stats.totalExclusiveTimeMs != null && /* @__PURE__ */ jsx(
1226
+ StatTile,
1227
+ {
1228
+ label: t("editor.visualExplain.stats.totalSelfTime"),
1229
+ value: formatTime(stats.totalExclusiveTimeMs)
1230
+ }
1231
+ ),
1232
+ stats.neverExecutedCount > 0 && /* @__PURE__ */ jsx(
1233
+ StatTile,
1234
+ {
1235
+ label: t("editor.visualExplain.stats.neverExecutedNodes"),
1236
+ value: String(stats.neverExecutedCount)
1237
+ }
1238
+ )
1239
+ ] }),
1240
+ /* @__PURE__ */ jsx(StatsSection, { title: t("editor.visualExplain.stats.byNodeType"), children: /* @__PURE__ */ jsxs("table", { className: "w-full", children: [
1241
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx(
1242
+ StatsHeaderRow,
1243
+ {
1244
+ columns: [
1245
+ { key: "type", label: t("editor.visualExplain.nodeType") },
1246
+ { key: "count", label: t("editor.visualExplain.stats.count"), align: "right" },
1247
+ { key: "time", label: t("editor.visualExplain.selfTime"), align: "right" },
1248
+ { key: "share", label: t("editor.visualExplain.stats.share") }
1249
+ ]
1250
+ }
1251
+ ) }),
1252
+ /* @__PURE__ */ jsx("tbody", { children: stats.nodeTypes.map((entry) => /* @__PURE__ */ jsxs("tr", { className: "border-b border-default/25", children: [
1253
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-primary", children: entry.nodeType }),
1254
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right font-mono text-secondary", children: entry.count }),
1255
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right font-mono text-secondary", children: entry.exclusiveTimeMs != null ? formatTime(entry.exclusiveTimeMs) : "-" }),
1256
+ /* @__PURE__ */ jsx("td", { className: "w-1/3 px-3 py-1.5", children: /* @__PURE__ */ jsx(ShareBar, { share: entry.timeShare }) })
1257
+ ] }, entry.nodeType)) })
1258
+ ] }) }),
1259
+ stats.relations.length > 0 && /* @__PURE__ */ jsx(StatsSection, { title: t("editor.visualExplain.stats.byRelation"), children: /* @__PURE__ */ jsxs("table", { className: "w-full", children: [
1260
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx(
1261
+ StatsHeaderRow,
1262
+ {
1263
+ columns: [
1264
+ { key: "relation", label: t("editor.visualExplain.relation") },
1265
+ {
1266
+ key: "accesses",
1267
+ label: t("editor.visualExplain.stats.accesses"),
1268
+ align: "right"
1269
+ },
1270
+ { key: "ops", label: t("editor.visualExplain.stats.operations") },
1271
+ {
1272
+ key: "rows",
1273
+ label: t("editor.visualExplain.actualRows"),
1274
+ align: "right"
1275
+ },
1276
+ {
1277
+ key: "time",
1278
+ label: t("editor.visualExplain.selfTime"),
1279
+ align: "right"
1280
+ }
1281
+ ]
1282
+ }
1283
+ ) }),
1284
+ /* @__PURE__ */ jsx("tbody", { children: stats.relations.map((entry) => /* @__PURE__ */ jsxs("tr", { className: "border-b border-default/25", children: [
1285
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 font-mono text-primary", children: entry.relation }),
1286
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right font-mono text-secondary", children: entry.accessCount }),
1287
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-muted", children: entry.nodeTypes.join(", ") }),
1288
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right font-mono text-secondary", children: entry.totalRows != null ? formatRows(entry.totalRows) : "-" }),
1289
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right font-mono text-secondary", children: entry.exclusiveTimeMs != null ? formatTime(entry.exclusiveTimeMs) : "-" })
1290
+ ] }, entry.relation)) })
1291
+ ] }) }),
1292
+ stats.indexes.length > 0 && /* @__PURE__ */ jsx(StatsSection, { title: t("editor.visualExplain.stats.byIndex"), children: /* @__PURE__ */ jsxs("table", { className: "w-full", children: [
1293
+ /* @__PURE__ */ jsx("thead", { children: /* @__PURE__ */ jsx(
1294
+ StatsHeaderRow,
1295
+ {
1296
+ columns: [
1297
+ { key: "index", label: t("editor.visualExplain.stats.indexName") },
1298
+ { key: "relation", label: t("editor.visualExplain.relation") },
1299
+ {
1300
+ key: "scans",
1301
+ label: t("editor.visualExplain.stats.scans"),
1302
+ align: "right"
1303
+ },
1304
+ {
1305
+ key: "time",
1306
+ label: t("editor.visualExplain.selfTime"),
1307
+ align: "right"
1308
+ }
1309
+ ]
1310
+ }
1311
+ ) }),
1312
+ /* @__PURE__ */ jsx("tbody", { children: stats.indexes.map((entry) => /* @__PURE__ */ jsxs("tr", { className: "border-b border-default/25", children: [
1313
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 font-mono text-primary", children: entry.indexName }),
1314
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 font-mono text-secondary", children: entry.relation ?? "-" }),
1315
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right font-mono text-secondary", children: entry.scanCount }),
1316
+ /* @__PURE__ */ jsx("td", { className: "px-3 py-1.5 text-right font-mono text-secondary", children: entry.exclusiveTimeMs != null ? formatTime(entry.exclusiveTimeMs) : "-" })
1317
+ ] }, entry.indexName)) })
1318
+ ] }) })
1319
+ ] });
1320
+ }
1321
+ function StatTile({ label, value }) {
1322
+ return /* @__PURE__ */ jsxs("div", { className: "min-w-[130px] rounded-xl border border-default bg-surface-secondary/20 px-3 py-2", children: [
1323
+ /* @__PURE__ */ jsx("div", { className: "text-[10px] uppercase tracking-[0.14em] text-muted", children: label }),
1324
+ /* @__PURE__ */ jsx("div", { className: "mt-1 font-mono text-sm font-semibold text-primary", children: value })
1325
+ ] });
1326
+ }
1327
+ function StatsSection({ title, children }) {
1328
+ return /* @__PURE__ */ jsxs("div", { className: "mb-4 overflow-hidden rounded-xl border border-default", children: [
1329
+ /* @__PURE__ */ jsx("div", { className: "border-b border-default bg-base/60 px-3 py-2 text-[11px] font-semibold uppercase tracking-wide text-muted", children: title }),
1330
+ /* @__PURE__ */ jsx("div", { className: "overflow-x-auto", children })
1331
+ ] });
1332
+ }
1333
+ function StatsHeaderRow({ columns }) {
1334
+ return /* @__PURE__ */ jsx("tr", { className: "border-b border-default/60", children: columns.map((column) => /* @__PURE__ */ jsx(
1335
+ "th",
1336
+ {
1337
+ className: clsx(
1338
+ "whitespace-nowrap px-3 py-1.5 font-semibold text-muted",
1339
+ column.align === "right" ? "text-right" : "text-left"
1340
+ ),
1341
+ children: column.label
1342
+ },
1343
+ column.key
1344
+ )) });
1345
+ }
1346
+ function ShareBar({ share }) {
1347
+ if (share == null) {
1348
+ return /* @__PURE__ */ jsx("span", { className: "text-muted", children: "-" });
1349
+ }
1350
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
1351
+ /* @__PURE__ */ jsx("div", { className: "h-2 flex-1 overflow-hidden rounded bg-surface-secondary/60", children: /* @__PURE__ */ jsx(
1352
+ "div",
1353
+ {
1354
+ className: "h-full rounded bg-blue-500/70",
1355
+ style: { width: `${Math.min(100, share * 100)}%` }
1356
+ }
1357
+ ) }),
1358
+ /* @__PURE__ */ jsxs("span", { className: "w-10 shrink-0 text-right font-mono text-[11px] text-muted", children: [
1359
+ Math.round(share * 100),
1360
+ "%"
1361
+ ] })
1362
+ ] });
1363
+ }
1364
+ function ExplainOverviewBar({
1365
+ plan,
1366
+ metrics,
1367
+ onSelectNode
1368
+ }) {
1369
+ const { t } = useTranslation();
1370
+ const summary = useMemo(
1371
+ () => getExplainPlanSummary(plan, metrics),
1372
+ [plan, metrics]
1373
+ );
1374
+ const legend = useMemo(() => getExplainDriverLegend(plan), [plan]);
1375
+ const [overviewExpanded, setOverviewExpanded] = useState(true);
1376
+ const findings = [
1377
+ summary.highestCostNode && {
1378
+ key: "highest-cost",
1379
+ label: t("editor.visualExplain.highestSelfCost"),
1380
+ value: formatCost(summary.highestCostNode.value),
1381
+ description: formatNodeLabel(
1382
+ summary.highestCostNode.nodeType,
1383
+ summary.highestCostNode.relation
1384
+ ),
1385
+ nodeId: summary.highestCostNode.nodeId,
1386
+ icon: Layers2,
1387
+ tone: "blue"
1388
+ },
1389
+ summary.slowestNode && {
1390
+ key: "slowest-step",
1391
+ label: t("editor.visualExplain.slowestSelfStep"),
1392
+ value: formatTime(summary.slowestNode.value),
1393
+ description: formatNodeLabel(
1394
+ summary.slowestNode.nodeType,
1395
+ summary.slowestNode.relation
1396
+ ),
1397
+ nodeId: summary.slowestNode.nodeId,
1398
+ icon: Clock3,
1399
+ tone: "amber"
1400
+ },
1401
+ summary.largestRowMismatchNode?.ratio != null && {
1402
+ key: "estimate-gap",
1403
+ label: t("editor.visualExplain.largestEstimateGap"),
1404
+ value: formatRatio(summary.largestRowMismatchNode.value),
1405
+ description: t(
1406
+ summary.largestRowMismatchNode.ratio >= 1 ? "editor.visualExplain.overEstimate" : "editor.visualExplain.underEstimate"
1407
+ ),
1408
+ nodeId: summary.largestRowMismatchNode.nodeId,
1409
+ icon: AlertTriangle,
1410
+ tone: "red"
1411
+ },
1412
+ summary.sequentialScans > 0 && {
1413
+ key: "sequential-scans",
1414
+ label: t("editor.visualExplain.sequentialScans"),
1415
+ value: String(summary.sequentialScans),
1416
+ description: t("editor.visualExplain.scanOperations"),
1417
+ nodeId: summary.highestCostNode?.nodeId ?? plan.root.id,
1418
+ icon: ScanSearch,
1419
+ tone: "amber"
1420
+ },
1421
+ summary.tempOperations > 0 && {
1422
+ key: "temp-operations",
1423
+ label: t("editor.visualExplain.tempOperations"),
1424
+ value: String(summary.tempOperations),
1425
+ description: t("editor.visualExplain.sortOrTempOperations"),
1426
+ nodeId: summary.slowestNode?.nodeId ?? plan.root.id,
1427
+ icon: Database,
1428
+ tone: "purple"
1429
+ }
1430
+ ].filter(Boolean);
1431
+ return /* @__PURE__ */ jsx("div", { className: "border-b border-default bg-base/40 px-4 py-2", children: /* @__PURE__ */ jsxs("div", { className: "rounded-xl border border-default bg-surface-secondary/20 overflow-hidden", children: [
1432
+ /* @__PURE__ */ jsxs(
1433
+ "button",
1434
+ {
1435
+ onClick: () => setOverviewExpanded((prev) => !prev),
1436
+ className: "w-full flex items-center gap-3 px-3 py-2.5 hover:bg-surface-secondary/35 transition-colors",
1437
+ children: [
1438
+ /* @__PURE__ */ jsx("div", { className: "p-1.5 rounded-md bg-blue-900/25 text-blue-300", children: /* @__PURE__ */ jsx(LayoutDashboard, { size: 13 }) }),
1439
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0 text-left", children: [
1440
+ /* @__PURE__ */ jsx("div", { className: "text-[11px] uppercase tracking-[0.14em] text-muted font-semibold", children: t("editor.visualExplain.overview") }),
1441
+ /* @__PURE__ */ jsxs("div", { className: "text-xs text-secondary", children: [
1442
+ findings.length,
1443
+ " ",
1444
+ t("editor.visualExplain.topIssues").toLowerCase(),
1445
+ legend.length > 0 ? ` \u2022 ${legend.length} ${t("editor.visualExplain.driverNotes").toLowerCase()}` : ""
1446
+ ] })
1447
+ ] }),
1448
+ /* @__PURE__ */ jsxs("div", { className: "ml-auto flex items-center gap-2 text-[11px] text-muted", children: [
1449
+ /* @__PURE__ */ jsx("span", { children: overviewExpanded ? t("editor.visualExplain.hideOverview") : t("editor.visualExplain.showOverview") }),
1450
+ overviewExpanded ? /* @__PURE__ */ jsx(ChevronDown, { size: 14 }) : /* @__PURE__ */ jsx(ChevronRight, { size: 14 })
1451
+ ] })
1452
+ ]
1453
+ }
1454
+ ),
1455
+ overviewExpanded && /* @__PURE__ */ jsxs("div", { className: "px-3 pb-3 space-y-3", children: [
1456
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-2", children: findings.length === 0 ? /* @__PURE__ */ jsx("div", { className: "text-xs text-secondary", children: t("editor.visualExplain.noIssues") }) : findings.map((finding) => {
1457
+ if (!finding) {
1458
+ return null;
1459
+ }
1460
+ const Icon = finding.icon;
1461
+ return /* @__PURE__ */ jsxs(
1462
+ "button",
1463
+ {
1464
+ onClick: () => onSelectNode(finding.nodeId),
1465
+ className: clsx(
1466
+ "min-w-[170px] flex items-start gap-2 rounded-xl border px-3 py-2 text-left transition-colors",
1467
+ getToneClass(finding.tone)
1468
+ ),
1469
+ children: [
1470
+ /* @__PURE__ */ jsx(Icon, { size: 14, className: "mt-0.5 shrink-0" }),
1471
+ /* @__PURE__ */ jsxs("div", { className: "min-w-0", children: [
1472
+ /* @__PURE__ */ jsx("div", { className: "text-[10px] uppercase tracking-[0.14em] opacity-80", children: finding.label }),
1473
+ /* @__PURE__ */ jsx("div", { className: "text-sm font-semibold leading-tight mt-1", children: finding.value }),
1474
+ /* @__PURE__ */ jsx("div", { className: "text-[11px] opacity-80 mt-1", children: finding.description })
1475
+ ] })
1476
+ ]
1477
+ },
1478
+ finding.key
1479
+ );
1480
+ }) }),
1481
+ legend.length > 0 && /* @__PURE__ */ jsxs("div", { className: "rounded-xl border border-default bg-base/45 px-3 py-2.5", children: [
1482
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2 mb-2", children: [
1483
+ /* @__PURE__ */ jsx("div", { className: "p-1.5 rounded-md bg-cyan-900/30 text-cyan-300", children: /* @__PURE__ */ jsx(BookOpenText, { size: 13 }) }),
1484
+ /* @__PURE__ */ jsx("span", { className: "text-[11px] uppercase tracking-[0.14em] text-muted font-semibold", children: t("editor.visualExplain.driverNotes") })
1485
+ ] }),
1486
+ /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-x-4 gap-y-1.5", children: legend.map((entry) => /* @__PURE__ */ jsxs(
1487
+ "div",
1488
+ {
1489
+ className: "flex items-start gap-2 text-xs text-secondary leading-relaxed max-w-[720px]",
1490
+ children: [
1491
+ /* @__PURE__ */ jsx("span", { className: "mt-[6px] h-1.5 w-1.5 shrink-0 rounded-full bg-cyan-400/80" }),
1492
+ /* @__PURE__ */ jsx("span", { children: t(entry) })
1493
+ ]
1494
+ },
1495
+ entry
1496
+ )) })
1497
+ ] })
1498
+ ] })
1499
+ ] }) });
1500
+ }
1501
+ function formatNodeLabel(nodeType, relation) {
1502
+ return relation ? `${nodeType} \xB7 ${relation}` : nodeType;
1503
+ }
1504
+ function getToneClass(tone) {
1505
+ switch (tone) {
1506
+ case "blue":
1507
+ return "border-blue-500/30 bg-blue-950/20 text-blue-200 hover:bg-blue-950/30";
1508
+ case "amber":
1509
+ return "border-amber-500/30 bg-amber-950/20 text-amber-200 hover:bg-amber-950/30";
1510
+ case "red":
1511
+ return "border-red-500/30 bg-red-950/20 text-red-200 hover:bg-red-950/30";
1512
+ case "purple":
1513
+ return "border-fuchsia-500/30 bg-fuchsia-950/20 text-fuchsia-200 hover:bg-fuchsia-950/30";
1514
+ }
1515
+ }
1516
+ var ExplainSummaryBar = memo(
1517
+ ({ plan, viewMode, onViewModeChange, aiEnabled }) => {
1518
+ const { t } = useTranslation();
1519
+ if (!plan) return null;
1520
+ const maxCost = getMaxCost(plan.root);
1521
+ const toggleButtonClass = (mode) => `flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors ${viewMode === mode ? mode === "ai" ? "bg-purple-900/40 text-purple-300" : "bg-blue-900/40 text-blue-300" : "text-muted hover:text-primary"}`;
1522
+ return /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-4 px-4 py-2 border-b border-default bg-base/50 text-xs", children: [
1523
+ plan.planning_time_ms != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
1524
+ /* @__PURE__ */ jsxs("span", { className: "text-muted", children: [
1525
+ t("editor.visualExplain.planningTime"),
1526
+ ":"
1527
+ ] }),
1528
+ /* @__PURE__ */ jsx("span", { className: "text-primary font-mono font-semibold", children: formatTime(plan.planning_time_ms) })
1529
+ ] }),
1530
+ plan.execution_time_ms != null && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
1531
+ /* @__PURE__ */ jsxs("span", { className: "text-muted", children: [
1532
+ t("editor.visualExplain.executionTime"),
1533
+ ":"
1534
+ ] }),
1535
+ /* @__PURE__ */ jsx("span", { className: "text-primary font-mono font-semibold", children: formatTime(plan.execution_time_ms) })
1536
+ ] }),
1537
+ maxCost > 0 && /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1.5", children: [
1538
+ /* @__PURE__ */ jsxs("span", { className: "text-muted", children: [
1539
+ t("editor.visualExplain.totalCost"),
1540
+ ":"
1541
+ ] }),
1542
+ /* @__PURE__ */ jsx("span", { className: "text-primary font-mono font-semibold", children: formatCost(maxCost) })
1543
+ ] }),
1544
+ /* @__PURE__ */ jsx("div", { className: "flex-1" }),
1545
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-1 bg-surface-secondary rounded-lg p-0.5", children: [
1546
+ /* @__PURE__ */ jsxs(
1547
+ "button",
1548
+ {
1549
+ onClick: () => onViewModeChange("graph"),
1550
+ className: toggleButtonClass("graph"),
1551
+ children: [
1552
+ /* @__PURE__ */ jsx(Network, { size: 12 }),
1553
+ t("editor.visualExplain.graphView")
1554
+ ]
1555
+ }
1556
+ ),
1557
+ /* @__PURE__ */ jsxs(
1558
+ "button",
1559
+ {
1560
+ onClick: () => onViewModeChange("diagram"),
1561
+ className: toggleButtonClass("diagram"),
1562
+ children: [
1563
+ /* @__PURE__ */ jsx(BarChart3, { size: 12 }),
1564
+ t("editor.visualExplain.diagramView")
1565
+ ]
1566
+ }
1567
+ ),
1568
+ /* @__PURE__ */ jsxs(
1569
+ "button",
1570
+ {
1571
+ onClick: () => onViewModeChange("table"),
1572
+ className: toggleButtonClass("table"),
1573
+ children: [
1574
+ /* @__PURE__ */ jsx(TableProperties, { size: 12 }),
1575
+ t("editor.visualExplain.tableView")
1576
+ ]
1577
+ }
1578
+ ),
1579
+ /* @__PURE__ */ jsxs(
1580
+ "button",
1581
+ {
1582
+ onClick: () => onViewModeChange("stats"),
1583
+ className: toggleButtonClass("stats"),
1584
+ children: [
1585
+ /* @__PURE__ */ jsx(PieChart, { size: 12 }),
1586
+ t("editor.visualExplain.statsView")
1587
+ ]
1588
+ }
1589
+ ),
1590
+ /* @__PURE__ */ jsxs(
1591
+ "button",
1592
+ {
1593
+ onClick: () => onViewModeChange("raw"),
1594
+ className: toggleButtonClass("raw"),
1595
+ children: [
1596
+ /* @__PURE__ */ jsx(FileText, { size: 12 }),
1597
+ t("editor.visualExplain.rawOutput")
1598
+ ]
1599
+ }
1600
+ ),
1601
+ aiEnabled && /* @__PURE__ */ jsxs(
1602
+ "button",
1603
+ {
1604
+ onClick: () => onViewModeChange("ai"),
1605
+ className: toggleButtonClass("ai"),
1606
+ children: [
1607
+ /* @__PURE__ */ jsx(Sparkles, { size: 12 }),
1608
+ t("editor.visualExplain.aiAnalysis")
1609
+ ]
1610
+ }
1611
+ )
1612
+ ] })
1613
+ ] });
1614
+ }
1615
+ );
1616
+
1617
+ export { ExplainDiagnosticChips, ExplainDiagnosticList, ExplainDiagramView, ExplainGraph, ExplainNodeDetails, ExplainOverviewBar, ExplainPlanNodeComponent, ExplainStatsView, ExplainSummaryBar, ExplainTableView };