Merge remote-tracking branch 'grafana/master'

* grafana/master: (54 commits)
  now /api/login/ping returns Response
  fix: Explore: Query wrapping on long queries #15222
  fix: Set ace editor min height to avoid problem with scrollbar overlapping ace content #15122
  fix: Data source picker in panel queries options should overlap content below, including ace scrollbar #15122
  fix util for splitting host and port
  changelog: add notes about closing #14231
  fixing logging action
  devenv: switching back using loki master plus various fixes
  Fix save provisioned dashboard modal
  Add AWS/Neptune to metricsMap and dimensionsMap
  did not add file, removing centerered
  Legend toggle should only trigger a re-render, not a refresh
  first stuff
  updated snapshot
  Adding pointer to colorpicker
  Minor post review changes
  More style tweaks to panel option group add button
  Made some style tweaks
  setting margin on label
  Make runQueries action independent from datasource loading
  ...
This commit is contained in:
ryan
2019-02-04 23:06:03 -08:00
104 changed files with 1725 additions and 787 deletions
+1
View File
@@ -2,6 +2,7 @@
### Minor
* **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae)
* **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson)
# 6.0.0-beta1 (2019-01-30)
+38 -16
View File
@@ -25,49 +25,71 @@ the latest master builds [here](https://grafana.com/grafana/download)
### Dependencies
- Go (Latest Stable)
- bra [`go get github.com/Unknwon/bra`]
- Node.js LTS
- yarn [`npm install -g yarn`]
### Get the project
**The project located in the go-path will be your working directory.**
### Building the backend
```bash
go get github.com/grafana/grafana
cd $GOPATH/src/github.com/grafana/grafana
```
### Building
#### The backend
```bash
go run build.go setup
go run build.go build
```
### Building frontend assets
#### Frontend assets
For this you need Node.js (LTS version).
*For this you need Node.js (LTS version).*
To build the assets, rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
```bash
npm install -g yarn
yarn install --pure-lockfile
```
### Run and rebuild on source change
#### Backend
To run the backend and rebuild on source change:
```bash
$GOPATH/bin/bra run
```
#### Frontend
Rebuild on file change, and serve them by Grafana's webserver (http://localhost:3000):
```bash
yarn watch
```
Build the assets, rebuild on file change with Hot Module Replacement (HMR), and serve them by webpack-dev-server (http://localhost:3333):
```bash
yarn start
# OR set a theme
env GRAFANA_THEME=light yarn start
```
Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload.
Run tests
*Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload.*
Run tests and rebuild on source change:
```bash
yarn jest
```
### Recompile backend on source change
To rebuild on source change.
```bash
go get github.com/Unknwon/bra
bra run
```
Open grafana in your browser (default: `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).
**Open grafana in your browser (default: e.g. `http://localhost:3000`) and login with admin user (default: `user/pass = admin/admin`).**
### Building a Docker image
+3
View File
@@ -113,6 +113,9 @@ cache_mode = private
# Login cookie name
cookie_name = grafana_session
# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
cookie_samesite = lax
# How many days an session can be unused before we inactivate it
login_remember_days = 7
+3
View File
@@ -109,6 +109,9 @@ log_queries =
# Login cookie name
;cookie_name = grafana_session
# Login cookie same site setting. defaults to `lax`. can be set to "lax", "strict" and "none"
;cookie_samesite = lax
# How many days an session can be unused before we inactivate it
;login_remember_days = 7
+27
View File
@@ -0,0 +1,27 @@
server:
http_listen_port: 9080
grpc_listen_port: 0
positions:
filename: /tmp/positions.yaml
client:
url: http://loki:3100/api/prom/push
scrape_configs:
- job_name: system
entry_parser: raw
static_configs:
- targets:
- localhost
labels:
job: varlogs
__path__: /var/log/*log
- job_name: grafana
entry_parser: raw
static_configs:
- targets:
- localhost
labels:
job: grafana
__path__: /var/log/grafana/*log
+2 -10
View File
@@ -1,22 +1,14 @@
version: "3"
networks:
loki:
services:
loki:
image: grafana/loki:master
ports:
- "3100:3100"
command: -config.file=/etc/loki/local-config.yaml
networks:
- loki
promtail:
image: grafana/promtail:master
volumes:
- ./docker/blocks/loki/config.yaml:/etc/promtail/docker-config.yaml
- /var/log:/var/log
- ../data/log:/var/log/grafana
command:
-config.file=/etc/promtail/docker-config.yaml
networks:
- loki
@@ -167,6 +167,7 @@ $arrowSize: 15px;
color: inherit;
padding: 0;
border-radius: 10px;
cursor: pointer;
}
.sp-replacer:hover,
@@ -2,7 +2,6 @@ import React from 'react';
import { shallow } from 'enzyme';
import { Gauge, Props } from './Gauge';
import { TimeSeriesVMs } from '../../types/data';
import { ValueMapping, MappingType } from '../../types';
jest.mock('jquery', () => ({
@@ -23,7 +22,7 @@ const setup = (propOverrides?: object) => {
stat: 'avg',
height: 300,
width: 300,
timeSeries: {} as TimeSeriesVMs,
value: 25,
decimals: 0,
};
@@ -1,7 +1,7 @@
import React, { PureComponent } from 'react';
import $ from 'jquery';
import { ValueMapping, Threshold, BasicGaugeColor, TimeSeriesVMs, GrafanaTheme } from '../../types';
import { ValueMapping, Threshold, BasicGaugeColor, GrafanaTheme } from '../../types';
import { getMappedValue } from '../../utils/valueMappings';
import { getColorFromHexRgbOrName, getValueFormat } from '../../utils';
@@ -14,7 +14,6 @@ export interface Props {
maxValue: number;
minValue: number;
prefix: string;
timeSeries: TimeSeriesVMs;
thresholds: Threshold[];
showThresholdMarkers: boolean;
showThresholdLabels: boolean;
@@ -22,6 +21,7 @@ export interface Props {
suffix: string;
unit: string;
width: number;
value: number;
theme?: GrafanaTheme;
}
@@ -122,25 +122,7 @@ export class Gauge extends PureComponent<Props> {
}
draw() {
const {
maxValue,
minValue,
timeSeries,
showThresholdLabels,
showThresholdMarkers,
width,
height,
stat,
theme,
} = this.props;
let value: TimeSeriesValue = '';
if (timeSeries[0]) {
value = timeSeries[0].stats[stat];
} else {
value = null;
}
const { maxValue, minValue, showThresholdLabels, showThresholdMarkers, width, height, theme, value } = this.props;
const formattedValue = this.formatValue(value) as string;
const dimension = Math.min(width, height * 1.3);
@@ -194,7 +176,7 @@ export class Gauge extends PureComponent<Props> {
try {
$.plot(this.canvasElement, [plotSeries], options);
} catch (err) {
console.log('Gauge rendering error', err, options, timeSeries);
console.log('Gauge rendering error', err, options, value);
}
}
@@ -1,26 +1,38 @@
// Libraries
import React, { SFC } from 'react';
import React, { FunctionComponent } from 'react';
interface Props {
title?: string;
onClose?: () => void;
children: JSX.Element | JSX.Element[];
children: JSX.Element | JSX.Element[] | boolean;
onAdd?: () => void;
}
export const PanelOptionsGroup: SFC<Props> = props => {
export const PanelOptionsGroup: FunctionComponent<Props> = props => {
return (
<div className="panel-options-group">
{props.title && (
{props.onAdd ? (
<div className="panel-options-group__header">
{props.title}
{props.onClose && (
<button className="btn btn-link" onClick={props.onClose}>
<i className="fa fa-remove" />
</button>
)}
<button className="panel-options-group__add-btn" onClick={props.onAdd}>
<div className="panel-options-group__add-circle">
<i className="fa fa-plus" />
</div>
<span className="panel-options-group__title">{props.title}</span>
</button>
</div>
) : (
props.title && (
<div className="panel-options-group__header">
<span className="panel-options-group__title">{props.title}</span>
{props.onClose && (
<button className="btn btn-link" onClick={props.onClose}>
<i className="fa fa-remove" />
</button>
)}
</div>
)
)}
<div className="panel-options-group__body">{props.children}</div>
{props.children && <div className="panel-options-group__body">{props.children}</div>}
</div>
);
};
@@ -7,18 +7,57 @@
.panel-options-group__header {
padding: 4px 8px;
font-size: 1.1rem;
background: $panel-options-group-header-bg;
position: relative;
border-radius: $border-radius $border-radius 0 0;
display: flex;
align-items: center;
.btn {
position: absolute;
right: 0;
top: 0px;
top: 0;
}
}
.panel-options-group__add-btn {
background: none;
border: none;
display: flex;
align-items: center;
padding: 0;
&:hover {
.panel-options-group__add-circle {
background-color: $btn-success-bg;
color: $text-color-strong;
}
}
}
.panel-options-group__add-circle {
@include gradientBar($btn-success-bg, $btn-success-bg-hl, $text-color);
border-radius: 50px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
margin-right: 6px;
i {
position: relative;
top: 1px;
}
}
.panel-options-group__title {
font-size: 1.1rem;
position: relative;
top: 1px;
}
.panel-options-group__body {
padding: 20px;
@@ -49,7 +49,7 @@ export default class SelectOptionGroup extends PureComponent<ExtendedGroupProps,
return (
<div className="gf-form-select-box__option-group">
<div className="gf-form-select-box__option-group__header" onClick={this.onToggleChildren}>
<span className="flex-grow">{label}</span>
<span className="flex-grow-1">{label}</span>
<i className={`fa ${expanded ? 'fa-caret-left' : 'fa-caret-down'}`} />{' '}
</div>
{expanded && children}
@@ -1,11 +1,11 @@
.thresholds {
margin-bottom: 10px;
margin-bottom: 20px;
}
.thresholds-row {
display: flex;
flex-direction: row;
height: 70px;
height: 62px;
}
.thresholds-row:first-child > .thresholds-row-color-indicator {
@@ -21,21 +21,21 @@
}
.thresholds-row-add-button {
@include buttonBackground($btn-success-bg, $btn-success-bg-hl, $text-color);
align-self: center;
margin-right: 5px;
color: $green;
height: 24px;
width: 24px;
background-color: $green;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.thresholds-row-add-button > i {
color: $white;
&:hover {
color: $text-color-strong;
}
}
.thresholds-row-color-indicator {
@@ -0,0 +1,10 @@
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { ValueMappingsEditor } from './ValueMappingsEditor';
const ValueMappingsEditorStories = storiesOf('UI/ValueMappingsEditor', module);
ValueMappingsEditorStories.add('default', () => {
return <ValueMappingsEditor valueMappings={[]} onChange={action('Mapping changed')} />;
});
@@ -2,7 +2,7 @@ import React from 'react';
import { shallow } from 'enzyme';
import { ValueMappingsEditor, Props } from './ValueMappingsEditor';
import { MappingType } from '../../types/panel';
import { MappingType } from '../../types';
const setup = (propOverrides?: object) => {
const props: Props = {
@@ -1,8 +1,8 @@
import React, { PureComponent } from 'react';
import MappingRow from './MappingRow';
import { MappingType, ValueMapping } from '../../types/panel';
import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
import { MappingType, ValueMapping } from '../../types';
import { PanelOptionsGroup } from '..';
export interface Props {
valueMappings: ValueMapping[];
@@ -81,8 +81,7 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
const { valueMappings } = this.state;
return (
<PanelOptionsGroup title="Value Mappings">
<div>
<PanelOptionsGroup title="Add value mapping" onAdd={this.addMapping}>
{valueMappings.length > 0 &&
valueMappings.map((valueMapping, index) => (
<MappingRow
@@ -92,13 +91,6 @@ export class ValueMappingsEditor extends PureComponent<Props, State> {
removeValueMapping={() => this.onRemoveMapping(valueMapping.id)}
/>
))}
</div>
<div className="add-mapping-row" onClick={this.addMapping}>
<div className="add-mapping-row-icon">
<i className="fa fa-plus" />
</div>
<div className="add-mapping-row-label">Add mapping</div>
</div>
</PanelOptionsGroup>
);
}
@@ -2,55 +2,37 @@
exports[`Render should render component 1`] = `
<Component
title="Value Mappings"
onAdd={[Function]}
title="Add value mapping"
>
<div>
<MappingRow
key="Ok-0"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
}
<MappingRow
key="Ok-0"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"id": 1,
"operator": "",
"text": "Ok",
"type": 1,
"value": "20",
}
/>
<MappingRow
key="Meh-1"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
}
}
/>
<MappingRow
key="Meh-1"
removeValueMapping={[Function]}
updateValueMapping={[Function]}
valueMapping={
Object {
"from": "21",
"id": 2,
"operator": "",
"text": "Meh",
"to": "30",
"type": 2,
}
/>
</div>
<div
className="add-mapping-row"
onClick={[Function]}
>
<div
className="add-mapping-row-icon"
>
<i
className="fa fa-plus"
/>
</div>
<div
className="add-mapping-row-label"
>
Add mapping
</div>
</div>
}
/>
</Component>
`;
+1 -1
View File
@@ -3,7 +3,7 @@ import { PluginMeta } from './plugin';
import { TableData, TimeSeries } from './data';
export interface DataQueryResponse {
data: TimeSeries[] | [TableData];
data: TimeSeries[] | [TableData] | any;
}
export interface DataQuery {
+1 -1
View File
@@ -4,7 +4,7 @@ import { TimeRange } from './time';
export type InterpolateFunction = (value: string, format?: string | Function) => string;
export interface PanelProps<T = any> {
timeSeries: TimeSeries[];
panelData: PanelData;
timeRange: TimeRange;
loading: LoadingState;
options: T;
+2 -2
View File
@@ -44,8 +44,8 @@ export interface DataSourceApi<TQuery extends DataQuery = DataQuery> {
export interface QueryEditorProps<DSType extends DataSourceApi, TQuery extends DataQuery> {
datasource: DSType;
query: TQuery;
onExecuteQuery?: () => void;
onQueryChange?: (value: TQuery) => void;
onRunQuery: () => void;
onChange: (value: TQuery) => void;
}
export interface PluginExports {
+2 -2
View File
@@ -108,8 +108,8 @@ func (hs *HTTPServer) registerRoutes() {
r.Get("/api/snapshots-delete/:deleteKey", Wrap(DeleteDashboardSnapshotByDeleteKey))
r.Delete("/api/snapshots/:key", reqEditorRole, Wrap(DeleteDashboardSnapshot))
// api renew session based on remember cookie
r.Get("/api/login/ping", quota("session"), hs.LoginAPIPing)
// api renew session based on cookie
r.Get("/api/login/ping", quota("session"), Wrap(hs.LoginAPIPing))
// authed api
r.Group("/api", func(apiRoute routing.RouteRegister) {
+1 -1
View File
@@ -149,4 +149,4 @@ func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqC
return nil
}
func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }
+1 -1
View File
@@ -136,7 +136,7 @@ func (hs *HTTPServer) loginUserWithUser(user *m.User, c *m.ReqContext) {
}
func (hs *HTTPServer) Logout(c *m.ReqContext) {
hs.AuthTokenService.UserSignedOutHook(c)
hs.AuthTokenService.SignOutUser(c)
if setting.SignoutRedirectUrl != "" {
c.Redirect(setting.SignoutRedirectUrl)
+1 -1
View File
@@ -602,4 +602,4 @@ func (s *fakeUserAuthTokenService) UserAuthenticatedHook(user *m.User, c *m.ReqC
return nil
}
func (s *fakeUserAuthTokenService) UserSignedOutHook(c *m.ReqContext) {}
func (s *fakeUserAuthTokenService) SignOutUser(c *m.ReqContext) error { return nil }
+16 -3
View File
@@ -3,6 +3,7 @@ package auth
import (
"crypto/sha256"
"encoding/hex"
"errors"
"net/http"
"net/url"
"time"
@@ -31,7 +32,7 @@ var (
type UserAuthTokenService interface {
InitContextWithToken(ctx *models.ReqContext, orgID int64) bool
UserAuthenticatedHook(user *models.User, c *models.ReqContext) error
UserSignedOutHook(c *models.ReqContext)
SignOutUser(c *models.ReqContext) error
}
type UserAuthTokenServiceImpl struct {
@@ -85,7 +86,7 @@ func (s *UserAuthTokenServiceImpl) InitContextWithToken(ctx *models.ReqContext,
func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, value string, maxAge int) {
if setting.Env == setting.DEV {
ctx.Logger.Info("new token", "unhashed token", value)
ctx.Logger.Debug("new token", "unhashed token", value)
}
ctx.Resp.Header().Del("Set-Cookie")
@@ -96,6 +97,7 @@ func (s *UserAuthTokenServiceImpl) writeSessionCookie(ctx *models.ReqContext, va
Path: setting.AppSubUrl + "/",
Secure: s.Cfg.SecurityHTTPSCookies,
MaxAge: maxAge,
SameSite: s.Cfg.LoginCookieSameSite,
}
http.SetCookie(ctx.Resp, &cookie)
@@ -111,8 +113,19 @@ func (s *UserAuthTokenServiceImpl) UserAuthenticatedHook(user *models.User, c *m
return nil
}
func (s *UserAuthTokenServiceImpl) UserSignedOutHook(c *models.ReqContext) {
func (s *UserAuthTokenServiceImpl) SignOutUser(c *models.ReqContext) error {
unhashedToken := c.GetCookie(s.Cfg.LoginCookieName)
if unhashedToken == "" {
return errors.New("cannot logout without session token")
}
hashedToken := hashToken(unhashedToken)
sql := `DELETE FROM user_auth_token WHERE auth_token = ?`
_, err := s.SQLStore.NewSession().Exec(sql, hashedToken)
s.writeSessionCookie(c, "", -1)
return err
}
func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent string) (*userAuthToken, error) {
+39
View File
@@ -1,10 +1,15 @@
package auth
import (
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/setting"
macaron "gopkg.in/macaron.v1"
"github.com/grafana/grafana/pkg/log"
"github.com/grafana/grafana/pkg/services/sqlstore"
@@ -46,6 +51,40 @@ func TestUserAuthToken(t *testing.T) {
So(err, ShouldEqual, ErrAuthTokenNotFound)
So(LookupToken, ShouldBeNil)
})
Convey("signing out should delete token and cookie if present", func() {
httpreq := &http.Request{Header: make(http.Header)}
httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: token.UnhashedToken})
ctx := &models.ReqContext{Context: &macaron.Context{
Req: macaron.Request{Request: httpreq},
Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
},
Logger: log.New("fakelogger"),
}
err = userAuthTokenService.SignOutUser(ctx)
So(err, ShouldBeNil)
// makes sure we tell the browser to overwrite the cookie
cookieHeader := fmt.Sprintf("%s=; Path=/; Max-Age=0; HttpOnly", userAuthTokenService.Cfg.LoginCookieName)
So(ctx.Resp.Header().Get("Set-Cookie"), ShouldEqual, cookieHeader)
})
Convey("signing out an none existing session should return an error", func() {
httpreq := &http.Request{Header: make(http.Header)}
httpreq.AddCookie(&http.Cookie{Name: userAuthTokenService.Cfg.LoginCookieName, Value: ""})
ctx := &models.ReqContext{Context: &macaron.Context{
Req: macaron.Request{Request: httpreq},
Resp: macaron.NewResponseWriter("POST", httptest.NewRecorder()),
},
Logger: log.New("fakelogger"),
}
err = userAuthTokenService.SignOutUser(ctx)
So(err, ShouldNotBeNil)
})
})
Convey("expires correctly", func() {
+1 -4
View File
@@ -242,10 +242,7 @@ func (ss *SqlStore) buildConnectionString() (string, error) {
cnnstr += ss.buildExtraConnectionString('&')
case migrator.POSTGRES:
host, port, err := util.SplitIPPort(ss.dbCfg.Host, "5432")
if err != nil {
return "", err
}
host, port := util.SplitHostPortDefault(ss.dbCfg.Host, "127.0.0.1", "5432")
if ss.dbCfg.Pwd == "" {
ss.dbCfg.Pwd = "''"
}
+16
View File
@@ -6,6 +6,7 @@ package setting
import (
"bytes"
"fmt"
"net/http"
"net/url"
"os"
"path"
@@ -227,6 +228,7 @@ type Cfg struct {
LoginCookieMaxDays int
LoginCookieRotation int
LoginDeleteExpiredTokensAfterDays int
LoginCookieSameSite http.SameSite
SecurityHTTPSCookies bool
}
@@ -557,6 +559,20 @@ func (cfg *Cfg) Load(args *CommandLineArgs) error {
cfg.LoginCookieName = login.Key("cookie_name").MustString("grafana_session")
cfg.LoginCookieMaxDays = login.Key("login_remember_days").MustInt(7)
cfg.LoginDeleteExpiredTokensAfterDays = login.Key("delete_expired_token_after_days").MustInt(30)
samesiteString := login.Key("cookie_samesite").MustString("lax")
validSameSiteValues := map[string]http.SameSite{
"lax": http.SameSiteLaxMode,
"strict": http.SameSiteStrictMode,
"none": http.SameSiteDefaultMode,
}
if samesite, ok := validSameSiteValues[samesiteString]; ok {
cfg.LoginCookieSameSite = samesite
} else {
cfg.LoginCookieSameSite = http.SameSiteLaxMode
}
cfg.LoginCookieRotation = login.Key("rotate_token_minutes").MustInt(10)
if cfg.LoginCookieRotation < 2 {
cfg.LoginCookieRotation = 2
+2
View File
@@ -95,6 +95,7 @@ func init() {
"AWS/Logs": {"IncomingBytes", "IncomingLogEvents", "ForwardedBytes", "ForwardedLogEvents", "DeliveryErrors", "DeliveryThrottling"},
"AWS/ML": {"PredictCount", "PredictFailureCount"},
"AWS/NATGateway": {"PacketsOutToDestination", "PacketsOutToSource", "PacketsInFromSource", "PacketsInFromDestination", "BytesOutToDestination", "BytesOutToSource", "BytesInFromSource", "BytesInFromDestination", "ErrorPortAllocation", "ActiveConnectionCount", "ConnectionAttemptCount", "ConnectionEstablishedCount", "IdleTimeoutCount", "PacketsDropCount"},
"AWS/Neptune": {"CPUUtilization", "ClusterReplicaLag", "ClusterReplicaLagMaximum", "ClusterReplicaLagMinimum", "EngineUptime", "FreeableMemory", "FreeLocalStorage", "GremlinHttp1xx", "GremlinHttp2xx", "GremlinHttp4xx", "GremlinHttp5xx", "GremlinErrors", "GremlinRequests", "GremlinRequestsPerSec", "GremlinWebSocketSuccess", "GremlinWebSocketClientErrors", "GremlinWebSocketServerErrors", "GremlinWebSocketAvailableConnections", "Http1xx", "Http2xx", "Http4xx", "Http5xx", "Http100", "Http101", "Http200", "Http400", "Http403", "Http405", "Http413", "Http429", "Http500", "Http501", "LoaderErrors", "LoaderRequests", "NetworkReceiveThroughput", "NetworkThroughput", "NetworkTransmitThroughput", "SparqlHttp1xx", "SparqlHttp2xx", "SparqlHttp4xx", "SparqlHttp5xx", "SparqlErrors", "SparqlRequests", "SparqlRequestsPerSec", "StatusErrors", "StatusRequests", "VolumeBytesUsed", "VolumeReadIOPs", "VolumeWriteIOPs"},
"AWS/NetworkELB": {"ActiveFlowCount", "ConsumedLCUs", "HealthyHostCount", "NewFlowCount", "ProcessedBytes", "TCP_Client_Reset_Count", "TCP_ELB_Reset_Count", "TCP_Target_Reset_Count", "UnHealthyHostCount"},
"AWS/OpsWorks": {"cpu_idle", "cpu_nice", "cpu_system", "cpu_user", "cpu_waitio", "load_1", "load_5", "load_15", "memory_buffers", "memory_cached", "memory_free", "memory_swap", "memory_total", "memory_used", "procs"},
"AWS/Redshift": {"CPUUtilization", "DatabaseConnections", "HealthStatus", "MaintenanceMode", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "PercentageDiskSpaceUsed", "QueriesCompletedPerSecond", "QueryDuration", "QueryRuntimeBreakdown", "ReadIOPS", "ReadLatency", "ReadThroughput", "WLMQueriesCompletedPerSecond", "WLMQueryDuration", "WLMQueueLength", "WriteIOPS", "WriteLatency", "WriteThroughput"},
@@ -149,6 +150,7 @@ func init() {
"AWS/Logs": {"LogGroupName", "DestinationType", "FilterName"},
"AWS/ML": {"MLModelId", "RequestMode"},
"AWS/NATGateway": {"NatGatewayId"},
"AWS/Neptune": {"DBClusterIdentifier", "Role", "DatabaseClass", "EngineName"},
"AWS/NetworkELB": {"LoadBalancer", "TargetGroup", "AvailabilityZone"},
"AWS/OpsWorks": {"StackId", "LayerId", "InstanceId"},
"AWS/Redshift": {"NodeID", "ClusterIdentifier", "latency", "service class", "wmlid"},
+1 -4
View File
@@ -49,10 +49,7 @@ func generateConnectionString(datasource *models.DataSource) (string, error) {
}
}
server, port, err := util.SplitIPPort(datasource.Url, "1433")
if err != nil {
return "", err
}
server, port := util.SplitHostPortDefault(datasource.Url, "localhost", "1433")
encrypt := datasource.JsonData.Get("encrypt").MustString("false")
connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
-25
View File
@@ -1,25 +0,0 @@
package util
import (
"net"
)
// SplitIPPort splits the ip string and port.
func SplitIPPort(ipStr string, portDefault string) (ip string, port string, err error) {
ipAddr := net.ParseIP(ipStr)
if ipAddr == nil {
// Port was included
ip, port, err = net.SplitHostPort(ipStr)
if err != nil {
return "", "", err
}
} else {
// No port was included
ip = ipAddr.String()
port = portDefault
}
return ip, port, nil
}
+36 -11
View File
@@ -7,23 +7,48 @@ import (
// ParseIPAddress parses an IP address and removes port and/or IPV6 format
func ParseIPAddress(input string) string {
s := input
lastIndex := strings.LastIndex(input, ":")
host, _ := SplitHostPort(input)
if lastIndex != -1 {
if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" {
s = input[:lastIndex]
}
ip := net.ParseIP(host)
if ip == nil {
return host
}
s = strings.Replace(s, "[", "", -1)
s = strings.Replace(s, "]", "", -1)
ip := net.ParseIP(s)
if ip.IsLoopback() {
return "127.0.0.1"
}
return ip.String()
}
// SplitHostPortDefault splits ip address/hostname string by host and port. Defaults used if no match found
func SplitHostPortDefault(input, defaultHost, defaultPort string) (host string, port string) {
port = defaultPort
s := input
lastIndex := strings.LastIndex(input, ":")
if lastIndex != -1 {
if lastIndex > 0 && input[lastIndex-1:lastIndex] != ":" {
s = input[:lastIndex]
port = input[lastIndex+1:]
} else if lastIndex == 0 {
s = defaultHost
port = input[lastIndex+1:]
}
} else {
port = defaultPort
}
s = strings.Replace(s, "[", "", -1)
s = strings.Replace(s, "]", "", -1)
port = strings.Replace(port, "[", "", -1)
port = strings.Replace(port, "]", "", -1)
return s, port
}
// SplitHostPort splits ip address/hostname string by host and port
func SplitHostPort(input string) (host string, port string) {
return SplitHostPortDefault(input, "", "")
}
+83 -1
View File
@@ -9,8 +9,90 @@ import (
func TestParseIPAddress(t *testing.T) {
Convey("Test parse ip address", t, func() {
So(ParseIPAddress("192.168.0.140:456"), ShouldEqual, "192.168.0.140")
So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140")
So(ParseIPAddress("[::1:456]"), ShouldEqual, "127.0.0.1")
So(ParseIPAddress("[::1]"), ShouldEqual, "127.0.0.1")
So(ParseIPAddress("192.168.0.140"), ShouldEqual, "192.168.0.140")
So(ParseIPAddress("::1"), ShouldEqual, "127.0.0.1")
So(ParseIPAddress("::1:123"), ShouldEqual, "127.0.0.1")
})
}
func TestSplitHostPortDefault(t *testing.T) {
Convey("Test split ip address to host and port", t, func() {
host, port := SplitHostPortDefault("192.168.0.140:456", "", "")
So(host, ShouldEqual, "192.168.0.140")
So(port, ShouldEqual, "456")
host, port = SplitHostPortDefault("192.168.0.140", "", "123")
So(host, ShouldEqual, "192.168.0.140")
So(port, ShouldEqual, "123")
host, port = SplitHostPortDefault("[::1:456]", "", "")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "456")
host, port = SplitHostPortDefault("[::1]", "", "123")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "123")
host, port = SplitHostPortDefault("::1:123", "", "")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "123")
host, port = SplitHostPortDefault("::1", "", "123")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "123")
host, port = SplitHostPortDefault(":456", "1.2.3.4", "")
So(host, ShouldEqual, "1.2.3.4")
So(port, ShouldEqual, "456")
host, port = SplitHostPortDefault("xyz.rds.amazonaws.com", "", "123")
So(host, ShouldEqual, "xyz.rds.amazonaws.com")
So(port, ShouldEqual, "123")
host, port = SplitHostPortDefault("xyz.rds.amazonaws.com:123", "", "")
So(host, ShouldEqual, "xyz.rds.amazonaws.com")
So(port, ShouldEqual, "123")
})
}
func TestSplitHostPort(t *testing.T) {
Convey("Test split ip address to host and port", t, func() {
host, port := SplitHostPort("192.168.0.140:456")
So(host, ShouldEqual, "192.168.0.140")
So(port, ShouldEqual, "456")
host, port = SplitHostPort("192.168.0.140")
So(host, ShouldEqual, "192.168.0.140")
So(port, ShouldEqual, "")
host, port = SplitHostPort("[::1:456]")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "456")
host, port = SplitHostPort("[::1]")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "")
host, port = SplitHostPort("::1:123")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "123")
host, port = SplitHostPort("::1")
So(host, ShouldEqual, "::1")
So(port, ShouldEqual, "")
host, port = SplitHostPort(":456")
So(host, ShouldEqual, "")
So(port, ShouldEqual, "456")
host, port = SplitHostPort("xyz.rds.amazonaws.com")
So(host, ShouldEqual, "xyz.rds.amazonaws.com")
So(port, ShouldEqual, "")
host, port = SplitHostPort("xyz.rds.amazonaws.com:123")
So(host, ShouldEqual, "xyz.rds.amazonaws.com")
So(port, ShouldEqual, "123")
})
}
-43
View File
@@ -1,43 +0,0 @@
package util
import (
"testing"
. "github.com/smartystreets/goconvey/convey"
)
func TestSplitIPPort(t *testing.T) {
Convey("When parsing an IPv4 without explicit port", t, func() {
ip, port, err := SplitIPPort("1.2.3.4", "5678")
So(err, ShouldEqual, nil)
So(ip, ShouldEqual, "1.2.3.4")
So(port, ShouldEqual, "5678")
})
Convey("When parsing an IPv6 without explicit port", t, func() {
ip, port, err := SplitIPPort("::1", "5678")
So(err, ShouldEqual, nil)
So(ip, ShouldEqual, "::1")
So(port, ShouldEqual, "5678")
})
Convey("When parsing an IPv4 with explicit port", t, func() {
ip, port, err := SplitIPPort("1.2.3.4:56", "78")
So(err, ShouldEqual, nil)
So(ip, ShouldEqual, "1.2.3.4")
So(port, ShouldEqual, "56")
})
Convey("When parsing an IPv6 with explicit port", t, func() {
ip, port, err := SplitIPPort("[::1]:56", "78")
So(err, ShouldEqual, nil)
So(ip, ShouldEqual, "::1")
So(port, ShouldEqual, "56")
})
}
@@ -1,8 +1,7 @@
import React from 'react';
import _ from 'lodash';
import { Select } from '@grafana/ui';
import { SelectOptionItem } from '@grafana/ui';
import { Select, SelectOptionItem } from '@grafana/ui';
import { Variable } from 'app/types/templates';
export interface Props {
+2 -3
View File
@@ -1,7 +1,6 @@
import _ from 'lodash';
import { colors } from '@grafana/ui';
import { TimeSeries } from 'app/core/core';
import { colors, TimeSeries } from '@grafana/ui';
import { getThemeColor } from 'app/core/utils/colors';
/**
@@ -341,6 +340,6 @@ export function makeSeriesForLogs(rows: LogRowModel[], intervalMs: number): Time
return a[1] - b[1];
});
return new TimeSeries(series);
return { datapoints: series.datapoints, target: series.alias, color: series.color };
});
}
+1 -102
View File
@@ -1,106 +1,20 @@
import $ from 'jquery';
import angular from 'angular';
export class Profiler {
panelsRendered: number;
enabled: boolean;
panelsInitCount: any;
timings: any;
digestCounter: any;
$rootScope: any;
scopeCount: any;
window: any;
init(config, $rootScope) {
this.enabled = config.buildInfo.env === 'development';
this.timings = {};
this.timings.appStart = { loadStart: new Date().getTime() };
this.$rootScope = $rootScope;
this.window = window;
if (!this.enabled) {
return;
}
$rootScope.$watch(
() => {
this.digestCounter++;
return false;
},
() => {}
);
$rootScope.onAppEvent('refresh', this.refresh.bind(this), $rootScope);
$rootScope.onAppEvent('dashboard-fetch-end', this.dashboardFetched.bind(this), $rootScope);
$rootScope.onAppEvent('dashboard-initialized', this.dashboardInitialized.bind(this), $rootScope);
$rootScope.onAppEvent('panel-initialized', this.panelInitialized.bind(this), $rootScope);
}
refresh() {
this.timings.query = 0;
this.timings.render = 0;
setTimeout(() => {
console.log('panel count: ' + this.panelsInitCount);
console.log('total query: ' + this.timings.query);
console.log('total render: ' + this.timings.render);
console.log('avg render: ' + this.timings.render / this.panelsInitCount);
}, 5000);
}
dashboardFetched() {
this.timings.dashboardLoadStart = new Date().getTime();
this.panelsInitCount = 0;
this.digestCounter = 0;
this.panelsInitCount = 0;
this.panelsRendered = 0;
this.timings.query = 0;
this.timings.render = 0;
}
dashboardInitialized() {
setTimeout(() => {
console.log('Dashboard::Performance Total Digests: ' + this.digestCounter);
console.log('Dashboard::Performance Total Watchers: ' + this.getTotalWatcherCount());
console.log('Dashboard::Performance Total ScopeCount: ' + this.scopeCount);
const timeTaken = this.timings.lastPanelInitializedAt - this.timings.dashboardLoadStart;
console.log('Dashboard::Performance All panels initialized in ' + timeTaken + ' ms');
// measure digest performance
const rootDigestStart = window.performance.now();
for (let i = 0; i < 30; i++) {
this.$rootScope.$apply();
}
console.log('Dashboard::Performance Root Digest ' + (window.performance.now() - rootDigestStart) / 30);
}, 3000);
}
getTotalWatcherCount() {
let count = 0;
let scopes = 0;
const root = $(document.getElementsByTagName('body'));
const f = element => {
if (element.data().hasOwnProperty('$scope')) {
scopes++;
angular.forEach(element.data().$scope.$$watchers, () => {
count++;
});
}
angular.forEach(element.children(), childElement => {
f($(childElement));
});
};
f(root);
this.scopeCount = scopes;
return count;
}
renderingCompleted(panelId, panelTimings) {
renderingCompleted(panelId) {
// add render counter to root scope
// used by phantomjs render.js to know when panel has rendered
this.panelsRendered = (this.panelsRendered || 0) + 1;
@@ -108,21 +22,6 @@ export class Profiler {
// this window variable is used by backend rendering tools to know
// all panels have completed rendering
this.window.panelsRendered = this.panelsRendered;
if (this.enabled) {
panelTimings.renderEnd = new Date().getTime();
this.timings.query += panelTimings.queryEnd - panelTimings.queryStart;
this.timings.render += panelTimings.renderEnd - panelTimings.renderStart;
}
}
panelInitialized() {
if (!this.enabled) {
return;
}
this.panelsInitCount++;
this.timings.lastPanelInitializedAt = new Date().getTime();
}
}
@@ -0,0 +1,83 @@
import {
actionCreatorFactory,
resetAllActionCreatorTypes,
noPayloadActionCreatorFactory,
} from './actionCreatorFactory';
interface Dummy {
n: number;
s: string;
o: {
n: number;
s: string;
b: boolean;
};
b: boolean;
}
const setup = (payload?: Dummy) => {
resetAllActionCreatorTypes();
const actionCreator = actionCreatorFactory<Dummy>('dummy').create();
const noPayloadactionCreator = noPayloadActionCreatorFactory('NoPayload').create();
const result = actionCreator(payload);
const noPayloadResult = noPayloadactionCreator();
return { actionCreator, noPayloadactionCreator, result, noPayloadResult };
};
describe('actionCreatorFactory', () => {
describe('when calling create', () => {
it('then it should create correct type string', () => {
const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
const { actionCreator, result } = setup(payload);
expect(actionCreator.type).toEqual('dummy');
expect(result.type).toEqual('dummy');
});
it('then it should create correct payload', () => {
const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
const { result } = setup(payload);
expect(result.payload).toEqual(payload);
});
});
describe('when calling create with existing type', () => {
it('then it should throw error', () => {
const payload = { n: 1, b: true, s: 'dummy', o: { n: 1, b: true, s: 'dummy' } };
setup(payload);
expect(() => {
noPayloadActionCreatorFactory('DuMmY').create();
}).toThrow();
});
});
});
describe('noPayloadActionCreatorFactory', () => {
describe('when calling create', () => {
it('then it should create correct type string', () => {
const { noPayloadResult, noPayloadactionCreator } = setup();
expect(noPayloadactionCreator.type).toEqual('NoPayload');
expect(noPayloadResult.type).toEqual('NoPayload');
});
it('then it should create correct payload', () => {
const { noPayloadResult } = setup();
expect(noPayloadResult.payload).toBeUndefined();
});
});
describe('when calling create with existing type', () => {
it('then it should throw error', () => {
setup();
expect(() => {
actionCreatorFactory<Dummy>('nOpAyLoAd').create();
}).toThrow();
});
});
});
@@ -0,0 +1,57 @@
import { Action } from 'redux';
const allActionCreators: string[] = [];
export interface ActionOf<Payload> extends Action {
readonly type: string;
readonly payload: Payload;
}
export interface ActionCreator<Payload> {
readonly type: string;
(payload: Payload): ActionOf<Payload>;
}
export interface NoPayloadActionCreator {
readonly type: string;
(): ActionOf<undefined>;
}
export interface ActionCreatorFactory<Payload> {
create: () => ActionCreator<Payload>;
}
export interface NoPayloadActionCreatorFactory {
create: () => NoPayloadActionCreator;
}
export const actionCreatorFactory = <Payload>(type: string): ActionCreatorFactory<Payload> => {
const create = (): ActionCreator<Payload> => {
return Object.assign((payload: Payload): ActionOf<Payload> => ({ type, payload }), { type });
};
if (allActionCreators.some(t => (t && type ? t.toLocaleUpperCase() === type.toLocaleUpperCase() : false))) {
throw new Error(`There is already an actionCreator defined with the type ${type}`);
}
allActionCreators.push(type);
return { create };
};
export const noPayloadActionCreatorFactory = (type: string): NoPayloadActionCreatorFactory => {
const create = (): NoPayloadActionCreator => {
return Object.assign((): ActionOf<undefined> => ({ type, payload: undefined }), { type });
};
if (allActionCreators.some(t => (t && type ? t.toLocaleUpperCase() === type.toLocaleUpperCase() : false))) {
throw new Error(`There is already an actionCreator defined with the type ${type}`);
}
allActionCreators.push(type);
return { create };
};
// Should only be used by tests
export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);
+4
View File
@@ -0,0 +1,4 @@
import { actionCreatorFactory } from './actionCreatorFactory';
import { reducerFactory } from './reducerFactory';
export { actionCreatorFactory, reducerFactory };
@@ -0,0 +1,97 @@
import { reducerFactory } from './reducerFactory';
import { actionCreatorFactory, ActionOf } from './actionCreatorFactory';
interface DummyReducerState {
n: number;
s: string;
b: boolean;
o: {
n: number;
s: string;
b: boolean;
};
}
const dummyReducerIntialState: DummyReducerState = {
n: 1,
s: 'One',
b: true,
o: {
n: 2,
s: 'two',
b: false,
},
};
const dummyActionCreator = actionCreatorFactory<DummyReducerState>('dummy').create();
const dummyReducer = reducerFactory(dummyReducerIntialState)
.addMapper({
filter: dummyActionCreator,
mapper: (state, action) => ({ ...state, ...action.payload }),
})
.create();
describe('reducerFactory', () => {
describe('given it is created with a defined handler', () => {
describe('when reducer is called with no state', () => {
describe('and with an action that the handler can not handle', () => {
it('then the resulting state should be intial state', () => {
const result = dummyReducer(undefined as DummyReducerState, {} as ActionOf<any>);
expect(result).toEqual(dummyReducerIntialState);
});
});
describe('and with an action that the handler can handle', () => {
it('then the resulting state should correct', () => {
const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
const result = dummyReducer(undefined as DummyReducerState, dummyActionCreator(payload));
expect(result).toEqual(payload);
});
});
});
describe('when reducer is called with a state', () => {
describe('and with an action that the handler can not handle', () => {
it('then the resulting state should be intial state', () => {
const result = dummyReducer(dummyReducerIntialState, {} as ActionOf<any>);
expect(result).toEqual(dummyReducerIntialState);
});
});
describe('and with an action that the handler can handle', () => {
it('then the resulting state should correct', () => {
const payload = { n: 10, s: 'ten', b: false, o: { n: 20, s: 'twenty', b: true } };
const result = dummyReducer(dummyReducerIntialState, dummyActionCreator(payload));
expect(result).toEqual(payload);
});
});
});
});
describe('given a handler is added', () => {
describe('when a handler with the same creator is added', () => {
it('then is should throw', () => {
const faultyReducer = reducerFactory(dummyReducerIntialState).addMapper({
filter: dummyActionCreator,
mapper: (state, action) => {
return { ...state, ...action.payload };
},
});
expect(() => {
faultyReducer.addMapper({
filter: dummyActionCreator,
mapper: state => {
return state;
},
});
}).toThrow();
});
});
});
});
+45
View File
@@ -0,0 +1,45 @@
import { ActionOf, ActionCreator } from './actionCreatorFactory';
import { Reducer } from 'redux';
export type Mapper<State, Payload> = (state: State, action: ActionOf<Payload>) => State;
export interface MapperConfig<State, Payload> {
filter: ActionCreator<Payload>;
mapper: Mapper<State, Payload>;
}
export interface AddMapper<State> {
addMapper: <Payload>(config: MapperConfig<State, Payload>) => CreateReducer<State>;
}
export interface CreateReducer<State> extends AddMapper<State> {
create: () => Reducer<State, ActionOf<any>>;
}
export const reducerFactory = <State>(initialState: State): AddMapper<State> => {
const allMappers: { [key: string]: Mapper<State, any> } = {};
const addMapper = <Payload>(config: MapperConfig<State, Payload>): CreateReducer<State> => {
if (allMappers[config.filter.type]) {
throw new Error(`There is already a mapper defined with the type ${config.filter.type}`);
}
allMappers[config.filter.type] = config.mapper;
return instance;
};
const create = (): Reducer<State, ActionOf<any>> => (state: State = initialState, action: ActionOf<any>): State => {
const mapper = allMappers[action.type];
if (mapper) {
return mapper(state, action);
}
return state;
};
const instance: CreateReducer<State> = { addMapper, create };
return instance;
};
+26 -11
View File
@@ -1,6 +1,7 @@
import _ from 'lodash';
import coreModule from 'app/core/core_module';
import appEvents from 'app/core/app_events';
import config from 'app/core/config';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
export class BackendSrv {
@@ -103,10 +104,17 @@ export class BackendSrv {
err => {
// handle unauthorized
if (err.status === 401 && this.contextSrv.user.isSignedIn && firstAttempt) {
return this.loginPing().then(() => {
options.retry = 1;
return this.request(options);
});
return this.loginPing()
.then(() => {
options.retry = 1;
return this.request(options);
})
.catch(err => {
if (err.status === 401) {
window.location.href = config.appSubUrl + '/logout';
throw err;
}
});
}
this.$timeout(this.requestErrorHandler.bind(this, err), 50);
@@ -184,13 +192,20 @@ export class BackendSrv {
// handle unauthorized for backend requests
if (requestIsLocal && firstAttempt && err.status === 401) {
return this.loginPing().then(() => {
options.retry = 1;
if (canceler) {
canceler.resolve();
}
return this.datasourceRequest(options);
});
return this.loginPing()
.then(() => {
options.retry = 1;
if (canceler) {
canceler.resolve();
}
return this.datasourceRequest(options);
})
.catch(err => {
if (err.status === 401) {
window.location.href = config.appSubUrl + '/logout';
throw err;
}
});
}
// populate error obj on Internal Error
+1 -1
View File
@@ -249,7 +249,7 @@ export class KeybindingSrv {
if (panelInfo.panel.legend) {
const panelRef = dashboard.getPanelById(dashboard.meta.focusPanelId);
panelRef.legend.show = !panelRef.legend.show;
panelRef.refresh();
panelRef.render();
}
}
});
+30 -2
View File
@@ -13,6 +13,11 @@ const DEFAULT_EXPLORE_STATE: ExploreUrlState = {
datasource: null,
queries: [],
range: DEFAULT_RANGE,
ui: {
showingGraph: true,
showingTable: true,
showingLogs: true,
}
};
describe('state functions', () => {
@@ -69,9 +74,11 @@ describe('state functions', () => {
to: 'now',
},
};
expect(serializeStateToUrlParam(state)).toBe(
'{"datasource":"foo","queries":[{"expr":"metric{test=\\"a/b\\"}"},' +
'{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"}}'
'{"expr":"super{foo=\\"x/z\\"}"}],"range":{"from":"now-5h","to":"now"},' +
'"ui":{"showingGraph":true,"showingTable":true,"showingLogs":true}}'
);
});
@@ -93,7 +100,7 @@ describe('state functions', () => {
},
};
expect(serializeStateToUrlParam(state, true)).toBe(
'["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"}]'
'["now-5h","now","foo",{"expr":"metric{test=\\"a/b\\"}"},{"expr":"super{foo=\\"x/z\\"}"},{"ui":[true,true,true]}]'
);
});
});
@@ -118,7 +125,28 @@ describe('state functions', () => {
};
const serialized = serializeStateToUrlParam(state);
const parsed = parseUrlState(serialized);
expect(state).toMatchObject(parsed);
});
it('can parse the compact serialized state into the original state', () => {
const state = {
...DEFAULT_EXPLORE_STATE,
datasource: 'foo',
queries: [
{
expr: 'metric{test="a/b"}',
},
{
expr: 'super{foo="x/z"}',
},
],
range: {
from: 'now - 5h',
to: 'now',
},
};
const serialized = serializeStateToUrlParam(state, true);
const parsed = parseUrlState(serialized);
expect(state).toMatchObject(parsed);
});
});
+36 -4
View File
@@ -27,6 +27,12 @@ export const DEFAULT_RANGE = {
to: 'now',
};
export const DEFAULT_UI_STATE = {
showingTable: true,
showingGraph: true,
showingLogs: true,
};
const MAX_HISTORY_ITEMS = 100;
export const LAST_USED_DATASOURCE_KEY = 'grafana.explore.datasource';
@@ -147,7 +153,12 @@ export function buildQueryTransaction(
export const clearQueryKeys: ((query: DataQuery) => object) = ({ key, refId, ...rest }) => rest;
const isMetricSegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('expr');
const isUISegment = (segment: { [key: string]: string }) => segment.hasOwnProperty('ui');
export function parseUrlState(initial: string | undefined): ExploreUrlState {
let uiState = DEFAULT_UI_STATE;
if (initial) {
try {
const parsed = JSON.parse(decodeURI(initial));
@@ -160,20 +171,41 @@ export function parseUrlState(initial: string | undefined): ExploreUrlState {
to: parsed[1],
};
const datasource = parsed[2];
const queries = parsed.slice(3);
return { datasource, queries, range };
let queries = [];
parsed.slice(3).forEach(segment => {
if (isMetricSegment(segment)) {
queries = [...queries, segment];
}
if (isUISegment(segment)) {
uiState = {
showingGraph: segment.ui[0],
showingLogs: segment.ui[1],
showingTable: segment.ui[2],
};
}
});
return { datasource, queries, range, ui: uiState };
}
return parsed;
} catch (e) {
console.error(e);
}
}
return { datasource: null, queries: [], range: DEFAULT_RANGE };
return { datasource: null, queries: [], range: DEFAULT_RANGE, ui: uiState };
}
export function serializeStateToUrlParam(urlState: ExploreUrlState, compact?: boolean): string {
if (compact) {
return JSON.stringify([urlState.range.from, urlState.range.to, urlState.datasource, ...urlState.queries]);
return JSON.stringify([
urlState.range.from,
urlState.range.to,
urlState.datasource,
...urlState.queries,
{ ui: [!!urlState.ui.showingGraph, !!urlState.ui.showingLogs, !!urlState.ui.showingTable] },
]);
}
return JSON.stringify(urlState);
}
@@ -18,13 +18,18 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
collapsed: this.props.panel.collapsed,
};
this.toggle = this.toggle.bind(this);
this.openSettings = this.openSettings.bind(this);
this.delete = this.delete.bind(this);
this.update = this.update.bind(this);
appEvents.on('template-variable-value-updated', this.onVariableUpdated);
}
toggle() {
componentWillUnmount() {
appEvents.off('template-variable-value-updated', this.onVariableUpdated);
}
onVariableUpdated = () => {
this.forceUpdate();
}
onToggle = () => {
this.props.dashboard.toggleRow(this.props.panel);
this.setState(prevState => {
@@ -32,23 +37,23 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
});
}
update() {
onUpdate = () => {
this.props.dashboard.processRepeats();
this.forceUpdate();
}
openSettings() {
onOpenSettings = () => {
appEvents.emit('show-modal', {
templateHtml: `<row-options row="model.row" on-updated="model.onUpdated()" dismiss="dismiss()"></row-options>`,
modalClass: 'modal--narrow',
model: {
row: this.props.panel,
onUpdated: this.update.bind(this),
onUpdated: this.onUpdate,
},
});
}
delete() {
onDelete = () => {
appEvents.emit('confirm-modal', {
title: 'Delete Row',
text: 'Are you sure you want to remove this row and all its panels?',
@@ -81,7 +86,7 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
return (
<div className={classes}>
<a className="dashboard-row__title pointer" onClick={this.toggle}>
<a className="dashboard-row__title pointer" onClick={this.onToggle}>
<i className={chevronClass} />
{title}
<span className="dashboard-row__panel_count">
@@ -90,16 +95,16 @@ export class DashboardRow extends React.Component<DashboardRowProps, any> {
</a>
{canEdit && (
<div className="dashboard-row__actions">
<a className="pointer" onClick={this.openSettings}>
<a className="pointer" onClick={this.onOpenSettings}>
<i className="fa fa-cog" />
</a>
<a className="pointer" onClick={this.delete}>
<a className="pointer" onClick={this.onDelete}>
<i className="fa fa-trash" />
</a>
</div>
)}
{this.state.collapsed === true && (
<div className="dashboard-row__toggle-target" onClick={this.toggle}>
<div className="dashboard-row__toggle-target" onClick={this.onToggle}>
&nbsp;
</div>
)}
@@ -1,2 +1,3 @@
export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl';
export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl';
export { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl';
@@ -0,0 +1,123 @@
// Libraries
import React, { Component } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
// Utils & Services
import appEvents from 'app/core/app_events';
import locationUtil from 'app/core/utils/location_util';
import { getBackendSrv } from 'app/core/services/backend_srv';
// Components
import { DashboardPanel } from '../dashgrid/DashboardPanel';
// Redux
import { updateLocation } from 'app/core/actions';
// Types
import { StoreState } from 'app/types';
import { PanelModel, DashboardModel } from 'app/features/dashboard/state';
interface Props {
panelId: string;
urlUid?: string;
urlSlug?: string;
urlType?: string;
$scope: any;
$injector: any;
updateLocation: typeof updateLocation;
}
interface State {
panel: PanelModel | null;
dashboard: DashboardModel | null;
notFound: boolean;
}
export class SoloPanelPage extends Component<Props, State> {
state: State = {
panel: null,
dashboard: null,
notFound: false,
};
componentDidMount() {
const { $injector, $scope, urlUid, urlType, urlSlug } = this.props;
// handle old urls with no uid
if (!urlUid && !(urlType === 'script' || urlType === 'snapshot')) {
this.redirectToNewUrl();
return;
}
const dashboardLoaderSrv = $injector.get('dashboardLoaderSrv');
// subscribe to event to know when dashboard controller is done with inititalization
appEvents.on('dashboard-initialized', this.onDashoardInitialized);
dashboardLoaderSrv.loadDashboard(urlType, urlSlug, urlUid).then(result => {
result.meta.soloMode = true;
$scope.initDashboard(result, $scope);
});
}
redirectToNewUrl() {
getBackendSrv().getDashboardBySlug(this.props.urlSlug).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
this.props.updateLocation(url);
}
});
}
onDashoardInitialized = () => {
const { $scope, panelId } = this.props;
const dashboard: DashboardModel = $scope.dashboard;
const panel = dashboard.getPanelById(parseInt(panelId, 10));
if (!panel) {
this.setState({ notFound: true });
return;
}
this.setState({ dashboard, panel });
};
render() {
const { panelId } = this.props;
const { notFound, panel, dashboard } = this.state;
if (notFound) {
return (
<div className="alert alert-error">
Panel with id { panelId } not found
</div>
);
}
if (!panel) {
return <div>Loading & initializing dashboard</div>;
}
return (
<div className="panel-solo">
<DashboardPanel dashboard={dashboard} panel={panel} isEditing={false} isFullscreen={false} />
</div>
);
}
}
const mapStateToProps = (state: StoreState) => ({
urlUid: state.location.routeParams.uid,
urlSlug: state.location.routeParams.slug,
urlType: state.location.routeParams.type,
panelId: state.location.query.panelId
});
const mapDispatchToProps = {
updateLocation
};
export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(SoloPanelPage));
@@ -135,11 +135,8 @@ export class DataPanel extends Component<Props, State> {
cacheTimeout: null,
};
console.log('Issuing DataPanel query', queryOptions);
const resp = await ds.query(queryOptions);
console.log('Issuing DataPanel query Resp', resp);
if (this.isUnmounted) {
return;
}
@@ -12,11 +12,12 @@ import { DataPanel } from './DataPanel';
// Utils
import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
import { profiler } from 'app/core/profiler';
// Types
import { DashboardModel, PanelModel } from '../state';
import { PanelPlugin } from 'app/types';
import { TimeRange } from '@grafana/ui';
import { TimeRange, LoadingState } from '@grafana/ui';
import variables from 'sass/_variables.scss';
import templateSrv from 'app/features/templating/template_srv';
@@ -93,16 +94,22 @@ export class PanelChrome extends PureComponent<Props, State> {
return !this.props.dashboard.otherPanelInFullscreen(this.props.panel);
}
renderPanel(loading, timeSeries, width, height): JSX.Element {
renderPanel(loading, panelData, width, height): JSX.Element {
const { panel, plugin } = this.props;
const { timeRange, renderCounter } = this.state;
const PanelComponent = plugin.exports.Panel;
// This is only done to increase a counter that is used by backend
// image rendering (phantomjs/headless chrome) to know when to capture image
if (loading === LoadingState.Done) {
profiler.renderingCompleted(panel.id);
}
return (
<div className="panel-content">
<PanelComponent
loading={loading}
timeSeries={timeSeries}
panelData={panelData}
timeRange={timeRange}
options={panel.getOptions(plugin.exports.PanelDefaults)}
width={width - 2 * variables.panelHorizontalPadding}
@@ -151,7 +158,7 @@ export class PanelChrome extends PureComponent<Props, State> {
onDataResponse={this.onDataResponse}
>
{({ loading, panelData }) => {
return this.renderPanel(loading, panelData.timeSeries, width, height);
return this.renderPanel(loading, panelData, width, height);
}}
</DataPanel>
)}
@@ -101,17 +101,6 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
return (
<div className="panel-editor-container__editor">
{
// <div className="panel-editor__close">
// <i className="fa fa-arrow-left" />
// </div>
// <div className="panel-editor-resizer">
// <div className="panel-editor-resizer__handle">
// <div className="panel-editor-resizer__handle-dots" />
// </div>
// </div>
}
<div className="panel-editor-tabs">
{tabs.map(tab => {
return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
@@ -133,7 +133,7 @@ export class QueriesTab extends PureComponent<Props, State> {
return (
<>
<DataSourcePicker datasources={this.datasources} onChange={this.onChangeDataSource} current={currentDS} />
<div className="flex-grow" />
<div className="flex-grow-1" />
{!isAddingMixed && (
<button className="btn navbar-button navbar-button--primary" onClick={this.onAddQueryClick}>
Add Query
@@ -165,6 +165,11 @@ export class QueriesTab extends PureComponent<Props, State> {
this.setState({ isAddingMixed: false });
};
onQueryChange = (query: DataQuery, index) => {
this.props.panel.changeQuery(query, index);
this.forceUpdate();
};
setScrollTop = (event: React.MouseEvent<HTMLElement>) => {
const target = event.target as HTMLElement;
this.setState({ scrollTop: target.scrollTop });
@@ -201,6 +206,7 @@ export class QueriesTab extends PureComponent<Props, State> {
key={query.refId}
panel={panel}
query={query}
onChange={query => this.onQueryChange(query, index)}
onRemoveQuery={this.onRemoveQuery}
onAddQuery={this.onAddQuery}
onMoveQuery={this.onMoveQuery}
@@ -18,6 +18,7 @@ interface Props {
onAddQuery: (query?: DataQuery) => void;
onRemoveQuery: (query: DataQuery) => void;
onMoveQuery: (query: DataQuery, direction: number) => void;
onChange: (query: DataQuery) => void;
dataSourceValue: string | null;
inMixedMode: boolean;
}
@@ -105,17 +106,12 @@ export class QueryEditorRow extends PureComponent<Props, State> {
this.setState({ isCollapsed: !this.state.isCollapsed });
};
onQueryChange = (query: DataQuery) => {
Object.assign(this.props.query, query);
this.onExecuteQuery();
};
onExecuteQuery = () => {
onRunQuery = () => {
this.props.panel.refresh();
};
renderPluginEditor() {
const { query } = this.props;
const { query, onChange } = this.props;
const { datasource } = this.state;
if (datasource.pluginExports.QueryCtrl) {
@@ -128,8 +124,8 @@ export class QueryEditorRow extends PureComponent<Props, State> {
<QueryEditor
query={query}
datasource={datasource}
onQueryChange={this.onQueryChange}
onExecuteQuery={this.onExecuteQuery}
onChange={onChange}
onRunQuery={this.onRunQuery}
/>
);
}
@@ -166,7 +162,7 @@ export class QueryEditorRow extends PureComponent<Props, State> {
onDisableQuery = () => {
this.props.query.hide = !this.props.query.hide;
this.onExecuteQuery();
this.onRunQuery();
this.forceUpdate();
};
@@ -271,7 +271,7 @@ export class DashboardModel {
}
}
getPanelById(id) {
getPanelById(id): PanelModel {
for (const panel of this.panels) {
if (panel.id === id) {
return panel;
@@ -269,6 +269,19 @@ export class PanelModel {
});
}
changeQuery(query: DataQuery, index: number) {
// ensure refId is maintained
query.refId = this.targets[index].refId;
// update query in array
this.targets = this.targets.map((item, itemIndex) => {
if (itemIndex === index) {
return query;
}
return item;
});
}
destroy() {
this.events.emit('panel-teardown');
this.events.removeAllListeners();
@@ -16,12 +16,12 @@ export class DataSourcesListItem extends PureComponent<Props> {
</div>
<div className="card-item-body">
<figure className="card-item-figure">
<img src={dataSource.typeLogoUrl} />
<img src={dataSource.typeLogoUrl} alt={dataSource.name} />
</figure>
<div className="card-item-details">
<div className="card-item-name">
{dataSource.name}
{dataSource.isDefault && <span className="btn btn-secondary btn-mini">default</span>}
{dataSource.isDefault && <span className="btn btn-secondary btn-mini card-item-label">default</span>}
</div>
<div className="card-item-sub-name">{dataSource.url}</div>
</div>
@@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
import { DataSourceSettings } from '@grafana/ui/src/types';
import { LayoutModes } from '../../core/components/LayoutSelector/LayoutSelector';
import { getMockDataSources } from './__mocks__/dataSourcesMocks';
import { setDataSourcesSearchQuery, setDataSourcesLayoutMode } from './state/actions';
const setup = (propOverrides?: object) => {
const props: Props = {
@@ -13,16 +14,16 @@ const setup = (propOverrides?: object) => {
loadDataSources: jest.fn(),
navModel: {
main: {
text: 'Configuration'
text: 'Configuration',
},
node: {
text: 'Data Sources'
}
text: 'Data Sources',
},
} as NavModel,
dataSourcesCount: 0,
searchQuery: '',
setDataSourcesSearchQuery: jest.fn(),
setDataSourcesLayoutMode: jest.fn(),
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
hasFetched: false,
};
@@ -24,6 +24,7 @@ exports[`Render should render component 1`] = `
className="card-item-figure"
>
<img
alt="gdev-cloudwatch"
src="public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png"
/>
</figure>
@@ -5,6 +5,7 @@ import { NavModel } from 'app/types';
import { DataSourceSettings } from '@grafana/ui';
import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
import { setDataSourceName, setIsDefault } from '../state/actions';
const setup = (propOverrides?: object) => {
const props: Props = {
@@ -14,9 +15,9 @@ const setup = (propOverrides?: object) => {
pageId: 1,
deleteDataSource: jest.fn(),
loadDataSource: jest.fn(),
setDataSourceName: jest.fn(),
setDataSourceName,
updateDataSource: jest.fn(),
setIsDefault: jest.fn(),
setIsDefault,
};
Object.assign(props, propOverrides);
+16 -111
View File
@@ -8,131 +8,36 @@ import { UpdateLocationAction } from 'app/core/actions/location';
import { buildNavModel } from './navModel';
import { DataSourceSettings } from '@grafana/ui/src/types';
import { Plugin, StoreState } from 'app/types';
import { actionCreatorFactory } from 'app/core/redux';
import { ActionOf, noPayloadActionCreatorFactory } from 'app/core/redux/actionCreatorFactory';
export enum ActionTypes {
LoadDataSources = 'LOAD_DATA_SOURCES',
LoadDataSourceTypes = 'LOAD_DATA_SOURCE_TYPES',
LoadedDataSourceTypes = 'LOADED_DATA_SOURCE_TYPES',
LoadDataSource = 'LOAD_DATA_SOURCE',
LoadDataSourceMeta = 'LOAD_DATA_SOURCE_META',
SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
SetDataSourceName = 'SET_DATA_SOURCE_NAME',
SetIsDefault = 'SET_IS_DEFAULT',
}
export const dataSourceLoaded = actionCreatorFactory<DataSourceSettings>('LOAD_DATA_SOURCE').create();
interface LoadDataSourcesAction {
type: ActionTypes.LoadDataSources;
payload: DataSourceSettings[];
}
export const dataSourcesLoaded = actionCreatorFactory<DataSourceSettings[]>('LOAD_DATA_SOURCES').create();
interface SetDataSourcesSearchQueryAction {
type: ActionTypes.SetDataSourcesSearchQuery;
payload: string;
}
export const dataSourceMetaLoaded = actionCreatorFactory<Plugin>('LOAD_DATA_SOURCE_META').create();
interface SetDataSourcesLayoutModeAction {
type: ActionTypes.SetDataSourcesLayoutMode;
payload: LayoutMode;
}
export const dataSourceTypesLoad = noPayloadActionCreatorFactory('LOAD_DATA_SOURCE_TYPES').create();
interface LoadDataSourceTypesAction {
type: ActionTypes.LoadDataSourceTypes;
}
export const dataSourceTypesLoaded = actionCreatorFactory<Plugin[]>('LOADED_DATA_SOURCE_TYPES').create();
interface LoadedDataSourceTypesAction {
type: ActionTypes.LoadedDataSourceTypes;
payload: Plugin[];
}
export const setDataSourcesSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCES_SEARCH_QUERY').create();
interface SetDataSourceTypeSearchQueryAction {
type: ActionTypes.SetDataSourceTypeSearchQuery;
payload: string;
}
export const setDataSourcesLayoutMode = actionCreatorFactory<LayoutMode>('SET_DATA_SOURCES_LAYOUT_MODE').create();
interface LoadDataSourceAction {
type: ActionTypes.LoadDataSource;
payload: DataSourceSettings;
}
export const setDataSourceTypeSearchQuery = actionCreatorFactory<string>('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create();
interface LoadDataSourceMetaAction {
type: ActionTypes.LoadDataSourceMeta;
payload: Plugin;
}
export const setDataSourceName = actionCreatorFactory<string>('SET_DATA_SOURCE_NAME').create();
interface SetDataSourceNameAction {
type: ActionTypes.SetDataSourceName;
payload: string;
}
interface SetIsDefaultAction {
type: ActionTypes.SetIsDefault;
payload: boolean;
}
const dataSourcesLoaded = (dataSources: DataSourceSettings[]): LoadDataSourcesAction => ({
type: ActionTypes.LoadDataSources,
payload: dataSources,
});
const dataSourceLoaded = (dataSource: DataSourceSettings): LoadDataSourceAction => ({
type: ActionTypes.LoadDataSource,
payload: dataSource,
});
const dataSourceMetaLoaded = (dataSourceMeta: Plugin): LoadDataSourceMetaAction => ({
type: ActionTypes.LoadDataSourceMeta,
payload: dataSourceMeta,
});
const dataSourceTypesLoad = (): LoadDataSourceTypesAction => ({
type: ActionTypes.LoadDataSourceTypes,
});
const dataSourceTypesLoaded = (dataSourceTypes: Plugin[]): LoadedDataSourceTypesAction => ({
type: ActionTypes.LoadedDataSourceTypes,
payload: dataSourceTypes,
});
export const setDataSourcesSearchQuery = (searchQuery: string): SetDataSourcesSearchQueryAction => ({
type: ActionTypes.SetDataSourcesSearchQuery,
payload: searchQuery,
});
export const setDataSourcesLayoutMode = (layoutMode: LayoutMode): SetDataSourcesLayoutModeAction => ({
type: ActionTypes.SetDataSourcesLayoutMode,
payload: layoutMode,
});
export const setDataSourceTypeSearchQuery = (query: string): SetDataSourceTypeSearchQueryAction => ({
type: ActionTypes.SetDataSourceTypeSearchQuery,
payload: query,
});
export const setDataSourceName = (name: string) => ({
type: ActionTypes.SetDataSourceName,
payload: name,
});
export const setIsDefault = (state: boolean) => ({
type: ActionTypes.SetIsDefault,
payload: state,
});
export const setIsDefault = actionCreatorFactory<boolean>('SET_IS_DEFAULT').create();
export type Action =
| LoadDataSourcesAction
| SetDataSourcesSearchQueryAction
| SetDataSourcesLayoutModeAction
| UpdateLocationAction
| LoadDataSourceTypesAction
| LoadedDataSourceTypesAction
| SetDataSourceTypeSearchQueryAction
| LoadDataSourceAction
| UpdateNavIndexAction
| LoadDataSourceMetaAction
| SetDataSourceNameAction
| SetIsDefaultAction;
| ActionOf<DataSourceSettings>
| ActionOf<DataSourceSettings[]>
| ActionOf<Plugin>
| ActionOf<Plugin[]>;
type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
@@ -0,0 +1,137 @@
import { reducerTester } from 'test/core/redux/reducerTester';
import { dataSourcesReducer, initialState } from './reducers';
import {
dataSourcesLoaded,
dataSourceLoaded,
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
dataSourceTypesLoad,
dataSourceTypesLoaded,
setDataSourceTypeSearchQuery,
dataSourceMetaLoaded,
setDataSourceName,
setIsDefault,
} from './actions';
import { getMockDataSources, getMockDataSource } from '../__mocks__/dataSourcesMocks';
import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
import { DataSourcesState } from 'app/types';
import { PluginMetaInfo } from '@grafana/ui';
const mockPlugin = () => ({
defaultNavUrl: 'defaultNavUrl',
enabled: true,
hasUpdate: true,
id: 'id',
info: {} as PluginMetaInfo,
latestVersion: 'latestVersion',
name: 'name',
pinned: true,
state: 'state',
type: 'type',
module: {},
});
describe('dataSourcesReducer', () => {
describe('when dataSourcesLoaded is dispatched', () => {
it('then state should be correct', () => {
const dataSources = getMockDataSources(0);
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(dataSourcesLoaded(dataSources))
.thenStateShouldEqual({ ...initialState, hasFetched: true, dataSources, dataSourcesCount: 1 });
});
});
describe('when dataSourceLoaded is dispatched', () => {
it('then state should be correct', () => {
const dataSource = getMockDataSource();
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(dataSourceLoaded(dataSource))
.thenStateShouldEqual({ ...initialState, dataSource });
});
});
describe('when setDataSourcesSearchQuery is dispatched', () => {
it('then state should be correct', () => {
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(setDataSourcesSearchQuery('some query'))
.thenStateShouldEqual({ ...initialState, searchQuery: 'some query' });
});
});
describe('when setDataSourcesLayoutMode is dispatched', () => {
it('then state should be correct', () => {
const layoutMode: LayoutModes = LayoutModes.Grid;
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(setDataSourcesLayoutMode(layoutMode))
.thenStateShouldEqual({ ...initialState, layoutMode: LayoutModes.Grid });
});
});
describe('when dataSourceTypesLoad is dispatched', () => {
it('then state should be correct', () => {
const state: DataSourcesState = { ...initialState, dataSourceTypes: [mockPlugin()] };
reducerTester()
.givenReducer(dataSourcesReducer, state)
.whenActionIsDispatched(dataSourceTypesLoad())
.thenStateShouldEqual({ ...initialState, dataSourceTypes: [], isLoadingDataSources: true });
});
});
describe('when dataSourceTypesLoaded is dispatched', () => {
it('then state should be correct', () => {
const dataSourceTypes = [mockPlugin()];
const state: DataSourcesState = { ...initialState, isLoadingDataSources: true };
reducerTester()
.givenReducer(dataSourcesReducer, state)
.whenActionIsDispatched(dataSourceTypesLoaded(dataSourceTypes))
.thenStateShouldEqual({ ...initialState, dataSourceTypes, isLoadingDataSources: false });
});
});
describe('when setDataSourceTypeSearchQuery is dispatched', () => {
it('then state should be correct', () => {
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(setDataSourceTypeSearchQuery('type search query'))
.thenStateShouldEqual({ ...initialState, dataSourceTypeSearchQuery: 'type search query' });
});
});
describe('when dataSourceMetaLoaded is dispatched', () => {
it('then state should be correct', () => {
const dataSourceMeta = mockPlugin();
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(dataSourceMetaLoaded(dataSourceMeta))
.thenStateShouldEqual({ ...initialState, dataSourceMeta });
});
});
describe('when setDataSourceName is dispatched', () => {
it('then state should be correct', () => {
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(setDataSourceName('some name'))
.thenStateShouldEqual({ ...initialState, dataSource: { name: 'some name' } });
});
});
describe('when setIsDefault is dispatched', () => {
it('then state should be correct', () => {
reducerTester()
.givenReducer(dataSourcesReducer, initialState)
.whenActionIsDispatched(setIsDefault(true))
.thenStateShouldEqual({ ...initialState, dataSource: { isDefault: true } });
});
});
});
@@ -1,56 +1,87 @@
import { DataSourcesState, Plugin } from 'app/types';
import { DataSourceSettings } from '@grafana/ui/src/types';
import { Action, ActionTypes } from './actions';
import {
dataSourceLoaded,
dataSourcesLoaded,
setDataSourcesSearchQuery,
setDataSourcesLayoutMode,
dataSourceTypesLoad,
dataSourceTypesLoaded,
setDataSourceTypeSearchQuery,
dataSourceMetaLoaded,
setDataSourceName,
setIsDefault,
} from './actions';
import { LayoutModes } from 'app/core/components/LayoutSelector/LayoutSelector';
import { reducerFactory } from 'app/core/redux';
const initialState: DataSourcesState = {
dataSources: [] as DataSourceSettings[],
export const initialState: DataSourcesState = {
dataSources: [],
dataSource: {} as DataSourceSettings,
layoutMode: LayoutModes.List,
searchQuery: '',
dataSourcesCount: 0,
dataSourceTypes: [] as Plugin[],
dataSourceTypes: [],
dataSourceTypeSearchQuery: '',
hasFetched: false,
isLoadingDataSources: false,
dataSourceMeta: {} as Plugin,
};
export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
switch (action.type) {
case ActionTypes.LoadDataSources:
return { ...state, hasFetched: true, dataSources: action.payload, dataSourcesCount: action.payload.length };
case ActionTypes.LoadDataSource:
return { ...state, dataSource: action.payload };
case ActionTypes.SetDataSourcesSearchQuery:
return { ...state, searchQuery: action.payload };
case ActionTypes.SetDataSourcesLayoutMode:
return { ...state, layoutMode: action.payload };
case ActionTypes.LoadDataSourceTypes:
return { ...state, dataSourceTypes: [], isLoadingDataSources: true };
case ActionTypes.LoadedDataSourceTypes:
return { ...state, dataSourceTypes: action.payload, isLoadingDataSources: false };
case ActionTypes.SetDataSourceTypeSearchQuery:
return { ...state, dataSourceTypeSearchQuery: action.payload };
case ActionTypes.LoadDataSourceMeta:
return { ...state, dataSourceMeta: action.payload };
case ActionTypes.SetDataSourceName:
return { ...state, dataSource: { ...state.dataSource, name: action.payload } };
case ActionTypes.SetIsDefault:
return { ...state, dataSource: { ...state.dataSource, isDefault: action.payload } };
}
return state;
};
export const dataSourcesReducer = reducerFactory(initialState)
.addMapper({
filter: dataSourcesLoaded,
mapper: (state, action) => ({
...state,
hasFetched: true,
dataSources: action.payload,
dataSourcesCount: action.payload.length,
}),
})
.addMapper({
filter: dataSourceLoaded,
mapper: (state, action) => ({ ...state, dataSource: action.payload }),
})
.addMapper({
filter: setDataSourcesSearchQuery,
mapper: (state, action) => ({ ...state, searchQuery: action.payload }),
})
.addMapper({
filter: setDataSourcesLayoutMode,
mapper: (state, action) => ({ ...state, layoutMode: action.payload }),
})
.addMapper({
filter: dataSourceTypesLoad,
mapper: state => ({ ...state, dataSourceTypes: [], isLoadingDataSources: true }),
})
.addMapper({
filter: dataSourceTypesLoaded,
mapper: (state, action) => ({
...state,
dataSourceTypes: action.payload,
isLoadingDataSources: false,
}),
})
.addMapper({
filter: setDataSourceTypeSearchQuery,
mapper: (state, action) => ({ ...state, dataSourceTypeSearchQuery: action.payload }),
})
.addMapper({
filter: dataSourceMetaLoaded,
mapper: (state, action) => ({ ...state, dataSourceMeta: action.payload }),
})
.addMapper({
filter: setDataSourceName,
mapper: (state, action) => ({ ...state, dataSource: { ...state.dataSource, name: action.payload } }),
})
.addMapper({
filter: setIsDefault,
mapper: (state, action) => ({
...state,
dataSource: { ...state.dataSource, isDefault: action.payload },
}),
})
.create();
export default {
dataSources: dataSourcesReducer,
+7 -5
View File
@@ -32,7 +32,7 @@ import {
import { RawTimeRange, TimeRange, DataQuery } from '@grafana/ui';
import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore';
import { StoreState } from 'app/types';
import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE } from 'app/core/utils/explore';
import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE, DEFAULT_UI_STATE } from 'app/core/utils/explore';
import { Emitter } from 'app/core/utils/emitter';
import { ExploreToolbar } from './ExploreToolbar';
@@ -61,7 +61,7 @@ interface ExploreProps {
supportsGraph: boolean | null;
supportsLogs: boolean | null;
supportsTable: boolean | null;
urlState: ExploreUrlState;
urlState?: ExploreUrlState;
}
/**
@@ -107,18 +107,20 @@ export class Explore extends React.PureComponent<ExploreProps> {
// Don't initialize on split, but need to initialize urlparameters when present
if (!initialized) {
// Load URL state and parse range
const { datasource, queries, range = DEFAULT_RANGE } = (urlState || {}) as ExploreUrlState;
const { datasource, queries, range = DEFAULT_RANGE, ui = DEFAULT_UI_STATE } = (urlState || {}) as ExploreUrlState;
const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
const initialQueries: DataQuery[] = ensureQueries(queries);
const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
const width = this.el ? this.el.offsetWidth : 0;
this.props.initializeExplore(
exploreId,
initialDatasource,
initialQueries,
initialRange,
width,
this.exploreEvents
this.exploreEvents,
ui
);
}
}
@@ -216,7 +218,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
{showingStartPage && <StartPage onClickExample={this.onClickExample} />}
{!showingStartPage && (
<>
{supportsGraph && <GraphContainer exploreId={exploreId} />}
{supportsGraph && !supportsLogs && <GraphContainer exploreId={exploreId} />}
{supportsTable && <TableContainer exploreId={exploreId} onClickCell={this.onClickLabel} />}
{supportsLogs && (
<LogsContainer
+4 -1
View File
@@ -3,6 +3,8 @@ import React, { PureComponent } from 'react';
import * as rangeUtil from 'app/core/utils/rangeutil';
import { RawTimeRange, Switch } from '@grafana/ui';
import TimeSeries from 'app/core/time_series2';
import {
LogsDedupDescription,
LogsDedupStrategy,
@@ -205,12 +207,13 @@ export default class Logs extends PureComponent<Props, State> {
// React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead
const getRows = () => processedRows;
const timeSeries = data.series.map(series => new TimeSeries(series));
return (
<div className="logs-panel">
<div className="logs-panel-graph">
<Graph
data={data.series}
data={timeSeries}
height="100px"
range={range}
id={`explore-logs-graph-${exploreId}`}
+13 -4
View File
@@ -104,11 +104,20 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
const { initialQuery, syntax } = this.props;
const { value, suggestions } = this.state;
// if query changed from the outside
if (initialQuery !== prevProps.initialQuery) {
// and we have a version that differs
if (initialQuery !== Plain.serialize(value)) {
this.placeholdersBuffer = new PlaceholdersBuffer(initialQuery || '');
this.setState({ value: makeValue(this.placeholdersBuffer.toString(), syntax) });
}
}
// Only update menu location when suggestion existence or text/selection changed
if (
this.state.value !== prevState.value ||
hasSuggestions(this.state.suggestions) !== hasSuggestions(prevState.suggestions)
) {
if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
this.updateMenu();
}
}
+6 -2
View File
@@ -65,6 +65,10 @@ export class QueryRow extends PureComponent<QueryRowProps> {
}
};
componentWillUnmount() {
console.log('QueryRow will unmount');
}
onClickAddButton = () => {
const { exploreId, index } = this.props;
this.props.addQueryRow(exploreId, index);
@@ -107,7 +111,7 @@ export class QueryRow extends PureComponent<QueryRowProps> {
<div className="query-row-status">
<QueryTransactionStatus transactions={transactions} />
</div>
<div className="query-row-field">
<div className="query-row-field flex-shrink-1">
{QueryField ? (
<QueryField
datasource={datasourceInstance}
@@ -131,7 +135,7 @@ export class QueryRow extends PureComponent<QueryRowProps> {
/>
)}
</div>
<div className="gf-form-inline">
<div className="gf-form-inline flex-shrink-0">
<div className="gf-form">
<button className="gf-form-label gf-form-label--btn" onClick={this.onClickClearButton}>
<i className="fa fa-times" />
@@ -8,6 +8,7 @@ import {
RangeScanner,
ResultType,
QueryTransaction,
ExploreUIState,
} from 'app/types/explore';
export enum ActionTypes {
@@ -106,6 +107,7 @@ export interface InitializeExploreAction {
exploreDatasources: DataSourceSelectItem[];
queries: DataQuery[];
range: RawTimeRange;
ui: ExploreUIState;
};
}
+72 -35
View File
@@ -38,6 +38,7 @@ import {
ResultType,
QueryOptions,
QueryTransaction,
ExploreUIState,
} from 'app/types/explore';
import {
@@ -78,7 +79,15 @@ export function changeDatasource(exploreId: ExploreId, datasource: string): Thun
await dispatch(importQueries(exploreId, modifiedQueries, currentDataSourceInstance, newDataSourceInstance));
dispatch(updateDatasourceInstance(exploreId, newDataSourceInstance));
dispatch(loadDatasource(exploreId, newDataSourceInstance));
try {
await dispatch(loadDatasource(exploreId, newDataSourceInstance));
} catch (error) {
console.error(error);
return;
}
dispatch(runQueries(exploreId));
};
}
@@ -154,7 +163,8 @@ export function initializeExplore(
queries: DataQuery[],
range: RawTimeRange,
containerWidth: number,
eventBridge: Emitter
eventBridge: Emitter,
ui: ExploreUIState
): ThunkResult<void> {
return async dispatch => {
const exploreDatasources: DataSourceSelectItem[] = getDatasourceSrv()
@@ -175,6 +185,7 @@ export function initializeExplore(
exploreDatasources,
queries,
range,
ui,
},
});
@@ -194,7 +205,14 @@ export function initializeExplore(
}
dispatch(updateDatasourceInstance(exploreId, instance));
dispatch(loadDatasource(exploreId, instance));
try {
await dispatch(loadDatasource(exploreId, instance));
} catch (error) {
console.error(error);
return;
}
dispatch(runQueries(exploreId, true));
} else {
dispatch(loadDatasourceMissing(exploreId));
}
@@ -258,10 +276,7 @@ export const queriesImported = (exploreId: ExploreId, queries: DataQuery[]): Que
* run datasource-specific code. Existing queries are imported to the new datasource if an importer exists,
* e.g., Prometheus -> Loki queries.
*/
export const loadDatasourceSuccess = (
exploreId: ExploreId,
instance: any,
): LoadDatasourceSuccessAction => {
export const loadDatasourceSuccess = (exploreId: ExploreId, instance: any): LoadDatasourceSuccessAction => {
// Capabilities
const supportsGraph = instance.meta.metrics;
const supportsLogs = instance.meta.logs;
@@ -343,8 +358,8 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
// Keep ID to track selection
dispatch(loadDatasourcePending(exploreId, datasourceName));
let datasourceError = null;
try {
const testResult = await instance.testDatasource();
datasourceError = testResult.status === 'success' ? null : testResult.message;
@@ -354,7 +369,7 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
if (datasourceError) {
dispatch(loadDatasourceFailure(exploreId, datasourceError));
return;
return Promise.reject(`${datasourceName} loading failed`);
}
if (datasourceName !== getState().explore[exploreId].requestedDatasourceName) {
@@ -372,7 +387,7 @@ export function loadDatasource(exploreId: ExploreId, instance: DataSourceApi): T
}
dispatch(loadDatasourceSuccess(exploreId, instance));
dispatch(runQueries(exploreId));
return Promise.resolve();
};
}
@@ -572,7 +587,7 @@ export function removeQueryRow(exploreId: ExploreId, index: number): ThunkResult
/**
* Main action to run queries and dispatches sub-actions based on which result viewers are active
*/
export function runQueries(exploreId: ExploreId) {
export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
return (dispatch, getState) => {
const {
datasourceInstance,
@@ -596,7 +611,7 @@ export function runQueries(exploreId: ExploreId) {
const interval = datasourceInstance.interval;
// Keep table queries first since they need to return quickly
if (showingTable && supportsTable) {
if ((ignoreUIState || showingTable) && supportsTable) {
dispatch(
runQueriesForType(
exploreId,
@@ -611,7 +626,7 @@ export function runQueries(exploreId: ExploreId) {
)
);
}
if (showingGraph && supportsGraph) {
if ((ignoreUIState || showingGraph) && supportsGraph) {
dispatch(
runQueriesForType(
exploreId,
@@ -625,9 +640,10 @@ export function runQueries(exploreId: ExploreId) {
)
);
}
if (showingLogs && supportsLogs) {
if ((ignoreUIState || showingLogs) && supportsLogs) {
dispatch(runQueriesForType(exploreId, 'Logs', { interval, format: 'logs' }));
}
dispatch(stateSave());
};
}
@@ -766,6 +782,11 @@ export function stateSave() {
datasource: left.datasourceInstance.name,
queries: left.modifiedQueries.map(clearQueryKeys),
range: left.range,
ui: {
showingGraph: left.showingGraph,
showingLogs: left.showingLogs,
showingTable: left.showingTable,
},
};
urlStates.left = serializeStateToUrlParam(leftUrlState, true);
if (split) {
@@ -773,48 +794,64 @@ export function stateSave() {
datasource: right.datasourceInstance.name,
queries: right.modifiedQueries.map(clearQueryKeys),
range: right.range,
ui: {
showingGraph: right.showingGraph,
showingLogs: right.showingLogs,
showingTable: right.showingTable,
},
};
urlStates.right = serializeStateToUrlParam(rightUrlState, true);
}
dispatch(updateLocation({ query: urlStates }));
};
}
/**
* Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
* Creates action to collapse graph/logs/table panel. When panel is collapsed,
* queries won't be run
*/
export function toggleGraph(exploreId: ExploreId): ThunkResult<void> {
const togglePanelActionCreator = (type: ActionTypes.ToggleGraph | ActionTypes.ToggleTable | ActionTypes.ToggleLogs) => (
exploreId: ExploreId
) => {
return (dispatch, getState) => {
dispatch({ type: ActionTypes.ToggleGraph, payload: { exploreId } });
if (getState().explore[exploreId].showingGraph) {
let shouldRunQueries;
dispatch({ type, payload: { exploreId } });
dispatch(stateSave());
switch (type) {
case ActionTypes.ToggleGraph:
shouldRunQueries = getState().explore[exploreId].showingGraph;
break;
case ActionTypes.ToggleLogs:
shouldRunQueries = getState().explore[exploreId].showingLogs;
break;
case ActionTypes.ToggleTable:
shouldRunQueries = getState().explore[exploreId].showingTable;
break;
}
if (shouldRunQueries) {
dispatch(runQueries(exploreId));
}
};
}
};
/**
* Expand/collapse the graph result viewer. When collapsed, graph queries won't be run.
*/
export const toggleGraph = togglePanelActionCreator(ActionTypes.ToggleGraph);
/**
* Expand/collapse the logs result viewer. When collapsed, log queries won't be run.
*/
export function toggleLogs(exploreId: ExploreId): ThunkResult<void> {
return (dispatch, getState) => {
dispatch({ type: ActionTypes.ToggleLogs, payload: { exploreId } });
if (getState().explore[exploreId].showingLogs) {
dispatch(runQueries(exploreId));
}
};
}
export const toggleLogs = togglePanelActionCreator(ActionTypes.ToggleLogs);
/**
* Expand/collapse the table result viewer. When collapsed, table queries won't be run.
*/
export function toggleTable(exploreId: ExploreId): ThunkResult<void> {
return (dispatch, getState) => {
dispatch({ type: ActionTypes.ToggleTable, payload: { exploreId } });
if (getState().explore[exploreId].showingTable) {
dispatch(runQueries(exploreId));
}
};
}
export const toggleTable = togglePanelActionCreator(ActionTypes.ToggleTable);
/**
* Resets state for explore.
@@ -163,7 +163,7 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
}
case ActionTypes.InitializeExplore: {
const { containerWidth, eventBridge, exploreDatasources, queries, range } = action.payload;
const { containerWidth, eventBridge, exploreDatasources, queries, range, ui } = action.payload;
return {
...state,
containerWidth,
@@ -173,6 +173,7 @@ export const itemReducer = (state, action: Action): ExploreItemState => {
initialQueries: queries,
initialized: true,
modifiedQueries: queries.slice(),
...ui,
};
}
-1
View File
@@ -1,6 +1,5 @@
import './panel_header';
import './panel_directive';
import './solo_panel_ctrl';
import './query_ctrl';
import './panel_editor_tab';
import './query_editor_row';
@@ -16,7 +16,6 @@ class MetricsPanelCtrl extends PanelCtrl {
datasourceSrv: any;
timeSrv: any;
templateSrv: any;
timing: any;
range: any;
interval: any;
intervalMs: any;
@@ -81,7 +80,6 @@ class MetricsPanelCtrl extends PanelCtrl {
this.loading = true;
// load datasource service
this.setTimeQueryStart();
this.datasourceSrv
.get(this.panel.datasource)
.then(this.updateTimeRange.bind(this))
@@ -112,14 +110,6 @@ class MetricsPanelCtrl extends PanelCtrl {
});
}
setTimeQueryStart() {
this.timing.queryStart = new Date().getTime();
}
setTimeQueryEnd() {
this.timing.queryEnd = new Date().getTime();
}
updateTimeRange(datasource?) {
this.datasource = datasource || this.datasource;
this.range = this.timeSrv.timeRange();
@@ -181,7 +171,6 @@ class MetricsPanelCtrl extends PanelCtrl {
}
handleQueryResult(result) {
this.setTimeQueryEnd();
this.loading = false;
// check for if data source returns subject
+6 -19
View File
@@ -1,5 +1,4 @@
import _ from 'lodash';
import $ from 'jquery';
import Remarkable from 'remarkable';
import config from 'app/core/config';
@@ -13,7 +12,7 @@ import {
sharePanel as sharePanelUtil,
} from 'app/features/dashboard/utils/panel';
import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, GRID_COLUMN_COUNT, PANEL_HEADER_HEIGHT, PANEL_BORDER } from 'app/core/constants';
import { GRID_COLUMN_COUNT, PANEL_HEADER_HEIGHT, PANEL_BORDER } from 'app/core/constants';
export class PanelCtrl {
panel: any;
@@ -31,8 +30,8 @@ export class PanelCtrl {
height: any;
containerHeight: any;
events: Emitter;
timing: any;
loading: boolean;
timing: any;
maxPanelsPerRowOptions: number[];
constructor($scope, $injector) {
@@ -42,7 +41,7 @@ export class PanelCtrl {
this.$timeout = $injector.get('$timeout');
this.editorTabs = [];
this.events = this.panel.events;
this.timing = {};
this.timing = {}; // not used but here to not break plugins
const plugin = config.panels[this.panel.type];
if (plugin) {
@@ -59,7 +58,7 @@ export class PanelCtrl {
}
renderingCompleted() {
profiler.renderingCompleted(this.panel.id, this.timing);
profiler.renderingCompleted(this.panel.id);
}
refresh() {
@@ -200,24 +199,12 @@ export class PanelCtrl {
return this.dashboard.meta.fullscreen && !this.panel.fullscreen;
}
calculatePanelHeight() {
if (this.panel.isEditing) {
this.containerHeight = $('.panel-wrapper--edit').height();
} else if (this.panel.fullscreen) {
this.containerHeight = $('.panel-wrapper--view').height();
} else {
this.containerHeight = this.panel.gridPos.h * GRID_CELL_HEIGHT + (this.panel.gridPos.h - 1) * GRID_CELL_VMARGIN;
}
if (this.panel.soloMode) {
this.containerHeight = $(window).height();
}
calculatePanelHeight(containerHeight) {
this.containerHeight = containerHeight;
this.height = this.containerHeight - (PANEL_BORDER + PANEL_HEADER_HEIGHT);
}
render(payload?) {
this.timing.renderStart = new Date().getTime();
this.events.emit('render', payload);
}
+8 -6
View File
@@ -101,7 +101,7 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
});
ctrl.events.on('panel-size-changed', () => {
ctrl.calculatePanelHeight();
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
$timeout(() => {
resizeScrollableContent();
ctrl.render();
@@ -112,19 +112,21 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
// first wait one pass for dashboard fullscreen view mode to take effect (classses being applied)
setTimeout(() => {
// then recalc style
ctrl.calculatePanelHeight();
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
// then wait another cycle (this might not be needed)
$timeout(() => {
ctrl.render();
resizeScrollableContent();
});
});
}, 10);
});
// set initial height
ctrl.calculatePanelHeight();
ctrl.events.on('render', () => {
// set initial height
if (!ctrl.height) {
ctrl.calculatePanelHeight(panelContainer[0].offsetHeight);
}
if (transparentLastState !== ctrl.panel.transparent) {
panelContainer.toggleClass('panel-transparent', ctrl.panel.transparent === true);
transparentLastState = ctrl.panel.transparent;
@@ -1,4 +0,0 @@
<div class="panel-solo" ng-if="panel">
<plugin-component type="panel">
</plugin-component>
</div>
@@ -1,58 +0,0 @@
import angular from 'angular';
import locationUtil from 'app/core/utils/location_util';
import appEvents from 'app/core/app_events';
export class SoloPanelCtrl {
/** @ngInject */
constructor($scope, $routeParams, $location, dashboardLoaderSrv, contextSrv, backendSrv) {
let panelId;
$scope.init = () => {
contextSrv.sidemenu = false;
appEvents.emit('toggle-sidemenu-hidden');
const params = $location.search();
panelId = parseInt(params.panelId, 10);
appEvents.on('dashboard-initialized', $scope.initPanelScope);
// if no uid, redirect to new route based on slug
if (!($routeParams.type === 'script' || $routeParams.type === 'snapshot') && !$routeParams.uid) {
backendSrv.getDashboardBySlug($routeParams.slug).then(res => {
if (res) {
const url = locationUtil.stripBaseFromUrl(res.meta.url.replace('/d/', '/d-solo/'));
$location.path(url).replace();
}
});
return;
}
dashboardLoaderSrv.loadDashboard($routeParams.type, $routeParams.slug, $routeParams.uid).then(result => {
result.meta.soloMode = true;
$scope.initDashboard(result, $scope);
});
};
$scope.initPanelScope = () => {
const panelInfo = $scope.dashboard.getPanelInfoById(panelId);
// fake row ctrl scope
$scope.ctrl = {
dashboard: $scope.dashboard,
};
$scope.panel = panelInfo.panel;
$scope.panel.soloMode = true;
$scope.$index = 0;
if (!$scope.panel) {
$scope.appEvent('alert-error', ['Panel not found', '']);
return;
}
};
$scope.init();
}
}
angular.module('grafana.routes').controller('SoloPanelCtrl', SoloPanelCtrl);
@@ -0,0 +1,80 @@
// Libraries
import React, { PureComponent } from 'react';
// Components
import { Select, SelectOptionItem } from '@grafana/ui';
// Types
import { QueryEditorProps } from '@grafana/ui/src/types';
import { LokiDatasource } from '../datasource';
import { LokiQuery } from '../types';
import { LokiQueryField } from './LokiQueryField';
type Props = QueryEditorProps<LokiDatasource, LokiQuery>;
interface State {
query: LokiQuery;
}
export class LokiQueryEditor extends PureComponent<Props> {
state: State = {
query: this.props.query,
};
onRunQuery = () => {
const { query } = this.state;
this.props.onChange(query);
this.props.onRunQuery();
};
onFieldChange = (query: LokiQuery, override?) => {
this.setState({
query: {
...this.state.query,
expr: query.expr,
}
});
};
onFormatChanged = (option: SelectOptionItem) => {
this.props.onChange({
...this.state.query,
resultFormat: option.value,
});
};
render() {
const { query } = this.state;
const { datasource } = this.props;
const formatOptions: SelectOptionItem[] = [
{ label: 'Time Series', value: 'time_series' },
{ label: 'Table', value: 'table' },
];
query.resultFormat = query.resultFormat || 'time_series';
const currentFormat = formatOptions.find(item => item.value === query.resultFormat);
return (
<div>
<LokiQueryField
datasource={datasource}
initialQuery={query}
onQueryChange={this.onFieldChange}
onPressEnter={this.onRunQuery}
/>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-label">Format as</div>
<Select isSearchable={false} options={formatOptions} onChange={this.onFormatChanged} value={currentFormat} />
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form-label gf-form-label--grow" />
</div>
</div>
</div>
);
}
}
export default LokiQueryEditor;
@@ -12,6 +12,7 @@ import QueryField, { TypeaheadInput, QueryFieldState } from 'app/features/explor
import { getNextCharacter, getPreviousCousin } from 'app/features/explore/utils/dom';
import BracesPlugin from 'app/features/explore/slate-plugins/braces';
import RunnerPlugin from 'app/features/explore/slate-plugins/runner';
import LokiDatasource from '../datasource';
// Types
import { LokiQuery } from '../types';
@@ -65,7 +66,7 @@ interface CascaderOption {
}
interface LokiQueryFieldProps {
datasource: any;
datasource: LokiDatasource;
error?: string | JSX.Element;
hint?: any;
history?: any[];
@@ -80,7 +81,7 @@ interface LokiQueryFieldState {
syntaxLoaded: boolean;
}
class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
export class LokiQueryField extends React.PureComponent<LokiQueryFieldProps, LokiQueryFieldState> {
plugins: any[];
pluginsSearch: any[];
languageProvider: any;
@@ -7,6 +7,17 @@ describe('LokiDatasource', () => {
url: 'myloggingurl',
};
const testResp = {
data: {
streams: [
{
entries: [{ ts: '2019-02-01T10:27:37.498180581Z', line: 'hello' }],
labels: '{}',
},
],
},
};
describe('when querying', () => {
const backendSrvMock = { datasourceRequest: jest.fn() };
@@ -17,7 +28,7 @@ describe('LokiDatasource', () => {
test('should use default max lines when no limit given', () => {
const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn();
backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] });
ds.query(options);
@@ -30,7 +41,7 @@ describe('LokiDatasource', () => {
const customData = { ...(instanceSettings.jsonData || {}), maxLines: 20 };
const customSettings = { ...instanceSettings, jsonData: customData };
const ds = new LokiDatasource(customSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn();
backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({ targets: [{ expr: 'foo', refId: 'B' }] });
ds.query(options);
@@ -38,6 +49,34 @@ describe('LokiDatasource', () => {
expect(backendSrvMock.datasourceRequest.mock.calls.length).toBe(1);
expect(backendSrvMock.datasourceRequest.mock.calls[0][0].url).toContain('limit=20');
});
test('should return log streams when resultFormat is undefined', async done => {
const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: 'foo', refId: 'B' }],
});
const res = await ds.query(options);
expect(res.data[0].entries[0].line).toBe('hello');
done();
});
test('should return time series when resultFormat is time_series', async done => {
const ds = new LokiDatasource(instanceSettings, backendSrvMock, templateSrvMock);
backendSrvMock.datasourceRequest = jest.fn(() => Promise.resolve(testResp));
const options = getQueryOptions<LokiQuery>({
targets: [{ expr: 'foo', refId: 'B', resultFormat: 'time_series' }],
});
const res = await ds.query(options);
expect(res.data[0].datapoints).toBeDefined();
done();
});
});
describe('when performing testDataSource', () => {
@@ -32,7 +32,7 @@ function serializeParams(data: any) {
.join('&');
}
export default class LokiDatasource {
export class LokiDatasource {
languageProvider: LanguageProvider;
maxLines: number;
@@ -73,10 +73,11 @@ export default class LokiDatasource {
};
}
query(options: DataQueryOptions<LokiQuery>): Promise<{ data: LogsStream[] }> {
async query(options: DataQueryOptions<LokiQuery>) {
const queryTargets = options.targets
.filter(target => target.expr)
.filter(target => target.expr && !target.hide)
.map(target => this.prepareQueryTarget(target, options));
if (queryTargets.length === 0) {
return Promise.resolve({ data: [] });
}
@@ -84,20 +85,29 @@ export default class LokiDatasource {
const queries = queryTargets.map(target => this._request('/api/prom/query', target));
return Promise.all(queries).then((results: any[]) => {
// Flatten streams from multiple queries
const allStreams: LogsStream[] = results.reduce((acc, response, i) => {
if (!response) {
return acc;
const allStreams: LogsStream[] = [];
for (let i = 0; i < results.length; i++) {
const result = results[i];
const query = queryTargets[i];
// add search term to stream & add to array
if (result.data) {
for (const stream of (result.data.streams || [])) {
stream.search = query.regexp;
allStreams.push(stream);
}
}
const streams: LogsStream[] = response.data.streams || [];
// Inject search for match highlighting
const search: string = queryTargets[i].regexp;
streams.forEach(s => {
s.search = search;
});
return [...acc, ...streams];
}, []);
return { data: allStreams };
}
// check resultType
if (options.targets[0].resultFormat === 'time_series') {
const logs = mergeStreamsToLogs(allStreams, this.maxLines);
logs.series = makeSeriesForLogs(logs.rows, options.intervalMs);
return { data: logs.series };
} else {
return { data: allStreams };
}
});
}
@@ -142,34 +152,36 @@ export default class LokiDatasource {
testDatasource() {
return this._request('/api/prom/label')
.then(res => {
if (res && res.data && res.data.values && res.data.values.length > 0) {
return { status: 'success', message: 'Data source connected and labels found.' };
}
return {
status: 'error',
message:
'Data source connected, but no labels received. Verify that Loki and Promtail is configured properly.',
};
})
.catch(err => {
let message = 'Loki: ';
if (err.statusText) {
message += err.statusText;
} else {
message += 'Cannot connect to Loki';
}
.then(res => {
if (res && res.data && res.data.values && res.data.values.length > 0) {
return { status: 'success', message: 'Data source connected and labels found.' };
}
return {
status: 'error',
message:
'Data source connected, but no labels received. Verify that Loki and Promtail is configured properly.',
};
})
.catch(err => {
let message = 'Loki: ';
if (err.statusText) {
message += err.statusText;
} else {
message += 'Cannot connect to Loki';
}
if (err.status) {
message += `. ${err.status}`;
}
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 };
});
if (err.data && err.data.message) {
message += `. ${err.data.message}`;
} else if (err.data) {
message += `. ${err.data}`;
}
return { status: 'error', message: message };
});
}
}
export default LokiDatasource;
@@ -2,6 +2,7 @@ import Datasource from './datasource';
import LokiStartPage from './components/LokiStartPage';
import LokiQueryField from './components/LokiQueryField';
import LokiQueryEditor from './components/LokiQueryEditor';
export class LokiConfigCtrl {
static templateUrl = 'partials/config.html';
@@ -9,6 +10,7 @@ export class LokiConfigCtrl {
export {
Datasource,
LokiQueryEditor as QueryEditor,
LokiConfigCtrl as ConfigCtrl,
LokiQueryField as ExploreQueryField,
LokiStartPage as ExploreStartPage,
@@ -2,12 +2,14 @@
"type": "datasource",
"name": "Loki",
"id": "loki",
"metrics": false,
"metrics": true,
"alerting": false,
"annotations": false,
"logs": true,
"explore": true,
"tables": false,
"info": {
"description": "Loki Logging Data Source for Grafana",
"author": {
@@ -2,5 +2,8 @@ import { DataQuery } from '@grafana/ui/src/types';
export interface LokiQuery extends DataQuery {
expr: string;
resultFormat?: LokiQueryResultFormats;
}
export type LokiQueryResultFormats = 'time_series' | 'logs';
@@ -249,15 +249,15 @@ class PromQueryField extends React.PureComponent<PromQueryFieldProps, PromQueryF
return (
<>
<div className="gf-form-inline">
<div className="gf-form">
<div className="gf-form-inline gf-form-inline--nowrap">
<div className="gf-form flex-shrink-0">
<Cascader options={metricsOptions} onChange={this.onChangeMetrics}>
<button className="gf-form-label gf-form-label--btn" disabled={!syntaxLoaded}>
{chooserText} <i className="fa fa-caret-down" />
</button>
</Cascader>
</div>
<div className="gf-form gf-form--grow">
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
additionalPlugins={this.plugins}
cleanText={cleanText}
+2 -2
View File
@@ -41,9 +41,9 @@ export class QueryEditor extends PureComponent<Props> {
}
onScenarioChange = (item: SelectOptionItem) => {
this.props.onQueryChange({
this.props.onChange({
...this.props.query,
scenarioId: item.value,
...this.props.query
});
}
+19 -8
View File
@@ -9,28 +9,39 @@ import { Gauge } from '@grafana/ui';
// Types
import { GaugeOptions } from './types';
import { PanelProps, NullValueMode } from '@grafana/ui/src/types';
import { PanelProps, NullValueMode, TimeSeriesValue } from '@grafana/ui/src/types';
import { ThemeProvider } from 'app/core/utils/ConfigProvider';
interface Props extends PanelProps<GaugeOptions> {}
export class GaugePanel extends PureComponent<Props> {
render() {
const { timeSeries, width, height, onInterpolate, options } = this.props;
const { panelData, width, height, onInterpolate, options } = this.props;
const prefix = onInterpolate(options.prefix);
const suffix = onInterpolate(options.suffix);
let value: TimeSeriesValue;
const vmSeries = processTimeSeries({
timeSeries: timeSeries,
nullValueMode: NullValueMode.Null,
});
if (panelData.timeSeries) {
const vmSeries = processTimeSeries({
timeSeries: panelData.timeSeries,
nullValueMode: NullValueMode.Null,
});
if (vmSeries[0]) {
value = vmSeries[0].stats[options.stat];
} else {
value = null;
}
} else if (panelData.tableData) {
value = panelData.tableData.rows[0].find(prop => prop > 0);
}
return (
<ThemeProvider>
{(theme) => (
{theme => (
<Gauge
timeSeries={vmSeries}
value={value}
{...this.props.options}
width={width}
height={height}
+1 -1
View File
@@ -281,7 +281,7 @@ class GraphCtrl extends MetricsPanelCtrl {
toggleLegend() {
this.panel.legend.show = !this.panel.legend.show;
this.refresh();
this.render();
}
legendValuesOptionChanged() {
@@ -3,7 +3,7 @@
<h5 class="section-heading">Options</h5>
<gf-form-switch class="gf-form"
label="Show" label-class="width-7"
checked="ctrl.panel.legend.show" on-change="ctrl.refresh()">
checked="ctrl.panel.legend.show" on-change="ctrl.render()">
</gf-form-switch>
<gf-form-switch class="gf-form"
label="As Table" label-class="width-7"
@@ -16,13 +16,16 @@ interface Props extends PanelProps<Options> {}
export class GraphPanel extends PureComponent<Props> {
render() {
const { timeSeries, timeRange, width, height } = this.props;
const { panelData, timeRange, width, height } = this.props;
const { showLines, showBars, showPoints } = this.props.options;
const vmSeries = processTimeSeries({
timeSeries: timeSeries,
nullValueMode: NullValueMode.Ignore,
});
let vmSeries;
if (panelData.timeSeries) {
vmSeries = processTimeSeries({
timeSeries: panelData.timeSeries,
nullValueMode: NullValueMode.Ignore,
});
}
return (
<Graph
-1
View File
@@ -80,7 +80,6 @@ class TablePanelCtrl extends MetricsPanelCtrl {
this.pageIndex = 0;
if (this.panel.transform === 'annotations') {
this.setTimeQueryStart();
return this.annotationsSrv
.getAnnotations({
dashboard: this.dashboard,
+7 -1
View File
@@ -18,6 +18,8 @@ function WrapInProvider(store, Component, props) {
export function reactContainer(
$route,
$location,
$injector,
$rootScope,
contextSrv: ContextSrv
) {
return {
@@ -38,7 +40,11 @@ export function reactContainer(
component = component.default;
}
const props = { };
const props = {
$injector: $injector,
$rootScope: $rootScope,
$scope: scope,
};
ReactDOM.render(WrapInProvider(store, component, props), elem[0]);
+11 -8
View File
@@ -19,6 +19,7 @@ import UsersListPage from 'app/features/users/UsersListPage';
import DataSourceDashboards from 'app/features/datasources/DataSourceDashboards';
import DataSourceSettingsPage from '../features/datasources/settings/DataSourceSettingsPage';
import OrgDetailsPage from '../features/org/OrgDetailsPage';
import SoloPanelPage from '../features/dashboard/containers/SoloPanelPage';
import config from 'app/core/config';
/** @ngInject */
@@ -51,16 +52,18 @@ export function setupAngularRoutes($routeProvider, $locationProvider) {
pageClass: 'page-dashboard',
})
.when('/d-solo/:uid/:slug', {
templateUrl: 'public/app/features/panel/partials/soloPanel.html',
controller: 'SoloPanelCtrl',
reloadOnSearch: false,
pageClass: 'page-dashboard',
template: '<react-container />',
pageClass: 'dashboard-solo',
resolve: {
component: () => SoloPanelPage,
},
})
.when('/dashboard-solo/:type/:slug', {
templateUrl: 'public/app/features/panel/partials/soloPanel.html',
controller: 'SoloPanelCtrl',
reloadOnSearch: false,
pageClass: 'page-dashboard',
template: '<react-container />',
pageClass: 'dashboard-solo',
resolve: {
component: () => SoloPanelPage,
},
})
.when('/dashboard/new', {
templateUrl: 'public/app/partials/dashboard.html',
+7
View File
@@ -231,10 +231,17 @@ export interface ExploreItemState {
tableResult?: TableModel;
}
export interface ExploreUIState {
showingTable: boolean;
showingGraph: boolean;
showingLogs: boolean;
}
export interface ExploreUrlState {
datasource: string;
queries: any[]; // Should be a DataQuery, but we're going to strip refIds, so typing makes less sense
range: RawTimeRange;
ui: ExploreUIState;
}
export interface HistoryItem<TQuery extends DataQuery = DataQuery> {
+1 -1
View File
@@ -135,7 +135,7 @@ $input-padding-y-sm: 4px !default;
$input-padding-x-lg: 20px !default;
$input-padding-y-lg: 10px !default;
$input-height: (($font-size-base * $line-height-base) + ($input-padding-y * 2)) !default;
$input-height: 35px !default;
$gf-form-margin: 0.2rem;
$gf-form-input-height: 35px;
+4
View File
@@ -109,6 +109,10 @@
width: 100%;
}
.card-item-label {
margin-left: 8px;
}
.card-item-sub-name {
color: $text-color-weak;
overflow: hidden;
+1 -1
View File
@@ -7,7 +7,7 @@
&.ace_editor {
@include font-family-monospace();
font-size: 1rem;
min-height: 2.6rem;
min-height: 3.6rem; // Include space for horizontal scrollbar
@include border-radius($input-border-radius-sm);
border: $input-btn-border-width solid $input-border-color;
+4
View File
@@ -84,6 +84,10 @@ $input-border: 1px solid $input-border-color;
.gf-form + .gf-form {
margin-left: $gf-form-margin;
}
&--nowrap {
flex-wrap: nowrap;
}
}
.gf-form-button-row {
@@ -19,6 +19,7 @@
border: $panel-border;
border-radius: $border-radius;
transition: all 0.3s;
line-height: $input-line-height;
}
.slate-query-field__wrapper--disabled {
+1 -1
View File
@@ -40,9 +40,9 @@
background-color: $input-bg;
border: 1px solid $input-border-color;
border-radius: $input-border-radius;
box-sizing: content-box;
display: inline-block;
color: $text-color;
height: $gf-form-input-height;
.label-tag {
margin: 0 5px;
-1
View File
@@ -4,7 +4,6 @@
align-items: center;
padding: 3px 20px 3px 20px;
position: relative;
z-index: 1;
flex: 0 0 auto;
background: $toolbar-bg;
border-radius: 3px;

Some files were not shown because too many files have changed in this diff Show More