Sfoglia il codice sorgente

Merge branch 'master' into tooling/storybook-poc

Dominik Prokop 7 anni fa
parent
commit
ad9c96abb5
100 ha cambiato i file con 2046 aggiunte e 1048 eliminazioni
  1. 49 29
      .circleci/config.yml
  2. 1 0
      CHANGELOG.md
  3. 3 1
      README.md
  4. 2 0
      build.go
  5. 1 0
      devenv/docker/blocks/influxdb/influxdb.conf
  6. 1 1
      docs/sources/reference/dashboard.md
  7. 4 2
      docs/sources/reference/templating.md
  8. 2 3
      package.json
  9. 11 1
      packages/grafana-ui/package.json
  10. 1 1
      packages/grafana-ui/src/components/ColorPicker/ColorPalette.test.tsx
  11. 3 3
      packages/grafana-ui/src/components/ColorPicker/ColorPalette.tsx
  12. 4 9
      packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx
  13. 12 21
      packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx
  14. 2 1
      packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx
  15. 6 10
      packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx
  16. 4 4
      packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx
  17. 0 0
      packages/grafana-ui/src/components/ColorPicker/__snapshots__/ColorPalette.test.tsx.snap
  18. 0 0
      packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx
  19. 1 1
      packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx
  20. 40 0
      packages/grafana-ui/src/components/CustomScrollbar/_CustomScrollbar.scss
  21. 0 0
      packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap
  22. 0 0
      packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx
  23. 0 0
      packages/grafana-ui/src/components/Graph/Graph.tsx
  24. 11 0
      packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx
  25. 15 0
      packages/grafana-ui/src/components/PanelOptionsGrid/PanelOptionsGrid.tsx
  26. 10 0
      packages/grafana-ui/src/components/PanelOptionsGrid/_PanelOptionsGrid.scss
  27. 4 4
      packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx
  28. 27 0
      packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss
  29. 2 2
      packages/grafana-ui/src/components/Portal/Portal.tsx
  30. 4 1
      packages/grafana-ui/src/components/Select/IndicatorsContainer.tsx
  31. 4 0
      packages/grafana-ui/src/components/Select/NoOptionsMessage.tsx
  32. 14 9
      packages/grafana-ui/src/components/Select/Select.tsx
  33. 23 12
      packages/grafana-ui/src/components/Select/SelectOption.test.tsx
  34. 6 3
      packages/grafana-ui/src/components/Select/SelectOption.tsx
  35. 2 2
      packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx
  36. 0 0
      packages/grafana-ui/src/components/Select/_Select.scss
  37. 7 2
      packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap
  38. 27 0
      packages/grafana-ui/src/components/Select/resetSelectStyles.ts
  39. 7 14
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx
  40. 44 49
      packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx
  41. 0 0
      packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss
  42. 12 15
      packages/grafana-ui/src/components/Tooltip/Popper.tsx
  43. 99 0
      packages/grafana-ui/src/components/Tooltip/PopperController.tsx
  44. 5 3
      packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx
  45. 32 0
      packages/grafana-ui/src/components/Tooltip/Tooltip.tsx
  46. 14 4
      packages/grafana-ui/src/components/Tooltip/_Tooltip.scss
  47. 12 0
      packages/grafana-ui/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
  48. 6 0
      packages/grafana-ui/src/components/index.scss
  49. 19 0
      packages/grafana-ui/src/components/index.ts
  50. 0 1
      packages/grafana-ui/src/forms/index.ts
  51. 0 2
      packages/grafana-ui/src/index.ts
  52. 16 0
      packages/grafana-ui/src/types/gauge.ts
  53. 1 0
      packages/grafana-ui/src/types/index.ts
  54. 32 0
      packages/grafana-ui/src/types/panel.ts
  55. 93 0
      packages/grafana-ui/src/utils/colors.ts
  56. 2 0
      packages/grafana-ui/src/utils/index.ts
  57. 40 0
      packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.test.ts
  58. 42 0
      packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.ts
  59. 322 0
      packages/grafana-ui/src/utils/valueFormats/categories.ts
  60. 231 0
      packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts
  61. 312 0
      packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts
  62. 7 0
      packages/grafana-ui/src/utils/valueFormats/symbolFormatters.test.ts
  63. 30 0
      packages/grafana-ui/src/utils/valueFormats/symbolFormatters.ts
  64. 166 0
      packages/grafana-ui/src/utils/valueFormats/valueFormats.ts
  65. 0 1
      packages/grafana-ui/src/visualizations/index.ts
  66. 4 2
      packaging/docker/Dockerfile
  67. 0 1
      packaging/docker/build-deploy.sh
  68. 32 8
      packaging/docker/build.sh
  69. 29 7
      packaging/docker/push_to_docker_hub.sh
  70. 4 0
      pkg/api/alerting.go
  71. 6 0
      pkg/api/alerting_test.go
  72. 1 1
      pkg/models/dashboards.go
  73. 1 1
      pkg/services/alerting/extractor.go
  74. 18 3
      pkg/services/alerting/rule.go
  75. 60 20
      pkg/services/alerting/rule_test.go
  76. 4 0
      pkg/services/notifications/webhook.go
  77. 10 0
      public/app/core/angular_wrappers.ts
  78. 5 3
      public/app/core/components/Label/Label.tsx
  79. 1 1
      public/app/core/components/PermissionList/AddPermission.tsx
  80. 1 1
      public/app/core/components/PermissionList/DisabledPermissionListItem.tsx
  81. 1 1
      public/app/core/components/PermissionList/PermissionListItem.tsx
  82. 0 78
      public/app/core/components/ScrollBar/ScrollBar.tsx
  83. 1 1
      public/app/core/components/Select/DataSourcePicker.tsx
  84. 0 25
      public/app/core/components/Select/ResetStyles.tsx
  85. 1 1
      public/app/core/components/Select/TeamPicker.tsx
  86. 3 3
      public/app/core/components/Select/UnitPicker.tsx
  87. 1 1
      public/app/core/components/Select/UserPicker.tsx
  88. 1 1
      public/app/core/components/SharedPreferences/SharedPreferences.tsx
  89. 2 4
      public/app/core/components/TagFilter/TagFilter.tsx
  90. 1 1
      public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx
  91. 0 16
      public/app/core/components/Tooltip/Popover.test.tsx
  92. 0 19
      public/app/core/components/Tooltip/Popover.tsx
  93. 0 17
      public/app/core/components/Tooltip/Tooltip.tsx
  94. 0 16
      public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap
  95. 0 19
      public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
  96. 0 89
      public/app/core/components/Tooltip/withPopper.tsx
  97. 1 3
      public/app/core/core.ts
  98. 3 1
      public/app/core/logs_model.ts
  99. 8 0
      public/app/core/specs/factors.test.ts
  100. 0 493
      public/app/core/specs/kbn.test.ts

+ 49 - 29
.circleci/config.yml

@@ -127,7 +127,7 @@ jobs:
 
   build-all:
     docker:
-     - image: grafana/build-container:1.2.1
+     - image: grafana/build-container:1.2.2
     working_directory: /go/src/github.com/grafana/grafana
     steps:
       - checkout
@@ -200,51 +200,51 @@ jobs:
             - dist/grafana*
 
   grafana-docker-master:
-    docker:
-      - image: docker:stable-git
+    machine:
+      image: circleci/classic:201808-01
     steps:
       - checkout
       - attach_workspace:
           at: .
-      - setup_remote_docker
       - run: docker info
-      - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
+      - run: docker run --privileged linuxkit/binfmt:v0.6
+      - run: cp dist/grafana-latest.linux-*.tar.gz packaging/docker
       - run: cd packaging/docker && ./build-deploy.sh "master-${CIRCLE_SHA1}"
-      - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
+      - run: rm packaging/docker/grafana-latest.linux-*.tar.gz
       - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
       - run: cd packaging/docker && ./build-enterprise.sh "master"
 
 
   grafana-docker-pr:
-    docker:
-      - image: docker:stable-git
+    machine:
+      image: circleci/classic:201808-01
     steps:
       - checkout
       - attach_workspace:
           at: .
-      - setup_remote_docker
       - run: docker info
-      - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
+      - run: docker run --privileged linuxkit/binfmt:v0.6
+      - run: cp dist/grafana-latest.linux-*.tar.gz packaging/docker
       - run: cd packaging/docker && ./build.sh "${CIRCLE_SHA1}"
 
   grafana-docker-release:
-      docker:
-        - image: docker:stable-git
-      steps:
-        - checkout
-        - attach_workspace:
-            at: .
-        - setup_remote_docker
-        - run: docker info
-        - run: cp dist/grafana-latest.linux-x64.tar.gz packaging/docker
-        - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}"
-        - run: rm packaging/docker/grafana-latest.linux-x64.tar.gz
-        - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
-        - run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}"
+    machine:
+      image: circleci/classic:201808-01
+    steps:
+      - checkout
+      - attach_workspace:
+          at: .
+      - run: docker info
+      - run: docker run --privileged linuxkit/binfmt:v0.6
+      - run: cp dist/grafana-latest.linux-*.tar.gz packaging/docker
+      - run: cd packaging/docker && ./build-deploy.sh "${CIRCLE_TAG}"
+      - run: rm packaging/docker/grafana-latest.linux-*.tar.gz
+      - run: cp enterprise-dist/grafana-enterprise-*.linux-amd64.tar.gz packaging/docker/grafana-latest.linux-x64.tar.gz
+      - run: cd packaging/docker && ./build-enterprise.sh "${CIRCLE_TAG}"
 
   build-enterprise:
     docker:
-     - image: grafana/build-container:1.2.1
+     - image: grafana/build-container:1.2.2
     working_directory: /go/src/github.com/grafana/grafana
     steps:
       - checkout
@@ -276,7 +276,7 @@ jobs:
 
   build-all-enterprise:
     docker:
-    - image: grafana/build-container:1.2.1
+    - image: grafana/build-container:1.2.2
     working_directory: /go/src/github.com/grafana/grafana
     steps:
     - checkout
@@ -323,7 +323,7 @@ jobs:
 
   deploy-enterprise-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.0.0
+      - image: grafana/grafana-ci-deploy:1.1.0
     steps:
       - attach_workspace:
           at: .
@@ -346,7 +346,7 @@ jobs:
 
   deploy-enterprise-release:
     docker:
-    - image: grafana/grafana-ci-deploy:1.0.0
+    - image: grafana/grafana-ci-deploy:1.1.0
     steps:
       - attach_workspace:
          at: .
@@ -365,10 +365,20 @@ jobs:
       - run:
           name: Deploy to Grafana.com
           command: './scripts/build/publish.sh --enterprise'
+      - run:
+          name: Load GPG private key
+          comand: './scripts/build/load-signing-key.sh'
+      - run:
+          name: Update Debian repository
+          command: './scripts/build/update_repo/update-deb.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+      - run:
+          name: Update RPM repository
+          command: './scripts/build/update_repo/update-rpm.sh "enterprise" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+
 
   deploy-master:
     docker:
-      - image: grafana/grafana-ci-deploy:1.0.0
+      - image: grafana/grafana-ci-deploy:1.1.0
     steps:
       - attach_workspace:
           at: .
@@ -398,8 +408,9 @@ jobs:
 
   deploy-release:
     docker:
-      - image: grafana/grafana-ci-deploy:1.0.0
+      - image: grafana/grafana-ci-deploy:1.1.0
     steps:
+      - checkout
       - attach_workspace:
           at: .
       - run:
@@ -417,6 +428,15 @@ jobs:
       - run:
           name: Deploy to Grafana.com
           command: './scripts/build/publish.sh'
+      - run:
+          name: Load GPG private key
+          comand: './scripts/build/load-signing-key.sh'
+      - run:
+          name: Update Debian repository
+          command: './scripts/build/update_repo/update-deb.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
+      - run:
+          name: Update RPM repository
+          command: './scripts/build/update_repo/update-rpm.sh "oss" "$GPG_KEY_PASSWORD" "$CIRCLE_TAG"'
 
 workflows:
   version: 2

+ 1 - 0
CHANGELOG.md

@@ -18,6 +18,7 @@
 * **OAuth**: Support OAuth providers that are not RFC6749 compliant [#14562](https://github.com/grafana/grafana/issues/14562), thx [@tdabasinskas](https://github.com/tdabasinskas)
 * **Units**: Add blood glucose level units mg/dL and mmol/L [#14519](https://github.com/grafana/grafana/issues/14519), thx [@kjedamzik](https://github.com/kjedamzik)
 * **Stackdriver**: Aggregating series returns more than one series [#14581](https://github.com/grafana/grafana/issues/14581) and [#13914](https://github.com/grafana/grafana/issues/13914), thx [@kinok](https://github.com/kinok)
+* **Docker**: Build and publish docker images for armv7 and arm64 [#14617](https://github.com/grafana/grafana/pull/14617), thx [@johanneswuerbach](https://github.com/johanneswuerbach)
 
 ### Bug fixes
 * **Search**: Fix for issue with scrolling the "tags filter" dropdown, fixes [#14486](https://github.com/grafana/grafana/issues/14486)

+ 3 - 1
README.md

@@ -131,7 +131,9 @@ GRAFANA_TEST_DB=postgres go test ./pkg/...
 
 If you have any idea for an improvement or found a bug, do not hesitate to open an issue.
 And if you have time clone this repo and submit a pull request and help me make Grafana
-the kickass metrics & devops dashboard we all dream about!
+the kickass metrics & devops dashboard we all dream about! 
+
+Read the [contributing](https://github.com/grafana/grafana/blob/master/CONTRIBUTING.md) guide then check the [`beginner friendly`](https://github.com/grafana/grafana/issues?q=is%3Aopen+is%3Aissue+label%3A%22beginner+friendly%22) label to find issues that are easy and that we would like help with.
 
 ## Plugin development
 

+ 2 - 0
build.go

@@ -164,6 +164,8 @@ func makeLatestDistCopies() {
 		"_amd64.deb":          "dist/grafana_latest_amd64.deb",
 		".x86_64.rpm":         "dist/grafana-latest-1.x86_64.rpm",
 		".linux-amd64.tar.gz": "dist/grafana-latest.linux-x64.tar.gz",
+		".linux-armv7.tar.gz": "dist/grafana-latest.linux-armv7.tar.gz",
+		".linux-arm64.tar.gz": "dist/grafana-latest.linux-arm64.tar.gz",
 	}
 
 	for _, file := range files {

+ 1 - 0
devenv/docker/blocks/influxdb/influxdb.conf

@@ -69,6 +69,7 @@ reporting-disabled = false
 
   unix-socket-enabled = false # enable http service over unix domain socket
   # bind-socket = "/var/run/influxdb.sock"
+  flux-enabled = true
 
 [subscriber]
   enabled = true

+ 1 - 1
docs/sources/reference/dashboard.md

@@ -51,7 +51,7 @@ When a user creates a new dashboard, a new dashboard JSON object is initialized
     "list": []
   },
   "refresh": "5s",
-  "schemaVersion": 16,
+  "schemaVersion": 17,
   "version": 0,
   "links": []
 }

+ 4 - 2
docs/sources/reference/templating.md

@@ -292,9 +292,11 @@ The `direction` controls how the panels will be arranged.
 
 By choosing `horizontal` the panels will be arranged side-by-side. Grafana will automatically adjust the width
 of each repeated panel so that the whole row is filled. Currently, you cannot mix other panels on a row with a repeated
-panel. Each panel will never be smaller that the provided `Min width` if you have many selected values.
+panel.
 
-By choosing `vertical` the panels will be arranged from top to bottom in a column. The `Min width` doesn't have any effect in this case. The width of the repeated panels will be the same as of the first panel (the original template) being repeated.
+Set `Max per row` to tell grafana how many panels per row you want at most. It defaults to *4* if you don't set anything.
+
+By choosing `vertical` the panels will be arranged from top to bottom in a column. The width of the repeated panels will be the same as of the first panel (the original template) being repeated.
 
 Only make changes to the first panel (the original template). To have the changes take effect on all panels you need to trigger a dynamic dashboard re-build.
 You can do this by either changing the variable value (that is the basis for the repeat) or reload the dashboard.

+ 2 - 3
package.json

@@ -24,7 +24,6 @@
     "@types/jquery": "^1.10.35",
     "@types/node": "^8.0.31",
     "@types/react": "^16.7.6",
-    "@types/react-custom-scrollbars": "^4.0.5",
     "@types/react-dom": "^16.0.9",
     "@types/react-select": "^2.0.4",
     "angular-mocks": "1.6.6",
@@ -65,6 +64,7 @@
     "html-webpack-plugin": "^3.2.0",
     "husky": "^0.14.3",
     "jest": "^23.6.0",
+    "jest-date-mock": "^1.0.6",
     "lint-staged": "^6.0.0",
     "load-grunt-tasks": "3.5.2",
     "mini-css-extract-plugin": "^0.4.0",
@@ -72,8 +72,8 @@
     "ng-annotate-loader": "^0.6.1",
     "ng-annotate-webpack-plugin": "^0.3.0",
     "ngtemplate-loader": "^2.0.1",
-    "npm": "^5.4.2",
     "node-sass": "^4.11.0",
+    "npm": "^5.4.2",
     "optimize-css-assets-webpack-plugin": "^4.0.2",
     "phantomjs-prebuilt": "^2.1.15",
     "postcss-browser-reporter": "^0.5.0",
@@ -167,7 +167,6 @@
     "prop-types": "^15.6.2",
     "rc-cascader": "^0.14.0",
     "react": "^16.6.3",
-    "react-custom-scrollbars": "^4.2.1",
     "react-dom": "^16.6.3",
     "react-grid-layout": "0.16.6",
     "react-highlight-words": "0.11.0",

+ 11 - 1
packages/grafana-ui/package.json

@@ -17,11 +17,15 @@
     "lodash": "^4.17.10",
     "moment": "^2.22.2",
     "react": "^16.6.3",
+    "react-custom-scrollbars": "^4.2.1",
     "react-dom": "^16.6.3",
     "react-highlight-words": "0.11.0",
     "react-popper": "^1.3.0",
     "react-transition-group": "^2.2.1",
-    "react-virtualized": "^9.21.0"
+    "react-virtualized": "^9.21.0",
+    "tether": "^1.4.0",
+    "tether-drop": "https://github.com/torkelo/drop/tarball/master",
+    "tinycolor2": "^1.4.1"
   },
   "devDependencies": {
     "@storybook/addon-info": "^4.1.4",
@@ -33,8 +37,14 @@
     "@types/node": "^10.12.18",
     "@types/react": "^16.7.6",
     "@types/storybook__react": "^4.0.0",
+    "@types/react-custom-scrollbars": "^4.0.5",
+    "@types/react-test-renderer": "^16.0.3",
+    "@types/react-transition-group": "^2.0.15",
+    "@types/tether-drop": "^1.4.8",
+    "@types/tinycolor2": "^1.4.1",
     "awesome-typescript-loader": "^5.2.1",
     "react-docgen-typescript-webpack-plugin": "^1.1.0",
+    "react-test-renderer": "^16.7.0",
     "typescript": "^3.2.2"
   },
   "resolutions": {

+ 1 - 1
public/app/core/specs/ColorPalette.test.tsx → packages/grafana-ui/src/components/ColorPicker/ColorPalette.test.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import renderer from 'react-test-renderer';
-import { ColorPalette } from '../components/colorpicker/ColorPalette';
+import { ColorPalette } from './ColorPalette';
 
 describe('CollorPalette', () => {
   it('renders correctly', () => {

+ 3 - 3
public/app/core/components/colorpicker/ColorPalette.tsx → packages/grafana-ui/src/components/ColorPicker/ColorPalette.tsx

@@ -1,5 +1,5 @@
 import React from 'react';
-import { sortedColors } from 'app/core/utils/colors';
+import { sortedColors } from '../../utils';
 
 export interface Props {
   color: string;
@@ -9,13 +9,13 @@ export interface Props {
 export class ColorPalette extends React.Component<Props, any> {
   paletteColors: string[];
 
-  constructor(props) {
+  constructor(props: Props) {
     super(props);
     this.paletteColors = sortedColors;
     this.onColorSelect = this.onColorSelect.bind(this);
   }
 
-  onColorSelect(color) {
+  onColorSelect(color: string) {
     return () => {
       this.props.onColorSelect(color);
     };

+ 4 - 9
public/app/core/components/colorpicker/ColorPicker.tsx → packages/grafana-ui/src/components/ColorPicker/ColorPicker.tsx

@@ -2,7 +2,6 @@ import React from 'react';
 import ReactDOM from 'react-dom';
 import Drop from 'tether-drop';
 import { ColorPickerPopover } from './ColorPickerPopover';
-import { react2AngularDirective } from 'app/core/utils/react2angular';
 
 export interface Props {
   color: string;
@@ -10,7 +9,7 @@ export interface Props {
 }
 
 export class ColorPicker extends React.Component<Props, any> {
-  pickerElem: HTMLElement;
+  pickerElem: HTMLElement | null;
   colorPickerDrop: any;
 
   openColorPicker = () => {
@@ -20,7 +19,7 @@ export class ColorPicker extends React.Component<Props, any> {
     ReactDOM.render(dropContent, dropContentElem);
 
     const drop = new Drop({
-      target: this.pickerElem,
+      target: this.pickerElem as Element,
       content: dropContentElem,
       position: 'top center',
       classes: 'drop-popover',
@@ -28,6 +27,7 @@ export class ColorPicker extends React.Component<Props, any> {
       hoverCloseDelay: 200,
       tetherOptions: {
         constraints: [{ to: 'scrollParent', attachment: 'none both' }],
+        attachment: 'bottom center',
       },
     });
 
@@ -45,7 +45,7 @@ export class ColorPicker extends React.Component<Props, any> {
     }, 100);
   };
 
-  onColorSelect = color => {
+  onColorSelect = (color: string) => {
     this.props.onChange(color);
   };
 
@@ -59,8 +59,3 @@ export class ColorPicker extends React.Component<Props, any> {
     );
   }
 }
-
-react2AngularDirective('colorPicker', ColorPicker, [
-  'color',
-  ['onChange', { watchDepth: 'reference', wrapApply: true }],
-]);

+ 12 - 21
public/app/core/components/colorpicker/ColorPickerPopover.tsx → packages/grafana-ui/src/components/ColorPicker/ColorPickerPopover.tsx

@@ -14,7 +14,7 @@ export interface Props {
 export class ColorPickerPopover extends React.Component<Props, any> {
   pickerNavElem: any;
 
-  constructor(props) {
+  constructor(props: Props) {
     super(props);
     this.state = {
       tab: 'palette',
@@ -23,60 +23,51 @@ export class ColorPickerPopover extends React.Component<Props, any> {
     };
   }
 
-  setPickerNavElem(elem) {
+  setPickerNavElem(elem: any) {
     this.pickerNavElem = $(elem);
   }
 
-  setColor(color) {
+  setColor(color: string) {
     const newColor = tinycolor(color);
     if (newColor.isValid()) {
-      this.setState({
-        color: newColor.toString(),
-        colorString: newColor.toString(),
-      });
+      this.setState({ color: newColor.toString(), colorString: newColor.toString() });
       this.props.onColorSelect(color);
     }
   }
 
-  sampleColorSelected(color) {
+  sampleColorSelected(color: string) {
     this.setColor(color);
   }
 
-  spectrumColorSelected(color) {
+  spectrumColorSelected(color: any) {
     const rgbColor = color.toRgbString();
     this.setColor(rgbColor);
   }
 
-  onColorStringChange(e) {
+  onColorStringChange(e: any) {
     const colorString = e.target.value;
-    this.setState({
-      colorString: colorString,
-    });
+    this.setState({ colorString: colorString });
 
     const newColor = tinycolor(colorString);
     if (newColor.isValid()) {
       // Update only color state
       const newColorString = newColor.toString();
-      this.setState({
-        color: newColorString,
-      });
+      this.setState({ color: newColorString });
       this.props.onColorSelect(newColorString);
     }
   }
 
-  onColorStringBlur(e) {
+  onColorStringBlur(e: any) {
     const colorString = e.target.value;
     this.setColor(colorString);
   }
 
   componentDidMount() {
     this.pickerNavElem.find('li:first').addClass('active');
-    this.pickerNavElem.on('show', e => {
+    this.pickerNavElem.on('show', (e: any) => {
       // use href attr (#name => name)
       const tab = e.target.hash.slice(1);
-      this.setState({
-        tab: tab,
-      });
+      this.setState({ tab: tab });
     });
   }
 

+ 2 - 1
public/app/core/components/colorpicker/SeriesColorPicker.tsx → packages/grafana-ui/src/components/ColorPicker/SeriesColorPicker.tsx

@@ -21,7 +21,7 @@ export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
     onToggleAxis: () => {},
   };
 
-  constructor(props) {
+  constructor(props: SeriesColorPickerProps) {
     super(props);
   }
 
@@ -51,6 +51,7 @@ export class SeriesColorPicker extends React.Component<SeriesColorPickerProps> {
       remove: true,
       tetherOptions: {
         constraints: [{ to: 'scrollParent', attachment: 'none both' }],
+        attachment: 'bottom center',
       },
     });
 

+ 6 - 10
public/app/core/components/colorpicker/SeriesColorPickerPopover.tsx → packages/grafana-ui/src/components/ColorPicker/SeriesColorPickerPopover.tsx

@@ -1,6 +1,5 @@
 import React from 'react';
 import { ColorPickerPopover } from './ColorPickerPopover';
-import { react2AngularDirective } from 'app/core/utils/react2angular';
 
 export interface SeriesColorPickerPopoverProps {
   color: string;
@@ -22,7 +21,7 @@ export class SeriesColorPickerPopover extends React.PureComponent<SeriesColorPic
 
 interface AxisSelectorProps {
   yaxis: number;
-  onToggleAxis: () => void;
+  onToggleAxis?: () => void;
 }
 
 interface AxisSelectorState {
@@ -30,7 +29,7 @@ interface AxisSelectorState {
 }
 
 export class AxisSelector extends React.PureComponent<AxisSelectorProps, AxisSelectorState> {
-  constructor(props) {
+  constructor(props: AxisSelectorProps) {
     super(props);
     this.state = {
       yaxis: this.props.yaxis,
@@ -42,7 +41,10 @@ export class AxisSelector extends React.PureComponent<AxisSelectorProps, AxisSel
     this.setState({
       yaxis: this.state.yaxis === 2 ? 1 : 2,
     });
-    this.props.onToggleAxis();
+
+    if (this.props.onToggleAxis) {
+      this.props.onToggleAxis();
+    }
   }
 
   render() {
@@ -62,9 +64,3 @@ export class AxisSelector extends React.PureComponent<AxisSelectorProps, AxisSel
     );
   }
 }
-
-react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
-  'series',
-  'onColorChange',
-  'onToggleAxis',
-]);

+ 4 - 4
public/app/core/components/colorpicker/SpectrumPicker.tsx → packages/grafana-ui/src/components/ColorPicker/SpectrumPicker.tsx

@@ -13,17 +13,17 @@ export class SpectrumPicker extends React.Component<Props, any> {
   elem: any;
   isMoving: boolean;
 
-  constructor(props) {
+  constructor(props: Props) {
     super(props);
     this.onSpectrumMove = this.onSpectrumMove.bind(this);
     this.setComponentElem = this.setComponentElem.bind(this);
   }
 
-  setComponentElem(elem) {
+  setComponentElem(elem: any) {
     this.elem = $(elem);
   }
 
-  onSpectrumMove(color) {
+  onSpectrumMove(color: any) {
     this.isMoving = true;
     this.props.onColorSelect(color);
   }
@@ -46,7 +46,7 @@ export class SpectrumPicker extends React.Component<Props, any> {
     this.elem.spectrum('set', this.props.color);
   }
 
-  componentWillUpdate(nextProps) {
+  componentWillUpdate(nextProps: any) {
     // If user move pointer over spectrum field this produce 'move' event and component
     // may update props.color. We don't want to update spectrum color in this case, so we can use
     // isMoving flag for tracking moving state. Flag should be cleared in componentDidUpdate() which

+ 0 - 0
public/app/core/specs/__snapshots__/ColorPalette.test.tsx.snap → packages/grafana-ui/src/components/ColorPicker/__snapshots__/ColorPalette.test.tsx.snap


+ 0 - 0
public/app/core/components/CustomScrollbar/CustomScrollbar.test.tsx → packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.test.tsx


+ 1 - 1
public/app/core/components/CustomScrollbar/CustomScrollbar.tsx → packages/grafana-ui/src/components/CustomScrollbar/CustomScrollbar.tsx

@@ -12,7 +12,7 @@ interface Props {
 /**
  * Wraps component into <Scrollbars> component from `react-custom-scrollbars`
  */
-class CustomScrollbar extends PureComponent<Props> {
+export class CustomScrollbar extends PureComponent<Props> {
   static defaultProps: Partial<Props> = {
     customClassName: 'custom-scrollbars',
     autoHide: true,

+ 40 - 0
packages/grafana-ui/src/components/CustomScrollbar/_CustomScrollbar.scss

@@ -0,0 +1,40 @@
+.custom-scrollbars {
+  // Fix for Firefox. For some reason sometimes .view container gets a height of its content, but in order to
+  // make scroll working it should fit outer container size (scroll appears only when inner container size is
+  // greater than outer one).
+  display: flex;
+  flex-grow: 1;
+
+  .view {
+    display: flex;
+    flex-grow: 1;
+    flex-direction: column;
+  }
+
+  .track-vertical {
+    border-radius: 3px;
+    width: 6px !important;
+    right: 2px;
+    bottom: 2px;
+    top: 2px;
+  }
+
+  .track-horizontal {
+    border-radius: 3px;
+    height: 6px !important;
+
+    right: 2px;
+    bottom: 2px;
+    left: 2px;
+  }
+
+  .thumb-vertical {
+    @include gradient-vertical($scrollbarBackground, $scrollbarBackground2);
+    border-radius: 6px;
+  }
+
+  .thumb-horizontal {
+    @include gradient-horizontal($scrollbarBackground, $scrollbarBackground2);
+    border-radius: 6px;
+  }
+}

+ 0 - 0
public/app/core/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap → packages/grafana-ui/src/components/CustomScrollbar/__snapshots__/CustomScrollbar.test.tsx.snap


+ 0 - 0
packages/grafana-ui/src/forms/GfFormLabel/GfFormLabel.tsx → packages/grafana-ui/src/components/GfFormLabel/GfFormLabel.tsx


+ 0 - 0
packages/grafana-ui/src/visualizations/Graph/Graph.tsx → packages/grafana-ui/src/components/Graph/Graph.tsx


+ 11 - 0
packages/grafana-ui/src/components/LoadingPlaceholder/LoadingPlaceholder.tsx

@@ -0,0 +1,11 @@
+import React, { SFC } from 'react';
+
+interface LoadingPlaceholderProps {
+  text: string;
+}
+
+export const LoadingPlaceholder: SFC<LoadingPlaceholderProps> = ({ text }) => (
+  <div className="gf-form-group">
+    {text} <i className="fa fa-spinner fa-spin" />
+  </div>
+);

+ 15 - 0
packages/grafana-ui/src/components/PanelOptionsGrid/PanelOptionsGrid.tsx

@@ -0,0 +1,15 @@
+import React, { SFC } from 'react';
+
+interface Props {
+  cols?: number;
+  children: JSX.Element[] | JSX.Element;
+}
+
+export const PanelOptionsGrid: SFC<Props> = ({ children }) => {
+
+  return (
+    <div className="panel-options-grid">
+      {children}
+    </div>
+  );
+};

+ 10 - 0
packages/grafana-ui/src/components/PanelOptionsGrid/_PanelOptionsGrid.scss

@@ -0,0 +1,10 @@
+.panel-options-grid {
+  display: grid;
+  grid-template-columns: repeat(1, 1fr);
+  grid-row-gap: 10px;
+  grid-column-gap: 10px;
+
+  @include media-breakpoint-up(lg) {
+    grid-template-columns: repeat(3, 1fr);
+  }
+}

+ 4 - 4
public/app/features/dashboard/dashgrid/PanelOptionSection.tsx → packages/grafana-ui/src/components/PanelOptionsGroup/PanelOptionsGroup.tsx

@@ -7,11 +7,11 @@ interface Props {
   children: JSX.Element | JSX.Element[];
 }
 
-export const PanelOptionSection: SFC<Props> = props => {
+export const PanelOptionsGroup: SFC<Props> = props => {
   return (
-    <div className="panel-option-section">
+    <div className="panel-options-group">
       {props.title && (
-        <div className="panel-option-section__header">
+        <div className="panel-options-group__header">
           {props.title}
           {props.onClose && (
             <button className="btn btn-link" onClick={props.onClose}>
@@ -20,7 +20,7 @@ export const PanelOptionSection: SFC<Props> = props => {
           )}
         </div>
       )}
-      <div className="panel-option-section__body">{props.children}</div>
+      <div className="panel-options-group__body">{props.children}</div>
     </div>
   );
 };

+ 27 - 0
packages/grafana-ui/src/components/PanelOptionsGroup/_PanelOptionsGroup.scss

@@ -0,0 +1,27 @@
+.panel-options-group {
+  margin-bottom: 10px;
+  border: $panel-options-group-border;
+  border-radius: $border-radius;
+  background: $page-bg;
+}
+
+.panel-options-group__header {
+  padding: 4px 20px;
+  font-size: 1.1rem;
+  background: $panel-options-group-header-bg;
+  position: relative;
+
+  .btn {
+    position: absolute;
+    right: 0;
+    top: 0px;
+  }
+}
+
+.panel-options-group__body {
+  padding: 20px;
+
+  &--queries {
+    min-height: 200px;
+  }
+}

+ 2 - 2
public/app/core/components/Portal/Portal.tsx → packages/grafana-ui/src/components/Portal/Portal.tsx

@@ -6,11 +6,11 @@ interface Props {
   root?: HTMLElement;
 }
 
-export default class BodyPortal extends PureComponent<Props> {
+export class Portal extends PureComponent<Props> {
   node: HTMLElement = document.createElement('div');
   portalRoot: HTMLElement;
 
-  constructor(props) {
+  constructor(props: Props) {
     super(props);
     const {
       className,

+ 4 - 1
public/app/core/components/Select/IndicatorsContainer.tsx → packages/grafana-ui/src/components/Select/IndicatorsContainer.tsx

@@ -1,7 +1,10 @@
 import React from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
 import { components } from '@torkelo/react-select';
 
-export const IndicatorsContainer = props => {
+export const IndicatorsContainer = (props: any) => {
   const isOpen = props.selectProps.menuIsOpen;
   return (
     <components.IndicatorsContainer {...props}>

+ 4 - 0
public/app/core/components/Select/NoOptionsMessage.tsx → packages/grafana-ui/src/components/Select/NoOptionsMessage.tsx

@@ -1,5 +1,9 @@
 import React from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
 import { components } from '@torkelo/react-select';
+// @ts-ignore
 import { OptionProps } from '@torkelo/react-select/lib/components/Option';
 
 export interface Props {

+ 14 - 9
public/app/core/components/Select/Select.tsx → packages/grafana-ui/src/components/Select/Select.tsx

@@ -1,17 +1,22 @@
 // Libraries
 import classNames from 'classnames';
 import React, { PureComponent } from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
 import { default as ReactSelect } from '@torkelo/react-select';
+// @ts-ignore
 import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async';
+// @ts-ignore
 import { components } from '@torkelo/react-select';
 
 // Components
-import { Option, SingleValue } from './PickerOption';
-import OptionGroup from './OptionGroup';
+import { SelectOption, SingleValue } from './SelectOption';
+import SelectOptionGroup from './SelectOptionGroup';
 import IndicatorsContainer from './IndicatorsContainer';
 import NoOptionsMessage from './NoOptionsMessage';
-import ResetStyles from './ResetStyles';
-import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
+import resetSelectStyles from './resetSelectStyles';
+import { CustomScrollbar } from '@grafana/ui';
 
 export interface SelectOptionItem {
   label?: string;
@@ -53,7 +58,7 @@ interface AsyncProps {
   loadingMessage?: () => string;
 }
 
-export const MenuList = props => {
+export const MenuList = (props: any) => {
   return (
     <components.MenuList {...props}>
       <CustomScrollbar autoHide={false}>{props.children}</CustomScrollbar>
@@ -112,11 +117,11 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
         classNamePrefix="gf-form-select-box"
         className={selectClassNames}
         components={{
-          Option,
+          Option: SelectOption,
           SingleValue,
           IndicatorsContainer,
           MenuList,
-          Group: OptionGroup,
+          Group: SelectOptionGroup,
         }}
         defaultValue={defaultValue}
         value={value}
@@ -127,7 +132,7 @@ export class Select extends PureComponent<CommonProps & SelectProps> {
         onChange={onChange}
         options={options}
         placeholder={placeholder || 'Choose'}
-        styles={ResetStyles}
+        styles={resetSelectStyles()}
         isDisabled={isDisabled}
         isLoading={isLoading}
         isClearable={isClearable}
@@ -212,7 +217,7 @@ export class AsyncSelect extends PureComponent<CommonProps & AsyncProps> {
         isLoading={isLoading}
         defaultOptions={defaultOptions}
         placeholder={placeholder || 'Choose'}
-        styles={ResetStyles}
+        styles={resetSelectStyles()}
         loadingMessage={loadingMessage}
         noOptionsMessage={noOptionsMessage}
         isDisabled={isDisabled}

+ 23 - 12
public/app/core/components/Select/PickerOption.test.tsx → packages/grafana-ui/src/components/Select/SelectOption.test.tsx

@@ -1,11 +1,12 @@
 import React from 'react';
 import renderer from 'react-test-renderer';
-import PickerOption from './PickerOption';
+import SelectOption from './SelectOption';
+import { OptionProps } from 'react-select/lib/components/Option';
 
-const model = {
+// @ts-ignore
+const model: OptionProps<any> = {
   cx: jest.fn(),
   clearValue: jest.fn(),
-  onSelect: jest.fn(),
   getStyles: jest.fn(),
   getValue: jest.fn(),
   hasValue: true,
@@ -18,21 +19,31 @@ const model = {
   isFocused: false,
   isSelected: false,
   innerRef: null,
-  innerProps: null,
+  innerProps: {
+    id: '',
+    key: '',
+    onClick: jest.fn(),
+    onMouseOver: jest.fn(),
+    tabIndex: 1,
+  },
   label: 'Option label',
-  type: null,
+  type: 'option',
   children: 'Model title',
-  data: {
-    title: 'Model title',
-    imgUrl: 'url/to/avatar',
-    label: 'User picker label',
-  },
   className: 'class-for-user-picker',
 };
 
-describe('PickerOption', () => {
+describe('SelectOption', () => {
   it('renders correctly', () => {
-    const tree = renderer.create(<PickerOption {...model} />).toJSON();
+    const tree = renderer
+      .create(
+        <SelectOption
+          {...model}
+          data={{
+            imgUrl: 'url/to/avatar',
+          }}
+        />
+      )
+      .toJSON();
     expect(tree).toMatchSnapshot();
   });
 });

+ 6 - 3
public/app/core/components/Select/PickerOption.tsx → packages/grafana-ui/src/components/Select/SelectOption.tsx

@@ -1,4 +1,7 @@
 import React from 'react';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
 import { components } from '@torkelo/react-select';
 import { OptionProps } from 'react-select/lib/components/Option';
 
@@ -10,7 +13,7 @@ interface ExtendedOptionProps extends OptionProps<any> {
   };
 }
 
-export const Option = (props: ExtendedOptionProps) => {
+export const SelectOption = (props: ExtendedOptionProps) => {
   const { children, isSelected, data } = props;
 
   return (
@@ -28,7 +31,7 @@ export const Option = (props: ExtendedOptionProps) => {
 };
 
 // was not able to type this without typescript error
-export const SingleValue = props => {
+export const SingleValue = (props: any) => {
   const { children, data } = props;
 
   return (
@@ -41,4 +44,4 @@ export const SingleValue = props => {
   );
 };
 
-export default Option;
+export default SelectOption;

+ 2 - 2
public/app/core/components/Select/OptionGroup.tsx → packages/grafana-ui/src/components/Select/SelectOptionGroup.tsx

@@ -9,7 +9,7 @@ interface State {
   expanded: boolean;
 }
 
-export default class OptionGroup extends PureComponent<ExtendedGroupProps, State> {
+export default class SelectOptionGroup extends PureComponent<ExtendedGroupProps, State> {
   state = {
     expanded: false,
   };
@@ -24,7 +24,7 @@ export default class OptionGroup extends PureComponent<ExtendedGroupProps, State
     }
   }
 
-  componentDidUpdate(nextProps) {
+  componentDidUpdate(nextProps: ExtendedGroupProps) {
     if (nextProps.selectProps.inputValue !== '') {
       this.setState({ expanded: true });
     }

+ 0 - 0
public/sass/components/_form_select_box.scss → packages/grafana-ui/src/components/Select/_Select.scss


+ 7 - 2
public/app/core/components/Select/__snapshots__/PickerOption.test.tsx.snap → packages/grafana-ui/src/components/Select/__snapshots__/SelectOption.test.tsx.snap

@@ -1,7 +1,12 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
-exports[`PickerOption renders correctly 1`] = `
-<div>
+exports[`SelectOption renders correctly 1`] = `
+<div
+  id=""
+  onClick={[MockFunction]}
+  onMouseOver={[MockFunction]}
+  tabIndex={1}
+>
   <div
     className="gf-form-select-box__desc-option"
   >

+ 27 - 0
packages/grafana-ui/src/components/Select/resetSelectStyles.ts

@@ -0,0 +1,27 @@
+export default function resetSelectStyles() {
+  return {
+    clearIndicator: () => ({}),
+    container: () => ({}),
+    control: () => ({}),
+    dropdownIndicator: () => ({}),
+    group: () => ({}),
+    groupHeading: () => ({}),
+    indicatorsContainer: () => ({}),
+    indicatorSeparator: () => ({}),
+    input: () => ({}),
+    loadingIndicator: () => ({}),
+    loadingMessage: () => ({}),
+    menu: () => ({}),
+    menuList: ({ maxHeight }: { maxHeight: number }) => ({
+      maxHeight,
+    }),
+    multiValue: () => ({}),
+    multiValueLabel: () => ({}),
+    multiValueRemove: () => ({}),
+    noOptionsMessage: () => ({}),
+    option: () => ({}),
+    placeholder: () => ({}),
+    singleValue: () => ({}),
+    valueContainer: () => ({}),
+  };
+}

+ 7 - 14
public/app/plugins/panel/gauge/Threshold.test.tsx → packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.test.tsx

@@ -1,22 +1,18 @@
 import React from 'react';
 import { shallow } from 'enzyme';
-import Thresholds from './Thresholds';
-import { defaultProps, OptionsProps } from './module';
-import { BasicGaugeColor } from 'app/types';
-import { PanelOptionsProps } from '@grafana/ui';
+
+import { ThresholdsEditor, Props } from './ThresholdsEditor';
+import { BasicGaugeColor } from '../../types';
 
 const setup = (propOverrides?: object) => {
-  const props: PanelOptionsProps<OptionsProps> = {
+  const props: Props = {
     onChange: jest.fn(),
-    options: {
-      ...defaultProps.options,
-      thresholds: [],
-    },
+    thresholds: [],
   };
 
   Object.assign(props, propOverrides);
 
-  return shallow(<Thresholds {...props} />).instance() as Thresholds;
+  return shallow(<ThresholdsEditor {...props} />).instance() as ThresholdsEditor;
 };
 
 describe('Add threshold', () => {
@@ -30,10 +26,7 @@ describe('Add threshold', () => {
 
   it('should add another threshold above a first', () => {
     const instance = setup({
-      options: {
-        ...defaultProps.options,
-        thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }],
-      },
+      thresholds: [{ index: 0, value: 50, color: 'rgb(127, 115, 64)' }],
     });
 
     instance.onAddThreshold(1);

+ 44 - 49
public/app/plugins/panel/gauge/Thresholds.tsx → packages/grafana-ui/src/components/ThresholdsEditor/ThresholdsEditor.tsx

@@ -1,31 +1,38 @@
 import React, { PureComponent } from 'react';
-import tinycolor from 'tinycolor2';
-import { ColorPicker } from 'app/core/components/colorpicker/ColorPicker';
-import { OptionModuleProps } from './module';
-import { BasicGaugeColor, Threshold } from 'app/types';
+import tinycolor, { ColorInput } from 'tinycolor2';
+
+import { Threshold, BasicGaugeColor } from '../../types';
+import { ColorPicker } from '../ColorPicker/ColorPicker';
+import { PanelOptionsGroup } from '../PanelOptionsGroup/PanelOptionsGroup';
+
+export interface Props {
+  thresholds: Threshold[];
+  onChange: (thresholds: Threshold[]) => void;
+}
 
 interface State {
   thresholds: Threshold[];
   baseColor: string;
 }
 
-export default class Thresholds extends PureComponent<OptionModuleProps, State> {
-  constructor(props) {
+export class ThresholdsEditor extends PureComponent<Props, State> {
+  constructor(props: Props) {
     super(props);
 
-    this.state = {
-      thresholds: props.options.thresholds,
-      baseColor: props.options.baseColor,
-    };
+    this.state = { thresholds: props.thresholds, baseColor: BasicGaugeColor.Green };
   }
 
-  onAddThreshold = index => {
-    const { maxValue, minValue } = this.props.options;
+  onAddThreshold = (index: number) => {
+    const maxValue = 100; // hardcoded for now before we add the base threshold
+    const minValue = 0; // hardcoded for now before we add the base threshold
     const { thresholds } = this.state;
 
     const newThresholds = thresholds.map(threshold => {
       if (threshold.index >= index) {
-        threshold = { ...threshold, index: threshold.index + 1 };
+        threshold = {
+          ...threshold,
+          index: threshold.index + 1,
+        };
       }
 
       return threshold;
@@ -47,27 +54,32 @@ export default class Thresholds extends PureComponent<OptionModuleProps, State>
     if (index === 0 && thresholds.length === 0) {
       color = tinycolor.mix(BasicGaugeColor.Green, BasicGaugeColor.Red, 50).toRgbString();
     } else {
-      color = tinycolor.mix(thresholds[index - 1].color, BasicGaugeColor.Red, 50).toRgbString();
+      color = tinycolor.mix(thresholds[index - 1].color as ColorInput, BasicGaugeColor.Red, 50).toRgbString();
     }
 
     this.setState(
       {
-        thresholds: this.sortThresholds([...newThresholds, { index: index, value: value, color: color }]),
+        thresholds: this.sortThresholds([
+          ...newThresholds,
+          {
+            index,
+            value: value as number,
+            color,
+          },
+        ]),
       },
       () => this.updateGauge()
     );
   };
 
-  onRemoveThreshold = threshold => {
+  onRemoveThreshold = (threshold: Threshold) => {
     this.setState(
-      prevState => ({
-        thresholds: prevState.thresholds.filter(t => t !== threshold),
-      }),
+      prevState => ({ thresholds: prevState.thresholds.filter(t => t !== threshold) }),
       () => this.updateGauge()
     );
   };
 
-  onChangeThresholdValue = (event, threshold) => {
+  onChangeThresholdValue = (event: any, threshold: Threshold) => {
     const { thresholds } = this.state;
 
     const newThresholds = thresholds.map(t => {
@@ -78,12 +90,10 @@ export default class Thresholds extends PureComponent<OptionModuleProps, State>
       return t;
     });
 
-    this.setState({
-      thresholds: newThresholds,
-    });
+    this.setState({ thresholds: newThresholds });
   };
 
-  onChangeThresholdColor = (threshold, color) => {
+  onChangeThresholdColor = (threshold: Threshold, color: string) => {
     const { thresholds } = this.state;
 
     const newThresholds = thresholds.map(t => {
@@ -102,20 +112,18 @@ export default class Thresholds extends PureComponent<OptionModuleProps, State>
     );
   };
 
-  onChangeBaseColor = color => this.props.onChange({ ...this.props.options, baseColor: color });
+  onChangeBaseColor = (color: string) => this.props.onChange(this.state.thresholds);
   onBlur = () => {
-    this.setState(prevState => ({
-      thresholds: this.sortThresholds(prevState.thresholds),
-    }));
+    this.setState(prevState => ({ thresholds: this.sortThresholds(prevState.thresholds) }));
 
     this.updateGauge();
   };
 
   updateGauge = () => {
-    this.props.onChange({ ...this.props.options, thresholds: this.state.thresholds });
+    this.props.onChange(this.state.thresholds);
   };
 
-  sortThresholds = thresholds => {
+  sortThresholds = (thresholds: Threshold[]) => {
     return thresholds.sort((t1, t2) => {
       return t2.value - t1.value;
     });
@@ -160,20 +168,8 @@ export default class Thresholds extends PureComponent<OptionModuleProps, State>
     return thresholds.map((t, i) => {
       return (
         <div key={`${t.value}-${i}`} className="indicator-section">
-          <div
-            onClick={() => this.onAddThreshold(t.index + 1)}
-            style={{
-              height: '50%',
-              backgroundColor: t.color,
-            }}
-          />
-          <div
-            onClick={() => this.onAddThreshold(t.index)}
-            style={{
-              height: '50%',
-              backgroundColor: t.color,
-            }}
-          />
+          <div onClick={() => this.onAddThreshold(t.index + 1)} style={{ height: '50%', backgroundColor: t.color }} />
+          <div onClick={() => this.onAddThreshold(t.index)} style={{ height: '50%', backgroundColor: t.color }} />
         </div>
       );
     });
@@ -184,14 +180,14 @@ export default class Thresholds extends PureComponent<OptionModuleProps, State>
       <div className="indicator-section" style={{ height: '100%' }}>
         <div
           onClick={() => this.onAddThreshold(0)}
-          style={{ height: '100%', backgroundColor: this.props.options.baseColor }}
+          style={{ height: '100%', backgroundColor: BasicGaugeColor.Green }}
         />
       </div>
     );
   }
 
   renderBase() {
-    const { baseColor } = this.props.options;
+    const baseColor = BasicGaugeColor.Green;
 
     return (
       <div className="threshold-row threshold-row-base">
@@ -209,8 +205,7 @@ export default class Thresholds extends PureComponent<OptionModuleProps, State>
 
   render() {
     return (
-      <div className="section gf-form-group">
-        <h5 className="section-heading">Thresholds</h5>
+      <PanelOptionsGroup title="Thresholds">
         <div className="thresholds">
           <div className="color-indicators">
             {this.renderIndicator()}
@@ -221,7 +216,7 @@ export default class Thresholds extends PureComponent<OptionModuleProps, State>
             {this.renderBase()}
           </div>
         </div>
-      </div>
+      </PanelOptionsGroup>
     );
   }
 }

+ 0 - 0
public/sass/components/_thresholds.scss → packages/grafana-ui/src/components/ThresholdsEditor/_ThresholdsEditor.scss


+ 12 - 15
public/app/core/components/Tooltip/Popper.tsx → packages/grafana-ui/src/components/Tooltip/Popper.tsx

@@ -1,11 +1,13 @@
 import React, { PureComponent } from 'react';
-import Portal from 'app/core/components/Portal/Portal';
-import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
+import * as PopperJS from 'popper.js';
+import { Manager, Popper as ReactPopper } from 'react-popper';
+import { Portal } from '@grafana/ui';
 import Transition from 'react-transition-group/Transition';
 
 export enum Themes {
   Default = 'popper__background--default',
   Error = 'popper__background--error',
+  Brand = 'popper__background--brand',
 }
 
 const defaultTransitionStyles = {
@@ -13,45 +15,40 @@ const defaultTransitionStyles = {
   opacity: 0,
 };
 
-const transitionStyles = {
+const transitionStyles: {[key: string]: object} = {
   exited: { opacity: 0 },
   entering: { opacity: 0 },
   entered: { opacity: 1 },
   exiting: { opacity: 0 },
 };
 
-interface Props {
+interface Props extends React.DOMAttributes<HTMLDivElement> {
   renderContent: (content: any) => any;
   show: boolean;
-  placement?: any;
+  placement?: PopperJS.Placement;
   content: string | ((props: any) => JSX.Element);
-  refClassName?: string;
+  referenceElement: PopperJS.ReferenceObject;
   theme?: Themes;
 }
 
 class Popper extends PureComponent<Props> {
   render() {
-    const { children, renderContent, show, placement, refClassName, theme } = this.props;
+    const { renderContent, show, placement, onMouseEnter, onMouseLeave, theme } = this.props;
     const { content } = this.props;
 
     const popperBackgroundClassName = 'popper__background' + (theme ? ' ' + theme : '');
 
     return (
       <Manager>
-        <Reference>
-          {({ ref }) => (
-            <div className={`popper_ref ${refClassName || ''}`} ref={ref}>
-              {children}
-            </div>
-          )}
-        </Reference>
         <Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
           {transitionState => (
             <Portal>
-              <ReactPopper placement={placement}>
+              <ReactPopper placement={placement} referenceElement={this.props.referenceElement}>
                 {({ ref, style, placement, arrowProps }) => {
                   return (
                     <div
+                      onMouseEnter={onMouseEnter}
+                      onMouseLeave={onMouseLeave}
                       ref={ref}
                       style={{
                         ...style,

+ 99 - 0
packages/grafana-ui/src/components/Tooltip/PopperController.tsx

@@ -0,0 +1,99 @@
+import React from 'react';
+import * as PopperJS from 'popper.js';
+import { Themes } from './Popper';
+
+type PopperContent = string | (() => JSX.Element);
+
+export interface UsingPopperProps {
+  show?: boolean;
+  placement?: PopperJS.Placement;
+  content: PopperContent;
+  children: JSX.Element;
+  renderContent?: (content: PopperContent) => JSX.Element;
+  theme?: Themes;
+}
+
+type PopperControllerRenderProp = (
+  showPopper: () => void,
+  hidePopper: () => void,
+  popperProps: {
+    show: boolean;
+    placement: PopperJS.Placement;
+    content: string | ((props: any) => JSX.Element);
+    renderContent: (content: any) => any;
+    theme?: Themes;
+  }
+) => JSX.Element;
+
+interface Props {
+  placement?: PopperJS.Placement;
+  content: PopperContent;
+  className?: string;
+  children: PopperControllerRenderProp;
+  theme?: Themes;
+}
+
+interface State {
+  placement: PopperJS.Placement;
+  show: boolean;
+}
+
+class PopperController extends React.Component<Props, State> {
+  constructor(props: Props) {
+    super(props);
+
+    this.state = {
+      placement: this.props.placement || 'auto',
+      show: false,
+    };
+  }
+
+  componentWillReceiveProps(nextProps: Props) {
+    if (nextProps.placement && nextProps.placement !== this.state.placement) {
+      this.setState((prevState: State) => {
+        return {
+          ...prevState,
+          placement: nextProps.placement || 'auto',
+        };
+      });
+    }
+  }
+
+  showPopper = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      show: true,
+    }));
+  };
+
+  hidePopper = () => {
+    this.setState(prevState => ({
+      ...prevState,
+      show: false,
+    }));
+  };
+
+  renderContent(content: PopperContent) {
+    if (typeof content === 'function') {
+      // If it's a function we assume it's a React component
+      const ReactComponent = content;
+      return <ReactComponent />;
+    }
+    return content;
+  }
+
+  render() {
+    const { children, content, theme } = this.props;
+    const { show, placement } = this.state;
+
+    return children(this.showPopper, this.hidePopper, {
+      show,
+      placement,
+      content,
+      renderContent: this.renderContent,
+      theme,
+    });
+  }
+}
+
+export default PopperController;

+ 5 - 3
public/app/core/components/Tooltip/Tooltip.test.tsx → packages/grafana-ui/src/components/Tooltip/Tooltip.test.tsx

@@ -1,13 +1,15 @@
 import React from 'react';
 import renderer from 'react-test-renderer';
-import Tooltip from './Tooltip';
+import { Tooltip } from './Tooltip';
 
 describe('Tooltip', () => {
   it('renders correctly', () => {
     const tree = renderer
       .create(
-        <Tooltip className="test-class" placement="auto" content="Tooltip text">
-          <a href="http://www.grafana.com">Link with tooltip</a>
+        <Tooltip placement="auto" content="Tooltip text">
+          <a className="test-class" href="http://www.grafana.com">
+            Link with tooltip
+          </a>
         </Tooltip>
       )
       .toJSON();

+ 32 - 0
packages/grafana-ui/src/components/Tooltip/Tooltip.tsx

@@ -0,0 +1,32 @@
+import React, { createRef } from 'react';
+import * as PopperJS from 'popper.js';
+import Popper from './Popper';
+import PopperController, { UsingPopperProps } from './PopperController';
+
+export const Tooltip = ({ children, renderContent, ...controllerProps }: UsingPopperProps) => {
+  const tooltipTriggerRef = createRef<PopperJS.ReferenceObject>();
+
+  return (
+    <PopperController {...controllerProps}>
+      {(showPopper, hidePopper, popperProps) => {
+        return (
+          <>
+            {tooltipTriggerRef.current && (
+              <Popper
+                {...popperProps}
+                onMouseEnter={showPopper}
+                onMouseLeave={hidePopper}
+                referenceElement={tooltipTriggerRef.current}
+              />
+            )}
+            {React.cloneElement(children, {
+              ref: tooltipTriggerRef,
+              onMouseEnter: showPopper,
+              onMouseLeave: hidePopper,
+            })}
+          </>
+        );
+      }}
+    </PopperController>
+  );
+};

+ 14 - 4
public/sass/components/_popper.scss → packages/grafana-ui/src/components/Tooltip/_Tooltip.scss

@@ -1,5 +1,13 @@
 $popper-margin-from-ref: 5px;
 
+
+@mixin popper-theme($backgroundColor, $arrowColor) {
+  background: $backgroundColor;
+  .popper__arrow {
+    border-color: $arrowColor;
+  }
+}
+
 .popper {
   position: absolute;
   z-index: $zindex-tooltip;
@@ -16,10 +24,12 @@ $popper-margin-from-ref: 5px;
 
   // Themes
   &.popper__background--error {
-    background: $tooltipBackgroundError;
-    .popper__arrow {
-      border-color: $tooltipBackgroundError;
-    }
+    @include popper-theme($tooltipBackgroundError, $tooltipBackgroundError);
+  }
+
+  &.popper__background--brand {
+    @include popper-theme($tooltipBackgroundBrand, $tooltipBackgroundBrand);
+    @include gradient-vertical($red, $orange);
   }
 }
 

+ 12 - 0
packages/grafana-ui/src/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap

@@ -0,0 +1,12 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Tooltip renders correctly 1`] = `
+<a
+  className="test-class"
+  href="http://www.grafana.com"
+  onMouseEnter={[Function]}
+  onMouseLeave={[Function]}
+>
+  Link with tooltip
+</a>
+`;

+ 6 - 0
packages/grafana-ui/src/components/index.scss

@@ -1 +1,7 @@
+@import 'CustomScrollbar/CustomScrollbar';
 @import 'DeleteButton/DeleteButton';
+@import 'ThresholdsEditor/ThresholdsEditor';
+@import 'Tooltip/Tooltip';
+@import 'Select/Select';
+@import 'PanelOptionsGroup/PanelOptionsGroup';
+@import 'PanelOptionsGrid/PanelOptionsGrid';

+ 19 - 0
packages/grafana-ui/src/components/index.ts

@@ -1 +1,20 @@
 export { DeleteButton } from './DeleteButton/DeleteButton';
+export { Tooltip } from './Tooltip/Tooltip';
+export { Portal } from './Portal/Portal';
+export { CustomScrollbar } from './CustomScrollbar/CustomScrollbar';
+
+// Select
+export { Select, AsyncSelect, SelectOptionItem } from './Select/Select';
+export { IndicatorsContainer } from './Select/IndicatorsContainer';
+export { NoOptionsMessage } from './Select/NoOptionsMessage';
+export { default as resetSelectStyles } from './Select/resetSelectStyles';
+
+export { LoadingPlaceholder } from './LoadingPlaceholder/LoadingPlaceholder';
+export { ColorPicker } from './ColorPicker/ColorPicker';
+export { SeriesColorPickerPopover } from './ColorPicker/SeriesColorPickerPopover';
+export { SeriesColorPicker } from './ColorPicker/SeriesColorPicker';
+export { ThresholdsEditor } from './ThresholdsEditor/ThresholdsEditor';
+export { GfFormLabel } from './GfFormLabel/GfFormLabel';
+export { Graph } from './Graph/Graph';
+export { PanelOptionsGroup } from './PanelOptionsGroup/PanelOptionsGroup';
+export { PanelOptionsGrid } from './PanelOptionsGrid/PanelOptionsGrid';

+ 0 - 1
packages/grafana-ui/src/forms/index.ts

@@ -1 +0,0 @@
-export { GfFormLabel } from './GfFormLabel/GfFormLabel';

+ 0 - 2
packages/grafana-ui/src/index.ts

@@ -1,5 +1,3 @@
 export * from './components';
-export * from './visualizations';
 export * from './types';
 export * from './utils';
-export * from './forms';

+ 16 - 0
packages/grafana-ui/src/types/gauge.ts

@@ -0,0 +1,16 @@
+import { RangeMap, Threshold, ValueMap } from './panel';
+
+export interface GaugeOptions {
+  baseColor: string;
+  decimals: number;
+  mappings: Array<RangeMap | ValueMap>;
+  maxValue: number;
+  minValue: number;
+  prefix: string;
+  showThresholdLabels: boolean;
+  showThresholdMarkers: boolean;
+  stat: string;
+  suffix: string;
+  thresholds: Threshold[];
+  unit: string;
+}

+ 1 - 0
packages/grafana-ui/src/types/index.ts

@@ -1,3 +1,4 @@
 export * from './series';
 export * from './time';
 export * from './panel';
+export * from './gauge';

+ 32 - 0
packages/grafana-ui/src/types/panel.ts

@@ -29,3 +29,35 @@ export interface PanelMenuItem {
   shortcut?: string;
   subMenu?: PanelMenuItem[];
 }
+
+export interface Threshold {
+  index: number;
+  value: number;
+  color?: string;
+}
+
+export enum BasicGaugeColor {
+  Green = '#299c46',
+  Red = '#d44a3a',
+}
+
+export enum MappingType {
+  ValueToText = 1,
+  RangeToText = 2,
+}
+
+interface BaseMap {
+  id: number;
+  operator: string;
+  text: string;
+  type: MappingType;
+}
+
+export interface ValueMap extends BaseMap {
+  value: string;
+}
+
+export interface RangeMap extends BaseMap {
+  from: string;
+  to: string;
+}

+ 93 - 0
packages/grafana-ui/src/utils/colors.ts

@@ -0,0 +1,93 @@
+import _ from 'lodash';
+import tinycolor from 'tinycolor2';
+
+export const PALETTE_ROWS = 4;
+export const PALETTE_COLUMNS = 14;
+export const DEFAULT_ANNOTATION_COLOR = 'rgba(0, 211, 255, 1)';
+export const OK_COLOR = 'rgba(11, 237, 50, 1)';
+export const ALERTING_COLOR = 'rgba(237, 46, 24, 1)';
+export const NO_DATA_COLOR = 'rgba(150, 150, 150, 1)';
+export const PENDING_COLOR = 'rgba(247, 149, 32, 1)';
+export const REGION_FILL_ALPHA = 0.09;
+
+export const colors = [
+  '#7EB26D', // 0: pale green
+  '#EAB839', // 1: mustard
+  '#6ED0E0', // 2: light blue
+  '#EF843C', // 3: orange
+  '#E24D42', // 4: red
+  '#1F78C1', // 5: ocean
+  '#BA43A9', // 6: purple
+  '#705DA0', // 7: violet
+  '#508642', // 8: dark green
+  '#CCA300', // 9: dark sand
+  '#447EBC',
+  '#C15C17',
+  '#890F02',
+  '#0A437C',
+  '#6D1F62',
+  '#584477',
+  '#B7DBAB',
+  '#F4D598',
+  '#70DBED',
+  '#F9BA8F',
+  '#F29191',
+  '#82B5D8',
+  '#E5A8E2',
+  '#AEA2E0',
+  '#629E51',
+  '#E5AC0E',
+  '#64B0C8',
+  '#E0752D',
+  '#BF1B00',
+  '#0A50A1',
+  '#962D82',
+  '#614D93',
+  '#9AC48A',
+  '#F2C96D',
+  '#65C5DB',
+  '#F9934E',
+  '#EA6460',
+  '#5195CE',
+  '#D683CE',
+  '#806EB7',
+  '#3F6833',
+  '#967302',
+  '#2F575E',
+  '#99440A',
+  '#58140C',
+  '#052B51',
+  '#511749',
+  '#3F2B5B',
+  '#E0F9D7',
+  '#FCEACA',
+  '#CFFAFF',
+  '#F9E2D2',
+  '#FCE2DE',
+  '#BADFF4',
+  '#F9D9F9',
+  '#DEDAF7',
+];
+
+function sortColorsByHue(hexColors: string[]) {
+  const hslColors = _.map(hexColors, hexToHsl);
+
+  const sortedHSLColors = _.sortBy(hslColors, ['h']);
+  const chunkedHSLColors = _.chunk(sortedHSLColors, PALETTE_ROWS);
+  const sortedChunkedHSLColors = _.map(chunkedHSLColors, chunk => {
+    return _.sortBy(chunk, 'l');
+  });
+  const flattenedZippedSortedChunkedHSLColors = _.flattenDeep(_.zip(...sortedChunkedHSLColors));
+
+  return _.map(flattenedZippedSortedChunkedHSLColors, hslToHex);
+}
+
+function hexToHsl(color: string) {
+  return tinycolor(color).toHsl();
+}
+
+function hslToHex(color: any) {
+  return tinycolor(color).toHexString();
+}
+
+export let sortedColors = sortColorsByHue(colors);

+ 2 - 0
packages/grafana-ui/src/utils/index.ts

@@ -1 +1,3 @@
 export * from './processTimeSeries';
+export * from './valueFormats/valueFormats';
+export * from './colors';

+ 40 - 0
packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.test.ts

@@ -0,0 +1,40 @@
+import { toHex, toHex0x } from './arithmeticFormatters';
+
+describe('hex', () => {
+  it('positive integer', () => {
+    const str = toHex(100, 0);
+    expect(str).toBe('64');
+  });
+  it('negative integer', () => {
+    const str = toHex(-100, 0);
+    expect(str).toBe('-64');
+  });
+  it('positive float', () => {
+    const str = toHex(50.52, 1);
+    expect(str).toBe('32.8');
+  });
+  it('negative float', () => {
+    const str = toHex(-50.333, 2);
+    expect(str).toBe('-32.547AE147AE14');
+  });
+});
+
+describe('hex 0x', () => {
+  it('positive integeter', () => {
+    const str = toHex0x(7999, 0);
+    expect(str).toBe('0x1F3F');
+  });
+  it('negative integer', () => {
+    const str = toHex0x(-584, 0);
+    expect(str).toBe('-0x248');
+  });
+
+  it('positive float', () => {
+    const str = toHex0x(74.443, 3);
+    expect(str).toBe('0x4A.716872B020C4');
+  });
+  it('negative float', () => {
+    const str = toHex0x(-65.458, 1);
+    expect(str).toBe('-0x41.8');
+  });
+});

+ 42 - 0
packages/grafana-ui/src/utils/valueFormats/arithmeticFormatters.ts

@@ -0,0 +1,42 @@
+import { toFixed } from './valueFormats';
+
+export function toPercent(size: number, decimals: number) {
+  if (size === null) {
+    return '';
+  }
+  return toFixed(size, decimals) + '%';
+}
+
+export function toPercentUnit(size: number, decimals: number) {
+  if (size === null) {
+    return '';
+  }
+  return toFixed(100 * size, decimals) + '%';
+}
+
+export function toHex0x(value: number, decimals: number) {
+  if (value == null) {
+    return '';
+  }
+  const hexString = toHex(value, decimals);
+  if (hexString.substring(0, 1) === '-') {
+    return '-0x' + hexString.substring(1);
+  }
+  return '0x' + hexString;
+}
+
+export function toHex(value: number, decimals: number) {
+  if (value == null) {
+    return '';
+  }
+  return parseFloat(toFixed(value, decimals))
+    .toString(16)
+    .toUpperCase();
+}
+
+export function sci(value: number, decimals: number) {
+  if (value == null) {
+    return '';
+  }
+  return value.toExponential(decimals);
+}

+ 322 - 0
packages/grafana-ui/src/utils/valueFormats/categories.ts

@@ -0,0 +1,322 @@
+import { locale, scaledUnits, simpleCountUnit, toFixed, toFixedUnit, ValueFormatCategory } from './valueFormats';
+import {
+  dateTimeAsIso,
+  dateTimeAsUS,
+  dateTimeFromNow,
+  toClockMilliseconds,
+  toClockSeconds,
+  toDays,
+  toDurationInHoursMinutesSeconds,
+  toDurationInMilliseconds,
+  toDurationInSeconds,
+  toHours,
+  toMicroSeconds,
+  toMilliSeconds,
+  toMinutes,
+  toNanoSeconds,
+  toSeconds,
+  toTimeTicks,
+} from './dateTimeFormatters';
+import { toHex, sci, toHex0x, toPercent, toPercentUnit } from './arithmeticFormatters';
+import { binarySIPrefix, currency, decimalSIPrefix } from './symbolFormatters';
+
+export const getCategories = (): ValueFormatCategory[] => [
+  {
+    name: 'Misc',
+    formats: [
+      { name: 'none', id: 'none', fn: toFixed },
+      {
+        name: 'short',
+        id: 'short',
+        fn: scaledUnits(1000, ['', ' K', ' Mil', ' Bil', ' Tri', ' Quadr', ' Quint', ' Sext', ' Sept']),
+      },
+      { name: 'percent (0-100)', id: 'percent', fn: toPercent },
+      { name: 'percent (0.0-1.0)', id: 'percentunit', fn: toPercentUnit },
+      { name: 'Humidity (%H)', id: 'humidity', fn: toFixedUnit('%H') },
+      { name: 'decibel', id: 'dB', fn: toFixedUnit('dB') },
+      { name: 'hexadecimal (0x)', id: 'hex0x', fn: toHex0x },
+      { name: 'hexadecimal', id: 'hex', fn: toHex },
+      { name: 'scientific notation', id: 'sci', fn: sci },
+      { name: 'locale format', id: 'locale', fn: locale },
+    ],
+  },
+  {
+    name: 'Acceleration',
+    formats: [
+      { name: 'Meters/sec²', id: 'accMS2', fn: toFixedUnit('m/sec²') },
+      { name: 'Feet/sec²', id: 'accFS2', fn: toFixedUnit('f/sec²') },
+      { name: 'G unit', id: 'accG', fn: toFixedUnit('g') },
+    ],
+  },
+  {
+    name: 'Angle',
+    formats: [
+      { name: 'Degrees (°)', id: 'degree', fn: toFixedUnit('°') },
+      { name: 'Radians', id: 'radian', fn: toFixedUnit('rad') },
+      { name: 'Gradian', id: 'grad', fn: toFixedUnit('grad') },
+    ],
+  },
+  {
+    name: 'Area',
+    formats: [
+      { name: 'Square Meters (m²)', id: 'areaM2', fn: toFixedUnit('m²') },
+      { name: 'Square Feet (ft²)', id: 'areaF2', fn: toFixedUnit('ft²') },
+      { name: 'Square Miles (mi²)', id: 'areaMI2', fn: toFixedUnit('mi²') },
+    ],
+  },
+  {
+    name: 'Computation',
+    formats: [
+      { name: 'FLOP/s', id: 'flops', fn: decimalSIPrefix('FLOP/s') },
+      { name: 'MFLOP/s', id: 'mflops', fn: decimalSIPrefix('FLOP/s', 2) },
+      { name: 'GFLOP/s', id: 'gflops', fn: decimalSIPrefix('FLOP/s', 3) },
+      { name: 'TFLOP/s', id: 'tflops', fn: decimalSIPrefix('FLOP/s', 4) },
+      { name: 'PFLOP/s', id: 'pflops', fn: decimalSIPrefix('FLOP/s', 5) },
+      { name: 'EFLOP/s', id: 'eflops', fn: decimalSIPrefix('FLOP/s', 6) },
+    ],
+  },
+  {
+    name: 'Concentration',
+    formats: [
+      { name: 'parts-per-million (ppm)', id: 'ppm', fn: toFixedUnit('ppm') },
+      { name: 'parts-per-billion (ppb)', id: 'conppb', fn: toFixedUnit('ppb') },
+      { name: 'nanogram per cubic meter (ng/m³)', id: 'conngm3', fn: toFixedUnit('ng/m³') },
+      { name: 'nanogram per normal cubic meter (ng/Nm³)', id: 'conngNm3', fn: toFixedUnit('ng/Nm³') },
+      { name: 'microgram per cubic meter (μg/m³)', id: 'conμgm3', fn: toFixedUnit('μg/m³') },
+      { name: 'microgram per normal cubic meter (μg/Nm³)', id: 'conμgNm3', fn: toFixedUnit('μg/Nm³') },
+      { name: 'milligram per cubic meter (mg/m³)', id: 'conmgm3', fn: toFixedUnit('mg/m³') },
+      { name: 'milligram per normal cubic meter (mg/Nm³)', id: 'conmgNm3', fn: toFixedUnit('mg/Nm³') },
+      { name: 'gram per cubic meter (g/m³)', id: 'congm3', fn: toFixedUnit('g/m³') },
+      { name: 'gram per normal cubic meter (g/Nm³)', id: 'congNm3', fn: toFixedUnit('g/Nm³') },
+      { name: 'milligrams per decilitre (mg/dL)', id: 'conmgdL', fn: toFixedUnit('mg/dL') },
+      { name: 'millimoles per litre (mmol/L)', id: 'conmmolL', fn: toFixedUnit('mmol/L') },
+    ],
+  },
+  {
+    name: 'Currency',
+    formats: [
+      { name: 'Dollars ($)', id: 'currencyUSD', fn: currency('$') },
+      { name: 'Pounds (£)', id: 'currencyGBP', fn: currency('£') },
+      { name: 'Euro (€)', id: 'currencyEUR', fn: currency('€') },
+      { name: 'Yen (¥)', id: 'currencyJPY', fn: currency('¥') },
+      { name: 'Rubles (₽)', id: 'currencyRUB', fn: currency('₽') },
+      { name: 'Hryvnias (₴)', id: 'currencyUAH', fn: currency('₴') },
+      { name: 'Real (R$)', id: 'currencyBRL', fn: currency('R$') },
+      { name: 'Danish Krone (kr)', id: 'currencyDKK', fn: currency('kr') },
+      { name: 'Icelandic Króna (kr)', id: 'currencyISK', fn: currency('kr') },
+      { name: 'Norwegian Krone (kr)', id: 'currencyNOK', fn: currency('kr') },
+      { name: 'Swedish Krona (kr)', id: 'currencySEK', fn: currency('kr') },
+      { name: 'Czech koruna (czk)', id: 'currencyCZK', fn: currency('czk') },
+      { name: 'Swiss franc (CHF)', id: 'currencyCHF', fn: currency('CHF') },
+      { name: 'Polish Złoty (PLN)', id: 'currencyPLN', fn: currency('PLN') },
+      { name: 'Bitcoin (฿)', id: 'currencyBTC', fn: currency('฿') },
+    ],
+  },
+  {
+    name: 'Data (IEC)',
+    formats: [
+      { name: 'bits', id: 'bits', fn: binarySIPrefix('b') },
+      { name: 'bytes', id: 'bytes', fn: binarySIPrefix('B') },
+      { name: 'kibibytes', id: 'kbytes', fn: binarySIPrefix('B', 1) },
+      { name: 'mebibytes', id: 'mbytes', fn: binarySIPrefix('B', 2) },
+      { name: 'gibibytes', id: 'gbytes', fn: binarySIPrefix('B', 3) },
+    ],
+  },
+  {
+    name: 'Data (Metric)',
+    formats: [
+      { name: 'bits', id: 'decbits', fn: decimalSIPrefix('d') },
+      { name: 'bytes', id: 'decbytes', fn: decimalSIPrefix('B') },
+      { name: 'kilobytes', id: 'deckbytes', fn: decimalSIPrefix('B', 1) },
+      { name: 'megabytes', id: 'decmbytes', fn: decimalSIPrefix('B', 2) },
+      { name: 'gigabytes', id: 'decgbytes', fn: decimalSIPrefix('B', 3) },
+    ],
+  },
+  {
+    name: 'Data Rate',
+    formats: [
+      { name: 'packets/sec', id: 'pps', fn: decimalSIPrefix('pps') },
+      { name: 'bits/sec', id: 'bps', fn: decimalSIPrefix('bps') },
+      { name: 'bytes/sec', id: 'Bps', fn: decimalSIPrefix('B/s') },
+      { name: 'kilobytes/sec', id: 'KBs', fn: decimalSIPrefix('Bs', 1) },
+      { name: 'kilobits/sec', id: 'Kbits', fn: decimalSIPrefix('bps', 1) },
+      { name: 'megabytes/sec', id: 'MBs', fn: decimalSIPrefix('Bs', 2) },
+      { name: 'megabits/sec', id: 'Mbits', fn: decimalSIPrefix('bps', 2) },
+      { name: 'gigabytes/sec', id: 'GBs', fn: decimalSIPrefix('Bs', 3) },
+      { name: 'gigabits/sec', id: 'Gbits', fn: decimalSIPrefix('bps', 3) },
+    ],
+  },
+  {
+    name: 'Date & Time',
+    formats: [
+      { name: 'YYYY-MM-DD HH:mm:ss', id: 'dateTimeAsIso', fn: dateTimeAsIso },
+      { name: 'DD/MM/YYYY h:mm:ss a', id: 'dateTimeAsUS', fn: dateTimeAsUS },
+      { name: 'From Now', id: 'dateTimeFromNow', fn: dateTimeFromNow },
+    ],
+  },
+  {
+    name: 'Energy',
+    formats: [
+      { name: 'Watt (W)', id: 'watt', fn: decimalSIPrefix('W') },
+      { name: 'Kilowatt (kW)', id: 'kwatt', fn: decimalSIPrefix('W', 1) },
+      { name: 'Milliwatt (mW)', id: 'mwatt', fn: decimalSIPrefix('W', -1) },
+      { name: 'Watt per square meter (W/m²)', id: 'Wm2', fn: toFixedUnit('W/m²') },
+      { name: 'Volt-ampere (VA)', id: 'voltamp', fn: decimalSIPrefix('VA') },
+      { name: 'Kilovolt-ampere (kVA)', id: 'kvoltamp', fn: decimalSIPrefix('VA', 1) },
+      { name: 'Volt-ampere reactive (var)', id: 'voltampreact', fn: decimalSIPrefix('var') },
+      { name: 'Kilovolt-ampere reactive (kvar)', id: 'kvoltampreact', fn: decimalSIPrefix('var', 1) },
+      { name: 'Watt-hour (Wh)', id: 'watth', fn: decimalSIPrefix('Wh') },
+      { name: 'Kilowatt-hour (kWh)', id: 'kwatth', fn: decimalSIPrefix('Wh', 1) },
+      { name: 'Kilowatt-min (kWm)', id: 'kwattm', fn: decimalSIPrefix('W/Min', 1) },
+      { name: 'Joule (J)', id: 'joule', fn: decimalSIPrefix('J') },
+      { name: 'Electron volt (eV)', id: 'ev', fn: decimalSIPrefix('eV') },
+      { name: 'Ampere (A)', id: 'amp', fn: decimalSIPrefix('A') },
+      { name: 'Kiloampere (kA)', id: 'kamp', fn: decimalSIPrefix('A', 1) },
+      { name: 'Milliampere (mA)', id: 'mamp', fn: decimalSIPrefix('A', -1) },
+      { name: 'Volt (V)', id: 'volt', fn: decimalSIPrefix('V') },
+      { name: 'Kilovolt (kV)', id: 'kvolt', fn: decimalSIPrefix('V', 1) },
+      { name: 'Millivolt (mV)', id: 'mvolt', fn: decimalSIPrefix('V', -1) },
+      { name: 'Decibel-milliwatt (dBm)', id: 'dBm', fn: decimalSIPrefix('dBm') },
+      { name: 'Ohm (Ω)', id: 'ohm', fn: decimalSIPrefix('Ω') },
+      { name: 'Lumens (Lm)', id: 'lumens', fn: decimalSIPrefix('Lm') },
+    ],
+  },
+  {
+    name: 'Flow',
+    formats: [
+      { name: 'Gallons/min (gpm)', id: 'flowgpm', fn: toFixedUnit('gpm') },
+      { name: 'Cubic meters/sec (cms)', id: 'flowcms', fn: toFixedUnit('cms') },
+      { name: 'Cubic feet/sec (cfs)', id: 'flowcfs', fn: toFixedUnit('cfs') },
+      { name: 'Cubic feet/min (cfm)', id: 'flowcfm', fn: toFixedUnit('cfm') },
+      { name: 'Litre/hour', id: 'litreh', fn: toFixedUnit('l/h') },
+      { name: 'Litre/min (l/min)', id: 'flowlpm', fn: toFixedUnit('l/min') },
+      { name: 'milliLitre/min (mL/min)', id: 'flowmlpm', fn: toFixedUnit('mL/min') },
+    ],
+  },
+  {
+    name: 'Force',
+    formats: [
+      { name: 'Newton-meters (Nm)', id: 'forceNm', fn: decimalSIPrefix('Nm') },
+      { name: 'Kilonewton-meters (kNm)', id: 'forcekNm', fn: decimalSIPrefix('Nm', 1) },
+      { name: 'Newtons (N)', id: 'forceN', fn: decimalSIPrefix('N') },
+      { name: 'Kilonewtons (kN)', id: 'forcekN', fn: decimalSIPrefix('N', 1) },
+    ],
+  },
+  {
+    name: 'Hash Rate',
+    formats: [
+      { name: 'hashes/sec', id: 'Hs', fn: decimalSIPrefix('H/s') },
+      { name: 'kilohashes/sec', id: 'KHs', fn: decimalSIPrefix('H/s', 1) },
+      { name: 'megahashes/sec', id: 'MHs', fn: decimalSIPrefix('H/s', 2) },
+      { name: 'gigahashes/sec', id: 'GHs', fn: decimalSIPrefix('H/s', 3) },
+      { name: 'terahashes/sec', id: 'THs', fn: decimalSIPrefix('H/s', 4) },
+      { name: 'petahashes/sec', id: 'PHs', fn: decimalSIPrefix('H/s', 5) },
+      { name: 'exahashes/sec', id: 'EHs', fn: decimalSIPrefix('H/s', 6) },
+    ],
+  },
+  {
+    name: 'Mass',
+    formats: [
+      { name: 'milligram (mg)', id: 'massmg', fn: decimalSIPrefix('g', -1) },
+      { name: 'gram (g)', id: 'massg', fn: decimalSIPrefix('g') },
+      { name: 'kilogram (kg)', id: 'masskg', fn: decimalSIPrefix('g', 1) },
+      { name: 'metric ton (t)', id: 'masst', fn: toFixedUnit('t') },
+    ],
+  },
+  {
+    name: 'length',
+    formats: [
+      { name: 'millimetre (mm)', id: 'lengthmm', fn: decimalSIPrefix('m', -1) },
+      { name: 'feet (ft)', id: 'lengthft', fn: toFixedUnit('ft') },
+      { name: 'meter (m)', id: 'lengthm', fn: decimalSIPrefix('m') },
+      { name: 'kilometer (km)', id: 'lengthkm', fn: decimalSIPrefix('m', 1) },
+      { name: 'mile (mi)', id: 'lengthmi', fn: toFixedUnit('mi') },
+    ],
+  },
+  {
+    name: 'Pressure',
+    formats: [
+      { name: 'Millibars', id: 'pressurembar', fn: decimalSIPrefix('bar', -1) },
+      { name: 'Bars', id: 'pressurebar', fn: decimalSIPrefix('bar') },
+      { name: 'Kilobars', id: 'pressurekbar', fn: decimalSIPrefix('bar', 1) },
+      { name: 'Hectopascals', id: 'pressurehpa', fn: toFixedUnit('hPa') },
+      { name: 'Kilopascals', id: 'pressurekpa', fn: toFixedUnit('kPa') },
+      { name: 'Inches of mercury', id: 'pressurehg', fn: toFixedUnit('"Hg') },
+      { name: 'PSI', id: 'pressurepsi', fn: scaledUnits(1000, ['psi', 'ksi', 'Mpsi']) },
+    ],
+  },
+  {
+    name: 'Radiation',
+    formats: [
+      { name: 'Becquerel (Bq)', id: 'radbq', fn: decimalSIPrefix('Bq') },
+      { name: 'curie (Ci)', id: 'radci', fn: decimalSIPrefix('Ci') },
+      { name: 'Gray (Gy)', id: 'radgy', fn: decimalSIPrefix('Gy') },
+      { name: 'rad', id: 'radrad', fn: decimalSIPrefix('rad') },
+      { name: 'Sievert (Sv)', id: 'radsv', fn: decimalSIPrefix('Sv') },
+      { name: 'rem', id: 'radrem', fn: decimalSIPrefix('rem') },
+      { name: 'Exposure (C/kg)', id: 'radexpckg', fn: decimalSIPrefix('C/kg') },
+      { name: 'roentgen (R)', id: 'radr', fn: decimalSIPrefix('R') },
+      { name: 'Sievert/hour (Sv/h)', id: 'radsvh', fn: decimalSIPrefix('Sv/h') },
+    ],
+  },
+  {
+    name: 'Temperature',
+    formats: [
+      { name: 'Celsius (°C)', id: 'celsius', fn: toFixedUnit('°C') },
+      { name: 'Farenheit (°F)', id: 'farenheit', fn: toFixedUnit('°F') },
+      { name: 'Kelvin (K)', id: 'kelvin', fn: toFixedUnit('K') },
+    ],
+  },
+  {
+    name: 'Time',
+    formats: [
+      { name: 'Hertz (1/s)', id: 'hertz', fn: decimalSIPrefix('Hz') },
+      { name: 'nanoseconds (ns)', id: 'ns', fn: toNanoSeconds },
+      { name: 'microseconds (µs)', id: 'µs', fn: toMicroSeconds },
+      { name: 'milliseconds (ms)', id: 'ms', fn: toMilliSeconds },
+      { name: 'seconds (s)', id: 's', fn: toSeconds },
+      { name: 'minutes (m)', id: 'm', fn: toMinutes },
+      { name: 'hours (h)', id: 'h', fn: toHours },
+      { name: 'days (d)', id: 'd', fn: toDays },
+      { name: 'duration (ms)', id: 'dtdurationms', fn: toDurationInMilliseconds },
+      { name: 'duration (s)', id: 'dtdurations', fn: toDurationInSeconds },
+      { name: 'duration (hh:mm:ss)', id: 'dthms', fn: toDurationInHoursMinutesSeconds },
+      { name: 'Timeticks (s/100)', id: 'timeticks', fn: toTimeTicks },
+      { name: 'clock (ms)', id: 'clockms', fn: toClockMilliseconds },
+      { name: 'clock (s)', id: 'clocks', fn: toClockSeconds },
+    ],
+  },
+  {
+    name: 'Throughput',
+    formats: [
+      { name: 'ops/sec (ops)', id: 'ops', fn: simpleCountUnit('ops') },
+      { name: 'requests/sec (rps)', id: 'reqps', fn: simpleCountUnit('reqps') },
+      { name: 'reads/sec (rps)', id: 'rps', fn: simpleCountUnit('rps') },
+      { name: 'writes/sec (wps)', id: 'wps', fn: simpleCountUnit('wps') },
+      { name: 'I/O ops/sec (iops)', id: 'iops', fn: simpleCountUnit('iops') },
+      { name: 'ops/min (opm)', id: 'opm', fn: simpleCountUnit('opm') },
+      { name: 'reads/min (rpm)', id: 'rpm', fn: simpleCountUnit('rpm') },
+      { name: 'writes/min (wpm)', id: 'wpm', fn: simpleCountUnit('wpm') },
+    ],
+  },
+  {
+    name: 'Velocity',
+    formats: [
+      { name: 'metres/second (m/s)', id: 'velocityms', fn: toFixedUnit('m/s') },
+      { name: 'kilometers/hour (km/h)', id: 'velocitykmh', fn: toFixedUnit('km/h') },
+      { name: 'miles/hour (mph)', id: 'velocitymph', fn: toFixedUnit('mph') },
+      { name: 'knot (kn)', id: 'velocityknot', fn: toFixedUnit('kn') },
+    ]
+  },
+  {
+    name: 'Volume',
+    formats: [
+      { name: 'millilitre (mL)', id: 'mlitre', fn: decimalSIPrefix('L', -1) },
+      { name: 'litre (L)', id: 'litre', fn: decimalSIPrefix('L') },
+      { name: 'cubic metre', id: 'm3', fn: toFixedUnit('m³') },
+      { name: 'Normal cubic metre', id: 'Nm3', fn: toFixedUnit('Nm³') },
+      { name: 'cubic decimetre', id: 'dm3', fn: toFixedUnit('dm³') },
+      { name: 'gallons', id: 'gallons', fn: toFixedUnit('gal') },
+    ],
+  }
+];

+ 231 - 0
packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.test.ts

@@ -0,0 +1,231 @@
+import moment from 'moment';
+import {
+  dateTimeAsIso,
+  dateTimeAsUS,
+  dateTimeFromNow,
+  Interval,
+  toClock,
+  toDuration,
+  toDurationInMilliseconds,
+  toDurationInSeconds,
+} from './dateTimeFormatters';
+
+describe('date time formats', () => {
+  const epoch = 1505634997920;
+  const utcTime = moment.utc(epoch);
+  const browserTime = moment(epoch);
+
+  it('should format as iso date', () => {
+    const expected = browserTime.format('YYYY-MM-DD HH:mm:ss');
+    const actual = dateTimeAsIso(epoch, 0, 0, false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as iso date (in UTC)', () => {
+    const expected = utcTime.format('YYYY-MM-DD HH:mm:ss');
+    const actual = dateTimeAsIso(epoch, 0, 0, true);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as iso date and skip date when today', () => {
+    const now = moment();
+    const expected = now.format('HH:mm:ss');
+    const actual = dateTimeAsIso(now.valueOf(), 0, 0, false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as iso date (in UTC) and skip date when today', () => {
+    const now = moment.utc();
+    const expected = now.format('HH:mm:ss');
+    const actual = dateTimeAsIso(now.valueOf(), 0, 0, true);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as US date', () => {
+    const expected = browserTime.format('MM/DD/YYYY h:mm:ss a');
+    const actual = dateTimeAsUS(epoch, 0, 0, false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as US date (in UTC)', () => {
+    const expected = utcTime.format('MM/DD/YYYY h:mm:ss a');
+    const actual = dateTimeAsUS(epoch, 0, 0, true);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as US date and skip date when today', () => {
+    const now = moment();
+    const expected = now.format('h:mm:ss a');
+    const actual = dateTimeAsUS(now.valueOf(), 0, 0, false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as US date (in UTC) and skip date when today', () => {
+    const now = moment.utc();
+    const expected = now.format('h:mm:ss a');
+    const actual = dateTimeAsUS(now.valueOf(), 0, 0, true);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as from now with days', () => {
+    const daysAgo = moment().add(-7, 'd');
+    const expected = '7 days ago';
+    const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as from now with days (in UTC)', () => {
+    const daysAgo = moment.utc().add(-7, 'd');
+    const expected = '7 days ago';
+    const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, true);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as from now with minutes', () => {
+    const daysAgo = moment().add(-2, 'm');
+    const expected = '2 minutes ago';
+    const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, false);
+    expect(actual).toBe(expected);
+  });
+
+  it('should format as from now with minutes (in UTC)', () => {
+    const daysAgo = moment.utc().add(-2, 'm');
+    const expected = '2 minutes ago';
+    const actual = dateTimeFromNow(daysAgo.valueOf(), 0, 0, true);
+    expect(actual).toBe(expected);
+  });
+});
+
+describe('duration', () => {
+  it('0 milliseconds', () => {
+    const str = toDurationInMilliseconds(0, 0);
+    expect(str).toBe('0 milliseconds');
+  });
+  it('1 millisecond', () => {
+    const str = toDurationInMilliseconds(1, 0);
+    expect(str).toBe('1 millisecond');
+  });
+  it('-1 millisecond', () => {
+    const str = toDurationInMilliseconds(-1, 0);
+    expect(str).toBe('1 millisecond ago');
+  });
+  it('seconds', () => {
+    const str = toDurationInSeconds(1, 0);
+    expect(str).toBe('1 second');
+  });
+  it('minutes', () => {
+    const str = toDuration(1, 0, Interval.Minute);
+    expect(str).toBe('1 minute');
+  });
+  it('hours', () => {
+    const str = toDuration(1, 0, Interval.Hour);
+    expect(str).toBe('1 hour');
+  });
+  it('days', () => {
+    const str = toDuration(1, 0, Interval.Day);
+    expect(str).toBe('1 day');
+  });
+  it('weeks', () => {
+    const str = toDuration(1, 0, Interval.Week);
+    expect(str).toBe('1 week');
+  });
+  it('months', () => {
+    const str = toDuration(1, 0, Interval.Month);
+    expect(str).toBe('1 month');
+  });
+  it('years', () => {
+    const str = toDuration(1, 0, Interval.Year);
+    expect(str).toBe('1 year');
+  });
+  it('decimal days', () => {
+    const str = toDuration(1.5, 2, Interval.Day);
+    expect(str).toBe('1 day, 12 hours, 0 minutes');
+  });
+  it('decimal months', () => {
+    const str = toDuration(1.5, 3, Interval.Month);
+    expect(str).toBe('1 month, 2 weeks, 1 day, 0 hours');
+  });
+  it('no decimals', () => {
+    const str = toDuration(38898367008, 0, Interval.Millisecond);
+    expect(str).toBe('1 year');
+  });
+  it('1 decimal', () => {
+    const str = toDuration(38898367008, 1, Interval.Millisecond);
+    expect(str).toBe('1 year, 2 months');
+  });
+  it('too many decimals', () => {
+    const str = toDuration(38898367008, 20, Interval.Millisecond);
+    expect(str).toBe('1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, 7 seconds, 8 milliseconds');
+  });
+  it('floating point error', () => {
+    const str = toDuration(36993906007, 8, Interval.Millisecond);
+    expect(str).toBe('1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds');
+  });
+});
+
+describe('clock', () => {
+  it('size less than 1 second', () => {
+    const str = toClock(999, 0);
+    expect(str).toBe('999ms');
+  });
+  describe('size less than 1 minute', () => {
+    it('default', () => {
+      const str = toClock(59999);
+      expect(str).toBe('59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = toClock(59999, 0);
+      expect(str).toBe('59s');
+    });
+  });
+  describe('size less than 1 hour', () => {
+    it('default', () => {
+      const str = toClock(3599999);
+      expect(str).toBe('59m:59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = toClock(3599999, 0);
+      expect(str).toBe('59m');
+    });
+    it('decimals equals 1', () => {
+      const str = toClock(3599999, 1);
+      expect(str).toBe('59m:59s');
+    });
+  });
+  describe('size greater than or equal 1 hour', () => {
+    it('default', () => {
+      const str = toClock(7199999);
+      expect(str).toBe('01h:59m:59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = toClock(7199999, 0);
+      expect(str).toBe('01h');
+    });
+    it('decimals equals 1', () => {
+      const str = toClock(7199999, 1);
+      expect(str).toBe('01h:59m');
+    });
+    it('decimals equals 2', () => {
+      const str = toClock(7199999, 2);
+      expect(str).toBe('01h:59m:59s');
+    });
+  });
+  describe('size greater than or equal 1 day', () => {
+    it('default', () => {
+      const str = toClock(89999999);
+      expect(str).toBe('24h:59m:59s:999ms');
+    });
+    it('decimals equals 0', () => {
+      const str = toClock(89999999, 0);
+      expect(str).toBe('24h');
+    });
+    it('decimals equals 1', () => {
+      const str = toClock(89999999, 1);
+      expect(str).toBe('24h:59m');
+    });
+    it('decimals equals 2', () => {
+      const str = toClock(89999999, 2);
+      expect(str).toBe('24h:59m:59s');
+    });
+  });
+});

+ 312 - 0
packages/grafana-ui/src/utils/valueFormats/dateTimeFormatters.ts

@@ -0,0 +1,312 @@
+import { toFixed, toFixedScaled } from './valueFormats';
+import moment from 'moment';
+
+interface IntervalsInSeconds {
+  [interval: string]: number;
+}
+
+export enum Interval {
+  Year = 'year',
+  Month = 'month',
+  Week = 'week',
+  Day = 'day',
+  Hour = 'hour',
+  Minute = 'minute',
+  Second = 'second',
+  Millisecond = 'millisecond',
+}
+
+const INTERVALS_IN_SECONDS: IntervalsInSeconds = {
+  [Interval.Year]: 31536000,
+  [Interval.Month]: 2592000,
+  [Interval.Week]: 604800,
+  [Interval.Day]: 86400,
+  [Interval.Hour]: 3600,
+  [Interval.Minute]: 60,
+  [Interval.Second]: 1,
+  [Interval.Millisecond]: 0.001,
+};
+
+export function toNanoSeconds(size: number, decimals: number, scaledDecimals: number) {
+  if (size === null) {
+    return '';
+  }
+
+  if (Math.abs(size) < 1000) {
+    return toFixed(size, decimals) + ' ns';
+  } else if (Math.abs(size) < 1000000) {
+    return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' µs');
+  } else if (Math.abs(size) < 1000000000) {
+    return toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' ms');
+  } else if (Math.abs(size) < 60000000000) {
+    return toFixedScaled(size / 1000000000, decimals, scaledDecimals, 9, ' s');
+  } else {
+    return toFixedScaled(size / 60000000000, decimals, scaledDecimals, 12, ' min');
+  }
+}
+
+export function toMicroSeconds(size: number, decimals: number, scaledDecimals: number) {
+  if (size === null) {
+    return '';
+  }
+
+  if (Math.abs(size) < 1000) {
+    return toFixed(size, decimals) + ' µs';
+  } else if (Math.abs(size) < 1000000) {
+    return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' ms');
+  } else {
+    return toFixedScaled(size / 1000000, decimals, scaledDecimals, 6, ' s');
+  }
+}
+
+export function toMilliSeconds(size: number, decimals: number, scaledDecimals: number) {
+  if (size === null) {
+    return '';
+  }
+
+  if (Math.abs(size) < 1000) {
+    return toFixed(size, decimals) + ' ms';
+  } else if (Math.abs(size) < 60000) {
+    // Less than 1 min
+    return toFixedScaled(size / 1000, decimals, scaledDecimals, 3, ' s');
+  } else if (Math.abs(size) < 3600000) {
+    // Less than 1 hour, divide in minutes
+    return toFixedScaled(size / 60000, decimals, scaledDecimals, 5, ' min');
+  } else if (Math.abs(size) < 86400000) {
+    // Less than one day, divide in hours
+    return toFixedScaled(size / 3600000, decimals, scaledDecimals, 7, ' hour');
+  } else if (Math.abs(size) < 31536000000) {
+    // Less than one year, divide in days
+    return toFixedScaled(size / 86400000, decimals, scaledDecimals, 8, ' day');
+  }
+
+  return toFixedScaled(size / 31536000000, decimals, scaledDecimals, 10, ' year');
+}
+
+export function toSeconds(size: number, decimals: number, scaledDecimals: number) {
+  if (size === null) {
+    return '';
+  }
+
+  // Less than 1 µs, divide in ns
+  if (Math.abs(size) < 0.000001) {
+    return toFixedScaled(size * 1e9, decimals, scaledDecimals - decimals, -9, ' ns');
+  }
+  // Less than 1 ms, divide in µs
+  if (Math.abs(size) < 0.001) {
+    return toFixedScaled(size * 1e6, decimals, scaledDecimals - decimals, -6, ' µs');
+  }
+  // Less than 1 second, divide in ms
+  if (Math.abs(size) < 1) {
+    return toFixedScaled(size * 1e3, decimals, scaledDecimals - decimals, -3, ' ms');
+  }
+
+  if (Math.abs(size) < 60) {
+    return toFixed(size, decimals) + ' s';
+  } else if (Math.abs(size) < 3600) {
+    // Less than 1 hour, divide in minutes
+    return toFixedScaled(size / 60, decimals, scaledDecimals, 1, ' min');
+  } else if (Math.abs(size) < 86400) {
+    // Less than one day, divide in hours
+    return toFixedScaled(size / 3600, decimals, scaledDecimals, 4, ' hour');
+  } else if (Math.abs(size) < 604800) {
+    // Less than one week, divide in days
+    return toFixedScaled(size / 86400, decimals, scaledDecimals, 5, ' day');
+  } else if (Math.abs(size) < 31536000) {
+    // Less than one year, divide in week
+    return toFixedScaled(size / 604800, decimals, scaledDecimals, 6, ' week');
+  }
+
+  return toFixedScaled(size / 3.15569e7, decimals, scaledDecimals, 7, ' year');
+}
+
+export function toMinutes(size: number, decimals: number, scaledDecimals: number) {
+  if (size === null) {
+    return '';
+  }
+
+  if (Math.abs(size) < 60) {
+    return toFixed(size, decimals) + ' min';
+  } else if (Math.abs(size) < 1440) {
+    return toFixedScaled(size / 60, decimals, scaledDecimals, 2, ' hour');
+  } else if (Math.abs(size) < 10080) {
+    return toFixedScaled(size / 1440, decimals, scaledDecimals, 3, ' day');
+  } else if (Math.abs(size) < 604800) {
+    return toFixedScaled(size / 10080, decimals, scaledDecimals, 4, ' week');
+  } else {
+    return toFixedScaled(size / 5.25948e5, decimals, scaledDecimals, 5, ' year');
+  }
+}
+
+export function toHours(size: number, decimals: number, scaledDecimals: number) {
+  if (size === null) {
+    return '';
+  }
+
+  if (Math.abs(size) < 24) {
+    return toFixed(size, decimals) + ' hour';
+  } else if (Math.abs(size) < 168) {
+    return toFixedScaled(size / 24, decimals, scaledDecimals, 2, ' day');
+  } else if (Math.abs(size) < 8760) {
+    return toFixedScaled(size / 168, decimals, scaledDecimals, 3, ' week');
+  } else {
+    return toFixedScaled(size / 8760, decimals, scaledDecimals, 4, ' year');
+  }
+}
+
+export function toDays(size: number, decimals: number, scaledDecimals: number) {
+  if (size === null) {
+    return '';
+  }
+
+  if (Math.abs(size) < 7) {
+    return toFixed(size, decimals) + ' day';
+  } else if (Math.abs(size) < 365) {
+    return toFixedScaled(size / 7, decimals, scaledDecimals, 2, ' week');
+  } else {
+    return toFixedScaled(size / 365, decimals, scaledDecimals, 3, ' year');
+  }
+}
+
+export function toDuration(size: number, decimals: number, timeScale: Interval): string {
+  if (size === null) {
+    return '';
+  }
+  if (size === 0) {
+    return '0 ' + timeScale + 's';
+  }
+  if (size < 0) {
+    return toDuration(-size, decimals, timeScale) + ' ago';
+  }
+
+  const units = [
+    { long: Interval.Year },
+    { long: Interval.Month },
+    { long: Interval.Week },
+    { long: Interval.Day },
+    { long: Interval.Hour },
+    { long: Interval.Minute },
+    { long: Interval.Second },
+    { long: Interval.Millisecond },
+  ];
+  // convert $size to milliseconds
+  // intervals_in_seconds uses seconds (duh), convert them to milliseconds here to minimize floating point errors
+  size *= INTERVALS_IN_SECONDS[timeScale] * 1000;
+
+  const strings = [];
+  // after first value >= 1 print only $decimals more
+  let decrementDecimals = false;
+  for (let i = 0; i < units.length && decimals >= 0; i++) {
+    const interval = INTERVALS_IN_SECONDS[units[i].long] * 1000;
+    const value = size / interval;
+    if (value >= 1 || decrementDecimals) {
+      decrementDecimals = true;
+      const floor = Math.floor(value);
+      const unit = units[i].long + (floor !== 1 ? 's' : '');
+      strings.push(floor + ' ' + unit);
+      size = size % interval;
+      decimals--;
+    }
+  }
+
+  return strings.join(', ');
+}
+
+export function toClock(size: number, decimals?: number) {
+  if (size === null) {
+    return '';
+  }
+
+  // < 1 second
+  if (size < 1000) {
+    return moment.utc(size).format('SSS\\m\\s');
+  }
+
+  // < 1 minute
+  if (size < 60000) {
+    let format = 'ss\\s:SSS\\m\\s';
+    if (decimals === 0) {
+      format = 'ss\\s';
+    }
+    return moment.utc(size).format(format);
+  }
+
+  // < 1 hour
+  if (size < 3600000) {
+    let format = 'mm\\m:ss\\s:SSS\\m\\s';
+    if (decimals === 0) {
+      format = 'mm\\m';
+    } else if (decimals === 1) {
+      format = 'mm\\m:ss\\s';
+    }
+    return moment.utc(size).format(format);
+  }
+
+  let format = 'mm\\m:ss\\s:SSS\\m\\s';
+
+  const hours = `${('0' + Math.floor(moment.duration(size, 'milliseconds').asHours())).slice(-2)}h`;
+
+  if (decimals === 0) {
+    format = '';
+  } else if (decimals === 1) {
+    format = 'mm\\m';
+  } else if (decimals === 2) {
+    format = 'mm\\m:ss\\s';
+  }
+
+  return format ? `${hours}:${moment.utc(size).format(format)}` : hours;
+}
+
+export function toDurationInMilliseconds(size: number, decimals: number) {
+  return toDuration(size, decimals, Interval.Millisecond);
+}
+
+export function toDurationInSeconds(size: number, decimals: number) {
+  return toDuration(size, decimals, Interval.Second);
+}
+
+export function toDurationInHoursMinutesSeconds(size: number) {
+  const strings = [];
+  const numHours = Math.floor(size / 3600);
+  const numMinutes = Math.floor((size % 3600) / 60);
+  const numSeconds = Math.floor((size % 3600) % 60);
+  numHours > 9 ? strings.push('' + numHours) : strings.push('0' + numHours);
+  numMinutes > 9 ? strings.push('' + numMinutes) : strings.push('0' + numMinutes);
+  numSeconds > 9 ? strings.push('' + numSeconds) : strings.push('0' + numSeconds);
+  return strings.join(':');
+}
+
+export function toTimeTicks(size: number, decimals: number, scaledDecimals: number) {
+  return toSeconds(size, decimals, scaledDecimals);
+}
+
+export function toClockMilliseconds(size: number, decimals: number) {
+  return toClock(size, decimals);
+}
+
+export function toClockSeconds(size: number, decimals: number) {
+  return toClock(size * 1000, decimals);
+}
+
+export function dateTimeAsIso(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
+  const time = isUtc ? moment.utc(value) : moment(value);
+
+  if (moment().isSame(value, 'day')) {
+    return time.format('HH:mm:ss');
+  }
+  return time.format('YYYY-MM-DD HH:mm:ss');
+}
+
+export function dateTimeAsUS(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
+  const time = isUtc ? moment.utc(value) : moment(value);
+
+  if (moment().isSame(value, 'day')) {
+    return time.format('h:mm:ss a');
+  }
+  return time.format('MM/DD/YYYY h:mm:ss a');
+}
+
+export function dateTimeFromNow(value: number, decimals: number, scaledDecimals: number, isUtc: boolean) {
+  const time = isUtc ? moment.utc(value) : moment(value);
+  return time.fromNow();
+}

+ 7 - 0
packages/grafana-ui/src/utils/valueFormats/symbolFormatters.test.ts

@@ -0,0 +1,7 @@
+import { currency } from './symbolFormatters';
+
+describe('Currency', () => {
+  it('should format as usd', () => {
+    expect(currency('$')(1532.82, 1, -1)).toEqual('$1.53K');
+  });
+});

+ 30 - 0
packages/grafana-ui/src/utils/valueFormats/symbolFormatters.ts

@@ -0,0 +1,30 @@
+import { scaledUnits } from './valueFormats';
+
+export function currency(symbol: string) {
+  const units = ['', 'K', 'M', 'B', 'T'];
+  const scaler = scaledUnits(1000, units);
+  return (size: number, decimals: number, scaledDecimals: number) => {
+    if (size === null) {
+      return '';
+    }
+    const scaled = scaler(size, decimals, scaledDecimals);
+    return symbol + scaled;
+  };
+}
+
+export function binarySIPrefix(unit: string, offset = 0) {
+  const prefixes = ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi', 'Yi'].slice(offset);
+  const units = prefixes.map(p => {
+    return ' ' + p + unit;
+  });
+  return scaledUnits(1024, units);
+}
+
+export function decimalSIPrefix(unit: string, offset = 0) {
+  let prefixes = ['n', 'µ', 'm', '', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
+  prefixes = prefixes.slice(3 + (offset || 0));
+  const units = prefixes.map(p => {
+    return ' ' + p + unit;
+  });
+  return scaledUnits(1000, units);
+}

+ 166 - 0
packages/grafana-ui/src/utils/valueFormats/valueFormats.ts

@@ -0,0 +1,166 @@
+import { getCategories } from './categories';
+
+type ValueFormatter = (value: number, decimals?: number, scaledDecimals?: number, isUtc?: boolean) => string;
+
+interface ValueFormat {
+  name: string;
+  id: string;
+  fn: ValueFormatter;
+}
+
+export interface ValueFormatCategory {
+  name: string;
+  formats: ValueFormat[];
+}
+
+interface ValueFormatterIndex {
+  [id: string]: ValueFormatter;
+}
+
+// Globals & formats cache
+let categories: ValueFormatCategory[] = [];
+const index: ValueFormatterIndex = {};
+let hasBuiltIndex = false;
+
+export function toFixed(value: number, decimals?: number): string {
+  if (value === null) {
+    return '';
+  }
+
+  const factor = decimals ? Math.pow(10, Math.max(0, decimals)) : 1;
+  const formatted = String(Math.round(value * factor) / factor);
+
+  // if exponent return directly
+  if (formatted.indexOf('e') !== -1 || value === 0) {
+    return formatted;
+  }
+
+  // If tickDecimals was specified, ensure that we have exactly that
+  // much precision; otherwise default to the value's own precision.
+  if (decimals != null) {
+    const decimalPos = formatted.indexOf('.');
+    const precision = decimalPos === -1 ? 0 : formatted.length - decimalPos - 1;
+    if (precision < decimals) {
+      return (precision ? formatted : formatted + '.') + String(factor).substr(1, decimals - precision);
+    }
+  }
+
+  return formatted;
+}
+
+export function toFixedScaled(
+  value: number,
+  decimals: number,
+  scaledDecimals: number,
+  additionalDecimals: number,
+  ext: string
+) {
+  if (scaledDecimals === null) {
+    return toFixed(value, decimals) + ext;
+  } else {
+    return toFixed(value, scaledDecimals + additionalDecimals) + ext;
+  }
+}
+
+export function toFixedUnit(unit: string) {
+  return (size: number, decimals: number) => {
+    if (size === null) {
+      return '';
+    }
+    return toFixed(size, decimals) + ' ' + unit;
+  };
+}
+
+// Formatter which scales the unit string geometrically according to the given
+// numeric factor. Repeatedly scales the value down by the factor until it is
+// less than the factor in magnitude, or the end of the array is reached.
+export function scaledUnits(factor: number, extArray: string[]) {
+  return (size: number, decimals: number, scaledDecimals: number) => {
+    if (size === null) {
+      return '';
+    }
+
+    let steps = 0;
+    const limit = extArray.length;
+
+    while (Math.abs(size) >= factor) {
+      steps++;
+      size /= factor;
+
+      if (steps >= limit) {
+        return 'NA';
+      }
+    }
+
+    if (steps > 0 && scaledDecimals !== null) {
+      decimals = scaledDecimals + 3 * steps;
+    }
+
+    return toFixed(size, decimals) + extArray[steps];
+  };
+}
+
+export function locale(value: number, decimals: number) {
+  if (value == null) {
+    return '';
+  }
+  return value.toLocaleString(undefined, { maximumFractionDigits: decimals });
+}
+
+export function simpleCountUnit(symbol: string) {
+  const units = ['', 'K', 'M', 'B', 'T'];
+  const scaler = scaledUnits(1000, units);
+  return (size: number, decimals: number, scaledDecimals: number) => {
+    if (size === null) {
+      return '';
+    }
+    const scaled = scaler(size, decimals, scaledDecimals);
+    return scaled + ' ' + symbol;
+  };
+}
+
+function buildFormats() {
+  categories = getCategories();
+
+  for (const cat of categories) {
+    for (const format of cat.formats) {
+      index[format.id] = format.fn;
+    }
+  }
+
+  hasBuiltIndex = true;
+}
+
+export function getValueFormat(id: string): ValueFormatter {
+  if (!hasBuiltIndex) {
+    buildFormats();
+  }
+
+  return index[id];
+}
+
+export function getValueFormatterIndex(): ValueFormatterIndex {
+  if (!hasBuiltIndex) {
+    buildFormats();
+  }
+
+  return index;
+}
+
+export function getValueFormats() {
+  if (!hasBuiltIndex) {
+    buildFormats();
+  }
+
+  return categories.map(cat => {
+    return {
+      text: cat.name,
+      submenu: cat.formats.map(format => {
+        return {
+          text: format.name,
+          value: format.id,
+        };
+      }),
+    };
+  });
+}

+ 0 - 1
packages/grafana-ui/src/visualizations/index.ts

@@ -1 +0,0 @@
-export { Graph } from './Graph/Graph';

+ 4 - 2
packaging/docker/Dockerfile

@@ -1,4 +1,5 @@
-FROM debian:stretch-slim
+ARG BASE_IMAGE=debian:stretch-slim
+FROM ${BASE_IMAGE}
 
 ARG GRAFANA_TGZ="grafana-latest.linux-x64.tar.gz"
 
@@ -10,7 +11,8 @@ COPY ${GRAFANA_TGZ} /tmp/grafana.tar.gz
 
 RUN mkdir /tmp/grafana && tar xfvz /tmp/grafana.tar.gz --strip-components=1 -C /tmp/grafana
 
-FROM debian:stretch-slim
+ARG BASE_IMAGE=debian:stretch-slim
+FROM ${BASE_IMAGE}
 
 ARG GF_UID="472"
 ARG GF_GID="472"

+ 0 - 1
packaging/docker/build-deploy.sh

@@ -8,6 +8,5 @@ docker login -u "$DOCKER_USER" -p "$DOCKER_PASS"
 ./push_to_docker_hub.sh "$_grafana_version"
 
 if echo "$_grafana_version" | grep -q "^master-"; then
-  apk add --no-cache curl
   ./deploy_to_k8s.sh "grafana/grafana-dev:$_grafana_version"
 fi

+ 32 - 8
packaging/docker/build.sh

@@ -1,25 +1,49 @@
 #!/bin/sh
 
-_grafana_tag=$1
+_grafana_tag=${1:-}
+_docker_repo=${2:-grafana/grafana}
 
 # If the tag starts with v, treat this as a official release
 if echo "$_grafana_tag" | grep -q "^v"; then
 	_grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2)
-	_docker_repo=${2:-grafana/grafana}
 else
 	_grafana_version=$_grafana_tag
-	_docker_repo=${2:-grafana/grafana-dev}
 fi
 
 echo "Building ${_docker_repo}:${_grafana_version}"
 
-docker build \
-	--tag "${_docker_repo}:${_grafana_version}" \
-	--no-cache=true .
+export DOCKER_CLI_EXPERIMENTAL=enabled
+
+# Build grafana image for a specific arch
+docker_build () {
+	base_image=$1
+	grafana_tgz=$2
+	tag=$3
+
+  docker build \
+		--build-arg BASE_IMAGE=${base_image} \
+		--build-arg GRAFANA_TGZ=${grafana_tgz} \
+		--tag "${tag}" \
+		--no-cache=true .
+}
+
+# Tag docker images of all architectures
+docker_tag_all () {
+	repo=$1
+	tag=$2
+	docker tag "${_docker_repo}:${_grafana_version}" "${repo}:${tag}"
+	docker tag "${_docker_repo}-arm32v7-linux:${_grafana_version}" "${repo}-arm32v7-linux:${tag}"
+	docker tag "${_docker_repo}-arm64v8-linux:${_grafana_version}" "${repo}-arm64v8-linux:${tag}"
+}
+
+docker_build "debian:stretch-slim" "grafana-latest.linux-x64.tar.gz" "${_docker_repo}:${_grafana_version}"
+docker_build "arm32v7/debian:stretch-slim" "grafana-latest.linux-armv7.tar.gz" "${_docker_repo}-arm32v7-linux:${_grafana_version}"
+docker_build "arm64v8/debian:stretch-slim" "grafana-latest.linux-arm64.tar.gz" "${_docker_repo}-arm64v8-linux:${_grafana_version}"
 
 # Tag as 'latest' for official release; otherwise tag as grafana/grafana:master
 if echo "$_grafana_tag" | grep -q "^v"; then
-	docker tag "${_docker_repo}:${_grafana_version}" "${_docker_repo}:latest"
+	docker_tag_all "${_docker_repo}" "latest"
 else
-	docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana:master"
+	docker_tag_all "${_docker_repo}" "master"
+	docker tag "${_docker_repo}:${_grafana_version}" "grafana/grafana-dev:${_grafana_version}"
 fi

+ 29 - 7
packaging/docker/push_to_docker_hub.sh

@@ -1,24 +1,46 @@
 #!/bin/sh
 set -e
 
-_grafana_tag=$1
+_grafana_tag=${1:-}
+_docker_repo=${2:-grafana/grafana}
 
 # If the tag starts with v, treat this as a official release
 if echo "$_grafana_tag" | grep -q "^v"; then
 	_grafana_version=$(echo "${_grafana_tag}" | cut -d "v" -f 2)
-	_docker_repo=${2:-grafana/grafana}
 else
 	_grafana_version=$_grafana_tag
-	_docker_repo=${2:-grafana/grafana-dev}
 fi
 
+export DOCKER_CLI_EXPERIMENTAL=enabled
+
 echo "pushing ${_docker_repo}:${_grafana_version}"
-docker push "${_docker_repo}:${_grafana_version}"
+
+
+docker_push_all () {
+	repo=$1
+	tag=$2
+
+	# Push each image individually
+	docker push "${repo}:${tag}"
+	docker push "${repo}-arm32v7-linux:${tag}"
+	docker push "${repo}-arm64v8-linux:${tag}"
+
+	# Create and push a multi-arch manifest
+	docker manifest create "${repo}:${tag}" \
+		"${repo}:${tag}" \
+  	"${repo}-arm32v7-linux:${tag}" \
+		"${repo}-arm64v8-linux:${tag}"
+
+	docker manifest push "${repo}:${tag}"
+}
 
 if echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -vq "beta"; then
 	echo "pushing ${_docker_repo}:latest"
-	docker push "${_docker_repo}:latest"
+	docker_push_all "${_docker_repo}" "latest"
+	docker_push_all "${_docker_repo}" "${_grafana_version}"
+elif echo "$_grafana_tag" | grep -q "^v" && echo "$_grafana_tag" | grep -q "beta"; then
+	docker_push_all "${_docker_repo}" "${_grafana_version}"
 elif echo "$_grafana_tag" | grep -q "master"; then
-	echo "pushing grafana/grafana:master"
-	docker push grafana/grafana:master
+	docker_push_all "${_docker_repo}" "master"
+	docker push "grafana/grafana-dev:${_grafana_version}"
 fi

+ 4 - 0
pkg/api/alerting.go

@@ -212,6 +212,10 @@ func GetAlertNotificationByID(c *m.ReqContext) Response {
 		return Error(500, "Failed to get alert notifications", err)
 	}
 
+	if query.Result == nil {
+		return Error(404, "Alert notification not found", nil)
+	}
+
 	return JSON(200, dtos.NewAlertNotification(query.Result))
 }
 

+ 6 - 0
pkg/api/alerting_test.go

@@ -119,6 +119,12 @@ func TestAlertingApiEndpoint(t *testing.T) {
 			So(getAlertsQuery.Limit, ShouldEqual, 5)
 			So(getAlertsQuery.Query, ShouldEqual, "alertQuery")
 		})
+
+		loggedInUserScenarioWithRole("When calling GET on", "GET", "/api/alert-notifications/1", "/alert-notifications/:notificationId", m.ROLE_ADMIN, func(sc *scenarioContext) {
+			sc.handlerFunc = GetAlertNotificationByID
+			sc.fakeReqWithParams("GET", sc.url, map[string]string{}).exec()
+			So(sc.resp.Code, ShouldEqual, 404)
+		})
 	})
 }
 

+ 1 - 1
pkg/models/dashboards.go

@@ -112,7 +112,7 @@ func NewDashboard(title string) *Dashboard {
 func NewDashboardFolder(title string) *Dashboard {
 	folder := NewDashboard(title)
 	folder.IsFolder = true
-	folder.Data.Set("schemaVersion", 16)
+	folder.Data.Set("schemaVersion", 17)
 	folder.Data.Set("version", 0)
 	folder.IsFolder = true
 	return folder

+ 1 - 1
pkg/services/alerting/extractor.go

@@ -112,7 +112,7 @@ func (e *DashAlertExtractor) getAlertFromPanels(jsonWithPanels *simplejson.Json,
 
 		frequency, err := getTimeDurationStringToSeconds(jsonAlert.Get("frequency").MustString())
 		if err != nil {
-			return nil, ValidationError{Reason: "Could not parse frequency"}
+			return nil, ValidationError{Reason: err.Error()}
 		}
 
 		rawFor := jsonAlert.Get("for").MustString()

+ 18 - 3
pkg/services/alerting/rule.go

@@ -1,16 +1,21 @@
 package alerting
 
 import (
+	"errors"
 	"fmt"
 	"regexp"
 	"strconv"
 	"time"
 
 	"github.com/grafana/grafana/pkg/components/simplejson"
-
 	m "github.com/grafana/grafana/pkg/models"
 )
 
+var (
+	ErrFrequencyCannotBeZeroOrLess = errors.New(`"evaluate every" cannot be zero or below`)
+	ErrFrequencyCouldNotBeParsed   = errors.New(`"evaluate every" field could not be parsed`)
+)
+
 type Rule struct {
 	Id                  int64
 	OrgId               int64
@@ -76,7 +81,7 @@ func getTimeDurationStringToSeconds(str string) (int64, error) {
 	matches := ValueFormatRegex.FindAllString(str, 1)
 
 	if len(matches) <= 0 {
-		return 0, fmt.Errorf("Frequency could not be parsed")
+		return 0, ErrFrequencyCouldNotBeParsed
 	}
 
 	value, err := strconv.Atoi(matches[0])
@@ -84,6 +89,10 @@ func getTimeDurationStringToSeconds(str string) (int64, error) {
 		return 0, err
 	}
 
+	if value == 0 {
+		return 0, ErrFrequencyCannotBeZeroOrLess
+	}
+
 	unit := UnitFormatRegex.FindAllString(str, 1)[0]
 
 	if val, ok := unitMultiplier[unit]; ok {
@@ -101,7 +110,6 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
 	model.PanelId = ruleDef.PanelId
 	model.Name = ruleDef.Name
 	model.Message = ruleDef.Message
-	model.Frequency = ruleDef.Frequency
 	model.State = ruleDef.State
 	model.LastStateChange = ruleDef.NewStateDate
 	model.For = ruleDef.For
@@ -109,6 +117,13 @@ func NewRuleFromDBAlert(ruleDef *m.Alert) (*Rule, error) {
 	model.ExecutionErrorState = m.ExecutionErrorOption(ruleDef.Settings.Get("executionErrorState").MustString("alerting"))
 	model.StateChanges = ruleDef.StateChanges
 
+	model.Frequency = ruleDef.Frequency
+	// frequency cannot be zero since that would not execute the alert rule.
+	// so we fallback to 60 seconds if `Freqency` is missing
+	if model.Frequency == 0 {
+		model.Frequency = 60
+	}
+
 	for _, v := range ruleDef.Settings.Get("notifications").MustArray() {
 		jsonModel := simplejson.NewFromAny(v)
 		id, err := jsonModel.Get("id").Int64()

+ 60 - 20
pkg/services/alerting/rule_test.go

@@ -14,6 +14,36 @@ func (f *FakeCondition) Eval(context *EvalContext) (*ConditionResult, error) {
 	return &ConditionResult{}, nil
 }
 
+func TestAlertRuleFrequencyParsing(t *testing.T) {
+	tcs := []struct {
+		input  string
+		err    error
+		result int64
+	}{
+		{input: "10s", result: 10},
+		{input: "10m", result: 600},
+		{input: "1h", result: 3600},
+		{input: "1o", result: 1},
+		{input: "0s", err: ErrFrequencyCannotBeZeroOrLess},
+		{input: "0m", err: ErrFrequencyCannotBeZeroOrLess},
+		{input: "0h", err: ErrFrequencyCannotBeZeroOrLess},
+		{input: "0", err: ErrFrequencyCannotBeZeroOrLess},
+		{input: "-1s", err: ErrFrequencyCouldNotBeParsed},
+	}
+
+	for _, tc := range tcs {
+		r, err := getTimeDurationStringToSeconds(tc.input)
+		if err != tc.err {
+			t.Errorf("expected error: '%v' got: '%v'", tc.err, err)
+			return
+		}
+
+		if r != tc.result {
+			t.Errorf("expected result: %d got %d", tc.result, r)
+		}
+	}
+}
+
 func TestAlertRuleModel(t *testing.T) {
 	Convey("Testing alert rule", t, func() {
 
@@ -21,26 +51,6 @@ func TestAlertRuleModel(t *testing.T) {
 			return &FakeCondition{}, nil
 		})
 
-		Convey("Can parse seconds", func() {
-			seconds, _ := getTimeDurationStringToSeconds("10s")
-			So(seconds, ShouldEqual, 10)
-		})
-
-		Convey("Can parse minutes", func() {
-			seconds, _ := getTimeDurationStringToSeconds("10m")
-			So(seconds, ShouldEqual, 600)
-		})
-
-		Convey("Can parse hours", func() {
-			seconds, _ := getTimeDurationStringToSeconds("1h")
-			So(seconds, ShouldEqual, 3600)
-		})
-
-		Convey("defaults to seconds", func() {
-			seconds, _ := getTimeDurationStringToSeconds("1o")
-			So(seconds, ShouldEqual, 1)
-		})
-
 		Convey("should return err for empty string", func() {
 			_, err := getTimeDurationStringToSeconds("")
 			So(err, ShouldNotBeNil)
@@ -89,5 +99,35 @@ func TestAlertRuleModel(t *testing.T) {
 				So(len(alertRule.Notifications), ShouldEqual, 2)
 			})
 		})
+
+		Convey("can construct alert rule model with invalid frequency", func() {
+			json := `
+			{
+				"name": "name2",
+				"description": "desc2",
+				"noDataMode": "critical",
+				"enabled": true,
+				"frequency": "0s",
+        		"conditions": [ { "type": "test", "prop": 123 } ],
+        		"notifications": []
+			}`
+
+			alertJSON, jsonErr := simplejson.NewJson([]byte(json))
+			So(jsonErr, ShouldBeNil)
+
+			alert := &m.Alert{
+				Id:          1,
+				OrgId:       1,
+				DashboardId: 1,
+				PanelId:     1,
+				Frequency:   0,
+
+				Settings: alertJSON,
+			}
+
+			alertRule, err := NewRuleFromDBAlert(alert)
+			So(err, ShouldBeNil)
+			So(alertRule.Frequency, ShouldEqual, 60)
+		})
 	})
 }

+ 4 - 0
pkg/services/notifications/webhook.go

@@ -3,6 +3,7 @@ package notifications
 import (
 	"bytes"
 	"context"
+	"crypto/tls"
 	"fmt"
 	"io"
 	"io/ioutil"
@@ -26,6 +27,9 @@ type Webhook struct {
 }
 
 var netTransport = &http.Transport{
+	TLSClientConfig: &tls.Config{
+		Renegotiation: tls.RenegotiateFreelyAsClient,
+	},
 	Proxy: http.ProxyFromEnvironment,
 	Dial: (&net.Dialer{
 		Timeout:   30 * time.Second,

+ 10 - 0
public/app/core/angular_wrappers.ts

@@ -6,6 +6,7 @@ import { SearchResult } from './components/search/SearchResult';
 import { TagFilter } from './components/TagFilter/TagFilter';
 import { SideMenu } from './components/sidemenu/SideMenu';
 import AppNotificationList from './components/AppNotifications/AppNotificationList';
+import { ColorPicker, SeriesColorPickerPopover } from '@grafana/ui';
 
 export function registerAngularDirectives() {
   react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -19,4 +20,13 @@ export function registerAngularDirectives() {
     ['onChange', { watchDepth: 'reference' }],
     ['tagOptions', { watchDepth: 'reference' }],
   ]);
+  react2AngularDirective('colorPicker', ColorPicker, [
+    'color',
+    ['onChange', { watchDepth: 'reference', wrapApply: true }],
+  ]);
+  react2AngularDirective('seriesColorPickerPopover', SeriesColorPickerPopover, [
+    'series',
+    'onColorChange',
+    'onToggleAxis',
+  ]);
 }

+ 5 - 3
public/app/core/components/Label/Label.tsx

@@ -1,5 +1,5 @@
 import React, { SFC, ReactNode } from 'react';
-import Tooltip from '../Tooltip/Tooltip';
+import { Tooltip } from '@grafana/ui';
 
 interface Props {
   tooltip?: string;
@@ -14,8 +14,10 @@ export const Label: SFC<Props> = props => {
     <span className={`gf-form-label width-${props.width ? props.width : '10'}`}>
       <span>{props.children}</span>
       {props.tooltip && (
-        <Tooltip className="gf-form-help-icon--right-normal" placement="auto" content={props.tooltip}>
-          <i className="gicon gicon-question gicon--has-hover" />
+        <Tooltip placement="auto" content={props.tooltip}>
+          <div className="gf-form-help-icon--right-normal">
+            <i className="gicon gicon-question gicon--has-hover" />
+          </div>
         </Tooltip>
       )}
     </span>

+ 1 - 1
public/app/core/components/PermissionList/AddPermission.tsx

@@ -1,7 +1,7 @@
 import React, { Component } from 'react';
 import { UserPicker } from 'app/core/components/Select/UserPicker';
 import { TeamPicker, Team } from 'app/core/components/Select/TeamPicker';
-import { Select, SelectOptionItem } from 'app/core/components/Select/Select';
+import { Select, SelectOptionItem } from '@grafana/ui';
 import { User } from 'app/types';
 import {
   dashboardPermissionLevels,

+ 1 - 1
public/app/core/components/PermissionList/DisabledPermissionListItem.tsx

@@ -1,5 +1,5 @@
 import React, { Component } from 'react';
-import Select from 'app/core/components/Select/Select';
+import { Select } from '@grafana/ui';
 import { dashboardPermissionLevels } from 'app/types/acl';
 
 export interface Props {

+ 1 - 1
public/app/core/components/PermissionList/PermissionListItem.tsx

@@ -1,5 +1,5 @@
 import React, { PureComponent } from 'react';
-import { Select } from 'app/core/components/Select/Select';
+import { Select } from '@grafana/ui';
 import { dashboardPermissionLevels, DashboardAcl, PermissionLevel } from 'app/types/acl';
 import { FolderInfo } from 'app/types';
 

+ 0 - 78
public/app/core/components/ScrollBar/ScrollBar.tsx

@@ -1,78 +0,0 @@
-import React from 'react';
-import baron from 'baron';
-
-export interface Props {
-  children: any;
-  className: string;
-}
-
-export default class ScrollBar extends React.Component<Props, any> {
-  private container: any;
-  private scrollbar: baron;
-
-  constructor(props) {
-    super(props);
-  }
-
-  componentDidMount() {
-    this.scrollbar = baron({
-      root: this.container.parentElement,
-      scroller: this.container,
-      bar: '.baron__bar',
-      barOnCls: '_scrollbar',
-      scrollingCls: '_scrolling',
-      track: '.baron__track',
-    });
-  }
-
-  componentDidUpdate() {
-    this.scrollbar.update();
-  }
-
-  componentWillUnmount() {
-    this.scrollbar.dispose();
-  }
-
-  // methods can be invoked by outside
-  setScrollTop(top) {
-    if (this.container) {
-      this.container.scrollTop = top;
-      this.scrollbar.update();
-
-      return true;
-    }
-    return false;
-  }
-
-  setScrollLeft(left) {
-    if (this.container) {
-      this.container.scrollLeft = left;
-      this.scrollbar.update();
-
-      return true;
-    }
-    return false;
-  }
-
-  update() {
-    this.scrollbar.update();
-  }
-
-  handleRef = ref => {
-    this.container = ref;
-  };
-
-  render() {
-    return (
-      <div className="baron baron__root baron__clipper">
-        <div className={this.props.className + ' baron__scroller'} ref={this.handleRef}>
-          {this.props.children}
-        </div>
-
-        <div className="baron__track">
-          <div className="baron__bar" />
-        </div>
-      </div>
-    );
-  }
-}

+ 1 - 1
public/app/core/components/Select/DataSourcePicker.tsx

@@ -3,7 +3,7 @@ import React, { PureComponent } from 'react';
 import _ from 'lodash';
 
 // Components
-import Select from './Select';
+import { Select } from '@grafana/ui';
 
 // Types
 import { DataSourceSelectItem } from 'app/types';

+ 0 - 25
public/app/core/components/Select/ResetStyles.tsx

@@ -1,25 +0,0 @@
-export default {
-  clearIndicator: () => ({}),
-  container: () => ({}),
-  control: () => ({}),
-  dropdownIndicator: () => ({}),
-  group: () => ({}),
-  groupHeading: () => ({}),
-  indicatorsContainer: () => ({}),
-  indicatorSeparator: () => ({}),
-  input: () => ({}),
-  loadingIndicator: () => ({}),
-  loadingMessage: () => ({}),
-  menu: () => ({}),
-  menuList: ({ maxHeight }: { maxHeight: number }) => ({
-    maxHeight,
-  }),
-  multiValue: () => ({}),
-  multiValueLabel: () => ({}),
-  multiValueRemove: () => ({}),
-  noOptionsMessage: () => ({}),
-  option: () => ({}),
-  placeholder: () => ({}),
-  singleValue: () => ({}),
-  valueContainer: () => ({}),
-};

+ 1 - 1
public/app/core/components/Select/TeamPicker.tsx

@@ -1,6 +1,6 @@
 import React, { Component } from 'react';
 import _ from 'lodash';
-import { AsyncSelect } from './Select';
+import { AsyncSelect } from '@grafana/ui';
 import { debounce } from 'lodash';
 import { getBackendSrv } from 'app/core/services/backend_srv';
 

+ 3 - 3
public/app/core/components/Select/UnitPicker.tsx

@@ -1,6 +1,6 @@
 import React, { PureComponent } from 'react';
-import Select from './Select';
-import kbn from 'app/core/utils/kbn';
+import { getValueFormats } from '@grafana/ui';
+import { Select } from '@grafana/ui';
 
 interface Props {
   onChange: (item: any) => void;
@@ -16,7 +16,7 @@ export default class UnitPicker extends PureComponent<Props> {
   render() {
     const { defaultValue, onChange, width } = this.props;
 
-    const unitGroups = kbn.getUnitFormats();
+    const unitGroups = getValueFormats();
 
     // Need to transform the data structure to work well with Select
     const groupOptions = unitGroups.map(group => {

+ 1 - 1
public/app/core/components/Select/UserPicker.tsx

@@ -3,7 +3,7 @@ import React, { Component } from 'react';
 import _ from 'lodash';
 
 // Components
-import { AsyncSelect } from './Select';
+import { AsyncSelect } from '@grafana/ui';
 
 // Utils & Services
 import { debounce } from 'lodash';

+ 1 - 1
public/app/core/components/SharedPreferences/SharedPreferences.tsx

@@ -1,7 +1,7 @@
 import React, { PureComponent } from 'react';
 
 import { Label } from 'app/core/components/Label/Label';
-import Select from 'app/core/components/Select/Select';
+import { Select } from '@grafana/ui';
 import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
 
 import { DashboardSearchHit } from 'app/types';

+ 2 - 4
public/app/core/components/TagFilter/TagFilter.tsx

@@ -1,12 +1,10 @@
 import React from 'react';
+import { NoOptionsMessage, IndicatorsContainer, resetSelectStyles } from '@grafana/ui';
 import AsyncSelect from '@torkelo/react-select/lib/Async';
 
 import { TagOption } from './TagOption';
 import { TagBadge } from './TagBadge';
-import IndicatorsContainer from 'app/core/components/Select/IndicatorsContainer';
-import NoOptionsMessage from 'app/core/components/Select/NoOptionsMessage';
 import { components } from '@torkelo/react-select';
-import ResetStyles from 'app/core/components/Select/ResetStyles';
 
 export interface Props {
   tags: string[];
@@ -51,7 +49,7 @@ export class TagFilter extends React.Component<Props, any> {
       getOptionValue: i => i.value,
       getOptionLabel: i => i.label,
       value: tags,
-      styles: ResetStyles,
+      styles: resetSelectStyles(),
       filterOption: (option, searchQuery) => {
         const regex = RegExp(searchQuery, 'i');
         return regex.test(option.value);

+ 1 - 1
public/app/core/components/ToggleButtonGroup/ToggleButtonGroup.tsx

@@ -1,5 +1,5 @@
 import React, { SFC, ReactNode, PureComponent } from 'react';
-import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import { Tooltip } from '@grafana/ui';
 
 interface ToggleButtonGroupProps {
   label?: string;

+ 0 - 16
public/app/core/components/Tooltip/Popover.test.tsx

@@ -1,16 +0,0 @@
-import React from 'react';
-import renderer from 'react-test-renderer';
-import Popover from './Popover';
-
-describe('Popover', () => {
-  it('renders correctly', () => {
-    const tree = renderer
-      .create(
-        <Popover className="test-class" placement="auto" content="Popover text">
-          <button>Button with Popover</button>
-        </Popover>
-      )
-      .toJSON();
-    expect(tree).toMatchSnapshot();
-  });
-});

+ 0 - 19
public/app/core/components/Tooltip/Popover.tsx

@@ -1,19 +0,0 @@
-import React, { PureComponent } from 'react';
-import Popper from './Popper';
-import withPopper, { UsingPopperProps } from './withPopper';
-
-class Popover extends PureComponent<UsingPopperProps> {
-  render() {
-    const { children, hidePopper, showPopper, className, ...restProps } = this.props;
-
-    const togglePopper = restProps.show ? hidePopper : showPopper;
-
-    return (
-      <div className={`popper__manager ${className}`} onClick={togglePopper}>
-        <Popper {...restProps}>{children}</Popper>
-      </div>
-    );
-  }
-}
-
-export default withPopper(Popover);

+ 0 - 17
public/app/core/components/Tooltip/Tooltip.tsx

@@ -1,17 +0,0 @@
-import React, { PureComponent } from 'react';
-import Popper from './Popper';
-import withPopper, { UsingPopperProps } from './withPopper';
-
-class Tooltip extends PureComponent<UsingPopperProps> {
-  render() {
-    const { children, hidePopper, showPopper, className, ...restProps } = this.props;
-
-    return (
-      <div className={`popper__manager ${className}`} onMouseEnter={showPopper} onMouseLeave={hidePopper}>
-        <Popper {...restProps}>{children}</Popper>
-      </div>
-    );
-  }
-}
-
-export default withPopper(Tooltip);

+ 0 - 16
public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap

@@ -1,16 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Popover renders correctly 1`] = `
-<div
-  className="popper__manager test-class"
-  onClick={[Function]}
->
-  <div
-    className="popper_ref "
-  >
-    <button>
-      Button with Popover
-    </button>
-  </div>
-</div>
-`;

+ 0 - 19
public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap

@@ -1,19 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Tooltip renders correctly 1`] = `
-<div
-  className="popper__manager test-class"
-  onMouseEnter={[Function]}
-  onMouseLeave={[Function]}
->
-  <div
-    className="popper_ref "
-  >
-    <a
-      href="http://www.grafana.com"
-    >
-      Link with tooltip
-    </a>
-  </div>
-</div>
-`;

+ 0 - 89
public/app/core/components/Tooltip/withPopper.tsx

@@ -1,89 +0,0 @@
-import React from 'react';
-import { Themes } from './Popper';
-export interface UsingPopperProps {
-  showPopper: (prevState: object) => void;
-  hidePopper: (prevState: object) => void;
-  renderContent: (content: any) => any;
-  show: boolean;
-  placement?: string;
-  content: string | ((props: any) => JSX.Element);
-  className?: string;
-  refClassName?: string;
-  theme?: Themes;
-}
-
-interface Props {
-  placement?: string;
-  className?: string;
-  refClassName?: string;
-  content: string | ((props: any) => JSX.Element);
-  theme?: Themes;
-}
-
-interface State {
-  placement: string;
-  show: boolean;
-}
-
-export default function withPopper(WrappedComponent) {
-  return class extends React.Component<Props, State> {
-    constructor(props) {
-      super(props);
-      this.setState = this.setState.bind(this);
-      this.state = {
-        placement: this.props.placement || 'auto',
-        show: false,
-      };
-    }
-
-    componentWillReceiveProps(nextProps) {
-      if (nextProps.placement && nextProps.placement !== this.state.placement) {
-        this.setState(prevState => {
-          return {
-            ...prevState,
-            placement: nextProps.placement,
-          };
-        });
-      }
-    }
-
-    showPopper = () => {
-      this.setState(prevState => ({
-        ...prevState,
-        show: true,
-      }));
-    };
-
-    hidePopper = () => {
-      this.setState(prevState => ({
-        ...prevState,
-        show: false,
-      }));
-    };
-
-    renderContent(content) {
-      if (typeof content === 'function') {
-        // If it's a function we assume it's a React component
-        const ReactComponent = content;
-        return <ReactComponent />;
-      }
-      return content;
-    }
-
-    render() {
-      const { show, placement } = this.state;
-      const className = this.props.className || '';
-      return (
-        <WrappedComponent
-          {...this.props}
-          showPopper={this.showPopper}
-          hidePopper={this.hidePopper}
-          renderContent={this.renderContent}
-          show={show}
-          placement={placement}
-          className={className}
-        />
-      );
-    }
-  };
-}

+ 1 - 3
public/app/core/core.ts

@@ -13,11 +13,10 @@ import './partials';
 import './components/jsontree/jsontree';
 import './components/code_editor/code_editor';
 import './utils/outline';
-import './components/colorpicker/ColorPicker';
-import './components/colorpicker/SeriesColorPickerPopover';
 import './components/colorpicker/spectrum_picker';
 import './services/search_srv';
 import './services/ng_react';
+import { colors } from '@grafana/ui/';
 
 import { searchDirective } from './components/search/search';
 import { infoPopover } from './components/info_popover';
@@ -36,7 +35,6 @@ import 'app/core/services/all';
 import './filters/filters';
 import coreModule from './core_module';
 import appEvents from './app_events';
-import colors from './utils/colors';
 import { assignModelProperties } from './utils/model_utils';
 import { contextSrv } from './services/context_srv';
 import { KeybindingSrv } from './services/keybindingSrv';

+ 3 - 1
public/app/core/logs_model.ts

@@ -1,6 +1,8 @@
 import _ from 'lodash';
+import { colors } from '@grafana/ui';
+
 import { TimeSeries } from 'app/core/core';
-import colors, { getThemeColor } from 'app/core/utils/colors';
+import { getThemeColor } from 'app/core/utils/colors';
 
 /**
  * Mapping of log level abbreviation to canonical log level.

+ 8 - 0
public/app/core/specs/factors.test.ts

@@ -0,0 +1,8 @@
+import getFactors from 'app/core/utils/factors';
+
+describe('factors', () => {
+  it('should return factors for 12', () => {
+    const factors = getFactors(12);
+    expect(factors).toEqual([1, 2, 3, 4, 6, 12]);
+  });
+});

+ 0 - 493
public/app/core/specs/kbn.test.ts

@@ -1,493 +0,0 @@
-import kbn from '../utils/kbn';
-import * as dateMath from '../utils/datemath';
-import moment from 'moment';
-
-describe('unit format menu', () => {
-  const menu = kbn.getUnitFormats();
-  menu.map(submenu => {
-    describe('submenu ' + submenu.text, () => {
-      it('should have a title', () => {
-        expect(typeof submenu.text).toBe('string');
-      });
-
-      it('should have a submenu', () => {
-        expect(Array.isArray(submenu.submenu)).toBe(true);
-      });
-
-      submenu.submenu.map(entry => {
-        describe('entry ' + entry.text, () => {
-          it('should have a title', () => {
-            expect(typeof entry.text).toBe('string');
-          });
-          it('should have a format', () => {
-            expect(typeof entry.value).toBe('string');
-          });
-          it('should have a valid format', () => {
-            expect(typeof kbn.valueFormats[entry.value]).toBe('function');
-          });
-        });
-      });
-    });
-  });
-});
-
-function describeValueFormat(desc, value, tickSize, tickDecimals, result) {
-  describe('value format: ' + desc, () => {
-    it('should translate ' + value + ' as ' + result, () => {
-      const scaledDecimals = tickDecimals - Math.floor(Math.log(tickSize) / Math.LN10);
-      const str = kbn.valueFormats[desc](value, tickDecimals, scaledDecimals);
-      expect(str).toBe(result);
-    });
-  });
-}
-
-describeValueFormat('ms', 0.0024, 0.0005, 4, '0.0024 ms');
-describeValueFormat('ms', 100, 1, 0, '100 ms');
-describeValueFormat('ms', 1250, 10, 0, '1.25 s');
-describeValueFormat('ms', 1250, 300, 0, '1.3 s');
-describeValueFormat('ms', 65150, 10000, 0, '1.1 min');
-describeValueFormat('ms', 6515000, 1500000, 0, '1.8 hour');
-describeValueFormat('ms', 651500000, 150000000, 0, '8 day');
-
-describeValueFormat('none', 2.75e-10, 0, 10, '3e-10');
-describeValueFormat('none', 0, 0, 2, '0');
-describeValueFormat('dB', 10, 1000, 2, '10.00 dB');
-
-describeValueFormat('percent', 0, 0, 0, '0%');
-describeValueFormat('percent', 53, 0, 1, '53.0%');
-describeValueFormat('percentunit', 0.0, 0, 0, '0%');
-describeValueFormat('percentunit', 0.278, 0, 1, '27.8%');
-describeValueFormat('percentunit', 1.0, 0, 0, '100%');
-
-describeValueFormat('currencyUSD', 7.42, 10000, 2, '$7.42');
-describeValueFormat('currencyUSD', 1532.82, 1000, 1, '$1.53K');
-describeValueFormat('currencyUSD', 18520408.7, 10000000, 0, '$19M');
-
-describeValueFormat('bytes', -1.57e308, -1.57e308, 2, 'NA');
-
-describeValueFormat('ns', 25, 1, 0, '25 ns');
-describeValueFormat('ns', 2558, 50, 0, '2.56 µs');
-
-describeValueFormat('ops', 123, 1, 0, '123 ops');
-describeValueFormat('rps', 456000, 1000, -1, '456K rps');
-describeValueFormat('rps', 123456789, 1000000, 2, '123.457M rps');
-describeValueFormat('wps', 789000000, 1000000, -1, '789M wps');
-describeValueFormat('iops', 11000000000, 1000000000, -1, '11B iops');
-
-describeValueFormat('s', 1.23456789e-7, 1e-10, 8, '123.5 ns');
-describeValueFormat('s', 1.23456789e-4, 1e-7, 5, '123.5 µs');
-describeValueFormat('s', 1.23456789e-3, 1e-6, 4, '1.235 ms');
-describeValueFormat('s', 1.23456789e-2, 1e-5, 3, '12.35 ms');
-describeValueFormat('s', 1.23456789e-1, 1e-4, 2, '123.5 ms');
-describeValueFormat('s', 24, 1, 0, '24 s');
-describeValueFormat('s', 246, 1, 0, '4.1 min');
-describeValueFormat('s', 24567, 100, 0, '6.82 hour');
-describeValueFormat('s', 24567890, 10000, 0, '40.62 week');
-describeValueFormat('s', 24567890000, 1000000, 0, '778.53 year');
-
-describeValueFormat('m', 24, 1, 0, '24 min');
-describeValueFormat('m', 246, 10, 0, '4.1 hour');
-describeValueFormat('m', 6545, 10, 0, '4.55 day');
-describeValueFormat('m', 24567, 100, 0, '2.44 week');
-describeValueFormat('m', 24567892, 10000, 0, '46.7 year');
-
-describeValueFormat('h', 21, 1, 0, '21 hour');
-describeValueFormat('h', 145, 1, 0, '6.04 day');
-describeValueFormat('h', 1234, 100, 0, '7.3 week');
-describeValueFormat('h', 9458, 1000, 0, '1.08 year');
-
-describeValueFormat('d', 3, 1, 0, '3 day');
-describeValueFormat('d', 245, 100, 0, '35 week');
-describeValueFormat('d', 2456, 10, 0, '6.73 year');
-
-describe('date time formats', () => {
-  const epoch = 1505634997920;
-  const utcTime = moment.utc(epoch);
-  const browserTime = moment(epoch);
-
-  it('should format as iso date', () => {
-    const expected = browserTime.format('YYYY-MM-DD HH:mm:ss');
-    const actual = kbn.valueFormats.dateTimeAsIso(epoch);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as iso date (in UTC)', () => {
-    const expected = utcTime.format('YYYY-MM-DD HH:mm:ss');
-    const actual = kbn.valueFormats.dateTimeAsIso(epoch, true);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as iso date and skip date when today', () => {
-    const now = moment();
-    const expected = now.format('HH:mm:ss');
-    const actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), false);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as iso date (in UTC) and skip date when today', () => {
-    const now = moment.utc();
-    const expected = now.format('HH:mm:ss');
-    const actual = kbn.valueFormats.dateTimeAsIso(now.valueOf(), true);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as US date', () => {
-    const expected = browserTime.format('MM/DD/YYYY h:mm:ss a');
-    const actual = kbn.valueFormats.dateTimeAsUS(epoch, false);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as US date (in UTC)', () => {
-    const expected = utcTime.format('MM/DD/YYYY h:mm:ss a');
-    const actual = kbn.valueFormats.dateTimeAsUS(epoch, true);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as US date and skip date when today', () => {
-    const now = moment();
-    const expected = now.format('h:mm:ss a');
-    const actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), false);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as US date (in UTC) and skip date when today', () => {
-    const now = moment.utc();
-    const expected = now.format('h:mm:ss a');
-    const actual = kbn.valueFormats.dateTimeAsUS(now.valueOf(), true);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as from now with days', () => {
-    const daysAgo = moment().add(-7, 'd');
-    const expected = '7 days ago';
-    const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as from now with days (in UTC)', () => {
-    const daysAgo = moment.utc().add(-7, 'd');
-    const expected = '7 days ago';
-    const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as from now with minutes', () => {
-    const daysAgo = moment().add(-2, 'm');
-    const expected = '2 minutes ago';
-    const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), false);
-    expect(actual).toBe(expected);
-  });
-
-  it('should format as from now with minutes (in UTC)', () => {
-    const daysAgo = moment.utc().add(-2, 'm');
-    const expected = '2 minutes ago';
-    const actual = kbn.valueFormats.dateTimeFromNow(daysAgo.valueOf(), true);
-    expect(actual).toBe(expected);
-  });
-});
-
-describe('kbn.toFixed and negative decimals', () => {
-  it('should treat as zero decimals', () => {
-    const str = kbn.toFixed(186.123, -2);
-    expect(str).toBe('186');
-  });
-});
-
-describe('kbn ms format when scaled decimals is null do not use it', () => {
-  it('should use specified decimals', () => {
-    const str = kbn.valueFormats['ms'](10000086.123, 1, null);
-    expect(str).toBe('2.8 hour');
-  });
-});
-
-describe('kbn kbytes format when scaled decimals is null do not use it', () => {
-  it('should use specified decimals', () => {
-    const str = kbn.valueFormats['kbytes'](10000000, 3, null);
-    expect(str).toBe('9.537 GiB');
-  });
-});
-
-describe('kbn deckbytes format when scaled decimals is null do not use it', () => {
-  it('should use specified decimals', () => {
-    const str = kbn.valueFormats['deckbytes'](10000000, 3, null);
-    expect(str).toBe('10.000 GB');
-  });
-});
-
-describe('kbn roundValue', () => {
-  it('should should handle null value', () => {
-    const str = kbn.roundValue(null, 2);
-    expect(str).toBe(null);
-  });
-  it('should round value', () => {
-    const str = kbn.roundValue(200.877, 2);
-    expect(str).toBe(200.88);
-  });
-});
-
-describe('calculateInterval', () => {
-  it('1h 100 resultion', () => {
-    const range = { from: dateMath.parse('now-1h'), to: dateMath.parse('now') };
-    const res = kbn.calculateInterval(range, 100, null);
-    expect(res.interval).toBe('30s');
-  });
-
-  it('10m 1600 resolution', () => {
-    const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
-    const res = kbn.calculateInterval(range, 1600, null);
-    expect(res.interval).toBe('500ms');
-    expect(res.intervalMs).toBe(500);
-  });
-
-  it('fixed user min interval', () => {
-    const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
-    const res = kbn.calculateInterval(range, 1600, '10s');
-    expect(res.interval).toBe('10s');
-    expect(res.intervalMs).toBe(10000);
-  });
-
-  it('short time range and user low limit', () => {
-    const range = { from: dateMath.parse('now-10m'), to: dateMath.parse('now') };
-    const res = kbn.calculateInterval(range, 1600, '>10s');
-    expect(res.interval).toBe('10s');
-  });
-
-  it('large time range and user low limit', () => {
-    const range = { from: dateMath.parse('now-14d'), to: dateMath.parse('now') };
-    const res = kbn.calculateInterval(range, 1000, '>10s');
-    expect(res.interval).toBe('20m');
-  });
-
-  it('10s 900 resolution and user low limit in ms', () => {
-    const range = { from: dateMath.parse('now-10s'), to: dateMath.parse('now') };
-    const res = kbn.calculateInterval(range, 900, '>15ms');
-    expect(res.interval).toBe('15ms');
-  });
-
-  it('1d 1 resolution', () => {
-    const range = { from: dateMath.parse('now-1d'), to: dateMath.parse('now') };
-    const res = kbn.calculateInterval(range, 1, null);
-    expect(res.interval).toBe('1d');
-    expect(res.intervalMs).toBe(86400000);
-  });
-
-  it('86399s 1 resolution', () => {
-    const range = {
-      from: dateMath.parse('now-86390s'),
-      to: dateMath.parse('now'),
-    };
-    const res = kbn.calculateInterval(range, 1, null);
-    expect(res.interval).toBe('12h');
-    expect(res.intervalMs).toBe(43200000);
-  });
-});
-
-describe('hex', () => {
-  it('positive integer', () => {
-    const str = kbn.valueFormats.hex(100, 0);
-    expect(str).toBe('64');
-  });
-  it('negative integer', () => {
-    const str = kbn.valueFormats.hex(-100, 0);
-    expect(str).toBe('-64');
-  });
-  it('null', () => {
-    const str = kbn.valueFormats.hex(null, 0);
-    expect(str).toBe('');
-  });
-  it('positive float', () => {
-    const str = kbn.valueFormats.hex(50.52, 1);
-    expect(str).toBe('32.8');
-  });
-  it('negative float', () => {
-    const str = kbn.valueFormats.hex(-50.333, 2);
-    expect(str).toBe('-32.547AE147AE14');
-  });
-});
-
-describe('hex 0x', () => {
-  it('positive integeter', () => {
-    const str = kbn.valueFormats.hex0x(7999, 0);
-    expect(str).toBe('0x1F3F');
-  });
-  it('negative integer', () => {
-    const str = kbn.valueFormats.hex0x(-584, 0);
-    expect(str).toBe('-0x248');
-  });
-  it('null', () => {
-    const str = kbn.valueFormats.hex0x(null, 0);
-    expect(str).toBe('');
-  });
-  it('positive float', () => {
-    const str = kbn.valueFormats.hex0x(74.443, 3);
-    expect(str).toBe('0x4A.716872B020C4');
-  });
-  it('negative float', () => {
-    const str = kbn.valueFormats.hex0x(-65.458, 1);
-    expect(str).toBe('-0x41.8');
-  });
-});
-
-describe('duration', () => {
-  it('null', () => {
-    const str = kbn.toDuration(null, 0, 'millisecond');
-    expect(str).toBe('');
-  });
-  it('0 milliseconds', () => {
-    const str = kbn.toDuration(0, 0, 'millisecond');
-    expect(str).toBe('0 milliseconds');
-  });
-  it('1 millisecond', () => {
-    const str = kbn.toDuration(1, 0, 'millisecond');
-    expect(str).toBe('1 millisecond');
-  });
-  it('-1 millisecond', () => {
-    const str = kbn.toDuration(-1, 0, 'millisecond');
-    expect(str).toBe('1 millisecond ago');
-  });
-  it('seconds', () => {
-    const str = kbn.toDuration(1, 0, 'second');
-    expect(str).toBe('1 second');
-  });
-  it('minutes', () => {
-    const str = kbn.toDuration(1, 0, 'minute');
-    expect(str).toBe('1 minute');
-  });
-  it('hours', () => {
-    const str = kbn.toDuration(1, 0, 'hour');
-    expect(str).toBe('1 hour');
-  });
-  it('days', () => {
-    const str = kbn.toDuration(1, 0, 'day');
-    expect(str).toBe('1 day');
-  });
-  it('weeks', () => {
-    const str = kbn.toDuration(1, 0, 'week');
-    expect(str).toBe('1 week');
-  });
-  it('months', () => {
-    const str = kbn.toDuration(1, 0, 'month');
-    expect(str).toBe('1 month');
-  });
-  it('years', () => {
-    const str = kbn.toDuration(1, 0, 'year');
-    expect(str).toBe('1 year');
-  });
-  it('decimal days', () => {
-    const str = kbn.toDuration(1.5, 2, 'day');
-    expect(str).toBe('1 day, 12 hours, 0 minutes');
-  });
-  it('decimal months', () => {
-    const str = kbn.toDuration(1.5, 3, 'month');
-    expect(str).toBe('1 month, 2 weeks, 1 day, 0 hours');
-  });
-  it('no decimals', () => {
-    const str = kbn.toDuration(38898367008, 0, 'millisecond');
-    expect(str).toBe('1 year');
-  });
-  it('1 decimal', () => {
-    const str = kbn.toDuration(38898367008, 1, 'millisecond');
-    expect(str).toBe('1 year, 2 months');
-  });
-  it('too many decimals', () => {
-    const str = kbn.toDuration(38898367008, 20, 'millisecond');
-    expect(str).toBe('1 year, 2 months, 3 weeks, 4 days, 5 hours, 6 minutes, 7 seconds, 8 milliseconds');
-  });
-  it('floating point error', () => {
-    const str = kbn.toDuration(36993906007, 8, 'millisecond');
-    expect(str).toBe('1 year, 2 months, 0 weeks, 3 days, 4 hours, 5 minutes, 6 seconds, 7 milliseconds');
-  });
-});
-
-describe('clock', () => {
-  it('null', () => {
-    const str = kbn.toClock(null, 0);
-    expect(str).toBe('');
-  });
-  it('size less than 1 second', () => {
-    const str = kbn.toClock(999, 0);
-    expect(str).toBe('999ms');
-  });
-  describe('size less than 1 minute', () => {
-    it('default', () => {
-      const str = kbn.toClock(59999);
-      expect(str).toBe('59s:999ms');
-    });
-    it('decimals equals 0', () => {
-      const str = kbn.toClock(59999, 0);
-      expect(str).toBe('59s');
-    });
-  });
-  describe('size less than 1 hour', () => {
-    it('default', () => {
-      const str = kbn.toClock(3599999);
-      expect(str).toBe('59m:59s:999ms');
-    });
-    it('decimals equals 0', () => {
-      const str = kbn.toClock(3599999, 0);
-      expect(str).toBe('59m');
-    });
-    it('decimals equals 1', () => {
-      const str = kbn.toClock(3599999, 1);
-      expect(str).toBe('59m:59s');
-    });
-  });
-  describe('size greater than or equal 1 hour', () => {
-    it('default', () => {
-      const str = kbn.toClock(7199999);
-      expect(str).toBe('01h:59m:59s:999ms');
-    });
-    it('decimals equals 0', () => {
-      const str = kbn.toClock(7199999, 0);
-      expect(str).toBe('01h');
-    });
-    it('decimals equals 1', () => {
-      const str = kbn.toClock(7199999, 1);
-      expect(str).toBe('01h:59m');
-    });
-    it('decimals equals 2', () => {
-      const str = kbn.toClock(7199999, 2);
-      expect(str).toBe('01h:59m:59s');
-    });
-  });
-  describe('size greater than or equal 1 day', () => {
-    it('default', () => {
-      const str = kbn.toClock(89999999);
-      expect(str).toBe('24h:59m:59s:999ms');
-    });
-    it('decimals equals 0', () => {
-      const str = kbn.toClock(89999999, 0);
-      expect(str).toBe('24h');
-    });
-    it('decimals equals 1', () => {
-      const str = kbn.toClock(89999999, 1);
-      expect(str).toBe('24h:59m');
-    });
-    it('decimals equals 2', () => {
-      const str = kbn.toClock(89999999, 2);
-      expect(str).toBe('24h:59m:59s');
-    });
-  });
-});
-
-describe('volume', () => {
-  it('1000m3', () => {
-    const str = kbn.valueFormats['m3'](1000, 1, null);
-    expect(str).toBe('1000.0 m³');
-  });
-});
-
-describe('hh:mm:ss', () => {
-  it('00:04:06', () => {
-    const str = kbn.valueFormats['dthms'](246, 1);
-    expect(str).toBe('00:04:06');
-  });
-  it('24:00:00', () => {
-    const str = kbn.valueFormats['dthms'](86400, 1);
-    expect(str).toBe('24:00:00');
-  });
-  it('6824413:53:20', () => {
-    const str = kbn.valueFormats['dthms'](24567890000, 1);
-    expect(str).toBe('6824413:53:20');
-  });
-});

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