diff --git a/studio/components/interfaces/Reports/ReportFilterBar.tsx b/studio/components/interfaces/Reports/ReportFilterBar.tsx index 15bc75c22ec..cc28535a6df 100644 --- a/studio/components/interfaces/Reports/ReportFilterBar.tsx +++ b/studio/components/interfaces/Reports/ReportFilterBar.tsx @@ -12,7 +12,7 @@ import { IconBox, IconCode, IconDatabase, - IconZapOff, + IconZap, } from 'ui' import DatePickers from '../Settings/Logs/Logs.DatePickers' import { REPORTS_DATEPICKER_HELPERS } from './Reports.constants' @@ -59,7 +59,7 @@ const PRODUCT_FILTERS = [ filterValue: '/realtime', label: 'Realtime', description: 'Realtime connection requests', - icon: IconZapOff, + icon: IconZap, }, // TODO: support functions once union parsing is fixed // { diff --git a/studio/components/interfaces/Reports/ReportPadding.tsx b/studio/components/interfaces/Reports/ReportPadding.tsx index ece24f6f81e..29eaf8c0df4 100644 --- a/studio/components/interfaces/Reports/ReportPadding.tsx +++ b/studio/components/interfaces/Reports/ReportPadding.tsx @@ -2,7 +2,7 @@ * Standardized padding and width layout for non-custom reports */ const ReportPadding: React.FC = ({ children }) => ( -
+
{children}
) diff --git a/studio/components/interfaces/Reports/ReportWidget.tsx b/studio/components/interfaces/Reports/ReportWidget.tsx index 45b55aef2a4..04a9e0df164 100644 --- a/studio/components/interfaces/Reports/ReportWidget.tsx +++ b/studio/components/interfaces/Reports/ReportWidget.tsx @@ -1,7 +1,6 @@ import { NextRouter, useRouter } from 'next/router' import * as Tooltip from '@radix-ui/react-tooltip' import { Button, IconExternalLink, IconHelpCircle } from 'ui' - import { BaseReportParams } from './Reports.types' import { LogsEndpointParams } from '../Settings/Logs' import Panel from 'components/ui/Panel' @@ -14,12 +13,15 @@ export interface ReportWidgetProps { tooltip?: string className?: string renderer: (props: ReportWidgetRendererProps) => React.ReactNode + append?: (props: ReportWidgetRendererProps) => React.ReactNode + // for overriding props, such as data + appendProps?: Partial // omitting params will hide the "View in logs explorer" button params?: BaseReportParams | LogsEndpointParams isLoading: boolean } -export interface ReportWidgetRendererProps extends ReportWidgetProps { +export interface ReportWidgetRendererProps extends ReportWidgetProps { router: NextRouter projectRef: string } @@ -28,7 +30,6 @@ const ReportWidget: React.FC = (props) => { const router = useRouter() const { ref } = router.query const projectRef = ref as string - return ( = (props) => { {props.data === undefined ? null : props.renderer({ ...props, router, projectRef })} + + {props.append && + props.append({ ...props, ...(props.appendProps || {}), router, projectRef })} ) diff --git a/studio/components/interfaces/Reports/Reports.constants.ts b/studio/components/interfaces/Reports/Reports.constants.ts index 025b39a3401..718250d0cc4 100644 --- a/studio/components/interfaces/Reports/Reports.constants.ts +++ b/studio/components/interfaces/Reports/Reports.constants.ts @@ -74,6 +74,29 @@ export const PRESET_CONFIG: Record = { ORDER BY timestamp ASC`, }, + topRoutes: { + queryType: "logs", + sql: (filters)=> ` + select + request.path as path, + request.method as method, + request.search as search, + response.status_code as status_code, + count(t.id) as count + from edge_logs t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + ${generateRexepWhere(filters)} + group by + request.path, request.method, request.search, response.status_code + order by + count desc + limit + 3 + ` + }, errorCounts: { queryType: 'logs', sql: (filters) => ` @@ -94,13 +117,37 @@ export const PRESET_CONFIG: Record = { timestamp ASC `, }, + topErrorRoutes: { + queryType: "logs", + sql: (filters)=> ` + select + request.path as path, + request.method as method, + request.search as search, + response.status_code as status_code, + count(t.id) as count + from edge_logs t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + where + response.status_code >= 400 + ${generateRexepWhere(filters, false)} + group by + request.path, request.method, request.search, response.status_code + order by + count desc + limit + 3 + ` + }, responseSpeed: { queryType: 'logs', sql: (filters) => ` select cast(timestamp_trunc(t.timestamp, hour) as datetime) as timestamp, - avg(response.origin_time) as avg, - APPROX_QUANTILES(response.origin_time, 100) as quantiles + avg(response.origin_time) as avg FROM edge_logs t cross join unnest(metadata) as m @@ -114,6 +161,30 @@ export const PRESET_CONFIG: Record = { timestamp ASC `, }, + topSlowRoutes: { + queryType: "logs", + sql: (filters)=> ` + select + request.path as path, + request.method as method, + request.search as search, + response.status_code as status_code, + count(t.id) as count, + avg(response.origin_time) as avg + from edge_logs t + cross join unnest(metadata) as m + cross join unnest(m.response) as response + cross join unnest(m.request) as request + cross join unnest(request.headers) as headers + ${generateRexepWhere(filters)} + group by + request.path, request.method, request.search, response.status_code + order by + avg desc + limit + 3 + ` + }, }, }, [Presets.AUTH]: { diff --git a/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx b/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx index 70a689b9eaa..d6b97f6c3ab 100644 --- a/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx +++ b/studio/components/interfaces/Reports/renderers/ApiRenderers.tsx @@ -1,7 +1,16 @@ -import { ReportWidgetProps } from '../ReportWidget' +import { ReportWidgetProps, ReportWidgetRendererProps } from '../ReportWidget' import BarChart from 'components/ui/Charts/BarChart' +import Table from 'components/to-be-cleaned/Table' +import { + jsonSyntaxHighlight, + TextFormatter, +} from 'components/interfaces/Settings/Logs/LogsFormatters' +import { Button, Collapsible, IconChevronRight } from 'ui' +import { queryParamsToObject } from '../Reports.utils' +import { Fragment } from 'react' +import useFillTimeseriesSorted from 'hooks/analytics/useFillTimeseriesSorted' -export const renderTotalRequests = ( +export const TotalRequestsChartRenderer = ( props: ReportWidgetProps<{ timestamp: string count: number @@ -10,13 +19,21 @@ export const renderTotalRequests = ( const total = props.data.reduce((acc, datum) => { return acc + datum.count }, 0) + const data = useFillTimeseriesSorted( + props.data, + 'timestamp', + 'count', + 0, + props.params?.iso_timestamp_start, + props.params?.iso_timestamp_end + ) return ( +) => { + if (props.data.length === 0) return null + const headerClasses = '!text-xs !py-2 p-0 font-bold !bg-scale-400' + const cellClasses = '!text-xs !py-2' + return ( + + Request + Count + {props.data[0].avg !== undefined && ( + Avg + )} + + } + body={ + <> + {props.data.map((datum) => ( + + + + + + + {datum.count} + + {props.data[0].avg !== undefined && ( + + {Number(datum.avg).toFixed(2)}ms + + )} + + + ))} + + } + /> + ) +} + +export const ErrorCountsChartRenderer = ( props: ReportWidgetProps<{ timestamp: string count: number @@ -33,13 +101,23 @@ export const renderErrorCounts = ( const total = props.data.reduce((acc, datum) => { return acc + datum.count }, 0) + + const data = useFillTimeseriesSorted( + props.data, + 'timestamp', + 'count', + 0, + props.params?.iso_timestamp_start, + props.params?.iso_timestamp_end + ) + return ( ) => { const transformedData = props.data.map((datum) => ({ timestamp: datum.timestamp, avg: datum.avg, - median: datum.quantiles[49], })) + + const data = useFillTimeseriesSorted( + transformedData, + 'timestamp', + 'avg', + 0, + props.params?.iso_timestamp_start, + props.params?.iso_timestamp_end + ) + const lastAvg = props.data[props.data.length - 1]?.avg return ( ) } + +interface RouteTdContentProps { + method: string + status_code?: number + path: string + search: string +} +const RouteTdContent = (datum: RouteTdContentProps) => ( + + +
+ + + {datum.status_code && ( + = 400 + ? 'bg-orange-500' + : datum.status_code >= 300 + ? 'bg-yellow-500' + : 'bg-green-500' + }`} + value={String(datum.status_code)} + /> + )} +
+ + +
+
+
+ + {datum.search ? ( +
+          
+
+ ) : ( +

No query parameters in this request

+ )} +
+
+) diff --git a/studio/components/ui/Charts/BarChart.tsx b/studio/components/ui/Charts/BarChart.tsx index 7970e8f5a64..8d371a1c89a 100644 --- a/studio/components/ui/Charts/BarChart.tsx +++ b/studio/components/ui/Charts/BarChart.tsx @@ -43,12 +43,12 @@ const BarChart: React.FC = ({ const resolvedHighlightedLabel = (focusDataIndex !== null && data && - data[focusDataIndex] && + data[focusDataIndex] !== undefined && day(data[focusDataIndex][xAxisKey]).format(customDateFormat)) || highlightedLabel const resolvedHighlightedValue = - (focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : null) || highlightedValue + (focusDataIndex !== null ? data[focusDataIndex]?.[yAxisKey] : highlightedValue) return (
diff --git a/studio/pages/project/[ref]/reports/api-overview.tsx b/studio/pages/project/[ref]/reports/api-overview.tsx index 0a8ce4ba0df..a1742dbd7ea 100644 --- a/studio/pages/project/[ref]/reports/api-overview.tsx +++ b/studio/pages/project/[ref]/reports/api-overview.tsx @@ -9,9 +9,10 @@ import { import ReportWidget from 'components/interfaces/Reports/ReportWidget' import { queriesFactory } from 'components/interfaces/Reports/Reports.utils' import { - renderTotalRequests, - renderErrorCounts, - renderResponseSpeed, + TotalRequestsChartRenderer, + ErrorCountsChartRenderer, + ResponseSpeedChartRenderer, + TopApiRoutesRenderer, } from 'components/interfaces/Reports/renderers/ApiRenderers' import { useState, useEffect } from 'react' import ReportHeader from 'components/interfaces/Reports/ReportHeader' @@ -63,7 +64,9 @@ export const ApiReport: NextPageWithLayout = () => { params={report.params.totalRequests} title="Total Requests" data={report.data.totalRequests || []} - renderer={renderTotalRequests} + renderer={TotalRequestsChartRenderer} + append={TopApiRoutesRenderer} + appendProps={{ data: report.data.topRoutes || [] }} /> { title="Response Errors" tooltip="Error responses with 4XX or 5XX status codes" data={report.data.errorCounts || []} - renderer={renderErrorCounts} + renderer={ErrorCountsChartRenderer} + appendProps={{ data: report.data.topErrorRoutes || [] }} + append={TopApiRoutesRenderer} /> { title="Response Speed" tooltip="Average response speed (in miliseconds) of a request" data={report.data.responseSpeed || []} - renderer={renderResponseSpeed} + renderer={ResponseSpeedChartRenderer} + appendProps={{ data: report.data.topSlowRoutes || [] }} + append={TopApiRoutesRenderer} /> ) @@ -94,9 +101,19 @@ const useApiReport = () => { projectRef ?? 'default' ) const totalRequests = queryHooks.totalRequests() + const topRoutes = queryHooks.topRoutes() const errorCounts = queryHooks.errorCounts() + const topErrorRoutes = queryHooks.topErrorRoutes() const responseSpeed = queryHooks.responseSpeed() - const activeHooks = [totalRequests, errorCounts, responseSpeed] + const topSlowRoutes = queryHooks.topSlowRoutes() + const activeHooks = [ + totalRequests, + topRoutes, + errorCounts, + topErrorRoutes, + responseSpeed, + topSlowRoutes, + ] const [filters, setFilters] = useState([]) const addFilter = (filter: ReportFilterItem) => { // use a deep equal when comparing objects. @@ -127,12 +144,23 @@ const useApiReport = () => { if (totalRequests[1].changeQuery) { totalRequests[1].changeQuery(PRESET_CONFIG.api.queries.totalRequests.sql(filters)) } + if (topRoutes[1].changeQuery) { + topRoutes[1].changeQuery(PRESET_CONFIG.api.queries.topRoutes.sql(filters)) + } if (errorCounts[1].changeQuery) { errorCounts[1].changeQuery(PRESET_CONFIG.api.queries.errorCounts.sql(filters)) } + + if (topErrorRoutes[1].changeQuery) { + topErrorRoutes[1].changeQuery(PRESET_CONFIG.api.queries.topErrorRoutes.sql(filters)) + } if (responseSpeed[1].changeQuery) { responseSpeed[1].changeQuery(PRESET_CONFIG.api.queries.responseSpeed.sql(filters)) } + + if (topSlowRoutes[1].changeQuery) { + topSlowRoutes[1].changeQuery(PRESET_CONFIG.api.queries.topSlowRoutes.sql(filters)) + } }, [JSON.stringify(filters)]) const handleRefresh = async () => { @@ -151,11 +179,17 @@ const useApiReport = () => { totalRequests: totalRequests[0].logData, errorCounts: errorCounts[0].logData, responseSpeed: responseSpeed[0].logData, + topRoutes: topRoutes[0].logData, + topErrorRoutes: topErrorRoutes[0].logData, + topSlowRoutes: topSlowRoutes[0].logData, }, params: { totalRequests: totalRequests[0].params, errorCounts: errorCounts[0].params, responseSpeed: responseSpeed[0].params, + topRoutes: topRoutes[0].params, + topErrorRoutes: topErrorRoutes[0].params, + topSlowRoutes: topSlowRoutes[0].params, }, mergeParams: handleSetParams, filters, diff --git a/studio/tests/components/Reports/ReportWidget.test.js b/studio/tests/components/Reports/ReportWidget.test.js index b657ec2d897..3eeb8da5e94 100644 --- a/studio/tests/components/Reports/ReportWidget.test.js +++ b/studio/tests/components/Reports/ReportWidget.test.js @@ -1,46 +1,12 @@ -// mock the fetch function -jest.mock('lib/common/fetch') import { get } from 'lib/common/fetch' - -// mock mobx -jest.mock('mobx-react-lite') -import { observer } from 'mobx-react-lite' -observer.mockImplementation((v) => v) - -// mock the router -jest.mock('next/router') import { useRouter } from 'next/router' -const defaultRouterMock = () => { - const router = jest.fn() - router.query = {} - router.push = jest.fn() - router.pathname = 'logs/path' - return router -} -useRouter.mockReturnValue(defaultRouterMock()) - -// mock usage flags -jest.mock('components/ui/Flag/Flag') -import Flag from 'components/ui/Flag/Flag' -Flag.mockImplementation(({ children }) => <>{children}) -jest.mock('hooks') -import { useFlag } from 'hooks' -useFlag.mockReturnValue(true) - -import { fireEvent, waitFor, screen, act } from '@testing-library/react' +import { screen } from '@testing-library/react' import { render } from '../../helpers' -import userEvent from '@testing-library/user-event' -import { wait } from '@testing-library/user-event/dist/utils' -import { logDataFixture } from '../../fixtures' -import { LogsTableName } from 'components/interfaces/Settings/Logs' -import { Presets } from 'components/interfaces/Reports/Reports.types' import ReportWidget from 'components/interfaces/Reports/ReportWidget' -import { clickDropdown } from 'tests/helpers' +import userEvent from '@testing-library/user-event' beforeEach(() => { // reset mocks between tests get.mockReset() - useRouter.mockReset() - useRouter.mockReturnValue(defaultRouterMock()) }) test('static elements', async () => { @@ -48,3 +14,9 @@ test('static elements', async () => { await screen.findByText(/Some chart/) await screen.findByText(/something/) }) + +test("append", async ()=>{ + const appendable = ()=> "some text" + render( null} append={appendable} />) + await screen.findByText(/some text/) +}) diff --git a/studio/tests/pages/projects/reports/api-report.test.js b/studio/tests/pages/projects/reports/api-report.test.js index 92cb474d077..be214ef6b73 100644 --- a/studio/tests/pages/projects/reports/api-report.test.js +++ b/studio/tests/pages/projects/reports/api-report.test.js @@ -7,7 +7,7 @@ import userEvent from '@testing-library/user-event' beforeEach(() => { // reset mocks between tests get.mockReset() - get.mockImplementation(async (_url) => [{ data: [] }]) + get.mockImplementation(async (_url) => [{ result: [] }]) }) test(`static elements`, async () => { @@ -26,3 +26,86 @@ test('refresh button', async () => { userEvent.click(await screen.findByText(/Refresh/)) await waitFor(() => expect(get).toBeCalled()) }) + +test('append - api request routes', async () => { + get.mockImplementation(async (url) => { + if (decodeURIComponent(url).includes("request.path")) { + return { result: [{ path: 'mypath', method: 'GET', status_code: 200, search: 'some-query', count: 22 }] } + } + return { result: [{ timestamp: new Date().toISOString(), count: 123 }] } + }) + render() + await waitFor(() => expect(get).toBeCalled()) + await screen.findAllByText(/mypath/) + await screen.findAllByText(/GET/) + await screen.findAllByText(/200/) + await screen.findAllByText(/some\-query/) + await screen.findAllByText(/22/) +}) + + +test('append - error routes', async () => { + get.mockImplementation(async (url) => { + const uri = decodeURIComponent(url) + if (uri.includes("400")) { + return { result: [{ path: 'mypath', method: 'GET', status_code: 200, search: 'some-query', count: 22 }] } + } + return { result: [{ timestamp: new Date().toISOString(), count: 123 }] } + }) + render() + await waitFor(() => expect(get).toBeCalled()) + await screen.findAllByText(/mypath/) + await screen.findAllByText(/GET/) + await screen.findAllByText(/200/) + await screen.findAllByText(/some\-query/) + await screen.findAllByText(/22/) +}) + + +test('append - error routes', async () => { + get.mockImplementation(async (url) => { + const uri = decodeURIComponent(url) + if (uri.includes("avg") && uri.includes("request.path")) { + return { result: [{ path: 'mypath', method: 'GET', status_code: 200, avg: 534, search: 'some-query', count: 22 }] } + } + return { result: [{ timestamp: new Date().toISOString(), count: 123 }] } + }) + render() + await waitFor(() => expect(get).toBeCalled()) + await screen.findAllByText(/mypath/) + await screen.findAllByText(/GET/) + await screen.findAllByText(/200/) + await screen.findAllByText(/some\-query/) + await screen.findAllByText(/22/) + await screen.findAllByText(/534\.00ms/) +}) + + + +// test('expandable error routes', async () => { +// get.mockImplementation(async (url) => { +// if (url.includes('path')) { +// return [{ data: [{ path: '/my-path', method: 'GET', search: '?=123', count: 22 }] }] +// } +// return [{ data: [{ timestamp: new Date().toISOString(), count: 123 }] }] +// }) + +// render() +// await screen.findByText(/\/my\-path/) +// await screen.findByText(/\?\=123/) +// await screen.findByText(/22/) +// }) + +// test('expandable high latency routes', async () => { +// get.mockImplementation(async (url) => { +// if (url.includes('path')) { +// return [{ data: [{ path: '/my-path', method: 'GET', search: '?=123', avg_ms: 55, count: 22 }] }] +// } +// return [{ data: [{ timestamp: new Date().toISOString(), avg_ms: 123 }] }] +// }) +// render() +// await screen.findByText(/\/my\-path/) +// await screen.findByText(/\?\=123/) +// await screen.findByText(/22/) +// await screen.findByText(/55/) +// })