diff --git a/CHANGELOG.md b/CHANGELOG.md index a389b427d6..399333a95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,10 +14,16 @@ 1. [#2386](https://github.com/influxdata/chronograf/pull/2386): Fix queries that include regex, numbers and wildcard 1. [#2398](https://github.com/influxdata/chronograf/pull/2398): Fix apps on hosts page from parsing tags with null values 1. [#2408](https://github.com/influxdata/chronograf/pull/2408): Fix updated Dashboard names not updating dashboard list +1. [#2416](https://github.com/influxdata/chronograf/pull/2416): Fix default y-axis labels not displaying properly +1. [#2423](https://github.com/influxdata/chronograf/pull/2423): Gracefully scale Template Variables Manager overlay on smaller displays +1. [#2426](https://github.com/influxdata/chronograf/pull/2426): Fix Influx Enterprise users from deletion in race condition + ### Features +1. [#2188](https://github.com/influxdata/chronograf/pull/2188): Add Kapacitor logs to the TICKscript editor 1. [#2384](https://github.com/influxdata/chronograf/pull/2384): Add filtering by name to Dashboard index page 1. [#2385](https://github.com/influxdata/chronograf/pull/2385): Add time shift feature to DataExplorer and Dashboards +1. [#2400](https://github.com/influxdata/chronograf/pull/2400): Allow override of generic oauth2 keys for email ### UI Improvements @@ -41,7 +47,7 @@ ### UI Improvements 1. [#2111](https://github.com/influxdata/chronograf/pull/2111): Increase size of Cell Editor query tabs to reveal more of their query strings 1. [#2120](https://github.com/influxdata/chronograf/pull/2120): Improve appearance of Admin Page tabs on smaller screens -1. [#2119](https://github.com/influxdata/chronograf/pull/2119): Add cancel button to Tickscript editor +1. [#2119](https://github.com/influxdata/chronograf/pull/2119): Add cancel button to TICKscript editor 1. [#2104](https://github.com/influxdata/chronograf/pull/2104): Redesign dashboard naming & renaming interaction 1. [#2104](https://github.com/influxdata/chronograf/pull/2104): Redesign dashboard switching dropdown @@ -61,7 +67,7 @@ ### Features 1. [#1885](https://github.com/influxdata/chronograf/pull/1885): Add `fill` options to data explorer and dashboard queries -1. [#1978](https://github.com/influxdata/chronograf/pull/1978): Support editing kapacitor TICKScript +1. [#1978](https://github.com/influxdata/chronograf/pull/1978): Support editing kapacitor TICKscript 1. [#1721](https://github.com/influxdata/chronograf/pull/1721): Introduce the TICKscript editor UI 1. [#1992](https://github.com/influxdata/chronograf/pull/1992): Add .csv download button to data explorer 1. [#2082](https://github.com/influxdata/chronograf/pull/2082): Add Data Explorer InfluxQL query and location query synchronization, so queries can be shared via a a URL diff --git a/enterprise/meta.go b/enterprise/meta.go index f27012224e..5a07453e76 100644 --- a/enterprise/meta.go +++ b/enterprise/meta.go @@ -23,8 +23,6 @@ type MetaClient struct { client client } -type ClientBuilder func() client - // NewMetaClient represents a meta node in an Influx Enterprise cluster func NewMetaClient(url *url.URL) *MetaClient { return &MetaClient{ @@ -118,39 +116,10 @@ func (m *MetaClient) DeleteUser(ctx context.Context, name string) error { return m.Post(ctx, "/user", a, nil) } -// RemoveAllUserPerms revokes all permissions for a user in Influx Enterprise -func (m *MetaClient) RemoveAllUserPerms(ctx context.Context, name string) error { - user, err := m.User(ctx, name) - if err != nil { - return err - } - - // No permissions to remove - if len(user.Permissions) == 0 { - return nil - } - +// RemoveUserPerms revokes permissions for a user in Influx Enterprise +func (m *MetaClient) RemoveUserPerms(ctx context.Context, name string, perms Permissions) error { a := &UserAction{ Action: "remove-permissions", - User: user, - } - return m.Post(ctx, "/user", a, nil) -} - -// SetUserPerms removes all permissions and then adds the requested perms -func (m *MetaClient) SetUserPerms(ctx context.Context, name string, perms Permissions) error { - err := m.RemoveAllUserPerms(ctx, name) - if err != nil { - return err - } - - // No permissions to add, so, user is in the right state - if len(perms) == 0 { - return nil - } - - a := &UserAction{ - Action: "add-permissions", User: &User{ Name: name, Permissions: perms, @@ -159,6 +128,38 @@ func (m *MetaClient) SetUserPerms(ctx context.Context, name string, perms Permis return m.Post(ctx, "/user", a, nil) } +// SetUserPerms removes permissions not in set and then adds the requested perms +func (m *MetaClient) SetUserPerms(ctx context.Context, name string, perms Permissions) error { + user, err := m.User(ctx, name) + if err != nil { + return err + } + + revoke, add := permissionsDifference(perms, user.Permissions) + + // first, revoke all the permissions the user currently has, but, + // shouldn't... + if len(revoke) > 0 { + err := m.RemoveUserPerms(ctx, name, revoke) + if err != nil { + return err + } + } + + // ... next, add any permissions the user should have + if len(add) > 0 { + a := &UserAction{ + Action: "add-permissions", + User: &User{ + Name: name, + Permissions: add, + }, + } + return m.Post(ctx, "/user", a, nil) + } + return nil +} + // UserRoles returns a map of users to all of their current roles func (m *MetaClient) UserRoles(ctx context.Context) (map[string]Roles, error) { res, err := m.Roles(ctx, nil) @@ -235,39 +236,10 @@ func (m *MetaClient) DeleteRole(ctx context.Context, name string) error { return m.Post(ctx, "/role", a, nil) } -// RemoveAllRolePerms removes all permissions from a role -func (m *MetaClient) RemoveAllRolePerms(ctx context.Context, name string) error { - role, err := m.Role(ctx, name) - if err != nil { - return err - } - - // No permissions to remove - if len(role.Permissions) == 0 { - return nil - } - +// RemoveRolePerms revokes permissions from a role +func (m *MetaClient) RemoveRolePerms(ctx context.Context, name string, perms Permissions) error { a := &RoleAction{ Action: "remove-permissions", - Role: role, - } - return m.Post(ctx, "/role", a, nil) -} - -// SetRolePerms removes all permissions and then adds the requested perms to role -func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permissions) error { - err := m.RemoveAllRolePerms(ctx, name) - if err != nil { - return err - } - - // No permissions to add, so, role is in the right state - if len(perms) == 0 { - return nil - } - - a := &RoleAction{ - Action: "add-permissions", Role: &Role{ Name: name, Permissions: perms, @@ -276,7 +248,39 @@ func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permis return m.Post(ctx, "/role", a, nil) } -// SetRoleUsers removes all users and then adds the requested users to role +// SetRolePerms removes permissions not in set and then adds the requested perms to role +func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permissions) error { + role, err := m.Role(ctx, name) + if err != nil { + return err + } + + revoke, add := permissionsDifference(perms, role.Permissions) + + // first, revoke all the permissions the role currently has, but, + // shouldn't... + if len(revoke) > 0 { + err := m.RemoveRolePerms(ctx, name, revoke) + if err != nil { + return err + } + } + + // ... next, add any permissions the role should have + if len(add) > 0 { + a := &RoleAction{ + Action: "add-permissions", + Role: &Role{ + Name: name, + Permissions: add, + }, + } + return m.Post(ctx, "/role", a, nil) + } + return nil +} + +// SetRoleUsers removes users not in role and then adds the requested users to role func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error { role, err := m.Role(ctx, name) if err != nil { @@ -320,6 +324,29 @@ func Difference(wants []string, haves []string) (revoke []string, add []string) return } +func permissionsDifference(wants Permissions, haves Permissions) (revoke Permissions, add Permissions) { + revoke = make(Permissions) + add = make(Permissions) + for scope, want := range wants { + have, ok := haves[scope] + if ok { + r, a := Difference(want, have) + revoke[scope] = r + add[scope] = a + } else { + add[scope] = want + } + } + + for scope, have := range haves { + _, ok := wants[scope] + if !ok { + revoke[scope] = have + } + } + return +} + // AddRoleUsers updates a role to have additional users. func (m *MetaClient) AddRoleUsers(ctx context.Context, name string, users []string) error { // No permissions to add, so, role is in the right state diff --git a/enterprise/meta_test.go b/enterprise/meta_test.go index fd1a223014..774ceea9b6 100644 --- a/enterprise/meta_test.go +++ b/enterprise/meta_test.go @@ -595,7 +595,7 @@ func TestMetaClient_SetUserPerms(t *testing.T) { wantErr bool }{ { - name: "Successful set permissions User", + name: "Remove all permissions for a user", fields: fields{ URL: &url.URL{ Host: "twinpinesmall.net:8091", @@ -615,7 +615,7 @@ func TestMetaClient_SetUserPerms(t *testing.T) { wantRm: `{"action":"remove-permissions","user":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]}}}`, }, { - name: "Successful set permissions User", + name: "Remove some permissions and add others", fields: fields{ URL: &url.URL{ Host: "twinpinesmall.net:8091", @@ -1137,7 +1137,7 @@ func TestMetaClient_SetRolePerms(t *testing.T) { wantErr bool }{ { - name: "Successful set permissions role", + name: "Remove all roles from user", fields: fields{ URL: &url.URL{ Host: "twinpinesmall.net:8091", @@ -1154,10 +1154,10 @@ func TestMetaClient_SetRolePerms(t *testing.T) { ctx: context.Background(), name: "admin", }, - wantRm: `{"action":"remove-permissions","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, + wantRm: `{"action":"remove-permissions","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]}}}`, }, { - name: "Successful set single permissions role", + name: "Remove some users and add permissions to other", fields: fields{ URL: &url.URL{ Host: "twinpinesmall.net:8091", @@ -1179,7 +1179,7 @@ func TestMetaClient_SetRolePerms(t *testing.T) { }, }, }, - wantRm: `{"action":"remove-permissions","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`, + wantRm: `{"action":"remove-permissions","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]}}}`, wantAdd: `{"action":"add-permissions","role":{"name":"admin","permissions":{"telegraf":["ReadData"]}}}`, }, } @@ -1218,7 +1218,7 @@ func TestMetaClient_SetRolePerms(t *testing.T) { got, _ := ioutil.ReadAll(prm.Body) if string(got) != tt.wantRm { - t.Errorf("%q. MetaClient.SetRolePerms() = %v, want %v", tt.name, string(got), tt.wantRm) + t.Errorf("%q. MetaClient.SetRolePerms() removal = \n%v\n, want \n%v\n", tt.name, string(got), tt.wantRm) } if tt.wantAdd != "" { prm := reqs[2] @@ -1231,7 +1231,7 @@ func TestMetaClient_SetRolePerms(t *testing.T) { got, _ := ioutil.ReadAll(prm.Body) if string(got) != tt.wantAdd { - t.Errorf("%q. MetaClient.SetRolePerms() = %v, want %v", tt.name, string(got), tt.wantAdd) + t.Errorf("%q. MetaClient.SetRolePerms() addition = \n%v\n, want \n%v\n", tt.name, string(got), tt.wantAdd) } } } diff --git a/enterprise/users.go b/enterprise/users.go index 68c04d193c..aa1b9f23ed 100644 --- a/enterprise/users.go +++ b/enterprise/users.go @@ -70,44 +70,49 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error { return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd) } - // Make a list of the roles we want this user to have: - want := make([]string, len(u.Roles)) - for i, r := range u.Roles { - want[i] = r.Name - } + if u.Roles != nil { + // Make a list of the roles we want this user to have: + want := make([]string, len(u.Roles)) + for i, r := range u.Roles { + want[i] = r.Name + } - // Find the list of all roles this user is currently in - userRoles, err := c.UserRoles(ctx) - if err != nil { - return nil - } - // Make a list of the roles the user currently has - roles := userRoles[u.Name] - have := make([]string, len(roles.Roles)) - for i, r := range roles.Roles { - have[i] = r.Name - } + // Find the list of all roles this user is currently in + userRoles, err := c.UserRoles(ctx) + if err != nil { + return nil + } + // Make a list of the roles the user currently has + roles := userRoles[u.Name] + have := make([]string, len(roles.Roles)) + for i, r := range roles.Roles { + have[i] = r.Name + } - // Calculate the roles the user will be removed from and the roles the user - // will be added to. - revoke, add := Difference(want, have) + // Calculate the roles the user will be removed from and the roles the user + // will be added to. + revoke, add := Difference(want, have) - // First, add the user to the new roles - for _, role := range add { - if err := c.Ctrl.AddRoleUsers(ctx, role, []string{u.Name}); err != nil { - return err + // First, add the user to the new roles + for _, role := range add { + if err := c.Ctrl.AddRoleUsers(ctx, role, []string{u.Name}); err != nil { + return err + } + } + + // ... and now remove the user from an extra roles + for _, role := range revoke { + if err := c.Ctrl.RemoveRoleUsers(ctx, role, []string{u.Name}); err != nil { + return err + } } } - // ... and now remove the user from an extra roles - for _, role := range revoke { - if err := c.Ctrl.RemoveRoleUsers(ctx, role, []string{u.Name}); err != nil { - return err - } + if u.Permissions != nil { + perms := ToEnterprise(u.Permissions) + return c.Ctrl.SetUserPerms(ctx, u.Name, perms) } - - perms := ToEnterprise(u.Permissions) - return c.Ctrl.SetUserPerms(ctx, u.Name, perms) + return nil } // All is all users in influx diff --git a/oauth2/generic.go b/oauth2/generic.go index aa18e716f5..0172c70afc 100644 --- a/oauth2/generic.go +++ b/oauth2/generic.go @@ -27,6 +27,7 @@ type Generic struct { AuthURL string TokenURL string APIURL string // APIURL returns OpenID Userinfo + APIKey string // APIKey is the JSON key to lookup email address in APIURL response Logger chronograf.Logger } @@ -69,9 +70,7 @@ func (g *Generic) Config() *oauth2.Config { // PrincipalID returns the email address of the user. func (g *Generic) PrincipalID(provider *http.Client) (string, error) { - res := struct { - Email string `json:"email"` - }{} + res := map[string]interface{}{} r, err := provider.Get(g.APIURL) if err != nil { @@ -83,7 +82,11 @@ func (g *Generic) PrincipalID(provider *http.Client) (string, error) { return "", err } - email := res.Email + email := "" + value := res[g.APIKey] + if e, ok := value.(string); ok { + email = e + } // If we did not receive an email address, try to lookup the email // in a similar way as github diff --git a/oauth2/generic_test.go b/oauth2/generic_test.go index a773c686ae..f33cc9ef41 100644 --- a/oauth2/generic_test.go +++ b/oauth2/generic_test.go @@ -34,6 +34,7 @@ func TestGenericPrincipalID(t *testing.T) { prov := oauth2.Generic{ Logger: logger, APIURL: mockAPI.URL, + APIKey: "email", } tt, err := oauth2.NewTestTripper(logger, mockAPI, http.DefaultTransport) if err != nil { diff --git a/server/server.go b/server/server.go index d51d8148c6..fc3354cb9c 100644 --- a/server/server.go +++ b/server/server.go @@ -79,6 +79,7 @@ type Server struct { GenericAuthURL string `long:"generic-auth-url" description:"OAuth 2.0 provider's authorization endpoint URL" env:"GENERIC_AUTH_URL"` GenericTokenURL string `long:"generic-token-url" description:"OAuth 2.0 provider's token endpoint URL" env:"GENERIC_TOKEN_URL"` GenericAPIURL string `long:"generic-api-url" description:"URL that returns OpenID UserInfo compatible information." env:"GENERIC_API_URL"` + GenericAPIKey string `long:"generic-api-key" description:"JSON lookup key into OpenID UserInfo. (Azure should be userPrincipalName)" default:"email" env:"GENERIC_API_KEY"` Auth0Domain string `long:"auth0-domain" description:"Subdomain of auth0.com used for Auth0 OAuth2 authentication" env:"AUTH0_DOMAIN"` Auth0ClientID string `long:"auth0-client-id" description:"Auth0 Client ID for OAuth2 support" env:"AUTH0_CLIENT_ID"` @@ -181,6 +182,7 @@ func (s *Server) genericOAuth(logger chronograf.Logger, auth oauth2.Authenticato AuthURL: s.GenericAuthURL, TokenURL: s.GenericTokenURL, APIURL: s.GenericAPIURL, + APIKey: s.GenericAPIKey, Logger: logger, } jwt := oauth2.NewJWT(s.TokenSecret) diff --git a/server/swagger.json b/server/swagger.json index 0580955f6f..b8a1c34e75 100644 --- a/server/swagger.json +++ b/server/swagger.json @@ -550,6 +550,7 @@ "patch": { "tags": ["sources", "users"], "summary": "Update user configuration", + "description": "Update one parameter at a time (one of password, permissions or roles)", "parameters": [ { "name": "id", diff --git a/ui/.eslintrc b/ui/.eslintrc index 5e8a1a3cb3..b2bea4ad04 100644 --- a/ui/.eslintrc +++ b/ui/.eslintrc @@ -48,7 +48,7 @@ 'arrow-parens': 0, 'comma-dangle': [2, 'always-multiline'], 'no-cond-assign': 2, - 'no-console': ['error', {allow: ['error']}], + 'no-console': ['error', {allow: ['error', 'warn']}], 'no-constant-condition': 2, 'no-control-regex': 2, 'no-debugger': 2, diff --git a/ui/spec/shared/presenters/presentersSpec.js b/ui/spec/shared/presenters/presentersSpec.js index 2cd1d7347c..d751e9cd42 100644 --- a/ui/spec/shared/presenters/presentersSpec.js +++ b/ui/spec/shared/presenters/presentersSpec.js @@ -1,10 +1,15 @@ -import {buildRoles, buildClusterAccounts} from 'shared/presenters' +import { + buildRoles, + buildClusterAccounts, + buildDefaultYLabel, +} from 'shared/presenters' +import defaultQueryConfig from 'utils/defaultQueryConfig' -describe('Presenters', function() { - describe('roles utils', function() { - describe('buildRoles', function() { - describe('when a role has no users', function() { - it("sets a role's users as an empty array", function() { +describe('Presenters', () => { + describe('roles utils', () => { + describe('buildRoles', () => { + describe('when a role has no users', () => { + it("sets a role's users as an empty array", () => { const roles = [ { name: 'Marketing', @@ -20,8 +25,8 @@ describe('Presenters', function() { }) }) - describe('when a role has no permissions', function() { - it("set's a roles permission as an empty array", function() { + describe('when a role has no permissions', () => { + it("set's a roles permission as an empty array", () => { const roles = [ { name: 'Marketing', @@ -35,9 +40,10 @@ describe('Presenters', function() { }) }) - describe('when a role has users and permissions', function() { - beforeEach(function() { - const roles = [ + describe('when a role has users and permissions', () => { + let roles + beforeEach(() => { + const rs = [ { name: 'Marketing', permissions: { @@ -49,18 +55,18 @@ describe('Presenters', function() { }, ] - this.roles = buildRoles(roles) + roles = buildRoles(rs) }) - it('each role has a name and a list of users (if they exist)', function() { - const role = this.roles[0] + it('each role has a name and a list of users (if they exist)', () => { + const role = roles[0] expect(role.name).to.equal('Marketing') expect(role.users).to.contain('roley@influxdb.com') expect(role.users).to.contain('will@influxdb.com') }) - it('transforms permissions into a list of objects and each permission has a list of resources', function() { - expect(this.roles[0].permissions).to.eql([ + it('transforms permissions into a list of objects and each permission has a list of resources', () => { + expect(roles[0].permissions).to.eql([ { name: 'ViewAdmin', displayName: 'View Admin', @@ -85,10 +91,10 @@ describe('Presenters', function() { }) }) - describe('cluster utils', function() { - describe('buildClusterAccounts', function() { + describe('cluster utils', () => { + describe('buildClusterAccounts', () => { // TODO: break down this test into smaller individual assertions. - it('adds role information to each cluster account and parses permissions', function() { + it('adds role information to each cluster account and parses permissions', () => { const users = [ { name: 'jon@example.com', @@ -192,7 +198,7 @@ describe('Presenters', function() { expect(actual).to.eql(expected) }) - it('can handle empty results for users and roles', function() { + it('can handle empty results for users and roles', () => { const users = undefined const roles = undefined @@ -201,7 +207,7 @@ describe('Presenters', function() { expect(actual).to.eql([]) }) - it('sets roles to an empty array if a user has no roles', function() { + it('sets roles to an empty array if a user has no roles', () => { const users = [ { name: 'ned@example.com', @@ -216,4 +222,41 @@ describe('Presenters', function() { }) }) }) + + describe('buildDefaultYLabel', () => { + it('can return the correct string for field', () => { + const query = defaultQueryConfig({id: 1}) + const fields = [{value: 'usage_system', type: 'field'}] + const measurement = 'm1' + const queryConfig = {...query, measurement, fields} + const actual = buildDefaultYLabel(queryConfig) + + expect(actual).to.equal('m1.usage_system') + }) + + it('can return the correct string for funcs with args', () => { + const query = defaultQueryConfig({id: 1}) + const field = {value: 'usage_system', type: 'field'} + const args = { + value: 'mean', + type: 'func', + args: [field], + alias: '', + } + + const f1 = { + value: 'derivative', + type: 'func', + args: [args], + alias: '', + } + + const fields = [f1] + const measurement = 'm1' + const queryConfig = {...query, measurement, fields} + const actual = buildDefaultYLabel(queryConfig) + + expect(actual).to.equal('m1.derivative_mean_usage_system') + }) + }) }) diff --git a/ui/src/dashboards/components/template_variables/RowButtons.js b/ui/src/dashboards/components/template_variables/RowButtons.js index 549b7a257f..7bc69bc759 100644 --- a/ui/src/dashboards/components/template_variables/RowButtons.js +++ b/ui/src/dashboards/components/template_variables/RowButtons.js @@ -1,33 +1,30 @@ import React, {PropTypes} from 'react' import DeleteConfirmButtons from 'shared/components/DeleteConfirmButtons' -const RowButtons = ({ - onStartEdit, - isEditing, - onCancelEdit, - onDelete, - id, - selectedType, -}) => { +const RowButtons = ({onStartEdit, isEditing, onCancelEdit, onDelete, id}) => { if (isEditing) { return (
-
) } return (
- +
@@ -55,11 +39,11 @@ const TickscriptHeader = ({ const {arrayOf, bool, func, shape, string} = PropTypes TickscriptHeader.propTypes = { + isNewTickscript: bool, onSave: func, - source: shape({ - id: string, - }), - onSelectDbrps: func.isRequired, + areLogsVisible: bool, + areLogsEnabled: bool, + onToggleLogsVisbility: func.isRequired, task: shape({ dbrps: arrayOf( shape({ @@ -68,9 +52,6 @@ TickscriptHeader.propTypes = { }) ), }), - onChangeType: func.isRequired, - onChangeID: func.isRequired, - isNewTickscript: bool.isRequired, } export default TickscriptHeader diff --git a/ui/src/kapacitor/components/TickscriptID.js b/ui/src/kapacitor/components/TickscriptID.js index c005f509b0..8121e70505 100644 --- a/ui/src/kapacitor/components/TickscriptID.js +++ b/ui/src/kapacitor/components/TickscriptID.js @@ -10,7 +10,7 @@ class TickscriptID extends Component { return ( -

+

{id}

diff --git a/ui/src/kapacitor/containers/TickscriptPage.js b/ui/src/kapacitor/containers/TickscriptPage.js index b882ecf8a1..8abf937aa2 100644 --- a/ui/src/kapacitor/containers/TickscriptPage.js +++ b/ui/src/kapacitor/containers/TickscriptPage.js @@ -1,11 +1,14 @@ import React, {PropTypes, Component} from 'react' import {connect} from 'react-redux' import {bindActionCreators} from 'redux' +import uuid from 'node-uuid' import Tickscript from 'src/kapacitor/components/Tickscript' import * as kapactiorActionCreators from 'src/kapacitor/actions/view' import * as errorActionCreators from 'shared/actions/errors' import {getActiveKapacitor} from 'src/shared/apis' +import {getLogStreamByRuleID, pingKapacitorVersion} from 'src/kapacitor/apis' +import {publishNotification} from 'shared/actions/notifications' class TickscriptPage extends Component { constructor(props) { @@ -23,6 +26,96 @@ class TickscriptPage extends Component { }, validation: '', isEditingID: true, + logs: [], + areLogsEnabled: false, + failStr: '', + } + } + + fetchChunkedLogs = async (kapacitor, ruleID) => { + const {notify} = this.props + + try { + const version = await pingKapacitorVersion(kapacitor) + + if (version && parseInt(version.split('.')[1], 10) < 4) { + this.setState({ + areLogsEnabled: false, + }) + notify( + 'warning', + 'Could not use logging, requires Kapacitor version 1.4' + ) + return + } + + if (this.state.logs.length === 0) { + this.setState({ + areLogsEnabled: true, + logs: [ + { + id: uuid.v4(), + key: uuid.v4(), + lvl: 'info', + msg: 'created log session', + service: 'sessions', + tags: 'nil', + ts: new Date().toISOString(), + }, + ], + }) + } + + const response = await getLogStreamByRuleID(kapacitor, ruleID) + + const reader = await response.body.getReader() + const decoder = new TextDecoder() + + let result + + while (this.state.areLogsEnabled === true && !(result && result.done)) { + result = await reader.read() + + const chunk = decoder.decode(result.value || new Uint8Array(), { + stream: !result.done, + }) + + const json = chunk.split('\n') + + let logs = [] + let failStr = this.state.failStr + + try { + for (let objStr of json) { + objStr = failStr + objStr + failStr = objStr + const jsonStr = `[${objStr.split('}{').join('},{')}]` + logs = [ + ...logs, + ...JSON.parse(jsonStr).map(log => ({ + ...log, + key: uuid.v4(), + })), + ] + failStr = '' + } + + this.setState({ + logs: [...this.state.logs, ...logs], + failStr, + }) + } catch (err) { + console.warn(err, failStr) + this.setState({ + logs: [...this.state.logs, ...logs], + failStr, + }) + } + } + } catch (error) { + console.error(error) + notify('error', error) + throw error } } @@ -50,9 +143,17 @@ class TickscriptPage extends Component { this.setState({task: {tickscript, dbrps, type, status, name, id}}) } + this.fetchChunkedLogs(kapacitor, ruleID) + this.setState({kapacitor}) } + componentWillUnmount() { + this.setState({ + areLogsEnabled: false, + }) + } + handleSave = async () => { const {kapacitor, task} = this.state const { @@ -96,13 +197,18 @@ class TickscriptPage extends Component { this.setState({task: {...this.state.task, id: e.target.value}}) } + handleToggleLogsVisbility = () => { + this.setState({areLogsVisible: !this.state.areLogsVisible}) + } + render() { const {source} = this.props - const {task, validation} = this.state + const {task, validation, logs, areLogsVisible, areLogsEnabled} = this.state return ( ) } @@ -142,6 +251,7 @@ TickscriptPage.propTypes = { ruleID: string, }).isRequired, rules: arrayOf(shape()), + notify: func.isRequired, } const mapStateToProps = state => { @@ -153,6 +263,7 @@ const mapStateToProps = state => { const mapDispatchToProps = dispatch => ({ kapacitorActions: bindActionCreators(kapactiorActionCreators, dispatch), errorActions: bindActionCreators(errorActionCreators, dispatch), + notify: bindActionCreators(publishNotification, dispatch), }) export default connect(mapStateToProps, mapDispatchToProps)(TickscriptPage) diff --git a/ui/src/shared/components/DeleteConfirmButtons.js b/ui/src/shared/components/DeleteConfirmButtons.js index 3f503727d2..14fdfa7334 100644 --- a/ui/src/shared/components/DeleteConfirmButtons.js +++ b/ui/src/shared/components/DeleteConfirmButtons.js @@ -4,14 +4,15 @@ import classnames from 'classnames' import OnClickOutside from 'shared/components/OnClickOutside' import ConfirmButtons from 'shared/components/ConfirmButtons' -const DeleteButton = ({onClickDelete, buttonSize}) => +const DeleteButton = ({onClickDelete, buttonSize, icon, square}) => class DeleteConfirmButtons extends Component { @@ -37,7 +38,7 @@ class DeleteConfirmButtons extends Component { } render() { - const {onDelete, item, buttonSize} = this.props + const {onDelete, item, buttonSize, icon, square} = this.props const {isConfirming} = this.state return isConfirming @@ -50,21 +51,27 @@ class DeleteConfirmButtons extends Component { : } } -const {func, oneOfType, shape, string} = PropTypes +const {bool, func, oneOfType, shape, string} = PropTypes DeleteButton.propTypes = { onClickDelete: func.isRequired, buttonSize: string, + icon: string, + square: bool, } DeleteConfirmButtons.propTypes = { item: oneOfType([(string, shape())]), onDelete: func.isRequired, buttonSize: string, + square: bool, + icon: string, } DeleteConfirmButtons.defaultProps = { diff --git a/ui/src/shared/components/ResizeContainer.js b/ui/src/shared/components/ResizeContainer.js index a8baab69e7..a67a40374b 100644 --- a/ui/src/shared/components/ResizeContainer.js +++ b/ui/src/shared/components/ResizeContainer.js @@ -97,7 +97,7 @@ class ResizeContainer extends Component { render() { const {bottomHeightPixels, topHeight, bottomHeight, isDragging} = this.state - const {containerClass, children} = this.props + const {containerClass, children, theme} = this.props if (React.Children.count(children) > maximumNumChildren) { console.error( @@ -122,6 +122,7 @@ class ResizeContainer extends Component { })} diff --git a/ui/src/shared/presenters/index.js b/ui/src/shared/presenters/index.js index dd23a5e04c..1c19b5e556 100644 --- a/ui/src/shared/presenters/index.js +++ b/ui/src/shared/presenters/index.js @@ -1,4 +1,6 @@ import _ from 'lodash' +import {fieldWalk} from 'src/shared/reducers/helpers/fields' + import {PERMISSIONS} from 'shared/constants' export function buildRoles(roles) { @@ -110,11 +112,18 @@ function getRolesForUser(roles, user) { } export const buildDefaultYLabel = queryConfig => { - return queryConfig.rawText - ? '' - : `${queryConfig.measurement}.${_.get( - queryConfig, - ['fields', '0', 'field'], - '' - )}` + const {measurement} = queryConfig + const fields = _.get(queryConfig, ['fields', '0'], []) + + const walkZerothArgs = f => { + if (f.type === 'field') { + return f.value + } + + return `${f.value}${_.get(f, ['0', 'args', 'value'], '')}` + } + + const values = fieldWalk([fields], walkZerothArgs) + + return `${measurement}.${values.join('_')}` } diff --git a/ui/src/style/chronograf.scss b/ui/src/style/chronograf.scss index 2be5b0290f..3c36159c26 100644 --- a/ui/src/style/chronograf.scss +++ b/ui/src/style/chronograf.scss @@ -52,6 +52,7 @@ @import 'components/source-indicator'; @import 'components/source-selector'; @import 'components/tables'; +@import 'components/kapacitor-logs-table'; // Pages @import 'pages/config-endpoints'; @@ -60,6 +61,7 @@ @import 'pages/kapacitor'; @import 'pages/dashboards'; @import 'pages/admin'; +@import 'pages/tickscript-editor'; // TODO @import 'unsorted'; diff --git a/ui/src/style/components/code-mirror-theme.scss b/ui/src/style/components/code-mirror-theme.scss index a067d11453..1311d32a7b 100644 --- a/ui/src/style/components/code-mirror-theme.scss +++ b/ui/src/style/components/code-mirror-theme.scss @@ -7,45 +7,6 @@ */ -$tickscript-console-height: 120px; - -.tickscript-console, -.tickscript-editor { - padding-left: $page-wrapper-padding; - padding-right: $page-wrapper-padding; - margin: 0 auto; - max-width: $page-wrapper-max-width; - position: relative; -} -.tickscript-console { - height: $tickscript-console-height; - padding-top: 30px; -} -.tickscript-console--output { - padding: 0 60px; - font-family: $code-font; - font-weight: 600; - display: flex; - align-items: center; - background-color: $g3-castle; - position: relative; - height: 100%; - width: 100%; - border-radius: $radius $radius 0 0; - - > p { - margin: 0; - } -} -.tickscript-console--default { - color: $g10-wolf; - font-style: italic; -} -.tickscript-editor { - margin: 0 auto; - padding-bottom: 30px; - height: calc(100% - #{$tickscript-console-height}); -} .ReactCodeMirror { position: relative; width: 100%; @@ -54,8 +15,8 @@ $tickscript-console-height: 120px; .cm-s-material.CodeMirror { border-radius: 0 0 $radius $radius; font-family: $code-font; - background-color: $g2-kevlar; - color: $c-neutrino; + background-color: transparent; + color: $g13-mist; font-weight: 600; height: 100%; } @@ -63,7 +24,7 @@ $tickscript-console-height: 120px; @include custom-scrollbar-round($g2-kevlar,$g6-smoke); } .cm-s-material .CodeMirror-gutters { - background-color: fade-out($g4-onyx, 0.5); + background-color: fade-out($g4-onyx, 0.7); border: none; } .CodeMirror-gutter.CodeMirror-linenumbers { diff --git a/ui/src/style/components/kapacitor-logs-table.scss b/ui/src/style/components/kapacitor-logs-table.scss new file mode 100644 index 0000000000..fed167ff9d --- /dev/null +++ b/ui/src/style/components/kapacitor-logs-table.scss @@ -0,0 +1,124 @@ +/* + Styles for Kapacitor Logs Table + ---------------------------------------------------------------------------- +*/ + +$logs-table-header-height: 60px; +$logs-table-padding: 60px; +$logs-row-indent: 6px; +$logs-level-dot: 8px; +$logs-margin: 4px; + +.logs-table--container { + width: 50%; + position: relative; + height: 100%; + @include gradient-v($g3-castle,$g1-raven); +} +.logs-table--header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: nowrap; + height: $logs-table-header-height; + padding: 0 $logs-table-padding 0 ($logs-table-padding / 2); + background-color: $g4-onyx; +} +.logs-table--panel { + position: absolute !important; + top: $logs-table-header-height; + left: 0; + width: 100%; + height: calc(100% - #{$logs-table-header-height}) !important; +} + +.logs-table, +.logs-table--row { + display: flex; + align-items: stretch; + flex-direction: column; +} +@keyframes LogsFadeIn { + from { + background-color: $g6-smoke; + } + to { + background-color: transparent; + } +} +.logs-table { + flex-direction: column-reverse; +} +.logs-table--row { + padding: 8px ($logs-table-padding - 16px) 8px ($logs-table-padding / 2); + border-bottom: 2px solid $g3-castle; + animation-name: LogsFadeIn; + animation-duration: 2.5s; + animation-iteration-count: 1; + animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); + transition: background-color 0.25s ease; + + &:hover { + background-color: $g4-onyx; + } + &:first-child { + border-bottom: none; + } +} +.logs-table--divider { + display: flex; + align-items: center; +} +.logs-table--level { + width: $logs-level-dot; + height: $logs-level-dot; + border-radius: 50%; + position: relative; + margin-right: $logs-row-indent; + + &.debug {background-color: $c-comet;} + &.info {background-color: $g6-smoke;} + &.warn {background-color: $c-pineapple;} + &.ok {background-color: $c-rainforest;} + &.error {background-color: $c-dreamsicle;} +} +.logs-table--timestamp { + font-family: $code-font; + font-weight: 500; + font-size: 11px; + color: $g9-mountain; + flex: 1 0 0; +} +.logs-table--details { + display: flex; + align-items: flex-start; + font-size: 13px; + color: $g13-mist; + font-weight: 600; + padding-left: ($logs-level-dot + $logs-row-indent); + + .error {color: $c-dreamsicle;} + .debug {color: $c-comet;} +} + +/* Logs Table Item Types */ +.logs-table--session { + text-transform: capitalize; + font-style: italic; +} +.logs-table--service { + width: 140px; +} +.logs-table--blah { + display: flex; + flex: 1 0 0; +} +.logs-table--key-values { + color: $g11-sidewalk; + flex: 1 0 50%; +} +.logs-table--key-value { +} +.logs-table--key-value span { + color: $c-pool; +} diff --git a/ui/src/style/components/resizer.scss b/ui/src/style/components/resizer.scss index d5d20872fb..0cc9107dbc 100644 --- a/ui/src/style/components/resizer.scss +++ b/ui/src/style/components/resizer.scss @@ -13,6 +13,7 @@ $resizer-dots: $g3-castle; $resizer-color: $g5-pepper; $resizer-color-hover: $g8-storm; $resizer-color-active: $c-pool; +$resizer-color-kapacitor: $c-rainforest; .resize--container { overflow: hidden !important; @@ -109,4 +110,13 @@ $resizer-color-active: $c-pool; box-shadow: 0 0 $resizer-glow $resizer-color-active; } } -} \ No newline at end of file +} + +/* Kapacitor Theme */ +.resizer--handle.resizer--malachite.dragging { + &:before, + &:after { + background-color: $resizer-color-kapacitor; + box-shadow: 0 0 $resizer-glow $resizer-color-kapacitor; + } +} diff --git a/ui/src/style/components/template-variables-manager.scss b/ui/src/style/components/template-variables-manager.scss index ad85d3c211..2f62ffa49a 100644 --- a/ui/src/style/components/template-variables-manager.scss +++ b/ui/src/style/components/template-variables-manager.scss @@ -42,19 +42,20 @@ $tvmp-table-gutter: 8px; /* Column Widths */ .tvm--col-1 {flex: 0 0 140px;} .tvm--col-2 {flex: 0 0 140px;} -.tvm--col-3 {flex: 1 0 500px;} -.tvm--col-4 {flex: 0 0 160px;} +.tvm--col-3 {flex: 1 0 0;} +.tvm--col-4 {flex: 0 0 68px;} /* Table Column Labels */ .template-variable-manager--table-heading { padding: 0 $tvmp-table-gutter; height: 18px; display: flex; - align-items: center; + align-items: stretch; flex-wrap: nowrap; font-weight: 600; font-size: 12px; color: $g11-sidewalk; + white-space: nowrap; > * { @include no-user-select(); @@ -132,7 +133,6 @@ $tvmp-table-gutter: 8px; line-height: 30px; border-radius: $radius; background-color: $g5-pepper; - margin-top: 2px; @include custom-scrollbar-round($g5-pepper, $g7-graphite); } .tvm-values-empty { @@ -160,9 +160,13 @@ $tvmp-table-gutter: 8px; .tvm-query-builder { display: flex; align-items: center; - height: 30px; + min-height: 30px; + flex-wrap: wrap; - > * {margin-right: 2px;} + > * { + margin-bottom: 2px; + margin-right: 2px; + } > *:last-child {margin-right: 0;} .dropdown { @@ -193,7 +197,9 @@ $tvmp-table-gutter: 8px; order: 1; } - > .btn {margin-left: $tvmp-table-gutter;} + > .btn:first-child { + margin-left: $tvmp-table-gutter; + } /* Override confirm buttons styles */ /* Janky, but doing this quick & dirty for now */ diff --git a/ui/src/style/layout/page.scss b/ui/src/style/layout/page.scss index 935a685ea7..1e88c5163e 100644 --- a/ui/src/style/layout/page.scss +++ b/ui/src/style/layout/page.scss @@ -15,7 +15,8 @@ .page { flex-grow: 1; } -.page-contents { +.page-contents, +.page-contents--split { position: absolute !important; top: $chronograf-page-header-height; left: 0; @@ -28,6 +29,10 @@ height: 100%; } } +.page-contents--split { + display: flex; + align-items: stretch; +} .container-fluid { padding: ($chronograf-page-header-height / 2) $page-wrapper-padding; max-width: $page-wrapper-max-width; diff --git a/ui/src/style/pages/tickscript-editor.scss b/ui/src/style/pages/tickscript-editor.scss new file mode 100644 index 0000000000..3f70bb942a --- /dev/null +++ b/ui/src/style/pages/tickscript-editor.scss @@ -0,0 +1,90 @@ +/* + Styles for TICKscript Editor + ---------------------------------------------------------------------------- +*/ + +$tickscript-console-height: 60px; + +.tickscript { + flex: 1 0 0; +} +.tickscript-controls, +.tickscript-console, +.tickscript-editor { + padding: 0; + margin: 0; + width: 100%; + position: relative; +} +.tickscript-controls, +.tickscript-console { + height: $tickscript-console-height; +} +.tickscript-controls { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 60px; + background-color: $g3-castle; +} +.tickscript-controls--name { + margin: 0; + letter-spacing: 0; + @include no-user-select(); + font-size: 17px; + font-weight: 400; + color: $g13-mist; +} +.tickscript-controls--right { + display: flex; + align-items: center; + flex-wrap: nowrap; + + > * {margin-left: 8px;} +} +.tickscript-console--output { + padding: 0 60px; + font-family: $code-font; + font-weight: 600; + display: flex; + align-items: center; + background-color: $g2-kevlar; + border-bottom: 2px solid $g3-castle; + position: relative; + height: 100%; + width: 100%; + border-radius: $radius $radius 0 0; + + > p { + margin: 0; + } +} +.tickscript-console--default { + color: $g10-wolf; + font-style: italic; +} +.tickscript-editor { + height: calc(100% - #{$tickscript-console-height * 2}); +} + +/* + Toggle for displaying Logs + ---------------------------------------------------------------------------- +*/ +.logs-toggle { + position: absolute; + left: 50%; + transform: translateX(-50%); + + > li { + width: 100px; + justify-content: center; + } + > li:not(.active) { + background-color: $g0-obsidian; + + &:hover { + background-color: $g3-castle; + } + } +}