import { EmbeddedExtension, ExtensionSlot, } from "@agent-native/core/client/extensions"; import { useDemoModeStatus } from "@agent-native/core/client/hooks"; import { useT } from "@agent-native/core/client/i18n"; import { IconArrowsSort, IconSortAscending, IconSortDescending, IconChevronLeft, IconChevronRight, IconAlertTriangle, IconInfoCircle, IconRefresh, IconTrendingUp, IconTrendingDown, } from "@tabler/icons-react"; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { Link } from "react-router"; import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, Legend, Line, LineChart, Pie, PieChart, ResponsiveContainer, Tooltip, XAxis, YAxis, } from "recharts"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Popover, PopoverAnchor, PopoverContent, } from "@/components/ui/popover"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { useChartTooltipPortalPosition } from "@/hooks/use-chart-tooltip-portal"; const PAGE_SIZE_OPTIONS = [10, 25, 50, 100]; import { createDemoChartTrendRows } from "@/lib/demo-chart-trend"; import { useSqlQuery } from "@/lib/sql-query"; import { resolveDualAxis, type ChartAxisSide, type ChartValueFormatter, type DualAxisPlan, } from "@/pages/adhoc/sql-dashboard/dual-axis"; import { serializePanelSql } from "@/pages/adhoc/sql-dashboard/panel-sql"; import { pivotRows } from "@/pages/adhoc/sql-dashboard/pivot"; import type { SqlPanel, ChartType, TableColumnConfig, ColumnFormat, } from "@/pages/adhoc/sql-dashboard/types"; import { DashboardPanelSkeleton } from "./DashboardPanelSkeleton"; const DEFAULT_COLORS = [ "var(--brand-blue)", "var(--brand-teal)", "#06b6d4", "#10b981", "#f59e0b", "#ef4444", "#ec4899", "#14b8a6", ]; const CHART_TOOLTIP_WRAPPER_STYLE: CSSProperties = { zIndex: 280, pointerEvents: "none", }; const CHART_TOOLTIP_PROPS = { allowEscapeViewBox: { x: true, y: true }, wrapperStyle: CHART_TOOLTIP_WRAPPER_STYLE, } as const; const BAR_TOOLTIP_CURSOR_PROPS = { fill: "hsl(var(--muted))", fillOpacity: 0.32, stroke: "hsl(var(--border))", strokeOpacity: 0.5, strokeWidth: 1, rx: 4, ry: 4, } as const; const CHART_LEGEND_WRAPPER_STYLE: CSSProperties = { fontSize: 11, paddingTop: 8, }; const CHART_LEGEND_PROPS = { iconSize: 8, wrapperStyle: CHART_LEGEND_WRAPPER_STYLE, } as const; const CHART_RESIZE_DEBOUNCE_MS = 50; // Recharts' default series animation duration, plus room for the debounced // resize callback that follows a lazy-loaded panel's first layout pass. const CHART_ENTRY_ANIMATION_MS = 1500 + CHART_RESIZE_DEBOUNCE_MS * 2; const LEGEND_ACTION_CLOSE_DELAY_MS = 600; type ChartSize = { width: number; height: number; }; export function hasChartSizeChanged( previous: ChartSize | null, next: ChartSize, ): boolean { return ( previous !== null && (previous.width !== next.width || previous.height !== next.height) ); } export function shouldDisableChartAnimation( entryAnimationSettled: boolean, previous: ChartSize | null, next: ChartSize, ): boolean { return entryAnimationSettled && hasChartSizeChanged(previous, next); } function useChartResizeAnimation() { const [isAnimationActive, setIsAnimationActive] = useState(true); const firstSizeRef = useRef(null); // Switching Recharts to isAnimationActive=false mid-flight freezes the line's // stroke-dasharray at whatever partial length it reached, leaving the series // invisible forever. Lazy-loaded panels reflow right after mounting, so the // entry animation has to be allowed to finish before a resize can disable it. const entryAnimationSettledRef = useRef(false); useEffect(() => { const timer = setTimeout(() => { entryAnimationSettledRef.current = true; }, CHART_ENTRY_ANIMATION_MS); return () => clearTimeout(timer); }, []); const handleResize = useCallback((width: number, height: number) => { const nextSize = { width, height }; if ( shouldDisableChartAnimation( entryAnimationSettledRef.current, firstSizeRef.current, nextSize, ) ) { setIsAnimationActive(false); } firstSizeRef.current = nextSize; }, []); return { isAnimationActive, handleResize }; } function ChartResponsiveContainer({ children, }: { children: (isAnimationActive: boolean) => ReactNode; }) { const { isAnimationActive, handleResize } = useChartResizeAnimation(); return ( {children(isAnimationActive)} ); } const PARTIAL_DAY_TIME_ZONE = "America/Los_Angeles"; const PARTIAL_DAY_DASH = "3 5"; const PARTIAL_DAY_KEY_PREFIX = "__sql_chart_partial_day"; const TABLE_PANEL_MIN_HEIGHT_CLASS = "min-h-[386px]"; const TABLE_PANEL_SKELETON_ROWS = 10; export function formatSqlChartError(error: unknown): string { const message = error instanceof Error ? error.message : typeof error === "string" ? error : String(error ?? ""); const readableMessage = message .replace(/<[^>]*>/g, " ") .replace(/\s+/g, " ") .trim(); if (/inactivity timeout|too much time has passed/i.test(readableMessage)) { return "This chart took too long to load. Try again."; } if (/internal server error/i.test(readableMessage)) { return "This chart could not be loaded. Try again."; } return readableMessage || "This chart could not be loaded. Try again."; } function formatYValue( value: number, formatter?: "number" | "currency" | "percent", ): string { if (formatter === "currency") return `$${value.toLocaleString()}`; if (formatter === "percent") { // SQL typically returns rate as 0..1 const pct = value <= 1 && value >= -1 ? value * 100 : value; return `${pct.toFixed(2)}%`; } return value.toLocaleString(); } const Y_AXIS_BASE_PROPS = { stroke: "hsl(var(--muted-foreground))", fontSize: 12, tickLine: false, axisLine: false, } as const; function axisLabelProps(value: string, side: ChartAxisSide) { return { value, angle: side === "left" ? -90 : 90, position: side === "left" ? ("insideLeft" as const) : ("insideRight" as const), style: { textAnchor: "middle" as const, fill: "hsl(var(--muted-foreground))", fontSize: 11, }, }; } /** * Recharts only discovers axes it finds among a chart's own children, so these * come back as an array rather than a wrapper component. */ function renderChartYAxes( plan: DualAxisPlan, yFormatter?: ChartValueFormatter, ): ReactNode[] { if (!plan.enabled) { return [ formatYValue(v, yFormatter)} />, ]; } return [ formatYValue(v, plan.leftFormatter)} label={ plan.leftLabel ? axisLabelProps(plan.leftLabel, "left") : undefined } />, formatYValue(v, plan.rightFormatter)} label={ plan.rightLabel ? axisLabelProps(plan.rightLabel, "right") : undefined } />, ]; } function seriesAxisId(plan: DualAxisPlan, key: string): string | undefined { return plan.enabled ? plan.sideFor(key) : undefined; } /** * Tooltip values follow their own axis, so a count and a rate in the same * tooltip each read with the right unit. Series arrive named by their display * label, which for Prometheus panels differs from the data key. */ export function seriesValueFormatter( yKeys: string[], plan: DualAxisPlan, seriesNameFormatter: (name: string) => string, fallback?: ChartValueFormatter, ): (value: number, name?: string | number) => string { if (!plan.enabled) return (value) => formatYValue(value, fallback); const byName = new Map(); for (const key of yKeys) { const formatter = plan.formatterFor(key); byName.set(key, formatter); byName.set(seriesNameFormatter(key), formatter); } return (value, name) => { const lookup = name == null ? undefined : String(name); return formatYValue( value, lookup !== undefined && byName.has(lookup) ? byName.get(lookup) : fallback, ); }; } /** * Format a single metric value for display. Coerces Postgres numeric/bigint * columns (returned as strings, e.g. a rate of "0.00000000000000000000") to a * number so the formatter applies — SQLite returns JS numbers, so this only * bites on Postgres/Neon, where the raw high-scale decimal would otherwise be * dumped verbatim. A configured `valueLabels` mapping wins; a non-numeric * string falls through unformatted. */ export function formatMetricValue( raw: unknown, formatter?: "number" | "currency" | "percent", valueLabels?: Record, ): string { const valueLabel = valueLabels?.[String(raw)]; if (valueLabel !== undefined) return valueLabel; const numericRaw = typeof raw === "number" ? raw : typeof raw === "string" && raw.trim() !== "" && Number.isFinite(Number(raw)) ? Number(raw) : null; return numericRaw !== null ? formatYValue(numericRaw, formatter) : String(raw ?? "-"); } function isNumericLikeValue(value: unknown): boolean { if (typeof value === "number") return Number.isFinite(value); return ( typeof value === "string" && value.trim() !== "" && Number.isFinite(Number(value)) ); } export function detectMetricValueColumn( row: Record, configuredKey?: string, ): string { const cols = Object.keys(row); if (configuredKey && cols.includes(configuredKey)) return configuredKey; return cols.find((key) => isNumericLikeValue(row[key])) || cols[0] || ""; } function parsePrometheusSeriesLabel(label: string): { metric: string; labels: Record; } { const trimmed = label.trim(); const match = /^(.*?)\{(.*)\}$/.exec(trimmed); if (!match) return { metric: trimmed, labels: {} }; const labels: Record = {}; const body = match[2]; const re = /([A-Za-z_][A-Za-z0-9_]*)="((?:\\.|[^"\\])*)"/g; let m: RegExpExecArray | null; while ((m = re.exec(body))) { labels[m[1]] = m[2].replace(/\\"/g, '"').replace(/\\\\/g, "\\"); } return { metric: match[1], labels }; } function compactGrafanaTarget(value: string): string { const withoutDevicePrefix = value.replace(/^device\s+-\s+/i, ""); const beforeExplanation = withoutDevicePrefix.split(/\s+[–-]\s+/)[0]; return beforeExplanation.trim() || value; } function truncateLabel(value: string, max = 48): string { if (value.length <= max) return value; return `${value.slice(0, max - 3)}...`; } function parseCalendarDate(value: string): Date | null { const normalized = /^\d{8}$/.test(value) ? `${value.slice(0, 4)}-${value.slice(4, 6)}-${value.slice(6, 8)}` : value; const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(normalized); if (dateOnly) { return new Date( Number(dateOnly[1]), Number(dateOnly[2]) - 1, Number(dateOnly[3]), ); } const parsed = new Date(normalized); return Number.isNaN(parsed.getTime()) ? null : parsed; } export function toSqlChartDateKey(value: unknown): string | null { if (typeof value === "string") { const trimmed = value.trim(); const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed); if (ymd) return `${ymd[1]}-${ymd[2]}-${ymd[3]}`; const compact = /^(\d{4})(\d{2})(\d{2})$/.exec(trimmed); if (compact) return `${compact[1]}-${compact[2]}-${compact[3]}`; } const parsed = new Date(String(value ?? "")); return Number.isNaN(parsed.getTime()) ? null : sqlChartLocalDateKey(parsed); } export function sqlChartLocalDateKey( date = new Date(), timeZone = PARTIAL_DAY_TIME_ZONE, ): string { try { const parts = new Intl.DateTimeFormat("en-US", { timeZone, year: "numeric", month: "2-digit", day: "2-digit", }).formatToParts(date); const get = (type: string) => parts.find((part) => part.type === type)?.value ?? ""; const year = get("year"); const month = get("month"); const day = get("day"); if (year && month && day) return `${year}-${month}-${day}`; } catch {} return date.toISOString().slice(0, 10); } export interface SplitTimeSeries { key: string; solidKey: string; partialKey: string | null; } export function splitCurrentDayTimeSeriesRows( rows: Record[], xKey: string, yKeys: string[], todayKey = sqlChartLocalDateKey(), ): { rows: Record[]; series: SplitTimeSeries[] } { const todayIndexes = rows .map((row, index) => ({ index, dateKey: toSqlChartDateKey(row[xKey]) })) .filter((entry) => entry.dateKey === todayKey) .map((entry) => entry.index); const firstTodayIndex = todayIndexes[0] ?? -1; if (firstTodayIndex <= 0 || yKeys.length === 0) { return { rows, series: yKeys.map((key) => ({ key, solidKey: key, partialKey: null })), }; } const todaySet = new Set(todayIndexes); const previousIndex = firstTodayIndex - 1; const series = yKeys.map((key, index) => ({ key, solidKey: `${PARTIAL_DAY_KEY_PREFIX}_${index}_solid`, partialKey: `${PARTIAL_DAY_KEY_PREFIX}_${index}_partial`, })); const splitRows = rows.map((row, index) => { const next = { ...row }; const isToday = todaySet.has(index); const isPartialSegmentStart = index === previousIndex; for (const item of series) { next[item.solidKey] = isToday ? null : row[item.key]; next[item.partialKey] = isToday || isPartialSegmentStart ? row[item.key] : null; } return next; }); return { rows: splitRows, series }; } export function shouldSplitCurrentDayTimeSeries( panel: Pick, xKey: string, ): boolean { if (panel.source === "prometheus") return false; const normalizedKey = xKey.trim().toLowerCase(); if (normalizedKey === "timestamp" || normalizedKey.endsWith("_timestamp")) { return false; } return ( normalizedKey === "date" || normalizedKey === "day" || normalizedKey.endsWith("_date") || normalizedKey.endsWith("_day") ); } function formatSeriesLabel(value: string): string { const { metric, labels } = parsePrometheusSeriesLabel(value); const target = typeof labels.grafana_target === "string" ? compactGrafanaTarget(labels.grafana_target) : ""; if (target) { if (labels.device) return truncateLabel(`${labels.device} ${target}`); if (labels.mountpoint) return truncateLabel(`${labels.mountpoint} ${target}`); if (labels.cpu) return truncateLabel(`cpu ${labels.cpu} ${target}`); if (labels.state) return truncateLabel(`${labels.state} ${target}`); if (labels.chip_name) return truncateLabel(`${labels.chip_name} ${target}`); if (labels.sensor) return truncateLabel(`${labels.sensor} ${target}`); return truncateLabel(target); } const preferred = [ "device", "mountpoint", "fstype", "cpu", "mode", "state", "collector", "name", "route", "status", "phase", "dependency", "type", "quantile", "le", ]; const parts = preferred .filter((key) => labels[key]) .map((key) => `${key}=${labels[key]}`); if (parts.length) { const prefix = metric ? `${metric} ` : ""; return truncateLabel(`${prefix}${parts.slice(0, 2).join(" ")}`); } return truncateLabel(metric || value); } function csvEscape(value: string): string { if (/[",\n\r]/.test(value)) { return `"${value.replace(/"/g, '""')}"`; } return value; } function usesPrometheusPresentation(panel: SqlPanel): boolean { return panel.source === "prometheus" || panel.source === "demo"; } function formatSeriesLabelForPanel(panel: SqlPanel, value: string): string { return usesPrometheusPresentation(panel) ? formatSeriesLabel(value) : value; } function formatXLabel(value: string, panel: SqlPanel): string { try { const s = String(value); const d = parseCalendarDate(s); if (d && s.length >= 8) { return d.toLocaleDateString("en-US", { month: "short", day: "numeric" }); } } catch {} return formatSeriesLabelForPanel(panel, String(value)); } function shouldShowLegend(panel: SqlPanel, seriesCount: number): boolean { return panel.config?.legend !== false && seriesCount > 0; } function numericTooltipValue(value: unknown): number { const numeric = typeof value === "number" ? value : Number(value); return Number.isFinite(numeric) ? numeric : Number.NEGATIVE_INFINITY; } function tooltipItemName(item: { dataKey?: string | number; name?: string | number; }): string { return String(item.name ?? item.dataKey ?? ""); } export function sortTooltipPayloadItems< T extends { dataKey?: string | number; name?: string | number; value?: unknown; }, >(items: T[]): T[] { const sorted = items .map((item, index) => ({ item, index })) .sort((a, b) => { const diff = numericTooltipValue(b.item.value) - numericTooltipValue(a.item.value); return diff !== 0 ? diff : a.index - b.index; }) .map(({ item }) => item); const seen = new Set(); return sorted.filter((item) => { const name = tooltipItemName(item); if (seen.has(name)) return false; seen.add(name); return true; }); } export function getHiddenSeriesKeysAfterFilter( keys: string[], filteredKey: string, ): Set { return new Set(keys.filter((key) => key !== filteredKey)); } function useSeriesVisibility(keys: string[]): { hiddenKeys: Set; visibleKeys: string[]; toggleSeries: (key: string) => void; filterSeries: (key: string) => void; } { const [hiddenKeys, setHiddenKeys] = useState>(() => new Set()); useEffect(() => { setHiddenKeys((prev) => { const next = new Set( Array.from(prev).filter((key) => keys.includes(key)), ); return next.size === prev.size ? prev : next; }); }, [keys]); const visibleKeys = useMemo( () => keys.filter((key) => !hiddenKeys.has(key)), [hiddenKeys, keys], ); const toggleSeries = useCallback( (key: string) => { setHiddenKeys((prev) => { const next = new Set(prev); if (next.has(key)) { next.delete(key); return next; } const visibleCount = keys.filter((k) => !next.has(k)).length; if (visibleCount <= 1) return prev; next.add(key); return next; }); }, [keys], ); const filterSeries = useCallback( (key: string) => { setHiddenKeys(getHiddenSeriesKeysAfterFilter(keys, key)); }, [keys], ); return { hiddenKeys, visibleKeys, toggleSeries, filterSeries }; } export function SeriesLegend({ keys, colors, panel, hiddenKeys, onToggleKey, onFilterKey, }: { keys: string[]; colors: string[]; panel: SqlPanel; hiddenKeys?: Set; onToggleKey?: (key: string) => void; onFilterKey?: (key: string) => void; }) { const t = useT(); const hasLegendActions = Boolean(onToggleKey || onFilterKey); const [openKey, setOpenKey] = useState(null); const closeTimeoutRef = useRef | null>(null); const skipNextTouchToggleRef = useRef(false); const clearCloseTimeout = useCallback(() => { if (closeTimeoutRef.current) { clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = null; } }, []); const openLegendActions = useCallback( (key: string) => { clearCloseTimeout(); setOpenKey(key); }, [clearCloseTimeout], ); const scheduleCloseLegendActions = useCallback(() => { clearCloseTimeout(); closeTimeoutRef.current = setTimeout(() => { setOpenKey(null); closeTimeoutRef.current = null; }, LEGEND_ACTION_CLOSE_DELAY_MS); }, [clearCloseTimeout]); useEffect(() => () => clearCloseTimeout(), [clearCloseTimeout]); if (!shouldShowLegend(panel, keys.length)) return null; return (
{keys.map((key, i) => { const hidden = hiddenKeys?.has(key) ?? false; const label = formatSeriesLabelForPanel(panel, key); const color = colors[i % colors.length]; return ( setOpenKey(open ? key : null)} >
{ if (!hasLegendActions || event.pointerType === "mouse") { return; } skipNextTouchToggleRef.current = true; openLegendActions(key); }} onPointerCancel={() => { skipNextTouchToggleRef.current = false; }} onPointerEnter={(event) => { if (hasLegendActions && event.pointerType !== "touch") { openLegendActions(key); } }} onPointerLeave={(event) => { if (hasLegendActions && event.pointerType !== "touch") { scheduleCloseLegendActions(); } }} onFocusCapture={ hasLegendActions ? () => openLegendActions(key) : undefined } onBlurCapture={ hasLegendActions ? scheduleCloseLegendActions : undefined } >
{hasLegendActions && (
{onFilterKey && ( )} {onToggleKey && ( )}
)}
); })}
); } // When a chart renders inside the full-screen modal it should grow to fill the // available space rather than the fixed 250px card height. ChartFrame reads // this via context so we avoid threading a prop through every renderer // (line/area/bar/pie all share ChartFrame). const ChartFillHeightContext = createContext(false); export function ChartFillHeight({ children }: { children: ReactNode }) { return ( {children} ); } function ChartFrame({ panel, legendKeys, colors, hiddenKeys, onToggleLegendKey, onFilterLegendKey, showCustomLegend = false, children, }: { panel: SqlPanel; legendKeys: string[]; colors: string[]; hiddenKeys?: Set; onToggleLegendKey?: (key: string) => void; onFilterLegendKey?: (key: string) => void; showCustomLegend?: boolean; children: ReactNode; }) { const fill = useContext(ChartFillHeightContext); const chartHeight = fill ? "h-full min-h-[250px]" : "h-[250px]"; const renderLegend = showCustomLegend || usesPrometheusPresentation(panel); if (!renderLegend) { return (
{children}
); } return (
{children}
); } function isTableLikeChartType(type: ChartType): boolean { return type === "table" || type === "heatmap"; } function chartTypeReservesLegend(panel: SqlPanel): boolean { if (panel.config?.legend === false) return false; const chartUsesFrame = panel.chartType === "line" || panel.chartType === "area" || panel.chartType === "bar" || panel.chartType === "pie"; if (!chartUsesFrame) return false; return ( panel.chartType === "line" || panel.chartType === "area" || panel.chartType === "bar" || usesPrometheusPresentation(panel) ); } function TableLoadingSkeleton() { const columnWidths = ["w-24", "w-32", "w-20", "w-28"]; return (
{columnWidths.map((width, index) => ( ))}
{Array.from({ length: TABLE_PANEL_SKELETON_ROWS }).map((_, row) => (
{columnWidths.map((width, col) => ( ))}
))}
); } function SqlChartLoadingSkeleton({ panel }: { panel: SqlPanel }) { const fill = useContext(ChartFillHeightContext); if (panel.chartType === "metric") { return ( ); } if (isTableLikeChartType(panel.chartType)) { return ; } const reserveLegend = chartTypeReservesLegend(panel); if (!reserveLegend) { return ( ); } return (
{Array.from({ length: 3 }).map((_, index) => (
))}
); } export function ChartTooltip({ active, payload, label, coordinate, labelFormatter, seriesNameFormatter, valueFormatter, }: { active?: boolean; payload?: Array<{ color?: string; dataKey?: string | number; name?: string | number; value?: unknown; }>; label?: unknown; coordinate?: { x?: number; y?: number }; labelFormatter?: (value: string) => string; seriesNameFormatter?: (value: string) => string; valueFormatter?: (value: number, name?: string | number) => string; }) { const items = useMemo( () => sortTooltipPayloadItems( payload?.filter((item) => item.value != null && item.value !== "") ?? [], ), [payload], ); const isVisible = Boolean(active) && items.length > 0; const { anchorRef, boxRef } = useChartTooltipPortalPosition( isVisible, coordinate, ); const labelText = label == null ? "" : labelFormatter ? labelFormatter(String(label)) : String(label); if (!isVisible) return null; const tooltip = (
{labelText && (
{labelText}
)}
{items.map((item) => { const raw = item.value; const numeric = typeof raw === "number" ? raw : Number(raw); const name = String(item.name ?? item.dataKey ?? ""); const value = Number.isFinite(numeric) && valueFormatter ? valueFormatter(numeric, name) : String(raw ?? ""); return (
{seriesNameFormatter ? seriesNameFormatter(name) : name} {value}
); })}
); return ( <>