Просмотр исходного кода

Merge branch 'master' into develop

Torkel Ödegaard 7 лет назад
Родитель
Сommit
eb6d0e9f51
61 измененных файлов с 1403 добавлено и 417 удалено
  1. 1 1
      pkg/components/dynmap/dynmap.go
  2. 1 1
      pkg/components/dynmap/dynmap_test.go
  3. 1 1
      pkg/services/sqlstore/org_test.go
  4. 6 6
      pkg/services/sqlstore/quota.go
  5. 65 0
      pkg/services/sqlstore/quota_test.go
  6. 1 1
      pkg/tsdb/elasticsearch/response_parser.go
  7. 1 0
      pkg/tsdb/influxdb/query_part.go
  8. 1 0
      pkg/tsdb/influxdb/query_part_test.go
  9. 1 1
      pkg/tsdb/opentsdb/opentsdb.go
  10. 1 1
      public/app/core/components/PermissionList/AddPermission.tsx
  11. 1 1
      public/app/core/components/Picker/UserPicker.tsx
  12. 68 0
      public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
  13. 5 1
      public/app/core/config.ts
  14. 78 5
      public/app/core/logs_model.ts
  15. 120 1
      public/app/core/specs/logs_model.test.ts
  16. 5 0
      public/app/core/utils/colors.ts
  17. 9 11
      public/app/core/utils/explore.ts
  18. 2 0
      public/app/features/dashboard/dashboard_model.ts
  19. 2 2
      public/app/features/dashboard/dashgrid/QueriesTab.tsx
  20. 1 1
      public/app/features/dashboard/panel_model.ts
  21. 8 1
      public/app/features/explore/Explore.tsx
  22. 15 9
      public/app/features/explore/LogLabels.tsx
  23. 231 103
      public/app/features/explore/Logs.tsx
  24. 1 2
      public/app/features/panel/partials/soloPanel.html
  25. 2 2
      public/app/features/plugins/built_in_plugins.ts
  26. 1 1
      public/app/features/teams/TeamMembers.tsx
  27. 3 3
      public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap
  28. 0 3
      public/app/plugins/datasource/logging/README.md
  29. 0 15
      public/app/plugins/datasource/logging/module.ts
  30. 3 0
      public/app/plugins/datasource/loki/README.md
  31. 1 1
      public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx
  32. 7 7
      public/app/plugins/datasource/loki/components/LokiQueryField.tsx
  33. 3 3
      public/app/plugins/datasource/loki/components/LokiStartPage.tsx
  34. 98 0
      public/app/plugins/datasource/loki/datasource.test.ts
  35. 21 4
      public/app/plugins/datasource/loki/datasource.ts
  36. 0 0
      public/app/plugins/datasource/loki/img/grafana_icon.svg
  37. 216 0
      public/app/plugins/datasource/loki/img/loki_icon.svg
  38. 0 0
      public/app/plugins/datasource/loki/language_provider.test.ts
  39. 1 1
      public/app/plugins/datasource/loki/language_provider.ts
  40. 15 0
      public/app/plugins/datasource/loki/module.ts
  41. 0 0
      public/app/plugins/datasource/loki/partials/config.html
  42. 7 7
      public/app/plugins/datasource/loki/plugin.json
  43. 0 0
      public/app/plugins/datasource/loki/query_utils.test.ts
  44. 0 0
      public/app/plugins/datasource/loki/query_utils.ts
  45. 0 0
      public/app/plugins/datasource/loki/result_transformer.test.ts
  46. 0 0
      public/app/plugins/datasource/loki/result_transformer.ts
  47. 0 0
      public/app/plugins/datasource/loki/syntax.ts
  48. 1 2
      public/app/plugins/datasource/postgres/meta_query.ts
  49. 3 3
      public/app/plugins/datasource/prometheus/result_transformer.ts
  50. 25 0
      public/app/plugins/datasource/prometheus/specs/result_transformer.test.ts
  51. 2 0
      public/app/types/index.ts
  52. 8 0
      public/app/types/series.ts
  53. 2 0
      public/sass/_grafana.scss
  54. 4 0
      public/sass/_variables.dark.scss
  55. 3 0
      public/sass/_variables.light.scss
  56. 0 1
      public/sass/base/_type.scss
  57. 294 0
      public/sass/components/_panel_logs.scss
  58. 37 0
      public/sass/components/_toggle_button_group.scss
  59. 15 9
      public/sass/pages/_dashboard.scss
  60. 0 206
      public/sass/pages/_explore.scss
  61. 6 0
      public/sass/utils/_widths.scss

+ 1 - 1
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
 

+ 1 - 1
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
 

+ 1 - 1
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)

+ 6 - 6
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(&quota)
 		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(&quota)
 		if err != nil {
 			return err
 		}
+		quota.Updated = time.Now()
 		quota.Limit = cmd.Limit
 		if !has {
 			quota.Created = time.Now()

+ 65 - 0
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)
+		})
 	})
 }

+ 1 - 1
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

+ 1 - 0
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,

+ 1 - 0
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")}

+ 1 - 1
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)
 	}
 

+ 1 - 1
public/app/core/components/PermissionList/AddPermission.tsx

@@ -84,7 +84,7 @@ class AddPermissions extends Component<Props, NewDashboardAclItem> {
   render() {
     const { onCancel } = this.props;
     const newItem = this.state;
-    const pickerClassName = 'width-20';
+    const pickerClassName = 'min-width-20';
     const isValid = this.isValid();
     return (
       <div className="gf-form-inline cta-form">

+ 1 - 1
public/app/core/components/Picker/UserPicker.tsx

@@ -40,7 +40,7 @@ export class UserPicker extends Component<Props, State> {
       .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,
         }));

+ 68 - 0
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<ToggleButtonGroupProps> {
+  getValues() {
+    const { children } = this.props;
+    return React.Children.toArray(children).map((c: ReactElement<any>) => c.props.value);
+  }
+
+  smallChildren() {
+    const { children } = this.props;
+    return React.Children.toArray(children).every((c: ReactElement<any>) => 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 (
+      <div className="gf-form">
+        <div className="toggle-button-group">
+          {label && <label className={labelClassName}>{label}</label>}
+          {this.props.render({ selectedValue, onChange: this.handleToggle.bind(this) })}
+        </div>
+      </div>
+    );
+  }
+}
+
+interface ToggleButtonProps {
+  onChange?: (value) => void;
+  selected?: boolean;
+  value: any;
+  className?: string;
+  children: ReactNode;
+}
+
+export const ToggleButton: SFC<ToggleButtonProps> = ({ children, selected, className = '', value, onChange }) => {
+  const handleChange = event => {
+    event.stopPropagation();
+    if (onChange) {
+      onChange(value);
+    }
+  };
+
+  const btnClassName = `btn ${className} ${selected ? 'active' : ''}`;
+  return (
+    <button className={btnClassName} onClick={handleChange}>
+      <span>{children}</span>
+    </button>
+  );
+};

+ 5 - 1
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;
 

+ 78 - 5
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<LogLevel>): LogsModel {
   if (hiddenLogLevels.size === 0) {
     return logs;
@@ -170,16 +234,25 @@ export function filterLogLevels(logs: LogsModel, hiddenLogLevels: Set<LogLevel>)
 }
 
 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]) {

+ 120 - 1
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');
+    });
+  });
+});

+ 5 - 0
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;

+ 9 - 11
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 store from 'app/core/store';
 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 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);
 }
 

+ 2 - 0
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();
     }

+ 2 - 2
public/app/features/dashboard/dashgrid/QueriesTab.tsx

@@ -258,8 +258,8 @@ export class QueriesTab extends PureComponent<Props, State> {
     };
 
     const options = {
-      title: '',
-      icon: 'fa fa-cog',
+      title: 'Time Range',
+      icon: '',
       disabled: false,
       render: this.renderOptions,
     };

+ 1 - 1
public/app/features/dashboard/panel_model.ts

@@ -189,7 +189,7 @@ export class PanelModel {
     }
   }
 
-  panelInitialized() {
+  initialized() {
     this.events.emit('panel-initialized');
   }
 

+ 8 - 1
public/app/features/explore/Explore.tsx

@@ -666,6 +666,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
         ...results,
         queryTransactions: nextQueryTransactions,
         showingStartPage: false,
+        graphInterval: queryOptions.intervalMs,
       };
     });
 
@@ -747,7 +748,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
 
     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<ExploreProps, ExploreState> {
       } 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 => {

+ 15 - 9
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 (
-      <>
-        <div className="logs-stats__info">
-          {label}: {total} of {rowCount} rows have that label
-          <span className="logs-stats__icon fa fa-window-close" onClick={onClickClose} />
+      <div className="logs-stats">
+        <div className="logs-stats__header">
+          <span className="logs-stats__title">
+            {label}: {total} of {rowCount} rows have that label
+          </span>
+          <span className="logs-stats__close fa fa-remove" onClick={onClickClose} />
+        </div>
+        <div className="logs-stats__body">
+          {topRows.map(stat => <StatsRow key={stat.value} {...stat} active={stat.value === value} />)}
+          {insertActiveRow && activeRow && <StatsRow key={activeRow.value} {...activeRow} active />}
+          {otherCount > 0 && (
+            <StatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />
+          )}
         </div>
-        {topRows.map(stat => <StatsRow key={stat.value} {...stat} active={stat.value === value} />)}
-        {insertActiveRow && <StatsRow key={activeRow.value} {...activeRow} active />}
-        {otherCount > 0 && <StatsRow key="__OTHERS__" count={otherCount} value="Other" proportion={otherProportion} />}
-      </>
+      </div>
     );
   }
 }

+ 231 - 103
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 (
+    <span className={props.className} style={props.style}>
+      {props.children}
+      <span className="logs-row__field-highlight--icon fa fa-signal" onClick={() => onClick(props.children)} />
+    </span>
+  );
+};
+
 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 && (
-        <div className="logs-row-duplicates">{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}</div>
-      )}
-      <div className={row.logLevel ? `logs-row-level logs-row-level-${row.logLevel}` : ''} />
-      {showUtc && (
-        <div className="logs-row-time" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
-          {row.timestamp}
-        </div>
-      )}
-      {showLocalTime && (
-        <div className="logs-row-time" title={`${row.timestamp} (${row.timeFromNow})`}>
-          {row.timeLocal}
-        </div>
-      )}
-      {showLabels && (
-        <div className="logs-row-labels">
-          <LogLabels allRows={allRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
-        </div>
-      )}
-      <div className="logs-row-message">
-        {needsHighlighter ? (
-          <Highlighter
-            textToHighlight={row.entry}
-            searchWords={highlights}
-            findChunks={findHighlightChunksInText}
-            highlightClassName={highlightClassName}
-          />
-        ) : (
-          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<RowProps, RowState> {
+  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 (
+      <div className="logs-row">
+        {showDuplicates && (
+          <div className="logs-row__duplicates">{row.duplicates > 0 ? `${row.duplicates + 1}x` : null}</div>
         )}
+        <div className={row.logLevel ? `logs-row__level logs-row__level--${row.logLevel}` : ''} />
+        {showUtc && (
+          <div className="logs-row__time" title={`Local: ${row.timeLocal} (${row.timeFromNow})`}>
+            {row.timestamp}
+          </div>
+        )}
+        {showLocalTime && (
+          <div className="logs-row__time" title={`${row.timestamp} (${row.timeFromNow})`}>
+            {row.timeLocal}
+          </div>
+        )}
+        {showLabels && (
+          <div className="logs-row__labels">
+            <LogLabels allRows={allRows} labels={row.uniqueLabels} onClickLabel={onClickLabel} />
+          </div>
+        )}
+        <div className="logs-row__message" onMouseEnter={this.onMouseOverMessage} onMouseLeave={this.onMouseOutMessage}>
+          {parsed && (
+            <Highlighter
+              autoEscape
+              highlightTag={FieldHighlight(this.onClickHighlight)}
+              textToHighlight={row.entry}
+              searchWords={parsedFieldHighlights}
+              highlightClassName="logs-row__field-highlight"
+            />
+          )}
+          {!parsed &&
+            needsHighlighter && (
+              <Highlighter
+                textToHighlight={row.entry}
+                searchWords={highlights}
+                findChunks={findHighlightChunksInText}
+                highlightClassName={highlightClassName}
+              />
+            )}
+          {!parsed && !needsHighlighter && row.entry}
+          {showFieldStats && (
+            <div className="logs-row__stats">
+              <Stats
+                stats={fieldStats}
+                label={fieldLabel}
+                value={fieldValue}
+                onClickClose={this.onClickClose}
+                rowCount={fieldCount}
+              />
+            </div>
+          )}
+        </div>
       </div>
-    </>
-  );
+    );
+  }
 }
 
 function renderMetaItem(value: any, kind: LogsMetaKind) {
   if (kind === LogsMetaKind.LabelsMap) {
     return (
-      <span className="logs-meta-item__value-labels">
+      <span className="logs-meta-item__labels">
         <LogLabels labels={value} plain />
       </span>
     );
@@ -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<LogsProps, LogsState> {
 
   render() {
     const {
-      className = '',
       data,
       highlighterExpressions,
       loading = false,
@@ -263,31 +394,31 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
     }
 
     // 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 (
-      <div className={`${className} logs`}>
-        <div className="logs-graph">
+      <div className="logs-panel">
+        <div className="logs-panel-graph">
           <Graph
             data={data.series}
             height="100px"
@@ -298,39 +429,36 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
             userOptions={graphOptions}
           />
         </div>
-
-        <div className="logs-options">
-          <div className="logs-controls">
+        <div className="logs-panel-options">
+          <div className="logs-panel-controls">
             <Switch label="Timestamp" checked={showUtc} onChange={this.onChangeUtc} />
             <Switch label="Local time" checked={showLocalTime} onChange={this.onChangeLocalTime} />
             <Switch label="Labels" checked={showLabels} onChange={this.onChangeLabels} />
-            <Switch
-              label="Dedup: off"
-              checked={dedup === LogsDedupStrategy.none}
-              onChange={() => this.onChangeDedup(LogsDedupStrategy.none)}
-            />
-            <Switch
-              label="Dedup: exact"
-              checked={dedup === LogsDedupStrategy.exact}
-              onChange={() => this.onChangeDedup(LogsDedupStrategy.exact)}
-            />
-            <Switch
-              label="Dedup: numbers"
-              checked={dedup === LogsDedupStrategy.numbers}
-              onChange={() => this.onChangeDedup(LogsDedupStrategy.numbers)}
-            />
-            <Switch
-              label="Dedup: signature"
-              checked={dedup === LogsDedupStrategy.signature}
-              onChange={() => this.onChangeDedup(LogsDedupStrategy.signature)}
+            <ToggleButtonGroup
+              label="Dedup"
+              onChange={this.onChangeDedup}
+              value={dedup}
+              render={({ selectedValue, onChange }) =>
+                Object.keys(LogsDedupStrategy).map((dedupType, i) => (
+                  <ToggleButton
+                    className="btn-small"
+                    key={i}
+                    value={dedupType}
+                    onChange={onChange}
+                    selected={selectedValue === dedupType}
+                  >
+                    {dedupType}
+                  </ToggleButton>
+                ))
+              }
             />
             {hasData &&
               meta && (
-                <div className="logs-meta">
+                <div className="logs-panel-meta">
                   {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">{renderMetaItem(item.value, item.kind)}</span>
+                    <div className="logs-panel-meta__item" key={item.label}>
+                      <span className="logs-panel-meta__label">{item.label}:</span>
+                      <span className="logs-panel-meta__value">{renderMetaItem(item.value, item.kind)}</span>
                     </div>
                   ))}
                 </div>
@@ -338,7 +466,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
           </div>
         </div>
 
-        <div className="logs-entries" style={logEntriesStyle}>
+        <div className="logs-rows">
           {hasData &&
             !deferLogs &&
             // Only inject highlighterExpression in the first set for performance reasons
@@ -375,7 +503,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
         {!loading &&
           !hasData &&
           !scanning && (
-            <div className="logs-nodata">
+            <div className="logs-panel-nodata">
               No logs found.
               <a className="link" onClick={this.onClickScan}>
                 Scan for older logs
@@ -384,7 +512,7 @@ export default class Logs extends PureComponent<LogsProps, LogsState> {
           )}
 
         {scanning && (
-          <div className="logs-nodata">
+          <div className="logs-panel-nodata">
             <span>{scanText}</span>
             <a className="link" onClick={this.onClickStopScan}>
               Stop scan

+ 1 - 2
public/app/features/panel/partials/soloPanel.html

@@ -1,5 +1,4 @@
-<div class="panel panel--solo" ng-if="panel" style="width: 100%">
+<div class="panel-solo" ng-if="panel">
 	<plugin-component type="panel">
 	</plugin-component>
 </div>
-<div class="clearfix"></div>

+ 2 - 2
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,

+ 1 - 1
public/app/features/teams/TeamMembers.tsx

@@ -115,7 +115,7 @@ export class TeamMembers extends PureComponent<Props, State> {
             </button>
             <h5>Add Team Member</h5>
             <div className="gf-form-inline">
-              <UserPicker onSelected={this.onUserSelected} className="width-30" />
+              <UserPicker onSelected={this.onUserSelected} className="min-width-30" />
               {this.state.newTeamMember && (
                 <button className="btn btn-success gf-form-btn" type="submit" onClick={this.onAddUserToTeam}>
                   Add to team

+ 3 - 3
public/app/features/teams/__snapshots__/TeamMembers.test.tsx.snap

@@ -58,7 +58,7 @@ exports[`Render should render component 1`] = `
         className="gf-form-inline"
       >
         <UserPicker
-          className="width-30"
+          className="min-width-30"
           onSelected={[Function]}
         />
       </div>
@@ -152,7 +152,7 @@ exports[`Render should render team members 1`] = `
         className="gf-form-inline"
       >
         <UserPicker
-          className="width-30"
+          className="min-width-30"
           onSelected={[Function]}
         />
       </div>
@@ -372,7 +372,7 @@ exports[`Render should render team members when sync enabled 1`] = `
         className="gf-form-inline"
       >
         <UserPicker
-          className="width-30"
+          className="min-width-30"
           onSelected={[Function]}
         />
       </div>

+ 0 - 3
public/app/plugins/datasource/logging/README.md

@@ -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.

+ 0 - 15
public/app/plugins/datasource/logging/module.ts

@@ -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,
-};

+ 3 - 0
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.

+ 1 - 1
public/app/plugins/datasource/logging/components/LoggingCheatSheet.tsx → public/app/plugins/datasource/loki/components/LokiCheatSheet.tsx

@@ -15,7 +15,7 @@ const CHEAT_SHEET_ITEMS = [
 
 export default (props: any) => (
   <div>
-    <h2>Logging Cheat Sheet</h2>
+    <h2>Loki Cheat Sheet</h2>
     {CHEAT_SHEET_ITEMS.map(item => (
       <div className="cheat-sheet-item" key={item.expression}>
         <div className="cheat-sheet-item__title">{item.title}</div>

+ 7 - 7
public/app/plugins/datasource/logging/components/LoggingQueryField.tsx → 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<LoggingQueryFieldProps, LoggingQueryFieldState> {
+class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
   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<LoggingQueryFieldProps, Logg
             onTypeahead={this.onTypeahead}
             onWillApplySuggestion={willApplySuggestion}
             onValueChanged={this.onChangeQuery}
-            placeholder="Enter a Logging query"
-            portalOrigin="logging"
+            placeholder="Enter a Loki Log query"
+            portalOrigin="loki"
             syntaxLoaded={syntaxLoaded}
           />
           {error ? <div className="prom-query-field-info text-error">{error}</div> : null}
@@ -229,4 +229,4 @@ class LoggingQueryField extends React.PureComponent<LoggingQueryFieldProps, Logg
   }
 }
 
-export default LoggingQueryField;
+export default LokiQueryField;

+ 3 - 3
public/app/plugins/datasource/logging/components/LoggingStartPage.tsx → public/app/plugins/datasource/loki/components/LokiStartPage.tsx

@@ -1,15 +1,15 @@
 import React, { PureComponent } from 'react';
-import LoggingCheatSheet from './LoggingCheatSheet';
+import LokiCheatSheet from './LokiCheatSheet';
 
 interface Props {
   onClickExample: () => void;
 }
 
-export default class LoggingStartPage extends PureComponent<Props> {
+export default class LokiStartPage extends PureComponent<Props> {
   render() {
     return (
       <div className="grafana-info-box grafana-info-box--max-lg">
-        <LoggingCheatSheet onClickExample={this.props.onClickExample} />
+        <LokiCheatSheet onClickExample={this.props.onClickExample} />
       </div>
     );
   }

+ 98 - 0
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');
+      });
+    });
+  });
+});

+ 21 - 4
public/app/plugins/datasource/logging/datasource.ts → 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 };
       });
   }
 }

+ 0 - 0
public/app/plugins/datasource/logging/img/grafana_icon.svg → public/app/plugins/datasource/loki/img/grafana_icon.svg


+ 216 - 0
public/app/plugins/datasource/loki/img/loki_icon.svg

@@ -0,0 +1,216 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 19.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+	 width="200px" height="200px" viewBox="0 0 200 200" style="enable-background:new 0 0 200 200;" xml:space="preserve">
+<style type="text/css">
+	.st0{fill:url(#SVGID_1_);}
+	.st1{fill:url(#SVGID_2_);}
+	.st2{fill:url(#SVGID_3_);}
+	.st3{fill:url(#SVGID_4_);}
+	.st4{fill:url(#SVGID_5_);}
+	.st5{fill:url(#SVGID_6_);}
+	.st6{fill:url(#SVGID_7_);}
+	.st7{fill:url(#SVGID_8_);}
+	.st8{fill:url(#SVGID_9_);}
+	.st9{fill:url(#SVGID_10_);}
+	.st10{fill:url(#SVGID_11_);}
+	.st11{fill:url(#SVGID_12_);}
+	.st12{fill:url(#SVGID_13_);}
+	.st13{fill:url(#SVGID_14_);}
+	.st14{fill:url(#SVGID_15_);}
+	.st15{fill:url(#SVGID_16_);}
+	.st16{fill:url(#SVGID_17_);}
+	.st17{fill:url(#SVGID_18_);}
+	.st18{fill:url(#SVGID_19_);}
+	.st19{fill:url(#SVGID_20_);}
+	.st20{fill:url(#SVGID_21_);}
+	.st21{fill:url(#SVGID_22_);}
+	.st22{fill:url(#SVGID_23_);}
+	.st23{fill:url(#SVGID_24_);}
+	.st24{fill:url(#SVGID_25_);}
+	.st25{fill:url(#SVGID_26_);}
+	.st26{fill:url(#SVGID_27_);}
+	.st27{fill:url(#SVGID_28_);}
+	.st28{fill:url(#SVGID_29_);}
+	.st29{fill:url(#SVGID_30_);}
+	.st30{fill:url(#SVGID_31_);}
+	.st31{fill:url(#SVGID_32_);}
+</style>
+<g>
+	<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="135.0285" y1="238.7858" x2="135.0285" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st0" d="M179.5,130c-6.9-9.5-18.6-16.1-30.2-17.9l-38.1-4.6l0,22.4l34.7,4c5.8,0.9,12.3,4.3,15.8,9.1
+		c3.5,4.7,4.9,10.5,4,16.3c-1.7,10.8-11,18.5-21.6,18.5c-1.1,0-2.3-0.1-3.4-0.3l-37.9-4.7c-5.1,8-12.2,14.7-20.6,19.2l55.2,7.4
+		c2.3,0.4,4.6,0.5,6.8,0.5c21.3,0,40-15.6,43.4-37.3C189.3,151.1,186.4,139.5,179.5,130z"/>
+	<linearGradient id="SVGID_2_" gradientUnits="userSpaceOnUse" x1="56.0866" y1="238.7858" x2="56.0866" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st1" d="M90.5,171c1.3-1.6,2.4-3.3,3.5-5.1c1-1.7,1.9-3.4,2.7-5.2c2.3-5.3,3.5-11.2,3.5-17.3l0-4l0-5.6l0-5.6l0-22.4
+		l0-5.6l0-5.6L100,43.9C100,19.7,80.2,0,55.9,0S12,19.8,12,44.1l0.1,66.7c5.7-7.6,13.3-13.7,22.1-17.6L34.1,44
+		c0-12.1,9.8-21.9,21.8-21.9c12.1,0,21.9,9.8,21.9,21.8L78,91.2l0,5.6l0,5.6l0,22.4l0,5.6l0,5.6l0,7.5c0,5.2-1.8,9.9-4.8,13.7
+		c-1.5,1.9-3.3,3.5-5.3,4.8c-3.4,2.2-7.4,3.5-11.7,3.5c-0.7,0-1.4,0-2,0l-1.4-0.2c-10.8-1.7-18.6-11.1-18.5-21.8
+		c0-1.1,0.1-2.1,0.2-3.2c0.7-4.3,2.6-8.1,5.3-11.1c1.6-1.8,3.5-3.3,5.5-4.5c3.2-1.8,6.9-2.9,10.8-2.9c1.1,0,2.3,0.1,3.4,0.3l7.5,1.2
+		l0-22.4l-4-0.6c-2.3-0.4-4.6-0.5-6.8-0.5c-3.7,0-7.3,0.5-10.8,1.4c-1.9,0.5-3.7,1.1-5.5,1.8c-1.9,0.8-3.8,1.7-5.5,2.7
+		c-11.2,6.4-19.4,17.7-21.6,31.4c-0.4,2.4-0.5,4.9-0.5,7.3c0,1.2,0.1,2.3,0.2,3.5c0,0.3,0.1,0.6,0.1,0.9c0.1,1.1,0.3,2.3,0.5,3.4
+		c0.1,0.3,0.1,0.7,0.2,1c0.2,1.1,0.5,2.1,0.8,3.2c0.1,0.4,0.2,0.7,0.3,1.1c0.3,1,0.7,2,1.1,3c0.1,0.3,0.3,0.7,0.4,1
+		c0.4,1,0.9,1.9,1.4,2.9c0.2,0.3,0.3,0.6,0.5,0.9c0.5,1,1.1,1.9,1.7,2.8c0.2,0.2,0.3,0.5,0.5,0.7c0.7,1,1.4,1.9,2.1,2.9
+		c0.1,0.1,0.2,0.3,0.4,0.4c0.8,1,1.7,1.9,2.6,2.8c0.1,0.1,0.1,0.1,0.2,0.2c1,1,2,1.9,3,2.7c0,0,0.1,0,0.1,0.1
+		c1.1,0.9,2.2,1.7,3.3,2.5c0,0,0,0,0.1,0.1c1.1,0.8,2.3,1.5,3.5,2.1c0.1,0,0.1,0.1,0.2,0.1c1.1,0.6,2.3,1.2,3.5,1.7
+		c0.2,0.1,0.3,0.1,0.5,0.2c1.1,0.5,2.2,0.9,3.3,1.2c0.3,0.1,0.6,0.2,0.9,0.3c1,0.3,2.1,0.6,3.2,0.8c0.4,0.1,0.8,0.2,1.2,0.2
+		c2.7,0.5,5.5,0.8,8.3,0.8C70.1,187.5,82.4,181.1,90.5,171z"/>
+	<linearGradient id="SVGID_3_" gradientUnits="userSpaceOnUse" x1="26.7057" y1="238.7858" x2="26.7057" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st2" d="M28.2,177.5c-1-0.9-2-1.8-3-2.7"/>
+	<linearGradient id="SVGID_4_" gradientUnits="userSpaceOnUse" x1="12.7354" y1="238.7858" x2="12.7354" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st3" d="M13,151.9c-0.2-1.1-0.4-2.2-0.5-3.4"/>
+	<linearGradient id="SVGID_5_" gradientUnits="userSpaceOnUse" x1="14.8849" y1="238.7858" x2="14.8849" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st4" d="M15.4,160.1c-0.4-1-0.8-2-1.1-3"/>
+	<linearGradient id="SVGID_6_" gradientUnits="userSpaceOnUse" x1="18.6023" y1="238.7858" x2="18.6023" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st5" d="M19.5,167.8c-0.6-0.9-1.2-1.9-1.7-2.8"/>
+	<linearGradient id="SVGID_7_" gradientUnits="userSpaceOnUse" x1="16.5561" y1="238.7858" x2="16.5561" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st6" d="M17.2,164c-0.5-0.9-1-1.9-1.4-2.9"/>
+	<linearGradient id="SVGID_8_" gradientUnits="userSpaceOnUse" x1="12.2907" y1="238.7858" x2="12.2907" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st7" d="M12.2,144.1c0,1.2,0.1,2.3,0.2,3.5"/>
+	<linearGradient id="SVGID_9_" gradientUnits="userSpaceOnUse" x1="23.7117" y1="238.7858" x2="23.7117" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st8" d="M25,174.6c-0.9-0.9-1.8-1.9-2.6-2.8"/>
+	<linearGradient id="SVGID_10_" gradientUnits="userSpaceOnUse" x1="20.9974" y1="238.7858" x2="20.9974" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st9" d="M22.1,171.3c-0.8-0.9-1.5-1.9-2.1-2.9"/>
+	<linearGradient id="SVGID_11_" gradientUnits="userSpaceOnUse" x1="13.6059" y1="238.7858" x2="13.6059" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st10" d="M14,156.1c-0.3-1-0.6-2.1-0.8-3.2"/>
+	<linearGradient id="SVGID_12_" gradientUnits="userSpaceOnUse" x1="37.1094" y1="238.7858" x2="37.1094" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st11" d="M38.8,184c-1.2-0.5-2.3-1.1-3.5-1.7"/>
+	<linearGradient id="SVGID_13_" gradientUnits="userSpaceOnUse" x1="45.1619" y1="238.7858" x2="45.1619" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st12" d="M46.8,186.5c-1.1-0.2-2.1-0.5-3.2-0.8"/>
+	<linearGradient id="SVGID_14_" gradientUnits="userSpaceOnUse" x1="29.9487" y1="238.7858" x2="29.9487" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st13" d="M31.6,180c-1.1-0.8-2.2-1.6-3.3-2.5"/>
+	<linearGradient id="SVGID_15_" gradientUnits="userSpaceOnUse" x1="41.0239" y1="238.7858" x2="41.0239" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st14" d="M42.7,185.4c-1.1-0.4-2.3-0.8-3.3-1.2"/>
+	<linearGradient id="SVGID_16_" gradientUnits="userSpaceOnUse" x1="33.4189" y1="238.7858" x2="33.4189" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st15" d="M35.2,182.2c-1.2-0.7-2.4-1.4-3.5-2.1"/>
+	<linearGradient id="SVGID_17_" gradientUnits="userSpaceOnUse" x1="53.9595" y1="238.7858" x2="53.9595" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st16" d="M54.1,154.2c-0.1,0-0.1,0-0.2-0.1"/>
+	<linearGradient id="SVGID_18_" gradientUnits="userSpaceOnUse" x1="21.1405" y1="238.7858" x2="21.1405" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st17" d="M21.4,178.8c-0.2-0.2-0.3-0.3-0.5-0.5"/>
+	<linearGradient id="SVGID_19_" gradientUnits="userSpaceOnUse" x1="35.2646" y1="238.7858" x2="35.2646" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st18" d="M35.2,182.2c0.1,0,0.1,0.1,0.2,0.1"/>
+	<linearGradient id="SVGID_20_" gradientUnits="userSpaceOnUse" x1="39.0979" y1="238.7858" x2="39.0979" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st19" d="M39.3,184.2c-0.2-0.1-0.3-0.1-0.5-0.2"/>
+	<linearGradient id="SVGID_21_" gradientUnits="userSpaceOnUse" x1="31.6434" y1="238.7858" x2="31.6434" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st20" d="M31.7,180.1C31.7,180.1,31.6,180.1,31.7,180.1"/>
+	<linearGradient id="SVGID_22_" gradientUnits="userSpaceOnUse" x1="28.2485" y1="238.7858" x2="28.2485" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st21" d="M28.3,177.6C28.3,177.5,28.2,177.5,28.3,177.6"/>
+	<linearGradient id="SVGID_23_" gradientUnits="userSpaceOnUse" x1="43.1314" y1="238.7858" x2="43.1314" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st22" d="M43.6,185.7c-0.3-0.1-0.6-0.2-0.9-0.3"/>
+	<linearGradient id="SVGID_24_" gradientUnits="userSpaceOnUse" x1="47.3468" y1="238.7858" x2="47.3468" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st23" d="M46.8,186.5c0.4,0.1,0.8,0.2,1.2,0.2"/>
+	<linearGradient id="SVGID_25_" gradientUnits="userSpaceOnUse" x1="19.6975" y1="238.7858" x2="19.6975" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st24" d="M19.9,168.4c-0.2-0.2-0.3-0.5-0.5-0.7"/>
+	<linearGradient id="SVGID_26_" gradientUnits="userSpaceOnUse" x1="17.4923" y1="238.7858" x2="17.4923" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st25" d="M17.7,164.9c-0.2-0.3-0.3-0.6-0.5-0.9"/>
+	<linearGradient id="SVGID_27_" gradientUnits="userSpaceOnUse" x1="12.4278" y1="238.7858" x2="12.4278" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st26" d="M12.5,148.5c0-0.3-0.1-0.6-0.1-0.9"/>
+	<linearGradient id="SVGID_28_" gradientUnits="userSpaceOnUse" x1="13.0965" y1="238.7858" x2="13.0965" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st27" d="M13.2,152.9c-0.1-0.3-0.1-0.7-0.2-1"/>
+	<linearGradient id="SVGID_29_" gradientUnits="userSpaceOnUse" x1="14.1769" y1="238.7858" x2="14.1769" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st28" d="M14.3,157.1c-0.1-0.3-0.2-0.7-0.3-1.1"/>
+	<linearGradient id="SVGID_30_" gradientUnits="userSpaceOnUse" x1="15.6479" y1="238.7858" x2="15.6479" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st29" d="M15.9,161.1c-0.2-0.3-0.3-0.7-0.4-1"/>
+	<linearGradient id="SVGID_31_" gradientUnits="userSpaceOnUse" x1="22.2436" y1="238.7858" x2="22.2436" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st30" d="M22.4,171.7c-0.1-0.1-0.2-0.3-0.4-0.4"/>
+	<linearGradient id="SVGID_32_" gradientUnits="userSpaceOnUse" x1="25.1051" y1="238.7858" x2="25.1051" y2="2.4079">
+		<stop  offset="0" style="stop-color:#FBED1D"/>
+		<stop  offset="1" style="stop-color:#F05A2A"/>
+	</linearGradient>
+	<path class="st31" d="M25.2,174.8c-0.1-0.1-0.1-0.1-0.2-0.2"/>
+</g>
+</svg>

+ 0 - 0
public/app/plugins/datasource/logging/language_provider.test.ts → public/app/plugins/datasource/loki/language_provider.test.ts


+ 1 - 1
public/app/plugins/datasource/logging/language_provider.ts → 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[];

+ 15 - 0
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,
+};

+ 0 - 0
public/app/plugins/datasource/logging/partials/config.html → public/app/plugins/datasource/loki/partials/config.html


+ 7 - 7
public/app/plugins/datasource/logging/plugin.json → 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"

+ 0 - 0
public/app/plugins/datasource/logging/query_utils.test.ts → public/app/plugins/datasource/loki/query_utils.test.ts


+ 0 - 0
public/app/plugins/datasource/logging/query_utils.ts → public/app/plugins/datasource/loki/query_utils.ts


+ 0 - 0
public/app/plugins/datasource/logging/result_transformer.test.ts → public/app/plugins/datasource/loki/result_transformer.test.ts


+ 0 - 0
public/app/plugins/datasource/logging/result_transformer.ts → public/app/plugins/datasource/loki/result_transformer.ts


+ 0 - 0
public/app/plugins/datasource/logging/syntax.ts → public/app/plugins/datasource/loki/syntax.ts


+ 1 - 2
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;
   }

+ 3 - 3
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;
     }
 

+ 25 - 0
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',

+ 2 - 0
public/app/types/index.ts

@@ -19,6 +19,7 @@ import {
   DataQuery,
   DataQueryResponse,
   DataQueryOptions,
+  IntervalValues,
 } from './series';
 import { PanelProps, PanelOptionsProps, Threshold } from './panel';
 import { PluginDashboard, PluginMeta, Plugin, PanelPlugin, PluginsState } from './plugins';
@@ -92,6 +93,7 @@ export {
   Threshold,
   ValidationEvents,
   ValidationRule,
+  IntervalValues,
 };
 
 export interface StoreState {

+ 8 - 0
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;
 
   /**

+ 2 - 0
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';

+ 4 - 0
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;

+ 3 - 0
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;

+ 0 - 1
public/sass/base/_type.scss

@@ -199,7 +199,6 @@ small,
 
 mark,
 .mark {
-  padding: 0.2em;
   background: $alert-warning-bg;
 }
 

+ 294 - 0
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;
+  }
+}

+ 37 - 0
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;
+    }
+  }
+}

+ 15 - 9
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;
   }
 }
 

+ 0 - 206
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 {

+ 6 - 0
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;