Skip to content

Commit

Permalink
feat: allow switching timezone for date rendering. Fixes #3474 (#10120)
Browse files Browse the repository at this point in the history
Signed-off-by: Isitha Subasinghe <isitha@pipekit.io>
  • Loading branch information
isubasinghe authored Jan 31, 2023
1 parent 22fa340 commit 8e7c734
Show file tree
Hide file tree
Showing 6 changed files with 147 additions and 6 deletions.
5 changes: 3 additions & 2 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
42 changes: 42 additions & 0 deletions ui/src/app/shared/hooks/uselocalstorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {useState} from 'react';

export function useCustomLocalStorage<T>(key: string, initial: T, onError: (err: any) => T | undefined): [T | undefined, React.Dispatch<T>] {
const [storedValue, setStoredValue]: [T | undefined, React.Dispatch<T>] = 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<T>(key: string, initial: T): [T, React.Dispatch<T>] {
return useCustomLocalStorage(key, initial, _ => initial);
}
3 changes: 2 additions & 1 deletion ui/src/app/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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([{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand All @@ -26,13 +32,61 @@ function identity<T>(value: T) {
return () => value;
}

// USED FOR MANUAL TESTING
// const timeSpammer:Observable<string> = 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);
const [grep, setGrep] = useState('');
const [error, setError] = useState<Error>();
const [loaded, setLoaded] = useState(false);
const [logsObservable, setLogsObservable] = useState<Observable<string>>();
// timezone used for ui rendering only
const [uiTimezone, setUITimezone] = useState<string>(DEFAULT_TZ);
// timezone used for timezone formatting
const [timezone, setTimezone] = useLocalStorage<string>(TZ_LOCALSTORAGE_KEY, DEFAULT_TZ);
// list of timezones the moment-timezone library supports
const [timezones, setTimezones] = useState<string[]>([]);

// update the UI everytime the timezone changes
useEffect(() => {
setUITimezone(timezone);
}, [timezone]);

useEffect(() => {
setError(null);
Expand All @@ -46,17 +100,24 @@ 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,
() => setLoaded(true)
);
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('');
Expand All @@ -65,6 +126,16 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container,
return () => clearTimeout(x);
}, [logFilter]);

useEffect(() => {
const tzs = moment.tz.names();
const tzsSet = new Set<string>();
tzs.forEach(item => {
tzsSet.add(item);
});
const flatTzs = [...tzsSet];
setTimezones(flatTzs);
}, []);

const annotations = workflow.metadata.annotations || {};
const podNameVersion = annotations[ANNOTATION_KEY_POD_NAME_VERSION];

Expand Down Expand Up @@ -96,7 +167,7 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container,
)
)
];

const filteredTimezones = timezones.filter(tz => tz.startsWith(uiTimezone) || uiTimezone === '');
return (
<div className='workflow-logs-viewer'>
<h3>Logs</h3>
Expand All @@ -116,7 +187,18 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container,
/>{' '}
/ <Autocomplete items={containers} value={selectedContainer} onSelect={setContainer} />
<span className='fa-pull-right'>
<i className='fa fa-filter' /> <input type='search' defaultValue={logFilter} onChange={v => setLogFilter(v.target.value)} placeholder='Filter (regexp)...' />
<div className='log-menu'>
<i className='fa fa-filter' />{' '}
<input type='search' defaultValue={logFilter} onChange={v => setLogFilter(v.target.value)} placeholder='Filter (regexp)...' />
<i className='fa fa-globe' />{' '}
<Autocomplete
items={filteredTimezones}
value={uiTimezone}
onChange={v => setUITimezone(v.target.value)}
// useEffect ensures UITimezone is also changed
onSelect={setTimezone}
/>
</div>
</span>
</div>
<ErrorNotice error={error} />
Expand Down
7 changes: 7 additions & 0 deletions ui/yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -6585,6 +6585,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"
Expand Down

0 comments on commit 8e7c734

Please sign in to comment.