diff --git a/CHANGELOG.md b/CHANGELOG.md index 7164f5d99a9..1a19957b3ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/README.md b/README.md index 3df6a383e05..658f1e34257 100644 --- a/README.md +++ b/README.md @@ -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 -```bash -yarn jest -``` +*Note: HMR for Angular is not supported. If you edit files in the Angular part of the app, the whole page will reload.* -### Recompile backend on source change +Run tests and rebuild on source change: -To rebuild on source change. ```bash -go get github.com/Unknwon/bra -bra run +yarn jest ``` -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 diff --git a/conf/defaults.ini b/conf/defaults.ini index 788112ae67e..d021d342fbf 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -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 diff --git a/conf/sample.ini b/conf/sample.ini index 89880106345..ef677320686 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -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 diff --git a/devenv/docker/blocks/loki/config.yaml b/devenv/docker/blocks/loki/config.yaml new file mode 100644 index 00000000000..9451b6ba79b --- /dev/null +++ b/devenv/docker/blocks/loki/config.yaml @@ -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 diff --git a/devenv/docker/blocks/loki/docker-compose.yaml b/devenv/docker/blocks/loki/docker-compose.yaml index bd4f8d3c728..0ac5d439354 100644 --- a/devenv/docker/blocks/loki/docker-compose.yaml +++ b/devenv/docker/blocks/loki/docker-compose.yaml @@ -1,24 +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 - depends_on: - - loki diff --git a/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss index 46eed5f7ff1..b07fe2433c9 100644 --- a/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss +++ b/packages/grafana-ui/src/components/ColorPicker/_ColorPicker.scss @@ -167,6 +167,7 @@ $arrowSize: 15px; color: inherit; padding: 0; border-radius: 10px; + cursor: pointer; } .sp-replacer:hover, diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx index d4051b5ea22..e210b0995ff 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.test.tsx @@ -2,7 +2,6 @@ import React from 'react'; import { shallow } from 'enzyme'; import { Gauge, Props } from './Gauge'; -import { TimeSeriesVMs } from '../../types/series'; 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, }; diff --git a/packages/grafana-ui/src/components/Gauge/Gauge.tsx b/packages/grafana-ui/src/components/Gauge/Gauge.tsx index d4d8442593e..04d89bf3f57 100644 --- a/packages/grafana-ui/src/components/Gauge/Gauge.tsx +++ b/packages/grafana-ui/src/components/Gauge/Gauge.tsx @@ -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 { } 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 { try { $.plot(this.canvasElement, [plotSeries], options); } catch (err) { - console.log('Gauge rendering error', err, options, timeSeries); + console.log('Gauge rendering error', err, options, value); } } diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx b/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx index 7ce4b8335ff..8516760d6f3 100644 --- a/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx +++ b/packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx @@ -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 => { +export const PanelOptionsGroup: FunctionComponent = props => { return (
- {props.title && ( + {props.onAdd ? (
- {props.title} - {props.onClose && ( - - )} +
+ ) : ( + props.title && ( +
+ {props.title} + {props.onClose && ( + + )} +
+ ) )} -
{props.children}
+ {props.children &&
{props.children}
}
); }; diff --git a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss index cfc832afa98..b5b815cf57c 100644 --- a/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss +++ b/packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss @@ -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; diff --git a/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx b/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx index efc5e4516fc..9a787a84819 100644 --- a/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx +++ b/packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx @@ -49,7 +49,7 @@ export default class SelectOptionGroup extends PureComponent
- {label} + {label} {' '}
{expanded && children} diff --git a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss index 61278321572..200adfbfd75 100644 --- a/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss +++ b/packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss @@ -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 { diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx index bbad3e5a7ca..caa09c9e5ff 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.test.tsx @@ -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 = { diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx index ca0a6e71f4a..f9646781048 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.tsx @@ -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 { const { valueMappings } = this.state; return ( - -
+ {valueMappings.length > 0 && valueMappings.map((valueMapping, index) => ( { removeValueMapping={() => this.onRemoveMapping(valueMapping.id)} /> ))} -
-
-
- -
-
Add mapping
-
); } diff --git a/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap index 8a465ff88df..b0dd7d81840 100644 --- a/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/__snapshots__/ValueMappingsEditor.test.tsx.snap @@ -2,55 +2,37 @@ exports[`Render should render component 1`] = ` -
- - + -
-
-
- -
-
- Add mapping -
-
+ } + />
`; diff --git a/packages/grafana-ui/src/types/series.ts b/packages/grafana-ui/src/types/data.ts similarity index 81% rename from packages/grafana-ui/src/types/series.ts rename to packages/grafana-ui/src/types/data.ts index 5cad1e4a72a..1e4ccba3948 100644 --- a/packages/grafana-ui/src/types/series.ts +++ b/packages/grafana-ui/src/types/data.ts @@ -52,3 +52,20 @@ export interface TimeSeriesVMs { [index: number]: TimeSeriesVM; length: number; } + +interface Column { + text: string; + title?: string; + type?: string; + sort?: boolean; + desc?: boolean; + filterable?: boolean; + unit?: string; +} + +export interface TableData { + columns: Column[]; + rows: any[]; + type: string; + columnMap: any; +} diff --git a/packages/grafana-ui/src/types/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index ffcbbb5fe64..e34cf25dc01 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -1,9 +1,9 @@ import { TimeRange, RawTimeRange } from './time'; -import { TimeSeries } from './series'; import { PluginMeta } from './plugin'; +import { TableData, TimeSeries } from './data'; export interface DataQueryResponse { - data: TimeSeries[]; + data: TimeSeries[] | [TableData] | any; } export interface DataQuery { diff --git a/packages/grafana-ui/src/types/index.ts b/packages/grafana-ui/src/types/index.ts index 575c749e07e..e23b5e63af8 100644 --- a/packages/grafana-ui/src/types/index.ts +++ b/packages/grafana-ui/src/types/index.ts @@ -1,4 +1,4 @@ -export * from './series'; +export * from './data'; export * from './time'; export * from './panel'; export * from './plugin'; diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index f2a699839b8..4eda85f9a28 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -1,10 +1,10 @@ -import { TimeSeries, LoadingState } from './series'; +import { TimeSeries, LoadingState, TableData } from './data'; import { TimeRange } from './time'; export type InterpolateFunction = (value: string, format?: string | Function) => string; export interface PanelProps { - timeSeries: TimeSeries[]; + panelData: PanelData; timeRange: TimeRange; loading: LoadingState; options: T; @@ -14,6 +14,11 @@ export interface PanelProps { onInterpolate: InterpolateFunction; } +export interface PanelData { + timeSeries?: TimeSeries[]; + tableData?: TableData; +} + export interface PanelOptionsProps { options: T; onChange: (options: T) => void; diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts index 420a54e5840..00735827825 100644 --- a/packages/grafana-ui/src/types/plugin.ts +++ b/packages/grafana-ui/src/types/plugin.ts @@ -44,8 +44,8 @@ export interface DataSourceApi { export interface QueryEditorProps { datasource: DSType; query: TQuery; - onExecuteQuery?: () => void; - onQueryChange?: (value: TQuery) => void; + onRunQuery: () => void; + onChange: (value: TQuery) => void; } export interface PluginExports { diff --git a/pkg/api/api.go b/pkg/api/api.go index 07cb712f794..980706d8355 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -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) { diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index 5cb43974d34..13b9ef607f5 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -97,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) @@ -163,7 +164,7 @@ func (s *UserAuthTokenServiceImpl) CreateToken(userId int64, clientIP, userAgent func (s *UserAuthTokenServiceImpl) LookupToken(unhashedToken string) (*userAuthToken, error) { hashedToken := hashToken(unhashedToken) if setting.Env == setting.DEV { - s.log.Info("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) + s.log.Debug("looking up token", "unhashed", unhashedToken, "hashed", hashedToken) } expireBefore := getTime().Add(time.Duration(-86400*s.Cfg.LoginCookieMaxDays) * time.Second).Unix() diff --git a/pkg/services/provisioning/notifiers/alert_notifications.go b/pkg/services/provisioning/notifiers/alert_notifications.go index 514f11379c8..7b595a3d32f 100644 --- a/pkg/services/provisioning/notifiers/alert_notifications.go +++ b/pkg/services/provisioning/notifiers/alert_notifications.go @@ -92,7 +92,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not } if cmd.Result == nil { - dc.log.Info("Inserting alert notification from configuration ", "name", notification.Name, "uid", notification.Uid) + dc.log.Debug("inserting alert notification from configuration", "name", notification.Name, "uid", notification.Uid) insertCmd := &models.CreateAlertNotificationCommand{ Uid: notification.Uid, Name: notification.Name, @@ -109,7 +109,7 @@ func (dc *NotificationProvisioner) mergeNotifications(notificationToMerge []*not return err } } else { - dc.log.Info("Updating alert notification from configuration", "name", notification.Name) + dc.log.Debug("updating alert notification from configuration", "name", notification.Name) updateCmd := &models.UpdateAlertNotificationWithUidCommand{ Uid: notification.Uid, Name: notification.Name, diff --git a/pkg/services/sqlstore/sqlstore.go b/pkg/services/sqlstore/sqlstore.go index fb0f0938573..6debaca89a1 100644 --- a/pkg/services/sqlstore/sqlstore.go +++ b/pkg/services/sqlstore/sqlstore.go @@ -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 = "''" } diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index cf486a228ab..c3c78d10fec 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -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 diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index dfa03d2dfa9..f898a65f911 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -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"}, diff --git a/pkg/tsdb/mssql/mssql.go b/pkg/tsdb/mssql/mssql.go index bd4510f6cf3..12f2b6c03c9 100644 --- a/pkg/tsdb/mssql/mssql.go +++ b/pkg/tsdb/mssql/mssql.go @@ -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;", diff --git a/pkg/util/ip.go b/pkg/util/ip.go deleted file mode 100644 index d3809318191..00000000000 --- a/pkg/util/ip.go +++ /dev/null @@ -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 -} diff --git a/pkg/util/ip_address.go b/pkg/util/ip_address.go index d8d95ef3acd..b5ffb361e0b 100644 --- a/pkg/util/ip_address.go +++ b/pkg/util/ip_address.go @@ -7,23 +7,48 @@ import ( // ParseIPAddress parses an IP address and removes port and/or IPV6 format func ParseIPAddress(input string) string { + host, _ := SplitHostPort(input) + + ip := net.ParseIP(host) + + if ip == nil { + return host + } + + 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) - ip := net.ParseIP(s) - - if ip.IsLoopback() { - return "127.0.0.1" - } + return s, port +} - return ip.String() +// SplitHostPort splits ip address/hostname string by host and port +func SplitHostPort(input string) (host string, port string) { + return SplitHostPortDefault(input, "", "") } diff --git a/pkg/util/ip_address_test.go b/pkg/util/ip_address_test.go index fd3e3ea8587..b926de1a36b 100644 --- a/pkg/util/ip_address_test.go +++ b/pkg/util/ip_address_test.go @@ -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") }) } diff --git a/pkg/util/ip_test.go b/pkg/util/ip_test.go deleted file mode 100644 index 3a62a080e26..00000000000 --- a/pkg/util/ip_test.go +++ /dev/null @@ -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") - }) - -} diff --git a/public/app/core/components/Select/MetricSelect.tsx b/public/app/core/components/Select/MetricSelect.tsx index c9247198052..62045662c64 100644 --- a/public/app/core/components/Select/MetricSelect.tsx +++ b/public/app/core/components/Select/MetricSelect.tsx @@ -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 { diff --git a/public/app/core/logs_model.ts b/public/app/core/logs_model.ts index a3f78e7152a..abcd5563bd0 100644 --- a/public/app/core/logs_model.ts +++ b/public/app/core/logs_model.ts @@ -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 }; }); } diff --git a/public/app/core/profiler.ts b/public/app/core/profiler.ts index 0e738dd3da1..19c67039c41 100644 --- a/public/app/core/profiler.ts +++ b/public/app/core/profiler.ts @@ -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(); } } diff --git a/public/app/core/redux/actionCreatorFactory.test.ts b/public/app/core/redux/actionCreatorFactory.test.ts new file mode 100644 index 00000000000..274079311b3 --- /dev/null +++ b/public/app/core/redux/actionCreatorFactory.test.ts @@ -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').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('nOpAyLoAd').create(); + }).toThrow(); + }); + }); +}); diff --git a/public/app/core/redux/actionCreatorFactory.ts b/public/app/core/redux/actionCreatorFactory.ts new file mode 100644 index 00000000000..d6477144df4 --- /dev/null +++ b/public/app/core/redux/actionCreatorFactory.ts @@ -0,0 +1,57 @@ +import { Action } from 'redux'; + +const allActionCreators: string[] = []; + +export interface ActionOf extends Action { + readonly type: string; + readonly payload: Payload; +} + +export interface ActionCreator { + readonly type: string; + (payload: Payload): ActionOf; +} + +export interface NoPayloadActionCreator { + readonly type: string; + (): ActionOf; +} + +export interface ActionCreatorFactory { + create: () => ActionCreator; +} + +export interface NoPayloadActionCreatorFactory { + create: () => NoPayloadActionCreator; +} + +export const actionCreatorFactory = (type: string): ActionCreatorFactory => { + const create = (): ActionCreator => { + return Object.assign((payload: Payload): ActionOf => ({ 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 => ({ 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); diff --git a/public/app/core/redux/index.ts b/public/app/core/redux/index.ts new file mode 100644 index 00000000000..359f160b9ce --- /dev/null +++ b/public/app/core/redux/index.ts @@ -0,0 +1,4 @@ +import { actionCreatorFactory } from './actionCreatorFactory'; +import { reducerFactory } from './reducerFactory'; + +export { actionCreatorFactory, reducerFactory }; diff --git a/public/app/core/redux/reducerFactory.test.ts b/public/app/core/redux/reducerFactory.test.ts new file mode 100644 index 00000000000..48cffc1ca7a --- /dev/null +++ b/public/app/core/redux/reducerFactory.test.ts @@ -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('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); + + 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); + + 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(); + }); + }); + }); +}); diff --git a/public/app/core/redux/reducerFactory.ts b/public/app/core/redux/reducerFactory.ts new file mode 100644 index 00000000000..bfa8e67bd4c --- /dev/null +++ b/public/app/core/redux/reducerFactory.ts @@ -0,0 +1,45 @@ +import { ActionOf, ActionCreator } from './actionCreatorFactory'; +import { Reducer } from 'redux'; + +export type Mapper = (state: State, action: ActionOf) => State; + +export interface MapperConfig { + filter: ActionCreator; + mapper: Mapper; +} + +export interface AddMapper { + addMapper: (config: MapperConfig) => CreateReducer; +} + +export interface CreateReducer extends AddMapper { + create: () => Reducer>; +} + +export const reducerFactory = (initialState: State): AddMapper => { + const allMappers: { [key: string]: Mapper } = {}; + + const addMapper = (config: MapperConfig): CreateReducer => { + 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: State = initialState, action: ActionOf): State => { + const mapper = allMappers[action.type]; + + if (mapper) { + return mapper(state, action); + } + + return state; + }; + + const instance: CreateReducer = { addMapper, create }; + + return instance; +}; diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index 38d7f2b76cb..c73cc7661f5 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -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 diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 989746fd067..ed321c6a69e 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -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(); } } }); diff --git a/public/app/core/utils/explore.test.ts b/public/app/core/utils/explore.test.ts index 32135eab90a..1c00142c3b8 100644 --- a/public/app/core/utils/explore.test.ts +++ b/public/app/core/utils/explore.test.ts @@ -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); }); }); diff --git a/public/app/core/utils/explore.ts b/public/app/core/utils/explore.ts index 7a9f54a0cae..faf46118718 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -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); } diff --git a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx index f9a56718c5e..e7778a31fdb 100644 --- a/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx +++ b/public/app/features/dashboard/components/DashboardRow/DashboardRow.tsx @@ -18,13 +18,18 @@ export class DashboardRow extends React.Component { 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 { }); } - update() { + onUpdate = () => { this.props.dashboard.processRepeats(); this.forceUpdate(); } - openSettings() { + onOpenSettings = () => { appEvents.emit('show-modal', { templateHtml: ``, 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 { return (
- + {title} @@ -90,16 +95,16 @@ export class DashboardRow extends React.Component { {canEdit && ( )} {this.state.collapsed === true && ( -
+
 
)} diff --git a/public/app/features/dashboard/components/SaveModals/index.ts b/public/app/features/dashboard/components/SaveModals/index.ts index afab0796d28..6f55cc2ce06 100644 --- a/public/app/features/dashboard/components/SaveModals/index.ts +++ b/public/app/features/dashboard/components/SaveModals/index.ts @@ -1,2 +1,3 @@ export { SaveDashboardAsModalCtrl } from './SaveDashboardAsModalCtrl'; export { SaveDashboardModalCtrl } from './SaveDashboardModalCtrl'; +export { SaveProvisionedDashboardModalCtrl } from './SaveProvisionedDashboardModalCtrl'; diff --git a/public/app/features/dashboard/containers/SoloPanelPage.tsx b/public/app/features/dashboard/containers/SoloPanelPage.tsx new file mode 100644 index 00000000000..097c8015929 --- /dev/null +++ b/public/app/features/dashboard/containers/SoloPanelPage.tsx @@ -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 { + + 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 ( +
+ Panel with id { panelId } not found +
+ ); + } + + if (!panel) { + return
Loading & initializing dashboard
; + } + + return ( +
+ +
+ ); + } +} + +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)); diff --git a/public/app/features/dashboard/dashgrid/DataPanel.tsx b/public/app/features/dashboard/dashgrid/DataPanel.tsx index e15ff8d4c0d..2183548000b 100644 --- a/public/app/features/dashboard/dashgrid/DataPanel.tsx +++ b/public/app/features/dashboard/dashgrid/DataPanel.tsx @@ -8,13 +8,21 @@ import { DatasourceSrv, getDatasourceSrv } from 'app/features/plugins/datasource // Utils import kbn from 'app/core/utils/kbn'; // Types -import { DataQueryOptions, DataQueryResponse, LoadingState, TimeRange, TimeSeries } from '@grafana/ui/src/types'; +import { + DataQueryOptions, + DataQueryResponse, + LoadingState, + PanelData, + TableData, + TimeRange, + TimeSeries, +} from '@grafana/ui'; const DEFAULT_PLUGIN_ERROR = 'Error in plugin'; interface RenderProps { loading: LoadingState; - timeSeries: TimeSeries[]; + panelData: PanelData; } export interface Props { @@ -127,9 +135,7 @@ export class DataPanel extends Component { 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; @@ -160,11 +166,27 @@ export class DataPanel extends Component { } }; + getPanelData = () => { + const { response } = this.state; + + if (response.data.length > 0 && (response.data[0] as TableData).type === 'table') { + return { + tableData: response.data[0] as TableData, + timeSeries: null, + }; + } + + return { + timeSeries: response.data as TimeSeries[], + tableData: null, + }; + }; + render() { const { queries } = this.props; - const { response, loading, isFirstLoad } = this.state; + const { loading, isFirstLoad } = this.state; - const timeSeries = response.data; + const panelData = this.getPanelData(); if (isFirstLoad && loading === LoadingState.Loading) { return this.renderLoadingStates(); @@ -190,8 +212,8 @@ export class DataPanel extends Component { return ( <> {this.props.children({ - timeSeries, loading, + panelData, })} ); diff --git a/public/app/features/dashboard/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index bdb6aca870a..b02d9479dcc 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -12,12 +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 { PanelModel } from '../state/PanelModel'; -import { DashboardModel } from '../state/DashboardModel'; +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'; @@ -94,16 +94,22 @@ export class PanelChrome extends PureComponent { 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 (
{ scopedVars={panel.scopedVars} links={panel.links} /> - {panel.snapshotData ? ( this.renderPanel(false, panel.snapshotData, width, height) ) : ( @@ -152,8 +157,8 @@ export class PanelChrome extends PureComponent { refreshCounter={refreshCounter} onDataResponse={this.onDataResponse} > - {({ loading, timeSeries }) => { - return this.renderPanel(loading, timeSeries, width, height); + {({ loading, panelData }) => { + return this.renderPanel(loading, panelData, width, height); }} )} diff --git a/public/app/features/dashboard/panel_editor/PanelEditor.tsx b/public/app/features/dashboard/panel_editor/PanelEditor.tsx index 7b8097b9f65..d7aafb89e55 100644 --- a/public/app/features/dashboard/panel_editor/PanelEditor.tsx +++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx @@ -101,17 +101,6 @@ export class PanelEditor extends PureComponent { return (
- { - //
- // - //
- //
- //
- //
- //
- //
- } -
{tabs.map(tab => { return ; diff --git a/public/app/features/dashboard/panel_editor/QueriesTab.tsx b/public/app/features/dashboard/panel_editor/QueriesTab.tsx index 140bb4b0fd7..d46ff020906 100644 --- a/public/app/features/dashboard/panel_editor/QueriesTab.tsx +++ b/public/app/features/dashboard/panel_editor/QueriesTab.tsx @@ -133,7 +133,7 @@ export class QueriesTab extends PureComponent { return ( <> -
+
{!isAddingMixed && (
- + {dataSource.name}
{dataSource.name} - {dataSource.isDefault && default} + {dataSource.isDefault && default}
{dataSource.url}
diff --git a/public/app/features/datasources/DataSourcesListPage.test.tsx b/public/app/features/datasources/DataSourcesListPage.test.tsx index 44ef7a1cc49..65077201f65 100644 --- a/public/app/features/datasources/DataSourcesListPage.test.tsx +++ b/public/app/features/datasources/DataSourcesListPage.test.tsx @@ -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, }; diff --git a/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap b/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap index a424276cf32..3ab1b1d53aa 100644 --- a/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap +++ b/public/app/features/datasources/__snapshots__/DataSourcesListItem.test.tsx.snap @@ -24,6 +24,7 @@ exports[`Render should render component 1`] = ` className="card-item-figure" > gdev-cloudwatch diff --git a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx index 8efc92be5be..204eeb8b1e9 100644 --- a/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx +++ b/public/app/features/datasources/settings/DataSourceSettingsPage.test.tsx @@ -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); diff --git a/public/app/features/datasources/state/actions.ts b/public/app/features/datasources/state/actions.ts index 0fa260ffafa..2e21b3066d1 100644 --- a/public/app/features/datasources/state/actions.ts +++ b/public/app/features/datasources/state/actions.ts @@ -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('LOAD_DATA_SOURCE').create(); -interface LoadDataSourcesAction { - type: ActionTypes.LoadDataSources; - payload: DataSourceSettings[]; -} +export const dataSourcesLoaded = actionCreatorFactory('LOAD_DATA_SOURCES').create(); -interface SetDataSourcesSearchQueryAction { - type: ActionTypes.SetDataSourcesSearchQuery; - payload: string; -} +export const dataSourceMetaLoaded = actionCreatorFactory('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('LOADED_DATA_SOURCE_TYPES').create(); -interface LoadedDataSourceTypesAction { - type: ActionTypes.LoadedDataSourceTypes; - payload: Plugin[]; -} +export const setDataSourcesSearchQuery = actionCreatorFactory('SET_DATA_SOURCES_SEARCH_QUERY').create(); -interface SetDataSourceTypeSearchQueryAction { - type: ActionTypes.SetDataSourceTypeSearchQuery; - payload: string; -} +export const setDataSourcesLayoutMode = actionCreatorFactory('SET_DATA_SOURCES_LAYOUT_MODE').create(); -interface LoadDataSourceAction { - type: ActionTypes.LoadDataSource; - payload: DataSourceSettings; -} - -interface LoadDataSourceMetaAction { - type: ActionTypes.LoadDataSourceMeta; - payload: Plugin; -} +export const setDataSourceTypeSearchQuery = actionCreatorFactory('SET_DATA_SOURCE_TYPE_SEARCH_QUERY').create(); -interface SetDataSourceNameAction { - type: ActionTypes.SetDataSourceName; - payload: string; -} - -interface SetIsDefaultAction { - type: ActionTypes.SetIsDefault; - payload: boolean; -} +export const setDataSourceName = actionCreatorFactory('SET_DATA_SOURCE_NAME').create(); -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('SET_IS_DEFAULT').create(); export type Action = - | LoadDataSourcesAction - | SetDataSourcesSearchQueryAction - | SetDataSourcesLayoutModeAction | UpdateLocationAction - | LoadDataSourceTypesAction - | LoadedDataSourceTypesAction - | SetDataSourceTypeSearchQueryAction - | LoadDataSourceAction | UpdateNavIndexAction - | LoadDataSourceMetaAction - | SetDataSourceNameAction - | SetIsDefaultAction; + | ActionOf + | ActionOf + | ActionOf + | ActionOf; type ThunkResult = ThunkAction; diff --git a/public/app/features/datasources/state/reducers.test.ts b/public/app/features/datasources/state/reducers.test.ts new file mode 100644 index 00000000000..540089f3e65 --- /dev/null +++ b/public/app/features/datasources/state/reducers.test.ts @@ -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 } }); + }); + }); +}); diff --git a/public/app/features/datasources/state/reducers.ts b/public/app/features/datasources/state/reducers.ts index 66151990aea..451e2d4650c 100644 --- a/public/app/features/datasources/state/reducers.ts +++ b/public/app/features/datasources/state/reducers.ts @@ -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, diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 909c4e81b8b..a2890427868 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -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 { // 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 { {showingStartPage && } {!showingStartPage && ( <> - {supportsGraph && } + {supportsGraph && !supportsLogs && } {supportsTable && } {supportsLogs && ( { // 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 (
{ } }; + 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 {
-
+
{QueryField ? ( { /> )}
-
+