Logs.DatePickers.tsx 15 KB

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