| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 |
- import { useParams } from 'common'
- import dayjs from 'dayjs'
- import { Rewind } from 'lucide-react'
- import { useRouter } from 'next/router'
- import { PropsWithChildren, useEffect, useRef, useState } from 'react'
- import { Button } from 'ui'
- import { LogsBarChart } from 'ui-patterns/LogsBarChart'
- import {
- LOG_ROUTES_WITH_REPLICA_SUPPORT,
- LOGS_TABLES,
- LogsTableName,
- PREVIEWER_DATEPICKER_HELPERS,
- } from './Logs.constants'
- import { DatePickerValue } from './Logs.DatePickers'
- import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
- import { maybeShowUpgradePromptIfNotEntitled } from './Logs.utils'
- import { LogTable } from './LogTable'
- import UpgradePrompt from './UpgradePrompt'
- import { useLogsPreviewShortcuts } from './useLogsPreviewShortcuts'
- import PreviewFilterPanel from '@/components/interfaces/Settings/Logs/PreviewFilterPanel'
- import LoadingOpacity from '@/components/ui/LoadingOpacity'
- import ShimmerLine from '@/components/ui/ShimmerLine'
- import { ShortcutTooltip } from '@/components/ui/ShortcutTooltip'
- import { useReadReplicasQuery } from '@/data/read-replicas/replicas-query'
- import useLogsPreview from '@/hooks/analytics/useLogsPreview'
- import { useLogsUrlState } from '@/hooks/analytics/useLogsUrlState'
- import { useSelectedLog } from '@/hooks/analytics/useSelectedLog'
- import useSingleLog from '@/hooks/analytics/useSingleLog'
- import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
- import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
- import { useUpgradePrompt } from '@/hooks/misc/useUpgradePrompt'
- import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
- import { SHORTCUT_IDS } from '@/state/shortcuts/registry'
- /**
- * Calculates the appropriate time range for bar click filtering based on the current time range duration.
- *
- * @param currentRangeStart - The start timestamp of the current time range
- * @param currentRangeEnd - The end timestamp of the current time range
- * @param clickedTimestamp - The timestamp of the clicked bar
- * @returns Object containing the new start and end timestamps for filtering
- */
- export const calculateBarClickTimeRange = (
- currentRangeStart: string,
- currentRangeEnd: string | undefined,
- clickedTimestamp: string
- ) => {
- const datumTimestamp = dayjs(clickedTimestamp).toISOString()
- // Calculate the current time range duration in hours
- // If currentRangeEnd is not provided, use current time as the end
- const endTime = currentRangeEnd ? dayjs(currentRangeEnd) : dayjs()
- const currentRangeDuration = endTime.diff(dayjs(currentRangeStart), 'hour', true)
- let rangeOffset: number
- let rangeUnit: dayjs.ManipulateType
- if (currentRangeDuration >= 12) {
- // For ranges >= 12h, use 1h range
- rangeOffset = 0.5
- rangeUnit = 'hour'
- } else if (currentRangeDuration >= 1) {
- // For ranges >= 1h but < 12h, use 5min range
- rangeOffset = 2.5
- rangeUnit = 'minute'
- } else if (currentRangeDuration >= 1 / 30) {
- // 2 minutes = 1/30 hour
- // For ranges >= 2min but < 1h, use 2min range
- rangeOffset = 1
- rangeUnit = 'minute'
- } else {
- // For ranges < 2min, use 15sec range
- rangeOffset = 7.5
- rangeUnit = 'second'
- }
- return {
- start: dayjs(datumTimestamp).subtract(rangeOffset, rangeUnit).toISOString(),
- end: dayjs(datumTimestamp).add(rangeOffset, rangeUnit).toISOString(),
- }
- }
- /**
- * Acts as a container component for the entire log display
- *
- * ## Query Params Syncing
- * Query params are synced on query submission.
- *
- * params used are:
- * - `s` for search query.
- * - `te` for timestamp start value.
- */
- interface LogsPreviewerProps {
- projectRef: string
- queryType: QueryType
- filterOverride?: Filters
- condensedLayout?: boolean
- tableName?: LogsTableName
- EmptyState?: React.ReactNode
- filterPanelClassName?: string
- }
- export const LogsPreviewer = ({
- projectRef,
- queryType,
- filterOverride,
- condensedLayout = false,
- tableName,
- children,
- EmptyState,
- filterPanelClassName,
- }: PropsWithChildren<LogsPreviewerProps>) => {
- const router = useRouter()
- const { db } = useParams()
- const { data: organization } = useSelectedOrganizationQuery()
- const state = useDatabaseSelectorStateSnapshot()
- const searchInputRef = useRef<HTMLInputElement>(null)
- const [showChart, setShowChart] = useState(true)
- const [selectedDatePickerValue, setSelectedDatePickerValue] = useState<DatePickerValue>(
- getDefaultDatePickerValue()
- )
- const { search, setSearch, timestampStart, timestampEnd, setTimeRange, filters, setFilters } =
- useLogsUrlState()
- useEffect(() => {
- if (timestampStart && timestampEnd) {
- setSelectedDatePickerValue({
- to: timestampEnd,
- from: timestampStart,
- text: `${dayjs(timestampStart).format('DD MMM, HH:mm')} - ${dayjs(timestampEnd).format('DD MMM, HH:mm')}`,
- isHelper: false,
- })
- }
- }, [timestampStart, timestampEnd])
- const [selectedLogId, setSelectedLogId] = useSelectedLog()
- const { data: databases, isSuccess } = useReadReplicasQuery({ projectRef })
- // TODO: Move this to useLogsUrlState to simplify LogsPreviewer. - Jordi
- function getDefaultDatePickerValue() {
- const iso_timestamp_start = router.query.iso_timestamp_start as string
- const iso_timestamp_end = router.query.iso_timestamp_end as string
- if (iso_timestamp_start && iso_timestamp_end) {
- return {
- to: iso_timestamp_end,
- from: iso_timestamp_start,
- text: `${dayjs(iso_timestamp_start).format('DD MMM, HH:mm')} - ${dayjs(iso_timestamp_end).format('DD MMM, HH:mm')}`,
- isHelper: false,
- }
- }
- const defaultDatePickerValue = PREVIEWER_DATEPICKER_HELPERS.find((x) => x.default)
- return {
- to: defaultDatePickerValue!.calcTo(),
- from: defaultDatePickerValue!.calcFrom(),
- text: defaultDatePickerValue!.text,
- isHelper: true,
- }
- }
- const table = !tableName ? LOGS_TABLES[queryType] : tableName
- const {
- error,
- logData,
- params,
- newCount,
- isLoading,
- eventChartData,
- isLoadingOlder,
- loadOlder,
- refresh,
- } = useLogsPreview({ projectRef, table, filterOverride })
- const {
- data: selectedLog,
- isLoading: isSelectedLogLoading,
- error: selectedLogError,
- } = useSingleLog({
- projectRef,
- id: selectedLogId ?? undefined,
- queryType,
- paramsToMerge: params,
- })
- const { showUpgradePrompt, setShowUpgradePrompt } = useUpgradePrompt(timestampStart)
- const onSelectTemplate = (template: LogTemplate) => {
- setFilters({ ...filters, search_query: template.searchString })
- }
- // [Joshen] For helper date picker values, reset the timestamp start to prevent data caching
- // Since the helpers are "Last n minutes" -> hitting refresh, you'd expect to see the latest result
- // Whereas if a specific range is selected, you'd not expect new data to show up
- const handleRefresh = () => {
- if (selectedDatePickerValue.isHelper) {
- const helper = PREVIEWER_DATEPICKER_HELPERS.find(
- (x) => x.text === selectedDatePickerValue.text
- )
- if (helper) {
- const newTimestampStart = helper.calcFrom()
- setTimeRange(newTimestampStart, timestampEnd)
- }
- }
- refresh()
- }
- const { getEntitlementNumericValue } = useCheckEntitlements('log.retention_days')
- const entitledToAuditLogDays = getEntitlementNumericValue()
- const handleSearch: LogSearchCallback = async (event, { query, to, from }) => {
- if (event === 'search-input-change') {
- setSearch(query || '')
- setSelectedLogId(null)
- } else if (event === 'event-chart-bar-click') {
- setTimeRange(from || '', to || '')
- } else if (event === 'datepicker-change') {
- const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
- from || '',
- entitledToAuditLogDays
- )
- if (shouldShowUpgradePrompt) {
- setShowUpgradePrompt(!showUpgradePrompt)
- } else {
- setTimeRange(from || '', to || '')
- }
- }
- }
- // Show the prompt on page load based on query params
- useEffect(() => {
- if (timestampStart) {
- const shouldShowUpgradePrompt = maybeShowUpgradePromptIfNotEntitled(
- timestampStart,
- entitledToAuditLogDays
- )
- if (shouldShowUpgradePrompt) {
- setShowUpgradePrompt(!showUpgradePrompt)
- }
- }
- }, [timestampStart, organization])
- useEffect(() => {
- if (db !== undefined) {
- const database = databases?.find((d) => d.identifier === db)
- if (database !== undefined) state.setSelectedDatabaseId(db)
- } else if (state.selectedDatabaseId !== undefined && state.selectedDatabaseId !== projectRef) {
- if (LOG_ROUTES_WITH_REPLICA_SUPPORT.includes(router.pathname)) {
- router.push({
- pathname: router.pathname,
- query: { ...router.query, db: state.selectedDatabaseId },
- })
- } else {
- state.setSelectedDatabaseId(projectRef)
- }
- }
- }, [db, isSuccess])
- // Common props shared between both filter panel components to avoid duplication
- const filterPanelProps = {
- className: filterPanelClassName,
- csvData: logData,
- isLoading,
- newCount,
- onRefresh: handleRefresh,
- onSearch: handleSearch,
- defaultSearchValue: search,
- defaultToValue: timestampEnd,
- defaultFromValue: timestampStart,
- queryUrl: `/project/${projectRef}/logs/explorer?q=${encodeURIComponent(
- params.sql || ''
- )}&its=${encodeURIComponent(timestampStart)}&ite=${encodeURIComponent(timestampEnd)}`,
- onSelectTemplate,
- filters,
- onFiltersChange: setFilters,
- table,
- condensedLayout,
- isShowingEventChart: showChart,
- onToggleEventChart: () => setShowChart(!showChart),
- onSelectedDatabaseChange: (id: string) => {
- setFilters({ ...filters, database: id !== projectRef ? id : undefined })
- const { db, ...params } = router.query
- router.push({
- pathname: router.pathname,
- query: id !== projectRef ? { ...router.query, db: id } : params,
- })
- },
- selectedDatePickerValue,
- setSelectedDatePickerValue,
- searchInputRef,
- }
- useLogsPreviewShortcuts({
- searchInputRef,
- hasSearch: search.length > 0,
- onResetSearch: () => {
- setSearch('')
- setSelectedLogId(null)
- },
- onRefresh: handleRefresh,
- onToggleChart: () => setShowChart((prev) => !prev),
- onLoadOlder: loadOlder,
- canLoadOlder: !error && logData.length > 0 && !isLoadingOlder,
- })
- return (
- <div className="flex-1 flex flex-col h-full">
- <PreviewFilterPanel {...filterPanelProps} />
- {children}
- <div
- className={
- 'transition-all duration-500 ' +
- (showChart && logData.length > 0 ? 'mb-2 mt-1 opacity-100' : 'h-0 opacity-0')
- }
- >
- <div className={condensedLayout ? 'px-3' : ''}>
- {showChart && (
- <LogsBarChart
- data={eventChartData}
- onBarClick={(datum) => {
- if (!datum?.timestamp) return
- const { start, end } = calculateBarClickTimeRange(
- timestampStart,
- timestampEnd,
- datum.timestamp
- )
- handleSearch('event-chart-bar-click', {
- query: filters.search_query?.toString(),
- to: end,
- from: start,
- })
- }}
- EmptyState={
- <div className="flex flex-col items-center justify-center h-[67px]">
- <p className="text-foreground-light text-xs">No data</p>
- <p className="text-foreground-lighter text-xs">
- It may take up to 24 hours for data to refresh
- </p>
- </div>
- }
- />
- )}
- </div>
- </div>
- <div className="relative flex flex-col grow flex-1 overflow-auto">
- <ShimmerLine active={isLoading} />
- <LoadingOpacity active={isLoading}>
- <LogTable
- projectRef={projectRef}
- isLoading={isLoading}
- data={logData}
- queryType={queryType}
- isHistogramShowing={showChart}
- onHistogramToggle={() => setShowChart(!showChart)}
- error={error}
- EmptyState={EmptyState}
- onSelectedLogChange={(log) => setSelectedLogId(log?.id ?? null)}
- selectedLog={selectedLog}
- isSelectedLogLoading={isSelectedLogLoading}
- selectedLogError={selectedLogError ?? undefined}
- />
- </LoadingOpacity>
- </div>
- {!error && logData.length > 0 && (
- <div className="border-t flex flex-row items-center gap-3 p-2">
- <ShortcutTooltip shortcutId={SHORTCUT_IDS.LOGS_PREVIEW_LOAD_OLDER} side="top">
- <Button
- onClick={loadOlder}
- icon={<Rewind />}
- type="default"
- loading={isLoadingOlder}
- disabled={isLoadingOlder}
- >
- Load older
- </Button>
- </ShortcutTooltip>
- <div className="text-sm text-foreground-lighter">
- Showing <span className="font-mono">{logData.length}</span> results
- </div>
- <div className="flex flex-row justify-end mt-2">
- <UpgradePrompt show={showUpgradePrompt} setShowUpgradePrompt={setShowUpgradePrompt} />
- </div>
- </div>
- )}
- </div>
- )
- }
|