diff --git a/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.stories.tsx b/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.stories.tsx index 9a6d18739e1772..38b86bbe8959d8 100644 --- a/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.stories.tsx +++ b/static/app/views/dashboards/widgets/areaChartWidget/areaChartWidget.stories.tsx @@ -1,221 +1,20 @@ import {Fragment} from 'react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; import JSXNode from 'sentry/components/stories/jsxNode'; -import SideBySide from 'sentry/components/stories/sideBySide'; -import SizingWindow from 'sentry/components/stories/sizingWindow'; import storyBook from 'sentry/stories/storyBook'; -import type {DateString} from 'sentry/types/core'; -import usePageFilters from 'sentry/utils/usePageFilters'; - -import type {Release, TimeSeries} from '../common/types'; -import {shiftTimeserieToNow} from '../timeSeriesWidget/shiftTimeserieToNow'; - -import {sampleLatencyTimeSeries} from './fixtures/sampleLatencyTimeSeries'; -import {sampleSpanDurationTimeSeries} from './fixtures/sampleSpanDurationTimeSeries'; -import {AreaChartWidget} from './areaChartWidget'; export default storyBook('AreaChartWidget', story => { story('Getting Started', () => { return (

- is a Dashboard Widget Component. It displays - a timeseries chart with multiple timeseries, and the timeseries are stacked. - Each timeseries is shown using a solid block of color. This chart is used to - visualize multiple timeseries that represent parts of something. For example, a - chart that shows time spent in the app broken down by component. In all other - ways, it behaves like , though it doesn't - support features like "Previous Period Data". -

-

- NOTE: This chart is not appropriate for showing a single timeseries! - You should use instead. + 🚨 is deprecated! Instead, see the stories + for , which explain in detail how to compose your own + widgets from standard components. If you want information on how to render a + time series visualization, see the stories for + .

); }); - - story('Visualization', () => { - const {selection} = usePageFilters(); - const {datetime} = selection; - const {start, end} = datetime; - - const latencyTimeSeries = toTimeSeriesSelection(sampleLatencyTimeSeries, start, end); - - const spanDurationTimeSeries = toTimeSeriesSelection( - sampleSpanDurationTimeSeries, - start, - end - ); - - return ( - -

- The visualization of a stacked area chart. It - has some bells and whistles including automatic axes labels, and a hover - tooltip. Like other widgets, it automatically fills the parent element. -

- - - - -

- The dataCompletenessDelay prop indicates that this data is live, - and the last few buckets might not have complete data. The delay is a number in - seconds. Any data bucket that happens in that delay window will be plotted with - a fainter fill. By default the delay is 0. -

- - - - - - -
- ); - }); - - story('State', () => { - return ( - -

- supports the usual loading and error states. - The loading state shows a spinner. The error state shows a message, and an - optional "Retry" button. -

- - - - - - - - - - - - - {}} - /> - - -
- ); - }); - - story('Colors', () => { - const theme = useTheme(); - - return ( - -

- You can control the color of each timeseries by setting the color{' '} - attribute to a string that contains a valid hex color code. -

- - - - -
- ); - }); - - story('Releases', () => { - const releases = [ - { - version: 'ui@0.1.2', - timestamp: sampleLatencyTimeSeries.data.at(2)?.timestamp, - }, - { - version: 'ui@0.1.3', - timestamp: sampleLatencyTimeSeries.data.at(20)?.timestamp, - }, - ].filter(hasTimestamp); - - return ( - -

- supports the releases prop. If - passed in, the widget will plot every release as a vertical line that overlays - the chart data. Clicking on a release line will open the release details page. -

- - - - -
- ); - }); }); - -const MediumWidget = styled('div')` - width: 420px; - height: 250px; -`; - -const SmallWidget = styled('div')` - width: 360px; - height: 160px; -`; - -const SmallSizingWindow = styled(SizingWindow)` - width: 50%; - height: 300px; -`; - -function toTimeSeriesSelection( - timeSeries: TimeSeries, - start: DateString | null, - end: DateString | null -): TimeSeries { - return { - ...timeSeries, - data: timeSeries.data.filter(datum => { - if (start && moment(datum.timestamp).isBefore(moment.utc(start))) { - return false; - } - - if (end && moment(datum.timestamp).isAfter(moment.utc(end))) { - return false; - } - - return true; - }), - }; -} - -function hasTimestamp(release: Partial): release is Release { - return Boolean(release?.timestamp); -} diff --git a/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.stories.tsx b/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.stories.tsx index e35a4b340e7ebc..6f76bb5a01c43c 100644 --- a/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.stories.tsx +++ b/static/app/views/dashboards/widgets/barChartWidget/barChartWidget.stories.tsx @@ -1,114 +1,20 @@ import {Fragment} from 'react'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; import JSXNode from 'sentry/components/stories/jsxNode'; -import SideBySide from 'sentry/components/stories/sideBySide'; -import SizingWindow from 'sentry/components/stories/sizingWindow'; import storyBook from 'sentry/stories/storyBook'; -import type {DateString} from 'sentry/types/core'; -import usePageFilters from 'sentry/utils/usePageFilters'; - -import type {TimeSeries} from '../common/types'; -import {shiftTimeserieToNow} from '../timeSeriesWidget/shiftTimeserieToNow'; - -import {sampleLatencyTimeSeries} from './fixtures/sampleLatencyTimeSeries'; -import {sampleSpanDurationTimeSeries} from './fixtures/sampleSpanDurationTimeSeries'; -import {BarChartWidget} from './barChartWidget'; export default storyBook('BarChartWidget', story => { story('Getting Started', () => { return (

- is a Dashboard Widget Component. It displays a - timeseries chart with multiple timeseries, and the timeseries and discontinuous. - In all other ways, it behaves like -

-

- NOTE: Prefer and{' '} - for timeseries visualizations! This should be - used for discontinuous categorized data. -

-
- ); - }); - - story('Visualization', () => { - const {selection} = usePageFilters(); - const {datetime} = selection; - const {start, end} = datetime; - - const latencyTimeSeries = toTimeSeriesSelection(sampleLatencyTimeSeries, start, end); - - const spanDurationTimeSeries = toTimeSeriesSelection( - sampleSpanDurationTimeSeries, - start, - end - ); - - return ( - -

- The visualization of is a bar chart. It has - some bells and whistles including automatic axes labels, and a hover tooltip. - Like other widgets, it automatically fills the parent element. + 🚨 is deprecated! Instead, see the stories for{' '} + , which explain in detail how to compose your own + widgets from standard components. If you want information on how to render a + time series visualization, see the stories for + .

-

- The stacked boolean prop controls stacking. Bar charts are not - stacked by default. -

- - - - - - - -
); }); }); - -const SmallSizingWindow = styled(SizingWindow)` - width: 50%; - height: 300px; -`; - -function toTimeSeriesSelection( - timeSeries: TimeSeries, - start: DateString | null, - end: DateString | null -): TimeSeries { - return { - ...timeSeries, - data: timeSeries.data.filter(datum => { - if (start && moment(datum.timestamp).isBefore(moment.utc(start))) { - return false; - } - - if (end && moment(datum.timestamp).isAfter(moment.utc(end))) { - return false; - } - - return true; - }), - }; -} diff --git a/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.stories.tsx b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.stories.tsx index 2b34137c8b7656..6e22f24598678e 100644 --- a/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.stories.tsx +++ b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidget.stories.tsx @@ -1,372 +1,20 @@ import {Fragment} from 'react'; -import styled from '@emotion/styled'; import JSXNode from 'sentry/components/stories/jsxNode'; -import JSXProperty from 'sentry/components/stories/jsxProperty'; -import SideBySide from 'sentry/components/stories/sideBySide'; -import SizingWindow from 'sentry/components/stories/sizingWindow'; import storyBook from 'sentry/stories/storyBook'; -import {BigNumberWidget} from 'sentry/views/dashboards/widgets/bigNumberWidget/bigNumberWidget'; export default storyBook('BigNumberWidget', story => { story('Getting Started', () => { return (

- is a Dashboard Widget Component. It displays - a single large value. Used in places like Dashboards Big Number widgets, Project - Details pages, and Organization Stats pages. + 🚨 is deprecated! Instead, see the stories + for , which explain in detail how to compose your own + widgets from standard components. If you want information on how to render a big + auto-scaling number (or string), see the stories for + .

); }); - - story('Visualization', () => { - return ( - -

- The visualization of a large number, just - like it says on the tin. Depending on the value passed to it, it intelligently - rounds and humanizes the results. If the number is humanized, hovering over the - visualization shows a tooltip with the full value. -

- -

- also supports string values. This is not - commonly used, but it's capable of rendering timestamps and in fact most fields - defined in our field renderer pipeline -

- - - - - - - - - - - - - - - -

- The maximumValue prop allows setting the maximum displayable value. - e.g., imagine a widget that displays a count. A count of more than a million is - too expensive for the API to compute, so the API returns a maximum of 1,000,000. - If the API returns exactly 1,000,000, that means the actual number is unknown, - something higher than the max. Setting{' '} - will show >1m. -

- - - - - -
- ); - }); - - story('State', () => { - return ( - -

- supports the usual loading and error states. - The loading state shows a simple placeholder. The error state also shows an - optional "Retry" button. -

- - - - - - - - - - - - - {}} - /> - - - -

The contents of the error adjust slightly as the widget gets bigger.

- - - - {}} - /> - - -
- ); - }); - - story('Previous Period Data', () => { - return ( - -

- shows the difference of the current value and - the previous period value as the difference between the two values, in small - text next to the main value. -

- -

- The preferredPolarity prop controls the color of the comparison - string. Setting mean that a - higher number is better and will paint increases in the value green. Vice - versa with negative polarity. Omitting a preferred polarity will prevent - colorization. -

- - - - - - - - - - - - - -
- ); - }); - - story('Thresholds', () => { - const meta = { - fields: { - 'eps()': 'rate', - }, - units: { - 'eps()': '1/second', - }, - }; - - const thresholds = { - max_values: { - max1: 20, - max2: 50, - }, - unit: '1/second', - }; - - return ( - -

- supports a thresholds prop. If - specified, the value in the widget will be evaluated against these thresholds, - and indicated using a colorful circle next to the value. -

- - - - - - - - - - - - - - - -

- The thresholds respect the preferred polarity. By default, the preferred - polarity is positive (higher numbers are good). -

- - - - - - - - - - - - - - -
- ); - }); }); - -const SmallSizingWindow = styled(SizingWindow)` - width: auto; - height: 200px; -`; - -const SmallWidget = styled('div')` - width: 250px; -`; - -const MediumWidget = styled('div')` - width: 420px; -`; diff --git a/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization.stories.tsx new file mode 100644 index 00000000000000..ef4ce0ff0aecf0 --- /dev/null +++ b/static/app/views/dashboards/widgets/bigNumberWidget/bigNumberWidgetVisualization.stories.tsx @@ -0,0 +1,374 @@ +import {Fragment} from 'react'; +import styled from '@emotion/styled'; + +import JSXNode from 'sentry/components/stories/jsxNode'; +import JSXProperty from 'sentry/components/stories/jsxProperty'; +import SideBySide from 'sentry/components/stories/sideBySide'; +import SizingWindow from 'sentry/components/stories/sizingWindow'; +import storyBook from 'sentry/stories/storyBook'; +import {space} from 'sentry/styles/space'; + +import {BigNumberWidgetVisualization} from './bigNumberWidgetVisualization'; + +export default storyBook('BigNumberWidgetVisualization', story => { + story('Getting Started', () => { + return ( + +

+ is a visualization used for "Big + Number" widgets across the app. It displays a single large value. Used in places + like Dashboards Big Number widgets, Project Details pages, and Organization + Stats pages. +

+

+ It has features like: +

+

+

+ You should use this component for showing large significant numbers in the UI! + It's highly customizable, with features like previous period data comparison, + thresholds, and more. +

+
+ ); + }); + + story('Basic Visualization', () => { + return ( + +

+ The visualization of a large + number, just like it says on the tin. Depending on the value passed to it, it + intelligently rounds and humanizes the results. If the number is humanized, + hovering over the visualization shows a tooltip with the full value. +

+ +

+ also supports string values. + This is not commonly used, but it's capable of rendering timestamps and in fact + most fields defined in our field renderer pipeline +

+ + + + + + + + + + + + + + + + + + + + + + + +
+ ); + }); + + story('Loading Placeholder', () => { + return ( + +

+ includes a loading placeholder. + You can use it via{' '} + +

+ + + + +
+ ); + }); + + story('Maximum Value', () => { + return ( + +

+ The maximumValue prop allows setting the maximum displayable value. + e.g., imagine a widget that displays a count. A count of more than a million is + too expensive for the API to compute, so the API returns a maximum of 1,000,000. + If the API returns exactly 1,000,000, that means the actual number is unknown, + something higher than the max. Setting{' '} + will show >1m. +

+ + + + + +
+ ); + }); + + story('Previous Period Data', () => { + return ( + +

+ can show the difference of the + current value and the previous period value as the difference between the two + values, in small text next to the main value. +

+ +

+ The preferredPolarity prop controls the color of the comparison + string. Setting mean that a + higher number is better and will paint increases in the value green. Vice + versa with negative polarity. Omitting a preferred polarity will prevent + colorization. +

+ + + + + + + + + + + + + +
+ ); + }); + + story('Thresholds', () => { + const meta = { + fields: { + 'eps()': 'rate', + }, + units: { + 'eps()': '1/second', + }, + }; + + const thresholds = { + max_values: { + max1: 20, + max2: 50, + }, + unit: '1/second', + }; + + return ( + +

+ supports a{' '} + thresholds prop. If specified, the value in the widget will be + evaluated against these thresholds, and indicated using a colorful circle next + to the value. +

+ + + + + + + + + + + + + + + +

+ The thresholds respect the preferred polarity. By default, the preferred + polarity is positive (higher numbers are good). +

+ + + + + + + + + + + + + + +
+ ); + }); +}); + +const SmallSizingWindow = styled(SizingWindow)` + width: auto; + height: 200px; +`; + +interface SmallWidgetProps { + children: React.ReactNode; +} +function SmallWidget(props: SmallWidgetProps) { + return ( + + {props.children} + + ); +} + +const Padded = styled('div')` + border-radius: ${p => p.theme.borderRadius}; + border: ${p => `1px solid ${p.theme.border}`}; + padding: ${space(2)} ${space(1)}; + width: 250px; + height: 80px; +`; + +const Container = styled('div')` + width: 100%; + height: 100%; + position: relative; +`; diff --git a/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.stories.tsx b/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.stories.tsx index 49182c2013a392..7ebf3e8201d0b6 100644 --- a/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.stories.tsx +++ b/static/app/views/dashboards/widgets/lineChartWidget/lineChartWidget.stories.tsx @@ -1,359 +1,20 @@ -import {Fragment, useState} from 'react'; -import {useTheme} from '@emotion/react'; -import styled from '@emotion/styled'; -import moment from 'moment-timezone'; +import {Fragment} from 'react'; -import {Button} from 'sentry/components/button'; import JSXNode from 'sentry/components/stories/jsxNode'; -import SideBySide from 'sentry/components/stories/sideBySide'; -import SizingWindow from 'sentry/components/stories/sizingWindow'; import storyBook from 'sentry/stories/storyBook'; -import type {DateString} from 'sentry/types/core'; -import usePageFilters from 'sentry/utils/usePageFilters'; - -import type {Release, TimeSeries, TimeseriesSelection} from '../common/types'; -import {shiftTimeserieToNow} from '../timeSeriesWidget/shiftTimeserieToNow'; - -import {sampleDurationTimeSeries} from './fixtures/sampleDurationTimeSeries'; -import {sampleThroughputTimeSeries} from './fixtures/sampleThroughputTimeSeries'; -import {LineChartWidget} from './lineChartWidget'; - -const sampleDurationTimeSeries2 = { - ...sampleDurationTimeSeries, - field: 'p50(span.duration)', - data: sampleDurationTimeSeries.data.map(datum => { - return { - ...datum, - value: datum.value * 0.3 + 30 * Math.random(), - }; - }), - meta: { - fields: { - 'p50(span.duration)': 'duration', - }, - units: { - 'p50(span.duration)': 'millisecond', - }, - }, -}; - -const sectionSize = sampleThroughputTimeSeries.data.length / 10; -const sectionStart = sectionSize * 2; -const sectionEnd = sectionSize * 3; -const sparseThroughputTimeSeries = { - ...sampleThroughputTimeSeries, - data: sampleThroughputTimeSeries.data.map((datum, index) => { - if (index > sectionStart && index < sectionEnd) { - return { - ...datum, - value: null, - }; - } - return datum; - }), -}; export default storyBook('LineChartWidget', story => { story('Getting Started', () => { return (

- is a Dashboard Widget Component. It displays - a timeseries chart with one or more timeseries. Used to visualize data that - changes over time in Project Details, Dashboards, Performance, and other UIs. -

-
- ); - }); - - story('Visualization', () => { - const {selection} = usePageFilters(); - const {datetime} = selection; - const {start, end} = datetime; - - const [timeseriesSelection, setTimeseriesSelection] = useState({ - 'p50(span.duration)': true, - 'p99(span.duration)': true, - }); - - const toggleTimeseriesSelection = (seriesName: string): void => { - setTimeseriesSelection(s => ({...s, [seriesName]: !s[seriesName]})); - }; - - const throughputTimeSeries = toTimeSeriesSelection( - sparseThroughputTimeSeries, - start, - end - ); - - const durationTimeSeries1 = toTimeSeriesSelection( - sampleDurationTimeSeries, - start, - end - ); - - const durationTimeSeries2 = toTimeSeriesSelection( - sampleDurationTimeSeries2, - start, - end - ); - - return ( - -

- The visualization of a line chart. It has - some bells and whistles including automatic axes labels, and a hover tooltip. - Like other widgets, it automatically fills the parent element. null{' '} - values are supported! -

- - - - -

- The dataCompletenessDelay prop indicates that this data is live, - and the last few buckets might not have complete data. The delay is a number in - seconds. Any data bucket that happens in that delay window will be plotted with - a dotted line. By default the delay is 0. -

- -

- To control the timeseries selection, you can use the{' '} - timeseriesSelection and onTimeseriesSelectionChange{' '} - props. -

- - - - { - setTimeseriesSelection(newSelection); - }} - /> - - - - - - - -

- will automatically check the types and unit - of all the incoming timeseries. If they do not all match, it will fall back to a - plain number scale. If the types match but the units do not, it will fall back - to a sensible unit -

- - - ({ - ...datum, - value: datum.value === null ? null : datum.value / 1000, - })), - meta: { - fields: durationTimeSeries2.meta?.fields!, - units: { - 'p50(span.duration)': 'second', - }, - }, - }, - ]} - /> - -
- ); - }); - - story('State', () => { - return ( - -

- supports the usual loading and error states. - The loading state shows a spinner. The error state shows a message, and an - optional "Retry" button. -

- - - - - - - - - - - - - {}} - /> - - -
- ); - }); - - story('Colors', () => { - const theme = useTheme(); - - return ( - -

- You can control the color of each timeseries by setting the color{' '} - attribute to a string that contains a valid hex color code. + 🚨 is deprecated! Instead, see the stories + for , which explain in detail how to compose your own + widgets from standard components. If you want information on how to render a + time series visualization, see the stories for + .

- - - - -
- ); - }); - - story('Releases', () => { - const releases = [ - { - version: 'ui@0.1.2', - timestamp: sampleThroughputTimeSeries.data.at(2)?.timestamp, - }, - { - version: 'ui@0.1.3', - timestamp: sampleThroughputTimeSeries.data.at(20)?.timestamp, - }, - ].filter(hasTimestamp); - - return ( - -

- supports the releases prop. If - passed in, the widget will plot every release as a vertical line that overlays - the chart data. Clicking on a release line will open the release details page. -

- - - -
); }); }); - -const MediumWidget = styled('div')` - width: 420px; - height: 250px; -`; - -const SmallWidget = styled('div')` - width: 360px; - height: 160px; -`; - -const SmallSizingWindow = styled(SizingWindow)` - width: 50%; - height: 300px; -`; - -function toTimeSeriesSelection( - timeSeries: TimeSeries, - start: DateString | null, - end: DateString | null -): TimeSeries { - return { - ...timeSeries, - data: timeSeries.data.filter(datum => { - if (start && moment(datum.timestamp).isBefore(moment.utc(start))) { - return false; - } - - if (end && moment(datum.timestamp).isAfter(moment.utc(end))) { - return false; - } - - return true; - }), - }; -} - -function hasTimestamp(release: Partial): release is Release { - return Boolean(release?.timestamp); -} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/fixtures/sampleDurationTimeSeries.ts b/static/app/views/dashboards/widgets/timeSeriesWidget/fixtures/sampleDurationTimeSeries.ts new file mode 100644 index 00000000000000..1643235353c29d --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/fixtures/sampleDurationTimeSeries.ts @@ -0,0 +1,209 @@ +export const sampleDurationTimeSeries = { + field: 'p99(span.duration)', + meta: { + fields: { + 'p99(span.duration)': 'duration', + }, + units: { + 'p99(span.duration)': 'millisecond', + }, + }, + data: [ + { + value: 163.26759544018776, + timestamp: '2024-10-24T15:00:00-04:00', + }, + { + value: 164.07690380778297, + timestamp: '2024-10-24T15:30:00-04:00', + }, + { + value: 163.99383844776062, + timestamp: '2024-10-24T16:00:00-04:00', + }, + { + value: 173.63336816976206, + timestamp: '2024-10-24T16:30:00-04:00', + }, + { + value: 175.3677834806518, + timestamp: '2024-10-24T17:00:00-04:00', + }, + { + value: 170.97728899915776, + timestamp: '2024-10-24T17:30:00-04:00', + }, + { + value: 157.01612877746825, + timestamp: '2024-10-24T18:00:00-04:00', + }, + { + value: 170.16561857179315, + timestamp: '2024-10-24T18:30:00-04:00', + }, + { + value: 160.16955120038932, + timestamp: '2024-10-24T19:00:00-04:00', + }, + { + value: 169.36653636105817, + timestamp: '2024-10-24T19:30:00-04:00', + }, + { + value: 158.14689668805912, + timestamp: '2024-10-24T20:00:00-04:00', + }, + { + value: 169.15717966126346, + timestamp: '2024-10-24T20:30:00-04:00', + }, + { + value: 159.93437627752522, + timestamp: '2024-10-24T21:00:00-04:00', + }, + { + value: 181.2808339108854, + timestamp: '2024-10-24T21:30:00-04:00', + }, + { + value: 174.5653311041271, + timestamp: '2024-10-24T22:00:00-04:00', + }, + { + value: 183.84079875522747, + timestamp: '2024-10-24T22:30:00-04:00', + }, + { + value: 167.7772694683986, + timestamp: '2024-10-24T23:00:00-04:00', + }, + { + value: 183.82398422937058, + timestamp: '2024-10-24T23:30:00-04:00', + }, + { + value: 184.52864336855654, + timestamp: '2024-10-25T00:00:00-04:00', + }, + { + value: 193.65015582927347, + timestamp: '2024-10-25T00:30:00-04:00', + }, + { + value: 178.46847533061455, + timestamp: '2024-10-25T01:00:00-04:00', + }, + { + value: 200.36778566817904, + timestamp: '2024-10-25T01:30:00-04:00', + }, + { + value: 190.90359955365727, + timestamp: '2024-10-25T02:00:00-04:00', + }, + { + value: 198.59051116263905, + timestamp: '2024-10-25T02:30:00-04:00', + }, + { + value: 188.65220261321352, + timestamp: '2024-10-25T03:00:00-04:00', + }, + { + value: 194.43420903582333, + timestamp: '2024-10-25T03:30:00-04:00', + }, + { + value: 197.647787580193, + timestamp: '2024-10-25T04:00:00-04:00', + }, + { + value: 190.58833865087888, + timestamp: '2024-10-25T04:30:00-04:00', + }, + { + value: 178.91675989347792, + timestamp: '2024-10-25T05:00:00-04:00', + }, + { + value: 190.0340975630367, + timestamp: '2024-10-25T05:30:00-04:00', + }, + { + value: 187.38906820511914, + timestamp: '2024-10-25T06:00:00-04:00', + }, + { + value: 189.56698436333198, + timestamp: '2024-10-25T06:30:00-04:00', + }, + { + value: 190.05121671101094, + timestamp: '2024-10-25T07:00:00-04:00', + }, + { + value: 190.17890269231188, + timestamp: '2024-10-25T07:30:00-04:00', + }, + { + value: 185.79800462057017, + timestamp: '2024-10-25T08:00:00-04:00', + }, + { + value: 189.58731836412431, + timestamp: '2024-10-25T08:30:00-04:00', + }, + { + value: 194.0240027791402, + timestamp: '2024-10-25T09:00:00-04:00', + }, + { + value: 194.36826153553162, + timestamp: '2024-10-25T09:30:00-04:00', + }, + { + value: 188.280130395783, + timestamp: '2024-10-25T10:00:00-04:00', + }, + { + value: 189.78493654183256, + timestamp: '2024-10-25T10:30:00-04:00', + }, + { + value: 194.27624664281058, + timestamp: '2024-10-25T11:00:00-04:00', + }, + { + value: 179.5612344192774, + timestamp: '2024-10-25T11:30:00-04:00', + }, + { + value: 172.61016559821348, + timestamp: '2024-10-25T12:00:00-04:00', + }, + { + value: 186.46779136621416, + timestamp: '2024-10-25T12:30:00-04:00', + }, + { + value: 171.43180664489284, + timestamp: '2024-10-25T13:00:00-04:00', + }, + { + value: 174.50192368733187, + timestamp: '2024-10-25T13:30:00-04:00', + }, + { + value: 164.72702271761602, + timestamp: '2024-10-25T14:00:00-04:00', + }, + { + value: 172.11837107180162, + timestamp: '2024-10-25T14:30:00-04:00', + }, + { + value: 185.64485387569914, + timestamp: '2024-10-25T15:00:00-04:00', + }, + ], +}; diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/fixtures/sampleThroughputTimeSeries.ts b/static/app/views/dashboards/widgets/timeSeriesWidget/fixtures/sampleThroughputTimeSeries.ts new file mode 100644 index 00000000000000..e4e740f9813597 --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/fixtures/sampleThroughputTimeSeries.ts @@ -0,0 +1,209 @@ +export const sampleThroughputTimeSeries = { + field: 'eps()', + meta: { + fields: { + 'eps()': 'rate', + }, + units: { + 'eps()': '1/second', + }, + }, + data: [ + { + value: 7456.966666666666, + timestamp: '2024-10-24T15:00:00-04:00', + }, + { + value: 8217.8, + timestamp: '2024-10-24T15:30:00-04:00', + }, + { + value: 8989.4, + timestamp: '2024-10-24T16:00:00-04:00', + }, + { + value: 7930.9, + timestamp: '2024-10-24T16:30:00-04:00', + }, + { + value: 7178.1, + timestamp: '2024-10-24T17:00:00-04:00', + }, + { + value: 6450.666666666667, + timestamp: '2024-10-24T17:30:00-04:00', + }, + { + value: 6497.8, + timestamp: '2024-10-24T18:00:00-04:00', + }, + { + value: 4844.933333333333, + timestamp: '2024-10-24T18:30:00-04:00', + }, + { + value: 4983.466666666666, + timestamp: '2024-10-24T19:00:00-04:00', + }, + { + value: 4231.633333333333, + timestamp: '2024-10-24T19:30:00-04:00', + }, + { + value: 4785.733333333334, + timestamp: '2024-10-24T20:00:00-04:00', + }, + { + value: 4394.566666666667, + timestamp: '2024-10-24T20:30:00-04:00', + }, + { + value: 4934.566666666667, + timestamp: '2024-10-24T21:00:00-04:00', + }, + { + value: 4785.966666666666, + timestamp: '2024-10-24T21:30:00-04:00', + }, + { + value: 4850.166666666667, + timestamp: '2024-10-24T22:00:00-04:00', + }, + { + value: 4630.966666666666, + timestamp: '2024-10-24T22:30:00-04:00', + }, + { + value: 4737.133333333333, + timestamp: '2024-10-24T23:00:00-04:00', + }, + { + value: 4093.6666666666665, + timestamp: '2024-10-24T23:30:00-04:00', + }, + { + value: 4393.9, + timestamp: '2024-10-25T00:00:00-04:00', + }, + { + value: 4763.366666666667, + timestamp: '2024-10-25T00:30:00-04:00', + }, + { + value: 5913.533333333334, + timestamp: '2024-10-25T01:00:00-04:00', + }, + { + value: 6001.933333333333, + timestamp: '2024-10-25T01:30:00-04:00', + }, + { + value: 7880.266666666666, + timestamp: '2024-10-25T02:00:00-04:00', + }, + { + value: 8901.1, + timestamp: '2024-10-25T02:30:00-04:00', + }, + { + value: 10684.533333333333, + timestamp: '2024-10-25T03:00:00-04:00', + }, + { + value: 11362.233333333334, + timestamp: '2024-10-25T03:30:00-04:00', + }, + { + value: 12708.933333333332, + timestamp: '2024-10-25T04:00:00-04:00', + }, + { + value: 12494.9, + timestamp: '2024-10-25T04:30:00-04:00', + }, + { + value: 13224.8, + timestamp: '2024-10-25T05:00:00-04:00', + }, + { + value: 11910, + timestamp: '2024-10-25T05:30:00-04:00', + }, + { + value: 11013.433333333332, + timestamp: '2024-10-25T06:00:00-04:00', + }, + { + value: 9856, + timestamp: '2024-10-25T06:30:00-04:00', + }, + { + value: 10530.033333333333, + timestamp: '2024-10-25T07:00:00-04:00', + }, + { + value: 10233.2, + timestamp: '2024-10-25T07:30:00-04:00', + }, + { + value: 11688.066666666668, + timestamp: '2024-10-25T08:00:00-04:00', + }, + { + value: 11654.533333333333, + timestamp: '2024-10-25T08:30:00-04:00', + }, + { + value: 11965.366666666667, + timestamp: '2024-10-25T09:00:00-04:00', + }, + { + value: 12124.9, + timestamp: '2024-10-25T09:30:00-04:00', + }, + { + value: 12114.466666666667, + timestamp: '2024-10-25T10:00:00-04:00', + }, + { + value: 11737.7, + timestamp: '2024-10-25T10:30:00-04:00', + }, + { + value: 11289.9, + timestamp: '2024-10-25T11:00:00-04:00', + }, + { + value: 10604.833333333334, + timestamp: '2024-10-25T11:30:00-04:00', + }, + { + value: 9463.233333333334, + timestamp: '2024-10-25T12:00:00-04:00', + }, + { + value: 8513.233333333334, + timestamp: '2024-10-25T12:30:00-04:00', + }, + { + value: 8485.233333333334, + timestamp: '2024-10-25T13:00:00-04:00', + }, + { + value: 8105.4, + timestamp: '2024-10-25T13:30:00-04:00', + }, + { + value: 7914.4, + timestamp: '2024-10-25T14:00:00-04:00', + }, + { + value: 7006.8, + timestamp: '2024-10-25T14:30:00-04:00', + }, + { + value: 399.3, + timestamp: '2024-10-25T15:00:00-04:00', + }, + ], +}; diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.stories.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.stories.tsx new file mode 100644 index 00000000000000..10479c7210cafb --- /dev/null +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.stories.tsx @@ -0,0 +1,457 @@ +import {Fragment, useState} from 'react'; +import {useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import moment from 'moment-timezone'; + +import {CodeSnippet} from 'sentry/components/codeSnippet'; +import JSXNode from 'sentry/components/stories/jsxNode'; +import SideBySide from 'sentry/components/stories/sideBySide'; +import SizingWindow from 'sentry/components/stories/sizingWindow'; +import storyBook from 'sentry/stories/storyBook'; +import type {DateString} from 'sentry/types/core'; +import {decodeScalar} from 'sentry/utils/queryString'; +import useLocationQuery from 'sentry/utils/url/useLocationQuery'; + +import type {Release, TimeSeries, TimeseriesSelection} from '../common/types'; +import {shiftTimeserieToNow} from '../timeSeriesWidget/shiftTimeserieToNow'; + +import {sampleDurationTimeSeries} from './fixtures/sampleDurationTimeSeries'; +import {sampleThroughputTimeSeries} from './fixtures/sampleThroughputTimeSeries'; +import {TimeSeriesWidgetVisualization} from './timeSeriesWidgetVisualization'; + +// eslint-disable-next-line import/no-webpack-loader-syntax +import types from '!!type-loader!sentry/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization'; + +const sampleDurationTimeSeries2 = { + ...sampleDurationTimeSeries, + field: 'p50(span.duration)', + data: sampleDurationTimeSeries.data.map(datum => { + return { + ...datum, + value: datum.value * 0.3 + 30 * Math.random(), + }; + }), + meta: { + fields: { + 'p50(span.duration)': 'duration', + }, + units: { + 'p50(span.duration)': 'millisecond', + }, + }, +}; + +export default storyBook('TimeSeriesWidgetVisualization', (story, APIReference) => { + APIReference(types.TimeSeriesWidgetVisualization); + + story('Getting Started', () => { + return ( + +

+ is a feature-full time series + chart, designed to plot data returned from /events-stats/ endpoints + in Explore, Dashboards, and other similar UIs. +

+

+ It includes features like: +

    +
  • scaling mis-matched units
  • +
  • displaying incomplete ingestion buckets
  • +
  • + stripping legend names of internal information like equation|{' '} + prefixes +
  • +
  • intelligently formatting the axes
  • +
  • and more!
  • +
+ If you (or someone you know) is plotting Sentry data and the X axis is time, you + should be using this component! It's highly configurable, and should suit your + needs. If it doesn't, reach out to the Dashboards team. +

+ + + + + + + + + + + + +
+ ); + }); + + story('Data Format', () => { + return ( + +

+ accepts the{' '} + timeSeries prop, which contains the plottable data. This is a + special format that we're slowly aligning with the server responses. For now, + you will probably have to transform your data into this format. Here's an + example: +

+ + {` +{ + "field": "p99(span.duration)", + "meta": { + "fields": { + "p99(span.duration)": "duration", + }, + "units": { + "p99(span.duration)": "millisecond", + }, + }, + "data": [ + { + "value": 163.26759544018776, + "timestamp": "2024-10-24T15:00:00-04:00", + }, + { + "value": 164.07690380778297, + "timestamp": "2024-10-24T15:30:00-04:00", + }, + ] +} + `} + +
+ ); + }); + + story('Choosing The Visualization Type', () => { + return ( + +

Here are a few guidelines on how to choose the right visualization:

+
    +
  • + Only use area charts if you want to plot multiple series and those + series represent components of a total. For example, area charts are a good + choice to show time spent, broken down by span operation. They are not a good + choice for plotting a single duration time series +
  • +
  • + Bar charts are to your discretion, it's most an aesthetic choice. Generally, + bars communicate discrete buckets, and lines communicate continuous data. If + you are plotting something like duration, even if it's broken down by time + buckets, a line feels right. If you are plotting someting like throughput (a + naturally bucketed value) and the buckets are big, a bar chart might be better +
  • +
  • Use line charts when in doubt! They are almost always the right choice
  • +
+
+ ); + }); + + story('Basic Plotting', () => { + return ( + +

+ can plot multiple time series + while accounting for their type and units. It adds X axis formatting, Y axis + formatting, a tooltip with correct units, it will scale units of the same type + if needed, and it supports null values. It also supports more + advanced features like drag-to-zoom, and marking incomplete data. The component + doesn't have its own size, it always respects the side of its parent. +

+ + + + + + +
+ ); + }); + + story('Loading Placeholder', () => { + return ( + +

+ includes a loading placeholder. + You can use it via{' '} + +

+ + + + +
+ ); + }); + + story('Stacking', () => { + return ( + +

+ Bar charts are unstacked by default. To turn on stacking, use the{' '} + stacked prop. Area charts are always stacked. Line charts are never + stacked. +

+
+ ); + }); + + story('Incomplete Data', () => { + const props = { + dataCompletenessDelay: 60 * 60 * 3, + timeSeries: [ + shiftTimeserieToNow(sampleDurationTimeSeries), + shiftTimeserieToNow(sampleDurationTimeSeries2), + ], + }; + return ( + +

+ The dataCompletenessDelay prop indicates that this data is live, + and the last few buckets might not have complete data. The delay is a number in + seconds. By default the delay is 0. +

+ + + + + + + + + + + + +
+ ); + }); + + story('Drag to Select', () => { + const {start, end} = useLocationQuery({ + fields: { + start: decodeScalar, + end: decodeScalar, + }, + }); + + const [timeseriesSelection, setTimeseriesSelection] = useState({ + 'p50(span.duration)': true, + 'p99(span.duration)': true, + }); + + const durationTimeSeries1 = toTimeSeriesSelection( + sampleDurationTimeSeries, + start, + end + ); + + const durationTimeSeries2 = toTimeSeriesSelection( + sampleDurationTimeSeries2, + start, + end + ); + + return ( + +

+ supports drag-to-select. + Dragging the mouse over the visualization area and releasing the cursor will + update the page URL with the new datetime selection. You can press{' '} + escape during selection to cancel selection. Give it a try! +

+ + + { + setTimeseriesSelection(newSelection); + }} + /> + +
+ ); + }); + + story('Legends', () => { + const [timeSeriesSelection, setTimeSeriesSelection] = useState({ + 'p99(span.duration)': false, + }); + + return ( + +

+ supports series legends, and a + few features on top of them. By default, if only one series is plotted, the + legend does not appear. If there are multiple series, a legend is shown above + the charts. +

+

+ You can control legend selection with the timeseriesSelection prop. + By default, all time series are shown. If any time series is set to{' '} + false it will be hidden. The companion{' '} + onTimeseriesSelectionChange prop is a callback, it will tell you + when the user changes the legend selection by clicking on legend labels. +

+

+ You can also provide aliases for legends to give them a friendlier name. In this + example, verbose names like "p99(span.duration)" are truncated, and the p99 + series is hidden by default. +

+ + + + +
+ ); + }); + + story('Colors', () => { + const theme = useTheme(); + + return ( + + {' '} +

+ You can control the color of each time series by setting the color{' '} + attribute to a string that contains a valid hex color code. +

+ + + +
+ ); + }); + + story('Releases', () => { + const releases = [ + { + version: 'ui@0.1.2', + timestamp: sampleThroughputTimeSeries.data.at(2)?.timestamp, + }, + { + version: 'ui@0.1.3', + timestamp: sampleThroughputTimeSeries.data.at(20)?.timestamp, + }, + ].filter(hasTimestamp); + + return ( + +

+ Area and line charts support showing release markers via the{' '} + releases prop. Clicking on a release line will open the release + details page. +

+ + + + +
+ ); + }); +}); + +const FillParent = styled('div')` + width: 100%; + height: 100%; +`; + +const MediumWidget = styled('div')` + position: relative; + width: 420px; + height: 250px; +`; + +const SmallWidget = styled('div')` + position: relative; + width: 360px; + height: 160px; +`; + +const SmallSizingWindow = styled(SizingWindow)` + width: 50%; + height: 300px; +`; + +function toTimeSeriesSelection( + timeSeries: TimeSeries, + start: DateString | null, + end: DateString | null +): TimeSeries { + return { + ...timeSeries, + data: timeSeries.data.filter(datum => { + if (start && moment(datum.timestamp).isBefore(moment.utc(start))) { + return false; + } + + if (end && moment(datum.timestamp).isAfter(moment.utc(end))) { + return false; + } + + return true; + }), + }; +} + +function hasTimestamp(release: Partial): release is Release { + return Boolean(release?.timestamp); +} diff --git a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx index e1c3818df016c9..823b92a5270031 100644 --- a/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx +++ b/static/app/views/dashboards/widgets/timeSeriesWidget/timeSeriesWidgetVisualization.tsx @@ -50,13 +50,37 @@ import {splitSeriesIntoCompleteAndIncomplete} from './splitSeriesIntoCompleteAnd type VisualizationType = 'area' | 'line' | 'bar'; export interface TimeSeriesWidgetVisualizationProps { + /** + * An array of time series, each one representing a changing value over time. This is the chart's data. See documentation for examples + */ timeSeries: TimeSeries[]; + /** + * Chart type + */ visualizationType: VisualizationType; + /** + * A mapping of time series fields to their user-friendly labels, if needed + */ aliases?: Aliases; + /** + * A duration in seconds. Any items in the time series that fall within that duration of the current time will be visually marked as "incomplete" + */ dataCompletenessDelay?: number; + /** + * Callback that returns an updated `timeseriesSelection` after a user manipulations the selection via the legend + */ onTimeseriesSelectionChange?: (selection: TimeseriesSelection) => void; + /** + * Array of `Release` objects. If provided, they are plotted on line and area visualizations as vertical lines + */ releases?: Release[]; + /** + * Only available for `visualizationType="bar"`. If `true`, the bars are stacked + */ stacked?: boolean; + /** + * A mapping of time series field name to boolean. If the value is `false`, the series is hidden from view + */ timeseriesSelection?: TimeseriesSelection; }