diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/domain/response/DistributionResponse.java b/gateway-ha/src/main/java/io/trino/gateway/ha/domain/response/DistributionResponse.java index e4d14c918..b15f461f8 100644 --- a/gateway-ha/src/main/java/io/trino/gateway/ha/domain/response/DistributionResponse.java +++ b/gateway-ha/src/main/java/io/trino/gateway/ha/domain/response/DistributionResponse.java @@ -220,20 +220,20 @@ public void setName(String name) public static class LineChart { - private String minute; + private Long epochMillis; private String backendUrl; private Long queryCount; private String name; @JsonProperty - public String getMinute() + public Long getEpochMillis() { - return minute; + return epochMillis; } - public void setMinute(String minute) + public void setEpochMillis(Long epochMillis) { - this.minute = minute; + this.epochMillis = epochMillis; } @JsonProperty diff --git a/gateway-ha/src/main/java/io/trino/gateway/ha/router/HaQueryHistoryManager.java b/gateway-ha/src/main/java/io/trino/gateway/ha/router/HaQueryHistoryManager.java index b7ea39001..b433336ac 100644 --- a/gateway-ha/src/main/java/io/trino/gateway/ha/router/HaQueryHistoryManager.java +++ b/gateway-ha/src/main/java/io/trino/gateway/ha/router/HaQueryHistoryManager.java @@ -22,9 +22,6 @@ import org.jdbi.v3.core.Jdbi; import java.time.Instant; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -146,9 +143,8 @@ public List findDistribution(Long ts) DistributionResponse.LineChart lineChart = new DistributionResponse.LineChart(); long minute = (long) Float.parseFloat(model.get("minute").toString()); Instant instant = Instant.ofEpochSecond(minute * 60L); - LocalDateTime dateTime = LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm"); - lineChart.setMinute(dateTime.format(formatter)); + long epochMillis = instant.toEpochMilli(); + lineChart.setEpochMillis(epochMillis); lineChart.setQueryCount(Long.parseLong(model.get("query_count").toString())); lineChart.setBackendUrl(model.get("backend_url").toString()); resList.add(lineChart); diff --git a/gateway-ha/src/test/java/io/trino/gateway/ha/TestObjectSerializable.java b/gateway-ha/src/test/java/io/trino/gateway/ha/TestObjectSerializable.java index 5d012604e..53621cdda 100644 --- a/gateway-ha/src/test/java/io/trino/gateway/ha/TestObjectSerializable.java +++ b/gateway-ha/src/test/java/io/trino/gateway/ha/TestObjectSerializable.java @@ -201,7 +201,7 @@ void testDistributionResponse() throws JsonProcessingException { DistributionResponse.LineChart lineChart = new DistributionResponse.LineChart(); - lineChart.setMinute("11:22"); + lineChart.setEpochMillis(1711974896630L); lineChart.setBackendUrl("example.com"); lineChart.setName("name1"); lineChart.setQueryCount(6L); @@ -230,7 +230,7 @@ void testDistributionResponse() "distributionChart", "lineChart", "startTime", - "minute", // LineChart + "epochMillis", // LineChart "backendUrl", "queryCount", "\"name\":\"name1\"", diff --git a/webapp/package.json b/webapp/package.json index 54320e796..1cf7bffce 100644 --- a/webapp/package.json +++ b/webapp/package.json @@ -14,6 +14,7 @@ "@douyinfe/semi-icons-lab": "^2.62.0", "@douyinfe/semi-illustrations": "^2.62.0", "@douyinfe/semi-ui": "^2.62.0", + "@vvo/tzdb": "^6.198.0", "echarts": "^5.4.3", "js-cookie": "^3.0.5", "moment": "^2.29.4", diff --git a/webapp/pnpm-lock.yaml b/webapp/pnpm-lock.yaml index a82782173..28248c4c8 100644 --- a/webapp/pnpm-lock.yaml +++ b/webapp/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@douyinfe/semi-ui': specifier: ^2.62.0 version: 2.83.0(acorn@8.11.3)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + '@vvo/tzdb': + specifier: ^6.198.0 + version: 6.198.0 echarts: specifier: ^5.4.3 version: 5.4.3 @@ -760,6 +763,9 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 + '@vvo/tzdb@6.198.0': + resolution: {integrity: sha512-bNRWBhWYl0edVgyX6AYbhoCM2tk2lXJjGCyO2VDc2xn6Dw8dLd7WGj2DDXkVOkmOIQTNjEAcxrEpIzz5pWVwFg==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -2636,6 +2642,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@vvo/tzdb@6.198.0': {} + acorn-jsx@5.3.2(acorn@8.11.3): dependencies: acorn: 8.11.3 diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 283669b21..341e76ea9 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -16,15 +16,18 @@ import { useEffect } from 'react'; import { getCSSVar } from './utils/utils'; import { IllustrationIdle, IllustrationIdleDark } from '@douyinfe/semi-illustrations'; import Cookies from 'js-cookie'; +import { TimezoneProvider } from "./components/TimezoneContext"; function App() { return ( <> - - - + + + + + diff --git a/webapp/src/components/TimezoneContext.tsx b/webapp/src/components/TimezoneContext.tsx new file mode 100644 index 000000000..aece01857 --- /dev/null +++ b/webapp/src/components/TimezoneContext.tsx @@ -0,0 +1,37 @@ +import { createContext, useCallback, useContext, useState } from "react"; +import { Typography, Select } from "@douyinfe/semi-ui"; +import Locale from "../locales"; +import {getTimeDisplayZoneOptions} from "../utils/time"; + +export const TimezoneContext = createContext<{ + timezone: string; + changeTimezone: (tz: string) => void; +}>({ timezone: 'UTC', changeTimezone: () => {} }); + +export function TimezoneProvider({ children }: { children: React.ReactNode }) { + const [timezone, setTimezone] = useState(() => Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'); + const changeTimezone = useCallback((newTZ: string) => { setTimezone(newTZ) }, []); + const value = ({ timezone, changeTimezone }); + return ( + + {children} + + ); +} + +export function TimezoneDropdown() { + const { timezone, changeTimezone } = useContext(TimezoneContext); + const timeZones = getTimeDisplayZoneOptions(); + return ( + <> + {Locale.Dashboard.TimeZone} + + + ); +} diff --git a/webapp/src/components/dashboard.tsx b/webapp/src/components/dashboard.tsx index 48bd7ae52..ae7eef028 100644 --- a/webapp/src/components/dashboard.tsx +++ b/webapp/src/components/dashboard.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState } from "react"; +import { useContext, useEffect, useRef, useState } from "react"; import Locale from "../locales"; import styles from './dashboard.module.scss'; import * as echarts from "echarts"; @@ -10,11 +10,13 @@ import { IconHelpCircle } from "@douyinfe/semi-icons"; import { useNavigate } from "react-router-dom"; import { hasPagePermission, routersMapper } from "../router"; import { useAccessStore } from "../store"; -import { formatZonedDateTime } from "../utils/time"; +import { formatZonedDateTime, formatZonedTimestamp } from "../utils/time"; +import { TimezoneContext } from "./TimezoneContext"; export function Dashboard() { const access = useAccessStore(); const navigate = useNavigate(); + const timezone = useContext(TimezoneContext).timezone; const [distributionDetail, setDistributionDetail] = useState(); useEffect(() => { @@ -27,7 +29,7 @@ export function Dashboard() { const data = [ { key: Locale.Dashboard.StartTime, - value: distributionDetail && formatZonedDateTime(distributionDetail.startTime) + value: distributionDetail && formatZonedDateTime(distributionDetail.startTime, timezone) }, { key: Locale.Dashboard.Backends, @@ -39,7 +41,6 @@ export function Dashboard() { } }}>{distributionDetail?.totalBackendCount} : {distributionDetail?.totalBackendCount} - }, { key: Locale.Dashboard.BackendsOnline, @@ -111,7 +112,7 @@ export function Dashboard() { - + @@ -143,36 +144,37 @@ function updateLegendColor() { } function LineChart(props: { - data: Record + data: Record, + timeZone: string }) { const chartRef = useRef(null); const legendColor = updateLegendColor(); useEffect(() => { const chartInstance = echarts.init(chartRef.current); - let minMinute = 2400; - let maxMinute = 0; - Object.keys(props.data).forEach(d => { - const lineChartDatas = props.data[d] - const lineChartDataTemp = lineChartDatas.map(lineChartData => parseInt(lineChartData.minute.replace(":", ""))) - const minMinuteTemp = Math.min(...lineChartDataTemp); - const maxMinuteTemp = Math.max(...lineChartDataTemp); - if (minMinuteTemp < minMinute) { - minMinute = minMinuteTemp; - } - if (maxMinuteTemp > maxMinute) { - maxMinute = maxMinuteTemp; - } - }); - const minuteStrings: string[] = []; - for (let i = minMinute; i <= maxMinute; i++) { - if ((i % 100) >= 60) { - continue; - } - const hour = Math.floor(i / 100).toString().padStart(2, "0"); - const minute = (i % 100).toString().padStart(2, "0"); - minuteStrings.push(`${hour}:${minute}`); + + const displayData: Record = Object.fromEntries( + Object.entries(props.data).map(([name, series]) => [ + name, + series.map((item) => ({ + ...item, + })), + ]) + ); + + const timestamps = Object.values(displayData).flat().map(d => Number(d.timestamp)); + let minTimestamp = Math.min(...timestamps); + let maxTimestamp = Math.max(...timestamps); + const xAxisTimeLabels: number[] = []; + const MINUTE = 60 * 1000; + + minTimestamp = Math.floor(minTimestamp / MINUTE) * MINUTE; + maxTimestamp = Math.ceil(maxTimestamp / MINUTE) * MINUTE; + + for (let t = minTimestamp; t <= maxTimestamp; t += MINUTE) { + xAxisTimeLabels.push(t); } + const option = { legend: { textStyle: { @@ -181,7 +183,7 @@ function LineChart(props: { }, xAxis: { type: 'category', - data: minuteStrings + data: xAxisTimeLabels.map(ts => formatZonedTimestamp(ts, props.timeZone)) }, yAxis: { type: 'value', @@ -190,14 +192,16 @@ function LineChart(props: { tooltip: { trigger: 'axis' }, - series: Object.keys(props.data).map(d => { - const lineChartDatas = props.data[d].reduce((obj, item) => { - obj[item.minute] = item.queryCount; - return obj; - }, {} as Record); + series: Object.keys(displayData).map(d => { + const data = displayData[d]; + const count = new Map(); + for (const dataPoint of data) { + const xValueHHMM = Math.floor(Number(dataPoint.timestamp) / MINUTE) * MINUTE; + count.set(xValueHHMM, dataPoint.queryCount); + } return { name: d, - data: minuteStrings.map(m => lineChartDatas[m] || 0), + data: xAxisTimeLabels.map(timeStamp => count.get(timeStamp) || 0), type: 'line', smooth: true } @@ -206,7 +210,7 @@ function LineChart(props: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore chartInstance.setOption(option); - }, [props.data, legendColor]); + }, [props.data, props.timeZone, legendColor]); return (
diff --git a/webapp/src/components/history.tsx b/webapp/src/components/history.tsx index efbd8addf..78fe15b95 100644 --- a/webapp/src/components/history.tsx +++ b/webapp/src/components/history.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useContext, useEffect, useState } from "react"; import styles from './history.module.scss'; import Locale from "../locales"; import { Button, Card, Form, Table, Tag, Modal, Typography, CodeHighlight } from "@douyinfe/semi-ui"; @@ -9,10 +9,12 @@ import { formatTimestamp } from "../utils/time"; import { backendsApi } from "../api/webapp/cluster"; import { Role, useAccessStore } from "../store"; import { BackendData } from "../types/cluster"; +import { TimezoneContext } from "./TimezoneContext"; export function History() { const { Text } = Typography; const access = useAccessStore(); + const timezone = useContext(TimezoneContext).timezone; const [backendData, setBackendData] = useState(); const [historyData, setHistoryData] = useState(); const [backendMapping, setBackendMapping] = useState>({}); @@ -74,7 +76,7 @@ export function History() { const timeRender = (text: number) => { return ( - {formatTimestamp(text)} + {formatTimestamp(text, timezone)} ); } diff --git a/webapp/src/components/layout.tsx b/webapp/src/components/layout.tsx index 3b54e0b4e..03165acf1 100644 --- a/webapp/src/components/layout.tsx +++ b/webapp/src/components/layout.tsx @@ -7,6 +7,7 @@ import { hasPagePermission, routers, routersMapper } from '../router'; import { Theme, useAccessStore, useConfigStore } from '../store'; import { getUIConfiguration, logoutApi } from '../api/webapp/login'; import Locale from "../locales"; +import { TimezoneDropdown } from "./TimezoneContext"; export const RootLayout = (props: { children: React.ReactNode @@ -85,6 +86,7 @@ export const RootLayout = (props: { }} footer={
+