mirror of
https://github.com/supabase/supabase.git
synced 2026-10-15 14:19:11 +03:00
add infinite and live logs example
This commit is contained in:
1 parent
14c6f9a867
commit
e744a409cb
11 files changed
+793
-32
No files matched your search
+18
-16
@@ -22,8 +22,8 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
column.toggleSorting(undefined)
|
||||
}}
|
||||
@@ -31,23 +31,25 @@ export function DataTableColumnHeader<TData, TValue>({
|
||||
'py-0 px-0 h-7 hover:bg-transparent flex gap-2 items-center justify-between w-full',
|
||||
className
|
||||
)}
|
||||
iconRight={
|
||||
<span className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
'-mb-0.5 h-3 w-3',
|
||||
column.getIsSorted() === 'asc' ? 'text-foreground' : 'text-foreground-muted'
|
||||
)}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'-mt-0.5 h-3 w-3',
|
||||
column.getIsSorted() === 'desc' ? 'text-foreground' : 'text-foreground-muted'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span className="flex flex-col">
|
||||
<ChevronUp
|
||||
className={cn(
|
||||
'-mb-0.5 h-3 w-3',
|
||||
column.getIsSorted() === 'asc' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'-mt-0.5 h-3 w-3',
|
||||
column.getIsSorted() === 'desc' ? 'text-accent-foreground' : 'text-muted-foreground'
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
+5
-3
@@ -63,10 +63,12 @@ export function LiveButton({ fetchPreviousPage }: LiveButtonProps) {
|
||||
<Button
|
||||
className={cn(live && 'border-info text-info hover:text-info')}
|
||||
onClick={handleClick}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
type="default"
|
||||
size="small"
|
||||
icon={
|
||||
live ? <CirclePause className="mr-2 h-4 w-4" /> : <CirclePlay className="mr-2 h-4 w-4" />
|
||||
}
|
||||
>
|
||||
{live ? <CirclePause className="mr-2 h-4 w-4" /> : <CirclePlay className="mr-2 h-4 w-4" />}
|
||||
Live
|
||||
</Button>
|
||||
)
|
||||
|
||||
+7
-1
@@ -10,7 +10,13 @@ export function RefreshButton({ onClick }: RefreshButtonProps) {
|
||||
const { isLoading } = useDataTable()
|
||||
|
||||
return (
|
||||
<Button type="outline" size="small" disabled={isLoading} onClick={onClick} className="h-9 w-9">
|
||||
<Button
|
||||
type="outline"
|
||||
size="small"
|
||||
disabled={isLoading}
|
||||
onClick={onClick}
|
||||
className="h-9 w-9 px-0"
|
||||
>
|
||||
{isLoading ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -68,6 +68,12 @@ export function Client() {
|
||||
})
|
||||
}, [facets])
|
||||
|
||||
// --- DEBUG: Check props before passing to DataTableInfinite ---
|
||||
console.log('>>> [Client] flatData length:', flatData?.length)
|
||||
console.log('>>> [Client] filterDBRowCount:', filterDBRowCount)
|
||||
console.log('>>> [Client] data object:', data) // Log the raw react-query data
|
||||
// --- END DEBUG ---
|
||||
|
||||
return (
|
||||
<DataTableInfinite
|
||||
columns={columns}
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Percentile } from 'components/interfaces/DataTableDemo/lib/request
|
||||
import SuperJSON from 'superjson'
|
||||
import type { BaseChartSchema, ColumnSchema, FacetMetadataSchema } from './schema'
|
||||
import type { SearchParamsType } from './search-params'
|
||||
// Removed: import { searchParamsSerializer } from './search-params'
|
||||
import {
|
||||
ARRAY_DELIMITER,
|
||||
RANGE_DELIMITER,
|
||||
@@ -11,14 +12,14 @@ import {
|
||||
SORT_DELIMITER,
|
||||
} from 'components/interfaces/DataTableDemo/lib/delimiters'
|
||||
|
||||
// Helper function to create query params string (assuming similar API conventions)
|
||||
// Restore the helper function to create query params string
|
||||
const createApiQueryString = (params: Record<string, any>): string => {
|
||||
const queryParams = new URLSearchParams()
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value === null || value === undefined) continue
|
||||
|
||||
// Add handling specific to infinite API if needed, otherwise assume similar to light
|
||||
// Using logic similar to the original light version
|
||||
if (key === 'date' && Array.isArray(value) && value.length === 2) {
|
||||
queryParams.set('dateStart', value[0].getTime().toString())
|
||||
queryParams.set('dateEnd', value[1].getTime().toString())
|
||||
@@ -36,16 +37,23 @@ const createApiQueryString = (params: Record<string, any>): string => {
|
||||
value.length > 0
|
||||
) {
|
||||
// Assuming slider or range gives min/max
|
||||
queryParams.set(`${key}Start`, value[0].toString())
|
||||
queryParams.set(`${key}End`, value[value.length - 1].toString())
|
||||
// Use the correct delimiters if necessary based on how backend expects ranges
|
||||
if (value.length === 1) {
|
||||
// Handle single value if needed, maybe just set the key?
|
||||
// queryParams.set(key, value[0].toString());
|
||||
} else if (value.length >= 2) {
|
||||
queryParams.set(`${key}Start`, value[0].toString())
|
||||
queryParams.set(`${key}End`, value[value.length - 1].toString())
|
||||
}
|
||||
} else if (Array.isArray(value)) {
|
||||
if (value.length > 0) {
|
||||
queryParams.set(key, value.join(',')) // Use comma separation for arrays
|
||||
// Use comma (ARRAY_DELIMITER) separation for arrays
|
||||
queryParams.set(key, value.join(ARRAY_DELIMITER))
|
||||
}
|
||||
} else if (key === 'sort' && typeof value === 'object' && value !== null) {
|
||||
queryParams.set(
|
||||
key,
|
||||
`${(value as { id: string; desc: boolean }).id}.${(value as { id: string; desc: boolean }).desc ? 'desc' : 'asc'}`
|
||||
`${(value as { id: string; desc: boolean }).id}${SORT_DELIMITER}${(value as { id: string; desc: boolean }).desc ? 'desc' : 'asc'}`
|
||||
)
|
||||
} else if (value instanceof Date) {
|
||||
queryParams.set(key, value.getTime().toString())
|
||||
@@ -112,16 +120,15 @@ export const dataOptions = (search: SearchParamsType) => {
|
||||
...(direction && { direction }), // Add direction if present
|
||||
}
|
||||
|
||||
const queryString = createApiQueryString(apiParams)
|
||||
// Assuming the API route is /api/data-table/infinite
|
||||
const response = await fetch(`/api/data-table/infinite${queryString}`)
|
||||
const response = await fetch(`/api/data-table/infinite${createApiQueryString(apiParams)}`)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok')
|
||||
}
|
||||
|
||||
const json = await response.json()
|
||||
return SuperJSON.parse<InfiniteQueryResponse<ColumnSchema[], LogsMeta>>(json)
|
||||
const jsonString = await response.text()
|
||||
return SuperJSON.parse<InfiniteQueryResponse<ColumnSchema[], LogsMeta>>(jsonString)
|
||||
},
|
||||
initialPageParam: { cursor: new Date().getTime(), direction: 'next' } as PageParam,
|
||||
getPreviousPageParam: (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
createParser,
|
||||
// createSearchParamsCache, // Removed for Pages Router compatibility
|
||||
// createSerializer, // Removed for Pages Router compatibility
|
||||
createSerializer, // Uncommented for Pages Router compatibility
|
||||
parseAsArrayOf,
|
||||
parseAsBoolean,
|
||||
parseAsInteger,
|
||||
@@ -64,7 +64,7 @@ export const searchParamsParser = {
|
||||
|
||||
// Removed for Pages Router compatibility
|
||||
// export const searchParamsCache = createSearchParamsCache(searchParamsParser)
|
||||
// export const searchParamsSerializer = createSerializer(searchParamsParser)
|
||||
export const searchParamsSerializer = createSerializer(searchParamsParser) // Uncommented
|
||||
|
||||
export type SearchParamsType = inferParserType<typeof searchParamsParser>
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ const HOSTED_SUPPORTED_API_URLS = [
|
||||
'/edge-functions/test',
|
||||
'/edge-functions/body',
|
||||
'/api/data-table/light',
|
||||
'/api/data-table/infinite',
|
||||
]
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { LEVELS } from 'components/interfaces/DataTableDemo/constants/levels'
|
||||
import { REGIONS } from 'components/interfaces/DataTableDemo/constants/region'
|
||||
import { isArrayOfDates, isArrayOfNumbers } from 'components/interfaces/DataTableDemo/lib/is-array'
|
||||
import {
|
||||
calculatePercentile,
|
||||
calculateSpecificPercentile,
|
||||
} from 'components/interfaces/DataTableDemo/lib/request/percentile'
|
||||
import { addDays, addMilliseconds, differenceInMinutes, isSameDay } from 'date-fns'
|
||||
import type {
|
||||
ColumnSchema,
|
||||
FacetMetadataSchema,
|
||||
TimelineChartSchema,
|
||||
} from 'components/interfaces/DataTableDemo/infinite/schema'
|
||||
import type { SearchParamsType } from 'components/interfaces/DataTableDemo/infinite/search-params'
|
||||
|
||||
export const sliderFilterValues = [
|
||||
'latency',
|
||||
'timing.dns',
|
||||
'timing.connection',
|
||||
'timing.tls',
|
||||
'timing.ttfb',
|
||||
'timing.transfer',
|
||||
] as const satisfies (keyof ColumnSchema)[]
|
||||
|
||||
export const filterValues = [
|
||||
'level',
|
||||
...sliderFilterValues,
|
||||
'status',
|
||||
'regions',
|
||||
'method',
|
||||
'host',
|
||||
'pathname',
|
||||
] as const satisfies (keyof ColumnSchema)[]
|
||||
|
||||
export function filterData(
|
||||
data: ColumnSchema[],
|
||||
search: Partial<SearchParamsType>
|
||||
): ColumnSchema[] {
|
||||
return data.filter((row) => {
|
||||
for (const key in search) {
|
||||
const filter = search[key as keyof typeof search]
|
||||
|
||||
if (filter === undefined || filter === null) continue
|
||||
|
||||
if (
|
||||
(key === 'latency' ||
|
||||
key === 'timing.dns' ||
|
||||
key === 'timing.connection' ||
|
||||
key === 'timing.tls' ||
|
||||
key === 'timing.ttfb' ||
|
||||
key === 'timing.transfer') &&
|
||||
isArrayOfNumbers(filter) &&
|
||||
filter.length > 0
|
||||
) {
|
||||
const rowValue = row[key as keyof ColumnSchema]
|
||||
if (typeof rowValue !== 'number') return false
|
||||
if (filter.length === 1 && rowValue !== filter[0]) return false
|
||||
if (filter.length === 2 && (rowValue < filter[0] || rowValue > filter[1])) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'status' && isArrayOfNumbers(filter) && filter.length > 0) {
|
||||
const rowValue = row[key as 'status']
|
||||
if (typeof rowValue !== 'number') return false
|
||||
if (!filter.includes(rowValue)) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'regions' && Array.isArray(filter) && filter.length > 0) {
|
||||
const rowRegions = row[key as 'regions']
|
||||
if (!Array.isArray(rowRegions) || rowRegions.length === 0) return false
|
||||
if (!(filter as string[]).includes(rowRegions[0])) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'date' && isArrayOfDates(filter) && filter.length > 0) {
|
||||
const rowValue = row[key as 'date']
|
||||
if (!(rowValue instanceof Date)) return false
|
||||
const rowTimestamp = rowValue.getTime()
|
||||
if (filter.length === 1 && !isSameDay(rowValue, filter[0])) return false
|
||||
if (
|
||||
filter.length === 2 &&
|
||||
(rowTimestamp < filter[0].getTime() || rowTimestamp > filter[1].getTime())
|
||||
)
|
||||
return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'level' && Array.isArray(filter) && filter.length > 0) {
|
||||
const rowValue = row[key as 'level']
|
||||
if (typeof rowValue !== 'string') return false
|
||||
if (!(filter as string[]).includes(rowValue)) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'host' && typeof filter === 'string' && filter.length > 0) {
|
||||
const rowValue = row[key as 'host']
|
||||
if (typeof rowValue !== 'string' || rowValue !== filter) return false
|
||||
continue
|
||||
}
|
||||
|
||||
if (key === 'pathname' && typeof filter === 'string' && filter.length > 0) {
|
||||
const rowValue = row[key as 'pathname']
|
||||
if (typeof rowValue !== 'string' || rowValue !== filter) return false
|
||||
continue
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function sortData(data: ColumnSchema[], sort: SearchParamsType['sort']) {
|
||||
if (!sort) return data
|
||||
return data.sort((a, b) => {
|
||||
if (sort.desc) {
|
||||
// @ts-ignore
|
||||
return a?.[sort.id] < b?.[sort.id] ? 1 : -1
|
||||
} else {
|
||||
// @ts-ignore
|
||||
return a?.[sort.id] > b?.[sort.id] ? 1 : -1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function percentileData(data: ColumnSchema[]): ColumnSchema[] {
|
||||
const latencies = data.map((row) => row.latency)
|
||||
return data.map((row) => ({
|
||||
...row,
|
||||
percentile: calculatePercentile(latencies, row.latency),
|
||||
}))
|
||||
}
|
||||
|
||||
export function splitData(data: ColumnSchema[], search: SearchParamsType) {
|
||||
let newData: ColumnSchema[] = []
|
||||
const now = new Date()
|
||||
const cursorTimestamp = search.cursor.getTime()
|
||||
|
||||
data.forEach((item) => {
|
||||
const itemTimestamp = item.date.getTime()
|
||||
|
||||
if (search.direction === 'next') {
|
||||
// If item is older than cursor and we need more items
|
||||
if (itemTimestamp < cursorTimestamp && newData.length < search.size) {
|
||||
newData.push(item)
|
||||
// If item has the exact same timestamp as the last added item (to include ties)
|
||||
} else if (
|
||||
newData.length > 0 &&
|
||||
itemTimestamp === newData[newData.length - 1].date.getTime()
|
||||
) {
|
||||
newData.push(item)
|
||||
}
|
||||
} else if (search.direction === 'prev') {
|
||||
// If item is newer than cursor and not in the future
|
||||
if (itemTimestamp > cursorTimestamp && itemTimestamp < now.getTime()) {
|
||||
newData.push(item)
|
||||
}
|
||||
// Note: For 'prev', we might need to sort in reverse and take size later
|
||||
// or handle it differently depending on desired behavior for ties.
|
||||
// Current logic fetches all newer items, potentially more than size.
|
||||
}
|
||||
})
|
||||
|
||||
// For 'prev', ensure we only return the required size, potentially sorting again if order matters
|
||||
if (search.direction === 'prev') {
|
||||
// Assuming sortData handles default if search.sort is null
|
||||
const sortedPrevData = sortData(newData, search.sort)
|
||||
newData = sortedPrevData.slice(0, search.size) // Limit to size
|
||||
}
|
||||
|
||||
return newData
|
||||
}
|
||||
|
||||
export function getFacetsFromData(data: ColumnSchema[]) {
|
||||
const valuesMap = data.reduce((prev, curr) => {
|
||||
Object.entries(curr).forEach(([key, value]) => {
|
||||
if (filterValues.includes(key as any)) {
|
||||
// REMINDER: because regions is an array with a single value we need to convert to string
|
||||
// TODO: we should make the region a single string instead of an array?!?
|
||||
const _value = Array.isArray(value) ? value.toString() : value
|
||||
const total = prev.get(key)?.get(_value) || 0
|
||||
if (prev.has(key) && _value) {
|
||||
prev.get(key)?.set(_value, total + 1)
|
||||
} else if (_value) {
|
||||
prev.set(key, new Map([[_value, 1]]))
|
||||
}
|
||||
}
|
||||
})
|
||||
return prev
|
||||
}, new Map<string, Map<any, number>>())
|
||||
|
||||
const facets = Object.fromEntries(
|
||||
Array.from(valuesMap.entries()).map(([key, valueMap]) => {
|
||||
let min: number | undefined
|
||||
let max: number | undefined
|
||||
const rows = Array.from(valueMap.entries()).map(([value, total]) => {
|
||||
if (typeof value === 'number') {
|
||||
if (!min) min = value
|
||||
else min = value < min ? value : min
|
||||
if (!max) max = value
|
||||
else max = value > max ? value : max
|
||||
}
|
||||
return {
|
||||
value,
|
||||
total,
|
||||
}
|
||||
})
|
||||
const total = Array.from(valueMap.values()).reduce((a, b) => a + b, 0)
|
||||
return [key, { rows, total, min, max }]
|
||||
})
|
||||
)
|
||||
|
||||
return facets satisfies Record<string, FacetMetadataSchema>
|
||||
}
|
||||
|
||||
export function getPercentileFromData(data: ColumnSchema[]) {
|
||||
const latencies = data.map((row) => row.latency)
|
||||
|
||||
const p50 = calculateSpecificPercentile(latencies, 50)
|
||||
const p75 = calculateSpecificPercentile(latencies, 75)
|
||||
const p90 = calculateSpecificPercentile(latencies, 90)
|
||||
const p95 = calculateSpecificPercentile(latencies, 95)
|
||||
const p99 = calculateSpecificPercentile(latencies, 99)
|
||||
|
||||
return { p50, p75, p90, p95, p99 }
|
||||
}
|
||||
|
||||
export function groupChartData(data: ColumnSchema[], dates: Date[] | null): TimelineChartSchema[] {
|
||||
console.log(`>>> [groupChartData] Input data length: ${data?.length}, Input dates:`, dates)
|
||||
|
||||
if (data?.length === 0 && !dates) {
|
||||
console.log('>>> [groupChartData] Returning [] due to empty data and no dates.')
|
||||
return []
|
||||
}
|
||||
|
||||
const _dates = dates?.length === 1 ? [dates[0], addDays(dates[0], 1)] : dates
|
||||
const between = _dates || (data?.length ? [data[data.length - 1].date, data[0].date] : [])
|
||||
|
||||
console.log(`>>> [groupChartData] Date range used ('between'):`, between)
|
||||
|
||||
if (!between.length || !(between[0] instanceof Date) || !(between[1] instanceof Date)) {
|
||||
// Added validity check
|
||||
console.log(`>>> [groupChartData] Returning [] due to invalid 'between' range.`)
|
||||
return []
|
||||
}
|
||||
|
||||
const interval = evaluateInterval(between)
|
||||
console.log(`>>> [groupChartData] Calculated interval (ms): ${interval}`)
|
||||
if (interval <= 0) {
|
||||
// Added check for valid interval
|
||||
console.log('>>> [groupChartData] Returning [] due to invalid interval.')
|
||||
return []
|
||||
}
|
||||
|
||||
const duration = Math.abs(between[0].getTime() - between[between.length - 1].getTime())
|
||||
const steps = Math.floor(duration / interval)
|
||||
console.log(`>>> [groupChartData] Calculated steps: ${steps}`)
|
||||
if (steps <= 0) {
|
||||
// Added check for valid steps
|
||||
console.log('>>> [groupChartData] Returning [] due to zero steps.')
|
||||
return []
|
||||
}
|
||||
|
||||
const timestamps: { date: Date }[] = []
|
||||
|
||||
for (let i = 0; i < steps; i++) {
|
||||
const newTimestamp = addMilliseconds(between[0], i * interval)
|
||||
timestamps.push({ date: newTimestamp })
|
||||
}
|
||||
|
||||
// TODO: make it dynamic to avoid havin 200, 400, 500 hardcoded
|
||||
// TODO: make it more efficient
|
||||
// e.g. make the "status" prop we use as T generic
|
||||
const result = timestamps.map((timestamp, i) => {
|
||||
const filteredData = data.filter((row) => {
|
||||
const diff = row.date.getTime() - timestamp.date.getTime()
|
||||
return diff < interval && diff >= 0
|
||||
})
|
||||
|
||||
return {
|
||||
timestamp: timestamp.date.getTime(), // TODO: use date-fns and interval to determine the format
|
||||
success: filteredData.filter((row) => row.level === 'success').length,
|
||||
warning: filteredData.filter((row) => row.level === 'warning').length,
|
||||
error: filteredData.filter((row) => row.level === 'error').length,
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`>>> [groupChartData] Result length: ${result.length}`)
|
||||
return result
|
||||
}
|
||||
|
||||
export function evaluateInterval(dates: Date[] | null): number {
|
||||
if (!dates) return 0
|
||||
if (dates.length < 1 || dates.length > 3) return 0
|
||||
|
||||
// Calculate the time difference in minutes
|
||||
const timeDiffInMinutes = Math.abs(differenceInMinutes(dates[0], dates[1]))
|
||||
|
||||
// Define thresholds and their respective intervals in milliseconds
|
||||
const intervals = [
|
||||
{ threshold: 1, interval: 1000 }, // 1 second
|
||||
{ threshold: 5, interval: 5000 }, // 5 seconds
|
||||
{ threshold: 10, interval: 10000 }, // 10 seconds
|
||||
{ threshold: 30, interval: 30000 }, // 30 seconds
|
||||
{ threshold: 60, interval: 60000 }, // 1 minute
|
||||
{ threshold: 120, interval: 120000 }, // 2 minutes
|
||||
{ threshold: 240, interval: 240000 }, // 4 minutes
|
||||
{ threshold: 480, interval: 480000 }, // 8 minutes
|
||||
{ threshold: 1440, interval: 1440000 }, // 24 minutes
|
||||
{ threshold: 2880, interval: 2880000 }, // 48 minutes
|
||||
{ threshold: 5760, interval: 5760000 }, // 96 minutes
|
||||
{ threshold: 11520, interval: 11520000 }, // 192 minutes
|
||||
{ threshold: 23040, interval: 23040000 }, // 384 minutes
|
||||
]
|
||||
|
||||
// Iterate over the intervals and return the matching one
|
||||
for (const { threshold, interval } of intervals) {
|
||||
if (timeDiffInMinutes < threshold) {
|
||||
return interval
|
||||
}
|
||||
}
|
||||
|
||||
// Default to the largest interval if no match found
|
||||
return 46080000 // 768 minutes
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { calculateSpecificPercentile } from 'components/interfaces/DataTableDemo/lib/request/percentile'
|
||||
import { addDays } from 'date-fns'
|
||||
import type { NextApiRequest, NextApiResponse } from 'next'
|
||||
import SuperJSON from 'superjson'
|
||||
import type {
|
||||
InfiniteQueryResponse,
|
||||
LogsMeta,
|
||||
} from 'components/interfaces/DataTableDemo/infinite/query-options'
|
||||
import type { ColumnSchema } from 'components/interfaces/DataTableDemo/infinite/schema'
|
||||
import { LEVELS } from 'components/interfaces/DataTableDemo/constants/levels'
|
||||
import { METHODS } from 'components/interfaces/DataTableDemo/constants/method'
|
||||
import { REGIONS } from 'components/interfaces/DataTableDemo/constants/region'
|
||||
import {
|
||||
filterData,
|
||||
getFacetsFromData,
|
||||
groupChartData,
|
||||
percentileData,
|
||||
sliderFilterValues,
|
||||
sortData,
|
||||
splitData,
|
||||
} from './helpers'
|
||||
import { mock, mockLive } from './mock'
|
||||
import { searchParamsParser } from 'components/interfaces/DataTableDemo/infinite/search-params'
|
||||
import type { inferParserType } from 'nuqs'
|
||||
|
||||
type SearchParamsType = inferParserType<typeof searchParamsParser>
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse): Promise<void> {
|
||||
if (req.method !== 'GET') {
|
||||
res.setHeader('Allow', ['GET'])
|
||||
res.status(405).end(`Method ${req.method} Not Allowed`)
|
||||
return
|
||||
}
|
||||
|
||||
const query = req.query
|
||||
|
||||
// Parse the full search object to get defaults for pagination etc.
|
||||
const search = Object.fromEntries(
|
||||
Object.entries(searchParamsParser).map(([key, parser]) => {
|
||||
const queryValue = query[key]
|
||||
const value = Array.isArray(queryValue) ? queryValue[0] : queryValue
|
||||
const parsedValue = value !== undefined ? parser.parse(value) : parser.parse('')
|
||||
return [key, parsedValue]
|
||||
})
|
||||
) as SearchParamsType
|
||||
|
||||
// --- Build effectiveFilters ONLY from params present in the query --- //
|
||||
const effectiveFilters: Partial<SearchParamsType> = {}
|
||||
Object.keys(query).forEach((key) => {
|
||||
// Only include keys that are actual filters (not pagination/sort etc.)
|
||||
// and are defined in our parser
|
||||
if (
|
||||
key in searchParamsParser &&
|
||||
!['start', 'size', 'sort', 'direction', 'cursor', 'live'].includes(key)
|
||||
) {
|
||||
// Use the fully parsed value (including defaults if needed for parsing logic)
|
||||
// but only if the key was actually in the query
|
||||
const parsedValue = search[key as keyof SearchParamsType]
|
||||
// Only assign if the value is not null (as Partial<> expects Type | undefined)
|
||||
if (parsedValue !== null) {
|
||||
effectiveFilters[key as keyof typeof effectiveFilters] = parsedValue
|
||||
}
|
||||
}
|
||||
})
|
||||
// --- End of effectiveFilters build --- //
|
||||
|
||||
const totalData = [...mockLive, ...mock]
|
||||
|
||||
// Handle date separately IF it was in the query
|
||||
const _date =
|
||||
search.date?.length === 1 ? [search.date[0], addDays(search.date[0], 1)] : search.date
|
||||
if (_date && effectiveFilters.date) {
|
||||
effectiveFilters.date = _date // Use the calculated range
|
||||
}
|
||||
|
||||
// Single filtering step using only filters from the query
|
||||
const filteredData = filterData(totalData, effectiveFilters)
|
||||
|
||||
// DEBUG:
|
||||
console.log('>>> [API] Filtered data length:', filteredData.length)
|
||||
|
||||
// --- Calculate Date Range for Chart --- //
|
||||
let chartDateRange: Date[] | null = null
|
||||
const explicitDateFilter = effectiveFilters.date as Date[] | undefined
|
||||
if (explicitDateFilter && explicitDateFilter.length > 0) {
|
||||
// Use the explicit date filter if provided
|
||||
chartDateRange =
|
||||
explicitDateFilter.length === 1
|
||||
? [explicitDateFilter[0], addDays(explicitDateFilter[0], 1)]
|
||||
: explicitDateFilter
|
||||
} else if (filteredData.length > 0) {
|
||||
// Otherwise, use the range from the filtered data
|
||||
const sortedByDate = [...filteredData].sort((a, b) => a.date.getTime() - b.date.getTime())
|
||||
chartDateRange = [sortedByDate[0].date, sortedByDate[sortedByDate.length - 1].date]
|
||||
}
|
||||
// If filteredData is empty and no explicit date, chartDateRange remains null
|
||||
// --- End Date Range Calculation --- //
|
||||
|
||||
// REMINDER: We might still need the facets from data filtered *without* sliders
|
||||
// if the UI sliders need the full min/max range.
|
||||
// const _rest = Object.fromEntries(
|
||||
// Object.entries(search).filter(([key]) => !sliderFilterValues.includes(key as any))
|
||||
// )
|
||||
// const withoutSliderData = filterData(totalData, { ..._rest, date: _date })
|
||||
// const withoutSliderFacets = getFacetsFromData(withoutSliderData)
|
||||
|
||||
// Calculate facets based on the final filtered data
|
||||
const facets = getFacetsFromData(filteredData)
|
||||
|
||||
// Sort the filtered data
|
||||
const sortedData = sortData(filteredData, search.sort)
|
||||
|
||||
// Calculate percentile for each row IN the filtered data
|
||||
const withPercentileData = percentileData(sortedData)
|
||||
|
||||
// Paginate the sorted, percentile-annotated data
|
||||
const data = splitData(withPercentileData, search)
|
||||
|
||||
// Calculate aggregate percentiles based on the filtered data (before pagination)
|
||||
const latencies = filteredData.map(({ latency }) => latency)
|
||||
const currentPercentiles = {
|
||||
50: calculateSpecificPercentile(latencies, 50),
|
||||
75: calculateSpecificPercentile(latencies, 75),
|
||||
90: calculateSpecificPercentile(latencies, 90),
|
||||
95: calculateSpecificPercentile(latencies, 95),
|
||||
99: calculateSpecificPercentile(latencies, 99),
|
||||
}
|
||||
|
||||
const nextCursor = data.length > 0 ? data[data.length - 1].date.getTime() : null
|
||||
const prevCursor = data.length > 0 ? data[0].date.getTime() : new Date().getTime()
|
||||
|
||||
const responsePayload = {
|
||||
data,
|
||||
meta: {
|
||||
totalRowCount: totalData.length,
|
||||
filterRowCount: filteredData.length,
|
||||
chartData: groupChartData(filteredData, chartDateRange),
|
||||
facets: {
|
||||
...facets,
|
||||
},
|
||||
metadata: { currentPercentiles },
|
||||
},
|
||||
prevCursor,
|
||||
nextCursor,
|
||||
} satisfies InfiniteQueryResponse<ColumnSchema[], LogsMeta>
|
||||
|
||||
res.setHeader('Content-Type', 'application/json')
|
||||
res.status(200).send(SuperJSON.stringify(responsePayload))
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { METHODS } from 'components/interfaces/DataTableDemo/constants/method'
|
||||
import { ColumnSchema } from 'components/interfaces/DataTableDemo/infinite/schema'
|
||||
import { subMinutes } from 'date-fns'
|
||||
import { REGIONS } from 'components/interfaces/DataTableDemo/constants/region'
|
||||
|
||||
const DAYS = 20
|
||||
|
||||
function getRandomTiming(latency: number) {
|
||||
// Generate random percentages within the specified ranges
|
||||
const dns = Math.random() * (0.15 - 0.05) + 0.05 // 5% to 15%
|
||||
const connection = Math.random() * (0.3 - 0.1) + 0.1 // 10% to 30%
|
||||
const tls = Math.random() * (0.1 - 0.05) + 0.05 // 5% to 10%
|
||||
const transfer = Math.random() * (0.004 - 0) + 0.004 // 0% to 0.4%
|
||||
|
||||
// Ensure the sum of dns, connection, tls, and transfer is subtracted from 100% for ttfb
|
||||
const remaining = 1 - (dns + connection + tls + transfer) // Calculate remaining for ttfb
|
||||
|
||||
return {
|
||||
'timing.dns': Math.round(latency * dns),
|
||||
'timing.connection': Math.round(latency * connection),
|
||||
'timing.tls': Math.round(latency * tls),
|
||||
'timing.ttfb': Math.round(latency * remaining), // Use the remaining percentage for ttfb
|
||||
'timing.transfer': Math.round(latency * transfer),
|
||||
}
|
||||
}
|
||||
|
||||
// REMINDER: for later
|
||||
function getRandomMetadata(): Record<string, string> {
|
||||
const rand = Math.random()
|
||||
if (rand < 0.5) {
|
||||
return {
|
||||
env: 'production',
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
env: 'staging',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getLevel(status: number) {
|
||||
if (`${status}`.startsWith('2')) return 'success'
|
||||
if (`${status}`.startsWith('4')) return 'warning'
|
||||
if (`${status}`.startsWith('5')) return 'error'
|
||||
return 'error'
|
||||
}
|
||||
|
||||
function getRandomStatusCode() {
|
||||
const rand = Math.random()
|
||||
if (rand < 0.9) {
|
||||
return 200
|
||||
} else if (rand < 0.96) {
|
||||
if (Math.random() < 0.5) {
|
||||
return 400
|
||||
} else {
|
||||
return 404
|
||||
}
|
||||
} else {
|
||||
return 500
|
||||
}
|
||||
}
|
||||
|
||||
function getMessage() {
|
||||
return 'ERR_INTERNAL_DISASTER: "The server spilled coffee on itself."'
|
||||
}
|
||||
|
||||
const shopPathnames = [
|
||||
'/bikes/gravel/road',
|
||||
'/bikes/racing/track',
|
||||
'/bikes/mountain/trail',
|
||||
'/bikes/city/cargo',
|
||||
]
|
||||
|
||||
const apiPathnames = ['/v1/products', '/v1/orders', '/v1/customers']
|
||||
|
||||
function getRandomRequestObject(): {
|
||||
method: (typeof METHODS)[number]
|
||||
host: string
|
||||
pathname: string
|
||||
} {
|
||||
const rand = Math.random()
|
||||
if (rand < 0.5) {
|
||||
return {
|
||||
method: 'POST',
|
||||
host: 'api.acme-shop.com',
|
||||
pathname: apiPathnames[Math.floor(Math.random() * apiPathnames.length)],
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
method: 'GET',
|
||||
host: 'acme-shop.com',
|
||||
pathname: shopPathnames[Math.floor(Math.random() * shopPathnames.length)],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaders() {
|
||||
return {
|
||||
Age: '0',
|
||||
'Cache-Control': 'private, no-cache, no-store, max-age=0, must-revalidate',
|
||||
Server: 'Cloudflare',
|
||||
}
|
||||
}
|
||||
|
||||
const multiplier: Record<(typeof REGIONS)[number], number> = {
|
||||
ams: 1,
|
||||
iad: 0.6,
|
||||
gru: 1.6,
|
||||
syd: 1.3,
|
||||
fra: 0.8,
|
||||
hkg: 1.4,
|
||||
}
|
||||
|
||||
export function createMockData({
|
||||
minutes = 0,
|
||||
}: {
|
||||
size?: number
|
||||
minutes?: number
|
||||
}): ColumnSchema[] {
|
||||
const date = subMinutes(new Date(), minutes)
|
||||
const random = Math.random()
|
||||
|
||||
const statusCode = {
|
||||
ams: getRandomStatusCode(),
|
||||
iad: getRandomStatusCode(),
|
||||
gru: getRandomStatusCode(),
|
||||
syd: getRandomStatusCode(),
|
||||
fra: getRandomStatusCode(),
|
||||
hkg: getRandomStatusCode(),
|
||||
}
|
||||
|
||||
const latency = {
|
||||
ams: Math.round(1000 * (random * (1 - multiplier.ams) + multiplier.ams)),
|
||||
iad: Math.round(1000 * (random * (1 - multiplier.iad) + multiplier.iad)),
|
||||
gru: Math.round(1000 * (random * (1 - multiplier.gru) + multiplier.gru)),
|
||||
syd: Math.round(1000 * (random * (1 - multiplier.syd) + multiplier.syd)),
|
||||
fra: Math.round(1000 * (random * (1 - multiplier.fra) + multiplier.fra)),
|
||||
hkg: Math.round(1000 * (random * (1 - multiplier.hkg) + multiplier.hkg)),
|
||||
}
|
||||
|
||||
const requestObject = getRandomRequestObject()
|
||||
const headers = getHeaders()
|
||||
|
||||
return [
|
||||
{
|
||||
uuid: crypto.randomUUID(),
|
||||
level: getLevel(statusCode.ams),
|
||||
latency: latency.ams,
|
||||
regions: ['ams'],
|
||||
status: statusCode.ams,
|
||||
date,
|
||||
headers,
|
||||
message: 500 === statusCode.ams ? getMessage() : undefined,
|
||||
...getRandomTiming(latency.ams),
|
||||
...requestObject,
|
||||
},
|
||||
{
|
||||
uuid: crypto.randomUUID(),
|
||||
level: getLevel(statusCode.iad),
|
||||
latency: latency.iad,
|
||||
regions: ['iad'],
|
||||
status: statusCode.iad,
|
||||
date,
|
||||
headers,
|
||||
message: 500 === statusCode.iad ? getMessage() : undefined,
|
||||
...getRandomTiming(latency.iad),
|
||||
...requestObject,
|
||||
},
|
||||
{
|
||||
uuid: crypto.randomUUID(),
|
||||
level: getLevel(statusCode.gru),
|
||||
latency: latency.gru,
|
||||
regions: ['gru'],
|
||||
status: statusCode.gru,
|
||||
date,
|
||||
headers,
|
||||
message: 500 === statusCode.gru ? getMessage() : undefined,
|
||||
...getRandomTiming(latency.gru),
|
||||
...requestObject,
|
||||
},
|
||||
{
|
||||
uuid: crypto.randomUUID(),
|
||||
level: getLevel(statusCode.syd),
|
||||
latency: latency.syd,
|
||||
regions: ['syd'],
|
||||
status: statusCode.syd,
|
||||
date,
|
||||
headers,
|
||||
message: 500 === statusCode.syd ? getMessage() : undefined,
|
||||
...getRandomTiming(latency.syd),
|
||||
...requestObject,
|
||||
},
|
||||
{
|
||||
uuid: crypto.randomUUID(),
|
||||
level: getLevel(statusCode.fra),
|
||||
latency: latency.fra,
|
||||
regions: ['fra'],
|
||||
status: statusCode.fra,
|
||||
date,
|
||||
headers,
|
||||
message: 500 === statusCode.fra ? getMessage() : undefined,
|
||||
...getRandomTiming(latency.fra),
|
||||
...requestObject,
|
||||
},
|
||||
{
|
||||
uuid: crypto.randomUUID(),
|
||||
level: getLevel(statusCode.hkg),
|
||||
latency: latency.hkg,
|
||||
regions: ['hkg'],
|
||||
status: statusCode.hkg,
|
||||
date,
|
||||
headers,
|
||||
message: 500 === statusCode.hkg ? getMessage() : undefined,
|
||||
...getRandomTiming(latency.hkg),
|
||||
...requestObject,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export const mock = Array.from({ length: DAYS * 24 })
|
||||
.map((_, i) => createMockData({ minutes: i * 60 }))
|
||||
.reduce((prev, curr) => prev.concat(curr), []) satisfies ColumnSchema[]
|
||||
|
||||
export const mockLive = Array.from({ length: 10 })
|
||||
// REMINDER: do not use random, otherwise data needs to be sorted
|
||||
.map((_, i) => createMockData({ minutes: -((i + 1) * 0.3) }))
|
||||
.reduce((prev, curr) => prev.concat(curr), [])
|
||||
.reverse() satisfies ColumnSchema[]
|
||||
@@ -0,0 +1,36 @@
|
||||
import { getQueryClient } from 'components/interfaces/DataTableDemo/providers/get-query-client'
|
||||
// import { SearchParams } from 'nuqs' // No longer needed directly
|
||||
import * as React from 'react'
|
||||
import { Client } from 'components/interfaces/DataTableDemo/infinite/client'
|
||||
import { dataOptions } from 'components/interfaces/DataTableDemo/light/query-options'
|
||||
import { searchParamsParser } from 'components/interfaces/DataTableDemo/light/search-params' // Import the parser
|
||||
// import { useSearchParams } from 'next/navigation' // Switch to useQueryStates
|
||||
import { useQueryStates } from 'nuqs' // Import useQueryStates
|
||||
|
||||
import DefaultLayout from 'components/layouts/DefaultLayout'
|
||||
import LogsLayout from 'components/layouts/LogsLayout/LogsLayout'
|
||||
|
||||
import type { NextPageWithLayout } from 'types'
|
||||
|
||||
export const LogPage: NextPageWithLayout = () => {
|
||||
// const searchParams = useSearchParams() // Replaced
|
||||
// const params = Object.fromEntries(searchParams.entries()) // Replaced
|
||||
// const search = searchParamsCache.parse(params) // Replaced
|
||||
const [search] = useQueryStates(searchParamsParser) // Use useQueryStates with the parser
|
||||
const queryClient = getQueryClient()
|
||||
|
||||
React.useEffect(() => {
|
||||
// Pass the parsed search object directly to dataOptions
|
||||
queryClient.prefetchInfiniteQuery(dataOptions(search))
|
||||
}, [search, queryClient]) // Add queryClient to dependencies
|
||||
|
||||
return <Client />
|
||||
}
|
||||
|
||||
LogPage.getLayout = (page) => (
|
||||
<DefaultLayout>
|
||||
<LogsLayout hideSidebar={true}>{page}</LogsLayout>
|
||||
</DefaultLayout>
|
||||
)
|
||||
|
||||
export default LogPage
|
||||
Reference in new issue
Block a user