1import React, { PureComponent, createRef } from 'react';
2import { css } from '@emotion/css';
3import { capitalize } from 'lodash';
4import memoizeOne from 'memoize-one';
5import { TooltipDisplayMode } from '@grafana/schema';
6import {
7  rangeUtil,
8  RawTimeRange,
9  LogLevel,
10  TimeZone,
11  AbsoluteTimeRange,
12  LogsDedupStrategy,
13  LogRowModel,
14  LogsDedupDescription,
15  LogsMetaItem,
16  LogsSortOrder,
17  LinkModel,
18  Field,
19  DataQuery,
20  DataFrame,
21  GrafanaTheme2,
22  LoadingState,
23} from '@grafana/data';
24import {
25  RadioButtonGroup,
26  LogRows,
27  Button,
28  InlineField,
29  InlineFieldRow,
30  InlineSwitch,
31  withTheme2,
32  Themeable2,
33} from '@grafana/ui';
34import store from 'app/core/store';
35import { dedupLogRows, filterLogLevels } from 'app/core/logs_model';
36import { LogsMetaRow } from './LogsMetaRow';
37import LogsNavigation from './LogsNavigation';
38import { RowContextOptions } from '@grafana/ui/src/components/Logs/LogRowContextProvider';
39import { ExploreGraph } from './ExploreGraph';
40
41const SETTINGS_KEYS = {
42  showLabels: 'grafana.explore.logs.showLabels',
43  showTime: 'grafana.explore.logs.showTime',
44  wrapLogMessage: 'grafana.explore.logs.wrapLogMessage',
45  prettifyLogMessage: 'grafana.explore.logs.prettifyLogMessage',
46};
47
48interface Props extends Themeable2 {
49  width: number;
50  logRows: LogRowModel[];
51  logsMeta?: LogsMetaItem[];
52  logsSeries?: DataFrame[];
53  logsQueries?: DataQuery[];
54  visibleRange?: AbsoluteTimeRange;
55  theme: GrafanaTheme2;
56  loading: boolean;
57  loadingState: LoadingState;
58  absoluteRange: AbsoluteTimeRange;
59  timeZone: TimeZone;
60  scanning?: boolean;
61  scanRange?: RawTimeRange;
62  showContextToggle?: (row?: LogRowModel) => boolean;
63  onChangeTime: (range: AbsoluteTimeRange) => void;
64  onClickFilterLabel?: (key: string, value: string) => void;
65  onClickFilterOutLabel?: (key: string, value: string) => void;
66  onStartScanning?: () => void;
67  onStopScanning?: () => void;
68  getRowContext?: (row: LogRowModel, options?: RowContextOptions) => Promise<any>;
69  getFieldLinks: (field: Field, rowIndex: number) => Array<LinkModel<Field>>;
70  addResultsToCache: () => void;
71  clearCache: () => void;
72}
73
74interface State {
75  showLabels: boolean;
76  showTime: boolean;
77  wrapLogMessage: boolean;
78  prettifyLogMessage: boolean;
79  dedupStrategy: LogsDedupStrategy;
80  hiddenLogLevels: LogLevel[];
81  logsSortOrder: LogsSortOrder | null;
82  isFlipping: boolean;
83  showDetectedFields: string[];
84  forceEscape: boolean;
85}
86
87class UnthemedLogs extends PureComponent<Props, State> {
88  flipOrderTimer?: number;
89  cancelFlippingTimer?: number;
90  topLogsRef = createRef<HTMLDivElement>();
91
92  state: State = {
93    showLabels: store.getBool(SETTINGS_KEYS.showLabels, false),
94    showTime: store.getBool(SETTINGS_KEYS.showTime, true),
95    wrapLogMessage: store.getBool(SETTINGS_KEYS.wrapLogMessage, true),
96    prettifyLogMessage: store.getBool(SETTINGS_KEYS.prettifyLogMessage, false),
97    dedupStrategy: LogsDedupStrategy.none,
98    hiddenLogLevels: [],
99    logsSortOrder: null,
100    isFlipping: false,
101    showDetectedFields: [],
102    forceEscape: false,
103  };
104
105  componentWillUnmount() {
106    if (this.flipOrderTimer) {
107      window.clearTimeout(this.flipOrderTimer);
108    }
109
110    if (this.cancelFlippingTimer) {
111      window.clearTimeout(this.cancelFlippingTimer);
112    }
113  }
114
115  onChangeLogsSortOrder = () => {
116    this.setState({ isFlipping: true });
117    // we are using setTimeout here to make sure that disabled button is rendered before the rendering of reordered logs
118    this.flipOrderTimer = window.setTimeout(() => {
119      this.setState((prevState) => {
120        if (prevState.logsSortOrder === null || prevState.logsSortOrder === LogsSortOrder.Descending) {
121          return { logsSortOrder: LogsSortOrder.Ascending };
122        }
123        return { logsSortOrder: LogsSortOrder.Descending };
124      });
125    }, 0);
126    this.cancelFlippingTimer = window.setTimeout(() => this.setState({ isFlipping: false }), 1000);
127  };
128
129  onEscapeNewlines = () => {
130    this.setState((prevState) => ({
131      forceEscape: !prevState.forceEscape,
132    }));
133  };
134
135  onChangeDedup = (dedupStrategy: LogsDedupStrategy) => {
136    this.setState({ dedupStrategy });
137  };
138
139  onChangeLabels = (event: React.ChangeEvent<HTMLInputElement>) => {
140    const { target } = event;
141    if (target) {
142      const showLabels = target.checked;
143      this.setState({
144        showLabels,
145      });
146      store.set(SETTINGS_KEYS.showLabels, showLabels);
147    }
148  };
149
150  onChangeTime = (event: React.ChangeEvent<HTMLInputElement>) => {
151    const { target } = event;
152    if (target) {
153      const showTime = target.checked;
154      this.setState({
155        showTime,
156      });
157      store.set(SETTINGS_KEYS.showTime, showTime);
158    }
159  };
160
161  onChangewrapLogMessage = (event: React.ChangeEvent<HTMLInputElement>) => {
162    const { target } = event;
163    if (target) {
164      const wrapLogMessage = target.checked;
165      this.setState({
166        wrapLogMessage,
167      });
168      store.set(SETTINGS_KEYS.wrapLogMessage, wrapLogMessage);
169    }
170  };
171
172  onChangePrettifyLogMessage = (event: React.ChangeEvent<HTMLInputElement>) => {
173    const { target } = event;
174    if (target) {
175      const prettifyLogMessage = target.checked;
176      this.setState({
177        prettifyLogMessage,
178      });
179      store.set(SETTINGS_KEYS.prettifyLogMessage, prettifyLogMessage);
180    }
181  };
182
183  onToggleLogLevel = (hiddenRawLevels: string[]) => {
184    const hiddenLogLevels = hiddenRawLevels.map((level) => LogLevel[level as LogLevel]);
185    this.setState({ hiddenLogLevels });
186  };
187
188  onClickScan = (event: React.SyntheticEvent) => {
189    event.preventDefault();
190    if (this.props.onStartScanning) {
191      this.props.onStartScanning();
192    }
193  };
194
195  onClickStopScan = (event: React.SyntheticEvent) => {
196    event.preventDefault();
197    if (this.props.onStopScanning) {
198      this.props.onStopScanning();
199    }
200  };
201
202  showDetectedField = (key: string) => {
203    const index = this.state.showDetectedFields.indexOf(key);
204
205    if (index === -1) {
206      this.setState((state) => {
207        return {
208          showDetectedFields: state.showDetectedFields.concat(key),
209        };
210      });
211    }
212  };
213
214  hideDetectedField = (key: string) => {
215    const index = this.state.showDetectedFields.indexOf(key);
216    if (index > -1) {
217      this.setState((state) => {
218        return {
219          showDetectedFields: state.showDetectedFields.filter((k) => key !== k),
220        };
221      });
222    }
223  };
224
225  clearDetectedFields = () => {
226    this.setState((state) => {
227      return {
228        showDetectedFields: [],
229      };
230    });
231  };
232
233  checkUnescapedContent = memoizeOne((logRows: LogRowModel[]) => {
234    return !!logRows.some((r) => r.hasUnescapedContent);
235  });
236
237  dedupRows = memoizeOne((logRows: LogRowModel[], dedupStrategy: LogsDedupStrategy) => {
238    const dedupedRows = dedupLogRows(logRows, dedupStrategy);
239    const dedupCount = dedupedRows.reduce((sum, row) => (row.duplicates ? sum + row.duplicates : sum), 0);
240    return { dedupedRows, dedupCount };
241  });
242
243  filterRows = memoizeOne((logRows: LogRowModel[], hiddenLogLevels: LogLevel[]) => {
244    return filterLogLevels(logRows, new Set(hiddenLogLevels));
245  });
246
247  scrollToTopLogs = () => this.topLogsRef.current?.scrollIntoView();
248
249  render() {
250    const {
251      width,
252      logRows,
253      logsMeta,
254      logsSeries,
255      visibleRange,
256      loading = false,
257      loadingState,
258      onClickFilterLabel,
259      onClickFilterOutLabel,
260      timeZone,
261      scanning,
262      scanRange,
263      showContextToggle,
264      absoluteRange,
265      onChangeTime,
266      getFieldLinks,
267      theme,
268      logsQueries,
269      clearCache,
270      addResultsToCache,
271    } = this.props;
272
273    const {
274      showLabels,
275      showTime,
276      wrapLogMessage,
277      prettifyLogMessage,
278      dedupStrategy,
279      hiddenLogLevels,
280      logsSortOrder,
281      isFlipping,
282      showDetectedFields,
283      forceEscape,
284    } = this.state;
285
286    const styles = getStyles(theme, wrapLogMessage);
287    const hasData = logRows && logRows.length > 0;
288    const hasUnescapedContent = this.checkUnescapedContent(logRows);
289
290    const filteredLogs = this.filterRows(logRows, hiddenLogLevels);
291    const { dedupedRows, dedupCount } = this.dedupRows(filteredLogs, dedupStrategy);
292
293    const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...';
294
295    return (
296      <>
297        {logsSeries && logsSeries.length ? (
298          <>
299            <div className={styles.infoText}>
300              This datasource does not support full-range histograms. The graph is based on the logs seen in the
301              response.
302            </div>
303            <ExploreGraph
304              graphStyle="lines"
305              data={logsSeries}
306              height={150}
307              width={width}
308              tooltipDisplayMode={TooltipDisplayMode.Multi}
309              absoluteRange={visibleRange || absoluteRange}
310              timeZone={timeZone}
311              loadingState={loadingState}
312              onChangeTime={onChangeTime}
313              onHiddenSeriesChanged={this.onToggleLogLevel}
314            />
315          </>
316        ) : undefined}
317        <div className={styles.logOptions} ref={this.topLogsRef}>
318          <InlineFieldRow>
319            <InlineField label="Time" transparent>
320              <InlineSwitch value={showTime} onChange={this.onChangeTime} transparent id="show-time" />
321            </InlineField>
322            <InlineField label="Unique labels" transparent>
323              <InlineSwitch value={showLabels} onChange={this.onChangeLabels} transparent id="unique-labels" />
324            </InlineField>
325            <InlineField label="Wrap lines" transparent>
326              <InlineSwitch value={wrapLogMessage} onChange={this.onChangewrapLogMessage} transparent id="wrap-lines" />
327            </InlineField>
328            <InlineField label="Prettify JSON" transparent>
329              <InlineSwitch
330                value={prettifyLogMessage}
331                onChange={this.onChangePrettifyLogMessage}
332                transparent
333                id="prettify"
334              />
335            </InlineField>
336            <InlineField label="Dedup" transparent>
337              <RadioButtonGroup
338                options={Object.values(LogsDedupStrategy).map((dedupType) => ({
339                  label: capitalize(dedupType),
340                  value: dedupType,
341                  description: LogsDedupDescription[dedupType],
342                }))}
343                value={dedupStrategy}
344                onChange={this.onChangeDedup}
345                className={styles.radioButtons}
346              />
347            </InlineField>
348          </InlineFieldRow>
349          <div>
350            <Button
351              variant="secondary"
352              disabled={isFlipping}
353              title={logsSortOrder === LogsSortOrder.Ascending ? 'Change to newest first' : 'Change to oldest first'}
354              aria-label="Flip results order"
355              className={styles.headerButton}
356              onClick={this.onChangeLogsSortOrder}
357            >
358              {isFlipping ? 'Flipping...' : 'Flip results order'}
359            </Button>
360          </div>
361        </div>
362        <LogsMetaRow
363          logRows={logRows}
364          meta={logsMeta || []}
365          dedupStrategy={dedupStrategy}
366          dedupCount={dedupCount}
367          hasUnescapedContent={hasUnescapedContent}
368          forceEscape={forceEscape}
369          showDetectedFields={showDetectedFields}
370          onEscapeNewlines={this.onEscapeNewlines}
371          clearDetectedFields={this.clearDetectedFields}
372        />
373        <div className={styles.logsSection}>
374          <div className={styles.logRows}>
375            <LogRows
376              logRows={logRows}
377              deduplicatedRows={dedupedRows}
378              dedupStrategy={dedupStrategy}
379              getRowContext={this.props.getRowContext}
380              onClickFilterLabel={onClickFilterLabel}
381              onClickFilterOutLabel={onClickFilterOutLabel}
382              showContextToggle={showContextToggle}
383              showLabels={showLabels}
384              showTime={showTime}
385              enableLogDetails={true}
386              forceEscape={forceEscape}
387              wrapLogMessage={wrapLogMessage}
388              prettifyLogMessage={prettifyLogMessage}
389              timeZone={timeZone}
390              getFieldLinks={getFieldLinks}
391              logsSortOrder={logsSortOrder}
392              showDetectedFields={showDetectedFields}
393              onClickShowDetectedField={this.showDetectedField}
394              onClickHideDetectedField={this.hideDetectedField}
395            />
396          </div>
397          <LogsNavigation
398            logsSortOrder={logsSortOrder}
399            visibleRange={visibleRange ?? absoluteRange}
400            absoluteRange={absoluteRange}
401            timeZone={timeZone}
402            onChangeTime={onChangeTime}
403            loading={loading}
404            queries={logsQueries ?? []}
405            scrollToTopLogs={this.scrollToTopLogs}
406            addResultsToCache={addResultsToCache}
407            clearCache={clearCache}
408          />
409        </div>
410        {!loading && !hasData && !scanning && (
411          <div className={styles.noData}>
412            No logs found.
413            <Button size="xs" fill="text" onClick={this.onClickScan}>
414              Scan for older logs
415            </Button>
416          </div>
417        )}
418
419        {scanning && (
420          <div className={styles.noData}>
421            <span>{scanText}</span>
422            <Button size="xs" fill="text" onClick={this.onClickStopScan}>
423              Stop scan
424            </Button>
425          </div>
426        )}
427      </>
428    );
429  }
430}
431
432export const Logs = withTheme2(UnthemedLogs);
433
434const getStyles = (theme: GrafanaTheme2, wrapLogMessage: boolean) => {
435  return {
436    noData: css`
437      > * {
438        margin-left: 0.5em;
439      }
440    `,
441    logOptions: css`
442      display: flex;
443      justify-content: space-between;
444      align-items: baseline;
445      flex-wrap: wrap;
446      background-color: ${theme.colors.background.primary};
447      padding: ${theme.spacing(1, 2)};
448      border-radius: ${theme.shape.borderRadius()};
449      margin: ${theme.spacing(2, 0, 1)};
450      border: 1px solid ${theme.colors.border.medium};
451    `,
452    headerButton: css`
453      margin: ${theme.spacing(0.5, 0, 0, 1)};
454    `,
455    radioButtons: css`
456      margin: 0 ${theme.spacing(1)};
457    `,
458    logsSection: css`
459      display: flex;
460      flex-direction: row;
461      justify-content: space-between;
462    `,
463    logRows: css`
464      overflow-x: ${wrapLogMessage ? 'unset' : 'scroll'};
465      overflow-y: visible;
466      width: 100%;
467    `,
468    infoText: css`
469      font-size: ${theme.typography.size.sm};
470      color: ${theme.colors.text.secondary};
471    `,
472  };
473};
474