Pārlūkot izejas kodu

Merge remote-tracking branch 'origin/develop' into unit-picker

Peter Holmberg 7 gadi atpakaļ
vecāks
revīzija
cc7bf31c3e
100 mainītis faili ar 2686 papildinājumiem un 1470 dzēšanām
  1. 1 0
      .gitignore
  2. 11 4
      CHANGELOG.md
  3. 65 1
      docs/sources/http_api/team.md
  4. 8 6
      package.json
  5. 3 1
      pkg/api/api.go
  6. 1 1
      pkg/api/dashboard.go
  7. 4 4
      pkg/api/dtos/plugins.go
  8. 1 1
      pkg/api/frontendsettings.go
  9. 1 1
      pkg/api/index.go
  10. 5 1
      pkg/api/plugins.go
  11. 8 7
      pkg/api/preferences.go
  12. 10 0
      pkg/api/team.go
  13. 4 3
      pkg/models/preferences.go
  14. 8 1
      pkg/plugins/models.go
  15. 9 0
      pkg/services/sqlstore/migrations/preferences_mig.go
  16. 19 7
      pkg/services/sqlstore/preferences.go
  17. 91 0
      pkg/services/sqlstore/preferences_test.go
  18. 0 28
      public/app/core/actions/user.ts
  19. 37 0
      public/app/core/components/Animations/FadeIn.tsx
  20. 1 1
      public/app/core/components/Animations/SlideDown.tsx
  21. 36 0
      public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx
  22. 4 1
      public/app/core/components/Picker/SimplePicker.tsx
  23. 141 0
      public/app/core/components/SharedPreferences/SharedPreferences.tsx
  24. 1 0
      public/app/core/components/Switch/Switch.tsx
  25. 11 26
      public/app/core/components/Tooltip/Popover.tsx
  26. 69 0
      public/app/core/components/Tooltip/Popper.tsx
  27. 9 28
      public/app/core/components/Tooltip/Tooltip.tsx
  28. 2 2
      public/app/core/components/Tooltip/__snapshots__/Popover.test.tsx.snap
  29. 3 3
      public/app/core/components/Tooltip/__snapshots__/Tooltip.test.tsx.snap
  30. 89 0
      public/app/core/components/Tooltip/withPopper.tsx
  31. 0 58
      public/app/core/components/Tooltip/withTooltip.tsx
  32. 3 0
      public/app/core/constants.ts
  33. 6 3
      public/app/core/directives/dash_class.ts
  34. 0 2
      public/app/core/reducers/index.ts
  35. 1 0
      public/app/core/reducers/location.ts
  36. 0 15
      public/app/core/reducers/user.ts
  37. 4 0
      public/app/core/services/AngularLoader.ts
  38. 1 1
      public/app/core/services/bridge_srv.ts
  39. 1 1
      public/app/core/utils/connectWithReduxStore.tsx
  40. 8 1
      public/app/features/dashboard/dashgrid/DashboardGrid.tsx
  41. 51 36
      public/app/features/dashboard/dashgrid/DashboardPanel.tsx
  42. 44 15
      public/app/features/dashboard/dashgrid/DataPanel.tsx
  43. 88 0
      public/app/features/dashboard/dashgrid/DataSourcePicker.tsx
  44. 96 0
      public/app/features/dashboard/dashgrid/EditorTabBody.tsx
  45. 60 35
      public/app/features/dashboard/dashgrid/PanelChrome.tsx
  46. 31 49
      public/app/features/dashboard/dashgrid/PanelEditor.tsx
  47. 52 25
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx
  48. 89 0
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx
  49. 2 1
      public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx
  50. 64 0
      public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx
  51. 24 4
      public/app/features/dashboard/dashgrid/QueriesTab.tsx
  52. 57 0
      public/app/features/dashboard/dashgrid/VisualizationTab.tsx
  53. 42 19
      public/app/features/dashboard/dashgrid/VizTypePicker.tsx
  54. 10 0
      public/app/features/dashboard/panel_model.ts
  55. 10 76
      public/app/features/dashboard/specs/AddPanelPanel.test.tsx
  56. 1 2
      public/app/features/dashboard/time_srv.ts
  57. 1 1
      public/app/features/dashboard/utils/getPanelMenu.ts
  58. 83 2
      public/app/features/dashboard/utils/panel.ts
  59. 0 125
      public/app/features/datasources/DataSourceSettings.tsx
  60. 3 0
      public/app/features/datasources/__mocks__/dataSourcesMocks.ts
  61. 20 0
      public/app/features/datasources/settings/BasicSettings.test.tsx
  62. 34 0
      public/app/features/datasources/settings/BasicSettings.tsx
  63. 31 0
      public/app/features/datasources/settings/ButtonRow.test.tsx
  64. 25 0
      public/app/features/datasources/settings/ButtonRow.tsx
  65. 63 0
      public/app/features/datasources/settings/DataSourceSettings.test.tsx
  66. 245 0
      public/app/features/datasources/settings/DataSourceSettings.tsx
  67. 63 0
      public/app/features/datasources/settings/PluginSettings.tsx
  68. 25 0
      public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap
  69. 59 0
      public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap
  70. 395 0
      public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap
  71. 54 13
      public/app/features/datasources/state/actions.ts
  72. 4 1
      public/app/features/datasources/state/navModel.ts
  73. 4 1
      public/app/features/datasources/state/reducers.ts
  74. 9 1
      public/app/features/datasources/state/selectors.ts
  75. 12 0
      public/app/features/explore/Explore.tsx
  76. 1 4
      public/app/features/org/OrgDetailsPage.test.tsx
  77. 8 22
      public/app/features/org/OrgDetailsPage.tsx
  78. 0 28
      public/app/features/org/OrgPreferences.test.tsx
  79. 0 113
      public/app/features/org/OrgPreferences.tsx
  80. 3 1
      public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap
  81. 0 136
      public/app/features/org/__snapshots__/OrgPreferences.test.tsx.snap
  82. 3 70
      public/app/features/org/state/actions.ts
  83. 1 14
      public/app/features/org/state/reducers.ts
  84. 7 73
      public/app/features/panel/metrics_panel_ctrl.ts
  85. 36 5
      public/app/features/panel/metrics_tab.ts
  86. 6 8
      public/app/features/panel/panel_ctrl.ts
  87. 2 15
      public/app/features/panel/panel_directive.ts
  88. 1 1
      public/app/features/panel/partials/metrics_tab.html
  89. 30 3
      public/app/features/plugins/__mocks__/pluginMocks.ts
  90. 36 6
      public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap
  91. 0 1
      public/app/features/plugins/all.ts
  92. 3 6
      public/app/features/plugins/datasource_srv.ts
  93. 1 1
      public/app/features/plugins/ds_dashboards_ctrl.ts
  94. 1 1
      public/app/features/plugins/ds_edit_ctrl.ts
  95. 0 72
      public/app/features/plugins/partials/ds_edit.html
  96. 10 4
      public/app/features/plugins/plugin_component.ts
  97. 0 179
      public/app/features/plugins/plugin_edit_ctrl.ts
  98. 3 91
      public/app/features/profile/PrefControlCtrl.ts
  99. 1 1
      public/app/features/profile/partials/profile.html
  100. 6 1
      public/app/features/teams/TeamPages.test.tsx

+ 1 - 0
.gitignore

@@ -76,3 +76,4 @@ debug.test
 /devenv/bulk_alerting_dashboards/*.json
 
 /scripts/build/release_publisher/release_publisher
+*.patch

+ 11 - 4
CHANGELOG.md

@@ -8,24 +8,31 @@
 * **MySQL**: Support connecting thru Unix socket for MySQL datasource [#12342](https://github.com/grafana/grafana/issues/12342), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
 * **MSSQL**: Add encrypt setting to allow configuration of how data sent between client and server are encrypted [#13629](https://github.com/grafana/grafana/issues/13629), thx [@ramiro](https://github.com/ramiro)
 * **Stackdriver**: Not possible to authenticate using GCE metadata server [#13669](https://github.com/grafana/grafana/issues/13669)
+* **Teams**: Team preferences (theme, home dashboard, timezone) support [#12550](https://github.com/grafana/grafana/issues/12550)
 
 ### Minor
 
 * **Cloudwatch**: Show all available CloudWatch regions [#12308](https://github.com/grafana/grafana/issues/12308), thx [@mtanda](https://github.com/mtanda)
 * **Cloudwatch**: AWS/Connect metrics and dimensions [#13970](https://github.com/grafana/grafana/pull/13970), thx [@zcoffy](https://github.com/zcoffy)
 * **Postgres**: Add delta window function to postgres query builder [#13925](https://github.com/grafana/grafana/issues/13925), thx [svenklemm](https://github.com/svenklemm)
+* **Elasticsearch**: Fix switching to/from es raw document metric query [#6367](https://github.com/grafana/grafana/issues/6367)
+* **Elasticsearch**: Fix deprecation warning about terms aggregation order key in Elasticsearch 6.x [#11977](https://github.com/grafana/grafana/issues/11977)
+* **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme)
 * **Units**: New clock time format, to format ms or second values as for example `01h:59m`, [#13635](https://github.com/grafana/grafana/issues/13635), thx [@franciscocpg](https://github.com/franciscocpg)
-* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
-* **DingDing**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
-* **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876)
 * **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945)
-* **Table**: Fix CSS alpha background-color applied twice in table cell with link [#13606](https://github.com/grafana/grafana/issues/13606), thx [@grisme](https://github.com/grisme)
 * **Alerting**: More options for the Slack Alert notifier [#13993](https://github.com/grafana/grafana/issues/13993), thx [@andreykaipov](https://github.com/andreykaipov)
+* **Alerting**: Can't receive DingDing alert when alert is triggered [#13723](https://github.com/grafana/grafana/issues/13723), thx [@Yukinoshita-Yukino](https://github.com/Yukinoshita-Yukino)
+* **Internal metrics**: Renamed `grafana_info` to `grafana_build_info` and added branch, goversion and revision [#13876](https://github.com/grafana/grafana/pull/13876)
+* **Datasource Proxy**: Keep trailing slash for datasource proxy requests [#13326](https://github.com/grafana/grafana/pull/13326), thx [@ryantxu](https://github.com/ryantxu)
 
 ### Breaking changes
 
 * Postgres/MySQL/MSSQL datasources now per default uses `max open connections` = `unlimited` (earlier 10), `max idle connections` = `2` (earlier 10) and `connection max lifetime` = `4` hours (earlier unlimited)
 
+# 5.3.5 (unreleased)
+
+* **Security**: Upgrade macaron session package to fix security issue. [#14043](https://github.com/grafana/grafana/pull/14043)
+
 # 5.3.4 (2018-11-13)
 
 * **Alerting**: Delete alerts when parent folder was deleted [#13322](https://github.com/grafana/grafana/issues/13322)

+ 65 - 1
docs/sources/http_api/team.md

@@ -30,7 +30,7 @@ Authorization: Basic YWRtaW46YWRtaW4=
 
 ### Using the query parameter
 
-Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`. 
+Default value for the `perpage` parameter is `1000` and for the `page` parameter is `1`.
 
 The `totalCount` field in the response can be used for pagination of the teams list E.g. if `totalCount` is equal to 100 teams and the `perpage` parameter is set to 10 then there are 10 pages of teams.
 
@@ -314,3 +314,67 @@ Status Codes:
 - **401** - Unauthorized
 - **403** - Permission denied
 - **404** - Team not found/Team member not found
+
+## Get Team Preferences
+
+`GET /api/teams/:teamId/preferences`
+
+**Example Request**:
+
+```http
+GET /api/teams/2/preferences HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+```
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: application/json
+
+{
+  "theme": "",
+  "homeDashboardId": 0,
+  "timezone": ""
+}
+```
+
+## Update Team Preferences
+
+`PUT /api/teams/:teamId/preferences`
+
+**Example Request**:
+
+```http
+PUT /api/teams/2/preferences HTTP/1.1
+Accept: application/json
+Content-Type: application/json
+Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk
+
+{
+  "theme": "dark",
+  "homeDashboardId": 39,
+  "timezone": "utc"
+}
+```
+
+JSON Body Schema:
+
+- **theme** - One of: ``light``, ``dark``, or an empty string for the default theme
+- **homeDashboardId** - The numerical ``:id`` of a dashboard, default: ``0``
+- **timezone** - One of: ``utc``, ``browser``, or an empty string for the default
+
+Omitting a key will cause the current value to be replaced with the system default value.
+
+**Example Response**:
+
+```http
+HTTP/1.1 200
+Content-Type: text/plain; charset=utf-8
+
+{
+  "message":"Preferences updated"
+}
+```

+ 8 - 6
package.json

@@ -14,9 +14,9 @@
     "@types/enzyme": "^3.1.13",
     "@types/jest": "^23.3.2",
     "@types/node": "^8.0.31",
-    "@types/react": "^16.4.14",
+    "@types/react": "^16.7.6",
     "@types/react-custom-scrollbars": "^4.0.5",
-    "@types/react-dom": "^16.0.7",
+    "@types/react-dom": "^16.0.9",
     "@types/react-select": "^2.0.4",
     "angular-mocks": "1.6.6",
     "autoprefixer": "^6.4.0",
@@ -152,17 +152,18 @@
     "prismjs": "^1.6.0",
     "prop-types": "^15.6.2",
     "rc-cascader": "^0.14.0",
-    "react": "^16.5.0",
+    "react": "^16.6.3",
     "react-custom-scrollbars": "^4.2.1",
-    "react-dom": "^16.5.0",
+    "react-dom": "^16.6.3",
     "react-grid-layout": "0.16.6",
     "react-highlight-words": "^0.10.0",
-    "react-popper": "^0.7.5",
+    "react-popper": "^1.3.0",
     "react-redux": "^5.0.7",
     "react-select": "2.1.0",
     "react-sizeme": "^2.3.6",
     "react-table": "^6.8.6",
     "react-transition-group": "^2.2.1",
+    "react-virtualized": "^9.21.0",
     "redux": "^4.0.0",
     "redux-logger": "^3.0.6",
     "redux-thunk": "^2.3.0",
@@ -179,6 +180,7 @@
     "tslint-react": "^3.6.0"
   },
   "resolutions": {
-    "caniuse-db": "1.0.30000772"
+    "caniuse-db": "1.0.30000772",
+    "**/@types/react": "16.7.6"
   }
 }

+ 3 - 1
pkg/api/api.go

@@ -155,6 +155,8 @@ func (hs *HTTPServer) registerRoutes() {
 			teamsRoute.Get("/:teamId/members", Wrap(GetTeamMembers))
 			teamsRoute.Post("/:teamId/members", bind(m.AddTeamMemberCommand{}), Wrap(AddTeamMember))
 			teamsRoute.Delete("/:teamId/members/:userId", Wrap(RemoveTeamMember))
+			teamsRoute.Get("/:teamId/preferences", Wrap(GetTeamPreferences))
+			teamsRoute.Put("/:teamId/preferences", bind(dtos.UpdatePrefsCmd{}), Wrap(UpdateTeamPreferences))
 		}, reqOrgAdmin)
 
 		// team without requirement of user to be org admin
@@ -242,7 +244,7 @@ func (hs *HTTPServer) registerRoutes() {
 
 		apiRoute.Get("/datasources/id/:name", Wrap(GetDataSourceIdByName), reqSignedIn)
 
-		apiRoute.Get("/plugins", Wrap(GetPluginList))
+		apiRoute.Get("/plugins", Wrap(hs.GetPluginList))
 		apiRoute.Get("/plugins/:pluginId/settings", Wrap(GetPluginSettingByID))
 		apiRoute.Get("/plugins/:pluginId/markdown/:name", Wrap(GetPluginMarkdown))
 

+ 1 - 1
pkg/api/dashboard.go

@@ -293,7 +293,7 @@ func PostDashboard(c *m.ReqContext, cmd m.SaveDashboardCommand) Response {
 }
 
 func GetHomeDashboard(c *m.ReqContext) Response {
-	prefsQuery := m.GetPreferencesWithDefaultsQuery{OrgId: c.OrgId, UserId: c.UserId}
+	prefsQuery := m.GetPreferencesWithDefaultsQuery{User: c.SignedInUser}
 	if err := bus.Dispatch(&prefsQuery); err != nil {
 		return Error(500, "Failed to get preferences", err)
 	}

+ 4 - 4
pkg/api/dtos/plugins.go

@@ -19,9 +19,9 @@ type PluginSetting struct {
 	JsonData      map[string]interface{}      `json:"jsonData"`
 	DefaultNavUrl string                      `json:"defaultNavUrl"`
 
-	LatestVersion string `json:"latestVersion"`
-	HasUpdate     bool   `json:"hasUpdate"`
-	State         string `json:"state"`
+	LatestVersion string              `json:"latestVersion"`
+	HasUpdate     bool                `json:"hasUpdate"`
+	State         plugins.PluginState `json:"state"`
 }
 
 type PluginListItem struct {
@@ -34,7 +34,7 @@ type PluginListItem struct {
 	LatestVersion string              `json:"latestVersion"`
 	HasUpdate     bool                `json:"hasUpdate"`
 	DefaultNavUrl string              `json:"defaultNavUrl"`
-	State         string              `json:"state"`
+	State         plugins.PluginState `json:"state"`
 }
 
 type PluginList []PluginListItem

+ 1 - 1
pkg/api/frontendsettings.go

@@ -133,7 +133,7 @@ func (hs *HTTPServer) getFrontendSettingsMap(c *m.ReqContext) (map[string]interf
 
 	panels := map[string]interface{}{}
 	for _, panel := range enabledPlugins.Panels {
-		if panel.State == "alpha" && !hs.Cfg.EnableAlphaPanels {
+		if panel.State == plugins.PluginStateAlpha && !hs.Cfg.EnableAlphaPanels {
 			continue
 		}
 

+ 1 - 1
pkg/api/index.go

@@ -23,7 +23,7 @@ func (hs *HTTPServer) setIndexViewData(c *m.ReqContext) (*dtos.IndexViewData, er
 		return nil, err
 	}
 
-	prefsQuery := m.GetPreferencesWithDefaultsQuery{OrgId: c.OrgId, UserId: c.UserId}
+	prefsQuery := m.GetPreferencesWithDefaultsQuery{User: c.SignedInUser}
 	if err := bus.Dispatch(&prefsQuery); err != nil {
 		return nil, err
 	}

+ 5 - 1
pkg/api/plugins.go

@@ -10,7 +10,7 @@ import (
 	"github.com/grafana/grafana/pkg/setting"
 )
 
-func GetPluginList(c *m.ReqContext) Response {
+func (hs *HTTPServer) GetPluginList(c *m.ReqContext) Response {
 	typeFilter := c.Query("type")
 	enabledFilter := c.Query("enabled")
 	embeddedFilter := c.Query("embedded")
@@ -39,6 +39,10 @@ func GetPluginList(c *m.ReqContext) Response {
 			continue
 		}
 
+		if pluginDef.State == plugins.PluginStateAlpha && !hs.Cfg.EnableAlphaPanels {
+			continue
+		}
+
 		listItem := dtos.PluginListItem{
 			Id:            pluginDef.Id,
 			Name:          pluginDef.Name,

+ 8 - 7
pkg/api/preferences.go

@@ -21,11 +21,11 @@ func SetHomeDashboard(c *m.ReqContext, cmd m.SavePreferencesCommand) Response {
 
 // GET /api/user/preferences
 func GetUserPreferences(c *m.ReqContext) Response {
-	return getPreferencesFor(c.OrgId, c.UserId)
+	return getPreferencesFor(c.OrgId, c.UserId, 0)
 }
 
-func getPreferencesFor(orgID int64, userID int64) Response {
-	prefsQuery := m.GetPreferencesQuery{UserId: userID, OrgId: orgID}
+func getPreferencesFor(orgID, userID, teamID int64) Response {
+	prefsQuery := m.GetPreferencesQuery{UserId: userID, OrgId: orgID, TeamId: teamID}
 
 	if err := bus.Dispatch(&prefsQuery); err != nil {
 		return Error(500, "Failed to get preferences", err)
@@ -42,13 +42,14 @@ func getPreferencesFor(orgID int64, userID int64) Response {
 
 // PUT /api/user/preferences
 func UpdateUserPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
-	return updatePreferencesFor(c.OrgId, c.UserId, &dtoCmd)
+	return updatePreferencesFor(c.OrgId, c.UserId, 0, &dtoCmd)
 }
 
-func updatePreferencesFor(orgID int64, userID int64, dtoCmd *dtos.UpdatePrefsCmd) Response {
+func updatePreferencesFor(orgID, userID, teamId int64, dtoCmd *dtos.UpdatePrefsCmd) Response {
 	saveCmd := m.SavePreferencesCommand{
 		UserId:          userID,
 		OrgId:           orgID,
+		TeamId:          teamId,
 		Theme:           dtoCmd.Theme,
 		Timezone:        dtoCmd.Timezone,
 		HomeDashboardId: dtoCmd.HomeDashboardID,
@@ -63,10 +64,10 @@ func updatePreferencesFor(orgID int64, userID int64, dtoCmd *dtos.UpdatePrefsCmd
 
 // GET /api/org/preferences
 func GetOrgPreferences(c *m.ReqContext) Response {
-	return getPreferencesFor(c.OrgId, 0)
+	return getPreferencesFor(c.OrgId, 0, 0)
 }
 
 // PUT /api/org/preferences
 func UpdateOrgPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
-	return updatePreferencesFor(c.OrgId, 0, &dtoCmd)
+	return updatePreferencesFor(c.OrgId, 0, 0, &dtoCmd)
 }

+ 10 - 0
pkg/api/team.go

@@ -96,3 +96,13 @@ func GetTeamByID(c *m.ReqContext) Response {
 	query.Result.AvatarUrl = dtos.GetGravatarUrlWithDefault(query.Result.Email, query.Result.Name)
 	return JSON(200, &query.Result)
 }
+
+// GET /api/teams/:teamId/preferences
+func GetTeamPreferences(c *m.ReqContext) Response {
+	return getPreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"))
+}
+
+// PUT /api/teams/:teamId/preferences
+func UpdateTeamPreferences(c *m.ReqContext, dtoCmd dtos.UpdatePrefsCmd) Response {
+	return updatePreferencesFor(c.OrgId, 0, c.ParamsInt64(":teamId"), &dtoCmd)
+}

+ 4 - 3
pkg/models/preferences.go

@@ -14,6 +14,7 @@ type Preferences struct {
 	Id              int64
 	OrgId           int64
 	UserId          int64
+	TeamId          int64
 	Version         int
 	HomeDashboardId int64
 	Timezone        string
@@ -29,14 +30,13 @@ type GetPreferencesQuery struct {
 	Id     int64
 	OrgId  int64
 	UserId int64
+	TeamId int64
 
 	Result *Preferences
 }
 
 type GetPreferencesWithDefaultsQuery struct {
-	Id     int64
-	OrgId  int64
-	UserId int64
+	User *SignedInUser
 
 	Result *Preferences
 }
@@ -46,6 +46,7 @@ type GetPreferencesWithDefaultsQuery struct {
 type SavePreferencesCommand struct {
 	UserId int64
 	OrgId  int64
+	TeamId int64
 
 	HomeDashboardId int64  `json:"homeDashboardId"`
 	Timezone        string `json:"timezone"`

+ 8 - 1
pkg/plugins/models.go

@@ -17,6 +17,13 @@ var (
 	PluginTypeDashboard  = "dashboard"
 )
 
+type PluginState string
+
+var (
+	PluginStateAlpha PluginState = "alpha"
+	PluginStateBeta  PluginState = "beta"
+)
+
 type PluginNotFoundError struct {
 	PluginId string
 }
@@ -39,7 +46,7 @@ type PluginBase struct {
 	Module       string             `json:"module"`
 	BaseUrl      string             `json:"baseUrl"`
 	HideFromList bool               `json:"hideFromList,omitempty"`
-	State        string             `json:"state,omitempty"`
+	State        PluginState        `json:"state,omitempty"`
 
 	IncludedInAppId string `json:"-"`
 	PluginDir       string `json:"-"`

+ 9 - 0
pkg/services/sqlstore/migrations/preferences_mig.go

@@ -34,4 +34,13 @@ func addPreferencesMigrations(mg *Migrator) {
 		{Name: "timezone", Type: DB_NVarchar, Length: 50, Nullable: false},
 		{Name: "theme", Type: DB_NVarchar, Length: 20, Nullable: false},
 	}))
+
+	mg.AddMigration("Add column team_id in preferences", NewAddColumnMigration(preferencesV2, &Column{
+		Name: "team_id", Type: DB_BigInt, Nullable: true,
+	}))
+
+	mg.AddMigration("Update team_id column values in preferences", NewRawSqlMigration("").
+		Sqlite("UPDATE preferences SET team_id=0 WHERE team_id IS NULL;").
+		Postgres("UPDATE preferences SET team_id=0 WHERE team_id IS NULL;").
+		Mysql("UPDATE preferences SET team_id=0 WHERE team_id IS NULL;"))
 }

+ 19 - 7
pkg/services/sqlstore/preferences.go

@@ -1,6 +1,7 @@
 package sqlstore
 
 import (
+	"strings"
 	"time"
 
 	"github.com/grafana/grafana/pkg/bus"
@@ -16,11 +17,22 @@ func init() {
 }
 
 func GetPreferencesWithDefaults(query *m.GetPreferencesWithDefaultsQuery) error {
-
+	params := make([]interface{}, 0)
+	filter := ""
+	if len(query.User.Teams) > 0 {
+		filter = "(org_id=? AND team_id IN (?" + strings.Repeat(",?", len(query.User.Teams)-1) + ")) OR "
+		params = append(params, query.User.OrgId)
+		for _, v := range query.User.Teams {
+			params = append(params, v)
+		}
+	}
+	filter += "(org_id=? AND user_id=? AND team_id=0) OR (org_id=? AND team_id=0 AND user_id=0)"
+	params = append(params, query.User.OrgId)
+	params = append(params, query.User.UserId)
+	params = append(params, query.User.OrgId)
 	prefs := make([]*m.Preferences, 0)
-	filter := "(org_id=? AND user_id=?) OR (org_id=? AND user_id=0)"
-	err := x.Where(filter, query.OrgId, query.UserId, query.OrgId).
-		OrderBy("user_id ASC").
+	err := x.Where(filter, params...).
+		OrderBy("user_id ASC, team_id ASC").
 		Find(&prefs)
 
 	if err != nil {
@@ -50,9 +62,8 @@ func GetPreferencesWithDefaults(query *m.GetPreferencesWithDefaultsQuery) error
 }
 
 func GetPreferences(query *m.GetPreferencesQuery) error {
-
 	var prefs m.Preferences
-	exists, err := x.Where("org_id=? AND user_id=?", query.OrgId, query.UserId).Get(&prefs)
+	exists, err := x.Where("org_id=? AND user_id=? AND team_id=?", query.OrgId, query.UserId, query.TeamId).Get(&prefs)
 
 	if err != nil {
 		return err
@@ -71,7 +82,7 @@ func SavePreferences(cmd *m.SavePreferencesCommand) error {
 	return inTransaction(func(sess *DBSession) error {
 
 		var prefs m.Preferences
-		exists, err := sess.Where("org_id=? AND user_id=?", cmd.OrgId, cmd.UserId).Get(&prefs)
+		exists, err := sess.Where("org_id=? AND user_id=? AND team_id=?", cmd.OrgId, cmd.UserId, cmd.TeamId).Get(&prefs)
 		if err != nil {
 			return err
 		}
@@ -80,6 +91,7 @@ func SavePreferences(cmd *m.SavePreferencesCommand) error {
 			prefs = m.Preferences{
 				UserId:          cmd.UserId,
 				OrgId:           cmd.OrgId,
+				TeamId:          cmd.TeamId,
 				HomeDashboardId: cmd.HomeDashboardId,
 				Timezone:        cmd.Timezone,
 				Theme:           cmd.Theme,

+ 91 - 0
pkg/services/sqlstore/preferences_test.go

@@ -0,0 +1,91 @@
+package sqlstore
+
+import (
+	"testing"
+
+	. "github.com/smartystreets/goconvey/convey"
+
+	"github.com/grafana/grafana/pkg/models"
+	"github.com/grafana/grafana/pkg/setting"
+)
+
+func TestPreferencesDataAccess(t *testing.T) {
+	Convey("Testing preferences data access", t, func() {
+		InitTestDB(t)
+
+		Convey("GetPreferencesWithDefaults with no saved preferences should return defaults", func() {
+			query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{}}
+			err := GetPreferencesWithDefaults(query)
+			So(err, ShouldBeNil)
+			So(query.Result.Theme, ShouldEqual, setting.DefaultTheme)
+			So(query.Result.Timezone, ShouldEqual, "browser")
+			So(query.Result.HomeDashboardId, ShouldEqual, 0)
+		})
+
+		Convey("GetPreferencesWithDefaults with saved org and user home dashboard should return user home dashboard", func() {
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, HomeDashboardId: 1})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, UserId: 1, HomeDashboardId: 4})
+
+			query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 1}}
+			err := GetPreferencesWithDefaults(query)
+			So(err, ShouldBeNil)
+			So(query.Result.HomeDashboardId, ShouldEqual, 4)
+		})
+
+		Convey("GetPreferencesWithDefaults with saved org and other user home dashboard should return org home dashboard", func() {
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, HomeDashboardId: 1})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, UserId: 1, HomeDashboardId: 4})
+
+			query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 2}}
+			err := GetPreferencesWithDefaults(query)
+			So(err, ShouldBeNil)
+			So(query.Result.HomeDashboardId, ShouldEqual, 1)
+		})
+
+		Convey("GetPreferencesWithDefaults with saved org and teams home dashboard should return last team home dashboard", func() {
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, HomeDashboardId: 1})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 2, HomeDashboardId: 2})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 3, HomeDashboardId: 3})
+
+			query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, Teams: []int64{2, 3}}}
+			err := GetPreferencesWithDefaults(query)
+			So(err, ShouldBeNil)
+			So(query.Result.HomeDashboardId, ShouldEqual, 3)
+		})
+
+		Convey("GetPreferencesWithDefaults with saved org and other teams home dashboard should return org home dashboard", func() {
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, HomeDashboardId: 1})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 2, HomeDashboardId: 2})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 3, HomeDashboardId: 3})
+
+			query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1}}
+			err := GetPreferencesWithDefaults(query)
+			So(err, ShouldBeNil)
+			So(query.Result.HomeDashboardId, ShouldEqual, 1)
+		})
+
+		Convey("GetPreferencesWithDefaults with saved org, teams and user home dashboard should return user home dashboard", func() {
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, HomeDashboardId: 1})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 2, HomeDashboardId: 2})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 3, HomeDashboardId: 3})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, UserId: 1, HomeDashboardId: 4})
+
+			query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 1, Teams: []int64{2, 3}}}
+			err := GetPreferencesWithDefaults(query)
+			So(err, ShouldBeNil)
+			So(query.Result.HomeDashboardId, ShouldEqual, 4)
+		})
+
+		Convey("GetPreferencesWithDefaults with saved org, other teams and user home dashboard should return org home dashboard", func() {
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, HomeDashboardId: 1})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 2, HomeDashboardId: 2})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, TeamId: 3, HomeDashboardId: 3})
+			SavePreferences(&models.SavePreferencesCommand{OrgId: 1, UserId: 1, HomeDashboardId: 4})
+
+			query := &models.GetPreferencesWithDefaultsQuery{User: &models.SignedInUser{OrgId: 1, UserId: 2}}
+			err := GetPreferencesWithDefaults(query)
+			So(err, ShouldBeNil)
+			So(query.Result.HomeDashboardId, ShouldEqual, 1)
+		})
+	})
+}

+ 0 - 28
public/app/core/actions/user.ts

@@ -1,28 +0,0 @@
-import { ThunkAction } from 'redux-thunk';
-import { getBackendSrv } from '../services/backend_srv';
-import { DashboardAcl, DashboardSearchHit, StoreState } from '../../types';
-
-type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
-
-export type Action = LoadStarredDashboardsAction;
-
-export enum ActionTypes {
-  LoadStarredDashboards = 'LOAD_STARRED_DASHBOARDS',
-}
-
-interface LoadStarredDashboardsAction {
-  type: ActionTypes.LoadStarredDashboards;
-  payload: DashboardSearchHit[];
-}
-
-const starredDashboardsLoaded = (dashboards: DashboardAcl[]) => ({
-  type: ActionTypes.LoadStarredDashboards,
-  payload: dashboards,
-});
-
-export function loadStarredDashboards(): ThunkResult<void> {
-  return async dispatch => {
-    const starredDashboards = await getBackendSrv().search({ starred: true });
-    dispatch(starredDashboardsLoaded(starredDashboards));
-  };
-}

+ 37 - 0
public/app/core/components/Animations/FadeIn.tsx

@@ -0,0 +1,37 @@
+import React, { SFC } from 'react';
+import Transition from 'react-transition-group/Transition';
+
+interface Props {
+  duration: number;
+  children: JSX.Element;
+  in: boolean;
+}
+
+export const FadeIn: SFC<Props> = props => {
+  const defaultStyle = {
+    transition: `opacity ${props.duration}ms linear`,
+    opacity: 0,
+  };
+
+  const transitionStyles = {
+    exited: { opacity: 0, display: 'none' },
+    entering: { opacity: 0 },
+    entered: { opacity: 1 },
+    exiting: { opacity: 0 },
+  };
+
+  return (
+    <Transition in={props.in} timeout={props.duration}>
+      {state => (
+        <div
+          style={{
+            ...defaultStyle,
+            ...transitionStyles[state],
+          }}
+        >
+          {props.children}
+        </div>
+      )}
+    </Transition>
+  );
+};

+ 1 - 1
public/app/core/components/Animations/SlideDown.tsx

@@ -23,7 +23,7 @@ export default ({ children, in: inProp, maxHeight = defaultMaxHeight, style = de
   const transitionStyles = {
     exited: { maxHeight: 0 },
     entering: { maxHeight: maxHeight },
-    entered: { maxHeight: maxHeight, overflow: 'visible' },
+    entered: { maxHeight: 'unset', overflow: 'visible' },
     exiting: { maxHeight: 0 },
   };
 

+ 36 - 0
public/app/core/components/ClickOutsideWrapper/ClickOutsideWrapper.tsx

@@ -0,0 +1,36 @@
+import { PureComponent } from 'react';
+import ReactDOM from 'react-dom';
+
+export interface Props {
+  onClick: () => void;
+}
+
+interface State {
+  hasEventListener: boolean;
+}
+
+export class ClickOutsideWrapper extends PureComponent<Props, State> {
+  state = {
+    hasEventListener: false,
+  };
+
+  componentDidMount() {
+    window.addEventListener('click', this.onOutsideClick, false);
+  }
+
+  componentWillUnmount() {
+    window.removeEventListener('click', this.onOutsideClick, false);
+  }
+
+  onOutsideClick = event => {
+    const domNode = ReactDOM.findDOMNode(this) as Element;
+
+    if (!domNode || !domNode.contains(event.target)) {
+      this.props.onClick();
+    }
+  };
+
+  render() {
+    return this.props.children;
+  }
+}

+ 4 - 1
public/app/core/components/Picker/SimplePicker.tsx

@@ -5,13 +5,14 @@ import ResetStyles from './ResetStyles';
 
 interface Props {
   className?: string;
-  defaultValue: any;
+  defaultValue?: any;
   getOptionLabel: (item: any) => string;
   getOptionValue: (item: any) => string;
   onSelected: (item: any) => {} | void;
   options: any[];
   placeholder?: string;
   width: number;
+  value: any;
 }
 
 const SimplePicker: SFC<Props> = ({
@@ -23,6 +24,7 @@ const SimplePicker: SFC<Props> = ({
   options,
   placeholder,
   width,
+  value,
 }) => {
   return (
     <Select
@@ -32,6 +34,7 @@ const SimplePicker: SFC<Props> = ({
         Option: DescriptionOption,
       }}
       defaultValue={defaultValue}
+      value={value}
       getOptionLabel={getOptionLabel}
       getOptionValue={getOptionValue}
       isSearchable={false}

+ 141 - 0
public/app/core/components/SharedPreferences/SharedPreferences.tsx

@@ -0,0 +1,141 @@
+import React, { PureComponent } from 'react';
+
+import { Label } from 'app/core/components/Label/Label';
+import SimplePicker from 'app/core/components/Picker/SimplePicker';
+import { getBackendSrv, BackendSrv } from 'app/core/services/backend_srv';
+
+import { DashboardSearchHit } from 'app/types';
+
+export interface Props {
+  resourceUri: string;
+}
+
+export interface State {
+  homeDashboardId: number;
+  theme: string;
+  timezone: string;
+  dashboards: DashboardSearchHit[];
+}
+
+const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
+
+const timezones = [
+  { value: '', text: 'Default' },
+  { value: 'browser', text: 'Local browser time' },
+  { value: 'utc', text: 'UTC' },
+];
+
+export class SharedPreferences extends PureComponent<Props, State> {
+  backendSrv: BackendSrv = getBackendSrv();
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      homeDashboardId: 0,
+      theme: '',
+      timezone: '',
+      dashboards: [],
+    };
+  }
+
+  async componentDidMount() {
+    const prefs = await this.backendSrv.get(`/api/${this.props.resourceUri}/preferences`);
+    const dashboards = await this.backendSrv.search({ starred: true });
+
+    if (prefs.homeDashboardId > 0 && !dashboards.find(d => d.id === prefs.homeDashboardId)) {
+      const missing = await this.backendSrv.search({ dashboardIds: [prefs.homeDashboardId] });
+      if (missing && missing.length > 0) {
+        dashboards.push(missing[0]);
+      }
+    }
+
+    this.setState({
+      homeDashboardId: prefs.homeDashboardId,
+      theme: prefs.theme,
+      timezone: prefs.timezone,
+      dashboards: [{ id: 0, title: 'Default', tags: [], type: '', uid: '', uri: '', url: '' }, ...dashboards],
+    });
+  }
+
+  onSubmitForm = async event => {
+    event.preventDefault();
+
+    const { homeDashboardId, theme, timezone } = this.state;
+
+    await this.backendSrv.put(`/api/${this.props.resourceUri}/preferences`, {
+      homeDashboardId,
+      theme,
+      timezone,
+    });
+    window.location.reload();
+  };
+
+  onThemeChanged = (theme: string) => {
+    this.setState({ theme });
+  };
+
+  onTimeZoneChanged = (timezone: string) => {
+    this.setState({ timezone });
+  };
+
+  onHomeDashboardChanged = (dashboardId: number) => {
+    this.setState({ homeDashboardId: dashboardId });
+  };
+
+  render() {
+    const { theme, timezone, homeDashboardId, dashboards } = this.state;
+
+    return (
+      <form className="section gf-form-group" onSubmit={this.onSubmitForm}>
+        <h3 className="page-heading">Preferences</h3>
+        <div className="gf-form">
+          <span className="gf-form-label width-11">UI Theme</span>
+          <SimplePicker
+            value={themes.find(item => item.value === theme)}
+            options={themes}
+            getOptionValue={i => i.value}
+            getOptionLabel={i => i.text}
+            onSelected={theme => this.onThemeChanged(theme.value)}
+            width={20}
+          />
+        </div>
+        <div className="gf-form">
+          <Label
+            width={11}
+            tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
+          >
+            Home Dashboard
+          </Label>
+          <SimplePicker
+            value={dashboards.find(dashboard => dashboard.id === homeDashboardId)}
+            getOptionValue={i => i.id}
+            getOptionLabel={i => i.title}
+            onSelected={(dashboard: DashboardSearchHit) => this.onHomeDashboardChanged(dashboard.id)}
+            options={dashboards}
+            placeholder="Chose default dashboard"
+            width={20}
+          />
+        </div>
+        <div className="gf-form">
+          <label className="gf-form-label width-11">Timezone</label>
+          <SimplePicker
+            value={timezones.find(item => item.value === timezone)}
+            getOptionValue={i => i.value}
+            getOptionLabel={i => i.text}
+            onSelected={timezone => this.onTimeZoneChanged(timezone.value)}
+            options={timezones}
+            width={20}
+          />
+        </div>
+        <div className="gf-form-button-row">
+          <button type="submit" className="btn btn-success">
+            Save
+          </button>
+        </div>
+      </form>
+    );
+  }
+}
+
+export default SharedPreferences;

+ 1 - 0
public/app/core/components/Switch/Switch.tsx

@@ -26,6 +26,7 @@ export class Switch extends PureComponent<Props, State> {
 
   render() {
     const { labelClass = '', switchClass = '', label, checked, small } = this.props;
+
     const labelId = `check-${this.state.id}`;
     let labelClassName = `gf-form-label ${labelClass} pointer`;
     let switchClassName = `gf-form-switch ${switchClass}`;

+ 11 - 26
public/app/core/components/Tooltip/Popover.tsx

@@ -1,34 +1,19 @@
-import React from 'react';
-import withTooltip from './withTooltip';
-import { Target } from 'react-popper';
+import React, { PureComponent } from 'react';
+import Popper from './Popper';
+import withPopper, { UsingPopperProps } from './withPopper';
 
-interface PopoverProps {
-  tooltipSetState: (prevState: object) => void;
-}
-
-class Popover extends React.Component<PopoverProps, any> {
-  constructor(props) {
-    super(props);
-    this.toggleTooltip = this.toggleTooltip.bind(this);
-  }
+class Popover extends PureComponent<UsingPopperProps> {
+  render() {
+    const { children, hidePopper, showPopper, className, ...restProps } = this.props;
 
-  toggleTooltip() {
-    const { tooltipSetState } = this.props;
-    tooltipSetState(prevState => {
-      return {
-        ...prevState,
-        show: !prevState.show,
-      };
-    });
-  }
+    const togglePopper = restProps.show ? hidePopper : showPopper;
 
-  render() {
     return (
-      <Target className="popper__target" onClick={this.toggleTooltip}>
-        {this.props.children}
-      </Target>
+      <div className={`popper__manager ${className}`} onClick={togglePopper}>
+        <Popper {...restProps}>{children}</Popper>
+      </div>
     );
   }
 }
 
-export default withTooltip(Popover);
+export default withPopper(Popover);

+ 69 - 0
public/app/core/components/Tooltip/Popper.tsx

@@ -0,0 +1,69 @@
+import React, { PureComponent } from 'react';
+import { Manager, Popper as ReactPopper, Reference } from 'react-popper';
+import Transition from 'react-transition-group/Transition';
+
+const defaultTransitionStyles = {
+  transition: 'opacity 200ms linear',
+  opacity: 0,
+};
+
+const transitionStyles = {
+  exited: { opacity: 0 },
+  entering: { opacity: 0 },
+  entered: { opacity: 1 },
+  exiting: { opacity: 0 },
+};
+
+interface Props {
+  renderContent: (content: any) => any;
+  show: boolean;
+  placement?: any;
+  content: string | ((props: any) => JSX.Element);
+  refClassName?: string;
+}
+
+class Popper extends PureComponent<Props> {
+  render() {
+    const { children, renderContent, show, placement, refClassName } = this.props;
+    const { content } = this.props;
+
+    return (
+      <Manager>
+        <Reference>
+          {({ ref }) => (
+            <div className={`popper_ref ${refClassName || ''}`} ref={ref}>
+              {children}
+            </div>
+          )}
+        </Reference>
+        <Transition in={show} timeout={100} mountOnEnter={true} unmountOnExit={true}>
+          {transitionState => (
+            <ReactPopper placement={placement}>
+              {({ ref, style, placement, arrowProps }) => {
+                return (
+                  <div
+                    ref={ref}
+                    style={{
+                      ...style,
+                      ...defaultTransitionStyles,
+                      ...transitionStyles[transitionState],
+                    }}
+                    data-placement={placement}
+                    className="popper"
+                  >
+                    <div className="popper__background">
+                      {renderContent(content)}
+                      <div ref={arrowProps.ref} data-placement={placement} className="popper__arrow" />
+                    </div>
+                  </div>
+                );
+              }}
+            </ReactPopper>
+          )}
+        </Transition>
+      </Manager>
+    );
+  }
+}
+
+export default Popper;

+ 9 - 28
public/app/core/components/Tooltip/Tooltip.tsx

@@ -1,36 +1,17 @@
 import React, { PureComponent } from 'react';
-import withTooltip from './withTooltip';
-import { Target } from 'react-popper';
-
-interface Props {
-  tooltipSetState: (prevState: object) => void;
-}
-
-class Tooltip extends PureComponent<Props> {
-  showTooltip = () => {
-    const { tooltipSetState } = this.props;
-
-    tooltipSetState(prevState => ({
-      ...prevState,
-      show: true,
-    }));
-  };
-
-  hideTooltip = () => {
-    const { tooltipSetState } = this.props;
-    tooltipSetState(prevState => ({
-      ...prevState,
-      show: false,
-    }));
-  };
+import Popper from './Popper';
+import withPopper, { UsingPopperProps } from './withPopper';
 
+class Tooltip extends PureComponent<UsingPopperProps> {
   render() {
+    const { children, hidePopper, showPopper, className, ...restProps } = this.props;
+
     return (
-      <Target className="popper__target" onMouseOver={this.showTooltip} onMouseOut={this.hideTooltip}>
-        {this.props.children}
-      </Target>
+      <div className={`popper__manager ${className}`} onMouseEnter={showPopper} onMouseLeave={hidePopper}>
+        <Popper {...restProps}>{children}</Popper>
+      </div>
     );
   }
 }
 
-export default withTooltip(Tooltip);
+export default withPopper(Tooltip);

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

@@ -3,10 +3,10 @@
 exports[`Popover renders correctly 1`] = `
 <div
   className="popper__manager test-class"
+  onClick={[Function]}
 >
   <div
-    className="popper__target"
-    onClick={[Function]}
+    className="popper_ref "
   >
     <button>
       Button with Popover

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

@@ -3,11 +3,11 @@
 exports[`Tooltip renders correctly 1`] = `
 <div
   className="popper__manager test-class"
+  onMouseEnter={[Function]}
+  onMouseLeave={[Function]}
 >
   <div
-    className="popper__target"
-    onMouseOut={[Function]}
-    onMouseOver={[Function]}
+    className="popper_ref "
   >
     <a
       href="http://www.grafana.com"

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

@@ -0,0 +1,89 @@
+import React from 'react';
+
+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;
+}
+
+interface Props {
+  placement?: string;
+  className?: string;
+  refClassName?: string;
+  content: string | ((props: any) => JSX.Element);
+}
+
+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) {
+      console.log('render 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}
+        />
+      );
+    }
+  };
+}

+ 0 - 58
public/app/core/components/Tooltip/withTooltip.tsx

@@ -1,58 +0,0 @@
-import React from 'react';
-import { Manager, Popper, Arrow } from 'react-popper';
-
-interface IwithTooltipProps {
-  placement?: string;
-  content: string | ((props: any) => JSX.Element);
-  className?: string;
-}
-
-export default function withTooltip(WrappedComponent) {
-  return class extends React.Component<IwithTooltipProps, any> {
-    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,
-          };
-        });
-      }
-    }
-
-    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 { content, className } = this.props;
-
-      return (
-        <Manager className={`popper__manager ${className || ''}`}>
-          <WrappedComponent {...this.props} tooltipSetState={this.setState} />
-          {this.state.show ? (
-            <Popper placement={this.state.placement} className="popper">
-              {this.renderContent(content)}
-              <Arrow className="popper__arrow" />
-            </Popper>
-          ) : null}
-        </Manager>
-      );
-    }
-  };
-}

+ 3 - 0
public/app/core/constants.ts

@@ -11,3 +11,6 @@ export const LS_PANEL_COPY_KEY = 'panel-copy';
 
 export const DASHBOARD_TOOLBAR_HEIGHT = 55;
 export const DASHBOARD_TOP_PADDING = 20;
+
+export const PANEL_HEADER_HEIGHT = 27;
+export const PANEL_BORDER = 2;

+ 6 - 3
public/app/core/directives/dash_class.ts

@@ -1,3 +1,4 @@
+import $ from 'jquery';
 import _ from 'lodash';
 import coreModule from '../core_module';
 
@@ -5,18 +6,20 @@ import coreModule from '../core_module';
 function dashClass($timeout) {
   return {
     link: ($scope, elem) => {
+      const body = $('body');
+
       $scope.ctrl.dashboard.events.on('view-mode-changed', panel => {
         console.log('view-mode-changed', panel.fullscreen);
         if (panel.fullscreen) {
-          elem.addClass('panel-in-fullscreen');
+          body.addClass('panel-in-fullscreen');
         } else {
           $timeout(() => {
-            elem.removeClass('panel-in-fullscreen');
+            body.removeClass('panel-in-fullscreen');
           });
         }
       });
 
-      elem.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
+      body.toggleClass('panel-in-fullscreen', $scope.ctrl.dashboard.meta.fullscreen === true);
 
       $scope.$watch('ctrl.dashboardViewState.state.editview', newValue => {
         if (newValue) {

+ 0 - 2
public/app/core/reducers/index.ts

@@ -1,11 +1,9 @@
 import { navIndexReducer as navIndex } from './navModel';
 import { locationReducer as location } from './location';
 import { appNotificationsReducer as appNotifications } from './appNotification';
-import { userReducer as user } from './user';
 
 export default {
   navIndex,
   location,
   appNotifications,
-  user,
 };

+ 1 - 0
public/app/core/reducers/location.ts

@@ -18,6 +18,7 @@ export const locationReducer = (state = initialState, action: Action): LocationS
 
       if (action.payload.partial) {
         query = _.defaults(query, state.query);
+        query = _.omitBy(query, _.isNull);
       }
 
       return {

+ 0 - 15
public/app/core/reducers/user.ts

@@ -1,15 +0,0 @@
-import { DashboardSearchHit, UserState } from '../../types';
-import { Action, ActionTypes } from '../actions/user';
-
-const initialState: UserState = {
-  starredDashboards: [] as DashboardSearchHit[],
-};
-
-export const userReducer = (state: UserState = initialState, action: Action): UserState => {
-  switch (action.type) {
-    case ActionTypes.LoadStarredDashboards:
-      return { ...state, starredDashboards: action.payload };
-  }
-
-  return state;
-};

+ 4 - 0
public/app/core/services/AngularLoader.ts

@@ -4,6 +4,7 @@ import _ from 'lodash';
 
 export interface AngularComponent {
   destroy();
+  digest();
 }
 
 export class AngularLoader {
@@ -24,6 +25,9 @@ export class AngularLoader {
         scope.$destroy();
         compiledElem.remove();
       },
+      digest: () => {
+        scope.$digest();
+      },
     };
   }
 }

+ 1 - 1
public/app/core/services/bridge_srv.ts

@@ -1,6 +1,6 @@
 import coreModule from 'app/core/core_module';
 import appEvents from 'app/core/app_events';
-import { store } from 'app/store/configureStore';
+import { store } from 'app/store/store';
 import locationUtil from 'app/core/utils/location_util';
 import { updateLocation } from 'app/core/actions';
 

+ 1 - 1
public/app/core/utils/connectWithReduxStore.tsx

@@ -1,6 +1,6 @@
 import React from 'react';
 import { connect } from 'react-redux';
-import { store } from '../../store/configureStore';
+import { store } from '../../store/store';
 
 export function connectWithStore(WrappedComponent, ...args) {
   const ConnectedWrappedComponent = connect(...args)(WrappedComponent);

+ 8 - 1
public/app/features/dashboard/dashgrid/DashboardGrid.tsx

@@ -8,6 +8,7 @@ import classNames from 'classnames';
 import sizeMe from 'react-sizeme';
 
 let lastGridWidth = 1200;
+let ignoreNextWidthChange = false;
 
 function GridWrapper({
   size,
@@ -24,8 +25,12 @@ function GridWrapper({
   isFullscreen,
 }) {
   const width = size.width > 0 ? size.width : lastGridWidth;
+
+  // logic to ignore width changes (optimization)
   if (width !== lastGridWidth) {
-    if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
+    if (ignoreNextWidthChange) {
+      ignoreNextWidthChange = false;
+    } else if (!isFullscreen && Math.abs(width - lastGridWidth) > 8) {
       onWidthChange();
       lastGridWidth = width;
     }
@@ -138,6 +143,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
   }
 
   onViewModeChanged(payload) {
+    ignoreNextWidthChange = true;
     this.setState({ animated: !payload.fullscreen });
   }
 
@@ -170,6 +176,7 @@ export class DashboardGrid extends React.Component<DashboardGridProps, any> {
 
   renderPanels() {
     const panelElements = [];
+    console.log('render panels');
 
     for (const panel of this.props.dashboard.panels) {
       const panelClasses = classNames({ panel: true, 'panel--fullscreen': panel.fullscreen });

+ 51 - 36
public/app/features/dashboard/dashgrid/DashboardPanel.tsx

@@ -1,15 +1,19 @@
 import React, { PureComponent } from 'react';
 import config from 'app/core/config';
-import { PanelModel } from '../panel_model';
-import { DashboardModel } from '../dashboard_model';
+
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
-import { DashboardRow } from './DashboardRow';
-import { AddPanelPanel } from './AddPanelPanel';
 import { importPluginModule } from 'app/features/plugins/plugin_loader';
-import { PluginExports, PanelPlugin } from 'app/types/plugins';
+
+import { AddPanelPanel } from './AddPanelPanel';
+import { getPanelPluginNotFound } from './PanelPluginNotFound';
+import { DashboardRow } from './DashboardRow';
 import { PanelChrome } from './PanelChrome';
 import { PanelEditor } from './PanelEditor';
 
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { PanelPlugin } from 'app/types';
+
 export interface Props {
   panel: PanelModel;
   dashboard: DashboardModel;
@@ -18,20 +22,19 @@ export interface Props {
 }
 
 export interface State {
-  pluginExports: PluginExports;
+  plugin: PanelPlugin;
 }
 
 export class DashboardPanel extends PureComponent<Props, State> {
   element: any;
   angularPanel: AngularComponent;
-  pluginInfo: any;
   specialPanels = {};
 
   constructor(props) {
     super(props);
 
     this.state = {
-      pluginExports: null,
+      plugin: null,
     };
 
     this.specialPanels['row'] = this.renderRow.bind(this);
@@ -64,20 +67,22 @@ export class DashboardPanel extends PureComponent<Props, State> {
       return;
     }
 
+    const { panel } = this.props;
+
     // handle plugin loading & changing of plugin type
-    if (!this.pluginInfo || this.pluginInfo.id !== this.props.panel.type) {
-      this.pluginInfo = config.panels[this.props.panel.type];
+    if (!this.state.plugin || this.state.plugin.id !== panel.type) {
+      const plugin = config.panels[panel.type] || getPanelPluginNotFound(panel.type);
 
-      if (this.pluginInfo.exports) {
+      if (plugin.exports) {
         this.cleanUpAngularPanel();
-        this.setState({ pluginExports: this.pluginInfo.exports });
+        this.setState({ plugin: plugin });
       } else {
-        importPluginModule(this.pluginInfo.module).then(pluginExports => {
+        importPluginModule(plugin.module).then(pluginExports => {
           this.cleanUpAngularPanel();
           // cache plugin exports (saves a promise async cycle next time)
-          this.pluginInfo.exports = pluginExports;
+          plugin.exports = pluginExports;
           // update panel state
-          this.setState({ pluginExports: pluginExports });
+          this.setState({ plugin: plugin });
         });
       }
     }
@@ -112,50 +117,60 @@ export class DashboardPanel extends PureComponent<Props, State> {
     this.cleanUpAngularPanel();
   }
 
+  onMouseEnter = () => {
+    this.props.dashboard.setPanelFocus(this.props.panel.id);
+  };
+
+  onMouseLeave = () => {
+    this.props.dashboard.setPanelFocus(0);
+  };
+
   renderReactPanel() {
-    const { pluginExports } = this.state;
+    const { dashboard, panel } = this.props;
+    const { plugin } = this.state;
+
     const containerClass = this.props.isEditing ? 'panel-editor-container' : 'panel-height-helper';
     const panelWrapperClass = this.props.isEditing ? 'panel-editor-container__panel' : 'panel-height-helper';
     // this might look strange with these classes that change when edit, but
     // I want to try to keep markup (parents) for panel the same in edit mode to avoide unmount / new mount of panel
     return (
       <div className={containerClass}>
-        <div className={panelWrapperClass}>
-          <PanelChrome
-            component={pluginExports.PanelComponent}
-            panel={this.props.panel}
-            dashboard={this.props.dashboard}
-          />
+        <div className={panelWrapperClass} onMouseEnter={this.onMouseEnter} onMouseLeave={this.onMouseLeave}>
+          <PanelChrome component={plugin.exports.Panel} panel={panel} dashboard={dashboard} />
         </div>
-        {this.props.panel.isEditing && (
-          <div className="panel-editor-container__editor">
-            <PanelEditor
-              panel={this.props.panel}
-              panelType={this.props.panel.type}
-              dashboard={this.props.dashboard}
-              onTypeChanged={this.onPluginTypeChanged}
-              pluginExports={pluginExports}
-            />
-          </div>
+        {panel.isEditing && (
+          <PanelEditor panel={panel} plugin={plugin} dashboard={dashboard} onTypeChanged={this.onPluginTypeChanged} />
         )}
       </div>
     );
   }
 
   render() {
+    const { panel } = this.props;
+    const { plugin } = this.state;
+
     if (this.isSpecial()) {
-      return this.specialPanels[this.props.panel.type]();
+      return this.specialPanels[panel.type]();
     }
 
-    if (!this.state.pluginExports) {
+    // if we have not loaded plugin exports yet, wait
+    if (!plugin || !plugin.exports) {
       return null;
     }
 
-    if (this.state.pluginExports.PanelComponent) {
+    // if exporting PanelComponent it must be a react panel
+    if (plugin.exports.Panel) {
       return this.renderReactPanel();
     }
 
     // legacy angular rendering
-    return <div ref={element => (this.element = element)} className="panel-height-helper" />;
+    return (
+      <div
+        ref={element => (this.element = element)}
+        className="panel-height-helper"
+        onMouseEnter={this.onMouseEnter}
+        onMouseLeave={this.onMouseLeave}
+      />
+    );
   }
 }

+ 44 - 15
public/app/features/dashboard/dashgrid/DataPanel.tsx

@@ -2,7 +2,10 @@
 import React, { Component } from 'react';
 
 // Services
-import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { getDatasourceSrv, DatasourceSrv } from 'app/features/plugins/datasource_srv';
+
+// Utils
+import kbn from 'app/core/utils/kbn';
 
 // Types
 import { TimeRange, LoadingState, DataQueryOptions, DataQueryResponse, TimeSeries } from 'app/types';
@@ -19,7 +22,10 @@ export interface Props {
   dashboardId?: number;
   isVisible?: boolean;
   timeRange?: TimeRange;
+  widthPixels: number;
   refreshCounter: number;
+  minInterval?: string;
+  maxDataPoints?: number;
   children: (r: RenderProps) => JSX.Element;
 }
 
@@ -36,6 +42,9 @@ export class DataPanel extends Component<Props, State> {
     dashboardId: 1,
   };
 
+  dataSourceSrv: DatasourceSrv = getDatasourceSrv();
+  isUnmounted = false;
+
   constructor(props: Props) {
     super(props);
 
@@ -48,6 +57,14 @@ export class DataPanel extends Component<Props, State> {
     };
   }
 
+  componentDidMount() {
+    this.issueQueries();
+  }
+
+  componentWillUnmount() {
+    this.isUnmounted = true;
+  }
+
   async componentDidUpdate(prevProps: Props) {
     if (!this.hasPropsChanged(prevProps)) {
       return;
@@ -57,11 +74,11 @@ export class DataPanel extends Component<Props, State> {
   }
 
   hasPropsChanged(prevProps: Props) {
-    return this.props.refreshCounter !== prevProps.refreshCounter || this.props.isVisible !== prevProps.isVisible;
+    return this.props.refreshCounter !== prevProps.refreshCounter;
   }
 
-  issueQueries = async () => {
-    const { isVisible, queries, datasource, panelId, dashboardId, timeRange } = this.props;
+  private issueQueries = async () => {
+    const { isVisible, queries, datasource, panelId, dashboardId, timeRange, widthPixels, maxDataPoints } = this.props;
 
     if (!isVisible) {
       return;
@@ -75,8 +92,11 @@ export class DataPanel extends Component<Props, State> {
     this.setState({ loading: LoadingState.Loading });
 
     try {
-      const dataSourceSrv = getDatasourceSrv();
-      const ds = await dataSourceSrv.get(datasource);
+      const ds = await this.dataSourceSrv.get(datasource);
+
+      // TODO interpolate variables
+      const minInterval = this.props.minInterval || ds.interval;
+      const intervalRes = kbn.calculateInterval(timeRange, widthPixels, minInterval);
 
       const queryOptions: DataQueryOptions = {
         timezone: 'browser',
@@ -84,10 +104,10 @@ export class DataPanel extends Component<Props, State> {
         dashboardId: dashboardId,
         range: timeRange,
         rangeRaw: timeRange.raw,
-        interval: '1s',
-        intervalMs: 60000,
+        interval: intervalRes.interval,
+        intervalMs: intervalRes.intervalMs,
         targets: queries,
-        maxDataPoints: 500,
+        maxDataPoints: maxDataPoints || widthPixels,
         scopedVars: {},
         cacheTimeout: null,
       };
@@ -96,6 +116,10 @@ export class DataPanel extends Component<Props, State> {
       const resp = await ds.query(queryOptions);
       console.log('Issuing DataPanel query Resp', resp);
 
+      if (this.isUnmounted) {
+        return;
+      }
+
       this.setState({
         loading: LoadingState.Done,
         response: resp,
@@ -108,21 +132,26 @@ export class DataPanel extends Component<Props, State> {
   };
 
   render() {
+    const { queries } = this.props;
     const { response, loading, isFirstLoad } = this.state;
 
     const timeSeries = response.data;
 
-    if (isFirstLoad && (loading === LoadingState.Loading || loading === LoadingState.NotStarted)) {
+    if (isFirstLoad && loading === LoadingState.Loading) {
+      return this.renderLoadingSpinner();
+    }
+
+    if (!queries.length) {
       return (
-        <div className="loading">
-          <p>Loading</p>
+        <div className="panel-empty">
+          <p>Add a query to get some data!</p>
         </div>
       );
     }
 
     return (
       <>
-        {this.loadingSpinner}
+        {this.renderLoadingSpinner()}
         {this.props.children({
           timeSeries,
           loading,
@@ -131,12 +160,12 @@ export class DataPanel extends Component<Props, State> {
     );
   }
 
-  private get loadingSpinner(): JSX.Element {
+  private renderLoadingSpinner(): JSX.Element {
     const { loading } = this.state;
 
     if (loading === LoadingState.Loading) {
       return (
-        <div className="panel__loading">
+        <div className="panel-loading">
           <i className="fa fa-spinner fa-spin" />
         </div>
       );

+ 88 - 0
public/app/features/dashboard/dashgrid/DataSourcePicker.tsx

@@ -0,0 +1,88 @@
+import React, { PureComponent } from 'react';
+import classNames from 'classnames';
+import _ from 'lodash';
+
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { DataSourceSelectItem } from 'app/types';
+
+interface Props {}
+
+interface State {
+  datasources: DataSourceSelectItem[];
+  searchQuery: string;
+}
+
+export class DataSourcePicker extends PureComponent<Props, State> {
+  searchInput: HTMLElement;
+
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      datasources: getDatasourceSrv().getMetricSources(),
+      searchQuery: '',
+    };
+  }
+
+  getDataSources() {
+    const { datasources, searchQuery } = this.state;
+    const regex = new RegExp(searchQuery, 'i');
+
+    const filtered = datasources.filter(item => {
+      return regex.test(item.name) || regex.test(item.meta.name);
+    });
+
+    return _.sortBy(filtered, 'sort');
+  }
+
+  renderDataSource = (ds: DataSourceSelectItem, index) => {
+    const cssClass = classNames({
+      'ds-picker-list__item': true,
+    });
+
+    return (
+      <div key={index} className={cssClass} title={ds.name}>
+        <img className="ds-picker-list__img" src={ds.meta.info.logos.small} />
+        <div className="ds-picker-list__name">{ds.name}</div>
+      </div>
+    );
+  };
+
+  componentDidMount() {
+    setTimeout(() => {
+      this.searchInput.focus();
+    }, 300);
+  }
+
+  renderFilters() {
+    return (
+      <>
+        <label className="gf-form--has-input-icon">
+          <input
+            type="text"
+            className="gf-form-input width-13"
+            placeholder=""
+            ref={elem => (this.searchInput = elem)}
+          />
+          <i className="gf-form-input-icon fa fa-search" />
+        </label>
+        <div className="p-l-1">
+          <button className="btn toggle-btn gf-form-btn active">All</button>
+          <button className="btn toggle-btn gf-form-btn">Favorites</button>
+        </div>
+      </>
+    );
+  }
+
+  render() {
+    return (
+      <>
+        <div className="cta-form__bar">
+          {this.renderFilters()}
+          <div className="gf-form--grow" />
+        </div>
+        <div className="ds-picker-list">{this.getDataSources().map(this.renderDataSource)}</div>
+      </>
+    );
+  }
+}

+ 96 - 0
public/app/features/dashboard/dashgrid/EditorTabBody.tsx

@@ -0,0 +1,96 @@
+import React, { PureComponent } from 'react';
+import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
+import { FadeIn } from 'app/core/components/Animations/FadeIn';
+
+interface Props {
+  children: JSX.Element;
+  main: EditorToolBarView;
+  toolbarItems: EditorToolBarView[];
+}
+
+export interface EditorToolBarView {
+  title: string;
+  imgSrc?: string;
+  icon?: string;
+  render: () => JSX.Element;
+}
+
+interface State {
+  openView?: EditorToolBarView;
+}
+
+export class EditorTabBody extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      openView: null,
+    };
+  }
+
+  onToggleToolBarView = (item: EditorToolBarView) => {
+    this.setState({
+      openView: item === this.state.openView ? null : item,
+    });
+  };
+
+  onCloseOpenView = () => {
+    this.setState({ openView: null });
+  };
+
+  renderMainSelection(view: EditorToolBarView) {
+    return (
+      <div className="toolbar__main" onClick={() => this.onToggleToolBarView(view)} key={view.title}>
+        <img className="toolbar__main-image" src={view.imgSrc} />
+        <div className="toolbar__main-name">{view.title}</div>
+        <i className="fa fa-caret-down" />
+      </div>
+    );
+  }
+
+  renderButton(view: EditorToolBarView) {
+    return (
+      <div className="nav-buttons" key={view.title}>
+        <button className="btn navbar-button" onClick={() => this.onToggleToolBarView(view)}>
+          {view.icon && <i className={view.icon} />} {view.title}
+        </button>
+      </div>
+    );
+  }
+
+  renderOpenView(view: EditorToolBarView) {
+    return (
+      <div className="toolbar-subview">
+        <button className="toolbar-subview__close" onClick={this.onCloseOpenView}>
+          <i className="fa fa-chevron-up" />
+        </button>
+        {view.render()}
+      </div>
+    );
+  }
+
+  render() {
+    const { children, toolbarItems, main } = this.props;
+    const { openView } = this.state;
+
+    return (
+      <>
+        <div className="toolbar">
+          {this.renderMainSelection(main)}
+          <div className="gf-form--grow" />
+          {toolbarItems.map(item => this.renderButton(item))}
+        </div>
+        <div className="panel-editor__scroll">
+          <CustomScrollbar autoHide={false}>
+            <div className="panel-editor__content">
+              <FadeIn in={openView !== null} duration={200}>
+                {openView && this.renderOpenView(openView)}
+              </FadeIn>
+              {children}
+            </div>
+          </CustomScrollbar>
+        </div>
+      </>
+    );
+  }
+}

+ 60 - 35
public/app/features/dashboard/dashgrid/PanelChrome.tsx

@@ -1,13 +1,18 @@
 // Libraries
 import React, { ComponentClass, PureComponent } from 'react';
+import { AutoSizer } from 'react-virtualized';
 
 // Services
-import { getTimeSrv } from '../time_srv';
+import { getTimeSrv, TimeSrv } from '../time_srv';
 
 // Components
 import { PanelHeader } from './PanelHeader/PanelHeader';
 import { DataPanel } from './DataPanel';
 
+// Utils
+import { applyPanelTimeOverrides } from 'app/features/dashboard/utils/panel';
+import { PANEL_HEADER_HEIGHT } from 'app/core/constants';
+
 // Types
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
@@ -22,10 +27,13 @@ export interface Props {
 export interface State {
   refreshCounter: number;
   renderCounter: number;
+  timeInfo?: string;
   timeRange?: TimeRange;
 }
 
 export class PanelChrome extends PureComponent<Props, State> {
+  timeSrv: TimeSrv = getTimeSrv();
+
   constructor(props) {
     super(props);
 
@@ -46,21 +54,25 @@ export class PanelChrome extends PureComponent<Props, State> {
   }
 
   onRefresh = () => {
-    const timeSrv = getTimeSrv();
-    const timeRange = timeSrv.timeRange();
+    console.log('onRefresh');
+    if (!this.isVisible) {
+      return;
+    }
+
+    const { panel } = this.props;
+    const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
 
-    this.setState(prevState => ({
-      ...prevState,
+    this.setState({
       refreshCounter: this.state.refreshCounter + 1,
-      timeRange: timeRange,
-    }));
+      timeRange: timeData.timeRange,
+      timeInfo: timeData.timeInfo,
+    });
   };
 
   onRender = () => {
-    this.setState(prevState => ({
-      ...prevState,
+    this.setState({
       renderCounter: this.state.renderCounter + 1,
-    }));
+    });
   };
 
   get isVisible() {
@@ -69,36 +81,49 @@ export class PanelChrome extends PureComponent<Props, State> {
 
   render() {
     const { panel, dashboard } = this.props;
-    const { refreshCounter, timeRange, renderCounter } = this.state;
+    const { refreshCounter, timeRange, timeInfo, renderCounter } = this.state;
 
     const { datasource, targets } = panel;
     const PanelComponent = this.props.component;
 
     return (
-      <div className="panel-container">
-        <PanelHeader panel={panel} dashboard={dashboard} />
-        <div className="panel-content">
-          <DataPanel
-            datasource={datasource}
-            queries={targets}
-            timeRange={timeRange}
-            isVisible={this.isVisible}
-            refreshCounter={refreshCounter}
-          >
-            {({ loading, timeSeries }) => {
-              return (
-                <PanelComponent
-                  loading={loading}
-                  timeSeries={timeSeries}
-                  timeRange={timeRange}
-                  options={panel.getOptions()}
-                  renderCounter={renderCounter}
-                />
-              );
-            }}
-          </DataPanel>
-        </div>
-      </div>
+      <AutoSizer>
+        {({ width, height }) => {
+          if (width === 0) {
+            return null;
+          }
+
+          return (
+            <div className="panel-container panel-container--absolute">
+              <PanelHeader panel={panel} dashboard={dashboard} timeInfo={timeInfo} />
+              <DataPanel
+                datasource={datasource}
+                queries={targets}
+                timeRange={timeRange}
+                isVisible={this.isVisible}
+                widthPixels={width}
+                refreshCounter={refreshCounter}
+              >
+                {({ loading, timeSeries }) => {
+                  return (
+                    <div className="panel-content">
+                      <PanelComponent
+                        loading={loading}
+                        timeSeries={timeSeries}
+                        timeRange={timeRange}
+                        options={panel.getOptions()}
+                        width={width}
+                        height={height - PANEL_HEADER_HEIGHT}
+                        renderCounter={renderCounter}
+                      />
+                    </div>
+                  );
+                }}
+              </DataPanel>
+            </div>
+          );
+        }}
+      </AutoSizer>
     );
   }
 }

+ 31 - 49
public/app/features/dashboard/dashgrid/PanelEditor.tsx

@@ -2,20 +2,19 @@ import React, { PureComponent } from 'react';
 import classNames from 'classnames';
 
 import { QueriesTab } from './QueriesTab';
-import { VizTypePicker } from './VizTypePicker';
+import { VisualizationTab } from './VisualizationTab';
 
-import { store } from 'app/store/configureStore';
+import { store } from 'app/store/store';
 import { updateLocation } from 'app/core/actions';
 
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
-import { PanelPlugin, PluginExports } from 'app/types/plugins';
+import { PanelPlugin } from 'app/types/plugins';
 
 interface PanelEditorProps {
   panel: PanelModel;
   dashboard: DashboardModel;
-  panelType: string;
-  pluginExports: PluginExports;
+  plugin: PanelPlugin;
   onTypeChanged: (newType: PanelPlugin) => void;
 }
 
@@ -34,43 +33,10 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
     this.tabs = [
       { id: 'queries', text: 'Queries', icon: 'fa fa-database' },
       { id: 'visualization', text: 'Visualization', icon: 'fa fa-line-chart' },
+      { id: 'alert', text: 'Alert', icon: 'gicon gicon-alert' },
     ];
   }
 
-  renderQueriesTab() {
-    return <QueriesTab panel={this.props.panel} dashboard={this.props.dashboard} />;
-  }
-
-  renderPanelOptions() {
-    const { pluginExports, panel } = this.props;
-
-    if (pluginExports.PanelOptionsComponent) {
-      const OptionsComponent = pluginExports.PanelOptionsComponent;
-      return <OptionsComponent options={panel.getOptions()} onChange={this.onPanelOptionsChanged} />;
-    } else {
-      return <p>Visualization has no options</p>;
-    }
-  }
-
-  onPanelOptionsChanged = (options: any) => {
-    this.props.panel.updateOptions(options);
-    this.forceUpdate();
-  };
-
-  renderVizTab() {
-    return (
-      <div className="viz-editor">
-        <div className="viz-editor-col1">
-          <VizTypePicker currentType={this.props.panel.type} onTypeChanged={this.props.onTypeChanged} />
-        </div>
-        <div className="viz-editor-col2">
-          <h5 className="page-heading">Options</h5>
-          {this.renderPanelOptions()}
-        </div>
-      </div>
-    );
-  }
-
   onChangeTab = (tab: PanelEditorTab) => {
     store.dispatch(
       updateLocation({
@@ -81,28 +47,44 @@ export class PanelEditor extends PureComponent<PanelEditorProps> {
     this.forceUpdate();
   };
 
+  onClose = () => {
+    store.dispatch(
+      updateLocation({
+        query: { tab: null, fullscreen: null, edit: null },
+        partial: true,
+      })
+    );
+  };
+
   render() {
+    const { panel, dashboard, onTypeChanged, plugin } = this.props;
     const { location } = store.getState();
     const activeTab = location.query.tab || 'queries';
 
     return (
-      <div className="tabbed-view tabbed-view--new">
-        <div className="tabbed-view-header">
+      <div className="panel-editor-container__editor">
+        <div className="panel-editor-resizer">
+          <div className="panel-editor-resizer__handle">
+            <div className="panel-editor-resizer__handle-dots" />
+          </div>
+        </div>
+
+        <div className="panel-editor-tabs">
           <ul className="gf-tabs">
             {this.tabs.map(tab => {
               return <TabItem tab={tab} activeTab={activeTab} onClick={this.onChangeTab} key={tab.id} />;
             })}
           </ul>
 
-          <button className="tabbed-view-close-btn" ng-click="ctrl.exitFullscreen();">
-            <i className="fa fa-remove" />
+          <button className="panel-editor-tabs__close" onClick={this.onClose}>
+            <i className="fa fa-reply" />
           </button>
         </div>
 
-        <div className="tabbed-view-body">
-          {activeTab === 'queries' && this.renderQueriesTab()}
-          {activeTab === 'visualization' && this.renderVizTab()}
-        </div>
+        {activeTab === 'queries' && <QueriesTab panel={panel} dashboard={dashboard} />}
+        {activeTab === 'visualization' && (
+          <VisualizationTab panel={panel} dashboard={dashboard} plugin={plugin} onTypeChanged={onTypeChanged} />
+        )}
       </div>
     );
   }
@@ -121,8 +103,8 @@ function TabItem({ tab, activeTab, onClick }: TabItemParams) {
   });
 
   return (
-    <li className="gf-tabs-item" key={tab.id}>
-      <a className={tabClasses} onClick={() => onClick(tab)}>
+    <li className="gf-tabs-item" onClick={() => onClick(tab)}>
+      <a className={tabClasses}>
         <i className={tab.icon} /> {tab.text}
       </a>
     </li>

+ 52 - 25
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeader.tsx

@@ -1,51 +1,78 @@
 import React, { PureComponent } from 'react';
 import classNames from 'classnames';
 
+import PanelHeaderCorner from './PanelHeaderCorner';
 import { PanelHeaderMenu } from './PanelHeaderMenu';
 
 import { DashboardModel } from 'app/features/dashboard/dashboard_model';
 import { PanelModel } from 'app/features/dashboard/panel_model';
+import { ClickOutsideWrapper } from 'app/core/components/ClickOutsideWrapper/ClickOutsideWrapper';
 
 export interface Props {
   panel: PanelModel;
   dashboard: DashboardModel;
+  timeInfo: string;
 }
 
-export class PanelHeader extends PureComponent<Props> {
+interface State {
+  panelMenuOpen: boolean;
+}
+
+export class PanelHeader extends PureComponent<Props, State> {
+  state = {
+    panelMenuOpen: false,
+  };
+
+  onMenuToggle = event => {
+    event.stopPropagation();
+
+    this.setState(prevState => ({
+      panelMenuOpen: !prevState.panelMenuOpen,
+    }));
+  };
+
+  closeMenu = () => {
+    this.setState({
+      panelMenuOpen: false,
+    });
+  };
+
   render() {
     const isFullscreen = false;
     const isLoading = false;
     const panelHeaderClass = classNames({ 'panel-header': true, 'grid-drag-handle': !isFullscreen });
-    const { panel, dashboard } = this.props;
-
+    const { panel, dashboard, timeInfo } = this.props;
     return (
-      <div className={panelHeaderClass}>
-        <span className="panel-info-corner">
-          <i className="fa" />
-          <span className="panel-info-corner-inner" />
-        </span>
-
-        {isLoading && (
-          <span className="panel-loading">
-            <i className="fa fa-spinner fa-spin" />
-          </span>
-        )}
-
-        <div className="panel-title-container">
-          <div className="panel-title">
-            <span className="icon-gf panel-alert-icon" />
-            <span className="panel-title-text" data-toggle="dropdown">
-              {panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
+      <>
+        <PanelHeaderCorner panel={panel} />
+        <div className={panelHeaderClass}>
+          {isLoading && (
+            <span className="panel-loading">
+              <i className="fa fa-spinner fa-spin" />
             </span>
+          )}
+          <div className="panel-title-container" onClick={this.onMenuToggle}>
+            <div className="panel-title">
+              <span className="icon-gf panel-alert-icon" />
+              <span className="panel-title-text">
+                {panel.title} <span className="fa fa-caret-down panel-menu-toggle" />
+              </span>
 
-            <PanelHeaderMenu panel={panel} dashboard={dashboard} />
+              {this.state.panelMenuOpen && (
+                <ClickOutsideWrapper onClick={this.closeMenu}>
+                  <PanelHeaderMenu panel={panel} dashboard={dashboard} />
+                </ClickOutsideWrapper>
+              )}
 
-            <span className="panel-time-info">
-              <i className="fa fa-clock-o" /> 4m
-            </span>
+              {timeInfo && (
+                <span className="panel-time-info">
+                  <i className="fa fa-clock-o" /> {timeInfo}
+                </span>
+              )}
+            </div>
           </div>
         </div>
-      </div>
+      </>
     );
   }
 }

+ 89 - 0
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderCorner.tsx

@@ -0,0 +1,89 @@
+import React, { PureComponent } from 'react';
+import { PanelModel } from 'app/features/dashboard/panel_model';
+import Tooltip from 'app/core/components/Tooltip/Tooltip';
+import templateSrv from 'app/features/templating/template_srv';
+import { LinkSrv } from 'app/features/dashboard/panellinks/link_srv';
+import { getTimeSrv, TimeSrv } from 'app/features/dashboard/time_srv';
+import Remarkable from 'remarkable';
+
+enum InfoModes {
+  Error = 'Error',
+  Info = 'Info',
+  Links = 'Links',
+}
+
+interface Props {
+  panel: PanelModel;
+}
+
+export class PanelHeaderCorner extends PureComponent<Props> {
+  timeSrv: TimeSrv = getTimeSrv();
+
+  getInfoMode = () => {
+    const { panel } = this.props;
+    if (!!panel.description) {
+      return InfoModes.Info;
+    }
+    if (panel.links && panel.links.length) {
+      return InfoModes.Links;
+    }
+
+    return undefined;
+  };
+
+  getInfoContent = (): JSX.Element => {
+    const { panel } = this.props;
+    const markdown = panel.description;
+    const linkSrv = new LinkSrv(templateSrv, this.timeSrv);
+    const interpolatedMarkdown = templateSrv.replace(markdown, panel.scopedVars);
+    const remarkableInterpolatedMarkdown = new Remarkable().render(interpolatedMarkdown);
+
+    const html = (
+      <div className="markdown-html">
+        <div dangerouslySetInnerHTML={{ __html: remarkableInterpolatedMarkdown }} />
+        {panel.links &&
+          panel.links.length > 0 && (
+            <ul>
+              {panel.links.map((link, idx) => {
+                const info = linkSrv.getPanelLinkAnchorInfo(link, panel.scopedVars);
+                return (
+                  <li key={idx}>
+                    <a className="panel-menu-link" href={info.href} target={info.target}>
+                      {info.title}
+                    </a>
+                  </li>
+                );
+              })}
+            </ul>
+          )}
+      </div>
+    );
+
+    return html;
+  };
+
+  render() {
+    const infoMode: InfoModes | undefined = this.getInfoMode();
+
+    if (!infoMode) {
+      return null;
+    }
+
+    return (
+      <>
+        {infoMode === InfoModes.Info || infoMode === InfoModes.Links ? (
+          <Tooltip
+            content={this.getInfoContent}
+            className="absolute"
+            refClassName={`panel-info-corner panel-info-corner--${infoMode.toLowerCase()}`}
+          >
+            <i className="fa" />
+            <span className="panel-info-corner-inner" />
+          </Tooltip>
+        ) : null}
+      </>
+    );
+  }
+}
+
+export default PanelHeaderCorner;

+ 2 - 1
public/app/features/dashboard/dashgrid/PanelHeader/PanelHeaderMenu.tsx

@@ -35,6 +35,7 @@ export class PanelHeaderMenu extends PureComponent<Props> {
   render() {
     const { dashboard, panel } = this.props;
     const menu = getPanelMenu(dashboard, panel);
-    return <div className="panel-menu-container dropdown">{this.renderItems(menu)}</div>;
+
+    return <div className="panel-menu-container dropdown open">{this.renderItems(menu)}</div>;
   }
 }

+ 64 - 0
public/app/features/dashboard/dashgrid/PanelPluginNotFound.tsx

@@ -0,0 +1,64 @@
+import _ from 'lodash';
+import React, { PureComponent } from 'react';
+import { PanelPlugin, PanelProps } from 'app/types';
+
+interface Props {
+  pluginId: string;
+}
+
+class PanelPluginNotFound extends PureComponent<Props> {
+  constructor(props) {
+    super(props);
+  }
+
+  render() {
+    const style = {
+      display: 'flex',
+      alignItems: 'center',
+      textAlign: 'center' as 'center',
+      height: '100%',
+    };
+
+    return (
+      <div style={style}>
+        <div className="alert alert-error" style={{ margin: '0 auto' }}>
+          Panel plugin with id {this.props.pluginId} could not be found
+        </div>
+      </div>
+    );
+  }
+}
+
+export function getPanelPluginNotFound(id: string): PanelPlugin {
+  const NotFound = class NotFound extends PureComponent<PanelProps> {
+    render() {
+      return <PanelPluginNotFound pluginId={id} />;
+    }
+  };
+
+  return {
+    id: id,
+    name: id,
+    sort: 100,
+    module: '',
+    baseUrl: '',
+    info: {
+      author: {
+        name: '',
+      },
+      description: '',
+      links: [],
+      logos: {
+        large: '',
+        small: '',
+      },
+      screenshots: [],
+      updated: '',
+      version: '',
+    },
+
+    exports: {
+      Panel: NotFound,
+    },
+  };
+}

+ 24 - 4
public/app/features/dashboard/dashgrid/QueriesTab.tsx

@@ -1,10 +1,9 @@
-// Libraries
 import React, { PureComponent } from 'react';
 
-// Services & utils
 import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+import { EditorTabBody } from './EditorTabBody';
+import { DataSourcePicker } from './DataSourcePicker';
 
-// Types
 import { PanelModel } from '../panel_model';
 import { DashboardModel } from '../dashboard_model';
 
@@ -48,6 +47,27 @@ export class QueriesTab extends PureComponent<Props> {
   }
 
   render() {
-    return <div ref={element => (this.element = element)} className="panel-height-helper" />;
+    const currentDataSource = {
+      title: 'ProductionDB',
+      imgSrc: 'public/app/plugins/datasource/prometheus/img/prometheus_logo.svg',
+      render: () => <DataSourcePicker />,
+    };
+
+    const queryInspector = {
+      title: 'Query Inspector',
+      render: () => <h2>hello</h2>,
+    };
+
+    const dsHelp = {
+      title: '',
+      icon: 'fa fa-question',
+      render: () => <h2>hello</h2>,
+    };
+
+    return (
+      <EditorTabBody main={currentDataSource} toolbarItems={[queryInspector, dsHelp]}>
+        <div ref={element => (this.element = element)} style={{ width: '100%' }} />
+      </EditorTabBody>
+    );
   }
 }

+ 57 - 0
public/app/features/dashboard/dashgrid/VisualizationTab.tsx

@@ -0,0 +1,57 @@
+import React, { PureComponent } from 'react';
+
+import { EditorTabBody } from './EditorTabBody';
+import { VizTypePicker } from './VizTypePicker';
+
+import { PanelModel } from '../panel_model';
+import { DashboardModel } from '../dashboard_model';
+import { PanelPlugin } from 'app/types/plugins';
+
+interface Props {
+  panel: PanelModel;
+  dashboard: DashboardModel;
+  plugin: PanelPlugin;
+  onTypeChanged: (newType: PanelPlugin) => void;
+}
+
+export class VisualizationTab extends PureComponent<Props> {
+  constructor(props) {
+    super(props);
+  }
+
+  renderPanelOptions() {
+    const { plugin, panel } = this.props;
+    const { PanelOptions } = plugin.exports;
+
+    if (PanelOptions) {
+      return <PanelOptions options={panel.getOptions()} onChange={this.onPanelOptionsChanged} />;
+    } else {
+      return <p>Visualization has no options</p>;
+    }
+  }
+
+  onPanelOptionsChanged = (options: any) => {
+    this.props.panel.updateOptions(options);
+    this.forceUpdate();
+  };
+
+  render() {
+    const { plugin } = this.props;
+
+    const panelSelection = {
+      title: plugin.name,
+      imgSrc: plugin.info.logos.small,
+      render: () => {
+        // the needs to be scoped inside this closure
+        const { plugin, onTypeChanged } = this.props;
+        return <VizTypePicker current={plugin} onTypeChanged={onTypeChanged} />;
+      },
+    };
+
+    return (
+      <EditorTabBody main={panelSelection} toolbarItems={[]}>
+        {this.renderPanelOptions()}
+      </EditorTabBody>
+    );
+  }
+}

+ 42 - 19
public/app/features/dashboard/dashgrid/VizTypePicker.tsx

@@ -1,12 +1,12 @@
 import React, { PureComponent } from 'react';
 import classNames from 'classnames';
+import _ from 'lodash';
+
 import config from 'app/core/config';
 import { PanelPlugin } from 'app/types/plugins';
-import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
-import _ from 'lodash';
 
 interface Props {
-  currentType: string;
+  current: PanelPlugin;
   onTypeChanged: (newType: PanelPlugin) => void;
 }
 
@@ -15,6 +15,8 @@ interface State {
 }
 
 export class VizTypePicker extends PureComponent<Props, State> {
+  searchInput: HTMLElement;
+
   constructor(props) {
     super(props);
 
@@ -36,34 +38,55 @@ export class VizTypePicker extends PureComponent<Props, State> {
   renderVizPlugin = (plugin, index) => {
     const cssClass = classNames({
       'viz-picker__item': true,
-      'viz-picker__item--selected': plugin.id === this.props.currentType,
+      'viz-picker__item--selected': plugin.id === this.props.current.id,
     });
 
     return (
       <div key={index} className={cssClass} onClick={() => this.props.onTypeChanged(plugin)} title={plugin.name}>
-        <img className="viz-picker__item-img" src={plugin.info.logos.small} />
         <div className="viz-picker__item-name">{plugin.name}</div>
+        <img className="viz-picker__item-img" src={plugin.info.logos.small} />
       </div>
     );
   };
 
-  render() {
+  componentDidMount() {
+    setTimeout(() => {
+      this.searchInput.focus();
+    }, 300);
+  }
+
+  renderFilters() {
     return (
-      <div className="viz-picker">
-        <div className="viz-picker__search">
-          <div className="gf-form gf-form--grow">
-            <label className="gf-form--has-input-icon gf-form--grow">
-              <input type="text" className="gf-form-input" placeholder="Search type" />
-              <i className="gf-form-input-icon fa fa-search" />
-            </label>
-          </div>
+      <>
+        <label className="gf-form--has-input-icon">
+          <input
+            type="text"
+            className="gf-form-input width-13"
+            placeholder=""
+            ref={elem => (this.searchInput = elem)}
+          />
+          <i className="gf-form-input-icon fa fa-search" />
+        </label>
+        <div className="p-l-1">
+          <button className="btn toggle-btn gf-form-btn active">Basic Types</button>
+          <button className="btn toggle-btn gf-form-btn">Master Types</button>
         </div>
-        <div className="viz-picker__items">
-          <CustomScrollbar>
-            <div className="scroll-margin-helper">{this.state.pluginList.map(this.renderVizPlugin)}</div>
-          </CustomScrollbar>
+      </>
+    );
+  }
+
+  render() {
+    const { pluginList } = this.state;
+
+    return (
+      <>
+        <div className="cta-form__bar">
+          {this.renderFilters()}
+          <div className="gf-form--grow" />
         </div>
-      </div>
+
+        <div className="viz-picker">{pluginList.map(this.renderVizPlugin)}</div>
+      </>
     );
   }
 }

+ 10 - 0
public/app/features/dashboard/panel_model.ts

@@ -42,6 +42,16 @@ export class PanelModel {
   datasource: string;
   thresholds?: any;
 
+  snapshotData?: any;
+  timeFrom?: any;
+  timeShift?: any;
+  hideTimeOverride?: any;
+
+  maxDataPoints?: number;
+  interval?: string;
+  description?: string;
+  links?: [];
+
   // non persisted
   fullscreen: boolean;
   isEditing: boolean;

+ 10 - 76
public/app/features/dashboard/specs/AddPanelPanel.test.tsx

@@ -3,6 +3,7 @@ import { AddPanelPanel } from './../dashgrid/AddPanelPanel';
 import { PanelModel } from '../panel_model';
 import { shallow } from 'enzyme';
 import config from '../../../core/config';
+import { getPanelPlugin } from 'app/features/plugins/__mocks__/pluginMocks';
 
 jest.mock('app/core/store', () => ({
   get: key => {
@@ -18,76 +19,11 @@ describe('AddPanelPanel', () => {
 
   beforeEach(() => {
     config.panels = [
-      {
-        id: 'singlestat',
-        hideFromList: false,
-        name: 'Singlestat',
-        sort: 2,
-        module: '',
-        baseUrl: '',
-        meta: {},
-        info: {
-          logos: {
-            small: '',
-          },
-        },
-      },
-      {
-        id: 'hidden',
-        hideFromList: true,
-        name: 'Hidden',
-        sort: 100,
-        meta: {},
-        module: '',
-        baseUrl: '',
-        info: {
-          logos: {
-            small: '',
-          },
-        },
-      },
-      {
-        id: 'graph',
-        hideFromList: false,
-        name: 'Graph',
-        sort: 1,
-        meta: {},
-        module: '',
-        baseUrl: '',
-        info: {
-          logos: {
-            small: '',
-          },
-        },
-      },
-      {
-        id: 'alexander_zabbix',
-        hideFromList: false,
-        name: 'Zabbix',
-        sort: 100,
-        meta: {},
-        module: '',
-        baseUrl: '',
-        info: {
-          logos: {
-            small: '',
-          },
-        },
-      },
-      {
-        id: 'piechart',
-        hideFromList: false,
-        name: 'Piechart',
-        sort: 100,
-        meta: {},
-        module: '',
-        baseUrl: '',
-        info: {
-          logos: {
-            small: '',
-          },
-        },
-      },
+      getPanelPlugin({ id: 'singlestat', sort: 2 }),
+      getPanelPlugin({ id: 'hidden', sort: 100, hideFromList: true }),
+      getPanelPlugin({ id: 'graph', sort: 1 }),
+      getPanelPlugin({ id: 'alexander_zabbix', sort: 100 }),
+      getPanelPlugin({ id: 'piechart', sort: 100 }),
     ];
 
     dashboardMock = { toggleRow: jest.fn() };
@@ -97,16 +33,14 @@ describe('AddPanelPanel', () => {
   });
 
   it('should fetch all panels sorted with core plugins first', () => {
-    //console.log(wrapper.debug());
-    //console.log(wrapper.find('.add-panel__item').get(0).props.title);
-    expect(wrapper.find('.add-panel__item').get(1).props.title).toBe('Singlestat');
-    expect(wrapper.find('.add-panel__item').get(4).props.title).toBe('Piechart');
+    expect(wrapper.find('.add-panel__item').get(1).props.title).toBe('singlestat');
+    expect(wrapper.find('.add-panel__item').get(4).props.title).toBe('piechart');
   });
 
   it('should filter', () => {
     wrapper.find('input').simulate('change', { target: { value: 'p' } });
 
-    expect(wrapper.find('.add-panel__item').get(1).props.title).toBe('Piechart');
-    expect(wrapper.find('.add-panel__item').get(0).props.title).toBe('Graph');
+    expect(wrapper.find('.add-panel__item').get(1).props.title).toBe('piechart');
+    expect(wrapper.find('.add-panel__item').get(0).props.title).toBe('graph');
   });
 });

+ 1 - 2
public/app/features/dashboard/time_srv.ts

@@ -20,7 +20,7 @@ export class TimeSrv {
   private autoRefreshBlocked: boolean;
 
   /** @ngInject */
-  constructor(private $rootScope, private $timeout, private $location, private timer, private contextSrv) {
+  constructor($rootScope, private $timeout, private $location, private timer, private contextSrv) {
     // default time
     this.time = { from: '6h', to: 'now' };
 
@@ -189,7 +189,6 @@ export class TimeSrv {
       this.$location.search(urlParams);
     }
 
-    this.$rootScope.appEvent('time-range-changed', this.time);
     this.$timeout(this.refreshDashboard.bind(this), 0);
   }
 

+ 1 - 1
public/app/features/dashboard/utils/getPanelMenu.ts

@@ -1,5 +1,5 @@
 import { updateLocation } from 'app/core/actions';
-import { store } from 'app/store/configureStore';
+import { store } from 'app/store/store';
 
 import { removePanel, duplicatePanel, copyPanel, editPanelJson, sharePanel } from 'app/features/dashboard/utils/panel';
 import { PanelModel } from 'app/features/dashboard/panel_model';

+ 83 - 2
public/app/features/dashboard/utils/panel.ts

@@ -1,7 +1,21 @@
-import appEvents from 'app/core/app_events';
+// Store
+import store from 'app/core/store';
+
+// Models
 import { DashboardModel } from 'app/features/dashboard/dashboard_model';
 import { PanelModel } from 'app/features/dashboard/panel_model';
-import store from 'app/core/store';
+import { TimeRange } from 'app/types/series';
+
+// Utils
+import { isString as _isString } from 'lodash';
+import * as rangeUtil from 'app/core/utils/rangeutil';
+import * as dateMath from 'app/core/utils/datemath';
+import appEvents from 'app/core/app_events';
+
+// Services
+import templateSrv from 'app/features/templating/template_srv';
+
+// Constants
 import { LS_PANEL_COPY_KEY } from 'app/core/constants';
 
 export const removePanel = (dashboard: DashboardModel, panel: PanelModel, ask: boolean) => {
@@ -84,3 +98,70 @@ export const toggleLegend = (panel: PanelModel) => {
   // panel.legend.show = !panel.legend.show;
   refreshPanel(panel);
 };
+
+export interface TimeOverrideResult {
+  timeRange: TimeRange;
+  timeInfo: string;
+}
+
+export function applyPanelTimeOverrides(panel: PanelModel, timeRange: TimeRange): TimeOverrideResult {
+  const newTimeData = {
+    timeInfo: '',
+    timeRange: timeRange,
+  };
+
+  if (panel.timeFrom) {
+    const timeFromInterpolated = templateSrv.replace(panel.timeFrom, panel.scopedVars);
+    const timeFromInfo = rangeUtil.describeTextRange(timeFromInterpolated);
+    if (timeFromInfo.invalid) {
+      newTimeData.timeInfo = 'invalid time override';
+      return newTimeData;
+    }
+
+    if (_isString(timeRange.raw.from)) {
+      const timeFromDate = dateMath.parse(timeFromInfo.from);
+      newTimeData.timeInfo = timeFromInfo.display;
+      newTimeData.timeRange = {
+        from: timeFromDate,
+        to: dateMath.parse(timeFromInfo.to),
+        raw: {
+          from: timeFromInfo.from,
+          to: timeFromInfo.to,
+        },
+      };
+    }
+  }
+
+  if (panel.timeShift) {
+    const timeShiftInterpolated = templateSrv.replace(panel.timeShift, panel.scopedVars);
+    const timeShiftInfo = rangeUtil.describeTextRange(timeShiftInterpolated);
+    if (timeShiftInfo.invalid) {
+      newTimeData.timeInfo = 'invalid timeshift';
+      return newTimeData;
+    }
+
+    const timeShift = '-' + timeShiftInterpolated;
+    newTimeData.timeInfo += ' timeshift ' + timeShift;
+    newTimeData.timeRange = {
+      from: dateMath.parseDateMath(timeShift, timeRange.from, false),
+      to: dateMath.parseDateMath(timeShift, timeRange.to, true),
+      raw: {
+        from: timeRange.from,
+        to: timeRange.to,
+      },
+    };
+  }
+
+  if (panel.hideTimeOverride) {
+    newTimeData.timeInfo = '';
+  }
+
+  return newTimeData;
+}
+
+export function getResolution(panel: PanelModel): number {
+  const htmlEl = document.getElementsByTagName('html')[0];
+  const width = htmlEl.getBoundingClientRect().width; // https://stackoverflow.com/a/21454625
+
+  return panel.maxDataPoints ? panel.maxDataPoints : Math.ceil(width * (panel.gridPos.w / 24));
+}

+ 0 - 125
public/app/features/datasources/DataSourceSettings.tsx

@@ -1,125 +0,0 @@
-import React, { PureComponent } from 'react';
-import { connect } from 'react-redux';
-import { DataSource, Plugin } from 'app/types';
-
-export interface Props {
-  dataSource: DataSource;
-  dataSourceMeta: Plugin;
-}
-interface State {
-  name: string;
-}
-
-enum DataSourceStates {
-  Alpha = 'alpha',
-  Beta = 'beta',
-}
-
-export class DataSourceSettings extends PureComponent<Props, State> {
-  constructor(props) {
-    super(props);
-
-    this.state = {
-      name: props.dataSource.name,
-    };
-  }
-
-  onNameChange = event => {
-    this.setState({
-      name: event.target.value,
-    });
-  };
-
-  onSubmit = event => {
-    event.preventDefault();
-    console.log(event);
-  };
-
-  onDelete = event => {
-    console.log(event);
-  };
-
-  isReadyOnly() {
-    return this.props.dataSource.readOnly === true;
-  }
-
-  shouldRenderInfoBox() {
-    const { state } = this.props.dataSourceMeta;
-
-    return state === DataSourceStates.Alpha || state === DataSourceStates.Beta;
-  }
-
-  getInfoText() {
-    const { dataSourceMeta } = this.props;
-
-    switch (dataSourceMeta.state) {
-      case DataSourceStates.Alpha:
-        return (
-          'This plugin is marked as being in alpha state, which means it is in early development phase and updates' +
-          ' will include breaking changes.'
-        );
-
-      case DataSourceStates.Beta:
-        return (
-          'This plugin is marked as being in a beta development state. This means it is in currently in active' +
-          ' development and could be missing important features.'
-        );
-    }
-
-    return null;
-  }
-
-  render() {
-    const { name } = this.state;
-
-    return (
-      <div>
-        <h3 className="page-sub-heading">Settings</h3>
-        <form onSubmit={this.onSubmit}>
-          <div className="gf-form-group">
-            <div className="gf-form-inline">
-              <div className="gf-form max-width-30">
-                <span className="gf-form-label width-10">Name</span>
-                <input
-                  className="gf-form-input max-width-23"
-                  type="text"
-                  value={name}
-                  placeholder="name"
-                  onChange={this.onNameChange}
-                  required
-                />
-              </div>
-            </div>
-          </div>
-          {this.shouldRenderInfoBox() && <div className="grafana-info-box">{this.getInfoText()}</div>}
-          {this.isReadyOnly() && (
-            <div className="grafana-info-box span8">
-              This datasource was added by config and cannot be modified using the UI. Please contact your server admin
-              to update this datasource.
-            </div>
-          )}
-          <div className="gf-form-button-row">
-            <button type="submit" className="btn btn-success" disabled={this.isReadyOnly()} onClick={this.onSubmit}>
-              Save &amp; Test
-            </button>
-            <button type="submit" className="btn btn-danger" disabled={this.isReadyOnly()} onClick={this.onDelete}>
-              Delete
-            </button>
-            <a className="btn btn-inverse" href="datasources">
-              Back
-            </a>
-          </div>
-        </form>
-      </div>
-    );
-  }
-}
-
-function mapStateToProps(state) {
-  return {
-    dataSource: state.dataSources.dataSource,
-    dataSourceMeta: state.dataSources.dataSourceMeta,
-  };
-}
-
-export default connect(mapStateToProps)(DataSourceSettings);

+ 3 - 0
public/app/features/datasources/__mocks__/dataSourcesMocks.ts

@@ -29,6 +29,9 @@ export const getMockDataSource = (): DataSource => {
   return {
     access: '',
     basicAuth: false,
+    basicAuthUser: '',
+    basicAuthPassword: '',
+    withCredentials: false,
     database: '',
     id: 13,
     isDefault: false,

+ 20 - 0
public/app/features/datasources/settings/BasicSettings.test.tsx

@@ -0,0 +1,20 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import BasicSettings, { Props } from './BasicSettings';
+
+const setup = () => {
+  const props: Props = {
+    dataSourceName: 'Graphite',
+    onChange: jest.fn(),
+  };
+
+  return shallow(<BasicSettings {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 34 - 0
public/app/features/datasources/settings/BasicSettings.tsx

@@ -0,0 +1,34 @@
+import React, { SFC } from 'react';
+import { Label } from 'app/core/components/Label/Label';
+
+export interface Props {
+  dataSourceName: string;
+  onChange: (name: string) => void;
+}
+
+const BasicSettings: SFC<Props> = ({ dataSourceName, onChange }) => {
+  return (
+    <div className="gf-form-group">
+      <div className="gf-form max-width-30">
+        <Label
+          tooltip={
+            'The name is used when you select the data source in panels. The Default data source is' +
+            'preselected in new panels.'
+          }
+        >
+          Name
+        </Label>
+        <input
+          className="gf-form-input max-width-23"
+          type="text"
+          value={dataSourceName}
+          placeholder="Name"
+          onChange={event => onChange(event.target.value)}
+          required
+        />
+      </div>
+    </div>
+  );
+};
+
+export default BasicSettings;

+ 31 - 0
public/app/features/datasources/settings/ButtonRow.test.tsx

@@ -0,0 +1,31 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import ButtonRow, { Props } from './ButtonRow';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    isReadOnly: true,
+    onSubmit: jest.fn(),
+    onDelete: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<ButtonRow {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render with buttons enabled', () => {
+    const wrapper = setup({
+      isReadOnly: false,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 25 - 0
public/app/features/datasources/settings/ButtonRow.tsx

@@ -0,0 +1,25 @@
+import React, { SFC } from 'react';
+
+export interface Props {
+  isReadOnly: boolean;
+  onDelete: () => void;
+  onSubmit: (event) => void;
+}
+
+const ButtonRow: SFC<Props> = ({ isReadOnly, onDelete, onSubmit }) => {
+  return (
+    <div className="gf-form-button-row">
+      <button type="submit" className="btn btn-success" disabled={isReadOnly} onClick={event => onSubmit(event)}>
+        Save &amp; Test
+      </button>
+      <button type="submit" className="btn btn-danger" disabled={isReadOnly} onClick={onDelete}>
+        Delete
+      </button>
+      <a className="btn btn-inverse" href="/datasources">
+        Back
+      </a>
+    </div>
+  );
+};
+
+export default ButtonRow;

+ 63 - 0
public/app/features/datasources/settings/DataSourceSettings.test.tsx

@@ -0,0 +1,63 @@
+import React from 'react';
+import { shallow } from 'enzyme';
+import { DataSourceSettings, Props } from './DataSourceSettings';
+import { DataSource, NavModel } from '../../../types';
+import { getMockDataSource } from '../__mocks__/dataSourcesMocks';
+import { getMockPlugin } from '../../plugins/__mocks__/pluginMocks';
+
+const setup = (propOverrides?: object) => {
+  const props: Props = {
+    navModel: {} as NavModel,
+    dataSource: getMockDataSource(),
+    dataSourceMeta: getMockPlugin(),
+    pageId: 1,
+    deleteDataSource: jest.fn(),
+    loadDataSource: jest.fn(),
+    setDataSourceName: jest.fn(),
+    updateDataSource: jest.fn(),
+  };
+
+  Object.assign(props, propOverrides);
+
+  return shallow(<DataSourceSettings {...props} />);
+};
+
+describe('Render', () => {
+  it('should render component', () => {
+    const wrapper = setup();
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render loader', () => {
+    const wrapper = setup({
+      dataSource: {} as DataSource,
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render beta info text', () => {
+    const wrapper = setup({
+      dataSourceMeta: { ...getMockPlugin(), state: 'beta' },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render alpha info text', () => {
+    const wrapper = setup({
+      dataSourceMeta: { ...getMockPlugin(), state: 'alpha' },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+
+  it('should render is ready only message', () => {
+    const wrapper = setup({
+      dataSource: { ...getMockDataSource(), readOnly: true },
+    });
+
+    expect(wrapper).toMatchSnapshot();
+  });
+});

+ 245 - 0
public/app/features/datasources/settings/DataSourceSettings.tsx

@@ -0,0 +1,245 @@
+import React, { PureComponent } from 'react';
+import { hot } from 'react-hot-loader';
+import { connect } from 'react-redux';
+
+import PageHeader from 'app/core/components/PageHeader/PageHeader';
+import PageLoader from 'app/core/components/PageLoader/PageLoader';
+import PluginSettings from './PluginSettings';
+import BasicSettings from './BasicSettings';
+import ButtonRow from './ButtonRow';
+
+import appEvents from 'app/core/app_events';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+
+import { getDataSource, getDataSourceMeta } from '../state/selectors';
+import { deleteDataSource, loadDataSource, setDataSourceName, updateDataSource } from '../state/actions';
+import { getNavModel } from 'app/core/selectors/navModel';
+import { getRouteParamsId } from 'app/core/selectors/location';
+
+import { DataSource, NavModel, Plugin } from 'app/types/';
+import { getDataSourceLoadingNav } from '../state/navModel';
+
+export interface Props {
+  navModel: NavModel;
+  dataSource: DataSource;
+  dataSourceMeta: Plugin;
+  pageId: number;
+  deleteDataSource: typeof deleteDataSource;
+  loadDataSource: typeof loadDataSource;
+  setDataSourceName: typeof setDataSourceName;
+  updateDataSource: typeof updateDataSource;
+}
+
+interface State {
+  dataSource: DataSource;
+  isTesting?: boolean;
+  testingMessage?: string;
+  testingStatus?: string;
+}
+
+enum DataSourceStates {
+  Alpha = 'alpha',
+  Beta = 'beta',
+}
+
+export class DataSourceSettings extends PureComponent<Props, State> {
+  constructor(props) {
+    super(props);
+
+    this.state = {
+      dataSource: {} as DataSource,
+    };
+  }
+
+  async componentDidMount() {
+    const { loadDataSource, pageId } = this.props;
+
+    await loadDataSource(pageId);
+  }
+
+  onSubmit = async event => {
+    event.preventDefault();
+
+    await this.props.updateDataSource({ ...this.state.dataSource, name: this.props.dataSource.name });
+
+    this.testDataSource();
+  };
+
+  onDelete = () => {
+    appEvents.emit('confirm-modal', {
+      title: 'Delete',
+      text: 'Are you sure you want to delete this data source?',
+      yesText: 'Delete',
+      icon: 'fa-trash',
+      onConfirm: () => {
+        this.confirmDelete();
+      },
+    });
+  };
+
+  confirmDelete = () => {
+    this.props.deleteDataSource();
+  };
+
+  onModelChange = dataSource => {
+    this.setState({
+      dataSource: dataSource,
+    });
+  };
+
+  isReadOnly() {
+    return this.props.dataSource.readOnly === true;
+  }
+
+  shouldRenderInfoBox() {
+    const { state } = this.props.dataSourceMeta;
+
+    return state === DataSourceStates.Alpha || state === DataSourceStates.Beta;
+  }
+
+  getInfoText() {
+    const { dataSourceMeta } = this.props;
+
+    switch (dataSourceMeta.state) {
+      case DataSourceStates.Alpha:
+        return (
+          'This plugin is marked as being in alpha state, which means it is in early development phase and updates' +
+          ' will include breaking changes.'
+        );
+
+      case DataSourceStates.Beta:
+        return (
+          'This plugin is marked as being in a beta development state. This means it is in currently in active' +
+          ' development and could be missing important features.'
+        );
+    }
+
+    return null;
+  }
+
+  renderIsReadOnlyMessage() {
+    return (
+      <div className="grafana-info-box span8">
+        This datasource was added by config and cannot be modified using the UI. Please contact your server admin to
+        update this datasource.
+      </div>
+    );
+  }
+
+  async testDataSource() {
+    const dsApi = await getDatasourceSrv().get(this.state.dataSource.name);
+
+    if (!dsApi.testDatasource) {
+      return;
+    }
+
+    this.setState({ isTesting: true, testingMessage: 'Testing...', testingStatus: 'info' });
+
+    getBackendSrv().withNoBackendCache(async () => {
+      try {
+        const result = await dsApi.testDatasource();
+
+        this.setState({
+          isTesting: false,
+          testingStatus: result.status,
+          testingMessage: result.message,
+        });
+      } catch (err) {
+        let message = '';
+
+        if (err.statusText) {
+          message = 'HTTP Error ' + err.statusText;
+        } else {
+          message = err.message;
+        }
+
+        this.setState({
+          isTesting: false,
+          testingStatus: 'error',
+          testingMessage: message,
+        });
+      }
+    });
+  }
+
+  render() {
+    const { dataSource, dataSourceMeta, navModel } = this.props;
+    const { testingMessage, testingStatus } = this.state;
+
+    return (
+      <div>
+        <PageHeader model={navModel} />
+        {Object.keys(dataSource).length === 0 ? (
+          <PageLoader pageName="Data source settings" />
+        ) : (
+          <div className="page-container page-body">
+            <div>
+              <form onSubmit={this.onSubmit}>
+                <BasicSettings
+                  dataSourceName={this.props.dataSource.name}
+                  onChange={name => this.props.setDataSourceName(name)}
+                />
+
+                {this.shouldRenderInfoBox() && <div className="grafana-info-box">{this.getInfoText()}</div>}
+
+                {this.isReadOnly() && this.renderIsReadOnlyMessage()}
+                {dataSourceMeta.module && (
+                  <PluginSettings
+                    dataSource={dataSource}
+                    dataSourceMeta={dataSourceMeta}
+                    onModelChange={this.onModelChange}
+                  />
+                )}
+
+                <div className="gf-form-group section">
+                  {testingMessage && (
+                    <div className={`alert-${testingStatus} alert`}>
+                      <div className="alert-icon">
+                        {testingStatus === 'error' ? (
+                          <i className="fa fa-exclamation-triangle" />
+                        ) : (
+                          <i className="fa fa-check" />
+                        )}
+                      </div>
+                      <div className="alert-body">
+                        <div className="alert-title">{testingMessage}</div>
+                      </div>
+                    </div>
+                  )}
+                </div>
+
+                <ButtonRow
+                  onSubmit={event => this.onSubmit(event)}
+                  isReadOnly={this.isReadOnly()}
+                  onDelete={this.onDelete}
+                />
+              </form>
+            </div>
+          </div>
+        )}
+      </div>
+    );
+  }
+}
+
+function mapStateToProps(state) {
+  const pageId = getRouteParamsId(state.location);
+  const dataSource = getDataSource(state.dataSources, pageId);
+
+  return {
+    navModel: getNavModel(state.navIndex, `datasource-settings-${pageId}`, getDataSourceLoadingNav('settings')),
+    dataSource: getDataSource(state.dataSources, pageId),
+    dataSourceMeta: getDataSourceMeta(state.dataSources, dataSource.type),
+    pageId: pageId,
+  };
+}
+
+const mapDispatchToProps = {
+  deleteDataSource,
+  loadDataSource,
+  setDataSourceName,
+  updateDataSource,
+};
+
+export default hot(module)(connect(mapStateToProps, mapDispatchToProps)(DataSourceSettings));

+ 63 - 0
public/app/features/datasources/settings/PluginSettings.tsx

@@ -0,0 +1,63 @@
+import React, { PureComponent } from 'react';
+import _ from 'lodash';
+import { DataSource, Plugin } from 'app/types/';
+import { getAngularLoader, AngularComponent } from 'app/core/services/AngularLoader';
+
+export interface Props {
+  dataSource: DataSource;
+  dataSourceMeta: Plugin;
+  onModelChange: (dataSource: DataSource) => void;
+}
+
+export class PluginSettings extends PureComponent<Props> {
+  element: any;
+  component: AngularComponent;
+  scopeProps: {
+    ctrl: { datasourceMeta: Plugin; current: DataSource };
+    onModelChanged: (dataSource: DataSource) => void;
+  };
+
+  constructor(props) {
+    super(props);
+
+    this.scopeProps = {
+      ctrl: { datasourceMeta: props.dataSourceMeta, current: _.cloneDeep(props.dataSource) },
+      onModelChanged: this.onModelChanged,
+    };
+  }
+
+  componentDidMount() {
+    if (!this.element) {
+      return;
+    }
+
+    const loader = getAngularLoader();
+    const template = '<plugin-component type="datasource-config-ctrl" />';
+
+    this.component = loader.load(this.element, this.scopeProps, template);
+  }
+
+  componentDidUpdate(prevProps) {
+    if (this.props.dataSource !== prevProps.dataSource) {
+      this.scopeProps.ctrl.current = _.cloneDeep(this.props.dataSource);
+
+      this.component.digest();
+    }
+  }
+
+  componentWillUnmount() {
+    if (this.component) {
+      this.component.destroy();
+    }
+  }
+
+  onModelChanged = (dataSource: DataSource) => {
+    this.props.onModelChange(dataSource);
+  };
+
+  render() {
+    return <div ref={element => (this.element = element)} />;
+  }
+}
+
+export default PluginSettings;

+ 25 - 0
public/app/features/datasources/settings/__snapshots__/BasicSettings.test.tsx.snap

@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="gf-form-group"
+>
+  <div
+    className="gf-form max-width-30"
+  >
+    <Component
+      tooltip="The name is used when you select the data source in panels. The Default data source ispreselected in new panels."
+    >
+      Name
+    </Component>
+    <input
+      className="gf-form-input max-width-23"
+      onChange={[Function]}
+      placeholder="Name"
+      required={true}
+      type="text"
+      value="Graphite"
+    />
+  </div>
+</div>
+`;

+ 59 - 0
public/app/features/datasources/settings/__snapshots__/ButtonRow.test.tsx.snap

@@ -0,0 +1,59 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render component 1`] = `
+<div
+  className="gf-form-button-row"
+>
+  <button
+    className="btn btn-success"
+    disabled={true}
+    onClick={[Function]}
+    type="submit"
+  >
+    Save & Test
+  </button>
+  <button
+    className="btn btn-danger"
+    disabled={true}
+    onClick={[MockFunction]}
+    type="submit"
+  >
+    Delete
+  </button>
+  <a
+    className="btn btn-inverse"
+    href="/datasources"
+  >
+    Back
+  </a>
+</div>
+`;
+
+exports[`Render should render with buttons enabled 1`] = `
+<div
+  className="gf-form-button-row"
+>
+  <button
+    className="btn btn-success"
+    disabled={false}
+    onClick={[Function]}
+    type="submit"
+  >
+    Save & Test
+  </button>
+  <button
+    className="btn btn-danger"
+    disabled={false}
+    onClick={[MockFunction]}
+    type="submit"
+  >
+    Delete
+  </button>
+  <a
+    className="btn btn-inverse"
+    href="/datasources"
+  >
+    Back
+  </a>
+</div>
+`;

+ 395 - 0
public/app/features/datasources/settings/__snapshots__/DataSourceSettings.test.tsx.snap

@@ -0,0 +1,395 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Render should render alpha info text 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          onChange={[Function]}
+        />
+        <div
+          className="grafana-info-box"
+        >
+          This plugin is marked as being in alpha state, which means it is in early development phase and updates will include breaking changes.
+        </div>
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": false,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
+            }
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  "one link",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
+                },
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "alpha",
+              "type": "",
+            }
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group section"
+        />
+        <ButtonRow
+          isReadOnly={false}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+        />
+      </form>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Render should render beta info text 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          onChange={[Function]}
+        />
+        <div
+          className="grafana-info-box"
+        >
+          This plugin is marked as being in a beta development state. This means it is in currently in active development and could be missing important features.
+        </div>
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": false,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
+            }
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  "one link",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
+                },
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "beta",
+              "type": "",
+            }
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group section"
+        />
+        <ButtonRow
+          isReadOnly={false}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+        />
+      </form>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Render should render component 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          onChange={[Function]}
+        />
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": false,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
+            }
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  "one link",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
+                },
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "",
+              "type": "",
+            }
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group section"
+        />
+        <ButtonRow
+          isReadOnly={false}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+        />
+      </form>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Render should render is ready only message 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <div
+    className="page-container page-body"
+  >
+    <div>
+      <form
+        onSubmit={[Function]}
+      >
+        <BasicSettings
+          dataSourceName="gdev-cloudwatch"
+          onChange={[Function]}
+        />
+        <div
+          className="grafana-info-box span8"
+        >
+          This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
+        </div>
+        <PluginSettings
+          dataSource={
+            Object {
+              "access": "",
+              "basicAuth": false,
+              "basicAuthPassword": "",
+              "basicAuthUser": "",
+              "database": "",
+              "id": 13,
+              "isDefault": false,
+              "jsonData": Object {
+                "authType": "credentials",
+                "defaultRegion": "eu-west-2",
+              },
+              "name": "gdev-cloudwatch",
+              "orgId": 1,
+              "password": "",
+              "readOnly": true,
+              "type": "cloudwatch",
+              "typeLogoUrl": "public/app/plugins/datasource/cloudwatch/img/amazon-web-services.png",
+              "url": "",
+              "user": "",
+              "withCredentials": false,
+            }
+          }
+          dataSourceMeta={
+            Object {
+              "defaultNavUrl": "some/url",
+              "enabled": false,
+              "hasUpdate": false,
+              "id": "1",
+              "info": Object {
+                "author": Object {
+                  "name": "Grafana Labs",
+                  "url": "url/to/GrafanaLabs",
+                },
+                "description": "pretty decent plugin",
+                "links": Array [
+                  "one link",
+                ],
+                "logos": Object {
+                  "large": "large/logo",
+                  "small": "small/logo",
+                },
+                "screenshots": Array [
+                  Object {
+                    "path": "screenshot",
+                  },
+                ],
+                "updated": "2018-09-26",
+                "version": "1",
+              },
+              "latestVersion": "1",
+              "module": Object {},
+              "name": "pretty cool plugin 1",
+              "pinned": false,
+              "state": "",
+              "type": "",
+            }
+          }
+          onModelChange={[Function]}
+        />
+        <div
+          className="gf-form-group section"
+        />
+        <ButtonRow
+          isReadOnly={true}
+          onDelete={[Function]}
+          onSubmit={[Function]}
+        />
+      </form>
+    </div>
+  </div>
+</div>
+`;
+
+exports[`Render should render loader 1`] = `
+<div>
+  <PageHeader
+    model={Object {}}
+  />
+  <PageLoader
+    pageName="Data source settings"
+  />
+</div>
+`;

+ 54 - 13
public/app/features/datasources/state/actions.ts

@@ -1,10 +1,12 @@
 import { ThunkAction } from 'redux-thunk';
-import { DataSource, Plugin, StoreState } from 'app/types';
-import { getBackendSrv } from '../../../core/services/backend_srv';
-import { LayoutMode } from '../../../core/components/LayoutSelector/LayoutSelector';
-import { updateLocation, updateNavIndex, UpdateNavIndexAction } from '../../../core/actions';
-import { UpdateLocationAction } from '../../../core/actions/location';
+import config from '../../../core/config';
+import { getBackendSrv } from 'app/core/services/backend_srv';
+import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
+import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
+import { updateLocation, updateNavIndex, UpdateNavIndexAction } from 'app/core/actions';
+import { UpdateLocationAction } from 'app/core/actions/location';
 import { buildNavModel } from './navModel';
+import { DataSource, Plugin, StoreState } from 'app/types';
 
 export enum ActionTypes {
   LoadDataSources = 'LOAD_DATA_SOURCES',
@@ -14,43 +16,49 @@ export enum ActionTypes {
   SetDataSourcesSearchQuery = 'SET_DATA_SOURCES_SEARCH_QUERY',
   SetDataSourcesLayoutMode = 'SET_DATA_SOURCES_LAYOUT_MODE',
   SetDataSourceTypeSearchQuery = 'SET_DATA_SOURCE_TYPE_SEARCH_QUERY',
+  SetDataSourceName = 'SET_DATA_SOURCE_NAME',
 }
 
-export interface LoadDataSourcesAction {
+interface LoadDataSourcesAction {
   type: ActionTypes.LoadDataSources;
   payload: DataSource[];
 }
 
-export interface SetDataSourcesSearchQueryAction {
+interface SetDataSourcesSearchQueryAction {
   type: ActionTypes.SetDataSourcesSearchQuery;
   payload: string;
 }
 
-export interface SetDataSourcesLayoutModeAction {
+interface SetDataSourcesLayoutModeAction {
   type: ActionTypes.SetDataSourcesLayoutMode;
   payload: LayoutMode;
 }
 
-export interface LoadDataSourceTypesAction {
+interface LoadDataSourceTypesAction {
   type: ActionTypes.LoadDataSourceTypes;
   payload: Plugin[];
 }
 
-export interface SetDataSourceTypeSearchQueryAction {
+interface SetDataSourceTypeSearchQueryAction {
   type: ActionTypes.SetDataSourceTypeSearchQuery;
   payload: string;
 }
 
-export interface LoadDataSourceAction {
+interface LoadDataSourceAction {
   type: ActionTypes.LoadDataSource;
   payload: DataSource;
 }
 
-export interface LoadDataSourceMetaAction {
+interface LoadDataSourceMetaAction {
   type: ActionTypes.LoadDataSourceMeta;
   payload: Plugin;
 }
 
+interface SetDataSourceNameAction {
+  type: ActionTypes.SetDataSourceName;
+  payload: string;
+}
+
 const dataSourcesLoaded = (dataSources: DataSource[]): LoadDataSourcesAction => ({
   type: ActionTypes.LoadDataSources,
   payload: dataSources,
@@ -86,6 +94,11 @@ export const setDataSourceTypeSearchQuery = (query: string): SetDataSourceTypeSe
   payload: query,
 });
 
+export const setDataSourceName = (name: string) => ({
+  type: ActionTypes.SetDataSourceName,
+  payload: name,
+});
+
 export type Action =
   | LoadDataSourcesAction
   | SetDataSourcesSearchQueryAction
@@ -95,7 +108,8 @@ export type Action =
   | SetDataSourceTypeSearchQueryAction
   | LoadDataSourceAction
   | UpdateNavIndexAction
-  | LoadDataSourceMetaAction;
+  | LoadDataSourceMetaAction
+  | SetDataSourceNameAction;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 
@@ -145,6 +159,23 @@ export function loadDataSourceTypes(): ThunkResult<void> {
   };
 }
 
+export function updateDataSource(dataSource: DataSource): ThunkResult<void> {
+  return async dispatch => {
+    await getBackendSrv().put(`/api/datasources/${dataSource.id}`, dataSource);
+    await updateFrontendSettings();
+    return dispatch(loadDataSource(dataSource.id));
+  };
+}
+
+export function deleteDataSource(): ThunkResult<void> {
+  return async (dispatch, getStore) => {
+    const dataSource = getStore().dataSources.dataSource;
+
+    await getBackendSrv().delete(`/api/datasources/${dataSource.id}`);
+    dispatch(updateLocation({ path: '/datasources' }));
+  };
+}
+
 export function nameExits(dataSources, name) {
   return (
     dataSources.filter(dataSource => {
@@ -173,6 +204,16 @@ export function findNewName(dataSources, name) {
   return name;
 }
 
+function updateFrontendSettings() {
+  return getBackendSrv()
+    .get('/api/frontend/settings')
+    .then(settings => {
+      config.datasources = settings.datasources;
+      config.defaultDatasource = settings.defaultDatasource;
+      getDatasourceSrv().init();
+    });
+}
+
 function nameHasSuffix(name) {
   return name.endsWith('-', name.length - 1);
 }

+ 4 - 1
public/app/features/datasources/state/navModel.ts

@@ -48,6 +48,9 @@ export function getDataSourceLoadingNav(pageName: string): NavModel {
     {
       access: '',
       basicAuth: false,
+      basicAuthUser: '',
+      basicAuthPassword: '',
+      withCredentials: false,
       database: '',
       id: 1,
       isDefault: false,
@@ -75,7 +78,7 @@ export function getDataSourceLoadingNav(pageName: string): NavModel {
           large: '',
           small: '',
         },
-        screenshots: '',
+        screenshots: [],
         updated: '',
         version: '',
       },

+ 4 - 1
public/app/features/datasources/state/reducers.ts

@@ -10,8 +10,8 @@ const initialState: DataSourcesState = {
   dataSourcesCount: 0,
   dataSourceTypes: [] as Plugin[],
   dataSourceTypeSearchQuery: '',
-  dataSourceMeta: {} as Plugin,
   hasFetched: false,
+  dataSourceMeta: {} as Plugin,
 };
 
 export const dataSourcesReducer = (state = initialState, action: Action): DataSourcesState => {
@@ -36,6 +36,9 @@ export const dataSourcesReducer = (state = initialState, action: Action): DataSo
 
     case ActionTypes.LoadDataSourceMeta:
       return { ...state, dataSourceMeta: action.payload };
+
+    case ActionTypes.SetDataSourceName:
+      return { ...state, dataSource: { ...state.dataSource, name: action.payload } };
   }
 
   return state;

+ 9 - 1
public/app/features/datasources/state/selectors.ts

@@ -20,7 +20,15 @@ export const getDataSource = (state, dataSourceId): DataSource | null => {
   if (state.dataSource.id === parseInt(dataSourceId, 10)) {
     return state.dataSource;
   }
-  return null;
+  return {} as DataSource;
+};
+
+export const getDataSourceMeta = (state, type): Plugin => {
+  if (state.dataSourceMeta.id === type) {
+    return state.dataSourceMeta;
+  }
+
+  return {} as Plugin;
 };
 
 export const getDataSourcesSearchQuery = state => state.searchQuery;

+ 12 - 0
public/app/features/explore/Explore.tsx

@@ -94,6 +94,10 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
    * Not kept in component state to prevent edit-render roundtrips.
    */
   queryExpressions: string[];
+  /**
+   * Local ID cache to compare requested vs selected datasource
+   */
+  requestedDatasourceId: string;
 
   constructor(props) {
     super(props);
@@ -167,6 +171,9 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
     const datasourceId = datasource.meta.id;
     let datasourceError = null;
 
+    // Keep ID to track selection
+    this.requestedDatasourceId = datasourceId;
+
     try {
       const testResult = await datasource.testDatasource();
       datasourceError = testResult.status === 'success' ? null : testResult.message;
@@ -174,6 +181,11 @@ export class Explore extends React.PureComponent<ExploreProps, ExploreState> {
       datasourceError = (error && error.statusText) || 'Network error';
     }
 
+    if (datasourceId !== this.requestedDatasourceId) {
+      // User already changed datasource again, discard results
+      return;
+    }
+
     const historyKey = `grafana.explore.history.${datasourceId}`;
     const history = store.getObject(historyKey, []);
 

+ 1 - 4
public/app/features/org/OrgDetailsPage.test.tsx

@@ -1,16 +1,13 @@
 import React from 'react';
 import { shallow } from 'enzyme';
 import { OrgDetailsPage, Props } from './OrgDetailsPage';
-import { NavModel, Organization, OrganizationPreferences } from '../../types';
+import { NavModel, Organization } from '../../types';
 
 const setup = (propOverrides?: object) => {
   const props: Props = {
-    preferences: {} as OrganizationPreferences,
     organization: {} as Organization,
     navModel: {} as NavModel,
     loadOrganization: jest.fn(),
-    loadOrganizationPreferences: jest.fn(),
-    loadStarredDashboards: jest.fn(),
     setOrganizationName: jest.fn(),
     updateOrganization: jest.fn(),
   };

+ 8 - 22
public/app/features/org/OrgDetailsPage.tsx

@@ -4,33 +4,22 @@ import { connect } from 'react-redux';
 import PageHeader from '../../core/components/PageHeader/PageHeader';
 import PageLoader from '../../core/components/PageLoader/PageLoader';
 import OrgProfile from './OrgProfile';
-import OrgPreferences from './OrgPreferences';
-import {
-  loadOrganization,
-  loadOrganizationPreferences,
-  setOrganizationName,
-  updateOrganization,
-} from './state/actions';
-import { loadStarredDashboards } from '../../core/actions/user';
-import { NavModel, Organization, OrganizationPreferences, StoreState } from 'app/types';
+import SharedPreferences from 'app/core/components/SharedPreferences/SharedPreferences';
+import { loadOrganization, setOrganizationName, updateOrganization } from './state/actions';
+import { NavModel, Organization, StoreState } from 'app/types';
 import { getNavModel } from '../../core/selectors/navModel';
 
 export interface Props {
   navModel: NavModel;
   organization: Organization;
-  preferences: OrganizationPreferences;
   loadOrganization: typeof loadOrganization;
-  loadOrganizationPreferences: typeof loadOrganizationPreferences;
-  loadStarredDashboards: typeof loadStarredDashboards;
   setOrganizationName: typeof setOrganizationName;
   updateOrganization: typeof updateOrganization;
 }
 
 export class OrgDetailsPage extends PureComponent<Props> {
   async componentDidMount() {
-    await this.props.loadStarredDashboards();
     await this.props.loadOrganization();
-    await this.props.loadOrganizationPreferences();
   }
 
   onOrgNameChange = name => {
@@ -42,22 +31,22 @@ export class OrgDetailsPage extends PureComponent<Props> {
   };
 
   render() {
-    const { navModel, organization, preferences } = this.props;
+    const { navModel, organization } = this.props;
+    const isLoading = Object.keys(organization).length === 0;
 
     return (
       <div>
         <PageHeader model={navModel} />
         <div className="page-container page-body">
-          {Object.keys(organization).length === 0 || Object.keys(preferences).length === 0 ? (
-            <PageLoader pageName="Organization" />
-          ) : (
+          {isLoading && <PageLoader pageName="Organization" />}
+          {!isLoading && (
             <div>
               <OrgProfile
                 onOrgNameChange={name => this.onOrgNameChange(name)}
                 onSubmit={this.onUpdateOrganization}
                 orgName={organization.name}
               />
-              <OrgPreferences />
+              <SharedPreferences resourceUri="org" />
             </div>
           )}
         </div>
@@ -70,14 +59,11 @@ function mapStateToProps(state: StoreState) {
   return {
     navModel: getNavModel(state.navIndex, 'org-settings'),
     organization: state.organization.organization,
-    preferences: state.organization.preferences,
   };
 }
 
 const mapDispatchToProps = {
   loadOrganization,
-  loadOrganizationPreferences,
-  loadStarredDashboards,
   setOrganizationName,
   updateOrganization,
 };

+ 0 - 28
public/app/features/org/OrgPreferences.test.tsx

@@ -1,28 +0,0 @@
-import React from 'react';
-import { shallow } from 'enzyme';
-import { OrgPreferences, Props } from './OrgPreferences';
-
-const setup = () => {
-  const props: Props = {
-    preferences: {
-      homeDashboardId: 1,
-      timezone: 'UTC',
-      theme: 'Default',
-    },
-    starredDashboards: [{ id: 1, title: 'Standard dashboard', url: '', uri: '', uid: '', type: '', tags: [] }],
-    setOrganizationTimezone: jest.fn(),
-    setOrganizationTheme: jest.fn(),
-    setOrganizationHomeDashboard: jest.fn(),
-    updateOrganizationPreferences: jest.fn(),
-  };
-
-  return shallow(<OrgPreferences {...props} />);
-};
-
-describe('Render', () => {
-  it('should render component', () => {
-    const wrapper = setup();
-
-    expect(wrapper).toMatchSnapshot();
-  });
-});

+ 0 - 113
public/app/features/org/OrgPreferences.tsx

@@ -1,113 +0,0 @@
-import React, { PureComponent } from 'react';
-import { connect } from 'react-redux';
-import { Label } from '../../core/components/Label/Label';
-import SimplePicker from '../../core/components/Picker/SimplePicker';
-import { DashboardSearchHit, OrganizationPreferences } from 'app/types';
-import {
-  setOrganizationHomeDashboard,
-  setOrganizationTheme,
-  setOrganizationTimezone,
-  updateOrganizationPreferences,
-} from './state/actions';
-
-export interface Props {
-  preferences: OrganizationPreferences;
-  starredDashboards: DashboardSearchHit[];
-  setOrganizationHomeDashboard: typeof setOrganizationHomeDashboard;
-  setOrganizationTheme: typeof setOrganizationTheme;
-  setOrganizationTimezone: typeof setOrganizationTimezone;
-  updateOrganizationPreferences: typeof updateOrganizationPreferences;
-}
-
-const themes = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
-
-const timezones = [
-  { value: '', text: 'Default' },
-  { value: 'browser', text: 'Local browser time' },
-  { value: 'utc', text: 'UTC' },
-];
-
-export class OrgPreferences extends PureComponent<Props> {
-  onSubmitForm = event => {
-    event.preventDefault();
-    this.props.updateOrganizationPreferences();
-  };
-
-  render() {
-    const {
-      preferences,
-      starredDashboards,
-      setOrganizationHomeDashboard,
-      setOrganizationTimezone,
-      setOrganizationTheme,
-    } = this.props;
-
-    starredDashboards.unshift({ id: 0, title: 'Default', tags: [], type: '', uid: '', uri: '', url: '' });
-
-    return (
-      <form className="section gf-form-group" onSubmit={this.onSubmitForm}>
-        <h3 className="page-heading">Preferences</h3>
-        <div className="gf-form">
-          <span className="gf-form-label width-11">UI Theme</span>
-          <SimplePicker
-            defaultValue={themes.find(theme => theme.value === preferences.theme)}
-            options={themes}
-            getOptionValue={i => i.value}
-            getOptionLabel={i => i.text}
-            onSelected={theme => setOrganizationTheme(theme.value)}
-            width={20}
-          />
-        </div>
-        <div className="gf-form">
-          <Label
-            width={11}
-            tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
-          >
-            Home Dashboard
-          </Label>
-          <SimplePicker
-            defaultValue={starredDashboards.find(dashboard => dashboard.id === preferences.homeDashboardId)}
-            getOptionValue={i => i.id}
-            getOptionLabel={i => i.title}
-            onSelected={(dashboard: DashboardSearchHit) => setOrganizationHomeDashboard(dashboard.id)}
-            options={starredDashboards}
-            placeholder="Chose default dashboard"
-            width={20}
-          />
-        </div>
-        <div className="gf-form">
-          <label className="gf-form-label width-11">Timezone</label>
-          <SimplePicker
-            defaultValue={timezones.find(timezone => timezone.value === preferences.timezone)}
-            getOptionValue={i => i.value}
-            getOptionLabel={i => i.text}
-            onSelected={timezone => setOrganizationTimezone(timezone.value)}
-            options={timezones}
-            width={20}
-          />
-        </div>
-        <div className="gf-form-button-row">
-          <button type="submit" className="btn btn-success">
-            Save
-          </button>
-        </div>
-      </form>
-    );
-  }
-}
-
-function mapStateToProps(state) {
-  return {
-    preferences: state.organization.preferences,
-    starredDashboards: state.user.starredDashboards,
-  };
-}
-
-const mapDispatchToProps = {
-  setOrganizationHomeDashboard,
-  setOrganizationTimezone,
-  setOrganizationTheme,
-  updateOrganizationPreferences,
-};
-
-export default connect(mapStateToProps, mapDispatchToProps)(OrgPreferences);

+ 3 - 1
public/app/features/org/__snapshots__/OrgDetailsPage.test.tsx.snap

@@ -29,7 +29,9 @@ exports[`Render should render organization and preferences 1`] = `
         onSubmit={[Function]}
         orgName="Cool org"
       />
-      <Connect(OrgPreferences) />
+      <SharedPreferences
+        resourceUri="org"
+      />
     </div>
   </div>
 </div>

+ 0 - 136
public/app/features/org/__snapshots__/OrgPreferences.test.tsx.snap

@@ -1,136 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`Render should render component 1`] = `
-<form
-  className="section gf-form-group"
-  onSubmit={[Function]}
->
-  <h3
-    className="page-heading"
-  >
-    Preferences
-  </h3>
-  <div
-    className="gf-form"
-  >
-    <span
-      className="gf-form-label width-11"
-    >
-      UI Theme
-    </span>
-    <SimplePicker
-      getOptionLabel={[Function]}
-      getOptionValue={[Function]}
-      onSelected={[Function]}
-      options={
-        Array [
-          Object {
-            "text": "Default",
-            "value": "",
-          },
-          Object {
-            "text": "Dark",
-            "value": "dark",
-          },
-          Object {
-            "text": "Light",
-            "value": "light",
-          },
-        ]
-      }
-      width={20}
-    />
-  </div>
-  <div
-    className="gf-form"
-  >
-    <Component
-      tooltip="Not finding dashboard you want? Star it first, then it should appear in this select box."
-      width={11}
-    >
-      Home Dashboard
-    </Component>
-    <SimplePicker
-      defaultValue={
-        Object {
-          "id": 1,
-          "tags": Array [],
-          "title": "Standard dashboard",
-          "type": "",
-          "uid": "",
-          "uri": "",
-          "url": "",
-        }
-      }
-      getOptionLabel={[Function]}
-      getOptionValue={[Function]}
-      onSelected={[Function]}
-      options={
-        Array [
-          Object {
-            "id": 0,
-            "tags": Array [],
-            "title": "Default",
-            "type": "",
-            "uid": "",
-            "uri": "",
-            "url": "",
-          },
-          Object {
-            "id": 1,
-            "tags": Array [],
-            "title": "Standard dashboard",
-            "type": "",
-            "uid": "",
-            "uri": "",
-            "url": "",
-          },
-        ]
-      }
-      placeholder="Chose default dashboard"
-      width={20}
-    />
-  </div>
-  <div
-    className="gf-form"
-  >
-    <label
-      className="gf-form-label width-11"
-    >
-      Timezone
-    </label>
-    <SimplePicker
-      getOptionLabel={[Function]}
-      getOptionValue={[Function]}
-      onSelected={[Function]}
-      options={
-        Array [
-          Object {
-            "text": "Default",
-            "value": "",
-          },
-          Object {
-            "text": "Local browser time",
-            "value": "browser",
-          },
-          Object {
-            "text": "UTC",
-            "value": "utc",
-          },
-        ]
-      }
-      width={20}
-    />
-  </div>
-  <div
-    className="gf-form-button-row"
-  >
-    <button
-      className="btn btn-success"
-      type="submit"
-    >
-      Save
-    </button>
-  </div>
-</form>
-`;

+ 3 - 70
public/app/features/org/state/actions.ts

@@ -1,16 +1,12 @@
 import { ThunkAction } from 'redux-thunk';
-import { Organization, OrganizationPreferences, StoreState } from 'app/types';
-import { getBackendSrv } from '../../../core/services/backend_srv';
+import { Organization, StoreState } from 'app/types';
+import { getBackendSrv } from 'app/core/services/backend_srv';
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, any>;
 
 export enum ActionTypes {
   LoadOrganization = 'LOAD_ORGANISATION',
-  LoadPreferences = 'LOAD_PREFERENCES',
   SetOrganizationName = 'SET_ORGANIZATION_NAME',
-  SetOrganizationTheme = 'SET_ORGANIZATION_THEME',
-  SetOrganizationHomeDashboard = 'SET_ORGANIZATION_HOME_DASHBOARD',
-  SetOrganizationTimezone = 'SET_ORGANIZATION_TIMEZONE',
 }
 
 interface LoadOrganizationAction {
@@ -18,68 +14,22 @@ interface LoadOrganizationAction {
   payload: Organization;
 }
 
-interface LoadPreferencesAction {
-  type: ActionTypes.LoadPreferences;
-  payload: OrganizationPreferences;
-}
-
 interface SetOrganizationNameAction {
   type: ActionTypes.SetOrganizationName;
   payload: string;
 }
 
-interface SetOrganizationThemeAction {
-  type: ActionTypes.SetOrganizationTheme;
-  payload: string;
-}
-
-interface SetOrganizationHomeDashboardAction {
-  type: ActionTypes.SetOrganizationHomeDashboard;
-  payload: number;
-}
-
-interface SetOrganizationTimezoneAction {
-  type: ActionTypes.SetOrganizationTimezone;
-  payload: string;
-}
-
 const organisationLoaded = (organisation: Organization) => ({
   type: ActionTypes.LoadOrganization,
   payload: organisation,
 });
 
-const preferencesLoaded = (preferences: OrganizationPreferences) => ({
-  type: ActionTypes.LoadPreferences,
-  payload: preferences,
-});
-
 export const setOrganizationName = (orgName: string) => ({
   type: ActionTypes.SetOrganizationName,
   payload: orgName,
 });
 
-export const setOrganizationTheme = (theme: string) => ({
-  type: ActionTypes.SetOrganizationTheme,
-  payload: theme,
-});
-
-export const setOrganizationHomeDashboard = (id: number) => ({
-  type: ActionTypes.SetOrganizationHomeDashboard,
-  payload: id,
-});
-
-export const setOrganizationTimezone = (timezone: string) => ({
-  type: ActionTypes.SetOrganizationTimezone,
-  payload: timezone,
-});
-
-export type Action =
-  | LoadOrganizationAction
-  | LoadPreferencesAction
-  | SetOrganizationNameAction
-  | SetOrganizationThemeAction
-  | SetOrganizationHomeDashboardAction
-  | SetOrganizationTimezoneAction;
+export type Action = LoadOrganizationAction | SetOrganizationNameAction;
 
 export function loadOrganization(): ThunkResult<void> {
   return async dispatch => {
@@ -90,13 +40,6 @@ export function loadOrganization(): ThunkResult<void> {
   };
 }
 
-export function loadOrganizationPreferences(): ThunkResult<void> {
-  return async dispatch => {
-    const preferencesResponse = await getBackendSrv().get('/api/org/preferences');
-    dispatch(preferencesLoaded(preferencesResponse));
-  };
-}
-
 export function updateOrganization() {
   return async (dispatch, getStore) => {
     const organization = getStore().organization.organization;
@@ -106,13 +49,3 @@ export function updateOrganization() {
     dispatch(loadOrganization());
   };
 }
-
-export function updateOrganizationPreferences() {
-  return async (dispatch, getStore) => {
-    const preferences = getStore().organization.preferences;
-
-    await getBackendSrv().put('/api/org/preferences', preferences);
-
-    window.location.reload();
-  };
-}

+ 1 - 14
public/app/features/org/state/reducers.ts

@@ -1,9 +1,8 @@
-import { Organization, OrganizationPreferences, OrganizationState } from 'app/types';
+import { Organization, OrganizationState } from 'app/types';
 import { Action, ActionTypes } from './actions';
 
 const initialState: OrganizationState = {
   organization: {} as Organization,
-  preferences: {} as OrganizationPreferences,
 };
 
 const organizationReducer = (state = initialState, action: Action): OrganizationState => {
@@ -11,20 +10,8 @@ const organizationReducer = (state = initialState, action: Action): Organization
     case ActionTypes.LoadOrganization:
       return { ...state, organization: action.payload };
 
-    case ActionTypes.LoadPreferences:
-      return { ...state, preferences: action.payload };
-
     case ActionTypes.SetOrganizationName:
       return { ...state, organization: { ...state.organization, name: action.payload } };
-
-    case ActionTypes.SetOrganizationTheme:
-      return { ...state, preferences: { ...state.preferences, theme: action.payload } };
-
-    case ActionTypes.SetOrganizationHomeDashboard:
-      return { ...state, preferences: { ...state.preferences, homeDashboardId: action.payload } };
-
-    case ActionTypes.SetOrganizationTimezone:
-      return { ...state, preferences: { ...state.preferences, timezone: action.payload } };
   }
 
   return state;

+ 7 - 73
public/app/features/panel/metrics_panel_ctrl.ts

@@ -1,13 +1,12 @@
-import $ from 'jquery';
 import _ from 'lodash';
 
-import config from 'app/core/config';
 import kbn from 'app/core/utils/kbn';
+import config from 'app/core/config';
+
 import { PanelCtrl } from 'app/features/panel/panel_ctrl';
-import * as rangeUtil from 'app/core/utils/rangeutil';
-import * as dateMath from 'app/core/utils/datemath';
 import { getExploreUrl } from 'app/core/utils/explore';
 import { metricsTabDirective } from './metrics_tab';
+import { applyPanelTimeOverrides, getResolution } from 'app/features/dashboard/utils/panel';
 
 class MetricsPanelCtrl extends PanelCtrl {
   scope: any;
@@ -28,7 +27,6 @@ class MetricsPanelCtrl extends PanelCtrl {
   dataStream: any;
   dataSubscription: any;
   dataList: any;
-  nextRefId: string;
 
   constructor($scope, $injector) {
     super($scope, $injector);
@@ -134,14 +132,11 @@ class MetricsPanelCtrl extends PanelCtrl {
   updateTimeRange(datasource?) {
     this.datasource = datasource || this.datasource;
     this.range = this.timeSrv.timeRange();
+    this.resolution = getResolution(this.panel);
 
-    this.applyPanelTimeOverrides();
-
-    if (this.panel.maxDataPoints) {
-      this.resolution = this.panel.maxDataPoints;
-    } else {
-      this.resolution = Math.ceil($(window).width() * (this.panel.gridPos.w / 24));
-    }
+    const newTimeData = applyPanelTimeOverrides(this.panel, this.range);
+    this.timeInfo = newTimeData.timeInfo;
+    this.range = newTimeData.timeRange;
 
     this.calculateInterval();
 
@@ -163,48 +158,6 @@ class MetricsPanelCtrl extends PanelCtrl {
     this.intervalMs = res.intervalMs;
   }
 
-  applyPanelTimeOverrides() {
-    this.timeInfo = '';
-
-    // check panel time overrrides
-    if (this.panel.timeFrom) {
-      const timeFromInterpolated = this.templateSrv.replace(this.panel.timeFrom, this.panel.scopedVars);
-      const timeFromInfo = rangeUtil.describeTextRange(timeFromInterpolated);
-      if (timeFromInfo.invalid) {
-        this.timeInfo = 'invalid time override';
-        return;
-      }
-
-      if (_.isString(this.range.raw.from)) {
-        const timeFromDate = dateMath.parse(timeFromInfo.from);
-        this.timeInfo = timeFromInfo.display;
-        this.range.from = timeFromDate;
-        this.range.to = dateMath.parse(timeFromInfo.to);
-        this.range.raw.from = timeFromInfo.from;
-        this.range.raw.to = timeFromInfo.to;
-      }
-    }
-
-    if (this.panel.timeShift) {
-      const timeShiftInterpolated = this.templateSrv.replace(this.panel.timeShift, this.panel.scopedVars);
-      const timeShiftInfo = rangeUtil.describeTextRange(timeShiftInterpolated);
-      if (timeShiftInfo.invalid) {
-        this.timeInfo = 'invalid timeshift';
-        return;
-      }
-
-      const timeShift = '-' + timeShiftInterpolated;
-      this.timeInfo += ' timeshift ' + timeShift;
-      this.range.from = dateMath.parseDateMath(timeShift, this.range.from, false);
-      this.range.to = dateMath.parseDateMath(timeShift, this.range.to, true);
-      this.range.raw = { from: this.range.from, to: this.range.to };
-    }
-
-    if (this.panel.hideTimeOverride) {
-      this.timeInfo = '';
-    }
-  }
-
   issueQueries(datasource) {
     this.datasource = datasource;
 
@@ -309,25 +262,6 @@ class MetricsPanelCtrl extends PanelCtrl {
       this.$timeout(() => this.$location.url(url));
     }
   }
-
-  addQuery(target) {
-    target.refId = this.dashboard.getNextQueryLetter(this.panel);
-
-    this.panel.targets.push(target);
-    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
-  }
-
-  removeQuery(target) {
-    const index = _.indexOf(this.panel.targets, target);
-    this.panel.targets.splice(index, 1);
-    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
-    this.refresh();
-  }
-
-  moveQuery(target, direction) {
-    const index = _.indexOf(this.panel.targets, target);
-    _.move(this.panel.targets, index, index + direction);
-  }
 }
 
 export { MetricsPanelCtrl };

+ 36 - 5
public/app/features/panel/metrics_tab.ts

@@ -5,6 +5,7 @@ import Remarkable from 'remarkable';
 // Services & utils
 import coreModule from 'app/core/core_module';
 import config from 'app/core/config';
+import { Emitter } from 'app/core/utils/emitter';
 
 // Types
 import { DashboardModel } from '../dashboard/dashboard_model';
@@ -25,6 +26,7 @@ export class MetricsTabCtrl {
   hasQueryHelp: boolean;
   helpHtml: string;
   queryOptions: any;
+  events: Emitter;
 
   /** @ngInject */
   constructor($scope, private $sce, datasourceSrv, private backendSrv) {
@@ -39,6 +41,10 @@ export class MetricsTabCtrl {
     this.datasources = datasourceSrv.getMetricSources();
     this.panelDsValue = this.panelCtrl.panel.datasource;
 
+    // addded here as old query controller expects this on panelCtrl but
+    // they are getting MetricsTabCtrl instead
+    this.events = this.panel.events;
+
     for (const ds of this.datasources) {
       if (ds.value === this.panelDsValue) {
         this.datasourceInstance = ds;
@@ -48,7 +54,7 @@ export class MetricsTabCtrl {
     this.addQueryDropdown = { text: 'Add Query', value: null, fake: true };
 
     // update next ref id
-    this.panelCtrl.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
+    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
     this.updateDatasourceOptions();
   }
 
@@ -112,10 +118,6 @@ export class MetricsTabCtrl {
     this.addQueryDropdown = { text: 'Add Query', value: null, fake: true };
   }
 
-  addQuery() {
-    this.panelCtrl.addQuery({ isNew: true });
-  }
-
   toggleHelp() {
     this.optionsOpen = false;
     this.queryTroubleshooterOpen = false;
@@ -138,6 +140,35 @@ export class MetricsTabCtrl {
     this.optionsOpen = false;
     this.queryTroubleshooterOpen = !this.queryTroubleshooterOpen;
   }
+
+  addQuery(query?) {
+    query = query || {};
+    query.refId = this.dashboard.getNextQueryLetter(this.panel);
+    query.isNew = true;
+
+    this.panel.targets.push(query);
+    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
+  }
+
+  refresh() {
+    this.panel.refresh();
+  }
+
+  render() {
+    this.panel.render();
+  }
+
+  removeQuery(target) {
+    const index = _.indexOf(this.panel.targets, target);
+    this.panel.targets.splice(index, 1);
+    this.nextRefId = this.dashboard.getNextQueryLetter(this.panel);
+    this.panel.refresh();
+  }
+
+  moveQuery(target, direction) {
+    const index = _.indexOf(this.panel.targets, target);
+    _.move(this.panel.targets, index, index + direction);
+  }
 }
 
 /** @ngInject */

+ 6 - 8
public/app/features/panel/panel_ctrl.ts

@@ -1,20 +1,18 @@
-import config from 'app/core/config';
 import _ from 'lodash';
 import $ from 'jquery';
+import Remarkable from 'remarkable';
+
+import config from 'app/core/config';
 import { profiler } from 'app/core/core';
+import { Emitter } from 'app/core/core';
 import {
   duplicatePanel,
   copyPanel as copyPanelUtil,
   editPanelJson as editPanelJsonUtil,
   sharePanel as sharePanelUtil,
 } from 'app/features/dashboard/utils/panel';
-import Remarkable from 'remarkable';
-import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN } from 'app/core/constants';
 
-const TITLE_HEIGHT = 27;
-const PANEL_BORDER = 2;
-
-import { Emitter } from 'app/core/core';
+import { GRID_CELL_HEIGHT, GRID_CELL_VMARGIN, PANEL_HEADER_HEIGHT, PANEL_BORDER } from 'app/core/constants';
 
 export class PanelCtrl {
   panel: any;
@@ -236,7 +234,7 @@ export class PanelCtrl {
       this.initEditMode();
     }
 
-    this.height = this.containerHeight - (PANEL_BORDER + TITLE_HEIGHT);
+    this.height = this.containerHeight - (PANEL_BORDER + PANEL_HEADER_HEIGHT);
   }
 
   render(payload?) {

+ 2 - 15
public/app/features/panel/panel_directive.ts

@@ -44,8 +44,8 @@ const panelTemplate = `
             </li>
           </ul>
 
-          <button class="tabbed-view-close-btn" ng-click="ctrl.exitFullscreen();">
-            <i class="fa fa-remove"></i>
+          <button class="panel-editor-tabs__close" ng-click="ctrl.exitFullscreen();">
+            <i class="fa fa-reply"></i>
           </button>
         </div>
 
@@ -80,16 +80,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
       let lastAlertState;
       let hasAlertRule;
 
-      function mouseEnter() {
-        panelContainer.toggleClass('panel-hover-highlight', true);
-        ctrl.dashboard.setPanelFocus(ctrl.panel.id);
-      }
-
-      function mouseLeave() {
-        panelContainer.toggleClass('panel-hover-highlight', false);
-        ctrl.dashboard.setPanelFocus(0);
-      }
-
       function resizeScrollableContent() {
         if (panelScrollbar) {
           panelScrollbar.update();
@@ -212,9 +202,6 @@ module.directive('grafanaPanel', ($rootScope, $document, $timeout) => {
         scope.$apply(ctrl.openInspector.bind(ctrl));
       });
 
-      elem.on('mouseenter', mouseEnter);
-      elem.on('mouseleave', mouseLeave);
-
       scope.$on('$destroy', () => {
         elem.off();
         cornerInfoElem.off();

+ 1 - 1
public/app/features/panel/partials/metrics_tab.html

@@ -83,7 +83,7 @@
 				<span class="gf-form-query-letter-cell-carret">
 					<i class="fa fa-caret-down"></i>
 				</span>
-				<span class="gf-form-query-letter-cell-letter">{{ctrl.panelCtrl.nextRefId}}</span>
+				<span class="gf-form-query-letter-cell-letter">{{ctrl.nextRefId}}</span>
 			</label>
 			<button class="btn btn-secondary gf-form-btn" ng-click="ctrl.addQuery()" ng-hide="ctrl.datasourceInstance.meta.mixed">
 				Add Query

+ 30 - 3
public/app/features/plugins/__mocks__/pluginMocks.ts

@@ -1,4 +1,4 @@
-import { Plugin } from 'app/types';
+import { Plugin, PanelPlugin } from 'app/types';
 
 export const getMockPlugins = (amount: number): Plugin[] => {
   const plugins = [];
@@ -17,7 +17,7 @@ export const getMockPlugins = (amount: number): Plugin[] => {
         description: 'pretty decent plugin',
         links: ['one link'],
         logos: { small: 'small/logo', large: 'large/logo' },
-        screenshots: `screenshot/${i}`,
+        screenshots: [{ path: `screenshot/${i}` }],
         updated: '2018-09-26',
         version: '1',
       },
@@ -26,12 +26,38 @@ export const getMockPlugins = (amount: number): Plugin[] => {
       pinned: false,
       state: '',
       type: '',
+      module: {},
     });
   }
 
   return plugins;
 };
 
+export const getPanelPlugin = (options: { id: string; sort?: number; hideFromList?: boolean }): PanelPlugin => {
+  return {
+    id: options.id,
+    name: options.id,
+    sort: options.sort || 1,
+    info: {
+      author: {
+        name: options.id + 'name',
+      },
+      description: '',
+      links: [],
+      logos: {
+        large: '',
+        small: '',
+      },
+      screenshots: [],
+      updated: '',
+      version: '',
+    },
+    hideFromList: options.hideFromList === true,
+    module: '',
+    baseUrl: '',
+  };
+};
+
 export const getMockPlugin = () => {
   return {
     defaultNavUrl: 'some/url',
@@ -46,7 +72,7 @@ export const getMockPlugin = () => {
       description: 'pretty decent plugin',
       links: ['one link'],
       logos: { small: 'small/logo', large: 'large/logo' },
-      screenshots: 'screenshot/1',
+      screenshots: [{ path: `screenshot` }],
       updated: '2018-09-26',
       version: '1',
     },
@@ -55,5 +81,6 @@ export const getMockPlugin = () => {
     pinned: false,
     state: '',
     type: '',
+    module: {},
   };
 };

+ 36 - 6
public/app/features/plugins/__snapshots__/PluginList.test.tsx.snap

@@ -28,11 +28,16 @@ exports[`Render should render component 1`] = `
               "large": "large/logo",
               "small": "small/logo",
             },
-            "screenshots": "screenshot/0",
+            "screenshots": Array [
+              Object {
+                "path": "screenshot/0",
+              },
+            ],
             "updated": "2018-09-26",
             "version": "1",
           },
           "latestVersion": "1.0",
+          "module": Object {},
           "name": "pretty cool plugin-0",
           "pinned": false,
           "state": "",
@@ -61,11 +66,16 @@ exports[`Render should render component 1`] = `
               "large": "large/logo",
               "small": "small/logo",
             },
-            "screenshots": "screenshot/1",
+            "screenshots": Array [
+              Object {
+                "path": "screenshot/1",
+              },
+            ],
             "updated": "2018-09-26",
             "version": "1",
           },
           "latestVersion": "1.1",
+          "module": Object {},
           "name": "pretty cool plugin-1",
           "pinned": false,
           "state": "",
@@ -94,11 +104,16 @@ exports[`Render should render component 1`] = `
               "large": "large/logo",
               "small": "small/logo",
             },
-            "screenshots": "screenshot/2",
+            "screenshots": Array [
+              Object {
+                "path": "screenshot/2",
+              },
+            ],
             "updated": "2018-09-26",
             "version": "1",
           },
           "latestVersion": "1.2",
+          "module": Object {},
           "name": "pretty cool plugin-2",
           "pinned": false,
           "state": "",
@@ -127,11 +142,16 @@ exports[`Render should render component 1`] = `
               "large": "large/logo",
               "small": "small/logo",
             },
-            "screenshots": "screenshot/3",
+            "screenshots": Array [
+              Object {
+                "path": "screenshot/3",
+              },
+            ],
             "updated": "2018-09-26",
             "version": "1",
           },
           "latestVersion": "1.3",
+          "module": Object {},
           "name": "pretty cool plugin-3",
           "pinned": false,
           "state": "",
@@ -160,11 +180,16 @@ exports[`Render should render component 1`] = `
               "large": "large/logo",
               "small": "small/logo",
             },
-            "screenshots": "screenshot/4",
+            "screenshots": Array [
+              Object {
+                "path": "screenshot/4",
+              },
+            ],
             "updated": "2018-09-26",
             "version": "1",
           },
           "latestVersion": "1.4",
+          "module": Object {},
           "name": "pretty cool plugin-4",
           "pinned": false,
           "state": "",
@@ -193,11 +218,16 @@ exports[`Render should render component 1`] = `
               "large": "large/logo",
               "small": "small/logo",
             },
-            "screenshots": "screenshot/5",
+            "screenshots": Array [
+              Object {
+                "path": "screenshot/5",
+              },
+            ],
             "updated": "2018-09-26",
             "version": "1",
           },
           "latestVersion": "1.5",
+          "module": Object {},
           "name": "pretty cool plugin-5",
           "pinned": false,
           "state": "",

+ 0 - 1
public/app/features/plugins/all.ts

@@ -1,4 +1,3 @@
-import './plugin_edit_ctrl';
 import './plugin_page_ctrl';
 import './import_list/import_list';
 import './ds_edit_ctrl';

+ 3 - 6
public/app/features/plugins/datasource_srv.ts

@@ -1,14 +1,11 @@
-// Libraries
 import _ from 'lodash';
 import coreModule from 'app/core/core_module';
 
-// Utils
 import config from 'app/core/config';
 import { importPluginModule } from './plugin_loader';
 
-// Types
 import { DataSourceApi } from 'app/types/series';
-import { DataSource } from 'app/types';
+import { DataSource, DataSourceSelectItem } from 'app/types';
 
 export class DatasourceSrv {
   datasources: { [name: string]: DataSource };
@@ -102,8 +99,8 @@ export class DatasourceSrv {
     return _.sortBy(es, ['name']);
   }
 
-  getMetricSources(options) {
-    const metricSources = [];
+  getMetricSources(options?) {
+    const metricSources: DataSourceSelectItem[] = [];
 
     _.each(config.datasources, (value, key) => {
       if (value.meta && value.meta.metrics) {

+ 1 - 1
public/app/features/plugins/ds_dashboards_ctrl.ts

@@ -1,5 +1,5 @@
 import { coreModule } from 'app/core/core';
-import { store } from 'app/store/configureStore';
+import { store } from 'app/store/store';
 import { getNavModel } from 'app/core/selectors/navModel';
 import { buildNavModel } from './state/navModel';
 

+ 1 - 1
public/app/features/plugins/ds_edit_ctrl.ts

@@ -1,7 +1,7 @@
 import _ from 'lodash';
 import config from 'app/core/config';
 import { coreModule, appEvents } from 'app/core/core';
-import { store } from 'app/store/configureStore';
+import { store } from 'app/store/store';
 import { getNavModel } from 'app/core/selectors/navModel';
 import { buildNavModel } from './state/navModel';
 

+ 0 - 72
public/app/features/plugins/partials/ds_edit.html

@@ -1,72 +0,0 @@
-<page-header model="ctrl.navModel"></page-header>
-
-<div class="page-container page-body">
-  <h3 class="page-sub-heading">Settings</h3>
-
-  <form name="ctrl.editForm" ng-if="ctrl.current">
-    <div class="gf-form-group">
-      <div class="gf-form-inline">
-        <div class="gf-form max-width-30">
-          <span class="gf-form-label width-10">Name</span>
-          <input class="gf-form-input max-width-23" type="text" ng-model="ctrl.current.name" placeholder="name" required>
-          <info-popover offset="0px -135px" mode="right-absolute">
-            The name is used when you select the data source in panels.
-            The <em>Default</em> data source is preselected in new
-            panels.
-          </info-popover>
-        </div>
-        <gf-form-switch class="gf-form" label="Default" checked="ctrl.current.isDefault" switch-class="max-width-6"></gf-form-switch>
-      </div>
-    </div>
-
-    <div class="grafana-info-box" ng-if="ctrl.datasourceMeta.state === 'alpha'">
-      This plugin is marked as being in alpha state, which means it is in early development phase and
-      updates will include breaking changes.
-    </div>
-
-		<div class="grafana-info-box" ng-if="ctrl.datasourceMeta.state === 'beta'">
-      This plugin is marked as being in a beta development state. This means it is in currently in active development and could be
-      missing important features.
-    </div>
-
-    <rebuild-on-change property="ctrl.datasourceMeta.id">
-      <plugin-component type="datasource-config-ctrl">
-      </plugin-component>
-    </rebuild-on-change>
-
-    <div ng-if="ctrl.hasDashboards">
-      <h3 class="section-heading">Bundled Plugin Dashboards</h3>
-      <div class="section">
-        <dashboard-import-list plugin="ctrl.datasourceMeta" datasource="ctrl.current"></dashboard-import-list>
-      </div>
-    </div>
-
-    <div ng-if="ctrl.testing" class="gf-form-group section">
-      <h5 ng-show="!ctrl.testing.done">Testing.... <i class="fa fa-spiner fa-spin"></i></h5>
-      <div class="alert-{{ctrl.testing.status}} alert" ng-show="ctrl.testing.done">
-        <div class="alert-icon">
-          <i class="fa fa-exclamation-triangle" ng-show="ctrl.testing.status === 'error'"></i>
-          <i class="fa fa-check" ng-show="ctrl.testing.status !== 'error'"></i>
-        </div>
-        <div class="alert-body">
-          <div class="alert-title">{{ctrl.testing.message}}</div>
-        </div>
-      </div>
-    </div>
-
-		<div class="grafana-info-box span8" ng-if="ctrl.current.readOnly">
-			This datasource was added by config and cannot be modified using the UI. Please contact your server admin to update this datasource.
-		</div>
-
-		<div class="gf-form-button-row">
-			<button type="submit" class="btn btn-success" ng-disabled="ctrl.current.readOnly"  ng-click="ctrl.saveChanges()">Save &amp; Test</button>
-			<button type="submit" class="btn btn-danger" ng-disabled="ctrl.current.readOnly"  ng-show="!ctrl.isNew" ng-click="ctrl.delete()">Delete</button>
-			<a class="btn btn-inverse" href="datasources">Back</a>
-		</div>
-
-		<br />
-		<br />
-		<br />
-
-	</form>
-</div>

+ 10 - 4
public/app/features/plugins/plugin_component.ts

@@ -5,8 +5,6 @@ import config from 'app/core/config';
 import coreModule from 'app/core/core_module';
 import { importPluginModule } from './plugin_loader';
 
-import { UnknownPanelCtrl } from 'app/plugins/panel/unknown/module';
-
 /** @ngInject */
 function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $templateCache, $timeout) {
   function getTemplate(component) {
@@ -69,7 +67,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
     };
 
     const panelInfo = config.panels[scope.panel.type];
-    let panelCtrlPromise = Promise.resolve(UnknownPanelCtrl);
+    let panelCtrlPromise = Promise.resolve(null);
     if (panelInfo) {
       panelCtrlPromise = importPluginModule(panelInfo.module).then(panelModule => {
         return panelModule.PanelCtrl;
@@ -118,7 +116,7 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
               bindings: { target: '=', panelCtrl: '=', datasource: '=' },
               attrs: {
                 target: 'target',
-                'panel-ctrl': 'ctrl.panelCtrl',
+                'panel-ctrl': 'ctrl',
                 datasource: 'datasource',
               },
               Component: dsModule.QueryCtrl,
@@ -149,6 +147,14 @@ function pluginDirectiveLoader($compile, datasourceSrv, $rootScope, $q, $http, $
             return { notFound: true };
           }
 
+          scope.$watch(
+            'ctrl.current',
+            () => {
+              scope.onModelChanged(scope.ctrl.current);
+            },
+            true
+          );
+
           return {
             baseUrl: dsMeta.baseUrl,
             name: 'ds-config-' + dsMeta.id,

+ 0 - 179
public/app/features/plugins/plugin_edit_ctrl.ts

@@ -1,179 +0,0 @@
-import angular from 'angular';
-import _ from 'lodash';
-import Remarkable from 'remarkable';
-
-export class PluginEditCtrl {
-  model: any;
-  pluginIcon: string;
-  pluginId: any;
-  includes: any;
-  readmeHtml: any;
-  includedDatasources: any;
-  tab: string;
-  navModel: any;
-  hasDashboards: any;
-  preUpdateHook: () => any;
-  postUpdateHook: () => any;
-
-  /** @ngInject */
-  constructor(private $scope, private $rootScope, private backendSrv, private $sce, private $routeParams, navModelSrv) {
-    this.pluginId = $routeParams.pluginId;
-    this.preUpdateHook = () => Promise.resolve();
-    this.postUpdateHook = () => Promise.resolve();
-
-    this.init();
-  }
-
-  setNavModel(model) {
-    let defaultTab = 'readme';
-
-    this.navModel = {
-      main: {
-        img: model.info.logos.large,
-        subTitle: model.info.author.name,
-        url: '',
-        text: model.name,
-        breadcrumbs: [{ title: 'Plugins', url: 'plugins' }],
-        children: [
-          {
-            icon: 'fa fa-fw fa-file-text-o',
-            id: 'readme',
-            text: 'Readme',
-            url: `plugins/${this.model.id}/edit?tab=readme`,
-          },
-        ],
-      },
-    };
-
-    if (model.type === 'app') {
-      this.navModel.main.children.push({
-        icon: 'gicon gicon-cog',
-        id: 'config',
-        text: 'Config',
-        url: `plugins/${this.model.id}/edit?tab=config`,
-      });
-
-      const hasDashboards = _.find(model.includes, { type: 'dashboard' });
-
-      if (hasDashboards) {
-        this.navModel.main.children.push({
-          icon: 'gicon gicon-dashboard',
-          id: 'dashboards',
-          text: 'Dashboards',
-          url: `plugins/${this.model.id}/edit?tab=dashboards`,
-        });
-      }
-
-      defaultTab = 'config';
-    }
-
-    this.tab = this.$routeParams.tab || defaultTab;
-
-    for (const tab of this.navModel.main.children) {
-      if (tab.id === this.tab) {
-        tab.active = true;
-      }
-    }
-  }
-
-  init() {
-    return this.backendSrv.get(`/api/plugins/${this.pluginId}/settings`).then(result => {
-      this.model = result;
-      this.pluginIcon = this.getPluginIcon(this.model.type);
-
-      this.model.dependencies.plugins.forEach(plug => {
-        plug.icon = this.getPluginIcon(plug.type);
-      });
-
-      this.includes = _.map(result.includes, plug => {
-        plug.icon = this.getPluginIcon(plug.type);
-        return plug;
-      });
-
-      this.setNavModel(this.model);
-      return this.initReadme();
-    });
-  }
-
-  initReadme() {
-    return this.backendSrv.get(`/api/plugins/${this.pluginId}/markdown/readme`).then(res => {
-      const md = new Remarkable({
-        linkify: true,
-      });
-      this.readmeHtml = this.$sce.trustAsHtml(md.render(res));
-    });
-  }
-
-  getPluginIcon(type) {
-    switch (type) {
-      case 'datasource':
-        return 'icon-gf icon-gf-datasources';
-      case 'panel':
-        return 'icon-gf icon-gf-panel';
-      case 'app':
-        return 'icon-gf icon-gf-apps';
-      case 'page':
-        return 'icon-gf icon-gf-endpoint-tiny';
-      case 'dashboard':
-        return 'icon-gf icon-gf-dashboard';
-      default:
-        return 'icon-gf icon-gf-apps';
-    }
-  }
-
-  update() {
-    this.preUpdateHook()
-      .then(() => {
-        const updateCmd = _.extend(
-          {
-            enabled: this.model.enabled,
-            pinned: this.model.pinned,
-            jsonData: this.model.jsonData,
-            secureJsonData: this.model.secureJsonData,
-          },
-          {}
-        );
-        return this.backendSrv.post(`/api/plugins/${this.pluginId}/settings`, updateCmd);
-      })
-      .then(this.postUpdateHook)
-      .then(res => {
-        window.location.href = window.location.href;
-      });
-  }
-
-  importDashboards() {
-    return Promise.resolve();
-  }
-
-  setPreUpdateHook(callback: () => any) {
-    this.preUpdateHook = callback;
-  }
-
-  setPostUpdateHook(callback: () => any) {
-    this.postUpdateHook = callback;
-  }
-
-  updateAvailable() {
-    const modalScope = this.$scope.$new(true);
-    modalScope.plugin = this.model;
-
-    this.$rootScope.appEvent('show-modal', {
-      src: 'public/app/features/plugins/partials/update_instructions.html',
-      scope: modalScope,
-    });
-  }
-
-  enable() {
-    this.model.enabled = true;
-    this.model.pinned = true;
-    this.update();
-  }
-
-  disable() {
-    this.model.enabled = false;
-    this.model.pinned = false;
-    this.update();
-  }
-}
-
-angular.module('grafana.controllers').controller('PluginEditCtrl', PluginEditCtrl);

+ 3 - 91
public/app/features/profile/PrefControlCtrl.ts

@@ -1,92 +1,4 @@
-import config from 'app/core/config';
-import coreModule from 'app/core/core_module';
+import { react2AngularDirective } from 'app/core/utils/react2angular';
+import { SharedPreferences } from 'app/core/components/SharedPreferences/SharedPreferences';
 
-export class PrefsControlCtrl {
-  prefs: any;
-  oldTheme: any;
-  prefsForm: any;
-  mode: string;
-
-  timezones: any = [
-    { value: '', text: 'Default' },
-    { value: 'browser', text: 'Local browser time' },
-    { value: 'utc', text: 'UTC' },
-  ];
-  themes: any = [{ value: '', text: 'Default' }, { value: 'dark', text: 'Dark' }, { value: 'light', text: 'Light' }];
-
-  /** @ngInject */
-  constructor(private backendSrv, private $location) {}
-
-  $onInit() {
-    return this.backendSrv.get(`/api/${this.mode}/preferences`).then(prefs => {
-      this.prefs = prefs;
-      this.oldTheme = prefs.theme;
-    });
-  }
-
-  updatePrefs() {
-    if (!this.prefsForm.$valid) {
-      return;
-    }
-
-    const cmd = {
-      theme: this.prefs.theme,
-      timezone: this.prefs.timezone,
-      homeDashboardId: this.prefs.homeDashboardId,
-    };
-
-    this.backendSrv.put(`/api/${this.mode}/preferences`, cmd).then(() => {
-      window.location.href = config.appSubUrl + this.$location.path();
-    });
-  }
-}
-
-const template = `
-<form name="ctrl.prefsForm" class="section gf-form-group">
-  <h3 class="page-heading">Preferences</h3>
-
-  <div class="gf-form">
-    <span class="gf-form-label width-11">UI Theme</span>
-    <div class="gf-form-select-wrapper max-width-20">
-      <select class="gf-form-input" ng-model="ctrl.prefs.theme" ng-options="f.value as f.text for f in ctrl.themes"></select>
-    </div>
-  </div>
-
-  <div class="gf-form">
-    <span class="gf-form-label width-11">
-      Home Dashboard
-      <info-popover mode="right-normal">
-        Not finding dashboard you want? Star it first, then it should appear in this select box.
-      </info-popover>
-    </span>
-    <dashboard-selector class="gf-form-select-wrapper max-width-20" model="ctrl.prefs.homeDashboardId">
-    </dashboard-selector>
-  </div>
-
-  <div class="gf-form">
-    <label class="gf-form-label width-11">Timezone</label>
-    <div class="gf-form-select-wrapper max-width-20">
-      <select class="gf-form-input" ng-model="ctrl.prefs.timezone" ng-options="f.value as f.text for f in ctrl.timezones"></select>
-    </div>
-  </div>
-
-  <div class="gf-form-button-row">
-    <button type="submit" class="btn btn-success" ng-click="ctrl.updatePrefs()">Save</button>
-  </div>
-</form>
-`;
-
-export function prefsControlDirective() {
-  return {
-    restrict: 'E',
-    controller: PrefsControlCtrl,
-    bindToController: true,
-    controllerAs: 'ctrl',
-    template: template,
-    scope: {
-      mode: '@',
-    },
-  };
-}
-
-coreModule.directive('prefsControl', prefsControlDirective);
+react2AngularDirective('prefsControl', SharedPreferences, ['resourceUri']);

+ 1 - 1
public/app/features/profile/partials/profile.html

@@ -24,7 +24,7 @@
     </div>
   </form>
 
-  <prefs-control mode="user"></prefs-control>
+  <prefs-control resource-uri="'user'"></prefs-control>
 
   <h3 class="page-heading" ng-show="ctrl.showTeamsList">Teams</h3>
   <div class="gf-form-group" ng-show="ctrl.showTeamsList">

+ 6 - 1
public/app/features/teams/TeamPages.test.tsx

@@ -43,10 +43,15 @@ describe('Render', () => {
     expect(wrapper).toMatchSnapshot();
   });
 
-  it('should render settings page', () => {
+  it('should render settings and preferences page', () => {
     const { wrapper } = setup({
       team: getMockTeam(),
       pageName: 'settings',
+      preferences: {
+        homeDashboardId: 1,
+        theme: 'Default',
+        timezone: 'Default',
+      },
     });
 
     expect(wrapper).toMatchSnapshot();

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels