From a2aa5bc83d69c6f56f085bf9b192bddf0494a9fa Mon Sep 17 00:00:00 2001 From: Jordi Enric <37541088+jordienr@users.noreply.github.com> Date: Wed, 15 Jan 2025 10:22:13 +0100 Subject: [PATCH] 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 --- apps/design-system/__registry__/index.tsx | 11 ++ apps/design-system/config/docs.ts | 5 + .../content/docs/fragments/logs-bar-chart.mdx | 21 +++ .../default/example/logs-bar-chart.tsx | 20 +++ apps/design-system/registry/examples.ts | 5 + .../Settings/Logs/LogEventChart.tsx | 27 ---- .../Settings/Logs/Logs.constants.ts | 10 ++ .../interfaces/Settings/Logs/Logs.types.ts | 3 +- .../interfaces/Settings/Logs/Logs.utils.ts | 24 +++- .../Settings/Logs/LogsPreviewer.tsx | 37 ++--- .../components/ui/Charts/Charts.constants.ts | 2 + .../pages/projects/LogEventChart.test.tsx | 35 ----- packages/ui-patterns/LogsBarChart/index.tsx | 131 ++++++++++++++++++ packages/ui-patterns/index.tsx | 1 + packages/ui-patterns/package.json | 1 + .../ui/src/components/shadcn/ui/chart.tsx | 2 +- pnpm-lock.yaml | 68 +-------- 17 files changed, 252 insertions(+), 151 deletions(-) create mode 100644 apps/design-system/content/docs/fragments/logs-bar-chart.mdx create mode 100644 apps/design-system/registry/default/example/logs-bar-chart.tsx delete mode 100644 apps/studio/components/interfaces/Settings/Logs/LogEventChart.tsx delete mode 100644 apps/studio/tests/pages/projects/LogEventChart.test.tsx create mode 100644 packages/ui-patterns/LogsBarChart/index.tsx diff --git a/apps/design-system/__registry__/index.tsx b/apps/design-system/__registry__/index.tsx index ba7caca35fa..8d55cc7b07a 100644 --- a/apps/design-system/__registry__/index.tsx +++ b/apps/design-system/__registry__/index.tsx @@ -2161,6 +2161,17 @@ export const Index: Record = { 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", diff --git a/apps/design-system/config/docs.ts b/apps/design-system/config/docs.ts index fa2e91a1c20..471a9c89158 100644 --- a/apps/design-system/config/docs.ts +++ b/apps/design-system/config/docs.ts @@ -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: [], + }, ], }, { diff --git a/apps/design-system/content/docs/fragments/logs-bar-chart.mdx b/apps/design-system/content/docs/fragments/logs-bar-chart.mdx new file mode 100644 index 00000000000..7127ecd2eac --- /dev/null +++ b/apps/design-system/content/docs/fragments/logs-bar-chart.mdx @@ -0,0 +1,21 @@ +--- +title: Logs Bar Chart +description: A stacked bar chart that displays logs errors and successes. +fragment: true +--- + + + +## 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 +} +``` diff --git a/apps/design-system/registry/default/example/logs-bar-chart.tsx b/apps/design-system/registry/default/example/logs-bar-chart.tsx new file mode 100644 index 00000000000..b477e491133 --- /dev/null +++ b/apps/design-system/registry/default/example/logs-bar-chart.tsx @@ -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 ( +
+ +
+ ) +} diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts index eab719afe0e..58d7c1dd508 100644 --- a/apps/design-system/registry/examples.ts +++ b/apps/design-system/registry/examples.ts @@ -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'], + }, ] diff --git a/apps/studio/components/interfaces/Settings/Logs/LogEventChart.tsx b/apps/studio/components/interfaces/Settings/Logs/LogEventChart.tsx deleted file mode 100644 index a5f1b84782f..00000000000 --- a/apps/studio/components/interfaces/Settings/Logs/LogEventChart.tsx +++ /dev/null @@ -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) => ( - { - if (!datum?.timestamp) return - onBarClick(datum.timestamp as string) - }} - customDateFormat="MMM D, HH:mm:s" - /> -) -export default LogEventChart diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts index ee10cbcf03a..4a79934d899 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.constants.ts @@ -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(), diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts index 565c00e41b1..eda734318a1 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.types.ts @@ -51,7 +51,8 @@ export interface CountData { } export interface EventChartData extends Datum { - count: number + error_count: number + ok_count: number timestamp: string } diff --git a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts index 527194f63af..01e41eb9c35 100644 --- a/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts +++ b/apps/studio/components/interfaces/Settings/Logs/Logs.utils.ts @@ -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 + } +} diff --git a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx index 8af8a9c74c3..12e6a7b7283 100644 --- a/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx +++ b/apps/studio/components/interfaces/Settings/Logs/LogsPreviewer.tsx @@ -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 = ({
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') } >
{showChart && ( - { + 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={ + + } /> )}
diff --git a/apps/studio/components/ui/Charts/Charts.constants.ts b/apps/studio/components/ui/Charts/Charts.constants.ts index a8bb2e8aca5..a3caa2fd19f 100644 --- a/apps/studio/components/ui/Charts/Charts.constants.ts +++ b/apps/studio/components/ui/Charts/Charts.constants.ts @@ -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 diff --git a/apps/studio/tests/pages/projects/LogEventChart.test.tsx b/apps/studio/tests/pages/projects/LogEventChart.test.tsx deleted file mode 100644 index 32a81a4b82e..00000000000 --- a/apps/studio/tests/pages/projects/LogEventChart.test.tsx +++ /dev/null @@ -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( - - ) - await screen.findByText(/Logs \/ Time/) -}) diff --git a/packages/ui-patterns/LogsBarChart/index.tsx b/packages/ui-patterns/LogsBarChart/index.tsx new file mode 100644 index 00000000000..51cfe6e44d6 --- /dev/null +++ b/packages/ui-patterns/LogsBarChart/index.tsx @@ -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(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 ( +
+ + { + if (e.activeTooltipIndex !== focusDataIndex) { + setFocusDataIndex(e.activeTooltipIndex) + } + }} + onMouseLeave={() => setFocusDataIndex(null)} + onClick={(tooltipData) => { + const datum = tooltipData?.activePayload?.[0]?.payload + if (onBarClick) onBarClick(datum, tooltipData) + }} + > + + + dayjs(v).format(DateTimeFormat)} + /> + } + /> + + {/* Error bars */} + + {data?.map((_entry: LogsBarChartDatum, index: number) => ( + + ))} + + + {/* Success bars */} + + {data?.map((_entry: LogsBarChartDatum, index: number) => ( + + ))} + + + + {data && ( +
+ {startDate} + {endDate} +
+ )} +
+ ) +} diff --git a/packages/ui-patterns/index.tsx b/packages/ui-patterns/index.tsx index 198641b0d1e..e244cb346f1 100644 --- a/packages/ui-patterns/index.tsx +++ b/packages/ui-patterns/index.tsx @@ -22,3 +22,4 @@ export * from './PromoToast' export * from './admonition' export * from './ComputeBadge' export * from './TimestampInfo' +export * from './LogsBarChart' diff --git a/packages/ui-patterns/package.json b/packages/ui-patterns/package.json index 2d8650f3d3c..b3d18fc06f3 100644 --- a/packages/ui-patterns/package.json +++ b/packages/ui-patterns/package.json @@ -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", diff --git a/packages/ui/src/components/shadcn/ui/chart.tsx b/packages/ui/src/components/shadcn/ui/chart.tsx index 3959978584e..60f896af271 100644 --- a/packages/ui/src/components/shadcn/ui/chart.tsx +++ b/packages/ui/src/components/shadcn/ui/chart.tsx @@ -216,7 +216,7 @@ const ChartTooltipContent = React.forwardRef< >
{nestLabel ? tooltipLabel : null} - + {itemConfig?.label || item.name}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd4ae6a9f58..ac06ba55d98 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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