diff --git a/CHANGELOG.md b/CHANGELOG.md index 7164f5d99a9..4cf2262f7d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ### Minor * **Pushover**: Adds support for images in pushover notifier [#10780](https://github.com/grafana/grafana/issues/10780), thx [@jpenalbae](https://github.com/jpenalbae) +* **Stackdriver**: Template variables in filters using globbing format [#15182](https://github.com/grafana/grafana/issues/15182) +* **Cloudwatch**: Add `resource_arns` template variable query function [#8207](https://github.com/grafana/grafana/issues/8207), thx [@jeroenvollenbrock](https://github.com/jeroenvollenbrock) +* **Cloudwatch**: Add AWS/Neptune metrics [#14231](https://github.com/grafana/grafana/issues/14231), thx [@tcpatterson](https://github.com/tcpatterson) +* **Cloudwatch**: Add AWS RDS ServerlessDatabaseCapacity metric [#15265](https://github.com/grafana/grafana/pull/15265), thx [@larsjoergensen](https://github.com/larsjoergensen) # 6.0.0-beta1 (2019-01-30) diff --git a/Gopkg.lock b/Gopkg.lock index d7795cbd6ba..dca36f1b3d0 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -37,6 +37,7 @@ "aws/credentials", "aws/credentials/ec2rolecreds", "aws/credentials/endpointcreds", + "aws/credentials/processcreds", "aws/credentials/stscreds", "aws/csm", "aws/defaults", @@ -45,13 +46,18 @@ "aws/request", "aws/session", "aws/signer/v4", + "internal/ini", + "internal/s3err", "internal/sdkio", "internal/sdkrand", + "internal/sdkuri", "internal/shareddefaults", "private/protocol", "private/protocol/ec2query", "private/protocol/eventstream", "private/protocol/eventstream/eventstreamapi", + "private/protocol/json/jsonutil", + "private/protocol/jsonrpc", "private/protocol/query", "private/protocol/query/queryutil", "private/protocol/rest", @@ -60,11 +66,13 @@ "service/cloudwatch", "service/ec2", "service/ec2/ec2iface", + "service/resourcegroupstaggingapi", + "service/resourcegroupstaggingapi/resourcegroupstaggingapiiface", "service/s3", "service/sts" ] - revision = "fde4ded7becdeae4d26bf1212916aabba79349b4" - version = "v1.14.12" + revision = "62936e15518acb527a1a9cb4a39d96d94d0fd9a2" + version = "v1.16.15" [[projects]] branch = "master" 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 +*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 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 d6cf21f7856..0ac5d439354 100644 --- a/devenv/docker/blocks/loki/docker-compose.yaml +++ b/devenv/docker/blocks/loki/docker-compose.yaml @@ -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 diff --git a/docs/sources/features/datasources/cloudwatch.md b/docs/sources/features/datasources/cloudwatch.md index 22f9f38c854..783b17874e0 100644 --- a/docs/sources/features/datasources/cloudwatch.md +++ b/docs/sources/features/datasources/cloudwatch.md @@ -74,6 +74,12 @@ Here is a minimal policy example: "ec2:DescribeRegions" ], "Resource": "*" + }, + { + "Sid": "AllowReadingResourcesForTags", + "Effect" : "Allow", + "Action" : "tag:GetResources", + "Resource" : "*" } ] } @@ -128,6 +134,7 @@ Name | Description *dimension_values(region, namespace, metric, dimension_key, [filters])* | Returns a list of dimension values matching the specified `region`, `namespace`, `metric`, `dimension_key` or you can use dimension `filters` to get more specific result as well. *ebs_volume_ids(region, instance_id)* | Returns a list of volume ids matching the specified `region`, `instance_id`. *ec2_instance_attribute(region, attribute_name, filters)* | Returns a list of attributes matching the specified `region`, `attribute_name`, `filters`. +*resource_arns(region, resource_type, tags)* | Returns a list of ARNs matching the specified `region`, `resource_type` and `tags`. For details about the metrics CloudWatch provides, please refer to the [CloudWatch documentation](https://docs.aws.amazon.com/AmazonCloudWatch/latest/DeveloperGuide/CW_Support_For_AWS.html). @@ -143,6 +150,8 @@ Query | Service *dimension_values(us-east-1,AWS/RDS,CPUUtilization,DBInstanceIdentifier)* | RDS *dimension_values(us-east-1,AWS/S3,BucketSizeBytes,BucketName)* | S3 *dimension_values(us-east-1,CWAgent,disk_used_percent,device,{"InstanceId":"$instance_id"})* | CloudWatch Agent +*resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})* | ELB +*resource_arns(eu-west-1,ec2:instance,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})* | EC2 ## ec2_instance_attribute examples diff --git a/docs/sources/installation/configuration.md b/docs/sources/installation/configuration.md index 46bab83654e..ac3dc6ebfd0 100644 --- a/docs/sources/installation/configuration.md +++ b/docs/sources/installation/configuration.md @@ -393,9 +393,7 @@ Analytics ID here. By default this feature is disabled. ### check_for_updates -Set to false to disable all checks to https://grafana.com for new versions of Grafana and installed plugins. Check is used -in some UI views to notify that a Grafana or plugin update exists. This option does not cause any auto updates, nor -send any sensitive information. +Set to false to disable all checks to https://grafana.com for new versions of installed plugins and to the Grafana GitHub repository to check for a newer version of Grafana. The version information is used in some UI views to notify that a new Grafana update or a plugin update exists. This option does not cause any auto updates, nor send any sensitive information. The check is run every 10 minutes.
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 1d2151a0627..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/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, }; 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.story.tsx b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx new file mode 100644 index 00000000000..85504f6cd09 --- /dev/null +++ b/packages/grafana-ui/src/components/ValueMappingsEditor/ValueMappingsEditor.story.tsx @@ -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 ; +}); 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/datasource.ts b/packages/grafana-ui/src/types/datasource.ts index 44d38ff20e2..e34cf25dc01 100644 --- a/packages/grafana-ui/src/types/datasource.ts +++ b/packages/grafana-ui/src/types/datasource.ts @@ -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 { diff --git a/packages/grafana-ui/src/types/panel.ts b/packages/grafana-ui/src/types/panel.ts index ad09b3aba9f..4eda85f9a28 100644 --- a/packages/grafana-ui/src/types/panel.ts +++ b/packages/grafana-ui/src/types/panel.ts @@ -4,7 +4,7 @@ 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; diff --git a/packages/grafana-ui/src/types/plugin.ts b/packages/grafana-ui/src/types/plugin.ts index 420a54e5840..c8f156c08dc 100644 --- a/packages/grafana-ui/src/types/plugin.ts +++ b/packages/grafana-ui/src/types/plugin.ts @@ -1,6 +1,6 @@ import { ComponentClass } from 'react'; import { PanelProps, PanelOptionsProps } from './panel'; -import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint } from './datasource'; +import { DataQueryOptions, DataQuery, DataQueryResponse, QueryHint, QueryFixAction } from './datasource'; export interface DataSourceApi { /** @@ -41,22 +41,43 @@ export interface DataSourceApi { pluginExports?: PluginExports; } +export interface ExploreDataSourceApi extends DataSourceApi { + modifyQuery?(query: TQuery, action: QueryFixAction): TQuery; + getHighlighterExpression?(query: TQuery): string; + languageProvider?: any; +} + export interface QueryEditorProps { datasource: DSType; query: TQuery; + onRunQuery: () => void; + onChange: (value: TQuery) => void; +} + +export interface ExploreQueryFieldProps { + datasource: DSType; + query: TQuery; + error?: string | JSX.Element; + hint?: QueryHint; + history: any[]; onExecuteQuery?: () => void; onQueryChange?: (value: TQuery) => void; + onExecuteHint?: (action: QueryFixAction) => void; +} + +export interface ExploreStartPageProps { + onClickExample: (query: DataQuery) => void; } export interface PluginExports { Datasource?: DataSourceApi; QueryCtrl?: any; - QueryEditor?: ComponentClass>; + QueryEditor?: ComponentClass>; ConfigCtrl?: any; AnnotationsQueryCtrl?: any; VariableQueryEditor?: any; - ExploreQueryField?: any; - ExploreStartPage?: any; + ExploreQueryField?: ComponentClass>; + ExploreStartPage?: ComponentClass; // Panel plugin PanelCtrl?: any; @@ -114,5 +135,3 @@ export interface PluginMetaInfo { updated: string; version: string; } - - 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/api/common_test.go b/pkg/api/common_test.go index eb1f89e3f22..fe02c94e277 100644 --- a/pkg/api/common_test.go +++ b/pkg/api/common_test.go @@ -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 } diff --git a/pkg/api/login.go b/pkg/api/login.go index 50c62e0835a..49da147724e 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -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) diff --git a/pkg/middleware/middleware_test.go b/pkg/middleware/middleware_test.go index 11740574d0b..4679c449853 100644 --- a/pkg/middleware/middleware_test.go +++ b/pkg/middleware/middleware_test.go @@ -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 } diff --git a/pkg/services/auth/auth_token.go b/pkg/services/auth/auth_token.go index db4d9d18624..13b9ef607f5 100644 --- a/pkg/services/auth/auth_token.go +++ b/pkg/services/auth/auth_token.go @@ -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) { diff --git a/pkg/services/auth/auth_token_test.go b/pkg/services/auth/auth_token_test.go index 2f75c660d9d..312e53a3970 100644 --- a/pkg/services/auth/auth_token_test.go +++ b/pkg/services/auth/auth_token_test.go @@ -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() { 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/cloudwatch.go b/pkg/tsdb/cloudwatch/cloudwatch.go index 8bb1ab6c928..8d67fe7db8c 100644 --- a/pkg/tsdb/cloudwatch/cloudwatch.go +++ b/pkg/tsdb/cloudwatch/cloudwatch.go @@ -21,6 +21,7 @@ import ( "github.com/aws/aws-sdk-go/aws/request" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" "github.com/grafana/grafana/pkg/components/null" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/metrics" @@ -28,7 +29,8 @@ import ( type CloudWatchExecutor struct { *models.DataSource - ec2Svc ec2iface.EC2API + ec2Svc ec2iface.EC2API + rgtaSvc resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI } type DatasourceInfo struct { diff --git a/pkg/tsdb/cloudwatch/metric_find_query.go b/pkg/tsdb/cloudwatch/metric_find_query.go index dfa03d2dfa9..34181d19673 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query.go +++ b/pkg/tsdb/cloudwatch/metric_find_query.go @@ -15,6 +15,7 @@ import ( "github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/metrics" "github.com/grafana/grafana/pkg/tsdb" @@ -95,10 +96,11 @@ 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"}, - "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, + "AWS/RDS": {"ActiveTransactions", "AuroraBinlogReplicaLag", "AuroraReplicaLag", "AuroraReplicaLagMaximum", "AuroraReplicaLagMinimum", "BinLogDiskUsage", "BlockedTransactions", "BufferCacheHitRatio", "BurstBalance", "CommitLatency", "CommitThroughput", "BinLogDiskUsage", "CPUCreditBalance", "CPUCreditUsage", "CPUUtilization", "DatabaseConnections", "DDLLatency", "DDLThroughput", "Deadlocks", "DeleteLatency", "DeleteThroughput", "DiskQueueDepth", "DMLLatency", "DMLThroughput", "EngineUptime", "FailedSqlStatements", "FreeableMemory", "FreeLocalStorage", "FreeStorageSpace", "InsertLatency", "InsertThroughput", "LoginFailures", "NetworkReceiveThroughput", "NetworkTransmitThroughput", "NetworkThroughput", "Queries", "ReadIOPS", "ReadLatency", "ReadThroughput", "ReplicaLag", "ResultSetCacheHitRatio", "SelectLatency", "SelectThroughput", "ServerlessDatabaseCapacity", "SwapUsage", "TotalConnections", "UpdateLatency", "UpdateThroughput", "VolumeBytesUsed", "VolumeReadIOPS", "VolumeWriteIOPS", "WriteIOPS", "WriteLatency", "WriteThroughput"}, "AWS/Route53": {"ChildHealthCheckHealthyCount", "HealthCheckStatus", "HealthCheckPercentageHealthy", "ConnectionTime", "SSLHandshakeTime", "TimeToFirstByte"}, "AWS/S3": {"BucketSizeBytes", "NumberOfObjects", "AllRequests", "GetRequests", "PutRequests", "DeleteRequests", "HeadRequests", "PostRequests", "ListRequests", "BytesDownloaded", "BytesUploaded", "4xxErrors", "5xxErrors", "FirstByteLatency", "TotalRequestLatency"}, "AWS/SES": {"Bounce", "Complaint", "Delivery", "Reject", "Send", "Reputation.BounceRate", "Reputation.ComplaintRate"}, @@ -149,6 +151,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"}, @@ -198,6 +201,8 @@ func (e *CloudWatchExecutor) executeMetricFindQuery(ctx context.Context, queryCo data, err = e.handleGetEbsVolumeIds(ctx, parameters, queryContext) case "ec2_instance_attribute": data, err = e.handleGetEc2InstanceAttribute(ctx, parameters, queryContext) + case "resource_arns": + data, err = e.handleGetResourceArns(ctx, parameters, queryContext) } transformToTable(data, queryResult) @@ -534,6 +539,65 @@ func (e *CloudWatchExecutor) handleGetEc2InstanceAttribute(ctx context.Context, return result, nil } +func (e *CloudWatchExecutor) ensureRGTAClientSession(region string) error { + if e.rgtaSvc == nil { + dsInfo := e.getDsInfo(region) + cfg, err := e.getAwsConfig(dsInfo) + if err != nil { + return fmt.Errorf("Failed to call ec2:getAwsConfig, %v", err) + } + sess, err := session.NewSession(cfg) + if err != nil { + return fmt.Errorf("Failed to call ec2:NewSession, %v", err) + } + e.rgtaSvc = resourcegroupstaggingapi.New(sess, cfg) + } + return nil +} + +func (e *CloudWatchExecutor) handleGetResourceArns(ctx context.Context, parameters *simplejson.Json, queryContext *tsdb.TsdbQuery) ([]suggestData, error) { + region := parameters.Get("region").MustString() + resourceType := parameters.Get("resourceType").MustString() + filterJson := parameters.Get("tags").MustMap() + + err := e.ensureRGTAClientSession(region) + if err != nil { + return nil, err + } + + var filters []*resourcegroupstaggingapi.TagFilter + for k, v := range filterJson { + if vv, ok := v.([]interface{}); ok { + var vvvvv []*string + for _, vvv := range vv { + if vvvv, ok := vvv.(string); ok { + vvvvv = append(vvvvv, &vvvv) + } + } + filters = append(filters, &resourcegroupstaggingapi.TagFilter{ + Key: aws.String(k), + Values: vvvvv, + }) + } + } + + var resourceTypes []*string + resourceTypes = append(resourceTypes, &resourceType) + + resources, err := e.resourceGroupsGetResources(region, filters, resourceTypes) + if err != nil { + return nil, err + } + + result := make([]suggestData, 0) + for _, resource := range resources.ResourceTagMappingList { + data := *resource.ResourceARN + result = append(result, suggestData{Text: data, Value: data}) + } + + return result, nil +} + func (e *CloudWatchExecutor) cloudwatchListMetrics(region string, namespace string, metricName string, dimensions []*cloudwatch.DimensionFilter) (*cloudwatch.ListMetricsOutput, error) { svc, err := e.getClient(region) if err != nil { @@ -585,6 +649,28 @@ func (e *CloudWatchExecutor) ec2DescribeInstances(region string, filters []*ec2. return &resp, nil } +func (e *CloudWatchExecutor) resourceGroupsGetResources(region string, filters []*resourcegroupstaggingapi.TagFilter, resourceTypes []*string) (*resourcegroupstaggingapi.GetResourcesOutput, error) { + params := &resourcegroupstaggingapi.GetResourcesInput{ + ResourceTypeFilters: resourceTypes, + TagFilters: filters, + } + + var resp resourcegroupstaggingapi.GetResourcesOutput + err := e.rgtaSvc.GetResourcesPages(params, + func(page *resourcegroupstaggingapi.GetResourcesOutput, lastPage bool) bool { + resources, _ := awsutil.ValuesAtPath(page, "ResourceTagMappingList") + for _, resource := range resources { + resp.ResourceTagMappingList = append(resp.ResourceTagMappingList, resource.(*resourcegroupstaggingapi.ResourceTagMapping)) + } + return !lastPage + }) + if err != nil { + return nil, errors.New("Failed to call tags:GetResources") + } + + return &resp, nil +} + func getAllMetrics(cwData *DatasourceInfo) (cloudwatch.ListMetricsOutput, error) { creds, err := GetCredentials(cwData) if err != nil { diff --git a/pkg/tsdb/cloudwatch/metric_find_query_test.go b/pkg/tsdb/cloudwatch/metric_find_query_test.go index 34c3379b4df..bc6c8b163a0 100644 --- a/pkg/tsdb/cloudwatch/metric_find_query_test.go +++ b/pkg/tsdb/cloudwatch/metric_find_query_test.go @@ -8,6 +8,8 @@ import ( "github.com/aws/aws-sdk-go/service/cloudwatch" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/ec2/ec2iface" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi" + "github.com/aws/aws-sdk-go/service/resourcegroupstaggingapi/resourcegroupstaggingapiiface" "github.com/bmizerany/assert" "github.com/grafana/grafana/pkg/components/securejsondata" "github.com/grafana/grafana/pkg/components/simplejson" @@ -22,6 +24,11 @@ type mockedEc2 struct { RespRegions ec2.DescribeRegionsOutput } +type mockedRGTA struct { + resourcegroupstaggingapiiface.ResourceGroupsTaggingAPIAPI + Resp resourcegroupstaggingapi.GetResourcesOutput +} + func (m mockedEc2) DescribeInstancesPages(in *ec2.DescribeInstancesInput, fn func(*ec2.DescribeInstancesOutput, bool) bool) error { fn(&m.Resp, true) return nil @@ -30,6 +37,11 @@ func (m mockedEc2) DescribeRegions(in *ec2.DescribeRegionsInput) (*ec2.DescribeR return &m.RespRegions, nil } +func (m mockedRGTA) GetResourcesPages(in *resourcegroupstaggingapi.GetResourcesInput, fn func(*resourcegroupstaggingapi.GetResourcesOutput, bool) bool) error { + fn(&m.Resp, true) + return nil +} + func TestCloudWatchMetrics(t *testing.T) { Convey("When calling getMetricsForCustomMetrics", t, func() { @@ -209,6 +221,51 @@ func TestCloudWatchMetrics(t *testing.T) { So(result[7].Text, ShouldEqual, "vol-4-2") }) }) + + Convey("When calling handleGetResourceArns", t, func() { + executor := &CloudWatchExecutor{ + rgtaSvc: mockedRGTA{ + Resp: resourcegroupstaggingapi.GetResourcesOutput{ + ResourceTagMappingList: []*resourcegroupstaggingapi.ResourceTagMapping{ + { + ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567"), + Tags: []*resourcegroupstaggingapi.Tag{ + { + Key: aws.String("Environment"), + Value: aws.String("production"), + }, + }, + }, + { + ResourceARN: aws.String("arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321"), + Tags: []*resourcegroupstaggingapi.Tag{ + { + Key: aws.String("Environment"), + Value: aws.String("production"), + }, + }, + }, + }, + }, + }, + } + + json := simplejson.New() + json.Set("region", "us-east-1") + json.Set("resourceType", "ec2:instance") + tags := make(map[string]interface{}) + tags["Environment"] = []string{"production"} + json.Set("tags", tags) + result, _ := executor.handleGetResourceArns(context.Background(), json, &tsdb.TsdbQuery{}) + + Convey("Should return all two instances", func() { + So(result[0].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567") + So(result[0].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-12345678901234567") + So(result[1].Text, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321") + So(result[1].Value, ShouldEqual, "arn:aws:ec2:us-east-1:123456789012:instance/i-76543210987654321") + + }) + }) } func TestParseMultiSelectValue(t *testing.T) { 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 { - 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, "", "") +} 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/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..107f411353c 100644 --- a/public/app/core/utils/explore.ts +++ b/public/app/core/utils/explore.ts @@ -11,7 +11,7 @@ import { colors } from '@grafana/ui'; import TableModel, { mergeTablesIntoModel } from 'app/core/table_model'; // Types -import { RawTimeRange, IntervalValues, DataQuery } from '@grafana/ui/src/types'; +import { RawTimeRange, IntervalValues, DataQuery, DataSourceApi } from '@grafana/ui/src/types'; import TimeSeries from 'app/core/time_series2'; import { ExploreUrlState, @@ -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); } @@ -304,3 +336,12 @@ export function clearHistory(datasourceId: string) { const historyKey = `grafana.explore.history.${datasourceId}`; store.delete(historyKey); } + +export const getQueryKeys = (queries: DataQuery[], datasourceInstance: DataSourceApi): string[] => { + const queryKeys = queries.reduce((newQueryKeys, query, index) => { + const primaryKey = datasourceInstance && datasourceInstance.name ? datasourceInstance.name : query.key; + return newQueryKeys.concat(`${primaryKey}-${index}`); + }, []); + + return queryKeys; +}; diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx new file mode 100644 index 00000000000..91da066e4cc --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.test.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { shallow } from 'enzyme'; +import { AddPanelWidget, Props } from './AddPanelWidget'; +import { DashboardModel, PanelModel } from '../../state'; + +const setup = (propOverrides?: object) => { + const props: Props = { + dashboard: {} as DashboardModel, + panel: {} as PanelModel, + }; + + Object.assign(props, propOverrides); + + return shallow(); +}; + +describe('Render', () => { + it('should render component', () => { + const wrapper = setup(); + + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx index 8c1ab93cec1..135b04a8ac5 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx +++ b/public/app/features/dashboard/components/AddPanelWidget/AddPanelWidget.tsx @@ -1,12 +1,20 @@ +// Libraries import React from 'react'; import _ from 'lodash'; + +// Utils import config from 'app/core/config'; -import { PanelModel } from '../../state/PanelModel'; -import { DashboardModel } from '../../state/DashboardModel'; import store from 'app/core/store'; -import { LS_PANEL_COPY_KEY } from 'app/core/constants'; -import { updateLocation } from 'app/core/actions'; + +// Store import { store as reduxStore } from 'app/store/store'; +import { updateLocation } from 'app/core/actions'; + +// Types +import { PanelModel } from '../../state'; +import { DashboardModel } from '../../state'; +import { LS_PANEL_COPY_KEY } from 'app/core/constants'; +import { LocationUpdate } from 'app/types'; export interface Props { panel: PanelModel; @@ -46,6 +54,7 @@ export class AddPanelWidget extends React.Component { copiedPanels.push(pluginCopy); } } + return _.sortBy(copiedPanels, 'sort'); } @@ -54,28 +63,7 @@ export class AddPanelWidget extends React.Component { this.props.dashboard.removePanel(this.props.dashboard.panels[0]); } - copyButton(panel) { - return ( - - ); - } - - moveToEdit(panel) { - reduxStore.dispatch( - updateLocation({ - query: { - panelId: panel.id, - edit: true, - fullscreen: true, - }, - partial: true, - }) - ); - } - - onCreateNewPanel = () => { + onCreateNewPanel = (tab = 'queries') => { const dashboard = this.props.dashboard; const { gridPos } = this.props.panel; @@ -88,7 +76,21 @@ export class AddPanelWidget extends React.Component { dashboard.addPanel(newPanel); dashboard.removePanel(this.props.panel); - this.moveToEdit(newPanel); + const location: LocationUpdate = { + query: { + panelId: newPanel.id, + edit: true, + fullscreen: true, + }, + partial: true, + }; + + if (tab === 'visualization') { + location.query.tab = 'visualization'; + location.query.openVizPicker = true; + } + + reduxStore.dispatch(updateLocation(location)); }; onPasteCopiedPanel = panelPluginInfo => { @@ -125,30 +127,50 @@ export class AddPanelWidget extends React.Component { dashboard.removePanel(this.props.panel); }; - render() { - let addCopyButton; + renderOptionLink = (icon, text, onClick) => { + return ( + + ); + }; - if (this.state.copiedPanelPlugins.length === 1) { - addCopyButton = this.copyButton(this.state.copiedPanelPlugins[0]); - } + render() { + const { copiedPanelPlugins } = this.state; return (
+ New Panel
- - {addCopyButton} - +
+ {this.renderOptionLink('queries', 'Add Query', this.onCreateNewPanel)} + {this.renderOptionLink('visualization', 'Choose Visualization', () => + this.onCreateNewPanel('visualization') + )} +
+
+ + {copiedPanelPlugins.length === 1 && ( + + )} +
diff --git a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss index 5a1cbee4b44..288b2e7a410 100644 --- a/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss +++ b/public/app/features/dashboard/components/AddPanelWidget/_AddPanelWidget.scss @@ -14,6 +14,9 @@ align-items: center; width: 100%; cursor: move; + background: $page-header-bg; + box-shadow: $page-header-shadow; + border-bottom: 1px solid $page-header-border-color; .gicon { font-size: 30px; @@ -26,6 +29,29 @@ } } +.add-panel-widget__title { + font-size: $font-size-md; + font-weight: $font-weight-semi-bold; + margin-right: $spacer*2; +} + +.add-panel-widget__link { + margin: 0 8px; + width: 154px; +} + +.add-panel-widget__icon { + margin-bottom: 8px; + + .gicon { + color: white; + height: 44px; + width: 53px; + position: relative; + left: 5px; + } +} + .add-panel-widget__close { margin-left: auto; background-color: transparent; @@ -34,14 +60,25 @@ margin-right: -10px; } +.add-panel-widget__create { + display: inherit; + margin-bottom: 24px; + // this is to have the big button appear centered + margin-top: 55px; +} + +.add-panel-widget__actions { + display: inherit; +} + +.add-panel-widget__action { + margin: 0 4px; +} + .add-panel-widget__btn-container { + height: 100%; display: flex; justify-content: center; align-items: center; - height: 100%; flex-direction: column; - - .btn { - margin-bottom: 10px; - } } diff --git a/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap new file mode 100644 index 00000000000..00faf48d8df --- /dev/null +++ b/public/app/features/dashboard/components/AddPanelWidget/__snapshots__/AddPanelWidget.test.tsx.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render should render component 1`] = ` +
+
+
+ + + New Panel + + +
+
+ +
+ +
+
+
+
+`; 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/dashgrid/PanelChrome.tsx b/public/app/features/dashboard/dashgrid/PanelChrome.tsx index 99e206c6f51..b02d9479dcc 100644 --- a/public/app/features/dashboard/dashgrid/PanelChrome.tsx +++ b/public/app/features/dashboard/dashgrid/PanelChrome.tsx @@ -94,7 +94,7 @@ 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; @@ -109,7 +109,7 @@ export class PanelChrome extends PureComponent {
{ onDataResponse={this.onDataResponse} > {({ loading, panelData }) => { - return this.renderPanel(loading, panelData.timeSeries, width, height); + 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 d7aafb89e55..bfdc13bc8f2 100644 --- a/public/app/features/dashboard/panel_editor/PanelEditor.tsx +++ b/public/app/features/dashboard/panel_editor/PanelEditor.tsx @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react'; import classNames from 'classnames'; import { QueriesTab } from './QueriesTab'; -import { VisualizationTab } from './VisualizationTab'; +import VisualizationTab from './VisualizationTab'; import { GeneralTab } from './GeneralTab'; import { AlertTab } from '../../alerting/AlertTab'; @@ -38,7 +38,7 @@ export class PanelEditor extends PureComponent { onChangeTab = (tab: PanelEditorTab) => { store.dispatch( updateLocation({ - query: { tab: tab.id }, + query: { tab: tab.id, openVizPicker: null }, partial: true, }) ); 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/__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/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index 909c4e81b8b..b210bcccc18 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -1,5 +1,5 @@ // Libraries -import React from 'react'; +import React, { ComponentClass } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; import _ from 'lodash'; @@ -18,34 +18,26 @@ import TableContainer from './TableContainer'; import TimePicker, { parseTime } from './TimePicker'; // Actions -import { - changeSize, - changeTime, - initializeExplore, - modifyQueries, - scanStart, - scanStop, - setQueries, -} from './state/actions'; +import { changeSize, changeTime, initializeExplore, modifyQueries, scanStart, setQueries } from './state/actions'; // Types -import { RawTimeRange, TimeRange, DataQuery } from '@grafana/ui'; +import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } 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'; +import { scanStopAction } from './state/actionTypes'; interface ExploreProps { - StartPage?: any; + StartPage?: ComponentClass; changeSize: typeof changeSize; changeTime: typeof changeTime; datasourceError: string; - datasourceInstance: any; + datasourceInstance: ExploreDataSourceApi; datasourceLoading: boolean | null; datasourceMissing: boolean; exploreId: ExploreId; - initialQueries: DataQuery[]; initializeExplore: typeof initializeExplore; initialized: boolean; modifyQueries: typeof modifyQueries; @@ -54,7 +46,7 @@ interface ExploreProps { scanning?: boolean; scanRange?: RawTimeRange; scanStart: typeof scanStart; - scanStop: typeof scanStop; + scanStopAction: typeof scanStopAction; setQueries: typeof setQueries; split: boolean; showingStartPage?: boolean; @@ -62,6 +54,7 @@ interface ExploreProps { supportsLogs: boolean | null; supportsTable: boolean | null; urlState: ExploreUrlState; + queryKeys: string[]; } /** @@ -107,18 +100,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 ); } } @@ -171,7 +166,7 @@ export class Explore extends React.PureComponent { }; onStopScanning = () => { - this.props.scanStop(this.props.exploreId); + this.props.scanStopAction({ exploreId: this.props.exploreId }); }; render() { @@ -182,12 +177,12 @@ export class Explore extends React.PureComponent { datasourceLoading, datasourceMissing, exploreId, - initialQueries, showingStartPage, split, supportsGraph, supportsLogs, supportsTable, + queryKeys, } = this.props; const exploreClass = split ? 'explore explore-split' : 'explore'; @@ -208,7 +203,7 @@ export class Explore extends React.PureComponent { {datasourceInstance && !datasourceError && (
- + {({ width }) => (
@@ -216,7 +211,7 @@ export class Explore extends React.PureComponent { {showingStartPage && } {!showingStartPage && ( <> - {supportsGraph && } + {supportsGraph && !supportsLogs && } {supportsTable && } {supportsLogs && ( { this.props.runQuery(this.props.exploreId); }; + onCloseTimePicker = () => { + this.props.timepickerRef.current.setState({ isOpen: false }); + }; + render() { const { datasourceMissing, @@ -137,7 +142,9 @@ export class UnConnectedExploreToolbar extends PureComponent {
) : null}
- + + +