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 -->
475 lines
15 KiB
TypeScript
475 lines
15 KiB
TypeScript
import { Label } from '@ui/components/shadcn/ui/label'
|
|
import { RadioGroup, RadioGroupItem } from '@ui/components/shadcn/ui/radio-group'
|
|
import dayjs from 'dayjs'
|
|
import { Clock, HistoryIcon, Lock } from 'lucide-react'
|
|
import type { PropsWithChildren } from 'react'
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import {
|
|
Button,
|
|
ButtonProps,
|
|
Calendar,
|
|
cn,
|
|
copyToClipboard,
|
|
Input,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from 'ui'
|
|
|
|
import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants'
|
|
import type { DatetimeHelper } from './Logs.types'
|
|
import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
|
|
import { TimeSplitInput } from '@/components/ui/DatePicker/TimeSplitInput'
|
|
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
|
|
|
|
type Unit = 'minute' | 'hour' | 'day'
|
|
|
|
export type ParsedCustomInput =
|
|
| { type: 'number'; value: number }
|
|
| { type: 'unit'; value: number; unit: Unit }
|
|
| { type: 'invalid' }
|
|
|
|
export const parseCustomInput = (input: string): ParsedCustomInput => {
|
|
const trimmed = input.trim().toLowerCase()
|
|
if (!trimmed) return { type: 'invalid' }
|
|
|
|
// Try to match "number + optional space + unit prefix"
|
|
const match = trimmed.match(/^(\d+)\s*([a-z]*)$/)
|
|
if (!match) return { type: 'invalid' }
|
|
|
|
const [, numStr, unitStr] = match
|
|
const value = parseInt(numStr, 10)
|
|
|
|
if (isNaN(value) || value <= 0) return { type: 'invalid' }
|
|
|
|
if (!unitStr) {
|
|
return { type: 'number', value }
|
|
}
|
|
|
|
// Match if unitStr is a prefix of any unit name or its first letter
|
|
const units: Unit[] = ['minute', 'hour', 'day']
|
|
const matchedUnit = units.find((u) => u.startsWith(unitStr) || u[0] === unitStr)
|
|
|
|
if (!matchedUnit) return { type: 'invalid' }
|
|
|
|
return { type: 'unit', value, unit: matchedUnit }
|
|
}
|
|
|
|
export const generateDynamicHelper = (value: number, unit: Unit): DatetimeHelper => {
|
|
return {
|
|
text: `Last ${value} ${unit}${value === 1 ? '' : 's'}`,
|
|
calcFrom: () => dayjs().subtract(value, unit).toISOString(),
|
|
calcTo: () => dayjs().toISOString(),
|
|
}
|
|
}
|
|
|
|
export const generateDynamicHelpers = (value: number): DatetimeHelper[] => {
|
|
const units: Unit[] = ['minute', 'hour', 'day']
|
|
return units.map((unit) => generateDynamicHelper(value, unit))
|
|
}
|
|
|
|
export const generateHelpersFromInput = (input: string): DatetimeHelper[] | null => {
|
|
const parsed = parseCustomInput(input)
|
|
|
|
switch (parsed.type) {
|
|
case 'number':
|
|
return generateDynamicHelpers(parsed.value)
|
|
case 'unit':
|
|
return [generateDynamicHelper(parsed.value, parsed.unit)]
|
|
case 'invalid':
|
|
return null
|
|
}
|
|
}
|
|
|
|
export type DatePickerValue = {
|
|
to: string
|
|
from: string
|
|
isHelper?: boolean
|
|
text?: string
|
|
}
|
|
|
|
interface LogsDatePickerProps {
|
|
value: DatePickerValue
|
|
helpers: DatetimeHelper[]
|
|
onSubmit: (value: DatePickerValue) => void
|
|
buttonTriggerProps?: ButtonProps
|
|
popoverContentProps?: typeof PopoverContent
|
|
hideWarnings?: boolean
|
|
align?: 'start' | 'end' | 'center'
|
|
open?: boolean
|
|
onOpenChange?: (open: boolean) => void
|
|
}
|
|
|
|
export const LogsDatePicker = ({
|
|
onSubmit,
|
|
helpers,
|
|
value,
|
|
buttonTriggerProps,
|
|
popoverContentProps,
|
|
hideWarnings,
|
|
align = 'end',
|
|
open: openProp,
|
|
onOpenChange,
|
|
}: PropsWithChildren<LogsDatePickerProps>) => {
|
|
const [internalOpen, setInternalOpen] = useState(false)
|
|
const isControlled = openProp !== undefined
|
|
const open = isControlled ? openProp : internalOpen
|
|
const setOpen = (next: boolean) => {
|
|
if (!isControlled) setInternalOpen(next)
|
|
onOpenChange?.(next)
|
|
}
|
|
const [customValue, setCustomValue] = useState('')
|
|
|
|
const displayedHelpers = useMemo(() => {
|
|
if (!customValue.trim()) return helpers
|
|
const generated = generateHelpersFromInput(customValue)
|
|
return generated ?? []
|
|
}, [customValue, helpers])
|
|
|
|
// Reset the state when the popover closes
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setCustomValue('')
|
|
setStartDate(value.from ? new Date(value.from) : null)
|
|
const defaultEndDate = value.to ? new Date(value.to) : new Date()
|
|
setEndDate(defaultEndDate)
|
|
setCurrentMonth(new Date(defaultEndDate))
|
|
|
|
const fromDate = value.from ? new Date(value.from) : null
|
|
const toDate = value.to ? new Date(value.to) : null
|
|
|
|
setStartTime({
|
|
HH: fromDate?.getHours().toString().padStart(2, '0') || '00',
|
|
mm: fromDate?.getMinutes().toString().padStart(2, '0') || '00',
|
|
ss: fromDate?.getSeconds().toString().padStart(2, '0') || '00',
|
|
})
|
|
|
|
const now = new Date()
|
|
const nowHH = now.getHours().toString().padStart(2, '0')
|
|
const nowMM = now.getMinutes().toString().padStart(2, '0')
|
|
const nowSS = now.getSeconds().toString().padStart(2, '0')
|
|
|
|
setEndTime({
|
|
HH: toDate?.getHours().toString().padStart(2, '0') || nowHH,
|
|
mm: toDate?.getMinutes().toString().padStart(2, '0') || nowMM,
|
|
ss: toDate?.getSeconds().toString().padStart(2, '0') || nowSS,
|
|
})
|
|
}
|
|
}, [open, value])
|
|
|
|
const handleHelperChange = (newValue: string) => {
|
|
const selectedHelper = displayedHelpers.find((h) => h.text === newValue)
|
|
if (onSubmit && selectedHelper) {
|
|
onSubmit({
|
|
to: selectedHelper.calcTo(),
|
|
from: selectedHelper.calcFrom(),
|
|
isHelper: true,
|
|
text: selectedHelper.text,
|
|
})
|
|
}
|
|
|
|
setOpen(false)
|
|
}
|
|
|
|
const [startDate, setStartDate] = useState<Date | null>(value.from ? new Date(value.from) : null)
|
|
const [endDate, setEndDate] = useState<Date | null>(value.to ? new Date(value.to) : new Date())
|
|
const [currentMonth, setCurrentMonth] = useState<Date>(() =>
|
|
value.to ? new Date(value.to) : new Date()
|
|
)
|
|
|
|
const [startTime, setStartTime] = useState({
|
|
HH: startDate?.getHours().toString() || '00',
|
|
mm: startDate?.getMinutes().toString() || '00',
|
|
ss: startDate?.getSeconds().toString() || '00',
|
|
})
|
|
const [endTime, setEndTime] = useState({
|
|
HH: endDate?.getHours().toString() || '23',
|
|
mm: endDate?.getMinutes().toString() || '59',
|
|
ss: endDate?.getSeconds().toString() || '59',
|
|
})
|
|
|
|
function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) {
|
|
const [from, to] = dates
|
|
|
|
setStartDate(from)
|
|
setEndDate(to)
|
|
}
|
|
|
|
function handleApply() {
|
|
const from = startDate || new Date()
|
|
const to = endDate || new Date()
|
|
|
|
// Add Time to the dates
|
|
const finalFrom = new Date(from.setHours(+startTime.HH, +startTime.mm, +startTime.ss))
|
|
const finalTo = new Date(to.setHours(+endTime.HH, +endTime.mm, +endTime.ss))
|
|
|
|
onSubmit({
|
|
from: finalFrom.toISOString(),
|
|
to: finalTo.toISOString(),
|
|
isHelper: false,
|
|
})
|
|
|
|
setOpen(false)
|
|
}
|
|
|
|
const [copied, setCopied] = useState(false)
|
|
const [pasted, setPasted] = useState(false)
|
|
|
|
useEffect(() => {
|
|
if (copied) {
|
|
setTimeout(() => {
|
|
setCopied(false)
|
|
}, 2000)
|
|
}
|
|
}, [copied])
|
|
|
|
function handlePaste() {
|
|
navigator.clipboard
|
|
.readText()
|
|
.then((text) => {
|
|
try {
|
|
const json = JSON.parse(text)
|
|
|
|
if (!json.from || !json.to) {
|
|
console.warn('Invalid date range format in clipboard')
|
|
return
|
|
}
|
|
|
|
const fromDate = new Date(json.from)
|
|
const toDate = new Date(json.to)
|
|
|
|
// Check if dates are valid
|
|
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
|
console.warn('Invalid date values in clipboard')
|
|
return
|
|
}
|
|
|
|
setStartDate(fromDate)
|
|
setEndDate(toDate)
|
|
setCurrentMonth(new Date(toDate))
|
|
|
|
// Update time states
|
|
setStartTime({
|
|
HH: fromDate.getHours().toString(),
|
|
mm: fromDate.getMinutes().toString(),
|
|
ss: fromDate.getSeconds().toString(),
|
|
})
|
|
|
|
setEndTime({
|
|
HH: toDate.getHours().toString(),
|
|
mm: toDate.getMinutes().toString(),
|
|
ss: toDate.getSeconds().toString(),
|
|
})
|
|
|
|
setPasted(true)
|
|
} catch (error) {
|
|
console.warn('Failed to parse clipboard content as date range:', error)
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.warn('Failed to read clipboard:', error)
|
|
})
|
|
}
|
|
|
|
const handleCopy = useCallback(() => {
|
|
if (!startDate || !endDate) return
|
|
|
|
const fromDate = new Date(startDate)
|
|
const toDate = new Date(endDate)
|
|
|
|
// Add time from time states
|
|
fromDate.setHours(+startTime.HH, +startTime.mm, +startTime.ss)
|
|
toDate.setHours(+endTime.HH, +endTime.mm, +endTime.ss)
|
|
|
|
copyToClipboard(
|
|
JSON.stringify({
|
|
from: fromDate.toISOString(),
|
|
to: toDate.toISOString(),
|
|
})
|
|
)
|
|
|
|
setCopied(true)
|
|
}, [startDate, endDate, startTime, endTime])
|
|
|
|
useEffect(() => {
|
|
if (pasted) {
|
|
setTimeout(() => {
|
|
setPasted(false)
|
|
}, 2000)
|
|
}
|
|
}, [pasted])
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
document.addEventListener('paste', handlePaste)
|
|
document.addEventListener('copy', handleCopy)
|
|
}
|
|
return () => {
|
|
document.removeEventListener('paste', handlePaste)
|
|
document.removeEventListener('copy', handleCopy)
|
|
}
|
|
}, [open, startDate, endDate, handleCopy])
|
|
|
|
const isLargeRange =
|
|
Math.abs(dayjs(startDate).diff(dayjs(endDate), 'days')) >
|
|
LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD - 1
|
|
|
|
const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
|
|
const entitledToAuditLogDays = getEntitlementNumericValue()
|
|
|
|
const showHelperBadge = (helper?: DatetimeHelper) => {
|
|
if (!helper) return false
|
|
if (!entitledToAuditLogDays) return false
|
|
|
|
const day = Math.abs(dayjs().diff(dayjs(helper.calcFrom()), 'day'))
|
|
if (day <= entitledToAuditLogDays) return false
|
|
return true
|
|
}
|
|
|
|
return (
|
|
<Popover open={open} onOpenChange={setOpen}>
|
|
<PopoverTrigger asChild>
|
|
<Button type="default" icon={<Clock size={12} />} {...buttonTriggerProps}>
|
|
{value.isHelper
|
|
? value.text
|
|
: `${dayjs(value.from).format('DD MMM, HH:mm')} - ${dayjs(value.to || new Date()).format('DD MMM, HH:mm')}`}
|
|
</Button>
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
className="flex w-full p-0"
|
|
side="bottom"
|
|
align={align}
|
|
{...popoverContentProps}
|
|
>
|
|
<div className="border-r p-2 flex flex-col gap-px">
|
|
<Input
|
|
type="text"
|
|
placeholder="e.g. 2h, 30m, 7d"
|
|
value={customValue}
|
|
onChange={(e) => setCustomValue(e.target.value)}
|
|
className="mb-2 text-xs h-7 rounded-xs"
|
|
/>
|
|
<RadioGroup
|
|
onValueChange={handleHelperChange}
|
|
value={value.isHelper ? value.text : ''}
|
|
className="flex flex-col gap-px"
|
|
>
|
|
{displayedHelpers.map((helper) => (
|
|
<Label
|
|
key={helper.text}
|
|
className={cn(
|
|
'[&:has([data-state=checked])]:bg-background-overlay-hover [&:has([data-state=checked])]:text-foreground px-4 py-1.5 text-foreground-light flex items-center gap-2 hover:bg-background-overlay-hover hover:text-foreground transition-all rounded-xs text-xs w-full',
|
|
{
|
|
'cursor-not-allowed pointer-events-none opacity-50': helper.disabled,
|
|
}
|
|
)}
|
|
>
|
|
<RadioGroupItem
|
|
hidden
|
|
key={helper.text}
|
|
value={helper.text}
|
|
disabled={helper.disabled}
|
|
aria-disabled={helper.disabled}
|
|
></RadioGroupItem>
|
|
{helper.text}
|
|
{showHelperBadge(helper) ? (
|
|
<Lock size={12} className="text-foreground-muted" />
|
|
) : null}
|
|
</Label>
|
|
))}
|
|
</RadioGroup>
|
|
</div>
|
|
|
|
<div>
|
|
<div className="flex p-2 gap-2 items-center">
|
|
<div className="flex grow *:grow gap-2 font-mono">
|
|
<TimeSplitInput
|
|
type="start"
|
|
startTime={startTime}
|
|
endTime={endTime}
|
|
time={startTime}
|
|
setTime={setStartTime}
|
|
setStartTime={setStartTime}
|
|
setEndTime={setEndTime}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
/>
|
|
<TimeSplitInput
|
|
type="end"
|
|
startTime={startTime}
|
|
endTime={endTime}
|
|
time={endTime}
|
|
setTime={setEndTime}
|
|
setStartTime={setStartTime}
|
|
setEndTime={setEndTime}
|
|
startDate={startDate}
|
|
endDate={endDate}
|
|
/>
|
|
</div>
|
|
<div className="shrink">
|
|
<ButtonTooltip
|
|
tooltip={{
|
|
content: {
|
|
text: 'Clear time range',
|
|
},
|
|
}}
|
|
icon={<HistoryIcon size={14} />}
|
|
type="text"
|
|
size="tiny"
|
|
className="px-1.5"
|
|
onClick={() => {
|
|
setStartTime({ HH: '00', mm: '00', ss: '00' })
|
|
setEndTime({ HH: '00', mm: '00', ss: '00' })
|
|
}}
|
|
></ButtonTooltip>
|
|
</div>
|
|
</div>
|
|
<div className="p-2 border-t">
|
|
<Calendar
|
|
mode="range"
|
|
month={currentMonth}
|
|
onMonthChange={(month) => setCurrentMonth(new Date(month))}
|
|
selected={{ from: startDate ?? undefined, to: endDate ?? undefined }}
|
|
onSelect={(range) => {
|
|
handleDatePickerChange([range?.from ?? null, range?.to ?? null])
|
|
}}
|
|
/>
|
|
</div>
|
|
{isLargeRange && !hideWarnings && (
|
|
<div className="text-xs px-3 py-1.5 border-y bg-warning-300 text-warning-foreground border-warning-500 text-warning">
|
|
Large ranges may result in memory errors for <br /> big projects.
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-end gap-2 p-2 border-t">
|
|
{startDate && endDate ? (
|
|
<Button
|
|
type="text"
|
|
size="tiny"
|
|
onClick={handleCopy}
|
|
className={cn({
|
|
'text-brand-600': copied || pasted,
|
|
})}
|
|
>
|
|
{copied ? 'Copied!' : pasted ? 'Pasted!' : 'Copy range'}
|
|
</Button>
|
|
) : null}
|
|
|
|
<Button
|
|
type="default"
|
|
onClick={() => {
|
|
const today = new Date()
|
|
setCurrentMonth(today)
|
|
setStartDate(new Date(today))
|
|
setEndDate(new Date(today))
|
|
}}
|
|
>
|
|
Today
|
|
</Button>
|
|
<Button onClick={handleApply}>Apply</Button>
|
|
</div>
|
|
</div>
|
|
</PopoverContent>
|
|
</Popover>
|
|
)
|
|
}
|