Merge branch 'master' into 14388/alert-tab-ux-update

This commit is contained in:
Peter Holmberg
2018-12-21 11:25:09 +01:00
66 changed files with 648 additions and 325 deletions
+5 -5
View File
@@ -19,7 +19,7 @@ version: 2
jobs:
mysql-integration-test:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
- image: circleci/mysql:5.6-ram
environment:
MYSQL_ROOT_PASSWORD: rootpass
@@ -39,7 +39,7 @@ jobs:
postgres-integration-test:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
- image: circleci/postgres:9.3-ram
environment:
POSTGRES_USER: grafanatest
@@ -74,7 +74,7 @@ jobs:
gometalinter:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
environment:
# we need CGO because of go-sqlite3
CGO_ENABLED: 1
@@ -117,7 +117,7 @@ jobs:
test-backend:
docker:
- image: circleci/golang:1.11
- image: circleci/golang:1.11.4
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
@@ -175,7 +175,7 @@ jobs:
build:
docker:
- image: grafana/build-container:1.2.1
- image: grafana/build-container:1.2.2
working_directory: /go/src/github.com/grafana/grafana
steps:
- checkout
+4
View File
@@ -15,6 +15,10 @@
* **Admin**: When multiple user invitations, all links are the same as the first user who was invited [#14483](https://github.com/grafana/grafana/issues/14483)
* **LDAP**: Upgrade go-ldap to v3 [#14548](https://github.com/grafana/grafana/issues/14548)
* **Proxy whitelist**: Add CIDR capability to auth_proxy whitelist [#14546](https://github.com/grafana/grafana/issues/14546), thx [@jacobrichard](https://github.com/jacobrichard)
* **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
### Bug fixes
* **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)
# 5.4.2 (2018-12-13)
+1 -1
View File
@@ -1,5 +1,5 @@
# Golang build container
FROM golang:1.11
FROM golang:1.11.4
WORKDIR $GOPATH/src/github.com/grafana/grafana
+1 -1
View File
@@ -90,7 +90,7 @@ Choose this option to build on platforms other than linux/amd64 and/or not have
The resulting image will be tagged as `grafana/grafana:dev`
Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Perferences -> Advanced), otherwize you may faild at `grunt build`
Notice: If you are using Docker for MacOS, be sure to let limit of Memory bigger than 2 GiB (at docker -> Preferences -> Advanced), otherwize you may faild at `grunt build`
### Dev config
+1 -1
View File
@@ -7,7 +7,7 @@ clone_folder: c:\gopath\src\github.com\grafana\grafana
environment:
nodejs_version: "8"
GOPATH: C:\gopath
GOVERSION: 1.11
GOVERSION: 1.11.4
install:
- rmdir c:\go /s /q
+1
View File
@@ -335,6 +335,7 @@ tls_skip_verify_insecure = false
tls_client_cert =
tls_client_key =
tls_client_ca =
send_client_credentials_via_post = false
#################################### Basic Auth ##########################
[auth.basic]
+4
View File
@@ -284,6 +284,10 @@ log_queries =
;tls_client_key =
;tls_client_ca =
; Set to true to enable sending client_id and client_secret via POST body instead of Basic authentication HTTP header
; This might be required if the OAuth provider is not RFC6749 compliant, only supporting credentials passed via POST payload
;send_client_credentials_via_post = false
#################################### Grafana.com Auth ####################
[auth.grafana_com]
;enabled = false
+12 -1
View File
@@ -17,7 +17,7 @@ can find examples using Okta, BitBucket, OneLogin and Azure.
This callback URL must match the full HTTP address that you use in your browser to access Grafana, but with the prefix path of `/login/generic_oauth`.
You may have to set the `root_url` option of `[server]` for the callback URL to be
You may have to set the `root_url` option of `[server]` for the callback URL to be
correct. For example in case you are serving Grafana behind a proxy.
Example config:
@@ -209,6 +209,17 @@ allowed_organizations =
token_url = https://<your domain>.my.centrify.com/OAuth2/Token/<Application ID>
```
## Set up OAuth2 with non-compliant providers
Some OAuth2 providers might not support `client_id` and `client_secret` passed via Basic Authentication HTTP header, which
results in `invalid_client` error. To allow Grafana to authenticate via these type of providers, the client identifiers must be
send via POST body, which can be enabled via the following settings:
```bash
[auth.generic_oauth]
send_client_credentials_via_post = true
```
<hr>
+8
View File
@@ -164,6 +164,14 @@ func GetPluginMarkdown(c *m.ReqContext) Response {
return Error(500, "Could not get markdown file", err)
}
// fallback try readme
if len(content) == 0 {
content, err = plugins.GetPluginMarkdown(pluginID, "readme")
if err != nil {
return Error(501, "Could not get markdown file", err)
}
}
resp := Respond(200, content)
resp.Header("Content-Type", "text/plain; charset=utf-8")
return resp
-12
View File
@@ -3,10 +3,8 @@ package plugins
import (
"context"
"encoding/json"
"os"
"os/exec"
"path"
"path/filepath"
"time"
"github.com/grafana/grafana-plugin-model/go/datasource"
@@ -29,7 +27,6 @@ type DataSourcePlugin struct {
QueryOptions map[string]bool `json:"queryOptions,omitempty"`
BuiltIn bool `json:"builtIn,omitempty"`
Mixed bool `json:"mixed,omitempty"`
HasQueryHelp bool `json:"hasQueryHelp,omitempty"`
Routes []*AppPluginRoute `json:"routes"`
Backend bool `json:"backend,omitempty"`
@@ -48,15 +45,6 @@ func (p *DataSourcePlugin) Load(decoder *json.Decoder, pluginDir string) error {
return err
}
// look for help markdown
helpPath := filepath.Join(p.PluginDir, "QUERY_HELP.md")
if _, err := os.Stat(helpPath); os.IsNotExist(err) {
helpPath = filepath.Join(p.PluginDir, "query_help.md")
}
if _, err := os.Stat(helpPath); err == nil {
p.HasQueryHelp = true
}
DataSources[p.Id] = p
return nil
}
+4 -2
View File
@@ -166,7 +166,7 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
var result notifierStateSlice
for _, notification := range query.Result {
not, err := n.createNotifierFor(notification)
not, err := InitNotifier(notification)
if err != nil {
n.log.Error("Could not create notifier", "notifier", notification.Id, "error", err)
continue
@@ -195,7 +195,8 @@ func (n *notificationService) getNeededNotifiers(orgId int64, notificationIds []
return result, nil
}
func (n *notificationService) createNotifierFor(model *m.AlertNotification) (Notifier, error) {
// InitNotifier instantiate a new notifier based on the model
func InitNotifier(model *m.AlertNotification) (Notifier, error) {
notifierPlugin, found := notifierFactories[model.Type]
if !found {
return nil, errors.New("Unsupported notification type")
@@ -208,6 +209,7 @@ type NotifierFactory func(notification *m.AlertNotification) (Notifier, error)
var notifierFactories = make(map[string]*NotifierPlugin)
// RegisterNotifier register an notifier
func RegisterNotifier(plugin *NotifierPlugin) {
notifierFactories[plugin.Type] = plugin
}
+1 -1
View File
@@ -32,7 +32,7 @@ func handleNotificationTestCommand(cmd *NotificationTestCommand) error {
Settings: cmd.Settings,
}
notifiers, err := notifier.createNotifierFor(model)
notifiers, err := InitNotifier(model)
if err != nil {
log.Error2("Failed to create notifier", "error", err.Error())
+2 -2
View File
@@ -53,14 +53,14 @@ func GetDataSourceByName(query *m.GetDataSourceByNameQuery) error {
}
func GetDataSources(query *m.GetDataSourcesQuery) error {
sess := x.Limit(1000, 0).Where("org_id=?", query.OrgId).Asc("name")
sess := x.Limit(5000, 0).Where("org_id=?", query.OrgId).Asc("name")
query.Result = make([]*m.DataSource, 0)
return sess.Find(&query.Result)
}
func GetAllDataSources(query *m.GetAllDataSourcesQuery) error {
sess := x.Limit(1000, 0).Asc("name")
sess := x.Limit(5000, 0).Asc("name")
query.Result = make([]*m.DataSource, 0)
return sess.Find(&query.Result)
+6 -1
View File
@@ -345,8 +345,12 @@ func GetUserOrgList(query *m.GetUserOrgListQuery) error {
return err
}
func newSignedInUserCacheKey(orgID, userID int64) string {
return fmt.Sprintf("signed-in-user-%d-%d", userID, orgID)
}
func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) error {
cacheKey := fmt.Sprintf("signed-in-user-%d-%d", query.UserId, query.OrgId)
cacheKey := newSignedInUserCacheKey(query.OrgId, query.UserId)
if cached, found := ss.CacheService.Get(cacheKey); found {
query.Result = cached.(*m.SignedInUser)
return nil
@@ -357,6 +361,7 @@ func (ss *SqlStore) GetSignedInUserWithCache(query *m.GetSignedInUserQuery) erro
return err
}
cacheKey = newSignedInUserCacheKey(query.Result.OrgId, query.UserId)
ss.CacheService.Set(cacheKey, query.Result, time.Second*5)
return nil
}
+22 -1
View File
@@ -13,7 +13,7 @@ import (
func TestUserDataAccess(t *testing.T) {
Convey("Testing DB", t, func() {
InitTestDB(t)
ss := InitTestDB(t)
Convey("Creating a user", func() {
cmd := &m.CreateUserCommand{
@@ -153,6 +153,27 @@ func TestUserDataAccess(t *testing.T) {
So(prefsQuery.Result.UserId, ShouldEqual, 0)
})
})
Convey("when retreiving signed in user for orgId=0 result should return active org id", func() {
ss.CacheService.Flush()
query := &m.GetSignedInUserQuery{OrgId: users[1].OrgId, UserId: users[1].Id}
err := ss.GetSignedInUserWithCache(query)
So(err, ShouldBeNil)
So(query.Result, ShouldNotBeNil)
So(query.OrgId, ShouldEqual, users[1].OrgId)
err = SetUsingOrg(&m.SetUsingOrgCommand{UserId: users[1].Id, OrgId: users[0].OrgId})
So(err, ShouldBeNil)
query = &m.GetSignedInUserQuery{OrgId: 0, UserId: users[1].Id}
err = ss.GetSignedInUserWithCache(query)
So(err, ShouldBeNil)
So(query.Result, ShouldNotBeNil)
So(query.Result.OrgId, ShouldEqual, users[0].OrgId)
cacheKey := newSignedInUserCacheKey(query.Result.OrgId, query.UserId)
_, found := ss.CacheService.Get(cacheKey)
So(found, ShouldBeTrue)
})
})
})
+15 -14
View File
@@ -1,20 +1,21 @@
package setting
type OAuthInfo struct {
ClientId, ClientSecret string
Scopes []string
AuthUrl, TokenUrl string
Enabled bool
EmailAttributeName string
AllowedDomains []string
HostedDomain string
ApiUrl string
AllowSignup bool
Name string
TlsClientCert string
TlsClientKey string
TlsClientCa string
TlsSkipVerify bool
ClientId, ClientSecret string
Scopes []string
AuthUrl, TokenUrl string
Enabled bool
EmailAttributeName string
AllowedDomains []string
HostedDomain string
ApiUrl string
AllowSignup bool
Name string
TlsClientCert string
TlsClientKey string
TlsClientCa string
TlsSkipVerify bool
SendClientCredentialsViaPost bool
}
type OAuther struct {
+22 -16
View File
@@ -63,28 +63,34 @@ func NewOAuthService() {
for _, name := range allOauthes {
sec := setting.Raw.Section("auth." + name)
info := &setting.OAuthInfo{
ClientId: sec.Key("client_id").String(),
ClientSecret: sec.Key("client_secret").String(),
Scopes: util.SplitString(sec.Key("scopes").String()),
AuthUrl: sec.Key("auth_url").String(),
TokenUrl: sec.Key("token_url").String(),
ApiUrl: sec.Key("api_url").String(),
Enabled: sec.Key("enabled").MustBool(),
EmailAttributeName: sec.Key("email_attribute_name").String(),
AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()),
HostedDomain: sec.Key("hosted_domain").String(),
AllowSignup: sec.Key("allow_sign_up").MustBool(),
Name: sec.Key("name").MustString(name),
TlsClientCert: sec.Key("tls_client_cert").String(),
TlsClientKey: sec.Key("tls_client_key").String(),
TlsClientCa: sec.Key("tls_client_ca").String(),
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
ClientId: sec.Key("client_id").String(),
ClientSecret: sec.Key("client_secret").String(),
Scopes: util.SplitString(sec.Key("scopes").String()),
AuthUrl: sec.Key("auth_url").String(),
TokenUrl: sec.Key("token_url").String(),
ApiUrl: sec.Key("api_url").String(),
Enabled: sec.Key("enabled").MustBool(),
EmailAttributeName: sec.Key("email_attribute_name").String(),
AllowedDomains: util.SplitString(sec.Key("allowed_domains").String()),
HostedDomain: sec.Key("hosted_domain").String(),
AllowSignup: sec.Key("allow_sign_up").MustBool(),
Name: sec.Key("name").MustString(name),
TlsClientCert: sec.Key("tls_client_cert").String(),
TlsClientKey: sec.Key("tls_client_key").String(),
TlsClientCa: sec.Key("tls_client_ca").String(),
TlsSkipVerify: sec.Key("tls_skip_verify_insecure").MustBool(),
SendClientCredentialsViaPost: sec.Key("send_client_credentials_via_post").MustBool(),
}
if !info.Enabled {
continue
}
// handle the clients that do not properly support Basic auth headers and require passing client_id/client_secret via POST payload
if info.SendClientCredentialsViaPost {
oauth2.RegisterBrokenAuthHeaderProvider(info.TokenUrl)
}
if name == "grafananet" {
name = grafanaCom
}
@@ -0,0 +1,83 @@
import React, { PureComponent } from 'react';
import Remarkable from 'remarkable';
import { getBackendSrv } from '../../services/backend_srv';
interface Props {
plugin: {
name: string;
id: string;
};
type: string;
}
interface State {
isError: boolean;
isLoading: boolean;
help: string;
}
export class PluginHelp extends PureComponent<Props, State> {
state = {
isError: false,
isLoading: false,
help: '',
};
componentDidMount(): void {
this.loadHelp();
}
constructPlaceholderInfo() {
return 'No plugin help or readme markdown file was found';
}
loadHelp = () => {
const { plugin, type } = this.props;
this.setState({ isLoading: true });
getBackendSrv()
.get(`/api/plugins/${plugin.id}/markdown/${type}`)
.then(response => {
const markdown = new Remarkable();
const helpHtml = markdown.render(response);
if (response === '' && type === 'help') {
this.setState({
isError: false,
isLoading: false,
help: this.constructPlaceholderInfo(),
});
} else {
this.setState({
isError: false,
isLoading: false,
help: helpHtml,
});
}
})
.catch(() => {
this.setState({
isError: true,
isLoading: false,
});
});
};
render() {
const { type } = this.props;
const { isError, isLoading, help } = this.state;
if (isLoading) {
return <h2>Loading help...</h2>;
}
if (isError) {
return <h3>'Error occurred when loading help'</h3>;
}
if (type === 'panel_help' && help === '') {
}
return <div className="markdown-html" dangerouslySetInnerHTML={{ __html: help }} />;
}
}
@@ -50,7 +50,7 @@ const DEFAULT_THEME_LIGHT = 'ace/theme/textmate';
const DEFAULT_MODE = 'text';
const DEFAULT_MAX_LINES = 10;
const DEFAULT_TAB_SIZE = 2;
const DEFAULT_BEHAVIOURS = true;
const DEFAULT_BEHAVIORS = true;
const DEFAULT_SNIPPETS = true;
const editorTemplate = `<div></div>`;
@@ -61,7 +61,7 @@ function link(scope, elem, attrs) {
const maxLines = attrs.maxLines || DEFAULT_MAX_LINES;
const showGutter = attrs.showGutter !== undefined;
const tabSize = attrs.tabSize || DEFAULT_TAB_SIZE;
const behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIOURS;
const behavioursEnabled = attrs.behavioursEnabled ? attrs.behavioursEnabled === 'true' : DEFAULT_BEHAVIORS;
const snippetsEnabled = attrs.snippetsEnabled ? attrs.snippetsEnabled === 'true' : DEFAULT_SNIPPETS;
// Initialize editor
@@ -1,5 +1,5 @@
// Based on work https://github.com/mohsen1/json-formatter-js
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
// License MIT, Copyright (c) 2015 Mohsen Azimi
/*
* Escapes `"` characters from string
@@ -1,5 +1,5 @@
// Based on work https://github.com/mohsen1/json-formatter-js
// Licence MIT, Copyright (c) 2015 Mohsen Azimi
// License MIT, Copyright (c) 2015 Mohsen Azimi
import { isObject, getObjectName, getType, getValuePreview, cssClass, createElement } from './helpers';
@@ -36,7 +36,7 @@ describe('Render', () => {
expect(wrapper).toMatchSnapshot();
});
it('should render organisation switcher', () => {
it('should render organization switcher', () => {
const wrapper = setup({
link: {
showOrgSwitcher: true,
@@ -73,7 +73,7 @@ exports[`Render should render component 1`] = `
</div>
`;
exports[`Render should render organisation switcher 1`] = `
exports[`Render should render organization switcher 1`] = `
<div
className="sidemenu-item dropdown dropup"
>
+2 -2
View File
@@ -5,7 +5,7 @@ import { DashboardModel } from 'app/features/dashboard/dashboard_model';
export class BackendSrv {
private inFlightRequests = {};
private HTTP_REQUEST_CANCELLED = -1;
private HTTP_REQUEST_CANCELED = -1;
private noBackendCache: boolean;
/** @ngInject */
@@ -178,7 +178,7 @@ export class BackendSrv {
return response;
})
.catch(err => {
if (err.status === this.HTTP_REQUEST_CANCELLED) {
if (err.status === this.HTTP_REQUEST_CANCELED) {
throw { err, cancelled: true };
}
+2 -2
View File
@@ -31,7 +31,7 @@ export class SearchSrv {
}
private queryForRecentDashboards() {
const dashIds = _.take(impressionSrv.getDashboardOpened(), 5);
const dashIds = _.take(impressionSrv.getDashboardOpened(), 30);
if (dashIds.length === 0) {
return Promise.resolve([]);
}
@@ -70,7 +70,7 @@ export class SearchSrv {
return Promise.resolve();
}
return this.backendSrv.search({ starred: true, limit: 5 }).then(result => {
return this.backendSrv.search({ starred: true, limit: 30 }).then(result => {
if (result.length > 0) {
sections['starred'] = {
title: 'Starred',
+1 -1
View File
@@ -2,7 +2,7 @@ import _ from 'lodash';
import coreModule from 'app/core/core_module';
// This service really just tracks a list of $timeout promises to give us a
// method for cancelling them all when we need to
// method for canceling them all when we need to
export class Timer {
timers = [];
@@ -73,6 +73,7 @@ describe('file_export', () => {
],
rows: [
[123, 'some_string', 1.234, true],
[1000, 'some_string', 1.234567891, true],
[0o765, 'some string with " in the middle', 1e-2, false],
[0o765, 'some string with "" in the middle', 1e-2, false],
[0o765, 'some string with """ in the middle', 1e-2, false],
@@ -89,6 +90,7 @@ describe('file_export', () => {
const expectedText =
'"integer_value";"string_value";"float_value";"boolean_value"\r\n' +
'123;"some_string";1.234;true\r\n' +
'1000;"some_string";1.234567891;true\r\n' +
'501;"some string with "" in the middle";0.01;false\r\n' +
'501;"some string with """" in the middle";0.01;false\r\n' +
'501;"some string with """""" in the middle";0.01;false\r\n' +
+1 -1
View File
@@ -40,7 +40,7 @@ export default class TableModel {
this.rows.sort((a, b) => {
a = a[options.col];
b = b[options.col];
// Sort null or undefined seperately from comparable values
// Sort null or undefined separately from comparable values
return +(a == null) - +(b == null) || +(a > b) || -(a < b);
});
+1 -3
View File
@@ -41,10 +41,8 @@ function formatSpecialHeader(useExcelHeader) {
function formatRow(row, addEndRowDelimiter = true) {
let text = '';
for (let i = 0; i < row.length; i += 1) {
if (isBoolean(row[i]) || isNullOrUndefined(row[i])) {
if (isBoolean(row[i]) || isNumber(row[i]) || isNullOrUndefined(row[i])) {
text += row[i];
} else if (isNumber(row[i])) {
text += row[i].toLocaleString();
} else {
text += `${QUOTE}${csvEscaped(htmlUnescaped(htmlDecoded(row[i])))}${QUOTE}`;
}
+28 -9
View File
@@ -484,6 +484,14 @@ kbn.valueFormats.Mbits = kbn.formatBuilders.decimalSIPrefix('bps', 2);
kbn.valueFormats.GBs = kbn.formatBuilders.decimalSIPrefix('Bs', 3);
kbn.valueFormats.Gbits = kbn.formatBuilders.decimalSIPrefix('bps', 3);
// Floating Point Operations per Second
kbn.valueFormats.flops = kbn.formatBuilders.decimalSIPrefix('FLOP/s');
kbn.valueFormats.mflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 2);
kbn.valueFormats.gflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 3);
kbn.valueFormats.tflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 4);
kbn.valueFormats.pflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 5);
kbn.valueFormats.eflops = kbn.formatBuilders.decimalSIPrefix('FLOP/s', 6);
// Hash Rate
kbn.valueFormats.Hs = kbn.formatBuilders.decimalSIPrefix('H/s');
kbn.valueFormats.KHs = kbn.formatBuilders.decimalSIPrefix('H/s', 1);
@@ -1019,6 +1027,17 @@ kbn.getUnitFormats = () => {
{ text: 'exahashes/sec', value: 'EHs' },
],
},
{
text: 'computation throughput',
submenu: [
{ text: 'FLOP/s', value: 'flops' },
{ text: 'MFLOP/s', value: 'mflops' },
{ text: 'GFLOP/s', value: 'gflops' },
{ text: 'TFLOP/s', value: 'tflops' },
{ text: 'PFLOP/s', value: 'pflops' },
{ text: 'EFLOP/s', value: 'eflops' },
],
},
{
text: 'throughput',
submenu: [
@@ -1085,7 +1104,7 @@ kbn.getUnitFormats = () => {
{ text: 'Watt (W)', value: 'watt' },
{ text: 'Kilowatt (kW)', value: 'kwatt' },
{ text: 'Milliwatt (mW)', value: 'mwatt' },
{ text: 'Watt per square metre (W/m²)', value: 'Wm2' },
{ text: 'Watt per square meter (W/m²)', value: 'Wm2' },
{ text: 'Volt-ampere (VA)', value: 'voltamp' },
{ text: 'Kilovolt-ampere (kVA)', value: 'kvoltamp' },
{ text: 'Volt-ampere reactive (var)', value: 'voltampreact' },
@@ -1182,14 +1201,14 @@ kbn.getUnitFormats = () => {
submenu: [
{ text: 'parts-per-million (ppm)', value: 'ppm' },
{ text: 'parts-per-billion (ppb)', value: 'conppb' },
{ text: 'nanogram per cubic metre (ng/m³)', value: 'conngm3' },
{ text: 'nanogram per normal cubic metre (ng/Nm³)', value: 'conngNm3' },
{ text: 'microgram per cubic metre (μg/m³)', value: 'conμgm3' },
{ text: 'microgram per normal cubic metre (μg/Nm³)', value: 'conμgNm3' },
{ text: 'milligram per cubic metre (mg/m³)', value: 'conmgm3' },
{ text: 'milligram per normal cubic metre (mg/Nm³)', value: 'conmgNm3' },
{ text: 'gram per cubic metre (g/m³)', value: 'congm3' },
{ text: 'gram per normal cubic metre (g/Nm³)', value: 'congNm3' },
{ text: 'nanogram per cubic meter (ng/m³)', value: 'conngm3' },
{ text: 'nanogram per normal cubic meter (ng/Nm³)', value: 'conngNm3' },
{ text: 'microgram per cubic meter (μg/m³)', value: 'conμgm3' },
{ text: 'microgram per normal cubic meter (μg/Nm³)', value: 'conμgNm3' },
{ text: 'milligram per cubic meter (mg/m³)', value: 'conmgm3' },
{ text: 'milligram per normal cubic meter (mg/Nm³)', value: 'conmgNm3' },
{ text: 'gram per cubic meter (g/m³)', value: 'congm3' },
{ text: 'gram per normal cubic meter (g/Nm³)', value: 'congNm3' },
],
},
];
@@ -14,6 +14,7 @@ import { PanelEditor } from './PanelEditor';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { PanelPlugin } from 'app/types';
import { PanelResizer } from './PanelResizer';
export interface Props {
panel: PanelModel;
@@ -158,10 +159,21 @@ export class DashboardPanel extends PureComponent<Props, State> {
return (
<div className={containerClass}>
<div className={panelWrapperClass} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
{plugin.exports.Panel && this.renderReactPanel()}
{plugin.exports.PanelCtrl && this.renderAngularPanel()}
</div>
<PanelResizer
isEditing={!!isEditing}
panel={panel}
render={(panelHeight: number | 'inherit') => (
<div
className={panelWrapperClass}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
style={{ height: panelHeight }}
>
{plugin.exports.Panel && this.renderReactPanel()}
{plugin.exports.PanelCtrl && this.renderAngularPanel()}
</div>
)}
/>
{panel.isEditing && (
<PanelEditor
panel={panel}
@@ -0,0 +1,81 @@
import React, { PureComponent } from 'react';
import { throttle } from 'lodash';
import Draggable from 'react-draggable';
import { PanelModel } from '../panel_model';
interface Props {
isEditing: boolean;
render: (height: number | 'inherit') => JSX.Element;
panel: PanelModel;
}
interface State {
editorHeight: number;
}
export class PanelResizer extends PureComponent<Props, State> {
initialHeight: number = Math.floor(document.documentElement.scrollHeight * 0.4);
prevEditorHeight: number;
throttledChangeHeight: (height: number) => void;
throttledResizeDone: () => void;
constructor(props) {
super(props);
const { panel } = this.props;
this.state = {
editorHeight: this.initialHeight,
};
this.throttledChangeHeight = throttle(this.changeHeight, 20, { trailing: true });
this.throttledResizeDone = throttle(() => {
panel.resizeDone();
}, 50);
}
get largestHeight() {
return document.documentElement.scrollHeight * 0.9;
}
get smallestHeight() {
return 100;
}
changeHeight = height => {
const sh = this.smallestHeight;
const lh = this.largestHeight;
height = height < sh ? sh : height;
height = height > lh ? lh : height;
this.prevEditorHeight = this.state.editorHeight;
this.setState({
editorHeight: height,
});
};
onDrag = (evt, data) => {
const newHeight = this.state.editorHeight + data.y;
this.throttledChangeHeight(newHeight);
this.throttledResizeDone();
};
render() {
const { render, isEditing } = this.props;
const { editorHeight } = this.state;
return (
<>
{render(isEditing ? editorHeight : 'inherit')}
{isEditing && (
<div className="panel-editor-container__resizer">
<Draggable axis="y" grid={[100, 1]} onDrag={this.onDrag} position={{ x: 0, y: 0 }}>
<div className="panel-editor-resizer">
<div className="panel-editor-resizer__handle" />
</div>
</Draggable>
</div>
)}
</>
);
}
}
@@ -1,6 +1,5 @@
// Libraries
import React, { SFC, PureComponent } from 'react';
import Remarkable from 'remarkable';
import _ from 'lodash';
// Components
@@ -22,6 +21,7 @@ import config from 'app/core/config';
import { PanelModel } from '../panel_model';
import { DashboardModel } from '../dashboard_model';
import { DataSourceSelectItem, DataQuery } from 'app/types';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
interface Props {
panel: PanelModel;
@@ -128,43 +128,13 @@ export class QueriesTab extends PureComponent<Props, State> {
});
};
loadHelp = () => {
const { currentDS } = this.state;
const hasHelp = currentDS.meta.hasQueryHelp;
if (hasHelp) {
this.setState({
helpContent: <h3>Loading help...</h3>,
isLoadingHelp: true,
});
this.backendSrv
.get(`/api/plugins/${currentDS.meta.id}/markdown/query_help`)
.then(res => {
const md = new Remarkable();
const helpHtml = md.render(res);
this.setState({
helpContent: <div className="markdown-html" dangerouslySetInnerHTML={{ __html: helpHtml }} />,
isLoadingHelp: false,
});
})
.catch(() => {
this.setState({
helpContent: <h3>'Error occured when loading help'</h3>,
isLoadingHelp: false,
});
});
}
};
renderQueryInspector = () => {
const { panel } = this.props;
return <QueryInspector panel={panel} LoadingPlaceholder={LoadingPlaceholder} />;
};
renderHelp = () => {
const { helpContent, isLoadingHelp } = this.state;
return isLoadingHelp ? <LoadingPlaceholder text="Loading help..." /> : helpContent;
return <PluginHelp plugin={this.state.currentDS.meta} type="query_help" />;
};
onAddQuery = (query?: Partial<DataQuery>) => {
@@ -233,7 +203,6 @@ export class QueriesTab extends PureComponent<Props, State> {
render() {
const { panel } = this.props;
const { currentDS, isAddingMixed } = this.state;
const { hasQueryHelp } = currentDS.meta;
const queryInspector = {
title: 'Query Inspector',
@@ -243,8 +212,6 @@ export class QueriesTab extends PureComponent<Props, State> {
const dsHelp = {
heading: 'Help',
icon: 'fa fa-question',
disabled: !hasQueryHelp,
onClick: this.loadHelp,
render: this.renderHelp,
};
@@ -38,7 +38,33 @@ interface Props {
datasource: DataSourceSelectItem;
}
export class QueryOptions extends PureComponent<Props> {
interface State {
relativeTime: string;
timeShift: string;
}
export class QueryOptions extends PureComponent<Props, State> {
constructor(props) {
super(props);
this.state = {
relativeTime: props.panel.timeFrom || '',
timeShift: props.panel.timeShift || '',
};
}
onRelativeTimeChange = event => {
this.setState({
relativeTime: event.target.value,
});
};
onTimeShiftChange = event => {
this.setState({
timeShift: event.target.value,
});
};
onOverrideTime = (evt, status: InputStatus) => {
const { value } = evt.target;
const { panel } = this.props;
@@ -128,8 +154,10 @@ export class QueryOptions extends PureComponent<Props> {
});
}
render = () => {
render() {
const hideTimeOverride = this.props.panel.hideTimeOverride;
const { relativeTime, timeShift } = this.state;
return (
<div className="gf-form-inline">
{this.renderOptions()}
@@ -140,9 +168,11 @@ export class QueryOptions extends PureComponent<Props> {
type="text"
className="width-6"
placeholder="1h"
onChange={this.onRelativeTimeChange}
onBlur={this.onOverrideTime}
validationEvents={timeRangeValidationEvents}
hideErrorMessage={true}
value={relativeTime}
/>
</div>
@@ -152,9 +182,11 @@ export class QueryOptions extends PureComponent<Props> {
type="text"
className="width-6"
placeholder="1h"
onChange={this.onTimeShiftChange}
onBlur={this.onTimeShift}
validationEvents={timeRangeValidationEvents}
hideErrorMessage={true}
value={timeShift}
/>
</div>
@@ -163,5 +195,5 @@ export class QueryOptions extends PureComponent<Props> {
</div>
</div>
);
};
}
}
@@ -7,6 +7,7 @@ import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoa
// Components
import { EditorTabBody } from './EditorTabBody';
import { VizTypePicker } from './VizTypePicker';
import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import { FadeIn } from 'app/core/components/Animations/FadeIn';
import { PanelOptionSection } from './PanelOptionSection';
@@ -105,6 +106,7 @@ export class VisualizationTab extends PureComponent<Props, State> {
}
const panelCtrl = scope.$$childHead.ctrl;
panelCtrl.initEditMode();
let template = '';
for (let i = 0; i < panelCtrl.editorTabs.length; i++) {
@@ -198,12 +200,20 @@ export class VisualizationTab extends PureComponent<Props, State> {
}
};
renderHelp = () => <PluginHelp plugin={this.props.plugin} type="help" />;
render() {
const { plugin } = this.props;
const { isVizPickerOpen, searchQuery } = this.state;
const pluginHelp = {
heading: 'Help',
icon: 'fa fa-question',
render: this.renderHelp,
};
return (
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar}>
<EditorTabBody heading="Visualization" renderToolbar={this.renderToolbar} toolbarItems={[pluginHelp]}>
<>
<FadeIn in={isVizPickerOpen} duration={200} unmountOnExit={true}>
<VizTypePicker
@@ -67,7 +67,7 @@ export function updateDashboardPermission(
const updated = toUpdateItem(item);
// if this is the item we want to update, update it's permisssion
// if this is the item we want to update, update it's permission
if (itemToUpdate === item) {
updated.permission = level;
}
@@ -24,60 +24,67 @@
</div>
<div ng-if="ctrl.isOpen" class="gf-timepicker-dropdown">
<form name="timeForm" class="gf-timepicker-absolute-section">
<h3 class="section-heading">Custom range</h3>
<label class="small">From:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openFromPicker=!openFromPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
<div class="popover-box">
<div class="popover-box__header">
<span class="popover-box__title">Quick ranges</span>
</div>
<div ng-if="openFromPicker">
<datepicker ng-model="ctrl.absolute.fromJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteFromChanged()"></datepicker>
<div class="popover-box__body gf-timepicker-relative-section">
<ul ng-repeat="group in ctrl.timeOptions">
<li bindonce ng-repeat='option in group' ng-class="{active: option.active}">
<a ng-click="ctrl.setRelativeFilter(option)" bo-text="option.display"></a>
</li>
</ul>
</div>
</div>
<label class="small">To:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openToPicker=!openToPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
<div class="popover-box">
<div class="popover-box__header">
<span class="popover-box__title">Custom range</span>
</div>
<div ng-if="openToPicker">
<datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
</div>
<label class="small">Refreshing every:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
<form name="timeForm" class="popover-box__body gf-timepicker-absolute-section">
<label class="small">From:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.from" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openFromPicker=!openFromPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
</div>
<div class="gf-form">
<button type="submit" class="btn gf-form-btn btn-secondary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
</div>
</div>
</form>
<div class="gf-timepicker-relative-section">
<h3 class="section-heading">Quick ranges</h3>
<ul ng-repeat="group in ctrl.timeOptions">
<li bindonce ng-repeat='option in group' ng-class="{active: option.active}">
<a ng-click="ctrl.setRelativeFilter(option)" bo-text="option.display"></a>
</li>
</ul>
<div ng-if="openFromPicker">
<datepicker ng-model="ctrl.absolute.fromJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteFromChanged()"></datepicker>
</div>
<label class="small">To:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<input type="text" class="gf-form-input input-large" ng-model="ctrl.editTimeRaw.to" input-datetime>
</div>
<div class="gf-form">
<button class="btn gf-form-btn btn-primary" type="button" ng-click="openToPicker=!openToPicker">
<i class="fa fa-calendar"></i>
</button>
</div>
</div>
<div ng-if="openToPicker">
<datepicker ng-model="ctrl.absolute.toJs" class="gf-timepicker-component" show-weeks="false" starting-day="ctrl.firstDayOfWeek" ng-change="ctrl.absoluteToChanged()"></datepicker>
</div>
<label class="small">Refreshing every:</label>
<div class="gf-form-inline">
<div class="gf-form max-width-28">
<select ng-model="ctrl.refresh.value" class="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
</div>
<div class="gf-form">
<button type="submit" class="btn gf-form-btn btn-secondary" ng-click="ctrl.applyCustom();" ng-disabled="!timeForm.$valid">Apply</button>
</div>
</div>
</form>
</div>
</div>
@@ -61,7 +61,10 @@ exports[`Render should render alpha info text 1`] = `
},
"description": "pretty decent plugin",
"links": Array [
"one link",
Object {
"name": "project",
"url": "one link",
},
],
"logos": Object {
"large": "large/logo",
@@ -160,7 +163,10 @@ exports[`Render should render beta info text 1`] = `
},
"description": "pretty decent plugin",
"links": Array [
"one link",
Object {
"name": "project",
"url": "one link",
},
],
"logos": Object {
"large": "large/logo",
@@ -254,7 +260,10 @@ exports[`Render should render component 1`] = `
},
"description": "pretty decent plugin",
"links": Array [
"one link",
Object {
"name": "project",
"url": "one link",
},
],
"logos": Object {
"large": "large/logo",
@@ -353,7 +362,10 @@ exports[`Render should render is ready only message 1`] = `
},
"description": "pretty decent plugin",
"links": Array [
"one link",
Object {
"name": "project",
"url": "one link",
},
],
"logos": Object {
"large": "large/logo",
@@ -73,7 +73,7 @@ export function getDataSourceLoadingNav(pageName: string): NavModel {
url: '',
},
description: '',
links: [''],
links: [{ name: '', url: '' }],
logos: {
large: '',
small: '',
+1 -1
View File
@@ -482,7 +482,7 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
} else {
// Modify query only at index
nextQueries = initialQueries.map((query, i) => {
// Synchronise all queries with local query cache to ensure consistency
// Synchronize all queries with local query cache to ensure consistency
// TODO still needed?
return i === index
? {
@@ -88,7 +88,7 @@ export default class PlaceholdersBuffer {
orders.push({ index: parts.length - 1, order });
textOffset += part.length + match.length;
}
// Ensures string serialisation still works if no placeholders were parsed
// Ensures string serialization still works if no placeholders were parsed
// and also accounts for the remainder of text with placeholders
parts.push(text.slice(textOffset));
return {
+50 -50
View File
@@ -232,60 +232,60 @@ export default class TimePicker extends PureComponent<TimePickerProps, TimePicke
const timeOptions = this.getTimeOptions();
return (
<div ref={this.dropdownRef} className="gf-timepicker-dropdown">
<div className="gf-timepicker-absolute-section">
<h3 className="section-heading">Custom range</h3>
<label className="small">From:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<input
type="text"
className="gf-form-input input-large timepicker-from"
value={fromRaw}
onChange={this.handleChangeFrom}
/>
</div>
<div className="popover-box">
<div className="popover-box__header">
<span className="popover-box__title">Quick ranges</span>
</div>
<label className="small">To:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<input
type="text"
className="gf-form-input input-large timepicker-to"
value={toRaw}
onChange={this.handleChangeTo}
/>
</div>
</div>
{/* <label className="small">Refreshing every:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<select className="gf-form-input input-medium" ng-options="f.value as f.text for f in ctrl.refresh.options"></select>
</div>
</div> */}
<div className="gf-form">
<button className="btn gf-form-btn btn-secondary" onClick={this.handleClickApply}>
Apply
</button>
<div className="popover-box__body gf-timepicker-relative-section">
{Object.keys(timeOptions).map(section => {
const group = timeOptions[section];
return (
<ul key={section}>
{group.map(option => (
<li className={option.active ? 'active' : ''} key={option.display}>
<a onClick={() => this.handleClickRelativeOption(option)}>{option.display}</a>
</li>
))}
</ul>
);
})}
</div>
</div>
<div className="gf-timepicker-relative-section">
<h3 className="section-heading">Quick ranges</h3>
{Object.keys(timeOptions).map(section => {
const group = timeOptions[section];
return (
<ul key={section}>
{group.map(option => (
<li className={option.active ? 'active' : ''} key={option.display}>
<a onClick={() => this.handleClickRelativeOption(option)}>{option.display}</a>
</li>
))}
</ul>
);
})}
<div className="popover-box">
<div className="popover-box__header">
<span className="popover-box__title">Custom range</span>
</div>
<div className="popover-box__body gf-timepicker-absolute-section">
<label className="small">From:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<input
type="text"
className="gf-form-input input-large timepicker-from"
value={fromRaw}
onChange={this.handleChangeFrom}
/>
</div>
</div>
<label className="small">To:</label>
<div className="gf-form-inline">
<div className="gf-form max-width-28">
<input
type="text"
className="gf-form-input input-large timepicker-to"
value={toRaw}
onChange={this.handleChangeTo}
/>
</div>
</div>
<div className="gf-form">
<button className="btn gf-form-btn btn-secondary" onClick={this.handleClickApply}>
Apply
</button>
</div>
</div>
</div>
</div>
);
+1 -1
View File
@@ -112,7 +112,7 @@ export function updateFolderPermission(itemToUpdate: DashboardAcl, level: Permis
const updated = toUpdateItem(item);
// if this is the item we want to update, update it's permisssion
// if this is the item we want to update, update it's permission
if (itemToUpdate === item) {
updated.permission = level;
}
+6 -6
View File
@@ -5,7 +5,7 @@ import { getBackendSrv } from 'app/core/services/backend_srv';
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
export enum ActionTypes {
LoadOrganization = 'LOAD_ORGANISATION',
LoadOrganization = 'LOAD_ORGANIZATION',
SetOrganizationName = 'SET_ORGANIZATION_NAME',
}
@@ -19,9 +19,9 @@ interface SetOrganizationNameAction {
payload: string;
}
const organisationLoaded = (organisation: Organization) => ({
const organizationLoaded = (organization: Organization) => ({
type: ActionTypes.LoadOrganization,
payload: organisation,
payload: organization,
});
export const setOrganizationName = (orgName: string) => ({
@@ -33,10 +33,10 @@ export type Action = LoadOrganizationAction | SetOrganizationNameAction;
export function loadOrganization(): ThunkResult<void> {
return async dispatch => {
const organisationResponse = await getBackendSrv().get('/api/org');
dispatch(organisationLoaded(organisationResponse));
const organizationResponse = await getBackendSrv().get('/api/org');
dispatch(organizationLoaded(organizationResponse));
return organisationResponse;
return organizationResponse;
};
}
@@ -30,8 +30,6 @@ class MetricsPanelCtrl extends PanelCtrl {
constructor($scope, $injector) {
super($scope, $injector);
// make metrics tab the default
this.editorTabIndex = 1;
this.$q = $injector.get('$q');
this.contextSrv = $injector.get('contextSrv');
this.datasourceSrv = $injector.get('datasourceSrv');
@@ -90,7 +88,7 @@ class MetricsPanelCtrl extends PanelCtrl {
.then(this.issueQueries.bind(this))
.then(this.handleQueryResult.bind(this))
.catch(err => {
// if cancelled keep loading set to true
// if canceled keep loading set to true
if (err.cancelled) {
console.log('Panel request cancelled', err);
return;
+5 -11
View File
@@ -18,7 +18,6 @@ export class PanelCtrl {
panel: any;
error: any;
dashboard: any;
editorTabIndex: number;
pluginName: string;
pluginId: string;
editorTabs: any;
@@ -39,7 +38,7 @@ export class PanelCtrl {
this.$location = $injector.get('$location');
this.$scope = $scope;
this.$timeout = $injector.get('$timeout');
this.editorTabIndex = 0;
this.editorTabs = [];
this.events = this.panel.events;
this.timing = {};
@@ -90,10 +89,10 @@ export class PanelCtrl {
}
initEditMode() {
this.editorTabs = [];
this.editModeInitiated = true;
this.events.emit('init-edit-mode', null);
if (!this.editModeInitiated) {
this.editModeInitiated = true;
this.events.emit('init-edit-mode', null);
}
}
addEditorTab(title, directiveFn, index?, icon?) {
@@ -212,11 +211,6 @@ export class PanelCtrl {
this.containerHeight = $(window).height();
}
// hacky solution
if (this.panel.isEditing && !this.editModeInitiated) {
this.initEditMode();
}
this.height = this.containerHeight - (PANEL_BORDER + PANEL_HEADER_HEIGHT);
}
@@ -70,7 +70,7 @@ export const getMockPlugin = () => {
url: 'url/to/GrafanaLabs',
},
description: 'pretty decent plugin',
links: ['one link'],
links: [{ name: 'project', url: 'one link' }],
logos: { small: 'small/logo', large: 'large/logo' },
screenshots: [{ path: `screenshot` }],
updated: '2018-09-26',
@@ -77,8 +77,8 @@ describe('VariableSrv init', function(this: any) {
{
name: 'apps',
type: type,
current: { text: 'test', value: 'test' },
options: [{ text: 'test', value: 'test' }],
current: { text: 'Test', value: 'test' },
options: [{ text: 'Test', value: 'test' }],
},
];
scenario.urlParams['var-apps'] = 'new';
@@ -161,11 +161,11 @@ describe('VariableSrv init', function(this: any) {
name: 'apps',
type: 'query',
multi: true,
current: { text: 'val1', value: 'val1' },
current: { text: 'Val1', value: 'val1' },
options: [
{ text: 'val1', value: 'val1' },
{ text: 'val2', value: 'val2' },
{ text: 'val3', value: 'val3', selected: true },
{ text: 'Val1', value: 'val1' },
{ text: 'Val2', value: 'val2' },
{ text: 'Val3', value: 'val3', selected: true },
],
},
];
@@ -177,7 +177,7 @@ describe('VariableSrv init', function(this: any) {
expect(variable.current.value.length).toBe(2);
expect(variable.current.value[0]).toBe('val2');
expect(variable.current.value[1]).toBe('val1');
expect(variable.current.text).toBe('val2 + val1');
expect(variable.current.text).toBe('Val2 + Val1');
expect(variable.options[0].selected).toBe(true);
expect(variable.options[1].selected).toBe(true);
});
@@ -188,6 +188,30 @@ describe('VariableSrv init', function(this: any) {
});
});
describeInitScenario(
'when template variable is present in url multiple times and variables have no text',
scenario => {
scenario.setup(() => {
scenario.variables = [
{
name: 'apps',
type: 'query',
multi: true,
},
];
scenario.urlParams['var-apps'] = ['val1', 'val2'];
});
it('should display concatenated values in text', () => {
const variable = ctx.variableSrv.variables[0];
expect(variable.current.value.length).toBe(2);
expect(variable.current.value[0]).toBe('val1');
expect(variable.current.value[1]).toBe('val2');
expect(variable.current.text).toBe('val1 + val2');
});
}
);
describeInitScenario('when template variable is present in url multiple times using key/values', scenario => {
scenario.setup(() => {
scenario.variables = [
@@ -237,8 +237,10 @@ export class VariableSrv {
setOptionAsCurrent(variable, option) {
variable.current = _.cloneDeep(option);
if (_.isArray(variable.current.text)) {
if (_.isArray(variable.current.text) && variable.current.text.length > 0) {
variable.current.text = variable.current.text.join(' + ');
} else if (_.isArray(variable.current.value) && variable.current.value[0] !== '$__all') {
variable.current.text = variable.current.value.join(' + ');
}
this.selectOptionsForCurrentValue(variable);
@@ -35,7 +35,7 @@ describe('getLoglevel()', () => {
});
describe('parseLabels()', () => {
it('returns no labels on emtpy labels string', () => {
it('returns no labels on empty labels string', () => {
expect(parseLabels('')).toEqual({});
expect(parseLabels('{}')).toEqual({});
});
@@ -46,7 +46,7 @@ describe('parseLabels()', () => {
});
describe('formatLabels()', () => {
it('returns no labels on emtpy label set', () => {
it('returns no labels on empty label set', () => {
expect(formatLabels({})).toEqual('');
expect(formatLabels({}, 'foo')).toEqual('foo');
});
@@ -60,7 +60,6 @@ class DashListCtrl extends PanelCtrl {
}
onInitEditMode() {
this.editorTabIndex = 1;
this.modes = ['starred', 'search', 'recently viewed'];
this.addEditorTab('Options', 'public/app/plugins/panel/dashlist/editor.html');
}
+4 -4
View File
@@ -1,7 +1,7 @@
# Graph Panel - Native Plugin
# Graph Panel
The Graph is the main graph panel and is **included** with Grafana. It provides a very rich set of graphing options.
This is the main Graph panel and is **included** with Grafana. It provides a very rich set of graphing options.
Read more about it here:
For full reference documentation:
[http://docs.grafana.org/reference/graph/](http://docs.grafana.org/reference/graph/)
[http://docs.grafana.org/reference/graph/](http://docs.grafana.org/reference/graph/)
+1 -1
View File
@@ -737,7 +737,7 @@ class GraphElement {
if (min && max && ticks) {
const range = max - min;
const secPerTick = range / ticks / 1000;
// Need have 10 milisecond margin on the day range
// Need have 10 millisecond margin on the day range
// As sometimes last 24 hour dashboard evaluates to more than 86400000
const oneDay = 86400010;
const oneYear = 31536000000;
@@ -54,7 +54,7 @@ export function createEditPopover(element, event, plot) {
const eventManager = plot.getOptions().events.manager;
if (eventManager.editorOpen) {
// update marker element to attach to (needed in case of legend on the right
// when there is a double render pass and the inital marker element is removed)
// when there is a double render pass and the initial marker element is removed)
markerElementToAttachTo = element;
return;
}
@@ -29,7 +29,6 @@ class PluginListCtrl extends PanelCtrl {
}
onInitEditMode() {
this.editorTabIndex = 1;
this.addEditorTab('Options', 'public/app/plugins/panel/pluginlist/editor.html');
}
-1
View File
@@ -43,7 +43,6 @@ export class TextPanelCtrl extends PanelCtrl {
onInitEditMode() {
this.addEditorTab('Options', 'public/app/plugins/panel/text/editor.html');
this.editorTabIndex = 1;
if (this.panel.mode === 'text') {
this.panel.mode = 'markdown';
+6 -1
View File
@@ -57,13 +57,18 @@ export interface PluginInclude {
path: string;
}
interface PluginMetaInfoLink {
name: string;
url: string;
}
export interface PluginMetaInfo {
author: {
name: string;
url?: string;
};
description: string;
links: string[];
links: PluginMetaInfoLink[];
logos: {
large: string;
small: string;
+1 -1
View File
@@ -31,7 +31,7 @@
"folderId": 0,
"headings": true,
"id": 3,
"limit": 4,
"limit": 30,
"links": [],
"query": "",
"recent": true,
+1
View File
@@ -104,6 +104,7 @@
@import 'components/thresholds';
@import 'components/toggle_button_group';
@import 'components/value-mappings';
@import 'components/popover-box';
// PAGES
@import 'pages/login';
+4
View File
@@ -400,3 +400,7 @@ $logs-color-unkown: $gray-2;
$button-toggle-group-btn-active-bg: linear-gradient(90deg, $orange, $red);
$button-toggle-group-btn-active-shadow: inset 0 0 4px $black;
$button-toggle-group-btn-seperator-border: 1px solid $page-bg;
$vertical-resize-handle-bg: $dark-5;
$vertical-resize-handle-dots: $gray-1;
$vertical-resize-handle-dots-hover: $gray-2;
+4
View File
@@ -409,3 +409,7 @@ $logs-color-unkown: $gray-5;
$button-toggle-group-btn-active-bg: $brand-primary;
$button-toggle-group-btn-active-shadow: inset 0 0 4px $white;
$button-toggle-group-btn-seperator-border: 1px solid $gray-6;
$vertical-resize-handle-bg: $gray-4;
$vertical-resize-handle-dots: $gray-3;
$vertical-resize-handle-dots-hover: $gray-2;
+21 -34
View File
@@ -84,46 +84,34 @@
}
}
.panel-editor-resizer {
position: absolute;
height: 2px;
width: 100%;
top: -23px;
text-align: center;
border-bottom: 2px dashed transparent;
&:hover {
transition: border-color 0.2s ease-in 0.4s;
transition-delay: 0.2s;
border-color: $text-color-faint;
}
.panel-editor-container__resizer {
position: relative;
margin-top: -3px;
}
.panel-editor-resizer__handle {
display: inline-block;
width: 180px;
position: relative;
border-radius: 2px;
height: 7px;
cursor: grabbing;
background: $input-label-bg;
top: -9px;
display: block;
background: $vertical-resize-handle-bg;
width: 150px;
margin-left: -75px;
height: 6px;
cursor: ns-resize;
border-radius: 3px;
margin: 0 auto;
&:hover {
transition: background 0.2s ease-in 0.4s;
transition-delay: 0.2s;
background: linear-gradient(90deg, $orange, $red);
.panel-editor-resizer__handle-dots {
transition: opacity 0.2s ease-in;
opacity: 0;
}
&::before {
content: ' ';
position: absolute;
left: 10px;
right: 10px;
top: 2px;
border-top: 2px dotted $vertical-resize-handle-dots;
}
}
.panel-editor-resizer__handle-dots {
border-top: 2px dashed $text-color-faint;
position: relative;
top: 4px;
&:hover::before {
border-color: $vertical-resize-handle-dots-hover;
}
}
.viz-picker {
@@ -149,7 +137,6 @@
display: flex;
margin-right: 10px;
margin-bottom: 10px;
//border: 1px solid transparent;
align-items: center;
justify-content: center;
padding-bottom: 6px;
@@ -1,4 +1,4 @@
// Colours
// Colors
$progress-color-dark: $panel-bg !default;
$progress-color: $panel-bg !default;
$progress-color-light: $panel-bg !default;
+31
View File
@@ -0,0 +1,31 @@
.popover-box {
background-color: $popover-bg;
color: $popover-color;
border: 1px solid $popover-border-color;
border-radius: $border-radius;
max-width: 500px;
}
.popover-box__header {
background-color: $popover-border-color;
padding: 6px 10px;
display: flex;
}
.popover-box__title {
font-weight: $font-weight-semi-bold;
padding-right: $spacer;
overflow: hidden;
display: inline-block;
white-space: nowrap;
text-overflow: ellipsis;
flex-grow: 1;
}
.popover-box__body {
padding: 20px 10px 10px 10px;
}
.popover-box__close {
cursor: pointer;
}
-3
View File
@@ -66,9 +66,6 @@
padding: 5px 8px;
}
.threshold-row-base {
}
.threshold-row-remove {
display: flex;
align-items: center;
+18 -13
View File
@@ -13,23 +13,28 @@
}
.gf-timepicker-dropdown {
position: absolute;
top: $navbarHeight;
right: 0;
padding: 10px 20px;
background-color: $page-bg;
border-radius: 0 0 0 4px;
box-shadow: $search-shadow;
z-index: $zindex-dropdown;
}
display: flex;
flex-direction: column;
position: absolute;
right: 20px;
top: $navbarHeight;
width: 550px;
.gf-timepicker-absolute-section {
width: 290px;
float: left;
padding: 0 10px;
select {
width: 183px;
margin-bottom: 0;
.popover-box {
max-width: 100%;
&:first-child {
border-radius: $border-radius $border-radius 0 0;
border-bottom: 0;
}
&:last-child {
border-radius: 0 0 $border-radius $border-radius;
}
}
}
@@ -48,9 +53,9 @@
}
.gf-timepicker-relative-section {
padding: 0 20px 0 30px;
min-height: 237px;
float: left;
ul {
list-style: none;
float: left;