mirror of
https://github.com/grafana/grafana.git
synced 2025-02-03 12:11:09 -06:00
Merge pull request #13942 from grafana/davkal/explore-logging-graph
Explore: Logging graph overview and view options
This commit is contained in:
commit
203078280f
@ -5,6 +5,7 @@ export interface Props {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
labelClass?: string;
|
||||
small?: boolean;
|
||||
switchClass?: string;
|
||||
onChange: (event) => any;
|
||||
}
|
||||
@ -24,10 +25,14 @@ export class Switch extends PureComponent<Props, State> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { labelClass, switchClass, label, checked } = this.props;
|
||||
const { labelClass = '', switchClass = '', label, checked, small } = this.props;
|
||||
const labelId = `check-${this.state.id}`;
|
||||
const labelClassName = `gf-form-label ${labelClass} pointer`;
|
||||
const switchClassName = `gf-form-switch ${switchClass}`;
|
||||
let labelClassName = `gf-form-label ${labelClass} pointer`;
|
||||
let switchClassName = `gf-form-switch ${switchClass}`;
|
||||
if (small) {
|
||||
labelClassName += ' gf-form-label--small';
|
||||
switchClassName += ' gf-form-switch--small';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="gf-form">
|
||||
|
@ -1,4 +1,6 @@
|
||||
import _ from 'lodash';
|
||||
import { TimeSeries } from 'app/core/core';
|
||||
import colors from 'app/core/utils/colors';
|
||||
|
||||
export enum LogLevel {
|
||||
crit = 'crit',
|
||||
@ -8,8 +10,20 @@ export enum LogLevel {
|
||||
info = 'info',
|
||||
debug = 'debug',
|
||||
trace = 'trace',
|
||||
none = 'none',
|
||||
}
|
||||
|
||||
export const LogLevelColor = {
|
||||
[LogLevel.crit]: colors[7],
|
||||
[LogLevel.warn]: colors[1],
|
||||
[LogLevel.err]: colors[4],
|
||||
[LogLevel.error]: colors[4],
|
||||
[LogLevel.info]: colors[0],
|
||||
[LogLevel.debug]: colors[3],
|
||||
[LogLevel.trace]: colors[3],
|
||||
[LogLevel.none]: '#eee',
|
||||
};
|
||||
|
||||
export interface LogSearchMatch {
|
||||
start: number;
|
||||
length: number;
|
||||
@ -17,27 +31,72 @@ export interface LogSearchMatch {
|
||||
}
|
||||
|
||||
export interface LogRow {
|
||||
key: string;
|
||||
entry: string;
|
||||
key: string; // timestamp + labels
|
||||
labels: string;
|
||||
logLevel: LogLevel;
|
||||
timestamp: string;
|
||||
timeFromNow: string;
|
||||
timeLocal: string;
|
||||
searchWords?: string[];
|
||||
timestamp: string; // ISO with nanosec precision
|
||||
timeFromNow: string;
|
||||
timeEpochMs: number;
|
||||
timeLocal: string;
|
||||
uniqueLabels?: string;
|
||||
}
|
||||
|
||||
export interface LogsMetaItem {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface LogsModel {
|
||||
meta?: LogsMetaItem[];
|
||||
rows: LogRow[];
|
||||
series?: TimeSeries[];
|
||||
}
|
||||
|
||||
export function mergeStreams(streams: LogsModel[], limit?: number): LogsModel {
|
||||
const combinedEntries = streams.reduce((acc, stream) => {
|
||||
return [...acc, ...stream.rows];
|
||||
}, []);
|
||||
const sortedEntries = _.chain(combinedEntries)
|
||||
.sortBy('timestamp')
|
||||
.reverse()
|
||||
.slice(0, limit || combinedEntries.length)
|
||||
.value();
|
||||
return { rows: sortedEntries };
|
||||
export interface LogsStream {
|
||||
labels: string;
|
||||
entries: LogsStreamEntry[];
|
||||
search?: string;
|
||||
parsedLabels?: LogsStreamLabels;
|
||||
uniqueLabels?: string;
|
||||
}
|
||||
|
||||
export interface LogsStreamEntry {
|
||||
line: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface LogsStreamLabels {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] {
|
||||
// Graph time series by log level
|
||||
const seriesByLevel = {};
|
||||
rows.forEach(row => {
|
||||
if (!seriesByLevel[row.logLevel]) {
|
||||
seriesByLevel[row.logLevel] = { lastTs: null, datapoints: [], alias: row.logLevel };
|
||||
}
|
||||
const levelSeries = seriesByLevel[row.logLevel];
|
||||
|
||||
// Bucket to nearest minute
|
||||
const time = Math.round(row.timeEpochMs / intervalMs / 10) * intervalMs * 10;
|
||||
// Entry for time
|
||||
if (time === levelSeries.lastTs) {
|
||||
levelSeries.datapoints[levelSeries.datapoints.length - 1][0]++;
|
||||
} else {
|
||||
levelSeries.datapoints.push([1, time]);
|
||||
levelSeries.lastTs = time;
|
||||
}
|
||||
});
|
||||
|
||||
return Object.keys(seriesByLevel).reduce((acc, level) => {
|
||||
if (seriesByLevel[level]) {
|
||||
const gs = new TimeSeries(seriesByLevel[level]);
|
||||
gs.setColor(LogLevelColor[level]);
|
||||
acc.push(gs);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
|
@ -10,16 +10,16 @@ export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)';
|
||||
export const REGION_FILL_ALPHA = 0.09;
|
||||
|
||||
const colors = [
|
||||
'#7EB26D',
|
||||
'#EAB839',
|
||||
'#6ED0E0',
|
||||
'#EF843C',
|
||||
'#E24D42',
|
||||
'#1F78C1',
|
||||
'#BA43A9',
|
||||
'#705DA0',
|
||||
'#508642',
|
||||
'#CCA300',
|
||||
'#7EB26D', // 0: pale green
|
||||
'#EAB839', // 1: mustard
|
||||
'#6ED0E0', // 2: light blue
|
||||
'#EF843C', // 3: orange
|
||||
'#E24D42', // 4: red
|
||||
'#1F78C1', // 5: ocean
|
||||
'#BA43A9', // 6: purple
|
||||
'#705DA0', // 7: violet
|
||||
'#508642', // 8: dark green
|
||||
'#CCA300', // 9: dark sand
|
||||
'#447EBC',
|
||||
'#C15C17',
|
||||
'#890F02',
|
||||
|
@ -25,10 +25,20 @@ import ErrorBoundary from './ErrorBoundary';
|
||||
import TimePicker from './TimePicker';
|
||||
import { ensureQueries, generateQueryKey, hasQuery } from './utils/query';
|
||||
import { DataSource } from 'app/types/datasources';
|
||||
import { mergeStreams } from 'app/core/logs_model';
|
||||
|
||||
const MAX_HISTORY_ITEMS = 100;
|
||||
|
||||
function getIntervals(range: RawTimeRange, datasource, resolution: number): { interval: string; intervalMs: number } {
|
||||
if (!datasource || !resolution) {
|
||||
return { interval: '1s', intervalMs: 1000 };
|
||||
}
|
||||
const absoluteRange: RawTimeRange = {
|
||||
from: parseDate(range.from, false),
|
||||
to: parseDate(range.to, true),
|
||||
};
|
||||
return kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
|
||||
}
|
||||
|
||||
function makeTimeSeriesList(dataList, options) {
|
||||
return dataList.map((seriesData, index) => {
|
||||
const datapoints = seriesData.datapoints || [];
|
||||
@ -471,12 +481,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
targetOptions: { format: string; hinting?: boolean; instant?: boolean }
|
||||
) {
|
||||
const { datasource, range } = this.state;
|
||||
const resolution = this.el.offsetWidth;
|
||||
const absoluteRange: RawTimeRange = {
|
||||
from: parseDate(range.from, false),
|
||||
to: parseDate(range.to, true),
|
||||
};
|
||||
const { interval } = kbn.calculateInterval(absoluteRange, resolution, datasource.interval);
|
||||
const { interval, intervalMs } = getIntervals(range, datasource, this.el.offsetWidth);
|
||||
const targets = [
|
||||
{
|
||||
...targetOptions,
|
||||
@ -491,6 +496,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
|
||||
return {
|
||||
interval,
|
||||
intervalMs,
|
||||
targets,
|
||||
range: queryRange,
|
||||
};
|
||||
@ -759,6 +765,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
const tableButtonActive = showingBoth || showingTable ? 'active' : '';
|
||||
const exploreClass = split ? 'explore explore-split' : 'explore';
|
||||
const selectedDatasource = datasource ? exploreDatasources.find(d => d.label === datasource.name) : undefined;
|
||||
const graphRangeIntervals = getIntervals(graphRange, datasource, this.el ? this.el.offsetWidth : 0);
|
||||
const graphLoading = queryTransactions.some(qt => qt.resultType === 'Graph' && !qt.done);
|
||||
const tableLoading = queryTransactions.some(qt => qt.resultType === 'Table' && !qt.done);
|
||||
const logsLoading = queryTransactions.some(qt => qt.resultType === 'Logs' && !qt.done);
|
||||
@ -770,9 +777,15 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
new TableModel(),
|
||||
...queryTransactions.filter(qt => qt.resultType === 'Table' && qt.done && qt.result).map(qt => qt.result)
|
||||
);
|
||||
const logsResult = mergeStreams(
|
||||
queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result)
|
||||
);
|
||||
const logsResult =
|
||||
datasource && datasource.mergeStreams
|
||||
? datasource.mergeStreams(
|
||||
_.flatten(
|
||||
queryTransactions.filter(qt => qt.resultType === 'Logs' && qt.done && qt.result).map(qt => qt.result)
|
||||
),
|
||||
graphRangeIntervals.intervalMs
|
||||
)
|
||||
: undefined;
|
||||
const loading = queryTransactions.some(qt => !qt.done);
|
||||
const showStartPages = StartPage && queryTransactions.length === 0;
|
||||
const viewModeCount = [supportsGraph, supportsLogs, supportsTable].filter(m => m).length;
|
||||
@ -894,6 +907,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
height={graphHeight}
|
||||
loading={graphLoading}
|
||||
id={`explore-graph-${position}`}
|
||||
onChangeTime={this.onChangeTime}
|
||||
range={graphRange}
|
||||
split={split}
|
||||
/>
|
||||
@ -903,7 +917,15 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
|
||||
<Table data={tableResult} loading={tableLoading} onClickCell={this.onClickTableCell} />
|
||||
</div>
|
||||
) : null}
|
||||
{supportsLogs && showingLogs ? <Logs data={logsResult} loading={logsLoading} /> : null}
|
||||
{supportsLogs && showingLogs ? (
|
||||
<Logs
|
||||
data={logsResult}
|
||||
loading={logsLoading}
|
||||
position={position}
|
||||
onChangeTime={this.onChangeTime}
|
||||
range={range}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
|
@ -5,6 +5,8 @@ import { withSize } from 'react-sizeme';
|
||||
|
||||
import 'vendor/flot/jquery.flot';
|
||||
import 'vendor/flot/jquery.flot.time';
|
||||
import 'vendor/flot/jquery.flot.selection';
|
||||
import 'vendor/flot/jquery.flot.stack';
|
||||
|
||||
import { RawTimeRange } from 'app/types/series';
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
@ -62,10 +64,10 @@ const FLOT_OPTIONS = {
|
||||
margin: { left: 0, right: 0 },
|
||||
labelMarginX: 0,
|
||||
},
|
||||
// selection: {
|
||||
// mode: 'x',
|
||||
// color: '#666',
|
||||
// },
|
||||
selection: {
|
||||
mode: 'x',
|
||||
color: '#666',
|
||||
},
|
||||
// crosshair: {
|
||||
// mode: 'x',
|
||||
// },
|
||||
@ -79,6 +81,8 @@ interface GraphProps {
|
||||
range: RawTimeRange;
|
||||
split?: boolean;
|
||||
size?: { width: number; height: number };
|
||||
userOptions?: any;
|
||||
onChangeTime?: (range: RawTimeRange) => void;
|
||||
}
|
||||
|
||||
interface GraphState {
|
||||
@ -86,6 +90,8 @@ interface GraphState {
|
||||
}
|
||||
|
||||
export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
$el: any;
|
||||
|
||||
state = {
|
||||
showAllTimeSeries: false,
|
||||
};
|
||||
@ -98,6 +104,8 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
|
||||
componentDidMount() {
|
||||
this.draw();
|
||||
this.$el = $(`#${this.props.id}`);
|
||||
this.$el.bind('plotselected', this.onPlotSelected);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: GraphProps) {
|
||||
@ -112,6 +120,20 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.$el.unbind('plotselected', this.onPlotSelected);
|
||||
}
|
||||
|
||||
onPlotSelected = (event, ranges) => {
|
||||
if (this.props.onChangeTime) {
|
||||
const range = {
|
||||
from: moment(ranges.xaxis.from),
|
||||
to: moment(ranges.xaxis.to),
|
||||
};
|
||||
this.props.onChangeTime(range);
|
||||
}
|
||||
};
|
||||
|
||||
onShowAllTimeSeries = () => {
|
||||
this.setState(
|
||||
{
|
||||
@ -122,7 +144,7 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
};
|
||||
|
||||
draw() {
|
||||
const { range, size } = this.props;
|
||||
const { range, size, userOptions = {} } = this.props;
|
||||
const data = this.getGraphData();
|
||||
|
||||
const $el = $(`#${this.props.id}`);
|
||||
@ -153,12 +175,14 @@ export class Graph extends PureComponent<GraphProps, GraphState> {
|
||||
max: max,
|
||||
label: 'Datetime',
|
||||
ticks: ticks,
|
||||
timezone: 'browser',
|
||||
timeformat: time_format(ticks, min, max),
|
||||
},
|
||||
};
|
||||
const options = {
|
||||
...FLOT_OPTIONS,
|
||||
...dynamicOptions,
|
||||
...userOptions,
|
||||
};
|
||||
$.plot($el, series, options);
|
||||
}
|
||||
|
@ -1,29 +1,135 @@
|
||||
import React, { Fragment, PureComponent } from 'react';
|
||||
import Highlighter from 'react-highlight-words';
|
||||
|
||||
import { RawTimeRange } from 'app/types/series';
|
||||
import { LogsModel } from 'app/core/logs_model';
|
||||
import { findHighlightChunksInText } from 'app/core/utils/text';
|
||||
import { Switch } from 'app/core/components/Switch/Switch';
|
||||
|
||||
import Graph from './Graph';
|
||||
|
||||
const graphOptions = {
|
||||
series: {
|
||||
bars: {
|
||||
show: true,
|
||||
lineWidth: 5,
|
||||
// barWidth: 10,
|
||||
},
|
||||
// stack: true,
|
||||
},
|
||||
yaxis: {
|
||||
tickDecimals: 0,
|
||||
},
|
||||
};
|
||||
|
||||
interface LogsProps {
|
||||
className?: string;
|
||||
data: LogsModel;
|
||||
loading: boolean;
|
||||
position: string;
|
||||
range?: RawTimeRange;
|
||||
onChangeTime?: (range: RawTimeRange) => void;
|
||||
}
|
||||
|
||||
export default class Logs extends PureComponent<LogsProps, {}> {
|
||||
interface LogsState {
|
||||
showLabels: boolean;
|
||||
showLocalTime: boolean;
|
||||
showUtc: boolean;
|
||||
}
|
||||
|
||||
export default class Logs extends PureComponent<LogsProps, LogsState> {
|
||||
state = {
|
||||
showLabels: true,
|
||||
showLocalTime: true,
|
||||
showUtc: false,
|
||||
};
|
||||
|
||||
onChangeLabels = (event: React.SyntheticEvent) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.setState({
|
||||
showLabels: target.checked,
|
||||
});
|
||||
};
|
||||
|
||||
onChangeLocalTime = (event: React.SyntheticEvent) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.setState({
|
||||
showLocalTime: target.checked,
|
||||
});
|
||||
};
|
||||
|
||||
onChangeUtc = (event: React.SyntheticEvent) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
this.setState({
|
||||
showUtc: target.checked,
|
||||
});
|
||||
};
|
||||
|
||||
render() {
|
||||
const { className = '', data, loading = false } = this.props;
|
||||
const { className = '', data, loading = false, position, range } = this.props;
|
||||
const { showLabels, showLocalTime, showUtc } = this.state;
|
||||
const hasData = data && data.rows && data.rows.length > 0;
|
||||
const cssColumnSizes = ['4px'];
|
||||
if (showUtc) {
|
||||
cssColumnSizes.push('minmax(100px, max-content)');
|
||||
}
|
||||
if (showLocalTime) {
|
||||
cssColumnSizes.push('minmax(100px, max-content)');
|
||||
}
|
||||
if (showLabels) {
|
||||
cssColumnSizes.push('minmax(100px, 25%)');
|
||||
}
|
||||
cssColumnSizes.push('1fr');
|
||||
const logEntriesStyle = {
|
||||
gridTemplateColumns: cssColumnSizes.join(' '),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${className} logs`}>
|
||||
<div className="logs-graph">
|
||||
<Graph
|
||||
data={data.series}
|
||||
height="100px"
|
||||
range={range}
|
||||
id={`explore-logs-graph-${position}`}
|
||||
onChangeTime={this.props.onChangeTime}
|
||||
userOptions={graphOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel-container logs-options">
|
||||
<div className="logs-controls">
|
||||
<Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} small />
|
||||
<Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} small />
|
||||
<Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} small />
|
||||
{hasData &&
|
||||
data.meta && (
|
||||
<div className="logs-meta">
|
||||
{data.meta.map(item => (
|
||||
<div className="logs-meta-item" key={item.label}>
|
||||
<span className="logs-meta-item__label">{item.label}:</span>
|
||||
<span className="logs-meta-item__value">{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="panel-container">
|
||||
{loading && <div className="explore-panel__loader" />}
|
||||
<div className="logs-entries">
|
||||
<div className="logs-entries" style={logEntriesStyle}>
|
||||
{hasData &&
|
||||
data.rows.map(row => (
|
||||
<Fragment key={row.key}>
|
||||
<div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
|
||||
<div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>
|
||||
{showUtc && <div title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>{row.timestamp}</div>}
|
||||
{showLocalTime && <div title={`${row.timestamp} (${row.timeFromNow})`}>{row.timeLocal}</div>}
|
||||
{showLabels && (
|
||||
<div className="max-width" title={row.labels}>
|
||||
{row.labels}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<Highlighter
|
||||
textToHighlight={row.entry}
|
||||
|
@ -16,6 +16,9 @@ export const DEFAULT_RANGE = {
|
||||
* @param value Epoch or relative time
|
||||
*/
|
||||
export function parseTime(value: string, isUtc = false): string {
|
||||
if (moment.isMoment(value)) {
|
||||
return value;
|
||||
}
|
||||
if (value.indexOf('now') !== -1) {
|
||||
return value;
|
||||
}
|
||||
@ -39,7 +42,8 @@ interface TimePickerState {
|
||||
isOpen: boolean;
|
||||
isUtc: boolean;
|
||||
rangeString: string;
|
||||
refreshInterval: string;
|
||||
refreshInterval?: string;
|
||||
initialRange?: RawTimeRange;
|
||||
|
||||
// Input-controlled text, keep these in a shape that is human-editable
|
||||
fromRaw: string;
|
||||
@ -52,6 +56,22 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
isOpen: props.isOpen,
|
||||
isUtc: props.isUtc,
|
||||
rangeString: '',
|
||||
fromRaw: '',
|
||||
toRaw: '',
|
||||
initialRange: DEFAULT_RANGE,
|
||||
refreshInterval: '',
|
||||
};
|
||||
}
|
||||
|
||||
static getDerivedStateFromProps(props, state) {
|
||||
if (state.initialRange && state.initialRange === props.range) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const from = props.range ? props.range.from : DEFAULT_RANGE.from;
|
||||
const to = props.range ? props.range.to : DEFAULT_RANGE.to;
|
||||
|
||||
@ -63,13 +83,12 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
|
||||
to: toRaw,
|
||||
};
|
||||
|
||||
this.state = {
|
||||
return {
|
||||
...state,
|
||||
fromRaw,
|
||||
toRaw,
|
||||
isOpen: props.isOpen,
|
||||
isUtc: props.isUtc,
|
||||
initialRange: props.range,
|
||||
rangeString: rangeUtil.describeTimeRange(range),
|
||||
refreshInterval: '',
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -3,9 +3,10 @@ import _ from 'lodash';
|
||||
import * as dateMath from 'app/core/utils/datemath';
|
||||
|
||||
import LanguageProvider from './language_provider';
|
||||
import { processStreams } from './result_transformer';
|
||||
import { mergeStreamsToLogs } from './result_transformer';
|
||||
import { LogsStream, LogsModel, makeSeriesForLogs } from 'app/core/logs_model';
|
||||
|
||||
const DEFAULT_LIMIT = 100;
|
||||
export const DEFAULT_LIMIT = 1000;
|
||||
|
||||
const DEFAULT_QUERY_PARAMS = {
|
||||
direction: 'BACKWARD',
|
||||
@ -67,6 +68,12 @@ export default class LoggingDatasource {
|
||||
return this.backendSrv.datasourceRequest(req);
|
||||
}
|
||||
|
||||
mergeStreams(streams: LogsStream[], intervalMs: number): LogsModel {
|
||||
const logs = mergeStreamsToLogs(streams);
|
||||
logs.series = makeSeriesForLogs(logs.rows, intervalMs);
|
||||
return logs;
|
||||
}
|
||||
|
||||
prepareQueryTarget(target, options) {
|
||||
const interpolated = this.templateSrv.replace(target.expr);
|
||||
const start = this.getTime(options.range.from, false);
|
||||
@ -79,7 +86,7 @@ export default class LoggingDatasource {
|
||||
};
|
||||
}
|
||||
|
||||
query(options) {
|
||||
query(options): Promise<{ data: LogsStream[] }> {
|
||||
const queryTargets = options.targets
|
||||
.filter(target => target.expr)
|
||||
.map(target => this.prepareQueryTarget(target, options));
|
||||
@ -91,17 +98,16 @@ export default class LoggingDatasource {
|
||||
|
||||
return Promise.all(queries).then((results: any[]) => {
|
||||
// Flatten streams from multiple queries
|
||||
const allStreams = results.reduce((acc, response, i) => {
|
||||
const streams = response.data.streams || [];
|
||||
const allStreams: LogsStream[] = results.reduce((acc, response, i) => {
|
||||
const streams: LogsStream[] = response.data.streams || [];
|
||||
// Inject search for match highlighting
|
||||
const search = queryTargets[i].regexp;
|
||||
const search: string = queryTargets[i].regexp;
|
||||
streams.forEach(s => {
|
||||
s.search = search;
|
||||
});
|
||||
return [...acc, ...streams];
|
||||
}, []);
|
||||
const model = processStreams(allStreams, DEFAULT_LIMIT);
|
||||
return { data: model };
|
||||
return { data: allStreams };
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -1,14 +1,21 @@
|
||||
import { LogLevel } from 'app/core/logs_model';
|
||||
import { LogLevel, LogsStream } from 'app/core/logs_model';
|
||||
|
||||
import { getLogLevel } from './result_transformer';
|
||||
import {
|
||||
findCommonLabels,
|
||||
findUniqueLabels,
|
||||
formatLabels,
|
||||
getLogLevel,
|
||||
mergeStreamsToLogs,
|
||||
parseLabels,
|
||||
} from './result_transformer';
|
||||
|
||||
describe('getLoglevel()', () => {
|
||||
it('returns no log level on empty line', () => {
|
||||
expect(getLogLevel('')).toBe(undefined);
|
||||
expect(getLogLevel('')).toBe(LogLevel.none);
|
||||
});
|
||||
|
||||
it('returns no log level on when level is part of a word', () => {
|
||||
expect(getLogLevel('this is a warning')).toBe(undefined);
|
||||
expect(getLogLevel('this is a warning')).toBe(LogLevel.none);
|
||||
});
|
||||
|
||||
it('returns log level on line contains a log level', () => {
|
||||
@ -20,3 +27,129 @@ describe('getLoglevel()', () => {
|
||||
expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseLabels()', () => {
|
||||
it('returns no labels on emtpy labels string', () => {
|
||||
expect(parseLabels('')).toEqual({});
|
||||
expect(parseLabels('{}')).toEqual({});
|
||||
});
|
||||
|
||||
it('returns labels on labels string', () => {
|
||||
expect(parseLabels('{foo="bar", baz="42"}')).toEqual({ foo: '"bar"', baz: '"42"' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatLabels()', () => {
|
||||
it('returns no labels on emtpy label set', () => {
|
||||
expect(formatLabels({})).toEqual('');
|
||||
expect(formatLabels({}, 'foo')).toEqual('foo');
|
||||
});
|
||||
|
||||
it('returns label string on label set', () => {
|
||||
expect(formatLabels({ foo: '"bar"', baz: '"42"' })).toEqual('{baz="42", foo="bar"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('findCommonLabels()', () => {
|
||||
it('returns no common labels on empty sets', () => {
|
||||
expect(findCommonLabels([{}])).toEqual({});
|
||||
expect(findCommonLabels([{}, {}])).toEqual({});
|
||||
});
|
||||
|
||||
it('returns no common labels on differing sets', () => {
|
||||
expect(findCommonLabels([{ foo: '"bar"' }, {}])).toEqual({});
|
||||
expect(findCommonLabels([{}, { foo: '"bar"' }])).toEqual({});
|
||||
expect(findCommonLabels([{ baz: '42' }, { foo: '"bar"' }])).toEqual({});
|
||||
expect(findCommonLabels([{ foo: '42', baz: '"bar"' }, { foo: '"bar"' }])).toEqual({});
|
||||
});
|
||||
|
||||
it('returns the single labels set as common labels', () => {
|
||||
expect(findCommonLabels([{ foo: '"bar"' }])).toEqual({ foo: '"bar"' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('findUniqueLabels()', () => {
|
||||
it('returns no uncommon labels on empty sets', () => {
|
||||
expect(findUniqueLabels({}, {})).toEqual({});
|
||||
});
|
||||
|
||||
it('returns all labels given no common labels', () => {
|
||||
expect(findUniqueLabels({ foo: '"bar"' }, {})).toEqual({ foo: '"bar"' });
|
||||
});
|
||||
|
||||
it('returns all labels except the common labels', () => {
|
||||
expect(findUniqueLabels({ foo: '"bar"', baz: '"42"' }, { foo: '"bar"' })).toEqual({ baz: '"42"' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergeStreamsToLogs()', () => {
|
||||
it('returns empty logs given no streams', () => {
|
||||
expect(mergeStreamsToLogs([]).rows).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns processed logs from single stream', () => {
|
||||
const stream1: LogsStream = {
|
||||
labels: '{foo="bar"}',
|
||||
entries: [
|
||||
{
|
||||
line: 'WARN boooo',
|
||||
timestamp: '1970-01-01T00:00:00Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(mergeStreamsToLogs([stream1]).rows).toMatchObject([
|
||||
{
|
||||
entry: 'WARN boooo',
|
||||
labels: '{foo="bar"}',
|
||||
key: 'EK1970-01-01T00:00:00Z{foo="bar"}',
|
||||
logLevel: 'warn',
|
||||
uniqueLabels: '',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns merged logs from multiple streams sorted by time and with unique labels', () => {
|
||||
const stream1: LogsStream = {
|
||||
labels: '{foo="bar", baz="1"}',
|
||||
entries: [
|
||||
{
|
||||
line: 'WARN boooo',
|
||||
timestamp: '1970-01-01T00:00:01Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
const stream2: LogsStream = {
|
||||
labels: '{foo="bar", baz="2"}',
|
||||
entries: [
|
||||
{
|
||||
line: 'INFO 1',
|
||||
timestamp: '1970-01-01T00:00:00Z',
|
||||
},
|
||||
{
|
||||
line: 'INFO 2',
|
||||
timestamp: '1970-01-01T00:00:02Z',
|
||||
},
|
||||
],
|
||||
};
|
||||
expect(mergeStreamsToLogs([stream1, stream2]).rows).toMatchObject([
|
||||
{
|
||||
entry: 'INFO 2',
|
||||
labels: '{foo="bar", baz="2"}',
|
||||
logLevel: 'info',
|
||||
uniqueLabels: '{baz="2"}',
|
||||
},
|
||||
{
|
||||
entry: 'WARN boooo',
|
||||
labels: '{foo="bar", baz="1"}',
|
||||
logLevel: 'warn',
|
||||
uniqueLabels: '{baz="1"}',
|
||||
},
|
||||
{
|
||||
entry: 'INFO 1',
|
||||
labels: '{foo="bar", baz="2"}',
|
||||
logLevel: 'info',
|
||||
uniqueLabels: '{baz="2"}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -1,11 +1,26 @@
|
||||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
|
||||
import { LogLevel, LogsModel, LogRow } from 'app/core/logs_model';
|
||||
import {
|
||||
LogLevel,
|
||||
LogsMetaItem,
|
||||
LogsModel,
|
||||
LogRow,
|
||||
LogsStream,
|
||||
LogsStreamEntry,
|
||||
LogsStreamLabels,
|
||||
} from 'app/core/logs_model';
|
||||
import { DEFAULT_LIMIT } from './datasource';
|
||||
|
||||
/**
|
||||
* Returns the log level of a log line.
|
||||
* Parse the line for level words. If no level is found, it returns `LogLevel.none`.
|
||||
*
|
||||
* Example: `getLogLevel('WARN 1999-12-31 this is great') // LogLevel.warn`
|
||||
*/
|
||||
export function getLogLevel(line: string): LogLevel {
|
||||
if (!line) {
|
||||
return undefined;
|
||||
return LogLevel.none;
|
||||
}
|
||||
let level: LogLevel;
|
||||
Object.keys(LogLevel).forEach(key => {
|
||||
@ -16,37 +31,149 @@ export function getLogLevel(line: string): LogLevel {
|
||||
}
|
||||
}
|
||||
});
|
||||
if (!level) {
|
||||
level = LogLevel.none;
|
||||
}
|
||||
return level;
|
||||
}
|
||||
|
||||
export function processEntry(entry: { line: string; timestamp: string }, stream): LogRow {
|
||||
/**
|
||||
* Regexp to extract Prometheus-style labels
|
||||
*/
|
||||
const labelRegexp = /\b(\w+)(!?=~?)("[^"\n]*?")/g;
|
||||
|
||||
/**
|
||||
* Returns a map of label keys to value from an input selector string.
|
||||
*
|
||||
* Example: `parseLabels('{job="foo", instance="bar"}) // {job: "foo", instance: "bar"}`
|
||||
*/
|
||||
export function parseLabels(labels: string): LogsStreamLabels {
|
||||
const labelsByKey: LogsStreamLabels = {};
|
||||
labels.replace(labelRegexp, (_, key, operator, value) => {
|
||||
labelsByKey[key] = value;
|
||||
return '';
|
||||
});
|
||||
return labelsByKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map labels that are common to the given label sets.
|
||||
*/
|
||||
export function findCommonLabels(labelsSets: LogsStreamLabels[]): LogsStreamLabels {
|
||||
return labelsSets.reduce((acc, labels) => {
|
||||
if (!labels) {
|
||||
throw new Error('Need parsed labels to find common labels.');
|
||||
}
|
||||
if (!acc) {
|
||||
// Initial set
|
||||
acc = { ...labels };
|
||||
} else {
|
||||
// Remove incoming labels that are missing or not matching in value
|
||||
Object.keys(labels).forEach(key => {
|
||||
if (acc[key] === undefined || acc[key] !== labels[key]) {
|
||||
delete acc[key];
|
||||
}
|
||||
});
|
||||
// Remove common labels that are missing from incoming label set
|
||||
Object.keys(acc).forEach(key => {
|
||||
if (labels[key] === undefined) {
|
||||
delete acc[key];
|
||||
}
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a map of labels that are in `labels`, but not in `commonLabels`.
|
||||
*/
|
||||
export function findUniqueLabels(labels: LogsStreamLabels, commonLabels: LogsStreamLabels): LogsStreamLabels {
|
||||
const uncommonLabels: LogsStreamLabels = { ...labels };
|
||||
Object.keys(commonLabels).forEach(key => {
|
||||
delete uncommonLabels[key];
|
||||
});
|
||||
return uncommonLabels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the given labels to a string.
|
||||
*/
|
||||
export function formatLabels(labels: LogsStreamLabels, defaultValue = ''): string {
|
||||
if (!labels || Object.keys(labels).length === 0) {
|
||||
return defaultValue;
|
||||
}
|
||||
const labelKeys = Object.keys(labels).sort();
|
||||
const cleanSelector = labelKeys.map(key => `${key}=${labels[key]}`).join(', ');
|
||||
return ['{', cleanSelector, '}'].join('');
|
||||
}
|
||||
|
||||
export function processEntry(entry: LogsStreamEntry, labels: string, uniqueLabels: string, search: string): LogRow {
|
||||
const { line, timestamp } = entry;
|
||||
const { labels } = stream;
|
||||
// Assumes unique-ness, needs nanosec precision for timestamp
|
||||
const key = `EK${timestamp}${labels}`;
|
||||
const time = moment(timestamp);
|
||||
const timeEpochMs = time.valueOf();
|
||||
const timeFromNow = time.fromNow();
|
||||
const timeLocal = time.format('YYYY-MM-DD HH:mm:ss');
|
||||
const logLevel = getLogLevel(line);
|
||||
|
||||
return {
|
||||
key,
|
||||
labels,
|
||||
logLevel,
|
||||
timeFromNow,
|
||||
timeEpochMs,
|
||||
timeLocal,
|
||||
uniqueLabels,
|
||||
entry: line,
|
||||
searchWords: [stream.search],
|
||||
searchWords: search ? [search] : [],
|
||||
timestamp: timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
export function processStreams(streams, limit?: number): LogsModel {
|
||||
const combinedEntries = streams.reduce((acc, stream) => {
|
||||
return [...acc, ...stream.entries.map(entry => processEntry(entry, stream))];
|
||||
}, []);
|
||||
const sortedEntries = _.chain(combinedEntries)
|
||||
export function mergeStreamsToLogs(streams: LogsStream[], limit = DEFAULT_LIMIT): LogsModel {
|
||||
// Find unique labels for each stream
|
||||
streams = streams.map(stream => ({
|
||||
...stream,
|
||||
parsedLabels: parseLabels(stream.labels),
|
||||
}));
|
||||
const commonLabels = findCommonLabels(streams.map(model => model.parsedLabels));
|
||||
streams = streams.map(stream => ({
|
||||
...stream,
|
||||
uniqueLabels: formatLabels(findUniqueLabels(stream.parsedLabels, commonLabels)),
|
||||
}));
|
||||
|
||||
// Merge stream entries into single list of log rows
|
||||
const sortedRows: LogRow[] = _.chain(streams)
|
||||
.reduce(
|
||||
(acc: LogRow[], stream: LogsStream) => [
|
||||
...acc,
|
||||
...stream.entries.map(entry => processEntry(entry, stream.labels, stream.uniqueLabels, stream.search)),
|
||||
],
|
||||
[]
|
||||
)
|
||||
.sortBy('timestamp')
|
||||
.reverse()
|
||||
.slice(0, limit || combinedEntries.length)
|
||||
.value();
|
||||
return { rows: sortedEntries };
|
||||
|
||||
// Meta data to display in status
|
||||
const meta: LogsMetaItem[] = [];
|
||||
if (_.size(commonLabels) > 0) {
|
||||
meta.push({
|
||||
label: 'Common labels',
|
||||
value: formatLabels(commonLabels),
|
||||
});
|
||||
}
|
||||
if (limit) {
|
||||
meta.push({
|
||||
label: 'Limit',
|
||||
value: `${limit} (${sortedRows.length} returned)`,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
meta,
|
||||
rows: sortedRows,
|
||||
};
|
||||
}
|
||||
|
@ -116,6 +116,11 @@ $input-border: 1px solid $input-border-color;
|
||||
color: $critical;
|
||||
}
|
||||
|
||||
&--small {
|
||||
padding: ($input-padding-y / 2) ($input-padding-x / 2);
|
||||
font-size: $font-size-xs;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: $text-color-weak;
|
||||
}
|
||||
|
@ -41,7 +41,6 @@
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
color: #fff;
|
||||
font-size: $font-size-sm;
|
||||
text-align: center;
|
||||
font-size: 150%;
|
||||
display: flex;
|
||||
@ -91,6 +90,20 @@
|
||||
transform: rotateY(0);
|
||||
}
|
||||
|
||||
&--small {
|
||||
max-width: 2rem;
|
||||
min-width: 1.5rem;
|
||||
|
||||
input + label {
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
input + label::before,
|
||||
input + label::after {
|
||||
font-size: $font-size-sm;
|
||||
}
|
||||
}
|
||||
|
||||
&--table-cell {
|
||||
margin-bottom: 0;
|
||||
margin-right: 0;
|
||||
|
@ -214,7 +214,42 @@
|
||||
display: grid;
|
||||
grid-column-gap: 1rem;
|
||||
grid-row-gap: 0.1rem;
|
||||
grid-template-columns: 4px minmax(100px, max-content) 1fr;
|
||||
grid-template-columns: 4px minmax(100px, max-content) minmax(100px, 25%) 1fr;
|
||||
font-family: $font-family-monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.logs-controls {
|
||||
display: flex;
|
||||
|
||||
> * {
|
||||
margin-right: 1em;
|
||||
}
|
||||
}
|
||||
|
||||
.logs-options,
|
||||
.logs-graph {
|
||||
margin-bottom: $panel-margin;
|
||||
}
|
||||
|
||||
.logs-meta {
|
||||
flex: 1;
|
||||
color: $text-color-weak;
|
||||
padding: 2px 0;
|
||||
}
|
||||
|
||||
.logs-meta-item {
|
||||
display: inline-block;
|
||||
margin-right: 1em;
|
||||
}
|
||||
|
||||
.logs-meta-item__label {
|
||||
margin-right: 0.5em;
|
||||
font-size: 0.9em;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.logs-meta-item__value {
|
||||
font-family: $font-family-monospace;
|
||||
}
|
||||
|
||||
@ -235,18 +270,26 @@
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.logs-row-level-crit,
|
||||
.logs-row-level-crit {
|
||||
background-color: #705da0;
|
||||
}
|
||||
|
||||
.logs-row-level-error,
|
||||
.logs-row-level-err {
|
||||
background-color: $red;
|
||||
background-color: #e24d42;
|
||||
}
|
||||
|
||||
.logs-row-level-warn {
|
||||
background-color: $orange;
|
||||
background-color: #eab839;
|
||||
}
|
||||
|
||||
.logs-row-level-info {
|
||||
background-color: $green;
|
||||
background-color: #7eb26d;
|
||||
}
|
||||
|
||||
.logs-row-level-trace,
|
||||
.logs-row-level-debug {
|
||||
background-color: #1f78c1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user