diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx deleted file mode 100644 index 286a51e133..0000000000 --- a/packages/app/src/DBDashboardPage.tsx +++ /dev/null @@ -1,2464 +0,0 @@ -import { - ForwardedRef, - forwardRef, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; -import dynamic from 'next/dynamic'; -import Head from 'next/head'; -import Link from 'next/link'; -import { useRouter } from 'next/router'; -import { formatDistanceToNow, formatRelative } from 'date-fns'; -import produce from 'immer'; -import { pick } from 'lodash'; -import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; -import { ErrorBoundary } from 'react-error-boundary'; -import RGL, { WidthProvider } from 'react-grid-layout'; -import { useForm, useWatch } from 'react-hook-form'; -import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; -import { - convertToDashboardTemplate, - displayTypeSupportsBuilderAlerts, - displayTypeSupportsRawSqlAlerts, -} from '@hyperdx/common-utils/dist/core/utils'; -import { - displayTypeRequiresSource, - isBuilderChartConfig, - isBuilderSavedChartConfig, - isRawSqlChartConfig, - isRawSqlSavedChartConfig, -} from '@hyperdx/common-utils/dist/guards'; -import { - AlertState, - BuilderChartConfigWithDateRange, - ChartConfigWithDateRange, - DashboardContainer as DashboardContainerSchema, - DashboardFilter, - DisplayType, - Filter, - getSampleWeightExpression, - isLogSource, - isTraceSource, - SearchCondition, - SearchConditionLanguage, - SourceKind, - SQLInterval, - TSource, -} from '@hyperdx/common-utils/dist/types'; -import { - ActionIcon, - Anchor, - Box, - Breadcrumbs, - Button, - Flex, - Group, - Indicator, - Menu, - Modal, - Paper, - Popover, - Portal, - Stack, - Text, - Tooltip, -} from '@mantine/core'; -import { useHotkeys } from '@mantine/hooks'; -import { notifications } from '@mantine/notifications'; -import { - IconArrowsMaximize, - IconBell, - IconChartBar, - IconCopy, - IconCornerDownRight, - IconDeviceFloppy, - IconDotsVertical, - IconDownload, - IconFilterEdit, - IconPencil, - IconPlayerPlay, - IconPlus, - IconRefresh, - IconSearch, - IconSquaresDiagonal, - IconTags, - IconTrash, - IconUpload, - IconX, - IconZoomExclamation, -} from '@tabler/icons-react'; - -import { ContactSupportText } from '@/components/ContactSupportText'; -import DashboardContainer from '@/components/DashboardContainer'; -import { - EmptyContainerPlaceholder, - SortableContainerWrapper, -} from '@/components/DashboardDndComponents'; -import { - DashboardDndProvider, - type DragHandleProps, -} from '@/components/DashboardDndContext'; -import EditTimeChartForm from '@/components/DBEditTimeChartForm'; -import DBNumberChart from '@/components/DBNumberChart'; -import DBTableChart from '@/components/DBTableChart'; -import { DBTimeChart } from '@/components/DBTimeChart'; -import { FavoriteButton } from '@/components/FavoriteButton'; -import FullscreenPanelModal from '@/components/FullscreenPanelModal'; -import { TimePicker } from '@/components/TimePicker'; -import { - Dashboard, - type Tile, - useCreateDashboard, - useDeleteDashboard, -} from '@/dashboard'; -import useDashboardContainers, { - TabDeleteAction, -} from '@/hooks/useDashboardContainers'; -import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; - -import ChartContainer from './components/charts/ChartContainer'; -import DBHeatmapChart, { - toHeatmapChartConfig, -} from './components/DBHeatmapChart'; -import { DBPieChart } from './components/DBPieChart'; -import DBSqlRowTableWithSideBar from './components/DBSqlRowTableWithSidebar'; -import OnboardingModal from './components/OnboardingModal'; -import SearchWhereInput, { - getStoredLanguage, -} from './components/SearchInput/SearchWhereInput'; -import { Tags } from './components/Tags'; -import useDashboardFilters from './hooks/useDashboardFilters'; -import { useDashboardRefresh } from './hooks/useDashboardRefresh'; -import useTileSelection from './hooks/useTileSelection'; -import { useBrandDisplayName } from './theme/ThemeProvider'; -import { parseAsJsonEncoded, parseAsStringEncoded } from './utils/queryParsers'; -import { - buildEventsSearchUrl, - buildTableRowSearchUrl, - DEFAULT_CHART_CONFIG, -} from './ChartUtils'; -import { useConnections } from './connection'; -import { useDashboard } from './dashboard'; -import DashboardFilters from './DashboardFilters'; -import DashboardFiltersModal from './DashboardFiltersModal'; -import { EditablePageName } from './EditablePageName'; -import { GranularityPickerControlled } from './GranularityPicker'; -import HDXMarkdownChart from './HDXMarkdownChart'; -import { withAppNav } from './layout'; -import { - getFirstTimestampValueExpression, - useSource, - useSources, -} from './source'; -import { parseTimeQuery, useNewTimeQuery } from './timeQuery'; -import { useConfirm } from './useConfirm'; -import { FormatTime } from './useFormatTime'; -import { getMetricTableName } from './utils'; -import { useZIndex, ZIndexContext } from './zIndex'; - -import 'react-grid-layout/css/styles.css'; -import 'react-resizable/css/styles.css'; - -function HeatmapTile({ - keyPrefix, - chartId, - title, - toolbar, - queriedConfig, - source, - dateRange, -}: { - keyPrefix: string; - chartId: string; - title: React.ReactNode; - toolbar: React.ReactNode[]; - queriedConfig: BuilderChartConfigWithDateRange; - source: TSource | undefined; - dateRange: [Date, Date]; -}) { - const { heatmapConfig, scaleType } = toHeatmapChartConfig(queriedConfig); - - const [clickPos, setClickPos] = useState<{ x: number; y: number } | null>( - null, - ); - const containerRef = useRef(null); - - const eventDeltasUrl = useMemo(() => { - if (!source) return null; - const url = buildEventsSearchUrl({ - source, - config: queriedConfig, - dateRange, - }); - if (!url) return null; - const separator = url.includes('?') ? '&' : '?'; - return `${url}${separator}mode=delta`; - }, [source, queriedConfig, dateRange]); - - const handleClick = useCallback( - (e: React.MouseEvent) => { - if (!eventDeltasUrl) return; - const rect = containerRef.current?.getBoundingClientRect(); - if (!rect) return; - setClickPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); - }, - [eventDeltasUrl], - ); - - const dismiss = useCallback(() => setClickPos(null), []); - - return ( -
- - {clickPos != null && eventDeltasUrl != null && ( - <> - -
{ - e.stopPropagation(); - e.preventDefault(); - dismiss(); - }} - onMouseDown={e => e.stopPropagation()} - /> - - { - if (!opened) dismiss(); - }} - position="bottom-start" - offset={4} - withinPortal - closeOnEscape - withArrow - shadow="md" - > - -
- - e.stopPropagation()} - onMouseDown={e => e.stopPropagation()} - > - - - - View in Event Deltas - - - - - - )} -
- ); -} - -const ReactGridLayout = WidthProvider(RGL); - -type MoveTarget = { - containerId: string; - tabId?: string; - label: string; - // For tabs: all tabs in order with the target tab ID - allTabs?: { id: string; title: string }[]; -}; - -const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ - i: chart.id, - x: chart.x, - y: chart.y, - w: chart.w, - h: chart.h, - minH: 1, - minW: 1, -}); - -// TODO: This is a hack to set the default time range -const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date]; - -const whereLanguageParser = parseAsString.withDefault( - typeof window !== 'undefined' ? (getStoredLanguage() ?? 'lucene') : 'lucene', -); - -const Tile = forwardRef( - ( - { - chart, - dateRange, - onDuplicateClick, - onEditClick, - onDeleteClick, - onUpdateChart, - onMoveToGroup, - moveTargets, - granularity, - onTimeRangeSelect, - filters, - - // Properties forwarded by grid layout - className, - style, - onMouseDown, - onMouseUp, - onTouchEnd, - children, - isHighlighted, - isSelected, - onSelect, - }: { - chart: Tile; - dateRange: [Date, Date]; - onDuplicateClick: () => void; - onEditClick: () => void; - onAddAlertClick?: () => void; - onDeleteClick: () => void; - onUpdateChart?: (chart: Tile) => void; - onMoveToGroup?: (containerId: string | undefined, tabId?: string) => void; - moveTargets?: MoveTarget[]; - onSettled?: () => void; - granularity: SQLInterval | undefined; - onTimeRangeSelect: (start: Date, end: Date) => void; - filters?: Filter[]; - - // Properties forwarded by grid layout - className?: string; - style?: React.CSSProperties; - onMouseDown?: (e: React.MouseEvent) => void; - onMouseUp?: (e: React.MouseEvent) => void; - onTouchEnd?: (e: React.TouchEvent) => void; - children?: React.ReactNode; // Resizer tooltip - isHighlighted?: boolean; - isSelected?: boolean; - onSelect?: (tileId: string) => void; - }, - ref: ForwardedRef, - ) => { - const [isFullscreen, setIsFullscreen] = useState(false); - const [isFocused, setIsFocused] = useState(false); - - useEffect(() => { - if (isHighlighted) { - document - .getElementById(`chart-${chart.id}`) - ?.scrollIntoView({ behavior: 'smooth' }); - } - }, [chart.id, isHighlighted]); - - // YouTube-style 'f' key shortcut for fullscreen toggle - useHotkeys([['f', () => isFocused && setIsFullscreen(prev => !prev)]]); - - const [queriedConfig, setQueriedConfig] = useState< - ChartConfigWithDateRange | undefined - >(undefined); - - const { data: source, isFetched: isSourceFetched } = useSource({ - id: chart.config.source, - }); - - const isSourceMissing = - !!chart.config.source && isSourceFetched && source == null; - const isSourceUnset = - !!chart.config && - isBuilderSavedChartConfig(chart.config) && - displayTypeRequiresSource(chart.config.displayType) && - !chart.config.source; - - useEffect(() => { - if (isRawSqlSavedChartConfig(chart.config)) { - // Some raw SQL charts don't have a source - if (!chart.config.source) { - setQueriedConfig({ - ...chart.config, - dateRange, - granularity, - filters, - }); - } else if (source != null) { - setQueriedConfig({ - ...chart.config, - // Populate these columns from the source to support Lucene-based filters and metric table macros - ...pick(source, [ - 'implicitColumnExpression', - 'from', - 'metricTables', - ]), - sampleWeightExpression: getSampleWeightExpression(source), - dateRange, - granularity, - filters, - }); - } - - return; - } - - if (source != null && isBuilderSavedChartConfig(chart.config)) { - const isMetricSource = source.kind === SourceKind.Metric; - - // TODO: will need to update this when we allow for multiple metrics per chart - const firstSelect = chart.config.select[0]; - const metricType = - isMetricSource && typeof firstSelect !== 'string' - ? firstSelect?.metricType - : undefined; - const tableName = getMetricTableName(source, metricType); - if (source.connection) { - setQueriedConfig({ - ...chart.config, - connection: source.connection, - dateRange, - granularity, - timestampValueExpression: source.timestampValueExpression, - from: { - databaseName: source.from?.databaseName || 'default', - tableName: tableName || '', - }, - implicitColumnExpression: - isLogSource(source) || isTraceSource(source) - ? source.implicitColumnExpression - : undefined, - sampleWeightExpression: getSampleWeightExpression(source), - filters, - metricTables: isMetricSource ? source.metricTables : undefined, - }); - } - } - }, [source, chart, dateRange, granularity, filters]); - - const [hovered, setHovered] = useState(false); - - const alert = chart.config.alert; - const alertIndicatorColor = useMemo(() => { - if (!alert) { - return 'transparent'; - } - if (alert.state === AlertState.OK) { - return 'green'; - } - if (alert.silenced?.at) { - return 'yellow'; - } - return 'red'; - }, [alert]); - - const alertTooltip = useMemo(() => { - if (!alert) { - return 'Add alert'; - } - let tooltip = `Has alert and is in ${alert.state} state`; - if (alert.silenced?.at) { - const silencedAt = new Date(alert.silenced.at); - // eslint-disable-next-line no-restricted-syntax - tooltip += `. Ack'd ${formatRelative(silencedAt, new Date())}`; - } - return tooltip; - }, [alert]); - - const filterWarning = useMemo(() => { - const doFiltersExist = !!filters?.filter( - f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(), - )?.length; - const doLuceneFiltersExist = !!filters?.filter( - f => f.type === 'lucene' && f.condition.trim(), - )?.length; - - if ( - !doFiltersExist || - !queriedConfig || - !isRawSqlChartConfig(queriedConfig) - ) - return null; - - const isMissingSourceForFiltering = !queriedConfig.source; - const isMissingFiltersMacro = - !queriedConfig.sqlTemplate.includes('$__filters'); - const isMetricsSourceWithLuceneFilter = - source?.kind === SourceKind.Metric && doLuceneFiltersExist; - - if ( - !isMissingSourceForFiltering && - !isMissingFiltersMacro && - !isMetricsSourceWithLuceneFilter - ) - return null; - - const message = isMissingFiltersMacro - ? 'Filters are not applied because the SQL does not include the required $__filters macro' - : isMetricsSourceWithLuceneFilter - ? 'Lucene filters are not applied because they are not supported for metrics sources.' - : 'Filters are not applied because no Source is set for this chart'; - - return ( - - - - ); - }, [filters, queriedConfig, source]); - - const hoverToolbar = useMemo(() => { - const isRawSql = isRawSqlSavedChartConfig(chart.config); - const displayTypeSupportsAlerts = isRawSql - ? displayTypeSupportsRawSqlAlerts(chart.config.displayType) - : displayTypeSupportsBuilderAlerts(chart.config.displayType); - return ( - e.stopPropagation()} - key="hover-toolbar" - my={4} // Margin to ensure that the Alert Indicator doesn't clip on non-Line/Bar display types - style={{ visibility: hovered ? 'visible' : 'hidden' }} - > - {displayTypeSupportsAlerts && ( - +} - mr={4} - > - - - - - - - )} - - - - - { - e.stopPropagation(); - setIsFullscreen(true); - }} - title="View Fullscreen (f)" - > - - - - - - {onMoveToGroup && moveTargets && moveTargets.length > 0 && ( - - - - - - - - - - Move to Group - {chart.containerId && ( - onMoveToGroup(undefined)}> - (Ungrouped) - - )} - {moveTargets - .filter( - t => - !( - t.containerId === chart.containerId && - t.tabId === chart.tabId - ), - ) - .map(t => ( - onMoveToGroup(t.containerId, t.tabId)} - > - {t.allTabs ? ( - - {t.allTabs.map((tab, i) => ( - - {i > 0 && ( - - {' | '} - - )} - - {tab.title} - - - ))} - - ) : ( - t.label - )} - - ))} - - - )} - - - - - ); - }, [ - alert, - alertIndicatorColor, - alertTooltip, - moveTargets, - chart.config, - chart.id, - chart.containerId, - chart.tabId, - hovered, - onDeleteClick, - onDuplicateClick, - onEditClick, - onMoveToGroup, - ]); - - const title = useMemo( - () => ( - - {chart.config.name} - - ), - [chart.config.name], - ); - - // Render chart content (used in both tile and fullscreen views) - const renderChartContent = useCallback( - (hideToolbar: boolean = false, isFullscreenView: boolean = false) => { - const toolbar = hideToolbar - ? [filterWarning] - : [hoverToolbar, filterWarning]; - const keyPrefix = isFullscreenView ? 'fullscreen' : 'tile'; - - // Markdown charts may not have queriedConfig, if config.source is not set - const effectiveMarkdownConfig = queriedConfig ?? chart.config; - - return ( - - An error occurred while rendering the chart. -
- } - > - {isSourceMissing ? ( - - - - The data source for this tile no longer exists. Edit the - tile to select a new source. - - - - ) : isSourceUnset ? ( - - - - The data source for this tile is not set. Edit the tile to - select a data source. - - - - ) : ( - <> - {(queriedConfig?.displayType === DisplayType.Line || - queriedConfig?.displayType === DisplayType.StackedBar) && ( - { - onUpdateChart?.({ - ...chart, - config: { - ...chart.config, - displayType, - }, - }); - }} - /> - )} - {queriedConfig?.displayType === DisplayType.Table && ( - - - buildTableRowSearchUrl({ - row, - source, - config: queriedConfig, - dateRange: dateRange, - }) - : undefined - } - /> - - )} - {queriedConfig?.displayType === DisplayType.Number && ( - - )} - {queriedConfig?.displayType === DisplayType.Pie && ( - - )} - {queriedConfig?.displayType === DisplayType.Heatmap && - isBuilderChartConfig(queriedConfig) && ( - - )} - {effectiveMarkdownConfig?.displayType === - DisplayType.Markdown && - 'markdown' in effectiveMarkdownConfig && ( - - )} - {queriedConfig?.displayType === DisplayType.Search && - isBuilderChartConfig(queriedConfig) && - isBuilderSavedChartConfig(chart.config) && ( - - - - )} - - )} - - ); - }, - [ - hoverToolbar, - queriedConfig, - title, - chart, - onTimeRangeSelect, - onUpdateChart, - source, - dateRange, - filterWarning, - isSourceMissing, - isSourceUnset, - ], - ); - - return ( - <> -
{ - setHovered(true); - setIsFocused(true); - }} - onMouseLeave={() => { - setHovered(false); - setIsFocused(false); - }} - key={chart.id} - ref={ref} - style={{ - ...style, - ...(isSelected - ? { - outline: '2px solid var(--color-outline-focus)', - outlineOffset: -2, - } - : {}), - }} - onClick={e => { - if (e.shiftKey && onSelect) { - e.preventDefault(); - onSelect(chart.id); - } - }} - onMouseDown={onMouseDown} - onMouseUp={onMouseUp} - onTouchEnd={onTouchEnd} - > - {hovered && ( -
- )} -
e.stopPropagation()} - > - {renderChartContent()} -
- {children} -
- - {/* Fullscreen Modal */} - setIsFullscreen(false)} - > - {isFullscreen && renderChartContent(true, true)} - - - ); - }, -); - -const EditTileModal = ({ - dashboardId, - chart, - onClose, - onSave, - isSaving, - dateRange, -}: { - dashboardId?: string; - chart: Tile | undefined; - onClose: () => void; - dateRange: [Date, Date]; - isSaving?: boolean; - onSave: (chart: Tile) => void; -}) => { - const contextZIndex = useZIndex(); - const modalZIndex = contextZIndex + 10; - const confirm = useConfirm(); - const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); - - useEffect(() => { - if (chart != null) { - setHasUnsavedChanges(false); - } - }, [chart]); - - const handleClose = useCallback(() => { - if (isSaving) return; - if (hasUnsavedChanges) { - confirm( - 'You have unsaved changes. Discard them and close the editor?', - 'Discard', - ).then(ok => { - if (ok) { - // Reset dirty state before closing so any re-invocation of - // handleClose (e.g. from Mantine focus management after the - // confirm modal closes) doesn't re-show the confirm dialog. - setHasUnsavedChanges(false); - onClose(); - } - }); - } else { - onClose(); - } - }, [confirm, isSaving, hasUnsavedChanges, onClose]); - - return ( - - {chart != null && ( - - { - onSave({ - ...chart, - config: config, - }); - }} - onClose={handleClose} - onDirtyChange={setHasUnsavedChanges} - isDashboardForm - autoRun - /> - - )} - - ); -}; - -const updateLayout = (newLayout: RGL.Layout[]) => { - return (dashboard: Dashboard) => { - for (const chart of dashboard.tiles) { - const newChartLayout = newLayout.find(layout => layout.i === chart.id); - if (newChartLayout) { - chart.x = newChartLayout.x; - chart.y = newChartLayout.y; - chart.w = newChartLayout.w; - chart.h = newChartLayout.h; - } - } - }; -}; - -// Download an object to users computer as JSON using specified name -function downloadObjectAsJson(object: object, fileName = 'output') { - const dataStr = - 'data:text/json;charset=utf-8,' + - encodeURIComponent(JSON.stringify(object)); - const downloadAnchorNode = document.createElement('a'); - downloadAnchorNode.setAttribute('href', dataStr); - downloadAnchorNode.setAttribute('download', fileName + '.json'); - document.body.appendChild(downloadAnchorNode); // required for firefox - downloadAnchorNode.click(); - downloadAnchorNode.remove(); -} - -function DashboardContainerRow({ - container, - containerTiles, - isCollapsed, - activeTabId, - alertingTabIds, - onToggleCollapse, - onToggleDefaultCollapsed, - onToggleCollapsible, - onToggleBordered, - onDeleteContainer, - onAddTile, - onAddTab, - onRenameTab, - onDeleteTab, - onRenameContainer, - onTabChange, - dragHandleProps, - makeLayoutChangeHandler, - tileToLayoutItem, - renderTileComponent, -}: { - container: DashboardContainerSchema; - containerTiles: Tile[]; - isCollapsed: boolean; - activeTabId: string | undefined; - alertingTabIds?: Set; - onToggleCollapse: () => void; - onToggleDefaultCollapsed: () => void; - onToggleCollapsible: () => void; - onToggleBordered: () => void; - onDeleteContainer: (action: 'ungroup' | 'delete') => void; - onAddTile: (containerId: string, tabId?: string) => void; - onAddTab: () => void; - onRenameTab: (tabId: string, newTitle: string) => void; - onDeleteTab: (tabId: string, action: TabDeleteAction) => void; - onRenameContainer: (newTitle: string) => void; - onTabChange: (tabId: string) => void; - dragHandleProps: DragHandleProps; - makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; - tileToLayoutItem: (tile: Tile) => RGL.Layout; - renderTileComponent: (tile: Tile) => React.ReactNode; -}) { - const groupTabs = container.tabs ?? []; - const hasTabs = groupTabs.length >= 2; - // Tiles actually rendered inside RGL (active tab only for multi-tab - // containers). Handler must be built from these so RGL's `newLayout` and our - // `currentLayout` have matching sizes — otherwise every drag triggers a - // bogus diff + setDashboard write. - const visibleTiles = hasTabs - ? containerTiles.filter(t => t.tabId === activeTabId) - : containerTiles; - const layoutChangeHandler = useMemo( - () => makeLayoutChangeHandler(visibleTiles), - [makeLayoutChangeHandler, visibleTiles], - ); - - return ( - - onAddTile(container.id, hasTabs ? activeTabId : undefined) - } - activeTabId={activeTabId} - onTabChange={onTabChange} - onAddTab={onAddTab} - onRenameTab={onRenameTab} - onDeleteTab={onDeleteTab} - onRename={onRenameContainer} - dragHandleProps={dragHandleProps} - alertingTabIds={alertingTabIds} - > - {(currentTabId: string | undefined) => { - const visibleTiles = currentTabId - ? containerTiles.filter(t => t.tabId === currentTabId) - : containerTiles; - const visibleIsEmpty = visibleTiles.length === 0; - return ( - onAddTile(container.id, currentTabId)} - > - {visibleTiles.length > 0 && ( - - {visibleTiles.map(renderTileComponent)} - - )} - - ); - }} - - ); -} - -function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { - const brandName = useBrandDisplayName(); - const confirm = useConfirm(); - - const router = useRouter(); - const dashboardId = router.query.dashboardId as string | undefined; - - const { - dashboard, - setDashboard, - dashboardHash, - isLocalDashboard, - isFetching: isFetchingDashboard, - isSetting: isSavingDashboard, - } = useDashboard({ - dashboardId: dashboardId as string | undefined, - presetConfig, - }); - - const { data: sources } = useSources(); - const { data: connections } = useConnections(); - - const [highlightedTileId] = useQueryState('highlightedTileId'); - const tableConnections = useMemo(() => { - if (!dashboard) return []; - const tc: TableConnection[] = []; - - for (const { config } of dashboard.tiles) { - if (!isBuilderSavedChartConfig(config)) continue; - const source = sources?.find(v => v.id === config.source); - if (!source) continue; - // TODO: will need to update this when we allow for multiple metrics per chart - const firstSelect = config.select[0]; - const metricType = - typeof firstSelect !== 'string' ? firstSelect?.metricType : undefined; - const tableName = getMetricTableName(source, metricType); - if (!tableName) continue; - tc.push({ - databaseName: source.from.databaseName, - tableName: tableName, - connectionId: source.connection, - }); - } - - return tc; - }, [dashboard, sources]); - - const [granularity, setGranularity] = useQueryState( - 'granularity', - parseAsString, - // TODO: Build parser - ) as [SQLInterval | undefined, (value: SQLInterval | undefined) => void]; - const [where, setWhere] = useQueryState( - 'where', - parseAsStringEncoded.withDefault(''), - ); - const [whereLanguage, setWhereLanguage] = useQueryState( - 'whereLanguage', - whereLanguageParser, - ); - // Get raw filter queries from URL (not processed by hook) - const [rawFilterQueries] = useQueryState( - 'filters', - parseAsJsonEncoded(), - ); - - // Track if we've initialized query for this dashboard - const initializedDashboard = useRef(undefined); - - const [showFiltersModal, setShowFiltersModal] = useState(false); - - const filters = dashboard?.filters ?? []; - const { filterValues, setFilterValue, filterQueries, setFilterQueries } = - useDashboardFilters(filters); - - const handleSaveFilter = (filter: DashboardFilter) => { - if (!dashboard) return; - - setDashboard( - produce(dashboard, draft => { - const filterIndex = - draft.filters?.findIndex(p => p.id === filter.id) ?? -1; - if (draft.filters && filterIndex !== -1) { - draft.filters[filterIndex] = filter; - } else { - draft.filters = [...(draft.filters ?? []), filter]; - } - }), - ); - }; - - const handleRemoveFilter = (id: string) => { - if (!dashboard) return; - - setDashboard({ - ...dashboard, - filters: dashboard.filters?.filter(p => p.id !== id) ?? [], - }); - }; - - const [isLive, setIsLive] = useState(false); - - const { control, setValue, getValues, handleSubmit } = useForm<{ - granularity: SQLInterval | 'auto'; - where: SearchCondition; - whereLanguage: SearchConditionLanguage; - }>({ - defaultValues: { - granularity: granularity ?? 'auto', - where: where ?? '', - whereLanguage: - (whereLanguage as SearchConditionLanguage) ?? - getStoredLanguage() ?? - 'lucene', - }, - }); - - const watchedGranularity = useWatch({ control, name: 'granularity' }); - - useEffect(() => { - if (watchedGranularity && watchedGranularity !== granularity) { - setGranularity(watchedGranularity as SQLInterval); - } - }, [watchedGranularity, granularity, setGranularity]); - - const [displayedTimeInputValue, setDisplayedTimeInputValue] = - useState('Past 1h'); - - const { searchedTimeRange, onSearch, onTimeRangeSelect } = useNewTimeQuery({ - initialDisplayValue: 'Past 1h', - initialTimeRange: defaultTimeRange, - setDisplayedTimeInputValue, - }); - - const { - granularityOverride, - isRefreshEnabled, - manualRefreshCooloff, - refresh, - } = useDashboardRefresh({ - searchedTimeRange, - onTimeRangeSelect, - isLive, - }); - - const onSubmit = useCallback(() => { - onSearch(displayedTimeInputValue); - handleSubmit(data => { - setWhere(data.where as SearchCondition); - setWhereLanguage((data.whereLanguage as SearchConditionLanguage) ?? null); - })(); - }, [ - displayedTimeInputValue, - handleSubmit, - onSearch, - setWhere, - setWhereLanguage, - ]); - - // Initialize query/filter state once when dashboard changes. - useEffect(() => { - if (!dashboard?.id || !router.isReady) return; - if (!isLocalDashboard && isFetchingDashboard) return; - if (initializedDashboard.current === dashboard.id) return; - const isSwitchingDashboards = - initializedDashboard.current != null && - initializedDashboard.current !== dashboard.id; - - const hasWhereInUrl = 'where' in router.query; - const hasFiltersInUrl = 'filters' in router.query; - - // Query defaults: URL query overrides saved defaults. If switching to a - // dashboard without defaults, clear query. On first load/reload, keep current state. - if (!hasWhereInUrl) { - if (dashboard.savedQuery) { - setValue('where', dashboard.savedQuery); - setWhere(dashboard.savedQuery); - const savedLanguage = - dashboard.savedQueryLanguage ?? getStoredLanguage() ?? 'lucene'; - setValue('whereLanguage', savedLanguage); - setWhereLanguage(savedLanguage); - } else if (isSwitchingDashboards) { - setValue('where', ''); - setWhere(''); - const storedLanguage = getStoredLanguage() ?? 'lucene'; - setValue('whereLanguage', storedLanguage); - setWhereLanguage(storedLanguage); - } - } - - // Filter defaults: URL filters override saved defaults. If switching to a - // dashboard without defaults, clear selected filters. - if (!hasFiltersInUrl) { - if (dashboard.savedFilterValues) { - setFilterQueries(dashboard.savedFilterValues); - } else if (isSwitchingDashboards) { - setFilterQueries(null); - } - } - - initializedDashboard.current = dashboard.id; - }, [ - dashboard?.id, - dashboard?.savedQuery, - dashboard?.savedQueryLanguage, - dashboard?.savedFilterValues, - isLocalDashboard, - isFetchingDashboard, - router.isReady, - router.query, - setValue, - setWhere, - setWhereLanguage, - setFilterQueries, - ]); - - // Sync changes to the URL params into the form - useEffect(() => { - setValue('where', where); - setValue( - 'whereLanguage', - whereLanguage === 'sql' || whereLanguage === 'lucene' - ? whereLanguage - : (getStoredLanguage() ?? 'lucene'), - ); - }, [setValue, where, whereLanguage]); - - const handleSaveQuery = useCallback(() => { - if (!dashboard || isLocalDashboard) return; - - // Execute the query first (updates URL) - onSubmit(); - - // Then save to database (reads from form values which were just submitted to URL) - const formValues = getValues(); - const currentWhere = formValues.where || null; - const currentWhereLanguage = currentWhere - ? formValues.whereLanguage || 'lucene' - : null; - const currentFilterValues = rawFilterQueries?.length - ? rawFilterQueries - : []; - - setDashboard( - produce(dashboard, draft => { - draft.savedQuery = currentWhere; - draft.savedQueryLanguage = currentWhereLanguage; - draft.savedFilterValues = currentFilterValues; - }), - () => { - notifications.show({ - color: 'green', - title: 'Query saved and executed', - message: - 'Filter query and dropdown values have been saved with the dashboard', - autoClose: 3000, - }); - }, - ); - }, [ - dashboard, - isLocalDashboard, - setDashboard, - getValues, - rawFilterQueries, - onSubmit, - ]); - const handleRemoveSavedQuery = useCallback(() => { - if (!dashboard || isLocalDashboard) return; - - setDashboard( - produce(dashboard, draft => { - draft.savedQuery = null; - draft.savedQueryLanguage = null; - draft.savedFilterValues = []; - }), - () => { - notifications.show({ - color: 'green', - title: 'Default query and filters removed', - message: 'Dashboard will no longer auto-apply saved defaults', - autoClose: 3000, - }); - }, - ); - }, [dashboard, isLocalDashboard, setDashboard]); - - const [editedTile, setEditedTile] = useState(); - - const containers = useMemo( - () => dashboard?.containers ?? [], - [dashboard?.containers], - ); - // URL-based collapse state: tracks which containers the current viewer has - // explicitly collapsed/expanded. Falls back to the DB-stored default. - const [urlCollapsedIds, setUrlCollapsedIds] = useQueryState( - 'collapsed', - parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }), - ); - const [urlExpandedIds, setUrlExpandedIds] = useQueryState( - 'expanded', - parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }), - ); - // Per-viewer active tab selection: `{ [containerId]: tabId }`. - // Falls back to the first tab for any container not in the map. - const [urlActiveTabs, setUrlActiveTabs] = useQueryState( - 'activeTabs', - parseAsJsonEncoded>().withOptions({ - history: 'replace', - }), - ); - - const collapsedIdSet = useMemo( - () => new Set(urlCollapsedIds ?? []), - [urlCollapsedIds], - ); - const expandedIdSet = useMemo( - () => new Set(urlExpandedIds ?? []), - [urlExpandedIds], - ); - - const isContainerCollapsed = useCallback( - (container: DashboardContainerSchema): boolean => { - // URL state takes precedence over DB default - if (collapsedIdSet.has(container.id)) return true; - if (expandedIdSet.has(container.id)) return false; - return container.collapsed ?? false; - }, - [collapsedIdSet, expandedIdSet], - ); - - const getActiveTabId = useCallback( - (container: DashboardContainerSchema): string | undefined => { - const tabs = container.tabs ?? []; - const urlTabId = urlActiveTabs?.[container.id]; - if (urlTabId && tabs.some(t => t.id === urlTabId)) return urlTabId; - return tabs[0]?.id; - }, - [urlActiveTabs], - ); - - const handleTabChange = useCallback( - (containerId: string, tabId: string) => { - setUrlActiveTabs(prev => ({ ...(prev ?? {}), [containerId]: tabId })); - }, - [setUrlActiveTabs], - ); - - // Valid move targets: groups and individual tabs within groups - const moveTargetContainers = useMemo(() => { - const targets: MoveTarget[] = []; - for (const c of containers) { - const cTabs = c.tabs ?? []; - if (cTabs.length >= 2) { - for (const tab of cTabs) { - targets.push({ - containerId: c.id, - tabId: tab.id, - label: tab.title, - allTabs: cTabs.map(t => ({ id: t.id, title: t.title })), - }); - } - } else if (cTabs.length === 1) { - // 1-tab group: show just the group name, target the single tab - targets.push({ - containerId: c.id, - tabId: cTabs[0].id, - label: cTabs[0].title, - }); - } else { - targets.push({ containerId: c.id, label: c.title }); - } - } - return targets; - }, [containers]); - - const hasContainers = containers.length > 0; - const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]); - - const { - selectedTileIds, - setSelectedTileIds, - handleToggleTileSelect, - handleGroupSelected, - } = useTileSelection({ dashboard, setDashboard }); - - const handleMoveTileToGroup = useCallback( - (tileId: string, containerId: string | undefined, tabId?: string) => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - const tile = draft.tiles.find(t => t.id === tileId); - if (!tile) return; - - if (containerId) tile.containerId = containerId; - else delete tile.containerId; - if (tabId) tile.tabId = tabId; - else delete tile.tabId; - - const targetTiles = draft.tiles.filter(t => { - if (t.id === tileId) return false; - if (containerId) { - if (t.containerId !== containerId) return false; - return tabId ? t.tabId === tabId : true; - } - return !t.containerId; - }); - const pos = calculateNextTilePosition(targetTiles, tile.w); - tile.x = pos.x; - tile.y = pos.y; - }), - ); - }, - [dashboard, setDashboard], - ); - - const renderTileComponent = useCallback( - (chart: Tile) => ( - setEditedTile(chart)} - granularity={ - isRefreshEnabled ? granularityOverride : (granularity ?? undefined) - } - filters={[ - { - type: whereLanguage === 'sql' ? 'sql' : 'lucene', - condition: where, - }, - ...(filterQueries ?? []), - ]} - onTimeRangeSelect={onTimeRangeSelect} - isHighlighted={highlightedTileId === chart.id} - onUpdateChart={newChart => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - const chartIndex = draft.tiles.findIndex(c => c.id === chart.id); - if (chartIndex === -1) return; - draft.tiles[chartIndex] = newChart; - }), - ); - }} - onDuplicateClick={async () => { - if (dashboard != null) { - if ( - !(await confirm( - <> - Duplicate {'"'} - - {chart.config.name} - - {'"'}? - , - 'Duplicate', - )) - ) { - return; - } - setDashboard({ - ...dashboard, - tiles: [ - ...dashboard.tiles, - { - ...chart, - id: makeId(), - config: { - ...chart.config, - alert: undefined, - }, - }, - ], - }); - } - }} - onDeleteClick={async () => { - if (dashboard != null) { - if ( - !(await confirm( - <> - Delete{' '} - - {chart.config.name} - - ? - , - 'Delete', - { variant: 'danger' }, - )) - ) { - return; - } - setDashboard({ - ...dashboard, - tiles: dashboard.tiles.filter(c => c.id !== chart.id), - }); - } - }} - moveTargets={moveTargetContainers} - onMoveToGroup={(containerId, tabId) => - handleMoveTileToGroup(chart.id, containerId, tabId) - } - isSelected={selectedTileIds.has(chart.id)} - onSelect={handleToggleTileSelect} - /> - ), - [ - dashboard, - searchedTimeRange, - isRefreshEnabled, - granularityOverride, - granularity, - highlightedTileId, - confirm, - setDashboard, - where, - whereLanguage, - onTimeRangeSelect, - filterQueries, - moveTargetContainers, - handleMoveTileToGroup, - selectedTileIds, - handleToggleTileSelect, - ], - ); - - const makeOnLayoutChange = useCallback( - (gridTiles: Tile[]) => (newLayout: RGL.Layout[]) => { - if (!dashboard) return; - const currentLayout = gridTiles.map(tileToLayoutItem); - let hasDiff = false; - if (newLayout.length !== currentLayout.length) { - hasDiff = true; - } else { - for (const curr of newLayout) { - const old = currentLayout.find(l => l.i === curr.i); - if ( - old?.x !== curr.x || - old?.y !== curr.y || - old?.h !== curr.h || - old?.w !== curr.w - ) { - hasDiff = true; - break; - } - } - } - if (hasDiff) { - setDashboard(produce(dashboard, updateLayout(newLayout))); - } - }, - [dashboard, setDashboard], - ); - - // Helpers for updating URL-based collapse sets via immer. - const addToUrlSet = useCallback( - (setter: typeof setUrlCollapsedIds, containerId: string) => { - setter(prev => - produce(prev ?? [], draft => { - if (!draft.includes(containerId)) draft.push(containerId); - }), - ); - }, - [], - ); - - const removeFromUrlSet = useCallback( - (setter: typeof setUrlCollapsedIds, containerId: string) => { - setter(prev => { - const next = (prev ?? []).filter(id => id !== containerId); - return next.length > 0 ? next : null; - }); - }, - [], - ); - - // Toggle collapse in URL state only (per-viewer, shareable via link). - // Does NOT persist to DB — the DB `collapsed` field is the default. - const handleToggleCollapse = useCallback( - (containerId: string) => { - const container = dashboard?.containers?.find(s => s.id === containerId); - const currentlyCollapsed = container - ? isContainerCollapsed(container) - : false; - - if (currentlyCollapsed) { - addToUrlSet(setUrlExpandedIds, containerId); - removeFromUrlSet(setUrlCollapsedIds, containerId); - } else { - addToUrlSet(setUrlCollapsedIds, containerId); - removeFromUrlSet(setUrlExpandedIds, containerId); - } - }, - [ - dashboard?.containers, - isContainerCollapsed, - addToUrlSet, - removeFromUrlSet, - setUrlCollapsedIds, - setUrlExpandedIds, - ], - ); - - // Toggle the DB-stored default collapsed state (menu action). - // This changes what all viewers see by default when opening the dashboard. - const handleToggleDefaultCollapsed = useCallback( - (containerId: string) => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - const c = draft.containers?.find(s => s.id === containerId); - if (c) c.collapsed = !c.collapsed; - }), - ); - }, - [dashboard, setDashboard], - ); - - const handleToggleCollapsible = useCallback( - (containerId: string) => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - const c = draft.containers?.find(s => s.id === containerId); - if (c) { - c.collapsible = !(c.collapsible ?? true); - // Ensure container is expanded when collapsing is disabled - if (c.collapsible === false) c.collapsed = false; - } - }), - ); - // Clear stale URL collapse state so re-enabling doesn't resurrect old state - removeFromUrlSet(setUrlCollapsedIds, containerId); - removeFromUrlSet(setUrlExpandedIds, containerId); - }, - [ - dashboard, - setDashboard, - removeFromUrlSet, - setUrlCollapsedIds, - setUrlExpandedIds, - ], - ); - - const handleToggleBordered = useCallback( - (containerId: string) => { - if (!dashboard) return; - setDashboard( - produce(dashboard, draft => { - const c = draft.containers?.find(s => s.id === containerId); - if (c) c.bordered = !(c.bordered ?? true); - }), - ); - }, - [dashboard, setDashboard], - ); - - const { - handleAddContainer, - handleRenameContainer, - handleDeleteContainer, - handleReorderContainers, - handleAddTab, - handleRenameTab, - handleDeleteTab, - } = useDashboardContainers({ dashboard, setDashboard }); - - const onAddTile = (containerId?: string, tabId?: string) => { - // Auto-expand collapsed container via URL state so the new tile is visible - if (containerId) { - const container = dashboard?.containers?.find(s => s.id === containerId); - if (container && isContainerCollapsed(container)) { - handleToggleCollapse(containerId); - } - } - // Default new tile size: w=8 (1/3 width), h=10 — matches original behavior - const newW = 8; - const newH = 10; - const targetTiles = (dashboard?.tiles ?? []).filter(t => { - if (containerId) { - if (t.containerId !== containerId) return false; - return tabId ? t.tabId === tabId : true; - } - return !t.containerId; - }); - const pos = calculateNextTilePosition(targetTiles, newW); - setEditedTile({ - id: makeId(), - x: pos.x, - y: pos.y, - w: newW, - h: newH, - config: { - ...DEFAULT_CHART_CONFIG, - source: sources?.[0]?.id ?? '', - }, - ...(containerId ? { containerId } : {}), - ...(tabId ? { tabId } : {}), - }); - }; - - // Orphaned tiles (containerId not matching any container) render as ungrouped. - const tilesByContainerId = useMemo(() => { - const map = new Map(); - for (const c of containers) { - map.set( - c.id, - allTiles.filter(t => t.containerId === c.id), - ); - } - return map; - }, [containers, allTiles]); - - const alertingTabIdsByContainer = useMemo(() => { - const map = new Map>(); - for (const container of containers) { - const tiles = tilesByContainerId.get(container.id) ?? []; - const firstTabId = container.tabs?.[0]?.id; - const alerting = new Set(); - for (const tile of tiles) { - if (tile.config.alert?.state === AlertState.ALERT) { - const attributedTabId = tile.tabId ?? firstTabId; - if (attributedTabId) alerting.add(attributedTabId); - } - } - if (alerting.size > 0) map.set(container.id, alerting); - } - return map; - }, [containers, tilesByContainerId]); - - const ungroupedTiles = useMemo( - () => - hasContainers - ? allTiles.filter( - t => !t.containerId || !tilesByContainerId.has(t.containerId), - ) - : allTiles, - [hasContainers, allTiles, tilesByContainerId], - ); - - const onUngroupedLayoutChange = useMemo( - () => makeOnLayoutChange(ungroupedTiles), - [makeOnLayoutChange, ungroupedTiles], - ); - - const deleteDashboard = useDeleteDashboard(); - - const handleUpdateTags = useCallback( - (newTags: string[]) => { - if (dashboard?.id) { - setDashboard( - { - ...dashboard, - tags: newTags, - }, - () => { - notifications.show({ - color: 'green', - message: 'Tags updated successfully', - }); - }, - () => { - notifications.show({ - color: 'red', - message: ( - <> - An error occurred. - - ), - }); - }, - ); - } - }, - [dashboard, setDashboard], - ); - - const createDashboard = useCreateDashboard(); - const onCreateDashboard = useCallback(() => { - createDashboard.mutate( - { - name: 'My Dashboard', - tiles: [], - tags: [], - }, - { - onSuccess: data => { - router.push(`/dashboards/${data.id}`); - }, - }, - ); - }, [createDashboard, router]); - - const [isSaving, setIsSaving] = useState(false); - - const hasTiles = dashboard && dashboard.tiles.length > 0; - const hasSavedQueryAndFilterDefaults = Boolean( - dashboard?.savedQuery || dashboard?.savedFilterValues?.length, - ); - - return ( - - - Dashboard – {brandName} - - - { - if (!isSaving) setEditedTile(undefined); - }} - dateRange={searchedTimeRange} - isSaving={isSaving} - onSave={newChart => { - if (dashboard == null) { - return; - } - setIsSaving(true); - setDashboard( - produce(dashboard, draft => { - const chartIndex = draft.tiles.findIndex( - chart => chart.id === newChart.id, - ); - // This is a new chart (probably?) - if (chartIndex === -1) { - draft.tiles.push(newChart); - } else { - draft.tiles[chartIndex] = newChart; - } - }), - () => { - setEditedTile(undefined); - setIsSaving(false); - }, - () => { - setIsSaving(false); - }, - ); - }} - /> - - {isLocalDashboard ? ( - <> - - - Dashboards - - - Temporary Dashboard - - - - - - This is a temporary dashboard and can not be saved. - - - - - - ) : ( - - - - Dashboards - - - {dashboard?.name ?? 'Untitled'} - - - {!isLocalDashboard && dashboard && ( - - {dashboard.createdBy && ( - - Created by{' '} - {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} - - )} - {dashboard.updatedAt && ( - - - {dashboard.updatedBy - ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` - : ''} - - } - > - {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} - - )} - - )} - - )} - - { - if (dashboard != null) { - setDashboard({ - ...dashboard, - name: editedName, - }); - } - }} - /> - - {!isLocalDashboard && dashboard?.id && ( - - )} - {!isLocalDashboard && dashboard?.id && ( - - - - )} - {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( - - - - - - - - - {hasTiles && ( - } - onClick={() => { - if (!sources || !dashboard) { - notifications.show({ - color: 'red', - message: 'Export Failed', - }); - return; - } - downloadObjectAsJson( - convertToDashboardTemplate( - dashboard, - // TODO: fix this type issue - sources, - connections, - ), - dashboard?.name, - ); - }} - > - Export Dashboard - - )} - } - onClick={() => { - if (dashboard && !dashboard.tiles.length) { - router.push( - `/dashboards/import?dashboardId=${dashboard.id}`, - ); - } else { - router.push('/dashboards/import'); - } - }} - > - {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} - - - } - onClick={handleSaveQuery} - > - {hasSavedQueryAndFilterDefaults - ? 'Update Default Query & Filters' - : 'Save Query & Filters as Default'} - - {hasSavedQueryAndFilterDefaults && ( - } - color="red" - onClick={handleRemoveSavedQuery} - > - Remove Default Query & Filters - - )} - - } - color="red" - onClick={() => - deleteDashboard.mutate(dashboard?.id ?? '', { - onSuccess: () => { - router.push('/dashboards'); - }, - }) - } - > - Delete Dashboard - - - - )} - - {/* */} - - { - e.preventDefault(); - onSubmit(); - }} - > - - setValue('whereLanguage', lang) - } - label="WHERE" - enableHotkey - allowMultiline - minWidth={300} - data-testid="search-input" - /> - { - onSearch(range); - }} - /> - - - - - - - - - - - setShowFiltersModal(true)} - data-testid="edit-filters-button" - size="input-sm" - > - - - - - - - {/* Selection indicator */} - {selectedTileIds.size > 0 && ( - - - - {selectedTileIds.size} tile{selectedTileIds.size > 1 ? 's' : ''}{' '} - selected - - - - - - )} - - {dashboard != null && dashboard.tiles != null ? ( - - An error occurred while rendering the dashboard. -
- } - > - - {ungroupedTiles.length > 0 && ( - - {ungroupedTiles.map(renderTileComponent)} - - )} - {containers.map(container => ( - - {(dragHandleProps: DragHandleProps) => ( - - handleToggleCollapse(container.id) - } - onToggleDefaultCollapsed={() => - handleToggleDefaultCollapsed(container.id) - } - onToggleCollapsible={() => - handleToggleCollapsible(container.id) - } - onToggleBordered={() => - handleToggleBordered(container.id) - } - onDeleteContainer={action => - handleDeleteContainer(container.id, action) - } - onAddTile={onAddTile} - onAddTab={() => { - const newTabId = handleAddTab(container.id); - if (newTabId) handleTabChange(container.id, newTabId); - }} - onRenameTab={(tabId, title) => - handleRenameTab(container.id, tabId, title) - } - onDeleteTab={(tabId, action) => - handleDeleteTab(container.id, tabId, action) - } - onRenameContainer={title => - handleRenameContainer(container.id, title) - } - onTabChange={tabId => - handleTabChange(container.id, tabId) - } - dragHandleProps={dragHandleProps} - makeLayoutChangeHandler={makeOnLayoutChange} - tileToLayoutItem={tileToLayoutItem} - renderTileComponent={renderTileComponent} - /> - )} - - ))} - - - ) : null} - - - - - - - } - onClick={() => onAddTile()} - > - New Tile - - - } - onClick={() => handleAddContainer()} - > - New Group - - - - setShowFiltersModal(false)} - filters={filters} - onSaveFilter={handleSaveFilter} - onRemoveFilter={handleRemoveFilter} - isLoading={isSavingDashboard || isFetchingDashboard} - /> - - ); -} - -const DBDashboardPageDynamic = dynamic(async () => DBDashboardPage, { - ssr: false, -}); - -// @ts-expect-error for getLayout -DBDashboardPageDynamic.getLayout = withAppNav; - -export default DBDashboardPageDynamic; diff --git a/packages/app/src/DBDashboardPage/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx new file mode 100644 index 0000000000..88cdded038 --- /dev/null +++ b/packages/app/src/DBDashboardPage/DBDashboardPage.tsx @@ -0,0 +1,955 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import dynamic from 'next/dynamic'; +import Head from 'next/head'; +import { useRouter } from 'next/router'; +import produce from 'immer'; +import { parseAsArrayOf, parseAsString, useQueryState } from 'nuqs'; +import RGL from 'react-grid-layout'; +import { useForm, useWatch } from 'react-hook-form'; +import { convertToDashboardTemplate } from '@hyperdx/common-utils/dist/core/utils'; +import { + DashboardContainer as DashboardContainerSchema, + DashboardFilter, + Filter, + SQLInterval, +} from '@hyperdx/common-utils/dist/types'; +import { Box, Text } from '@mantine/core'; +import { notifications } from '@mantine/notifications'; + +import { DEFAULT_CHART_CONFIG } from '@/ChartUtils'; +import { ContactSupportText } from '@/components/ContactSupportText'; +import OnboardingModal from '@/components/OnboardingModal'; +import { getStoredLanguage } from '@/components/SearchInput/SearchWhereInput'; +import { useConnections } from '@/connection'; +import { + Dashboard, + type Tile, + useCreateDashboard, + useDashboard, + useDeleteDashboard, +} from '@/dashboard'; +import DashboardFilters from '@/DashboardFilters'; +import DashboardFiltersModal from '@/DashboardFiltersModal'; +import useDashboardContainers from '@/hooks/useDashboardContainers'; +import useDashboardFilters from '@/hooks/useDashboardFilters'; +import { useDashboardRefresh } from '@/hooks/useDashboardRefresh'; +import useTileSelection from '@/hooks/useTileSelection'; +import { withAppNav } from '@/layout'; +import { useSources } from '@/source'; +import { useBrandDisplayName } from '@/theme/ThemeProvider'; +import { parseTimeQuery, useNewTimeQuery } from '@/timeQuery'; +import { useConfirm } from '@/useConfirm'; +import { parseAsJsonEncoded, parseAsStringEncoded } from '@/utils/queryParsers'; +import { calculateNextTilePosition, makeId } from '@/utils/tilePositioning'; + +import { DashboardGrid } from './DashboardGrid'; +import { DashboardHeader } from './DashboardHeader'; +import { DashboardTile } from './DashboardTile'; +import { DashboardToolbar } from './DashboardToolbar'; +import { EditTileModal } from './EditTileModal'; +import type { DashboardQueryFormValues, MoveTarget } from './types'; +import { + buildMoveTargets, + downloadObjectAsJson, + getAlertingTabIdsByContainer, + getDashboardTableConnections, + getTilesByContainerId, + getUngroupedTiles, + hasLayoutChanged, + tileToLayoutItem, + updateLayout, +} from './utils'; + +import 'react-grid-layout/css/styles.css'; +import 'react-resizable/css/styles.css'; + +// TODO: This is a hack to set the default time range +const defaultTimeRange = parseTimeQuery('Past 1h', false) as [Date, Date]; + +const whereLanguageParser = parseAsString.withDefault( + typeof window !== 'undefined' ? (getStoredLanguage() ?? 'lucene') : 'lucene', +); + +function DBDashboardPage({ presetConfig }: { presetConfig?: Dashboard }) { + const brandName = useBrandDisplayName(); + const confirm = useConfirm(); + + const router = useRouter(); + const dashboardId = router.query.dashboardId as string | undefined; + + const { + dashboard, + setDashboard, + dashboardHash, + isLocalDashboard, + isFetching: isFetchingDashboard, + isSetting: isSavingDashboard, + } = useDashboard({ + dashboardId: dashboardId as string | undefined, + presetConfig, + }); + + const { data: sources } = useSources(); + const { data: connections } = useConnections(); + + const [highlightedTileId] = useQueryState('highlightedTileId'); + const tableConnections = useMemo( + () => getDashboardTableConnections({ dashboard, sources }), + [dashboard, sources], + ); + + const [granularity, setGranularity] = useQueryState( + 'granularity', + parseAsString, + // TODO: Build parser + ) as [SQLInterval | undefined, (value: SQLInterval | undefined) => void]; + const [where, setWhere] = useQueryState( + 'where', + parseAsStringEncoded.withDefault(''), + ); + const [whereLanguage, setWhereLanguage] = useQueryState( + 'whereLanguage', + whereLanguageParser, + ); + // Get raw filter queries from URL (not processed by hook) + const [rawFilterQueries] = useQueryState( + 'filters', + parseAsJsonEncoded(), + ); + + // Track if we've initialized query for this dashboard + const initializedDashboard = useRef(undefined); + + const [showFiltersModal, setShowFiltersModal] = useState(false); + + const filters = dashboard?.filters ?? []; + const { filterValues, setFilterValue, filterQueries, setFilterQueries } = + useDashboardFilters(filters); + + const handleSaveFilter = (filter: DashboardFilter) => { + if (!dashboard) return; + + setDashboard( + produce(dashboard, draft => { + const filterIndex = + draft.filters?.findIndex(p => p.id === filter.id) ?? -1; + if (draft.filters && filterIndex !== -1) { + draft.filters[filterIndex] = filter; + } else { + draft.filters = [...(draft.filters ?? []), filter]; + } + }), + ); + }; + + const handleRemoveFilter = (id: string) => { + if (!dashboard) return; + + setDashboard({ + ...dashboard, + filters: dashboard.filters?.filter(p => p.id !== id) ?? [], + }); + }; + + const [isLive, setIsLive] = useState(false); + + const { control, setValue, getValues, handleSubmit } = + useForm({ + defaultValues: { + granularity: granularity ?? 'auto', + where: where ?? '', + whereLanguage: + whereLanguage === 'sql' || whereLanguage === 'lucene' + ? whereLanguage + : (getStoredLanguage() ?? 'lucene'), + }, + }); + + const watchedGranularity = useWatch({ control, name: 'granularity' }); + + useEffect(() => { + if (watchedGranularity && watchedGranularity !== granularity) { + setGranularity(watchedGranularity as SQLInterval); + } + }, [watchedGranularity, granularity, setGranularity]); + + const [displayedTimeInputValue, setDisplayedTimeInputValue] = + useState('Past 1h'); + + const { searchedTimeRange, onSearch, onTimeRangeSelect } = useNewTimeQuery({ + initialDisplayValue: 'Past 1h', + initialTimeRange: defaultTimeRange, + setDisplayedTimeInputValue, + }); + + const { + granularityOverride, + isRefreshEnabled, + manualRefreshCooloff, + refresh, + } = useDashboardRefresh({ + searchedTimeRange, + onTimeRangeSelect, + isLive, + }); + + const onSubmit = useCallback(() => { + onSearch(displayedTimeInputValue); + handleSubmit(data => { + setWhere(data.where); + setWhereLanguage(data.whereLanguage ?? null); + })(); + }, [ + displayedTimeInputValue, + handleSubmit, + onSearch, + setWhere, + setWhereLanguage, + ]); + + // Initialize query/filter state once when dashboard changes. + useEffect(() => { + if (!dashboard?.id || !router.isReady) return; + if (!isLocalDashboard && isFetchingDashboard) return; + if (initializedDashboard.current === dashboard.id) return; + const isSwitchingDashboards = + initializedDashboard.current != null && + initializedDashboard.current !== dashboard.id; + + const hasWhereInUrl = 'where' in router.query; + const hasFiltersInUrl = 'filters' in router.query; + + // Query defaults: URL query overrides saved defaults. If switching to a + // dashboard without defaults, clear query. On first load/reload, keep current state. + if (!hasWhereInUrl) { + if (dashboard.savedQuery) { + setValue('where', dashboard.savedQuery); + setWhere(dashboard.savedQuery); + const savedLanguage = + dashboard.savedQueryLanguage ?? getStoredLanguage() ?? 'lucene'; + setValue('whereLanguage', savedLanguage); + setWhereLanguage(savedLanguage); + } else if (isSwitchingDashboards) { + setValue('where', ''); + setWhere(''); + const storedLanguage = getStoredLanguage() ?? 'lucene'; + setValue('whereLanguage', storedLanguage); + setWhereLanguage(storedLanguage); + } + } + + // Filter defaults: URL filters override saved defaults. If switching to a + // dashboard without defaults, clear selected filters. + if (!hasFiltersInUrl) { + if (dashboard.savedFilterValues) { + setFilterQueries(dashboard.savedFilterValues); + } else if (isSwitchingDashboards) { + setFilterQueries(null); + } + } + + initializedDashboard.current = dashboard.id; + }, [ + dashboard?.id, + dashboard?.savedQuery, + dashboard?.savedQueryLanguage, + dashboard?.savedFilterValues, + isLocalDashboard, + isFetchingDashboard, + router.isReady, + router.query, + setValue, + setWhere, + setWhereLanguage, + setFilterQueries, + ]); + + // Sync changes to the URL params into the form + useEffect(() => { + setValue('where', where); + setValue( + 'whereLanguage', + whereLanguage === 'sql' || whereLanguage === 'lucene' + ? whereLanguage + : (getStoredLanguage() ?? 'lucene'), + ); + }, [setValue, where, whereLanguage]); + + const handleSaveQuery = useCallback(() => { + if (!dashboard || isLocalDashboard) return; + + // Execute the query first (updates URL) + onSubmit(); + + // Then save to database (reads from form values which were just submitted to URL) + const formValues = getValues(); + const currentWhere = formValues.where || null; + const currentWhereLanguage = currentWhere + ? formValues.whereLanguage || 'lucene' + : null; + const currentFilterValues = rawFilterQueries?.length + ? rawFilterQueries + : []; + + setDashboard( + produce(dashboard, draft => { + draft.savedQuery = currentWhere; + draft.savedQueryLanguage = currentWhereLanguage; + draft.savedFilterValues = currentFilterValues; + }), + () => { + notifications.show({ + color: 'green', + title: 'Query saved and executed', + message: + 'Filter query and dropdown values have been saved with the dashboard', + autoClose: 3000, + }); + }, + ); + }, [ + dashboard, + isLocalDashboard, + setDashboard, + getValues, + rawFilterQueries, + onSubmit, + ]); + const handleRemoveSavedQuery = useCallback(() => { + if (!dashboard || isLocalDashboard) return; + + setDashboard( + produce(dashboard, draft => { + draft.savedQuery = null; + draft.savedQueryLanguage = null; + draft.savedFilterValues = []; + }), + () => { + notifications.show({ + color: 'green', + title: 'Default query and filters removed', + message: 'Dashboard will no longer auto-apply saved defaults', + autoClose: 3000, + }); + }, + ); + }, [dashboard, isLocalDashboard, setDashboard]); + + const [editedTile, setEditedTile] = useState(); + + const containers = useMemo( + () => dashboard?.containers ?? [], + [dashboard?.containers], + ); + // URL-based collapse state: tracks which containers the current viewer has + // explicitly collapsed/expanded. Falls back to the DB-stored default. + const [urlCollapsedIds, setUrlCollapsedIds] = useQueryState( + 'collapsed', + parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }), + ); + const [urlExpandedIds, setUrlExpandedIds] = useQueryState( + 'expanded', + parseAsArrayOf(parseAsString).withOptions({ history: 'replace' }), + ); + // Per-viewer active tab selection: `{ [containerId]: tabId }`. + // Falls back to the first tab for any container not in the map. + const [urlActiveTabs, setUrlActiveTabs] = useQueryState( + 'activeTabs', + parseAsJsonEncoded>().withOptions({ + history: 'replace', + }), + ); + + const collapsedIdSet = useMemo( + () => new Set(urlCollapsedIds ?? []), + [urlCollapsedIds], + ); + const expandedIdSet = useMemo( + () => new Set(urlExpandedIds ?? []), + [urlExpandedIds], + ); + + const isContainerCollapsed = useCallback( + (container: DashboardContainerSchema): boolean => { + // URL state takes precedence over DB default + if (collapsedIdSet.has(container.id)) return true; + if (expandedIdSet.has(container.id)) return false; + return container.collapsed ?? false; + }, + [collapsedIdSet, expandedIdSet], + ); + + const getActiveTabId = useCallback( + (container: DashboardContainerSchema): string | undefined => { + const tabs = container.tabs ?? []; + const urlTabId = urlActiveTabs?.[container.id]; + if (urlTabId && tabs.some(t => t.id === urlTabId)) return urlTabId; + return tabs[0]?.id; + }, + [urlActiveTabs], + ); + + const handleTabChange = useCallback( + (containerId: string, tabId: string) => { + setUrlActiveTabs(prev => ({ ...(prev ?? {}), [containerId]: tabId })); + }, + [setUrlActiveTabs], + ); + + // Valid move targets: groups and individual tabs within groups + const moveTargetContainers = useMemo( + () => buildMoveTargets(containers), + [containers], + ); + + const hasContainers = containers.length > 0; + const allTiles = useMemo(() => dashboard?.tiles ?? [], [dashboard?.tiles]); + + const { + selectedTileIds, + setSelectedTileIds, + handleToggleTileSelect, + handleGroupSelected, + } = useTileSelection({ dashboard, setDashboard }); + + const handleMoveTileToGroup = useCallback( + (tileId: string, containerId: string | undefined, tabId?: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const tile = draft.tiles.find(t => t.id === tileId); + if (!tile) return; + + if (containerId) tile.containerId = containerId; + else delete tile.containerId; + if (tabId) tile.tabId = tabId; + else delete tile.tabId; + + const targetTiles = draft.tiles.filter(t => { + if (t.id === tileId) return false; + if (containerId) { + if (t.containerId !== containerId) return false; + return tabId ? t.tabId === tabId : true; + } + return !t.containerId; + }); + const pos = calculateNextTilePosition(targetTiles, tile.w); + tile.x = pos.x; + tile.y = pos.y; + }), + ); + }, + [dashboard, setDashboard], + ); + + const renderTileComponent = useCallback( + (chart: Tile) => ( + setEditedTile(chart)} + granularity={ + isRefreshEnabled ? granularityOverride : (granularity ?? undefined) + } + filters={[ + { + type: whereLanguage === 'sql' ? 'sql' : 'lucene', + condition: where, + }, + ...(filterQueries ?? []), + ]} + onTimeRangeSelect={onTimeRangeSelect} + isHighlighted={highlightedTileId === chart.id} + onUpdateChart={newChart => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const chartIndex = draft.tiles.findIndex(c => c.id === chart.id); + if (chartIndex === -1) return; + draft.tiles[chartIndex] = newChart; + }), + ); + }} + onDuplicateClick={async () => { + if (dashboard != null) { + if ( + !(await confirm( + <> + Duplicate {'"'} + + {chart.config.name} + + {'"'}? + , + 'Duplicate', + )) + ) { + return; + } + setDashboard({ + ...dashboard, + tiles: [ + ...dashboard.tiles, + { + ...chart, + id: makeId(), + config: { + ...chart.config, + alert: undefined, + }, + }, + ], + }); + } + }} + onDeleteClick={async () => { + if (dashboard != null) { + if ( + !(await confirm( + <> + Delete{' '} + + {chart.config.name} + + ? + , + 'Delete', + { variant: 'danger' }, + )) + ) { + return; + } + setDashboard({ + ...dashboard, + tiles: dashboard.tiles.filter(c => c.id !== chart.id), + }); + } + }} + moveTargets={moveTargetContainers} + onMoveToGroup={(containerId, tabId) => + handleMoveTileToGroup(chart.id, containerId, tabId) + } + isSelected={selectedTileIds.has(chart.id)} + onSelect={handleToggleTileSelect} + /> + ), + [ + dashboard, + searchedTimeRange, + isRefreshEnabled, + granularityOverride, + granularity, + highlightedTileId, + confirm, + setDashboard, + where, + whereLanguage, + onTimeRangeSelect, + filterQueries, + moveTargetContainers, + handleMoveTileToGroup, + selectedTileIds, + handleToggleTileSelect, + ], + ); + + const makeOnLayoutChange = useCallback( + (gridTiles: Tile[]) => (newLayout: RGL.Layout[]) => { + if (!dashboard) return; + const currentLayout = gridTiles.map(tileToLayoutItem); + if (hasLayoutChanged({ currentLayout, newLayout })) { + setDashboard(produce(dashboard, updateLayout(newLayout))); + } + }, + [dashboard, setDashboard], + ); + + // Helpers for updating URL-based collapse sets via immer. + const addToUrlSet = useCallback( + (setter: typeof setUrlCollapsedIds, containerId: string) => { + setter(prev => + produce(prev ?? [], draft => { + if (!draft.includes(containerId)) draft.push(containerId); + }), + ); + }, + [], + ); + + const removeFromUrlSet = useCallback( + (setter: typeof setUrlCollapsedIds, containerId: string) => { + setter(prev => { + const next = (prev ?? []).filter(id => id !== containerId); + return next.length > 0 ? next : null; + }); + }, + [], + ); + + // Toggle collapse in URL state only (per-viewer, shareable via link). + // Does NOT persist to DB — the DB `collapsed` field is the default. + const handleToggleCollapse = useCallback( + (containerId: string) => { + const container = dashboard?.containers?.find(s => s.id === containerId); + const currentlyCollapsed = container + ? isContainerCollapsed(container) + : false; + + if (currentlyCollapsed) { + addToUrlSet(setUrlExpandedIds, containerId); + removeFromUrlSet(setUrlCollapsedIds, containerId); + } else { + addToUrlSet(setUrlCollapsedIds, containerId); + removeFromUrlSet(setUrlExpandedIds, containerId); + } + }, + [ + dashboard?.containers, + isContainerCollapsed, + addToUrlSet, + removeFromUrlSet, + setUrlCollapsedIds, + setUrlExpandedIds, + ], + ); + + // Toggle the DB-stored default collapsed state (menu action). + // This changes what all viewers see by default when opening the dashboard. + const handleToggleDefaultCollapsed = useCallback( + (containerId: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const c = draft.containers?.find(s => s.id === containerId); + if (c) c.collapsed = !c.collapsed; + }), + ); + }, + [dashboard, setDashboard], + ); + + const handleToggleCollapsible = useCallback( + (containerId: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const c = draft.containers?.find(s => s.id === containerId); + if (c) { + c.collapsible = !(c.collapsible ?? true); + // Ensure container is expanded when collapsing is disabled + if (c.collapsible === false) c.collapsed = false; + } + }), + ); + // Clear stale URL collapse state so re-enabling doesn't resurrect old state + removeFromUrlSet(setUrlCollapsedIds, containerId); + removeFromUrlSet(setUrlExpandedIds, containerId); + }, + [ + dashboard, + setDashboard, + removeFromUrlSet, + setUrlCollapsedIds, + setUrlExpandedIds, + ], + ); + + const handleToggleBordered = useCallback( + (containerId: string) => { + if (!dashboard) return; + setDashboard( + produce(dashboard, draft => { + const c = draft.containers?.find(s => s.id === containerId); + if (c) c.bordered = !(c.bordered ?? true); + }), + ); + }, + [dashboard, setDashboard], + ); + + const { + handleAddContainer, + handleRenameContainer, + handleDeleteContainer, + handleReorderContainers, + handleAddTab, + handleRenameTab, + handleDeleteTab, + } = useDashboardContainers({ dashboard, setDashboard }); + + const onAddTile = (containerId?: string, tabId?: string) => { + // Auto-expand collapsed container via URL state so the new tile is visible + if (containerId) { + const container = dashboard?.containers?.find(s => s.id === containerId); + if (container && isContainerCollapsed(container)) { + handleToggleCollapse(containerId); + } + } + // Default new tile size: w=8 (1/3 width), h=10 — matches original behavior + const newW = 8; + const newH = 10; + const targetTiles = (dashboard?.tiles ?? []).filter(t => { + if (containerId) { + if (t.containerId !== containerId) return false; + return tabId ? t.tabId === tabId : true; + } + return !t.containerId; + }); + const pos = calculateNextTilePosition(targetTiles, newW); + setEditedTile({ + id: makeId(), + x: pos.x, + y: pos.y, + w: newW, + h: newH, + config: { + ...DEFAULT_CHART_CONFIG, + source: sources?.[0]?.id ?? '', + }, + ...(containerId ? { containerId } : {}), + ...(tabId ? { tabId } : {}), + }); + }; + + // Orphaned tiles (containerId not matching any container) render as ungrouped. + const tilesByContainerId = useMemo( + () => getTilesByContainerId({ containers, allTiles }), + [containers, allTiles], + ); + + const alertingTabIdsByContainer = useMemo( + () => getAlertingTabIdsByContainer({ containers, tilesByContainerId }), + [containers, tilesByContainerId], + ); + + const ungroupedTiles = useMemo( + () => getUngroupedTiles({ hasContainers, allTiles, tilesByContainerId }), + [hasContainers, allTiles, tilesByContainerId], + ); + + const onUngroupedLayoutChange = useMemo( + () => makeOnLayoutChange(ungroupedTiles), + [makeOnLayoutChange, ungroupedTiles], + ); + + const deleteDashboard = useDeleteDashboard(); + + const handleUpdateTags = useCallback( + (newTags: string[]) => { + if (dashboard?.id) { + setDashboard( + { + ...dashboard, + tags: newTags, + }, + () => { + notifications.show({ + color: 'green', + message: 'Tags updated successfully', + }); + }, + () => { + notifications.show({ + color: 'red', + message: ( + <> + An error occurred. + + ), + }); + }, + ); + } + }, + [dashboard, setDashboard], + ); + + const createDashboard = useCreateDashboard(); + const onCreateDashboard = useCallback(() => { + createDashboard.mutate( + { + name: 'My Dashboard', + tiles: [], + tags: [], + }, + { + onSuccess: data => { + router.push(`/dashboards/${data.id}`); + }, + }, + ); + }, [createDashboard, router]); + + const [isSaving, setIsSaving] = useState(false); + + const hasTiles = dashboard && dashboard.tiles.length > 0; + const hasSavedQueryAndFilterDefaults = Boolean( + dashboard?.savedQuery || dashboard?.savedFilterValues?.length, + ); + + return ( + + + Dashboard – {brandName} + + + { + if (!isSaving) setEditedTile(undefined); + }} + dateRange={searchedTimeRange} + isSaving={isSaving} + onSave={newChart => { + if (dashboard == null) { + return; + } + setIsSaving(true); + setDashboard( + produce(dashboard, draft => { + const chartIndex = draft.tiles.findIndex( + chart => chart.id === newChart.id, + ); + // This is a new chart (probably?) + if (chartIndex === -1) { + draft.tiles.push(newChart); + } else { + draft.tiles[chartIndex] = newChart; + } + }), + () => { + setEditedTile(undefined); + setIsSaving(false); + }, + () => { + setIsSaving(false); + }, + ); + }} + /> + + { + if (dashboard != null) { + setDashboard({ + ...dashboard, + name: editedName, + }); + } + }} + onUpdateTags={handleUpdateTags} + onExportDashboard={() => { + if (!sources || !dashboard) { + notifications.show({ + color: 'red', + message: 'Export Failed', + }); + return; + } + downloadObjectAsJson( + convertToDashboardTemplate( + dashboard, + // TODO: fix this type issue + sources, + connections, + ), + dashboard?.name, + ); + }} + onImportDashboard={() => { + if (dashboard && !dashboard.tiles.length) { + router.push(`/dashboards/import?dashboardId=${dashboard.id}`); + } else { + router.push('/dashboards/import'); + } + }} + onSaveQuery={handleSaveQuery} + onRemoveSavedQuery={handleRemoveSavedQuery} + onDeleteDashboard={() => + deleteDashboard.mutate(dashboard?.id ?? '', { + onSuccess: () => { + router.push('/dashboards'); + }, + }) + } + /> + setShowFiltersModal(true)} + /> + + + setShowFiltersModal(false)} + filters={filters} + onSaveFilter={handleSaveFilter} + onRemoveFilter={handleRemoveFilter} + isLoading={isSavingDashboard || isFetchingDashboard} + /> + + ); +} + +const DBDashboardPageDynamic = dynamic(async () => DBDashboardPage, { + ssr: false, +}); + +// @ts-expect-error for getLayout +DBDashboardPageDynamic.getLayout = withAppNav; + +export default DBDashboardPageDynamic; diff --git a/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx new file mode 100644 index 0000000000..4fe39321af --- /dev/null +++ b/packages/app/src/DBDashboardPage/DashboardContainerRow.tsx @@ -0,0 +1,122 @@ +import { useMemo } from 'react'; +import RGL, { WidthProvider } from 'react-grid-layout'; +import { DashboardContainer as DashboardContainerSchema } from '@hyperdx/common-utils/dist/types'; + +import DashboardContainer from '@/components/DashboardContainer'; +import { EmptyContainerPlaceholder } from '@/components/DashboardDndComponents'; +import { type DragHandleProps } from '@/components/DashboardDndContext'; +import { type Tile } from '@/dashboard'; +import { type TabDeleteAction } from '@/hooks/useDashboardContainers'; + +const ReactGridLayout = WidthProvider(RGL); + +type DashboardContainerRowProps = { + container: DashboardContainerSchema; + containerTiles: Tile[]; + isCollapsed: boolean; + activeTabId: string | undefined; + alertingTabIds?: Set; + onToggleCollapse: () => void; + onToggleDefaultCollapsed: () => void; + onToggleCollapsible: () => void; + onToggleBordered: () => void; + onDeleteContainer: (action: 'ungroup' | 'delete') => void; + onAddTile: (containerId?: string, tabId?: string) => void; + onAddTab: () => void; + onRenameTab: (tabId: string, newTitle: string) => void; + onDeleteTab: (tabId: string, action: TabDeleteAction) => void; + onRenameContainer: (newTitle: string) => void; + onTabChange: (tabId: string) => void; + dragHandleProps: DragHandleProps; + makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; + tileToLayoutItem: (tile: Tile) => RGL.Layout; + renderTileComponent: (tile: Tile) => React.ReactNode; +}; + +export function DashboardContainerRow({ + container, + containerTiles, + isCollapsed, + activeTabId, + alertingTabIds, + onToggleCollapse, + onToggleDefaultCollapsed, + onToggleCollapsible, + onToggleBordered, + onDeleteContainer, + onAddTile, + onAddTab, + onRenameTab, + onDeleteTab, + onRenameContainer, + onTabChange, + dragHandleProps, + makeLayoutChangeHandler, + tileToLayoutItem, + renderTileComponent, +}: DashboardContainerRowProps) { + const groupTabs = container.tabs ?? []; + const hasTabs = groupTabs.length >= 2; + // Tiles actually rendered inside RGL (active tab only for multi-tab + // containers). Handler must be built from these so RGL's `newLayout` and our + // `currentLayout` have matching sizes - otherwise every drag triggers a + // bogus diff + setDashboard write. + const visibleTiles = hasTabs + ? containerTiles.filter(t => t.tabId === activeTabId) + : containerTiles; + const layoutChangeHandler = useMemo( + () => makeLayoutChangeHandler(visibleTiles), + [makeLayoutChangeHandler, visibleTiles], + ); + + return ( + + onAddTile(container.id, hasTabs ? activeTabId : undefined) + } + activeTabId={activeTabId} + onTabChange={onTabChange} + onAddTab={onAddTab} + onRenameTab={onRenameTab} + onDeleteTab={onDeleteTab} + onRename={onRenameContainer} + dragHandleProps={dragHandleProps} + alertingTabIds={alertingTabIds} + > + {(currentTabId: string | undefined) => { + const visibleTiles = currentTabId + ? containerTiles.filter(t => t.tabId === currentTabId) + : containerTiles; + const visibleIsEmpty = visibleTiles.length === 0; + return ( + onAddTile(container.id, currentTabId)} + > + {visibleTiles.length > 0 && ( + + {visibleTiles.map(renderTileComponent)} + + )} + + ); + }} + + ); +} diff --git a/packages/app/src/DBDashboardPage/DashboardGrid.tsx b/packages/app/src/DBDashboardPage/DashboardGrid.tsx new file mode 100644 index 0000000000..ec6c5b66e6 --- /dev/null +++ b/packages/app/src/DBDashboardPage/DashboardGrid.tsx @@ -0,0 +1,232 @@ +import { Dispatch, SetStateAction } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import RGL, { WidthProvider } from 'react-grid-layout'; +import { DashboardContainer as DashboardContainerSchema } from '@hyperdx/common-utils/dist/types'; +import { Box, Button, Flex, Menu, Paper, Text } from '@mantine/core'; +import { + IconChartBar, + IconPlus, + IconSquaresDiagonal, +} from '@tabler/icons-react'; + +import { SortableContainerWrapper } from '@/components/DashboardDndComponents'; +import { + DashboardDndProvider, + type DragHandleProps, +} from '@/components/DashboardDndContext'; +import { type Tile } from '@/dashboard'; +import { type TabDeleteAction } from '@/hooks/useDashboardContainers'; + +import { DashboardContainerRow } from './DashboardContainerRow'; + +const ReactGridLayout = WidthProvider(RGL); + +type DashboardGridProps = { + canRenderDashboard: boolean; + hasTiles: boolean; + containers: DashboardContainerSchema[]; + ungroupedTiles: Tile[]; + selectedTileIds: Set; + setSelectedTileIds: Dispatch>>; + onGroupSelected: () => void; + onReorderContainers: (fromIndex: number, toIndex: number) => void; + onUngroupedLayoutChange: (newLayout: RGL.Layout[]) => void; + renderTileComponent: (tile: Tile) => React.ReactNode; + tileToLayoutItem: (tile: Tile) => RGL.Layout; + tilesByContainerId: Map; + isContainerCollapsed: (container: DashboardContainerSchema) => boolean; + getActiveTabId: (container: DashboardContainerSchema) => string | undefined; + alertingTabIdsByContainer: Map>; + onToggleCollapse: (containerId: string) => void; + onToggleDefaultCollapsed: (containerId: string) => void; + onToggleCollapsible: (containerId: string) => void; + onToggleBordered: (containerId: string) => void; + onDeleteContainer: ( + containerId: string, + action: 'ungroup' | 'delete', + ) => void; + onAddTile: (containerId?: string, tabId?: string) => void; + onAddContainer: () => void; + onAddTab: (containerId: string) => string | undefined; + onRenameTab: (containerId: string, tabId: string, title: string) => void; + onDeleteTab: ( + containerId: string, + tabId: string, + action: TabDeleteAction, + ) => void; + onRenameContainer: (containerId: string, title: string) => void; + onTabChange: (containerId: string, tabId: string) => void; + makeLayoutChangeHandler: (tiles: Tile[]) => (newLayout: RGL.Layout[]) => void; +}; + +export function DashboardGrid({ + canRenderDashboard, + hasTiles, + containers, + ungroupedTiles, + selectedTileIds, + setSelectedTileIds, + onGroupSelected, + onReorderContainers, + onUngroupedLayoutChange, + renderTileComponent, + tileToLayoutItem, + tilesByContainerId, + isContainerCollapsed, + getActiveTabId, + alertingTabIdsByContainer, + onToggleCollapse, + onToggleDefaultCollapsed, + onToggleCollapsible, + onToggleBordered, + onDeleteContainer, + onAddTile, + onAddContainer, + onAddTab, + onRenameTab, + onDeleteTab, + onRenameContainer, + onTabChange, + makeLayoutChangeHandler, +}: DashboardGridProps) { + return ( + <> + {selectedTileIds.size > 0 && ( + + + + {selectedTileIds.size} tile{selectedTileIds.size > 1 ? 's' : ''}{' '} + selected + + + + + + )} + + {canRenderDashboard ? ( + + An error occurred while rendering the dashboard. +
+ } + > + + {ungroupedTiles.length > 0 && ( + + {ungroupedTiles.map(renderTileComponent)} + + )} + {containers.map(container => ( + + {(dragHandleProps: DragHandleProps) => ( + onToggleCollapse(container.id)} + onToggleDefaultCollapsed={() => + onToggleDefaultCollapsed(container.id) + } + onToggleCollapsible={() => + onToggleCollapsible(container.id) + } + onToggleBordered={() => onToggleBordered(container.id)} + onDeleteContainer={action => + onDeleteContainer(container.id, action) + } + onAddTile={onAddTile} + onAddTab={() => { + const newTabId = onAddTab(container.id); + if (newTabId) onTabChange(container.id, newTabId); + }} + onRenameTab={(tabId, title) => + onRenameTab(container.id, tabId, title) + } + onDeleteTab={(tabId, action) => + onDeleteTab(container.id, tabId, action) + } + onRenameContainer={title => + onRenameContainer(container.id, title) + } + onTabChange={tabId => onTabChange(container.id, tabId)} + dragHandleProps={dragHandleProps} + makeLayoutChangeHandler={makeLayoutChangeHandler} + tileToLayoutItem={tileToLayoutItem} + renderTileComponent={renderTileComponent} + /> + )} + + ))} + + + ) : null} + + + + + + + } + onClick={() => onAddTile()} + > + New Tile + + + } + onClick={onAddContainer} + > + New Group + + + + + ); +} diff --git a/packages/app/src/DBDashboardPage/DashboardHeader.tsx b/packages/app/src/DBDashboardPage/DashboardHeader.tsx new file mode 100644 index 0000000000..5fa8cda88f --- /dev/null +++ b/packages/app/src/DBDashboardPage/DashboardHeader.tsx @@ -0,0 +1,222 @@ +import Link from 'next/link'; +import { formatDistanceToNow } from 'date-fns'; +import { + ActionIcon, + Anchor, + Breadcrumbs, + Button, + Flex, + Group, + Menu, + Paper, + Text, + Tooltip, +} from '@mantine/core'; +import { + IconDeviceFloppy, + IconDotsVertical, + IconDownload, + IconTags, + IconTrash, + IconUpload, + IconX, +} from '@tabler/icons-react'; + +import { FavoriteButton } from '@/components/FavoriteButton'; +import { Tags } from '@/components/Tags'; +import { Dashboard } from '@/dashboard'; +import { EditablePageName } from '@/EditablePageName'; +import { FormatTime } from '@/useFormatTime'; + +type DashboardHeaderProps = { + dashboard: Dashboard | undefined; + dashboardHash: string | number | undefined; + isLocalDashboard: boolean; + hasTiles: boolean | undefined; + hasSavedQueryAndFilterDefaults: boolean; + onCreateDashboard: () => void; + onRenameDashboard: (editedName: string) => void; + onUpdateTags: (newTags: string[]) => void; + onExportDashboard: () => void; + onImportDashboard: () => void; + onSaveQuery: () => void; + onRemoveSavedQuery: () => void; + onDeleteDashboard: () => void; +}; + +export function DashboardHeader({ + dashboard, + dashboardHash, + isLocalDashboard, + hasTiles, + hasSavedQueryAndFilterDefaults, + onCreateDashboard, + onRenameDashboard, + onUpdateTags, + onExportDashboard, + onImportDashboard, + onSaveQuery, + onRemoveSavedQuery, + onDeleteDashboard, +}: DashboardHeaderProps) { + return ( + <> + {isLocalDashboard ? ( + <> + + + Dashboards + + + Temporary Dashboard + + + + + + This is a temporary dashboard and can not be saved. + + + + + + ) : ( + + + + Dashboards + + + {dashboard?.name ?? 'Untitled'} + + + {!isLocalDashboard && dashboard && ( + + {dashboard.createdBy && ( + + Created by{' '} + {dashboard.createdBy.name || dashboard.createdBy.email}.{' '} + + )} + {dashboard.updatedAt && ( + + + {dashboard.updatedBy + ? ` by ${dashboard.updatedBy.name || dashboard.updatedBy.email}` + : ''} + + } + > + {`Updated ${formatDistanceToNow(new Date(dashboard.updatedAt), { addSuffix: true })}.`} + + )} + + )} + + )} + + + + {!isLocalDashboard && dashboard?.id && ( + + )} + {!isLocalDashboard && dashboard?.id && ( + + + + )} + {!isLocalDashboard /* local dashboards cant be "deleted" */ && ( + + + + + + + + + {hasTiles && ( + } + onClick={onExportDashboard} + > + Export Dashboard + + )} + } + onClick={onImportDashboard} + > + {hasTiles ? 'Import New Dashboard' : 'Import Dashboard'} + + + } + onClick={onSaveQuery} + > + {hasSavedQueryAndFilterDefaults + ? 'Update Default Query & Filters' + : 'Save Query & Filters as Default'} + + {hasSavedQueryAndFilterDefaults && ( + } + color="red" + onClick={onRemoveSavedQuery} + > + Remove Default Query & Filters + + )} + + } + color="red" + onClick={onDeleteDashboard} + > + Delete Dashboard + + + + )} + + {/* */} + + + ); +} diff --git a/packages/app/src/DBDashboardPage/DashboardTile.tsx b/packages/app/src/DBDashboardPage/DashboardTile.tsx new file mode 100644 index 0000000000..a7c260c496 --- /dev/null +++ b/packages/app/src/DBDashboardPage/DashboardTile.tsx @@ -0,0 +1,709 @@ +import { + ForwardedRef, + forwardRef, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { formatRelative } from 'date-fns'; +import { pick } from 'lodash'; +import { ErrorBoundary } from 'react-error-boundary'; +import { + displayTypeSupportsBuilderAlerts, + displayTypeSupportsRawSqlAlerts, +} from '@hyperdx/common-utils/dist/core/utils'; +import { + displayTypeRequiresSource, + isBuilderChartConfig, + isBuilderSavedChartConfig, + isRawSqlChartConfig, + isRawSqlSavedChartConfig, +} from '@hyperdx/common-utils/dist/guards'; +import { + AlertState, + ChartConfigWithDateRange, + DisplayType, + Filter, + getSampleWeightExpression, + isLogSource, + isTraceSource, + SourceKind, + SQLInterval, +} from '@hyperdx/common-utils/dist/types'; +import { + ActionIcon, + Box, + Flex, + Indicator, + Menu, + Stack, + Text, + Tooltip, +} from '@mantine/core'; +import { useHotkeys } from '@mantine/hooks'; +import { + IconArrowsMaximize, + IconBell, + IconCopy, + IconCornerDownRight, + IconPencil, + IconTrash, + IconZoomExclamation, +} from '@tabler/icons-react'; + +import { buildTableRowSearchUrl } from '@/ChartUtils'; +import ChartContainer from '@/components/charts/ChartContainer'; +import DBNumberChart from '@/components/DBNumberChart'; +import { DBPieChart } from '@/components/DBPieChart'; +import DBSqlRowTableWithSideBar from '@/components/DBSqlRowTableWithSidebar'; +import DBTableChart from '@/components/DBTableChart'; +import { DBTimeChart } from '@/components/DBTimeChart'; +import FullscreenPanelModal from '@/components/FullscreenPanelModal'; +import { type Tile } from '@/dashboard'; +import HDXMarkdownChart from '@/HDXMarkdownChart'; +import { getFirstTimestampValueExpression, useSource } from '@/source'; +import { getMetricTableName } from '@/utils'; + +import { HeatmapTile } from './HeatmapTile'; +import { type MoveTarget } from './types'; + +type DashboardTileProps = { + chart: Tile; + dateRange: [Date, Date]; + onDuplicateClick: () => void; + onEditClick: () => void; + onAddAlertClick?: () => void; + onDeleteClick: () => void; + onUpdateChart?: (chart: Tile) => void; + onMoveToGroup?: (containerId: string | undefined, tabId?: string) => void; + moveTargets?: MoveTarget[]; + onSettled?: () => void; + granularity: SQLInterval | undefined; + onTimeRangeSelect: (start: Date, end: Date) => void; + filters?: Filter[]; + + // Properties forwarded by grid layout + className?: string; + style?: React.CSSProperties; + onMouseDown?: (e: React.MouseEvent) => void; + onMouseUp?: (e: React.MouseEvent) => void; + onTouchEnd?: (e: React.TouchEvent) => void; + children?: React.ReactNode; // Resizer tooltip + isHighlighted?: boolean; + isSelected?: boolean; + onSelect?: (tileId: string) => void; +}; + +export const DashboardTile = forwardRef( + ( + { + chart, + dateRange, + onDuplicateClick, + onEditClick, + onDeleteClick, + onUpdateChart, + onMoveToGroup, + moveTargets, + granularity, + onTimeRangeSelect, + filters, + + // Properties forwarded by grid layout + className, + style, + onMouseDown, + onMouseUp, + onTouchEnd, + children, + isHighlighted, + isSelected, + onSelect, + }: DashboardTileProps, + ref: ForwardedRef, + ) => { + const [isFullscreen, setIsFullscreen] = useState(false); + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + if (isHighlighted) { + document + .getElementById(`chart-${chart.id}`) + ?.scrollIntoView({ behavior: 'smooth' }); + } + }, [chart.id, isHighlighted]); + + // YouTube-style 'f' key shortcut for fullscreen toggle + useHotkeys([['f', () => isFocused && setIsFullscreen(prev => !prev)]]); + + const [queriedConfig, setQueriedConfig] = useState< + ChartConfigWithDateRange | undefined + >(undefined); + + const { data: source, isFetched: isSourceFetched } = useSource({ + id: chart.config.source, + }); + + const isSourceMissing = + !!chart.config.source && isSourceFetched && source == null; + const isSourceUnset = + !!chart.config && + isBuilderSavedChartConfig(chart.config) && + displayTypeRequiresSource(chart.config.displayType) && + !chart.config.source; + + useEffect(() => { + if (isRawSqlSavedChartConfig(chart.config)) { + // Some raw SQL charts don't have a source + if (!chart.config.source) { + setQueriedConfig({ + ...chart.config, + dateRange, + granularity, + filters, + }); + } else if (source != null) { + setQueriedConfig({ + ...chart.config, + // Populate these columns from the source to support Lucene-based filters and metric table macros + ...pick(source, [ + 'implicitColumnExpression', + 'from', + 'metricTables', + ]), + sampleWeightExpression: getSampleWeightExpression(source), + dateRange, + granularity, + filters, + }); + } + + return; + } + + if (source != null && isBuilderSavedChartConfig(chart.config)) { + const isMetricSource = source.kind === SourceKind.Metric; + + // TODO: will need to update this when we allow for multiple metrics per chart + const firstSelect = chart.config.select[0]; + const metricType = + isMetricSource && typeof firstSelect !== 'string' + ? firstSelect?.metricType + : undefined; + const tableName = getMetricTableName(source, metricType); + if (source.connection) { + setQueriedConfig({ + ...chart.config, + connection: source.connection, + dateRange, + granularity, + timestampValueExpression: source.timestampValueExpression, + from: { + databaseName: source.from?.databaseName || 'default', + tableName: tableName || '', + }, + implicitColumnExpression: + isLogSource(source) || isTraceSource(source) + ? source.implicitColumnExpression + : undefined, + sampleWeightExpression: getSampleWeightExpression(source), + filters, + metricTables: isMetricSource ? source.metricTables : undefined, + }); + } + } + }, [source, chart, dateRange, granularity, filters]); + + const [hovered, setHovered] = useState(false); + + const alert = chart.config.alert; + const alertIndicatorColor = useMemo(() => { + if (!alert) { + return 'transparent'; + } + if (alert.state === AlertState.OK) { + return 'green'; + } + if (alert.silenced?.at) { + return 'yellow'; + } + return 'red'; + }, [alert]); + + const alertTooltip = useMemo(() => { + if (!alert) { + return 'Add alert'; + } + let tooltip = `Has alert and is in ${alert.state} state`; + if (alert.silenced?.at) { + const silencedAt = new Date(alert.silenced.at); + // eslint-disable-next-line no-restricted-syntax + tooltip += `. Ack'd ${formatRelative(silencedAt, new Date())}`; + } + return tooltip; + }, [alert]); + + const filterWarning = useMemo(() => { + const doFiltersExist = !!filters?.filter( + f => (f.type === 'lucene' || f.type === 'sql') && f.condition.trim(), + )?.length; + const doLuceneFiltersExist = !!filters?.filter( + f => f.type === 'lucene' && f.condition.trim(), + )?.length; + + if ( + !doFiltersExist || + !queriedConfig || + !isRawSqlChartConfig(queriedConfig) + ) + return null; + + const isMissingSourceForFiltering = !queriedConfig.source; + const isMissingFiltersMacro = + !queriedConfig.sqlTemplate.includes('$__filters'); + const isMetricsSourceWithLuceneFilter = + source?.kind === SourceKind.Metric && doLuceneFiltersExist; + + if ( + !isMissingSourceForFiltering && + !isMissingFiltersMacro && + !isMetricsSourceWithLuceneFilter + ) + return null; + + const message = isMissingFiltersMacro + ? 'Filters are not applied because the SQL does not include the required $__filters macro' + : isMetricsSourceWithLuceneFilter + ? 'Lucene filters are not applied because they are not supported for metrics sources.' + : 'Filters are not applied because no Source is set for this chart'; + + return ( + + + + ); + }, [filters, queriedConfig, source]); + + const hoverToolbar = useMemo(() => { + const isRawSql = isRawSqlSavedChartConfig(chart.config); + const displayTypeSupportsAlerts = isRawSql + ? displayTypeSupportsRawSqlAlerts(chart.config.displayType) + : displayTypeSupportsBuilderAlerts(chart.config.displayType); + return ( + e.stopPropagation()} + key="hover-toolbar" + my={4} // Margin to ensure that the Alert Indicator doesn't clip on non-Line/Bar display types + style={{ visibility: hovered ? 'visible' : 'hidden' }} + > + {displayTypeSupportsAlerts && ( + +} + mr={4} + > + + + + + + + )} + + + + + { + e.stopPropagation(); + setIsFullscreen(true); + }} + title="View Fullscreen (f)" + > + + + + + + {onMoveToGroup && moveTargets && moveTargets.length > 0 && ( + + + + + + + + + + Move to Group + {chart.containerId && ( + onMoveToGroup(undefined)}> + (Ungrouped) + + )} + {moveTargets + .filter( + t => + !( + t.containerId === chart.containerId && + t.tabId === chart.tabId + ), + ) + .map(t => ( + onMoveToGroup(t.containerId, t.tabId)} + > + {t.allTabs ? ( + + {t.allTabs.map((tab, i) => ( + + {i > 0 && ( + + {' | '} + + )} + + {tab.title} + + + ))} + + ) : ( + t.label + )} + + ))} + + + )} + + + + + ); + }, [ + alert, + alertIndicatorColor, + alertTooltip, + moveTargets, + chart.config, + chart.id, + chart.containerId, + chart.tabId, + hovered, + onDeleteClick, + onDuplicateClick, + onEditClick, + onMoveToGroup, + ]); + + const title = useMemo( + () => ( + + {chart.config.name} + + ), + [chart.config.name], + ); + + // Render chart content (used in both tile and fullscreen views) + const renderChartContent = useCallback( + (hideToolbar: boolean = false, isFullscreenView: boolean = false) => { + const toolbar = hideToolbar + ? [filterWarning] + : [hoverToolbar, filterWarning]; + const keyPrefix = isFullscreenView ? 'fullscreen' : 'tile'; + + // Markdown charts may not have queriedConfig, if config.source is not set + const effectiveMarkdownConfig = queriedConfig ?? chart.config; + + return ( + + An error occurred while rendering the chart. + + } + > + {isSourceMissing ? ( + + + + The data source for this tile no longer exists. Edit the + tile to select a new source. + + + + ) : isSourceUnset ? ( + + + + The data source for this tile is not set. Edit the tile to + select a data source. + + + + ) : ( + <> + {(queriedConfig?.displayType === DisplayType.Line || + queriedConfig?.displayType === DisplayType.StackedBar) && ( + { + onUpdateChart?.({ + ...chart, + config: { + ...chart.config, + displayType, + }, + }); + }} + /> + )} + {queriedConfig?.displayType === DisplayType.Table && ( + + + buildTableRowSearchUrl({ + row, + source, + config: queriedConfig, + dateRange: dateRange, + }) + : undefined + } + /> + + )} + {queriedConfig?.displayType === DisplayType.Number && ( + + )} + {queriedConfig?.displayType === DisplayType.Pie && ( + + )} + {queriedConfig?.displayType === DisplayType.Heatmap && + isBuilderChartConfig(queriedConfig) && ( + + )} + {effectiveMarkdownConfig?.displayType === + DisplayType.Markdown && + 'markdown' in effectiveMarkdownConfig && ( + + )} + {queriedConfig?.displayType === DisplayType.Search && + isBuilderChartConfig(queriedConfig) && + isBuilderSavedChartConfig(chart.config) && ( + + + + )} + + )} + + ); + }, + [ + hoverToolbar, + queriedConfig, + title, + chart, + onTimeRangeSelect, + onUpdateChart, + source, + dateRange, + filterWarning, + isSourceMissing, + isSourceUnset, + ], + ); + + return ( + <> +
{ + setHovered(true); + setIsFocused(true); + }} + onMouseLeave={() => { + setHovered(false); + setIsFocused(false); + }} + key={chart.id} + ref={ref} + style={{ + ...style, + ...(isSelected + ? { + outline: '2px solid var(--color-outline-focus)', + outlineOffset: -2, + } + : {}), + }} + onClick={e => { + if (e.shiftKey && onSelect) { + e.preventDefault(); + onSelect(chart.id); + } + }} + onMouseDown={onMouseDown} + onMouseUp={onMouseUp} + onTouchEnd={onTouchEnd} + > + {hovered && ( +
+ )} +
e.stopPropagation()} + > + {renderChartContent()} +
+ {children} +
+ + {/* Fullscreen Modal */} + setIsFullscreen(false)} + > + {isFullscreen && renderChartContent(true, true)} + + + ); + }, +); diff --git a/packages/app/src/DBDashboardPage/DashboardToolbar.tsx b/packages/app/src/DBDashboardPage/DashboardToolbar.tsx new file mode 100644 index 0000000000..e41d0d4b7b --- /dev/null +++ b/packages/app/src/DBDashboardPage/DashboardToolbar.tsx @@ -0,0 +1,136 @@ +import { Dispatch, SetStateAction } from 'react'; +import { Control, UseFormSetValue } from 'react-hook-form'; +import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { SQLInterval } from '@hyperdx/common-utils/dist/types'; +import { ActionIcon, Button, Flex, Tooltip } from '@mantine/core'; +import { + IconFilterEdit, + IconPlayerPlay, + IconRefresh, +} from '@tabler/icons-react'; + +import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; +import { TimePicker } from '@/components/TimePicker'; +import { GranularityPickerControlled } from '@/GranularityPicker'; + +import { DashboardQueryFormValues } from './types'; + +type DashboardToolbarProps = { + tableConnections: TableConnection[]; + control: Control; + setValue: UseFormSetValue; + displayedTimeInputValue: string; + setDisplayedTimeInputValue: Dispatch>; + onSubmit: () => void; + onSearch: (range: string) => void; + isRefreshEnabled: boolean; + granularityOverride: SQLInterval | undefined; + isLive: boolean; + setIsLive: Dispatch>; + refresh: () => void; + manualRefreshCooloff: boolean; + onOpenFilters: () => void; +}; + +export function DashboardToolbar({ + tableConnections, + control, + setValue, + displayedTimeInputValue, + setDisplayedTimeInputValue, + onSubmit, + onSearch, + isRefreshEnabled, + granularityOverride, + isLive, + setIsLive, + refresh, + manualRefreshCooloff, + onOpenFilters, +}: DashboardToolbarProps) { + return ( + { + e.preventDefault(); + onSubmit(); + }} + > + + setValue('whereLanguage', lang) + } + label="WHERE" + enableHotkey + allowMultiline + minWidth={300} + data-testid="search-input" + /> + { + onSearch(range); + }} + /> + + + + + + + + + + + + + + + + + ); +} diff --git a/packages/app/src/DBDashboardPage/EditTileModal.tsx b/packages/app/src/DBDashboardPage/EditTileModal.tsx new file mode 100644 index 0000000000..ea6a92cba8 --- /dev/null +++ b/packages/app/src/DBDashboardPage/EditTileModal.tsx @@ -0,0 +1,89 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Modal } from '@mantine/core'; + +import EditTimeChartForm from '@/components/DBEditTimeChartForm'; +import { type Tile } from '@/dashboard'; +import { useConfirm } from '@/useConfirm'; +import { useZIndex, ZIndexContext } from '@/zIndex'; + +type EditTileModalProps = { + dashboardId?: string; + chart: Tile | undefined; + onClose: () => void; + dateRange: [Date, Date]; + isSaving?: boolean; + onSave: (chart: Tile) => void; +}; + +export function EditTileModal({ + dashboardId, + chart, + onClose, + onSave, + isSaving, + dateRange, +}: EditTileModalProps) { + const contextZIndex = useZIndex(); + const modalZIndex = contextZIndex + 10; + const confirm = useConfirm(); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + + useEffect(() => { + if (chart != null) { + setHasUnsavedChanges(false); + } + }, [chart]); + + const handleClose = useCallback(() => { + if (isSaving) return; + if (hasUnsavedChanges) { + confirm( + 'You have unsaved changes. Discard them and close the editor?', + 'Discard', + ).then(ok => { + if (ok) { + // Reset dirty state before closing so any re-invocation of + // handleClose (e.g. from Mantine focus management after the + // confirm modal closes) doesn't re-show the confirm dialog. + setHasUnsavedChanges(false); + onClose(); + } + }); + } else { + onClose(); + } + }, [confirm, isSaving, hasUnsavedChanges, onClose]); + + return ( + + {chart != null && ( + + { + onSave({ + ...chart, + config: config, + }); + }} + onClose={handleClose} + onDirtyChange={setHasUnsavedChanges} + isDashboardForm + autoRun + /> + + )} + + ); +} diff --git a/packages/app/src/DBDashboardPage/HeatmapTile.tsx b/packages/app/src/DBDashboardPage/HeatmapTile.tsx new file mode 100644 index 0000000000..99ff29ad2c --- /dev/null +++ b/packages/app/src/DBDashboardPage/HeatmapTile.tsx @@ -0,0 +1,144 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import Link from 'next/link'; +import { + BuilderChartConfigWithDateRange, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { Group, Popover, Portal } from '@mantine/core'; +import { IconSearch } from '@tabler/icons-react'; + +import { buildEventsSearchUrl } from '@/ChartUtils'; +import DBHeatmapChart, { + toHeatmapChartConfig, +} from '@/components/DBHeatmapChart'; + +type HeatmapTileProps = { + keyPrefix: string; + chartId: string; + title: React.ReactNode; + toolbar: React.ReactNode[]; + queriedConfig: BuilderChartConfigWithDateRange; + source: TSource | undefined; + dateRange: [Date, Date]; +}; + +export function HeatmapTile({ + keyPrefix, + chartId, + title, + toolbar, + queriedConfig, + source, + dateRange, +}: HeatmapTileProps) { + const { heatmapConfig, scaleType } = toHeatmapChartConfig(queriedConfig); + + const [clickPos, setClickPos] = useState<{ x: number; y: number } | null>( + null, + ); + const containerRef = useRef(null); + + const eventDeltasUrl = useMemo(() => { + if (!source) return null; + const url = buildEventsSearchUrl({ + source, + config: queriedConfig, + dateRange, + }); + if (!url) return null; + const separator = url.includes('?') ? '&' : '?'; + return `${url}${separator}mode=delta`; + }, [source, queriedConfig, dateRange]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + if (!eventDeltasUrl) return; + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + setClickPos({ x: e.clientX - rect.left, y: e.clientY - rect.top }); + }, + [eventDeltasUrl], + ); + + const dismiss = useCallback(() => setClickPos(null), []); + + return ( +
+ + {clickPos != null && eventDeltasUrl != null && ( + <> + +
{ + e.stopPropagation(); + e.preventDefault(); + dismiss(); + }} + onMouseDown={e => e.stopPropagation()} + /> + + { + if (!opened) dismiss(); + }} + position="bottom-start" + offset={4} + withinPortal + closeOnEscape + withArrow + shadow="md" + > + +
+ + e.stopPropagation()} + onMouseDown={e => e.stopPropagation()} + > + + + + View in Event Deltas + + + + + + )} +
+ ); +} diff --git a/packages/app/src/DBDashboardPage/__tests__/DashboardGrid.test.tsx b/packages/app/src/DBDashboardPage/__tests__/DashboardGrid.test.tsx new file mode 100644 index 0000000000..7c28371ff1 --- /dev/null +++ b/packages/app/src/DBDashboardPage/__tests__/DashboardGrid.test.tsx @@ -0,0 +1,84 @@ +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { DashboardGrid } from '../DashboardGrid'; + +const renderDashboardGrid = ( + overrides: Partial> = {}, +) => { + const onGroupSelected = jest.fn(); + const setSelectedTileIds = jest.fn(); + const onAddTile = jest.fn(); + const onAddContainer = jest.fn(); + + renderWithMantine( + , + ); + + return { + onGroupSelected, + setSelectedTileIds, + onAddTile, + onAddContainer, + }; +}; + +describe('DashboardGrid', () => { + it('shows selected tile actions and invokes callbacks', async () => { + const user = userEvent.setup(); + const { onGroupSelected, setSelectedTileIds } = renderDashboardGrid({ + selectedTileIds: new Set(['tile-1', 'tile-2']), + }); + + expect(screen.getByText('2 tiles selected')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'Group' })); + expect(onGroupSelected).toHaveBeenCalled(); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + expect(setSelectedTileIds).toHaveBeenCalledWith(new Set()); + }); + + it('opens the add menu and invokes add callbacks', async () => { + const user = userEvent.setup(); + const { onAddTile, onAddContainer } = renderDashboardGrid(); + + await user.click(screen.getByTestId('add-dropdown-button')); + await user.click(await screen.findByText('New Tile')); + expect(onAddTile).toHaveBeenCalledWith(); + + await user.click(screen.getByTestId('add-dropdown-button')); + await user.click(await screen.findByText('New Group')); + expect(onAddContainer).toHaveBeenCalled(); + }); +}); diff --git a/packages/app/src/DBDashboardPage/__tests__/DashboardTile.test.tsx b/packages/app/src/DBDashboardPage/__tests__/DashboardTile.test.tsx new file mode 100644 index 0000000000..21149bdfa4 --- /dev/null +++ b/packages/app/src/DBDashboardPage/__tests__/DashboardTile.test.tsx @@ -0,0 +1,344 @@ +import { + AlertState, + DisplayType, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; +import { screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { type Tile } from '@/dashboard'; + +jest.mock('@/source', () => ({ + __esModule: true, + useSource: jest.fn(), + getFirstTimestampValueExpression: (s: string) => s, +})); + +jest.mock('@/components/charts/ChartContainer', () => ({ + __esModule: true, + default: ({ + title, + toolbarItems, + children, + }: { + title: React.ReactNode; + toolbarItems?: React.ReactNode[]; + children?: React.ReactNode; + }) => ( +
+
{title}
+
{toolbarItems}
+
{children}
+
+ ), +})); + +// Render toolbarPrefix/toolbarItems so the hover-toolbar (alert button, +// filter warning) is visible in the DOM for assertions. +jest.mock('@/components/DBNumberChart', () => ({ + __esModule: true, + default: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/DBPieChart', () => ({ + __esModule: true, + DBPieChart: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/DBSqlRowTableWithSidebar', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('@/components/DBTableChart', () => ({ + __esModule: true, + default: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/DBTimeChart', () => ({ + __esModule: true, + DBTimeChart: ({ toolbarPrefix }: { toolbarPrefix?: React.ReactNode }) => ( +
{toolbarPrefix}
+ ), +})); +jest.mock('@/components/FullscreenPanelModal', () => ({ + __esModule: true, + default: ({ + opened, + children, + }: { + opened: boolean; + children: React.ReactNode; + }) => (opened ?
{children}
: null), +})); +jest.mock('@/HDXMarkdownChart', () => ({ + __esModule: true, + default: () =>
, +})); +jest.mock('@/ChartUtils', () => ({ + __esModule: true, + buildTableRowSearchUrl: jest.fn(), +})); +jest.mock('../HeatmapTile', () => ({ + __esModule: true, + HeatmapTile: () =>
, +})); + +import { useSource } from '@/source'; + +import { DashboardTile } from '../DashboardTile'; + +const mockUseSource = useSource as jest.MockedFunction; + +const baseSource = { + id: 'src-1', + kind: SourceKind.Log, + name: 'Logs', + connection: 'conn-1', + from: { databaseName: 'default', tableName: 'logs' }, + timestampValueExpression: 'Timestamp', + defaultTableSelectExpression: '', + implicitColumnExpression: '', +} as any; + +const setSource = (source: typeof baseSource | undefined, isFetched = true) => { + mockUseSource.mockReturnValue({ + data: source, + isFetched, + } as any); +}; + +const baseProps = { + dateRange: [new Date('2024-01-01'), new Date('2024-01-02')] as [Date, Date], + onDuplicateClick: jest.fn(), + onEditClick: jest.fn(), + onDeleteClick: jest.fn(), + granularity: undefined, + onTimeRangeSelect: jest.fn(), +}; + +const makeTile = (overrides: Partial = {}): Tile => + ({ + id: 'tile-1', + x: 0, + y: 0, + w: 4, + h: 4, + config: { + name: 'My Tile', + source: 'src-1', + displayType: DisplayType.Line, + select: [], + where: '', + whereLanguage: 'sql', + ...((overrides as any).config ?? {}), + }, + ...overrides, + }) as Tile; + +const getIndicatorColor = (alertButton: HTMLElement) => { + // The button is wrapped in a Tooltip (target span) which is wrapped in + // Mantine's Indicator. The Indicator's outer element carries + // --indicator-color via inline style. + let el: HTMLElement | null = alertButton; + while (el) { + const value = el.style?.getPropertyValue?.('--indicator-color'); + if (value) return value; + el = el.parentElement; + } + return ''; +}; + +describe('DashboardTile', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('source state messaging', () => { + it('shows source-missing message when the source id resolves to null', () => { + setSource(undefined, true); + renderWithMantine( + , + ); + expect( + screen.getByText( + /The data source for this tile no longer exists\. Edit the tile to select a new source\./, + ), + ).toBeInTheDocument(); + }); + + it('shows source-unset message when the chart has no source and requires one', () => { + setSource(undefined, true); + const tile = makeTile({ + config: { + name: 'Unset', + displayType: DisplayType.Line, + select: [], + } as any, + }); + renderWithMantine(); + expect( + screen.getByText( + /The data source for this tile is not set\. Edit the tile to select a data source\./, + ), + ).toBeInTheDocument(); + }); + + it('does not show the source-unset message for a Markdown tile (sourceless display type)', () => { + setSource(undefined, true); + const tile = makeTile({ + config: { + name: 'Notes', + displayType: DisplayType.Markdown, + select: [], + markdown: 'hello', + } as any, + }); + renderWithMantine(); + expect( + screen.queryByText( + /The data source for this tile is not set\. Edit the tile to select a data source\./, + ), + ).not.toBeInTheDocument(); + }); + }); + + describe('alert indicator color', () => { + const tileWithAlert = (alert: any) => + makeTile({ + config: { + name: 'Alerted', + source: 'src-1', + displayType: DisplayType.Line, + select: [], + alert, + } as any, + }); + + it('uses transparent when no alert is configured', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toBe('transparent'); + }); + + it('uses green for AlertState.OK', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toMatch(/green/); + }); + + it('uses red for AlertState.ALERT without silence', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toMatch(/red/); + }); + + it('uses yellow when an alert is silenced', () => { + setSource(baseSource); + renderWithMantine( + , + ); + const btn = screen.getByTestId('tile-alerts-button-tile-1'); + expect(getIndicatorColor(btn)).toMatch(/yellow/); + }); + }); + + describe('filter warning', () => { + it('renders the warning icon when a raw SQL chart lacks the $__filters macro', () => { + setSource(baseSource); + const tile = makeTile({ + config: { + configType: 'sql', + name: 'Raw', + source: 'src-1', + displayType: DisplayType.Line, + select: '', + sqlTemplate: 'SELECT count() FROM logs', + } as any, + }); + const { container } = renderWithMantine( + , + ); + // tabler IconZoomExclamation renders an SVG with this class + expect( + container.querySelectorAll('.tabler-icon-zoom-exclamation').length, + ).toBeGreaterThan(0); + }); + + it('does not render the warning icon when no filters are set', () => { + setSource(baseSource); + const tile = makeTile({ + config: { + configType: 'sql', + name: 'Raw', + source: 'src-1', + displayType: DisplayType.Line, + select: '', + sqlTemplate: 'SELECT count() FROM logs', + } as any, + }); + const { container } = renderWithMantine( + , + ); + expect( + container.querySelectorAll('.tabler-icon-zoom-exclamation').length, + ).toBe(0); + }); + }); + + describe('f-hotkey fullscreen toggle', () => { + it('opens the fullscreen modal when the fullscreen toolbar button is clicked', async () => { + setSource(baseSource); + const user = userEvent.setup(); + const tile = makeTile(); + renderWithMantine(); + + // Modal is initially closed + expect(screen.queryByTestId('fullscreen-modal')).not.toBeInTheDocument(); + + // The fullscreen toolbar button and the `f` hotkey both flip the same + // `isFullscreen` state, so exercising the button covers that surface. + const fullscreenBtn = screen.getByTestId('tile-fullscreen-button-tile-1'); + await user.click(fullscreenBtn); + + expect(screen.getByTestId('fullscreen-modal')).toBeInTheDocument(); + }); + + it('does not open the fullscreen modal when the `f` hotkey fires without hovering the tile', async () => { + setSource(baseSource); + const user = userEvent.setup(); + const tile = makeTile(); + renderWithMantine(); + + await user.keyboard('f'); + expect(screen.queryByTestId('fullscreen-modal')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/app/src/DBDashboardPage/__tests__/utils.test.ts b/packages/app/src/DBDashboardPage/__tests__/utils.test.ts new file mode 100644 index 0000000000..a7f597211b --- /dev/null +++ b/packages/app/src/DBDashboardPage/__tests__/utils.test.ts @@ -0,0 +1,352 @@ +import { AlertState } from '@hyperdx/common-utils/dist/types'; + +import { type Dashboard, type Tile } from '@/dashboard'; + +import { + buildMoveTargets, + downloadObjectAsJson, + getAlertingTabIdsByContainer, + getDashboardTableConnections, + getTilesByContainerId, + getUngroupedTiles, + hasLayoutChanged, + tileToLayoutItem, + updateLayout, +} from '../utils'; + +const makeTile = ({ + id, + containerId, + tabId, + isAlerting = false, +}: { + id: string; + containerId?: string; + tabId?: string; + isAlerting?: boolean; +}): Tile => + ({ + id, + x: 1, + y: 2, + w: 3, + h: 4, + containerId, + tabId, + config: { + alert: isAlerting ? { state: AlertState.ALERT } : undefined, + }, + }) as Tile; + +describe('DBDashboardPage utils', () => { + it('converts a tile to a react-grid-layout item', () => { + expect(tileToLayoutItem(makeTile({ id: 'tile-1' }))).toEqual({ + i: 'tile-1', + x: 1, + y: 2, + w: 3, + h: 4, + minH: 1, + minW: 1, + }); + }); + + it('detects changed layout dimensions and positions', () => { + const currentLayout = [tileToLayoutItem(makeTile({ id: 'tile-1' }))]; + + expect( + hasLayoutChanged({ + currentLayout, + newLayout: [{ ...currentLayout[0], x: currentLayout[0].x + 1 }], + }), + ).toBe(true); + + expect( + hasLayoutChanged({ + currentLayout, + newLayout: currentLayout, + }), + ).toBe(false); + }); + + it('builds move targets for plain, single-tab, and multi-tab groups', () => { + expect( + buildMoveTargets([ + { id: 'plain', title: 'Plain', collapsed: false }, + { + id: 'single', + title: 'Single', + collapsed: false, + tabs: [{ id: 'single-tab', title: 'Single Tab' }], + }, + { + id: 'multi', + title: 'Multi', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + ]), + ).toEqual([ + { containerId: 'plain', label: 'Plain' }, + { + containerId: 'single', + tabId: 'single-tab', + label: 'Single Tab', + }, + { + containerId: 'multi', + tabId: 'tab-a', + label: 'Tab A', + allTabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + { + containerId: 'multi', + tabId: 'tab-b', + label: 'Tab B', + allTabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + ]); + }); + + it('groups tiles by container and treats orphaned tiles as ungrouped', () => { + const containers = [{ id: 'group-1', title: 'Group 1', collapsed: false }]; + const allTiles = [ + makeTile({ id: 'grouped', containerId: 'group-1' }), + makeTile({ id: 'orphaned', containerId: 'deleted-group' }), + makeTile({ id: 'ungrouped' }), + ]; + + const tilesByContainerId = getTilesByContainerId({ + containers, + allTiles, + }); + + expect(tilesByContainerId.get('group-1')?.map(tile => tile.id)).toEqual([ + 'grouped', + ]); + expect( + getUngroupedTiles({ + hasContainers: true, + allTiles, + tilesByContainerId, + }).map(tile => tile.id), + ).toEqual(['orphaned', 'ungrouped']); + }); + + it('attributes alerting tiles to their tab or the first tab fallback', () => { + const containers = [ + { + id: 'group-1', + title: 'Group 1', + collapsed: false, + tabs: [ + { id: 'tab-a', title: 'Tab A' }, + { id: 'tab-b', title: 'Tab B' }, + ], + }, + ]; + const tilesByContainerId = getTilesByContainerId({ + containers, + allTiles: [ + makeTile({ id: 'fallback', containerId: 'group-1', isAlerting: true }), + makeTile({ + id: 'tabbed', + containerId: 'group-1', + tabId: 'tab-b', + isAlerting: true, + }), + makeTile({ id: 'quiet', containerId: 'group-1' }), + ], + }); + + expect( + getAlertingTabIdsByContainer({ + containers, + tilesByContainerId, + }).get('group-1'), + ).toEqual(new Set(['tab-a', 'tab-b'])); + }); + + it('updateLayout mutates tile coordinates by id', () => { + const dashboard = { + id: 'dash-1', + name: 'Test', + tiles: [ + { ...makeTile({ id: 'tile-1' }) }, + { ...makeTile({ id: 'tile-2' }) }, + ], + tags: [], + } as Dashboard; + + updateLayout([ + { i: 'tile-1', x: 10, y: 20, w: 5, h: 6 }, + { i: 'tile-missing', x: 99, y: 99, w: 99, h: 99 }, + ])(dashboard); + + expect(dashboard.tiles[0]).toMatchObject({ + id: 'tile-1', + x: 10, + y: 20, + w: 5, + h: 6, + }); + // Untouched tile keeps its original coords + expect(dashboard.tiles[1]).toMatchObject({ + id: 'tile-2', + x: 1, + y: 2, + w: 3, + h: 4, + }); + }); + + it('downloadObjectAsJson triggers a download via a temporary anchor', () => { + const click = jest.fn(); + const remove = jest.fn(); + const setAttribute = jest.fn(); + const fakeAnchor = { setAttribute, click, remove } as unknown as Element; + + const createElementSpy = jest + .spyOn(document, 'createElement') + .mockReturnValue(fakeAnchor as HTMLAnchorElement); + const appendChildSpy = jest + .spyOn(document.body, 'appendChild') + .mockReturnValue(fakeAnchor as Node); + + downloadObjectAsJson({ hello: 'world' }, 'my-file'); + + expect(createElementSpy).toHaveBeenCalledWith('a'); + expect(setAttribute).toHaveBeenCalledWith( + 'href', + `data:text/json;charset=utf-8,${encodeURIComponent( + JSON.stringify({ hello: 'world' }), + )}`, + ); + expect(setAttribute).toHaveBeenCalledWith('download', 'my-file.json'); + expect(appendChildSpy).toHaveBeenCalledWith(fakeAnchor); + expect(click).toHaveBeenCalledTimes(1); + expect(remove).toHaveBeenCalledTimes(1); + + createElementSpy.mockRestore(); + appendChildSpy.mockRestore(); + }); + + describe('getDashboardTableConnections', () => { + it('returns [] when dashboard is undefined', () => { + expect( + getDashboardTableConnections({ + dashboard: undefined, + sources: [], + }), + ).toEqual([]); + }); + + it('skips tiles that are not builder configs', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'raw' }), + config: { configType: 'sql', source: 'src-1' } as any, + }, + ], + } as Dashboard; + expect(getDashboardTableConnections({ dashboard, sources: [] })).toEqual( + [], + ); + }); + + it('skips tiles whose source cannot be resolved', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'b' }), + config: { source: 'unknown', select: [] } as any, + }, + ], + } as Dashboard; + expect( + getDashboardTableConnections({ + dashboard, + sources: [{ id: 'src-1' } as any], + }), + ).toEqual([]); + }); + + it('skips tiles whose tableName cannot be resolved', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'b' }), + config: { source: 'src-1', select: [] } as any, + }, + ], + } as Dashboard; + // Source exists but has no `from.tableName`, so getMetricTableName falls + // back to source.from?.tableName which is undefined → skip. + expect( + getDashboardTableConnections({ + dashboard, + sources: [ + { + id: 'src-1', + kind: 'log', + from: { databaseName: 'default' }, + connection: 'conn-1', + } as any, + ], + }), + ).toEqual([]); + }); + + it('returns a TableConnection for each builder tile with a resolvable source/table', () => { + const dashboard = { + id: 'dash', + name: 'd', + tags: [], + tiles: [ + { + ...makeTile({ id: 'b' }), + config: { source: 'src-1', select: [] } as any, + }, + ], + } as Dashboard; + expect( + getDashboardTableConnections({ + dashboard, + sources: [ + { + id: 'src-1', + kind: 'log', + from: { databaseName: 'default', tableName: 'logs' }, + connection: 'conn-1', + } as any, + ], + }), + ).toEqual([ + { + databaseName: 'default', + tableName: 'logs', + connectionId: 'conn-1', + }, + ]); + }); + }); +}); diff --git a/packages/app/src/DBDashboardPage/index.ts b/packages/app/src/DBDashboardPage/index.ts new file mode 100644 index 0000000000..cb3af3fecf --- /dev/null +++ b/packages/app/src/DBDashboardPage/index.ts @@ -0,0 +1 @@ +export { default } from './DBDashboardPage'; diff --git a/packages/app/src/DBDashboardPage/types.ts b/packages/app/src/DBDashboardPage/types.ts new file mode 100644 index 0000000000..1e03f6c4e0 --- /dev/null +++ b/packages/app/src/DBDashboardPage/types.ts @@ -0,0 +1,19 @@ +import { + SearchCondition, + SearchConditionLanguage, + SQLInterval, +} from '@hyperdx/common-utils/dist/types'; + +export type DashboardQueryFormValues = { + granularity: SQLInterval | 'auto'; + where: SearchCondition; + whereLanguage: SearchConditionLanguage; +}; + +export type MoveTarget = { + containerId: string; + tabId?: string; + label: string; + // For tabs: all tabs in order with the target tab ID + allTabs?: { id: string; title: string }[]; +}; diff --git a/packages/app/src/DBDashboardPage/utils.ts b/packages/app/src/DBDashboardPage/utils.ts new file mode 100644 index 0000000000..1c1dd8e765 --- /dev/null +++ b/packages/app/src/DBDashboardPage/utils.ts @@ -0,0 +1,190 @@ +import RGL from 'react-grid-layout'; +import { TableConnection } from '@hyperdx/common-utils/dist/core/metadata'; +import { isBuilderSavedChartConfig } from '@hyperdx/common-utils/dist/guards'; +import { + AlertState, + DashboardContainer as DashboardContainerSchema, + TSource, +} from '@hyperdx/common-utils/dist/types'; + +import { Dashboard, type Tile } from '@/dashboard'; +import { getMetricTableName } from '@/utils'; + +import { MoveTarget } from './types'; + +export const tileToLayoutItem = (chart: Tile): RGL.Layout => ({ + i: chart.id, + x: chart.x, + y: chart.y, + w: chart.w, + h: chart.h, + minH: 1, + minW: 1, +}); + +export const updateLayout = (newLayout: RGL.Layout[]) => { + return (dashboard: Dashboard) => { + for (const chart of dashboard.tiles) { + const newChartLayout = newLayout.find(layout => layout.i === chart.id); + if (newChartLayout) { + chart.x = newChartLayout.x; + chart.y = newChartLayout.y; + chart.w = newChartLayout.w; + chart.h = newChartLayout.h; + } + } + }; +}; + +export function hasLayoutChanged({ + currentLayout, + newLayout, +}: { + currentLayout: RGL.Layout[]; + newLayout: RGL.Layout[]; +}) { + if (newLayout.length !== currentLayout.length) { + return true; + } + + for (const curr of newLayout) { + const old = currentLayout.find(l => l.i === curr.i); + if ( + old?.x !== curr.x || + old?.y !== curr.y || + old?.h !== curr.h || + old?.w !== curr.w + ) { + return true; + } + } + + return false; +} + +export function downloadObjectAsJson(object: object, fileName = 'output') { + const dataStr = + 'data:text/json;charset=utf-8,' + + encodeURIComponent(JSON.stringify(object)); + const downloadAnchorNode = document.createElement('a'); + downloadAnchorNode.setAttribute('href', dataStr); + downloadAnchorNode.setAttribute('download', fileName + '.json'); + document.body.appendChild(downloadAnchorNode); // required for firefox + downloadAnchorNode.click(); + downloadAnchorNode.remove(); +} + +export function getDashboardTableConnections({ + dashboard, + sources, +}: { + dashboard: Dashboard | undefined; + sources: TSource[] | undefined; +}): TableConnection[] { + if (!dashboard) return []; + const tableConnections: TableConnection[] = []; + + for (const { config } of dashboard.tiles) { + if (!isBuilderSavedChartConfig(config)) continue; + const source = sources?.find(v => v.id === config.source); + if (!source) continue; + // TODO: will need to update this when we allow for multiple metrics per chart + const firstSelect = config.select[0]; + const metricType = + typeof firstSelect !== 'string' ? firstSelect?.metricType : undefined; + const tableName = getMetricTableName(source, metricType); + if (!tableName) continue; + tableConnections.push({ + databaseName: source.from.databaseName, + tableName: tableName, + connectionId: source.connection, + }); + } + + return tableConnections; +} + +export function buildMoveTargets( + containers: DashboardContainerSchema[], +): MoveTarget[] { + const targets: MoveTarget[] = []; + for (const container of containers) { + const tabs = container.tabs ?? []; + if (tabs.length >= 2) { + for (const tab of tabs) { + targets.push({ + containerId: container.id, + tabId: tab.id, + label: tab.title, + allTabs: tabs.map(t => ({ id: t.id, title: t.title })), + }); + } + } else if (tabs.length === 1) { + // 1-tab group: show just the group name, target the single tab + targets.push({ + containerId: container.id, + tabId: tabs[0].id, + label: tabs[0].title, + }); + } else { + targets.push({ containerId: container.id, label: container.title }); + } + } + return targets; +} + +export function getTilesByContainerId({ + containers, + allTiles, +}: { + containers: DashboardContainerSchema[]; + allTiles: Tile[]; +}): Map { + const map = new Map(); + for (const container of containers) { + map.set( + container.id, + allTiles.filter(tile => tile.containerId === container.id), + ); + } + return map; +} + +export function getUngroupedTiles({ + hasContainers, + allTiles, + tilesByContainerId, +}: { + hasContainers: boolean; + allTiles: Tile[]; + tilesByContainerId: Map; +}): Tile[] { + return hasContainers + ? allTiles.filter( + tile => !tile.containerId || !tilesByContainerId.has(tile.containerId), + ) + : allTiles; +} + +export function getAlertingTabIdsByContainer({ + containers, + tilesByContainerId, +}: { + containers: DashboardContainerSchema[]; + tilesByContainerId: Map; +}): Map> { + const map = new Map>(); + for (const container of containers) { + const tiles = tilesByContainerId.get(container.id) ?? []; + const firstTabId = container.tabs?.[0]?.id; + const alerting = new Set(); + for (const tile of tiles) { + if (tile.config.alert?.state === AlertState.ALERT) { + const attributedTabId = tile.tabId ?? firstTabId; + if (attributedTabId) alerting.add(attributedTabId); + } + } + if (alerting.size > 0) map.set(container.id, alerting); + } + return map; +}