Logs.DatePickers.tsx474 lines · main
1import { Label } from '@ui/components/shadcn/ui/label'
2import { RadioGroup, RadioGroupItem } from '@ui/components/shadcn/ui/radio-group'
3import dayjs from 'dayjs'
4import { Clock, HistoryIcon, Lock } from 'lucide-react'
5import type { PropsWithChildren } from 'react'
6import { useCallback, useEffect, useMemo, useState } from 'react'
7import {
8 Button,
9 ButtonProps,
10 Calendar,
11 cn,
12 copyToClipboard,
13 Input,
14 Popover,
15 PopoverContent,
16 PopoverTrigger,
17} from 'ui'
18
19import { LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD } from './Logs.constants'
20import type { DatetimeHelper } from './Logs.types'
21import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
22import { TimeSplitInput } from '@/components/ui/DatePicker/TimeSplitInput'
23import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
24
25type Unit = 'minute' | 'hour' | 'day'
26
27export type ParsedCustomInput =
28 | { type: 'number'; value: number }
29 | { type: 'unit'; value: number; unit: Unit }
30 | { type: 'invalid' }
31
32export const parseCustomInput = (input: string): ParsedCustomInput => {
33 const trimmed = input.trim().toLowerCase()
34 if (!trimmed) return { type: 'invalid' }
35
36 // Try to match "number + optional space + unit prefix"
37 const match = trimmed.match(/^(\d+)\s*([a-z]*)$/)
38 if (!match) return { type: 'invalid' }
39
40 const [, numStr, unitStr] = match
41 const value = parseInt(numStr, 10)
42
43 if (isNaN(value) || value <= 0) return { type: 'invalid' }
44
45 if (!unitStr) {
46 return { type: 'number', value }
47 }
48
49 // Match if unitStr is a prefix of any unit name or its first letter
50 const units: Unit[] = ['minute', 'hour', 'day']
51 const matchedUnit = units.find((u) => u.startsWith(unitStr) || u[0] === unitStr)
52
53 if (!matchedUnit) return { type: 'invalid' }
54
55 return { type: 'unit', value, unit: matchedUnit }
56}
57
58export const generateDynamicHelper = (value: number, unit: Unit): DatetimeHelper => {
59 return {
60 text: `Last ${value} ${unit}${value === 1 ? '' : 's'}`,
61 calcFrom: () => dayjs().subtract(value, unit).toISOString(),
62 calcTo: () => dayjs().toISOString(),
63 }
64}
65
66export const generateDynamicHelpers = (value: number): DatetimeHelper[] => {
67 const units: Unit[] = ['minute', 'hour', 'day']
68 return units.map((unit) => generateDynamicHelper(value, unit))
69}
70
71export const generateHelpersFromInput = (input: string): DatetimeHelper[] | null => {
72 const parsed = parseCustomInput(input)
73
74 switch (parsed.type) {
75 case 'number':
76 return generateDynamicHelpers(parsed.value)
77 case 'unit':
78 return [generateDynamicHelper(parsed.value, parsed.unit)]
79 case 'invalid':
80 return null
81 }
82}
83
84export type DatePickerValue = {
85 to: string
86 from: string
87 isHelper?: boolean
88 text?: string
89}
90
91interface LogsDatePickerProps {
92 value: DatePickerValue
93 helpers: DatetimeHelper[]
94 onSubmit: (value: DatePickerValue) => void
95 buttonTriggerProps?: ButtonProps
96 popoverContentProps?: typeof PopoverContent
97 hideWarnings?: boolean
98 align?: 'start' | 'end' | 'center'
99 open?: boolean
100 onOpenChange?: (open: boolean) => void
101}
102
103export const LogsDatePicker = ({
104 onSubmit,
105 helpers,
106 value,
107 buttonTriggerProps,
108 popoverContentProps,
109 hideWarnings,
110 align = 'end',
111 open: openProp,
112 onOpenChange,
113}: PropsWithChildren<LogsDatePickerProps>) => {
114 const [internalOpen, setInternalOpen] = useState(false)
115 const isControlled = openProp !== undefined
116 const open = isControlled ? openProp : internalOpen
117 const setOpen = (next: boolean) => {
118 if (!isControlled) setInternalOpen(next)
119 onOpenChange?.(next)
120 }
121 const [customValue, setCustomValue] = useState('')
122
123 const displayedHelpers = useMemo(() => {
124 if (!customValue.trim()) return helpers
125 const generated = generateHelpersFromInput(customValue)
126 return generated ?? []
127 }, [customValue, helpers])
128
129 // Reset the state when the popover closes
130 useEffect(() => {
131 if (!open) {
132 setCustomValue('')
133 setStartDate(value.from ? new Date(value.from) : null)
134 const defaultEndDate = value.to ? new Date(value.to) : new Date()
135 setEndDate(defaultEndDate)
136 setCurrentMonth(new Date(defaultEndDate))
137
138 const fromDate = value.from ? new Date(value.from) : null
139 const toDate = value.to ? new Date(value.to) : null
140
141 setStartTime({
142 HH: fromDate?.getHours().toString().padStart(2, '0') || '00',
143 mm: fromDate?.getMinutes().toString().padStart(2, '0') || '00',
144 ss: fromDate?.getSeconds().toString().padStart(2, '0') || '00',
145 })
146
147 const now = new Date()
148 const nowHH = now.getHours().toString().padStart(2, '0')
149 const nowMM = now.getMinutes().toString().padStart(2, '0')
150 const nowSS = now.getSeconds().toString().padStart(2, '0')
151
152 setEndTime({
153 HH: toDate?.getHours().toString().padStart(2, '0') || nowHH,
154 mm: toDate?.getMinutes().toString().padStart(2, '0') || nowMM,
155 ss: toDate?.getSeconds().toString().padStart(2, '0') || nowSS,
156 })
157 }
158 }, [open, value])
159
160 const handleHelperChange = (newValue: string) => {
161 const selectedHelper = displayedHelpers.find((h) => h.text === newValue)
162 if (onSubmit && selectedHelper) {
163 onSubmit({
164 to: selectedHelper.calcTo(),
165 from: selectedHelper.calcFrom(),
166 isHelper: true,
167 text: selectedHelper.text,
168 })
169 }
170
171 setOpen(false)
172 }
173
174 const [startDate, setStartDate] = useState<Date | null>(value.from ? new Date(value.from) : null)
175 const [endDate, setEndDate] = useState<Date | null>(value.to ? new Date(value.to) : new Date())
176 const [currentMonth, setCurrentMonth] = useState<Date>(() =>
177 value.to ? new Date(value.to) : new Date()
178 )
179
180 const [startTime, setStartTime] = useState({
181 HH: startDate?.getHours().toString() || '00',
182 mm: startDate?.getMinutes().toString() || '00',
183 ss: startDate?.getSeconds().toString() || '00',
184 })
185 const [endTime, setEndTime] = useState({
186 HH: endDate?.getHours().toString() || '23',
187 mm: endDate?.getMinutes().toString() || '59',
188 ss: endDate?.getSeconds().toString() || '59',
189 })
190
191 function handleDatePickerChange(dates: [from: Date | null, to: Date | null]) {
192 const [from, to] = dates
193
194 setStartDate(from)
195 setEndDate(to)
196 }
197
198 function handleApply() {
199 const from = startDate || new Date()
200 const to = endDate || new Date()
201
202 // Add Time to the dates
203 const finalFrom = new Date(from.setHours(+startTime.HH, +startTime.mm, +startTime.ss))
204 const finalTo = new Date(to.setHours(+endTime.HH, +endTime.mm, +endTime.ss))
205
206 onSubmit({
207 from: finalFrom.toISOString(),
208 to: finalTo.toISOString(),
209 isHelper: false,
210 })
211
212 setOpen(false)
213 }
214
215 const [copied, setCopied] = useState(false)
216 const [pasted, setPasted] = useState(false)
217
218 useEffect(() => {
219 if (copied) {
220 setTimeout(() => {
221 setCopied(false)
222 }, 2000)
223 }
224 }, [copied])
225
226 function handlePaste() {
227 navigator.clipboard
228 .readText()
229 .then((text) => {
230 try {
231 const json = JSON.parse(text)
232
233 if (!json.from || !json.to) {
234 console.warn('Invalid date range format in clipboard')
235 return
236 }
237
238 const fromDate = new Date(json.from)
239 const toDate = new Date(json.to)
240
241 // Check if dates are valid
242 if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
243 console.warn('Invalid date values in clipboard')
244 return
245 }
246
247 setStartDate(fromDate)
248 setEndDate(toDate)
249 setCurrentMonth(new Date(toDate))
250
251 // Update time states
252 setStartTime({
253 HH: fromDate.getHours().toString(),
254 mm: fromDate.getMinutes().toString(),
255 ss: fromDate.getSeconds().toString(),
256 })
257
258 setEndTime({
259 HH: toDate.getHours().toString(),
260 mm: toDate.getMinutes().toString(),
261 ss: toDate.getSeconds().toString(),
262 })
263
264 setPasted(true)
265 } catch (error) {
266 console.warn('Failed to parse clipboard content as date range:', error)
267 }
268 })
269 .catch((error) => {
270 console.warn('Failed to read clipboard:', error)
271 })
272 }
273
274 const handleCopy = useCallback(() => {
275 if (!startDate || !endDate) return
276
277 const fromDate = new Date(startDate)
278 const toDate = new Date(endDate)
279
280 // Add time from time states
281 fromDate.setHours(+startTime.HH, +startTime.mm, +startTime.ss)
282 toDate.setHours(+endTime.HH, +endTime.mm, +endTime.ss)
283
284 copyToClipboard(
285 JSON.stringify({
286 from: fromDate.toISOString(),
287 to: toDate.toISOString(),
288 })
289 )
290
291 setCopied(true)
292 }, [startDate, endDate, startTime, endTime])
293
294 useEffect(() => {
295 if (pasted) {
296 setTimeout(() => {
297 setPasted(false)
298 }, 2000)
299 }
300 }, [pasted])
301
302 useEffect(() => {
303 if (open) {
304 document.addEventListener('paste', handlePaste)
305 document.addEventListener('copy', handleCopy)
306 }
307 return () => {
308 document.removeEventListener('paste', handlePaste)
309 document.removeEventListener('copy', handleCopy)
310 }
311 }, [open, startDate, endDate, handleCopy])
312
313 const isLargeRange =
314 Math.abs(dayjs(startDate).diff(dayjs(endDate), 'days')) >
315 LOGS_LARGE_DATE_RANGE_DAYS_THRESHOLD - 1
316
317 const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
318 const entitledToAuditLogDays = getEntitlementNumericValue()
319
320 const showHelperBadge = (helper?: DatetimeHelper) => {
321 if (!helper) return false
322 if (!entitledToAuditLogDays) return false
323
324 const day = Math.abs(dayjs().diff(dayjs(helper.calcFrom()), 'day'))
325 if (day <= entitledToAuditLogDays) return false
326 return true
327 }
328
329 return (
330 <Popover open={open} onOpenChange={setOpen}>
331 <PopoverTrigger asChild>
332 <Button type="default" icon={<Clock size={12} />} {...buttonTriggerProps}>
333 {value.isHelper
334 ? value.text
335 : `${dayjs(value.from).format('DD MMM, HH:mm')} - ${dayjs(value.to || new Date()).format('DD MMM, HH:mm')}`}
336 </Button>
337 </PopoverTrigger>
338 <PopoverContent
339 className="flex w-full p-0"
340 side="bottom"
341 align={align}
342 {...popoverContentProps}
343 >
344 <div className="border-r p-2 flex flex-col gap-px">
345 <Input
346 type="text"
347 placeholder="e.g. 2h, 30m, 7d"
348 value={customValue}
349 onChange={(e) => setCustomValue(e.target.value)}
350 className="mb-2 text-xs h-7 rounded-xs"
351 />
352 <RadioGroup
353 onValueChange={handleHelperChange}
354 value={value.isHelper ? value.text : ''}
355 className="flex flex-col gap-px"
356 >
357 {displayedHelpers.map((helper) => (
358 <Label
359 key={helper.text}
360 className={cn(
361 '[&: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',
362 {
363 'cursor-not-allowed pointer-events-none opacity-50': helper.disabled,
364 }
365 )}
366 >
367 <RadioGroupItem
368 hidden
369 key={helper.text}
370 value={helper.text}
371 disabled={helper.disabled}
372 aria-disabled={helper.disabled}
373 ></RadioGroupItem>
374 {helper.text}
375 {showHelperBadge(helper) ? (
376 <Lock size={12} className="text-foreground-muted" />
377 ) : null}
378 </Label>
379 ))}
380 </RadioGroup>
381 </div>
382
383 <div>
384 <div className="flex p-2 gap-2 items-center">
385 <div className="flex grow *:grow gap-2 font-mono">
386 <TimeSplitInput
387 type="start"
388 startTime={startTime}
389 endTime={endTime}
390 time={startTime}
391 setTime={setStartTime}
392 setStartTime={setStartTime}
393 setEndTime={setEndTime}
394 startDate={startDate}
395 endDate={endDate}
396 />
397 <TimeSplitInput
398 type="end"
399 startTime={startTime}
400 endTime={endTime}
401 time={endTime}
402 setTime={setEndTime}
403 setStartTime={setStartTime}
404 setEndTime={setEndTime}
405 startDate={startDate}
406 endDate={endDate}
407 />
408 </div>
409 <div className="shrink">
410 <ButtonTooltip
411 tooltip={{
412 content: {
413 text: 'Clear time range',
414 },
415 }}
416 icon={<HistoryIcon size={14} />}
417 type="text"
418 size="tiny"
419 className="px-1.5"
420 onClick={() => {
421 setStartTime({ HH: '00', mm: '00', ss: '00' })
422 setEndTime({ HH: '00', mm: '00', ss: '00' })
423 }}
424 ></ButtonTooltip>
425 </div>
426 </div>
427 <div className="p-2 border-t">
428 <Calendar
429 mode="range"
430 month={currentMonth}
431 onMonthChange={(month) => setCurrentMonth(new Date(month))}
432 selected={{ from: startDate ?? undefined, to: endDate ?? undefined }}
433 onSelect={(range) => {
434 handleDatePickerChange([range?.from ?? null, range?.to ?? null])
435 }}
436 />
437 </div>
438 {isLargeRange && !hideWarnings && (
439 <div className="text-xs px-3 py-1.5 border-y bg-warning-300 text-warning-foreground border-warning-500 text-warning">
440 Large ranges may result in memory errors for <br /> big projects.
441 </div>
442 )}
443 <div className="flex items-center justify-end gap-2 p-2 border-t">
444 {startDate && endDate ? (
445 <Button
446 type="text"
447 size="tiny"
448 onClick={handleCopy}
449 className={cn({
450 'text-brand-600': copied || pasted,
451 })}
452 >
453 {copied ? 'Copied!' : pasted ? 'Pasted!' : 'Copy range'}
454 </Button>
455 ) : null}
456
457 <Button
458 type="default"
459 onClick={() => {
460 const today = new Date()
461 setCurrentMonth(today)
462 setStartDate(new Date(today))
463 setEndDate(new Date(today))
464 }}
465 >
466 Today
467 </Button>
468 <Button onClick={handleApply}>Apply</Button>
469 </div>
470 </div>
471 </PopoverContent>
472 </Popover>
473 )
474}