From d907484101db39d06b8942e8c1a6a353e9383087 Mon Sep 17 00:00:00 2001 From: Isitha Subasinghe Date: Thu, 1 Dec 2022 16:40:39 +1100 Subject: [PATCH 1/2] feat: allow switching timezones for date rendering Signed-off-by: Isitha Subasinghe --- ui/package.json | 5 +- ui/src/app/shared/hooks/uselocalstorage.ts | 42 +++++++++ ui/src/app/webpack.config.js | 3 +- .../workflow-logs-viewer.scss | 8 ++ .../workflow-logs-viewer.tsx | 89 ++++++++++++++++++- ui/yarn.lock | 7 ++ 6 files changed, 148 insertions(+), 6 deletions(-) create mode 100644 ui/src/app/shared/hooks/uselocalstorage.ts diff --git a/ui/package.json b/ui/package.json index a1b62eec2c5f..8c18cec1aaf6 100644 --- a/ui/package.json +++ b/ui/package.json @@ -11,8 +11,8 @@ "lint": "tslint --fix -p ./src/app", "test": "jest" }, - "engines" : { - "node" : ">=16" + "engines": { + "node": ">=16" }, "dependencies": { "argo-ui": "https://github.com/argoproj/argo-ui.git#v2.5.0", @@ -26,6 +26,7 @@ "js-yaml": "^4.1.0", "json-merge-patch": "^0.2.3", "moment": "^2.29.4", + "moment-timezone": "^0.5.39", "monaco-editor": "0.20.0", "prop-types": "^15.8.1", "react": "^16.14.0", diff --git a/ui/src/app/shared/hooks/uselocalstorage.ts b/ui/src/app/shared/hooks/uselocalstorage.ts new file mode 100644 index 000000000000..4495a3dacd24 --- /dev/null +++ b/ui/src/app/shared/hooks/uselocalstorage.ts @@ -0,0 +1,42 @@ +import {useState} from 'react'; + +export function useCustomLocalStorage(key: string, initial: T, onError: (err: any) => T | undefined): [T | undefined, React.Dispatch] { + const [storedValue, setStoredValue]: [T | undefined, React.Dispatch] = useState(() => { + if (window === undefined) { + return initial; + } + try { + const item = window.localStorage.getItem(key); + // try retrieve if none present, default to initial + return item ? JSON.parse(item) : initial; + } catch (err) { + const val = onError(err) || undefined; + if (val === undefined) { + return undefined; + } + return val; + } + }); + + const setValue = (value: T | ((oldVal: T) => T)) => { + try { + const valueToStore = value instanceof Function ? value(storedValue) : value; + if (window !== undefined) { + window.localStorage.setItem(key, JSON.stringify(valueToStore)); + setStoredValue(valueToStore); + } + } catch (err) { + const val = onError(err) || undefined; + if (val === undefined) { + return undefined; + } + return val; + } + }; + + return [storedValue, setValue]; +} + +export function useLocalStorage(key: string, initial: T): [T, React.Dispatch] { + return useCustomLocalStorage(key, initial, _ => initial); +} diff --git a/ui/src/app/webpack.config.js b/ui/src/app/webpack.config.js index 09cb7f12ae73..bd43b0c13ff6 100644 --- a/ui/src/app/webpack.config.js +++ b/ui/src/app/webpack.config.js @@ -59,7 +59,8 @@ const config = { "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development"), SYSTEM_INFO: JSON.stringify({ version: process.env.VERSION || "latest" - }) + }), + "process.env.DEFAULT_TZ": JSON.stringify("UTC"), }), new HtmlWebpackPlugin({ template: "src/app/index.html" }), new CopyWebpackPlugin([{ diff --git a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.scss b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.scss index fed2cc1d4103..4e0b13df44a1 100644 --- a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.scss +++ b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.scss @@ -10,4 +10,12 @@ padding: 10px; margin: 10px; background-color: white; +} + +.log-menu { + display: flex; + column-gap: 12px; + flex-direction: row; + justify-content: space-between; + align-items: center; } \ No newline at end of file diff --git a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx index faea8d602827..3cf1f6e32a8f 100644 --- a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx +++ b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; import {useEffect, useState} from 'react'; import {Autocomplete} from 'argo-ui'; +import moment = require('moment-timezone'); import {Observable} from 'rxjs'; import {map, publishReplay, refCount} from 'rxjs/operators'; import * as models from '../../../../models'; @@ -10,10 +11,15 @@ import {ANNOTATION_KEY_POD_NAME_VERSION} from '../../../shared/annotations'; import {ErrorNotice} from '../../../shared/components/error-notice'; import {InfoIcon, WarningIcon} from '../../../shared/components/fa-icons'; import {Links} from '../../../shared/components/links'; +import {useLocalStorage} from '../../../shared/hooks/uselocalstorage'; import {getPodName, getTemplateNameFromNode} from '../../../shared/pod-name'; import {services} from '../../../shared/services'; import {FullHeightLogsViewer} from './full-height-logs-viewer'; +const TZ_LOCALSTORAGE_KEY = 'DEFAULT_TZ'; + +const DEFAULT_TZ = process.env.DEFAULT_TZ || 'UTC'; + interface WorkflowLogsViewerProps { workflow: models.Workflow; nodeId?: string; @@ -26,6 +32,43 @@ function identity(value: T) { return () => value; } +// USED FOR MANUAL TESTING +// const timeSpammer:Observable = new Observable((subscriber) => { +// setInterval(() => { +// subscriber.next('time="2022-11-27T04:07:37.291Z" level=info msg="running spammer" argo=true\n'); +// }, 2000); +// }); + +interface ParsedTime { + quoted: string; + fullstring: string; +} +// extract the time field from a string +const parseTime = (formattedString: string): undefined | ParsedTime => { + const re = new RegExp('time="(.*?)"'); + const table = re.exec(formattedString); + if (table === null || table.length !== 2) { + return undefined; + } + return {quoted: table[1], fullstring: table[0]}; +}; + +const parseAndTransform = (formattedString: string, timezone: string) => { + const maybeTime = parseTime(formattedString); + if (maybeTime === undefined) { + return formattedString; + } + + try { + const newTime = moment.tz(maybeTime.quoted, timezone).format('YYYY-MM-DDTHH:mm:ss z'); + const newFormattedTime = `time=\"${newTime}\"`; + const newFormattedString = formattedString.replace(maybeTime.fullstring, newFormattedTime); + return newFormattedString; + } catch { + return formattedString; + } +}; + export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, archived}: WorkflowLogsViewerProps) => { const [podName, setPodName] = useState(initialPodName || ''); const [selectedContainer, setContainer] = useState(container); @@ -33,6 +76,16 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, const [error, setError] = useState(); const [loaded, setLoaded] = useState(false); const [logsObservable, setLogsObservable] = useState>(); + // timezone used for ui rendering only + const [uiTimezone, setUITimezone] = useState(DEFAULT_TZ); + // timezone used for timezone formatting + const [timezone, setTimezone] = useLocalStorage(TZ_LOCALSTORAGE_KEY, DEFAULT_TZ); + // list of timezones moment tz supports + const [timezones, setTimezones] = useState([]); + + useEffect(() => { + setUITimezone(timezone); + }, [timezone]); useEffect(() => { setError(null); @@ -46,9 +99,16 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, } return x; }), + map((x: string) => parseAndTransform(x, timezone)), publishReplay(), refCount() ); + + // const source = timeSpammer.pipe( + // map((x)=> parseAndTransform(x, timezone)), + // publishReplay(), + // refCount() + // ); const subscription = source.subscribe( () => setLoaded(true), setError, @@ -56,7 +116,7 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, ); setLogsObservable(source); return () => subscription.unsubscribe(); - }, [workflow.metadata.namespace, workflow.metadata.name, podName, selectedContainer, grep, archived]); + }, [workflow.metadata.namespace, workflow.metadata.name, podName, selectedContainer, grep, archived, timezone]); // filter allows us to introduce a short delay, before we actually change grep const [logFilter, setLogFilter] = useState(''); @@ -65,6 +125,16 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, return () => clearTimeout(x); }, [logFilter]); + useEffect(() => { + const tzs = moment.tz.names(); + const tzsSet = new Set(); + tzs.forEach(item => { + tzsSet.add(item); + }); + const flatTzs = [...tzsSet]; + setTimezones(flatTzs); + }, []); + const annotations = workflow.metadata.annotations || {}; const podNameVersion = annotations[ANNOTATION_KEY_POD_NAME_VERSION]; @@ -96,7 +166,7 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, ) ) ]; - + const filteredTimezones = timezones.filter(tz => tz.startsWith(uiTimezone) || uiTimezone === ''); return (

Logs

@@ -116,7 +186,20 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, />{' '} / - setLogFilter(v.target.value)} placeholder='Filter (regexp)...' /> +
+ {' '} + setLogFilter(v.target.value)} placeholder='Filter (regexp)...' /> + {' '} + setUITimezone(v.target.value)} + onSelect={tz => { + setUITimezone(tz); + setTimezone(tz); + }} + /> +
diff --git a/ui/yarn.lock b/ui/yarn.lock index 3cfb7f29691e..3d7a7cd94e9a 100644 --- a/ui/yarn.lock +++ b/ui/yarn.lock @@ -6579,6 +6579,13 @@ moment-timezone@^0.5.33: dependencies: moment ">= 2.9.0" +moment-timezone@^0.5.39: + version "0.5.39" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.39.tgz#342625a3b98810f04c8f4ea917e448d3525e600b" + integrity sha512-hoB6suq4ISDj7BDgctiOy6zljBsdYT0++0ZzZm9rtxIvJhIbQ3nmbgSWe7dNFGurl6/7b1OUkHlmN9JWgXVz7w== + dependencies: + moment ">= 2.9.0" + "moment@>= 2.9.0", moment@^2.10.2, moment@^2.24.0, moment@^2.25.3, moment@^2.29.1, moment@^2.29.4: version "2.29.4" resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" From 629a0e33397cc71ca32cbee219900dc65f1f656a Mon Sep 17 00:00:00 2001 From: Isitha Subasinghe Date: Mon, 12 Dec 2022 12:07:40 +1100 Subject: [PATCH 2/2] fix: remove unnecessary setUITimezone call Signed-off-by: Isitha Subasinghe --- .../workflow-logs-viewer/workflow-logs-viewer.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx index 3cf1f6e32a8f..7fd18d88bb1c 100644 --- a/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx +++ b/ui/src/app/workflows/components/workflow-logs-viewer/workflow-logs-viewer.tsx @@ -80,9 +80,10 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, const [uiTimezone, setUITimezone] = useState(DEFAULT_TZ); // timezone used for timezone formatting const [timezone, setTimezone] = useLocalStorage(TZ_LOCALSTORAGE_KEY, DEFAULT_TZ); - // list of timezones moment tz supports + // list of timezones the moment-timezone library supports const [timezones, setTimezones] = useState([]); + // update the UI everytime the timezone changes useEffect(() => { setUITimezone(timezone); }, [timezone]); @@ -194,10 +195,8 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, items={filteredTimezones} value={uiTimezone} onChange={v => setUITimezone(v.target.value)} - onSelect={tz => { - setUITimezone(tz); - setTimezone(tz); - }} + // useEffect ensures UITimezone is also changed + onSelect={setTimezone} />