Files
supabase/apps/studio/components/interfaces/Settings/Logs/Logs.DatePickers.tsx
Jordi Enric bac4814123 fix(logs): color regressions from design system update (#47676)
## Summary
- Fix unreadable "Large ranges may result in memory errors" warning text
in the Logs date picker — a stray `text-warning-foreground` class (dark
ink) was winning over `text-warning` on the dark `bg-warning-300` fill.
- Fix "Search collections..." sidebar wrapper background mismatch —
`bg-background-200` now resolves to the elevated `--card` surface
instead of `--background`, so it no longer matches the sidebar's
`bg-dash-sidebar`.
- Fix the Unified Logs "Live" toggle button rendering blue text instead
of white when active — a leftover `border-info text-info` override was
fighting the `primary` variant's own text color, now that `--info`
resolves to a more distinct blue.

All three are contrast/color regressions surfaced by the recent
design-system color token changes.

## Test plan
- [ ] Open a project's Logs Explorer, pick a large date range, confirm
the warning text is readable
- [ ] Check the Logs sidebar "Search collections..." box background
matches the rest of the sidebar in both light and dark mode
- [ ] Toggle "Live" mode in Unified Logs and confirm the button text is
white/legible on the green background

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Style**
  * Updated the large-range warning banner appearance in Logs settings.
* Refined the Logs sidebar header background styling for a more
consistent look.
* Simplified the DataTable live button styling behavior by removing
conditional class composition while preserving the existing live-mode
visuals.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-07 13:37:37 +02:00

494 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 { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
import type { ShortcutId } from '@/state/shortcuts/registry'
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
/**
* Registered shortcut id whose hotkey is shown in a tooltip on the trigger
* button. The tooltip hides while the popover is open so it doesn't sit on
* top of the picker. Leave undefined to render no tooltip.
*/
shortcutId?: ShortcutId
}
export const LogsDatePicker = ({
onSubmit,
helpers,
value,
buttonTriggerProps,
popoverContentProps,
hideWarnings,
align = 'end',
open: openProp,
onOpenChange,
shortcutId,
}: 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
}
const triggerButton = (
<PopoverTrigger asChild>
<Button variant="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>
)
return (
<Popover open={open} onOpenChange={setOpen}>
{shortcutId ? (
<ShortcutTooltip shortcutId={shortcutId} side="bottom" open={open ? false : undefined}>
{triggerButton}
</ShortcutTooltip>
) : (
triggerButton
)}
<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} />}
variant="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 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
variant="text"
size="tiny"
onClick={handleCopy}
className={cn({
'text-brand-600': copied || pasted,
})}
>
{copied ? 'Copied!' : pasted ? 'Pasted!' : 'Copy range'}
</Button>
) : null}
<Button
variant="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>
)
}