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