| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218 |
- import React, { PureComponent } from 'react';
- import { css, cx } from 'emotion';
- import tinycolor from 'tinycolor2';
- import { last } from 'lodash';
- import { Themeable, withTheme, GrafanaTheme, getLogRowStyles } from '@grafana/ui';
- import { LogsModel, LogRowModel, TimeZone } from '@grafana/data';
- import ElapsedTime from './ElapsedTime';
- const getStyles = (theme: GrafanaTheme) => ({
- logsRowsLive: css`
- label: logs-rows-live;
- font-family: ${theme.typography.fontFamily.monospace};
- font-size: ${theme.typography.size.sm};
- display: flex;
- flex-flow: column nowrap;
- height: 65vh;
- overflow-y: auto;
- :first-child {
- margin-top: auto !important;
- }
- `,
- logsRowFresh: css`
- label: logs-row-fresh;
- color: ${theme.colors.text};
- background-color: ${tinycolor(theme.colors.blueLight)
- .setAlpha(0.25)
- .toString()};
- animation: fade 1s ease-out 1s 1 normal forwards;
- @keyframes fade {
- from {
- background-color: ${tinycolor(theme.colors.blueLight)
- .setAlpha(0.25)
- .toString()};
- }
- to {
- background-color: transparent;
- }
- }
- `,
- logsRowOld: css`
- label: logs-row-old;
- `,
- logsRowsIndicator: css`
- font-size: ${theme.typography.size.md};
- padding-top: ${theme.spacing.sm};
- display: flex;
- align-items: center;
- `,
- button: css`
- margin-right: ${theme.spacing.sm};
- `,
- });
- export interface Props extends Themeable {
- logsResult?: LogsModel;
- timeZone: TimeZone;
- stopLive: () => void;
- onPause: () => void;
- onResume: () => void;
- isPaused: boolean;
- }
- interface State {
- logsResultToRender?: LogsModel;
- lastTimestamp: number;
- }
- class LiveLogs extends PureComponent<Props, State> {
- private liveEndDiv: HTMLDivElement = null;
- private scrollContainerRef = React.createRef<HTMLDivElement>();
- private lastScrollPos: number | null = null;
- constructor(props: Props) {
- super(props);
- this.state = {
- logsResultToRender: props.logsResult,
- lastTimestamp: 0,
- };
- }
- componentDidUpdate(prevProps: Props) {
- if (!prevProps.isPaused && this.props.isPaused) {
- // So we paused the view and we changed the content size, but we want to keep the relative offset from the bottom.
- if (this.lastScrollPos) {
- // There is last scroll pos from when user scrolled up a bit so go to that position.
- const { clientHeight, scrollHeight } = this.scrollContainerRef.current;
- const scrollTop = scrollHeight - (this.lastScrollPos + clientHeight);
- this.scrollContainerRef.current.scrollTo(0, scrollTop);
- this.lastScrollPos = null;
- } else {
- // We do not have any position to jump to su the assumption is user just clicked pause. We can just scroll
- // to the bottom.
- if (this.liveEndDiv) {
- this.liveEndDiv.scrollIntoView(false);
- }
- }
- }
- }
- static getDerivedStateFromProps(nextProps: Props, state: State) {
- if (!nextProps.isPaused) {
- return {
- // We update what we show only if not paused. We keep any background subscriptions running and keep updating
- // our state, but we do not show the updates, this allows us start again showing correct result after resuming
- // without creating a gap in the log results.
- logsResultToRender: nextProps.logsResult,
- lastTimestamp:
- state.logsResultToRender && last(state.logsResultToRender.rows)
- ? last(state.logsResultToRender.rows).timeEpochMs
- : 0,
- };
- } else {
- return null;
- }
- }
- /**
- * Handle pausing when user scrolls up so that we stop resetting his position to the bottom when new row arrives.
- * We do not need to throttle it here much, adding new rows should be throttled/buffered itself in the query epics
- * and after you pause we remove the handler and add it after you manually resume, so this should not be fired often.
- */
- onScroll = (event: React.SyntheticEvent) => {
- const { isPaused, onPause } = this.props;
- const { scrollTop, clientHeight, scrollHeight } = event.currentTarget;
- const distanceFromBottom = scrollHeight - (scrollTop + clientHeight);
- if (distanceFromBottom >= 5 && !isPaused) {
- onPause();
- this.lastScrollPos = distanceFromBottom;
- }
- };
- rowsToRender = () => {
- const { isPaused } = this.props;
- let rowsToRender: LogRowModel[] = this.state.logsResultToRender ? this.state.logsResultToRender.rows : [];
- if (!isPaused) {
- // A perf optimisation here. Show just 100 rows when streaming and full length when the streaming is paused.
- rowsToRender = rowsToRender.slice(-100);
- }
- return rowsToRender;
- };
- /**
- * Check if row is fresh so we can apply special styling. This is bit naive and does not take into account rows
- * which arrive out of order. Because loki datasource sends full data instead of deltas we need to compare the
- * data and this is easier than doing some intersection of some uuid of each row (which we do not have now anyway)
- */
- isFresh = (row: LogRowModel): boolean => {
- return row.timeEpochMs > this.state.lastTimestamp;
- };
- render() {
- const { theme, timeZone, onPause, onResume, isPaused } = this.props;
- const styles = getStyles(theme);
- const showUtc = timeZone === 'utc';
- const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
- return (
- <>
- <div
- onScroll={isPaused ? undefined : this.onScroll}
- className={cx(['logs-rows', styles.logsRowsLive])}
- ref={this.scrollContainerRef}
- >
- {this.rowsToRender().map((row: LogRowModel, index) => {
- return (
- <div
- className={cx(logsRow, this.isFresh(row) ? styles.logsRowFresh : styles.logsRowOld)}
- key={`${row.timeEpochMs}-${index}`}
- >
- {showUtc && (
- <div className={cx([logsRowLocalTime])} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
- {row.timeUtc}
- </div>
- )}
- {!showUtc && (
- <div className={cx([logsRowLocalTime])} title={`${row.timeUtc} (${row.timeFromNow})`}>
- {row.timeLocal}
- </div>
- )}
- <div className={cx([logsRowMessage])}>{row.entry}</div>
- </div>
- );
- })}
- <div
- ref={element => {
- this.liveEndDiv = element;
- // This is triggered on every update so on every new row. It keeps the view scrolled at the bottom by
- // default.
- if (this.liveEndDiv && !isPaused) {
- this.liveEndDiv.scrollIntoView(false);
- }
- }}
- />
- </div>
- <div className={cx([styles.logsRowsIndicator])}>
- <button onClick={isPaused ? onResume : onPause} className={cx('btn btn-secondary', styles.button)}>
- <i className={cx('fa', isPaused ? 'fa-play' : 'fa-pause')} />
-
- {isPaused ? 'Resume' : 'Pause'}
- </button>
- <button onClick={this.props.stopLive} className={cx('btn btn-inverse', styles.button)}>
- <i className={'fa fa-stop'} />
- Exit live mode
- </button>
- {isPaused || (
- <span>
- Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago
- </span>
- )}
- </div>
- </>
- );
- }
- }
- export const LiveLogsWithTheme = withTheme(LiveLogs);
|