From ec44a145f84d75d5b5693bb6d122a05a74231e26 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 27 Nov 2024 13:08:39 +0000 Subject: [PATCH 01/32] Homepage --- src/components/Home/AttributePanel.tsx | 117 ++++++++++++ src/components/Home/AttributePanelScene.tsx | 168 +++++++++++++++++ .../Home/DurationAttributePanel.tsx | 105 +++++++++++ src/components/Home/HomeHeaderScene.tsx | 169 ++++++++++++++++++ src/components/Routes/Routes.tsx | 4 +- src/pages/Home/Home.tsx | 109 +++++++++++ src/pages/Home/HomePage.tsx | 32 ++++ src/utils/shared.ts | 1 + src/utils/utils.ts | 12 ++ 9 files changed, 716 insertions(+), 1 deletion(-) create mode 100644 src/components/Home/AttributePanel.tsx create mode 100644 src/components/Home/AttributePanelScene.tsx create mode 100644 src/components/Home/DurationAttributePanel.tsx create mode 100644 src/components/Home/HomeHeaderScene.tsx create mode 100644 src/pages/Home/Home.tsx create mode 100644 src/pages/Home/HomePage.tsx diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx new file mode 100644 index 0000000..f8d399a --- /dev/null +++ b/src/components/Home/AttributePanel.tsx @@ -0,0 +1,117 @@ +import React from 'react'; + +import { + SceneComponentProps, + SceneFlexItem, + SceneFlexLayout, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, +} from '@grafana/scenes'; +import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { explorationDS, MetricFunction } from 'utils/shared'; +import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; +import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; +import { SkeletonComponent } from '../Explore/ByFrameRepeater'; +import { useStyles2 } from '@grafana/ui'; +import { css } from '@emotion/css'; +import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesByServiceScene'; +import { AttributePanelScene } from './AttributePanelScene'; + +export interface AttributePanelState extends SceneObjectState { + panel?: SceneFlexLayout; + query: string; + title: string; + type: MetricFunction; +} + +export class AttributePanel extends SceneObjectBase { + constructor(state: AttributePanelState) { + super({ + $data: new SceneQueryRunner({ + datasource: explorationDS, + queries: [{ refId: 'A', query: state.query, queryType: 'traceql', tableType: 'spans', limit: 10, spss: 1 }], + }), + ...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 SceneFlexItem({ + body: new EmptyStateScene({ + imgWidth: 110, + }), + }), + ], + }), + }); + } else if (data.data.series.length > 0) { + this.setState({ + panel: new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: new AttributePanelScene({ + series: data.data.series, + title: state.title, + type: state.type + }), + }), + ], + }) + }); + } + } else if (data.data?.state === LoadingState.Loading) { + this.setState({ + panel: new SceneFlexLayout({ + direction: 'column', + maxHeight: MINI_PANEL_HEIGHT, + height: MINI_PANEL_HEIGHT, + children: [ + new LoadingStateScene({ + component: () => SkeletonComponent(1), + }), + ], + }), + }); + } + }) + ); + }); + } + + public static Component = ({ model }: SceneComponentProps) => { + const { panel } = model.useState(); + const styles = useStyles2(getStyles); + + if (!panel) { + return; + } + + return ( +
+ +
+ ); + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + border: `1px solid ${theme.colors.border.medium}`, + borderRadius: '4px', + minWidth: '350px', + width: '-webkit-fill-available', + }), + }; +} diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx new file mode 100644 index 0000000..c63ae16 --- /dev/null +++ b/src/components/Home/AttributePanelScene.tsx @@ -0,0 +1,168 @@ +import { css } from '@emotion/css'; +import { DataFrame, dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; +import { locationService } from '@grafana/runtime'; +import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; +import { Badge, Button, useStyles2 } from '@grafana/ui'; +import React from 'react'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import { formatDuration } from 'utils/dates'; +import { EXPLORATIONS_ROUTE, MetricFunction } from 'utils/shared'; + +interface AttributePanelSceneState extends SceneObjectState { + series?: DataFrame[]; + title: string; + type: MetricFunction; +} + +export class AttributePanelScene extends SceneObjectBase { + public static Component = ({ model }: SceneComponentProps) => { + const { series, title, type } = model.useState(); + const navigate = useNavigate(); + const styles = useStyles2(getStyles); + + const Traces = () => { + if (series && series.length > 0) { + console.log(series); + + const sortByField = series[0].fields.find((f) => f.name === (type === 'duration' ? 'duration' : 'time')); + if (sortByField) { + const sortedByDuration = sortByField?.values.map((_, i) => i)?.sort((a, b) => sortByField?.values[b] - sortByField?.values[a]); + const sortedFields = series[0].fields.map((f) => { + return { + ...f, + values: sortedByDuration?.map((i) => f.values[i]), + }; + }); + + 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'); + const timeField = sortedFields.find((f) => f.name === 'time'); + + const getLabel = (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 getErrorTimeAgo = (dateString: string) => { + const now = new Date(); + const date = new Date(dateString); + const diff = Math.floor((now.getTime() - date.getTime()) / 1000); // Difference in seconds + + if (diff < 60) { + return `${diff} second${diff === 1 ? '' : 's'} ago`; + } else if (diff < 3600) { + const minutes = Math.floor(diff / 60); + return `${minutes} minute${minutes === 1 ? '' : 's'} ago`; + } else if (diff < 86400) { + const hours = Math.floor(diff / 3600); + return `${hours} hour${hours === 1 ? '' : 's'} ago`; + } else { + const days = Math.floor(diff / 86400); + return `${days} day${days === 1 ? '' : 's'} ago`; + } + } + + // http://localhost:3000/a/grafana-exploretraces-app/explore? + // primarySignal=full_traces&traceId=76712b89f2507a5 + // &spanId=4f3109e5b3e2c67c + // &from=now-15m&to=now&timezone=UTC&var-ds=EbPO1fYnz + // &var-filters=nestedSetParent%7C%3C%7C0 + // &var-filters=resource.service.name%7C%3D%7Cgrafana + // &var-groupBy=resource.service.name + // &var-metric=rate + // &var-latencyThreshold= + // &var-partialLatencyThreshold= + // &actionView=breakdown + + return ( + <> + {traceIdField && spanIdField && traceNameField && traceServiceField && durationField && timeField && ( + traceIdField.values.map((traceId, index) => ( +
+ {getLabel(index)} + +
+ + + +
+
+ )) + )} + + ); + } + } + return <>; + } + + return ( +
+
+ {title} +
+
+ +
+
+ ); + }; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + width: '100%', + }), + title: css({ + backgroundColor: theme.colors.background.secondary, + fontSize: '1.3rem', + padding: `${theme.spacing(1.5)} ${theme.spacing(2)}`, + textAlign: 'center', + }), + traces: css({ + padding: theme.spacing(2), + }), + tracesRow: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: `${theme.spacing(0.5)} 0`, + }), + action: css({ + display: 'flex', + alignItems: 'center', + }), + actionText: css({ + paddingRight: theme.spacing(1), + }), + }; +} diff --git a/src/components/Home/DurationAttributePanel.tsx b/src/components/Home/DurationAttributePanel.tsx new file mode 100644 index 0000000..9d6eba8 --- /dev/null +++ b/src/components/Home/DurationAttributePanel.tsx @@ -0,0 +1,105 @@ +import React from 'react'; + +import { + SceneComponentProps, + SceneFlexItem, + SceneFlexLayout, + sceneGraph, + SceneObjectBase, + SceneObjectState, + SceneQueryRunner, +} from '@grafana/scenes'; +import { LoadingState } from '@grafana/data'; +import { explorationDS } from 'utils/shared'; +import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; +import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; +import { SkeletonComponent } from '../Explore/ByFrameRepeater'; +import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesByServiceScene'; +import { yBucketToDuration } from 'components/Explore/panels/histogram'; +import { AttributePanel } from './AttributePanel'; + +export interface DurationAttributePanelState extends SceneObjectState { + panel?: SceneFlexLayout; +} + +export class DurationAttributePanel extends SceneObjectBase { + constructor(state: DurationAttributePanelState) { + super({ + $data: new SceneQueryRunner({ + datasource: explorationDS, + queries: [{ refId: 'A', query: '{nestedSetParent<0} | histogram_over_time(duration)', queryType: 'traceql', tableType: 'spans', limit: 10, spss: 1 }], + }), + ...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 SceneFlexItem({ + body: new EmptyStateScene({ + imgWidth: 110, + }), + }), + ], + }), + }); + } else if (data.data.series.length > 0) { + 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); + + this.setState({ + panel: new SceneFlexLayout({ + children: [ + new SceneFlexItem({ + body: new AttributePanel({ query: `{nestedSetParent<0 && duration > ${minDuration}}`, title: 'Slowest traces', type: 'duration' }) + }), + ], + }) + }); + } + } + } else if (data.data?.state === LoadingState.Loading) { + this.setState({ + panel: new SceneFlexLayout({ + direction: 'column', + maxHeight: MINI_PANEL_HEIGHT, + height: MINI_PANEL_HEIGHT, + children: [ + new LoadingStateScene({ + component: () => SkeletonComponent(1), + }), + ], + }), + }); + } + }) + ); + }); + } + + public static Component = ({ model }: SceneComponentProps) => { + const { panel } = model.useState(); + + if (!panel) { + return; + } + + return ( + + ); + }; +} diff --git a/src/components/Home/HomeHeaderScene.tsx b/src/components/Home/HomeHeaderScene.tsx new file mode 100644 index 0000000..07b1d01 --- /dev/null +++ b/src/components/Home/HomeHeaderScene.tsx @@ -0,0 +1,169 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { + SceneComponentProps, + sceneGraph, + SceneObjectBase, +} from '@grafana/scenes'; +import { Badge, Button, Dropdown, Icon, Menu, Stack, Tooltip, useStyles2 } from '@grafana/ui'; + +import { + EXPLORATIONS_ROUTE, + VAR_DATASOURCE, +} from '../../utils/shared'; +import { getHomeScene } from '../../utils/utils'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; +import { useNavigate } from 'react-router-dom-v5-compat'; +import { Home } from 'pages/Home/Home'; + +const version = process.env.VERSION; +const buildTime = process.env.BUILD_TIME; +const commitSha = process.env.COMMIT_SHA; +const compositeVersion = `v${version} - ${buildTime?.split('T')[0]} (${commitSha})`; + +export class HomeHeaderScene extends SceneObjectBase { + static Component = ({ model }: SceneComponentProps) => { + const home = getHomeScene(model); + const navigate = useNavigate(); + const { controls } = home.useState(); + const styles = useStyles2(getStyles); + const [menuVisible, setMenuVisible] = React.useState(false); + + const dsVariable = sceneGraph.lookupVariable(VAR_DATASOURCE, home); + + const menu = + +
+ reportAppInteraction(USER_EVENTS_PAGES.common, USER_EVENTS_ACTIONS.common.global_docs_link_clicked)} + /> + reportAppInteraction(USER_EVENTS_PAGES.common, USER_EVENTS_ACTIONS.common.feedback_link_clicked)} + /> +
+
; + + return ( +
+
+
+

Explore Traces

+
+

A completely query-less experience to help you visualise your tracing data

+ +
+
+ + + {dsVariable && ( + +
Data source
+ +
+ )} +
+ } interactive> + + + + + + setMenuVisible(!menuVisible)}> + + + {controls.map((control) => ( + + ))} +
+
+
+
+ ); + }; +} + +const PreviewTooltip = ({ text }: { text: string }) => { + const styles = useStyles2(getStyles); + + return ( + +
{text}
+
+ ); +}; + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + display: 'flex', + gap: theme.spacing(2), + flexDirection: 'column', + padding: `0 ${theme.spacing(2)} ${theme.spacing(2)} ${theme.spacing(2)}`, + }), + + header: css({ + backgroundColor: theme.colors.background.canvas, + display: 'flex', + flexDirection: 'column', + padding: `${theme.spacing(1.5)} 0`, + }), + title: css({ + alignItems: 'baseline', + justifyContent: 'space-between', + display: 'flex', + }), + titleAction: css({ + alignItems: 'baseline', + justifyContent: 'space-between', + display: 'flex', + gap: theme.spacing(2), + }), + + datasourceLabel: css({ + fontSize: '12px', + }), + controls: css({ + display: 'flex', + gap: theme.spacing(1), + }), + menu: css({ + 'svg, span': { + color: theme.colors.text.link, + }, + }), + preview: css({ + cursor: 'help', + + '> div:first-child': { + padding: '5.5px', + }, + }), + tooltip: css({ + fontSize: '14px', + lineHeight: '22px', + width: '180px', + textAlign: 'center', + }), + helpIcon: css({ + marginLeft: theme.spacing(1), + }), + }; +} diff --git a/src/components/Routes/Routes.tsx b/src/components/Routes/Routes.tsx index a6da58b..cbc01ca 100644 --- a/src/components/Routes/Routes.tsx +++ b/src/components/Routes/Routes.tsx @@ -2,12 +2,14 @@ import React from 'react'; import { Route, Routes as ReactRoutes, Navigate } from 'react-router-dom-v5-compat'; import { TraceExplorationPage } from '../../pages/Explore'; import { ROUTES } from 'utils/shared'; +import { HomePage } from 'pages/Home/HomePage'; export const Routes = () => { return ( } /> - } /> + } /> + } /> ); }; diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx new file mode 100644 index 0000000..e85f471 --- /dev/null +++ b/src/pages/Home/Home.tsx @@ -0,0 +1,109 @@ +import { css } from '@emotion/css'; +import React from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { + DataSourceVariable, + SceneComponentProps, + SceneCSSGridItem, + SceneCSSGridLayout, + sceneGraph, + SceneObject, + SceneObjectBase, + SceneObjectState, + SceneRefreshPicker, + SceneTimePicker, + SceneTimeRange, + 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 { HomeHeaderScene } from 'components/Home/HomeHeaderScene'; +import { DurationAttributePanel } from 'components/Home/DurationAttributePanel'; + +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({})], + body: buildSplitLayout(), + ...state, + }); + + this.addActivationHandler(this._onActivate.bind(this)); + } + + private _onActivate() { + const datasourceVar = sceneGraph.lookupVariable(VAR_DATASOURCE, this) as DataSourceVariable; + datasourceVar.subscribeToState((newState) => { + if (newState.value) { + localStorage.setItem(DATASOURCE_LS_KEY, newState.value.toString()); + } + }); + } + + static Component = ({ model }: SceneComponentProps) => { + const { body } = model.useState(); + const styles = useStyles2(getStyles); + + return ( + <> + +
{body && }
+ + ); + }; +} + +function buildSplitLayout() { + return new SceneCSSGridLayout({ + children: [ + new SceneCSSGridLayout({ + autoRows: 'min-content', + columnGap: 2, + rowGap: 2, + children: [ + new SceneCSSGridItem({ + body: new AttributePanel({ query: '{nestedSetParent<0 && status=error}', title: 'Errored traces', type: 'errors' }), + }), + new SceneCSSGridItem({ + body: new DurationAttributePanel({}), + }), + ], + }), + ], + }) +} + +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({ + padding: theme.spacing(2), + }), + }; +} diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx new file mode 100644 index 0000000..2ef74e1 --- /dev/null +++ b/src/pages/Home/HomePage.tsx @@ -0,0 +1,32 @@ +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'; + +export const HomePage = () => { + const initialDs = localStorage.getItem(DATASOURCE_LS_KEY) || ''; + const [home] = useState(newHome(initialDs)); + + return ; +}; + +export function HomeView({ home }: { home: Home }) { + const [isInitialized, setIsInitialized] = React.useState(false); + + useEffect(() => { + if (!isInitialized) { + setIsInitialized(true); + + reportAppInteraction(USER_EVENTS_PAGES.common, USER_EVENTS_ACTIONS.common.app_initialized); + } + }, [home, isInitialized]); + + if (!isInitialized) { + return null; + } + + return ( + + ); +} diff --git a/src/utils/shared.ts b/src/utils/shared.ts index f040401..03d6f7f 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -4,6 +4,7 @@ export type MetricFunction = 'rate' | 'errors' | 'duration'; export enum ROUTES { Explore = 'explore', + Home = 'home', } export const EXPLORATIONS_ROUTE = '/a/grafana-exploretraces-app/explore'; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 747776f..86bd13a 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); } @@ -48,6 +53,13 @@ export function newTracesExploration( }); } +export function newHome(initialDS?: string): Home { + return new Home({ + initialDS, + $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), + }); +} + export function getUrlForExploration(exploration: TraceExploration) { const params = sceneUtils.getUrlState(exploration); return getUrlForValues(params); From bd7838d19f9090ad4facdab96317df5c865674d4 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Fri, 29 Nov 2024 10:56:50 +0000 Subject: [PATCH 02/32] Navigation and error checking --- src/components/Home/AttributePanel.tsx | 6 +- src/components/Home/AttributePanelScene.tsx | 99 ++++++++++++------- .../Home/DurationAttributePanel.tsx | 2 +- src/pages/Explore/TraceExplorationPage.tsx | 2 +- src/pages/Home/Home.tsx | 2 +- src/utils/shared.ts | 7 +- src/utils/utils.ts | 2 +- 7 files changed, 75 insertions(+), 45 deletions(-) diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index f8d399a..98c8043 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -9,7 +9,7 @@ import { SceneObjectState, SceneQueryRunner, } from '@grafana/scenes'; -import { GrafanaTheme2, LoadingState } from '@grafana/data'; +import { LoadingState } from '@grafana/data'; import { explorationDS, MetricFunction } from 'utils/shared'; import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; @@ -105,11 +105,9 @@ export class AttributePanel extends SceneObjectBase { }; } -function getStyles(theme: GrafanaTheme2) { +function getStyles() { return { container: css({ - border: `1px solid ${theme.colors.border.medium}`, - borderRadius: '4px', minWidth: '350px', width: '-webkit-fill-available', }), diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index c63ae16..be17253 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -1,10 +1,9 @@ import { css } from '@emotion/css'; -import { DataFrame, dateTimeFormat, GrafanaTheme2 } from '@grafana/data'; +import { DataFrame, dateTimeFormat, Field, GrafanaTheme2 } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; import { Badge, Button, useStyles2 } from '@grafana/ui'; import React from 'react'; -import { useNavigate } from 'react-router-dom-v5-compat'; import { formatDuration } from 'utils/dates'; import { EXPLORATIONS_ROUTE, MetricFunction } from 'utils/shared'; @@ -17,7 +16,6 @@ interface AttributePanelSceneState extends SceneObjectState { export class AttributePanelScene extends SceneObjectBase { public static Component = ({ model }: SceneComponentProps) => { const { series, title, type } = model.useState(); - const navigate = useNavigate(); const styles = useStyles2(getStyles); const Traces = () => { @@ -25,7 +23,7 @@ export class AttributePanelScene extends SceneObjectBase f.name === (type === 'duration' ? 'duration' : 'time')); - if (sortByField) { + if (sortByField && sortByField.values) { const sortedByDuration = sortByField?.values.map((_, i) => i)?.sort((a, b) => sortByField?.values[b] - sortByField?.values[a]); const sortedFields = series[0].fields.map((f) => { return { @@ -52,7 +50,13 @@ export class AttributePanelScene extends SceneObjectBase { + const getErrorTimeAgo = (timeField: Field | undefined, index: number) => { + if (!timeField || !timeField.values) { + return 'Times not found'; + } + + const dateString = dateTimeFormat(timeField?.values[index]); + const now = new Date(); const date = new Date(dateString); const diff = Math.floor((now.getTime() - date.getTime()) / 1000); // Difference in seconds @@ -71,6 +75,29 @@ export class AttributePanelScene extends SceneObjectBase { + if (!durationField || !durationField.values) { + return 'Durations not found'; + } + + return formatDuration(durationField.values[index] / 1000); + } + + const getUrl = (traceId: string, spanIdField: Field | undefined, traceServiceField: Field | undefined, index: number) => { + let url = EXPLORATIONS_ROUTE + '?primarySignal=full_traces'; + + if (!spanIdField || !spanIdField.values || !traceServiceField || !traceServiceField.values) { + console.error('SpanId or traceService not found'); + return url; + } + + url = url + `&traceId=${traceId}&spanId=${spanIdField.values[index]}`; + url = url + `&var-filters=resource.service.name|=|${traceServiceField.values[index]}`; + url = type === 'duration' ? url + '&var-metric=duration' : url + '&var-metric=errors'; + + return url; + } + // http://localhost:3000/a/grafana-exploretraces-app/explore? // primarySignal=full_traces&traceId=76712b89f2507a5 // &spanId=4f3109e5b3e2c67c @@ -83,40 +110,38 @@ export class AttributePanelScene extends SceneObjectBase - {traceIdField && spanIdField && traceNameField && traceServiceField && durationField && timeField && ( - traceIdField.values.map((traceId, index) => ( -
- {getLabel(index)} - -
- - - -
- )) - )} +
+ ))} ); } @@ -140,6 +165,8 @@ export class AttributePanelScene extends SceneObjectBase ${minDuration}}`, title: 'Slowest traces', type: 'duration' }) + body: new AttributePanel({ query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}} | by (resource.service.name)`, title: 'Slowest services', type: 'duration' }) }), ], }) diff --git a/src/pages/Explore/TraceExplorationPage.tsx b/src/pages/Explore/TraceExplorationPage.tsx index 3b2944f..139620d 100644 --- a/src/pages/Explore/TraceExplorationPage.tsx +++ b/src/pages/Explore/TraceExplorationPage.tsx @@ -42,7 +42,7 @@ export function TraceExplorationView({ exploration }: { exploration: TraceExplor } return ( - + ); diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index e85f471..e1bca3b 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -76,7 +76,7 @@ function buildSplitLayout() { rowGap: 2, children: [ new SceneCSSGridItem({ - body: new AttributePanel({ query: '{nestedSetParent<0 && status=error}', title: 'Errored traces', type: 'errors' }), + body: new AttributePanel({ query: '{nestedSetParent<0 && status=error}', title: 'Errored services', type: 'errors' }), }), new SceneCSSGridItem({ body: new DurationAttributePanel({}), diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 03d6f7f..6e241be 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -1,4 +1,5 @@ import { BusEventWithPayload, DataFrame } from '@grafana/data'; +import pluginJson from '../plugin.json'; export type MetricFunction = 'rate' | 'errors' | 'duration'; @@ -7,7 +8,11 @@ export enum ROUTES { 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 HOME_ROUTE = `${PLUGIN_BASE_URL}/${ROUTES.Home}`; + 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 86bd13a..980499a 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -56,7 +56,7 @@ export function newTracesExploration( export function newHome(initialDS?: string): Home { return new Home({ initialDS, - $timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }), + $timeRange: new SceneTimeRange({ from: 'now-15m', to: 'now' }), }); } From 81ef65aab04286b744f2db833c5364c06a14d0ef Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 10 Dec 2024 09:33:52 +0000 Subject: [PATCH 03/32] Several improvements --- src/components/Home/AttributePanelScene.tsx | 6 +- .../Home/DurationAttributePanel.tsx | 2 +- .../{HomeHeaderScene.tsx => HeaderScene.tsx} | 174 ++++++++++-------- src/components/Home/rockets.tsx | 21 +++ src/pages/Home/Home.tsx | 21 ++- src/utils/utils.ts | 4 +- 6 files changed, 136 insertions(+), 92 deletions(-) rename src/components/Home/{HomeHeaderScene.tsx => HeaderScene.tsx} (50%) create mode 100644 src/components/Home/rockets.tsx diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index be17253..8d5cfcc 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -20,8 +20,6 @@ export class AttributePanelScene extends SceneObjectBase { if (series && series.length > 0) { - console.log(series); - const sortByField = series[0].fields.find((f) => f.name === (type === 'duration' ? 'duration' : 'time')); if (sortByField && sortByField.values) { const sortedByDuration = sortByField?.values.map((_, i) => i)?.sort((a, b) => sortByField?.values[b] - sortByField?.values[a]); @@ -135,7 +133,6 @@ export class AttributePanelScene extends SceneObjectBase { const url = getUrl(traceId, spanIdField, traceServiceField, index); - console.log(url) locationService.push(url); }} /> @@ -166,7 +163,8 @@ function getStyles(theme: GrafanaTheme2) { return { container: css({ border: `1px solid ${theme.colors.border.medium}`, - borderRadius: '4px', + borderRadius: theme.spacing(0.5), + marginBottom: theme.spacing(4), width: '100%', }), title: css({ diff --git a/src/components/Home/DurationAttributePanel.tsx b/src/components/Home/DurationAttributePanel.tsx index 2e22429..ed24caf 100644 --- a/src/components/Home/DurationAttributePanel.tsx +++ b/src/components/Home/DurationAttributePanel.tsx @@ -65,7 +65,7 @@ export class DurationAttributePanel extends SceneObjectBase ${minDuration}} | by (resource.service.name)`, title: 'Slowest services', type: 'duration' }) + body: new AttributePanel({ query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}} | by (resource.service.name)`, title: 'Slow services', type: 'duration' }) }), ], }) diff --git a/src/components/Home/HomeHeaderScene.tsx b/src/components/Home/HeaderScene.tsx similarity index 50% rename from src/components/Home/HomeHeaderScene.tsx rename to src/components/Home/HeaderScene.tsx index 07b1d01..4e093ef 100644 --- a/src/components/Home/HomeHeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -7,69 +7,68 @@ import { sceneGraph, SceneObjectBase, } from '@grafana/scenes'; -import { Badge, Button, Dropdown, Icon, Menu, Stack, Tooltip, useStyles2 } from '@grafana/ui'; +import { Badge, Button, Icon, LinkButton, Stack, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; import { EXPLORATIONS_ROUTE, VAR_DATASOURCE, } from '../../utils/shared'; import { getHomeScene } from '../../utils/utils'; -import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; import { useNavigate } from 'react-router-dom-v5-compat'; import { Home } from 'pages/Home/Home'; +import { DarkModeRocket, LightModeRocket } from './rockets'; const version = process.env.VERSION; const buildTime = process.env.BUILD_TIME; const commitSha = process.env.COMMIT_SHA; const compositeVersion = `v${version} - ${buildTime?.split('T')[0]} (${commitSha})`; -export class HomeHeaderScene extends SceneObjectBase { +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 [menuVisible, setMenuVisible] = React.useState(false); + const theme = useTheme2(); const dsVariable = sceneGraph.lookupVariable(VAR_DATASOURCE, home); - const menu = - -
- reportAppInteraction(USER_EVENTS_PAGES.common, USER_EVENTS_ACTIONS.common.global_docs_link_clicked)} - /> - reportAppInteraction(USER_EVENTS_PAGES.common, USER_EVENTS_ACTIONS.common.feedback_link_clicked)} - /> -
-
; - return (
-
-
-

Explore Traces

-
-

A completely query-less experience to help you visualise your tracing data

+
+
+ {theme.isDark ? : } +

Start your traces exploration!

+
+
+

Explore and visualize your trace data without writing a query.

+
+ + Read documentation +
+
- +
+

Or quick-start into your tracing data.

+
+ +
+ {dsVariable && (
Data source
@@ -77,18 +76,12 @@ export class HomeHeaderScene extends SceneObjectBase {
)}
- } interactive> + {/* } interactive> - - - setMenuVisible(!menuVisible)}> - - + */} + {controls.map((control) => ( ))} @@ -105,65 +98,92 @@ const PreviewTooltip = ({ text }: { text: string }) => { return ( -
{text}
+
{text}
); }; function getStyles(theme: GrafanaTheme2) { return { + preview: css({ + cursor: 'help', + + '> div:first-child': { + padding: '5.5px', + }, + }), + previewTooltip: css({ + fontSize: '14px', + lineHeight: '22px', + width: '180px', + textAlign: 'center', + }), + container: css({ + label: 'joeyContainer', display: 'flex', - gap: theme.spacing(2), + gap: theme.spacing(7), flexDirection: 'column', - padding: `0 ${theme.spacing(2)} ${theme.spacing(2)} ${theme.spacing(2)}`, + margin: `0 0 ${theme.spacing(4)} 0`, + justifyContent: 'center', }), - header: css({ - backgroundColor: theme.colors.background.canvas, + top: css({ + label: 'joeyTop', display: 'flex', - flexDirection: 'column', - padding: `${theme.spacing(1.5)} 0`, + alignItems: 'center', + backgroundColor: theme.colors.background.secondary, + borderRadius: theme.spacing(0.5), + justifyContent: 'center', + padding: theme.spacing(3), + width: '100%', }), - title: css({ - alignItems: 'baseline', - justifyContent: 'space-between', + left: css({ + label: 'joeyLeft', display: 'flex', + alignItems: 'center', }), - titleAction: css({ - alignItems: 'baseline', - justifyContent: 'space-between', - display: 'flex', - gap: theme.spacing(2), + title: css({ + margin: `0 0 0 ${theme.spacing(2)}`, }), - datasourceLabel: css({ - fontSize: '12px', + right: css({ + label: 'joeyRight', + margin: `0 0 0 ${theme.spacing(4)}`, }), - controls: css({ + headerActions: css({ + alignItems: 'center', + justifyContent: 'flex-start', display: 'flex', - gap: theme.spacing(1), + gap: theme.spacing(2), }), - menu: css({ - 'svg, span': { - color: theme.colors.text.link, - }, + arrowIcon: css({ + marginLeft: theme.spacing(1), }), - preview: css({ - cursor: 'help', - - '> div:first-child': { - padding: '5.5px', + documentationLink: css({ + textDecoration: 'underline', + '&:hover': { + textDecoration: 'underline', }, }), - tooltip: css({ - fontSize: '14px', - lineHeight: '22px', - width: '180px', + + middle: css({ + label: 'joeyMiddle', textAlign: 'center', + 'h4': { + margin: 0, + } }), - helpIcon: css({ - marginLeft: theme.spacing(1), + + bottom: css({ + label: 'joeyBottom', + }), + datasourceLabel: css({ + fontSize: '12px', + }), + controls: css({ + display: 'flex', + gap: theme.spacing(1), }), }; } diff --git a/src/components/Home/rockets.tsx b/src/components/Home/rockets.tsx new file mode 100644 index 0000000..f9e6487 --- /dev/null +++ b/src/components/Home/rockets.tsx @@ -0,0 +1,21 @@ +import React from "react"; + +export const LightModeRocket = () => ( + + + +); + +export const DarkModeRocket = () => ( + + + +); diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index e1bca3b..04e8f43 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -23,7 +23,7 @@ import { VAR_DATASOURCE, } from '../../utils/shared'; import { AttributePanel } from 'components/Home/AttributePanel'; -import { HomeHeaderScene } from 'components/Home/HomeHeaderScene'; +import { HeaderScene } from 'components/Home/HeaderScene'; import { DurationAttributePanel } from 'components/Home/DurationAttributePanel'; export interface HomeState extends SceneObjectState { @@ -38,7 +38,7 @@ export class Home extends SceneObjectBase { $timeRange: state.$timeRange ?? new SceneTimeRange({}), $variables: state.$variables ?? getVariableSet(state.initialDS), controls: state.controls ?? [new SceneTimePicker({}), new SceneRefreshPicker({})], - body: buildSplitLayout(), + body: buildPanels(), ...state, }); @@ -59,15 +59,15 @@ export class Home extends SceneObjectBase { const styles = useStyles2(getStyles); return ( - <> - -
{body && }
- +
+ + {body && } +
); }; } -function buildSplitLayout() { +function buildPanels() { return new SceneCSSGridLayout({ children: [ new SceneCSSGridLayout({ @@ -103,7 +103,12 @@ function getVariableSet(initialDS?: string) { function getStyles(theme: GrafanaTheme2) { return { container: css({ - padding: theme.spacing(2), + margin: `${theme.spacing(4)} auto`, + width: '75%', + + '@media (max-width: 900px)': { + width: '100%', + }, }), }; } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 980499a..1e3489b 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -48,7 +48,7 @@ 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, }); } @@ -56,7 +56,7 @@ export function newTracesExploration( export function newHome(initialDS?: string): Home { return new Home({ initialDS, - $timeRange: new SceneTimeRange({ from: 'now-15m', to: 'now' }), + $timeRange: new SceneTimeRange({ from: 'now-30m', to: 'now' }), }); } From e819a6460ce6630c5d360a3f28ad472d81f75242 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 10 Dec 2024 10:28:34 +0000 Subject: [PATCH 04/32] Card design --- src/components/Home/AttributePanelScene.tsx | 119 ++++++++++++-------- 1 file changed, 69 insertions(+), 50 deletions(-) diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index 8d5cfcc..dce0770 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -2,7 +2,7 @@ import { css } from '@emotion/css'; import { DataFrame, dateTimeFormat, Field, GrafanaTheme2 } from '@grafana/data'; import { locationService } from '@grafana/runtime'; import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; -import { Badge, Button, useStyles2 } from '@grafana/ui'; +import { Icon, useStyles2 } from '@grafana/ui'; import React from 'react'; import { formatDuration } from 'utils/dates'; import { EXPLORATIONS_ROUTE, MetricFunction } from 'utils/shared'; @@ -60,16 +60,13 @@ export class AttributePanelScene extends SceneObjectBase { + const getLink = (traceId: string, spanIdField: Field | undefined, traceServiceField: Field | undefined, index: number) => { let url = EXPLORATIONS_ROUTE + '?primarySignal=full_traces'; if (!spanIdField || !spanIdField.values || !traceServiceField || !traceServiceField.values) { @@ -96,46 +93,38 @@ export class AttributePanelScene extends SceneObjectBase {traceIdField?.values?.map((traceId, index) => ( -
- {getLabel(index)} - -
- - + {index === 0 && ( +
+ Trace Name + {type === 'duration' ? 'Duration' : 'Since'} +
+ )} + +
{ + const link = getLink(traceId, spanIdField, traceServiceField, index); + locationService.push(link); + }} + > +
{getLabel(index)}
+ +
+ + {type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index)} + + - -
))} @@ -149,9 +138,10 @@ export class AttributePanelScene extends SceneObjectBase
- {title} + + {title}
-
+
@@ -168,26 +158,55 @@ function getStyles(theme: GrafanaTheme2) { 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', }), - traces: css({ - padding: theme.spacing(2), + titleText: css({ + marginLeft: theme.spacing(1), + }), + + tracesContainer: css({ + padding: `${theme.spacing(2)} 0`, + }), + tracesRowHeader: css({ + color: theme.colors.text.secondary, + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: `0 ${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(2)}`, + }), + tracesRowHeaderText: css({ + margin: '0 45px 0 0', }), + tracesRow: css({ display: 'flex', justifyContent: 'space-between', alignItems: 'center', - padding: `${theme.spacing(0.5)} 0`, + padding: `${theme.spacing(0.75)} ${theme.spacing(2)}`, + + '&:hover': { + backgroundColor: theme.colors.background.secondary, + cursor: 'pointer', + '.tracesRowLabel': { + textDecoration: 'underline', + } + }, }), action: css({ display: 'flex', alignItems: 'center', }), actionText: css({ + color: '#d5983c', padding: `0 ${theme.spacing(1)}`, }), + actionIcon: css({ + cursor: 'pointer', + margin: `0 ${theme.spacing(0.5)} 0 ${theme.spacing(1)}`, + }), }; } From a8ccf7cdb199ee8adfc88651f36f042620140440 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 10 Dec 2024 11:29:38 +0000 Subject: [PATCH 05/32] Add skeleton component for loading state --- src/components/Home/AttributePanel.tsx | 62 ++++++++++++++++++- .../Home/DurationAttributePanel.tsx | 5 +- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index 98c8043..5ec5d9f 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -9,15 +9,15 @@ import { SceneObjectState, SceneQueryRunner, } from '@grafana/scenes'; -import { LoadingState } from '@grafana/data'; +import { GrafanaTheme2, LoadingState } from '@grafana/data'; import { explorationDS, MetricFunction } from 'utils/shared'; import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; -import { SkeletonComponent } from '../Explore/ByFrameRepeater'; 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'; export interface AttributePanelState extends SceneObjectState { panel?: SceneFlexLayout; @@ -78,7 +78,7 @@ export class AttributePanel extends SceneObjectBase { height: MINI_PANEL_HEIGHT, children: [ new LoadingStateScene({ - component: () => SkeletonComponent(1), + component: () => SkeletonComponent(), }), ], }), @@ -113,3 +113,59 @@ function getStyles() { }), }; } + +export const SkeletonComponent = () => { + const styles = useStyles2(getSkeletonStyles); + + return ( +
+
+ +
+
+ {[...Array(5)].map((_, i) => ( +
+
+ +
+
+ +
+
+ ))} +
+
+ ); +}; + +function getSkeletonStyles(theme: GrafanaTheme2) { + return { + container: css({ + border: `1px solid ${theme.colors.border.medium}`, + 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: `${theme.spacing(2)}`, + }), + row: css({ + display: 'flex', + justifyContent: 'space-between', + }), + rowLeft: css({ + margin: '6px 0', + width: '150px', + }), + rowRight: css({ + width: '50px', + }), + }; +} diff --git a/src/components/Home/DurationAttributePanel.tsx b/src/components/Home/DurationAttributePanel.tsx index ed24caf..70abd7b 100644 --- a/src/components/Home/DurationAttributePanel.tsx +++ b/src/components/Home/DurationAttributePanel.tsx @@ -13,10 +13,9 @@ import { LoadingState } from '@grafana/data'; import { explorationDS } from 'utils/shared'; import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; -import { SkeletonComponent } from '../Explore/ByFrameRepeater'; import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesByServiceScene'; import { yBucketToDuration } from 'components/Explore/panels/histogram'; -import { AttributePanel } from './AttributePanel'; +import { AttributePanel, SkeletonComponent } from './AttributePanel'; export interface DurationAttributePanelState extends SceneObjectState { panel?: SceneFlexLayout; @@ -80,7 +79,7 @@ export class DurationAttributePanel extends SceneObjectBase SkeletonComponent(1), + component: () => SkeletonComponent(), }), ], }), From 73f50971d971d7f7650e62c9a81699d8860c9c10 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 10 Dec 2024 13:17:15 +0000 Subject: [PATCH 06/32] Light theme styling --- src/components/Home/AttributePanel.tsx | 2 +- src/components/Home/AttributePanelScene.tsx | 10 ++++++---- src/components/Home/HeaderScene.tsx | 2 +- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index 5ec5d9f..dd7ca75 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -141,7 +141,7 @@ export const SkeletonComponent = () => { function getSkeletonStyles(theme: GrafanaTheme2) { return { container: css({ - border: `1px solid ${theme.colors.border.medium}`, + border: `1px solid ${theme.isDark ? theme.colors.border.medium : theme.colors.border.weak}`, borderRadius: theme.spacing(0.5), marginBottom: theme.spacing(4), width: '100%', diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index dce0770..f1aac77 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -152,14 +152,16 @@ export class AttributePanelScene extends SceneObjectBase Date: Tue, 10 Dec 2024 14:29:36 +0000 Subject: [PATCH 07/32] Overall styling and responsiveness --- src/components/Home/HeaderScene.tsx | 70 ++++++++++++----------------- src/pages/Home/Home.tsx | 2 +- 2 files changed, 30 insertions(+), 42 deletions(-) diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index 3cff8f0..51a8c54 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -35,12 +35,12 @@ export class HeaderScene extends SceneObjectBase { return (
-
-
+
+
{theme.isDark ? : }

Start your traces exploration!

-
+

Explore and visualize your trace data without writing a query.

-
+

Or quick-start into your tracing data.

-
- - {dsVariable && ( - -
Data source
- -
- )} -
- {/* } interactive> - - - - */} - - {controls.map((control) => ( - - ))} -
-
-
+ + {dsVariable && ( + +
Data source
+ +
+ )} +
+ {/* } interactive> + + + + */} + + {controls.map((control) => ( + + ))} +
+
); }; @@ -120,7 +118,6 @@ function getStyles(theme: GrafanaTheme2) { }), container: css({ - label: 'joeyContainer', display: 'flex', gap: theme.spacing(7), flexDirection: 'column', @@ -128,18 +125,17 @@ function getStyles(theme: GrafanaTheme2) { justifyContent: 'center', }), - top: css({ - label: 'joeyTop', + 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), - width: '100%', + gap: theme.spacing(4), }), - left: css({ - label: 'joeyLeft', + headerTitleContainer: css({ display: 'flex', alignItems: 'center', }), @@ -147,10 +143,6 @@ function getStyles(theme: GrafanaTheme2) { margin: `0 0 0 ${theme.spacing(2)}`, }), - right: css({ - label: 'joeyRight', - margin: `0 0 0 ${theme.spacing(4)}`, - }), headerActions: css({ alignItems: 'center', justifyContent: 'flex-start', @@ -158,7 +150,7 @@ function getStyles(theme: GrafanaTheme2) { gap: theme.spacing(2), }), arrowIcon: css({ - marginLeft: theme.spacing(1), + marginheaderTitleContainer: theme.spacing(1), }), documentationLink: css({ textDecoration: 'underline', @@ -167,17 +159,13 @@ function getStyles(theme: GrafanaTheme2) { }, }), - middle: css({ - label: 'joeyMiddle', + subHeader: css({ textAlign: 'center', 'h4': { margin: 0, } }), - bottom: css({ - label: 'joeyBottom', - }), datasourceLabel: css({ fontSize: '12px', }), diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 04e8f43..2c710fb 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -107,7 +107,7 @@ function getStyles(theme: GrafanaTheme2) { width: '75%', '@media (max-width: 900px)': { - width: '100%', + width: '95%', }, }), }; From 66fb603cf9d57b3c9c4d095d945c1b0ffbfc77e8 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 17 Dec 2024 09:30:06 +0000 Subject: [PATCH 08/32] Lazy load home page --- src/components/Routes/Routes.tsx | 2 +- src/pages/Home/HomePage.tsx | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/Routes/Routes.tsx b/src/components/Routes/Routes.tsx index b596059..8258078 100644 --- a/src/components/Routes/Routes.tsx +++ b/src/components/Routes/Routes.tsx @@ -1,7 +1,7 @@ import React, { lazy } from 'react'; import { Route, Routes as ReactRoutes, Navigate } from 'react-router-dom-v5-compat'; import { ROUTES } from 'utils/shared'; -import { HomePage } from 'pages/Home/HomePage'; +const HomePage = lazy(() => import('../../pages/Home/HomePage')); const TraceExplorationPage = lazy(() => import('../../pages/Explore/TraceExplorationPage')); export const Routes = () => { diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx index 2ef74e1..a05672a 100644 --- a/src/pages/Home/HomePage.tsx +++ b/src/pages/Home/HomePage.tsx @@ -4,13 +4,15 @@ import { DATASOURCE_LS_KEY } from '../../utils/shared'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from '../../utils/analytics'; import { Home } from './Home'; -export const HomePage = () => { +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); From eee304881e4b6c260d51bc7dcb22a00fd327528a Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 17 Dec 2024 10:09:38 +0000 Subject: [PATCH 09/32] Feature tracking --- src/components/Home/AttributePanelScene.tsx | 6 ++++++ src/components/Home/HeaderScene.tsx | 7 ++++++- src/pages/Home/HomePage.tsx | 2 +- src/utils/analytics.ts | 8 ++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index f1aac77..37c38c4 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -4,6 +4,7 @@ import { locationService } from '@grafana/runtime'; import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; import { Icon, useStyles2 } from '@grafana/ui'; import React from 'react'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; import { formatDuration } from 'utils/dates'; import { EXPLORATIONS_ROUTE, MetricFunction } from 'utils/shared'; @@ -108,6 +109,11 @@ export class AttributePanelScene extends SceneObjectBase { + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.attribute_panel_item_clicked, { + type, + index, + value: type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index) + }); const link = getLink(traceId, spanIdField, traceServiceField, index); locationService.push(link); }} diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index 51a8c54..d333806 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -17,6 +17,7 @@ import { getHomeScene } from '../../utils/utils'; import { useNavigate } from 'react-router-dom-v5-compat'; import { Home } from 'pages/Home/Home'; import { DarkModeRocket, LightModeRocket } from './rockets'; +import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; const version = process.env.VERSION; const buildTime = process.env.BUILD_TIME; @@ -43,7 +44,10 @@ export class HeaderScene extends SceneObjectBase {

Explore and visualize your trace data without writing a query.

- @@ -56,6 +60,7 @@ export class HeaderScene extends SceneObjectBase { 'https://grafana.com/docs/grafana-cloud/visualizations/simplified-exploration/traces' } className={styles.documentationLink} + onClick={() => reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.read_documentation_clicked)} > Read documentation diff --git a/src/pages/Home/HomePage.tsx b/src/pages/Home/HomePage.tsx index a05672a..8cb9416 100644 --- a/src/pages/Home/HomePage.tsx +++ b/src/pages/Home/HomePage.tsx @@ -20,7 +20,7 @@ export function HomeView({ home }: { home: Home }) { if (!isInitialized) { setIsInitialized(true); - reportAppInteraction(USER_EVENTS_PAGES.common, USER_EVENTS_ACTIONS.common.app_initialized); + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.homepage_initialized); } }, [home, isInitialized]); diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 3360501..730064a 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', + attribute_panel_item_clicked: 'attribute_panel_item_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', From 39995bef4cf36f27c5e96a42ee6f798169f3adec Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 17 Dec 2024 10:10:32 +0000 Subject: [PATCH 10/32] Remove preview badge --- src/components/Home/HeaderScene.tsx | 29 +---------------------------- 1 file changed, 1 insertion(+), 28 deletions(-) diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index d333806..820ec86 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -7,7 +7,7 @@ import { sceneGraph, SceneObjectBase, } from '@grafana/scenes'; -import { Badge, Button, Icon, LinkButton, Stack, Tooltip, useStyles2, useTheme2 } from '@grafana/ui'; +import { Button, Icon, LinkButton, Stack, useStyles2, useTheme2 } from '@grafana/ui'; import { EXPLORATIONS_ROUTE, @@ -19,11 +19,6 @@ import { Home } from 'pages/Home/Home'; import { DarkModeRocket, LightModeRocket } from './rockets'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; -const version = process.env.VERSION; -const buildTime = process.env.BUILD_TIME; -const commitSha = process.env.COMMIT_SHA; -const compositeVersion = `v${version} - ${buildTime?.split('T')[0]} (${commitSha})`; - export class HeaderScene extends SceneObjectBase { static Component = ({ model }: SceneComponentProps) => { const home = getHomeScene(model); @@ -80,12 +75,6 @@ export class HeaderScene extends SceneObjectBase { )}
- {/* } interactive> - - - - */} - {controls.map((control) => ( ))} @@ -96,16 +85,6 @@ export class HeaderScene extends SceneObjectBase { }; } -const PreviewTooltip = ({ text }: { text: string }) => { - const styles = useStyles2(getStyles); - - return ( - -
{text}
-
- ); -}; - function getStyles(theme: GrafanaTheme2) { return { preview: css({ @@ -115,12 +94,6 @@ function getStyles(theme: GrafanaTheme2) { padding: '5.5px', }, }), - previewTooltip: css({ - fontSize: '14px', - lineHeight: '22px', - width: '180px', - textAlign: 'center', - }), container: css({ display: 'flex', From be25180f37fd5296d8f8deb7b1e48e5d52536f76 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 17 Dec 2024 10:15:35 +0000 Subject: [PATCH 11/32] Move rockets --- src/components/Home/HeaderScene.tsx | 2 +- src/{components/Home => utils}/rockets.tsx | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/{components/Home => utils}/rockets.tsx (100%) diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index 820ec86..cfd9e93 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -16,7 +16,7 @@ import { import { getHomeScene } from '../../utils/utils'; import { useNavigate } from 'react-router-dom-v5-compat'; import { Home } from 'pages/Home/Home'; -import { DarkModeRocket, LightModeRocket } from './rockets'; +import { DarkModeRocket, LightModeRocket } from '../../utils/rockets'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; export class HeaderScene extends SceneObjectBase { diff --git a/src/components/Home/rockets.tsx b/src/utils/rockets.tsx similarity index 100% rename from src/components/Home/rockets.tsx rename to src/utils/rockets.tsx From ba79ae9f041f01f6b453869cb83658849127e5ff Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 17 Dec 2024 10:37:29 +0000 Subject: [PATCH 12/32] AttributePanelRows --- src/components/Home/AttributePanelRows.tsx | 182 ++++++++++++++++++++ src/components/Home/AttributePanelScene.tsx | 175 +------------------ src/pages/Home/Home.tsx | 2 +- 3 files changed, 187 insertions(+), 172 deletions(-) create mode 100644 src/components/Home/AttributePanelRows.tsx diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx new file mode 100644 index 0000000..dffcbba --- /dev/null +++ b/src/components/Home/AttributePanelRows.tsx @@ -0,0 +1,182 @@ +import { css } from "@emotion/css"; +import { DataFrame, dateTimeFormat, Field, 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 { formatDuration } from "utils/dates"; +import { EXPLORATIONS_ROUTE, MetricFunction } from "utils/shared"; + +type Props = { + series: DataFrame[] | undefined; + type: MetricFunction; +} + +export const AttributePanelRows = (props: Props) => { + const { series, type } = props; + const styles = useStyles2(getStyles); + + 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 getErrorTimeAgo = (timeField: Field | undefined, index: number) => { + if (!timeField || !timeField.values) { + return 'Times not found'; + } + + const dateString = dateTimeFormat(timeField?.values[index]); + + const now = new Date(); + const date = new Date(dateString); + const diff = Math.floor((now.getTime() - date.getTime()) / 1000); // Difference in seconds + + if (diff < 60) { + return `${diff}s`; + } else if (diff < 3600) { + return `${Math.floor(diff / 60)}m`; + } else if (diff < 86400) { + return `${Math.floor(diff / 3600)}h`; + } else { + return `${Math.floor(diff / 86400)}d`; + } + } + + const getDuration = (durationField: Field | undefined, index: number) => { + if (!durationField || !durationField.values) { + return 'Durations not found'; + } + + return formatDuration(durationField.values[index] / 1000); + } + + const getLink = (traceId: string, spanIdField: Field | undefined, traceServiceField: Field | undefined, index: number) => { + let url = EXPLORATIONS_ROUTE + '?primarySignal=full_traces'; + + if (!spanIdField || !spanIdField.values || !traceServiceField || !traceServiceField.values) { + console.error('SpanId or traceService not found'); + return url; + } + + url = url + `&traceId=${traceId}&spanId=${spanIdField.values[index]}`; + url = url + `&var-filters=resource.service.name|=|${traceServiceField.values[index]}`; + url = type === 'duration' ? url + '&var-metric=duration' : url + '&var-metric=errors'; + + return url; + } + + if (series && series.length > 0) { + const sortByField = series[0].fields.find((f) => f.name === (type === 'duration' ? 'duration' : 'time')); + if (sortByField && sortByField.values) { + const sortedByDuration = sortByField?.values.map((_, i) => i)?.sort((a, b) => sortByField?.values[b] - sortByField?.values[a]); + const sortedFields = series[0].fields.map((f) => { + return { + ...f, + values: sortedByDuration?.map((i) => f.values[i]), + }; + }); + + 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'); + const timeField = sortedFields.find((f) => f.name === 'time'); + + return ( +
+ {traceIdField?.values?.map((traceId, index) => ( +
+ {index === 0 && ( +
+ Trace Name + {type === 'duration' ? 'Duration' : 'Since'} +
+ )} + +
{ + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.attribute_panel_item_clicked, { + type, + index, + value: type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index) + }); + const link = getLink(traceId, spanIdField, traceServiceField, index); + locationService.push(link); + }} + > +
{getLabel(traceServiceField, traceNameField, index)}
+ +
+ + {type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index)} + + +
+
+
+ ))} +
+ ); + } + } + return <>; +} + +function getStyles(theme: GrafanaTheme2) { + return { + container: css({ + padding: `${theme.spacing(2)} 0`, + }), + 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)}`, + }), + rowHeaderText: css({ + margin: '0 45px 0 0', + }), + row: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + 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)}`, + }), + actionIcon: css({ + cursor: 'pointer', + margin: `0 ${theme.spacing(0.5)} 0 ${theme.spacing(1)}`, + }), + }; +} diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index 37c38c4..d74407b 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -1,12 +1,10 @@ import { css } from '@emotion/css'; -import { DataFrame, dateTimeFormat, Field, GrafanaTheme2 } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { DataFrame, GrafanaTheme2 } from '@grafana/data'; import { SceneObjectState, SceneObjectBase, SceneComponentProps } from '@grafana/scenes'; import { Icon, useStyles2 } from '@grafana/ui'; import React from 'react'; -import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'utils/analytics'; -import { formatDuration } from 'utils/dates'; -import { EXPLORATIONS_ROUTE, MetricFunction } from 'utils/shared'; +import { MetricFunction } from 'utils/shared'; +import { AttributePanelRows } from './AttributePanelRows'; interface AttributePanelSceneState extends SceneObjectState { series?: DataFrame[]; @@ -19,137 +17,13 @@ export class AttributePanelScene extends SceneObjectBase { - if (series && series.length > 0) { - const sortByField = series[0].fields.find((f) => f.name === (type === 'duration' ? 'duration' : 'time')); - if (sortByField && sortByField.values) { - const sortedByDuration = sortByField?.values.map((_, i) => i)?.sort((a, b) => sortByField?.values[b] - sortByField?.values[a]); - const sortedFields = series[0].fields.map((f) => { - return { - ...f, - values: sortedByDuration?.map((i) => f.values[i]), - }; - }); - - 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'); - const timeField = sortedFields.find((f) => f.name === 'time'); - - const getLabel = (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 getErrorTimeAgo = (timeField: Field | undefined, index: number) => { - if (!timeField || !timeField.values) { - return 'Times not found'; - } - - const dateString = dateTimeFormat(timeField?.values[index]); - - const now = new Date(); - const date = new Date(dateString); - const diff = Math.floor((now.getTime() - date.getTime()) / 1000); // Difference in seconds - - if (diff < 60) { - return `${diff}s`; - } else if (diff < 3600) { - return `${Math.floor(diff / 60)}m`; - } else if (diff < 86400) { - return `${Math.floor(diff / 3600)}h`; - } else { - return `${Math.floor(diff / 86400)}d`; - } - } - - const getDuration = (durationField: Field | undefined, index: number) => { - if (!durationField || !durationField.values) { - return 'Durations not found'; - } - - return formatDuration(durationField.values[index] / 1000); - } - - const getLink = (traceId: string, spanIdField: Field | undefined, traceServiceField: Field | undefined, index: number) => { - let url = EXPLORATIONS_ROUTE + '?primarySignal=full_traces'; - - if (!spanIdField || !spanIdField.values || !traceServiceField || !traceServiceField.values) { - console.error('SpanId or traceService not found'); - return url; - } - - url = url + `&traceId=${traceId}&spanId=${spanIdField.values[index]}`; - url = url + `&var-filters=resource.service.name|=|${traceServiceField.values[index]}`; - url = type === 'duration' ? url + '&var-metric=duration' : url + '&var-metric=errors'; - - return url; - } - - return ( - <> - {traceIdField?.values?.map((traceId, index) => ( -
- {index === 0 && ( -
- Trace Name - {type === 'duration' ? 'Duration' : 'Since'} -
- )} - -
{ - reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.attribute_panel_item_clicked, { - type, - index, - value: type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index) - }); - const link = getLink(traceId, spanIdField, traceServiceField, index); - locationService.push(link); - }} - > -
{getLabel(index)}
- -
- - {type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index)} - - -
-
-
- ))} - - ); - } - } - return <>; - } - return (
{title}
-
- -
+
); }; @@ -175,46 +49,5 @@ function getStyles(theme: GrafanaTheme2) { titleText: css({ marginLeft: theme.spacing(1), }), - - tracesContainer: css({ - padding: `${theme.spacing(2)} 0`, - }), - tracesRowHeader: css({ - color: theme.colors.text.secondary, - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: `0 ${theme.spacing(2)} ${theme.spacing(1)} ${theme.spacing(2)}`, - }), - tracesRowHeaderText: css({ - margin: '0 45px 0 0', - }), - - tracesRow: css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - padding: `${theme.spacing(0.75)} ${theme.spacing(2)}`, - - '&:hover': { - backgroundColor: theme.isDark ? theme.colors.background.secondary : theme.colors.background.primary, - cursor: 'pointer', - '.tracesRowLabel': { - textDecoration: 'underline', - } - }, - }), - action: css({ - display: 'flex', - alignItems: 'center', - }), - actionText: css({ - color: '#d5983c', - padding: `0 ${theme.spacing(1)}`, - }), - actionIcon: css({ - cursor: 'pointer', - margin: `0 ${theme.spacing(0.5)} 0 ${theme.spacing(1)}`, - }), }; } diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 2c710fb..c4243be 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -76,7 +76,7 @@ function buildPanels() { rowGap: 2, children: [ new SceneCSSGridItem({ - body: new AttributePanel({ query: '{nestedSetParent<0 && status=error}', title: 'Errored services', type: 'errors' }), + body: new AttributePanel({ query: '{nestedSetParent<0 && status=error} | by (resource.service.name)', title: 'Errored services', type: 'errors' }), }), new SceneCSSGridItem({ body: new DurationAttributePanel({}), From 9bfef1bec7c1461a850878e10939046a1547d1e7 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 18 Dec 2024 09:10:12 +0000 Subject: [PATCH 13/32] Loading, error, empty states --- src/components/Home/AttributePanel.tsx | 35 ++++++++++++------- src/components/Home/AttributePanelRows.tsx | 27 ++++++++++++-- src/components/Home/AttributePanelScene.tsx | 5 +-- .../Home/DurationAttributePanel.tsx | 20 +++++------ src/components/Home/HeaderScene.tsx | 9 ----- src/utils/shared.ts | 1 - src/utils/utils.ts | 8 +++++ 7 files changed, 68 insertions(+), 37 deletions(-) diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index dd7ca75..50cc208 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { SceneComponentProps, - SceneFlexItem, SceneFlexLayout, sceneGraph, SceneObjectBase, @@ -11,13 +10,13 @@ import { } from '@grafana/scenes'; import { GrafanaTheme2, LoadingState } from '@grafana/data'; import { explorationDS, MetricFunction } from 'utils/shared'; -import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; 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'; export interface AttributePanelState extends SceneObjectState { panel?: SceneFlexLayout; @@ -47,10 +46,10 @@ export class AttributePanel extends SceneObjectBase { this.setState({ panel: new SceneFlexLayout({ children: [ - new SceneFlexItem({ - body: new EmptyStateScene({ - imgWidth: 110, - }), + new AttributePanelScene({ + message: getNoDataMessage(state.title.toLowerCase()), + title: state.title, + type: state.type, }), ], }), @@ -59,18 +58,28 @@ export class AttributePanel extends SceneObjectBase { this.setState({ panel: new SceneFlexLayout({ children: [ - new SceneFlexItem({ - body: new AttributePanelScene({ - series: data.data.series, - title: state.title, - type: state.type - }), + new AttributePanelScene({ + series: data.data.series, + title: state.title, + type: state.type }), ], }) }); } - } else if (data.data?.state === LoadingState.Loading) { + } 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', diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index dffcbba..aca8814 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -8,12 +8,13 @@ import { formatDuration } from "utils/dates"; import { EXPLORATIONS_ROUTE, MetricFunction } from "utils/shared"; type Props = { - series: DataFrame[] | undefined; + series?: DataFrame[]; type: MetricFunction; + message?: string; } export const AttributePanelRows = (props: Props) => { - const { series, type } = props; + const { series, type, message } = props; const styles = useStyles2(getStyles); const getLabel = (traceServiceField: Field | undefined, traceNameField: Field | undefined, index: number) => { @@ -72,6 +73,21 @@ export const AttributePanelRows = (props: Props) => { return url; } + if (message) { + return ( +
+
+ + {message} +
+
+ ); + } + if (series && series.length > 0) { const sortByField = series[0].fields.find((f) => f.name === (type === 'duration' ? 'duration' : 'time')); if (sortByField && sortByField.values) { @@ -178,5 +194,12 @@ function getStyles(theme: GrafanaTheme2) { cursor: 'pointer', 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 index d74407b..dffb22e 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -10,11 +10,12 @@ 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 } = model.useState(); + const { series, title, type, message } = model.useState(); const styles = useStyles2(getStyles); return ( @@ -23,7 +24,7 @@ export class AttributePanelScene extends SceneObjectBase {title}
- +
); }; diff --git a/src/components/Home/DurationAttributePanel.tsx b/src/components/Home/DurationAttributePanel.tsx index 70abd7b..7241be8 100644 --- a/src/components/Home/DurationAttributePanel.tsx +++ b/src/components/Home/DurationAttributePanel.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { SceneComponentProps, - SceneFlexItem, SceneFlexLayout, sceneGraph, SceneObjectBase, @@ -11,11 +10,12 @@ import { } from '@grafana/scenes'; import { LoadingState } from '@grafana/data'; import { explorationDS } from 'utils/shared'; -import { EmptyStateScene } from 'components/states/EmptyState/EmptyStateScene'; import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesByServiceScene'; import { yBucketToDuration } from 'components/Explore/panels/histogram'; import { AttributePanel, SkeletonComponent } from './AttributePanel'; +import { getNoDataMessage } from 'utils/utils'; +import { AttributePanelScene } from './AttributePanelScene'; export interface DurationAttributePanelState extends SceneObjectState { panel?: SceneFlexLayout; @@ -33,6 +33,8 @@ export class DurationAttributePanel extends SceneObjectBase { const data = sceneGraph.getData(this); + const type = 'duration'; + const title = 'Slow services'; this._subs.add( data.subscribeToState((data) => { @@ -41,10 +43,10 @@ export class DurationAttributePanel extends SceneObjectBase ${minDuration}} | by (resource.service.name)`, title: 'Slow services', type: 'duration' }) - }), + new AttributePanel({ query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}} | by (resource.service.name)`, title, type }), ], }) }); } } - } else if (data.data?.state === LoadingState.Loading) { + } else if (data.data?.state === LoadingState.Loading || data.data?.state === LoadingState.Streaming) { this.setState({ panel: new SceneFlexLayout({ direction: 'column', diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index cfd9e93..58287cb 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -87,14 +87,6 @@ export class HeaderScene extends SceneObjectBase { function getStyles(theme: GrafanaTheme2) { return { - preview: css({ - cursor: 'help', - - '> div:first-child': { - padding: '5.5px', - }, - }), - container: css({ display: 'flex', gap: theme.spacing(7), @@ -102,7 +94,6 @@ function getStyles(theme: GrafanaTheme2) { margin: `0 0 ${theme.spacing(4)} 0`, justifyContent: 'center', }), - header: css({ display: 'flex', alignItems: 'center', diff --git a/src/utils/shared.ts b/src/utils/shared.ts index 6e241be..c39be2d 100644 --- a/src/utils/shared.ts +++ b/src/utils/shared.ts @@ -11,7 +11,6 @@ export enum ROUTES { 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 HOME_ROUTE = `${PLUGIN_BASE_URL}/${ROUTES.Home}`; export const DATASOURCE_LS_KEY = 'grafana.explore.traces.datasource'; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 1e3489b..21bc122 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -60,6 +60,14 @@ export function newHome(initialDS?: string): Home { }); } +export function getErrorMessage(data: SceneDataState) { + return data?.data?.error?.message ?? 'Data source error'; +} + +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); From 5ef2afd0e6b0b656ec4179551d9c19312a8d3993 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 18 Dec 2024 09:52:33 +0000 Subject: [PATCH 14/32] Update url link --- src/components/Home/AttributePanelRows.tsx | 24 +++++++++++----------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index aca8814..b21891b 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -5,7 +5,7 @@ import { Icon, useStyles2 } from "@grafana/ui"; import React from "react"; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from "utils/analytics"; import { formatDuration } from "utils/dates"; -import { EXPLORATIONS_ROUTE, MetricFunction } from "utils/shared"; +import { EXPLORATIONS_ROUTE, MetricFunction, ROUTES } from "utils/shared"; type Props = { series?: DataFrame[]; @@ -58,19 +58,19 @@ export const AttributePanelRows = (props: Props) => { return formatDuration(durationField.values[index] / 1000); } - const getLink = (traceId: string, spanIdField: Field | undefined, traceServiceField: Field | undefined, index: number) => { - let url = EXPLORATIONS_ROUTE + '?primarySignal=full_traces'; - - if (!spanIdField || !spanIdField.values || !traceServiceField || !traceServiceField.values) { + 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 url; + return ROUTES.Explore; } - url = url + `&traceId=${traceId}&spanId=${spanIdField.values[index]}`; - url = url + `&var-filters=resource.service.name|=|${traceServiceField.values[index]}`; - url = type === 'duration' ? url + '&var-metric=duration' : url + '&var-metric=errors'; + const params = new URLSearchParams(); + params.set('traceId', traceId); + params.set('spanId', spanIdField.values[index]); + params.set('var-filters', `resource.service.name|=|${traceServiceField.values[index]}`); + params.set('var-metric', type); - return url; + return `${EXPLORATIONS_ROUTE}?${params.toString()}&var-filters=nestedSetParent|<|0`; } if (message) { @@ -126,8 +126,8 @@ export const AttributePanelRows = (props: Props) => { index, value: type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index) }); - const link = getLink(traceId, spanIdField, traceServiceField, index); - locationService.push(link); + const url = getUrl(traceId, spanIdField, traceServiceField, index); + locationService.push(url); }} >
{getLabel(traceServiceField, traceNameField, index)}
From 0e9f488fabb2ab93efc28e65ec1db8f4d338c7ab Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 18 Dec 2024 10:39:32 +0000 Subject: [PATCH 15/32] Improve styling --- src/components/Home/AttributePanelRows.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index b21891b..b8245c0 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -172,6 +172,7 @@ function getStyles(theme: GrafanaTheme2) { display: 'flex', justifyContent: 'space-between', alignItems: 'center', + gap: theme.spacing(2), padding: `${theme.spacing(0.75)} ${theme.spacing(2)}`, '&:hover': { @@ -189,6 +190,7 @@ function getStyles(theme: GrafanaTheme2) { actionText: css({ color: '#d5983c', padding: `0 ${theme.spacing(1)}`, + width: 'max-content', }), actionIcon: css({ cursor: 'pointer', From ff6e905d49ce979ee39e084eb5c88d07000512fc Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 18 Dec 2024 10:47:41 +0000 Subject: [PATCH 16/32] Improve skeleton styling --- src/components/Home/AttributePanel.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index 50cc208..a7cc700 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -132,7 +132,7 @@ export const SkeletonComponent = () => {
- {[...Array(5)].map((_, i) => ( + {[...Array(11)].map((_, i) => (
@@ -163,14 +163,14 @@ function getSkeletonStyles(theme: GrafanaTheme2) { textAlign: 'center', }), tracesContainer: css({ - padding: `${theme.spacing(2)}`, + padding: `13px ${theme.spacing(2)}`, }), row: css({ display: 'flex', justifyContent: 'space-between', }), rowLeft: css({ - margin: '6px 0', + margin: '7px 0', width: '150px', }), rowRight: css({ From 15b171abd05c290499857c8a58670d79745eb966 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Thu, 19 Dec 2024 09:53:28 +0000 Subject: [PATCH 17/32] Fix cspell --- src/components/Home/HeaderScene.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index 58287cb..4689ccd 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -44,7 +44,7 @@ export class HeaderScene extends SceneObjectBase { navigate(EXPLORATIONS_ROUTE); }}> Let’s explore traces - + Date: Thu, 2 Jan 2025 18:54:42 +0000 Subject: [PATCH 18/32] Utils and styling --- src/components/Home/AttributePanelScene.tsx | 4 +++- src/components/Home/HeaderScene.tsx | 6 ++---- src/pages/Home/Home.tsx | 7 +++---- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index dffb22e..594275d 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -43,9 +43,11 @@ function getStyles(theme: GrafanaTheme2) { 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)}`, - textAlign: 'center', }), titleText: css({ marginLeft: theme.spacing(1), diff --git a/src/components/Home/HeaderScene.tsx b/src/components/Home/HeaderScene.tsx index 4689ccd..f1f7ac1 100644 --- a/src/components/Home/HeaderScene.tsx +++ b/src/components/Home/HeaderScene.tsx @@ -4,16 +4,14 @@ import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { SceneComponentProps, - sceneGraph, SceneObjectBase, } from '@grafana/scenes'; import { Button, Icon, LinkButton, Stack, useStyles2, useTheme2 } from '@grafana/ui'; import { EXPLORATIONS_ROUTE, - VAR_DATASOURCE, } from '../../utils/shared'; -import { getHomeScene } from '../../utils/utils'; +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'; @@ -27,7 +25,7 @@ export class HeaderScene extends SceneObjectBase { const styles = useStyles2(getStyles); const theme = useTheme2(); - const dsVariable = sceneGraph.lookupVariable(VAR_DATASOURCE, home); + const dsVariable = getDatasourceVariable(home); return (
diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index c4243be..3584f7e 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -7,7 +7,6 @@ import { SceneComponentProps, SceneCSSGridItem, SceneCSSGridLayout, - sceneGraph, SceneObject, SceneObjectBase, SceneObjectState, @@ -25,6 +24,7 @@ import { import { AttributePanel } from 'components/Home/AttributePanel'; import { HeaderScene } from 'components/Home/HeaderScene'; import { DurationAttributePanel } from 'components/Home/DurationAttributePanel'; +import { getDatasourceVariable } from 'utils/utils'; export interface HomeState extends SceneObjectState { controls: SceneObject[]; @@ -46,8 +46,7 @@ export class Home extends SceneObjectBase { } private _onActivate() { - const datasourceVar = sceneGraph.lookupVariable(VAR_DATASOURCE, this) as DataSourceVariable; - datasourceVar.subscribeToState((newState) => { + getDatasourceVariable(this).subscribeToState((newState) => { if (newState.value) { localStorage.setItem(DATASOURCE_LS_KEY, newState.value.toString()); } @@ -76,7 +75,7 @@ function buildPanels() { rowGap: 2, children: [ new SceneCSSGridItem({ - body: new AttributePanel({ query: '{nestedSetParent<0 && status=error} | by (resource.service.name)', title: 'Errored services', type: 'errors' }), + body: new AttributePanel({ query: '{nestedSetParent<0 && status=error}', title: 'Errored services', type: 'errors' }), }), new SceneCSSGridItem({ body: new DurationAttributePanel({}), From 9d81be62b51203c7f3e77fa46cdee4930d06285d Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Sun, 5 Jan 2025 17:03:04 +0000 Subject: [PATCH 19/32] Update url --- src/components/Home/AttributePanelRows.tsx | 16 +++++++++------- src/components/Home/DurationAttributePanel.tsx | 2 +- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index b8245c0..b9246c8 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -1,5 +1,5 @@ import { css } from "@emotion/css"; -import { DataFrame, dateTimeFormat, Field, GrafanaTheme2 } from "@grafana/data"; +import { DataFrame, dateTimeFormat, Field, GrafanaTheme2, urlUtil } from "@grafana/data"; import { locationService } from "@grafana/runtime"; import { Icon, useStyles2 } from "@grafana/ui"; import React from "react"; @@ -64,13 +64,15 @@ export const AttributePanelRows = (props: Props) => { return ROUTES.Explore; } - const params = new URLSearchParams(); - params.set('traceId', traceId); - params.set('spanId', spanIdField.values[index]); - params.set('var-filters', `resource.service.name|=|${traceServiceField.values[index]}`); - params.set('var-metric', type); + 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 `${EXPLORATIONS_ROUTE}?${params.toString()}&var-filters=nestedSetParent|<|0`; + return `${url}&var-filters=nestedSetParent|<|0`; } if (message) { diff --git a/src/components/Home/DurationAttributePanel.tsx b/src/components/Home/DurationAttributePanel.tsx index 7241be8..1c9e965 100644 --- a/src/components/Home/DurationAttributePanel.tsx +++ b/src/components/Home/DurationAttributePanel.tsx @@ -26,7 +26,7 @@ export class DurationAttributePanel extends SceneObjectBase Date: Sun, 5 Jan 2025 17:15:58 +0000 Subject: [PATCH 20/32] Update error messages and improvements --- src/components/Home/AttributePanelRows.tsx | 2 +- .../Home/DurationAttributePanel.tsx | 19 ++++++++++++++++--- src/utils/utils.ts | 2 +- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index b9246c8..f8e3d69 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -114,7 +114,7 @@ export const AttributePanelRows = (props: Props) => {
{index === 0 && (
- Trace Name + Service {type === 'duration' ? 'Duration' : 'Since'}
)} diff --git a/src/components/Home/DurationAttributePanel.tsx b/src/components/Home/DurationAttributePanel.tsx index 1c9e965..2f6074c 100644 --- a/src/components/Home/DurationAttributePanel.tsx +++ b/src/components/Home/DurationAttributePanel.tsx @@ -14,7 +14,7 @@ import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateSc import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesByServiceScene'; import { yBucketToDuration } from 'components/Explore/panels/histogram'; import { AttributePanel, SkeletonComponent } from './AttributePanel'; -import { getNoDataMessage } from 'utils/utils'; +import { getErrorMessage, getNoDataMessage } from 'utils/utils'; import { AttributePanelScene } from './AttributePanelScene'; export interface DurationAttributePanelState extends SceneObjectState { @@ -31,10 +31,11 @@ export class DurationAttributePanel extends SceneObjectBase { const data = sceneGraph.getData(this); - const type = 'duration'; - const title = 'Slow services'; this._subs.add( data.subscribeToState((data) => { @@ -84,6 +85,18 @@ export class DurationAttributePanel extends SceneObjectBase Date: Wed, 8 Jan 2025 08:56:56 +0000 Subject: [PATCH 21/32] Reuse AttributePanel for duration --- src/components/Home/AttributePanel.tsx | 59 +++++++++++++++----- src/pages/Home/Home.tsx | 76 ++++++++++++++++++-------- 2 files changed, 99 insertions(+), 36 deletions(-) diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index a7cc700..ba3078d 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -17,12 +17,17 @@ import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesBySe 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: string; + query: { + query: string; + step?: string; + }; title: string; type: MetricFunction; + renderDurationPanel?: boolean; } export class AttributePanel extends SceneObjectBase { @@ -30,7 +35,7 @@ export class AttributePanel extends SceneObjectBase { super({ $data: new SceneQueryRunner({ datasource: explorationDS, - queries: [{ refId: 'A', query: state.query, queryType: 'traceql', tableType: 'spans', limit: 10, spss: 1 }], + queries: [{ refId: 'A', queryType: 'traceql', tableType: 'spans', limit: 10, ...state.query }], }), ...state, }); @@ -55,17 +60,45 @@ export class AttributePanel extends SceneObjectBase { }), }); } else if (data.data.series.length > 0) { - this.setState({ - panel: new SceneFlexLayout({ - children: [ - new AttributePanelScene({ - series: data.data.series, - title: state.title, - type: state.type - }), - ], - }) - }); + 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); + + this.setState({ + panel: new SceneFlexLayout({ + children: [ + new AttributePanel({ + query: { + query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}} | by (resource.service.name)`, + }, + title: state.title, + type: state.type, + renderDurationPanel: true, + }), + ], + }) + }); + } + } } } else if (data.data?.state === LoadingState.Error) { this.setState({ diff --git a/src/pages/Home/Home.tsx b/src/pages/Home/Home.tsx index 3584f7e..0c88d7f 100644 --- a/src/pages/Home/Home.tsx +++ b/src/pages/Home/Home.tsx @@ -1,5 +1,7 @@ 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 { @@ -7,12 +9,14 @@ import { SceneComponentProps, SceneCSSGridItem, SceneCSSGridLayout, + sceneGraph, SceneObject, SceneObjectBase, SceneObjectState, SceneRefreshPicker, SceneTimePicker, SceneTimeRange, + SceneTimeRangeLike, SceneVariableSet, } from '@grafana/scenes'; import { useStyles2 } from '@grafana/ui'; @@ -23,13 +27,12 @@ import { } from '../../utils/shared'; import { AttributePanel } from 'components/Home/AttributePanel'; import { HeaderScene } from 'components/Home/HeaderScene'; -import { DurationAttributePanel } from 'components/Home/DurationAttributePanel'; import { getDatasourceVariable } from 'utils/utils'; export interface HomeState extends SceneObjectState { controls: SceneObject[]; initialDS?: string; - body: SceneCSSGridLayout; + body?: SceneCSSGridLayout; } export class Home extends SceneObjectBase { @@ -38,7 +41,6 @@ export class Home extends SceneObjectBase { $timeRange: state.$timeRange ?? new SceneTimeRange({}), $variables: state.$variables ?? getVariableSet(state.initialDS), controls: state.controls ?? [new SceneTimePicker({}), new SceneRefreshPicker({})], - body: buildPanels(), ...state, }); @@ -51,6 +53,54 @@ export class Home extends SceneObjectBase { 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) => { @@ -66,26 +116,6 @@ export class Home extends SceneObjectBase { }; } -function buildPanels() { - return new SceneCSSGridLayout({ - children: [ - new SceneCSSGridLayout({ - autoRows: 'min-content', - columnGap: 2, - rowGap: 2, - children: [ - new SceneCSSGridItem({ - body: new AttributePanel({ query: '{nestedSetParent<0 && status=error}', title: 'Errored services', type: 'errors' }), - }), - new SceneCSSGridItem({ - body: new DurationAttributePanel({}), - }), - ], - }), - ], - }) -} - function getVariableSet(initialDS?: string) { return new SceneVariableSet({ variables: [ From cd7704884c053fac81772e84d5e388778e36a21e Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 8 Jan 2025 08:57:44 +0000 Subject: [PATCH 22/32] Update icon --- src/components/Home/AttributePanelScene.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Home/AttributePanelScene.tsx b/src/components/Home/AttributePanelScene.tsx index 594275d..037f4ce 100644 --- a/src/components/Home/AttributePanelScene.tsx +++ b/src/components/Home/AttributePanelScene.tsx @@ -21,7 +21,7 @@ export class AttributePanelScene extends SceneObjectBase
- + {title}
From e3fe49819ba04c91bb276b40e5f6a6ef0c3b6147 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 8 Jan 2025 08:58:13 +0000 Subject: [PATCH 23/32] Remove DurationAttributePanel file --- .../Home/DurationAttributePanel.tsx | 117 ------------------ 1 file changed, 117 deletions(-) delete mode 100644 src/components/Home/DurationAttributePanel.tsx diff --git a/src/components/Home/DurationAttributePanel.tsx b/src/components/Home/DurationAttributePanel.tsx deleted file mode 100644 index 2f6074c..0000000 --- a/src/components/Home/DurationAttributePanel.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; - -import { - SceneComponentProps, - SceneFlexLayout, - sceneGraph, - SceneObjectBase, - SceneObjectState, - SceneQueryRunner, -} from '@grafana/scenes'; -import { LoadingState } from '@grafana/data'; -import { explorationDS } from 'utils/shared'; -import { LoadingStateScene } from 'components/states/LoadingState/LoadingStateScene'; -import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesByServiceScene'; -import { yBucketToDuration } from 'components/Explore/panels/histogram'; -import { AttributePanel, SkeletonComponent } from './AttributePanel'; -import { getErrorMessage, getNoDataMessage } from 'utils/utils'; -import { AttributePanelScene } from './AttributePanelScene'; - -export interface DurationAttributePanelState extends SceneObjectState { - panel?: SceneFlexLayout; -} - -export class DurationAttributePanel extends SceneObjectBase { - constructor(state: DurationAttributePanelState) { - super({ - $data: new SceneQueryRunner({ - datasource: explorationDS, - queries: [{ refId: 'A', query: '{nestedSetParent<0} | histogram_over_time(duration)', queryType: 'traceql' }], - }), - ...state, - }); - - const type = 'duration'; - const title = 'Slow services'; - - 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(title.toLowerCase()), - title, - type, - }), - ], - }), - }); - } else if (data.data.series.length > 0) { - 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); - - this.setState({ - panel: new SceneFlexLayout({ - children: [ - new AttributePanel({ query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}} | by (resource.service.name)`, title, 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(), - }), - ], - }), - }); - } else if (data.data?.state === LoadingState.Error) { - this.setState({ - panel: new SceneFlexLayout({ - children: [ - new AttributePanelScene({ - message: getErrorMessage(data), - title, - type - }), - ], - }) - }); - } - }) - ); - }); - } - - public static Component = ({ model }: SceneComponentProps) => { - const { panel } = model.useState(); - - if (!panel) { - return; - } - - return ( - - ); - }; -} From 42dc261802ce7cc476638444ec2146d344ffefae Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 8 Jan 2025 12:05:11 +0000 Subject: [PATCH 24/32] Add AttributePanelRow --- src/components/Home/AttributePanelRow.tsx | 102 ++++++++++++ src/components/Home/AttributePanelRows.tsx | 176 +++++++++------------ 2 files changed, 173 insertions(+), 105 deletions(-) create mode 100644 src/components/Home/AttributePanelRow.tsx diff --git a/src/components/Home/AttributePanelRow.tsx b/src/components/Home/AttributePanelRow.tsx new file mode 100644 index 0000000..181086b --- /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; + text: string; + textTitle: string; + url: string; +} + +export const AttributePanelRow = (props: Props) => { + const { index, type, label, labelTitle, text, textTitle, url } = props; + const styles = useStyles2(getStyles); + + return ( +
+ {index === 0 && ( +
+ {labelTitle} + {textTitle} +
+ )} + +
{ + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.attribute_panel_item_clicked, { + type, + index, + value: text + }); + locationService.push(url); + }} + > +
{label}
+ +
+ + {text} + + +
+
+
+ ); +} + +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)}`, + }), + rowHeaderText: 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.tsx b/src/components/Home/AttributePanelRows.tsx index f8e3d69..5642c88 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -1,11 +1,10 @@ import { css } from "@emotion/css"; -import { DataFrame, dateTimeFormat, Field, GrafanaTheme2, urlUtil } from "@grafana/data"; -import { locationService } from "@grafana/runtime"; +import { DataFrame, Field, GrafanaTheme2, urlUtil } from "@grafana/data"; import { Icon, useStyles2 } from "@grafana/ui"; import React from "react"; -import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from "utils/analytics"; import { formatDuration } from "utils/dates"; import { EXPLORATIONS_ROUTE, MetricFunction, ROUTES } from "utils/shared"; +import { AttributePanelRow } from "./AttributePanelRow"; type Props = { series?: DataFrame[]; @@ -17,7 +16,13 @@ export const AttributePanelRows = (props: Props) => { const { series, type, message } = props; const styles = useStyles2(getStyles); - const getLabel = (traceServiceField: Field | undefined, traceNameField: Field | undefined, index: number) => { + const getLabel = (df: DataFrame) => { + const valuesField = df.fields.find((f) => f.name !== 'time'); + const labels = valuesField?.labels; + return labels?.['resource.service.name'].slice(1, -1) ?? 'Service name not found'; // remove quotes + } + + const getLabelForDuration = (traceServiceField: Field | undefined, traceNameField: Field | undefined, index: number) => { let label = ''; if (traceServiceField?.values[index]) { label = traceServiceField.values[index]; @@ -28,37 +33,24 @@ export const AttributePanelRows = (props: Props) => { return label.length === 0 ? 'Trace service & name not found' : label; } - const getErrorTimeAgo = (timeField: Field | undefined, index: number) => { - if (!timeField || !timeField.values) { - return 'Times not found'; - } - - const dateString = dateTimeFormat(timeField?.values[index]); - - const now = new Date(); - const date = new Date(dateString); - const diff = Math.floor((now.getTime() - date.getTime()) / 1000); // Difference in seconds - - if (diff < 60) { - return `${diff}s`; - } else if (diff < 3600) { - return `${Math.floor(diff / 60)}m`; - } else if (diff < 86400) { - return `${Math.floor(diff / 3600)}h`; - } else { - return `${Math.floor(diff / 86400)}d`; - } - } - - const getDuration = (durationField: Field | undefined, index: number) => { - if (!durationField || !durationField.values) { - return 'Durations not found'; + const getUrl = (df: DataFrame) => { + const valuesField = df.fields.find((f) => f.name !== 'time'); + const labels = valuesField?.labels; + const serviceName = labels?.['resource.service.name'].slice(1, -1) ?? 'Service name not found'; // remove quotes + + if (serviceName) { + const params = { + 'var-filters': `resource.service.name|=|${serviceName}`, + 'var-metric': type, + } + const url = urlUtil.renderUrl(EXPLORATIONS_ROUTE, params); + + return `${url}&var-filters=nestedSetParent|<|0`; } - - return formatDuration(durationField.values[index] / 1000); + return ''; } - const getUrl = (traceId: string, spanIdField: Field | undefined, traceServiceField: Field | undefined, index: number) => { + const getUrlForDuration = (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; @@ -75,6 +67,19 @@ export const AttributePanelRows = (props: Props) => { 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) => x + acc) ?? 1; + } + + const getDuration = (durationField: Field | undefined, index: number) => { + if (!durationField || !durationField.values) { + return 'Durations not found'; + } + + return formatDuration(durationField.values[index] / 1000); + } + if (message) { return (
@@ -91,7 +96,29 @@ export const AttributePanelRows = (props: Props) => { } if (series && series.length > 0) { - const sortByField = series[0].fields.find((f) => f.name === (type === 'duration' ? 'duration' : 'time')); + if (type === 'errors') { + return ( +
+ {series + .sort((a, b) => getTotalErrs(b) - getTotalErrs(a)) + .slice(0, 10)?.map((df, index) => ( + + + + ))} +
+ ); + } + + const sortByField = series[0].fields.find((f) => f.name === 'duration'); if (sortByField && sortByField.values) { const sortedByDuration = sortByField?.values.map((_, i) => i)?.sort((a, b) => sortByField?.values[b] - sortByField?.values[a]); const sortedFields = series[0].fields.map((f) => { @@ -106,47 +133,21 @@ export const AttributePanelRows = (props: Props) => { const traceNameField = sortedFields.find((f) => f.name === 'traceName'); const traceServiceField = sortedFields.find((f) => f.name === 'traceService'); const durationField = sortedFields.find((f) => f.name === 'duration'); - const timeField = sortedFields.find((f) => f.name === 'time'); return (
{traceIdField?.values?.map((traceId, index) => ( -
- {index === 0 && ( -
- Service - {type === 'duration' ? 'Duration' : 'Since'} -
- )} - -
{ - reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.attribute_panel_item_clicked, { - type, - index, - value: type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index) - }); - const url = getUrl(traceId, spanIdField, traceServiceField, index); - locationService.push(url); - }} - > -
{getLabel(traceServiceField, traceNameField, index)}
- -
- - {type === 'duration' ? getDuration(durationField, index) : getErrorTimeAgo(timeField, index)} - - -
-
-
+ + + ))}
); @@ -160,45 +161,10 @@ function getStyles(theme: GrafanaTheme2) { container: css({ padding: `${theme.spacing(2)} 0`, }), - 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)}`, - }), - rowHeaderText: 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)}`, }), - message: css({ display: 'flex', gap: theme.spacing(1.5), From fe0c848fed1ad3b2837be814e61ad5a14101f9ef Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 8 Jan 2025 12:08:36 +0000 Subject: [PATCH 25/32] Update icon hover --- src/components/Home/AttributePanelRows.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index 5642c88..5654052 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -85,7 +85,7 @@ export const AttributePanelRows = (props: Props) => {
@@ -161,8 +161,7 @@ function getStyles(theme: GrafanaTheme2) { container: css({ padding: `${theme.spacing(2)} 0`, }), - actionIcon: css({ - cursor: 'pointer', + icon: css({ margin: `0 ${theme.spacing(0.5)} 0 ${theme.spacing(1)}`, }), message: css({ From 2be0320bf7e9336e7997c5038d4bd3740fe9e5e0 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Wed, 8 Jan 2025 14:22:28 +0000 Subject: [PATCH 26/32] Tests and improvements --- .../Home/AttributePanelRow.test.tsx | 61 ++++++++ src/components/Home/AttributePanelRow.tsx | 16 +- .../Home/AttributePanelRows.test.tsx | 69 +++++++++ src/components/Home/AttributePanelRows.tsx | 140 +++++++++--------- src/utils/analytics.ts | 2 +- 5 files changed, 205 insertions(+), 83 deletions(-) create mode 100644 src/components/Home/AttributePanelRow.test.tsx create mode 100644 src/components/Home/AttributePanelRows.test.tsx 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 index 181086b..ade5b06 100644 --- a/src/components/Home/AttributePanelRow.tsx +++ b/src/components/Home/AttributePanelRow.tsx @@ -11,13 +11,13 @@ type Props = { type: MetricFunction; label: string; labelTitle: string; - text: string; - textTitle: string; + value: string; + valueTitle: string; url: string; } export const AttributePanelRow = (props: Props) => { - const { index, type, label, labelTitle, text, textTitle, url } = props; + const { index, type, label, labelTitle, value, valueTitle, url } = props; const styles = useStyles2(getStyles); return ( @@ -25,7 +25,7 @@ export const AttributePanelRow = (props: Props) => { {index === 0 && (
{labelTitle} - {textTitle} + {valueTitle}
)} @@ -33,10 +33,10 @@ export const AttributePanelRow = (props: Props) => { className={styles.row} key={index} onClick={() => { - reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.attribute_panel_item_clicked, { + reportAppInteraction(USER_EVENTS_PAGES.home, USER_EVENTS_ACTIONS.home.panel_row_clicked, { type, index, - value: text + value }); locationService.push(url); }} @@ -45,7 +45,7 @@ export const AttributePanelRow = (props: Props) => {
- {text} + {value} { + 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 index 5654052..9bb5d91 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -16,70 +16,6 @@ export const AttributePanelRows = (props: Props) => { const { series, type, message } = props; const styles = useStyles2(getStyles); - const getLabel = (df: DataFrame) => { - const valuesField = df.fields.find((f) => f.name !== 'time'); - const labels = valuesField?.labels; - return labels?.['resource.service.name'].slice(1, -1) ?? 'Service name not found'; // remove quotes - } - - const getLabelForDuration = (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 = (df: DataFrame) => { - const valuesField = df.fields.find((f) => f.name !== 'time'); - const labels = valuesField?.labels; - const serviceName = labels?.['resource.service.name'].slice(1, -1) ?? 'Service name not found'; // remove quotes - - if (serviceName) { - const params = { - 'var-filters': `resource.service.name|=|${serviceName}`, - 'var-metric': type, - } - const url = urlUtil.renderUrl(EXPLORATIONS_ROUTE, params); - - return `${url}&var-filters=nestedSetParent|<|0`; - } - return ''; - } - - const getUrlForDuration = (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 getTotalErrs = (df: DataFrame) => { - const valuesField = df.fields.find((f) => f.name !== 'time'); - return valuesField?.values?.reduce((x, acc) => x + acc) ?? 1; - } - - const getDuration = (durationField: Field | undefined, index: number) => { - if (!durationField || !durationField.values) { - return 'Durations not found'; - } - - return formatDuration(durationField.values[index] / 1000); - } - if (message) { return (
@@ -97,6 +33,26 @@ export const AttributePanelRows = (props: Props) => { 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'].slice(1, -1) /* remove quotes */ ?? '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) => x + acc) ?? 1; + } + return (
{series @@ -108,8 +64,8 @@ export const AttributePanelRows = (props: Props) => { index={index} label={getLabel(df)} labelTitle='Service' - text={getTotalErrs(df)} - textTitle='Total errors' + value={getTotalErrs(df)} + valueTitle='Total errors' url={getUrl(df)} /> @@ -118,9 +74,9 @@ export const AttributePanelRows = (props: Props) => { ); } - const sortByField = series[0].fields.find((f) => f.name === 'duration'); - if (sortByField && sortByField.values) { - const sortedByDuration = sortByField?.values.map((_, i) => i)?.sort((a, b) => sortByField?.values[b] - sortByField?.values[a]); + 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, @@ -128,6 +84,42 @@ export const AttributePanelRows = (props: Props) => { }; }); + 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'); @@ -141,11 +133,11 @@ export const AttributePanelRows = (props: Props) => { ))} @@ -153,7 +145,7 @@ export const AttributePanelRows = (props: Props) => { ); } } - return <>; + return
No series data
; } function getStyles(theme: GrafanaTheme2) { diff --git a/src/utils/analytics.ts b/src/utils/analytics.ts index 730064a..9cc6077 100644 --- a/src/utils/analytics.ts +++ b/src/utils/analytics.ts @@ -41,7 +41,7 @@ export const USER_EVENTS_ACTIONS = { }, [USER_EVENTS_PAGES.home]: { homepage_initialized: 'homepage_initialized', - attribute_panel_item_clicked: 'attribute_panel_item_clicked', + panel_row_clicked: 'panel_row_clicked', explore_traces_clicked: 'explore_traces_clicked', read_documentation_clicked: 'read_documentation_clicked', }, From c030b6ffa94ac70a5dc093bc504f47b88dd38f53 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Thu, 9 Jan 2025 10:04:07 +0000 Subject: [PATCH 27/32] Update slice to replace --- src/components/Home/AttributePanelRows.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index 9bb5d91..609e836 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -35,7 +35,7 @@ export const AttributePanelRows = (props: Props) => { if (type === 'errors') { const getLabel = (df: DataFrame) => { const valuesField = df.fields.find((f) => f.name !== 'time'); - return valuesField?.labels?.['resource.service.name'].slice(1, -1) /* remove quotes */ ?? 'Service name not found'; + return valuesField?.labels?.['resource.service.name'].replace(/"/g, '') ?? 'Service name not found'; } const getUrl = (df: DataFrame) => { From 67a90705774e974c140054655fa0f02232a55d0f Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Thu, 9 Jan 2025 10:35:12 +0000 Subject: [PATCH 28/32] Reuse comparison logic --- .../Explore/TracesByService/REDPanel.tsx | 29 +++++++++++++------ src/components/Home/AttributePanel.tsx | 14 +++------ 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/components/Explore/TracesByService/REDPanel.tsx b/src/components/Explore/TracesByService/REDPanel.tsx index 50ddde2..d92e4b3 100644 --- a/src/components/Explore/TracesByService/REDPanel.tsx +++ b/src/components/Explore/TracesByService/REDPanel.tsx @@ -9,7 +9,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'; @@ -73,7 +73,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); @@ -89,14 +89,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( @@ -294,6 +288,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 index ba3078d..8007f7b 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -17,7 +17,7 @@ import { MINI_PANEL_HEIGHT } from 'components/Explore/TracesByService/TracesBySe import { AttributePanelScene } from './AttributePanelScene'; import Skeleton from 'react-loading-skeleton'; import { getErrorMessage, getNoDataMessage } from 'utils/utils'; -import { yBucketToDuration } from 'components/Explore/panels/histogram'; +import { getMinimumsForDuration, getYBuckets } from 'components/Explore/TracesByService/REDPanel'; export interface AttributePanelState extends SceneObjectState { panel?: SceneFlexLayout; @@ -73,16 +73,10 @@ export class AttributePanel extends SceneObjectBase { }) }); } else { - let yBuckets = data.data?.series.map((s) => parseFloat(s.fields[1].name)).sort((a, b) => a - b); + let yBuckets = getYBuckets(data.data?.series ?? []); 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); - + const { minDuration } = getMinimumsForDuration(yBuckets); + this.setState({ panel: new SceneFlexLayout({ children: [ From 646771c82dda7383870c6986bc072b19be6e603a Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Thu, 9 Jan 2025 10:40:11 +0000 Subject: [PATCH 29/32] Remove superfluous part of query --- src/components/Home/AttributePanel.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Home/AttributePanel.tsx b/src/components/Home/AttributePanel.tsx index 8007f7b..496556c 100644 --- a/src/components/Home/AttributePanel.tsx +++ b/src/components/Home/AttributePanel.tsx @@ -82,7 +82,7 @@ export class AttributePanel extends SceneObjectBase { children: [ new AttributePanel({ query: { - query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}} | by (resource.service.name)`, + query: `{nestedSetParent<0 && kind=server && duration > ${minDuration}}`, }, title: state.title, type: state.type, From 0973e7709a26ca1296d6faf808432d71d3556aea Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Fri, 10 Jan 2025 15:32:40 +0000 Subject: [PATCH 30/32] Only count error total if value is a number --- src/components/Home/AttributePanelRows.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index 609e836..870d4e7 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -50,7 +50,12 @@ export const AttributePanelRows = (props: Props) => { const getTotalErrs = (df: DataFrame) => { const valuesField = df.fields.find((f) => f.name !== 'time'); - return valuesField?.values?.reduce((x, acc) => x + acc) ?? 1; + return valuesField?.values?.reduce((x, acc) => { + if (typeof x === 'number' && !isNaN(x)) { + return x + acc + } + return acc + }) ?? 1; } return ( From b7c1b8b209704f8d935dffa9111d7ae2109be61d Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Fri, 10 Jan 2025 15:36:02 +0000 Subject: [PATCH 31/32] Fix spellcheck --- cspell.config.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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" ] } From 40f6f2da3058bf6f629d845a7d8aaabbf58885a2 Mon Sep 17 00:00:00 2001 From: Joey Tawadrous Date: Tue, 14 Jan 2025 11:38:26 +0000 Subject: [PATCH 32/32] Init acc in reduce to 0 --- src/components/Home/AttributePanelRows.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Home/AttributePanelRows.tsx b/src/components/Home/AttributePanelRows.tsx index 870d4e7..1e11f11 100644 --- a/src/components/Home/AttributePanelRows.tsx +++ b/src/components/Home/AttributePanelRows.tsx @@ -55,7 +55,7 @@ export const AttributePanelRows = (props: Props) => { return x + acc } return acc - }) ?? 1; + }, 0) ?? 1; } return (