From 7da30bd510fe40dc070be78056f40bc035933112 Mon Sep 17 00:00:00 2001 From: Isitha Subasinghe Date: Wed, 22 Mar 2023 08:50:23 +1100 Subject: [PATCH] feat: Parse JSON structured logs in Argo UI. Fixes #6856 (#10145) Signed-off-by: krrrr38 Signed-off-by: Isitha Subasinghe Co-authored-by: krrrr38 Co-authored-by: Alex Collins --- .../json-logs-field-selector.tsx | 77 +++++++++++++++++++ .../workflow-logs-viewer.tsx | 58 +++++++++++++- 2 files changed, 132 insertions(+), 3 deletions(-) create mode 100644 ui/src/app/workflows/components/workflow-logs-viewer/json-logs-field-selector.tsx diff --git a/ui/src/app/workflows/components/workflow-logs-viewer/json-logs-field-selector.tsx b/ui/src/app/workflows/components/workflow-logs-viewer/json-logs-field-selector.tsx new file mode 100644 index 000000000000..0079f7663b12 --- /dev/null +++ b/ui/src/app/workflows/components/workflow-logs-viewer/json-logs-field-selector.tsx @@ -0,0 +1,77 @@ +import * as React from 'react'; +import {TextInput} from '../../../shared/components/text-input'; + +export interface SelectedJsonFields { + values: string[]; +} + +export const JsonLogsFieldSelector = ({fields, onChange}: {fields: SelectedJsonFields; onChange: (v: string[]) => void}) => { + const [inputFields, setInputFields] = React.useState(fields); + const [key, setKey] = React.useState(''); + const deleteItem = (k: string) => { + const index = inputFields.values.indexOf(k, 0); + if (index === -1) { + return; + } + const values = inputFields.values.filter(v => v !== k); + setInputFields({values}); + onChange(values); + }; + const addItem = () => { + if (!key || key.trim().length === 0) { + return; + } + const index = inputFields.values.indexOf(key, 0); + if (index !== -1) { + return; + } + const values = [...inputFields.values, key]; + setInputFields({values}); + setKey(''); + onChange(values); + }; + + return ( + <> + {inputFields.values.map(k => ( +
+
{k}
+
+ +
+
+ ))} +
{ + if (e.key === 'Enter') { + addItem(); + } + }}> +
+ +
+
+ +
+
+ + ); +}; + +export const extractJsonValue = (obj: any, jsonpath: string): string | null => { + const fields = jsonpath.split('.'); + try { + let target = obj; + for (const field of fields) { + target = target[field]; + } + return typeof target === 'string' ? target : JSON.stringify(target); + } catch (e) { + return null; + } +}; 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 574596a2ab17..14579c606170 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 @@ -1,5 +1,5 @@ import * as React from 'react'; -import {useEffect, useState} from 'react'; +import {useContext, useEffect, useState} from 'react'; import {Autocomplete} from 'argo-ui'; import moment = require('moment-timezone'); @@ -8,13 +8,17 @@ import {map, publishReplay, refCount} from 'rxjs/operators'; import * as models from '../../../../models'; import {execSpec} from '../../../../models'; import {ANNOTATION_KEY_POD_NAME_VERSION} from '../../../shared/annotations'; +import {Button} from '../../../shared/components/button'; import {ErrorNotice} from '../../../shared/components/error-notice'; import {InfoIcon, WarningIcon} from '../../../shared/components/fa-icons'; import {Links} from '../../../shared/components/links'; +import {Context} from '../../../shared/context'; import {useLocalStorage} from '../../../shared/hooks/uselocalstorage'; import {getPodName, getTemplateNameFromNode} from '../../../shared/pod-name'; +import {ScopedLocalStorage} from '../../../shared/scoped-local-storage'; import {services} from '../../../shared/services'; import {FullHeightLogsViewer} from './full-height-logs-viewer'; +import {extractJsonValue, JsonLogsFieldSelector, SelectedJsonFields} from './json-logs-field-selector'; const TZ_LOCALSTORAGE_KEY = 'DEFAULT_TZ'; @@ -70,6 +74,12 @@ const parseAndTransform = (formattedString: string, timezone: string) => { }; export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, archived}: WorkflowLogsViewerProps) => { + const storage = new ScopedLocalStorage('workflow-logs-viewer'); + const storedJsonFields = storage.getItem('jsonFields', { + values: [] + } as SelectedJsonFields); + + const {popup} = useContext(Context); const [podName, setPodName] = useState(initialPodName || ''); const [selectedContainer, setContainer] = useState(container); const [grep, setGrep] = useState(''); @@ -87,12 +97,34 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, useEffect(() => { setUITimezone(timezone); }, [timezone]); + const [selectedJsonFields, setSelectedJsonFields] = useState(storedJsonFields); useEffect(() => { setError(null); setLoaded(false); const source = services.workflows.getContainerLogs(workflow, podName, nodeId, selectedContainer, grep, archived).pipe( - map(e => (!podName ? e.podName + ': ' : '') + e.content + '\n'), + // extract message from LogEntry + map(e => { + const values: string[] = []; + const content = e.content; + if (selectedJsonFields.values.length > 0) { + try { + const json = JSON.parse(content); + selectedJsonFields.values.forEach(selectedJsonField => { + const value = extractJsonValue(json, selectedJsonField); + if (value) { + values.push(value); + } + }); + } catch (e) { + // if not json, show content directly + } + } + if (values.length === 0) { + values.push(content); + } + return `${!podName ? e.podName + ': ' : ''}${values.join(' ')}\n`; + }), // this next line highlights the search term in bold with a yellow background, white text map(x => { if (grep !== '') { @@ -117,7 +149,7 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, ); setLogsObservable(source); return () => subscription.unsubscribe(); - }, [workflow.metadata.namespace, workflow.metadata.name, podName, selectedContainer, grep, archived, timezone]); + }, [workflow.metadata.namespace, workflow.metadata.name, podName, selectedContainer, grep, archived, selectedJsonFields, timezone]); // filter allows us to introduce a short delay, before we actually change grep const [logFilter, setLogFilter] = useState(''); @@ -169,6 +201,23 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, ]; const [candidateContainer, setCandidateContainer] = useState(container); const filteredTimezones = timezones.filter(tz => tz.startsWith(uiTimezone) || uiTimezone === ''); + + const popupJsonFieldSelector = async () => { + const fields = {...selectedJsonFields}; + const updated = await popup.confirm('Select Json Fields', () => ( + { + fields.values = values; + }} + /> + )); + if (updated) { + storage.setItem('jsonFields', fields, {values: []}); + setSelectedJsonFields(fields); + } + }; + return (

Logs

@@ -207,6 +256,9 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container, /> )} /> +
{' '}