Compare commits

...

3 Commits

Author SHA1 Message Date
Torkel Ödegaard
aa7659d1dd Build: fixed signing script issue with circle-ci (#19397)
(cherry picked from commit 680a22b898)
2019-09-25 11:56:22 +02:00
Marcus Efraimsson
199031a6e2 Cherry picks for v6.4.0-beta2 (#19378)
* API: adds redirect helper to simplify http redirects (#19180)

(cherry picked from commit dd794625dd)

* Dashboard: Fixes back button styles in kiosk mode (#19165)

Fixes: #18114
(cherry picked from commit 38e948a1ad)

* Menu: fix menu button in the mobile view (#19191)

* replace "sandwich" (menu) button with logo(back home) if kiosk=tv
* update navbar initialize padding-left befause menu button is overlapped by the navbar
(cherry picked from commit 5ef40b259d)

* LDAP debug page: deduplicate errors (#19168)

(cherry picked from commit 6b2e95a1f2)

* MSSQL: Revert usage of new connectionstring format (#19203)

This reverts commit 2514209 from #18384. Reason is that it doesn't
work due to xorm 0.7.1 which doesn't support this new connectionstring
format.

Fixes #19189
Ref #18384
Ref #17665
(cherry picked from commit 0f524fc947)

* Docker: Upgrade packages to resolve reported vulnerabilities (#19188)

Fixes #19186
(cherry picked from commit 4d96bc590f)

* FieldDisplay: Update title variable syntax (#19217)

(cherry picked from commit 14f1cf29f0)

* Cloudwatch: Fix autocomplete for Gamelift dimensions (#19145) (#19146)

(cherry picked from commit 79f8433675)

* grafana/ui: Add disabled prop on LinkButton (#19192)

(cherry picked from commit f445369d68)

* plugins: expose whole rxjs to plugins (#19226)

(cherry picked from commit 98c95a8a83)

* Snapshots: store DataFrameDTO instead of MutableDataFrame in snapshot data (#19247)

(cherry picked from commit be8097fca2)

* grafana/toolkit: Add plugin scaffolding (#19207)

(cherry picked from commit 54ebf174a0)

* Alerting: Truncate PagerDuty summary when greater than 1024 characters (#18730)

Requests to PagerDuty fail with an HTTP 400 if the `summary`
attribute contains more than 1024 characters, this fixes this.
API spec:
https://v2.developer.pagerduty.com/docs/send-an-event-events-api-v2

Fixes #18727
(cherry picked from commit 8a991244d5)

* grafana/toolkit: Fix toolkit not building @grafana/toolkit (#19253)

* Fix toolkit not building

Weird TS didn't pick this up...

* Update packages/grafana-toolkit/src/cli/index.ts

(cherry picked from commit 809e2ca3c7)

* Docs: Update theming docs (#19248)

(cherry picked from commit 9feac7753b)

* Explore: live tail UI fixes and improvements (#19187)

(cherry picked from commit bf24cbba76)

* Graphite: Changed range expansion from 1m to 1s (#19246)

Fixes #11472
(cherry picked from commit d95318b325)

* MySQL, Postgres, MSSQL: Only debug log when in development (#19239)

Found some additional debug statements in relation to #19049 that
can cause memory issues.

Ref #19049
(cherry picked from commit 19f3ec4891)

* Vector: remove toJSON() from interface (#19254)

(cherry picked from commit 6787e7b5ab)

* Update changelog task to generate toolkit changelog too (#19262)

(cherry picked from commit b7752b8c02)

* Dashboard: Hides alpha icon for visualization that is not in alpha/beta stage #19300

Fixes #19251
(cherry picked from commit f01836c17a)

* Build: Split up task in the CI pipeline to ease running outside circleci (#18861)

* build: make sign rpm packages not depend on checking out private key

* build: move commands from circleci config into verify signed packages script

* build: split update and publish of deb and rpm into two scripts

* use files argument for sign and verify packages

* validate files argument for sign and verify packages

* update test publish of deb/rpm readme

(cherry picked from commit 4386604751)

* Admin/user: fix textarea postion in 'Pending Invites' to avoid page scrolling (#19288)

* hide textarea element after click 'Copy Invite' button on firefox
(cherry picked from commit 50b4695cf5)

* Alerting: Prevents creating alerts from unsupported queries (#19250)

* Refactor: Makes PanelEditor use state and shows validation message on AlerTab

* Refactor: Makes validation message nicer looking

* Refactor: Changes imports

* Refactor: Removes conditional props

* Refactor: Changes after feedback from PR review

* Refactor: Removes unused action

(cherry picked from commit 9bd6ed887c)

* Chore: Update Slate to 0.47.8 (#19197)

* Chore: Update Slate to 0.47.8
Closes #17430

(cherry picked from commit 68d6da77da)

* DataLinks: Small UX improvements to DataLinksInput (#19313)

Closes #19257
(cherry picked from commit feb6bc6747)

* Multi-LDAP: Do not fail-fast on invalid credentials (#19261)

* Multi-LDAP: Do not fail-fast on invalid credentials

When configuring LDAP authentication, it is very common to have multiple
servers configured. When using user bind (authenticating with LDAP using
the same credentials as the user authenticating to Grafana) we don't
expect all the users to be on all LDAP servers.

Because of this use-case, we should not fail-fast when authenticating on
multiple LDAP server configurations. Instead, we should continue to try
the credentials with the next LDAP server configured.

Fixes #19066
(cherry picked from commit 279249ef56)

* Explore: Fix unsubscribing from Loki websocket (#19263)

(cherry picked from commit 4c1bc59889)

* Plugins: Skips existence of module.js for renderer plugins (#19318)

* Fix: Skips test for module.js for plugins of renderer type
Fixes #19130

* Refactor: Changes after PR comments

* Chore: Fixes go lint issue

(cherry picked from commit 75dcaecc99)

* Keybindings: Improve esc / exit / blur logic (#19320)

* Keybindings: Improve esc / exit / blur logic

* Slight modifications

* removed use of jquery

(cherry picked from commit 08cc4f0c8a)

* Select: Set placeholder color (#19309)

(cherry picked from commit 2c9577fcc5)

* Azure Monitor: Revert support for cross resource queries (#19115)" (#19346)

This reverts commit 88051258e9.
(cherry picked from commit 4dbedb8405)

* Dashboard: Fix export for sharing when panels use default data source (#19315)

* PanelModel: moved datasource: null away from defaults that are removed

* Added unit test

(cherry picked from commit ac3fb6452d)

* Heatmap: use DataFrame rather than LegacyResponseData (#19026)

* merge master

* TimeSeries: datasources with labels should export tags (not labels) (#18977)

* merge master

* export prometheus tags

* Annotations: Add annotations support to Loki (#18949)

* Explore: Unify background color for fresh logs (#18973)

* Singlestat: render lines on the panel when sparklines are enabled (#18984)

* Image rendering: Add deprecation warning when PhantomJS is used for rendering images (#18933)

* Add deprecation warning

* Update pkg/services/rendering/rendering.go

Co-Authored-By: Marcus Efraimsson <marcus.efraimsson@gmail.com>

* Units: Adding T,P,E,Z,and Y bytes (#18706)

* Adding T and P for bytes

Luckily, all the hard work was done before; just added in these prefixes for our production environment.

* Future-proofing with other values (why not?)

* Yottaflops?

* Cutting back down to Peta sizes, except for hashes

* Refactor: move ScopedVars to grafana/data (#18992)

* Refactor: Move sql_engine to sub package of tsdb (#18991)

this way importing the tsdb package does not come with xorm dependencies

* use DataFrame in heatmaps

* actually use the setting :)

* remove unused timeSrv

* merge with master / useDataFrames

* fix test function

* merge master

* fix datasource type on snapshot

* reuse DataFrame calcs from graph panel

* update comments

(cherry picked from commit 2474511d03)

* Explore: Do not send explicit maxDataPoints for logs. (#19235)

(cherry picked from commit f203e82b40)

* MySQL, Postgres, MSSQL: Fix validating query with template variables in alert  (#19237)

Adds support for validating query in alert for mysql,
postgres and mssql.

Fixes #13155
(cherry picked from commit 96046a7ba6)

* MySQL, Postgres: Update raw sql when query builder updates (#19209)

Raw sql now updates when changing query using
graphical query editor for mysql and postgres.

Fixes #19063
(cherry picked from commit 7c499ffdd8)

* MySQL: Limit datasource error details returned from the backend (#19373)

Only return certain mysql errors from backend.
The following errors is returned as is from backend:
error code 1064 (parse error)
error code 1054 (bad column/field selected)
error code 1146 (table not exists)
Any other errors is logged and returned as a generic
error.
Restrict use of certain functions:
Do not allow usage of the following in query:
system_user()
session_user()
current_user() or current_user
user()
show grants

Fixes #19360
(cherry picked from commit 3de693af49)

* SQL: Rewrite statistics query (#19178)

* Rewrite statistics query
(cherry picked from commit 56f5106717)

* Release v6.4.0-beta2

* ValueFormats: check for inf (#19376)


(cherry picked from commit 32b73bb496)

* Build: Fix correct sort order of merged pr's in cherrypick task (#19379)


(cherry picked from commit c4a03f482c)
2019-09-25 09:49:55 +02:00
Dominik Prokop
10d47ab095 release 6.4.0-beta.1 2019-09-17 13:29:14 +02:00
182 changed files with 4195 additions and 3038 deletions

View File

@@ -214,15 +214,15 @@ jobs:
- run:
name: build and package grafana
command: './scripts/build/build-all.sh'
- run:
name: Prepare GPG private key
command: './scripts/build/prepare_signing_key.sh'
- run:
name: sign packages
command: './scripts/build/sign_packages.sh'
command: './scripts/build/sign_packages.sh dist/*.rpm'
- run:
name: verify signed packages
command: |
mkdir -p ~/.rpmdb/pubkeys
curl -s https://packages.grafana.com/gpg.key > ~/.rpmdb/pubkeys/grafana.key
./scripts/build/verify_signed_packages.sh dist/*.rpm
command: './scripts/build/verify_signed_packages.sh dist/*.rpm'
- run:
name: sha-sum packages
command: 'go run build.go sha-dist'
@@ -249,9 +249,12 @@ jobs:
- run:
name: build and package grafana
command: './scripts/build/build.sh'
- run:
name: Prepare GPG private key
command: './scripts/build/prepare_signing_key.sh'
- run:
name: sign packages
command: './scripts/build/sign_packages.sh'
command: './scripts/build/sign_packages.sh dist/*.rpm'
- run:
name: sha-sum packages
command: 'go run build.go sha-dist'
@@ -360,9 +363,12 @@ jobs:
- run:
name: package grafana
command: './scripts/build/build.sh --fast --package-only'
- run:
name: Prepare GPG private key
command: './scripts/build/prepare_signing_key.sh'
- run:
name: sign packages
command: './scripts/build/sign_packages.sh'
command: './scripts/build/sign_packages.sh dist/*.rpm'
- run:
name: sha-sum packages
command: 'go run build.go sha-dist'
@@ -435,9 +441,12 @@ jobs:
- run:
name: build and package enterprise
command: './scripts/build/build.sh -enterprise'
- run:
name: Prepare GPG private key
command: './scripts/build/prepare_signing_key.sh'
- run:
name: sign packages
command: './scripts/build/sign_packages.sh'
command: './scripts/build/sign_packages.sh dist/*.rpm'
- run:
name: sha-sum packages
command: 'go run build.go sha-dist'
@@ -476,15 +485,15 @@ jobs:
- run:
name: build and package grafana
command: './scripts/build/build-all.sh -enterprise'
- run:
name: Prepare GPG private key
command: './scripts/build/prepare_signing_key.sh'
- run:
name: sign packages
command: './scripts/build/sign_packages.sh'
command: './scripts/build/sign_packages.sh dist/*.rpm'
- run:
name: verify signed packages
command: |
mkdir -p ~/.rpmdb/pubkeys
curl -s https://packages.grafana.com/gpg.key > ~/.rpmdb/pubkeys/grafana.key
./scripts/build/verify_signed_packages.sh dist/*.rpm
command: './scripts/build/verify_signed_packages.sh dist/*.rpm'
- run:
name: sha-sum packages
command: 'go run build.go sha-dist'
@@ -537,15 +546,24 @@ jobs:
- run:
name: Deploy to Grafana.com
command: './scripts/build/publish.sh --enterprise'
- run:
name: Prepare GPG private key
command: './scripts/build/prepare_signing_key.sh'
- run:
name: Load GPG private key
command: './scripts/build/load-signing-key.sh'
command: './scripts/build/update_repo/load-signing-key.sh'
- run:
name: Update Debian repository
command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
- run:
name: Publish Debian repository
command: './scripts/build/update_repo/publish-deb.sh "enterprise"'
- run:
name: Update RPM repository
command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "enterprise-dist"'
- run:
name: Publish RPM repository
command: './scripts/build/update_repo/publish-rpm.sh "enterprise" "$CIRCLE_TAG"'
deploy-master:
@@ -591,15 +609,24 @@ jobs:
- run:
name: Deploy to Grafana.com
command: './scripts/build/publish.sh'
- run:
name: Prepare GPG private key
command: './scripts/build/prepare_signing_key.sh'
- run:
name: Load GPG private key
command: './scripts/build/load-signing-key.sh'
command: './scripts/build/update_repo/load-signing-key.sh'
- run:
name: Update Debian repository
command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
- run:
name: Publish Debian repository
command: './scripts/build/update_repo/publish-deb.sh "oss"'
- run:
name: Update RPM repository
command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG" "dist"'
- run:
name: Publish RPM repository
command: './scripts/build/update_repo/publish-rpm.sh "oss" "$CIRCLE_TAG"'
build-oss-msi:
docker:

View File

@@ -62,7 +62,8 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi
WORKDIR $GF_PATHS_HOME
RUN apk add --no-cache ca-certificates bash
RUN apk add --no-cache ca-certificates bash && \
apk add --no-cache --upgrade --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main openssl musl-utils
COPY conf ./conf

View File

@@ -2,5 +2,5 @@
"npmClient": "yarn",
"useWorkspaces": true,
"packages": ["packages/*"],
"version": "6.4.0-pre"
"version": "6.4.0-beta.2"
}

View File

@@ -3,7 +3,7 @@
"license": "Apache-2.0",
"private": true,
"name": "grafana",
"version": "6.4.0-pre",
"version": "6.4.0-beta2",
"repository": {
"type": "git",
"url": "http://github.com/grafana/grafana.git"
@@ -52,7 +52,9 @@
"@types/redux-logger": "3.0.7",
"@types/redux-mock-store": "1.0.1",
"@types/reselect": "2.2.0",
"@types/slate": "0.44.11",
"@types/slate": "0.47.1",
"@types/slate-plain-serializer": "0.6.1",
"@types/slate-react": "0.22.5",
"@types/tinycolor2": "1.4.2",
"angular-mocks": "1.6.6",
"autoprefixer": "9.5.0",
@@ -121,6 +123,7 @@
"redux-mock-store": "1.5.3",
"regexp-replace-loader": "1.0.1",
"rimraf": "2.6.3",
"rxjs-spy": "^7.5.1",
"sass-lint": "1.12.1",
"sass-loader": "7.1.0",
"sinon": "1.17.6",
@@ -193,6 +196,7 @@
},
"dependencies": {
"@babel/polyfill": "7.2.5",
"@grafana/slate-react": "0.22.9-grafana",
"@torkelo/react-select": "2.4.1",
"angular": "1.6.6",
"angular-bindonce": "0.3.1",
@@ -243,10 +247,8 @@
"rst2html": "github:thoward/rst2html#990cb89",
"rxjs": "6.4.0",
"search-query-parser": "1.5.2",
"slate": "0.33.8",
"slate-plain-serializer": "0.5.41",
"slate-prism": "0.5.0",
"slate-react": "0.12.11",
"slate": "0.47.8",
"slate-plain-serializer": "0.7.10",
"tether": "1.4.5",
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
"tinycolor2": "1.4.1",

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/data",
"version": "6.4.0-pre",
"version": "6.4.0-beta.2",
"description": "Grafana Data Library",
"keywords": [
"typescript"

View File

@@ -82,7 +82,7 @@ describe('FieldCache', () => {
it('should get the first field with a duplicate name', () => {
const field = ext.getFieldByName('value');
expect(field!.name).toEqual('value');
expect(field!.values.toJSON()).toEqual([1, 2, 3]);
expect(field!.values.toArray()).toEqual([1, 2, 3]);
});
it('should return index of the field', () => {

View File

@@ -189,14 +189,14 @@ describe('sorted DataFrame', () => {
it('Should sort numbers', () => {
const sorted = sortDataFrame(frame, 0, true);
expect(sorted.length).toEqual(3);
expect(sorted.fields[0].values.toJSON()).toEqual([3, 2, 1]);
expect(sorted.fields[1].values.toJSON()).toEqual(['c', 'b', 'a']);
expect(sorted.fields[0].values.toArray()).toEqual([3, 2, 1]);
expect(sorted.fields[1].values.toArray()).toEqual(['c', 'b', 'a']);
});
it('Should sort strings', () => {
const sorted = sortDataFrame(frame, 1, true);
expect(sorted.length).toEqual(3);
expect(sorted.fields[0].values.toJSON()).toEqual([3, 2, 1]);
expect(sorted.fields[1].values.toJSON()).toEqual(['c', 'b', 'a']);
expect(sorted.fields[0].values.toArray()).toEqual([3, 2, 1]);
expect(sorted.fields[1].values.toArray()).toEqual(['c', 'b', 'a']);
});
});

View File

@@ -401,7 +401,7 @@ export function toDataFrameDTO(data: DataFrame): DataFrameDTO {
name: f.name,
type: f.type,
config: f.config,
values: f.values.toJSON(),
values: f.values.toArray(),
};
});

View File

@@ -12,3 +12,4 @@ export * from './displayValue';
export * from './graph';
export * from './ScopedVars';
export * from './transformations';
export * from './vector';

View File

@@ -10,11 +10,6 @@ export interface Vector<T = any> {
* Get the resutls as an array.
*/
toArray(): T[];
/**
* Return the values as a simple array for json serialization
*/
toJSON(): any; // same results as toArray()
}
/**

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/runtime",
"version": "6.4.0-pre",
"version": "6.4.0-beta.2",
"description": "Grafana Runtime Library",
"keywords": [
"grafana",
@@ -21,8 +21,8 @@
"build": "grafana-toolkit package:build --scope=runtime"
},
"dependencies": {
"@grafana/data": "^6.4.0-alpha",
"@grafana/ui": "^6.4.0-alpha",
"@grafana/data": "6.4.0-beta.2",
"@grafana/ui": "6.4.0-beta.2",
"systemjs": "0.20.19",
"systemjs-plugin-css": "0.1.37"
},

View File

@@ -0,0 +1,4 @@
# 6.4.0 (unreleased)
# 6.4.0-beta1 (2019-09-17)
First release, see [Readme](https://github.com/grafana/grafana/blob/v6.4.0-beta1/packages/grafana-toolkit/README.md) for details.

View File

@@ -2,23 +2,27 @@
> **@grafana/toolkit is currently in ALPHA**. Core API is unstable and can be a subject of breaking changes!
# grafana-toolkit
grafana-toolkit is CLI that enables efficient development of Grafana extensions
grafana-toolkit is CLI that enables efficient development of Grafana plugins
## Rationale
Historically, creating Grafana extension was an exercise of reverse engineering and ceremony around testing, developing and eventually building the plugin. We want to help our community to focus on the core value of their plugins rather than all the setup required to develop an extension.
Historically, creating Grafana plugin was an exercise of reverse engineering and ceremony around testing, developing and eventually building the plugin. We want to help our community to focus on the core value of their plugins rather than all the setup required to develop them.
## Installation
## Getting started
You can either add grafana-toolkit to your extension's `package.json` file by running
`yarn add @grafana/toolkit` or `npm instal @grafana/toolkit`, or use one of our extension templates:
- [React Panel](https://github.com/grafana/simple-react-panel)
- [Angular Panel](https://github.com/grafana/simple-angular-panel)
Setup new plugin with `grafana-toolkit plugin:create` command:
### Updating your extension to use grafana-toolkit
In order to start using grafana-toolkit in your extension you need to follow the steps below:
1. Add `@grafana/toolkit` package to your project
2. Create `tsconfig.json` file in the root dir of your extension and paste the code below:
```sh
npx grafana-toolkit plugin:create my-grafana-plugin
cd my-grafana-plugin
yarn install
yarn dev
```
### Updating your plugin to use grafana-toolkit
In order to start using grafana-toolkit in your existing plugin you need to follow the steps below:
1. Add `@grafana/toolkit` package to your project by running `yarn add @grafana/toolkit` or `npm install @grafana/toolkit`
2. Create `tsconfig.json` file in the root dir of your plugin and paste the code below:
```json
{
"extends": "./node_modules/@grafana/toolkit/src/config/tsconfig.plugin.json",
@@ -31,7 +35,7 @@ In order to start using grafana-toolkit in your extension you need to follow the
}
```
3. Create `.prettierrc.js` file in the root dir of your extension and paste the code below:
3. Create `.prettierrc.js` file in the root dir of your plugin and paste the code below:
```js
module.exports = {
...require("./node_modules/@grafana/toolkit/src/config/prettier.plugin.config.json"),
@@ -49,13 +53,21 @@ module.exports = {
```
## Usage
With grafana-toolkit we put in your hands a CLI that addresses common tasks performed when working on Grafana extension:
- `grafana-toolkit plugin:test`
With grafana-toolkit we put in your hands a CLI that addresses common tasks performed when working on Grafana plugin:
- `grafana-toolkit plugin:create`
- `grafana-toolkit plugin:dev`
- `grafana-toolkit plugin:test`
- `grafana-toolkit plugin:build`
### Developing extensions
### Creating plugin
`grafana-toolkit plugin:create plugin-name`
Creates new Grafana plugin from template.
If `plugin-name` is provided, the template will be downloaded to `./plugin-name` directory. Otherwise, it will be downloaded to current directory.
### Developing plugin
`grafana-toolkit plugin:dev`
Creates development build that's easy to play with and debug using common browser tooling
@@ -63,7 +75,7 @@ Creates development build that's easy to play with and debug using common browse
Available options:
- `-w`, `--watch` - run development task in a watch mode
### Testing extensions
### Testing plugin
`grafana-toolkit plugin:test`
Runs Jest against your codebase
@@ -76,26 +88,29 @@ Available options:
- `--testPathPattern=<regex>` - runs test with paths that match provided regex (https://jestjs.io/docs/en/cli#testpathpattern-regex)
### Building extensions
### Building plugin
`grafana-toolkit plugin:build`
Creates production ready build of your extension
Creates production ready build of your plugin
## FAQ
### Which version should I use?
Please refer to [Grafana packages versioning guide](https://github.com/grafana/grafana/blob/master/packages/README.md#versioning)
### What tools does grafana-toolkit use?
grafana-toolkit comes with Typescript, TSLint, Prettier, Jest, CSS and SASS support.
### How to start using grafana-toolkit in my extension?
See [Updating your extension to use grafana-toolkit](#updating-your-extension-to-use-grafana-toolkit)
### Can I use Typescript to develop Grafana extensions?
### How to start using grafana-toolkit in my plugin?
See [Updating your plugin to use grafana-toolkit](#updating-your-plugin-to-use-grafana-toolkit)
### Can I use Typescript to develop Grafana plugins?
Yes! grafana-toolkit supports Typescript by default.
### How can I test my extension?
### How can I test my plugin?
grafana-toolkit comes with Jest as a test runner.
Internally at Grafana we use Enzyme. If you are developing React extension and you want to configure Enzyme as a testing utility, you need to configure `enzyme-adapter-react`. To do so create `<YOUR_EXTENSION>/config/jest-setup.ts` file that will provide necessary setup. Copy the following code into that file to get Enzyme working with React:
Internally at Grafana we use Enzyme. If you are developing React plugin and you want to configure Enzyme as a testing utility, you need to configure `enzyme-adapter-react`. To do so create `<YOUR_PLUGIN_DIR>/config/jest-setup.ts` file that will provide necessary setup. Copy the following code into that file to get Enzyme working with React:
```ts
import { configure } from 'enzyme';
@@ -104,7 +119,7 @@ import Adapter from 'enzyme-adapter-react-16';
configure({ adapter: new Adapter() });
```
You can also setup Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `<YOUR_EXTENSION>/config/jest-shim.ts`
You can also setup Jest with shims of your needs by creating `jest-shim.ts` file in the same directory: `<YOUR_PLUGIN_DIR_>/config/jest-shim.ts`
### Can I provide custom setup for Jest?
@@ -114,7 +129,7 @@ Currently we support following Jest config properties:
- [`snapshotSerializers`](https://jest-bot.github.io/jest/docs/configuration.html#snapshotserializers-array-string)
- [`moduleNameMapper`](https://jestjs.io/docs/en/configuration#modulenamemapper-object-string-string)
### How can I style my extension?
### How can I style my plugin?
We support pure CSS, SASS and CSS-in-JS approach (via [Emotion](https://emotion.sh/)).
#### Single CSS or SASS file
@@ -132,18 +147,18 @@ The styles will be injected via `style` tag during runtime.
If you want to provide different stylesheets for dark/light theme, create `dark.[css|scss]` and `light.[css|scss]` files in `src/styles` directory of your plugin. grafana-toolkit will generate theme specific stylesheets that will end up in `dist/styles` directory.
In order for Grafana to pickup up you theme stylesheets you need to use `loadPluginCss` from `@grafana/runtime` package. Typically you would do that in the entrypoint of your extension:
In order for Grafana to pickup up you theme stylesheets you need to use `loadPluginCss` from `@grafana/runtime` package. Typically you would do that in the entrypoint of your plugin:
```ts
import { loadPluginCss } from '@grafana/runtime';
loadPluginCss({
dark: 'plugins/<YOUR-EXTENSION-NAME>/styles/dark.css',
light: 'plugins/<YOUR-EXTENSION-NAME>/styles/light.css',
dark: 'plugins/<YOUR-PLUGIN-ID>/styles/dark.css',
light: 'plugins/<YOUR-PLUGIN-ID>/styles/light.css',
});
```
You need to add `@grafana/runtime` to your extension dependencies by running `yarn add @grafana/runtime` or `npm instal @grafana/runtime`
You need to add `@grafana/runtime` to your plugin dependencies by running `yarn add @grafana/runtime` or `npm instal @grafana/runtime`
> Note that in this case static files (png, svg, json, html) are all copied to dist directory when the plugin is bundled. Relative paths to those files does not change!
@@ -194,7 +209,7 @@ grafana-toolkit comes with [default config for TSLint](https://github.com/grafan
### How is Prettier integrated into grafana-toolkit workflow?
When building extension with [`grafana-toolkit plugin:build`](#building-extensions) task, grafana-toolkit performs Prettier check. If the check detects any Prettier issues, the build will not pass. To avoid such situation we suggest developing plugin with [`grafana-toolkit plugin:dev --watch`](#developing-extensions) task running. This task tries to fix Prettier issues automatically.
When building plugin with [`grafana-toolkit plugin:build`](#building-plugin) task, grafana-toolkit performs Prettier check. If the check detects any Prettier issues, the build will not pass. To avoid such situation we suggest developing plugin with [`grafana-toolkit plugin:dev --watch`](#developing-plugin) task running. This task tries to fix Prettier issues automatically.
### My editor does not respect Prettier config, what should I do?
In order for your editor to pickup our Prettier config you need to create `.prettierrc.js` file in the root directory of your plugin with following content:

View File

@@ -11,4 +11,5 @@ require('ts-node').register({
transpileOnly: true
});
require('../src/cli/index.ts').run(true);

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/toolkit",
"version": "6.4.0-pre",
"version": "6.4.0-beta.2",
"description": "Grafana Toolkit",
"keywords": [
"grafana",
@@ -28,6 +28,9 @@
"dependencies": {
"@babel/core": "7.4.5",
"@babel/preset-env": "7.4.5",
"@grafana/data": "6.4.0-beta.2",
"@grafana/ui": "6.4.0-beta.2",
"@types/command-exists": "^1.2.0",
"@types/execa": "^0.9.0",
"@types/expect-puppeteer": "3.3.1",
"@types/inquirer": "^6.0.3",
@@ -40,12 +43,11 @@
"@types/tmp": "^0.1.0",
"@types/webpack": "4.4.34",
"aws-sdk": "^2.495.0",
"@grafana/data": "^6.4.0-alpha",
"@grafana/ui": "^6.4.0-alpha",
"axios": "0.19.0",
"babel-loader": "8.0.6",
"babel-plugin-angularjs-annotate": "0.10.0",
"chalk": "^2.4.2",
"command-exists": "^1.2.8",
"commander": "^2.20.0",
"concurrently": "4.1.0",
"copy-webpack-plugin": "5.0.3",

View File

@@ -21,6 +21,7 @@ import {
ciPluginReportTask,
} from './tasks/plugin.ci';
import { buildPackageTask } from './tasks/package.build';
import { pluginCreateTask } from './tasks/plugin.create';
export const run = (includeInternalScripts = false) => {
if (includeInternalScripts) {
@@ -61,6 +62,7 @@ export const run = (includeInternalScripts = false) => {
await execTask(changelogTask)({
milestone: cmd.milestone,
silent: true,
});
});
@@ -89,8 +91,7 @@ export const run = (includeInternalScripts = false) => {
.command('toolkit:build')
.description('Prepares grafana/toolkit dist package')
.action(async cmd => {
// @ts-ignore
await execTask(toolkitBuildTask)();
await execTask(toolkitBuildTask)({});
});
program
@@ -117,11 +118,18 @@ export const run = (includeInternalScripts = false) => {
});
}
program
.command('plugin:create [name]')
.description('Creates plugin from template')
.action(async cmd => {
await execTask(pluginCreateTask)({ name: cmd, silent: true });
});
program
.command('plugin:build')
.description('Prepares plugin dist package')
.action(async cmd => {
await execTask(pluginBuildTask)({ coverage: false });
await execTask(pluginBuildTask)({ coverage: false, silent: true });
});
program
@@ -133,6 +141,7 @@ export const run = (includeInternalScripts = false) => {
await execTask(pluginDevTask)({
watch: !!cmd.watch,
yarnlink: !!cmd.yarnlink,
silent: true,
});
});
@@ -151,6 +160,7 @@ export const run = (includeInternalScripts = false) => {
watch: !!cmd.watch,
testPathPattern: cmd.testPathPattern,
testNamePattern: cmd.testNamePattern,
silent: true,
});
});

View File

@@ -2,67 +2,84 @@
import * as _ from 'lodash';
import { Task, TaskRunner } from './task';
import GithubClient from '../utils/githubClient';
import difference from 'lodash/difference';
import chalk from 'chalk';
import { useSpinner } from '../utils/useSpinner';
interface ChangelogOptions {
milestone: string;
}
const changelogTaskRunner: TaskRunner<ChangelogOptions> = async ({ milestone }) => {
const githubClient = new GithubClient();
const client = githubClient.client;
const filterBugs = (item: any) => {
if (item.title.match(/fix|fixes/i)) {
return true;
}
if (item.labels.find((label: any) => label.name === 'type/bug')) {
return true;
}
return false;
};
if (!/^\d+$/.test(milestone)) {
console.log('Use milestone number not title, find number in milestone url');
return;
const getPackageChangelog = (packageName: string, issues: any[]) => {
if (issues.length === 0) {
return '';
}
const res = await client.get('/issues', {
params: {
state: 'closed',
per_page: 100,
labels: 'add to changelog',
milestone: milestone,
},
});
const issues = res.data;
const bugs = _.sortBy(
issues.filter((item: any) => {
if (item.title.match(/fix|fixes/i)) {
return true;
}
if (item.labels.find((label: any) => label.name === 'type/bug')) {
return true;
}
return false;
}),
'title'
);
const notBugs = _.sortBy(issues.filter((item: any) => !bugs.find((bug: any) => bug === item)), 'title');
let markdown = '';
let markdown = chalk.bold.yellow(`\n\n/*** ${packageName} changelog ***/\n\n`);
const bugs = _.sortBy(issues.filter(filterBugs), 'title');
const notBugs = _.sortBy(difference(issues, bugs), 'title');
if (notBugs.length > 0) {
markdown = '### Features / Enhancements\n';
}
for (const item of notBugs) {
markdown += getMarkdownLineForIssue(item);
markdown += '### Features / Enhancements\n';
for (const item of notBugs) {
markdown += getMarkdownLineForIssue(item);
}
}
if (bugs.length > 0) {
markdown += '\n### Bug Fixes\n';
for (const item of bugs) {
markdown += getMarkdownLineForIssue(item);
}
}
for (const item of bugs) {
markdown += getMarkdownLineForIssue(item);
}
console.log(markdown);
return markdown;
};
const changelogTaskRunner: TaskRunner<ChangelogOptions> = useSpinner<ChangelogOptions>(
'Generating changelog',
async ({ milestone }) => {
const githubClient = new GithubClient();
const client = githubClient.client;
if (!/^\d+$/.test(milestone)) {
console.log('Use milestone number not title, find number in milestone url');
return;
}
const res = await client.get('/issues', {
params: {
state: 'closed',
per_page: 100,
labels: 'add to changelog',
milestone: milestone,
},
});
const issues = res.data;
const toolkitIssues = issues.filter((item: any) =>
item.labels.find((label: any) => label.name === 'area/grafana/toolkit')
);
let markdown = '';
markdown += getPackageChangelog('Grafana', issues);
markdown += getPackageChangelog('grafana-toolkit', toolkitIssues);
console.log(markdown);
}
);
function getMarkdownLineForIssue(item: any) {
const githubGrafanaUrl = 'https://github.com/grafana/grafana';
let markdown = '';

View File

@@ -10,7 +10,10 @@ const cherryPickRunner: TaskRunner<CherryPickOptions> = async () => {
const res = await client.get('/issues', {
params: {
state: 'closed',
per_page: 100,
labels: 'cherry-pick needed',
sort: 'closed',
direction: 'asc',
},
});

View File

@@ -0,0 +1,47 @@
import { prompt } from 'inquirer';
import path from 'path';
import { Task, TaskRunner } from './task';
import { promptConfirm } from '../utils/prompt';
import {
getPluginIdFromName,
verifyGitExists,
promptPluginType,
fetchTemplate,
promptPluginDetails,
formatPluginDetails,
prepareJsonFiles,
removeGitFiles,
} from './plugin/create';
interface PluginCreateOptions {
name?: string;
}
const pluginCreateRunner: TaskRunner<PluginCreateOptions> = async ({ name }) => {
const destPath = path.resolve(process.cwd(), getPluginIdFromName(name || ''));
let pluginDetails;
// 1. Verifying if git exists in user's env as templates are cloned from git templates
await verifyGitExists();
// 2. Prompt plugin template
const { type } = await promptPluginType();
// 3. Fetch plugin template from Github
await fetchTemplate({ type, dest: destPath });
// 4. Prompt plugin details
do {
pluginDetails = await promptPluginDetails(name);
formatPluginDetails(pluginDetails);
} while ((await prompt<{ confirm: boolean }>(promptConfirm('confirm', 'Is that ok?'))).confirm === false);
// 5. Update json files (package.json, src/plugin.json)
await prepareJsonFiles({ pluginDetails, pluginPath: destPath });
// 6. Remove cloned repository .git dir
await removeGitFiles(destPath);
};
export const pluginCreateTask = new Task<PluginCreateOptions>('plugin:create task', pluginCreateRunner);

View File

@@ -0,0 +1,150 @@
import commandExists from 'command-exists';
import { readFileSync, promises as fs } from 'fs';
import { prompt } from 'inquirer';
import kebabCase from 'lodash/kebabCase';
import path from 'path';
import gitPromise from 'simple-git/promise';
import { useSpinner } from '../../utils/useSpinner';
import { rmdir } from '../../utils/rmdir';
import { promptInput, promptConfirm } from '../../utils/prompt';
import chalk from 'chalk';
const simpleGit = gitPromise(process.cwd());
interface PluginDetails {
name: string;
org: string;
description: string;
author: boolean | string;
url: string;
keywords: string;
}
type PluginType = 'angular-panel' | 'react-panel' | 'datasource-plugin';
const RepositoriesPaths = {
'angular-panel': 'git@github.com:grafana/simple-angular-panel.git',
'react-panel': 'git@github.com:grafana/simple-react-panel.git',
'datasource-plugin': 'git@github.com:grafana/simple-datasource.git',
};
export const getGitUsername = async () => await simpleGit.raw(['config', '--global', 'user.name']);
export const getPluginIdFromName = (name: string) => kebabCase(name);
export const getPluginId = (pluginDetails: PluginDetails) =>
`${kebabCase(pluginDetails.org)}-${getPluginIdFromName(pluginDetails.name)}`;
export const getPluginKeywords = (pluginDetails: PluginDetails) =>
pluginDetails.keywords
.split(',')
.map(k => k.trim())
.filter(k => k !== '');
export const verifyGitExists = async () => {
return new Promise((resolve, reject) => {
commandExists('git', (err, exists) => {
if (exists) {
resolve(true);
}
reject(new Error('git is not installed'));
});
});
};
export const promptPluginType = async () =>
prompt<{ type: PluginType }>([
{
type: 'list',
message: 'Select plugin type',
name: 'type',
choices: [
{ name: 'Angular panel', value: 'angular-panel' },
{ name: 'React panel', value: 'react-panel' },
{ name: 'Datasource plugin', value: 'datasource-plugin' },
],
},
]);
export const promptPluginDetails = async (name?: string) => {
const username = (await getGitUsername()).trim();
const responses = await prompt<PluginDetails>([
promptInput('name', 'Plugin name', true, name),
promptInput('org', 'Organization (used as part of plugin ID)', true),
promptInput('description', 'Description'),
promptInput('keywords', 'Keywords (separated by comma)'),
// Try using git specified username
promptConfirm('author', `Author (${username})`, username, username !== ''),
// Prompt for manual author entry if no git user.name specifed
promptInput('author', `Author`, true, undefined, answers => !answers.author || username === ''),
promptInput('url', 'Your URL (i.e. organisation url)'),
]);
return {
...responses,
author: responses.author === true ? username : responses.author,
};
};
export const fetchTemplate = useSpinner<{ type: PluginType; dest: string }>(
'Fetching plugin template...',
async ({ type, dest }) => {
const url = RepositoriesPaths[type];
if (!url) {
throw new Error('Unknown plugin type');
}
await simpleGit.clone(url, dest);
}
);
export const prepareJsonFiles = useSpinner<{ pluginDetails: PluginDetails; pluginPath: string }>(
'Saving package.json and plugin.json files',
async ({ pluginDetails, pluginPath }) => {
const packageJsonPath = path.resolve(pluginPath, 'package.json');
const pluginJsonPath = path.resolve(pluginPath, 'src/plugin.json');
const packageJson: any = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
const pluginJson: any = JSON.parse(readFileSync(pluginJsonPath, 'utf8'));
const pluginId = `${kebabCase(pluginDetails.org)}-${getPluginIdFromName(pluginDetails.name)}`;
packageJson.name = pluginId;
packageJson.author = pluginDetails.author;
packageJson.description = pluginDetails.description;
pluginJson.name = pluginDetails.name;
pluginJson.id = pluginId;
pluginJson.info = {
...pluginJson.info,
description: pluginDetails.description,
author: {
name: pluginDetails.author,
url: pluginDetails.url,
},
keywords: getPluginKeywords(pluginDetails),
};
await Promise.all(
[packageJson, pluginJson].map((f, i) => {
const filePath = i === 0 ? packageJsonPath : pluginJsonPath;
return fs.writeFile(filePath, JSON.stringify(f, null, 2));
})
);
}
);
export const removeGitFiles = useSpinner('Cleaning', async pluginPath => rmdir(`${path.resolve(pluginPath, '.git')}`));
export const formatPluginDetails = (details: PluginDetails) => {
console.group();
console.log();
console.log(chalk.bold.yellow('Your plugin details'));
console.log('---');
console.log(chalk.bold('Name: '), details.name);
console.log(chalk.bold('ID: '), getPluginId(details));
console.log(chalk.bold('Description: '), details.description);
console.log(chalk.bold('Keywords: '), getPluginKeywords(details));
console.log(chalk.bold('Author: '), details.author);
console.log(chalk.bold('Organisation: '), details.org);
console.log(chalk.bold('Website: '), details.url);
console.log();
console.groupEnd();
};

View File

@@ -105,7 +105,9 @@ const copySassFiles = () => {
})();
};
const toolkitBuildTaskRunner: TaskRunner<void> = async () => {
interface ToolkitBuildOptions {}
const toolkitBuildTaskRunner: TaskRunner<ToolkitBuildOptions> = async () => {
cwd = path.resolve(__dirname, '../../../');
distDir = `${cwd}/dist`;
const pkg = require(`${cwd}/package.json`);
@@ -135,4 +137,4 @@ const toolkitBuildTaskRunner: TaskRunner<void> = async () => {
});
};
export const toolkitBuildTask = new Task<void>('@grafana/toolkit build', toolkitBuildTaskRunner);
export const toolkitBuildTask = new Task<ToolkitBuildOptions>('@grafana/toolkit build', toolkitBuildTaskRunner);

View File

@@ -1,8 +1,15 @@
import { Task } from '../tasks/task';
import chalk from 'chalk';
export const execTask = <TOptions>(task: Task<TOptions>) => async (options: TOptions) => {
console.log(chalk.yellow(`Running ${chalk.bold(task.name)} task`));
interface TaskBasicOptions {
// Don't print task details when running
silent?: boolean;
}
export const execTask = <TOptions>(task: Task<TOptions>) => async (options: TOptions & TaskBasicOptions) => {
if (!options.silent) {
console.log(chalk.yellow(`Running ${chalk.bold(task.name)} task`));
}
task.setOptions(options);
try {
console.group();

View File

@@ -0,0 +1,58 @@
import {
Question,
InputQuestion,
CheckboxQuestion,
NumberQuestion,
PasswordQuestion,
EditorQuestion,
ConfirmQuestion,
} from 'inquirer';
type QuestionWithValidation<A = any> =
| InputQuestion<A>
| CheckboxQuestion<A>
| NumberQuestion<A>
| PasswordQuestion<A>
| EditorQuestion<A>;
export const answerRequired = (question: QuestionWithValidation): Question<any> => {
return {
...question,
validate: (answer: any) => answer.trim() !== '' || `${question.name} is required`,
};
};
export const promptInput = <A>(
name: string,
message: string | ((answers: A) => string),
required = false,
def: any = undefined,
when: boolean | ((answers: A) => boolean | Promise<boolean>) = true
) => {
const model: InputQuestion<A> = {
type: 'input',
name,
message,
default: def,
when,
};
return required ? answerRequired(model) : model;
};
export const promptConfirm = <A>(
name: string,
message: string | ((answers: A) => string),
def: any = undefined,
when: boolean | ((answers: A) => boolean | Promise<boolean>) = true
) => {
const model: ConfirmQuestion<A> = {
type: 'confirm',
name,
message,
default: def,
when,
};
return model;
};

View File

@@ -0,0 +1,23 @@
import fs = require('fs');
import path = require('path');
/**
* Remove directory recursively
* Ref https://stackoverflow.com/a/42505874
*/
export const rmdir = (dirPath: string) => {
if (!fs.existsSync(dirPath)) {
return;
}
fs.readdirSync(dirPath).forEach(entry => {
const entryPath = path.join(dirPath, entry);
if (fs.lstatSync(entryPath).isDirectory()) {
rmdir(entryPath);
} else {
fs.unlinkSync(entryPath);
}
});
fs.rmdirSync(dirPath);
};

View File

@@ -2,7 +2,7 @@ import ora from 'ora';
type FnToSpin<T> = (options: T) => Promise<void>;
export const useSpinner = <T>(spinnerLabel: string, fn: FnToSpin<T>, killProcess = true) => {
export const useSpinner = <T = any>(spinnerLabel: string, fn: FnToSpin<T>, killProcess = true) => {
return async (options: T) => {
const spinner = ora(spinnerLabel);
spinner.start();

View File

@@ -149,7 +149,7 @@ export const getWebpackConfig: WebpackConfigurationGetter = options => {
'emotion',
'prismjs',
'slate-plain-serializer',
'slate-react',
'@grafana/slate-react',
'react',
'react-dom',
'react-redux',

View File

@@ -2,7 +2,7 @@
"author": "Grafana Labs",
"license": "Apache-2.0",
"name": "@grafana/ui",
"version": "6.4.0-pre",
"version": "6.4.0-beta.2",
"description": "Grafana Components Library",
"keywords": [
"grafana",
@@ -25,11 +25,13 @@
"build": "grafana-toolkit package:build --scope=ui"
},
"dependencies": {
"@grafana/data": "^6.4.0-alpha",
"@grafana/data": "6.4.0-beta.2",
"@grafana/slate-react": "0.22.9-grafana",
"@torkelo/react-select": "2.1.1",
"@types/react-color": "2.17.0",
"classnames": "2.2.6",
"d3": "5.9.1",
"immutable": "3.8.2",
"jquery": "3.4.1",
"lodash": "4.17.15",
"moment": "2.24.0",
@@ -45,6 +47,7 @@
"react-storybook-addon-props-combinations": "1.1.0",
"react-transition-group": "2.6.1",
"react-virtualized": "9.21.0",
"slate": "0.47.8",
"tinycolor2": "1.4.1"
},
"devDependencies": {
@@ -65,6 +68,8 @@
"@types/react-custom-scrollbars": "4.0.5",
"@types/react-test-renderer": "16.8.1",
"@types/react-transition-group": "2.0.16",
"@types/slate": "0.47.1",
"@types/slate-react": "0.22.5",
"@types/storybook__addon-actions": "3.4.2",
"@types/storybook__addon-info": "4.1.1",
"@types/storybook__addon-knobs": "4.0.4",

View File

@@ -1,6 +1,6 @@
import resolve from 'rollup-plugin-node-resolve';
import commonjs from 'rollup-plugin-commonjs';
import sourceMaps from 'rollup-plugin-sourcemaps';
// import sourceMaps from 'rollup-plugin-sourcemaps';
import { terser } from 'rollup-plugin-terser';
const pkg = require('./package.json');
@@ -47,19 +47,20 @@ const buildCjsPackage = ({ env }) => {
],
'../../node_modules/react-color/lib/components/common': ['Saturation', 'Hue', 'Alpha'],
'../../node_modules/immutable/dist/immutable.js': [
'Record',
'Set',
'Map',
'List',
'OrderedSet',
'is',
'Stack',
'Record',
],
'node_modules/immutable/dist/immutable.js': ['Record', 'Set', 'Map', 'List', 'OrderedSet', 'is', 'Stack'],
'../../node_modules/esrever/esrever.js': ['reverse'],
},
}),
resolve(),
sourceMaps(),
// sourceMaps(),
env === 'production' && terser(),
],
};

View File

@@ -19,7 +19,9 @@ export interface CommonButtonProps {
className?: string;
}
export interface LinkButtonProps extends CommonButtonProps, AnchorHTMLAttributes<HTMLAnchorElement> {}
export interface LinkButtonProps extends CommonButtonProps, AnchorHTMLAttributes<HTMLAnchorElement> {
disabled?: boolean;
}
export interface ButtonProps extends CommonButtonProps, ButtonHTMLAttributes<HTMLButtonElement> {}
interface AbstractButtonProps extends CommonButtonProps, Themeable {

View File

@@ -11,7 +11,7 @@ interface DataLinkEditorProps {
isLast: boolean;
value: DataLink;
suggestions: VariableSuggestion[];
onChange: (index: number, link: DataLink) => void;
onChange: (index: number, link: DataLink, callback?: () => void) => void;
onRemove: (link: DataLink) => void;
}
@@ -20,8 +20,8 @@ export const DataLinkEditor: React.FC<DataLinkEditorProps> = React.memo(
const theme = useContext(ThemeContext);
const [title, setTitle] = useState(value.title);
const onUrlChange = (url: string) => {
onChange(index, { ...value, url });
const onUrlChange = (url: string, callback?: () => void) => {
onChange(index, { ...value, url }, callback);
};
const onTitleChange = (event: ChangeEvent<HTMLInputElement>) => {
setTitle(event.target.value);

View File

@@ -1,46 +1,45 @@
import React, { useState, useMemo, useCallback, useContext } from 'react';
import React, { useState, useMemo, useCallback, useContext, useRef, RefObject } from 'react';
import { VariableSuggestion, VariableOrigin, DataLinkSuggestions } from './DataLinkSuggestions';
import { makeValue, ThemeContext, DataLinkBuiltInVars } from '../../index';
import { ThemeContext, DataLinkBuiltInVars, makeValue } from '../../index';
import { SelectionReference } from './SelectionReference';
import { Portal } from '../index';
// @ts-ignore
import { Editor } from 'slate-react';
// @ts-ignore
import { Value, Change, Document } from 'slate';
// @ts-ignore
import { Editor } from '@grafana/slate-react';
import { Value, Editor as CoreEditor } from 'slate';
import Plain from 'slate-plain-serializer';
import { Popper as ReactPopper } from 'react-popper';
import useDebounce from 'react-use/lib/useDebounce';
import { css, cx } from 'emotion';
// @ts-ignore
import PluginPrism from 'slate-prism';
import { SlatePrism } from '../../slate-plugins';
import { SCHEMA } from '../../utils/slate';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
interface DataLinkInputProps {
value: string;
onChange: (url: string) => void;
onChange: (url: string, callback?: () => void) => void;
suggestions: VariableSuggestion[];
}
const plugins = [
PluginPrism({
SlatePrism({
onlyIn: (node: any) => node.type === 'code_block',
getSyntax: () => 'links',
}),
];
export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, suggestions }) => {
const editorRef = useRef<Editor>() as RefObject<Editor>;
const theme = useContext(ThemeContext);
const [showingSuggestions, setShowingSuggestions] = useState(false);
const [suggestionsIndex, setSuggestionsIndex] = useState(0);
const [usedSuggestions, setUsedSuggestions] = useState(
suggestions.filter(suggestion => {
return value.indexOf(suggestion.value) > -1;
})
suggestions.filter(suggestion => value.includes(suggestion.value))
);
// Using any here as TS has problem pickung up `change` method existance on Value
// According to code and documentation `change` is an instance method on Value in slate 0.33.8 that we use
// https://github.com/ianstormtaylor/slate/blob/slate%400.33.8/docs/reference/slate/value.md#change
const [linkUrl, setLinkUrl] = useState<any>(makeValue(value));
const [linkUrl, setLinkUrl] = useState<Value>(makeValue(value));
const getStyles = useCallback(() => {
return {
@@ -56,22 +55,21 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
}, [theme]);
const currentSuggestions = useMemo(
() =>
suggestions.filter(suggestion => {
return usedSuggestions.map(s => s.value).indexOf(suggestion.value) === -1;
}),
() => suggestions.filter(suggestion => !usedSuggestions.map(s => s.value).includes(suggestion.value)),
[usedSuggestions, suggestions]
);
// Workaround for https://github.com/ianstormtaylor/slate/issues/2927
const stateRef = useRef({ showingSuggestions, currentSuggestions, suggestionsIndex, linkUrl, onChange });
stateRef.current = { showingSuggestions, currentSuggestions, suggestionsIndex, linkUrl, onChange };
// SelectionReference is used to position the variables suggestion relatively to current DOM selection
const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions]);
const selectionRef = useMemo(() => new SelectionReference(), [setShowingSuggestions, linkUrl]);
// Keep track of variables that has been used already
const updateUsedSuggestions = () => {
const currentLink = Plain.serialize(linkUrl);
const next = usedSuggestions.filter(suggestion => {
return currentLink.indexOf(suggestion.value) > -1;
});
const next = usedSuggestions.filter(suggestion => currentLink.includes(suggestion.value));
if (next.length !== usedSuggestions.length) {
setUsedSuggestions(next);
}
@@ -79,75 +77,62 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
useDebounce(updateUsedSuggestions, 250, [linkUrl]);
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Backspace' || event.key === 'Escape') {
setShowingSuggestions(false);
setSuggestionsIndex(0);
}
if (event.key === 'Enter') {
if (showingSuggestions) {
onVariableSelect(currentSuggestions[suggestionsIndex]);
const onKeyDown = React.useCallback((event: KeyboardEvent, next: () => any) => {
if (!stateRef.current.showingSuggestions) {
if (event.key === '=' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
return setShowingSuggestions(true);
}
return next();
}
if (showingSuggestions) {
if (event.key === 'ArrowDown') {
switch (event.key) {
case 'Backspace':
case 'Escape':
setShowingSuggestions(false);
return setSuggestionsIndex(0);
case 'Enter':
event.preventDefault();
setSuggestionsIndex(index => {
return (index + 1) % currentSuggestions.length;
});
}
if (event.key === 'ArrowUp') {
return onVariableSelect(stateRef.current.currentSuggestions[stateRef.current.suggestionsIndex]);
case 'ArrowDown':
case 'ArrowUp':
event.preventDefault();
setSuggestionsIndex(index => {
const nextIndex = index - 1 < 0 ? currentSuggestions.length - 1 : (index - 1) % currentSuggestions.length;
return nextIndex;
});
}
const direction = event.key === 'ArrowDown' ? 1 : -1;
return setSuggestionsIndex(index => modulo(index + direction, stateRef.current.currentSuggestions.length));
default:
return next();
}
}, []);
if (event.key === '?' || event.key === '&' || event.key === '$' || (event.keyCode === 32 && event.ctrlKey)) {
setShowingSuggestions(true);
}
if (event.key === 'Enter' && showingSuggestions) {
// Preventing entering a new line
// As of https://github.com/ianstormtaylor/slate/issues/1345#issuecomment-340508289
return false;
} else {
// @ts-ignore
return;
}
};
const onUrlChange = ({ value }: Change) => {
const onUrlChange = React.useCallback(({ value }: { value: Value }) => {
setLinkUrl(value);
};
}, []);
const onUrlBlur = () => {
onChange(Plain.serialize(linkUrl));
};
const onVariableSelect = (item: VariableSuggestion) => {
const includeDollarSign = Plain.serialize(linkUrl).slice(-1) !== '$';
const change = linkUrl.change();
const onUrlBlur = React.useCallback((event: Event, editor: CoreEditor, next: () => any) => {
// Callback needed for blur to work correctly
stateRef.current.onChange(Plain.serialize(stateRef.current.linkUrl), () => {
editorRef.current!.blur();
});
}, []);
const onVariableSelect = (item: VariableSuggestion, editor = editorRef.current!) => {
const includeDollarSign = Plain.serialize(editor.value).slice(-1) !== '$';
if (item.origin !== VariableOrigin.Template || item.value === DataLinkBuiltInVars.includeVars) {
change.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`);
editor.insertText(`${includeDollarSign ? '$' : ''}\{${item.value}}`);
} else {
change.insertText(`var-${item.value}=$\{${item.value}}`);
editor.insertText(`var-${item.value}=$\{${item.value}}`);
}
setLinkUrl(change.value);
setLinkUrl(editor.value);
setShowingSuggestions(false);
setUsedSuggestions((previous: VariableSuggestion[]) => {
return [...previous, item];
});
setSuggestionsIndex(0);
onChange(Plain.serialize(change.value));
onChange(Plain.serialize(editor.value));
};
return (
<div
className={cx(
@@ -163,7 +148,7 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
<Portal>
<ReactPopper
referenceElement={selectionRef}
placement="auto-end"
placement="top-end"
modifiers={{
preventOverflow: { enabled: true, boundariesElement: 'window' },
arrow: { enabled: false },
@@ -186,11 +171,13 @@ export const DataLinkInput: React.FC<DataLinkInputProps> = ({ value, onChange, s
</Portal>
)}
<Editor
schema={SCHEMA}
ref={editorRef}
placeholder="http://your-grafana.com/d/000000010/annotations"
value={linkUrl}
value={stateRef.current.linkUrl}
onChange={onUrlChange}
onBlur={onUrlBlur}
onKeyDown={onKeyDown}
onKeyDown={(event, _editor, next) => onKeyDown(event as KeyboardEvent, next)}
plugins={plugins}
className={getStyles().editor}
/>

View File

@@ -12,7 +12,7 @@ import { VariableSuggestion } from './DataLinkSuggestions';
interface DataLinksEditorProps {
value: DataLink[];
onChange: (links: DataLink[]) => void;
onChange: (links: DataLink[], callback?: () => void) => void;
suggestions: VariableSuggestion[];
maxLinks?: number;
}
@@ -30,14 +30,15 @@ export const DataLinksEditor: FC<DataLinksEditorProps> = React.memo(({ value, on
onChange(value ? [...value, { url: '', title: '' }] : [{ url: '', title: '' }]);
};
const onLinkChanged = (linkIndex: number, newLink: DataLink) => {
const onLinkChanged = (linkIndex: number, newLink: DataLink, callback?: () => void) => {
onChange(
value.map((item, listIndex) => {
if (linkIndex === listIndex) {
return newLink;
}
return item;
})
}),
callback
);
};

View File

@@ -83,9 +83,9 @@ $select-input-bg-disabled: $input-bg-disabled;
.gf-form-select-box__multi-value__remove {
text-align: center;
display: inline-block;
height: 14px;
vertical-align: middle;
margin-left: 2px;
position: relative;
top: 3px;
}
.gf-form-select-box__multi-value__label {
@@ -111,6 +111,10 @@ $select-input-bg-disabled: $input-bg-disabled;
}
}
.gf-form-select-box__placeholder {
color: $input-color-placeholder;
}
.gf-form-select-box__control--is-focused .gf-form-select-box__placeholder {
display: none;
}

View File

@@ -11,7 +11,7 @@ export const ReduceTransformerEditor: React.FC<TransformerUIProps<ReduceTransfor
}) => {
return (
<StatsPicker
width={12}
width={25}
placeholder="Choose Stat"
allowMultiple
stats={options.reducers || []}

View File

@@ -2,3 +2,4 @@ export * from './components';
export * from './types';
export * from './utils';
export * from './themes';
export * from './slate-plugins';

View File

@@ -0,0 +1 @@
export { SlatePrism } from './slate-prism';

View File

@@ -0,0 +1,3 @@
const TOKEN_MARK = 'prism-token';
export default TOKEN_MARK;

View File

@@ -0,0 +1,160 @@
import Prism from 'prismjs';
import { Block, Text, Decoration } from 'slate';
import { Plugin } from '@grafana/slate-react';
import Options, { OptionsFormat } from './options';
import TOKEN_MARK from './TOKEN_MARK';
/**
* A Slate plugin to highlight code syntax.
*/
export function SlatePrism(optsParam: OptionsFormat = {}): Plugin {
const opts: Options = new Options(optsParam);
return {
decorateNode: (node, editor, next) => {
if (!opts.onlyIn(node)) {
return next();
}
return decorateNode(opts, Block.create(node as Block));
},
renderDecoration: (props, editor, next) =>
opts.renderDecoration(
{
children: props.children,
decoration: props.decoration,
},
editor as any,
next
),
};
}
/**
* Returns the decoration for a node
*/
function decorateNode(opts: Options, block: Block) {
const grammarName = opts.getSyntax(block);
const grammar = Prism.languages[grammarName];
if (!grammar) {
// Grammar not loaded
return [];
}
// Tokenize the whole block text
const texts = block.getTexts();
const blockText = texts.map(text => text && text.getText()).join('\n');
const tokens = Prism.tokenize(blockText, grammar);
// The list of decorations to return
const decorations: Decoration[] = [];
let textStart = 0;
let textEnd = 0;
texts.forEach(text => {
textEnd = textStart + text!.getText().length;
let offset = 0;
function processToken(token: string | Prism.Token, accu?: string | number) {
if (typeof token === 'string') {
if (accu) {
const decoration = createDecoration({
text: text!,
textStart,
textEnd,
start: offset,
end: offset + token.length,
className: `prism-token token ${accu}`,
block,
});
if (decoration) {
decorations.push(decoration);
}
}
offset += token.length;
} else {
accu = `${accu} ${token.type} ${token.alias || ''}`;
if (typeof token.content === 'string') {
const decoration = createDecoration({
text: text!,
textStart,
textEnd,
start: offset,
end: offset + token.content.length,
className: `prism-token token ${accu}`,
block,
});
if (decoration) {
decorations.push(decoration);
}
offset += token.content.length;
} else {
// When using token.content instead of token.matchedStr, token can be deep
for (let i = 0; i < token.content.length; i += 1) {
// @ts-ignore
processToken(token.content[i], accu);
}
}
}
}
tokens.forEach(processToken);
textStart = textEnd + 1; // account for added `\n`
});
return decorations;
}
/**
* Return a decoration range for the given text.
*/
function createDecoration({
text,
textStart,
textEnd,
start,
end,
className,
block,
}: {
text: Text; // The text being decorated
textStart: number; // Its start position in the whole text
textEnd: number; // Its end position in the whole text
start: number; // The position in the whole text where the token starts
end: number; // The position in the whole text where the token ends
className: string; // The prism token classname
block: Block;
}): Decoration | null {
if (start >= textEnd || end <= textStart) {
// Ignore, the token is not in the text
return null;
}
// Shrink to this text boundaries
start = Math.max(start, textStart);
end = Math.min(end, textEnd);
// Now shift offsets to be relative to this text
start -= textStart;
end -= textStart;
const myDec = block.createDecoration({
object: 'decoration',
anchor: {
key: text.key,
offset: start,
object: 'point',
},
focus: {
key: text.key,
offset: end,
object: 'point',
},
type: TOKEN_MARK,
data: { className },
});
return myDec;
}

View File

@@ -0,0 +1,77 @@
import React from 'react';
import { Mark, Node, Decoration } from 'slate';
import { Editor } from '@grafana/slate-react';
import { Record } from 'immutable';
import TOKEN_MARK from './TOKEN_MARK';
export interface OptionsFormat {
// Determine which node should be highlighted
onlyIn?: (node: Node) => boolean;
// Returns the syntax for a node that should be highlighted
getSyntax?: (node: Node) => string;
// Render a highlighting mark in a highlighted node
renderMark?: ({ mark, children }: { mark: Mark; children: React.ReactNode }) => void | React.ReactNode;
}
/**
* Default filter for code blocks
*/
function defaultOnlyIn(node: Node): boolean {
return node.object === 'block' && node.type === 'code_block';
}
/**
* Default getter for syntax
*/
function defaultGetSyntax(node: Node): string {
return 'javascript';
}
/**
* Default rendering for decorations
*/
function defaultRenderDecoration(
props: { children: React.ReactNode; decoration: Decoration },
editor: Editor,
next: () => any
): void | React.ReactNode {
const { decoration } = props;
if (decoration.type !== TOKEN_MARK) {
return next();
}
const className = decoration.data.get('className');
return <span className={className}>{props.children}</span>;
}
/**
* The plugin options
*/
class Options
extends Record({
onlyIn: defaultOnlyIn,
getSyntax: defaultGetSyntax,
renderDecoration: defaultRenderDecoration,
})
implements OptionsFormat {
readonly onlyIn!: (node: Node) => boolean;
readonly getSyntax!: (node: Node) => string;
readonly renderDecoration!: (
{
decoration,
children,
}: {
decoration: Decoration;
children: React.ReactNode;
},
editor: Editor,
next: () => any
) => void | React.ReactNode;
constructor(props: OptionsFormat) {
super(props);
}
}
export default Options;

View File

@@ -193,6 +193,7 @@ $btn-semi-transparent: rgba(0, 0, 0, 0.2) !default;
// sidemenu
$side-menu-width: 60px;
$navbar-padding: 20px;
// dashboard
$dashboard-padding: $space-md;

View File

@@ -50,13 +50,13 @@ function getTitleTemplate(title: string | undefined, stats: string[], data?: Dat
const parts: string[] = [];
if (stats.length > 1) {
parts.push('$' + VAR_CALC);
parts.push('${' + VAR_CALC + '}');
}
if (data.length > 1) {
parts.push('${' + VAR_SERIES_NAME + '}');
}
if (fieldCount > 1 || !parts.length) {
parts.push('$' + VAR_FIELD_NAME);
parts.push('${' + VAR_FIELD_NAME + '}');
}
return parts.join(' ');
}

View File

@@ -1,22 +1,22 @@
// @ts-ignore
import { Block, Document, Text, Value } from 'slate';
import { Block, Document, Text, Value, SchemaProperties } from 'slate';
const SCHEMA = {
blocks: {
paragraph: 'paragraph',
codeblock: 'code_block',
codeline: 'code_line',
export const SCHEMA: SchemaProperties = {
document: {
nodes: [
{
match: [{ type: 'paragraph' }, { type: 'code_block' }, { type: 'code_line' }],
},
],
},
inlines: {},
marks: {},
};
export const makeFragment = (text: string, syntax?: string) => {
export const makeFragment = (text: string, syntax?: string): Document => {
const lines = text.split('\n').map(line =>
Block.create({
type: 'code_line',
nodes: [Text.create(line)],
} as any)
})
);
const block = Block.create({
@@ -25,18 +25,17 @@ export const makeFragment = (text: string, syntax?: string) => {
},
type: 'code_block',
nodes: lines,
} as any);
});
return Document.create({
nodes: [block],
});
};
export const makeValue = (text: string, syntax?: string) => {
export const makeValue = (text: string, syntax?: string): Value => {
const fragment = makeFragment(text, syntax);
return Value.create({
document: fragment,
SCHEMA,
} as any);
});
};

View File

@@ -1,6 +1,14 @@
import { toFixed, getValueFormat } from './valueFormats';
describe('valueFormats', () => {
describe('toFixed with edge cases', () => {
it('should handle non number input gracefully', () => {
expect(toFixed(NaN)).toBe('NaN');
expect(toFixed(Number.NEGATIVE_INFINITY)).toBe('-Inf');
expect(toFixed(Number.POSITIVE_INFINITY)).toBe('Inf');
});
});
describe('toFixed and negative decimals', () => {
it('should treat as zero decimals', () => {
const str = toFixed(186.123, -2);

View File

@@ -33,6 +33,12 @@ export function toFixed(value: number, decimals?: DecimalCount): string {
if (value === null) {
return '';
}
if (value === Number.NEGATIVE_INFINITY) {
return '-Inf';
}
if (value === Number.POSITIVE_INFINITY) {
return 'Inf';
}
const factor = decimals ? Math.pow(10, Math.max(0, decimals)) : 1;
const formatted = String(Math.round(value * factor) / factor);

View File

@@ -5,6 +5,10 @@
"compilerOptions": {
"rootDirs": [".", "stories"],
"typeRoots": ["./node_modules/@types", "types"],
"baseUrl": "./node_modules/@types",
"paths": {
"@grafana/slate-react": ["slate-react"]
},
"declarationDir": "dist",
"outDir": "compiled"
}

View File

@@ -23,7 +23,8 @@ ENV PATH=/usr/share/grafana/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bi
WORKDIR $GF_PATHS_HOME
RUN apk add --no-cache ca-certificates bash
RUN apk add --no-cache ca-certificates bash && \
apk add --no-cache --upgrade --repository=http://dl-cdn.alpinelinux.org/alpine/edge/main openssl musl-utils
# PhantomJS
RUN if [ `arch` = "x86_64" ]; then \

View File

@@ -135,3 +135,15 @@ func Respond(status int, body interface{}) *NormalResponse {
header: make(http.Header),
}
}
type RedirectResponse struct {
location string
}
func (r *RedirectResponse) WriteTo(ctx *m.ReqContext) {
ctx.Redirect(r.location)
}
func Redirect(location string) *RedirectResponse {
return &RedirectResponse{location: location}
}

View File

@@ -217,7 +217,7 @@ func (scanner *PluginScanner) loadPluginJson(pluginJsonFilePath string) error {
loader = reflect.New(reflect.TypeOf(pluginGoType)).Interface().(PluginLoader)
// External plugins need a module.js file for SystemJS to load
if !strings.HasPrefix(pluginJsonFilePath, setting.StaticRootPath) {
if !strings.HasPrefix(pluginJsonFilePath, setting.StaticRootPath) && !scanner.IsBackendOnlyPlugin(pluginCommon.Type) {
module := filepath.Join(filepath.Dir(pluginJsonFilePath), "module.js")
if _, err := os.Stat(module); os.IsNotExist(err) {
plog.Warn("Plugin missing module.js",
@@ -231,6 +231,10 @@ func (scanner *PluginScanner) loadPluginJson(pluginJsonFilePath string) error {
return loader.Load(jsonParser, currentDir)
}
func (scanner *PluginScanner) IsBackendOnlyPlugin(pluginType string) bool {
return pluginType == "renderer"
}
func GetPluginMarkdown(pluginId string, name string) ([]byte, error) {
plug, exists := Plugins[pluginId]
if !exists {

View File

@@ -42,4 +42,18 @@ func TestPluginScans(t *testing.T) {
So(Apps["test-app"].Info.Screenshots[1].Path, ShouldEqual, "public/plugins/test-app/img/screenshot2.png")
})
Convey("When checking if renderer is backend only plugin", t, func() {
pluginScanner := &PluginScanner{}
result := pluginScanner.IsBackendOnlyPlugin("renderer")
So(result, ShouldEqual, true)
})
Convey("When checking if app is backend only plugin", t, func() {
pluginScanner := &PluginScanner{}
result := pluginScanner.IsBackendOnlyPlugin("app")
So(result, ShouldEqual, false)
})
}

View File

@@ -88,7 +88,13 @@ func (pn *PagerdutyNotifier) Notify(evalContext *alerting.EvalContext) error {
pn.log.Info("Notifying Pagerduty", "event_type", eventType)
payloadJSON := simplejson.New()
payloadJSON.Set("summary", evalContext.Rule.Name+" - "+evalContext.Rule.Message)
summary := evalContext.Rule.Name + " - " + evalContext.Rule.Message
if len(summary) > 1024 {
summary = summary[0:1024]
}
payloadJSON.Set("summary", summary)
if hostname, err := os.Hostname(); err == nil {
payloadJSON.Set("source", hostname)
}

View File

@@ -3,10 +3,14 @@ package multildap
import (
"errors"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/models"
"github.com/grafana/grafana/pkg/services/ldap"
)
// logger to log
var logger = log.New("ldap")
// GetConfig gets LDAP config
var GetConfig = ldap.GetConfig
@@ -119,12 +123,18 @@ func (multiples *MultiLDAP) Login(query *models.LoginUserQuery) (
return user, nil
}
// Continue if we couldn't find the user
if err == ErrCouldNotFindUser {
continue
}
if err != nil {
if isSilentError(err) {
logger.Debug(
"unable to login with LDAP - skipping server",
"host", config.Host,
"port", config.Port,
"error", err,
)
continue
}
return nil, err
}
}
@@ -204,3 +214,17 @@ func (multiples *MultiLDAP) Users(logins []string) (
return result, nil
}
// isSilentError evaluates an error and tells whenever we should fail the LDAP request
// immediately or if we should continue into other LDAP servers
func isSilentError(err error) bool {
continueErrs := []error{ErrInvalidCredentials, ErrCouldNotFindUser}
for _, cerr := range continueErrs {
if err == cerr {
return true
}
}
return false
}

View File

@@ -152,6 +152,25 @@ func TestMultiLDAP(t *testing.T) {
teardown()
})
Convey("Should still try to auth with the second server after receiving an invalid credentials error from the first", func() {
mock := setup()
mock.loginErrReturn = ErrInvalidCredentials
multi := New([]*ldap.ServerConfig{
{}, {},
})
_, err := multi.Login(&models.LoginUserQuery{})
So(mock.dialCalledTimes, ShouldEqual, 2)
So(mock.loginCalledTimes, ShouldEqual, 2)
So(mock.closeCalledTimes, ShouldEqual, 2)
So(err, ShouldEqual, ErrInvalidCredentials)
teardown()
})
Convey("Should return unknown error", func() {
mock := setup()

View File

@@ -96,22 +96,13 @@ func roleCounterSQL(role, alias string) string {
return `
(
SELECT COUNT(*)
FROM ` + dialect.Quote("user") + ` as u
WHERE
(SELECT COUNT(*)
FROM org_user
WHERE org_user.user_id=u.id
AND org_user.role='` + role + `')>0
FROM ` + dialect.Quote("user") + ` as u, org_user
WHERE ( org_user.user_id=u.id AND org_user.role='` + role + `' )
) as ` + alias + `,
(
SELECT COUNT(*)
FROM ` + dialect.Quote("user") + ` as u
WHERE
(SELECT COUNT(*)
FROM org_user
WHERE org_user.user_id=u.id
AND org_user.role='` + role + `')>0
AND u.last_seen_at>?
FROM ` + dialect.Quote("user") + ` as u, org_user
WHERE u.last_seen_at>? AND ( org_user.user_id=u.id AND org_user.role='` + role + `' )
) as active_` + alias
}

View File

@@ -60,11 +60,7 @@ func (e *AzureMonitorDatasource) executeTimeSeriesQuery(ctx context.Context, ori
if err != nil {
queryRes.Error = err
}
if val, ok := result.Results[query.RefID]; ok {
val.Series = append(result.Results[query.RefID].Series, queryRes.Series...)
} else {
result.Results[query.RefID] = queryRes
}
result.Results[query.RefID] = queryRes
}
return result, nil
@@ -88,22 +84,11 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange *
azureMonitorTarget := query.Model.Get("azureMonitor").MustMap()
azlog.Debug("AzureMonitor", "target", azureMonitorTarget)
queryMode := fmt.Sprintf("%v", azureMonitorTarget["queryMode"])
if queryMode == "crossResource" {
return nil, fmt.Errorf("Alerting not supported for multiple resource queries")
}
var azureMonitorData map[string]interface{}
if queryMode == "singleResource" {
azureMonitorData = azureMonitorTarget["data"].(map[string]interface{})[queryMode].(map[string]interface{})
} else {
azureMonitorData = azureMonitorTarget
}
urlComponents := map[string]string{}
urlComponents["subscription"] = fmt.Sprintf("%v", query.Model.Get("subscription").MustString())
urlComponents["resourceGroup"] = fmt.Sprintf("%v", azureMonitorData["resourceGroup"])
urlComponents["metricDefinition"] = fmt.Sprintf("%v", azureMonitorData["metricDefinition"])
urlComponents["resourceName"] = fmt.Sprintf("%v", azureMonitorData["resourceName"])
urlComponents["resourceGroup"] = fmt.Sprintf("%v", azureMonitorTarget["resourceGroup"])
urlComponents["metricDefinition"] = fmt.Sprintf("%v", azureMonitorTarget["metricDefinition"])
urlComponents["resourceName"] = fmt.Sprintf("%v", azureMonitorTarget["resourceName"])
ub := urlBuilder{
DefaultSubscription: query.DataSource.JsonData.Get("subscriptionId").MustString(),
@@ -115,12 +100,12 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange *
azureURL := ub.Build()
alias := ""
if val, ok := azureMonitorData["alias"]; ok {
if val, ok := azureMonitorTarget["alias"]; ok {
alias = fmt.Sprintf("%v", val)
}
timeGrain := fmt.Sprintf("%v", azureMonitorData["timeGrain"])
timeGrains := azureMonitorData["allowedTimeGrainsMs"]
timeGrain := fmt.Sprintf("%v", azureMonitorTarget["timeGrain"])
timeGrains := azureMonitorTarget["allowedTimeGrainsMs"]
if timeGrain == "auto" {
timeGrain, err = e.setAutoTimeGrain(query.IntervalMs, timeGrains)
if err != nil {
@@ -132,16 +117,13 @@ func (e *AzureMonitorDatasource) buildQueries(queries []*tsdb.Query, timeRange *
params.Add("api-version", "2018-01-01")
params.Add("timespan", fmt.Sprintf("%v/%v", startTime.UTC().Format(time.RFC3339), endTime.UTC().Format(time.RFC3339)))
params.Add("interval", timeGrain)
params.Add("aggregation", fmt.Sprintf("%v", azureMonitorData["aggregation"]))
params.Add("metricnames", fmt.Sprintf("%v", azureMonitorData["metricName"]))
params.Add("aggregation", fmt.Sprintf("%v", azureMonitorTarget["aggregation"]))
params.Add("metricnames", fmt.Sprintf("%v", azureMonitorTarget["metricName"]))
params.Add("metricnamespace", fmt.Sprintf("%v", azureMonitorTarget["metricNamespace"]))
if val, ok := azureMonitorData["metricNamespace"]; ok {
params.Add("metricnamespace", fmt.Sprintf("%v", val))
}
dimension := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorData["dimension"]))
dimensionFilter := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorData["dimensionFilter"]))
if azureMonitorData["dimension"] != nil && azureMonitorData["dimensionFilter"] != nil && len(dimension) > 0 && len(dimensionFilter) > 0 && dimension != "None" {
dimension := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimension"]))
dimensionFilter := strings.TrimSpace(fmt.Sprintf("%v", azureMonitorTarget["dimensionFilter"]))
if azureMonitorTarget["dimension"] != nil && azureMonitorTarget["dimensionFilter"] != nil && len(dimension) > 0 && len(dimensionFilter) > 0 && dimension != "None" {
params.Add("$filter", fmt.Sprintf("%s eq '%s'", dimension, dimensionFilter))
}

View File

@@ -36,20 +36,15 @@ func TestAzureMonitorDatasource(t *testing.T) {
Model: simplejson.NewFromAny(map[string]interface{}{
"subscription": "12345678-aaaa-bbbb-cccc-123456789abc",
"azureMonitor": map[string]interface{}{
"queryMode": "singleResource",
"data": map[string]interface{}{
"singleResource": map[string]interface{}{
"timeGrain": "PT1M",
"aggregation": "Average",
"resourceGroup": "grafanastaging",
"resourceName": "grafana",
"metricDefinition": "Microsoft.Compute/virtualMachines",
"metricNamespace": "Microsoft.Compute-virtualMachines",
"metricName": "Percentage CPU",
"alias": "testalias",
"queryType": "Azure Monitor",
},
},
"timeGrain": "PT1M",
"aggregation": "Average",
"resourceGroup": "grafanastaging",
"resourceName": "grafana",
"metricDefinition": "Microsoft.Compute/virtualMachines",
"metricNamespace": "Microsoft.Compute-virtualMachines",
"metricName": "Percentage CPU",
"alias": "testalias",
"queryType": "Azure Monitor",
},
}),
RefId: "A",

View File

@@ -155,7 +155,7 @@ func init() {
"AWS/Events": {"RuleName"},
"AWS/FSx": {},
"AWS/Firehose": {"DeliveryStreamName"},
"AWS/GameLift": {"FleetId", "InstanceType", "MatchmakingConfigurationName", "MatchmakingConfigurationName-RuleName", "MetricGroup", "OperatingSystem", "QueueName"},
"AWS/GameLift": {"FleetId", "InstanceType", "MatchmakingConfigurationName", "MatchmakingConfigurationName-RuleName", "MetricGroups", "OperatingSystem", "QueueName"},
"AWS/Glue": {"JobName", "JobRunId", "Type"},
"AWS/Inspector": {},
"AWS/IoT": {"ActionType", "BehaviorName", "CheckName", "JobId", "Protocol", "RuleName", "ScheduledAuditName", "SecurityProfileName"},

View File

@@ -3,7 +3,6 @@ package mssql
import (
"database/sql"
"fmt"
"net/url"
"strconv"
"github.com/grafana/grafana/pkg/setting"
@@ -24,7 +23,10 @@ func init() {
func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoint, error) {
logger := log.New("tsdb.mssql")
cnnstr := generateConnectionString(datasource)
cnnstr, err := generateConnectionString(datasource)
if err != nil {
return nil, err
}
if setting.Env == setting.DEV {
logger.Debug("getEngine", "connection", cnnstr)
}
@@ -36,35 +38,35 @@ func newMssqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
MetricColumnTypes: []string{"VARCHAR", "CHAR", "NVARCHAR", "NCHAR"},
}
rowTransformer := mssqlRowTransformer{
queryResultTransformer := mssqlQueryResultTransformer{
log: logger,
}
return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newMssqlMacroEngine(), logger)
return sqleng.NewSqlQueryEndpoint(&config, &queryResultTransformer, newMssqlMacroEngine(), logger)
}
func generateConnectionString(datasource *models.DataSource) string {
func generateConnectionString(datasource *models.DataSource) (string, error) {
server, port := util.SplitHostPortDefault(datasource.Url, "localhost", "1433")
encrypt := datasource.JsonData.Get("encrypt").MustString("false")
query := url.Values{}
query.Add("database", datasource.Database)
query.Add("encrypt", encrypt)
u := &url.URL{
Scheme: "sqlserver",
User: url.UserPassword(datasource.User, datasource.DecryptedPassword()),
Host: fmt.Sprintf("%s:%s", server, port),
RawQuery: query.Encode(),
connStr := fmt.Sprintf("server=%s;port=%s;database=%s;user id=%s;password=%s;",
server,
port,
datasource.Database,
datasource.User,
datasource.DecryptedPassword(),
)
if encrypt != "false" {
connStr += fmt.Sprintf("encrypt=%s;", encrypt)
}
return u.String()
return connStr, nil
}
type mssqlRowTransformer struct {
type mssqlQueryResultTransformer struct {
log log.Logger
}
func (t *mssqlRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) {
func (t *mssqlQueryResultTransformer) TransformQueryResult(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) {
values := make([]interface{}, len(columnTypes))
valuePtrs := make([]interface{}, len(columnTypes))
@@ -98,3 +100,7 @@ func (t *mssqlRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *cor
return values, nil
}
func (t *mssqlQueryResultTransformer) TransformQueryError(err error) error {
return err
}

View File

@@ -28,33 +28,6 @@ import (
// If needed, change the variable below to the IP address of the database.
var serverIP = "localhost"
func TestGenerateConnectionString(t *testing.T) {
encrypted, _ := simplejson.NewJson([]byte(`{"encrypt":"false"}`))
testSet := []struct {
ds *models.DataSource
expected string
}{
{
&models.DataSource{
User: "user",
Database: "db",
Url: "localhost:1433",
SecureJsonData: securejsondata.GetEncryptedJsonData(map[string]string{
"password": "pass;word",
}),
JsonData: encrypted,
},
"sqlserver://user:pass;word@localhost:1433?database=db&encrypt=false",
},
}
for i := range testSet {
got := generateConnectionString(testSet[i].ds)
if got != testSet[i].expected {
t.Errorf("mssql connString error for testCase %d got: %s expected: %s", i, got, testSet[i].expected)
}
}
}
func TestMSSQL(t *testing.T) {
SkipConvey("MSSQL", t, func() {
x := InitMSSQLTestDB(t)

View File

@@ -1,11 +1,13 @@
package mysql
import (
"errors"
"fmt"
"regexp"
"strings"
"github.com/grafana/grafana/pkg/components/gtime"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tsdb"
"github.com/grafana/grafana/pkg/tsdb/sqleng"
)
@@ -13,19 +15,29 @@ import (
const rsIdentifier = `([_a-zA-Z0-9]+)`
const sExpr = `\$` + rsIdentifier + `\(([^\)]*)\)`
var restrictedRegExp = regexp.MustCompile(`(?im)([\s]*show[\s]+grants|[\s,]session_user\([^\)]*\)|[\s,]current_user(\([^\)]*\))?|[\s,]system_user\([^\)]*\)|[\s,]user\([^\)]*\))([\s,;]|$)`)
type mySqlMacroEngine struct {
*sqleng.SqlMacroEngineBase
timeRange *tsdb.TimeRange
query *tsdb.Query
logger log.Logger
}
func newMysqlMacroEngine() sqleng.SqlMacroEngine {
return &mySqlMacroEngine{SqlMacroEngineBase: sqleng.NewSqlMacroEngineBase()}
func newMysqlMacroEngine(logger log.Logger) sqleng.SqlMacroEngine {
return &mySqlMacroEngine{SqlMacroEngineBase: sqleng.NewSqlMacroEngineBase(), logger: logger}
}
func (m *mySqlMacroEngine) Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error) {
m.timeRange = timeRange
m.query = query
matches := restrictedRegExp.FindAllStringSubmatch(sql, 1)
if len(matches) > 0 {
m.logger.Error("show grants, session_user(), current_user(), system_user() or user() not allowed in query")
return "", errors.New("Invalid query. Inspect Grafana server log for details")
}
rExp, _ := regexp.Compile(sExpr)
var macroError error

View File

@@ -6,13 +6,16 @@ import (
"testing"
"time"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tsdb"
. "github.com/smartystreets/goconvey/convey"
)
func TestMacroEngine(t *testing.T) {
Convey("MacroEngine", t, func() {
engine := &mySqlMacroEngine{}
engine := &mySqlMacroEngine{
logger: log.New("test"),
}
query := &tsdb.Query{}
Convey("Given a time range between 2018-04-12 00:00 and 2018-04-12 00:05", func() {
@@ -157,5 +160,33 @@ func TestMacroEngine(t *testing.T) {
So(sql, ShouldEqual, fmt.Sprintf("select time >= %d AND time <= %d", from.Unix(), to.Unix()))
})
})
Convey("Given queries that contains unallowed user functions", func() {
tcs := []string{
"select \nSESSION_USER(), abc",
"SELECT session_User( ) ",
"SELECT session_User( )\n",
"SELECT current_user",
"SELECT current_USER",
"SELECT current_user()",
"SELECT Current_User()",
"SELECT current_user( )",
"SELECT current_user(\t )",
"SELECT user()",
"SELECT USER()",
"SELECT SYSTEM_USER()",
"SELECT System_User()",
"SELECT System_User( )",
"SELECT System_User(\t \t)",
"SHOW \t grants",
" show Grants\n",
"show grants;",
}
for _, tc := range tcs {
_, err := engine.Interpolate(nil, nil, tc)
So(err.Error(), ShouldEqual, "Invalid query. Inspect Grafana server log for details")
}
})
})
}

View File

@@ -2,11 +2,14 @@ package mysql
import (
"database/sql"
"errors"
"fmt"
"reflect"
"strconv"
"strings"
"github.com/VividCortex/mysqlerr"
"github.com/grafana/grafana/pkg/setting"
"github.com/go-sql-driver/mysql"
@@ -59,18 +62,18 @@ func newMysqlQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndpoin
MetricColumnTypes: []string{"CHAR", "VARCHAR", "TINYTEXT", "TEXT", "MEDIUMTEXT", "LONGTEXT"},
}
rowTransformer := mysqlRowTransformer{
rowTransformer := mysqlQueryResultTransformer{
log: logger,
}
return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newMysqlMacroEngine(), logger)
return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newMysqlMacroEngine(logger), logger)
}
type mysqlRowTransformer struct {
type mysqlQueryResultTransformer struct {
log log.Logger
}
func (t *mysqlRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) {
func (t *mysqlQueryResultTransformer) TransformQueryResult(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) {
values := make([]interface{}, len(columnTypes))
for i := range values {
@@ -128,3 +131,16 @@ func (t *mysqlRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *cor
return values, nil
}
func (t *mysqlQueryResultTransformer) TransformQueryError(err error) error {
if driverErr, ok := err.(*mysql.MySQLError); ok {
if driverErr.Number != mysqlerr.ER_PARSE_ERROR && driverErr.Number != mysqlerr.ER_BAD_FIELD_ERROR && driverErr.Number != mysqlerr.ER_NO_SUCH_TABLE {
t.log.Error("query error", "err", err)
return errQueryFailed
}
}
return err
}
var errQueryFailed = errors.New("Query failed. Please inspect Grafana server log for details")

View File

@@ -33,13 +33,13 @@ func newPostgresQueryEndpoint(datasource *models.DataSource) (tsdb.TsdbQueryEndp
MetricColumnTypes: []string{"UNKNOWN", "TEXT", "VARCHAR", "CHAR"},
}
rowTransformer := postgresRowTransformer{
queryResultTransformer := postgresQueryResultTransformer{
log: logger,
}
timescaledb := datasource.JsonData.Get("timescaledb").MustBool(false)
return sqleng.NewSqlQueryEndpoint(&config, &rowTransformer, newPostgresMacroEngine(timescaledb), logger)
return sqleng.NewSqlQueryEndpoint(&config, &queryResultTransformer, newPostgresMacroEngine(timescaledb), logger)
}
func generateConnectionString(datasource *models.DataSource) string {
@@ -54,11 +54,11 @@ func generateConnectionString(datasource *models.DataSource) string {
return u.String()
}
type postgresRowTransformer struct {
type postgresQueryResultTransformer struct {
log log.Logger
}
func (t *postgresRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) {
func (t *postgresQueryResultTransformer) TransformQueryResult(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error) {
values := make([]interface{}, len(columnTypes))
valuePtrs := make([]interface{}, len(columnTypes))
@@ -93,3 +93,7 @@ func (t *postgresRowTransformer) Transform(columnTypes []*sql.ColumnType, rows *
return values, nil
}
func (t *postgresQueryResultTransformer) TransformQueryError(err error) error {
return err
}

View File

@@ -12,6 +12,8 @@ import (
"sync"
"time"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/infra/log"
"github.com/grafana/grafana/pkg/tsdb"
@@ -29,9 +31,12 @@ type SqlMacroEngine interface {
Interpolate(query *tsdb.Query, timeRange *tsdb.TimeRange, sql string) (string, error)
}
// SqlTableRowTransformer transforms a query result row to RowValues with proper types.
type SqlTableRowTransformer interface {
Transform(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error)
// SqlQueryResultTransformer transforms a query result row to RowValues with proper types.
type SqlQueryResultTransformer interface {
// TransformQueryResult transforms a query result row to RowValues with proper types.
TransformQueryResult(columnTypes []*sql.ColumnType, rows *core.Rows) (tsdb.RowValues, error)
// TransformQueryError transforms a query error.
TransformQueryError(err error) error
}
type engineCacheType struct {
@@ -52,12 +57,12 @@ var NewXormEngine = func(driverName string, connectionString string) (*xorm.Engi
}
type sqlQueryEndpoint struct {
macroEngine SqlMacroEngine
rowTransformer SqlTableRowTransformer
engine *xorm.Engine
timeColumnNames []string
metricColumnTypes []string
log log.Logger
macroEngine SqlMacroEngine
queryResultTransformer SqlQueryResultTransformer
engine *xorm.Engine
timeColumnNames []string
metricColumnTypes []string
log log.Logger
}
type SqlQueryEndpointConfiguration struct {
@@ -68,12 +73,12 @@ type SqlQueryEndpointConfiguration struct {
MetricColumnTypes []string
}
var NewSqlQueryEndpoint = func(config *SqlQueryEndpointConfiguration, rowTransformer SqlTableRowTransformer, macroEngine SqlMacroEngine, log log.Logger) (tsdb.TsdbQueryEndpoint, error) {
var NewSqlQueryEndpoint = func(config *SqlQueryEndpointConfiguration, queryResultTransformer SqlQueryResultTransformer, macroEngine SqlMacroEngine, log log.Logger) (tsdb.TsdbQueryEndpoint, error) {
queryEndpoint := sqlQueryEndpoint{
rowTransformer: rowTransformer,
macroEngine: macroEngine,
timeColumnNames: []string{"time"},
log: log,
queryResultTransformer: queryResultTransformer,
macroEngine: macroEngine,
timeColumnNames: []string{"time"},
log: log,
}
if len(config.TimeColumnNames) > 0 {
@@ -158,7 +163,7 @@ func (e *sqlQueryEndpoint) Query(ctx context.Context, dsInfo *models.DataSource,
rows, err := db.Query(rawSQL)
if err != nil {
queryResult.Error = err
queryResult.Error = e.queryResultTransformer.TransformQueryError(err)
return
}
@@ -240,7 +245,7 @@ func (e *sqlQueryEndpoint) transformToTable(query *tsdb.Query, rows *core.Rows,
return fmt.Errorf("query row limit exceeded, limit %d", rowLimit)
}
values, err := e.rowTransformer.Transform(columnTypes, rows)
values, err := e.queryResultTransformer.TransformQueryResult(columnTypes, rows)
if err != nil {
return err
}
@@ -338,7 +343,7 @@ func (e *sqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.R
return fmt.Errorf("query row limit exceeded, limit %d", rowLimit)
}
values, err := e.rowTransformer.Transform(columnTypes, rows)
values, err := e.queryResultTransformer.TransformQueryResult(columnTypes, rows)
if err != nil {
return err
}
@@ -418,7 +423,9 @@ func (e *sqlQueryEndpoint) transformToTimeSeries(query *tsdb.Query, rows *core.R
series.Points = append(series.Points, tsdb.TimePoint{value, null.FloatFrom(timestamp)})
e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value)
if setting.Env == setting.DEV {
e.log.Debug("Rows", "metric", metric, "time", timestamp, "value", value)
}
}
}

View File

@@ -1,4 +1,3 @@
import $ from 'jquery';
import _ from 'lodash';
import coreModule from 'app/core/core_module';
@@ -47,9 +46,36 @@ export class KeybindingSrv {
this.bind('s o', this.openSearch);
this.bind('f', this.openSearch);
this.bind('esc', this.exit);
this.bindGlobal('esc', this.globalEsc);
}
}
globalEsc() {
const anyDoc = document as any;
const activeElement = anyDoc.activeElement;
// typehead needs to handle it
const typeaheads = document.querySelectorAll('.slate-typeahead--open');
if (typeaheads.length > 0) {
return;
}
// second check if we are in an input we can blur
if (activeElement && activeElement.blur) {
if (
activeElement.nodeName === 'INPUT' ||
activeElement.nodeName === 'TEXTAREA' ||
activeElement.hasAttribute('data-slate-editor')
) {
anyDoc.activeElement.blur();
return;
}
}
// ok no focused input or editor that should block this, let exist!
this.exit();
}
openSearch() {
appEvents.emit('show-dash-search');
}
@@ -71,11 +97,6 @@ export class KeybindingSrv {
}
exit() {
const popups = $('.popover.in, .slate-typeahead');
if (popups.length > 0) {
return;
}
appEvents.emit('hide-modal');
if (this.modalOpen) {

View File

@@ -38,6 +38,7 @@ export function loadLdapState(): ThunkResult<void> {
const connectionInfo = await getLdapState();
dispatch(ldapConnectionInfoLoadedAction(connectionInfo));
} catch (error) {
error.isHandled = true;
const ldapError = {
title: error.data.message,
body: error.data.error,
@@ -63,6 +64,7 @@ export function loadUserMapping(username: string): ThunkResult<void> {
const userInfo = await getUserInfo(username);
dispatch(userMappingInfoLoadedAction(userInfo));
} catch (error) {
error.isHandled = true;
const userError = {
title: error.data.message,
body: error.data.error,
@@ -106,6 +108,7 @@ export function loadLdapUserInfo(userId: number): ThunkResult<void> {
dispatch(loadUserSessions(userId));
dispatch(loadUserMapping(user.login));
} catch (error) {
error.isHandled = true;
const userError = {
title: error.data.message,
body: error.data.error,

View File

@@ -47,18 +47,14 @@ export const syncLdapUser = async (userId: number) => {
};
export const getUserInfo = async (username: string): Promise<LdapUser> => {
try {
const response = await getBackendSrv().get(`/api/admin/ldap/${username}`);
const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response;
return {
info: { name, surname, email, login },
permissions: { isGrafanaAdmin, isDisabled },
roles,
teams,
};
} catch (error) {
throw error;
}
const response = await getBackendSrv().get(`/api/admin/ldap/${username}`);
const { name, surname, email, login, isGrafanaAdmin, isDisabled, roles, teams } = response;
return {
info: { name, surname, email, login },
permissions: { isGrafanaAdmin, isDisabled },
roles,
teams,
};
};
export const getUser = async (id: number): Promise<User> => {

View File

@@ -1,34 +1,46 @@
// Libraries
import React, { PureComponent } from 'react';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { css } from 'emotion';
import { Button } from '@grafana/ui';
// Services & Utils
import { AngularComponent, getAngularLoader } from '@grafana/runtime';
import { AngularComponent, getAngularLoader, getDataSourceSrv } from '@grafana/runtime';
import appEvents from 'app/core/app_events';
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
// Components
import { EditorTabBody, EditorToolbarView } from '../dashboard/panel_editor/EditorTabBody';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import StateHistory from './StateHistory';
import 'app/features/alerting/AlertTabCtrl';
// Types
import { DashboardModel } from '../dashboard/state/DashboardModel';
import { PanelModel } from '../dashboard/state/PanelModel';
import { TestRuleResult } from './TestRuleResult';
import { AlertBox } from 'app/core/components/AlertBox/AlertBox';
import { AppNotificationSeverity } from 'app/types';
import { AppNotificationSeverity, StoreState } from 'app/types';
import { PanelEditorTabIds, getPanelEditorTab } from '../dashboard/panel_editor/state/reducers';
import { changePanelEditorTab } from '../dashboard/panel_editor/state/actions';
interface Props {
angularPanel?: AngularComponent;
dashboard: DashboardModel;
panel: PanelModel;
changePanelEditorTab: typeof changePanelEditorTab;
}
export class AlertTab extends PureComponent<Props> {
interface State {
validatonMessage: string;
}
class UnConnectedAlertTab extends PureComponent<Props, State> {
element: any;
component: AngularComponent;
panelCtrl: any;
state: State = {
validatonMessage: '',
};
componentDidMount() {
if (this.shouldLoadAlertTab()) {
this.loadAlertTab();
@@ -51,8 +63,8 @@ export class AlertTab extends PureComponent<Props> {
}
}
loadAlertTab() {
const { angularPanel } = this.props;
async loadAlertTab() {
const { angularPanel, panel } = this.props;
const scope = angularPanel.getScope();
@@ -71,6 +83,17 @@ export class AlertTab extends PureComponent<Props> {
const scopeProps = { ctrl: this.panelCtrl };
this.component = loader.load(this.element, scopeProps, template);
const validatonMessage = await getAlertingValidationMessage(
panel.transformations,
panel.targets,
getDataSourceSrv(),
panel.datasource
);
if (validatonMessage) {
this.setState({ validatonMessage });
}
}
stateHistory = (): EditorToolbarView => {
@@ -128,19 +151,39 @@ export class AlertTab extends PureComponent<Props> {
this.forceUpdate();
};
switchToQueryTab = () => {
const { changePanelEditorTab } = this.props;
changePanelEditorTab(getPanelEditorTab(PanelEditorTabIds.Queries));
};
renderValidationMessage = () => {
const { validatonMessage } = this.state;
return (
<div
className={css`
width: 508px;
margin: 128px auto;
`}
>
<h2>{validatonMessage}</h2>
<br />
<div className="gf-form-group">
<Button size={'md'} variant={'secondary'} icon="fa fa-arrow-left" onClick={this.switchToQueryTab}>
Go back to Queries
</Button>
</div>
</div>
);
};
render() {
const { alert, transformations } = this.props.panel;
const hasTransformations = transformations && transformations.length;
const { validatonMessage } = this.state;
const hasTransformations = transformations && transformations.length > 0;
if (!alert && hasTransformations) {
return (
<EditorTabBody heading="Alert">
<AlertBox
severity={AppNotificationSeverity.Warning}
title="Transformations are not supported in alert queries"
/>
</EditorTabBody>
);
if (!alert && validatonMessage) {
return this.renderValidationMessage();
}
const toolbarItems = alert ? [this.stateHistory(), this.testRule(), this.deleteAlert()] : [];
@@ -163,9 +206,20 @@ export class AlertTab extends PureComponent<Props> {
)}
<div ref={element => (this.element = element)} />
{!alert && <EmptyListCTA {...model} />}
{!alert && !validatonMessage && <EmptyListCTA {...model} />}
</>
</EditorTabBody>
);
}
}
export const mapStateToProps = (state: StoreState) => ({});
const mapDispatchToProps = { changePanelEditorTab };
export const AlertTab = hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(UnConnectedAlertTab)
);

View File

@@ -10,6 +10,7 @@ import { DashboardSrv } from '../dashboard/services/DashboardSrv';
import DatasourceSrv from '../plugins/datasource_srv';
import { DataQuery } from '@grafana/ui/src/types/datasource';
import { PanelModel } from 'app/features/dashboard/state';
import { getDefaultCondition } from './getAlertingValidationMessage';
export class AlertTabCtrl {
panel: PanelModel;
@@ -179,7 +180,7 @@ export class AlertTabCtrl {
alert.conditions = alert.conditions || [];
if (alert.conditions.length === 0) {
alert.conditions.push(this.buildDefaultCondition());
alert.conditions.push(getDefaultCondition());
}
alert.noDataState = alert.noDataState || config.alertingNoDataOrNullValues;
@@ -241,16 +242,6 @@ export class AlertTabCtrl {
}
}
buildDefaultCondition() {
return {
type: 'query',
query: { params: ['A', '5m', 'now'] },
reducer: { type: 'avg', params: [] as any[] },
evaluator: { type: 'gt', params: [null] as any[] },
operator: { type: 'and' },
};
}
validateModel() {
if (!this.alert) {
return;
@@ -348,7 +339,7 @@ export class AlertTabCtrl {
}
addCondition(type: string) {
const condition = this.buildDefaultCondition();
const condition = getDefaultCondition();
// add to persited model
this.alert.conditions.push(condition);
// add to view model

View File

@@ -0,0 +1,148 @@
import { DataSourceSrv } from '@grafana/runtime';
import { DataSourceApi, PluginMeta } from '@grafana/ui';
import { DataTransformerConfig } from '@grafana/data';
import { ElasticsearchQuery } from '../../plugins/datasource/elasticsearch/types';
import { getAlertingValidationMessage } from './getAlertingValidationMessage';
describe('getAlertingValidationMessage', () => {
describe('when called with some targets containing template variables', () => {
it('then it should return false', async () => {
let call = 0;
const datasource: DataSourceApi = ({
meta: ({ alerting: true } as any) as PluginMeta,
targetContainsTemplate: () => {
if (call === 0) {
call++;
return true;
}
return false;
},
name: 'some name',
} as any) as DataSourceApi;
const getMock = jest.fn().mockResolvedValue(datasource);
const datasourceSrv: DataSourceSrv = {
get: getMock,
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
{ refId: 'B', query: '@instance:instance', isLogsQuery: false },
];
const transformations: DataTransformerConfig[] = [];
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
expect(result).toBe('');
expect(getMock).toHaveBeenCalledTimes(2);
expect(getMock).toHaveBeenCalledWith(datasource.name);
});
});
describe('when called with some targets using a datasource that does not support alerting', () => {
it('then it should return false', async () => {
const alertingDatasource: DataSourceApi = ({
meta: ({ alerting: true } as any) as PluginMeta,
targetContainsTemplate: () => false,
name: 'alertingDatasource',
} as any) as DataSourceApi;
const datasource: DataSourceApi = ({
meta: ({ alerting: false } as any) as PluginMeta,
targetContainsTemplate: () => false,
name: 'datasource',
} as any) as DataSourceApi;
const datasourceSrv: DataSourceSrv = {
get: (name: string) => {
if (name === datasource.name) {
return Promise.resolve(datasource);
}
return Promise.resolve(alertingDatasource);
},
};
const targets: any[] = [
{ refId: 'A', query: 'some query', datasource: 'alertingDatasource' },
{ refId: 'B', query: 'some query', datasource: 'datasource' },
];
const transformations: DataTransformerConfig[] = [];
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
expect(result).toBe('');
});
});
describe('when called with all targets containing template variables', () => {
it('then it should return false', async () => {
const datasource: DataSourceApi = ({
meta: ({ alerting: true } as any) as PluginMeta,
targetContainsTemplate: () => true,
name: 'some name',
} as any) as DataSourceApi;
const getMock = jest.fn().mockResolvedValue(datasource);
const datasourceSrv: DataSourceSrv = {
get: getMock,
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:$hostname', isLogsQuery: false },
{ refId: 'B', query: '@instance:$instance', isLogsQuery: false },
];
const transformations: DataTransformerConfig[] = [];
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
expect(result).toBe('Template variables are not supported in alert queries');
expect(getMock).toHaveBeenCalledTimes(2);
expect(getMock).toHaveBeenCalledWith(datasource.name);
});
});
describe('when called with all targets using a datasource that does not support alerting', () => {
it('then it should return false', async () => {
const datasource: DataSourceApi = ({
meta: ({ alerting: false } as any) as PluginMeta,
targetContainsTemplate: () => false,
name: 'some name',
} as any) as DataSourceApi;
const getMock = jest.fn().mockResolvedValue(datasource);
const datasourceSrv: DataSourceSrv = {
get: getMock,
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
{ refId: 'B', query: '@instance:instance', isLogsQuery: false },
];
const transformations: DataTransformerConfig[] = [];
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
expect(result).toBe('The datasource does not support alerting queries');
expect(getMock).toHaveBeenCalledTimes(2);
expect(getMock).toHaveBeenCalledWith(datasource.name);
});
});
describe('when called with transformations', () => {
it('then it should return false', async () => {
const datasource: DataSourceApi = ({
meta: ({ alerting: true } as any) as PluginMeta,
targetContainsTemplate: () => false,
name: 'some name',
} as any) as DataSourceApi;
const getMock = jest.fn().mockResolvedValue(datasource);
const datasourceSrv: DataSourceSrv = {
get: getMock,
};
const targets: ElasticsearchQuery[] = [
{ refId: 'A', query: '@hostname:hostname', isLogsQuery: false },
{ refId: 'B', query: '@instance:instance', isLogsQuery: false },
];
const transformations: DataTransformerConfig[] = [{ id: 'A', options: null }];
const result = await getAlertingValidationMessage(transformations, targets, datasourceSrv, datasource.name);
expect(result).toBe('Transformations are not supported in alert queries');
expect(getMock).toHaveBeenCalledTimes(0);
});
});
});

View File

@@ -0,0 +1,49 @@
import { DataQuery } from '@grafana/ui';
import { DataSourceSrv } from '@grafana/runtime';
import { DataTransformerConfig } from '@grafana/data';
export const getDefaultCondition = () => ({
type: 'query',
query: { params: ['A', '5m', 'now'] },
reducer: { type: 'avg', params: [] as any[] },
evaluator: { type: 'gt', params: [null] as any[] },
operator: { type: 'and' },
});
export const getAlertingValidationMessage = async (
transformations: DataTransformerConfig[],
targets: DataQuery[],
datasourceSrv: DataSourceSrv,
datasourceName: string
): Promise<string> => {
if (targets.length === 0) {
return 'Could not find any metric queries';
}
if (transformations && transformations.length) {
return 'Transformations are not supported in alert queries';
}
let alertingNotSupported = 0;
let templateVariablesNotSupported = 0;
for (const target of targets) {
const dsName = target.datasource || datasourceName;
const ds = await datasourceSrv.get(dsName);
if (!ds.meta.alerting) {
alertingNotSupported++;
} else if (ds.targetContainsTemplate && ds.targetContainsTemplate(target)) {
templateVariablesNotSupported++;
}
}
if (alertingNotSupported === targets.length) {
return 'The datasource does not support alerting queries';
}
if (templateVariablesNotSupported === targets.length) {
return 'Template variables are not supported in alert queries';
}
return '';
};

View File

@@ -14,7 +14,7 @@ import templateSrv from 'app/features/templating/template_srv';
import config from 'app/core/config';
// Types
import { DashboardModel, PanelModel } from '../state';
import { LoadingState, ScopedVars, AbsoluteTimeRange, toUtc } from '@grafana/data';
import { LoadingState, ScopedVars, AbsoluteTimeRange, toUtc, toDataFrameDTO } from '@grafana/data';
const DEFAULT_PLUGIN_ERROR = 'Error in plugin';
@@ -129,7 +129,7 @@ export class PanelChrome extends PureComponent<Props, State> {
if (data.state === LoadingState.Done) {
// If we are doing a snapshot save data in panel model
if (this.props.dashboard.snapshot) {
this.props.panel.snapshotData = data.series;
this.props.panel.snapshotData = data.series.map(frame => toDataFrameDTO(frame));
}
if (isFirstLoad) {
isFirstLoad = false;

View File

@@ -48,10 +48,10 @@ export class GeneralTab extends PureComponent<Props> {
}
}
onDataLinksChanged = (links: DataLink[]) => {
onDataLinksChanged = (links: DataLink[], callback?: () => void) => {
this.props.panel.links = links;
this.props.panel.render();
this.forceUpdate();
this.forceUpdate(callback);
};
render() {

View File

@@ -1,19 +1,19 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import { hot } from 'react-hot-loader';
import { connect } from 'react-redux';
import { Tooltip, PanelPlugin, PanelPluginMeta } from '@grafana/ui';
import { AngularComponent, config } from '@grafana/runtime';
import { QueriesTab } from './QueriesTab';
import VisualizationTab from './VisualizationTab';
import { GeneralTab } from './GeneralTab';
import { AlertTab } from '../../alerting/AlertTab';
import config from 'app/core/config';
import { store } from 'app/store/store';
import { updateLocation } from 'app/core/actions';
import { AngularComponent } from '@grafana/runtime';
import { PanelModel } from '../state/PanelModel';
import { DashboardModel } from '../state/DashboardModel';
import { Tooltip, PanelPlugin, PanelPluginMeta } from '@grafana/ui';
import { StoreState } from '../../../types';
import { PanelEditorTabIds, PanelEditorTab } from './state/reducers';
import { refreshPanelEditor, changePanelEditorTab, panelEditorCleanUp } from './state/actions';
interface PanelEditorProps {
panel: PanelModel;
@@ -21,56 +21,54 @@ interface PanelEditorProps {
plugin: PanelPlugin;
angularPanel?: AngularComponent;
onPluginTypeChange: (newType: PanelPluginMeta) => void;
activeTab: PanelEditorTabIds;
tabs: PanelEditorTab[];
refreshPanelEditor: typeof refreshPanelEditor;
panelEditorCleanUp: typeof panelEditorCleanUp;
changePanelEditorTab: typeof changePanelEditorTab;
}
interface PanelEditorTab {
id: string;
text: string;
}
enum PanelEditorTabIds {
Queries = 'queries',
Visualization = 'visualization',
Advanced = 'advanced',
Alert = 'alert',
}
interface PanelEditorTab {
id: string;
text: string;
}
const panelEditorTabTexts = {
[PanelEditorTabIds.Queries]: 'Queries',
[PanelEditorTabIds.Visualization]: 'Visualization',
[PanelEditorTabIds.Advanced]: 'General',
[PanelEditorTabIds.Alert]: 'Alert',
};
const getPanelEditorTab = (tabId: PanelEditorTabIds): PanelEditorTab => {
return {
id: tabId,
text: panelEditorTabTexts[tabId],
};
};
export class PanelEditor extends PureComponent<PanelEditorProps> {
class UnConnectedPanelEditor extends PureComponent<PanelEditorProps> {
constructor(props: PanelEditorProps) {
super(props);
}
componentDidMount(): void {
this.refreshFromState();
}
componentWillUnmount(): void {
const { panelEditorCleanUp } = this.props;
panelEditorCleanUp();
}
refreshFromState = (meta?: PanelPluginMeta) => {
const { refreshPanelEditor, plugin } = this.props;
meta = meta || plugin.meta;
refreshPanelEditor({
hasQueriesTab: !meta.skipDataQuery,
usesGraphPlugin: meta.id === 'graph',
alertingEnabled: config.alertingEnabled,
});
};
onChangeTab = (tab: PanelEditorTab) => {
store.dispatch(
updateLocation({
query: { tab: tab.id, openVizPicker: null },
partial: true,
})
);
this.forceUpdate();
const { changePanelEditorTab } = this.props;
// Angular Query Components can potentially refresh the PanelModel
// onBlur so this makes sure we change tab after that
setTimeout(() => changePanelEditorTab(tab), 10);
};
onPluginTypeChange = (newType: PanelPluginMeta) => {
const { onPluginTypeChange } = this.props;
onPluginTypeChange(newType);
this.refreshFromState(newType);
};
renderCurrentTab(activeTab: string) {
const { panel, dashboard, onPluginTypeChange, plugin, angularPanel } = this.props;
const { panel, dashboard, plugin, angularPanel } = this.props;
switch (activeTab) {
case 'advanced':
@@ -85,7 +83,7 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
panel={panel}
dashboard={dashboard}
plugin={plugin}
onPluginTypeChange={onPluginTypeChange}
onPluginTypeChange={this.onPluginTypeChange}
angularPanel={angularPanel}
/>
);
@@ -95,28 +93,7 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
}
render() {
const { plugin } = this.props;
let activeTab: PanelEditorTabIds = store.getState().location.query.tab || PanelEditorTabIds.Queries;
const tabs: PanelEditorTab[] = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
];
// handle panels that do not have queries tab
if (plugin.meta.skipDataQuery) {
// remove queries tab
tabs.shift();
// switch tab
if (activeTab === PanelEditorTabIds.Queries) {
activeTab = PanelEditorTabIds.Visualization;
}
}
if (config.alertingEnabled && plugin.meta.id === 'graph') {
tabs.push(getPanelEditorTab(PanelEditorTabIds.Alert));
}
const { activeTab, tabs } = this.props;
return (
<div className="panel-editor-container__editor">
@@ -131,6 +108,20 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
}
}
export const mapStateToProps = (state: StoreState) => ({
activeTab: state.location.query.tab || PanelEditorTabIds.Queries,
tabs: state.panelEditor.tabs,
});
const mapDispatchToProps = { refreshPanelEditor, panelEditorCleanUp, changePanelEditorTab };
export const PanelEditor = hot(module)(
connect(
mapStateToProps,
mapDispatchToProps
)(UnConnectedPanelEditor)
);
interface TabItemParams {
tab: PanelEditorTab;
activeTab: string;

View File

@@ -0,0 +1,127 @@
import { thunkTester } from '../../../../../test/core/thunk/thunkTester';
import { initialState, getPanelEditorTab, PanelEditorTabIds } from './reducers';
import { refreshPanelEditor, panelEditorInitCompleted, changePanelEditorTab } from './actions';
import { updateLocation } from '../../../../core/actions';
describe('refreshPanelEditor', () => {
describe('when called and there is no activeTab in state', () => {
it('then the dispatched action should default the activeTab to PanelEditorTabIds.Queries', async () => {
const activeTab = PanelEditorTabIds.Queries;
const tabs = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
getPanelEditorTab(PanelEditorTabIds.Alert),
];
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState, activeTab: null } })
.givenThunk(refreshPanelEditor)
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true });
expect(dispatchedActions.length).toBe(1);
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
});
});
describe('when called and there is already an activeTab in state', () => {
it('then the dispatched action should include activeTab from state', async () => {
const activeTab = PanelEditorTabIds.Visualization;
const tabs = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
getPanelEditorTab(PanelEditorTabIds.Alert),
];
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState, activeTab } })
.givenThunk(refreshPanelEditor)
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true });
expect(dispatchedActions.length).toBe(1);
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
});
});
describe('when called and plugin has no queries tab', () => {
it('then the dispatched action should not include Queries tab and default the activeTab to PanelEditorTabIds.Visualization', async () => {
const activeTab = PanelEditorTabIds.Visualization;
const tabs = [
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
getPanelEditorTab(PanelEditorTabIds.Alert),
];
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
.givenThunk(refreshPanelEditor)
.whenThunkIsDispatched({ hasQueriesTab: false, alertingEnabled: true, usesGraphPlugin: true });
expect(dispatchedActions.length).toBe(1);
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
});
});
describe('when called and alerting is enabled and the visualization is the graph plugin', () => {
it('then the dispatched action should include the alert tab', async () => {
const activeTab = PanelEditorTabIds.Queries;
const tabs = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
getPanelEditorTab(PanelEditorTabIds.Alert),
];
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
.givenThunk(refreshPanelEditor)
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: true });
expect(dispatchedActions.length).toBe(1);
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
});
});
describe('when called and alerting is not enabled', () => {
it('then the dispatched action should not include the alert tab', async () => {
const activeTab = PanelEditorTabIds.Queries;
const tabs = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
];
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
.givenThunk(refreshPanelEditor)
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: false, usesGraphPlugin: true });
expect(dispatchedActions.length).toBe(1);
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
});
});
describe('when called and the visualization is not the graph plugin', () => {
it('then the dispatched action should not include the alert tab', async () => {
const activeTab = PanelEditorTabIds.Queries;
const tabs = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
];
const dispatchedActions = await thunkTester({ panelEditor: { ...initialState } })
.givenThunk(refreshPanelEditor)
.whenThunkIsDispatched({ hasQueriesTab: true, alertingEnabled: true, usesGraphPlugin: false });
expect(dispatchedActions.length).toBe(1);
expect(dispatchedActions[0]).toEqual(panelEditorInitCompleted({ activeTab, tabs }));
});
});
});
describe('changePanelEditorTab', () => {
describe('when called', () => {
it('then it should dispatch correct actions', async () => {
const activeTab = getPanelEditorTab(PanelEditorTabIds.Visualization);
const dispatchedActions = await thunkTester({})
.givenThunk(changePanelEditorTab)
.whenThunkIsDispatched(activeTab);
expect(dispatchedActions.length).toBe(1);
expect(dispatchedActions).toEqual([
updateLocation({ query: { tab: activeTab.id, openVizPicker: null }, partial: true }),
]);
});
});
});

View File

@@ -0,0 +1,54 @@
import { actionCreatorFactory, noPayloadActionCreatorFactory } from '../../../../core/redux';
import { PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers';
import { ThunkResult } from '../../../../types';
import { updateLocation } from '../../../../core/actions';
export interface PanelEditorInitCompleted {
activeTab: PanelEditorTabIds;
tabs: PanelEditorTab[];
}
export const panelEditorInitCompleted = actionCreatorFactory<PanelEditorInitCompleted>(
'PANEL_EDITOR_INIT_COMPLETED'
).create();
export const panelEditorCleanUp = noPayloadActionCreatorFactory('PANEL_EDITOR_CLEAN_UP').create();
export const refreshPanelEditor = (props: {
hasQueriesTab?: boolean;
usesGraphPlugin?: boolean;
alertingEnabled?: boolean;
}): ThunkResult<void> => {
return async (dispatch, getState) => {
let activeTab = getState().panelEditor.activeTab || PanelEditorTabIds.Queries;
const { hasQueriesTab, usesGraphPlugin, alertingEnabled } = props;
const tabs: PanelEditorTab[] = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
];
// handle panels that do not have queries tab
if (!hasQueriesTab) {
// remove queries tab
tabs.shift();
// switch tab
if (activeTab === PanelEditorTabIds.Queries) {
activeTab = PanelEditorTabIds.Visualization;
}
}
if (alertingEnabled && usesGraphPlugin) {
tabs.push(getPanelEditorTab(PanelEditorTabIds.Alert));
}
dispatch(panelEditorInitCompleted({ activeTab, tabs }));
};
};
export const changePanelEditorTab = (activeTab: PanelEditorTab): ThunkResult<void> => {
return async dispatch => {
dispatch(updateLocation({ query: { tab: activeTab.id, openVizPicker: null }, partial: true }));
};
};

View File

@@ -0,0 +1,35 @@
import { reducerTester } from '../../../../../test/core/redux/reducerTester';
import { initialState, panelEditorReducer, PanelEditorTabIds, PanelEditorTab, getPanelEditorTab } from './reducers';
import { panelEditorInitCompleted, panelEditorCleanUp } from './actions';
describe('panelEditorReducer', () => {
describe('when panelEditorInitCompleted is dispatched', () => {
it('then state should be correct', () => {
const activeTab = PanelEditorTabIds.Alert;
const tabs: PanelEditorTab[] = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
];
reducerTester()
.givenReducer(panelEditorReducer, initialState)
.whenActionIsDispatched(panelEditorInitCompleted({ activeTab, tabs }))
.thenStateShouldEqual({ activeTab, tabs });
});
});
describe('when panelEditorCleanUp is dispatched', () => {
it('then state should be intialState', () => {
const activeTab = PanelEditorTabIds.Alert;
const tabs: PanelEditorTab[] = [
getPanelEditorTab(PanelEditorTabIds.Queries),
getPanelEditorTab(PanelEditorTabIds.Visualization),
getPanelEditorTab(PanelEditorTabIds.Advanced),
];
reducerTester()
.givenReducer(panelEditorReducer, { activeTab, tabs })
.whenActionIsDispatched(panelEditorCleanUp())
.thenStateShouldEqual(initialState);
});
});
});

View File

@@ -0,0 +1,56 @@
import { reducerFactory } from '../../../../core/redux';
import { panelEditorCleanUp, panelEditorInitCompleted } from './actions';
export interface PanelEditorTab {
id: string;
text: string;
}
export enum PanelEditorTabIds {
Queries = 'queries',
Visualization = 'visualization',
Advanced = 'advanced',
Alert = 'alert',
}
export const panelEditorTabTexts = {
[PanelEditorTabIds.Queries]: 'Queries',
[PanelEditorTabIds.Visualization]: 'Visualization',
[PanelEditorTabIds.Advanced]: 'General',
[PanelEditorTabIds.Alert]: 'Alert',
};
export const getPanelEditorTab = (tabId: PanelEditorTabIds): PanelEditorTab => {
return {
id: tabId,
text: panelEditorTabTexts[tabId],
};
};
export interface PanelEditorState {
activeTab: PanelEditorTabIds;
tabs: PanelEditorTab[];
}
export const initialState: PanelEditorState = {
activeTab: null,
tabs: [],
};
export const panelEditorReducer = reducerFactory<PanelEditorState>(initialState)
.addMapper({
filter: panelEditorInitCompleted,
mapper: (state, action): PanelEditorState => {
const { activeTab, tabs } = action.payload;
return {
...state,
activeTab,
tabs,
};
},
})
.addMapper({
filter: panelEditorCleanUp,
mapper: (): PanelEditorState => initialState,
})
.create();

View File

@@ -93,6 +93,11 @@ describe('PanelModel', () => {
expect(saveModel.gridPos).toBe(undefined);
});
it('getSaveModel should not remove datasource default', () => {
const saveModel = model.getSaveModel();
expect(saveModel.datasource).toBe(null);
});
it('getSaveModel should remove nonPersistedProperties', () => {
const saveModel = model.getSaveModel();
expect(saveModel.events).toBe(undefined);

View File

@@ -1,10 +1,8 @@
// Libraries
import _ from 'lodash';
// Utils
import { Emitter } from 'app/core/utils/emitter';
import { getNextRefIdChar } from 'app/core/utils/query';
// Types
import { DataQuery, DataQueryResponseData, PanelPlugin } from '@grafana/ui';
import { DataLink, DataTransformerConfig, ScopedVars } from '@grafana/data';
@@ -71,7 +69,6 @@ const mustKeepProps: { [str: string]: boolean } = {
const defaults: any = {
gridPos: { x: 0, y: 0, h: 3, w: 6 },
datasource: null,
targets: [{ refId: 'A' }],
cachedPluginOptions: {},
transparent: false,
@@ -128,6 +125,10 @@ export class PanelModel {
constructor(model: any) {
this.events = new Emitter();
// should not be part of defaults as defaults are removed in save model and
// this should not be removed in save model as exporter needs to templatize it
this.datasource = null;
// copy properties from persisted model
for (const property in model) {
(this as any)[property] = model[property];

View File

@@ -2,11 +2,9 @@
import { getBackendSrv } from '@grafana/runtime';
import { actionCreatorFactory, noPayloadActionCreatorFactory } from 'app/core/redux';
import { createSuccessNotification } from 'app/core/copy/appNotification';
// Actions
import { loadPluginDashboards } from '../../plugins/state/actions';
import { notifyApp } from 'app/core/actions';
// Types
import {
ThunkResult,

View File

@@ -11,6 +11,7 @@ import {
import { reducerFactory } from 'app/core/redux';
import { processAclItems } from 'app/core/utils/acl';
import { DashboardModel } from './DashboardModel';
import { panelEditorReducer } from '../panel_editor/state/reducers';
export const initialState: DashboardState = {
initPhase: DashboardInitPhase.NotStarted,
@@ -87,4 +88,5 @@ export const dashboardReducer = reducerFactory(initialState)
export default {
dashboard: dashboardReducer,
panelEditor: panelEditorReducer,
};

View File

@@ -13,7 +13,6 @@ import {
DataQueryResponseData,
DataQueryError,
} from '@grafana/ui';
import { LoadingState, dateMath, toDataFrame, DataFrame, guessFieldTypes } from '@grafana/data';
type MapOfResponsePackets = { [str: string]: DataQueryResponse };

View File

@@ -1,6 +1,7 @@
// Libraries
import React, { ComponentClass } from 'react';
import { hot } from 'react-hot-loader';
import { css } from 'emotion';
// @ts-ignore
import { connect } from 'react-redux';
import { AutoSizer } from 'react-virtualized';
@@ -52,6 +53,16 @@ import { ErrorContainer } from './ErrorContainer';
import { scanStopAction } from './state/actionTypes';
import { ExploreGraphPanel } from './ExploreGraphPanel';
const getStyles = memoizeOne(() => {
return {
logsMain: css`
label: logsMain;
// Is needed for some transition animations to work.
position: relative;
`,
};
});
interface ExploreProps {
StartPage?: ComponentClass<ExploreStartPageProps>;
changeSize: typeof changeSize;
@@ -257,6 +268,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
queryResponse,
} = this.props;
const exploreClass = split ? 'explore explore-split' : 'explore';
const styles = getStyles();
return (
<div className={exploreClass} ref={this.getRef}>
@@ -284,7 +296,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
}
return (
<main className="m-t-2" style={{ width }}>
<main className={`m-t-2 ${styles.logsMain}`} style={{ width }}>
<ErrorBoundaryAlert>
{showingStartPage && (
<div className="grafana-info-box grafana-info-box--max-lg">

View File

@@ -4,6 +4,7 @@ import { connect } from 'react-redux';
import { hot } from 'react-hot-loader';
import memoizeOne from 'memoize-one';
import classNames from 'classnames';
import { css } from 'emotion';
import { ExploreId, ExploreItemState, ExploreMode } from 'app/types/explore';
import {
@@ -39,6 +40,14 @@ import { LiveTailButton } from './LiveTailButton';
import { ResponsiveButton } from './ResponsiveButton';
import { RunButton } from './RunButton';
const getStyles = memoizeOne(() => {
return {
liveTailButtons: css`
margin-left: 10px;
`,
};
});
interface OwnProps {
exploreId: ExploreId;
onChangeTime: (range: RawTimeRange, changedByScanner?: boolean) => void;
@@ -132,6 +141,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
stopLive = () => {
const { exploreId } = this.props;
this.pauseLive();
// TODO referencing this from perspective of refresh picker when there is designated button for it now is not
// great. Needs another refactor.
this.props.changeRefreshIntervalAction({ exploreId, refreshInterval: RefreshPicker.offOption.value });
@@ -174,6 +184,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
originPanelId,
} = this.props;
const styles = getStyles();
const originDashboardIsEditable = Number.isInteger(originPanelId);
const panelReturnClasses = classNames('btn', 'navbar-button', {
'btn--radius-right-0': originDashboardIsEditable,
@@ -293,14 +304,16 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
</div>
{hasLiveOption && (
<LiveTailButton
isLive={isLive}
isPaused={isPaused}
start={this.startLive}
pause={this.pauseLive}
resume={this.resumeLive}
stop={this.stopLive}
/>
<div className={`explore-toolbar-content-item ${styles.liveTailButtons}`}>
<LiveTailButton
isLive={isLive}
isPaused={isPaused}
start={this.startLive}
pause={this.pauseLive}
resume={this.resumeLive}
stop={this.stopLive}
/>
</div>
)}
</div>
</div>

View File

@@ -157,7 +157,7 @@ class LiveLogs extends PureComponent<Props, State> {
const { logsRow, logsRowLocalTime, logsRowMessage } = getLogRowStyles(theme);
return (
<>
<div>
<div
onScroll={isPaused ? undefined : this.onScroll}
className={cx(['logs-rows', styles.logsRowsLive])}
@@ -210,7 +210,7 @@ class LiveLogs extends PureComponent<Props, State> {
</span>
)}
</div>
</>
</div>
);
}
}

View File

@@ -2,28 +2,30 @@ import React from 'react';
import classNames from 'classnames';
import { css } from 'emotion';
import memoizeOne from 'memoize-one';
import { GrafanaTheme, GrafanaThemeType, useTheme } from '@grafana/ui';
import tinycolor from 'tinycolor2';
import { CSSTransition } from 'react-transition-group';
const orangeDark = '#FF780A';
const orangeDarkLighter = tinycolor(orangeDark)
.lighten(10)
.toString();
const orangeLight = '#ED5700';
const orangeLightLighter = tinycolor(orangeLight)
.lighten(10)
.toString();
import { GrafanaTheme, GrafanaThemeType, useTheme } from '@grafana/ui';
const getStyles = memoizeOne((theme: GrafanaTheme) => {
const orange = theme.type === GrafanaThemeType.Dark ? orangeDark : orangeLight;
const orangeLighter = theme.type === GrafanaThemeType.Dark ? orangeDarkLighter : orangeLightLighter;
const textColor = theme.type === GrafanaThemeType.Dark ? theme.colors.white : theme.colors.black;
const orange = theme.type === GrafanaThemeType.Dark ? '#FF780A' : '#ED5700';
const orangeLighter = tinycolor(orange)
.lighten(10)
.toString();
const pulseTextColor = tinycolor(orange)
.desaturate(90)
.toString();
return {
noRightBorderStyle: css`
label: noRightBorderStyle;
border-right: 0;
`,
liveButton: css`
label: liveButton;
transition: background-color 1s, border-color 1s, color 1s;
margin: 0;
`,
isLive: css`
label: isLive;
border-color: ${orange};
@@ -43,7 +45,7 @@ const getStyles = memoizeOne((theme: GrafanaTheme) => {
label: isPaused;
border-color: ${orange};
background: transparent;
animation: pulse 2s ease-out 0s infinite normal forwards;
animation: pulse 3s ease-out 0s infinite normal forwards;
&:focus {
border-color: ${orange};
}
@@ -53,16 +55,40 @@ const getStyles = memoizeOne((theme: GrafanaTheme) => {
}
@keyframes pulse {
0% {
color: ${textColor};
color: ${pulseTextColor};
}
50% {
color: ${orange};
}
100% {
color: ${textColor};
color: ${pulseTextColor};
}
}
`,
stopButtonEnter: css`
label: stopButtonEnter;
width: 0;
opacity: 0;
overflow: hidden;
`,
stopButtonEnterActive: css`
label: stopButtonEnterActive;
opacity: 1;
width: 32px;
transition: opacity 500ms ease-in 50ms, width 500ms ease-in 50ms;
`,
stopButtonExit: css`
label: stopButtonExit;
width: 32px;
opacity: 1;
overflow: hidden;
`,
stopButtonExitActive: css`
label: stopButtonExitActive;
opacity: 0;
width: 0;
transition: opacity 500ms ease-in 50ms, width 500ms ease-in 50ms;
`,
};
});
@@ -82,9 +108,9 @@ export function LiveTailButton(props: LiveTailButtonProps) {
const onClickMain = isLive ? (isPaused ? resume : pause) : start;
return (
<div className="explore-toolbar-content-item">
<>
<button
className={classNames('btn navbar-button', {
className={classNames('btn navbar-button', styles.liveButton, {
[`btn--radius-right-0 ${styles.noRightBorderStyle}`]: isLive,
[styles.isLive]: isLive && !isPaused,
[styles.isPaused]: isLive && isPaused,
@@ -94,11 +120,24 @@ export function LiveTailButton(props: LiveTailButtonProps) {
<i className={classNames('fa', isPaused || !isLive ? 'fa-play' : 'fa-pause')} />
&nbsp; Live tailing
</button>
{isLive && (
<button className={`btn navbar-button navbar-button--attached ${styles.isLive}`} onClick={stop}>
<i className={'fa fa-stop'} />
</button>
)}
</div>
<CSSTransition
mountOnEnter={true}
unmountOnExit={true}
timeout={500}
in={isLive}
classNames={{
enter: styles.stopButtonEnter,
enterActive: styles.stopButtonEnterActive,
exit: styles.stopButtonExit,
exitActive: styles.stopButtonExitActive,
}}
>
<div>
<button className={`btn navbar-button navbar-button--attached ${styles.isLive}`} onClick={stop}>
<i className={'fa fa-stop'} />
</button>
</div>
</CSSTransition>
</>
);
}

View File

@@ -1,4 +1,3 @@
import _ from 'lodash';
import React, { PureComponent } from 'react';
import {

View File

@@ -27,6 +27,7 @@ import { deduplicatedLogsSelector, exploreItemUIStateSelector } from 'app/featur
import { getTimeZone } from '../profile/state/selectors';
import { LiveLogsWithTheme } from './LiveLogs';
import { Logs } from './Logs';
import { LogsCrossFadeTransition } from './utils/LogsCrossFadeTransition';
interface LogsContainerProps {
datasourceInstance: DataSourceApi | null;
@@ -64,6 +65,7 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
onStopLive = () => {
const { exploreId } = this.props;
this.onPause();
this.props.stopLive({ exploreId, refreshInterval: RefreshPicker.offOption.value });
};
@@ -116,43 +118,44 @@ export class LogsContainer extends PureComponent<LogsContainerProps> {
isLive,
} = this.props;
if (isLive) {
return (
<Collapse label="Logs" loading={false} isOpen>
<LiveLogsWithTheme
logsResult={logsResult}
timeZone={timeZone}
stopLive={this.onStopLive}
isPaused={this.props.isPaused}
onPause={this.onPause}
onResume={this.onResume}
/>
</Collapse>
);
}
return (
<Collapse label="Logs" loading={loading} isOpen>
<Logs
dedupStrategy={this.props.dedupStrategy || LogsDedupStrategy.none}
data={logsResult}
dedupedData={dedupedResult}
highlighterExpressions={logsHighlighterExpressions}
loading={loading}
onChangeTime={this.onChangeTime}
onClickLabel={onClickLabel}
onStartScanning={onStartScanning}
onStopScanning={onStopScanning}
onDedupStrategyChange={this.handleDedupStrategyChange}
onToggleLogLevel={this.handleToggleLogLevel}
absoluteRange={absoluteRange}
timeZone={timeZone}
scanning={scanning}
scanRange={range.raw}
width={width}
getRowContext={this.getLogRowContext}
/>
</Collapse>
<>
<LogsCrossFadeTransition visible={isLive}>
<Collapse label="Logs" loading={false} isOpen>
<LiveLogsWithTheme
logsResult={logsResult}
timeZone={timeZone}
stopLive={this.onStopLive}
isPaused={this.props.isPaused}
onPause={this.onPause}
onResume={this.onResume}
/>
</Collapse>
</LogsCrossFadeTransition>
<LogsCrossFadeTransition visible={!isLive}>
<Collapse label="Logs" loading={loading} isOpen>
<Logs
dedupStrategy={this.props.dedupStrategy || LogsDedupStrategy.none}
data={logsResult}
dedupedData={dedupedResult}
highlighterExpressions={logsHighlighterExpressions}
loading={loading}
onChangeTime={this.onChangeTime}
onClickLabel={onClickLabel}
onStartScanning={onStartScanning}
onStopScanning={onStopScanning}
onDedupStrategyChange={this.handleDedupStrategyChange}
onToggleLogLevel={this.handleToggleLogLevel}
absoluteRange={absoluteRange}
timeZone={timeZone}
scanning={scanning}
scanRange={range.raw}
width={width}
getRowContext={this.getLogRowContext}
/>
</Collapse>
</LogsCrossFadeTransition>
</>
);
}
}

View File

@@ -17,45 +17,4 @@ describe('<QueryField />', () => {
const wrapper = shallow(<QueryField initialQuery="my query" />);
expect(wrapper.find('div').exists()).toBeTruthy();
});
it('should execute query when enter is pressed and there are no suggestions visible', () => {
const wrapper = shallow(<QueryField initialQuery="my query" />);
const instance = wrapper.instance() as QueryField;
instance.executeOnChangeAndRunQueries = jest.fn();
const handleEnterAndTabKeySpy = jest.spyOn(instance, 'handleEnterKey');
instance.onKeyDown({ key: 'Enter', preventDefault: () => {} } as KeyboardEvent, {});
expect(handleEnterAndTabKeySpy).toBeCalled();
expect(instance.executeOnChangeAndRunQueries).toBeCalled();
});
it('should copy selected text', () => {
const wrapper = shallow(<QueryField initialQuery="" />);
const instance = wrapper.instance() as QueryField;
const textBlocks = ['ignore this text. copy this text'];
const copiedText = instance.getCopiedText(textBlocks, 18, 32);
expect(copiedText).toBe('copy this text');
});
it('should copy selected text across 2 lines', () => {
const wrapper = shallow(<QueryField initialQuery="" />);
const instance = wrapper.instance() as QueryField;
const textBlocks = ['ignore this text. start copying here', 'lorem ipsum. stop copying here. lorem ipsum'];
const copiedText = instance.getCopiedText(textBlocks, 18, 30);
expect(copiedText).toBe('start copying here\nlorem ipsum. stop copying here');
});
it('should copy selected text across > 2 lines', () => {
const wrapper = shallow(<QueryField initialQuery="" />);
const instance = wrapper.instance() as QueryField;
const textBlocks = [
'ignore this text. start copying here',
'lorem ipsum doler sit amet',
'lorem ipsum. stop copying here. lorem ipsum',
];
const copiedText = instance.getCopiedText(textBlocks, 18, 30);
expect(copiedText).toBe('start copying here\nlorem ipsum doler sit amet\nlorem ipsum. stop copying here');
});
});

View File

@@ -1,55 +1,36 @@
import _ from 'lodash';
import React, { Context } from 'react';
import ReactDOM from 'react-dom';
// @ts-ignore
import { Change, Range, Value, Block } from 'slate';
// @ts-ignore
import { Editor } from 'slate-react';
// @ts-ignore
import { Value, Editor as CoreEditor } from 'slate';
import { Editor, Plugin } from '@grafana/slate-react';
import Plain from 'slate-plain-serializer';
import classnames from 'classnames';
// @ts-ignore
import { isKeyHotkey } from 'is-hotkey';
import { CompletionItem, CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
import { CompletionItemGroup, TypeaheadOutput } from 'app/types/explore';
import ClearPlugin from './slate-plugins/clear';
import NewlinePlugin from './slate-plugins/newline';
import SelectionShortcutsPlugin from './slate-plugins/selection_shortcuts';
import IndentationPlugin from './slate-plugins/indentation';
import ClipboardPlugin from './slate-plugins/clipboard';
import RunnerPlugin from './slate-plugins/runner';
import SuggestionsPlugin, { SuggestionsState } from './slate-plugins/suggestions';
import { TypeaheadWithTheme } from './Typeahead';
import { makeFragment, makeValue } from '@grafana/ui';
import { Typeahead } from './Typeahead';
import { makeValue, SCHEMA } from '@grafana/ui';
export const TYPEAHEAD_DEBOUNCE = 100;
export const HIGHLIGHT_WAIT = 500;
const SLATE_TAB = ' ';
const isIndentLeftHotkey = isKeyHotkey('mod+[');
const isIndentRightHotkey = isKeyHotkey('mod+]');
const isSelectLeftHotkey = isKeyHotkey('shift+left');
const isSelectRightHotkey = isKeyHotkey('shift+right');
const isSelectUpHotkey = isKeyHotkey('shift+up');
const isSelectDownHotkey = isKeyHotkey('shift+down');
const isSelectLineHotkey = isKeyHotkey('mod+l');
function getSuggestionByIndex(suggestions: CompletionItemGroup[], index: number): CompletionItem {
// Flatten suggestion groups
const flattenedSuggestions = suggestions.reduce((acc, g) => acc.concat(g.items), []);
const correctedIndex = Math.max(index, 0) % flattenedSuggestions.length;
return flattenedSuggestions[correctedIndex];
}
function hasSuggestions(suggestions: CompletionItemGroup[]): boolean {
return suggestions && suggestions.length > 0;
}
export interface QueryFieldProps {
additionalPlugins?: any[];
additionalPlugins?: Plugin[];
cleanText?: (text: string) => string;
disabled?: boolean;
initialQuery: string | null;
onRunQuery?: () => void;
onChange?: (value: string) => void;
onTypeahead?: (typeahead: TypeaheadInput) => TypeaheadOutput;
onWillApplySuggestion?: (suggestion: string, state: QueryFieldState) => string;
onTypeahead?: (typeahead: TypeaheadInput) => Promise<TypeaheadOutput>;
onWillApplySuggestion?: (suggestion: string, state: SuggestionsState) => string;
placeholder?: string;
portalOrigin?: string;
syntax?: string;
@@ -59,20 +40,19 @@ export interface QueryFieldProps {
export interface QueryFieldState {
suggestions: CompletionItemGroup[];
typeaheadContext: string | null;
typeaheadIndex: number;
typeaheadPrefix: string;
typeaheadText: string;
value: any;
value: Value;
lastExecutedValue: Value;
}
export interface TypeaheadInput {
editorNode: Element;
prefix: string;
selection?: Selection;
text: string;
value: Value;
wrapperNode: Element;
wrapperClasses: string[];
labelKey?: string;
}
/**
@@ -83,23 +63,35 @@ export interface TypeaheadInput {
*/
export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldState> {
menuEl: HTMLElement | null;
plugins: any[];
resetTimer: any;
plugins: Plugin[];
resetTimer: NodeJS.Timer;
mounted: boolean;
updateHighlightsTimer: any;
updateHighlightsTimer: Function;
editor: Editor;
typeaheadRef: Typeahead;
constructor(props: QueryFieldProps, context: Context<any>) {
super(props, context);
this.updateHighlightsTimer = _.debounce(this.updateLogsHighlights, HIGHLIGHT_WAIT);
const { onTypeahead, cleanText, portalOrigin, onWillApplySuggestion } = props;
// Base plugins
this.plugins = [ClearPlugin(), NewlinePlugin(), ...(props.additionalPlugins || [])].filter(p => p);
this.plugins = [
NewlinePlugin(),
SuggestionsPlugin({ onTypeahead, cleanText, portalOrigin, onWillApplySuggestion, component: this }),
ClearPlugin(),
RunnerPlugin({ handler: this.executeOnChangeAndRunQueries }),
SelectionShortcutsPlugin(),
IndentationPlugin(),
ClipboardPlugin(),
...(props.additionalPlugins || []),
].filter(p => p);
this.state = {
suggestions: [],
typeaheadContext: null,
typeaheadIndex: 0,
typeaheadPrefix: '',
typeaheadText: '',
value: makeValue(props.initialQuery || '', props.syntax),
@@ -109,7 +101,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
componentDidMount() {
this.mounted = true;
this.updateMenu();
}
componentWillUnmount() {
@@ -119,7 +110,7 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
componentDidUpdate(prevProps: QueryFieldProps, prevState: QueryFieldState) {
const { initialQuery, syntax } = this.props;
const { value, suggestions } = this.state;
const { value } = this.state;
// if query changed from the outside
if (initialQuery !== prevProps.initialQuery) {
@@ -128,26 +119,17 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
this.setState({ value: makeValue(initialQuery || '', syntax) });
}
}
// Only update menu location when suggestion existence or text/selection changed
if (value !== prevState.value || hasSuggestions(suggestions) !== hasSuggestions(prevState.suggestions)) {
this.updateMenu();
}
}
UNSAFE_componentWillReceiveProps(nextProps: QueryFieldProps) {
if (nextProps.syntaxLoaded && !this.props.syntaxLoaded) {
// Need a bogus edit to re-render the editor after syntax has fully loaded
const change = this.state.value
.change()
.insertText(' ')
.deleteBackward();
this.onChange(change, true);
const editor = this.editor.insertText(' ').deleteBackward(1);
this.onChange(editor.value, true);
}
}
onChange = ({ value }: Change, invokeParentOnValueChanged?: boolean) => {
onChange = (value: Value, invokeParentOnValueChanged?: boolean) => {
const documentChanged = value.document !== this.state.value.document;
const prevValue = this.state.value;
@@ -163,14 +145,6 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
}
});
// Show suggest menu on text input
if (documentChanged && value.selection.isCollapsed) {
// Need one paint to allow DOM-based typeahead rules to work
window.requestAnimationFrame(this.handleTypeahead);
} else if (!this.resetTimer) {
this.resetTypeahead();
}
};
updateLogsHighlights = () => {
@@ -194,475 +168,18 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
};
handleTypeahead = _.debounce(async () => {
const selection = window.getSelection();
const { cleanText, onTypeahead } = this.props;
const { value } = this.state;
if (onTypeahead && selection.anchorNode) {
const wrapperNode = selection.anchorNode.parentElement;
const editorNode = wrapperNode.closest('.slate-query-field');
if (!editorNode || this.state.value.isBlurred) {
// Not inside this editor
return;
}
const range = selection.getRangeAt(0);
const offset = range.startOffset;
const text = selection.anchorNode.textContent;
let prefix = text.substr(0, offset);
// Label values could have valid characters erased if `cleanText()` is
// blindly applied, which would undesirably interfere with suggestions
const labelValueMatch = prefix.match(/(?:!?=~?"?|")(.*)/);
if (labelValueMatch) {
prefix = labelValueMatch[1];
} else if (cleanText) {
prefix = cleanText(prefix);
}
const { suggestions, context, refresher } = onTypeahead({
editorNode,
prefix,
selection,
text,
value,
wrapperNode,
});
let filteredSuggestions = suggestions
.map(group => {
if (group.items) {
if (prefix) {
// Filter groups based on prefix
if (!group.skipFilter) {
group.items = group.items.filter(c => (c.filterText || c.label).length >= prefix.length);
if (group.prefixMatch) {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) === 0);
} else {
group.items = group.items.filter(c => (c.filterText || c.label).indexOf(prefix) > -1);
}
}
// Filter out the already typed value (prefix) unless it inserts custom text
group.items = group.items.filter(c => c.insertText || (c.filterText || c.label) !== prefix);
}
if (!group.skipSort) {
group.items = _.sortBy(group.items, (item: CompletionItem) => item.sortText || item.label);
}
}
return group;
})
.filter(group => group.items && group.items.length > 0); // Filter out empty groups
// Keep same object for equality checking later
if (_.isEqual(filteredSuggestions, this.state.suggestions)) {
filteredSuggestions = this.state.suggestions;
}
this.setState(
{
suggestions: filteredSuggestions,
typeaheadPrefix: prefix,
typeaheadContext: context,
typeaheadText: text,
},
() => {
if (refresher) {
refresher.then(this.handleTypeahead).catch(e => console.error(e));
}
}
);
}
}, TYPEAHEAD_DEBOUNCE);
applyTypeahead(change: Change, suggestion: CompletionItem): Change {
const { cleanText, onWillApplySuggestion, syntax } = this.props;
const { typeaheadPrefix, typeaheadText } = this.state;
let suggestionText = suggestion.insertText || suggestion.label;
const preserveSuffix = suggestion.kind === 'function';
const move = suggestion.move || 0;
if (onWillApplySuggestion) {
suggestionText = onWillApplySuggestion(suggestionText, { ...this.state });
}
this.resetTypeahead();
// Remove the current, incomplete text and replace it with the selected suggestion
const backward = suggestion.deleteBackwards || typeaheadPrefix.length;
const text = cleanText ? cleanText(typeaheadText) : typeaheadText;
const suffixLength = text.length - typeaheadPrefix.length;
const offset = typeaheadText.indexOf(typeaheadPrefix);
const midWord = typeaheadPrefix && ((suffixLength > 0 && offset > -1) || suggestionText === typeaheadText);
const forward = midWord && !preserveSuffix ? suffixLength + offset : 0;
// If new-lines, apply suggestion as block
if (suggestionText.match(/\n/)) {
const fragment = makeFragment(suggestionText, syntax);
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertFragment(fragment)
.focus();
}
return change
.deleteBackward(backward)
.deleteForward(forward)
.insertText(suggestionText)
.move(move)
.focus();
}
handleEnterKey = (event: KeyboardEvent, change: Change) => {
event.preventDefault();
if (event.shiftKey) {
// pass through if shift is pressed
return undefined;
} else if (!this.menuEl) {
this.executeOnChangeAndRunQueries();
return true;
} else {
return this.selectSuggestion(change);
}
};
selectSuggestion = (change: Change) => {
const { typeaheadIndex, suggestions } = this.state;
event.preventDefault();
if (!suggestions || suggestions.length === 0) {
return undefined;
}
const suggestion = getSuggestionByIndex(suggestions, typeaheadIndex);
const nextChange = this.applyTypeahead(change, suggestion);
const insertTextOperation = nextChange.operations.find((operation: any) => operation.type === 'insert_text');
return insertTextOperation ? true : undefined;
};
handleTabKey = (change: Change): void => {
const {
startBlock,
endBlock,
selection: { startOffset, startKey, endOffset, endKey },
} = change.value;
if (this.menuEl) {
this.selectSuggestion(change);
return;
}
const first = startBlock.getFirstText();
const startBlockIsSelected =
startOffset === 0 && startKey === first.key && endOffset === first.text.length && endKey === first.key;
if (startBlockIsSelected || !startBlock.equals(endBlock)) {
this.handleIndent(change, 'right');
} else {
change.insertText(SLATE_TAB);
}
};
handleIndent = (change: Change, indentDirection: 'left' | 'right') => {
const curSelection = change.value.selection;
const selectedBlocks = change.value.document.getBlocksAtRange(curSelection);
if (indentDirection === 'left') {
for (const block of selectedBlocks) {
const blockWhitespace = block.text.length - block.text.trimLeft().length;
const rangeProperties = {
anchorKey: block.getFirstText().key,
anchorOffset: blockWhitespace,
focusKey: block.getFirstText().key,
focusOffset: blockWhitespace,
};
// @ts-ignore
const whitespaceToDelete = Range.create(rangeProperties);
change.deleteBackwardAtRange(whitespaceToDelete, Math.min(SLATE_TAB.length, blockWhitespace));
}
} else {
const { startText } = change.value;
const textBeforeCaret = startText.text.slice(0, curSelection.startOffset);
const isWhiteSpace = /^\s*$/.test(textBeforeCaret);
for (const block of selectedBlocks) {
change.insertTextByKey(block.getFirstText().key, 0, SLATE_TAB);
}
if (isWhiteSpace) {
change.moveStart(-SLATE_TAB.length);
}
}
};
handleSelectVertical = (change: Change, direction: 'up' | 'down') => {
const { focusBlock } = change.value;
const adjacentBlock =
direction === 'up'
? change.value.document.getPreviousBlock(focusBlock.key)
: change.value.document.getNextBlock(focusBlock.key);
if (!adjacentBlock) {
return true;
}
const adjacentText = adjacentBlock.getFirstText();
change.moveFocusTo(adjacentText.key, Math.min(change.value.anchorOffset, adjacentText.text.length)).focus();
return true;
};
handleSelectUp = (change: Change) => this.handleSelectVertical(change, 'up');
handleSelectDown = (change: Change) => this.handleSelectVertical(change, 'down');
onKeyDown = (event: KeyboardEvent, change: Change) => {
const { typeaheadIndex } = this.state;
// Shortcuts
if (isIndentLeftHotkey(event)) {
event.preventDefault();
this.handleIndent(change, 'left');
return true;
} else if (isIndentRightHotkey(event)) {
event.preventDefault();
this.handleIndent(change, 'right');
return true;
} else if (isSelectLeftHotkey(event)) {
event.preventDefault();
if (change.value.focusOffset > 0) {
change.moveFocus(-1);
}
return true;
} else if (isSelectRightHotkey(event)) {
event.preventDefault();
if (change.value.focusOffset < change.value.startText.text.length) {
change.moveFocus(1);
}
return true;
} else if (isSelectUpHotkey(event)) {
event.preventDefault();
this.handleSelectUp(change);
return true;
} else if (isSelectDownHotkey(event)) {
event.preventDefault();
this.handleSelectDown(change);
return true;
} else if (isSelectLineHotkey(event)) {
event.preventDefault();
const { focusBlock, document } = change.value;
change.moveAnchorToStartOfBlock(focusBlock.key);
const nextBlock = document.getNextBlock(focusBlock.key);
if (nextBlock) {
change.moveFocusToStartOfNextBlock();
} else {
change.moveFocusToEndOfText();
}
return true;
}
switch (event.key) {
case 'Escape': {
if (this.menuEl) {
event.preventDefault();
event.stopPropagation();
this.resetTypeahead();
return true;
}
break;
}
case ' ': {
if (event.ctrlKey) {
event.preventDefault();
this.handleTypeahead();
return true;
}
break;
}
case 'Enter':
return this.handleEnterKey(event, change);
case 'Tab': {
event.preventDefault();
return this.handleTabKey(change);
}
case 'ArrowDown': {
if (this.menuEl) {
// Select next suggestion
event.preventDefault();
const itemsCount =
this.state.suggestions.length > 0
? this.state.suggestions.reduce((totalCount, current) => totalCount + current.items.length, 0)
: 0;
this.setState({ typeaheadIndex: Math.min(itemsCount - 1, typeaheadIndex + 1) });
}
break;
}
case 'ArrowUp': {
if (this.menuEl) {
// Select previous suggestion
event.preventDefault();
this.setState({ typeaheadIndex: Math.max(0, typeaheadIndex - 1) });
}
break;
}
default: {
// console.log('default key', event.key, event.which, event.charCode, event.locale, data.key);
break;
}
}
return undefined;
};
resetTypeahead = () => {
if (this.mounted) {
this.setState({ suggestions: [], typeaheadIndex: 0, typeaheadPrefix: '', typeaheadContext: null });
this.resetTimer = null;
}
};
handleBlur = (event: FocusEvent, change: Change) => {
handleBlur = (event: Event, editor: CoreEditor, next: Function) => {
const { lastExecutedValue } = this.state;
const previousValue = lastExecutedValue ? Plain.serialize(this.state.lastExecutedValue) : null;
const currentValue = Plain.serialize(change.value);
// If we dont wait here, menu clicks wont work because the menu
// will be gone.
this.resetTimer = setTimeout(this.resetTypeahead, 100);
const currentValue = Plain.serialize(editor.value);
if (previousValue !== currentValue) {
this.executeOnChangeAndRunQueries();
}
};
onClickMenu = (item: CompletionItem) => {
// Manually triggering change
const change = this.applyTypeahead(this.state.value.change(), item);
this.onChange(change, true);
};
editor.blur();
updateMenu = () => {
const { suggestions } = this.state;
const menu = this.menuEl;
// Exit for unit tests
if (!window.getSelection) {
return;
}
const selection = window.getSelection();
const node = selection.anchorNode;
// No menu, nothing to do
if (!menu) {
return;
}
// No suggestions or blur, remove menu
if (!hasSuggestions(suggestions)) {
menu.removeAttribute('style');
return;
}
// Align menu overlay to editor node
if (node) {
// Read from DOM
const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
// Write DOM
requestAnimationFrame(() => {
menu.style.opacity = '1';
menu.style.top = `${rect.top + scrollY + rect.height + 4}px`;
menu.style.left = `${rect.left + scrollX - 2}px`;
});
}
};
menuRef = (el: HTMLElement) => {
this.menuEl = el;
};
renderMenu = () => {
const { portalOrigin } = this.props;
const { suggestions, typeaheadIndex, typeaheadPrefix } = this.state;
if (!hasSuggestions(suggestions)) {
return null;
}
const selectedItem = getSuggestionByIndex(suggestions, typeaheadIndex);
// Create typeahead in DOM root so we can later position it absolutely
return (
<Portal origin={portalOrigin}>
<TypeaheadWithTheme
menuRef={this.menuRef}
selectedItem={selectedItem}
onClickItem={this.onClickMenu}
prefix={typeaheadPrefix}
groupedItems={suggestions}
typeaheadIndex={typeaheadIndex}
/>
</Portal>
);
};
getCopiedText(textBlocks: string[], startOffset: number, endOffset: number) {
if (!textBlocks.length) {
return undefined;
}
const excludingLastLineLength = textBlocks.slice(0, -1).join('').length + textBlocks.length - 1;
return textBlocks.join('\n').slice(startOffset, excludingLastLineLength + endOffset);
}
handleCopy = (event: ClipboardEvent, change: Change) => {
event.preventDefault();
const { document, selection, startOffset, endOffset } = change.value;
const selectedBlocks = document.getBlocksAtRangeAsArray(selection).map((block: Block) => block.text);
const copiedText = this.getCopiedText(selectedBlocks, startOffset, endOffset);
if (copiedText) {
event.clipboardData.setData('Text', copiedText);
}
return true;
};
handlePaste = (event: ClipboardEvent, change: Change) => {
event.preventDefault();
const pastedValue = event.clipboardData.getData('Text');
const lines = pastedValue.split('\n');
if (lines.length) {
change.insertText(lines[0]);
for (const line of lines.slice(1)) {
change.splitBlock().insertText(line);
}
}
return true;
};
handleCut = (event: ClipboardEvent, change: Change) => {
this.handleCopy(event, change);
change.deleteAtRange(change.value.selection);
return true;
return next();
};
render() {
@@ -670,19 +187,20 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
const wrapperClassName = classnames('slate-query-field__wrapper', {
'slate-query-field__wrapper--disabled': disabled,
});
return (
<div className={wrapperClassName}>
<div className="slate-query-field">
{this.renderMenu()}
<Editor
ref={editor => (this.editor = editor)}
schema={SCHEMA}
autoCorrect={false}
readOnly={this.props.disabled}
onBlur={this.handleBlur}
onKeyDown={this.onKeyDown}
onChange={this.onChange}
onCopy={this.handleCopy}
onPaste={this.handlePaste}
onCut={this.handleCut}
// onKeyDown={this.onKeyDown}
onChange={(change: { value: Value }) => {
this.onChange(change.value, false);
}}
placeholder={this.props.placeholder}
plugins={this.plugins}
spellCheck={false}
@@ -694,29 +212,4 @@ export class QueryField extends React.PureComponent<QueryFieldProps, QueryFieldS
}
}
interface PortalProps {
index?: number;
origin: string;
}
class Portal extends React.PureComponent<PortalProps, {}> {
node: HTMLElement;
constructor(props: PortalProps) {
super(props);
const { index = 0, origin = 'query' } = props;
this.node = document.createElement('div');
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
document.body.appendChild(this.node);
}
componentWillUnmount() {
document.body.removeChild(this.node);
}
render() {
return ReactDOM.createPortal(this.props.children, this.node);
}
}
export default QueryField;

View File

@@ -28,9 +28,19 @@ export const ResponsiveButton = (props: Props) => {
onClick={onClick}
disabled={disabled || false}
>
{iconClassName && iconSide === IconSide.left ? <i className={`${iconClassName}`} /> : null}
{iconClassName && iconSide === IconSide.left ? (
<>
<i className={`${iconClassName}`} />
&nbsp;
</>
) : null}
<span className="btn-title">{!splitted ? title : ''}</span>
{iconClassName && iconSide === IconSide.right ? <i className={`${iconClassName}`} /> : null}
{iconClassName && iconSide === IconSide.right ? (
<>
&nbsp;
<i className={`${iconClassName}`} />
</>
) : null}
</button>
);
};

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { RefreshPicker } from '@grafana/ui';
import memoizeOne from 'memoize-one';
import { css } from 'emotion';
import classNames from 'classnames';
import { ResponsiveButton } from './ResponsiveButton';
@@ -33,7 +34,7 @@ export function RunButton(props: Props) {
splitted={splitted}
title="Run Query"
onClick={onRun}
buttonClassName="navbar-button--secondary btn--radius-right-0 "
buttonClassName={classNames('navbar-button--secondary', { 'btn--radius-right-0': showDropdown })}
iconClassName={loading ? 'fa fa-spinner fa-fw fa-spin run-icon' : 'fa fa-refresh fa-fw'}
/>
);

View File

@@ -1,21 +1,24 @@
import React, { createRef } from 'react';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import { FixedSizeList } from 'react-window';
import { Themeable, withTheme } from '@grafana/ui';
import { CompletionItem, CompletionItemGroup } from 'app/types/explore';
import { CompletionItem, CompletionItemKind, CompletionItemGroup } from 'app/types/explore';
import { TypeaheadItem } from './TypeaheadItem';
import { TypeaheadInfo } from './TypeaheadInfo';
import { flattenGroupItems, calculateLongestLabel, calculateListSizes } from './utils/typeahead';
const modulo = (a: number, n: number) => a - n * Math.floor(a / n);
interface Props extends Themeable {
origin: string;
groupedItems: CompletionItemGroup[];
menuRef: any;
selectedItem: CompletionItem | null;
onClickItem: (suggestion: CompletionItem) => void;
prefix?: string;
typeaheadIndex: number;
menuRef?: (el: Typeahead) => void;
onSelectSuggestion?: (suggestion: CompletionItem) => void;
isOpen?: boolean;
}
interface State {
@@ -23,11 +26,12 @@ interface State {
listWidth: number;
listHeight: number;
itemHeight: number;
hoveredItem: number;
typeaheadIndex: number;
}
export class Typeahead extends React.PureComponent<Props, State> {
listRef: any = createRef();
documentationRef: any = createRef();
listRef = createRef<FixedSizeList>();
constructor(props: Props) {
super(props);
@@ -35,97 +39,188 @@ export class Typeahead extends React.PureComponent<Props, State> {
const allItems = flattenGroupItems(props.groupedItems);
const longestLabel = calculateLongestLabel(allItems);
const { listWidth, listHeight, itemHeight } = calculateListSizes(props.theme, allItems, longestLabel);
this.state = { listWidth, listHeight, itemHeight, allItems };
this.state = { listWidth, listHeight, itemHeight, hoveredItem: null, typeaheadIndex: 1, allItems };
}
componentDidUpdate = (prevProps: Readonly<Props>) => {
if (prevProps.typeaheadIndex !== this.props.typeaheadIndex && this.listRef && this.listRef.current) {
if (prevProps.typeaheadIndex === 1 && this.props.typeaheadIndex === 0) {
componentDidMount = () => {
this.props.menuRef(this);
document.addEventListener('selectionchange', this.handleSelectionChange);
};
componentWillUnmount = () => {
document.removeEventListener('selectionchange', this.handleSelectionChange);
};
handleSelectionChange = () => {
this.forceUpdate();
};
componentDidUpdate = (prevProps: Readonly<Props>, prevState: Readonly<State>) => {
if (prevState.typeaheadIndex !== this.state.typeaheadIndex && this.listRef && this.listRef.current) {
if (this.state.typeaheadIndex === 1) {
this.listRef.current.scrollToItem(0); // special case for handling the first group label
this.refreshDocumentation();
return;
}
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem);
this.listRef.current.scrollToItem(index);
this.refreshDocumentation();
this.listRef.current.scrollToItem(this.state.typeaheadIndex);
}
if (_.isEqual(prevProps.groupedItems, this.props.groupedItems) === false) {
const allItems = flattenGroupItems(this.props.groupedItems);
const longestLabel = calculateLongestLabel(allItems);
const { listWidth, listHeight, itemHeight } = calculateListSizes(this.props.theme, allItems, longestLabel);
this.setState({ listWidth, listHeight, itemHeight, allItems }, () => this.refreshDocumentation());
this.setState({ listWidth, listHeight, itemHeight, allItems });
}
};
refreshDocumentation = () => {
if (!this.documentationRef.current) {
return;
}
const index = this.state.allItems.findIndex(item => item === this.props.selectedItem);
const item = this.state.allItems[index];
if (item) {
this.documentationRef.current.refresh(item);
}
};
onMouseEnter = (item: CompletionItem) => {
this.documentationRef.current.refresh(item);
onMouseEnter = (index: number) => {
this.setState({
hoveredItem: index,
});
};
onMouseLeave = () => {
this.documentationRef.current.hide();
this.setState({
hoveredItem: null,
});
};
moveMenuIndex = (moveAmount: number) => {
const itemCount = this.state.allItems.length;
if (itemCount) {
// Select next suggestion
event.preventDefault();
let newTypeaheadIndex = modulo(this.state.typeaheadIndex + moveAmount, itemCount);
if (this.state.allItems[newTypeaheadIndex].kind === CompletionItemKind.GroupTitle) {
newTypeaheadIndex = modulo(newTypeaheadIndex + moveAmount, itemCount);
}
this.setState({
typeaheadIndex: newTypeaheadIndex,
});
return;
}
};
insertSuggestion = () => {
this.props.onSelectSuggestion(this.state.allItems[this.state.typeaheadIndex]);
};
get menuPosition(): string {
// Exit for unit tests
if (!window.getSelection) {
return '';
}
const selection = window.getSelection();
const node = selection.anchorNode;
// Align menu overlay to editor node
if (node) {
// Read from DOM
const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
return `position: absolute; display: flex; top: ${rect.top + scrollY + rect.height + 6}px; left: ${rect.left +
scrollX -
2}px`;
}
return '';
}
render() {
const { menuRef, selectedItem, onClickItem, prefix, theme } = this.props;
const { listWidth, listHeight, itemHeight, allItems } = this.state;
const { prefix, theme, isOpen, origin } = this.props;
const { allItems, listWidth, listHeight, itemHeight, hoveredItem, typeaheadIndex } = this.state;
const showDocumentation = hoveredItem || typeaheadIndex;
return (
<ul className="typeahead" ref={menuRef}>
<TypeaheadInfo
ref={this.documentationRef}
width={listWidth}
height={listHeight}
theme={theme}
initialItem={selectedItem}
/>
<FixedSizeList
ref={this.listRef}
itemCount={allItems.length}
itemSize={itemHeight}
itemKey={index => {
const item = allItems && allItems[index];
const key = item ? `${index}-${item.label}` : `${index}`;
return key;
}}
width={listWidth}
height={listHeight}
>
{({ index, style }) => {
const item = allItems && allItems[index];
if (!item) {
return null;
}
<Portal origin={origin} isOpen={isOpen} style={this.menuPosition}>
<ul className="typeahead">
<FixedSizeList
ref={this.listRef}
itemCount={allItems.length}
itemSize={itemHeight}
itemKey={index => {
const item = allItems && allItems[index];
const key = item ? `${index}-${item.label}` : `${index}`;
return key;
}}
width={listWidth}
height={listHeight}
>
{({ index, style }) => {
const item = allItems && allItems[index];
if (!item) {
return null;
}
return (
<TypeaheadItem
onClickItem={onClickItem}
isSelected={selectedItem === item}
item={item}
prefix={prefix}
style={style}
onMouseEnter={this.onMouseEnter}
onMouseLeave={this.onMouseLeave}
/>
);
}}
</FixedSizeList>
</ul>
return (
<TypeaheadItem
onClickItem={() => this.props.onSelectSuggestion(item)}
isSelected={allItems[typeaheadIndex] === item}
item={item}
prefix={prefix}
style={style}
onMouseEnter={() => this.onMouseEnter(index)}
onMouseLeave={this.onMouseLeave}
/>
);
}}
</FixedSizeList>
</ul>
{showDocumentation && (
<TypeaheadInfo
width={listWidth}
height={listHeight}
theme={theme}
item={allItems[hoveredItem ? hoveredItem : typeaheadIndex]}
/>
)}
</Portal>
);
}
}
export const TypeaheadWithTheme = withTheme(Typeahead);
interface PortalProps {
index?: number;
isOpen: boolean;
origin: string;
style: string;
}
class Portal extends React.PureComponent<PortalProps, {}> {
node: HTMLElement;
constructor(props: PortalProps) {
super(props);
const { index = 0, origin = 'query', style } = props;
this.node = document.createElement('div');
this.node.setAttribute('style', style);
this.node.classList.add(`slate-typeahead`, `slate-typeahead-${origin}-${index}`);
document.body.appendChild(this.node);
}
componentWillUnmount() {
document.body.removeChild(this.node);
}
render() {
if (this.props.isOpen) {
this.node.setAttribute('style', this.props.style);
this.node.classList.add(`slate-typeahead--open`);
return ReactDOM.createPortal(this.props.children, this.node);
} else {
this.node.classList.remove(`slate-typeahead--open`);
}
return null;
}
}

View File

@@ -1,44 +1,27 @@
import React, { PureComponent } from 'react';
import { Themeable, selectThemeVariant } from '@grafana/ui';
import { css, cx } from 'emotion';
import { Themeable, selectThemeVariant } from '@grafana/ui';
import { CompletionItem } from 'app/types/explore';
interface Props extends Themeable {
initialItem: CompletionItem;
item: CompletionItem;
width: number;
height: number;
}
interface State {
item: CompletionItem;
}
export class TypeaheadInfo extends PureComponent<Props, State> {
export class TypeaheadInfo extends PureComponent<Props> {
constructor(props: Props) {
super(props);
this.state = { item: props.initialItem };
}
getStyles = (visible: boolean) => {
const { width, height, theme } = this.props;
const selection = window.getSelection();
const node = selection.anchorNode;
if (!node) {
return {};
}
// Read from DOM
const rect = node.parentElement.getBoundingClientRect();
const scrollX = window.scrollX;
const scrollY = window.scrollY;
const left = `${rect.left + scrollX + width + parseInt(theme.spacing.xs, 10)}px`;
const top = `${rect.top + scrollY + rect.height + 6}px`;
const { height, theme } = this.props;
return {
typeaheadItem: css`
label: type-ahead-item;
z-index: auto;
padding: ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.sm} ${theme.spacing.md};
border-radius: ${theme.border.radius.md};
border: ${selectThemeVariant(
@@ -55,25 +38,15 @@ export class TypeaheadInfo extends PureComponent<Props, State> {
theme.type
)};
visibility: ${visible === true ? 'visible' : 'hidden'};
left: ${left};
top: ${top};
width: 250px;
height: ${height + parseInt(theme.spacing.xxs, 10)}px;
position: fixed;
position: relative;
`,
};
};
refresh = (item: CompletionItem) => {
this.setState({ item });
};
hide = () => {
this.setState({ item: null });
};
render() {
const { item } = this.state;
const { item } = this.props;
const visible = item && !!item.documentation;
const label = item ? item.label : '';
const documentation = item && item.documentation ? item.documentation : '';

View File

@@ -1,25 +1,21 @@
import React, { FunctionComponent, useContext } from 'react';
// @ts-ignore
import Highlighter from 'react-highlight-words';
import { css, cx } from 'emotion';
import { GrafanaTheme, ThemeContext, selectThemeVariant } from '@grafana/ui';
import { CompletionItem } from 'app/types/explore';
export const GROUP_TITLE_KIND = 'GroupTitle';
export const isGroupTitle = (item: CompletionItem) => {
return item.kind && item.kind === GROUP_TITLE_KIND ? true : false;
};
import { CompletionItem, CompletionItemKind } from 'app/types/explore';
interface Props {
isSelected: boolean;
item: CompletionItem;
onClickItem: (suggestion: CompletionItem) => void;
prefix?: string;
style: any;
onMouseEnter: (item: CompletionItem) => void;
onMouseLeave: (item: CompletionItem) => void;
prefix?: string;
onClickItem?: (event: React.MouseEvent) => void;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
}
const getStyles = (theme: GrafanaTheme) => ({
@@ -38,10 +34,12 @@ const getStyles = (theme: GrafanaTheme) => ({
transition: color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), border-color 0.3s cubic-bezier(0.645, 0.045, 0.355, 1),
background 0.3s cubic-bezier(0.645, 0.045, 0.355, 1), padding 0.15s cubic-bezier(0.645, 0.045, 0.355, 1);
`,
typeaheadItemSelected: css`
label: type-ahead-item-selected;
background-color: ${selectThemeVariant({ light: theme.colors.gray6, dark: theme.colors.dark9 }, theme.type)};
`,
typeaheadItemMatch: css`
label: type-ahead-item-match;
color: ${theme.colors.yellow};
@@ -49,6 +47,7 @@ const getStyles = (theme: GrafanaTheme) => ({
padding: inherit;
background: inherit;
`,
typeaheadItemGroupTitle: css`
label: type-ahead-item-group-title;
color: ${theme.colors.textWeak};
@@ -62,16 +61,13 @@ export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
const theme = useContext(ThemeContext);
const styles = getStyles(theme);
const { isSelected, item, prefix, style, onClickItem } = props;
const onClick = () => onClickItem(item);
const onMouseEnter = () => props.onMouseEnter(item);
const onMouseLeave = () => props.onMouseLeave(item);
const { isSelected, item, prefix, style, onMouseEnter, onMouseLeave, onClickItem } = props;
const className = isSelected ? cx([styles.typeaheadItem, styles.typeaheadItemSelected]) : cx([styles.typeaheadItem]);
const highlightClassName = cx([styles.typeaheadItemMatch]);
const itemGroupTitleClassName = cx([styles.typeaheadItemGroupTitle]);
const label = item.label || '';
if (isGroupTitle(item)) {
if (item.kind === CompletionItemKind.GroupTitle) {
return (
<li className={itemGroupTitleClassName} style={style}>
<span>{label}</span>
@@ -80,7 +76,13 @@ export const TypeaheadItem: FunctionComponent<Props> = (props: Props) => {
}
return (
<li className={className} onClick={onClick} style={style} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
<li
className={className}
style={style}
onMouseDown={onClickItem}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
>
<Highlighter textToHighlight={label} searchWords={[prefix]} highlightClassName={highlightClassName} />
</li>
);

View File

@@ -1,39 +0,0 @@
// @ts-ignore
import Plain from 'slate-plain-serializer';
import BracesPlugin from './braces';
declare global {
interface Window {
KeyboardEvent: any;
}
}
describe('braces', () => {
const handler = BracesPlugin().onKeyDown;
it('adds closing braces around empty value', () => {
const change = Plain.deserialize('').change();
const event = new window.KeyboardEvent('keydown', { key: '(' });
handler(event, change);
expect(Plain.serialize(change.value)).toEqual('()');
});
it('removes closing brace when opening brace is removed', () => {
const change = Plain.deserialize('time()').change();
let event;
change.move(5);
event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
handler(event, change);
expect(Plain.serialize(change.value)).toEqual('time');
});
it('keeps closing brace when opening brace is removed and inner values exist', () => {
const change = Plain.deserialize('time(value)').change();
let event;
change.move(5);
event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
const handled = handler(event, change);
expect(handled).toBeFalsy();
});
});

View File

@@ -0,0 +1,40 @@
import React from 'react';
import Plain from 'slate-plain-serializer';
import { Editor } from '@grafana/slate-react';
import { shallow } from 'enzyme';
import BracesPlugin from './braces';
declare global {
interface Window {
KeyboardEvent: any;
}
}
describe('braces', () => {
const handler = BracesPlugin().onKeyDown;
const nextMock = () => {};
it('adds closing braces around empty value', () => {
const value = Plain.deserialize('');
const editor = shallow<Editor>(<Editor value={value} />);
const event = new window.KeyboardEvent('keydown', { key: '(' });
handler(event as Event, editor.instance() as any, nextMock);
expect(Plain.serialize(editor.instance().value)).toEqual('()');
});
it('removes closing brace when opening brace is removed', () => {
const value = Plain.deserialize('time()');
const editor = shallow<Editor>(<Editor value={value} />);
const event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
handler(event as Event, editor.instance().moveForward(5) as any, nextMock);
expect(Plain.serialize(editor.instance().value)).toEqual('time');
});
it('keeps closing brace when opening brace is removed and inner values exist', () => {
const value = Plain.deserialize('time(value)');
const editor = shallow<Editor>(<Editor value={value} />);
const event = new window.KeyboardEvent('keydown', { key: 'Backspace' });
const handled = handler(event as Event, editor.instance().moveForward(5) as any, nextMock);
expect(handled).toBeFalsy();
});
});

View File

@@ -1,5 +1,5 @@
// @ts-ignore
import { Change } from 'slate';
import { Plugin } from '@grafana/slate-react';
import { Editor as CoreEditor } from 'slate';
const BRACES: any = {
'[': ']',
@@ -7,34 +7,37 @@ const BRACES: any = {
'(': ')',
};
export default function BracesPlugin() {
export default function BracesPlugin(): Plugin {
return {
onKeyDown(event: KeyboardEvent, change: Change) {
const { value } = change;
onKeyDown(event: KeyboardEvent, editor: CoreEditor, next: Function) {
const { value } = editor;
switch (event.key) {
case '(':
case '{':
case '[': {
event.preventDefault();
const { startOffset, startKey, endOffset, endKey, focusOffset } = value.selection;
const text: string = value.focusText.text;
const {
start: { offset: startOffset, key: startKey },
end: { offset: endOffset, key: endKey },
focus: { offset: focusOffset },
} = value.selection;
const text = value.focusText.text;
// If text is selected, wrap selected text in parens
if (value.isExpanded) {
change
if (value.selection.isExpanded) {
editor
.insertTextByKey(startKey, startOffset, event.key)
.insertTextByKey(endKey, endOffset + 1, BRACES[event.key])
.moveEnd(-1);
.moveEndBackward(1);
} else if (
focusOffset === text.length ||
text[focusOffset] === ' ' ||
Object.values(BRACES).includes(text[focusOffset])
) {
change.insertText(`${event.key}${BRACES[event.key]}`).move(-1);
editor.insertText(`${event.key}${BRACES[event.key]}`).moveBackward(1);
} else {
change.insertText(event.key);
editor.insertText(event.key);
}
return true;
@@ -42,15 +45,15 @@ export default function BracesPlugin() {
case 'Backspace': {
const text = value.anchorText.text;
const offset = value.anchorOffset;
const offset = value.selection.anchor.offset;
const previousChar = text[offset - 1];
const nextChar = text[offset];
if (BRACES[previousChar] && BRACES[previousChar] === nextChar) {
event.preventDefault();
// Remove closing brace if directly following
change
.deleteBackward()
.deleteForward()
editor
.deleteBackward(1)
.deleteForward(1)
.focus();
return true;
}
@@ -60,7 +63,8 @@ export default function BracesPlugin() {
break;
}
}
return undefined;
return next();
},
};
}

View File

@@ -1,39 +0,0 @@
// @ts-ignore
import Plain from 'slate-plain-serializer';
import ClearPlugin from './clear';
describe('clear', () => {
const handler = ClearPlugin().onKeyDown;
it('does not change the empty value', () => {
const change = Plain.deserialize('').change();
const event = new window.KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
});
handler(event, change);
expect(Plain.serialize(change.value)).toEqual('');
});
it('clears to the end of the line', () => {
const change = Plain.deserialize('foo').change();
const event = new window.KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
});
handler(event, change);
expect(Plain.serialize(change.value)).toEqual('');
});
it('clears from the middle to the end of the line', () => {
const change = Plain.deserialize('foo bar').change();
change.move(4);
const event = new window.KeyboardEvent('keydown', {
key: 'k',
ctrlKey: true,
});
handler(event, change);
expect(Plain.serialize(change.value)).toEqual('foo ');
});
});

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