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

This commit is contained in:
Peter Holmberg 2018-11-16 16:54:02 +01:00
commit cc7bf31c3e
155 changed files with 3516 additions and 1846 deletions

1
.gitignore vendored
View File

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

View File

@ -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)
* **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)
* **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)
* **Alerting**: Increaste default duration for queries [#13945](https://github.com/grafana/grafana/pull/13945)
* **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)

View File

@ -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"
}
```

View File

@ -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"
}
}

View File

@ -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))

View File

@ -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)
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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,

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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"`

View File

@ -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:"-"`

View File

@ -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;"))
}

View File

@ -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,

View File

@ -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)
})
})
}

View File

@ -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));
};
}

View File

@ -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>
);
};

View File

@ -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 },
};

View File

@ -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;
}
}

View File

@ -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}

View File

@ -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;

View File

@ -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}`;

View File

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

View File

@ -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;

View File

@ -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);

View File

@ -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

View File

@ -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"

View File

@ -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}
/>
);
}
};
}

View File

@ -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>
);
}
};
}

View File

@ -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;

View File

@ -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) {

View File

@ -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,
};

View File

@ -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 {

View File

@ -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;
};

View File

@ -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();
},
};
}
}

View File

@ -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';

View File

@ -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);

View File

@ -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 });

View File

@ -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;
}
// 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];
const { panel } = this.props;
if (this.pluginInfo.exports) {
// handle plugin loading & changing of plugin type
if (!this.state.plugin || this.state.plugin.id !== panel.type) {
const plugin = config.panels[panel.type] || getPanelPluginNotFound(panel.type);
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}
/>
);
}
}

View File

@ -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>
);

View File

@ -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>
</>
);
}
}

View File

@ -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>
</>
);
}
}

View File

@ -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;
}
this.setState(prevState => ({
...prevState,
const { panel } = this.props;
const timeData = applyPanelTimeOverrides(panel, this.timeSrv.timeRange());
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>
);
}
}

View File

@ -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>

View File

@ -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>
</>
);
}
}

View File

@ -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;

View File

@ -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>;
}
}

View File

@ -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,
},
};
}

View File

@ -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>
);
}
}

View File

@ -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>
);
}
}

View File

@ -1,12 +1,12 @@
import React, { PureComponent } from 'react';
import classNames from 'classnames';
import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins';
import CustomScrollbar from 'app/core/components/CustomScrollbar/CustomScrollbar';
import _ from 'lodash';
import config from 'app/core/config';
import { PanelPlugin } from 'app/types/plugins';
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>
</>
);
}
}

View File

@ -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;

View File

@ -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');
});
});

View File

@ -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);
}

View File

@ -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';

View File

@ -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));
}

View File

@ -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);

View File

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

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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;

View File

@ -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();
});
});

View File

@ -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));

View File

@ -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;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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>
`;

View File

@ -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);
}

View File

@ -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: '',
},

View File

@ -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;

View File

@ -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;

View File

@ -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, []);

View File

@ -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(),
};

View File

@ -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,
};

View File

@ -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();
});
});

View File

@ -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);

View File

@ -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>

View File

@ -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>
`;

View File

@ -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();
};
}

View File

@ -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;

View File

@ -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 };

View File

@ -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 */

View File

@ -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?) {

View File

@ -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();

View File

@ -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

View File

@ -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: {},
};
};

View File

@ -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": "",

View File

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

View File

@ -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) {

View File

@ -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';

View File

@ -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';

View File

@ -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>

View File

@ -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,

View File

@ -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);

View File

@ -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']);

View File

@ -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">

View File

@ -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();

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