Files
supabase/studio/components/interfaces/Settings/Logs/LogTable.tsx
T

381 lines
12 KiB
TypeScript

import dayjs from 'dayjs'
import { useEffect, useState, useMemo } from 'react'
import {
Alert,
Button,
IconDownloadCloud,
IconEye,
IconEyeOff,
IconLoader,
Input,
Typography,
} from '@supabase/ui'
import DataGrid from '@supabase/react-data-grid'
import LogSelection from './LogSelection'
import { LogData, QueryType } from './Logs.types'
import { SeverityFormatter, ResponseCodeFormatter, HeaderFormmater } from './LogsFormatters'
// column renders
import DatabaseApiColumnRender from './LogColumnRenderers/DatabaseApiColumnRender'
import DatabasePostgresColumnRender from './LogColumnRenderers/DatabasePostgresColumnRender'
import CSVButton from 'components/ui/CSVButton'
interface Props {
data?: Array<LogData | Object>
queryType?: QueryType
onHistogramToggle?: () => void
isHistogramShowing?: boolean
isLoading?: boolean
error?: any
showDownload?: boolean
}
type LogMap = { [id: string]: LogData }
/**
* Logs table view with focus side panel
*
* When in custom data display mode, the side panel will not open when focusing on logs.
*/
const LogTable = ({
data = [],
queryType,
onHistogramToggle,
isHistogramShowing,
isLoading,
showDownload,
error,
}: Props) => {
const [focusedLog, setFocusedLog] = useState<LogData | null>(null)
const columnNames = Object.keys(data[0] || {})
const hasId = columnNames.includes('id')
const hasTimestamp = columnNames.includes('timestamp')
const DEFAULT_COLUMNS = columnNames.map((v) => {
let formatter = undefined
const firstRow: any = data[0]
const value = firstRow?.[v]
if (typeof value === 'object') {
formatter = () => `[Object]`
}
return { key: v, name: v, resizable: true, formatter, header: v, minWidth: 128 }
})
let columns
if (!queryType) {
columns = DEFAULT_COLUMNS
} else {
switch (queryType) {
case 'api':
columns = DatabaseApiColumnRender
break
case 'database':
columns = DatabasePostgresColumnRender
break
case 'fn_edge':
columns = [
{
key: 'timestamp',
headerRenderer: () => (
<div className="flex w-full justify-end h-full">
<HeaderFormmater value={'timestamp'} />
</div>
),
name: 'timestamp',
formatter: (data: any) => (
<span className="flex w-full h-full items-center gap-1">
<span className="text-xs">
{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}
</span>
<span className="text-xs">
{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}
</span>
</span>
),
width: 128,
},
{
key: 'status_code',
headerRenderer: () => <HeaderFormmater value={'Status'} />,
name: 'status_code',
formatter: (data: any) => (
<ResponseCodeFormatter row={data} value={data.row.status_code} />
),
width: 0,
resizable: true,
},
{
key: 'method',
headerRenderer: () => <HeaderFormmater value={'method'} />,
width: 0,
resizable: true,
},
{
key: 'id',
headerRenderer: () => <HeaderFormmater value={'id'} />,
name: 'id',
resizable: true,
},
]
break
case 'functions':
columns = [
{
key: 'timestamp',
headerRenderer: () => (
<div className="flex w-full justify-end h-full">
<HeaderFormmater value={'timestamp'} />
</div>
),
name: 'timestamp',
formatter: (data: any) => (
<span className="flex w-full h-full items-center gap-1">
<span className="text-xs !text-scale-1100">
{dayjs(data?.row?.timestamp / 1000).format('DD MMM')}
</span>
<span className="text-xs !text-scale-1100">
{dayjs(data?.row?.timestamp / 1000).format('HH:mm:ss')}
</span>
</span>
),
width: 128,
resizable: true,
},
{
key: 'level',
headerRenderer: () => <HeaderFormmater value={'Level'} />,
name: 'level',
formatter: (data: any) => <SeverityFormatter value={data.row.level} />,
width: 24,
resizable: true,
},
{
key: 'event_message',
headerRenderer: () => <HeaderFormmater value={'Event message'} />,
resizable: true,
},
]
break
default:
columns = DEFAULT_COLUMNS
break
}
}
const stringData = JSON.stringify(data)
const [dedupedData, logMap] = useMemo<[LogData[], LogMap]>(() => {
const deduped = [...new Set(data)] as LogData[]
if (!hasId) {
return [deduped, {} as LogMap]
}
const map = deduped.reduce((acc: LogMap, d: LogData) => {
acc[d.id] = d
return acc
}, {}) as LogMap
return [deduped, map]
}, [stringData])
useEffect(() => {
if (!hasId || data === null) return
if (focusedLog && !(focusedLog.id in logMap)) {
setFocusedLog(null)
}
}, [stringData])
if (!data) return null
// [Joshen] Hmm quite hacky now, but will do
const maxHeight = !queryType ? 'calc(100vh - 42px - 10rem)' : 'calc(100vh - 42px - 3rem)'
const logDataRows = useMemo(() => {
if (hasId && hasTimestamp) {
return Object.values(logMap).sort((a, b) => b.timestamp - a.timestamp)
} else {
return dedupedData
}
}, [stringData])
return (
<>
<section
className={'flex flex-col w-full ' + (!queryType ? 'shadow-lg' : '')}
style={{ maxHeight }}
>
{!queryType && (
<div>
<div
className="
w-full bg-scale-100 dark:bg-scale-300
rounded-tl rounded-tr
border-t
border-l
border-r
flex items-center justify-between
px-5 py-2
"
>
<div className="flex items-center gap-2">
{data && data.length ? (
<>
<span className="text-sm text-scale-1200">Query results</span>
<span className="text-sm text-scale-1100">{data && data.length}</span>
</>
) : (
<span className="text-xs text-scale-1200">Results will be shown below</span>
)}
</div>
<div className="flex items-center gap-2">
{onHistogramToggle && (
<Button
type="default"
icon={isHistogramShowing ? <IconEye /> : <IconEyeOff />}
onClick={onHistogramToggle}
>
Histogram
</Button>
)}
{showDownload && <CSVButton data={data}>Download</CSVButton>}
</div>
</div>
</div>
)}
<div
className={'flex flex-row h-full ' + (!queryType ? 'border-l border-r' : '')}
>
<DataGrid
style={{ height: '100%' }}
className={`
flex-grow flex-1
${!queryType ? 'data-grid--logs-explorer' : ' data-grid--simple-logs'}
`}
rowHeight={40}
headerRowHeight={queryType ? 0 : 28}
onSelectedCellChange={({ idx, rowIdx }) => {
if (!hasId) return
setFocusedLog(data[rowIdx] as LogData)
}}
noRowsFallback={
!isLoading ? (
<>
<div className="py-4 w-full h-full flex-col space-y-12">
<div
className={`transition-all
duration-500
delay-200
flex
flex-col
items-center
gap-6
text-center
mt-16
opacity-100
scale-100
justify-center
`}
>
<div className="flex flex-col gap-1">
<div className="relative border border-scale-600 border-dashed dark:border-scale-400 w-32 h-4 rounded px-2 flex items-center"></div>
<div className="relative border border-scale-600 border-dashed dark:border-scale-400 w-32 h-4 rounded px-2 flex items-center">
<div className="absolute right-1 -bottom-4">
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-6 w-6"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth="2"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M9.172 16.172a4 4 0 015.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
/>
</svg>
</div>
</div>
</div>
<div className="flex flex-col gap-1 px-5">
<h3 className="text-lg text-scale-1200">No results</h3>
<p className="text-sm text-scale-900">
Try another search, or adjusting the filters
</p>
</div>
</div>
{error && (
<div className="flex justify-center px-5">
<Alert
variant="danger"
title="Sorry! An error occured when fetching data."
withIcon
className="max-w-xl"
>
<Input.TextArea
size="small"
value={JSON.stringify(error, null, 2)}
borderless
className="font-mono w-full mt-4"
copy
rows={12}
/>
</Alert>
</div>
)}
</div>
</>
) : null
}
columns={columns as any}
rowClass={(r) => {
const row = r as LogData
let classes = []
classes.push(
`${
row.id === focusedLog?.id ? '!bg-scale-400 rdg-row--focussed' : 'cursor-pointer'
}`
)
return classes.join(' ')
}}
rows={logDataRows}
rowKeyGetter={(r) => {
if (!hasId) return Object.keys(r)[0]
const row = r as LogData
return row.id
}}
onRowClick={(r) => setFocusedLog(r)}
/>
{logDataRows.length > 0 ? (
<div
className={
queryType
? 'w-1/2 flex flex-col'
: focusedLog
? 'w-1/2 flex flex-col'
: 'w-0 hidden'
}
>
<LogSelection
isLoading={isLoading}
onClose={() => setFocusedLog(null)}
log={focusedLog}
queryType={queryType}
/>
</div>
) : null}
</div>
</section>
</>
)
}
export default LogTable