LogsPreviewer.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import { useParams } from 'common'
  2. import dayjs from 'dayjs'
  3. import { Rewind } from 'lucide-react'
  4. import { useRouter } from 'next/router'
  5. import { PropsWithChildren, useEffect, useRef, useState } from 'react'
  6. import { Button } from 'ui'
  7. import { LogsBarChart } from 'ui-patterns/LogsBarChart'
  8. import {
  9. LOG_ROUTES_WITH_REPLICA_SUPPORT,
  10. LOGS_TABLES,
  11. LogsTableName,
  12. PREVIEWER_DATEPICKER_HELPERS,
  13. } from './Logs.constants'
  14. import { DatePickerValue } from './Logs.DatePickers'
  15. import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
  16. import { maybeShowUpgradePromptIfNotEntitled } from './Logs.utils'
  17. import { LogTable } from './LogTable'
  18. import UpgradePrompt from './UpgradePrompt'
  19. import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts'
  20. import PreviewFilterPanel from '@/components/interfaces/Settings/Logs/PreviewFilterPanel'
  21. import LoadingOpacity from '@/components/ui/LoadingOpacity'
  22. import ShimmerLine from '@/components/ui/ShimmerLine'
  23. import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
  24. import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
  25. import useLogsPreview from '@/hooks/analytics/useLogsPreview'
  26. import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
  27. import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
  28. import useSingleLog from '@/hooks/analytics/useSingleLog'
  29. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  30. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  31. import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
  32. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  33. import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
  34. /**
  35. * Calculates the appropriate time range for bar click filtering based on the current time range duration.
  36. *
  37. * @param currentRangeStart - The start timestamp of the current time range
  38. * @param currentRangeEnd - The end timestamp of the current time range
  39. * @param clickedTimestamp - The timestamp of the clicked bar
  40. * @returns Object containing the new start and end timestamps for filtering
  41. */
  42. export const calculateBarClickTimeRange = (
  43. currentRangeStart: string,
  44. currentRangeEnd: string | undefined,
  45. clickedTimestamp: string
  46. ) => {
  47. const datumTimestamp = dayjs(clickedTimestamp).toISOString()
  48. // Calculate the current time range duration in hours
  49. // If currentRangeEnd is not provided, use current time as the end
  50. const endTime = currentRangeEnd ? dayjs(currentRangeEnd) : dayjs()
  51. const currentRangeDuration = endTime.diff(dayjs(currentRangeStart), 'hour', true)
  52. let rangeOffset: number
  53. let rangeUnit: dayjs.ManipulateType
  54. if (currentRangeDuration >= 12) {
  55. // For ranges >= 12h, use 1h range
  56. rangeOffset = 0.5
  57. rangeUnit = 'hour'
  58. } else if (currentRangeDuration >= 1) {
  59. // For ranges >= 1h but < 12h, use 5min range
  60. rangeOffset = 2.5
  61. rangeUnit = 'minute'
  62. } else if (currentRangeDuration >= 1 / 30) {
  63. // 2 minutes = 1/30 hour
  64. // For ranges >= 2min but < 1h, use 2min range
  65. rangeOffset = 1
  66. rangeUnit = 'minute'
  67. } else {
  68. // For ranges < 2min, use 15sec range
  69. rangeOffset = 7.5
  70. rangeUnit = 'second'
  71. }
  72. return {
  73. start: dayjs(datumTimestamp).subtract(rangeOffset, rangeUnit).toISOString(),
  74. end: dayjs(datumTimestamp).add(rangeOffset, rangeUnit).toISOString(),
  75. }
  76. }
  77. /**
  78. * Acts as a container component for the entire log display
  79. *
  80. * ## Query Params Syncing
  81. * Query params are synced on query submission.
  82. *
  83. * params used are:
  84. * - `s` for search query.
  85. * - `te` for timestamp start value.
  86. */
  87. interface LogsPreviewerProps {
  88. projectRef: string
  89. queryType: QueryType
  90. filterOverride?: Filters
  91. condensedLayout?: boolean
  92. tableName?: LogsTableName
  93. EmptyState?: React.ReactNode
  94. filterPanelClassName?: string
  95. }
  96. export const LogsPreviewer = ({
  97. projectRef,
  98. queryType,
  99. filterOverride,
  100. condensedLayout = false,
  101. tableName,
  102. children,
  103. EmptyState,
  104. filterPanelClassName,
  105. }: PropsWithChildren<LogsPreviewerProps>) => {
  106. const router = useRouter()
  107. const { db } = useParams()
  108. const { data: organization } = useSelectedOrganizationQuery()
  109. const state = useDatabaseSelectorStateSnapshot()
  110. const searchInputRef = useRef<HTMLInputElement>(null)
  111. const [showChart, setShowChart] = useState(true)
  112. const [selectedDatePickerValue, setSelectedDatePickerValue] = useState<DatePickerValue>(
  113. getDefaultDatePickerValue()
  114. )
  115. const { search, setSearch, timestampStart, timestampEnd, setTimeRange, filters, setFilters } =
  116. useLogsUrlState()
  117. useEffect(() => {
  118. if (timestampStart && timestampEnd) {
  119. setSelectedDatePickerValue({
  120. to: timestampEnd,
  121. from: timestampStart,
  122. text: `${dayjs(timestampStart).format('DD MMM, HH:mm')} - ${dayjs(timestampEnd).format('DD MMM, HH:mm')}`,
  123. isHelper: false,
  124. })
  125. }
  126. }, [timestampStart, timestampEnd])
  127. const [selectedLogId, setSelectedLogId] = useSelectedLog()
  128. const { data: databases, isSuccess } = useReadReplicasQuery({ projectRef })
  129. // TODO: Move this to useLogsUrlState to simplify LogsPreviewer. - Jordi
  130. function getDefaultDatePickerValue() {
  131. const iso_timestamp_start = router.query.iso_timestamp_start as string
  132. const iso_timestamp_end = router.query.iso_timestamp_end as string
  133. if (iso_timestamp_start && iso_timestamp_end) {
  134. return {
  135. to: iso_timestamp_end,
  136. from: iso_timestamp_start,
  137. text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`,
  138. isHelper: false,
  139. }
  140. }
  141. const defaultDatePickerValue = PREVIEWER_DATEPICKER_HELPERS.find((x) => x.default)
  142. return {
  143. to: defaultDatePickerValue!.calcTo(),
  144. from: defaultDatePickerValue!.calcFrom(),
  145. text: defaultDatePickerValue!.text,
  146. isHelper: true,
  147. }
  148. }
  149. const table = !tableName ? LOGS_TABLES[queryType] : tableName
  150. const {
  151. error,
  152. logData,
  153. params,
  154. newCount,
  155. isLoading,
  156. eventChartData,
  157. isLoadingOlder,
  158. loadOlder,
  159. refresh,
  160. } = useLogsPreview({ projectRef, table, filterOverride })
  161. const {
  162. data: selectedLog,
  163. isLoading: isSelectedLogLoading,
  164. error: selectedLogError,
  165. } = useSingleLog({
  166. projectRef,
  167. id: selectedLogId ?? undefined,
  168. queryType,
  169. paramsToMerge: params,
  170. })
  171. const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(timestampStart)
  172. const onSelectTemplate = (template: LogTemplate) => {
  173. setFilters({ ...filters, search_query: template.searchString })
  174. }
  175. // [Joshen] For helper date picker values, reset the timestamp start to prevent data caching
  176. // Since the helpers are "Last n minutes" -> hitting refresh, you'd expect to see the latest result
  177. // Whereas if a specific range is selected, you'd not expect new data to show up
  178. const handleRefresh = () => {
  179. if (selectedDatePickerValue.isHelper) {
  180. const helper = PREVIEWER_DATEPICKER_HELPERS.find(
  181. (x) => x.text === selectedDatePickerValue.text
  182. )
  183. if (helper) {
  184. const newTimestampStart = helper.calcFrom()
  185. setTimeRange(newTimestampStart, timestampEnd)
  186. }
  187. }
  188. refresh()
  189. }
  190. const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
  191. const entitledToAuditLogDays = getEntitlementNumericValue()
  192. const handleSearch: LogSearchCallback = async (event, { query, to, from }) => {
  193. if (event === 'search-input-change') {
  194. setSearch(query || '')
  195. setSelectedLogId(null)
  196. } else if (event === 'event-chart-bar-click') {
  197. setTimeRange(from || '', to || '')
  198. } else if (event === 'datepicker-change') {
  199. const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
  200. from || '',
  201. entitledToAuditLogDays
  202. )
  203. if (shouldShowUpgradePrompt) {
  204. setShowUpgradePrompt(!showUpgradePrompt)
  205. } else {
  206. setTimeRange(from || '', to || '')
  207. }
  208. }
  209. }
  210. // Show the prompt on page load based on query params
  211. useEffect(() => {
  212. if (timestampStart) {
  213. const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
  214. timestampStart,
  215. entitledToAuditLogDays
  216. )
  217. if (shouldShowUpgradePrompt) {
  218. setShowUpgradePrompt(!showUpgradePrompt)
  219. }
  220. }
  221. }, [timestampStart, organization])
  222. useEffect(() => {
  223. if (db !== undefined) {
  224. const database = databases?.find((d) => d.identifier === db)
  225. if (database !== undefined) state.setSelectedDatabaseId(db)
  226. } else if (state.selectedDatabaseId !== undefined && state.selectedDatabaseId !== projectRef) {
  227. if (LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname)) {
  228. router.push({
  229. pathname: router.pathname,
  230. query: { ...router.query, db: state.selectedDatabaseId },
  231. })
  232. } else {
  233. state.setSelectedDatabaseId(projectRef)
  234. }
  235. }
  236. }, [db, isSuccess])
  237. // Common props shared between both filter panel components to avoid duplication
  238. const filterPanelProps = {
  239. className: filterPanelClassName,
  240. csvData: logData,
  241. isLoading,
  242. newCount,
  243. onRefresh: handleRefresh,
  244. onSearch: handleSearch,
  245. defaultSearchValue: search,
  246. defaultToValue: timestampEnd,
  247. defaultFromValue: timestampStart,
  248. queryUrl: `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
  249. params.sql || ''
  250. )}&its=${encodeURIComponent(timestampStart)}&ite=${encodeURIComponent(timestampEnd)}`,
  251. onSelectTemplate,
  252. filters,
  253. onFiltersChange: setFilters,
  254. table,
  255. condensedLayout,
  256. isShowingEventChart: showChart,
  257. onToggleEventChart: () => setShowChart(!showChart),
  258. onSelectedDatabaseChange: (id: string) => {
  259. setFilters({ ...filters, database: id !== projectRef ? id : undefined })
  260. const { db, ...params } = router.query
  261. router.push({
  262. pathname: router.pathname,
  263. query: id !== projectRef ? { ...router.query, db: id } : params,
  264. })
  265. },
  266. selectedDatePickerValue,
  267. setSelectedDatePickerValue,
  268. searchInputRef,
  269. }
  270. useLogsPreviewShortcuts({
  271. searchInputRef,
  272. hasSearch: search.length > 0,
  273. onResetSearch: () => {
  274. setSearch('')
  275. setSelectedLogId(null)
  276. },
  277. onRefresh: handleRefresh,
  278. onToggleChart: () => setShowChart((prev) => !prev),
  279. onLoadOlder: loadOlder,
  280. canLoadOlder: !error && logData.length > 0 && !isLoadingOlder,
  281. })
  282. return (
  283. <div className="flex-1 flex flex-col h-full">
  284. <PreviewFilterPanel {...filterPanelProps} />
  285. {children}
  286. <div
  287. className={
  288. 'transition-all duration-500 ' +
  289. (showChart && logData.length > 0 ? 'mb-2 mt-1 opacity-100' : 'h-0 opacity-0')
  290. }
  291. >
  292. <div className={condensedLayout ? 'px-3' : ''}>
  293. {showChart && (
  294. <LogsBarChart
  295. data={eventChartData}
  296. onBarClick={(datum) => {
  297. if (!datum?.timestamp) return
  298. const { start, end } = calculateBarClickTimeRange(
  299. timestampStart,
  300. timestampEnd,
  301. datum.timestamp
  302. )
  303. handleSearch('event-chart-bar-click', {
  304. query: filters.search_query?.toString(),
  305. to: end,
  306. from: start,
  307. })
  308. }}
  309. EmptyState={
  310. <div className="flex flex-col items-center justify-center h-[67px]">
  311. <p className="text-foreground-light text-xs">No data</p>
  312. <p className="text-foreground-lighter text-xs">
  313. It may take up to 24 hours for data to refresh
  314. </p>
  315. </div>
  316. }
  317. />
  318. )}
  319. </div>
  320. </div>
  321. <div className="relative flex flex-col grow flex-1 overflow-auto">
  322. <ShimmerLine active={isLoading} />
  323. <LoadingOpacity active={isLoading}>
  324. <LogTable
  325. projectRef={projectRef}
  326. isLoading={isLoading}
  327. data={logData}
  328. queryType={queryType}
  329. isHistogramShowing={showChart}
  330. onHistogramToggle={() => setShowChart(!showChart)}
  331. error={error}
  332. EmptyState={EmptyState}
  333. onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
  334. selectedLog={selectedLog}
  335. isSelectedLogLoading={isSelectedLogLoading}
  336. selectedLogError={selectedLogError ?? undefined}
  337. />
  338. </LoadingOpacity>
  339. </div>
  340. {!error && logData.length > 0 && (
  341. <div className="border-t flex flex-row items-center gap-3 p-2">
  342. <ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER} side="top">
  343. <Button
  344. onClick={loadOlder}
  345. icon={<Rewind />}
  346. type="default"
  347. loading={isLoadingOlder}
  348. disabled={isLoadingOlder}
  349. >
  350. Load older
  351. </Button>
  352. </ShortcutTooltip>
  353. <div className="text-sm text-foreground-lighter">
  354. Showing <span className="font-mono">{logData.length}</span> results
  355. </div>
  356. <div className="flex flex-row justify-end mt-2">
  357. <UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
  358. </div>
  359. </div>
  360. )}
  361. </div>
  362. )
  363. }