feature: Adds logs chart with stacked charts with total errors (#32742)

* add logschart with stacked bars

* use ui charts pkg

* fix contrast

* move to ui-patterns

* undo areachart

* add recharts peerDep

* update pckgjson and lock w recharts

* feat: enable client-side rendering for LogsBarChart component

* fix registry
This commit is contained in:
Jordi Enric authored and GitHub committed 2025-01-15 10:22:13 +01:00
1 parent c7ebd91740
commit a2aa5bc83d
17 files changed
+252 -151

No files matched your search

+11
View File
@@ -2161,6 +2161,17 @@ export const Index: Record<string, any> = {
subcategory: "undefined",
chunks: []
},
"logs-bar-chart": {
name: "logs-bar-chart",
type: "components:example",
registryDependencies: undefined,
component: React.lazy(() => import("@/registry/default/example/logs-bar-chart")),
source: "",
files: ["registry/default/example/logs-bar-chart.tsx"],
category: "undefined",
subcategory: "undefined",
chunks: []
},
"chart-area-axes": {
name: "chart-area-axes",
type: "components:block",
+5
View File
@@ -117,6 +117,11 @@ export const docsConfig: DocsConfig = {
href: '/docs/fragments/multi-select',
items: [],
},
{
title: 'Logs Bar Chart',
href: '/docs/fragments/logs-bar-chart',
items: [],
},
],
},
{
@@ -0,0 +1,21 @@
---
title: Logs Bar Chart
description: A stacked bar chart that displays logs errors and successes.
fragment: true
---
<ComponentPreview name="logs-bar-chart" peekCode wide />
## Usage
This component is used to display the bar chart in logs pages.
It requires that the `data` prop is an array of objects with the following shape:
```ts
type LogsBarChartDatum = {
timestamp: string
ok_count: number
error_count: number
}
```
@@ -0,0 +1,20 @@
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
export default function LogsBarChartDemo() {
const data = Array.from({ length: 100 }, (_, i) => {
const date = new Date()
date.setMinutes(date.getMinutes() - i * 5) // Each point 5 minutes apart
return {
timestamp: date.toISOString(),
ok_count: Math.floor(Math.random() * 100), // Random value 0-99
error_count: Math.floor(Math.random() * 50), // Random value 0-50
}
}).reverse()
return (
<div className="w-full">
<LogsBarChart data={data} />
</div>
)
}
+5
View File
@@ -1201,4 +1201,9 @@ export const examples: Registry = [
type: 'docs:example',
files: ['example/expanding-textarea-demo.tsx'],
},
{
name: 'logs-bar-chart',
type: 'components:example',
files: ['example/logs-bar-chart.tsx'],
},
]
@@ -1,27 +0,0 @@
import BarChart from 'components/ui/Charts/BarChart'
import type { Datum } from 'components/ui/Charts/Charts.types'
import type { EventChartData } from './Logs.types'
export interface LogEventChartProps {
data: EventChartData[]
onBarClick: (isoTimestamp: string) => void
className?: string
}
const LogEventChart = ({ data, onBarClick, className }: LogEventChartProps) => (
<BarChart
className={className}
minimalHeader
size="tiny"
yAxisKey="count"
xAxisKey="timestamp"
data={data}
title="Logs / Time"
onBarClick={(datum: Datum | EventChartData) => {
if (!datum?.timestamp) return
onBarClick(datum.timestamp as string)
}}
customDateFormat="MMM D, HH:mm:s"
/>
)
export default LogEventChart
@@ -673,6 +673,16 @@ export const LOGS_TAILWIND_CLASSES = {
}
export const PREVIEWER_DATEPICKER_HELPERS: DatetimeHelper[] = [
{
text: 'Last 15 minutes',
calcFrom: () => dayjs().subtract(15, 'minute').startOf('minute').toISOString(),
calcTo: () => '',
},
{
text: 'Last 30 minutes',
calcFrom: () => dayjs().subtract(30, 'minute').startOf('minute').toISOString(),
calcTo: () => '',
},
{
text: 'Last hour',
calcFrom: () => dayjs().subtract(1, 'hour').startOf('hour').toISOString(),
@@ -51,7 +51,8 @@ export interface CountData {
}
export interface EventChartData extends Datum {
count: number
error_count: number
ok_count: number
timestamp: string
}
@@ -321,14 +321,16 @@ export const genChartQuery = (
) => {
const [startOffset, trunc] = calcChartStart(params)
const where = genWhereStatement(table, filters)
const errorCondition = getErrorCondition(table)
let joins = genCrossJoinUnnests(table)
return `
const q = `
SELECT
-- log-event-chart
timestamp_trunc(t.timestamp, ${trunc}) as timestamp,
count(t.timestamp) as count
count(CASE WHEN NOT (${errorCondition}) THEN 1 END) as ok_count,
count(CASE WHEN ${errorCondition} THEN 1 END) as error_count,
FROM
${table} t
${joins}
@@ -342,6 +344,7 @@ timestamp
ORDER BY
timestamp ASC
`
return q
}
type TsPair = [string | '', string | '']
@@ -579,3 +582,20 @@ export function checkForWildcard(query: string) {
const wildcardRegex = /\*/
return wildcardRegex.test(queryWithoutCount)
}
function getErrorCondition(table: LogsTableName): string {
switch (table) {
case 'edge_logs':
return 'response.status_code >= 400'
case 'postgres_logs':
return "parsed.error_severity IN ('ERROR', 'FATAL', 'PANIC')"
case 'auth_logs':
return "metadata.level = 'error' OR metadata.status >= 400"
case 'function_edge_logs':
return 'response.status_code >= 400'
case 'function_logs':
return "metadata.level IN ('error', 'fatal')"
default:
return 'false' // Default to no errors if table type is unknown
}
}
@@ -13,7 +13,6 @@ import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
import { useUpgradePrompt } from 'hooks/misc/useUpgradePrompt'
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
import { Button, cn } from 'ui'
import LogEventChart from './LogEventChart'
import LogTable from './LogTable'
import { LOGS_TABLES, LOG_ROUTES_WITH_REPLICA_SUPPORT, LogsTableName } from './Logs.constants'
import type { Filters, LogSearchCallback, LogTemplate, QueryType } from './Logs.types'
@@ -21,6 +20,8 @@ import { ensureNoTimestampConflict, maybeShowUpgradePrompt } from './Logs.utils'
import UpgradePrompt from './UpgradePrompt'
import { useSelectedLog } from 'hooks/analytics/useSelectedLog'
import useSingleLog from 'hooks/analytics/useSingleLog'
import { LogsBarChart } from 'ui-patterns/LogsBarChart'
import NoDataPlaceholder from 'components/ui/Charts/NoDataPlaceholder'
/**
* Acts as a container component for the entire log display
@@ -93,21 +94,6 @@ export const LogsPreviewer = ({
params.iso_timestamp_start as string
)
useEffect(() => {
setFilters((prev) => ({
...prev,
search_query: s,
database: db,
}))
if (ite || its) {
setParams((prev) => ({
...prev,
iso_timestamp_start: its || '',
iso_timestamp_end: ite || '',
}))
}
}, [db, s, ite, its])
// Show the prompt on page load based on query params
useEffect(() => {
if (its) {
@@ -239,23 +225,28 @@ export const LogsPreviewer = ({
<div
className={
'transition-all duration-500 ' +
(showChart && logData.length > 0 ? 'mb-4 h-28 opacity-100' : 'h-0 opacity-0')
(showChart && logData.length > 0 ? 'mb-2 mt-1 opacity-100' : 'h-0 opacity-0')
}
>
<div className={condensedLayout ? 'px-3' : ''}>
{showChart && (
<LogEventChart
className={cn({
'opacity-40': isLoading,
})}
<LogsBarChart
data={eventChartData}
onBarClick={(isoTimestamp) => {
onBarClick={(datum) => {
if (!datum?.timestamp) return
handleSearch('event-chart-bar-click', {
query: filters.search_query as string,
to: isoTimestamp as string,
to: datum.timestamp.toString(),
from: null,
})
}}
EmptyState={
<NoDataPlaceholder
message={'No data'}
description="It may take up to 24 hours for data to refresh"
size={'tiny'}
/>
}
/>
)}
</div>
@@ -7,6 +7,8 @@ export const CHART_COLORS = {
AXIS: 'hsl(var(--background-overlay-hover))',
GREEN_1: 'hsl(var(--brand-default))', // #3ECF8E
GREEN_2: 'hsl(var(--brand-500))',
RED_1: 'hsl(var(--destructive-default))',
RED_2: 'hsl(var(--destructive-500))',
}
// refer to packages/ui/radix-colors.js for full list of colors
@@ -1,35 +0,0 @@
import { afterEach, beforeEach, test, vi } from 'vitest'
import { screen } from '@testing-library/react'
import LogEventChart from 'components/interfaces/Settings/Logs/LogEventChart'
import { render } from '../../helpers'
const { ResizeObserver } = window
beforeEach(() => {
delete (window as any).ResizeObserver
window.ResizeObserver = vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
}))
})
afterEach(() => {
window.ResizeObserver = ResizeObserver
})
test('renders chart', async () => {
const mockFn = vi.fn()
const tsMicro = new Date().getTime() * 1000
render(
<LogEventChart
data={[
{ timestamp: tsMicro.toString(), count: 1 },
{ timestamp: (tsMicro + 1).toString(), count: 2 },
]}
onBarClick={mockFn}
/>
)
await screen.findByText(/Logs \/ Time/)
})
+131
View File
@@ -0,0 +1,131 @@
'use client'
import dayjs from 'dayjs'
import { ReactNode, useState } from 'react'
import { Bar, Cell, BarChart as RechartBarChart, XAxis, YAxis } from 'recharts'
import type { CategoricalChartState } from 'recharts/types/chart/types'
import { ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent, cn } from 'ui'
const CHART_COLORS = {
TICK: 'hsl(var(--background-overlay-hover))',
AXIS: 'hsl(var(--background-overlay-hover))',
GREEN_1: 'hsl(var(--brand-default))',
GREEN_2: 'hsl(var(--brand-500))',
RED_1: 'hsl(var(--destructive-default))',
RED_2: 'hsl(var(--destructive-500))',
}
type LogsBarChartDatum = {
timestamp: string
error_count: number
ok_count: number
}
export const LogsBarChart = ({
data,
onBarClick,
EmptyState,
DateTimeFormat = 'MMM D, YYYY, hh:mma',
}: {
data: LogsBarChartDatum[]
onBarClick?: (datum: LogsBarChartDatum, tooltipData?: CategoricalChartState) => void
EmptyState?: ReactNode
DateTimeFormat?: string
}) => {
const [focusDataIndex, setFocusDataIndex] = useState<number | null>(null)
if (data.length === 0) {
if (EmptyState) return EmptyState
return null
}
const startDate = dayjs(data[0]['timestamp']).format(DateTimeFormat)
const endDate = dayjs(data[data?.length - 1]?.['timestamp']).format(DateTimeFormat)
return (
<div className={cn('flex flex-col gap-y-3')}>
<ChartContainer
config={
{
error_count: {
label: 'Errors',
},
ok_count: {
label: 'Ok',
},
} satisfies ChartConfig
}
className="h-[80px]"
>
<RechartBarChart
data={data}
onMouseMove={(e: any) => {
if (e.activeTooltipIndex !== focusDataIndex) {
setFocusDataIndex(e.activeTooltipIndex)
}
}}
onMouseLeave={() => setFocusDataIndex(null)}
onClick={(tooltipData) => {
const datum = tooltipData?.activePayload?.[0]?.payload
if (onBarClick) onBarClick(datum, tooltipData)
}}
>
<YAxis
tick={false}
width={0}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
/>
<XAxis
dataKey="timestamp"
interval={data.length - 2}
tick={false}
axisLine={{ stroke: CHART_COLORS.AXIS }}
tickLine={{ stroke: CHART_COLORS.AXIS }}
/>
<ChartTooltip
content={
<ChartTooltipContent
className="text-foreground-light"
labelFormatter={(v) => dayjs(v).format(DateTimeFormat)}
/>
}
/>
{/* Error bars */}
<Bar dataKey="error_count" fill={CHART_COLORS.RED_1} maxBarSize={24} stackId="stack">
{data?.map((_entry: LogsBarChartDatum, index: number) => (
<Cell
className="cursor-pointer transition-colors"
key={`error-${index}`}
fill={
focusDataIndex === index || focusDataIndex === null
? CHART_COLORS.RED_1
: CHART_COLORS.RED_2
}
/>
))}
</Bar>
{/* Success bars */}
<Bar dataKey="ok_count" fill={CHART_COLORS.GREEN_1} maxBarSize={24} stackId="stack">
{data?.map((_entry: LogsBarChartDatum, index: number) => (
<Cell
className="cursor-pointer transition-colors"
key={`success-${index}`}
fill={
focusDataIndex === index || focusDataIndex === null
? CHART_COLORS.GREEN_1
: CHART_COLORS.GREEN_2
}
/>
))}
</Bar>
</RechartBarChart>
</ChartContainer>
{data && (
<div className="text-foreground-lighter -mt-10 flex items-center justify-between text-[10px] font-mono">
<span>{startDate}</span>
<span>{endDate}</span>
</div>
)}
</div>
)
}
+1
View File
@@ -22,3 +22,4 @@ export * from './PromoToast'
export * from './admonition'
export * from './ComputeBadge'
export * from './TimestampInfo'
export * from './LogsBarChart'
+1
View File
@@ -34,6 +34,7 @@
"react-tooltip": "*",
"react-use": "^17.5.0",
"reactflow": "*",
"recharts": "^2.8.0",
"remark-gfm": "^4.0.0",
"sonner": "^1.5.0",
"sql-formatter": "^15.3.1",
@@ -216,7 +216,7 @@ const ChartTooltipContent = React.forwardRef<
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-foreground-muted">
<span className="text-foreground-light">
{itemConfig?.label || item.name}
</span>
</div>
+6 -62
View File
@@ -206,7 +206,7 @@ importers:
version: 14.2.3
'@next/mdx':
specifier: ^14.2.3
version: 14.2.3(@mdx-js/loader@2.3.0(supports-color@8.1.1)(webpack@5.94.0))(@mdx-js/react@2.3.0(react@18.2.0))
version: 14.2.3(@mdx-js/loader@2.3.0(supports-color@8.1.1))(@mdx-js/react@2.3.0(react@18.2.0))
'@octokit/auth-app':
specifier: ^6.0.3
version: 6.0.3
@@ -988,7 +988,7 @@ importers:
version: 14.2.3
'@next/mdx':
specifier: ^14.2.3
version: 14.2.3(@mdx-js/loader@2.3.0(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.20.2)))(@mdx-js/react@2.3.0(react@18.2.0))
version: 14.2.3(@mdx-js/loader@2.3.0(supports-color@8.1.1))(@mdx-js/react@2.3.0(react@18.2.0))
'@octokit/auth-app':
specifier: ^6.0.3
version: 6.0.3
@@ -1852,6 +1852,9 @@ importers:
reactflow:
specifier: '*'
version: 11.10.1(@types/react@18.3.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
recharts:
specifier: ^2.8.0
version: 2.12.7(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
remark-gfm:
specifier: ^4.0.0
version: 4.0.0(supports-color@8.1.1)
@@ -14005,15 +14008,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@mdx-js/loader@2.3.0(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.20.2))':
dependencies:
'@mdx-js/mdx': 2.3.0(supports-color@8.1.1)
source-map: 0.7.4
webpack: 5.94.0(esbuild@0.20.2)
transitivePeerDependencies:
- supports-color
optional: true
'@mdx-js/loader@2.3.0(supports-color@8.1.1)(webpack@5.94.0)':
dependencies:
'@mdx-js/mdx': 2.3.0(supports-color@8.1.1)
@@ -14155,14 +14149,7 @@ snapshots:
dependencies:
glob: 10.3.10
'@next/mdx@14.2.3(@mdx-js/loader@2.3.0(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.20.2)))(@mdx-js/react@2.3.0(react@18.2.0))':
dependencies:
source-map: 0.7.4
optionalDependencies:
'@mdx-js/loader': 2.3.0(supports-color@8.1.1)(webpack@5.94.0(esbuild@0.20.2))
'@mdx-js/react': 2.3.0(react@18.2.0)
'@next/mdx@14.2.3(@mdx-js/loader@2.3.0(supports-color@8.1.1)(webpack@5.94.0))(@mdx-js/react@2.3.0(react@18.2.0))':
'@next/mdx@14.2.3(@mdx-js/loader@2.3.0(supports-color@8.1.1))(@mdx-js/react@2.3.0(react@18.2.0))':
dependencies:
source-map: 0.7.4
optionalDependencies:
@@ -24787,18 +24774,6 @@ snapshots:
mkdirp: 1.0.4
yallist: 4.0.0
terser-webpack-plugin@5.3.10(esbuild@0.20.2)(webpack@5.94.0(esbuild@0.20.2)):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
jest-worker: 27.5.1
schema-utils: 3.3.0
serialize-javascript: 6.0.2
terser: 5.32.0
webpack: 5.94.0(esbuild@0.20.2)
optionalDependencies:
esbuild: 0.20.2
optional: true
terser-webpack-plugin@5.3.10(webpack@5.94.0):
dependencies:
'@jridgewell/trace-mapping': 0.3.25
@@ -25653,37 +25628,6 @@ snapshots:
- esbuild
- uglify-js
webpack@5.94.0(esbuild@0.20.2):
dependencies:
'@types/estree': 1.0.5
'@webassemblyjs/ast': 1.12.1
'@webassemblyjs/wasm-edit': 1.12.1
'@webassemblyjs/wasm-parser': 1.12.1
acorn: 8.11.3
acorn-import-attributes: 1.9.5(acorn@8.11.3)
browserslist: 4.23.3
chrome-trace-event: 1.0.3
enhanced-resolve: 5.17.1
es-module-lexer: 1.5.4
eslint-scope: 5.1.1
events: 3.3.0
glob-to-regexp: 0.4.1
graceful-fs: 4.2.11
json-parse-even-better-errors: 2.3.1
loader-runner: 4.3.0
mime-types: 2.1.35
neo-async: 2.6.2
schema-utils: 3.3.0
tapable: 2.2.1
terser-webpack-plugin: 5.3.10(esbuild@0.20.2)(webpack@5.94.0(esbuild@0.20.2))
watchpack: 2.4.2
webpack-sources: 3.2.3
transitivePeerDependencies:
- '@swc/core'
- esbuild
- uglify-js
optional: true
whatwg-encoding@2.0.0:
dependencies:
iconv-lite: 0.6.3