diff --git a/webapp/javascript/components/TimelineChart/SyncTimelines/SyncTimelines.spec.tsx b/webapp/javascript/components/TimelineChart/SyncTimelines/SyncTimelines.spec.tsx new file mode 100644 index 0000000000..0176ce1a75 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/SyncTimelines/SyncTimelines.spec.tsx @@ -0,0 +1,148 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import SyncTimelines from './index'; +import { getTitle, getSelectionBoundaries } from './useSync'; +import { Selection } from '../markings'; + +const from = 1666790156; +const to = 1666791905; + +const propsWhenActive = { + timeline: { + color: 'rgb(208, 102, 212)', + data: { + startTime: 1666790760, + samples: [ + 16629, 50854, 14454, 3819, 40720, 23172, 22483, 7854, 33186, 81804, + 46942, 40631, 14135, 12824, 27514, 14366, 39691, 45412, 18631, 10371, + 31606, 53775, 42399, 40527, 20599, 27836, 23624, 80152, 9149, 45283, + 58361, 48738, 30363, 13834, 30849, 81892, + ], + durationDelta: 10, + }, + }, + leftSelection: { + from: String(from), + to: '1666790783', + }, + rightSelection: { + from: '1666791459', + to: String(to), + }, +}; + +const propsWhenHidden = { + timeline: { + data: { + startTime: 1666779070, + samples: [ + 1601, 30312, 22044, 53925, 44264, 26014, 15645, 14376, 21880, 8555, + 15995, 5849, 14138, 18929, 41842, 59101, 18931, 65541, 47674, 35886, + 55583, 19283, 19745, 9314, 1531, + ], + durationDelta: 10, + }, + }, + leftSelection: { + from: '1666779093', + to: '1666779239', + }, + rightSelection: { + from: '1666779140', + to: '1666779296', + }, +}; + +const { getByRole, queryByText } = screen; + +describe('SyncTimelines', () => { + it('renders sync and ignore buttons when active', async () => { + render( {}} {...propsWhenActive} />); + + expect(getByRole('button', { name: 'Ignore' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Sync Timelines' })).toBeInTheDocument(); + }); + + it('hidden when selections are in range', async () => { + render( {}} {...propsWhenHidden} />); + + expect(queryByText('Sync')).not.toBeInTheDocument(); + }); + + it('onSync returns correct from/to', async () => { + let result = { from: '', to: '' }; + render( + { + result = { from, to }; + }} + /> + ); + + fireEvent.click(getByRole('button', { name: 'Sync Timelines' })); + + // new main timeline FROM = from - 1ms, TO = to + 1ms + expect(Number(result.from) - from * 1000).toEqual(-1); + expect(Number(result.to) - to * 1000).toEqual(1); + }); + + it('Hide button works', async () => { + render( {}} {...propsWhenActive} />); + + fireEvent.click(getByRole('button', { name: 'Ignore' })); + + expect(queryByText('Sync')).not.toBeInTheDocument(); + }); +}); + +describe('getTitle', () => { + it('both selections are out of range', () => { + expect(getTitle(false, false)).toEqual( + 'Warning: Baseline and Comparison timeline selections are out of range' + ); + }); + it('baseline timeline selection is out of range', () => { + expect(getTitle(false, true)).toEqual( + 'Warning: Baseline timeline selection is out of range' + ); + }); + it('comparison timeline selection is out of range', () => { + expect(getTitle(true, false)).toEqual( + 'Warning: Comparison timeline selection is out of range' + ); + }); +}); + +describe('getSelectionBoundaries', () => { + const boundariesFromRelativeTime = getSelectionBoundaries({ + from: 'now-1h', + to: 'now', + } as Selection); + const boundariesFromUnixTime = getSelectionBoundaries({ + from: '1667204605', + to: '1667204867', + } as Selection); + + const res = [ + boundariesFromRelativeTime.from, + boundariesFromRelativeTime.to, + boundariesFromUnixTime.from, + boundariesFromUnixTime.to, + ]; + + it('returns correct data type', () => { + expect(res.every((i) => typeof i === 'number')).toBe(true); + }); + + it('returns ms format (13 digits)', () => { + expect(res.every((i) => String(i).length === 13)).toBe(true); + }); + + it('TO greater than FROM', () => { + expect( + boundariesFromRelativeTime.to > boundariesFromRelativeTime.from && + boundariesFromUnixTime.to > boundariesFromUnixTime.from + ).toBe(true); + }); +}); diff --git a/webapp/javascript/components/TimelineChart/SyncTimelines/index.tsx b/webapp/javascript/components/TimelineChart/SyncTimelines/index.tsx new file mode 100644 index 0000000000..995223cb12 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/SyncTimelines/index.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import Button from '@webapp/ui/Button'; +import { TimelineData } from '@webapp/components/TimelineChart/TimelineChartWrapper'; +import StatusMessage from '@webapp/ui/StatusMessage'; +import { useSync } from './useSync'; +import styles from './styles.module.scss'; + +interface SyncTimelinesProps { + timeline: TimelineData; + leftSelection: { + from: string; + to: string; + }; + rightSelection: { + from: string; + to: string; + }; + onSync: (from: string, until: string) => void; +} + +function SyncTimelines({ + timeline, + leftSelection, + rightSelection, + onSync, +}: SyncTimelinesProps) { + const { isWarningHidden, onIgnore, title, onSyncClick } = useSync({ + timeline, + leftSelection, + rightSelection, + onSync, + }); + + if (isWarningHidden) { + return null; + } + + return ( + + + + + } + /> + ); +} + +export default SyncTimelines; diff --git a/webapp/javascript/components/TimelineChart/SyncTimelines/styles.module.scss b/webapp/javascript/components/TimelineChart/SyncTimelines/styles.module.scss new file mode 100644 index 0000000000..fb87c12449 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/SyncTimelines/styles.module.scss @@ -0,0 +1,14 @@ +.buttons { + display: flex; + align-items: center; + flex-direction: row; +} + +.syncButton { + margin-left: 0.5em; + font-weight: normal; +} + +.ignoreButton { + @extend .syncButton; +} diff --git a/webapp/javascript/components/TimelineChart/SyncTimelines/useSync.ts b/webapp/javascript/components/TimelineChart/SyncTimelines/useSync.ts new file mode 100644 index 0000000000..ae56ab0edc --- /dev/null +++ b/webapp/javascript/components/TimelineChart/SyncTimelines/useSync.ts @@ -0,0 +1,119 @@ +import { useEffect, useState } from 'react'; +import { centerTimelineData } from '@webapp/components/TimelineChart/centerTimelineData'; +import { TimelineData } from '@webapp/components/TimelineChart/TimelineChartWrapper'; +import { formatAsOBject } from '@webapp/util/formatDate'; +import { Selection } from '../markings'; + +interface UseSyncParams { + timeline: TimelineData; + leftSelection: { + from: string; + to: string; + }; + rightSelection: { + from: string; + to: string; + }; + onSync: (from: string, until: string) => void; +} + +const timeOffset = 5 * 60 * 1000; +const selectionOffset = 5000; + +export const getTitle = (leftInRange: boolean, rightInRange: boolean) => { + if (!leftInRange && !rightInRange) { + return 'Warning: Baseline and Comparison timeline selections are out of range'; + } + if (!rightInRange) { + return 'Warning: Comparison timeline selection is out of range'; + } + return 'Warning: Baseline timeline selection is out of range'; +}; + +const isInRange = ( + from: number, + to: number, + selectionFrom: number, + selectionTo: number +) => { + return selectionFrom + timeOffset >= from && selectionTo - timeOffset <= to; +}; + +export const getSelectionBoundaries = (selection: Selection) => { + if (selection.from.startsWith('now') || selection.to.startsWith('now')) { + return { + from: new Date(formatAsOBject(selection.from)).getTime(), + to: new Date(formatAsOBject(selection.to)).getTime(), + }; + } + + return { + from: Number(selection.from) * 1000, + to: Number(selection.to) * 1000, + }; +}; + +export function useSync({ + timeline, + leftSelection, + rightSelection, + onSync, +}: UseSyncParams) { + const [isIgnoring, setIgnoring] = useState(false); + + useEffect(() => { + if (isIgnoring) { + setIgnoring(false); + } + }, [leftSelection, rightSelection, timeline]); + + const { from: leftFrom, to: leftTo } = getSelectionBoundaries( + leftSelection as Selection + ); + + const { from: rightFrom, to: rightTo } = getSelectionBoundaries( + rightSelection as Selection + ); + + const centeredData = centerTimelineData(timeline); + + const timelineFrom = centeredData?.[0]?.[0]; + const timelineTo = centeredData?.[centeredData?.length - 1]?.[0]; + + const isLeftInRange = isInRange(timelineFrom, timelineTo, leftFrom, leftTo); + const isRightInRange = isInRange( + timelineFrom, + timelineTo, + rightFrom, + rightTo + ); + + const onSyncClick = () => { + const selectionsLimits = [leftFrom, leftTo, rightFrom, rightTo]; + const selectionMin = Math.min(...selectionsLimits); + const selectionMax = Math.max(...selectionsLimits); + // when some of selection is in relative time (now, now-1h etc.), we have to extend detecting time buffer + // 1) to prevent falsy detections + // 2) to workaraund pecularity that when we change selection we don't refetch main timeline + const offset = [ + leftSelection.from, + rightSelection.from, + leftSelection.to, + rightSelection.to, + ].some((p) => String(p).startsWith('now')) + ? selectionOffset + : 1; + + onSync(String(selectionMin - offset), String(selectionMax + offset)); + }; + + return { + isWarningHidden: + !timeline.data?.samples.length || + (isLeftInRange && isRightInRange) || + isIgnoring, + title: getTitle(isLeftInRange, isRightInRange), + onIgnore: () => setIgnoring(true), + onSyncClick, + }; +} diff --git a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx index 03e3ed8609..ae39365790 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx +++ b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx @@ -13,6 +13,7 @@ import TimelineChart from './TimelineChart'; import styles from './TimelineChartWrapper.module.css'; import { markingsFromSelection, ANNOTATION_COLOR } from './markings'; import { ContextMenuProps } from './ContextMenu.plugin'; +import { centerTimelineData } from './centerTimelineData'; export interface TimelineGroupData { data: Group; @@ -20,7 +21,7 @@ export interface TimelineGroupData { color?: Color; } -interface TimelineData { +export interface TimelineData { data?: Timeline; color?: string; } @@ -495,28 +496,4 @@ function areTimelinesTheSame( return smallest.samples.every((a) => map.has(a)); } -// Since profiling data is chuked by 10 seconds slices -// it's more user friendly to point a `center` of a data chunk -// as a bar rather than starting point, so we add 5 seconds to each chunk to 'center' it -function centerTimelineData(timelineData: TimelineData) { - return timelineData.data - ? decodeTimelineData(timelineData.data).map((x) => [ - x[0] + 5000, - x[1] === 0 ? 0 : x[1] - 1, - ]) - : [[]]; -} - -function decodeTimelineData(timeline: Timeline) { - if (!timeline) { - return []; - } - let time = timeline.startTime; - return timeline.samples.map((x) => { - const res = [time * 1000, x]; - time += timeline.durationDelta; - return res; - }); -} - export default TimelineChartWrapper; diff --git a/webapp/javascript/components/TimelineChart/centerTimelineData.ts b/webapp/javascript/components/TimelineChart/centerTimelineData.ts new file mode 100644 index 0000000000..0eebc3f0ee --- /dev/null +++ b/webapp/javascript/components/TimelineChart/centerTimelineData.ts @@ -0,0 +1,30 @@ +import type { Timeline } from '@webapp/models/timeline'; + +export interface TimelineData { + data?: Timeline; + color?: string; +} + +function decodeTimelineData(timeline: Timeline) { + if (!timeline) { + return []; + } + let time = timeline.startTime; + return timeline.samples.map((x) => { + const res = [time * 1000, x]; + time += timeline.durationDelta; + return res; + }); +} + +// Since profiling data is chuked by 10 seconds slices +// it's more user friendly to point a `center` of a data chunk +// as a bar rather than starting point, so we add 5 seconds to each chunk to 'center' it +export function centerTimelineData(timelineData: TimelineData) { + return timelineData.data + ? decodeTimelineData(timelineData.data).map((x) => [ + x[0] + 5000, + x[1] === 0 ? 0 : x[1] - 1, + ]) + : [[]]; +} diff --git a/webapp/javascript/components/TimelineChart/markings.ts b/webapp/javascript/components/TimelineChart/markings.ts index 85703f9a57..8667c1db02 100644 --- a/webapp/javascript/components/TimelineChart/markings.ts +++ b/webapp/javascript/components/TimelineChart/markings.ts @@ -17,7 +17,7 @@ type FlotMarkings = { }[]; // Unify these types -interface Selection { +export interface Selection { from: string; to: string; color: Color; diff --git a/webapp/javascript/pages/ContinuousComparisonView.tsx b/webapp/javascript/pages/ContinuousComparisonView.tsx index 6f750e6c75..efe8810fb6 100644 --- a/webapp/javascript/pages/ContinuousComparisonView.tsx +++ b/webapp/javascript/pages/ContinuousComparisonView.tsx @@ -13,6 +13,7 @@ import { selectQueries, } from '@webapp/redux/reducers/continuous'; import TimelineChartWrapper from '@webapp/components/TimelineChart/TimelineChartWrapper'; +import SyncTimelines from '@webapp/components/TimelineChart/SyncTimelines'; import Toolbar from '@webapp/components/Toolbar'; import ExportData from '@webapp/components/ExportData'; import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; @@ -129,6 +130,14 @@ function ComparisonApp() { } selectionType="double" /> + { + dispatch(actions.setFromAndUntil({ from, until })); + }} + />
} /> + { + dispatch(actions.setFromAndUntil({ from, until })); + }} + />
diff --git a/webapp/javascript/ui/Button.module.scss b/webapp/javascript/ui/Button.module.scss index df7f7c29c6..233fef75ce 100644 --- a/webapp/javascript/ui/Button.module.scss +++ b/webapp/javascript/ui/Button.module.scss @@ -92,6 +92,15 @@ $border-radius: 4px; } } +.outline { + @extend .default; + background-color: transparent; + + &:hover:not(:disabled) { + background-color: rgba(144, 202, 249, 0.08); + } +} + // only the first and last elements should have styling .grouped { margin: 0; diff --git a/webapp/javascript/ui/Button.tsx b/webapp/javascript/ui/Button.tsx index a4e4008889..9bdcc94efe 100644 --- a/webapp/javascript/ui/Button.tsx +++ b/webapp/javascript/ui/Button.tsx @@ -5,7 +5,8 @@ import cx from 'classnames'; import styles from './Button.module.scss'; export interface ButtonProps { - kind?: 'default' | 'primary' | 'secondary' | 'danger' | 'float'; + kind?: 'default' | 'primary' | 'secondary' | 'danger' | 'outline' | 'float'; + // kind?: 'default' | 'primary' | 'secondary' | 'danger' | 'float' | 'outline'; /** Whether the button is disabled or not */ disabled?: boolean; icon?: IconDefinition; @@ -106,6 +107,10 @@ function getKindStyles(kind: ButtonProps['kind']) { return styles.danger; } + case 'outline': { + return styles.outline; + } + case 'float': { return styles.float; } diff --git a/webapp/javascript/ui/StatusMessage/StatusMessage.module.css b/webapp/javascript/ui/StatusMessage/StatusMessage.module.css deleted file mode 100644 index 28f5270a61..0000000000 --- a/webapp/javascript/ui/StatusMessage/StatusMessage.module.css +++ /dev/null @@ -1,19 +0,0 @@ -.statusMessage { - width: 100%; -} - -.success { - color: var(--ps-neutral-2); - background-color: var(--ps-green-disabled); - padding: 10px; - margin: 1em 0; - padding: 1em; -} - -.error { - color: var(--ps-neutral-2); - font-weight: bold; - background-color: var(--ps-red-primary); - margin: 1em 0; - padding: 1em; -} diff --git a/webapp/javascript/ui/StatusMessage/StatusMessage.module.scss b/webapp/javascript/ui/StatusMessage/StatusMessage.module.scss new file mode 100644 index 0000000000..224325ab2c --- /dev/null +++ b/webapp/javascript/ui/StatusMessage/StatusMessage.module.scss @@ -0,0 +1,33 @@ +.statusMessage { + width: 100%; + display: flex; + flex-direction: row; + align-items: center; + justify-content: space-between; + margin: 1em 0; + padding: 0.5em; +} + +.success { + color: var(--ps-neutral-2); + background-color: var(--ps-green-disabled); + padding: 10px; +} + +.error { + color: var(--ps-neutral-2); + font-weight: bold; + background-color: var(--ps-red-primary); +} + +.warning { + background-color: #f2cd51; + color: var(--ps-immutable-placeholder-text); + font-weight: bold; +} + +.action { + &:empty { + display: none; + } +} diff --git a/webapp/javascript/ui/StatusMessage/index.tsx b/webapp/javascript/ui/StatusMessage/index.tsx index ef92947a89..fe6cd6106f 100644 --- a/webapp/javascript/ui/StatusMessage/index.tsx +++ b/webapp/javascript/ui/StatusMessage/index.tsx @@ -1,19 +1,26 @@ import React from 'react'; import cx from 'classnames'; -import styles from './StatusMessage.module.css'; +import styles from './StatusMessage.module.scss'; interface StatusMessageProps { - type: 'error' | 'success'; + type: 'error' | 'success' | 'warning'; message: string; + action?: React.ReactNode; } -export default function StatusMessage({ type, message }: StatusMessageProps) { +export default function StatusMessage({ + type, + message, + action, +}: StatusMessageProps) { const getClassnameForType = () => { switch (type) { case 'error': return styles.error; case 'success': return styles.success; + case 'warning': + return styles.warning; default: return styles.error; } @@ -26,7 +33,8 @@ export default function StatusMessage({ type, message }: StatusMessageProps) { [getClassnameForType()]: true, })} > - {message} +
{message}
+
{action}
) : null; }