Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Explore Traces Homepage #279

Merged
merged 34 commits into from
Jan 14, 2025
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
ec44a14
Homepage
joey-grafana Nov 27, 2024
bd7838d
Navigation and error checking
joey-grafana Nov 29, 2024
81ef65a
Several improvements
joey-grafana Dec 10, 2024
e819a64
Card design
joey-grafana Dec 10, 2024
a8ccf7c
Add skeleton component for loading state
joey-grafana Dec 10, 2024
73f5097
Light theme styling
joey-grafana Dec 10, 2024
3ae2c37
Overall styling and responsiveness
joey-grafana Dec 10, 2024
0ee4a71
Fix conflcit in routes
joey-grafana Dec 10, 2024
66fb603
Lazy load home page
joey-grafana Dec 17, 2024
eee3048
Feature tracking
joey-grafana Dec 17, 2024
39995be
Remove preview badge
joey-grafana Dec 17, 2024
be25180
Move rockets
joey-grafana Dec 17, 2024
ba79ae9
AttributePanelRows
joey-grafana Dec 17, 2024
9bfef1b
Loading, error, empty states
joey-grafana Dec 18, 2024
5ef2afd
Update url link
joey-grafana Dec 18, 2024
0e9f488
Improve styling
joey-grafana Dec 18, 2024
ff6e905
Improve skeleton styling
joey-grafana Dec 18, 2024
15b171a
Fix cspell
joey-grafana Dec 19, 2024
079e6b2
Utils and styling
joey-grafana Jan 2, 2025
9d81be6
Update url
joey-grafana Jan 5, 2025
1fd557d
Update error messages and improvements
joey-grafana Jan 5, 2025
c58d17d
Reuse AttributePanel for duration
joey-grafana Jan 8, 2025
cd77048
Update icon
joey-grafana Jan 8, 2025
e3fe498
Remove DurationAttributePanel file
joey-grafana Jan 8, 2025
42dc261
Add AttributePanelRow
joey-grafana Jan 8, 2025
fe0c848
Update icon hover
joey-grafana Jan 8, 2025
2be0320
Tests and improvements
joey-grafana Jan 8, 2025
c030b6f
Update slice to replace
joey-grafana Jan 9, 2025
67a9070
Reuse comparison logic
joey-grafana Jan 9, 2025
646771c
Remove superfluous part of query
joey-grafana Jan 9, 2025
0973e77
Only count error total if value is a number
joey-grafana Jan 10, 2025
b7c1b8b
Fix spellcheck
joey-grafana Jan 10, 2025
97a0218
Merge branch 'main' into joey/homepage
joey-grafana Jan 13, 2025
40f6f2d
Init acc in reduce to 0
joey-grafana Jan 14, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
213 changes: 213 additions & 0 deletions src/components/Home/AttributePanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
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 { yBucketToDuration } from 'components/Explore/panels/histogram';

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 = data.data?.series.map((s) => parseFloat(s.fields[1].name)).sort((a, b) => a - b);
if (yBuckets?.length) {
const slowestBuckets = Math.floor(yBuckets.length / 4);
let minBucket = yBuckets.length - slowestBuckets - 1;
if (minBucket < 0) {
minBucket = 0;
}

const minDuration = yBucketToDuration(minBucket - 1, yBuckets);
joey-grafana marked this conversation as resolved.
Show resolved Hide resolved

this.setState({
panel: new SceneFlexLayout({
children: [
new AttributePanel({
query: {
query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}} | by (resource.service.name)`,
adrapereira marked this conversation as resolved.
Show resolved Hide resolved
},
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();
});
});
102 changes: 102 additions & 0 deletions src/components/Home/AttributePanelRow.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div key={index}>
{index === 0 && (
<div className={styles.rowHeader}>
<span>{labelTitle}</span>
<span className={styles.valueTitle}>{valueTitle}</span>
</div>
)}

<div
className={styles.row}
key={index}
onClick={() => {
reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.panel_row_clicked, {
type,
index,
value
});
locationService.push(url);
}}
>
<div className={'rowLabel'}>{label}</div>

<div className={styles.action}>
<span className={styles.actionText}>
{value}
</span>
<Icon
className={styles.actionIcon}
name='arrow-right'
size='xl'
/>
</div>
</div>
</div>
);
}

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)}`,
}),
};
}
Loading
Loading