From 3297ae462da26f004c7d8bf4ef1da552aef84432 Mon Sep 17 00:00:00 2001 From: David Kaltschmidt Date: Fri, 20 Jul 2018 17:07:17 +0200 Subject: [PATCH] Datasource for Grafana logging platform - new builtin datasource plugin "Logging" (likely going to be renamed) - plugin implements no panel ctrls yet, only ships datasource - new models for logging data as first class citizen (aside from table and time_series model) - Logs as new view for Explore - JSON view for development Testable only against existing logish deployment. Then test with queries like `{job="..."} regexp`. --- pkg/plugins/datasource_plugin.go | 2 + public/app/containers/Explore/Explore.tsx | 108 +++++++++++--- public/app/containers/Explore/JSONViewer.tsx | 9 ++ public/app/containers/Explore/Logs.tsx | 66 +++++++++ public/app/containers/Explore/QueryField.tsx | 1 + public/app/core/logs_model.ts | 29 ++++ .../app/features/plugins/built_in_plugins.ts | 2 + .../app/plugins/datasource/logging/README.md | 3 + .../datasource/logging/datasource.jest.ts | 38 +++++ .../plugins/datasource/logging/datasource.ts | 134 ++++++++++++++++++ .../datasource/logging/img/grafana_icon.svg | 57 ++++++++ .../app/plugins/datasource/logging/module.ts | 7 + .../datasource/logging/partials/config.html | 2 + .../plugins/datasource/logging/plugin.json | 28 ++++ .../logging/result_transformer.jest.ts | 45 ++++++ .../datasource/logging/result_transformer.ts | 71 ++++++++++ public/sass/pages/_explore.scss | 37 +++++ 17 files changed, 620 insertions(+), 19 deletions(-) create mode 100644 public/app/containers/Explore/JSONViewer.tsx create mode 100644 public/app/containers/Explore/Logs.tsx create mode 100644 public/app/core/logs_model.ts create mode 100644 public/app/plugins/datasource/logging/README.md create mode 100644 public/app/plugins/datasource/logging/datasource.jest.ts create mode 100644 public/app/plugins/datasource/logging/datasource.ts create mode 100644 public/app/plugins/datasource/logging/img/grafana_icon.svg create mode 100644 public/app/plugins/datasource/logging/module.ts create mode 100644 public/app/plugins/datasource/logging/partials/config.html create mode 100644 public/app/plugins/datasource/logging/plugin.json create mode 100644 public/app/plugins/datasource/logging/result_transformer.jest.ts create mode 100644 public/app/plugins/datasource/logging/result_transformer.ts diff --git a/pkg/plugins/datasource_plugin.go b/pkg/plugins/datasource_plugin.go index cef35a2e7d969..ff44805e35f16 100644 --- a/pkg/plugins/datasource_plugin.go +++ b/pkg/plugins/datasource_plugin.go @@ -17,12 +17,14 @@ import ( plugin "github.com/hashicorp/go-plugin" ) +// DataSourcePlugin contains all metadata about a datasource plugin type DataSourcePlugin struct { FrontendPluginBase Annotations bool `json:"annotations"` Metrics bool `json:"metrics"` Alerting bool `json:"alerting"` Explore bool `json:"explore"` + Logs bool `json:"logs"` QueryOptions map[string]bool `json:"queryOptions,omitempty"` BuiltIn bool `json:"builtIn,omitempty"` Mixed bool `json:"mixed,omitempty"` diff --git a/public/app/containers/Explore/Explore.tsx b/public/app/containers/Explore/Explore.tsx index e50c06c8b1769..178e53198d454 100644 --- a/public/app/containers/Explore/Explore.tsx +++ b/public/app/containers/Explore/Explore.tsx @@ -11,6 +11,7 @@ import { parse as parseDate } from 'app/core/utils/datemath'; import ElapsedTime from './ElapsedTime'; import QueryRows from './QueryRows'; import Graph from './Graph'; +import Logs from './Logs'; import Table from './Table'; import TimePicker, { DEFAULT_RANGE } from './TimePicker'; import { ensureQueries, generateQueryKey, hasQuery } from './utils/query'; @@ -58,12 +59,17 @@ interface IExploreState { initialDatasource?: string; latency: number; loading: any; + logsResult: any; queries: any; queryError: any; range: any; requestOptions: any; showingGraph: boolean; + showingLogs: boolean; showingTable: boolean; + supportsGraph: boolean | null; + supportsLogs: boolean | null; + supportsTable: boolean | null; tableResult: any; } @@ -82,12 +88,17 @@ export class Explore extends React.Component { initialDatasource: datasource, latency: 0, loading: false, + logsResult: null, queries: ensureQueries(queries), queryError: null, range: range || { ...DEFAULT_RANGE }, requestOptions: null, showingGraph: true, + showingLogs: true, showingTable: true, + supportsGraph: null, + supportsLogs: null, + supportsTable: null, tableResult: null, ...props.initialState, }; @@ -124,17 +135,29 @@ export class Explore extends React.Component { } async setDatasource(datasource) { + const supportsGraph = datasource.meta.metrics; + const supportsLogs = datasource.meta.logs; + const supportsTable = datasource.meta.metrics; + let datasourceError = null; + try { const testResult = await datasource.testDatasource(); - if (testResult.status === 'success') { - this.setState({ datasource, datasourceError: null, datasourceLoading: false }, () => this.handleSubmit()); - } else { - this.setState({ datasource: datasource, datasourceError: testResult.message, datasourceLoading: false }); - } + datasourceError = testResult.status === 'success' ? null : testResult.message; } catch (error) { - const message = (error && error.statusText) || error; - this.setState({ datasource: datasource, datasourceError: message, datasourceLoading: false }); + datasourceError = (error && error.statusText) || error; } + + this.setState( + { + datasource, + datasourceError, + supportsGraph, + supportsLogs, + supportsTable, + datasourceLoading: false, + }, + () => datasourceError === null && this.handleSubmit() + ); } getRef = el => { @@ -157,6 +180,7 @@ export class Explore extends React.Component { datasourceError: null, datasourceLoading: true, graphResult: null, + logsResult: null, tableResult: null, }); const datasource = await this.props.datasourceSrv.get(option.value); @@ -193,6 +217,10 @@ export class Explore extends React.Component { this.setState(state => ({ showingGraph: !state.showingGraph })); }; + handleClickLogsButton = () => { + this.setState(state => ({ showingLogs: !state.showingLogs })); + }; + handleClickSplit = () => { const { onChangeSplit } = this.props; if (onChangeSplit) { @@ -214,16 +242,19 @@ export class Explore extends React.Component { }; handleSubmit = () => { - const { showingGraph, showingTable } = this.state; - if (showingTable) { + const { showingLogs, showingGraph, showingTable, supportsGraph, supportsLogs, supportsTable } = this.state; + if (showingTable && supportsTable) { this.runTableQuery(); } - if (showingGraph) { + if (showingGraph && supportsGraph) { this.runGraphQuery(); } + if (showingLogs && supportsLogs) { + this.runLogsQuery(); + } }; - buildQueryOptions(targetOptions: { format: string; instant: boolean }) { + buildQueryOptions(targetOptions: { format: string; instant?: boolean }) { const { datasource, queries, range } = this.state; const resolution = this.el.offsetWidth; const absoluteRange = { @@ -285,6 +316,29 @@ export class Explore extends React.Component { } } + async runLogsQuery() { + const { datasource, queries } = this.state; + if (!hasQuery(queries)) { + return; + } + this.setState({ latency: 0, loading: true, queryError: null, logsResult: null }); + const now = Date.now(); + const options = this.buildQueryOptions({ + format: 'logs', + }); + + try { + const res = await datasource.query(options); + const logsData = res.data; + const latency = Date.now() - now; + this.setState({ latency, loading: false, logsResult: logsData, requestOptions: options }); + } catch (response) { + console.error(response); + const queryError = response.data ? response.data.error : response; + this.setState({ loading: false, queryError }); + } + } + request = url => { const { datasource } = this.state; return datasource.metadataRequest(url); @@ -300,17 +354,23 @@ export class Explore extends React.Component { graphResult, latency, loading, + logsResult, queries, queryError, range, requestOptions, showingGraph, + showingLogs, showingTable, + supportsGraph, + supportsLogs, + supportsTable, tableResult, } = this.state; const showingBoth = showingGraph && showingTable; const graphHeight = showingBoth ? '200px' : '400px'; const graphButtonActive = showingBoth || showingGraph ? 'active' : ''; + const logsButtonActive = showingLogs ? 'active' : ''; const tableButtonActive = showingBoth || showingTable ? 'active' : ''; const exploreClass = split ? 'explore explore-split' : 'explore'; const datasources = datasourceSrv.getExploreSources().map(ds => ({ @@ -357,12 +417,21 @@ export class Explore extends React.Component { ) : null}
- - + {supportsGraph ? ( + + ) : null} + {supportsTable ? ( + + ) : null} + {supportsLogs ? ( + + ) : null}
@@ -395,7 +464,7 @@ export class Explore extends React.Component { /> {queryError ?
{queryError}
: null}
- {showingGraph ? ( + {supportsGraph && showingGraph ? ( { split={split} /> ) : null} - {showingTable ? : null} + {supportsTable && showingTable ?
: null} + {supportsLogs && showingLogs ? : null} ) : null} diff --git a/public/app/containers/Explore/JSONViewer.tsx b/public/app/containers/Explore/JSONViewer.tsx new file mode 100644 index 0000000000000..d0dbad7816986 --- /dev/null +++ b/public/app/containers/Explore/JSONViewer.tsx @@ -0,0 +1,9 @@ +import React from 'react'; + +export default function({ value }) { + return ( +
+
{JSON.stringify(value, undefined, 2)}
+
+ ); +} diff --git a/public/app/containers/Explore/Logs.tsx b/public/app/containers/Explore/Logs.tsx new file mode 100644 index 0000000000000..10d7827a9a3d6 --- /dev/null +++ b/public/app/containers/Explore/Logs.tsx @@ -0,0 +1,66 @@ +import React, { Fragment, PureComponent } from 'react'; + +import { LogsModel, LogRow } from 'app/core/logs_model'; + +interface LogsProps { + className?: string; + data: LogsModel; +} + +const EXAMPLE_QUERY = '{job="default/prometheus"}'; + +const Entry: React.SFC = props => { + const { entry, searchMatches } = props; + if (searchMatches && searchMatches.length > 0) { + let lastMatchEnd = 0; + const spans = searchMatches.reduce((acc, match, i) => { + // Insert non-match + if (match.start !== lastMatchEnd) { + acc.push(<>{entry.slice(lastMatchEnd, match.start)}); + } + // Match + acc.push( + + {entry.substr(match.start, match.length)} + + ); + lastMatchEnd = match.start + match.length; + // Non-matching end + if (i === searchMatches.length - 1) { + acc.push(<>{entry.slice(lastMatchEnd)}); + } + return acc; + }, []); + return <>{spans}; + } + return <>{props.entry}; +}; + +export default class Logs extends PureComponent { + render() { + const { className = '', data } = this.props; + const hasData = data && data.rows && data.rows.length > 0; + return ( +
+ {hasData ? ( +
+ {data.rows.map(row => ( + +
+
{row.timeLocal}
+
+ +
+ + ))} +
+ ) : null} + {!hasData ? ( +
+ Enter a query like {EXAMPLE_QUERY} +
+ ) : null} +
+ ); + } +} diff --git a/public/app/containers/Explore/QueryField.tsx b/public/app/containers/Explore/QueryField.tsx index c9d71b3b49e06..41f6d53541c68 100644 --- a/public/app/containers/Explore/QueryField.tsx +++ b/public/app/containers/Explore/QueryField.tsx @@ -417,6 +417,7 @@ class QueryField extends React.Component { const url = `/api/v1/label/${key}/values`; try { const res = await this.request(url); + console.log(res); const body = await (res.data || res.json()); const pairs = this.state.labelValues[EMPTY_METRIC]; const values = { diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts new file mode 100644 index 0000000000000..46e95a471ceec --- /dev/null +++ b/public/app/core/logs_model.ts @@ -0,0 +1,29 @@ +export enum LogLevel { + crit = 'crit', + warn = 'warn', + err = 'error', + error = 'error', + info = 'info', + debug = 'debug', + trace = 'trace', +} + +export interface LogSearchMatch { + start: number; + length: number; + text?: string; +} + +export interface LogRow { + key: string; + entry: string; + logLevel: LogLevel; + timestamp: string; + timeFromNow: string; + timeLocal: string; + searchMatches?: LogSearchMatch[]; +} + +export interface LogsModel { + rows: LogRow[]; +} diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index 656ce2bfa382a..2c5bf459edaeb 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -4,6 +4,7 @@ import * as elasticsearchPlugin from 'app/plugins/datasource/elasticsearch/modul import * as opentsdbPlugin from 'app/plugins/datasource/opentsdb/module'; import * as grafanaPlugin from 'app/plugins/datasource/grafana/module'; import * as influxdbPlugin from 'app/plugins/datasource/influxdb/module'; +import * as loggingPlugin from 'app/plugins/datasource/logging/module'; import * as mixedPlugin from 'app/plugins/datasource/mixed/module'; import * as mysqlPlugin from 'app/plugins/datasource/mysql/module'; import * as postgresPlugin from 'app/plugins/datasource/postgres/module'; @@ -28,6 +29,7 @@ const builtInPlugins = { 'app/plugins/datasource/opentsdb/module': opentsdbPlugin, 'app/plugins/datasource/grafana/module': grafanaPlugin, 'app/plugins/datasource/influxdb/module': influxdbPlugin, + 'app/plugins/datasource/logging/module': loggingPlugin, 'app/plugins/datasource/mixed/module': mixedPlugin, 'app/plugins/datasource/mysql/module': mysqlPlugin, 'app/plugins/datasource/postgres/module': postgresPlugin, diff --git a/public/app/plugins/datasource/logging/README.md b/public/app/plugins/datasource/logging/README.md new file mode 100644 index 0000000000000..333726059735a --- /dev/null +++ b/public/app/plugins/datasource/logging/README.md @@ -0,0 +1,3 @@ +# Grafana Logging Datasource - Native Plugin + +This is a **built in** datasource that allows you to connect to Grafana's logging service. \ No newline at end of file diff --git a/public/app/plugins/datasource/logging/datasource.jest.ts b/public/app/plugins/datasource/logging/datasource.jest.ts new file mode 100644 index 0000000000000..212d352dfca4a --- /dev/null +++ b/public/app/plugins/datasource/logging/datasource.jest.ts @@ -0,0 +1,38 @@ +import { parseQuery } from './datasource'; + +describe('parseQuery', () => { + it('returns empty for empty string', () => { + expect(parseQuery('')).toEqual({ + query: '', + regexp: '', + }); + }); + + it('returns regexp for strings without query', () => { + expect(parseQuery('test')).toEqual({ + query: '', + regexp: 'test', + }); + }); + + it('returns query for strings without regexp', () => { + expect(parseQuery('{foo="bar"}')).toEqual({ + query: '{foo="bar"}', + regexp: '', + }); + }); + + it('returns query for strings with query and search string', () => { + expect(parseQuery('x {foo="bar"}')).toEqual({ + query: '{foo="bar"}', + regexp: 'x', + }); + }); + + it('returns query for strings with query and regexp', () => { + expect(parseQuery('{foo="bar"} x|y')).toEqual({ + query: '{foo="bar"}', + regexp: 'x|y', + }); + }); +}); diff --git a/public/app/plugins/datasource/logging/datasource.ts b/public/app/plugins/datasource/logging/datasource.ts new file mode 100644 index 0000000000000..22edba5807aa9 --- /dev/null +++ b/public/app/plugins/datasource/logging/datasource.ts @@ -0,0 +1,134 @@ +import _ from 'lodash'; + +import * as dateMath from 'app/core/utils/datemath'; + +import { processStreams } from './result_transformer'; + +const DEFAULT_LIMIT = 100; + +const DEFAULT_QUERY_PARAMS = { + direction: 'BACKWARD', + limit: DEFAULT_LIMIT, + regexp: '', + query: '', +}; + +const QUERY_REGEXP = /({\w+="[^"]+"})?\s*(\w[^{]+)?\s*({\w+="[^"]+"})?/; +export function parseQuery(input: string) { + const match = input.match(QUERY_REGEXP); + let query = ''; + let regexp = ''; + + if (match) { + if (match[1]) { + query = match[1]; + } + if (match[2]) { + regexp = match[2].trim(); + } + if (match[3]) { + if (match[1]) { + query = `${match[1].slice(0, -1)},${match[3].slice(1)}`; + } else { + query = match[3]; + } + } + } + + return { query, regexp }; +} + +function serializeParams(data: any) { + return Object.keys(data) + .map(k => { + const v = data[k]; + return encodeURIComponent(k) + '=' + encodeURIComponent(v); + }) + .join('&'); +} + +export default class LoggingDatasource { + /** @ngInject */ + constructor(private instanceSettings, private backendSrv, private templateSrv) {} + + _request(apiUrl: string, data?, options?: any) { + const baseUrl = this.instanceSettings.url; + const params = data ? serializeParams(data) : ''; + const url = `${baseUrl}${apiUrl}?${params}`; + const req = { + ...options, + url, + }; + return this.backendSrv.datasourceRequest(req); + } + + prepareQueryTarget(target, options) { + const interpolated = this.templateSrv.replace(target.expr); + const start = this.getTime(options.range.from, false); + const end = this.getTime(options.range.to, true); + return { + ...DEFAULT_QUERY_PARAMS, + ...parseQuery(interpolated), + start, + end, + }; + } + + query(options) { + const queryTargets = options.targets + .filter(target => target.expr) + .map(target => this.prepareQueryTarget(target, options)); + if (queryTargets.length === 0) { + return Promise.resolve({ data: [] }); + } + + const queries = queryTargets.map(target => this._request('/api/prom/query', target)); + + return Promise.all(queries).then((results: any[]) => { + // Flatten streams from multiple queries + const allStreams = results.reduce((acc, response, i) => { + const streams = response.data.streams || []; + // Inject search for match highlighting + const search = queryTargets[i].regexp; + streams.forEach(s => { + s.search = search; + }); + return [...acc, ...streams]; + }, []); + const model = processStreams(allStreams, DEFAULT_LIMIT); + return { data: model }; + }); + } + + metadataRequest(url) { + // HACK to get label values for {job=|}, will be replaced when implementing LoggingQueryField + const apiUrl = url.replace('v1', 'prom'); + return this._request(apiUrl, { silent: true }).then(res => { + const data = { data: { data: res.data.values || [] } }; + return data; + }); + } + + getTime(date, roundUp) { + if (_.isString(date)) { + date = dateMath.parse(date, roundUp); + } + return Math.ceil(date.valueOf() * 1e6); + } + + testDatasource() { + return this._request('/api/prom/label') + .then(res => { + if (res && res.data && res.data.values && res.data.values.length > 0) { + return { status: 'success', message: 'Data source connected and labels found.' }; + } + return { + status: 'error', + message: 'Data source connected, but no labels received. Verify that logging is configured properly.', + }; + }) + .catch(err => { + return { status: 'error', message: err.message }; + }); + } +} diff --git a/public/app/plugins/datasource/logging/img/grafana_icon.svg b/public/app/plugins/datasource/logging/img/grafana_icon.svg new file mode 100644 index 0000000000000..72702223dc77b --- /dev/null +++ b/public/app/plugins/datasource/logging/img/grafana_icon.svg @@ -0,0 +1,57 @@ + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/logging/module.ts b/public/app/plugins/datasource/logging/module.ts new file mode 100644 index 0000000000000..5e3ffb3282a89 --- /dev/null +++ b/public/app/plugins/datasource/logging/module.ts @@ -0,0 +1,7 @@ +import Datasource from './datasource'; + +export class LoggingConfigCtrl { + static templateUrl = 'partials/config.html'; +} + +export { Datasource, LoggingConfigCtrl as ConfigCtrl }; diff --git a/public/app/plugins/datasource/logging/partials/config.html b/public/app/plugins/datasource/logging/partials/config.html new file mode 100644 index 0000000000000..8e79cc0adccb1 --- /dev/null +++ b/public/app/plugins/datasource/logging/partials/config.html @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/public/app/plugins/datasource/logging/plugin.json b/public/app/plugins/datasource/logging/plugin.json new file mode 100644 index 0000000000000..9aa844f21cbbf --- /dev/null +++ b/public/app/plugins/datasource/logging/plugin.json @@ -0,0 +1,28 @@ +{ + "type": "datasource", + "name": "Grafana Logging", + "id": "logging", + "metrics": false, + "alerting": false, + "annotations": false, + "logs": true, + "explore": true, + "info": { + "description": "Grafana Logging Data Source for Grafana", + "author": { + "name": "Grafana Project", + "url": "https://grafana.com" + }, + "logos": { + "small": "img/grafana_icon.svg", + "large": "img/grafana_icon.svg" + }, + "links": [ + { + "name": "Grafana Logging", + "url": "https://grafana.com/" + } + ], + "version": "5.3.0" + } +} \ No newline at end of file diff --git a/public/app/plugins/datasource/logging/result_transformer.jest.ts b/public/app/plugins/datasource/logging/result_transformer.jest.ts new file mode 100644 index 0000000000000..0d203f748ba49 --- /dev/null +++ b/public/app/plugins/datasource/logging/result_transformer.jest.ts @@ -0,0 +1,45 @@ +import { LogLevel } from 'app/core/logs_model'; + +import { getLogLevel, getSearchMatches } from './result_transformer'; + +describe('getSearchMatches()', () => { + it('gets no matches for when search and or line are empty', () => { + expect(getSearchMatches('', '')).toEqual([]); + expect(getSearchMatches('foo', '')).toEqual([]); + expect(getSearchMatches('', 'foo')).toEqual([]); + }); + + it('gets no matches for unmatched search string', () => { + expect(getSearchMatches('foo', 'bar')).toEqual([]); + }); + + it('gets matches for matched search string', () => { + expect(getSearchMatches('foo', 'foo')).toEqual([{ length: 3, start: 0, text: 'foo' }]); + expect(getSearchMatches(' foo ', 'foo')).toEqual([{ length: 3, start: 1, text: 'foo' }]); + }); + + expect(getSearchMatches(' foo foo bar ', 'foo|bar')).toEqual([ + { length: 3, start: 1, text: 'foo' }, + { length: 3, start: 5, text: 'foo' }, + { length: 3, start: 9, text: 'bar' }, + ]); +}); + +describe('getLoglevel()', () => { + it('returns no log level on empty line', () => { + expect(getLogLevel('')).toBe(undefined); + }); + + it('returns no log level on when level is part of a word', () => { + expect(getLogLevel('this is a warning')).toBe(undefined); + }); + + it('returns log level on line contains a log level', () => { + expect(getLogLevel('warn: it is looking bad')).toBe(LogLevel.warn); + expect(getLogLevel('2007-12-12 12:12:12 [WARN]: it is looking bad')).toBe(LogLevel.warn); + }); + + it('returns first log level found', () => { + expect(getLogLevel('WARN this could be a debug message')).toBe(LogLevel.warn); + }); +}); diff --git a/public/app/plugins/datasource/logging/result_transformer.ts b/public/app/plugins/datasource/logging/result_transformer.ts new file mode 100644 index 0000000000000..e238778614cc0 --- /dev/null +++ b/public/app/plugins/datasource/logging/result_transformer.ts @@ -0,0 +1,71 @@ +import _ from 'lodash'; +import moment from 'moment'; + +import { LogLevel, LogsModel, LogRow } from 'app/core/logs_model'; + +export function getLogLevel(line: string): LogLevel { + if (!line) { + return undefined; + } + let level: LogLevel; + Object.keys(LogLevel).forEach(key => { + if (!level) { + const regexp = new RegExp(`\\b${key}\\b`, 'i'); + if (regexp.test(line)) { + level = LogLevel[key]; + } + } + }); + return level; +} + +export function getSearchMatches(line: string, search: string) { + // Empty search can send re.exec() into infinite loop, exit early + if (!line || !search) { + return []; + } + const regexp = new RegExp(`(?:${search})`, 'g'); + const matches = []; + let match; + while ((match = regexp.exec(line))) { + matches.push({ + text: match[0], + start: match.index, + length: match[0].length, + }); + } + return matches; +} + +export function processEntry(entry: { line: string; timestamp: string }, stream): LogRow { + const { line, timestamp } = entry; + const { labels } = stream; + const key = `EK${timestamp}${labels}`; + const time = moment(timestamp); + const timeFromNow = time.fromNow(); + const timeLocal = time.format('YYYY-MM-DD HH:mm:ss'); + const searchMatches = getSearchMatches(line, stream.search); + const logLevel = getLogLevel(line); + + return { + key, + logLevel, + searchMatches, + timeFromNow, + timeLocal, + entry: line, + 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) + .sortBy('timestamp') + .reverse() + .slice(0, limit || combinedEntries.length) + .value(); + return { rows: sortedEntries }; +} diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index e1b170c636dbf..158f0eb68ad1f 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -97,3 +97,40 @@ .query-row-tools { width: 4rem; } + +.explore { + .logs { + .logs-entries { + display: grid; + grid-column-gap: 1rem; + grid-row-gap: 0.1rem; + grid-template-columns: 4px minmax(100px, max-content) 1fr; + font-family: $font-family-monospace; + } + + .logs-row-match-highlight { + background-color: lighten($blue, 20%); + } + + .logs-row-level { + background-color: transparent; + margin: 6px 0; + border-radius: 2px; + opacity: 0.8; + } + + .logs-row-level-crit, + .logs-row-level-error, + .logs-row-level-err { + background-color: $red; + } + + .logs-row-level-warn { + background-color: $orange; + } + + .logs-row-level-info { + background-color: $green; + } + } +}