diff --git a/pkg/components/dynmap/dynmap.go b/pkg/components/dynmap/dynmap.go index 96effb24332..f247aca959f 100644 --- a/pkg/components/dynmap/dynmap.go +++ b/pkg/components/dynmap/dynmap.go @@ -1,5 +1,5 @@ // uses code from https://github.com/antonholmquist/jason/blob/master/jason.go -// MIT Licence +// MIT License package dynmap diff --git a/pkg/components/dynmap/dynmap_test.go b/pkg/components/dynmap/dynmap_test.go index 62d356bd67d..68d938214a3 100644 --- a/pkg/components/dynmap/dynmap_test.go +++ b/pkg/components/dynmap/dynmap_test.go @@ -1,5 +1,5 @@ // uses code from https://github.com/antonholmquist/jason/blob/master/jason.go -// MIT Licence +// MIT License package dynmap diff --git a/pkg/services/sqlstore/org_test.go b/pkg/services/sqlstore/org_test.go index c02686c24ba..7e966106b96 100644 --- a/pkg/services/sqlstore/org_test.go +++ b/pkg/services/sqlstore/org_test.go @@ -187,7 +187,7 @@ func TestAccountDataAccess(t *testing.T) { err := DeleteOrg(&m.DeleteOrgCommand{Id: ac2.OrgId}) So(err, ShouldBeNil) - // remove frome ac2 from ac1 org + // remove ac2 user from ac1 org remCmd := m.RemoveOrgUserCommand{OrgId: ac1.OrgId, UserId: ac2.Id, ShouldDeleteOrphanedUser: true} err = RemoveOrgUser(&remCmd) So(err, ShouldBeNil) diff --git a/pkg/services/sqlstore/quota.go b/pkg/services/sqlstore/quota.go index 7005b341268..e90b7fec131 100644 --- a/pkg/services/sqlstore/quota.go +++ b/pkg/services/sqlstore/quota.go @@ -99,14 +99,14 @@ func UpdateOrgQuota(cmd *m.UpdateOrgQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - OrgId: cmd.OrgId, - Updated: time.Now(), + Target: cmd.Target, + OrgId: cmd.OrgId, } has, err := sess.Get("a) if err != nil { return err } + quota.Updated = time.Now() quota.Limit = cmd.Limit if !has { quota.Created = time.Now() @@ -201,14 +201,14 @@ func UpdateUserQuota(cmd *m.UpdateUserQuotaCmd) error { return inTransaction(func(sess *DBSession) error { //Check if quota is already defined in the DB quota := m.Quota{ - Target: cmd.Target, - UserId: cmd.UserId, - Updated: time.Now(), + Target: cmd.Target, + UserId: cmd.UserId, } has, err := sess.Get("a) if err != nil { return err } + quota.Updated = time.Now() quota.Limit = cmd.Limit if !has { quota.Created = time.Now() diff --git a/pkg/services/sqlstore/quota_test.go b/pkg/services/sqlstore/quota_test.go index 49e028e9cd3..976d54d10e2 100644 --- a/pkg/services/sqlstore/quota_test.go +++ b/pkg/services/sqlstore/quota_test.go @@ -2,6 +2,7 @@ package sqlstore import ( "testing" + "time" m "github.com/grafana/grafana/pkg/models" "github.com/grafana/grafana/pkg/setting" @@ -168,5 +169,69 @@ func TestQuotaCommandsAndQueries(t *testing.T) { So(query.Result.Limit, ShouldEqual, 5) So(query.Result.Used, ShouldEqual, 1) }) + + // related: https://github.com/grafana/grafana/issues/14342 + Convey("Should org quota updating is successful even if it called multiple time", func() { + orgCmd := m.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 5, + } + err := UpdateOrgQuota(&orgCmd) + So(err, ShouldBeNil) + + query := m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = GetOrgQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 5) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + orgCmd = m.UpdateOrgQuotaCmd{ + OrgId: orgId, + Target: "org_user", + Limit: 10, + } + err = UpdateOrgQuota(&orgCmd) + So(err, ShouldBeNil) + + query = m.GetOrgQuotaByTargetQuery{OrgId: orgId, Target: "org_user", Default: 1} + err = GetOrgQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 10) + }) + + // related: https://github.com/grafana/grafana/issues/14342 + Convey("Should user quota updating is successful even if it called multiple time", func() { + userQuotaCmd := m.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 5, + } + err := UpdateUserQuota(&userQuotaCmd) + So(err, ShouldBeNil) + + query := m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = GetUserQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 5) + + // XXX: resolution of `Updated` column is 1sec, so this makes delay + time.Sleep(1 * time.Second) + + userQuotaCmd = m.UpdateUserQuotaCmd{ + UserId: userId, + Target: "org_user", + Limit: 10, + } + err = UpdateUserQuota(&userQuotaCmd) + So(err, ShouldBeNil) + + query = m.GetUserQuotaByTargetQuery{UserId: userId, Target: "org_user", Default: 1} + err = GetUserQuotaByTarget(&query) + So(err, ShouldBeNil) + So(query.Result.Limit, ShouldEqual, 10) + }) }) } diff --git a/pkg/tsdb/elasticsearch/response_parser.go b/pkg/tsdb/elasticsearch/response_parser.go index 0837c3dd9d5..b2c724a9b93 100644 --- a/pkg/tsdb/elasticsearch/response_parser.go +++ b/pkg/tsdb/elasticsearch/response_parser.go @@ -541,7 +541,7 @@ func getErrorFromElasticResponse(response *es.SearchResponse) *tsdb.QueryResult } else if reason != "" { result.ErrorString = reason } else { - result.ErrorString = "Unkown elasticsearch error response" + result.ErrorString = "Unknown elasticsearch error response" } return result diff --git a/pkg/tsdb/influxdb/query_part.go b/pkg/tsdb/influxdb/query_part.go index 77f565a8597..29a77f15617 100644 --- a/pkg/tsdb/influxdb/query_part.go +++ b/pkg/tsdb/influxdb/query_part.go @@ -32,6 +32,7 @@ func init() { renders["median"] = QueryDefinition{Renderer: functionRenderer} renders["sum"] = QueryDefinition{Renderer: functionRenderer} renders["mode"] = QueryDefinition{Renderer: functionRenderer} + renders["cumulative_sum"] = QueryDefinition{Renderer: functionRenderer} renders["holt_winters"] = QueryDefinition{ Renderer: functionRenderer, diff --git a/pkg/tsdb/influxdb/query_part_test.go b/pkg/tsdb/influxdb/query_part_test.go index 08bcff9b727..76daf6446d8 100644 --- a/pkg/tsdb/influxdb/query_part_test.go +++ b/pkg/tsdb/influxdb/query_part_test.go @@ -23,6 +23,7 @@ func TestInfluxdbQueryPart(t *testing.T) { {mode: "alias", params: []string{"test"}, input: "mean(value)", expected: `mean(value) AS "test"`}, {mode: "count", params: []string{}, input: "distinct(value)", expected: `count(distinct(value))`}, {mode: "mode", params: []string{}, input: "value", expected: `mode(value)`}, + {mode: "cumulative_sum", params: []string{}, input: "mean(value)", expected: `cumulative_sum(mean(value))`}, } queryContext := &tsdb.TsdbQuery{TimeRange: tsdb.NewTimeRange("5m", "now")} diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index 16da764de54..a810d3c7338 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -84,7 +84,7 @@ func (e *OpenTsdbExecutor) createRequest(dsInfo *models.DataSource, data OpenTsd postData, err := json.Marshal(data) if err != nil { - plog.Info("Failed marshalling data", "error", err) + plog.Info("Failed marshaling data", "error", err) return nil, fmt.Errorf("Failed to create request. error: %v", err) } diff --git a/public/app/core/components/PermissionList/AddPermission.tsx b/public/app/core/components/PermissionList/AddPermission.tsx index 71cc937ddfa..d6da7c68544 100644 --- a/public/app/core/components/PermissionList/AddPermission.tsx +++ b/public/app/core/components/PermissionList/AddPermission.tsx @@ -84,7 +84,7 @@ class AddPermissions extends Component { render() { const { onCancel } = this.props; const newItem = this.state; - const pickerClassName = 'width-20'; + const pickerClassName = 'min-width-20'; const isValid = this.isValid(); return (
diff --git a/public/app/core/components/Picker/UserPicker.tsx b/public/app/core/components/Picker/UserPicker.tsx index f78cf69bf5e..f80a3fc135f 100644 --- a/public/app/core/components/Picker/UserPicker.tsx +++ b/public/app/core/components/Picker/UserPicker.tsx @@ -40,7 +40,7 @@ export class UserPicker extends Component { .then(result => { return result.map(user => ({ id: user.userId, - label: `${user.login} - ${user.email}`, + label: user.login === user.email ? user.login : `${user.login} - ${user.email}`, avatarUrl: user.avatarUrl, login: user.login, })); diff --git a/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx new file mode 100644 index 00000000000..1e9ae4732df --- /dev/null +++ b/public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx @@ -0,0 +1,68 @@ +import React, { SFC, ReactNode, PureComponent, ReactElement } from 'react'; + +interface ToggleButtonGroupProps { + onChange: (value) => void; + value?: any; + label?: string; + render: (props) => void; +} + +export default class ToggleButtonGroup extends PureComponent { + getValues() { + const { children } = this.props; + return React.Children.toArray(children).map((c: ReactElement) => c.props.value); + } + + smallChildren() { + const { children } = this.props; + return React.Children.toArray(children).every((c: ReactElement) => c.props.className.includes('small')); + } + + handleToggle(toggleValue) { + const { value, onChange } = this.props; + if (value && value === toggleValue) { + return; + } + onChange(toggleValue); + } + + render() { + const { value, label } = this.props; + const values = this.getValues(); + const selectedValue = value || values[0]; + const labelClassName = `gf-form-label ${this.smallChildren() ? 'small' : ''}`; + + return ( +
+
+ {label && } + {this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })} +
+
+ ); + } +} + +interface ToggleButtonProps { + onChange?: (value) => void; + selected?: boolean; + value: any; + className?: string; + children: ReactNode; +} + +export const ToggleButton: SFC = ({ children, selected, className = '', value, onChange }) => { + const handleChange = event => { + event.stopPropagation(); + if (onChange) { + onChange(value); + } + }; + + const btnClassName = `btn ${className} ${selected ? 'active' : ''}`; + return ( + + ); +}; diff --git a/public/app/core/config.ts b/public/app/core/config.ts index 1473f8a91f8..13d84772ecf 100644 --- a/public/app/core/config.ts +++ b/public/app/core/config.ts @@ -54,7 +54,11 @@ export class Settings { } } -const bootData = (window as any).grafanaBootData || { settings: {} }; +const bootData = (window as any).grafanaBootData || { + settings: {}, + user: {}, +}; + const options = bootData.settings; options.bootData = bootData; diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index 1efe26d28ef..09f5bb3a916 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -1,6 +1,6 @@ import _ from 'lodash'; import { TimeSeries } from 'app/core/core'; -import colors from 'app/core/utils/colors'; +import colors, { getThemeColor } from 'app/core/utils/colors'; export enum LogLevel { crit = 'critical', @@ -22,7 +22,7 @@ export const LogLevelColor = { [LogLevel.info]: colors[0], [LogLevel.debug]: colors[5], [LogLevel.trace]: colors[2], - [LogLevel.unkown]: '#ddd', + [LogLevel.unkown]: getThemeColor('#8e8e8e', '#dde4ed'), }; export interface LogSearchMatch { @@ -95,6 +95,57 @@ export enum LogsDedupStrategy { signature = 'signature', } +export interface LogsParser { + /** + * Value-agnostic matcher for a field label. + * Used to filter rows, and first capture group contains the value. + */ + buildMatcher: (label: string) => RegExp; + /** + * Regex to find a field in the log line. + * First capture group contains the label value, second capture group the value. + */ + fieldRegex: RegExp; + /** + * Function to verify if this is a valid parser for the given line. + * The parser accepts the line unless it returns undefined. + */ + test: (line: string) => any; +} + +export const LogsParsers: { [name: string]: LogsParser } = { + JSON: { + buildMatcher: label => new RegExp(`(?:{|,)\\s*"${label}"\\s*:\\s*"([^"]*)"`), + fieldRegex: /"(\w+)"\s*:\s*"([^"]*)"/, + test: line => { + try { + return JSON.parse(line); + } catch (error) {} + }, + }, + logfmt: { + buildMatcher: label => new RegExp(`(?:^|\\s)${label}=("[^"]*"|\\S+)`), + fieldRegex: /(?:^|\s)(\w+)=("[^"]*"|\S+)/, + test: line => LogsParsers.logfmt.fieldRegex.test(line), + }, +}; + +export function calculateFieldStats(rows: LogRow[], extractor: RegExp): LogsLabelStat[] { + // Consider only rows that satisfy the matcher + const rowsWithField = rows.filter(row => extractor.test(row.entry)); + const rowCount = rowsWithField.length; + + // Get field value counts for eligible rows + const countsByValue = _.countBy(rowsWithField, row => (row as LogRow).entry.match(extractor)[1]); + const sortedCounts = _.chain(countsByValue) + .map((count, value) => ({ count, value, proportion: count / rowCount })) + .sortBy('count') + .reverse() + .value(); + + return sortedCounts; +} + export function calculateLogsLabelStats(rows: LogRow[], label: string): LogsLabelStat[] { // Consider only rows that have the given label const rowsWithLabel = rows.filter(row => row.labels[label] !== undefined); @@ -151,6 +202,19 @@ export function dedupLogRows(logs: LogsModel, strategy: LogsDedupStrategy): Logs }; } +export function getParser(line: string): LogsParser { + let parser; + try { + if (LogsParsers.JSON.test(line)) { + parser = LogsParsers.JSON; + } + } catch (error) {} + if (!parser && LogsParsers.logfmt.test(line)) { + parser = LogsParsers.logfmt; + } + return parser; +} + export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set): LogsModel { if (hiddenLogLevels.size === 0) { return logs; @@ -170,16 +234,25 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set) } export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSeries[] { + // currently interval is rangeMs / resolution, which is too low for showing series as bars. + // need at least 10px per bucket, so we multiply interval by 10. Should be solved higher up the chain + // when executing queries & interval calculated and not here but this is a temporary fix. + // intervalMs = intervalMs * 10; + // Graph time series by log level const seriesByLevel = {}; - rows.forEach(row => { + const bucketSize = intervalMs * 10; + + for (const row of rows) { 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; + const time = Math.round(row.timeEpochMs / bucketSize) * bucketSize; + // Entry for time if (time === levelSeries.lastTs) { levelSeries.datapoints[levelSeries.datapoints.length - 1][0]++; @@ -187,7 +260,7 @@ export function makeSeriesForLogs(rows: LogRow[], intervalMs: number): TimeSerie levelSeries.datapoints.push([1, time]); levelSeries.lastTs = time; } - }); + } return Object.keys(seriesByLevel).reduce((acc, level) => { if (seriesByLevel[level]) { diff --git a/public/app/core/specs/logs_model.test.ts b/public/app/core/specs/logs_model.test.ts index 22673278b13..85f75b50ed0 100644 --- a/public/app/core/specs/logs_model.test.ts +++ b/public/app/core/specs/logs_model.test.ts @@ -1,4 +1,12 @@ -import { calculateLogsLabelStats, dedupLogRows, LogsDedupStrategy, LogsModel } from '../logs_model'; +import { + calculateFieldStats, + calculateLogsLabelStats, + dedupLogRows, + getParser, + LogsDedupStrategy, + LogsModel, + LogsParsers, +} from '../logs_model'; describe('dedupLogRows()', () => { test('should return rows as is when dedup is set to none', () => { @@ -107,6 +115,50 @@ describe('dedupLogRows()', () => { }); }); +describe('calculateFieldStats()', () => { + test('should return no stats for empty rows', () => { + expect(calculateFieldStats([], /foo=(.*)/)).toEqual([]); + }); + + test('should return no stats if extractor does not match', () => { + const rows = [ + { + entry: 'foo=bar', + }, + ]; + + expect(calculateFieldStats(rows as any, /baz=(.*)/)).toEqual([]); + }); + + test('should return stats for found field', () => { + const rows = [ + { + entry: 'foo="42 + 1"', + }, + { + entry: 'foo=503 baz=foo', + }, + { + entry: 'foo="42 + 1"', + }, + { + entry: 't=2018-12-05T07:44:59+0000 foo=503', + }, + ]; + + expect(calculateFieldStats(rows as any, /foo=("[^"]*"|\S+)/)).toMatchObject([ + { + value: '"42 + 1"', + count: 2, + }, + { + value: '503', + count: 2, + }, + ]); + }); +}); + describe('calculateLogsLabelStats()', () => { test('should return no stats for empty rows', () => { expect(calculateLogsLabelStats([], '')).toEqual([]); @@ -159,3 +211,70 @@ describe('calculateLogsLabelStats()', () => { ]); }); }); + +describe('getParser()', () => { + test('should return no parser on empty line', () => { + expect(getParser('')).toBeUndefined(); + }); + + test('should return no parser on unknown line pattern', () => { + expect(getParser('To Be or not to be')).toBeUndefined(); + }); + + test('should return logfmt parser on key value patterns', () => { + expect(getParser('foo=bar baz="41 + 1')).toEqual(LogsParsers.logfmt); + }); + + test('should return JSON parser on JSON log lines', () => { + // TODO implement other JSON value types than string + expect(getParser('{"foo": "bar", "baz": "41 + 1"}')).toEqual(LogsParsers.JSON); + }); +}); + +describe('LogsParsers', () => { + describe('logfmt', () => { + const parser = LogsParsers.logfmt; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('foo=bar')).toBeTruthy(); + }); + + test('should have a valid fieldRegex', () => { + const match = 'foo=bar'.match(parser.fieldRegex); + expect(match).toBeDefined(); + expect(match[1]).toBe('foo'); + expect(match[2]).toBe('bar'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = 'foo=bar'.match(matcher); + expect(match).toBeDefined(); + expect(match[1]).toBe('bar'); + }); + }); + + describe('JSON', () => { + const parser = LogsParsers.JSON; + + test('should detect format', () => { + expect(parser.test('foo')).toBeFalsy(); + expect(parser.test('{"foo":"bar"}')).toBeTruthy(); + }); + + test('should have a valid fieldRegex', () => { + const match = '{"foo":"bar"}'.match(parser.fieldRegex); + expect(match).toBeDefined(); + expect(match[1]).toBe('foo'); + expect(match[2]).toBe('bar'); + }); + + test('should build a valid value matcher', () => { + const matcher = parser.buildMatcher('foo'); + const match = '{"foo":"bar"}'.match(matcher); + expect(match).toBeDefined(); + expect(match[1]).toBe('bar'); + }); + }); +}); diff --git a/public/app/core/utils/colors.ts b/public/app/core/utils/colors.ts index 13e02b76e30..34508e94a9f 100644 --- a/public/app/core/utils/colors.ts +++ b/public/app/core/utils/colors.ts @@ -1,5 +1,6 @@ import _ from 'lodash'; import tinycolor from 'tinycolor2'; +import config from 'app/core/config'; export const PALETTE_ROWS = 4; export const PALETTE_COLUMNS = 14; @@ -90,5 +91,9 @@ export function hslToHex(color) { return tinycolor(color).toHexString(); } +export function getThemeColor(dark: string, light: string): string { + return config.bootData.user.lightTheme ? light : dark; +} + export let sortedColors = sortColorsByHue(colors); export default colors; diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index e268508b833..26b6a527d95 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -1,15 +1,15 @@ import _ from 'lodash'; import { renderUrl } from 'app/core/utils/url'; -import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore'; -import { DataQuery, RawTimeRange } from 'app/types/series'; - -import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; import kbn from 'app/core/utils/kbn'; -import colors from 'app/core/utils/colors'; -import TimeSeries from 'app/core/time_series2'; -import { parse as parseDate } from 'app/core/utils/datemath'; import store from 'app/core/store'; +import colors from 'app/core/utils/colors'; +import { parse as parseDate } from 'app/core/utils/datemath'; + +import TimeSeries from 'app/core/time_series2'; +import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; +import { ExploreState, ExploreUrlState, HistoryItem, QueryTransaction } from 'app/types/explore'; +import { DataQuery, RawTimeRange, IntervalValues, DataSourceApi } from 'app/types/series'; export const DEFAULT_RANGE = { from: 'now-6h', @@ -170,18 +170,16 @@ export function calculateResultsFromQueryTransactions( }; } -export function getIntervals( - range: RawTimeRange, - datasource, - resolution: number -): { interval: string; intervalMs: number } { +export function getIntervals(range: RawTimeRange, datasource: DataSourceApi, resolution: number): IntervalValues { 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); } diff --git a/public/app/features/dashboard/dashboard_model.ts b/public/app/features/dashboard/dashboard_model.ts index 3320783ec67..18a16d5c1d4 100644 --- a/public/app/features/dashboard/dashboard_model.ts +++ b/public/app/features/dashboard/dashboard_model.ts @@ -223,6 +223,8 @@ export class DashboardModel { } panelInitialized(panel: PanelModel) { + panel.initialized(); + if (!this.otherPanelInFullscreen(panel)) { panel.refresh(); } diff --git a/public/app/features/dashboard/dashgrid/GeneralTab.tsx b/public/app/features/dashboard/dashgrid/GeneralTab.tsx index 96694e346df..91e236c8b31 100644 --- a/public/app/features/dashboard/dashgrid/GeneralTab.tsx +++ b/public/app/features/dashboard/dashgrid/GeneralTab.tsx @@ -44,7 +44,7 @@ export class GeneralTab extends PureComponent { render() { return ( - +
(this.element = element)} /> ); diff --git a/public/app/features/dashboard/dashgrid/PanelEditor.tsx b/public/app/features/dashboard/dashgrid/PanelEditor.tsx index 44857f989f3..fa00b51823f 100644 --- a/public/app/features/dashboard/dashgrid/PanelEditor.tsx +++ b/public/app/features/dashboard/dashgrid/PanelEditor.tsx @@ -77,7 +77,7 @@ export class PanelEditor extends PureComponent { const tabs = [ { id: 'queries', text: 'Queries' }, { id: 'visualization', text: 'Visualization' }, - { id: 'advanced', text: 'Advanced' }, + { id: 'advanced', text: 'Panel Options' }, ]; if (config.alertingEnabled && plugin.id === 'graph') { diff --git a/public/app/features/dashboard/dashgrid/QueriesTab.tsx b/public/app/features/dashboard/dashgrid/QueriesTab.tsx index f202aca4277..4c341f684f7 100644 --- a/public/app/features/dashboard/dashgrid/QueriesTab.tsx +++ b/public/app/features/dashboard/dashgrid/QueriesTab.tsx @@ -258,8 +258,8 @@ export class QueriesTab extends PureComponent { }; const options = { - title: '', - icon: 'fa fa-cog', + title: 'Time Range', + icon: '', disabled: false, render: this.renderOptions, }; diff --git a/public/app/features/dashboard/dashnav/dashnav.ts b/public/app/features/dashboard/dashnav/dashnav.ts index 7312d6db784..81aeeed97d3 100644 --- a/public/app/features/dashboard/dashnav/dashnav.ts +++ b/public/app/features/dashboard/dashnav/dashnav.ts @@ -74,6 +74,11 @@ export class DashNavCtrl { } showSearch() { + if (this.dashboard.meta.fullscreen) { + this.close(); + return; + } + appEvents.emit('show-dash-search'); } diff --git a/public/app/features/dashboard/panel_model.ts b/public/app/features/dashboard/panel_model.ts index 47b0bfc1724..a664e3d8ea1 100644 --- a/public/app/features/dashboard/panel_model.ts +++ b/public/app/features/dashboard/panel_model.ts @@ -189,7 +189,7 @@ export class PanelModel { } } - panelInitialized() { + initialized() { this.events.emit('panel-initialized'); } diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index d3c4a832a13..b395468313f 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -666,6 +666,7 @@ export class Explore extends React.PureComponent { ...results, queryTransactions: nextQueryTransactions, showingStartPage: false, + graphInterval: queryOptions.intervalMs, }; }); @@ -747,7 +748,7 @@ export class Explore extends React.PureComponent { console.error(response); - let error: string | JSX.Element = response; + let error: string | JSX.Element; if (response.data) { if (typeof response.data === 'string') { error = response.data; @@ -764,6 +765,12 @@ export class Explore extends React.PureComponent { } else { throw new Error('Could not handle error response'); } + } else if (response.message) { + error = response.message; + } else if (typeof response === 'string') { + error = response; + } else { + error = 'Unknown error during query transaction. Please check JS console logs.'; } this.setState(state => { diff --git a/public/app/features/explore/LogLabels.tsx b/public/app/features/explore/LogLabels.tsx index 91e2d44e517..eb9c39050f6 100644 --- a/public/app/features/explore/LogLabels.tsx +++ b/public/app/features/explore/LogLabels.tsx @@ -24,7 +24,7 @@ function StatsRow({ active, count, proportion, value }: LogsLabelStat) { } const STATS_ROW_LIMIT = 5; -class Stats extends PureComponent<{ +export class Stats extends PureComponent<{ stats: LogsLabelStat[]; label: string; value: string; @@ -48,15 +48,21 @@ class Stats extends PureComponent<{ const otherProportion = otherCount / total; return ( - <> -
- {label}: {total} of {rowCount} rows have that label - +
+
+ + {label}: {total} of {rowCount} rows have that label + +
- {topRows.map(stat => )} - {insertActiveRow && } - {otherCount > 0 && } - +
+ {topRows.map(stat => )} + {insertActiveRow && activeRow && } + {otherCount > 0 && ( + + )} +
+
); } } diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index 6b0ae065eba..05b70fcc878 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -10,20 +10,26 @@ import { LogsModel, dedupLogRows, filterLogLevels, + getParser, LogLevel, LogsMetaKind, + LogsLabelStat, + LogsParser, LogRow, + calculateFieldStats, } from 'app/core/logs_model'; import { findHighlightChunksInText } from 'app/core/utils/text'; import { Switch } from 'app/core/components/Switch/Switch'; +import ToggleButtonGroup, { ToggleButton } from 'app/core/components/ToggleButtonGroup/ToggleButtonGroup'; import Graph from './Graph'; -import LogLabels from './LogLabels'; +import LogLabels, { Stats } from './LogLabels'; const PREVIEW_LIMIT = 100; const graphOptions = { series: { + stack: true, bars: { show: true, lineWidth: 5, @@ -36,6 +42,19 @@ const graphOptions = { }, }; +/** + * Renders a highlighted field. + * When hovering, a stats icon is shown. + */ +const FieldHighlight = onClick => props => { + return ( + + {props.children} + onClick(props.children)} /> + + ); +}; + interface RowProps { allRows: LogRow[]; highlighterExpressions?: string[]; @@ -47,63 +66,177 @@ interface RowProps { onClickLabel?: (label: string, value: string) => void; } -function Row({ - allRows, - highlighterExpressions, - onClickLabel, - row, - showDuplicates, - showLabels, - showLocalTime, - showUtc, -}: RowProps) { - const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords); - const highlights = previewHighlights ? highlighterExpressions : row.searchWords; - const needsHighlighter = highlights && highlights.length > 0; - const highlightClassName = classnames('logs-row-match-highlight', { - 'logs-row-match-highlight--preview': previewHighlights, - }); - return ( - <> - {showDuplicates && ( -
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
- )} -
- {showUtc && ( -
- {row.timestamp} -
- )} - {showLocalTime && ( -
- {row.timeLocal} -
- )} - {showLabels && ( -
- -
- )} -
- {needsHighlighter ? ( - - ) : ( - row.entry +interface RowState { + fieldCount: number; + fieldLabel: string; + fieldStats: LogsLabelStat[]; + fieldValue: string; + parsed: boolean; + parser: LogsParser; + parsedFieldHighlights: string[]; + showFieldStats: boolean; +} + +/** + * Renders a log line. + * + * When user hovers over it for a certain time, it lazily parses the log line. + * Once a parser is found, it will determine fields, that will be highlighted. + * When the user requests stats for a field, they will be calculated and rendered below the row. + */ +class Row extends PureComponent { + mouseMessageTimer: NodeJS.Timer; + + state = { + fieldCount: 0, + fieldLabel: null, + fieldStats: null, + fieldValue: null, + parsed: false, + parser: null, + parsedFieldHighlights: [], + showFieldStats: false, + }; + + componentWillUnmount() { + clearTimeout(this.mouseMessageTimer); + } + + onClickClose = () => { + this.setState({ showFieldStats: false }); + }; + + onClickHighlight = (fieldText: string) => { + const { allRows } = this.props; + const { parser } = this.state; + + const fieldMatch = fieldText.match(parser.fieldRegex); + if (fieldMatch) { + // Build value-agnostic row matcher based on the field label + const fieldLabel = fieldMatch[1]; + const fieldValue = fieldMatch[2]; + const matcher = parser.buildMatcher(fieldLabel); + const fieldStats = calculateFieldStats(allRows, matcher); + const fieldCount = fieldStats.reduce((sum, stat) => sum + stat.count, 0); + + this.setState({ fieldCount, fieldLabel, fieldStats, fieldValue, showFieldStats: true }); + } + }; + + onMouseOverMessage = () => { + // Don't parse right away, user might move along + this.mouseMessageTimer = setTimeout(this.parseMessage, 500); + }; + + onMouseOutMessage = () => { + clearTimeout(this.mouseMessageTimer); + this.setState({ parsed: false }); + }; + + parseMessage = () => { + if (!this.state.parsed) { + const { row } = this.props; + const parser = getParser(row.entry); + if (parser) { + // Use parser to highlight detected fields + const parsedFieldHighlights = []; + this.props.row.entry.replace(new RegExp(parser.fieldRegex, 'g'), substring => { + parsedFieldHighlights.push(substring.trim()); + return ''; + }); + this.setState({ parsedFieldHighlights, parsed: true, parser }); + } + } + }; + + render() { + const { + allRows, + highlighterExpressions, + onClickLabel, + row, + showDuplicates, + showLabels, + showLocalTime, + showUtc, + } = this.props; + const { + fieldCount, + fieldLabel, + fieldStats, + fieldValue, + parsed, + parsedFieldHighlights, + showFieldStats, + } = this.state; + const previewHighlights = highlighterExpressions && !_.isEqual(highlighterExpressions, row.searchWords); + const highlights = previewHighlights ? highlighterExpressions : row.searchWords; + const needsHighlighter = highlights && highlights.length > 0; + const highlightClassName = classnames('logs-row__match-highlight', { + 'logs-row__match-highlight--preview': previewHighlights, + }); + return ( +
+ {showDuplicates && ( +
{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}
)} +
+ {showUtc && ( +
+ {row.timestamp} +
+ )} + {showLocalTime && ( +
+ {row.timeLocal} +
+ )} + {showLabels && ( +
+ +
+ )} +
+ {parsed && ( + + )} + {!parsed && + needsHighlighter && ( + + )} + {!parsed && !needsHighlighter && row.entry} + {showFieldStats && ( +
+ +
+ )} +
- - ); + ); + } } function renderMetaItem(value: any, kind: LogsMetaKind) { if (kind === LogsMetaKind.LabelsMap) { return ( - + ); @@ -112,7 +245,6 @@ function renderMetaItem(value: any, kind: LogsMetaKind) { } interface LogsProps { - className?: string; data: LogsModel; highlighterExpressions: string[]; loading: boolean; @@ -220,7 +352,6 @@ export default class Logs extends PureComponent { render() { const { - className = '', data, highlighterExpressions, loading = false, @@ -263,31 +394,31 @@ export default class Logs extends PureComponent { } // Grid options - const cssColumnSizes = []; - if (showDuplicates) { - cssColumnSizes.push('max-content'); - } - // Log-level indicator line - cssColumnSizes.push('3px'); - if (showUtc) { - cssColumnSizes.push('minmax(100px, max-content)'); - } - if (showLocalTime) { - cssColumnSizes.push('minmax(100px, max-content)'); - } - if (showLabels) { - cssColumnSizes.push('fit-content(20%)'); - } - cssColumnSizes.push('1fr'); - const logEntriesStyle = { - gridTemplateColumns: cssColumnSizes.join(' '), - }; + // const cssColumnSizes = []; + // if (showDuplicates) { + // cssColumnSizes.push('max-content'); + // } + // // Log-level indicator line + // cssColumnSizes.push('3px'); + // if (showUtc) { + // cssColumnSizes.push('minmax(220px, max-content)'); + // } + // if (showLocalTime) { + // cssColumnSizes.push('minmax(140px, max-content)'); + // } + // if (showLabels) { + // cssColumnSizes.push('fit-content(20%)'); + // } + // cssColumnSizes.push('1fr'); + // const logEntriesStyle = { + // gridTemplateColumns: cssColumnSizes.join(' '), + // }; const scanText = scanRange ? `Scanning ${rangeUtil.describeTimeRange(scanRange)}` : 'Scanning...'; return ( -
-
+
+
{ userOptions={graphOptions} />
- -
-
+
+
- this.onChangeDedup(LogsDedupStrategy.none)} - /> - this.onChangeDedup(LogsDedupStrategy.exact)} - /> - this.onChangeDedup(LogsDedupStrategy.numbers)} - /> - this.onChangeDedup(LogsDedupStrategy.signature)} + + Object.keys(LogsDedupStrategy).map((dedupType, i) => ( + + {dedupType} + + )) + } /> {hasData && meta && ( -
+
{meta.map(item => ( -
- {item.label}: - {renderMetaItem(item.value, item.kind)} +
+ {item.label}: + {renderMetaItem(item.value, item.kind)}
))}
@@ -338,7 +466,7 @@ export default class Logs extends PureComponent {
-
+
{hasData && !deferLogs && // Only inject highlighterExpression in the first set for performance reasons @@ -375,7 +503,7 @@ export default class Logs extends PureComponent { {!loading && !hasData && !scanning && ( -
+
No logs found. Scan for older logs @@ -384,7 +512,7 @@ export default class Logs extends PureComponent { )} {scanning && ( -
+
{scanText} Stop scan diff --git a/public/app/features/panel/partials/soloPanel.html b/public/app/features/panel/partials/soloPanel.html index 0940e07afdd..644bbe74ffb 100644 --- a/public/app/features/panel/partials/soloPanel.html +++ b/public/app/features/panel/partials/soloPanel.html @@ -1,5 +1,4 @@ -
+
-
diff --git a/public/app/features/plugins/built_in_plugins.ts b/public/app/features/plugins/built_in_plugins.ts index a0cedb3ebfd..c4621fda289 100644 --- a/public/app/features/plugins/built_in_plugins.ts +++ b/public/app/features/plugins/built_in_plugins.ts @@ -4,7 +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 lokiPlugin from 'app/plugins/datasource/loki/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'; @@ -33,7 +33,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/loki/module': lokiPlugin, 'app/plugins/datasource/mixed/module': mixedPlugin, 'app/plugins/datasource/mysql/module': mysqlPlugin, 'app/plugins/datasource/postgres/module': postgresPlugin, diff --git a/public/app/features/teams/TeamMembers.tsx b/public/app/features/teams/TeamMembers.tsx index f43dc44808f..433702fa0d5 100644 --- a/public/app/features/teams/TeamMembers.tsx +++ b/public/app/features/teams/TeamMembers.tsx @@ -115,7 +115,7 @@ export class TeamMembers extends PureComponent {
Add Team Member
- + {this.state.newTeamMember && (
@@ -152,7 +152,7 @@ exports[`Render should render team members 1`] = ` className="gf-form-inline" >
@@ -372,7 +372,7 @@ exports[`Render should render team members when sync enabled 1`] = ` className="gf-form-inline" >
diff --git a/public/app/plugins/datasource/logging/README.md b/public/app/plugins/datasource/logging/README.md deleted file mode 100644 index 33372605973..00000000000 --- a/public/app/plugins/datasource/logging/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# 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/module.ts b/public/app/plugins/datasource/logging/module.ts deleted file mode 100644 index da00edbf40f..00000000000 --- a/public/app/plugins/datasource/logging/module.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Datasource from './datasource'; - -import LoggingStartPage from './components/LoggingStartPage'; -import LoggingQueryField from './components/LoggingQueryField'; - -export class LoggingConfigCtrl { - static templateUrl = 'partials/config.html'; -} - -export { - Datasource, - LoggingConfigCtrl as ConfigCtrl, - LoggingQueryField as ExploreQueryField, - LoggingStartPage as ExploreStartPage, -}; diff --git a/public/app/plugins/datasource/loki/README.md b/public/app/plugins/datasource/loki/README.md new file mode 100644 index 00000000000..222b7e432eb --- /dev/null +++ b/public/app/plugins/datasource/loki/README.md @@ -0,0 +1,3 @@ +# Loki Datasource - Native Plugin + +This is a **built in** datasource that allows you to connect to the Loki logging service. diff --git a/public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx similarity index 96% rename from public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx rename to public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx index 4dbf8d1ab89..01c6519916d 100644 --- a/public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx +++ b/public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx @@ -15,7 +15,7 @@ const CHEAT_SHEET_ITEMS = [ export default (props: any) => (
-

Logging Cheat Sheet

+

Loki Cheat Sheet

{CHEAT_SHEET_ITEMS.map(item => (
{item.title}
diff --git a/public/app/plugins/datasource/logging/components/LoggingQueryField.tsx b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx similarity index 94% rename from public/app/plugins/datasource/logging/components/LoggingQueryField.tsx rename to public/app/plugins/datasource/loki/components/LokiQueryField.tsx index 5667bd9a20d..005706bb8d1 100644 --- a/public/app/plugins/datasource/logging/components/LoggingQueryField.tsx +++ b/public/app/plugins/datasource/loki/components/LokiQueryField.tsx @@ -49,7 +49,7 @@ interface CascaderOption { disabled?: boolean; } -interface LoggingQueryFieldProps { +interface LokiQueryFieldProps { datasource: any; error?: string | JSX.Element; hint?: any; @@ -60,16 +60,16 @@ interface LoggingQueryFieldProps { onQueryChange?: (value: DataQuery, override?: boolean) => void; } -interface LoggingQueryFieldState { +interface LokiQueryFieldState { logLabelOptions: any[]; syntaxLoaded: boolean; } -class LoggingQueryField extends React.PureComponent { +class LokiQueryField extends React.PureComponent { plugins: any[]; languageProvider: any; - constructor(props: LoggingQueryFieldProps, context) { + constructor(props: LokiQueryFieldProps, context) { super(props, context); if (props.datasource.languageProvider) { @@ -208,8 +208,8 @@ class LoggingQueryField extends React.PureComponent {error ?
{error}
: null} @@ -229,4 +229,4 @@ class LoggingQueryField extends React.PureComponent void; } -export default class LoggingStartPage extends PureComponent { +export default class LokiStartPage extends PureComponent { render() { return (
- +
); } diff --git a/public/app/plugins/datasource/loki/datasource.test.ts b/public/app/plugins/datasource/loki/datasource.test.ts new file mode 100644 index 00000000000..ddb4d6ed549 --- /dev/null +++ b/public/app/plugins/datasource/loki/datasource.test.ts @@ -0,0 +1,98 @@ +import LokiDatasource from './datasource'; + +describe('LokiDatasource', () => { + const instanceSettings = { + url: 'myloggingurl', + }; + + describe('when performing testDataSource', () => { + let ds; + let result; + + describe('and call succeeds', () => { + beforeEach(async () => { + const backendSrv = { + async datasourceRequest() { + return Promise.resolve({ + status: 200, + data: { + values: ['avalue'], + }, + }); + }, + }; + ds = new LokiDatasource(instanceSettings, backendSrv, {}); + result = await ds.testDatasource(); + }); + + it('should return successfully', () => { + expect(result.status).toBe('success'); + }); + }); + + describe('and call fails with 401 error', () => { + beforeEach(async () => { + const backendSrv = { + async datasourceRequest() { + return Promise.reject({ + statusText: 'Unauthorized', + status: 401, + data: { + message: 'Unauthorized', + }, + }); + }, + }; + ds = new LokiDatasource(instanceSettings, backendSrv, {}); + result = await ds.testDatasource(); + }); + + it('should return error status and a detailed error message', () => { + expect(result.status).toEqual('error'); + expect(result.message).toBe('Loki: Unauthorized. 401. Unauthorized'); + }); + }); + + describe('and call fails with 404 error', () => { + beforeEach(async () => { + const backendSrv = { + async datasourceRequest() { + return Promise.reject({ + statusText: 'Not found', + status: 404, + data: '404 page not found', + }); + }, + }; + ds = new LokiDatasource(instanceSettings, backendSrv, {}); + result = await ds.testDatasource(); + }); + + it('should return error status and a detailed error message', () => { + expect(result.status).toEqual('error'); + expect(result.message).toBe('Loki: Not found. 404. 404 page not found'); + }); + }); + + describe('and call fails with 502 error', () => { + beforeEach(async () => { + const backendSrv = { + async datasourceRequest() { + return Promise.reject({ + statusText: 'Bad Gateway', + status: 502, + data: '', + }); + }, + }; + ds = new LokiDatasource(instanceSettings, backendSrv, {}); + result = await ds.testDatasource(); + }); + + it('should return error status and a detailed error message', () => { + expect(result.status).toEqual('error'); + expect(result.message).toBe('Loki: Bad Gateway. 502'); + }); + }); + }); +}); diff --git a/public/app/plugins/datasource/logging/datasource.ts b/public/app/plugins/datasource/loki/datasource.ts similarity index 86% rename from public/app/plugins/datasource/logging/datasource.ts rename to public/app/plugins/datasource/loki/datasource.ts index fca49a2f253..ebbe6bb4b56 100644 --- a/public/app/plugins/datasource/logging/datasource.ts +++ b/public/app/plugins/datasource/loki/datasource.ts @@ -27,7 +27,7 @@ function serializeParams(data: any) { .join('&'); } -export default class LoggingDatasource { +export default class LokiDatasource { languageProvider: LanguageProvider; /** @ngInject */ @@ -94,7 +94,7 @@ export default class LoggingDatasource { } metadataRequest(url) { - // HACK to get label values for {job=|}, will be replaced when implementing LoggingQueryField + // HACK to get label values for {job=|}, will be replaced when implementing LokiQueryField const apiUrl = url.replace('v1', 'prom'); return this._request(apiUrl, { silent: true }).then(res => { const data = { data: { data: res.data.values || [] } }; @@ -136,11 +136,28 @@ export default class LoggingDatasource { } return { status: 'error', - message: 'Data source connected, but no labels received. Verify that logging is configured properly.', + message: + 'Data source connected, but no labels received. Verify that Loki and Promtail is configured properly.', }; }) .catch(err => { - return { status: 'error', message: err.message }; + let message = 'Loki: '; + if (err.statusText) { + message += err.statusText; + } else { + message += 'Cannot connect to Loki'; + } + + if (err.status) { + message += `. ${err.status}`; + } + + if (err.data && err.data.message) { + message += `. ${err.data.message}`; + } else if (err.data) { + message += `. ${err.data}`; + } + return { status: 'error', message: message }; }); } } diff --git a/public/app/plugins/datasource/logging/img/grafana_icon.svg b/public/app/plugins/datasource/loki/img/grafana_icon.svg similarity index 100% rename from public/app/plugins/datasource/logging/img/grafana_icon.svg rename to public/app/plugins/datasource/loki/img/grafana_icon.svg diff --git a/public/app/plugins/datasource/loki/img/loki_icon.svg b/public/app/plugins/datasource/loki/img/loki_icon.svg new file mode 100644 index 00000000000..50199611c06 --- /dev/null +++ b/public/app/plugins/datasource/loki/img/loki_icon.svg @@ -0,0 +1,216 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/plugins/datasource/logging/language_provider.test.ts b/public/app/plugins/datasource/loki/language_provider.test.ts similarity index 100% rename from public/app/plugins/datasource/logging/language_provider.test.ts rename to public/app/plugins/datasource/loki/language_provider.test.ts diff --git a/public/app/plugins/datasource/logging/language_provider.ts b/public/app/plugins/datasource/loki/language_provider.ts similarity index 99% rename from public/app/plugins/datasource/logging/language_provider.ts rename to public/app/plugins/datasource/loki/language_provider.ts index a992084159a..dd3b4885ea5 100644 --- a/public/app/plugins/datasource/logging/language_provider.ts +++ b/public/app/plugins/datasource/loki/language_provider.ts @@ -36,7 +36,7 @@ export function addHistoryMetadata(item: CompletionItem, history: HistoryItem[]) }; } -export default class LoggingLanguageProvider extends LanguageProvider { +export default class LokiLanguageProvider extends LanguageProvider { labelKeys?: { [index: string]: string[] }; // metric -> [labelKey,...] labelValues?: { [index: string]: { [index: string]: string[] } }; // metric -> labelKey -> [labelValue,...] logLabelOptions: any[]; diff --git a/public/app/plugins/datasource/loki/module.ts b/public/app/plugins/datasource/loki/module.ts new file mode 100644 index 00000000000..41847855c2f --- /dev/null +++ b/public/app/plugins/datasource/loki/module.ts @@ -0,0 +1,15 @@ +import Datasource from './datasource'; + +import LokiStartPage from './components/LokiStartPage'; +import LokiQueryField from './components/LokiQueryField'; + +export class LokiConfigCtrl { + static templateUrl = 'partials/config.html'; +} + +export { + Datasource, + LokiConfigCtrl as ConfigCtrl, + LokiQueryField as ExploreQueryField, + LokiStartPage as ExploreStartPage, +}; diff --git a/public/app/plugins/datasource/logging/partials/config.html b/public/app/plugins/datasource/loki/partials/config.html similarity index 100% rename from public/app/plugins/datasource/logging/partials/config.html rename to public/app/plugins/datasource/loki/partials/config.html diff --git a/public/app/plugins/datasource/logging/plugin.json b/public/app/plugins/datasource/loki/plugin.json similarity index 56% rename from public/app/plugins/datasource/logging/plugin.json rename to public/app/plugins/datasource/loki/plugin.json index c2e49842521..30997ca7632 100644 --- a/public/app/plugins/datasource/logging/plugin.json +++ b/public/app/plugins/datasource/loki/plugin.json @@ -1,7 +1,7 @@ { "type": "datasource", - "name": "Grafana Logging", - "id": "logging", + "name": "Loki", + "id": "loki", "metrics": false, "alerting": false, "annotations": false, @@ -9,19 +9,19 @@ "explore": true, "tables": true, "info": { - "description": "Grafana Logging Data Source for Grafana", + "description": "Loki Logging Data Source for Grafana", "author": { "name": "Grafana Project", "url": "https://grafana.com" }, "logos": { - "small": "img/grafana_icon.svg", - "large": "img/grafana_icon.svg" + "small": "img/loki_icon.svg", + "large": "img/loki_icon.svg" }, "links": [ { - "name": "Grafana Logging", - "url": "https://grafana.com/" + "name": "Loki", + "url": "https://github.com/grafana/loki" } ], "version": "5.3.0" diff --git a/public/app/plugins/datasource/logging/query_utils.test.ts b/public/app/plugins/datasource/loki/query_utils.test.ts similarity index 100% rename from public/app/plugins/datasource/logging/query_utils.test.ts rename to public/app/plugins/datasource/loki/query_utils.test.ts diff --git a/public/app/plugins/datasource/logging/query_utils.ts b/public/app/plugins/datasource/loki/query_utils.ts similarity index 100% rename from public/app/plugins/datasource/logging/query_utils.ts rename to public/app/plugins/datasource/loki/query_utils.ts diff --git a/public/app/plugins/datasource/logging/result_transformer.test.ts b/public/app/plugins/datasource/loki/result_transformer.test.ts similarity index 100% rename from public/app/plugins/datasource/logging/result_transformer.test.ts rename to public/app/plugins/datasource/loki/result_transformer.test.ts diff --git a/public/app/plugins/datasource/logging/result_transformer.ts b/public/app/plugins/datasource/loki/result_transformer.ts similarity index 100% rename from public/app/plugins/datasource/logging/result_transformer.ts rename to public/app/plugins/datasource/loki/result_transformer.ts diff --git a/public/app/plugins/datasource/logging/syntax.ts b/public/app/plugins/datasource/loki/syntax.ts similarity index 100% rename from public/app/plugins/datasource/logging/syntax.ts rename to public/app/plugins/datasource/loki/syntax.ts diff --git a/public/app/plugins/datasource/postgres/meta_query.ts b/public/app/plugins/datasource/postgres/meta_query.ts index fd13f3b4482..07ea3e51d87 100644 --- a/public/app/plugins/datasource/postgres/meta_query.ts +++ b/public/app/plugins/datasource/postgres/meta_query.ts @@ -151,8 +151,7 @@ table_schema IN ( buildDatatypeQuery(column: string) { let query = 'SELECT udt_name FROM information_schema.columns WHERE '; - query += this.buildSchemaConstraint(); - query += ' AND table_name = ' + this.quoteIdentAsLiteral(this.target.table); + query += this.buildTableConstraint(this.target.table); query += ' AND column_name = ' + this.quoteIdentAsLiteral(column); return query; } diff --git a/public/app/plugins/datasource/prometheus/result_transformer.ts b/public/app/plugins/datasource/prometheus/result_transformer.ts index c9693eaf657..3c21e0c3d51 100644 --- a/public/app/plugins/datasource/prometheus/result_transformer.ts +++ b/public/app/plugins/datasource/prometheus/result_transformer.ts @@ -16,7 +16,7 @@ export class ResultTransformer { options.valueWithRefId ), ]; - } else if (options.format === 'heatmap') { + } else if (prometheusResult && options.format === 'heatmap') { let seriesList = []; prometheusResult.sort(sortSeriesByLabel); for (const metricData of prometheusResult) { @@ -24,7 +24,7 @@ export class ResultTransformer { } seriesList = this.transformToHistogramOverTime(seriesList); return seriesList; - } else { + } else if (prometheusResult) { const seriesList = []; for (const metricData of prometheusResult) { if (response.data.data.resultType === 'matrix') { @@ -82,7 +82,7 @@ export class ResultTransformer { let i, j; const metricLabels = {}; - if (md.length === 0) { + if (!md || md.length === 0) { return table; } diff --git a/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts b/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts index 0ccb79a5d1f..d7e42237f8a 100644 --- a/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts +++ b/public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts @@ -10,6 +10,31 @@ describe('Prometheus Result Transformer', () => { ctx.resultTransformer = new ResultTransformer(ctx.templateSrv); }); + describe('When nothing is returned', () => { + test('should return empty series', () => { + const response = { + status: 'success', + data: { + resultType: '', + result: null, + }, + }; + const series = ctx.resultTransformer.transform({ data: response }, {}); + expect(series).toEqual([]); + }); + test('should return empty table', () => { + const response = { + status: 'success', + data: { + resultType: '', + result: null, + }, + }; + const table = ctx.resultTransformer.transform({ data: response }, { format: 'table' }); + expect(table).toMatchObject([{ type: 'table', rows: [] }]); + }); + }); + describe('When resultFormat is table', () => { const response = { status: 'success', diff --git a/public/app/types/index.ts b/public/app/types/index.ts index e66153ab723..89d1f3a79a1 100644 --- a/public/app/types/index.ts +++ b/public/app/types/index.ts @@ -19,6 +19,7 @@ import { DataQuery, DataQueryResponse, DataQueryOptions, + IntervalValues, } from './series'; import { PanelProps, PanelOptionsProps, RangeMap, Threshold, ValueMap } from './panel'; import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins'; @@ -94,6 +95,7 @@ export { ValidationRule, ValueMap, RangeMap, + IntervalValues, }; export interface StoreState { diff --git a/public/app/types/series.ts b/public/app/types/series.ts index 0ad0f6e00f8..a9585a2c842 100644 --- a/public/app/types/series.ts +++ b/public/app/types/series.ts @@ -19,6 +19,11 @@ export interface TimeRange { raw: RawTimeRange; } +export interface IntervalValues { + interval: string; // 10s,5m + intervalMs: number; +} + export type TimeSeriesValue = string | number | null; export type TimeSeriesPoints = TimeSeriesValue[][]; @@ -90,6 +95,9 @@ export interface DataQueryOptions { } export interface DataSourceApi { + /** + * min interval range + */ interval?: string; /** diff --git a/public/sass/_grafana.scss b/public/sass/_grafana.scss index 22db9a24ecf..892a576fc87 100644 --- a/public/sass/_grafana.scss +++ b/public/sass/_grafana.scss @@ -59,6 +59,7 @@ @import 'components/panel_text'; @import 'components/panel_heatmap'; @import 'components/panel_add_panel'; +@import 'components/panel_logs'; @import 'components/settings_permissions'; @import 'components/tagsinput'; @import 'components/tables_lists'; @@ -104,6 +105,7 @@ @import 'components/page_loader'; @import 'components/unit-picker'; @import 'components/thresholds'; +@import 'components/toggle_button_group'; // PAGES @import 'pages/login'; diff --git a/public/sass/_variables.dark.scss b/public/sass/_variables.dark.scss index a08fa7f6a0d..ae410ac6cc0 100644 --- a/public/sass/_variables.dark.scss +++ b/public/sass/_variables.dark.scss @@ -237,6 +237,7 @@ $horizontalComponentOffset: 180px; // ------------------------- $navbarHeight: 55px; + $navbarBackground: $panel-bg; $navbarBorder: 1px solid $dark-3; $navbarShadow: 0 0 20px black; @@ -387,3 +388,6 @@ $panel-editor-viz-item-bg-hover-active: darken($orange, 45%); $panel-grid-placeholder-bg: darken($blue, 47%); $panel-grid-placeholder-shadow: 0 0 4px $blue; + +// logs +$logs-color-unkown: $gray-2; diff --git a/public/sass/_variables.light.scss b/public/sass/_variables.light.scss index 8a7c0c9ff8e..9c056294062 100644 --- a/public/sass/_variables.light.scss +++ b/public/sass/_variables.light.scss @@ -397,3 +397,6 @@ $panel-editor-viz-item-bg-hover-active: lighten($orange, 34%); $panel-grid-placeholder-bg: lighten($blue, 62%); $panel-grid-placeholder-shadow: 0 0 4px $blue-light; + +// logs +$logs-color-unkown: $gray-5; diff --git a/public/sass/base/_type.scss b/public/sass/base/_type.scss index 2de8665f06a..1a005b0d511 100644 --- a/public/sass/base/_type.scss +++ b/public/sass/base/_type.scss @@ -199,7 +199,6 @@ small, mark, .mark { - padding: 0.2em; background: $alert-warning-bg; } diff --git a/public/sass/components/_panel_logs.scss b/public/sass/components/_panel_logs.scss new file mode 100644 index 00000000000..8220cfed878 --- /dev/null +++ b/public/sass/components/_panel_logs.scss @@ -0,0 +1,294 @@ +$column-horizontal-spacing: 10px; + +.logs-panel-controls { + display: flex; + background-color: $page-bg; + padding: $panel-padding; + padding-top: 10px; + border-radius: $border-radius; + margin: 2*$panel-margin 0; + border: $panel-border; + justify-items: flex-start; + align-items: flex-start; + + > * { + margin-right: 1em; + } +} + +.logs-panel-nodata { + > * { + margin-left: 0.5em; + } +} + +.logs-panel-meta { + flex: 1; + color: $text-color-weak; + // Align first line with controls labels + margin-top: -2px; +} + +.logs-panel-meta__item { + margin-right: 1em; +} + +.logs-panel-meta__label { + margin-right: 0.5em; + font-size: 0.9em; + font-weight: 500; +} + +.logs-panel-meta__value { + font-family: $font-family-monospace; +} + +.logs-panel-meta-item__labels { + // compensate for the labels padding + position: relative; + top: 4px; +} + +.logs-rows { + font-family: $font-family-monospace; + font-size: 12px; + display: table; + table-layout: fixed; +} + +.logs-row { + display: table-row; + + > div { + display: table-cell; + padding-right: $column-horizontal-spacing; + vertical-align: middle; + border-top: 1px solid transparent; + border-bottom: 1px solid transparent; + } + + &:hover { + background: $page-bg; + } +} + +.logs-row__time { + white-space: nowrap; +} + +.logs-row__labels { + max-width: 20%; + line-height: 1.2; +} + +.logs-row__message { + word-break: break-all; + min-width: 80%; +} + +.logs-row__match-highlight { + // Undoing mark styling + background: inherit; + padding: inherit; + + color: $typeahead-selected-color; + border-bottom: 1px solid $typeahead-selected-color; + background-color: rgba($typeahead-selected-color, 0.1); + + &--preview { + background-color: rgba($typeahead-selected-color, 0.2); + border-bottom-style: dotted; + } +} + +.logs-row__level { + position: relative; + + &::after { + content: ''; + display: block; + position: absolute; + top: 1px; + bottom: 1px; + width: 3px; + background-color: $logs-color-unkown; + } + + &--critical, + &--crit { + &::after { + background-color: #705da0; + } + } + + &--error, + &--err { + &::after { + background-color: #e24d42; + } + } + + &--warning, + &--warn { + &::after { + background-color: $warn; + } + } + + &--info { + &::after { + background-color: #7eb26d; + } + } + + &--debug { + &::after { + background-color: #1f78c1; + } + } + + &--trace { + &::after { + background-color: #6ed0e0; + } + } +} + +.logs-row__duplicates { + text-align: right; +} + +.logs-row__field-highlight { + // Undoing mark styling + background: inherit; + padding: inherit; + border-bottom: 1px dotted $typeahead-selected-color; + + .logs-row__field-highlight--icon { + margin-left: 0.5em; + cursor: pointer; + display: none; + } +} + +.logs-row__stats { + margin: 5px 0; +} + +.logs-row__field-highlight:hover { + color: $typeahead-selected-color; + border-bottom-style: solid; + + .logs-row__field-highlight--icon { + display: inline; + } +} + +.logs-label { + display: inline-block; + padding: 0 2px; + background-color: $btn-inverse-bg; + border-radius: $border-radius; + margin: 0 4px 2px 0; + text-overflow: ellipsis; + white-space: nowrap; + position: relative; +} + +.logs-label__icon { + border-left: $panel-border; + padding: 0 2px; + cursor: pointer; + margin-left: 2px; +} + +.logs-label__stats { + position: absolute; + top: 1.25em; + left: -10px; + z-index: 100; + justify-content: space-between; + box-shadow: $popover-shadow; +} + +/* +* Stats popover & message stats box +*/ +.logs-stats { + background-color: $popover-bg; + color: $popover-color; + border: 1px solid $popover-border-color; + border-radius: $border-radius; + max-width: 500px; +} + +.logs-stats__header { + background-color: $popover-border-color; + padding: 6px 10px; + display: flex; +} + +.logs-stats__title { + font-weight: $font-weight-semi-bold; + padding-right: $spacer; + overflow: hidden; + display: inline-block; + white-space: nowrap; + text-overflow: ellipsis; + flex-grow: 1; +} + +.logs-stats__body { + padding: 20px 10px 10px 10px; +} + +.logs-stats__close { + cursor: pointer; +} + +.logs-stats-row { + margin: $spacer/1.75 0; + + &--active { + color: $blue; + position: relative; + } + + &--active::after { + display: inline; + content: '*'; + position: absolute; + top: 0; + left: -8px; + } + + &__label { + display: flex; + margin-bottom: 1px; + } + + &__value { + flex: 1; + } + + &__count, + &__percent { + text-align: right; + margin-left: 0.5em; + } + + &__percent { + width: 3em; + } + + &__bar, + &__innerbar { + height: 4px; + overflow: hidden; + background: $text-color-faint; + } + + &__innerbar { + background: $blue; + } +} diff --git a/public/sass/components/_toggle_button_group.scss b/public/sass/components/_toggle_button_group.scss new file mode 100644 index 00000000000..ed701a489a9 --- /dev/null +++ b/public/sass/components/_toggle_button_group.scss @@ -0,0 +1,37 @@ +.toggle-button-group { + display: flex; + + .gf-form-label { + background-color: $input-label-bg; + &:first-child { + border-radius: $border-radius 0 0 $border-radius; + margin: 0; + } + &.small { + padding: ($input-padding-y / 2) ($input-padding-x / 2); + font-size: $font-size-xs; + } + } + + .btn { + background-color: $typeahead-selected-bg; + border-radius: 0; + color: $text-color; + &.active { + background-color: $input-bg; + &:hover { + cursor: default; + } + } + + &:first-child { + border-radius: $border-radius 0 0 $border-radius; + margin: 0; + } + + &:last-child { + border-radius: 0 $border-radius $border-radius 0; + margin-left: 0; + } + } +} diff --git a/public/sass/pages/_dashboard.scss b/public/sass/pages/_dashboard.scss index a005b0386a3..589012bff3f 100644 --- a/public/sass/pages/_dashboard.scss +++ b/public/sass/pages/_dashboard.scss @@ -15,17 +15,23 @@ div.flot-text { .panel { height: 100%; +} - &--solo { - position: fixed; - bottom: 0; - right: 0; - margin: 0; +.panel-solo { + position: fixed; + bottom: 0; + right: 0; + margin: 0; + left: 0; + top: 0; - .panel-container { - border: none; - z-index: $zindex-sidemenu + 1; - } + .panel-container { + border: none; + } + + .panel-menu-toggle, + .panel-menu { + display: none; } } diff --git a/public/sass/pages/_explore.scss b/public/sass/pages/_explore.scss index 5e69b9a1f1a..37ed0bcbc92 100644 --- a/public/sass/pages/_explore.scss +++ b/public/sass/pages/_explore.scss @@ -238,212 +238,6 @@ padding-right: 0.25em; } -.explore { - .logs { - .logs-controls { - display: flex; - background-color: $page-bg; - padding: $panel-padding; - padding-top: 10px; - border-radius: $border-radius; - margin: 2*$panel-margin 0; - border: $panel-border; - justify-items: flex-start; - align-items: flex-start; - - > * { - margin-right: 1em; - } - } - - .logs-nodata { - > * { - margin-left: 0.5em; - } - } - - .logs-meta { - flex: 1; - color: $text-color-weak; - // Align first line with controls labels - margin-top: -2px; - } - - .logs-meta-item { - 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; - } - - .logs-meta-item__value-labels { - // compensate for the labels padding - position: relative; - top: 4px; - } - - .logs-entries { - display: grid; - grid-column-gap: 1rem; - grid-row-gap: 0.1rem; - font-family: $font-family-monospace; - font-size: 12px; - } - - .logs-row-match-highlight { - // Undoing mark styling - background: inherit; - padding: inherit; - - color: $typeahead-selected-color; - border-bottom: 1px solid $typeahead-selected-color; - background-color: rgba($typeahead-selected-color, 0.1); - } - - .logs-row-match-highlight--preview { - background-color: rgba($typeahead-selected-color, 0.2); - border-bottom-style: dotted; - } - - .logs-row-level { - background-color: transparent; - margin: 2px 0; - position: relative; - opacity: 0.8; - } - - .logs-row-level-critical, - .logs-row-level-crit { - background-color: #705da0; - } - - .logs-row-level-error, - .logs-row-level-err { - background-color: #e24d42; - } - - .logs-row-level-warning, - .logs-row-level-warn { - background-color: #eab839; - } - - .logs-row-level-info { - background-color: #7eb26d; - } - - .logs-row-level-debug { - background-color: #1f78c1; - } - - .logs-row-level-trace { - background-color: #6ed0e0; - } - - .logs-row-duplicates { - text-align: right; - } - - .logs-label { - display: inline-block; - padding: 0 2px; - background-color: $btn-inverse-bg; - border-radius: $border-radius; - margin: 0 4px 2px 0; - text-overflow: ellipsis; - white-space: nowrap; - position: relative; - } - - .logs-label__icon { - border-left: $panel-border; - padding: 0 2px; - cursor: pointer; - margin-left: 2px; - } - - .logs-label__stats { - position: absolute; - top: 1.25em; - left: -10px; - z-index: 100; - background-color: $popover-bg; - color: $popover-color; - border: 1px solid $popover-border-color; - padding: 10px; - border-radius: $border-radius; - justify-content: space-between; - box-shadow: $popover-shadow; - } - - .logs-row-labels { - line-height: 1.2; - } - - .logs-stats__info { - margin-bottom: $spacer / 2; - } - - .logs-stats__icon { - margin-left: 0.5em; - cursor: pointer; - } - - .logs-stats-row { - margin: $spacer/1.75 0; - - &--active { - color: $blue; - position: relative; - } - - &--active:after { - display: inline; - content: '*'; - position: absolute; - top: 0; - left: -8px; - } - - &__label { - display: flex; - margin-bottom: 1px; - } - - &__value { - flex: 1; - } - - &__count, - &__percent { - text-align: right; - margin-left: 0.5em; - } - - &__percent { - width: 3em; - } - - &__bar, - &__innerbar { - height: 4px; - overflow: hidden; - background: $text-color-faint; - } - - &__innerbar { - background-color: $blue; - } - } - } -} - // Prometheus-specifics, to be extracted to datasource soon .explore { diff --git a/public/sass/utils/_widths.scss b/public/sass/utils/_widths.scss index 2000982f08d..b1213e6ea60 100644 --- a/public/sass/utils/_widths.scss +++ b/public/sass/utils/_widths.scss @@ -19,6 +19,12 @@ } } +@for $i from 1 through 30 { + .min-width-#{$i} { + min-width: ($spacer * $i) - $gf-form-margin !important; + } +} + @for $i from 1 through 30 { .offset-width-#{$i} { margin-left: ($spacer * $i) !important;