Skip to content

Commit

Permalink
feat: Parse JSON structured logs in Argo UI. Fixes #6856 (#10145)
Browse files Browse the repository at this point in the history
Signed-off-by: krrrr38 <[email protected]>
Signed-off-by: Isitha Subasinghe <[email protected]>
Co-authored-by: krrrr38 <[email protected]>
Co-authored-by: Alex Collins <[email protected]>
  • Loading branch information
3 people authored Mar 21, 2023
1 parent d3eab6f commit 7da30bd
Show file tree
Hide file tree
Showing 2 changed files with 132 additions and 3 deletions.
Original file line number Diff line number Diff line change
@@ -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 => (
<div className='row white-box__details-row' key={k}>
<div className='columns small-10'>{k}</div>
<div className='columns small-2'>
<button onClick={() => deleteItem(k)}>
<i className='fa fa-times-circle' />
</button>
</div>
</div>
))}
<div
className='row white-box__details-row'
onKeyPress={e => {
if (e.key === 'Enter') {
addItem();
}
}}>
<div className='columns small-10'>
<TextInput value={key} onChange={setKey} placeholder='jsonPayload.message' />
</div>
<div className='columns small-2'>
<button onClick={() => addItem()}>
<i className='fa fa-plus-circle' />
</button>
</div>
</div>
</>
);
};

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;
}
};
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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';

Expand Down Expand Up @@ -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('');
Expand All @@ -87,12 +97,34 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container,
useEffect(() => {
setUITimezone(timezone);
}, [timezone]);
const [selectedJsonFields, setSelectedJsonFields] = useState<SelectedJsonFields>(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 !== '') {
Expand All @@ -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('');
Expand Down Expand Up @@ -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', () => (
<JsonLogsFieldSelector
fields={selectedJsonFields}
onChange={values => {
fields.values = values;
}}
/>
));
if (updated) {
storage.setItem('jsonFields', fields, {values: []});
setSelectedJsonFields(fields);
}
};

return (
<div className='workflow-logs-viewer'>
<h3>Logs</h3>
Expand Down Expand Up @@ -207,6 +256,9 @@ export const WorkflowLogsViewer = ({workflow, nodeId, initialPodName, container,
/>
)}
/>
<Button onClick={popupJsonFieldSelector} icon={'exchange-alt'}>
Log Fields
</Button>
<span className='fa-pull-right'>
<div className='log-menu'>
<i className='fa fa-filter' />{' '}
Expand Down

0 comments on commit 7da30bd

Please sign in to comment.