2019-05-20 06:28:23 -05:00
|
|
|
import React, { PureComponent } from 'react';
|
|
|
|
import { css, cx } from 'emotion';
|
2019-09-10 04:30:25 -05:00
|
|
|
import tinycolor from 'tinycolor2';
|
2019-09-05 07:04:01 -05:00
|
|
|
import { last } from 'lodash';
|
2019-07-06 01:05:53 -05:00
|
|
|
|
2019-09-10 04:30:25 -05:00
|
|
|
import { Themeable, withTheme, GrafanaTheme, getLogRowStyles } from '@grafana/ui';
|
2019-07-06 01:05:53 -05:00
|
|
|
import { LogsModel, LogRowModel, TimeZone } from '@grafana/data';
|
2019-05-20 06:28:23 -05:00
|
|
|
|
|
|
|
import ElapsedTime from './ElapsedTime';
|
|
|
|
|
|
|
|
const getStyles = (theme: GrafanaTheme) => ({
|
|
|
|
logsRowsLive: css`
|
|
|
|
label: logs-rows-live;
|
2019-09-09 12:09:06 -05:00
|
|
|
font-family: ${theme.typography.fontFamily.monospace};
|
|
|
|
font-size: ${theme.typography.size.sm};
|
2019-05-20 06:28:23 -05:00
|
|
|
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};
|
2019-09-10 04:30:25 -05:00
|
|
|
background-color: ${tinycolor(theme.colors.blueLight)
|
|
|
|
.setAlpha(0.25)
|
|
|
|
.toString()};
|
2019-09-09 12:09:06 -05:00
|
|
|
animation: fade 1s ease-out 1s 1 normal forwards;
|
|
|
|
@keyframes fade {
|
|
|
|
from {
|
2019-09-10 04:30:25 -05:00
|
|
|
background-color: ${tinycolor(theme.colors.blueLight)
|
|
|
|
.setAlpha(0.25)
|
|
|
|
.toString()};
|
2019-09-09 12:09:06 -05:00
|
|
|
}
|
|
|
|
to {
|
|
|
|
background-color: transparent;
|
|
|
|
}
|
|
|
|
}
|
2019-05-20 06:28:23 -05:00
|
|
|
`,
|
|
|
|
logsRowOld: css`
|
|
|
|
label: logs-row-old;
|
|
|
|
`,
|
|
|
|
logsRowsIndicator: css`
|
|
|
|
font-size: ${theme.typography.size.md};
|
2019-09-09 12:09:06 -05:00
|
|
|
padding-top: ${theme.spacing.sm};
|
2019-05-20 06:28:23 -05:00
|
|
|
display: flex;
|
|
|
|
align-items: center;
|
|
|
|
`,
|
2019-09-03 04:23:39 -05:00
|
|
|
button: css`
|
|
|
|
margin-right: ${theme.spacing.sm};
|
|
|
|
`,
|
2019-05-20 06:28:23 -05:00
|
|
|
});
|
|
|
|
|
|
|
|
export interface Props extends Themeable {
|
|
|
|
logsResult?: LogsModel;
|
2019-06-26 06:35:23 -05:00
|
|
|
timeZone: TimeZone;
|
2019-05-20 06:28:23 -05:00
|
|
|
stopLive: () => void;
|
2019-09-03 04:23:39 -05:00
|
|
|
onPause: () => void;
|
|
|
|
onResume: () => void;
|
|
|
|
isPaused: boolean;
|
|
|
|
}
|
|
|
|
|
|
|
|
interface State {
|
|
|
|
logsResultToRender?: LogsModel;
|
2019-09-05 07:04:01 -05:00
|
|
|
lastTimestamp: number;
|
2019-05-20 06:28:23 -05:00
|
|
|
}
|
|
|
|
|
2019-09-03 04:23:39 -05:00
|
|
|
class LiveLogs extends PureComponent<Props, State> {
|
2019-05-20 06:28:23 -05:00
|
|
|
private liveEndDiv: HTMLDivElement = null;
|
2019-09-03 04:23:39 -05:00
|
|
|
private scrollContainerRef = React.createRef<HTMLDivElement>();
|
|
|
|
private lastScrollPos: number | null = null;
|
|
|
|
|
|
|
|
constructor(props: Props) {
|
|
|
|
super(props);
|
|
|
|
this.state = {
|
|
|
|
logsResultToRender: props.logsResult,
|
2019-09-05 07:04:01 -05:00
|
|
|
lastTimestamp: 0,
|
2019-09-03 04:23:39 -05:00
|
|
|
};
|
|
|
|
}
|
2019-05-20 06:28:23 -05:00
|
|
|
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
2019-09-03 04:23:39 -05:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-09-05 07:04:01 -05:00
|
|
|
static getDerivedStateFromProps(nextProps: Props, state: State) {
|
2019-09-03 04:23:39 -05:00
|
|
|
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,
|
2019-09-05 07:04:01 -05:00
|
|
|
lastTimestamp:
|
|
|
|
state.logsResultToRender && last(state.logsResultToRender.rows)
|
|
|
|
? last(state.logsResultToRender.rows).timeEpochMs
|
|
|
|
: 0,
|
2019-09-03 04:23:39 -05:00
|
|
|
};
|
|
|
|
} else {
|
|
|
|
return null;
|
2019-05-20 06:28:23 -05:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2019-09-03 04:23:39 -05:00
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
};
|
|
|
|
|
2019-09-05 07:04:01 -05:00
|
|
|
/**
|
|
|
|
* 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;
|
|
|
|
};
|
|
|
|
|
2019-05-20 06:28:23 -05:00
|
|
|
render() {
|
2019-09-03 04:23:39 -05:00
|
|
|
const { theme, timeZone, onPause, onResume, isPaused } = this.props;
|
2019-05-20 06:28:23 -05:00
|
|
|
const styles = getStyles(theme);
|
2019-06-26 06:35:23 -05:00
|
|
|
const showUtc = timeZone === 'utc';
|
2019-08-26 01:11:07 -05:00
|
|
|
const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
|
2019-05-20 06:28:23 -05:00
|
|
|
|
|
|
|
return (
|
2019-09-20 06:00:11 -05:00
|
|
|
<div>
|
2019-09-03 04:23:39 -05:00
|
|
|
<div
|
|
|
|
onScroll={isPaused ? undefined : this.onScroll}
|
|
|
|
className={cx(['logs-rows', styles.logsRowsLive])}
|
|
|
|
ref={this.scrollContainerRef}
|
|
|
|
>
|
2019-09-05 07:04:01 -05:00
|
|
|
{this.rowsToRender().map((row: LogRowModel, index) => {
|
2019-05-20 06:28:23 -05:00
|
|
|
return (
|
|
|
|
<div
|
2019-09-05 07:04:01 -05:00
|
|
|
className={cx(logsRow, this.isFresh(row) ? styles.logsRowFresh : styles.logsRowOld)}
|
2019-05-20 06:28:23 -05:00
|
|
|
key={`${row.timeEpochMs}-${index}`}
|
|
|
|
>
|
2019-06-26 06:35:23 -05:00
|
|
|
{showUtc && (
|
2019-08-26 01:11:07 -05:00
|
|
|
<div className={cx([logsRowLocalTime])} title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
|
2019-06-26 06:35:23 -05:00
|
|
|
{row.timeUtc}
|
|
|
|
</div>
|
|
|
|
)}
|
|
|
|
{!showUtc && (
|
2019-08-26 01:11:07 -05:00
|
|
|
<div className={cx([logsRowLocalTime])} title={`${row.timeUtc} (${row.timeFromNow})`}>
|
2019-06-26 06:35:23 -05:00
|
|
|
{row.timeLocal}
|
|
|
|
</div>
|
|
|
|
)}
|
2019-08-26 01:11:07 -05:00
|
|
|
<div className={cx([logsRowMessage])}>{row.entry}</div>
|
2019-05-20 06:28:23 -05:00
|
|
|
</div>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
<div
|
|
|
|
ref={element => {
|
|
|
|
this.liveEndDiv = element;
|
2019-09-03 04:23:39 -05:00
|
|
|
// 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) {
|
2019-05-20 06:28:23 -05:00
|
|
|
this.liveEndDiv.scrollIntoView(false);
|
|
|
|
}
|
|
|
|
}}
|
|
|
|
/>
|
|
|
|
</div>
|
|
|
|
<div className={cx([styles.logsRowsIndicator])}>
|
2019-09-03 04:23:39 -05:00
|
|
|
<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)}>
|
2019-09-12 09:01:22 -05:00
|
|
|
<i className={'fa fa-stop'} />
|
2019-09-09 12:09:06 -05:00
|
|
|
Exit live mode
|
2019-09-03 04:23:39 -05:00
|
|
|
</button>
|
|
|
|
{isPaused || (
|
|
|
|
<span>
|
|
|
|
Last line received: <ElapsedTime resetKey={this.props.logsResult} humanize={true} /> ago
|
|
|
|
</span>
|
|
|
|
)}
|
2019-05-20 06:28:23 -05:00
|
|
|
</div>
|
2019-09-20 06:00:11 -05:00
|
|
|
</div>
|
2019-05-20 06:28:23 -05:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
export const LiveLogsWithTheme = withTheme(LiveLogs);
|