Skip to content

Commit

Permalink
Explore Traces Homepage (#279)
Browse files Browse the repository at this point in the history
* Homepage

* Navigation and error checking

* Several improvements

* Card design

* Add skeleton component for loading state

* Light theme styling

* Overall styling and responsiveness

* Lazy load home page

* Feature tracking

* Remove preview badge

* Move rockets

* AttributePanelRows

* Loading, error, empty states

* Update url link

* Improve styling

* Improve skeleton styling

* Fix cspell

* Utils and styling

* Update url

* Update error messages and improvements

* Reuse AttributePanel for duration

* Update icon

* Remove DurationAttributePanel file

* Add AttributePanelRow

* Update icon hover

* Tests and improvements

* Update slice to replace

* Reuse comparison logic

* Remove superfluous part of query

* Only count error total if value is a number

* Fix spellcheck

* Init acc in reduce to 0
  • Loading branch information
joey-grafana authored Jan 14, 2025
1 parent 85b88b3 commit 992e0c1
Show file tree
Hide file tree
Showing 16 changed files with 1,064 additions and 13 deletions.
29 changes: 20 additions & 9 deletions src/components/Explore/TracesByService/REDPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -80,7 +80,7 @@ export class REDPanel extends SceneObjectBase<RateMetricsPanelState> {
} 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);
Expand All @@ -96,14 +96,8 @@ export class REDPanel extends SceneObjectBase<RateMetricsPanelState> {
}

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(
Expand Down Expand Up @@ -303,6 +297,23 @@ export class REDPanel extends SceneObjectBase<RateMetricsPanelState> {
};
}

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({
Expand Down
207 changes: 207 additions & 0 deletions src/components/Home/AttributePanel.tsx
Original file line number Diff line number Diff line change
@@ -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<AttributePanelState> {
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<AttributePanel>) => {
const { panel } = model.useState();
const styles = useStyles2(getStyles);

if (!panel) {
return;
}

return (
<div className={styles.container}>
<panel.Component model={panel} />
</div>
);
};
}

function getStyles() {
return {
container: css({
minWidth: '350px',
width: '-webkit-fill-available',
}),
};
}

export const SkeletonComponent = () => {
const styles = useStyles2(getSkeletonStyles);

return (
<div className={styles.container}>
<div className={styles.title}>
<Skeleton count={1} width={200} />
</div>
<div className={styles.tracesContainer}>
{[...Array(11)].map((_, i) => (
<div className={styles.row} key={i}>
<div className={styles.rowLeft}>
<Skeleton count={1} />
</div>
<div className={styles.rowRight}>
<Skeleton count={1} />
</div>
</div>
))}
</div>
</div>
);
};

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',
}),
};
}
61 changes: 61 additions & 0 deletions src/components/Home/AttributePanelRow.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AttributePanelRow {...mockProps} />);

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(<AttributePanelRow {...mockProps} />);
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(<AttributePanelRow {...mockProps} />);
expect(screen.getByText(mockProps.labelTitle)).toBeInTheDocument();
});

it('does not render the row header only if index is > 0', () => {
render(<AttributePanelRow {...{ ...mockProps, index: 1 }} />);
expect(screen.queryByText(mockProps.labelTitle)).not.toBeInTheDocument();
});
});
Loading

0 comments on commit 992e0c1

Please sign in to comment.