diff --git a/cspell.config.json b/cspell.config.json index 8befa3e..676289e 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -46,6 +46,7 @@ "queryless", "unroute", "gdev", - "lokiexplore" + "lokiexplore", + "viewports" ] } diff --git a/src/components/Explore/TracesByService/REDPanel.tsx b/src/components/Explore/TracesByService/REDPanel.tsx index e00db48..7935767 100644 --- a/src/components/Explore/TracesByService/REDPanel.tsx +++ b/src/components/Explore/TracesByService/REDPanel.tsx @@ -10,7 +10,7 @@ import { SceneObjectBase, SceneObjectState, } from '@grafana/scenes'; -import { arrayToDataFrame, GrafanaTheme2, LoadingState } from '@grafana/data'; +import { arrayToDataFrame, DataFrame, GrafanaTheme2, LoadingState } from '@grafana/data'; import { ComparisonSelection, EMPTY_STATE_ERROR_MESSAGE, explorationDS, MetricFunction } from 'utils/shared'; import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; @@ -80,7 +80,7 @@ export class REDPanel extends SceneObjectBase { } else { let yBuckets: number[] | undefined = []; if (this.isDuration()) { - yBuckets = data.state.data?.series.map((s) => parseFloat(s.fields[1].name)).sort((a, b) => a - b); + yBuckets = getYBuckets(data.state.data?.series || []); if (parent.state.selection && newData.data?.state === LoadingState.Done) { // set selection annotation if it exists const annotations = this.buildSelectionAnnotation(parent.state); @@ -96,14 +96,8 @@ export class REDPanel extends SceneObjectBase { } if (yBuckets?.length) { - const slowestBuckets = Math.floor(yBuckets.length / 4); - let minBucket = yBuckets.length - slowestBuckets - 1; - if (minBucket < 0) { - minBucket = 0; - } - + const { minDuration, minBucket } = getMinimumsForDuration(yBuckets); const selection: ComparisonSelection = { type: 'auto' }; - const minDuration = yBucketToDuration(minBucket - 1, yBuckets); getLatencyThresholdVariable(this).changeValueTo(minDuration); getLatencyPartialThresholdVariable(this).changeValueTo( @@ -303,6 +297,23 @@ export class REDPanel extends SceneObjectBase { }; } +export const getYBuckets = (series: DataFrame[]) => { + return series.map((s) => parseFloat(s.fields[1].name)).sort((a, b) => a - b); +} + +export const getMinimumsForDuration = (yBuckets: number[]) => { + const slowestBuckets = Math.floor(yBuckets.length / 4); + let minBucket = yBuckets.length - slowestBuckets - 1; + if (minBucket < 0) { + minBucket = 0; + } + + return { + minDuration: yBucketToDuration(minBucket - 1, yBuckets), + minBucket + }; +} + function getStyles(theme: GrafanaTheme2) { return { container: css({ diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx new file mode 100644 index 0000000..496556c --- /dev/null +++ b/src/components/Home/AttributePanel.tsx @@ -0,0 +1,207 @@ +import React from 'react'; + +import { + SceneComponentProps, + SceneFlexLayout, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, +} from '@grafana/scenes'; +import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { explorationDS, MetricFunction } from 'utils/shared'; +import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; +import { useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesByServiceScene'; +import { AttributePanelScene } from './AttributePanelScene'; +import Skeleton from 'react-loading-skeleton'; +import { getErrorMessage, getNoDataMessage } from 'utils/utils'; +import { getMinimumsForDuration, getYBuckets } from 'components/Explore/TracesByService/REDPanel'; + +export interface AttributePanelState extends SceneObjectState { + panel?: SceneFlexLayout; + query: { + query: string; + step?: string; + }; + title: string; + type: MetricFunction; + renderDurationPanel?: boolean; +} + +export class AttributePanel extends SceneObjectBase { + constructor(state: AttributePanelState) { + super({ + $data: new SceneQueryRunner({ + datasource: explorationDS, + queries: [{ refId: 'A', queryType: 'traceql', tableType: 'spans', limit: 10, ...state.query }], + }), + ...state, + }); + + + this.addActivationHandler(() => { + const data = sceneGraph.getData(this); + + this._subs.add( + data.subscribeToState((data) => { + if (data.data?.state === LoadingState.Done) { + if (data.data.series.length === 0 || data.data.series[0].length === 0) { + this.setState({ + panel: new SceneFlexLayout({ + children: [ + new AttributePanelScene({ + message: getNoDataMessage(state.title.toLowerCase()), + title: state.title, + type: state.type, + }), + ], + }), + }); + } else if (data.data.series.length > 0) { + if (state.type === 'errors' || state.renderDurationPanel) { + this.setState({ + panel: new SceneFlexLayout({ + children: [ + new AttributePanelScene({ + series: data.data.series, + title: state.title, + type: state.type + }), + ], + }) + }); + } else { + let yBuckets = getYBuckets(data.data?.series ?? []); + if (yBuckets?.length) { + const { minDuration } = getMinimumsForDuration(yBuckets); + + this.setState({ + panel: new SceneFlexLayout({ + children: [ + new AttributePanel({ + query: { + query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}}`, + }, + title: state.title, + type: state.type, + renderDurationPanel: true, + }), + ], + }) + }); + } + } + } + } else if (data.data?.state === LoadingState.Error) { + this.setState({ + panel: new SceneFlexLayout({ + children: [ + new AttributePanelScene({ + message: getErrorMessage(data), + title: state.title, + type: state.type, + }), + ], + }) + }); + } else if (data.data?.state === LoadingState.Loading || data.data?.state === LoadingState.Streaming) { + this.setState({ + panel: new SceneFlexLayout({ + direction: 'column', + maxHeight: MINI_PANEL_HEIGHT, + height: MINI_PANEL_HEIGHT, + children: [ + new LoadingStateScene({ + component: () => SkeletonComponent(), + }), + ], + }), + }); + } + }) + ); + }); + } + + public static Component = ({ model }: SceneComponentProps) => { + const { panel } = model.useState(); + const styles = useStyles2(getStyles); + + if (!panel) { + return; + } + + return ( +
+ +
+ ); + }; +} + +function getStyles() { + return { + container: css({ + minWidth: '350px', + width: '-webkit-fill-available', + }), + }; +} + +export const SkeletonComponent = () => { + const styles = useStyles2(getSkeletonStyles); + + return ( +
+
+ +
+
+ {[...Array(11)].map((_, i) => ( +
+
+ +
+
+ +
+
+ ))} +
+
+ ); +}; + +function getSkeletonStyles(theme: GrafanaTheme2) { + return { + container: css({ + border: `1px solid ${theme.isDark ? theme.colors.border.medium : theme.colors.border.weak}`, + borderRadius: theme.spacing(0.5), + marginBottom: theme.spacing(4), + width: '100%', + }), + title: css({ + color: theme.colors.text.secondary, + backgroundColor: theme.colors.background.secondary, + fontSize: '1.3rem', + padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`, + textAlign: 'center', + }), + tracesContainer: css({ + padding: `13px ${theme.spacing(2)}`, + }), + row: css({ + display: 'flex', + justifyContent: 'space-between', + }), + rowLeft: css({ + margin: '7px 0', + width: '150px', + }), + rowRight: css({ + width: '50px', + }), + }; +} diff --git a/src/components/Home/AttributePanelRow.test.tsx b/src/components/Home/AttributePanelRow.test.tsx new file mode 100644 index 0000000..555d505 --- /dev/null +++ b/src/components/Home/AttributePanelRow.test.tsx @@ -0,0 +1,61 @@ +import React from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { AttributePanelRow } from './AttributePanelRow'; +import { locationService } from '@grafana/runtime'; +import { MetricFunction } from 'utils/shared'; + +jest.mock('@grafana/runtime', () => ({ + locationService: { + push: jest.fn(), + }, +})); + +jest.mock('utils/analytics', () => ({ + reportAppInteraction: jest.fn(), + USER_EVENTS_ACTIONS: { + home: { + attribute_panel_item_clicked: 'attribute_panel_item_clicked', + }, + }, + USER_EVENTS_PAGES: { + home: 'home', + }, +})); + +describe('AttributePanelRow', () => { + const mockProps = { + index: 0, + type: 'errors' as MetricFunction, + label: 'Test Label', + labelTitle: 'Label Title', + value: 'Test Text', + valueTitle: 'Text Title', + url: '/test-url', + }; + + it('renders correctly with required props', () => { + render(); + + expect(screen.getByText(mockProps.labelTitle)).toBeInTheDocument(); + expect(screen.getByText(mockProps.valueTitle)).toBeInTheDocument(); + expect(screen.getByText(mockProps.label)).toBeInTheDocument(); + expect(screen.getByText(mockProps.value)).toBeInTheDocument(); + }); + + it('navigates to the correct URL on click', () => { + render(); + const rowElement = screen.getByText(mockProps.label).closest('div'); + fireEvent.click(rowElement!); + expect(locationService.push).toHaveBeenCalledWith(mockProps.url); + }); + + it('renders the row header only if index is 0', () => { + render(); + expect(screen.getByText(mockProps.labelTitle)).toBeInTheDocument(); + }); + + it('does not render the row header only if index is > 0', () => { + render(); + expect(screen.queryByText(mockProps.labelTitle)).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/Home/AttributePanelRow.tsx b/src/components/Home/AttributePanelRow.tsx new file mode 100644 index 0000000..ade5b06 --- /dev/null +++ b/src/components/Home/AttributePanelRow.tsx @@ -0,0 +1,102 @@ +import { css } from "@emotion/css"; +import { GrafanaTheme2 } from "@grafana/data"; +import { locationService } from "@grafana/runtime"; +import { Icon, useStyles2 } from "@grafana/ui"; +import React from "react"; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from "utils/analytics"; +import { MetricFunction } from "utils/shared"; + +type Props = { + index: number; + type: MetricFunction; + label: string; + labelTitle: string; + value: string; + valueTitle: string; + url: string; +} + +export const AttributePanelRow = (props: Props) => { + const { index, type, label, labelTitle, value, valueTitle, url } = props; + const styles = useStyles2(getStyles); + + return ( +
+ {index === 0 && ( +
+ {labelTitle} + {valueTitle} +
+ )} + +
{ + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.panel_row_clicked, { + type, + index, + value + }); + locationService.push(url); + }} + > +
{label}
+ +
+ + {value} + + +
+
+
+ ); +} + +function getStyles(theme: GrafanaTheme2) { + return { + rowHeader: css({ + color: theme.colors.text.secondary, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: `0 ${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(2)}`, + }), + valueTitle: css({ + margin: '0 45px 0 0', + }), + row: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + gap: theme.spacing(2), + padding: `${theme.spacing(0.75)} ${theme.spacing(2)}`, + + '&:hover': { + backgroundColor: theme.isDark ? theme.colors.background.secondary : theme.colors.background.primary, + cursor: 'pointer', + '.rowLabel': { + textDecoration: 'underline', + } + }, + }), + action: css({ + display: 'flex', + alignItems: 'center', + }), + actionText: css({ + color: '#d5983c', + padding: `0 ${theme.spacing(1)}`, + width: 'max-content', + }), + actionIcon: css({ + cursor: 'pointer', + margin: `0 ${theme.spacing(0.5)} 0 ${theme.spacing(1)}`, + }), + }; +} diff --git a/src/components/Home/AttributePanelRows.test.tsx b/src/components/Home/AttributePanelRows.test.tsx new file mode 100644 index 0000000..ff0fbba --- /dev/null +++ b/src/components/Home/AttributePanelRows.test.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { AttributePanelRows } from './AttributePanelRows'; +import { DataFrame, Field } from '@grafana/data'; +import { MetricFunction } from 'utils/shared'; + +describe('AttributePanelRows', () => { + const createField = (name: string, values: any[], labels: Record = {}) => ({ + name, + values, + labels, + }) as Field; + + const createDataFrame = (fields: Field[]) => ({ + fields, + }) as DataFrame; + + const dummySeries = [ + createDataFrame([ + createField('time', []), + createField('Test service 1', [10, 20], { 'resource.service.name': '"Test service 1"' }), + ]), + createDataFrame([ + createField('time', []), + createField('Test service 2', [15, 5], { 'resource.service.name': '"Test service 2"' }), + ]), + ]; + + const dummyDurationSeries = [ + createDataFrame([ + createField('traceIdHidden', ['trace-1', 'trace-2']), + createField('spanID', ['span-1', 'span-2']), + createField('traceName', ['Test name 1', 'Test name 2']), + createField('traceService', ['Test service 1', 'Test service 2']), + createField('duration', [3000, 500]), + ]), + ]; + + it('renders message if provided', () => { + const msg = 'No data available.'; + render(); + expect(screen.getByText(msg)).toBeInTheDocument(); + }); + + it('renders an empty container if no series or message is provided', () => { + render(); + expect(screen.getByText('No series data')).toBeInTheDocument(); + }); + + it('renders error rows sorted by total errors when type is "errors"', () => { + render(); + + expect(screen.getAllByText('Total errors').length).toBe(1); + + const labels = screen.getAllByText('Test service', { exact: false }); + expect(labels[0].textContent).toContain('Test service 1'); + expect(labels[1].textContent).toContain('Test service 2'); + }); + + it('renders duration rows sorted by duration when type is not "errors"', () => { + render(); + + expect(screen.getAllByText('Duration').length).toBe(1); + + const labels = screen.getAllByText('Test', { exact: false }); + expect(labels[0].textContent).toContain('Test service 1: Test name 1'); + expect(labels[1].textContent).toContain('Test service 2: Test name 2'); + }); +}); diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx new file mode 100644 index 0000000..1e11f11 --- /dev/null +++ b/src/components/Home/AttributePanelRows.tsx @@ -0,0 +1,171 @@ +import { css } from "@emotion/css"; +import { DataFrame, Field, GrafanaTheme2, urlUtil } from "@grafana/data"; +import { Icon, useStyles2 } from "@grafana/ui"; +import React from "react"; +import { formatDuration } from "utils/dates"; +import { EXPLORATIONS_ROUTE, MetricFunction, ROUTES } from "utils/shared"; +import { AttributePanelRow } from "./AttributePanelRow"; + +type Props = { + series?: DataFrame[]; + type: MetricFunction; + message?: string; +} + +export const AttributePanelRows = (props: Props) => { + const { series, type, message } = props; + const styles = useStyles2(getStyles); + + if (message) { + return ( +
+
+ + {message} +
+
+ ); + } + + if (series && series.length > 0) { + if (type === 'errors') { + const getLabel = (df: DataFrame) => { + const valuesField = df.fields.find((f) => f.name !== 'time'); + return valuesField?.labels?.['resource.service.name'].replace(/"/g, '') ?? 'Service name not found'; + } + + const getUrl = (df: DataFrame) => { + const serviceName = getLabel(df); + const params = { + 'var-filters': `resource.service.name|=|${serviceName}`, + 'var-metric': type, + } + const url = urlUtil.renderUrl(EXPLORATIONS_ROUTE, params); + return `${url}&var-filters=nestedSetParent|<|0`; + } + + const getTotalErrs = (df: DataFrame) => { + const valuesField = df.fields.find((f) => f.name !== 'time'); + return valuesField?.values?.reduce((x, acc) => { + if (typeof x === 'number' && !isNaN(x)) { + return x + acc + } + return acc + }, 0) ?? 1; + } + + return ( +
+ {series + .sort((a, b) => getTotalErrs(b) - getTotalErrs(a)) + .slice(0, 10)?.map((df, index) => ( + + + + ))} +
+ ); + } + + const durField = series[0].fields.find((f) => f.name === 'duration'); + if (durField && durField.values) { + const sortedByDuration = durField?.values.map((_, i) => i)?.sort((a, b) => durField?.values[b] - durField?.values[a]); + const sortedFields = series[0].fields.map((f) => { + return { + ...f, + values: sortedByDuration?.map((i) => f.values[i]), + }; + }); + + const getLabel = (traceServiceField: Field | undefined, traceNameField: Field | undefined, index: number) => { + let label = ''; + if (traceServiceField?.values[index]) { + label = traceServiceField.values[index]; + } + if (traceNameField?.values[index]) { + label = label.length === 0 ? traceNameField.values[index] : `${label}: ${traceNameField.values[index]}`; + } + return label.length === 0 ? 'Trace service & name not found' : label; + } + + const getUrl = (traceId: string, spanIdField: Field | undefined, traceServiceField: Field | undefined, index: number) => { + if (!spanIdField || !spanIdField.values[index] || !traceServiceField || !traceServiceField.values[index]) { + console.error('SpanId or traceService not found'); + return ROUTES.Explore; + } + + const params = { + traceId, + spanId: spanIdField.values[index], + 'var-filters': `resource.service.name|=|${traceServiceField.values[index]}`, + 'var-metric': type, + } + const url = urlUtil.renderUrl(EXPLORATIONS_ROUTE, params); + + return `${url}&var-filters=nestedSetParent|<|0`; + } + + const getDuration = (durationField: Field | undefined, index: number) => { + if (!durationField || !durationField.values) { + return 'Duration not found'; + } + + return formatDuration(durationField.values[index] / 1000); + } + + const traceIdField = sortedFields.find((f) => f.name === 'traceIdHidden'); + const spanIdField = sortedFields.find((f) => f.name === 'spanID'); + const traceNameField = sortedFields.find((f) => f.name === 'traceName'); + const traceServiceField = sortedFields.find((f) => f.name === 'traceService'); + const durationField = sortedFields.find((f) => f.name === 'duration'); + + return ( +
+ {traceIdField?.values?.map((traceId, index) => ( + + + + ))} +
+ ); + } + } + return
No series data
; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + padding: `${theme.spacing(2)} 0`, + }), + icon: css({ + margin: `0 ${theme.spacing(0.5)} 0 ${theme.spacing(1)}`, + }), + message: css({ + display: 'flex', + gap: theme.spacing(1.5), + margin: `${theme.spacing(2)} auto`, + width: '60%', + }), + }; +} diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx new file mode 100644 index 0000000..037f4ce --- /dev/null +++ b/src/components/Home/AttributePanelScene.tsx @@ -0,0 +1,56 @@ +import { css } from '@emotion/css'; +import { DataFrame, GrafanaTheme2 } from '@grafana/data'; +import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; +import { Icon, useStyles2 } from '@grafana/ui'; +import React from 'react'; +import { MetricFunction } from 'utils/shared'; +import { AttributePanelRows } from './AttributePanelRows'; + +interface AttributePanelSceneState extends SceneObjectState { + series?: DataFrame[]; + title: string; + type: MetricFunction; + message?: string +} + +export class AttributePanelScene extends SceneObjectBase { + public static Component = ({ model }: SceneComponentProps) => { + const { series, title, type, message } = model.useState(); + const styles = useStyles2(getStyles); + + return ( +
+
+ + {title} +
+ +
+ ); + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + border: `1px solid ${theme.isDark ? theme.colors.border.medium : theme.colors.border.weak}`, + borderRadius: theme.spacing(0.5), + marginBottom: theme.spacing(4), + width: '100%', + }), + title: css({ + color: theme.isDark ? theme.colors.text.secondary : theme.colors.text.primary, + backgroundColor: theme.isDark ? theme.colors.background.secondary : theme.colors.background.primary, + borderTopLeftRadius: theme.spacing(0.5), + borderTopRightRadius: theme.spacing(0.5), + display: 'flex', + justifyContent: 'center', + alignItems: 'center', + fontSize: '1.3rem', + padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`, + }), + titleText: css({ + marginLeft: theme.spacing(1), + }), + }; +} diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx new file mode 100644 index 0000000..f1f7ac1 --- /dev/null +++ b/src/components/Home/HeaderScene.tsx @@ -0,0 +1,141 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { + SceneComponentProps, + SceneObjectBase, +} from '@grafana/scenes'; +import { Button, Icon, LinkButton, Stack, useStyles2, useTheme2 } from '@grafana/ui'; + +import { + EXPLORATIONS_ROUTE, +} from '../../utils/shared'; +import { getDatasourceVariable, getHomeScene } from '../../utils/utils'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import { Home } from 'pages/Home/Home'; +import { DarkModeRocket, LightModeRocket } from '../../utils/rockets'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; + +export class HeaderScene extends SceneObjectBase { + static Component = ({ model }: SceneComponentProps) => { + const home = getHomeScene(model); + const navigate = useNavigate(); + const { controls } = home.useState(); + const styles = useStyles2(getStyles); + const theme = useTheme2(); + + const dsVariable = getDatasourceVariable(home); + + return ( +
+
+
+ {theme.isDark ? : } +

Start your traces exploration!

+
+
+

Explore and visualize your trace data without writing a query.

+
+ + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.read_documentation_clicked)} + > + Read documentation + +
+
+
+ +
+

Or quick-start into your tracing data.

+
+ + + {dsVariable && ( + +
Data source
+ +
+ )} +
+ {controls.map((control) => ( + + ))} +
+
+
+ ); + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + display: 'flex', + gap: theme.spacing(7), + flexDirection: 'column', + margin: `0 0 ${theme.spacing(4)} 0`, + justifyContent: 'center', + }), + header: css({ + display: 'flex', + alignItems: 'center', + backgroundColor: theme.isDark ? theme.colors.background.secondary : theme.colors.background.primary, + borderRadius: theme.spacing(0.5), + flexWrap: 'wrap', + justifyContent: 'center', + padding: theme.spacing(3), + gap: theme.spacing(4), + }), + headerTitleContainer: css({ + display: 'flex', + alignItems: 'center', + }), + title: css({ + margin: `0 0 0 ${theme.spacing(2)}`, + }), + + headerActions: css({ + alignItems: 'center', + justifyContent: 'flex-start', + display: 'flex', + gap: theme.spacing(2), + }), + documentationLink: css({ + textDecoration: 'underline', + '&:hover': { + textDecoration: 'underline', + }, + }), + + subHeader: css({ + textAlign: 'center', + 'h4': { + margin: 0, + } + }), + + datasourceLabel: css({ + fontSize: '12px', + }), + controls: css({ + display: 'flex', + gap: theme.spacing(1), + }), + }; +} diff --git a/src/components/Routes/Routes.tsx b/src/components/Routes/Routes.tsx index ecef9b0..8258078 100644 --- a/src/components/Routes/Routes.tsx +++ b/src/components/Routes/Routes.tsx @@ -1,13 +1,15 @@ import React, { lazy } from 'react'; import { Route, Routes as ReactRoutes, Navigate } from 'react-router-dom-v5-compat'; import { ROUTES } from 'utils/shared'; +const HomePage = lazy(() => import('../../pages/Home/HomePage')); const TraceExplorationPage = lazy(() => import('../../pages/Explore/TraceExplorationPage')); export const Routes = () => { return ( } /> - } /> + } /> + } /> ); }; diff --git a/src/pages/Explore/TraceExplorationPage.tsx b/src/pages/Explore/TraceExplorationPage.tsx index 3619ddc..46b0e81 100644 --- a/src/pages/Explore/TraceExplorationPage.tsx +++ b/src/pages/Explore/TraceExplorationPage.tsx @@ -44,7 +44,7 @@ export function TraceExplorationView({ exploration }: { exploration: TraceExplor } return ( - + ); diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx new file mode 100644 index 0000000..0c88d7f --- /dev/null +++ b/src/pages/Home/Home.tsx @@ -0,0 +1,143 @@ +import { css } from '@emotion/css'; +import React from 'react'; +// eslint-disable-next-line no-restricted-imports +import { duration } from 'moment'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { + DataSourceVariable, + SceneComponentProps, + SceneCSSGridItem, + SceneCSSGridLayout, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneObjectState, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + SceneTimeRangeLike, + SceneVariableSet, +} from '@grafana/scenes'; +import { useStyles2 } from '@grafana/ui'; + +import { + DATASOURCE_LS_KEY, + VAR_DATASOURCE, +} from '../../utils/shared'; +import { AttributePanel } from 'components/Home/AttributePanel'; +import { HeaderScene } from 'components/Home/HeaderScene'; +import { getDatasourceVariable } from 'utils/utils'; + +export interface HomeState extends SceneObjectState { + controls: SceneObject[]; + initialDS?: string; + body?: SceneCSSGridLayout; +} + +export class Home extends SceneObjectBase { + public constructor(state: Partial) { + super({ + $timeRange: state.$timeRange ?? new SceneTimeRange({}), + $variables: state.$variables ?? getVariableSet(state.initialDS), + controls: state.controls ?? [new SceneTimePicker({}), new SceneRefreshPicker({})], + ...state, + }); + + this.addActivationHandler(this._onActivate.bind(this)); + } + + private _onActivate() { + getDatasourceVariable(this).subscribeToState((newState) => { + if (newState.value) { + localStorage.setItem(DATASOURCE_LS_KEY, newState.value.toString()); + } + }); + + const sceneTimeRange = sceneGraph.getTimeRange(this); + sceneTimeRange.subscribeToState((newState, prevState) => { + if (newState.value.from !== prevState.value.from || newState.value.to !== prevState.value.to) { + this.buildPanels(sceneTimeRange); + } + }); + this.buildPanels(sceneTimeRange); + } + + buildPanels(sceneTimeRange: SceneTimeRangeLike) { + const from = sceneTimeRange.state.value.from.unix(); + const to = sceneTimeRange.state.value.to.unix(); + const dur = duration(to - from, 's'); + const durString = `${dur.asSeconds()}s`; + + this.setState({ + body: new SceneCSSGridLayout({ + children: [ + new SceneCSSGridLayout({ + autoRows: 'min-content', + columnGap: 2, + rowGap: 2, + children: [ + new SceneCSSGridItem({ + body: new AttributePanel({ + query: { + query: '{nestedSetParent < 0 && status = error} | count_over_time() by (resource.service.name)', + step: durString + }, + title: 'Errored services', + type: 'errors', + }), + }), + new SceneCSSGridItem({ + body: new AttributePanel({ + query: { + query: '{nestedSetParent<0} | histogram_over_time(duration)', + }, + title: 'Slow traces', + type: 'duration', + }), + }), + ], + }), + ], + }), + }); + } + + static Component = ({ model }: SceneComponentProps) => { + const { body } = model.useState(); + const styles = useStyles2(getStyles); + + return ( +
+ + {body && } +
+ ); + }; +} + +function getVariableSet(initialDS?: string) { + return new SceneVariableSet({ + variables: [ + new DataSourceVariable({ + name: VAR_DATASOURCE, + label: 'Data source', + value: initialDS, + pluginId: 'tempo', + }), + ], + }); +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + margin: `${theme.spacing(4)} auto`, + width: '75%', + + '@media (max-width: 900px)': { + width: '95%', + }, + }), + }; +} diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx new file mode 100644 index 0000000..8cb9416 --- /dev/null +++ b/src/pages/Home/HomePage.tsx @@ -0,0 +1,34 @@ +import React, { useEffect, useState } from 'react'; +import { newHome } from '../../utils/utils'; +import { DATASOURCE_LS_KEY } from '../../utils/shared'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../utils/analytics'; +import { Home } from './Home'; + +const HomePage = () => { + const initialDs = localStorage.getItem(DATASOURCE_LS_KEY) || ''; + const [home] = useState(newHome(initialDs)); + + return ; +}; + +export default HomePage; + +export function HomeView({ home }: { home: Home }) { + const [isInitialized, setIsInitialized] = React.useState(false); + + useEffect(() => { + if (!isInitialized) { + setIsInitialized(true); + + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.homepage_initialized); + } + }, [home, isInitialized]); + + if (!isInitialized) { + return null; + } + + return ( + + ); +} diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 3360501..9cc6077 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -17,12 +17,14 @@ export const reportAppInteraction = ( export const USER_EVENTS_PAGES = { analyse_traces: 'analyse_traces', + home: 'home', common: 'common', } as const; export type UserEventPagesType = keyof typeof USER_EVENTS_PAGES; type UserEventActionType = | keyof (typeof USER_EVENTS_ACTIONS)['analyse_traces'] + | keyof (typeof USER_EVENTS_ACTIONS)['home'] | keyof (typeof USER_EVENTS_ACTIONS)['common']; export const USER_EVENTS_ACTIONS = { @@ -37,6 +39,12 @@ export const USER_EVENTS_ACTIONS = { stop_investigation: 'stop_investigation', open_trace: 'open_trace', }, + [USER_EVENTS_PAGES.home]: { + homepage_initialized: 'homepage_initialized', + panel_row_clicked: 'panel_row_clicked', + explore_traces_clicked: 'explore_traces_clicked', + read_documentation_clicked: 'read_documentation_clicked', + }, [USER_EVENTS_PAGES.common]: { metric_changed: 'metric_changed', new_filter_added_manually: 'new_filter_added_manually', diff --git a/src/utils/rockets.tsx b/src/utils/rockets.tsx new file mode 100644 index 0000000..f9e6487 --- /dev/null +++ b/src/utils/rockets.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +export const LightModeRocket = () => ( + + + +); + +export const DarkModeRocket = () => ( + + + +); diff --git a/src/utils/shared.ts b/src/utils/shared.ts index f040401..c39be2d 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -1,12 +1,17 @@ import { BusEventWithPayload, DataFrame } from '@grafana/data'; +import pluginJson from '../plugin.json'; export type MetricFunction = 'rate' | 'errors' | 'duration'; export enum ROUTES { Explore = 'explore', + Home = 'home', } -export const EXPLORATIONS_ROUTE = '/a/grafana-exploretraces-app/explore'; +export const PLUGIN_ID = pluginJson.id; +export const PLUGIN_BASE_URL = `/a/${PLUGIN_ID}`; +export const EXPLORATIONS_ROUTE = `${PLUGIN_BASE_URL}/${ROUTES.Explore}`; + export const DATASOURCE_LS_KEY = 'grafana.explore.traces.datasource'; export const GRID_TEMPLATE_COLUMNS = 'repeat(auto-fit, minmax(400px, 1fr))'; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 747776f..17105b6 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -26,11 +26,16 @@ import { primarySignalOptions } from '../pages/Explore/primary-signals'; import { TracesByServiceScene } from 'components/Explore/TracesByService/TracesByServiceScene'; import { ActionViewType } from '../components/Explore/TracesByService/Tabs/TabsBarScene'; import { LocationService } from '@grafana/runtime'; +import { Home } from 'pages/Home/Home'; export function getTraceExplorationScene(model: SceneObject): TraceExploration { return sceneGraph.getAncestor(model, TraceExploration); } +export function getHomeScene(model: SceneObject): Home { + return sceneGraph.getAncestor(model, Home); +} + export function getTraceByServiceScene(model: SceneObject): TracesByServiceScene { return sceneGraph.getAncestor(model, TracesByServiceScene); } @@ -43,11 +48,26 @@ export function newTracesExploration( return new TraceExploration({ initialDS, initialFilters: initialFilters ?? [primarySignalOptions[0].filter], - $timeRange: new SceneTimeRange({ from: 'now-15m', to: 'now' }), + $timeRange: new SceneTimeRange({ from: 'now-30m', to: 'now' }), locationService, }); } +export function newHome(initialDS?: string): Home { + return new Home({ + initialDS, + $timeRange: new SceneTimeRange({ from: 'now-30m', to: 'now' }), + }); +} + +export function getErrorMessage(data: SceneDataState) { + return data?.data?.error?.message ?? 'There are no Tempo data sources'; +} + +export function getNoDataMessage(context: string) { + return `No data for selected data source. Select another to see ${context}.`; +} + export function getUrlForExploration(exploration: TraceExploration) { const params = sceneUtils.getUrlState(exploration); return getUrlForValues(params);