mirror of
https://github.com/supabase/supabase.git
synced 2026-10-11 04:15:04 +03:00
Closes [FE-3378](https://linear.app/supabase/issue/FE-3378/featlogs-keyboard-shortcuts-for-function-logs-invocations-and-logs). ## Summary Adds a shared shortcut registry for every `LogsPreviewer` surface — Function Logs, Function Invocations, and the Logs Explorer — and brings the grid keyboard model in line with the Auth Users / Table Editor patterns. ## Shortcuts | Key | Action | | --- | --- | | `↑` / `↓` | Move single-row selection; opens side panel | | `Shift+Space` | Toggle current row in multi-select | | `Mod+A` | Toggle all visible rows in multi-select | | `Esc` | Staged: clear multi-select → close side panel | | `Shift+R` | Refresh logs | | `Shift+H` | Toggle histogram | | `Shift+L` | Load older logs | | `Shift+P` | Open time range picker | | `Mod+Shift+J / M / C` | Copy selected rows as JSON / Markdown / CSV (existing global handler) | ## Other changes - `ShortcutTooltip` on search, refresh, histogram, load older, and time-picker controls. - `onSearchInputEscape` wired on the logs search bar (clear → blur). - Visual row highlight (`rdg-row--focused`) when a row is keyboard-focused or multi-selected. - Multi-select copy dropdown gains a **Copy as CSV** entry and shows the keybind on each item via `ShortcutBadge`. - Manual arrow-nav (`navigate()`) updates `selectedRow` directly without going through `onRowClick`, so multi-select checkmarks survive keyboard navigation. ## Test plan - [x] Function Logs and Function Invocations: all shortcuts above fire while the page is mounted, no firing in other tabs. - [x] Logs Explorer: same shortcuts work; copy keybinds still copy *all* rows when nothing is multi-selected. - [x] Arrow keys on first load select the first row even when the focus sink is the active element. - [x] Selecting rows via checkbox or `Shift+Space`, then pressing arrow keys, preserves the checkmarks. - [x] Escape on a populated search input clears it; Escape on an empty input blurs it. - [x] Esc with multi-select active clears the selection before closing the side panel. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * CSV export for log selections (adds CSV alongside JSON and Markdown). * New logs-preview keyboard shortcuts: search focus, refresh, chart toggle, date picker, load older, navigation, and selection. * **Improvements** * Shortcut badges and tooltip integration across the logs UI. * Search input focus/ref support and controlled date-picker visibility. * Better no-results/error rendering and expanded copy dropdown sizing. * **Tests** * Added CSV formatting tests covering RFC 4180 edge cases. <!-- review_stack_entry_start --> [](https://app.coderabbit.ai/change-stack/supabase/supabase/pull/45989) <!-- review_stack_entry_end --> <!-- end of auto-generated comment: release notes by coderabbit.ai -->
393 lines
13 KiB
TypeScript
393 lines
13 KiB
TypeScript
import { useParams } from 'common'
|
|
import dayjs from 'dayjs'
|
|
import { Rewind } from 'lucide-react'
|
|
import { useRouter } from 'next/router'
|
|
import { PropsWithChildren, useEffect, useRef, useState } from 'react'
|
|
import { Button } from 'ui'
|
|
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
|
|
|
|
import {
|
|
LOG_ROUTES_WITH_REPLICA_SUPPORT,
|
|
LOGS_TABLES,
|
|
LogsTableName,
|
|
PREVIEWER_DATEPICKER_HELPERS,
|
|
} from './Logs.constants'
|
|
import { DatePickerValue } from './Logs.DatePickers'
|
|
import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
|
|
import { maybeShowUpgradePromptIfNotEntitled } from './Logs.utils'
|
|
import { LogTable } from './LogTable'
|
|
import UpgradePrompt from './UpgradePrompt'
|
|
import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts'
|
|
import PreviewFilterPanel from '@/components/interfaces/Settings/Logs/PreviewFilterPanel'
|
|
import LoadingOpacity from '@/components/ui/LoadingOpacity'
|
|
import ShimmerLine from '@/components/ui/ShimmerLine'
|
|
import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
|
|
import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
|
|
import useLogsPreview from '@/hooks/analytics/useLogsPreview'
|
|
import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
|
|
import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
|
|
import useSingleLog from '@/hooks/analytics/useSingleLog'
|
|
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
|
import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
|
|
import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
|
|
import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
|
|
import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
|
|
|
|
/**
|
|
* Calculates the appropriate time range for bar click filtering based on the current time range duration.
|
|
*
|
|
* @param currentRangeStart - The start timestamp of the current time range
|
|
* @param currentRangeEnd - The end timestamp of the current time range
|
|
* @param clickedTimestamp - The timestamp of the clicked bar
|
|
* @returns Object containing the new start and end timestamps for filtering
|
|
*/
|
|
export const calculateBarClickTimeRange = (
|
|
currentRangeStart: string,
|
|
currentRangeEnd: string | undefined,
|
|
clickedTimestamp: string
|
|
) => {
|
|
const datumTimestamp = dayjs(clickedTimestamp).toISOString()
|
|
|
|
// Calculate the current time range duration in hours
|
|
// If currentRangeEnd is not provided, use current time as the end
|
|
const endTime = currentRangeEnd ? dayjs(currentRangeEnd) : dayjs()
|
|
const currentRangeDuration = endTime.diff(dayjs(currentRangeStart), 'hour', true)
|
|
|
|
let rangeOffset: number
|
|
let rangeUnit: dayjs.ManipulateType
|
|
|
|
if (currentRangeDuration >= 12) {
|
|
// For ranges >= 12h, use 1h range
|
|
rangeOffset = 0.5
|
|
rangeUnit = 'hour'
|
|
} else if (currentRangeDuration >= 1) {
|
|
// For ranges >= 1h but < 12h, use 5min range
|
|
rangeOffset = 2.5
|
|
rangeUnit = 'minute'
|
|
} else if (currentRangeDuration >= 1 / 30) {
|
|
// 2 minutes = 1/30 hour
|
|
// For ranges >= 2min but < 1h, use 2min range
|
|
rangeOffset = 1
|
|
rangeUnit = 'minute'
|
|
} else {
|
|
// For ranges < 2min, use 15sec range
|
|
rangeOffset = 7.5
|
|
rangeUnit = 'second'
|
|
}
|
|
|
|
return {
|
|
start: dayjs(datumTimestamp).subtract(rangeOffset, rangeUnit).toISOString(),
|
|
end: dayjs(datumTimestamp).add(rangeOffset, rangeUnit).toISOString(),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Acts as a container component for the entire log display
|
|
*
|
|
* ## Query Params Syncing
|
|
* Query params are synced on query submission.
|
|
*
|
|
* params used are:
|
|
* - `s` for search query.
|
|
* - `te` for timestamp start value.
|
|
*/
|
|
interface LogsPreviewerProps {
|
|
projectRef: string
|
|
queryType: QueryType
|
|
filterOverride?: Filters
|
|
condensedLayout?: boolean
|
|
tableName?: LogsTableName
|
|
EmptyState?: React.ReactNode
|
|
filterPanelClassName?: string
|
|
}
|
|
export const LogsPreviewer = ({
|
|
projectRef,
|
|
queryType,
|
|
filterOverride,
|
|
condensedLayout = false,
|
|
tableName,
|
|
children,
|
|
EmptyState,
|
|
filterPanelClassName,
|
|
}: PropsWithChildren<LogsPreviewerProps>) => {
|
|
const router = useRouter()
|
|
const { db } = useParams()
|
|
const { data: organization } = useSelectedOrganizationQuery()
|
|
const state = useDatabaseSelectorStateSnapshot()
|
|
|
|
const searchInputRef = useRef<HTMLInputElement>(null)
|
|
const [showChart, setShowChart] = useState(true)
|
|
const [selectedDatePickerValue, setSelectedDatePickerValue] = useState<DatePickerValue>(
|
|
getDefaultDatePickerValue()
|
|
)
|
|
|
|
const { search, setSearch, timestampStart, timestampEnd, setTimeRange, filters, setFilters } =
|
|
useLogsUrlState()
|
|
|
|
useEffect(() => {
|
|
if (timestampStart && timestampEnd) {
|
|
setSelectedDatePickerValue({
|
|
to: timestampEnd,
|
|
from: timestampStart,
|
|
text: `${dayjs(timestampStart).format('DD MMM, HH:mm')} - ${dayjs(timestampEnd).format('DD MMM, HH:mm')}`,
|
|
isHelper: false,
|
|
})
|
|
}
|
|
}, [timestampStart, timestampEnd])
|
|
|
|
const [selectedLogId, setSelectedLogId] = useSelectedLog()
|
|
const { data: databases, isSuccess } = useReadReplicasQuery({ projectRef })
|
|
|
|
// TODO: Move this to useLogsUrlState to simplify LogsPreviewer. - Jordi
|
|
function getDefaultDatePickerValue() {
|
|
const iso_timestamp_start = router.query.iso_timestamp_start as string
|
|
const iso_timestamp_end = router.query.iso_timestamp_end as string
|
|
|
|
if (iso_timestamp_start && iso_timestamp_end) {
|
|
return {
|
|
to: iso_timestamp_end,
|
|
from: iso_timestamp_start,
|
|
text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`,
|
|
isHelper: false,
|
|
}
|
|
}
|
|
|
|
const defaultDatePickerValue = PREVIEWER_DATEPICKER_HELPERS.find((x) => x.default)
|
|
return {
|
|
to: defaultDatePickerValue!.calcTo(),
|
|
from: defaultDatePickerValue!.calcFrom(),
|
|
text: defaultDatePickerValue!.text,
|
|
isHelper: true,
|
|
}
|
|
}
|
|
|
|
const table = !tableName ? LOGS_TABLES[queryType] : tableName
|
|
|
|
const {
|
|
error,
|
|
logData,
|
|
params,
|
|
newCount,
|
|
isLoading,
|
|
eventChartData,
|
|
isLoadingOlder,
|
|
loadOlder,
|
|
refresh,
|
|
} = useLogsPreview({ projectRef, table, filterOverride })
|
|
|
|
const {
|
|
data: selectedLog,
|
|
isLoading: isSelectedLogLoading,
|
|
error: selectedLogError,
|
|
} = useSingleLog({
|
|
projectRef,
|
|
id: selectedLogId ?? undefined,
|
|
queryType,
|
|
paramsToMerge: params,
|
|
})
|
|
|
|
const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(timestampStart)
|
|
|
|
const onSelectTemplate = (template: LogTemplate) => {
|
|
setFilters({ ...filters, search_query: template.searchString })
|
|
}
|
|
|
|
// [Joshen] For helper date picker values, reset the timestamp start to prevent data caching
|
|
// Since the helpers are "Last n minutes" -> hitting refresh, you'd expect to see the latest result
|
|
// Whereas if a specific range is selected, you'd not expect new data to show up
|
|
const handleRefresh = () => {
|
|
if (selectedDatePickerValue.isHelper) {
|
|
const helper = PREVIEWER_DATEPICKER_HELPERS.find(
|
|
(x) => x.text === selectedDatePickerValue.text
|
|
)
|
|
if (helper) {
|
|
const newTimestampStart = helper.calcFrom()
|
|
setTimeRange(newTimestampStart, timestampEnd)
|
|
}
|
|
}
|
|
refresh()
|
|
}
|
|
|
|
const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
|
|
const entitledToAuditLogDays = getEntitlementNumericValue()
|
|
|
|
const handleSearch: LogSearchCallback = async (event, { query, to, from }) => {
|
|
if (event === 'search-input-change') {
|
|
setSearch(query || '')
|
|
setSelectedLogId(null)
|
|
} else if (event === 'event-chart-bar-click') {
|
|
setTimeRange(from || '', to || '')
|
|
} else if (event === 'datepicker-change') {
|
|
const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
|
|
from || '',
|
|
entitledToAuditLogDays
|
|
)
|
|
if (shouldShowUpgradePrompt) {
|
|
setShowUpgradePrompt(!showUpgradePrompt)
|
|
} else {
|
|
setTimeRange(from || '', to || '')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show the prompt on page load based on query params
|
|
useEffect(() => {
|
|
if (timestampStart) {
|
|
const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
|
|
timestampStart,
|
|
entitledToAuditLogDays
|
|
)
|
|
if (shouldShowUpgradePrompt) {
|
|
setShowUpgradePrompt(!showUpgradePrompt)
|
|
}
|
|
}
|
|
}, [timestampStart, organization])
|
|
|
|
useEffect(() => {
|
|
if (db !== undefined) {
|
|
const database = databases?.find((d) => d.identifier === db)
|
|
if (database !== undefined) state.setSelectedDatabaseId(db)
|
|
} else if (state.selectedDatabaseId !== undefined && state.selectedDatabaseId !== projectRef) {
|
|
if (LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname)) {
|
|
router.push({
|
|
pathname: router.pathname,
|
|
query: { ...router.query, db: state.selectedDatabaseId },
|
|
})
|
|
} else {
|
|
state.setSelectedDatabaseId(projectRef)
|
|
}
|
|
}
|
|
}, [db, isSuccess])
|
|
|
|
// Common props shared between both filter panel components to avoid duplication
|
|
const filterPanelProps = {
|
|
className: filterPanelClassName,
|
|
csvData: logData,
|
|
isLoading,
|
|
newCount,
|
|
onRefresh: handleRefresh,
|
|
onSearch: handleSearch,
|
|
defaultSearchValue: search,
|
|
defaultToValue: timestampEnd,
|
|
defaultFromValue: timestampStart,
|
|
queryUrl: `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
|
|
params.sql || ''
|
|
)}&its=${encodeURIComponent(timestampStart)}&ite=${encodeURIComponent(timestampEnd)}`,
|
|
onSelectTemplate,
|
|
filters,
|
|
onFiltersChange: setFilters,
|
|
table,
|
|
condensedLayout,
|
|
isShowingEventChart: showChart,
|
|
onToggleEventChart: () => setShowChart(!showChart),
|
|
onSelectedDatabaseChange: (id: string) => {
|
|
setFilters({ ...filters, database: id !== projectRef ? id : undefined })
|
|
const { db, ...params } = router.query
|
|
router.push({
|
|
pathname: router.pathname,
|
|
query: id !== projectRef ? { ...router.query, db: id } : params,
|
|
})
|
|
},
|
|
selectedDatePickerValue,
|
|
setSelectedDatePickerValue,
|
|
searchInputRef,
|
|
}
|
|
|
|
useLogsPreviewShortcuts({
|
|
searchInputRef,
|
|
hasSearch: search.length > 0,
|
|
onResetSearch: () => {
|
|
setSearch('')
|
|
setSelectedLogId(null)
|
|
},
|
|
onRefresh: handleRefresh,
|
|
onToggleChart: () => setShowChart((prev) => !prev),
|
|
onLoadOlder: loadOlder,
|
|
canLoadOlder: !error && logData.length > 0 && !isLoadingOlder,
|
|
})
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col h-full">
|
|
<PreviewFilterPanel {...filterPanelProps} />
|
|
{children}
|
|
<div
|
|
className={
|
|
'transition-all duration-500 ' +
|
|
(showChart && logData.length > 0 ? 'mb-2 mt-1 opacity-100' : 'h-0 opacity-0')
|
|
}
|
|
>
|
|
<div className={condensedLayout ? 'px-3' : ''}>
|
|
{showChart && (
|
|
<LogsBarChart
|
|
data={eventChartData}
|
|
onBarClick={(datum) => {
|
|
if (!datum?.timestamp) return
|
|
|
|
const { start, end } = calculateBarClickTimeRange(
|
|
timestampStart,
|
|
timestampEnd,
|
|
datum.timestamp
|
|
)
|
|
|
|
handleSearch('event-chart-bar-click', {
|
|
query: filters.search_query?.toString(),
|
|
to: end,
|
|
from: start,
|
|
})
|
|
}}
|
|
EmptyState={
|
|
<div className="flex flex-col items-center justify-center h-[67px]">
|
|
<p className="text-foreground-light text-xs">No data</p>
|
|
<p className="text-foreground-lighter text-xs">
|
|
It may take up to 24 hours for data to refresh
|
|
</p>
|
|
</div>
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="relative flex flex-col grow flex-1 overflow-auto">
|
|
<ShimmerLine active={isLoading} />
|
|
<LoadingOpacity active={isLoading}>
|
|
<LogTable
|
|
projectRef={projectRef}
|
|
isLoading={isLoading}
|
|
data={logData}
|
|
queryType={queryType}
|
|
isHistogramShowing={showChart}
|
|
onHistogramToggle={() => setShowChart(!showChart)}
|
|
error={error}
|
|
EmptyState={EmptyState}
|
|
onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
|
|
selectedLog={selectedLog}
|
|
isSelectedLogLoading={isSelectedLogLoading}
|
|
selectedLogError={selectedLogError ?? undefined}
|
|
/>
|
|
</LoadingOpacity>
|
|
</div>
|
|
{!error && logData.length > 0 && (
|
|
<div className="border-t flex flex-row items-center gap-3 p-2">
|
|
<ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER} side="top">
|
|
<Button
|
|
onClick={loadOlder}
|
|
icon={<Rewind />}
|
|
type="default"
|
|
loading={isLoadingOlder}
|
|
disabled={isLoadingOlder}
|
|
>
|
|
Load older
|
|
</Button>
|
|
</ShortcutTooltip>
|
|
<div className="text-sm text-foreground-lighter">
|
|
Showing <span className="font-mono">{logData.length}</span> results
|
|
</div>
|
|
<div className="flex flex-row justify-end mt-2">
|
|
<UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|