diff --git a/.github/workflows/cypress-tests.yml b/.github/workflows/cypress-tests.yml
index 8621b30cb3..abee3fdafb 100644
--- a/.github/workflows/cypress-tests.yml
+++ b/.github/workflows/cypress-tests.yml
@@ -37,10 +37,6 @@ jobs:
- run: yarn install --frozen-lockfile
- run: make --version
- run: make -j e2e-build
- - uses: actions/cache@v3
- with:
- path: bin/pyroscope
- key: ${{ runner.os }}-pyroscope
- name: Cypress run
uses: cypress-io/github-action@v4
with:
diff --git a/cypress/integration/webapp/annotations.ts b/cypress/integration/webapp/annotations.ts
new file mode 100644
index 0000000000..1c4db7b0e2
--- /dev/null
+++ b/cypress/integration/webapp/annotations.ts
@@ -0,0 +1,51 @@
+describe('Annotations', () => {
+ it('add annotation flow works as expected', () => {
+ const basePath = Cypress.env('basePath') || '';
+ cy.intercept(`${basePath}**/labels*`).as('labels');
+ cy.intercept(`${basePath}**/label-values*`, {
+ fixture: 'appNames.json',
+ }).as('labelValues');
+ cy.intercept('**/render*', {
+ fixture: 'render.json',
+ }).as('render');
+
+ cy.visit('/');
+
+ cy.wait('@labels');
+ cy.wait('@labelValues');
+ cy.wait('@render');
+
+ cy.get('canvas.flot-overlay').click();
+
+ cy.get('li[role=menuitem]').contains('Add annotation').click();
+
+ const content = 'test';
+ let time;
+
+ cy.get('form#annotation-form')
+ .findByTestId('annotation_timestamp_input')
+ .invoke('val')
+ .then((sometext) => (time = sometext));
+
+ cy.get('form#annotation-form')
+ .findByTestId('annotation_content_input')
+ .type(content);
+
+ cy.get('button[form=annotation-form]').click();
+
+ cy.get('div[data-testid="annotation_mark_wrapper"]').click();
+
+ cy.get('form#annotation-form')
+ .findByTestId('annotation_content_input')
+ .should('have.value', content);
+
+ cy.get('form#annotation-form')
+ .findByTestId('annotation_timestamp_input')
+ .invoke('val')
+ .then((sometext2) => assert.isTrue(sometext2 === time));
+
+ cy.get('button[form=annotation-form]').contains('Close').click();
+
+ cy.get('form#annotation-form').should('not.exist');
+ });
+});
diff --git a/webapp/images/comment.svg b/webapp/images/comment.svg
new file mode 100644
index 0000000000..f69f82c8f8
--- /dev/null
+++ b/webapp/images/comment.svg
@@ -0,0 +1,12 @@
+
+
diff --git a/webapp/javascript/components/TimelineChart/Annotation.module.scss b/webapp/javascript/components/TimelineChart/Annotation.module.scss
deleted file mode 100644
index 0314413561..0000000000
--- a/webapp/javascript/components/TimelineChart/Annotation.module.scss
+++ /dev/null
@@ -1,15 +0,0 @@
-.wrapper {
- overflow: hidden;
- max-width: 300px;
- max-height: 125px; // same height as the canvas
-}
-
-.body {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.header {
- font-size: 10px;
-}
diff --git a/webapp/javascript/components/TimelineChart/Annotation.spec.tsx b/webapp/javascript/components/TimelineChart/Annotation.spec.tsx
deleted file mode 100644
index c43b41de32..0000000000
--- a/webapp/javascript/components/TimelineChart/Annotation.spec.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import React from 'react';
-import { render, screen } from '@testing-library/react';
-import AnnotationTooltipBody, { THRESHOLD } from './Annotation';
-
-describe('AnnotationTooltipBody', () => {
- it('return null when theres no annotation', () => {
- const { container } = render(
-
- );
-
- expect(container.querySelector('div')).toBeNull();
- });
-
- it('return nothing when no annotation match', () => {
- const annotations = [
- {
- timestamp: 0,
- content: 'annotation 1',
- },
- ];
- const coordsToCanvasPos = jest.fn();
-
- // reference position
- coordsToCanvasPos.mockReturnValueOnce({ left: 100 });
- // our annotation position, point is to be outside the threshold
- coordsToCanvasPos.mockReturnValueOnce({ left: 100 + THRESHOLD });
-
- const { container } = render(
-
- );
-
- expect(container.querySelector('div')).toBeNull();
- });
-
- describe('rendering annotation', () => {
- it('return an annotation', () => {
- const annotations = [
- {
- timestamp: 1663000000,
- content: 'annotation 1',
- },
- ];
- const coordsToCanvasPos = jest.fn();
-
- // reference position
- coordsToCanvasPos.mockReturnValueOnce({ left: 100 });
-
- render(
-
- );
-
- expect(screen.queryByText(/annotation 1/i)).toBeInTheDocument();
- });
-
- it('renders the closest annotation', () => {
- const furthestAnnotation = {
- timestamp: 1663000010,
- content: 'annotation 1',
- };
- const closestAnnotation = {
- timestamp: 1663000009,
- content: 'annotation closest',
- };
- const annotations = [furthestAnnotation, closestAnnotation];
- const values = [{ closest: [1663000000] }];
- const coordsToCanvasPos = jest.fn();
-
- coordsToCanvasPos.mockImplementation((a) => {
- // our reference point
- if (a.x === furthestAnnotation.timestamp) {
- return { left: 100 };
- }
-
- // closest
- if (a.x === closestAnnotation.timestamp) {
- return { left: 99 };
- }
- });
-
- render(
-
- );
-
- expect(screen.queryByText(/annotation closest/i)).toBeInTheDocument();
- });
- });
-});
diff --git a/webapp/javascript/components/TimelineChart/Annotation.tsx b/webapp/javascript/components/TimelineChart/Annotation.tsx
deleted file mode 100644
index c0ab0b8334..0000000000
--- a/webapp/javascript/components/TimelineChart/Annotation.tsx
+++ /dev/null
@@ -1,92 +0,0 @@
-import React from 'react';
-import { Maybe } from 'true-myth';
-import { format } from 'date-fns';
-import { Annotation } from '@webapp/models/annotation';
-import { getUTCdate, timezoneToOffset } from '@webapp/util/formatDate';
-import styles from './Annotation.module.scss';
-
-// TODO(eh-am): what are these units?
-export const THRESHOLD = 3;
-
-interface AnnotationTooltipBodyProps {
- /** list of annotations */
- annotations: { timestamp: number; content: string }[];
-
- /** given a timestamp, it returns the offset within the canvas */
- coordsToCanvasPos: jquery.flot.axis['p2c'];
-
- /* where in the canvas the mouse is */
- canvasX: number;
-
- timezone: 'browser' | 'utc';
-}
-
-export default function Annotations(props: AnnotationTooltipBodyProps) {
- if (!props.annotations?.length) {
- return null;
- }
-
- return getClosestAnnotation(
- props.annotations,
- props.coordsToCanvasPos,
- props.canvasX
- )
- .map((annotation: Annotation) => (
-
- ))
- .unwrapOr(null);
-}
-
-function AnnotationComponent({
- timestamp,
- content,
- timezone,
-}: {
- timestamp: number;
- content: string;
- timezone: AnnotationTooltipBodyProps['timezone'];
-}) {
- // TODO: these don't account for timezone
- return (
-
-
- {format(
- getUTCdate(new Date(timestamp), timezoneToOffset(timezone)),
- 'yyyy-MM-dd HH:mm'
- )}
-
-
{content}
-
- );
-}
-
-function getClosestAnnotation(
- annotations: { timestamp: number; content: string }[],
- coordsToCanvasPos: AnnotationTooltipBodyProps['coordsToCanvasPos'],
- canvasX: number
-): Maybe {
- if (!annotations.length) {
- return Maybe.nothing();
- }
-
- // pointOffset requires a y position, even though we don't use it
- const dummyY = -1;
-
- // Create a score based on how distant it is from the timestamp
- // Then get the first value (the closest to the timestamp)
- const f = annotations
- .map((a) => ({
- ...a,
- score: Math.abs(
- coordsToCanvasPos({ x: a.timestamp, y: dummyY }).left - canvasX
- ),
- }))
- .filter((a) => a.score < THRESHOLD)
- .sort((a, b) => a.score - b.score);
-
- return Maybe.of(f[0]);
-}
diff --git a/webapp/javascript/components/TimelineChart/AnnotationMark/index.tsx b/webapp/javascript/components/TimelineChart/AnnotationMark/index.tsx
new file mode 100644
index 0000000000..31634d8bd5
--- /dev/null
+++ b/webapp/javascript/components/TimelineChart/AnnotationMark/index.tsx
@@ -0,0 +1,74 @@
+/* eslint-disable default-case, consistent-return */
+import Color from 'color';
+import React, { useState } from 'react';
+import classNames from 'classnames/bind';
+import AnnotationInfo from '@webapp/pages/continuous/contextMenu/AnnotationInfo';
+import useTimeZone from '@webapp/hooks/timeZone.hook';
+
+import styles from './styles.module.scss';
+
+const cx = classNames.bind(styles);
+
+interface IAnnotationMarkProps {
+ type: 'message';
+ color: Color;
+ value: {
+ content: string;
+ timestamp: number;
+ };
+}
+
+const getIcon = (type: IAnnotationMarkProps['type']) => {
+ switch (type) {
+ case 'message':
+ return styles.message;
+ }
+};
+
+const AnnotationMark = ({ type, color, value }: IAnnotationMarkProps) => {
+ const { offset } = useTimeZone();
+ const [visible, setVisible] = useState(false);
+ const [target, setTarget] = useState();
+ const [hovered, setHovered] = useState(false);
+
+ const onClick = (e: React.MouseEvent) => {
+ e.stopPropagation();
+ setTarget(e.target as Element);
+ setVisible(true);
+ };
+
+ const annotationInfoPopover = target ? (
+ setVisible(false)}
+ popoverClassname={styles.form}
+ />
+ ) : null;
+
+ const onHoverStyle = {
+ backgroundColor: hovered ? color.darken(0.2).hex() : color.hex(),
+ zIndex: hovered ? 2 : 1,
+ };
+
+ return (
+ <>
+ setHovered(true)}
+ onMouseLeave={() => setHovered(false)}
+ />
+ {annotationInfoPopover}
+ >
+ );
+};
+
+export default AnnotationMark;
diff --git a/webapp/javascript/components/TimelineChart/AnnotationMark/styles.module.scss b/webapp/javascript/components/TimelineChart/AnnotationMark/styles.module.scss
new file mode 100644
index 0000000000..f15e7f6687
--- /dev/null
+++ b/webapp/javascript/components/TimelineChart/AnnotationMark/styles.module.scss
@@ -0,0 +1,23 @@
+.wrapper {
+ position: relative;
+ width: 18px;
+ height: 18px;
+ left: -9px;
+ top: -7px;
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ background-size: 14px;
+ background-position: center;
+ background-repeat: no-repeat;
+}
+
+.message {
+ background-image: url('../../../../images/comment.svg');
+}
+
+.form {
+ width: 180px;
+}
diff --git a/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx b/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx
new file mode 100644
index 0000000000..20cb58adb3
--- /dev/null
+++ b/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx
@@ -0,0 +1,125 @@
+import React from 'react';
+import * as ReactDOM from 'react-dom';
+import Color from 'color';
+import { Provider } from 'react-redux';
+import store from '@webapp/redux/store';
+import extractRange from './extractRange';
+import AnnotationMark from './AnnotationMark';
+
+type AnnotationType = {
+ content: string;
+ timestamp: number;
+ type: 'message';
+ color: Color;
+};
+
+interface IFlotOptions extends jquery.flot.plotOptions {
+ annotations?: AnnotationType[];
+ wrapperId?: string;
+}
+
+interface IPlot extends jquery.flot.plot, jquery.flot.plotOptions {}
+
+(function ($) {
+ function init(plot: IPlot) {
+ plot.hooks!.draw!.push(renderAnnotationListInTimeline);
+ }
+
+ $.plot.plugins.push({
+ init,
+ options: {},
+ name: 'annotations',
+ version: '1.0',
+ });
+})(jQuery);
+
+function renderAnnotationListInTimeline(
+ plot: IPlot,
+ ctx: CanvasRenderingContext2D
+) {
+ const options: IFlotOptions = plot.getOptions();
+
+ if (options.annotations?.length) {
+ const plotOffset: { top: number; left: number } = plot.getPlotOffset();
+ const extractedX = extractRange(plot, 'x');
+ const extractedY = extractRange(plot, 'y');
+
+ options.annotations.forEach((annotation: AnnotationType) => {
+ const left: number =
+ Math.floor(extractedX.axis.p2c(annotation.timestamp * 1000)) +
+ plotOffset.left;
+
+ renderAnnotationIcon({
+ annotation,
+ options,
+ left,
+ });
+
+ drawAnnotationLine({
+ ctx,
+ yMin: plotOffset.top,
+ yMax:
+ Math.floor(extractedY.axis.p2c(extractedY.axis.min)) + plotOffset.top,
+ left,
+ color: annotation.color,
+ });
+ });
+ }
+}
+
+function drawAnnotationLine({
+ ctx,
+ color,
+ left,
+ yMax,
+ yMin,
+}: {
+ ctx: CanvasRenderingContext2D;
+ color: Color;
+ left: number;
+ yMax: number;
+ yMin: number;
+}) {
+ ctx.beginPath();
+ ctx.strokeStyle = color.hex();
+ ctx.lineWidth = 1;
+ ctx.moveTo(left + 0.5, yMax);
+ ctx.lineTo(left + 0.5, yMin);
+ ctx.stroke();
+}
+
+function renderAnnotationIcon({
+ annotation,
+ options,
+ left,
+}: {
+ annotation: AnnotationType;
+ options: { wrapperId?: string };
+ left: number;
+}) {
+ const annotationMarkElementId =
+ `${options.wrapperId}_annotation_mark_`.concat(
+ String(annotation.timestamp)
+ );
+
+ const annotationMarkElement = $(`#${annotationMarkElementId}`);
+
+ if (!annotationMarkElement.length) {
+ $(
+ `
`
+ ).appendTo(`#${options.wrapperId}`);
+ } else {
+ annotationMarkElement.css({ left });
+ }
+
+ ReactDOM.render(
+
+
+ ,
+ document.getElementById(annotationMarkElementId)
+ );
+}
diff --git a/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx b/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx
index 4cbdf7fdb0..cb92b581d0 100644
--- a/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx
+++ b/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx
@@ -1,12 +1,11 @@
import React from 'react';
import * as ReactDOM from 'react-dom';
import { randomId } from '@webapp/util/randomId';
-import { Provider } from 'react-redux';
-import store from '@webapp/redux/store';
+import { PlotType } from './types';
// Pre calculated once
// TODO(eh-am): does this work with multiple contextMenus?
-const WRAPPER_ID = randomId('contextMenu');
+const WRAPPER_ID = randomId('context_menu');
export interface ContextMenuProps {
click: {
@@ -20,66 +19,50 @@ export interface ContextMenuProps {
}
(function ($: JQueryStatic) {
- function init(plot: jquery.flot.plot & jquery.flot.plotOptions) {
+ function init(plot: jquery.flot.plot & jquery.flot.plotOptions & PlotType) {
+ const placeholder = plot.getPlaceholder();
+
function onClick(
event: unknown,
pos: { x: number; pageX: number; pageY: number }
) {
+ const options: jquery.flot.plotOptions & {
+ ContextMenu?: React.FC
;
+ } = plot.getOptions();
const container = inject($);
const containerEl = container?.[0];
// unmount any previous menus
ReactDOM.unmountComponentAtNode(containerEl);
- // TODO(eh-am): improve typing
- const ContextMenu = (plot.getOptions() as ShamefulAny).ContextMenu as
- | React.FC
- | undefined;
+ const ContextMenu = options?.ContextMenu;
if (ContextMenu && containerEl) {
- // TODO(eh-am): why do we need this conversion?
- const timestamp = Math.round(pos.x / 1000);
-
- // Add a Provider (reux) so that we can communicate with the main app via actions
- // idea from https://stackoverflow.com/questions/52660770/how-to-communicate-reactdom-render-with-other-reactdom-render
- // TODO(eh-am): add a global Context too?
ReactDOM.render(
-
-
- ,
+ ,
containerEl
);
}
}
- const flotEl = plot.getPlaceholder();
-
// Register events and shutdown
// It's important to bind/unbind to the SAME element
// Since a plugin may be register/unregistered multiple times due to react re-rendering
+ plot.hooks.bindEvents.push(function () {
+ placeholder.bind('plotclick', onClick);
+ });
- // TODO: not entirely sure when these are disabled
- if (plot.hooks?.bindEvents) {
- plot.hooks.bindEvents.push(function () {
- flotEl.bind('plotclick', onClick);
- });
- }
-
- if (plot.hooks?.shutdown) {
- plot.hooks.shutdown.push(function () {
- flotEl.unbind('plotclick', onClick);
+ plot.hooks.shutdown.push(function () {
+ placeholder.unbind('plotclick', onClick);
- const container = inject($);
- const containerEl = container?.[0];
+ const container = inject($);
- // unmount any previous menus
- ReactDOM.unmountComponentAtNode(containerEl);
- });
- }
+ ReactDOM.unmountComponentAtNode(container?.[0]);
+ });
}
$.plot.plugins.push({
@@ -90,7 +73,7 @@ export interface ContextMenuProps {
});
})(jQuery);
-const inject = ($: JQueryStatic) => {
+function inject($: JQueryStatic) {
const alreadyInitialized = $(`#${WRAPPER_ID}`).length > 0;
if (alreadyInitialized) {
@@ -99,4 +82,4 @@ const inject = ($: JQueryStatic) => {
const body = $('body');
return $(``).appendTo(body);
-};
+}
diff --git a/webapp/javascript/components/TimelineChart/Selection.plugin.ts b/webapp/javascript/components/TimelineChart/Selection.plugin.ts
index 17ed22094c..6cb359c926 100644
--- a/webapp/javascript/components/TimelineChart/Selection.plugin.ts
+++ b/webapp/javascript/components/TimelineChart/Selection.plugin.ts
@@ -1,13 +1,15 @@
/* eslint-disable */
// extending logic of Flot's selection plugin (react-flot/flot/jquery.flot.selection)
-import { PlotType, CtxType, EventHolderType, EventType } from './types';
+import { PlotType, EventHolderType, EventType } from './types';
import clamp from './clamp';
+import extractRange from './extractRange';
const handleWidth = 4;
const handleHeight = 22;
(function ($) {
function init(plot: PlotType) {
+ const placeholder = plot.getPlaceholder();
var selection = {
first: { x: -1, y: -1 },
second: { x: -1, y: -1 },
@@ -36,7 +38,7 @@ const handleHeight = 22;
function getCursorPositionX(e: EventType) {
const plotOffset = plot.getPlotOffset();
- const offset = plot.getPlaceholder().offset();
+ const offset = placeholder.offset();
return clamp(0, plot.width(), e.pageX - offset.left - plotOffset.left);
}
@@ -44,9 +46,8 @@ const handleHeight = 22;
// unlike function getSelection() which shows temp selection (it doesnt save any data between rerenders)
// this function returns left X and right X coords of visible user selection (translates opts.grid.markings to X coords)
const o = plot.getOptions();
- const axes = plot.getAxes();
const plotOffset = plot.getPlotOffset();
- const extractedX = extractRange(axes, 'x');
+ const extractedX = extractRange(plot as jquery.flot.plot & PlotType, 'x');
return {
left:
@@ -108,7 +109,7 @@ const handleHeight = 22;
setCursor('crosshair');
}
- plot.getPlaceholder().trigger('plotselecting', [getSelection()]);
+ placeholder.trigger('plotselecting', [getSelection()]);
}
}
@@ -137,7 +138,7 @@ const handleHeight = 22;
};
}
- const offset = plot.getPlaceholder().offset();
+ const offset = placeholder.offset();
const plotOffset = plot.getPlotOffset();
const { left, right } = getPlotSelection();
const clickX = getCursorPositionX(e);
@@ -190,8 +191,8 @@ const handleHeight = 22;
if (selectionIsSane()) triggerSelectedEvent();
else {
// this counts as a clear
- plot.getPlaceholder().trigger('plotunselected', []);
- plot.getPlaceholder().trigger('plotselecting', [null]);
+ placeholder.trigger('plotunselected', []);
+ placeholder.trigger('plotselecting', [null]);
}
setCursor('crosshair');
@@ -220,11 +221,11 @@ const handleHeight = 22;
function triggerSelectedEvent() {
var r: any = getSelection();
- plot.getPlaceholder().trigger('plotselected', [r]);
+ placeholder.trigger('plotselected', [r]);
// backwards-compat stuff, to be removed in future
if (r.xaxis && r.yaxis)
- plot.getPlaceholder().trigger('selected', [
+ placeholder.trigger('selected', [
{
x1: r.xaxis.from,
y1: r.yaxis.from,
@@ -236,7 +237,7 @@ const handleHeight = 22;
function setSelectionPos(pos: { x: number; y: number }, e: EventType) {
var o = plot.getOptions();
- var offset = plot.getPlaceholder().offset();
+ var offset = placeholder.offset();
var plotOffset = plot.getPlotOffset();
pos.x = clamp(0, plot.width(), e.pageX - offset.left - plotOffset.left);
pos.y = clamp(0, plot.height(), e.pageY - offset.top - plotOffset.top);
@@ -262,48 +263,10 @@ const handleHeight = 22;
if (selection.show) {
selection.show = false;
plot.triggerRedrawOverlay();
- if (!preventEvent) plot.getPlaceholder().trigger('plotunselected', []);
+ if (!preventEvent) placeholder.trigger('plotunselected', []);
}
}
- // function taken from markings support in Flot
- function extractRange(ranges: { [x: string]: any }, coord: string) {
- var axis,
- from,
- to,
- key,
- axes = plot.getAxes();
-
- for (var k in axes) {
- axis = axes[k];
- if (axis.direction == coord) {
- key = coord + axis.n + 'axis';
- if (!ranges[key] && axis.n == 1) key = coord + 'axis'; // support x1axis as xaxis
- if (ranges[key]) {
- from = ranges[key].from;
- to = ranges[key].to;
- break;
- }
- }
- }
-
- // backwards-compat stuff - to be removed in future
- if (!ranges[key as string]) {
- axis = coord == 'x' ? plot.getXAxes()[0] : plot.getYAxes()[0];
- from = ranges[coord + '1'];
- to = ranges[coord + '2'];
- }
-
- // auto-reverse as an added bonus
- if (from != null && to != null && from > to) {
- var tmp = from;
- from = to;
- to = tmp;
- }
-
- return { from: from, to: to, axis: axis };
- }
-
function setSelection(ranges: any, preventEvent: any) {
var axis,
range,
@@ -313,7 +276,7 @@ const handleHeight = 22;
selection.first.x = 0;
selection.second.x = plot.width();
} else {
- range = extractRange(ranges, 'x');
+ range = extractRange(plot as jquery.flot.plot & PlotType, 'x');
selection.first.x = range.axis.p2c(range.from);
selection.second.x = range.axis.p2c(range.to);
@@ -323,7 +286,7 @@ const handleHeight = 22;
selection.first.y = 0;
selection.second.y = plot.height();
} else {
- range = extractRange(ranges, 'y');
+ range = extractRange(plot as jquery.flot.plot & PlotType, 'y');
selection.first.y = range.axis.p2c(range.from);
selection.second.y = range.axis.p2c(range.to);
@@ -357,7 +320,10 @@ const handleHeight = 22;
}
});
- plot.hooks.drawOverlay.push(function (plot: PlotType, ctx: CtxType) {
+ plot.hooks.drawOverlay.push(function (
+ plot: PlotType,
+ ctx: CanvasRenderingContext2D
+ ) {
// draw selection
if (selection.show && selectionIsSane()) {
const plotOffset = plot.getPlotOffset();
@@ -419,16 +385,21 @@ const handleHeight = 22;
}
});
- plot.hooks.draw.push(function (plot: PlotType, ctx: CtxType) {
+ plot.hooks.draw.push(function (
+ plot: PlotType,
+ ctx: CanvasRenderingContext2D
+ ) {
const opts = plot.getOptions();
if (
opts?.selection?.selectionType === 'single' &&
opts?.selection?.selectionWithHandler
) {
- const axes = plot.getAxes();
const plotOffset = plot.getPlotOffset();
- const extractedY = extractRange(axes, 'y');
+ const extractedY = extractRange(
+ plot as jquery.flot.plot & PlotType,
+ 'y'
+ );
const { left, right } = getPlotSelection();
const yMax =
diff --git a/webapp/javascript/components/TimelineChart/TimelineChart.tsx b/webapp/javascript/components/TimelineChart/TimelineChart.tsx
index 153b400a74..2c75f7aa6c 100644
--- a/webapp/javascript/components/TimelineChart/TimelineChart.tsx
+++ b/webapp/javascript/components/TimelineChart/TimelineChart.tsx
@@ -8,6 +8,7 @@ import './Selection.plugin';
import 'react-flot/flot/jquery.flot.crosshair.min';
import './TimelineChartPlugin';
import './Tooltip.plugin';
+import './Annotations.plugin';
import './ContextMenu.plugin';
interface TimelineChartProps {
diff --git a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css
index c29cd76100..012e213cb6 100644
--- a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css
+++ b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.module.css
@@ -1,3 +1,3 @@
.wrapper {
- overflow: hidden;
+ overflow: visible;
}
diff --git a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx
index 2f5404893f..03e3ed8609 100644
--- a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx
+++ b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx
@@ -10,9 +10,8 @@ import type { ExploreTooltipProps } from '@webapp/components/TimelineChart/Explo
import type { ITooltipWrapperProps } from './TooltipWrapper';
import TooltipWrapper from './TooltipWrapper';
import TimelineChart from './TimelineChart';
-import Annotation from './Annotation';
import styles from './TimelineChartWrapper.module.css';
-import { markingsFromAnnotations, markingsFromSelection } from './markings';
+import { markingsFromSelection, ANNOTATION_COLOR } from './markings';
import { ContextMenuProps } from './ContextMenu.plugin';
export interface TimelineGroupData {
@@ -148,6 +147,7 @@ class TimelineChartWrapper extends React.Component<
// a position and a nearby data item object as parameters.
clickable: true,
},
+ annotations: [],
yaxis: {
show: false,
min: 0,
@@ -203,6 +203,7 @@ class TimelineChartWrapper extends React.Component<
this.state = { flotOptions };
this.state.flotOptions.grid.markings = this.plotMarkings();
+ this.state.flotOptions.annotations = this.composeAnnotationsList();
}
componentDidUpdate(prevProps: TimelineChartWrapperProps) {
@@ -212,10 +213,22 @@ class TimelineChartWrapper extends React.Component<
) {
const newFlotOptions = this.state.flotOptions;
newFlotOptions.grid.markings = this.plotMarkings();
+ newFlotOptions.annotations = this.composeAnnotationsList();
this.setState({ flotOptions: newFlotOptions });
}
}
+ composeAnnotationsList = () => {
+ return Array.isArray(this.props.annotations)
+ ? this.props.annotations?.map((a) => ({
+ timestamp: a.timestamp,
+ content: a.content,
+ type: 'message',
+ color: ANNOTATION_COLOR,
+ }))
+ : [];
+ };
+
plotMarkings = () => {
const selectionMarkings = markingsFromSelection(
this.props.selectionType,
@@ -223,16 +236,13 @@ class TimelineChartWrapper extends React.Component<
this.props.selection?.right
);
- const annotationsMarkings = markingsFromAnnotations(this.props.annotations);
-
- return [...selectionMarkings, ...annotationsMarkings];
+ return [...selectionMarkings];
};
setOnHoverDisplayTooltip = (
data: ITooltipWrapperProps & ExploreTooltipProps
) => {
- const { timezone } = this.props;
- let tooltipContent = [];
+ const tooltipContent = [];
const TooltipBody: React.FC | undefined =
this.props?.onHoverDisplayTooltip;
@@ -247,43 +257,6 @@ class TimelineChartWrapper extends React.Component<
);
}
- // convert to the format we are expecting
- const annotations =
- this.props.annotations?.map((a) => ({
- ...a,
- timestamp: a.timestamp * 1000,
- })) || [];
-
- if (this.props.annotations) {
- if (
- this.props.mode === 'singles' &&
- data.coordsToCanvasPos &&
- data.canvasX
- ) {
- const an = Annotation({
- timezone,
- annotations,
- canvasX: data.canvasX,
- coordsToCanvasPos: data.coordsToCanvasPos,
- });
-
- // if available, only render annotation
- // so that the tooltip is not bloated
- if (an) {
- // Rerender as tsx to make use of key
- tooltipContent = [
- ,
- ];
- }
- }
- }
-
if (tooltipContent.length) {
return (
to) {
+ var tmp = from;
+ from = to;
+ to = tmp;
+ }
+
+ return { from: from, to: to, axis: axis };
+}
diff --git a/webapp/javascript/components/TimelineChart/markings.spec.ts b/webapp/javascript/components/TimelineChart/markings.spec.ts
index a6d40806fc..1445aa4724 100644
--- a/webapp/javascript/components/TimelineChart/markings.spec.ts
+++ b/webapp/javascript/components/TimelineChart/markings.spec.ts
@@ -1,31 +1,5 @@
import Color from 'color';
-import {
- ANNOTATION_COLOR,
- ANNOTATION_WIDTH,
- markingsFromAnnotations,
- markingsFromSelection,
-} from './markings';
-
-describe('markingsFromAnnotations', () => {
- it('works', () => {
- const timestamp = 1663000000;
- const annotations = [
- {
- timestamp,
- },
- ];
- expect(markingsFromAnnotations(annotations)).toStrictEqual([
- {
- lineWidth: ANNOTATION_WIDTH,
- color: ANNOTATION_COLOR,
- xaxis: {
- from: timestamp * 1000,
- to: timestamp * 1000,
- },
- },
- ]);
- });
-});
+import { markingsFromSelection } from './markings';
// Tests are definitely confusing, but that's due to the nature of the implementation
// TODO: refactor implementatino
diff --git a/webapp/javascript/components/TimelineChart/markings.ts b/webapp/javascript/components/TimelineChart/markings.ts
index ff50c123f6..85703f9a57 100644
--- a/webapp/javascript/components/TimelineChart/markings.ts
+++ b/webapp/javascript/components/TimelineChart/markings.ts
@@ -3,7 +3,6 @@ import Color from 'color';
// Same green as button
export const ANNOTATION_COLOR = Color('#2ecc40');
-export const ANNOTATION_WIDTH = '2px';
type FlotMarkings = {
xaxis: {
@@ -17,27 +16,6 @@ type FlotMarkings = {
color: Color;
}[];
-/**
- * generate markings in flotjs format
- */
-export function markingsFromAnnotations(
- annotations?: { timestamp: number }[]
-): FlotMarkings {
- if (!annotations?.length) {
- return [];
- }
-
- return annotations.map((a) => ({
- xaxis: {
- // TODO(eh-am): look this up
- from: a.timestamp * 1000,
- to: a.timestamp * 1000,
- },
- lineWidth: ANNOTATION_WIDTH,
- color: ANNOTATION_COLOR,
- }));
-}
-
// Unify these types
interface Selection {
from: string;
diff --git a/webapp/javascript/components/TimelineChart/types.ts b/webapp/javascript/components/TimelineChart/types.ts
index 7f7cd95722..29a60e92ea 100644
--- a/webapp/javascript/components/TimelineChart/types.ts
+++ b/webapp/javascript/components/TimelineChart/types.ts
@@ -23,18 +23,6 @@ export type PlotType = {
getData: () => ShamefulAny[];
};
-export type CtxType = {
- save: () => void;
- translate: (arg0: ShamefulAny, arg1: ShamefulAny) => void;
- strokeStyle: ShamefulAny;
- lineWidth: number;
- lineJoin: ShamefulAny;
- fillStyle: ShamefulAny;
- fillRect: (arg0: number, arg1: number, arg2: number, arg3: number) => void;
- strokeRect: (arg0: number, arg1: number, arg2: number, arg3: number) => void;
- restore: () => void;
-};
-
export type EventHolderType = {
unbind: (
arg0: string,
diff --git a/webapp/javascript/components/TimelineTitle.tsx b/webapp/javascript/components/TimelineTitle.tsx
index 67bbfe8af5..cef1525270 100644
--- a/webapp/javascript/components/TimelineTitle.tsx
+++ b/webapp/javascript/components/TimelineTitle.tsx
@@ -1,6 +1,6 @@
import React from 'react';
import Color from 'color';
-
+import clsx from 'clsx';
import styles from './TimelineTitle.module.scss';
const unitsToFlamegraphTitle = {
@@ -20,14 +20,16 @@ const unitsToFlamegraphTitle = {
interface TimelineTitleProps {
color?: Color;
titleKey?: keyof typeof unitsToFlamegraphTitle;
+ className?: string;
}
export default function TimelineTitle({
color,
titleKey = '',
+ className,
}: TimelineTitleProps) {
return (
-
+
{color && (
+
}
annotations={annotations}
selectionType="single"
diff --git a/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx b/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx
index c375fdcb7c..76cef5fa26 100644
--- a/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx
+++ b/webapp/javascript/pages/continuous/contextMenu/AddAnnotation.menuitem.tsx
@@ -7,17 +7,13 @@ import {
PopoverFooter,
PopoverHeader,
} from '@webapp/ui/Popover';
-import { format } from 'date-fns';
-import { getUTCdate, timezoneToOffset } from '@webapp/util/formatDate';
import Button from '@webapp/ui/Button';
import { Portal, PortalProps } from '@webapp/ui/Portal';
import { NewAnnotation } from '@webapp/services/annotations';
-import * as z from 'zod';
-import { useForm } from 'react-hook-form';
-import { zodResolver } from '@hookform/resolvers/zod';
import TextField from '@webapp/ui/Form/TextField';
+import { useAnnotationForm } from './useAnnotationForm';
-interface AddAnnotationProps {
+export interface AddAnnotationProps {
/** where to put the popover in the DOM */
container: PortalProps['container'];
@@ -32,10 +28,6 @@ interface AddAnnotationProps {
timezone: 'browser' | 'utc';
}
-const newAnnotationFormSchema = z.object({
- content: z.string().min(1, { message: 'Required' }),
-});
-
function AddAnnotation(props: AddAnnotationProps) {
const {
container,
@@ -45,13 +37,9 @@ function AddAnnotation(props: AddAnnotationProps) {
timezone,
} = props;
const [isPopoverOpen, setPopoverOpen] = useState(false);
- const {
- register,
- handleSubmit,
- formState: { errors },
- setFocus,
- } = useForm({
- resolver: zodResolver(newAnnotationFormSchema),
+ const { register, handleSubmit, errors, setFocus } = useAnnotationForm({
+ timezone,
+ value: { timestamp },
});
// Focus on the only input
@@ -61,6 +49,41 @@ function AddAnnotation(props: AddAnnotationProps) {
}
}, [setFocus, isPopoverOpen]);
+ const popoverContent = isPopoverOpen ? (
+ <>
+
Add annotation
+
+
+
+
+
+
+ >
+ ) : null;
+
return (
<>