diff --git a/.pnp.cjs b/.pnp.cjs index 9ace35e28..e46474ff1 100755 --- a/.pnp.cjs +++ b/.pnp.cjs @@ -6674,6 +6674,7 @@ const RAW_RUNTIME_STATE = ["prop-types", "npm:15.8.1"],\ ["ramda", "npm:0.27.1"],\ ["react", "npm:17.0.2"],\ + ["react-calendar", "virtual:b7c775051d99785ec73273707ec685f06b84c737b39cc023ebc60bda25254288f27e86643fb15b7a00bd8a6d76cc119d4628443d858e76eb62af2718d7eb18cf#npm:4.8.0"],\ ["react-contextmenu", "virtual:b7c775051d99785ec73273707ec685f06b84c737b39cc023ebc60bda25254288f27e86643fb15b7a00bd8a6d76cc119d4628443d858e76eb62af2718d7eb18cf#npm:2.14.0"],\ ["react-dom", "virtual:455cc1315669a6e622038e6093381f8d95ab8bb473af09abaaf7e72fce2d6a9ff2602fe53215abe1948a8259d689b4411a17531ea4acc5522a659c642ee7696d#npm:17.0.2"],\ ["react-highlight-words", "virtual:b7c775051d99785ec73273707ec685f06b84c737b39cc023ebc60bda25254288f27e86643fb15b7a00bd8a6d76cc119d4628443d858e76eb62af2718d7eb18cf#npm:0.20.0"],\ @@ -12402,6 +12403,15 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["@wojtekmaj/date-utils", [\ + ["npm:1.5.1", {\ + "packageLocation": "./.yarn/cache/@wojtekmaj-date-utils-npm-1.5.1-e21d58f022-73dced08ab.zip/node_modules/@wojtekmaj/date-utils/",\ + "packageDependencies": [\ + ["@wojtekmaj/date-utils", "npm:1.5.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["@xtuc/ieee754", [\ ["npm:1.2.0", {\ "packageLocation": "./.yarn/cache/@xtuc-ieee754-npm-1.2.0-ec0ce4e025-ab033b0329.zip/node_modules/@xtuc/ieee754/",\ @@ -14914,6 +14924,13 @@ const RAW_RUNTIME_STATE = ["clsx", "npm:1.2.1"]\ ],\ "linkType": "HARD"\ + }],\ + ["npm:2.1.1", {\ + "packageLocation": "./.yarn/cache/clsx-npm-2.1.1-96125b98be-cdfb57fa6c.zip/node_modules/clsx/",\ + "packageDependencies": [\ + ["clsx", "npm:2.1.1"]\ + ],\ + "linkType": "HARD"\ }]\ ]],\ ["co", [\ @@ -18984,6 +19001,16 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["get-user-locale", [\ + ["npm:2.3.2", {\ + "packageLocation": "./.yarn/cache/get-user-locale-npm-2.3.2-3b463b4839-58c1dd99b8.zip/node_modules/get-user-locale/",\ + "packageDependencies": [\ + ["get-user-locale", "npm:2.3.2"],\ + ["mem", "npm:8.1.1"]\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["get-value", [\ ["npm:2.0.6", {\ "packageLocation": "./.yarn/cache/get-value-npm-2.0.6-03cd422e0a-5c3b99cb53.zip/node_modules/get-value/",\ @@ -25627,6 +25654,37 @@ const RAW_RUNTIME_STATE = "linkType": "HARD"\ }]\ ]],\ + ["react-calendar", [\ + ["npm:4.8.0", {\ + "packageLocation": "./.yarn/cache/react-calendar-npm-4.8.0-daa5462842-26a58d9bbe.zip/node_modules/react-calendar/",\ + "packageDependencies": [\ + ["react-calendar", "npm:4.8.0"]\ + ],\ + "linkType": "SOFT"\ + }],\ + ["virtual:b7c775051d99785ec73273707ec685f06b84c737b39cc023ebc60bda25254288f27e86643fb15b7a00bd8a6d76cc119d4628443d858e76eb62af2718d7eb18cf#npm:4.8.0", {\ + "packageLocation": "./.yarn/__virtual__/react-calendar-virtual-a36b69e08e/0/cache/react-calendar-npm-4.8.0-daa5462842-26a58d9bbe.zip/node_modules/react-calendar/",\ + "packageDependencies": [\ + ["react-calendar", "virtual:b7c775051d99785ec73273707ec685f06b84c737b39cc023ebc60bda25254288f27e86643fb15b7a00bd8a6d76cc119d4628443d858e76eb62af2718d7eb18cf#npm:4.8.0"],\ + ["@types/react", "npm:17.0.2"],\ + ["@types/react-dom", "npm:16.9.8"],\ + ["@wojtekmaj/date-utils", "npm:1.5.1"],\ + ["clsx", "npm:2.1.1"],\ + ["get-user-locale", "npm:2.3.2"],\ + ["prop-types", "npm:15.8.1"],\ + ["react", "npm:17.0.2"],\ + ["react-dom", "virtual:455cc1315669a6e622038e6093381f8d95ab8bb473af09abaaf7e72fce2d6a9ff2602fe53215abe1948a8259d689b4411a17531ea4acc5522a659c642ee7696d#npm:17.0.2"],\ + ["warning", "npm:4.0.3"]\ + ],\ + "packagePeers": [\ + "@types/react-dom",\ + "@types/react",\ + "react-dom",\ + "react"\ + ],\ + "linkType": "HARD"\ + }]\ + ]],\ ["react-contextmenu", [\ ["npm:2.14.0", {\ "packageLocation": "./.yarn/cache/react-contextmenu-npm-2.14.0-53d23b7cab-9824f5f143.zip/node_modules/react-contextmenu/",\ diff --git a/.yarn/cache/@wojtekmaj-date-utils-npm-1.5.1-e21d58f022-73dced08ab.zip b/.yarn/cache/@wojtekmaj-date-utils-npm-1.5.1-e21d58f022-73dced08ab.zip new file mode 100644 index 000000000..09e7c4b7b Binary files /dev/null and b/.yarn/cache/@wojtekmaj-date-utils-npm-1.5.1-e21d58f022-73dced08ab.zip differ diff --git a/.yarn/cache/clsx-npm-2.1.1-96125b98be-cdfb57fa6c.zip b/.yarn/cache/clsx-npm-2.1.1-96125b98be-cdfb57fa6c.zip new file mode 100644 index 000000000..74c4aaae6 Binary files /dev/null and b/.yarn/cache/clsx-npm-2.1.1-96125b98be-cdfb57fa6c.zip differ diff --git a/.yarn/cache/get-user-locale-npm-2.3.2-3b463b4839-58c1dd99b8.zip b/.yarn/cache/get-user-locale-npm-2.3.2-3b463b4839-58c1dd99b8.zip new file mode 100644 index 000000000..6fddb3fad Binary files /dev/null and b/.yarn/cache/get-user-locale-npm-2.3.2-3b463b4839-58c1dd99b8.zip differ diff --git a/.yarn/cache/react-calendar-npm-4.8.0-daa5462842-26a58d9bbe.zip b/.yarn/cache/react-calendar-npm-4.8.0-daa5462842-26a58d9bbe.zip new file mode 100644 index 000000000..b260212c1 Binary files /dev/null and b/.yarn/cache/react-calendar-npm-4.8.0-daa5462842-26a58d9bbe.zip differ diff --git a/.yarn/cache/uplot-npm-1.6.31-416b7ecff6-8a24bed5c5.zip b/.yarn/cache/uplot-npm-1.6.31-416b7ecff6-8a24bed5c5.zip index fdf644c62..fd3c296a8 100644 Binary files a/.yarn/cache/uplot-npm-1.6.31-416b7ecff6-8a24bed5c5.zip and b/.yarn/cache/uplot-npm-1.6.31-416b7ecff6-8a24bed5c5.zip differ diff --git a/.yarnrc.yml b/.yarnrc.yml index f86caaee5..005519637 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -1,3 +1,5 @@ +checksumBehavior: update + compressionLevel: mixed enableGlobalCache: false diff --git a/packages/web-console/package.json b/packages/web-console/package.json index 879a13535..d9b8f8d4c 100644 --- a/packages/web-console/package.json +++ b/packages/web-console/package.json @@ -68,6 +68,7 @@ "prop-types": "^15.8.1", "ramda": "0.27.1", "react": "17.0.2", + "react-calendar": "^4.0.0", "react-contextmenu": "2.14.0", "react-dom": "17.0.2", "react-highlight-words": "^0.20.0", diff --git a/packages/web-console/src/components/Calendar/index.tsx b/packages/web-console/src/components/Calendar/index.tsx new file mode 100644 index 000000000..e659ea085 --- /dev/null +++ b/packages/web-console/src/components/Calendar/index.tsx @@ -0,0 +1,34 @@ +import React from "react" +import ReactCalendar from "react-calendar" +import type { CalendarProps } from "react-calendar" +import { LooseValue } from "react-calendar/dist/cjs/shared/types" + +type Props = { + className?: string + min: Date + max: Date + value: LooseValue | undefined + selectRange: boolean + onChange: (value: CalendarProps["value"]) => void +} + +export const Calendar = ({ + className, + min, + max, + value, + onChange, + selectRange, +}: Props) => ( + onChange(date)} + selectRange={selectRange} + /> +) diff --git a/packages/web-console/src/scenes/Editor/Metrics/date-time-picker.tsx b/packages/web-console/src/scenes/Editor/Metrics/date-time-picker.tsx new file mode 100644 index 000000000..9381457dc --- /dev/null +++ b/packages/web-console/src/scenes/Editor/Metrics/date-time-picker.tsx @@ -0,0 +1,290 @@ +import React, { useState } from "react" +import styled from "styled-components" +import { + metricDurations, + durationToHumanReadable, + durationTokenToDate, + isDateToken, +} from "./utils" +import { Box, Button, Popover } from "@questdb/react-components" +import { + Calendar as CalendarIcon, + Time, + World, +} from "@styled-icons/boxicons-regular" +import { ArrowDropDown, ArrowDropUp } from "@styled-icons/remix-line" +import { Text } from "../../../components/Text" +import { getLocalTimeZone, getLocalGMTOffset } from "../../../utils/dateTime" +import { Form } from "../../../components/Form" +import { Calendar } from "../../../components/Calendar" +import { formatISO, subMonths } from "date-fns" +import { useFormContext } from "react-hook-form" +import Joi from "joi" + +const Root = styled(Box).attrs({ + gap: "1rem", + flexDirection: "column", + align: "flex-start", +})` + background: ${({ theme }) => theme.color.backgroundDarker}; + width: 50rem; + padding: 1rem 1rem 0 1rem; +` + +const Cols = styled(Box).attrs({ gap: 0, align: "flex-start" })` + width: 100%; +` + +const Trigger = styled(Button)` + padding-right: 0; +` + +const DatePickers = styled(Box).attrs({ + flexDirection: "column", + gap: "1rem", + align: "flex-start", +})` + width: 60%; + align-self: flex-start; + padding-right: 1rem; +` + +const MetricDurations = styled.ul` + width: 40%; + list-style: none; + margin: 0; + padding: 0 0 0 1rem; + border-left: 1px solid ${({ theme }) => theme.color.selection}; +` + +const MetricDurationItem = styled.li<{ selected?: boolean }>` + cursor: pointer; + height: 3rem; + padding: 0 1rem; + line-height: 3rem; + + &:hover { + background: ${({ theme }) => theme.color.selection}; + } + + ${({ selected, theme }) => + selected && `& { background: ${theme.color.selection}; }`} +` + +const Footer = styled(Box).attrs({ + gap: 0, + align: "center", +})` + width: 100%; + padding: 1rem 0; + border-top: 1px solid ${({ theme }) => theme.color.selection}; +` + +const DatePickerItem = ({ + min, + max, + name, + label, + onChange, +}: { + min: Date + max: Date + name: string + label: string + onChange: (values: string[]) => void +}) => { + const { getValues, setValue } = useFormContext() + + const values = getValues() + return ( + + + + + {" "} + {" "} + + } + align="center" + > + { + const vals = values as string[] + + ;["dateFrom", "dateTo"].forEach((name, index) => { + if (values && vals[index]) { + setValue(name, vals[index]) + } + }) + + onChange(vals) + }} + value={[ + new Date(durationTokenToDate(values.dateFrom)), + new Date(durationTokenToDate(values.dateTo)), + ]} + selectRange={true} + /> + + + + ) +} + +type FormValues = { + dateFrom: string + dateTo: string +} + +export const DateTimePicker = ({ + dateFrom, + dateTo, + onDateFromToChange, +}: { + dateFrom: string + dateTo: string + // This can be either a date string or something like `now-2h` which does not exist on the list + onDateFromToChange: (dateFrom: string, dateTo: string) => void +}) => { + const [mainOpen, setMainOpen] = useState(false) + const [currentFrom, setCurrentFrom] = useState(dateFrom) + const [currentTo, setCurrentTo] = useState(dateTo) + + const handleSubmit = async (values: FormValues) => { + if (values.dateFrom && values.dateTo) { + onDateFromToChange( + isDateToken(values.dateFrom) + ? values.dateFrom + : formatISO(values.dateFrom), + isDateToken(values.dateTo) ? values.dateTo : formatISO(values.dateTo), + ) + setMainOpen(false) + } + } + + const min = subMonths(new Date(), 12) + const max = new Date() + + const schema = Joi.object({ + dateFrom: Joi.any() + .required() + .custom((value, helpers) => { + const dateValue = durationTokenToDate(value) + if (dateValue === "Invalid date") { + return helpers.error("string.invalidDate") + } else if ( + new Date(dateValue).getTime() >= new Date(currentTo).getTime() + ) { + return helpers.error("string.fromIsAfterTo") + } + return value + }) + .messages({ + "string.empty": "Please enter a date or duration", + "string.invalidDate": "Date format or duration is invalid", + "string.fromIsAfterTo": "From date must be before To date", + }), + dateTo: Joi.any() + .required() + .custom((value, helpers) => { + const dateValue = durationTokenToDate(value) + if (dateValue === "Invalid date") { + return helpers.error("string.invalidDate") + } else if ( + new Date(dateValue).getTime() <= new Date(currentFrom).getTime() + ) { + return helpers.error("string.toIsBeforeFrom") + } + return value + }) + .messages({ + "string.empty": "Please enter a date or duration", + "string.invalidDate": "Date format or duration is invalid", + "string.toIsBeforeFrom": "To date must be after From date", + }), + }) + + const setCurrentRange = (values: string[]) => { + setCurrentFrom(values[0]) + setCurrentTo(values[1]) + } + + return ( + }> + {durationToHumanReadable(dateFrom, dateTo)} + {mainOpen ? ( + + ) : ( + + )} + + } + > + + + + + Absolute time range + +
+ + + + Apply + +
+
+ + {Object.values(metricDurations).map( + ({ label, dateFrom: mFrom, dateTo: mTo }) => ( + { + onDateFromToChange(mFrom, mTo) + setMainOpen(false) + }} + > + {label} + + ), + )} + +
+
+ + + + {getLocalTimeZone()} ({getLocalGMTOffset()}) + + +
+
+
+ ) +} diff --git a/packages/web-console/src/scenes/Editor/Metrics/graph.tsx b/packages/web-console/src/scenes/Editor/Metrics/graph.tsx index cd1d6b9c9..1fb9aea49 100644 --- a/packages/web-console/src/scenes/Editor/Metrics/graph.tsx +++ b/packages/web-console/src/scenes/Editor/Metrics/graph.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useRef, useState } from "react" +import React, { useEffect, useRef } from "react" import styled from "styled-components" -import { MetricDuration, Widget, xAxisFormat, hasData } from "./utils" +import { Widget, hasData, getXAxisFormat } from "./utils" import { useGraphOptions } from "./useGraphOptions" import uPlot from "uplot" import UplotReact from "uplot-react" @@ -71,8 +71,8 @@ const LabelValue = styled.span` ` type Props = { - dateFrom: Date - dateTo: Date + dateFrom: string + dateTo: string tableId?: number tableName?: string beforeLabel?: React.ReactNode @@ -80,7 +80,6 @@ type Props = { data: uPlot.AlignedData canZoomToData?: boolean colors: string[] - duration: MetricDuration actions?: React.ReactNode onZoomToData?: () => void widgetConfig: Widget @@ -95,7 +94,6 @@ export const Graph = ({ data, canZoomToData, colors, - duration, loading, actions, onZoomToData, @@ -119,10 +117,9 @@ export const Graph = ({ dateFrom, dateTo, colors, - duration, timeRef, valueRef, - mapXValue: xAxisFormat[duration], + mapXValue: (rawValue) => getXAxisFormat(rawValue, dateFrom, dateTo), mapYValue, }) diff --git a/packages/web-console/src/scenes/Editor/Metrics/index.tsx b/packages/web-console/src/scenes/Editor/Metrics/index.tsx index a4adbb4da..ffe09482a 100644 --- a/packages/web-console/src/scenes/Editor/Metrics/index.tsx +++ b/packages/web-console/src/scenes/Editor/Metrics/index.tsx @@ -4,23 +4,15 @@ import { Box, Button, Select } from "@questdb/react-components" import { Text, Link } from "../../../components" import { useEditor } from "../../../providers" import { - MetricDuration, RefreshRate, - autoRefreshRates, refreshRatesInSeconds, getRollingAppendRowLimit, MetricViewMode, FetchMode, - durationInMinutes, MetricsRefreshPayload, + getAutoRefreshRate, } from "./utils" -import { - GridAlt, - Menu, - Time, - Refresh, - World, -} from "@styled-icons/boxicons-regular" +import { GridAlt, Menu, Refresh } from "@styled-icons/boxicons-regular" import { AddMetricDialog } from "./add-metric-dialog" import type { Metric } from "../../../store/buffers" import { Metric as MetricComponent } from "./metric" @@ -29,12 +21,13 @@ import { selectors } from "../../../store" import { ExternalLink } from "@styled-icons/remix-line" import merge from "lodash.merge" import { AddChart } from "@styled-icons/material" -import { getLocalTimeZone } from "../../../utils/dateTime" import { IconWithTooltip } from "../../../components/IconWithTooltip" import { useLocalStorage } from "../../../providers/LocalStorageProvider" import { eventBus } from "../../../modules/EventBus" import { EventType } from "../../../modules/EventBus/types" -import { subMinutes } from "date-fns" +import { formatISO } from "date-fns" +import { DateTimePicker } from "./date-time-picker" +import { ForwardRef } from "@questdb/react-components" const Root = styled.div` display: flex; @@ -94,24 +87,13 @@ const GlobalInfo = styled(Box).attrs({ } ` -const formatDurationLabel = (duration: MetricDuration) => `Last ${duration}` - -const formatRefreshRateLabel = ( - rate: RefreshRate, - duration: MetricDuration, -) => { - if (rate === RefreshRate.AUTO) { - return `${RefreshRate.AUTO} (${autoRefreshRates[duration]})` - } - return rate -} - export const Metrics = () => { const { activeBuffer, updateBuffer, buffers } = useEditor() - const [metricDuration, setMetricDuration] = useState() const [metricViewMode, setMetricViewMode] = useState( MetricViewMode.GRID, ) + const [dateFrom, setDateFrom] = useState(formatISO(new Date())) + const [dateTo, setDateTo] = useState(formatISO(new Date())) const [refreshRate, setRefreshRate] = useState() const [dialogOpen, setDialogOpen] = useState(false) const [metrics, setMetrics] = useState([]) @@ -121,23 +103,21 @@ export const Metrics = () => { const tabInFocusRef = React.useRef(true) const refreshRateRef = React.useRef() const intervalRef = React.useRef() - const metricDurationRef = React.useRef() const { autoRefreshTables } = useLocalStorage() const buffer = buffers.find((b) => b.id === activeBuffer?.id) - const duration = metricDuration || MetricDuration.ONE_HOUR - const refreshRateInSec = refreshRate ? refreshRate === RefreshRate.AUTO - ? refreshRatesInSeconds[autoRefreshRates[duration]] + ? refreshRatesInSeconds[getAutoRefreshRate(dateFrom, dateTo)] : refreshRatesInSeconds[refreshRate] : 0 const rollingAppendLimit = getRollingAppendRowLimit( refreshRateInSec, - duration, + dateFrom, + dateTo, ) const updateMetrics = (metrics: Metric[]) => { @@ -152,13 +132,6 @@ export const Metrics = () => { } const refreshMetricsData = () => { - if (!metricDurationRef?.current) return - const now = new Date() - const dateFrom = subMinutes( - now, - durationInMinutes[metricDurationRef.current], - ) - const dateTo = now eventBus.publish(EventType.METRICS_REFRESH_DATA, { dateFrom, dateTo, @@ -195,6 +168,11 @@ export const Metrics = () => { } } + const handleDateFromToChange = (dateFrom: string, dateTo: string) => { + setDateFrom(dateFrom) + setDateTo(dateTo) + } + const focusListener = useCallback(() => { tabInFocusRef.current = true if (refreshRateRef.current !== RefreshRate.OFF) { @@ -228,16 +206,20 @@ export const Metrics = () => { useEffect(() => { if (buffer) { const metrics = buffer?.metricsViewState?.metrics - const metricDuration = buffer?.metricsViewState?.metricDuration const refreshRate = buffer?.metricsViewState?.refreshRate const metricViewMode = buffer?.metricsViewState?.viewMode + const dateFrom = buffer?.metricsViewState?.dateFrom + const dateTo = buffer?.metricsViewState?.dateTo + if (dateFrom) { + setDateFrom(dateFrom) + } + if (dateTo) { + setDateTo(dateTo) + } if (metrics) { setMetrics(metrics) } - if (metricDuration) { - setMetricDuration(metricDuration) - } if (refreshRate) { setRefreshRate(refreshRate) } @@ -251,30 +233,32 @@ export const Metrics = () => { if (buffer?.id) { const merged = merge(buffer, { metricsViewState: { - ...(metricDuration !== buffer?.metricsViewState?.metricDuration && { - metricDuration, - }), ...(refreshRate !== buffer?.metricsViewState?.refreshRate && { refreshRate, }), ...(metricViewMode !== buffer?.metricsViewState?.viewMode && { viewMode: metricViewMode, }), + ...(dateFrom !== buffer?.metricsViewState?.dateFrom && { + dateFrom, + }), + ...(dateTo !== buffer?.metricsViewState?.dateTo && { + dateTo, + }), }, }) - if (metricDuration) { - metricDurationRef.current = metricDuration + if (dateFrom && dateTo) { if (refreshRate === RefreshRate.AUTO) { setupListeners() } } - if (metricDuration && refreshRate && metricViewMode) { + if (dateFrom && dateTo && refreshRate && metricViewMode) { updateBuffer(buffer.id, merged) setFetchMode(FetchMode.OVERWRITE) refreshMetricsData() } } - }, [metricDuration, refreshRate, metricViewMode]) + }, [refreshRate, metricViewMode, dateFrom, dateTo]) useEffect(() => { if (refreshRate) { @@ -333,10 +317,6 @@ export const Metrics = () => { - - - {getLocalTimeZone()} - { name="refresh_rate" value={refreshRate} options={Object.values(RefreshRate).map((rate) => ({ - label: formatRefreshRateLabel(rate, duration), + label: `Refresh: ${rate}`, value: rate, }))} onChange={(e) => @@ -367,18 +347,13 @@ export const Metrics = () => { ({ - label: formatDurationLabel(duration), - value: duration, - }))} - onChange={(e) => - setMetricDuration(e.target.value as MetricDuration) - } - prefixIcon={