diff --git a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx index 44ab6291afd..17708d338ce 100644 --- a/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx +++ b/apps/studio/components/interfaces/QueryInsights/hooks/useQueryInsightsTableColumns.tsx @@ -8,7 +8,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, - DropdownMenuSeparator, DropdownMenuTrigger, Tooltip, TooltipContent, @@ -556,27 +555,25 @@ export function useQueryInsightsTableColumns({ buildPrompt={() => buildQueryInsightFixPrompt(row).prompt} onOpenAssistant={() => handleAiSuggestedFix(row)} copyLabel="Copy Markdown" - extraDropdownItems={ - <> - handleGoToLogs()} className="gap-2"> - - Go to Logs - - {row.issueType === 'slow' && ( - { - setSelectedTriageRow(props.rowIdx) - setSheetView('explain') - }} - className="gap-2" - > - - Explain - - )} - - - } + additionalDropdownItems={[ + { + label: 'Go to Logs', + icon: , + onClick: () => handleGoToLogs(), + }, + ...(row.issueType === 'slow' + ? [ + { + label: 'Explain', + icon: , + onClick: () => { + setSelectedTriageRow(props.rowIdx) + setSheetView('explain') + }, + }, + ] + : []), + ]} /> )} diff --git a/apps/studio/components/interfaces/UnifiedLogs/RowSelectionHeader.tsx b/apps/studio/components/interfaces/UnifiedLogs/RowSelectionHeader.tsx new file mode 100644 index 00000000000..2edf080d685 --- /dev/null +++ b/apps/studio/components/interfaces/UnifiedLogs/RowSelectionHeader.tsx @@ -0,0 +1,114 @@ +import { AnimatePresence, motion } from 'framer-motion' +import { Copy, X } from 'lucide-react' +import { toast } from 'sonner' +import { + copyToClipboard, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' + +import { type LogData } from '../Settings/Logs/Logs.types' +import { + buildLogsPrompt, + formatLogsAsJson, + formatLogsAsMarkdown, +} from '../Settings/Logs/Logs.utils' +import { SIDEBAR_KEYS } from '@/components/layouts/ProjectLayout/LayoutSidebar/LayoutSidebarProvider' +import { AiAssistantDropdown } from '@/components/ui/AiAssistantDropdown' +import { ButtonTooltip } from '@/components/ui/ButtonTooltip' +import { useDataTable } from '@/components/ui/DataTable/providers/DataTableProvider' +import { useAiAssistantStateSnapshot } from '@/state/ai-assistant-state' +import { useSidebarManagerSnapshot } from '@/state/sidebar-manager-state' + +// TODO - format Logs as JSON, as markdown, and as prompt + +export const RowSelectionHeader = () => { + const { openSidebar } = useSidebarManagerSnapshot() + const aiSnap = useAiAssistantStateSnapshot() + + const { table } = useDataTable() + const selectedRows = table.getSelectedRowModel().rows.map((x) => x.original) as LogData[] + + const handleOpenAiAssistant = () => { + const prompt = buildLogsPrompt(selectedRows) + openSidebar(SIDEBAR_KEYS.AI_ASSISTANT) + aiSnap.newChat({ initialMessage: prompt }) + } + + const onCopy = (format: 'json' | 'markdown') => { + const text = + format === 'json' ? formatLogsAsJson(selectedRows) : formatLogsAsMarkdown(selectedRows) + copyToClipboard(text, () => { + toast.success( + `Copied ${selectedRows.length} log${selectedRows.length !== 1 ? 's' : ''} as ${format.toUpperCase()}` + ) + }) + } + + return ( +
+ + {selectedRows.length > 0 && ( + +

+ {selectedRows.length} row{selectedRows.length > 1 ? 's' : ''} selected +

+ +
+ + + } + className="w-7" + tooltip={{ content: { side: 'bottom', text: 'Copy selected logs' } }} + /> + + + onCopy('json')} className="gap-2 text-xs"> + + Copy as JSON + + onCopy('markdown')} className="gap-2 text-xs"> + + Copy as Markdown + + + + + buildLogsPrompt(selectedRows)} + onOpenAssistant={handleOpenAiAssistant} + telemetrySource="log_explorer" + /> + + } + className="px-1" + onClick={() => table.resetRowSelection()} + tooltip={{ content: { side: 'bottom', text: 'Clear selection' } }} + /> +
+
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx index 07088400a03..cd421eee538 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/ServiceFlow/components/ServiceFlowPanelControls.tsx @@ -25,14 +25,12 @@ export const ServiceFlowPanelControls = ({ dock = 'bottom', setDock, }: ServiceFlowPanelControlsProps) => { - const { table, rowSelection, isLoading } = useDataTable() - - const selectedRowKey = Object.keys(rowSelection)?.[0] + const { table, openRowId, setOpenRowId, isLoading } = useDataTable() const selectedRowData = useMemo(() => { - if (isLoading && !selectedRowKey) return - return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) - }, [selectedRowKey, isLoading, table]) + if (isLoading && !openRowId) return + return table.getCoreRowModel().flatRows.find((row) => row.id === openRowId) + }, [openRowId, isLoading, table]) const index = table.getCoreRowModel().flatRows.findIndex((row) => row.id === selectedRowData?.id) @@ -49,20 +47,20 @@ export const ServiceFlowPanelControls = ({ ) const onPrev = useCallback(() => { - if (prevId) table.setRowSelection({ [prevId]: true }) - }, [prevId, table]) + if (prevId) setOpenRowId(prevId) + }, [prevId, setOpenRowId]) const onNext = useCallback(() => { - if (nextId) table.setRowSelection({ [nextId]: true }) - }, [nextId, table]) + if (nextId) setOpenRowId(nextId) + }, [nextId, setOpenRowId]) const onClose = useCallback(() => { - table.resetRowSelection() - }, [table]) + setOpenRowId(undefined) + }, [setOpenRowId]) useEffect(() => { const down = (e: KeyboardEvent) => { - if (!selectedRowKey) return + if (!openRowId) return const activeElement = document.activeElement if (activeElement?.closest('[role="menu"]')) return @@ -87,7 +85,7 @@ export const ServiceFlowPanelControls = ({ document.addEventListener('keydown', down) return () => document.removeEventListener('keydown', down) - }, [selectedRowKey, onNext, onPrev]) + }, [openRowId, onNext, onPrev]) return (
diff --git a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx index ae1abc01f5b..a06406666aa 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/UnifiedLogs.tsx @@ -33,6 +33,7 @@ import { DownloadLogsButton } from './components/DownloadLogsButton' import { LogsFilterBar } from './components/LogsFilterBar' import { LogsListPanel } from './components/LogsListPanel' import { TooltipLabel } from './components/TooltipLabel' +import { RowSelectionHeader } from './RowSelectionHeader' import { ServiceFlowPanel } from './ServiceFlowPanel' import { SEARCH_PARAMS_PARSER } from './UnifiedLogs.constants' import { filterFields as defaultFilterFields } from './UnifiedLogs.fields' @@ -85,7 +86,6 @@ export const UnifiedLogs = () => { const { sort, start, size, id, cursor, direction, live, ...filter } = search const defaultColumnSorting = sort ? [sort] : [] const defaultColumnVisibility = { uuid: false } - const defaultRowSelection = search.id ? { [search.id]: true } : {} const defaultColumnFilters = Object.entries(filter) .map(([key, value]) => ({ id: key, value })) .filter(({ value }) => value ?? undefined) @@ -106,7 +106,8 @@ export const UnifiedLogs = () => { const [sorting, setSorting] = useState(defaultColumnSorting) const [columnFilters, setColumnFilters] = useState(defaultColumnFilters) - const [rowSelection, setRowSelection] = useState(defaultRowSelection) + const [rowSelection, setRowSelection] = useState({}) + const [openRowId, setOpenRowId] = useState(search.id ?? undefined) const [dock, setDock] = useLocalStorageQuery<'bottom' | 'right'>( LOCAL_STORAGE_KEYS.UNIFIED_LOGS_DOCK, @@ -222,7 +223,7 @@ export const UnifiedLogs = () => { // Generate dynamic columns based on current data const { columns: dynamicColumns, columnVisibility: dynamicColumnVisibility } = useMemo(() => { - return generateDynamicColumns(flatData) + return generateDynamicColumns({ data: flatData }) }, [flatData]) const table: Table = useReactTable({ @@ -235,7 +236,7 @@ export const UnifiedLogs = () => { rowSelection, columnOrder, }, - enableMultiRowSelection: false, + enableMultiRowSelection: true, columnResizeMode: 'onChange', filterFns: { inDateRange, arrSome }, meta: { getRowClassName }, @@ -253,12 +254,10 @@ export const UnifiedLogs = () => { getFacetedMinMaxValues: getTTableFacetedMinMaxValues(), }) - const selectedRowKey = Object.keys(rowSelection)?.[0] const selectedRow = useMemo(() => { if ((isLoading || isFetching) && !flatData.length) return - - return table.getCoreRowModel().flatRows.find((row) => row.id === selectedRowKey) - }, [isLoading, isFetching, flatData.length, table, selectedRowKey]) + return table.getCoreRowModel().flatRows.find((row) => row.id === openRowId) + }, [isLoading, isFetching, flatData.length, table, openRowId]) // REMINDER: this is currently needed for the cmdk search // [Joshen] This is where facets are getting dynamically loaded @@ -321,24 +320,20 @@ export const UnifiedLogs = () => { useEffect(() => { if (isLoading || isFetching) return - const selectedRowId = Object.keys(rowSelection)?.[0] - if (selectedRowId && !selectedRow) { - // Clear both uuid and logId when no row is selected + if (openRowId && !selectedRow) { + // Clear both uuid and logId when the open row no longer exists in data setSearch({ id: null }) - setRowSelection({}) - } else if (selectedRowId && selectedRow) { - setSearch({ - id: selectedRowId, - }) + setOpenRowId(undefined) + } else if (openRowId && selectedRow) { + setSearch({ id: openRowId }) track('unified_logs_row_clicked', { logType: selectedRow.original.log_type }) - // Don't clear rowSelection here - let it persist to maintain the selection - } else if (!selectedRowId && search.id) { - // Clear the URL parameter when no row is selected + } else if (!openRowId && search.id) { + // Clear the URL parameter when no row is open setSearch({ id: null }) } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [rowSelection, selectedRow, isLoading, isFetching]) + }, [openRowId, selectedRow, isLoading, isFetching]) const isMobile = useIsMobile() const [isFilterBarOpen, setIsFilterBarOpen] = useState(!isMobile) @@ -353,6 +348,10 @@ export const UnifiedLogs = () => { } }, [isMobile]) + useEffect(() => { + table.resetRowSelection() + }, [searchParameters, table]) + return ( { columnFilters={columnFilters} sorting={sorting} rowSelection={rowSelection} + openRowId={openRowId} + setOpenRowId={setOpenRowId} columnOrder={columnOrder} columnVisibility={columnVisibility} searchParameters={searchParameters} @@ -431,6 +432,8 @@ export const UnifiedLogs = () => { />
+ + { - {!!selectedRow && ( + {!!openRowId && !!selectedRow && ( <> diff --git a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx index 165712c5587..98574cd488d 100644 --- a/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx +++ b/apps/studio/components/interfaces/UnifiedLogs/components/Columns.tsx @@ -1,5 +1,5 @@ import { ColumnDef } from '@tanstack/react-table' -import { Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { Checkbox, Tooltip, TooltipContent, TooltipTrigger } from 'ui' import { STATUS_CODE_LABELS } from '../UnifiedLogs.constants' import { ColumnFilterSchema, ColumnSchema } from '../UnifiedLogs.schema' @@ -30,7 +30,7 @@ function shouldHideColumn(data: ColumnSchema[], columnKey: keyof ColumnSchema): } // Generate dynamic columns based on data -export function generateDynamicColumns(data: ColumnSchema[]): { +export function generateDynamicColumns({ data }: { data: ColumnSchema[] }): { columns: ColumnDef[] columnVisibility: Record } { @@ -39,6 +39,32 @@ export function generateDynamicColumns(data: ColumnSchema[]): { const hideEventMessage = shouldHideColumn(data, 'event_message') const columns: ColumnDef[] = [ + { + accessorKey: 'select', + header: '', + cell: ({ row }) => { + return ( +
+ row.toggleSelected(!!value)} + onClick={(e) => e.stopPropagation()} + /> +
+ ) + }, + enableHiding: false, + enableResizing: false, + enableSorting: false, + filterFn: (_row, _columnId, _filterValue) => true, + size: 48, + minSize: 48, + maxSize: 48, + meta: { + cellClassName: 'w-[32px]', + headerClassName: 'w-[32px]', + }, + }, // Level column - always visible { accessorKey: 'level', @@ -243,4 +269,6 @@ export function generateDynamicColumns(data: ColumnSchema[]): { } // Static fallback columns -export const UNIFIED_LOGS_COLUMNS: ColumnDef[] = generateDynamicColumns([]).columns +export const UNIFIED_LOGS_COLUMNS: ColumnDef[] = generateDynamicColumns({ + data: [], +}).columns diff --git a/apps/studio/components/ui/AiAssistantDropdown.tsx b/apps/studio/components/ui/AiAssistantDropdown.tsx index e3652414c00..da2c95264fd 100644 --- a/apps/studio/components/ui/AiAssistantDropdown.tsx +++ b/apps/studio/components/ui/AiAssistantDropdown.tsx @@ -1,6 +1,7 @@ import { AiAssistantSource } from 'common/telemetry-constants' import { Chatgpt, Claude } from 'icons' import { Check, ChevronDown, Copy } from 'lucide-react' +import Link from 'next/link' import { ComponentProps, ReactNode, useEffect, useState } from 'react' import { AiIconAnimation, @@ -41,7 +42,8 @@ const EXTERNAL_AI_TOOLS = [ export interface AiAssistantDropdownItem { label: string icon?: ReactNode - onClick: () => void + href?: string + onClick?: () => void } export interface AiAssistantDropdownProps { @@ -59,7 +61,6 @@ export interface AiAssistantDropdownProps { tooltip?: string copyLabel?: string showExternalAI?: boolean - extraDropdownItems?: ReactNode additionalDropdownItems?: AiAssistantDropdownItem[] } @@ -78,7 +79,6 @@ export function AiAssistantDropdown({ tooltip, copyLabel = 'Copy prompt', showExternalAI = false, - extraDropdownItems, additionalDropdownItems, }: AiAssistantDropdownProps) { const track = useTrack() @@ -150,11 +150,11 @@ export function AiAssistantDropdown({ /> - {extraDropdownItems} {showCopied ? : } {showCopied ? 'Copied!' : copyLabel} + {showExternalAI && ( <> @@ -170,13 +170,23 @@ export function AiAssistantDropdown({ ))} )} + {additionalDropdownItems && additionalDropdownItems.length > 0 && ( <> {additionalDropdownItems.map((item, i) => ( - {item.icon} - {item.label} + {item.href ? ( + + {item.icon} + {item.label} + + ) : ( + <> + {item.icon} + {item.label} + + )} ))} diff --git a/apps/studio/components/ui/DataTable/DataTable.utils.ts b/apps/studio/components/ui/DataTable/DataTable.utils.ts index deec18fab97..5443a55599a 100644 --- a/apps/studio/components/ui/DataTable/DataTable.utils.ts +++ b/apps/studio/components/ui/DataTable/DataTable.utils.ts @@ -86,43 +86,3 @@ export function getLevelColor( } } } - -export function getStatusColor(value?: number | string): Record<'text' | 'bg' | 'border', string> { - switch (value) { - case '1': - case 'info': - return { - text: 'text-blue-500', - bg: '', - border: 'border-blue-200 dark:border-blue-800', - } - case '2': - case 'success': - return { - text: 'text-foreground', - bg: '', - border: 'border-green-200 dark:border-green-800', - } - case '4': - case 'warning': - case 'redirect': - return { - text: 'text-warning', - bg: 'bg-warning-300 dark:bg-warning-200', - border: 'border border-warning-400/50 dark:border-warning-400/50', - } - case '5': - case 'error': - return { - text: 'text-destructive', - bg: 'bg-destructive-300 dark:bg-destructive-300/50', - border: 'border border-destructive-400/50 dark:border-destructive-400/50', - } - default: - return { - text: 'text-foreground', - bg: '', - border: '', - } - } -} diff --git a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx index dde560e82d9..a4b8ed3ebcf 100644 --- a/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx +++ b/apps/studio/components/ui/DataTable/DataTableColumn/DataTableColumnLevelIndicator.tsx @@ -16,7 +16,7 @@ export const DataTableColumnLevelIndicator = ({
{ - const colors = getStatusColor(level) + const colorClassName = getStatusColor(level) + + function getStatusColor(value?: number | string): string { + switch (value) { + case '1': + case 'info': + return 'text-blue-500' + case '2': + case 'success': + return 'text-foreground' + case '4': + case 'warning': + case 'redirect': + return 'text-warning' + case '5': + case 'error': + return 'text-destructive' + default: + return 'text-foreground' + } + } + if (!value) { return } return (
-
+
{value}
diff --git a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx index e190f9d6dee..221466617b0 100644 --- a/apps/studio/components/ui/DataTable/DataTableInfinite.tsx +++ b/apps/studio/components/ui/DataTable/DataTableInfinite.tsx @@ -45,8 +45,8 @@ export function DataTableInfinite({ setColumnVisibility, searchParamsParser, }: DataTableInfiniteProps) { - const { table, error, isError, isLoading, isFetching } = useDataTable() const tableRef = useRef(null) + const { table, error, isError, isLoading, isFetching, openRowId, setOpenRowId } = useDataTable() const headerGroups = table.getHeaderGroups() const headers = headerGroups[0].headers @@ -128,7 +128,8 @@ export function DataTableInfinite({ row={row} table={table} searchParamsParser={searchParamsParser} - selected={row.getIsSelected()} + selected={row.id === openRowId} + onSelect={() => setOpenRowId(row.id === openRowId ? undefined : row.id)} /> )) ) : isLoading ? ( @@ -229,11 +230,13 @@ function DataTableRow({ table, selected, searchParamsParser, + onSelect, }: { row: Row table: TTable selected?: boolean searchParamsParser: any + onSelect: () => void }) { useQueryState('live', searchParamsParser.live) const rowClassName = (table.options.meta as any)?.getRowClassName?.(row) @@ -244,11 +247,11 @@ function DataTableRow({ id={row.id} tabIndex={0} data-state={selected && 'selected'} - onClick={() => row.toggleSelected()} + onClick={onSelect} onKeyDown={(event) => { if (event.key === 'Enter') { event.preventDefault() - row.toggleSelected() + onSelect() } }} className={cn(rowClassName)} diff --git a/apps/studio/components/ui/DataTable/Table.tsx b/apps/studio/components/ui/DataTable/Table.tsx index 7b5c3d8798c..9f81520bbfe 100644 --- a/apps/studio/components/ui/DataTable/Table.tsx +++ b/apps/studio/components/ui/DataTable/Table.tsx @@ -79,7 +79,7 @@ export const TableHead = forwardRef< className={cn( 'text-xs! font-normal! text-foreground-lighter font-mono', 'relative select-none truncate [&>.cursor-col-resize]:last:opacity-0', - 'text-muted-foreground h-8 px-2 text-left align-middle [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]', + 'text-muted-foreground h-9 px-2 text-left align-middle [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]', className )} {...props} diff --git a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx index 9dfd396da82..b60950fe331 100644 --- a/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx +++ b/apps/studio/components/ui/DataTable/providers/DataTableProvider.tsx @@ -25,6 +25,8 @@ interface DataTableStateContextType { pagination: PaginationState enableColumnOrdering: boolean searchParameters: QuerySearchParamsType + openRowId: string | undefined + setOpenRowId: (id: string | undefined) => void } interface DataTableBaseContextType { @@ -63,6 +65,8 @@ export function DataTableProvider({ pagination: props.pagination ?? { pageIndex: 0, pageSize: 10 }, enableColumnOrdering: props.enableColumnOrdering ?? false, searchParameters: props.searchParameters ?? ({} as any), + openRowId: props.openRowId, + setOpenRowId: props.setOpenRowId ?? (() => {}), }), [props] ) diff --git a/apps/studio/components/ui/ErrorCodeTooltip/ErrorCodeTooltip.tsx b/apps/studio/components/ui/ErrorCodeTooltip/ErrorCodeTooltip.tsx index 13fd2f4f850..83e5125a06f 100644 --- a/apps/studio/components/ui/ErrorCodeTooltip/ErrorCodeTooltip.tsx +++ b/apps/studio/components/ui/ErrorCodeTooltip/ErrorCodeTooltip.tsx @@ -1,17 +1,8 @@ import { ExternalLink } from 'lucide-react' import { useTheme } from 'next-themes' import Image from 'next/image' -import Link from 'next/link' import { useState } from 'react' -import { - cn, - DropdownMenuItem, - DropdownMenuSeparator, - HoverCard, - HoverCardContent, - HoverCardTrigger, - InfoIcon, -} from 'ui' +import { cn, HoverCard, HoverCardContent, HoverCardTrigger, InfoIcon } from 'ui' import { ShimmeringLoader } from 'ui-patterns/ShimmeringLoader' import { getErrorCodeInfo } from './ErrorCodeTooltip.utils' @@ -117,25 +108,19 @@ export const ErrorCodeTooltip = ({ errorCode, service, children }: ErrorCodeTool
)} - - - - Go to Docs - - - - - ) : undefined - } + additionalDropdownItems={[ + { + label: 'Go to Docs', + icon: , + href: docsUrl, + }, + ]} />