Merge branch 'master' into feature/admin

Conflicts:
	CHANGELOG.md
	enterprise/users.go
	server/admin.go
	server/admin_test.go
	ui/.eslintrc
	ui/src/shared/constants/index.js
	ui/src/store/configureStore.js
	ui/src/style/theme/theme-dark.scss
pull/993/head
Jared Scheib 2017-03-10 18:36:37 -08:00
commit 4e2617ea24
122 changed files with 4083 additions and 2787 deletions

View File

@ -2,14 +2,17 @@
### Bug Fixes
1. [#936](https://github.com/influxdata/chronograf/pull/936): Fix leaking sockets for InfluxQL queries
2. [#967](https://github.com/influxdata/chronograf/pull/967): Fix flash of empty graph on auto-refresh when no results were previously returned from a query.
3. [#968](https://github.com/influxdata/chronograf/issue/968): Fix wrong database used in dashboards
### Features
1. [#993](https://github.com/influxdata/chronograf/pull/993): Add Admin page with ability to manage Users, Roles, and Permissions for InfluxDB and Enterprise
2. [#993](https://github.com/influxdata/chronograf/pull/993): Add ability to manage Queries for InfluxDB and Enterprise
### UI Improvements
1. [#993](https://github.com/influxdata/chronograf/pull/993): Improve multi-select dropdown
2. [#993](https://github.com/influxdata/chronograf/pull/993): Provide better error information to users
1. [#989](https://github.com/influxdata/chronograf/pull/989) Add a canned dashboard for mesos
2. [#993](https://github.com/influxdata/chronograf/pull/993): Improve multi-select dropdown
3. [#993](https://github.com/influxdata/chronograf/pull/993): Provide better error information to users
## v1.2.0-beta4 [2017-02-24]
@ -25,6 +28,7 @@
4. [#892](https://github.com/influxdata/chronograf/issues/891): Make dashboard visualizations resizable
5. [#893](https://github.com/influxdata/chronograf/issues/893): Persist dashboard visualization position
6. [#922](https://github.com/influxdata/chronograf/issues/922): Additional OAuth2 support for [Heroku](https://github.com/influxdata/chronograf/blob/master/docs/auth.md#heroku) and [Google](https://github.com/influxdata/chronograf/blob/master/docs/auth.md#google)
7. [#781](https://github.com/influxdata/chronograf/issues/781): Add global auto-refresh dropdown to all graph dashboards
### UI Improvements
1. [#905](https://github.com/influxdata/chronograf/pull/905): Make scroll bar thumb element bigger

View File

@ -17,9 +17,8 @@ You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
InfluxData Inc.
150 Spear Street
Suite 1750
San Francisco, CA 94105
799 Market Street, Suite 400
San Francisco, CA 94103
contact@influxdata.com

View File

@ -29,6 +29,7 @@ Currently, Chronograf offers dashboard templates for the following Telegraf inpu
* [InfluxDB](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/influxdb)
* [Kubernetes](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/kubernetes)
* [Memcached](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/memcached)
* [Mesos](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/mesos)
* [MongoDB](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/mongodb)
* [MySQL](https://github.com/influxdata/telegraf/blob/master/plugins/inputs/mysql)
* Network

View File

@ -11,8 +11,10 @@ import (
// Ensure AlertsStore implements chronograf.AlertsStore.
var _ chronograf.AlertRulesStore = &AlertsStore{}
// AlertsBucket is the name of the bucket alert configuration is stored in
var AlertsBucket = []byte("Alerts")
// AlertsStore represents the bolt implementation of a store for alerts
type AlertsStore struct {
client *Client
}

View File

@ -23,6 +23,7 @@ type Client struct {
DashboardsStore *DashboardsStore
}
// NewClient initializes all stores
func NewClient() *Client {
c := &Client{Now: time.Now}
c.SourcesStore = &SourcesStore{client: c}
@ -79,6 +80,7 @@ func (c *Client) Open() error {
return nil
}
// Close the connection to the bolt database
func (c *Client) Close() error {
if c.db != nil {
return c.db.Close()

View File

@ -12,8 +12,10 @@ import (
// Ensure DashboardsStore implements chronograf.DashboardsStore.
var _ chronograf.DashboardsStore = &DashboardsStore{}
// DashboardBucket is the bolt bucket dashboards are stored in
var DashboardBucket = []byte("Dashoard")
// DashboardsStore is the bolt implementation of storing dashboards
type DashboardsStore struct {
client *Client
IDs chronograf.DashboardID
@ -81,9 +83,9 @@ func (d *DashboardsStore) Get(ctx context.Context, id chronograf.DashboardID) (c
}
// Delete the dashboard from DashboardsStore
func (s *DashboardsStore) Delete(ctx context.Context, d chronograf.Dashboard) error {
if err := s.client.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket(DashboardBucket).Delete(itob(int(d.ID))); err != nil {
func (d *DashboardsStore) Delete(ctx context.Context, dash chronograf.Dashboard) error {
if err := d.client.db.Update(func(tx *bolt.Tx) error {
if err := tx.Bucket(DashboardBucket).Delete(itob(int(dash.ID))); err != nil {
return err
}
return nil
@ -95,16 +97,16 @@ func (s *DashboardsStore) Delete(ctx context.Context, d chronograf.Dashboard) er
}
// Update the dashboard in DashboardsStore
func (s *DashboardsStore) Update(ctx context.Context, d chronograf.Dashboard) error {
if err := s.client.db.Update(func(tx *bolt.Tx) error {
func (d *DashboardsStore) Update(ctx context.Context, dash chronograf.Dashboard) error {
if err := d.client.db.Update(func(tx *bolt.Tx) error {
// Get an existing dashboard with the same ID.
b := tx.Bucket(DashboardBucket)
strID := strconv.Itoa(int(d.ID))
strID := strconv.Itoa(int(dash.ID))
if v := b.Get([]byte(strID)); v == nil {
return chronograf.ErrDashboardNotFound
}
if v, err := internal.MarshalDashboard(d); err != nil {
if v, err := internal.MarshalDashboard(dash); err != nil {
return err
} else if err := b.Put([]byte(strID), v); err != nil {
return err

View File

@ -11,8 +11,10 @@ import (
// Ensure LayoutStore implements chronograf.LayoutStore.
var _ chronograf.LayoutStore = &LayoutStore{}
// LayoutBucket is the bolt bucket layouts are stored in
var LayoutBucket = []byte("Layout")
// LayoutStore is the bolt implementation to store layouts
type LayoutStore struct {
client *Client
IDs chronograf.ID

View File

@ -11,8 +11,11 @@ import (
// Ensure ServersStore implements chronograf.ServersStore.
var _ chronograf.ServersStore = &ServersStore{}
// ServersBucket is the bolt bucket to store lists of servers
var ServersBucket = []byte("Servers")
// ServersStore is the bolt implementation to store servers in a store.
// Used store servers that are associated in some way with a source
type ServersStore struct {
client *Client
}

View File

@ -11,8 +11,10 @@ import (
// Ensure SourcesStore implements chronograf.SourcesStore.
var _ chronograf.SourcesStore = &SourcesStore{}
// SourcesBucket is the bolt bucket used to store source information
var SourcesBucket = []byte("Sources")
// SourcesStore is a bolt implementation to store time-series source information.
type SourcesStore struct {
client *Client
}

View File

@ -11,6 +11,7 @@ import (
"github.com/influxdata/chronograf"
)
// AppExt is the the file extension searched for in the directory for layout files
const AppExt = ".json"
// Apps are canned JSON layouts. Implements LayoutStore.
@ -25,6 +26,7 @@ type Apps struct {
Logger chronograf.Logger
}
// NewApps constructs a layout store wrapping a file system directory
func NewApps(dir string, ids chronograf.ID, logger chronograf.Logger) chronograf.LayoutStore {
return &Apps{
Dir: dir,
@ -63,14 +65,14 @@ func createLayout(file string, layout chronograf.Layout) error {
defer h.Close()
if octets, err := json.MarshalIndent(layout, " ", " "); err != nil {
return chronograf.ErrLayoutInvalid
} else {
if _, err := h.Write(octets); err != nil {
} else if _, err := h.Write(octets); err != nil {
return err
}
}
return nil
}
// All returns all layouts from the directory
func (a *Apps) All(ctx context.Context) ([]chronograf.Layout, error) {
files, err := a.ReadDir(a.Dir)
if err != nil {
@ -91,6 +93,7 @@ func (a *Apps) All(ctx context.Context) ([]chronograf.Layout, error) {
return layouts, nil
}
// Add creates a new layout within the directory
func (a *Apps) Add(ctx context.Context, layout chronograf.Layout) (chronograf.Layout, error) {
var err error
layout.ID, err = a.IDs.Generate()
@ -118,6 +121,7 @@ func (a *Apps) Add(ctx context.Context, layout chronograf.Layout) (chronograf.La
return layout, nil
}
// Delete removes a layout file from the directory
func (a *Apps) Delete(ctx context.Context, layout chronograf.Layout) error {
_, file, err := a.idToFile(layout.ID)
if err != nil {
@ -134,6 +138,7 @@ func (a *Apps) Delete(ctx context.Context, layout chronograf.Layout) error {
return nil
}
// Get returns an app file from the layout directory
func (a *Apps) Get(ctx context.Context, ID string) (chronograf.Layout, error) {
l, file, err := a.idToFile(ID)
if err != nil {
@ -157,6 +162,7 @@ func (a *Apps) Get(ctx context.Context, ID string) (chronograf.Layout, error) {
return l, nil
}
// Update replaces a layout from the file system directory
func (a *Apps) Update(ctx context.Context, layout chronograf.Layout) error {
l, _, err := a.idToFile(layout.ID)
if err != nil {

View File

@ -10,6 +10,7 @@ import (
//go:generate go-bindata -o bin_gen.go -ignore README|apps|.sh|go -pkg canned .
// BinLayoutStore represents a layout store using data generated by go-bindata
type BinLayoutStore struct {
Logger chronograf.Logger
}

131
canned/mesos.json Normal file
View File

@ -0,0 +1,131 @@
{
"id": "0fa47984-825b-46f1-9ca5-0366e3220000",
"measurement": "mesos",
"app": "mesos",
"autoflow": true,
"cells": [
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"i": "0fa47984-825b-46f1-9ca5-0366e3220007",
"name": "Mesos Active Slaves",
"queries": [
{
"query": "SELECT max(\"master/slaves_active\") AS \"Active Slaves\" FROM \"mesos\"",
"label": "count",
"groupbys": [],
"wheres": []
}
]
},
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"i": "0fa47984-825b-46f1-9ca5-0366e3220001",
"name": "Mesos Tasks Active",
"queries": [
{
"query": "SELECT max(\"master/tasks_running\") AS \"num tasks\" FROM \"mesos\"",
"label": "count",
"groupbys": [],
"wheres": []
}
]
},
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"i": "0fa47984-825b-46f1-9ca5-0366e3220004",
"name": "Mesos Tasks",
"queries": [
{
"query": "SELECT non_negative_derivative(max(\"master/tasks_finished\"), 60s) AS \"tasks finished\" FROM \"mesos\"",
"label": "count",
"groupbys": [],
"wheres": []
},
{
"query": "SELECT non_negative_derivative(max(\"master/tasks_failed\"), 60s) AS \"tasks failed\" FROM \"mesos\"",
"groupbys": [],
"wheres": []
},
{
"query": "SELECT non_negative_derivative(max(\"master/tasks_killed\"), 60s) AS \"tasks killed\" FROM \"mesos\"",
"groupbys": [],
"wheres": []
}
]
},
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"i": "0fa47984-825b-46f1-9ca5-0366e3220005",
"name": "Mesos Outstanding offers",
"queries": [
{
"query": "SELECT max(\"master/outstanding_offers\") AS \"Outstanding Offers\" FROM \"mesos\"",
"label": "count",
"groupbys": [],
"wheres": []
}
]
},
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"i": "0fa47984-825b-46f1-9ca5-0366e3220002",
"name": "Mesos Available/Used CPUs",
"queries": [
{
"query": "SELECT max(\"master/cpus_total\") AS \"cpu total\", max(\"master/cpus_used\") AS \"cpu used\" FROM \"mesos\"",
"label": "count",
"groupbys": [],
"wheres": []
}
]
},
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"i": "0fa47984-825b-46f1-9ca5-0366e3220003",
"name": "Mesos Available/Used Memory",
"queries": [
{
"query": "SELECT max(\"master/mem_total\") AS \"memory total\", max(\"master/mem_used\") AS \"memory used\" FROM \"mesos\"",
"label": "MB",
"groupbys": [],
"wheres": []
}
]
},
{
"x": 0,
"y": 0,
"w": 4,
"h": 4,
"i": "0fa47984-825b-46f1-9ca5-0366e3220008",
"name": "Mesos Master Uptime",
"type": "single-stat",
"queries": [
{
"query": "SELECT max(\"master/uptime_secs\") AS \"uptime\" FROM \"mesos\"",
"label": "Seconds",
"groupbys": [],
"wheres": []
}
]
}
]
}

View File

@ -2,6 +2,7 @@ package chronograf
import (
"context"
"io"
"net/http"
)
@ -32,12 +33,13 @@ func (e Error) Error() string {
type Logger interface {
Debug(...interface{})
Info(...interface{})
Warn(...interface{})
Error(...interface{})
Fatal(...interface{})
Panic(...interface{})
WithField(string, interface{}) Logger
// Logger can be transformed into an io.Writer.
// That writer is the end of an io.Pipe and it is your responsibility to close it.
Writer() *io.PipeWriter
}
// Assets returns a handler to serve the website.

1
dist/dir.go vendored
View File

@ -11,6 +11,7 @@ type Dir struct {
dir http.Dir
}
// NewDir constructs a Dir with a default file
func NewDir(dir, def string) Dir {
return Dir{
Default: def,

View File

@ -32,6 +32,8 @@ type Ctrl interface {
DeleteRole(ctx context.Context, name string) error
SetRolePerms(ctx context.Context, name string, perms Permissions) error
SetRoleUsers(ctx context.Context, name string, users []string) error
AddRoleUsers(ctx context.Context, name string, users []string) error
RemoveRoleUsers(ctx context.Context, name string, users []string) error
}
// Client is a device for retrieving time series data from an Influx Enterprise
@ -148,7 +150,7 @@ func (c *Client) Roles(ctx context.Context) (chronograf.RolesStore, error) {
return c.RolesStore, nil
}
// Allowances returns all Influx Enterprise permission strings
// Permissions returns all Influx Enterprise permission strings
func (c *Client) Permissions(context.Context) chronograf.Permissions {
all := chronograf.Allowances{
"NoPermissions",

View File

@ -272,32 +272,52 @@ func (m *MetaClient) SetRolePerms(ctx context.Context, name string, perms Permis
return m.Post(ctx, "/role", a, nil)
}
// RemoveAllRoleUsers removes all users from a role
func (m *MetaClient) RemoveAllRoleUsers(ctx context.Context, name string) error {
// SetRoleUsers removes all users 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 {
return err
}
// No users to remove
if len(role.Users) == 0 {
return nil
}
a := &RoleAction{
Action: "remove-users",
Role: role,
}
return m.Post(ctx, "/role", a, nil)
}
// SetRoleUsers removes all users and then adds the requested users to role
func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []string) error {
err := m.RemoveAllRoleUsers(ctx, name)
if err != nil {
revoke, add := Difference(users, role.Users)
if err := m.RemoveRoleUsers(ctx, name, revoke); err != nil {
return err
}
return m.AddRoleUsers(ctx, name, add)
}
// Difference compares two sets and returns a set to be removed and a set to be added
func Difference(wants []string, haves []string) (revoke []string, add []string) {
for _, want := range wants {
found := false
for _, got := range haves {
if want != got {
continue
}
found = true
}
if !found {
add = append(add, want)
}
}
for _, got := range haves {
found := false
for _, want := range wants {
if want != got {
continue
}
found = true
break
}
if !found {
revoke = append(revoke, got)
}
}
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
if len(users) == 0 {
return nil
@ -313,6 +333,23 @@ func (m *MetaClient) SetRoleUsers(ctx context.Context, name string, users []stri
return m.Post(ctx, "/role", a, nil)
}
// RemoveRoleUsers updates a role to remove some users.
func (m *MetaClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
// No permissions to add, so, role is in the right state
if len(users) == 0 {
return nil
}
a := &RoleAction{
Action: "remove-users",
Role: &Role{
Name: name,
Users: users,
},
}
return m.Post(ctx, "/role", a, nil)
}
// Post is a helper function to POST to Influx Enterprise
func (m *MetaClient) Post(ctx context.Context, path string, action interface{}, params map[string]string) error {
b, err := json.Marshal(action)

View File

@ -70,7 +70,7 @@ func TestMetaClient_ShowCluster(t *testing.T) {
http.StatusBadGateway,
nil,
nil,
fmt.Errorf("Time circuits on. Flux Capacitor... fluxxing."),
fmt.Errorf("time circuits on. Flux Capacitor... fluxxing"),
),
},
wantErr: true,
@ -214,7 +214,7 @@ func TestMetaClient_Users(t *testing.T) {
http.StatusOK,
[]byte(`{"users":[{"name":"admin","hash":"1234","permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
nil,
fmt.Errorf("Time circuits on. Flux Capacitor... fluxxing."),
fmt.Errorf("time circuits on. Flux Capacitor... fluxxing"),
),
},
args: args{
@ -1252,12 +1252,11 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
name string
fields fields
args args
wantRm string
wantAdd string
wants []string
wantErr bool
}{
{
name: "Successful set users role",
name: "Successful set users role (remove user from role)",
fields: fields{
URL: &url.URL{
Host: "twinpinesmall.net:8091",
@ -1274,7 +1273,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
ctx: context.Background(),
name: "admin",
},
wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`,
wants: []string{`{"action":"remove-users","role":{"name":"admin","users":["marty"]}}`},
},
{
name: "Successful set single user role",
@ -1285,7 +1284,7 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
},
client: NewMockClient(
http.StatusOK,
[]byte(`{"roles":[{"name":"admin","users":["marty"],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
[]byte(`{"roles":[{"name":"admin","users":[],"permissions":{"":["ViewAdmin","ViewChronograf"]}}]}`),
nil,
nil,
),
@ -1295,8 +1294,9 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
name: "admin",
users: []string{"marty"},
},
wantRm: `{"action":"remove-users","role":{"name":"admin","permissions":{"":["ViewAdmin","ViewChronograf"]},"users":["marty"]}}`,
wantAdd: `{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
wants: []string{
`{"action":"add-users","role":{"name":"admin","users":["marty"]}}`,
},
},
}
for _, tt := range tests {
@ -1312,8 +1312,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
continue
}
reqs := tt.fields.client.(*MockClient).Requests
if len(reqs) < 2 {
t.Errorf("%q. MetaClient.SetRoleUsers() expected 2 but got %d", tt.name, len(reqs))
if len(reqs) != len(tt.wants)+1 {
t.Errorf("%q. MetaClient.SetRoleUsers() expected %d but got %d", tt.name, len(tt.wants)+1, len(reqs))
continue
}
@ -1324,8 +1324,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
if usr.URL.Path != "/role" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected /user path but got %s", tt.name, usr.URL.Path)
}
prm := reqs[1]
for i := range tt.wants {
prm := reqs[i+1]
if prm.Method != "POST" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name)
}
@ -1334,21 +1334,8 @@ func TestMetaClient_SetRoleUsers(t *testing.T) {
}
got, _ := ioutil.ReadAll(prm.Body)
if string(got) != tt.wantRm {
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantRm)
}
if tt.wantAdd != "" {
prm := reqs[2]
if prm.Method != "POST" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected GET method", tt.name)
}
if prm.URL.Path != "/role" {
t.Errorf("%q. MetaClient.SetRoleUsers() expected /role path but got %s", tt.name, prm.URL.Path)
}
got, _ := ioutil.ReadAll(prm.Body)
if string(got) != tt.wantAdd {
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wantAdd)
if string(got) != tt.wants[i] {
t.Errorf("%q. MetaClient.SetRoleUsers() = %v, want %v", tt.name, string(got), tt.wants[i])
}
}
}

View File

@ -88,6 +88,14 @@ func (cc *ControlClient) SetRoleUsers(ctx context.Context, name string, users []
return nil
}
func (cc *ControlClient) AddRoleUsers(ctx context.Context, name string, users []string) error {
return nil
}
func (cc *ControlClient) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
return nil
}
type TimeSeries struct {
URLs []string
Response Response

View File

@ -22,7 +22,13 @@ func (c *UserStore) Add(ctx context.Context, u *chronograf.User) (*chronograf.Us
if err := c.Ctrl.SetUserPerms(ctx, u.Name, perms); err != nil {
return nil, err
}
return u, nil
for _, role := range u.Roles {
if err := c.Ctrl.AddRoleUsers(ctx, role.Name, []string{u.Name}); err != nil {
return nil, err
}
}
return c.Get(ctx, u.Name)
}
// Delete the User from Influx Enterprise
@ -63,11 +69,45 @@ func (c *UserStore) Update(ctx context.Context, u *chronograf.User) error {
if u.Passwd != "" {
return c.Ctrl.ChangePassword(ctx, u.Name, u.Passwd)
}
if u.Permissions != 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
}
// 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
}
}
// ... 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
}
}
perms := ToEnterprise(u.Permissions)
return c.Ctrl.SetUserPerms(ctx, u.Name, perms)
}
return nil
}
// All is all users in influx

View File

@ -36,6 +36,22 @@ func TestClient_Add(t *testing.T) {
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil
},
user: func(ctx context.Context, name string) (*enterprise.User, error) {
return &enterprise.User{
Name: "marty",
Password: "johnny be good",
Permissions: map[string][]string{
"": {
"ViewChronograf",
"ReadData",
"WriteData",
},
},
}, nil
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
},
},
args: args{
@ -46,8 +62,82 @@ func TestClient_Add(t *testing.T) {
},
},
want: &chronograf.User{
Name: "marty",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"},
},
},
Roles: []chronograf.Role{},
},
},
{
name: "Successful Create User with roles",
fields: fields{
Ctrl: &mockCtrl{
createUser: func(ctx context.Context, name, passwd string) error {
return nil
},
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil
},
user: func(ctx context.Context, name string) (*enterprise.User, error) {
return &enterprise.User{
Name: "marty",
Password: "johnny be good",
Permissions: map[string][]string{
"": {
"ViewChronograf",
"ReadData",
"WriteData",
},
},
}, nil
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{
"marty": enterprise.Roles{
Roles: []enterprise.Role{
{
Name: "admin",
},
},
},
}, nil
},
addRoleUsers: func(ctx context.Context, name string, users []string) error {
return nil
},
},
},
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "marty",
Passwd: "johnny be good",
Roles: []chronograf.Role{
{
Name: "admin",
},
},
},
},
want: &chronograf.User{
Name: "marty",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"ViewChronograf", "ReadData", "WriteData"},
},
},
Roles: []chronograf.Role{
{
Name: "admin",
Users: []chronograf.User{},
Permissions: chronograf.Permissions{},
},
},
},
},
{
@ -80,7 +170,7 @@ func TestClient_Add(t *testing.T) {
continue
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want)
t.Errorf("%q. Client.Add() = \n%#v\n, want \n%#v\n", tt.name, got, tt.want)
}
}
}
@ -353,6 +443,9 @@ func TestClient_Update(t *testing.T) {
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
},
},
args: args{
@ -369,6 +462,40 @@ func TestClient_Update(t *testing.T) {
},
wantErr: false,
},
{
name: "Success setting permissions and roles for user",
fields: fields{
Ctrl: &mockCtrl{
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return nil
},
addRoleUsers: func(ctx context.Context, name string, users []string) error {
return nil
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
},
},
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "marty",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"ViewChronograf", "KapacitorAPI"},
},
},
Roles: []chronograf.Role{
{
Name: "adminrole",
},
},
},
},
wantErr: false,
},
{
name: "Failure setting permissions User",
fields: fields{
@ -376,6 +503,9 @@ func TestClient_Update(t *testing.T) {
setUserPerms: func(ctx context.Context, name string, perms enterprise.Permissions) error {
return fmt.Errorf("They found me, I don't know how, but they found me.")
},
userRoles: func(ctx context.Context) (map[string]enterprise.Roles, error) {
return map[string]enterprise.Roles{}, nil
},
},
},
args: args{
@ -579,6 +709,8 @@ type mockCtrl struct {
deleteRole func(ctx context.Context, name string) error
setRolePerms func(ctx context.Context, name string, perms enterprise.Permissions) error
setRoleUsers func(ctx context.Context, name string, users []string) error
addRoleUsers func(ctx context.Context, name string, users []string) error
removeRoleUsers func(ctx context.Context, name string, users []string) error
}
func (m *mockCtrl) ShowCluster(ctx context.Context) (*enterprise.Cluster, error) {
@ -636,3 +768,11 @@ func (m *mockCtrl) SetRolePerms(ctx context.Context, name string, perms enterpri
func (m *mockCtrl) SetRoleUsers(ctx context.Context, name string, users []string) error {
return m.setRoleUsers(ctx, name, users)
}
func (m *mockCtrl) AddRoleUsers(ctx context.Context, name string, users []string) error {
return m.addRoleUsers(ctx, name, users)
}
func (m *mockCtrl) RemoveRoleUsers(ctx context.Context, name string, users []string) error {
return m.removeRoleUsers(ctx, name, users)
}

View File

@ -8,8 +8,10 @@ import (
)
var (
// AllowAll means a user gets both read and write permissions
AllowAll = chronograf.Allowances{"WRITE", "READ"}
// AllowAllDB means a user gets both read and write permissions for a db
AllowAllDB = chronograf.Allowances{"WRITE", "READ"}
// AllowAllAdmin means a user gets both read and write permissions for an admin
AllowAllAdmin = chronograf.Allowances{"ALL"}
// AllowRead means a user is only able to read the database.
AllowRead = chronograf.Allowances{"READ"}
// AllowWrite means a user is able to only write to the database
@ -31,11 +33,11 @@ func (c *Client) Permissions(context.Context) chronograf.Permissions {
return chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: AllowAll,
Allowed: AllowAllAdmin,
},
{
Scope: chronograf.DBScope,
Allowed: AllowAll,
Allowed: AllowAllDB,
},
}
}
@ -90,7 +92,7 @@ func (r *showResults) Permissions() chronograf.Permissions {
}
switch priv {
case AllPrivileges, All:
c.Allowed = AllowAll
c.Allowed = AllowAllDB
case Read:
c.Allowed = AllowRead
case Write:
@ -111,7 +113,7 @@ func adminPerms() chronograf.Permissions {
return []chronograf.Permission{
{
Scope: chronograf.AllScope,
Allowed: AllowAll,
Allowed: AllowAllAdmin,
},
}
}

View File

@ -318,7 +318,7 @@ func Test_showResults_Users(t *testing.T) {
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"WRITE", "READ"},
Allowed: chronograf.Allowances{"ALL"},
},
},
},

View File

@ -16,8 +16,12 @@ func (c *Client) Add(ctx context.Context, u *chronograf.User) (*chronograf.User,
if err != nil {
return nil, err
}
return u, nil
for _, p := range u.Permissions {
if err := c.grantPermission(ctx, u.Name, p); err != nil {
return nil, err
}
}
return c.Get(ctx, u.Name)
}
// Delete the User from InfluxDB

View File

@ -101,7 +101,7 @@ func TestClient_Add(t *testing.T) {
args args
status int
want *chronograf.User
wantQuery string
wantQueries []string
wantErr bool
}{
{
@ -114,10 +114,57 @@ func TestClient_Add(t *testing.T) {
Passwd: "Dont Need Roads",
},
},
wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
wantQueries: []string{
`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
`SHOW USERS`,
`SHOW GRANTS FOR "docbrown"`,
},
want: &chronograf.User{
Name: "docbrown",
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{
"ALL",
},
},
},
},
},
{
name: "Create User with permissions",
status: http.StatusOK,
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "docbrown",
Passwd: "Dont Need Roads",
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{
"ALL",
},
},
},
},
},
wantQueries: []string{
`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
`GRANT ALL PRIVILEGES TO "docbrown"`,
`SHOW USERS`,
`SHOW GRANTS FOR "docbrown"`,
},
want: &chronograf.User{
Name: "docbrown",
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{
"ALL",
},
},
},
},
},
{
@ -130,19 +177,19 @@ func TestClient_Add(t *testing.T) {
Passwd: "Dont Need Roads",
},
},
wantQuery: `CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`,
wantQueries: []string{`CREATE USER "docbrown" WITH PASSWORD 'Dont Need Roads'`},
wantErr: true,
},
}
for _, tt := range tests {
query := ""
queries := []string{}
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
if path := r.URL.Path; path != "/query" {
t.Error("Expected the path to contain `/query` but was", path)
}
query = r.URL.Query().Get("q")
queries = append(queries, r.URL.Query().Get("q"))
rw.WriteHeader(tt.status)
rw.Write([]byte(`{"results":[{}]}`))
rw.Write([]byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",true],["reader",false]]}]}]}`))
}))
u, _ := url.Parse(ts.URL)
c := &Client{
@ -155,9 +202,16 @@ func TestClient_Add(t *testing.T) {
t.Errorf("%q. Client.Add() error = %v, wantErr %v", tt.name, err, tt.wantErr)
continue
}
if tt.wantQuery != query {
t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, query, tt.wantQuery)
if len(tt.wantQueries) != len(queries) {
t.Errorf("%q. Client.Add() queries = %v, want %v", tt.name, queries, tt.wantQueries)
continue
}
for i := range tt.wantQueries {
if tt.wantQueries[i] != queries[i] {
t.Errorf("%q. Client.Add() query = %v, want %v", tt.name, queries[i], tt.wantQueries[i])
}
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("%q. Client.Add() = %v, want %v", tt.name, got, tt.want)
}
@ -275,7 +329,7 @@ func TestClient_Get(t *testing.T) {
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: "all",
Allowed: []string{"WRITE", "READ"},
Allowed: []string{"ALL"},
},
chronograf.Permission{
Scope: "database",
@ -548,7 +602,7 @@ func TestClient_All(t *testing.T) {
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: "all",
Allowed: []string{"WRITE", "READ"},
Allowed: []string{"ALL"},
},
chronograf.Permission{
Scope: "database",
@ -562,7 +616,7 @@ func TestClient_All(t *testing.T) {
Permissions: chronograf.Permissions{
chronograf.Permission{
Scope: "all",
Allowed: []string{"WRITE", "READ"},
Allowed: []string{"ALL"},
},
chronograf.Permission{
Scope: "database",
@ -688,7 +742,7 @@ func TestClient_Update(t *testing.T) {
Permissions: chronograf.Permissions{
{
Scope: "all",
Allowed: []string{"WRITE", "READ"},
Allowed: []string{"all"},
},
{
Scope: "database",
@ -743,7 +797,7 @@ func TestClient_Update(t *testing.T) {
Permissions: chronograf.Permissions{
{
Scope: "all",
Allowed: []string{"WRITE", "READ"},
Allowed: []string{"all"},
},
{
Scope: "database",
@ -800,6 +854,34 @@ func TestClient_Update(t *testing.T) {
`REVOKE ALL PRIVILEGES FROM "docbrown"`,
},
},
{
name: "Revoke some",
statusUsers: http.StatusOK,
showUsers: []byte(`{"results":[{"series":[{"columns":["user","admin"],"values":[["admin",true],["docbrown",false],["reader",false]]}]}]}`),
statusGrants: http.StatusOK,
showGrants: []byte(`{"results":[]}`),
statusRevoke: http.StatusOK,
revoke: []byte(`{"results":[]}`),
statusGrant: http.StatusOK,
grant: []byte(`{"results":[]}`),
args: args{
ctx: context.Background(),
u: &chronograf.User{
Name: "docbrown",
Permissions: chronograf.Permissions{
{
Scope: "all",
Allowed: []string{"ALL"},
},
},
},
},
want: []string{
`SHOW USERS`,
`SHOW GRANTS FOR "docbrown"`,
`GRANT ALL PRIVILEGES TO "docbrown"`,
},
},
{
name: "Fail users",
statusUsers: http.StatusBadRequest,

View File

@ -6,8 +6,10 @@ import (
"github.com/influxdata/chronograf"
)
// HTTPEndpoint is the default location of the tickscript output
const HTTPEndpoint = "output"
// HTTPOut adds a kapacitor httpOutput to a tickscript
func HTTPOut(rule chronograf.AlertRule) (string, error) {
return fmt.Sprintf(`trigger|httpOut('%s')`, HTTPEndpoint), nil
}

View File

@ -3,8 +3,8 @@ package kapacitor
import "fmt"
const (
GreaterThan = "greater than"
LessThan = "less than"
greaterThan = "greater than"
lessThan = "less than"
LessThanEqual = "equal to or less than"
GreaterThanEqual = "equal to or greater"
Equal = "equal to"
@ -16,9 +16,9 @@ const (
// kapaOperator converts UI strings to kapacitor operators
func kapaOperator(operator string) (string, error) {
switch operator {
case GreaterThan:
case greaterThan:
return ">", nil
case LessThan:
case lessThan:
return "<", nil
case LessThanEqual:
return "<=", nil

View File

@ -3,7 +3,6 @@ package kapacitor
import (
"bytes"
"fmt"
"log"
"time"
"github.com/influxdata/chronograf"
@ -25,7 +24,6 @@ func ValidateAlert(service string) error {
func formatTick(tickscript string) (chronograf.TICKScript, error) {
node, err := ast.Parse(tickscript)
if err != nil {
log.Fatalf("parse execution: %s", err)
return "", err
}
@ -41,6 +39,9 @@ func validateTick(script chronograf.TICKScript) error {
return err
}
// deadman is an empty implementation of a kapacitor DeadmanService to allow CreatePipeline
var _ pipeline.DeadmanService = &deadman{}
type deadman struct {
interval time.Duration
threshold float64

View File

@ -41,7 +41,7 @@ func Vars(rule chronograf.AlertRule) (string, error) {
var crit = %s
`
return fmt.Sprintf(vars, common, formatValue(rule.TriggerValues.Value)), nil
} else {
}
vars := `
%s
var lower = %s
@ -51,7 +51,6 @@ func Vars(rule chronograf.AlertRule) (string, error) {
common,
rule.TriggerValues.Value,
rule.TriggerValues.RangeValue), nil
}
case Relative:
vars := `
%s

View File

@ -1,6 +1,7 @@
package log
import (
"io"
"os"
"github.com/Sirupsen/logrus"
@ -81,6 +82,11 @@ func (ll *logrusLogger) WithField(key string, value interface{}) chronograf.Logg
return &logrusLogger{ll.l.WithField(key, value)}
}
func (ll *logrusLogger) Writer() *io.PipeWriter {
return ll.l.Logger.WriterLevel(logrus.ErrorLevel)
}
// New wraps a logrus Logger
func New(l Level) chronograf.Logger {
logger := &logrus.Logger{
Out: os.Stderr,

View File

@ -1,4 +1,4 @@
// The oauth2 package provides http.Handlers necessary for implementing Oauth2
// Package oauth2 provides http.Handlers necessary for implementing Oauth2
// authentication with multiple Providers.
//
// This is how the pieces of this package fit together:

View File

@ -10,7 +10,7 @@ import (
goauth2 "google.golang.org/api/oauth2/v2"
)
// Endpoint is Google's OAuth 2.0 endpoint.
// GoogleEndpoint is Google's OAuth 2.0 endpoint.
// Copied here to remove tons of package dependencies
var GoogleEndpoint = oauth2.Endpoint{
AuthURL: "https://accounts.google.com/o/oauth2/auth",
@ -18,6 +18,7 @@ var GoogleEndpoint = oauth2.Endpoint{
}
var _ Provider = &Google{}
// Google is an oauth2 provider supporting google.
type Google struct {
ClientID string
ClientSecret string

View File

@ -14,8 +14,8 @@ import (
var _ Provider = &Heroku{}
const (
// Routes required for interacting with Heroku API
HEROKU_ACCOUNT_ROUTE string = "https://api.heroku.com/account"
// HerokuAccountRoute is required for interacting with Heroku API
HerokuAccountRoute string = "https://api.heroku.com/account"
)
// Heroku is an OAuth2 Provider allowing users to authenticate with Heroku to
@ -61,13 +61,14 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
DefaultOrganization DefaultOrg `json:"default_organization"`
}
resp, err := provider.Get(HEROKU_ACCOUNT_ROUTE)
resp, err := provider.Get(HerokuAccountRoute)
if err != nil {
h.Logger.Error("Unable to communicate with Heroku. err:", err)
return "", err
}
defer resp.Body.Close()
d := json.NewDecoder(resp.Body)
var account Account
if err := d.Decode(&account); err != nil {
h.Logger.Error("Unable to decode response from Heroku. err:", err)
@ -83,9 +84,8 @@ func (h *Heroku) PrincipalID(provider *http.Client) (string, error) {
}
h.Logger.Error(ErrOrgMembership)
return "", ErrOrgMembership
} else {
return account.Email, nil
}
return account.Email, nil
}
// Scopes for heroku is "identity" which grants access to user account

View File

@ -24,6 +24,7 @@ type cookie struct {
// Check to ensure CookieMux is an oauth2.Mux
var _ Mux = &CookieMux{}
// NewCookieMux constructs a Mux handler that checks a cookie against the authenticator
func NewCookieMux(p Provider, a Authenticator, l chronograf.Logger) *CookieMux {
return &CookieMux{
Provider: p,
@ -55,7 +56,7 @@ type CookieMux struct {
Now func() time.Time // Now returns the current time
}
// Uses a Cookie with a random string as the state validation method. JWTs are
// Login uses a Cookie with a random string as the state validation method. JWTs are
// a good choice here for encoding because they can be validated without
// storing state.
func (j *CookieMux) Login() http.Handler {

View File

@ -27,8 +27,8 @@ func (mp *MockProvider) Config() *goauth.Config {
ClientID: "4815162342",
ClientSecret: "8675309",
Endpoint: goauth.Endpoint{
mp.ProviderURL + "/oauth/auth",
mp.ProviderURL + "/oauth/token",
AuthURL: mp.ProviderURL + "/oauth/auth",
TokenURL: mp.ProviderURL + "/oauth/token",
},
}
}

View File

@ -1,586 +0,0 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
)
func validPermissions(perms *chronograf.Permissions) error {
if perms == nil {
return nil
}
for _, perm := range *perms {
if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope {
return fmt.Errorf("Invalid permission scope")
}
if perm.Scope == chronograf.DBScope && perm.Name == "" {
return fmt.Errorf("Database scoped permission requires a name")
}
}
return nil
}
type sourceUserRequest struct {
Username string `json:"name,omitempty"` // Username for new account
Password string `json:"password,omitempty"` // Password for new account
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions
}
func (r *sourceUserRequest) ValidCreate() error {
if r.Username == "" {
return fmt.Errorf("Username required")
}
if r.Password == "" {
return fmt.Errorf("Password required")
}
return validPermissions(&r.Permissions)
}
func (r *sourceUserRequest) ValidUpdate() error {
if r.Password == "" && len(r.Permissions) == 0 {
return fmt.Errorf("No fields to update")
}
return validPermissions(&r.Permissions)
}
type sourceUser struct {
Username string `json:"name"` // Username for new account
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Account's permissions
Links selfLinks `json:"links"` // Links are URI locations related to user
}
type enterpriseSourceUser struct {
Username string `json:"name"` // Username for new account
Permissions chronograf.Permissions `json:"permissions"` // Account's permissions
Roles []userRoleResponse `json:"roles"` // Roles if source uses them
Links selfLinks `json:"links"` // Links are URI locations related to user
}
type userRoleResponse struct {
Name string `json:"name"`
Permissions chronograf.Permissions `json:"permissions"`
Links selfLinks `json:"links"`
}
func newUserRoleResponse(srcID int, res *chronograf.Role) userRoleResponse {
if res.Permissions == nil {
res.Permissions = make(chronograf.Permissions, 0)
}
return userRoleResponse{
Name: res.Name,
Permissions: res.Permissions,
Links: newSelfLinks(srcID, "roles", res.Name),
}
}
type selfLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
func sourceUserResponse(u *chronograf.User, srcID int, hasRoles bool) interface{} {
// Permissions should always be returned. If no permissions, then
// return empty array
perms := u.Permissions
if len(perms) == 0 {
perms = make([]chronograf.Permission, 0)
}
// If the source supports roles, we return all
// associated with this user
if hasRoles {
res := enterpriseSourceUser{
Username: u.Name,
Permissions: perms,
Roles: make([]userRoleResponse, 0),
Links: newSelfLinks(srcID, "users", u.Name),
}
if len(u.Roles) > 0 {
rr := make([]userRoleResponse, len(u.Roles))
for i, role := range u.Roles {
rr[i] = newUserRoleResponse(srcID, &role)
}
res.Roles = rr
}
return &res
}
res := sourceUser{
Username: u.Name,
Permissions: perms,
Links: newSelfLinks(srcID, "users", u.Name),
}
return &res
}
func newSelfLinks(id int, parent, resource string) selfLinks {
httpAPISrcs := "/chronograf/v1/sources"
u := &url.URL{Path: resource}
encodedResource := u.String()
return selfLinks{
Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource),
}
}
// NewSourceUser adds user to source
func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
var req sourceUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
user := &chronograf.User{
Name: req.Username,
Passwd: req.Password,
Permissions: req.Permissions,
}
res, err := store.Add(ctx, user)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
su := sourceUser{
Username: res.Name,
Permissions: req.Permissions,
Links: newSelfLinks(srcID, "users", res.Name),
}
w.Header().Add("Location", su.Links.Self)
encodeJSON(w, http.StatusCreated, su, h.Logger)
}
type sourceUsers struct {
Users []interface{} `json:"users"`
}
// SourceUsers retrieves all users from source.
func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store := ts.Users(ctx)
users, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
_, hasRoles := h.hasRoles(ctx, ts)
su := []interface{}{}
for _, u := range users {
res := sourceUserResponse(&u, srcID, hasRoles)
su = append(su, res)
}
res := sourceUsers{
Users: su,
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// SourceUserID retrieves a user with ID from store.
func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store := ts.Users(ctx)
u, err := store.Get(ctx, uid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
_, hasRoles := h.hasRoles(ctx, ts)
res := sourceUserResponse(u, srcID, hasRoles)
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveSourceUser removes the user from the InfluxDB source
func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
_, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateSourceUser changes the password or permissions of a source user
func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) {
var req sourceUserRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
user := &chronograf.User{
Name: uid,
Passwd: req.Password,
Permissions: req.Permissions,
}
if err := store.Update(ctx, user); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
su := sourceUser{
Username: user.Name,
Permissions: user.Permissions,
Links: newSelfLinks(srcID, "users", user.Name),
}
w.Header().Add("Location", su.Links.Self)
encodeJSON(w, http.StatusOK, su, h.Logger)
}
func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) {
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return 0, nil, err
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return 0, nil, err
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
return srcID, ts, nil
}
func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) {
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return 0, nil, err
}
store := ts.Users(ctx)
return srcID, store, nil
}
// hasRoles checks if the influx source has roles or not
func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) {
store, err := ts.Roles(ctx)
if err != nil {
return nil, false
}
return store, true
}
// Permissions returns all possible permissions for this source.
func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
perms := ts.Permissions(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
httpAPISrcs := "/chronograf/v1/sources"
res := struct {
Permissions chronograf.Permissions `json:"permissions"`
Links map[string]string `json:"links"` // Links are URI locations related to user
}{
Permissions: perms,
Links: map[string]string{
"self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID),
"source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID),
},
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
type sourceRoleRequest struct {
chronograf.Role
}
func (r *sourceRoleRequest) ValidCreate() error {
if r.Name == "" || len(r.Name) > 254 {
return fmt.Errorf("Name is required for a role")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
func (r *sourceRoleRequest) ValidUpdate() error {
if len(r.Name) > 254 {
return fmt.Errorf("Username too long; must be less than 254 characters")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
type roleResponse struct {
Users []sourceUser `json:"users"`
Name string `json:"name"`
Permissions chronograf.Permissions `json:"permissions"`
Links selfLinks `json:"links"`
}
func newRoleResponse(srcID int, res *chronograf.Role) roleResponse {
su := make([]sourceUser, len(res.Users))
for i := range res.Users {
name := res.Users[i].Name
su[i] = sourceUser{
Username: name,
Links: newSelfLinks(srcID, "users", name),
}
}
if res.Permissions == nil {
res.Permissions = make(chronograf.Permissions, 0)
}
return roleResponse{
Name: res.Name,
Permissions: res.Permissions,
Users: su,
Links: newSelfLinks(srcID, "roles", res.Name),
}
}
// NewRole adds role to source
func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
res, err := roles.Add(ctx, &req.Role)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, res)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusCreated, rr, h.Logger)
}
// UpdateRole changes the permissions or users of a role
func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
req.Name = rid
if err := roles.Update(ctx, &req.Role); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
role, err := roles.Get(ctx, req.Name)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// RoleID retrieves a role with ID from store.
func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
role, err := roles.Get(ctx, rid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// Roles retrieves all roles from the store
func (h *Service) Roles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
roles, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := make([]roleResponse, len(roles))
for i, role := range roles {
rr[i] = newRoleResponse(srcID, &role)
}
res := struct {
Roles []roleResponse `json:"roles"`
}{rr}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveRole removes role from data source.
func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}

File diff suppressed because it is too large Load Diff

99
server/me.go Normal file
View File

@ -0,0 +1,99 @@
package server
import (
"fmt"
"net/http"
"net/url"
"golang.org/x/net/context"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/oauth2"
)
type meLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
type meResponse struct {
*chronograf.User
Links meLinks `json:"links"`
}
// If new user response is nil, return an empty meResponse because it
// indicates authentication is not needed
func newMeResponse(usr *chronograf.User) meResponse {
base := "/chronograf/v1/users"
name := "me"
if usr != nil {
// TODO: Change to urls.PathEscape for go 1.8
u := &url.URL{Path: usr.Name}
name = u.String()
}
return meResponse{
User: usr,
Links: meLinks{
Self: fmt.Sprintf("%s/%s", base, name),
},
}
}
func getEmail(ctx context.Context) (string, error) {
principal, err := getPrincipal(ctx)
if err != nil {
return "", err
}
if principal.Subject == "" {
return "", fmt.Errorf("Token not found")
}
return principal.Subject, nil
}
func getPrincipal(ctx context.Context) (oauth2.Principal, error) {
principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal)
if !ok {
return oauth2.Principal{}, fmt.Errorf("Token not found")
}
return principal, nil
}
// Me does a findOrCreate based on the email in the context
func (h *Service) Me(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !h.UseAuth {
// If there's no authentication, return an empty user
res := newMeResponse(nil)
encodeJSON(w, http.StatusOK, res, h.Logger)
return
}
email, err := getEmail(ctx)
if err != nil {
invalidData(w, err, h.Logger)
return
}
usr, err := h.UsersStore.Get(ctx, email)
if err == nil {
res := newMeResponse(usr)
encodeJSON(w, http.StatusOK, res, h.Logger)
return
}
// Because we didnt find a user, making a new one
user := &chronograf.User{
Name: email,
}
newUser, err := h.UsersStore.Add(ctx, user)
if err != nil {
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
unknownErrorWithMessage(w, msg, h.Logger)
return
}
res := newMeResponse(newUser)
encodeJSON(w, http.StatusOK, res, h.Logger)
}

168
server/me_test.go Normal file
View File

@ -0,0 +1,168 @@
package server
import (
"context"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
"github.com/influxdata/chronograf/oauth2"
)
type MockUsers struct{}
func TestService_Me(t *testing.T) {
type fields struct {
UsersStore chronograf.UsersStore
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
principal oauth2.Principal
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Existing user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return &chronograf.User{
Name: "me",
Passwd: "hunter2",
}, nil
},
},
},
principal: oauth2.Principal{
Subject: "me",
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}}
`,
},
{
name: "New user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return nil, fmt.Errorf("Unknown User")
},
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return u, nil
},
},
},
principal: oauth2.Principal{
Subject: "secret",
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}}
`,
},
{
name: "Error adding user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return nil, fmt.Errorf("Unknown User")
},
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return nil, fmt.Errorf("Why Heavy?")
},
},
Logger: log.New(log.DebugLevel),
},
principal: oauth2.Principal{
Subject: "secret",
},
wantStatus: http.StatusInternalServerError,
wantContentType: "application/json",
wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`,
},
{
name: "No Auth",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: false,
Logger: log.New(log.DebugLevel),
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/users/me"}}
`,
},
{
name: "Empty Principal",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
},
wantStatus: http.StatusUnprocessableEntity,
principal: oauth2.Principal{
Subject: "",
},
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal))
h := &Service{
UsersStore: tt.fields.UsersStore,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.Me(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. Me() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. Me() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}

70
server/permissions.go Normal file
View File

@ -0,0 +1,70 @@
package server
import (
"fmt"
"net/http"
"github.com/influxdata/chronograf"
)
// Permissions returns all possible permissions for this source.
func (h *Service) Permissions(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
perms := ts.Permissions(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
httpAPISrcs := "/chronograf/v1/sources"
res := struct {
Permissions chronograf.Permissions `json:"permissions"`
Links map[string]string `json:"links"` // Links are URI locations related to user
}{
Permissions: perms,
Links: map[string]string{
"self": fmt.Sprintf("%s/%d/permissions", httpAPISrcs, srcID),
"source": fmt.Sprintf("%s/%d", httpAPISrcs, srcID),
},
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
func validPermissions(perms *chronograf.Permissions) error {
if perms == nil {
return nil
}
for _, perm := range *perms {
if perm.Scope != chronograf.AllScope && perm.Scope != chronograf.DBScope {
return fmt.Errorf("Invalid permission scope")
}
if perm.Scope == chronograf.DBScope && perm.Name == "" {
return fmt.Errorf("Database scoped permission requires a name")
}
}
return nil
}

112
server/permissions_test.go Normal file
View File

@ -0,0 +1,112 @@
package server
import (
"bytes"
"context"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
)
func TestService_Permissions(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "New user for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
PermissionsF: func(ctx context.Context) chronograf.Permissions {
return chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"READ", "WRITE"},
},
}
},
},
},
ID: "1",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"permissions":[{"scope":"all","allowed":["READ","WRITE"]}],"links":{"self":"/chronograf/v1/sources/1/permissions","source":"/chronograf/v1/sources/1"}}
`,
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.Permissions(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. Permissions() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. Permissions() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. Permissions() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}

224
server/roles.go Normal file
View File

@ -0,0 +1,224 @@
package server
import (
"encoding/json"
"fmt"
"net/http"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
)
// NewRole adds role to source
func (h *Service) NewRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
if _, err := roles.Get(ctx, req.Name); err == nil {
Error(w, http.StatusBadRequest, fmt.Sprintf("Source %d already has role %s", srcID, req.Name), h.Logger)
return
}
res, err := roles.Add(ctx, &req.Role)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, res)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusCreated, rr, h.Logger)
}
// UpdateRole changes the permissions or users of a role
func (h *Service) UpdateRole(w http.ResponseWriter, r *http.Request) {
var req sourceRoleRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
req.Name = rid
if err := roles.Update(ctx, &req.Role); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
role, err := roles.Get(ctx, req.Name)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
w.Header().Add("Location", rr.Links.Self)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// RoleID retrieves a role with ID from store.
func (h *Service) RoleID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
role, err := roles.Get(ctx, rid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := newRoleResponse(srcID, role)
encodeJSON(w, http.StatusOK, rr, h.Logger)
}
// Roles retrieves all roles from the store
func (h *Service) Roles(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
roles, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rr := make([]roleResponse, len(roles))
for i, role := range roles {
rr[i] = newRoleResponse(srcID, &role)
}
res := struct {
Roles []roleResponse `json:"roles"`
}{rr}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveRole removes role from data source.
func (h *Service) RemoveRole(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
roles, ok := h.hasRoles(ctx, ts)
if !ok {
Error(w, http.StatusNotFound, fmt.Sprintf("Source %d does not have role capability", srcID), h.Logger)
return
}
rid := httprouter.GetParamFromContext(ctx, "rid")
if err := roles.Delete(ctx, &chronograf.Role{Name: rid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// sourceRoleRequest is the format used for both creating and updating roles
type sourceRoleRequest struct {
chronograf.Role
}
func (r *sourceRoleRequest) ValidCreate() error {
if r.Name == "" || len(r.Name) > 254 {
return fmt.Errorf("Name is required for a role")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
func (r *sourceRoleRequest) ValidUpdate() error {
if len(r.Name) > 254 {
return fmt.Errorf("Username too long; must be less than 254 characters")
}
for _, user := range r.Users {
if user.Name == "" {
return fmt.Errorf("Username required")
}
}
return validPermissions(&r.Permissions)
}
type roleResponse struct {
Users []*userResponse `json:"users"`
Name string `json:"name"`
Permissions chronograf.Permissions `json:"permissions"`
Links selfLinks `json:"links"`
}
func newRoleResponse(srcID int, res *chronograf.Role) roleResponse {
su := make([]*userResponse, len(res.Users))
for i := range res.Users {
name := res.Users[i].Name
su[i] = newUserResponse(srcID, name)
}
if res.Permissions == nil {
res.Permissions = make(chronograf.Permissions, 0)
}
return roleResponse{
Name: res.Name,
Permissions: res.Permissions,
Users: su,
Links: newSelfLinks(srcID, "roles", res.Name),
}
}

697
server/roles_test.go Normal file
View File

@ -0,0 +1,697 @@
package server
import (
"bytes"
"context"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
)
func TestService_NewSourceRole(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Bad JSON",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{BAD}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
},
wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
wantBody: `{"code":400,"message":"Unparsable JSON"}`,
},
{
name: "Invalid request",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": ""}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
},
ID: "1",
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "application/json",
wantBody: `{"code":422,"message":"Name is required for a role"}`,
},
{
name: "Invalid source ID",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "newrole"}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
},
ID: "BADROLE",
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "application/json",
wantBody: `{"code":422,"message":"Error converting ID BADROLE"}`,
},
{
name: "Source doesn't support roles",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "role"}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, fmt.Errorf("roles not supported")
},
},
},
ID: "1",
wantStatus: http.StatusNotFound,
wantContentType: "application/json",
wantBody: `{"code":404,"message":"Source 1 does not have role capability"}`,
},
{
name: "Unable to add role to server",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "role"}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) {
return nil, fmt.Errorf("server had and issue")
},
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return nil, fmt.Errorf("No such role")
},
}, nil
},
},
},
ID: "1",
wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
wantBody: `{"code":400,"message":"server had and issue"}`,
},
{
name: "New role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
AddF: func(ctx context.Context, u *chronograf.Role) (*chronograf.Role, error) {
return u, nil
},
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return nil, fmt.Errorf("no such role")
},
}, nil
},
},
},
ID: "1",
wantStatus: http.StatusCreated,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h.NewRole(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_UpdateRole(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Update role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1/roles",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "biffsgang","users": [{"name": "match"},{"name": "skinhead"},{"name": "3-d"}]}`)))),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
UpdateF: func(ctx context.Context, u *chronograf.Role) error {
return nil
},
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return &chronograf.Role{
Name: "biffsgang",
Users: []chronograf.User{
{
Name: "match",
},
{
Name: "skinhead",
},
{
Name: "3-d",
},
},
}, nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.UpdateRole(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. NewRole() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. NewRole() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_RoleID(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Get role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1/roles/biffsgang",
nil),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
GetF: func(ctx context.Context, name string) (*chronograf.Role, error) {
return &chronograf.Role{
Name: "biffsgang",
Permissions: chronograf.Permissions{
{
Name: "grays_sports_almanac",
Scope: "DBScope",
Allowed: chronograf.Allowances{
"ReadData",
},
},
},
Users: []chronograf.User{
{
Name: "match",
},
{
Name: "skinhead",
},
{
Name: "3-d",
},
},
}, nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.RoleID(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_RemoveRole(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
}{
{
name: "remove role for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1/roles/biffsgang",
nil),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
DeleteF: func(context.Context, *chronograf.Role) error {
return nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusNoContent,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.RemoveRole(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. RemoveRole() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
}
}
func TestService_Roles(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries TimeSeriesClient
Logger chronograf.Logger
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
RoleID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Get roles for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1/roles",
nil),
},
fields: fields{
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return &mocks.RolesStore{
AllF: func(ctx context.Context) ([]chronograf.Role, error) {
return []chronograf.Role{
chronograf.Role{
Name: "biffsgang",
Permissions: chronograf.Permissions{
{
Name: "grays_sports_almanac",
Scope: "DBScope",
Allowed: chronograf.Allowances{
"ReadData",
},
},
},
Users: []chronograf.User{
{
Name: "match",
},
{
Name: "skinhead",
},
{
Name: "3-d",
},
},
},
}, nil
},
}, nil
},
},
},
ID: "1",
RoleID: "biffsgang",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"roles":[{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/match"},"name":"match"},{"links":{"self":"/chronograf/v1/sources/1/users/skinhead"},"name":"skinhead"},{"links":{"self":"/chronograf/v1/sources/1/users/3-d"},"name":"3-d"}],"name":"biffsgang","permissions":[{"scope":"DBScope","name":"grays_sports_almanac","allowed":["ReadData"]}],"links":{"self":"/chronograf/v1/sources/1/roles/biffsgang"}}]}
`,
},
}
for _, tt := range tests {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
}
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "rid",
Value: tt.RoleID,
},
}))
h.Roles(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. RoleID() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. RoleID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}

View File

@ -2,9 +2,11 @@ package server
import (
"crypto/tls"
"log"
"math/rand"
"net"
"net/http"
"os"
"runtime"
"strconv"
"time"
@ -57,7 +59,7 @@ type Server struct {
HerokuOrganizations []string `long:"heroku-organization" description:"Heroku Organization Memberships a user is required to have for access to Chronograf (comma separated)" env:"HEROKU_ORGS" env-delim:","`
ReportingDisabled bool `short:"r" long:"reporting-disabled" description:"Disable reporting of usage stats (os,arch,version,cluster_id,uptime) once every 24hr" env:"REPORTING_DISABLED"`
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"warn" choice:"error" choice:"fatal" choice:"panic" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
LogLevel string `short:"l" long:"log-level" value-name:"choice" choice:"debug" choice:"info" choice:"error" default:"info" description:"Set the logging level" env:"LOG_LEVEL"`
Basepath string `short:"p" long:"basepath" description:"A URL path prefix under which all chronograf routes will be mounted" env:"BASE_PATH"`
ShowVersion bool `short:"v" long:"version" description:"Show Chronograf version info"`
BuildInfo BuildInfo
@ -73,14 +75,17 @@ func provide(p oauth2.Provider, m oauth2.Mux, ok func() bool) func(func(oauth2.P
}
}
// UseGithub validates the CLI parameters to enable github oauth support
func (s *Server) UseGithub() bool {
return s.TokenSecret != "" && s.GithubClientID != "" && s.GithubClientSecret != ""
}
// UseGoogle validates the CLI parameters to enable google oauth support
func (s *Server) UseGoogle() bool {
return s.TokenSecret != "" && s.GoogleClientID != "" && s.GoogleClientSecret != "" && s.PublicURL != ""
}
// UseHeroku validates the CLI parameters to enable heroku oauth support
func (s *Server) UseHeroku() bool {
return s.TokenSecret != "" && s.HerokuClientID != "" && s.HerokuSecret != ""
}
@ -208,10 +213,21 @@ func (s *Server) Serve() error {
}
s.Listener = listener
httpServer := &graceful.Server{Server: new(http.Server)}
// Using a log writer for http server logging
w := logger.Writer()
defer w.Close()
stdLog := log.New(w, "", 0)
// TODO: Remove graceful when changing to go 1.8
httpServer := &graceful.Server{
Server: &http.Server{
ErrorLog: stdLog,
Handler: s.handler,
},
Logger: stdLog,
TCPKeepAlive: 5 * time.Second,
}
httpServer.SetKeepAlivesEnabled(true)
httpServer.TCPKeepAlive = 5 * time.Second
httpServer.Handler = s.handler
if !s.ReportingDisabled {
go reportUsageStats(s.BuildInfo, logger)
@ -244,7 +260,8 @@ func openService(boltPath, cannedPath string, logger chronograf.Logger, useAuth
if err := db.Open(); err != nil {
logger.
WithField("component", "boltstore").
Fatal("Unable to open boltdb; is there a chronograf already running? ", err)
Error("Unable to open boltdb; is there a chronograf already running? ", err)
os.Exit(1)
}
// These apps are those handled from a directory

View File

@ -62,7 +62,8 @@ func (wrw *wrapResponseWriter) Header() http.Header {
return *wrw.dupHeader
}
const CHUNK_SIZE int = 512
// ChunkSize is the number of bytes per chunked transfer-encoding
const ChunkSize int = 512
// ServeHTTP implements an http.Handler that prefixes relative URLs from the
// Next handler with the configured prefix. It does this by examining the
@ -79,9 +80,9 @@ func (up *URLPrefixer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
// extract the flusher for flushing chunks
flusher, ok := rw.(http.Flusher)
if !ok {
up.Logger.
WithField("component", "prefixer").
Fatal("Expected http.ResponseWriter to be an http.Flusher, but wasn't")
msg := "Expected http.ResponseWriter to be an http.Flusher, but wasn't"
Error(rw, http.StatusInternalServerError, msg, up.Logger)
return
}
nextRead, nextWrite := io.Pipe()
@ -109,7 +110,7 @@ func (up *URLPrefixer) ServeHTTP(rw http.ResponseWriter, r *http.Request) {
writtenCount++
buf.Write(src.Bytes())
if writtenCount >= CHUNK_SIZE {
if writtenCount >= ChunkSize {
flusher.Flush()
writtenCount = 0
}

View File

@ -1,99 +1,317 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"golang.org/x/net/context"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/oauth2"
)
type userLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
type userResponse struct {
*chronograf.User
Links userLinks `json:"links"`
}
// If new user response is nil, return an empty userResponse because it
// indicates authentication is not needed
func newUserResponse(usr *chronograf.User) userResponse {
base := "/chronograf/v1/users"
name := "me"
if usr != nil {
// TODO: Change to usrl.PathEscape for go 1.8
u := &url.URL{Path: usr.Name}
name = u.String()
}
return userResponse{
User: usr,
Links: userLinks{
Self: fmt.Sprintf("%s/%s", base, name),
},
}
}
func getEmail(ctx context.Context) (string, error) {
principal, err := getPrincipal(ctx)
if err != nil {
return "", err
}
if principal.Subject == "" {
return "", fmt.Errorf("Token not found")
}
return principal.Subject, nil
}
func getPrincipal(ctx context.Context) (oauth2.Principal, error) {
principal, ok := ctx.Value(oauth2.PrincipalKey).(oauth2.Principal)
if !ok {
return oauth2.Principal{}, fmt.Errorf("Token not found")
}
return principal, nil
}
// Me does a findOrCreate based on the email in the context
func (h *Service) Me(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
if !h.UseAuth {
// If there's no authentication, return an empty user
res := newUserResponse(nil)
encodeJSON(w, http.StatusOK, res, h.Logger)
// NewSourceUser adds user to source
func (h *Service) NewSourceUser(w http.ResponseWriter, r *http.Request) {
var req userRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
email, err := getEmail(ctx)
if err != nil {
if err := req.ValidCreate(); err != nil {
invalidData(w, err, h.Logger)
return
}
usr, err := h.UsersStore.Get(ctx, email)
if err == nil {
res := newUserResponse(usr)
encodeJSON(w, http.StatusOK, res, h.Logger)
return
}
// Because we didnt find a user, making a new one
user := &chronograf.User{
Name: email,
}
newUser, err := h.UsersStore.Add(ctx, user)
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
msg := fmt.Errorf("error storing user %s: %v", user.Name, err)
unknownErrorWithMessage(w, msg, h.Logger)
return
}
res := newUserResponse(newUser)
store := ts.Users(ctx)
user := &chronograf.User{
Name: req.Username,
Passwd: req.Password,
Permissions: req.Permissions,
Roles: req.Roles,
}
res, err := store.Add(ctx, user)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
su := newUserResponse(srcID, res.Name).WithPermissions(res.Permissions)
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
su.WithRoles(srcID, res.Roles)
}
w.Header().Add("Location", su.Links.Self)
encodeJSON(w, http.StatusCreated, su, h.Logger)
}
// SourceUsers retrieves all users from source.
func (h *Service) SourceUsers(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store := ts.Users(ctx)
users, err := store.All(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
_, hasRoles := h.hasRoles(ctx, ts)
ur := make([]userResponse, len(users))
for i, u := range users {
usr := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
if hasRoles {
usr.WithRoles(srcID, u.Roles)
}
ur[i] = *usr
}
res := usersResponse{
Users: ur,
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// SourceUserID retrieves a user with ID from store.
func (h *Service) SourceUserID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
store := ts.Users(ctx)
u, err := store.Get(ctx, uid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
res.WithRoles(srcID, u.Roles)
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// RemoveSourceUser removes the user from the InfluxDB source
func (h *Service) RemoveSourceUser(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
_, store, err := h.sourceUsersStore(ctx, w, r)
if err != nil {
return
}
if err := store.Delete(ctx, &chronograf.User{Name: uid}); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// UpdateSourceUser changes the password or permissions of a source user
func (h *Service) UpdateSourceUser(w http.ResponseWriter, r *http.Request) {
var req userRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := req.ValidUpdate(); err != nil {
invalidData(w, err, h.Logger)
return
}
ctx := r.Context()
uid := httprouter.GetParamFromContext(ctx, "uid")
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return
}
user := &chronograf.User{
Name: uid,
Passwd: req.Password,
Permissions: req.Permissions,
Roles: req.Roles,
}
store := ts.Users(ctx)
if err := store.Update(ctx, user); err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
u, err := store.Get(ctx, uid)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := newUserResponse(srcID, u.Name).WithPermissions(u.Permissions)
if _, hasRoles := h.hasRoles(ctx, ts); hasRoles {
res.WithRoles(srcID, u.Roles)
}
w.Header().Add("Location", res.Links.Self)
encodeJSON(w, http.StatusOK, res, h.Logger)
}
func (h *Service) sourcesSeries(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.TimeSeries, error) {
srcID, err := paramID("id", r)
if err != nil {
Error(w, http.StatusUnprocessableEntity, err.Error(), h.Logger)
return 0, nil, err
}
src, err := h.SourcesStore.Get(ctx, srcID)
if err != nil {
notFound(w, srcID, h.Logger)
return 0, nil, err
}
ts, err := h.TimeSeries(src)
if err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
if err = ts.Connect(ctx, &src); err != nil {
msg := fmt.Sprintf("Unable to connect to source %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return 0, nil, err
}
return srcID, ts, nil
}
func (h *Service) sourceUsersStore(ctx context.Context, w http.ResponseWriter, r *http.Request) (int, chronograf.UsersStore, error) {
srcID, ts, err := h.sourcesSeries(ctx, w, r)
if err != nil {
return 0, nil, err
}
store := ts.Users(ctx)
return srcID, store, nil
}
// hasRoles checks if the influx source has roles or not
func (h *Service) hasRoles(ctx context.Context, ts chronograf.TimeSeries) (chronograf.RolesStore, bool) {
store, err := ts.Roles(ctx)
if err != nil {
return nil, false
}
return store, true
}
type userRequest struct {
Username string `json:"name,omitempty"` // Username for new account
Password string `json:"password,omitempty"` // Password for new account
Permissions chronograf.Permissions `json:"permissions,omitempty"` // Optional permissions
Roles []chronograf.Role `json:"roles,omitempty"` // Optional roles
}
func (r *userRequest) ValidCreate() error {
if r.Username == "" {
return fmt.Errorf("Username required")
}
if r.Password == "" {
return fmt.Errorf("Password required")
}
return validPermissions(&r.Permissions)
}
type usersResponse struct {
Users []userResponse `json:"users"`
}
func (r *userRequest) ValidUpdate() error {
if r.Password == "" && len(r.Permissions) == 0 && len(r.Roles) == 0 {
return fmt.Errorf("No fields to update")
}
return validPermissions(&r.Permissions)
}
type userResponse struct {
Name string // Username for new account
Permissions chronograf.Permissions // Account's permissions
Roles []roleResponse // Roles if source uses them
Links selfLinks // Links are URI locations related to user
hasPermissions bool
hasRoles bool
}
func (u *userResponse) MarshalJSON() ([]byte, error) {
res := map[string]interface{}{
"name": u.Name,
"links": u.Links,
}
if u.hasRoles {
res["roles"] = u.Roles
}
if u.hasPermissions {
res["permissions"] = u.Permissions
}
return json.Marshal(res)
}
// newUserResponse creates an HTTP JSON response for a user w/o roles
func newUserResponse(srcID int, name string) *userResponse {
self := newSelfLinks(srcID, "users", name)
return &userResponse{
Name: name,
Links: self,
}
}
func (u *userResponse) WithPermissions(perms chronograf.Permissions) *userResponse {
u.hasPermissions = true
if perms == nil {
perms = make(chronograf.Permissions, 0)
}
u.Permissions = perms
return u
}
// WithRoles adds roles to the HTTP JSON response for a user
func (u *userResponse) WithRoles(srcID int, roles []chronograf.Role) *userResponse {
u.hasRoles = true
rr := make([]roleResponse, len(roles))
for i, role := range roles {
rr[i] = newRoleResponse(srcID, &role)
}
u.Roles = rr
return u
}
type selfLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
func newSelfLinks(id int, parent, resource string) selfLinks {
httpAPISrcs := "/chronograf/v1/sources"
u := &url.URL{Path: resource}
encodedResource := u.String()
return selfLinks{
Self: fmt.Sprintf("%s/%d/%s/%s", httpAPISrcs, id, parent, encodedResource),
}
}

View File

@ -1,6 +1,7 @@
package server
package server_test
import (
"bytes"
"context"
"fmt"
"io/ioutil"
@ -8,17 +9,17 @@ import (
"net/http/httptest"
"testing"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/log"
"github.com/influxdata/chronograf/mocks"
"github.com/influxdata/chronograf/oauth2"
"github.com/influxdata/chronograf/server"
)
type MockUsers struct{}
func TestService_Me(t *testing.T) {
func TestService_NewSourceUser(t *testing.T) {
type fields struct {
UsersStore chronograf.UsersStore
SourcesStore chronograf.SourcesStore
TimeSeries server.TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
}
@ -30,139 +31,900 @@ func TestService_Me(t *testing.T) {
name string
fields fields
args args
principal oauth2.Principal
ID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Existing user",
name: "New user for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return &chronograf.User{
Name: "me",
Passwd: "hunter2",
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
principal: oauth2.Principal{
Subject: "me",
},
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"name":"me","password":"hunter2","links":{"self":"/chronograf/v1/users/me"}}
`,
},
{
name: "New user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return nil, fmt.Errorf("Unknown User")
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return u, nil
},
}
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, fmt.Errorf("no roles")
},
},
principal: oauth2.Principal{
Subject: "secret",
},
wantStatus: http.StatusOK,
ID: "1",
wantStatus: http.StatusCreated,
wantContentType: "application/json",
wantBody: `{"name":"secret","password":"","links":{"self":"/chronograf/v1/users/secret"}}
wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]}
`,
},
{
name: "New user for data source with roles",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return u, nil
},
}
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, nil
},
},
},
ID: "1",
wantStatus: http.StatusCreated,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]}
`,
},
{
name: "Error adding user",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
UsersStore: &mocks.UsersStore{
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return nil, fmt.Errorf("Unknown User")
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
AddF: func(ctx context.Context, u *chronograf.User) (*chronograf.User, error) {
return nil, fmt.Errorf("Why Heavy?")
return nil, fmt.Errorf("Weight Has Nothing to Do With It")
},
}
},
},
Logger: log.New(log.DebugLevel),
},
principal: oauth2.Principal{
Subject: "secret",
},
wantStatus: http.StatusInternalServerError,
ID: "1",
wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
wantBody: `{"code":500,"message":"Unknown error: error storing user secret: Why Heavy?"}`,
wantBody: `{"code":400,"message":"Weight Has Nothing to Do With It"}`,
},
{
name: "No Auth",
name: "Failure connecting to user store",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: false,
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
wantStatus: http.StatusOK,
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return fmt.Errorf("Biff just happens to be my supervisor")
},
},
},
ID: "1",
wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/users/me"}}
`,
wantBody: `{"code":400,"message":"Unable to connect to source 1: Biff just happens to be my supervisor"}`,
},
{
name: "Empty Principal",
name: "Failure getting source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest("GET", "http://example.com/foo", nil),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{}, fmt.Errorf("No McFly ever amounted to anything in the history of Hill Valley")
},
},
},
ID: "1",
wantStatus: http.StatusNotFound,
wantContentType: "application/json",
wantBody: `{"code":404,"message":"ID 1 not found"}`,
},
{
name: "Bad ID",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
},
ID: "BAD",
wantStatus: http.StatusUnprocessableEntity,
principal: oauth2.Principal{
Subject: "",
wantContentType: "application/json",
wantBody: `{"code":422,"message":"Error converting ID BAD"}`,
},
{
name: "Bad name",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
},
ID: "BAD",
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "application/json",
wantBody: `{"code":422,"message":"Username required"}`,
},
{
name: "Bad JSON",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{password}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
},
ID: "BAD",
wantStatus: http.StatusBadRequest,
wantContentType: "application/json",
wantBody: `{"code":400,"message":"Unparsable JSON"}`,
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(context.WithValue(context.Background(), oauth2.PrincipalKey, tt.principal))
h := &Service{
UsersStore: tt.fields.UsersStore,
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h := &server.Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.Me(tt.args.w, tt.args.r)
h.NewSourceUser(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. Me() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. Me() = %v, want %v", tt.name, content, tt.wantContentType)
t.Errorf("%q. NewSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. Me() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
t.Errorf("%q. NewSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_SourceUsers(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries server.TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "All users for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1",
nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, fmt.Errorf("no roles")
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
AllF: func(ctx context.Context) ([]chronograf.User, error) {
return []chronograf.User{
{
Name: "strickland",
Passwd: "discipline",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"READ"},
},
},
},
}, nil
},
}
},
},
},
ID: "1",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]}]}
`,
},
{
name: "All users for data source with roles",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1",
nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, nil
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
AllF: func(ctx context.Context) ([]chronograf.User, error) {
return []chronograf.User{
{
Name: "strickland",
Passwd: "discipline",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"READ"},
},
},
},
}, nil
},
}
},
},
},
ID: "1",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"users":[{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]}]}
`,
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h := &server.Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.SourceUsers(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. SourceUsers() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. SourceUsers() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_SourceUserID(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries server.TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
UID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Single user for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1",
nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, fmt.Errorf("no roles")
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
GetF: func(ctx context.Context, uid string) (*chronograf.User, error) {
return &chronograf.User{
Name: "strickland",
Passwd: "discipline",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"READ"},
},
},
}, nil
},
}
},
},
},
ID: "1",
UID: "strickland",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}]}
`,
},
{
name: "Single user for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1",
nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, nil
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
GetF: func(ctx context.Context, uid string) (*chronograf.User, error) {
return &chronograf.User{
Name: "strickland",
Passwd: "discipline",
Permissions: chronograf.Permissions{
{
Scope: chronograf.AllScope,
Allowed: chronograf.Allowances{"READ"},
},
},
}, nil
},
}
},
},
},
ID: "1",
UID: "strickland",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/strickland"},"name":"strickland","permissions":[{"scope":"all","allowed":["READ"]}],"roles":[]}
`,
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h := &server.Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.SourceUserID(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. SourceUserID() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. SourceUserID() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_RemoveSourceUser(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries server.TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
UID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Delete user for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"GET",
"http://server.local/chronograf/v1/sources/1",
nil),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
DeleteF: func(ctx context.Context, u *chronograf.User) error {
return nil
},
}
},
},
},
ID: "1",
UID: "strickland",
wantStatus: http.StatusNoContent,
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
}))
h := &server.Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.RemoveSourceUser(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. RemoveSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. RemoveSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}
func TestService_UpdateSourceUser(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
TimeSeries server.TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
}
type args struct {
w *httptest.ResponseRecorder
r *http.Request
}
tests := []struct {
name string
fields fields
args args
ID string
UID string
wantStatus int
wantContentType string
wantBody string
}{
{
name: "Update user password for data source",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, fmt.Errorf("no roles")
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
UpdateF: func(ctx context.Context, u *chronograf.User) error {
return nil
},
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return &chronograf.User{
Name: "marty",
}, nil
},
}
},
},
},
ID: "1",
UID: "marty",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[]}
`,
},
{
name: "Update user password for data source with roles",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty", "password": "the_lake"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
SourcesStore: &mocks.SourcesStore{
GetF: func(ctx context.Context, ID int) (chronograf.Source, error) {
return chronograf.Source{
ID: 1,
Name: "muh source",
Username: "name",
Password: "hunter2",
URL: "http://localhost:8086",
}, nil
},
},
TimeSeries: &mocks.TimeSeries{
ConnectF: func(ctx context.Context, src *chronograf.Source) error {
return nil
},
RolesF: func(ctx context.Context) (chronograf.RolesStore, error) {
return nil, nil
},
UsersF: func(ctx context.Context) chronograf.UsersStore {
return &mocks.UsersStore{
UpdateF: func(ctx context.Context, u *chronograf.User) error {
return nil
},
GetF: func(ctx context.Context, name string) (*chronograf.User, error) {
return &chronograf.User{
Name: "marty",
}, nil
},
}
},
},
},
ID: "1",
UID: "marty",
wantStatus: http.StatusOK,
wantContentType: "application/json",
wantBody: `{"links":{"self":"/chronograf/v1/sources/1/users/marty"},"name":"marty","permissions":[],"roles":[]}
`,
},
{
name: "Invalid update JSON",
args: args{
w: httptest.NewRecorder(),
r: httptest.NewRequest(
"POST",
"http://server.local/chronograf/v1/sources/1",
ioutil.NopCloser(
bytes.NewReader([]byte(`{"name": "marty"}`)))),
},
fields: fields{
UseAuth: true,
Logger: log.New(log.DebugLevel),
},
ID: "1",
UID: "marty",
wantStatus: http.StatusUnprocessableEntity,
wantContentType: "application/json",
wantBody: `{"code":422,"message":"No fields to update"}`,
},
}
for _, tt := range tests {
tt.args.r = tt.args.r.WithContext(httprouter.WithParams(
context.Background(),
httprouter.Params{
{
Key: "id",
Value: tt.ID,
},
{
Key: "uid",
Value: tt.UID,
},
}))
h := &server.Service{
SourcesStore: tt.fields.SourcesStore,
TimeSeriesClient: tt.fields.TimeSeries,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
}
h.UpdateSourceUser(tt.args.w, tt.args.r)
resp := tt.args.w.Result()
content := resp.Header.Get("Content-Type")
body, _ := ioutil.ReadAll(resp.Body)
if resp.StatusCode != tt.wantStatus {
t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, resp.StatusCode, tt.wantStatus)
}
if tt.wantContentType != "" && content != tt.wantContentType {
t.Errorf("%q. UpdateSourceUser() = %v, want %v", tt.name, content, tt.wantContentType)
}
if tt.wantBody != "" && string(body) != tt.wantBody {
t.Errorf("%q. UpdateSourceUser() = \n***%v***\n,\nwant\n***%v***", tt.name, string(body), tt.wantBody)
}
}
}

View File

@ -4,6 +4,7 @@ import (
"net/http"
)
// Version handler adds X-Chronograf-Version header to responses
func Version(version string, h http.Handler) http.Handler {
fn := func(w http.ResponseWriter, r *http.Request) {
w.Header().Add("X-Chronograf-Version", version)

View File

@ -230,7 +230,7 @@
'react/no-string-refs': 0, // TODO: 2
'react/no-unknown-property': 2,
'react/prop-types': 2,
'react/prefer-es6-class': 0,
'react/prefer-es6-class': [0, 'never'],
'react/react-in-jsx-scope': 2,
'react/require-extension': 0,
'react/self-closing-comp': 0, // TODO: we can re-enable this if some brave soul wants to update the code (mostly spans acting as icons)

View File

@ -0,0 +1,40 @@
import appReducer from 'src/shared/reducers/app'
import {
enablePresentationMode,
disablePresentationMode,
// delayEnablePresentationMode,
setAutoRefresh,
} from 'src/shared/actions/app'
describe('Shared.Reducers.appReducer', () => {
const initialState = {
ephemeral: {
inPresentationMode: false,
},
persisted: {
autoRefresh: 0
},
}
it('should handle ENABLE_PRESENTATION_MODE', () => {
const reducedState = appReducer(initialState, enablePresentationMode());
expect(reducedState.ephemeral.inPresentationMode).to.equal(true);
})
it('should handle DISABLE_PRESENTATION_MODE', () => {
Object.assign(initialState, {ephemeral: {inPresentationMode: true}})
const reducedState = appReducer(initialState, disablePresentationMode());
expect(reducedState.ephemeral.inPresentationMode).to.equal(false);
})
it('should handle SET_AUTOREFRESH', () => {
const expectedMs = 15000
const reducedState = appReducer(initialState, setAutoRefresh(expectedMs));
expect(reducedState.persisted.autoRefresh).to.equal(expectedMs);
})
})

View File

@ -158,7 +158,7 @@ class AdminPage extends Component {
<div className="container-fluid">
<div className="row">
{
users.length ?
users ?
<AdminTabs
users={users}
roles={roles}

View File

@ -17,7 +17,7 @@ const newDefaultRole = {
}
const initialState = {
users: [],
users: null,
roles: [],
permissions: [],
queries: [],

View File

@ -1,5 +1,6 @@
import React, {PropTypes} from 'react';
import AlertsTable from '../components/AlertsTable';
import SourceIndicator from '../../shared/components/SourceIndicator';
import {getAlerts} from '../apis';
import AJAX from 'utils/ajax';
import _ from 'lodash';
@ -89,6 +90,7 @@ const AlertsApp = React.createClass({
},
render() {
const {source} = this.props;
return (
// I stole this from the Hosts page.
// Perhaps we should create an abstraction?
@ -100,6 +102,9 @@ const AlertsApp = React.createClass({
Alert History
</h1>
</div>
<div className="page-header__right">
<SourceIndicator sourceName={source.name} />
</div>
</div>
</div>
<div className="page-contents">

View File

@ -10,6 +10,7 @@ const Dashboard = ({
inPresentationMode,
onPositionChange,
source,
autoRefresh,
timeRange,
}) => {
if (dashboard.id === 0) {
@ -20,20 +21,19 @@ const Dashboard = ({
<div className={classnames({'page-contents': true, 'presentation-mode': inPresentationMode})}>
<div className={classnames('container-fluid full-width dashboard', {'dashboard-edit': isEditMode})}>
{isEditMode ? <Visualizations/> : null}
{Dashboard.renderDashboard(dashboard, timeRange, source, onPositionChange)}
{Dashboard.renderDashboard(dashboard, autoRefresh, timeRange, source, onPositionChange)}
</div>
</div>
)
}
Dashboard.renderDashboard = (dashboard, timeRange, source, onPositionChange) => {
const autoRefreshMs = 15000
Dashboard.renderDashboard = (dashboard, autoRefresh, timeRange, source, onPositionChange) => {
const cells = dashboard.cells.map((cell, i) => {
i = `${i}`
const dashboardCell = {...cell, i}
dashboardCell.queries.forEach((q) => {
q.text = q.query;
q.database = source.telegraf;
q.database = q.db;
});
return dashboardCell;
})
@ -42,7 +42,7 @@ Dashboard.renderDashboard = (dashboard, timeRange, source, onPositionChange) =>
<LayoutRenderer
timeRange={timeRange}
cells={cells}
autoRefreshMs={autoRefreshMs}
autoRefresh={autoRefresh}
source={source.links.proxy}
onPositionChange={onPositionChange}
/>
@ -54,6 +54,7 @@ const {
func,
shape,
string,
number,
} = PropTypes
Dashboard.propTypes = {
@ -66,6 +67,7 @@ Dashboard.propTypes = {
proxy: string,
}).isRequired,
}).isRequired,
autoRefresh: number.isRequired,
timeRange: shape({}).isRequired,
}

View File

@ -2,7 +2,9 @@ import React, {PropTypes} from 'react'
import ReactTooltip from 'react-tooltip'
import {Link} from 'react-router';
import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown'
import TimeRangeDropdown from 'shared/components/TimeRangeDropdown'
import SourceIndicator from '../../shared/components/SourceIndicator'
const DashboardHeader = ({
children,
@ -10,10 +12,13 @@ const DashboardHeader = ({
dashboard,
headerText,
timeRange,
autoRefresh,
isHidden,
handleChooseTimeRange,
handleChooseAutoRefresh,
handleClickPresentationButton,
sourceID,
source,
}) => isHidden ? null : (
<div className="page-header full-width">
<div className="page-header__container">
@ -45,6 +50,8 @@ const DashboardHeader = ({
Graph Tips
</div>
<ReactTooltip id="graph-tips-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip place-bottom" />
<SourceIndicator sourceName={source.name} />
<AutoRefreshDropdown onChoose={handleChooseAutoRefresh} selected={autoRefresh} iconName="refresh" />
<TimeRangeDropdown onChooseTimeRange={handleChooseTimeRange} selected={timeRange.inputValue} />
<div className="btn btn-info btn-sm" onClick={handleClickPresentationButton}>
<span className="icon expand-a" style={{margin: 0}}></span>
@ -55,11 +62,12 @@ const DashboardHeader = ({
)
const {
shape,
array,
string,
func,
bool,
func,
number,
shape,
string,
} = PropTypes
DashboardHeader.propTypes = {
@ -69,9 +77,12 @@ DashboardHeader.propTypes = {
dashboard: shape({}),
headerText: string,
timeRange: shape({}).isRequired,
autoRefresh: number.isRequired,
isHidden: bool.isRequired,
handleChooseTimeRange: func.isRequired,
handleChooseAutoRefresh: func.isRequired,
handleClickPresentationButton: func.isRequired,
source: shape({}),
}
export default DashboardHeader

View File

@ -51,6 +51,7 @@ const DashboardPage = React.createClass({
id: number.isRequired,
cells: arrayOf(shape({})).isRequired,
}).isRequired,
autoRefresh: number.isRequired,
timeRange: shape({}).isRequired,
inPresentationMode: bool.isRequired,
isEditMode: bool.isRequired,
@ -100,6 +101,7 @@ const DashboardPage = React.createClass({
isEditMode,
handleClickPresentationButton,
source,
autoRefresh,
timeRange,
} = this.props
@ -110,12 +112,14 @@ const DashboardPage = React.createClass({
<EditHeader dashboard={dashboard} onSave={() => {}} /> :
<Header
buttonText={dashboard ? dashboard.name : ''}
autoRefresh={autoRefresh}
timeRange={timeRange}
handleChooseTimeRange={this.handleChooseTimeRange}
isHidden={inPresentationMode}
handleClickPresentationButton={handleClickPresentationButton}
dashboard={dashboard}
sourceID={sourceID}
source={source}
>
{(dashboards).map((d, i) => {
return (
@ -133,6 +137,7 @@ const DashboardPage = React.createClass({
isEditMode={isEditMode}
inPresentationMode={inPresentationMode}
source={source}
autoRefresh={autoRefresh}
timeRange={timeRange}
onPositionChange={this.handleUpdatePosition}
/>
@ -143,7 +148,10 @@ const DashboardPage = React.createClass({
const mapStateToProps = (state) => {
const {
appUI,
app: {
ephemeral: {inPresentationMode},
persisted: {autoRefresh},
},
dashboardUI: {
dashboards,
dashboard,
@ -153,11 +161,12 @@ const mapStateToProps = (state) => {
} = state
return {
inPresentationMode: appUI.presentationMode,
dashboards,
dashboard,
autoRefresh,
timeRange,
isEditMode,
inPresentationMode,
}
}

View File

@ -1,5 +1,6 @@
import React, {PropTypes} from 'react';
import {Link} from 'react-router';
import SourceIndicator from '../../shared/components/SourceIndicator';
import {getDashboards} from '../apis';
@ -53,6 +54,9 @@ const DashboardsPage = React.createClass({
Dashboards
</h1>
</div>
<div className="page-header__right">
<SourceIndicator sourceName={this.props.source.name} />
</div>
</div>
</div>
<div className="page-contents">

View File

@ -15,6 +15,7 @@ const {
const Visualization = React.createClass({
propTypes: {
autoRefresh: number.isRequired,
timeRange: shape({
upper: string,
lower: string,
@ -45,7 +46,7 @@ const Visualization = React.createClass({
},
render() {
const {queryConfigs, timeRange, activeQueryIndex, height, heightPixels} = this.props;
const {queryConfigs, autoRefresh, timeRange, activeQueryIndex, height, heightPixels} = this.props;
const {source} = this.context;
const proxyLink = source.links.proxy;
@ -57,7 +58,6 @@ const Visualization = React.createClass({
const queries = statements.filter((s) => s.text !== null).map((s) => {
return {host: [proxyLink], text: s.text, id: s.id};
});
const autoRefreshMs = 10000;
const isInDataExplorer = true;
return (
@ -77,7 +77,7 @@ const Visualization = React.createClass({
{isGraphInView ? (
<RefreshingLineGraph
queries={queries}
autoRefresh={autoRefreshMs}
autoRefresh={autoRefresh}
activeQueryIndex={activeQueryIndex}
isInDataExplorer={isInDataExplorer}
/>

View File

@ -1,17 +1,18 @@
import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import QueryBuilder from '../components/QueryBuilder';
import Visualization from '../components/Visualization';
import Header from '../containers/Header';
import ResizeContainer from 'src/shared/components/ResizeContainer';
import {
setTimeRange as setTimeRangeAction,
} from '../actions/view';
import {setAutoRefresh} from 'shared/actions/app'
import {setTimeRange as setTimeRangeAction} from '../actions/view';
const {
arrayOf,
func,
number,
shape,
string,
} = PropTypes;
@ -25,6 +26,8 @@ const DataExplorer = React.createClass({
}).isRequired,
}).isRequired,
queryConfigs: PropTypes.shape({}),
autoRefresh: number.isRequired,
handleChooseAutoRefresh: func.isRequired,
timeRange: shape({
upper: string,
lower: string,
@ -59,18 +62,20 @@ const DataExplorer = React.createClass({
},
render() {
const {timeRange, setTimeRange, queryConfigs, dataExplorer} = this.props;
const {autoRefresh, handleChooseAutoRefresh, timeRange, setTimeRange, queryConfigs, dataExplorer} = this.props;
const {activeQueryID} = this.state;
const queries = dataExplorer.queryIDs.map((qid) => queryConfigs[qid]);
return (
<div className="data-explorer">
<Header
actions={{setTimeRange}}
actions={{handleChooseAutoRefresh, setTimeRange}}
autoRefresh={autoRefresh}
timeRange={timeRange}
/>
<ResizeContainer>
<Visualization
autoRefresh={autoRefresh}
timeRange={timeRange}
queryConfigs={queries}
activeQueryID={this.state.activeQueryID}
@ -78,6 +83,7 @@ const DataExplorer = React.createClass({
/>
<QueryBuilder
queries={queries}
autoRefresh={autoRefresh}
timeRange={timeRange}
setActiveQuery={this.handleSetActiveQuery}
activeQueryID={activeQueryID}
@ -89,15 +95,21 @@ const DataExplorer = React.createClass({
});
function mapStateToProps(state) {
const {timeRange, queryConfigs, dataExplorer} = state;
const {app: {persisted: {autoRefresh}}, timeRange, queryConfigs, dataExplorer} = state;
return {
autoRefresh,
timeRange,
queryConfigs,
dataExplorer,
};
}
export default connect(mapStateToProps, {
setTimeRange: setTimeRangeAction,
})(DataExplorer);
function mapDispatchToProps(dispatch) {
return {
handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch),
setTimeRange: bindActionCreators(setTimeRangeAction, dispatch),
}
}
export default connect(mapStateToProps, mapDispatchToProps)(DataExplorer);

View File

@ -1,23 +1,36 @@
import React, {PropTypes} from 'react';
import moment from 'moment';
import {withRouter} from 'react-router';
import AutoRefreshDropdown from 'shared/components/AutoRefreshDropdown'
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
import SourceIndicator from '../../shared/components/SourceIndicator';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
const {
func,
number,
shape,
string,
} = PropTypes
const Header = React.createClass({
propTypes: {
timeRange: PropTypes.shape({
upper: PropTypes.string,
lower: PropTypes.string,
autoRefresh: number.isRequired,
timeRange: shape({
upper: string,
lower: string,
}).isRequired,
actions: PropTypes.shape({
setTimeRange: PropTypes.func.isRequired,
actions: shape({
handleChooseAutoRefresh: func.isRequired,
setTimeRange: func.isRequired,
}),
},
contextTypes: {
source: PropTypes.shape({
name: PropTypes.string,
source: shape({
name: string,
}),
},
@ -36,7 +49,7 @@ const Header = React.createClass({
},
render() {
const {timeRange} = this.props;
const {autoRefresh, actions: {handleChooseAutoRefresh}, timeRange} = this.props;
return (
<div className="page-header">
@ -45,11 +58,8 @@ const Header = React.createClass({
<h1>Explorer</h1>
</div>
<div className="page-header__right">
<h1>Source:</h1>
<div className="source-indicator">
<span className="icon cpu"></span>
{this.context.source.name}
</div>
<SourceIndicator sourceName={this.context.source.name} />
<AutoRefreshDropdown onChoose={handleChooseAutoRefresh} selected={autoRefresh} iconName="refresh" />
<TimeRangeDropdown onChooseTimeRange={this.handleChooseTimeRange} selected={this.findSelected(timeRange)} />
</div>
</div>

View File

@ -2,8 +2,8 @@ import queryConfigs from './queryConfigs';
import timeRange from './timeRange';
import dataExplorer from './ui';
export {
export default {
queryConfigs,
timeRange,
dataExplorer,
};
}

View File

@ -87,12 +87,12 @@ const HostsTable = React.createClass({
const hostCount = sortedHosts.length;
let hostsTitle;
if (hostCount === 1) {
hostsTitle = `${hostCount} Host`;
} else if (hostCount > 1) {
hostsTitle = `${hostCount} Hosts`;
} else {
if (hosts.length === 0) {
hostsTitle = `Loading Hosts...`;
} else if (hostCount === 1) {
hostsTitle = `${hostCount} Host`;
} else {
hostsTitle = `${hostCount} Hosts`;
}
return (

View File

@ -1,6 +1,7 @@
import React, {PropTypes} from 'react'
import {Link} from 'react-router'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import _ from 'lodash'
import classnames from 'classnames';
@ -9,6 +10,8 @@ import DashboardHeader from 'src/dashboards/components/DashboardHeader';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
import {getMappings, getAppsForHosts, getMeasurementsForHost, getAllHosts} from 'src/hosts/apis';
import {fetchLayouts} from 'shared/apis';
import {setAutoRefresh} from 'shared/actions/app'
import {presentationButtonDispatcher} from 'shared/dispatchers'
const {
@ -16,6 +19,7 @@ const {
string,
bool,
func,
number,
} = PropTypes
export const HostPage = React.createClass({
@ -35,6 +39,8 @@ export const HostPage = React.createClass({
app: string,
}),
}),
autoRefresh: number.isRequired,
handleChooseAutoRefresh: func.isRequired,
inPresentationMode: bool,
handleClickPresentationButton: func,
},
@ -87,9 +93,8 @@ export const HostPage = React.createClass({
},
renderLayouts(layouts) {
const autoRefreshMs = 15000;
const {timeRange} = this.state;
const {source} = this.props;
const {source, autoRefresh} = this.props;
const autoflowLayouts = layouts.filter((layout) => !!layout.autoflow);
@ -137,7 +142,7 @@ export const HostPage = React.createClass({
<LayoutRenderer
timeRange={timeRange}
cells={layoutCells}
autoRefreshMs={autoRefreshMs}
autoRefresh={autoRefresh}
source={source.links.proxy}
host={this.props.params.hostID}
/>
@ -145,7 +150,16 @@ export const HostPage = React.createClass({
},
render() {
const {params: {hostID}, location: {query: {app}}, source: {id}, inPresentationMode, handleClickPresentationButton} = this.props
const {
params: {hostID},
location: {query: {app}},
source: {id},
autoRefresh,
handleChooseAutoRefresh,
inPresentationMode,
handleClickPresentationButton,
source,
} = this.props
const {layouts, timeRange, hosts} = this.state
const appParam = app ? `?app=${app}` : ''
@ -153,10 +167,13 @@ export const HostPage = React.createClass({
<div className="page">
<DashboardHeader
buttonText={hostID}
autoRefresh={autoRefresh}
timeRange={timeRange}
isHidden={inPresentationMode}
handleChooseTimeRange={this.handleChooseTimeRange}
handleChooseAutoRefresh={handleChooseAutoRefresh}
handleClickPresentationButton={handleClickPresentationButton}
source={source}
>
{Object.keys(hosts).map((host, i) => {
return (
@ -181,11 +198,13 @@ export const HostPage = React.createClass({
},
});
const mapStateToProps = (state) => ({
inPresentationMode: state.appUI.presentationMode,
const mapStateToProps = ({app: {ephemeral: {inPresentationMode}, persisted: {autoRefresh}}}) => ({
inPresentationMode,
autoRefresh,
})
const mapDispatchToProps = (dispatch) => ({
handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch),
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
})

View File

@ -1,6 +1,7 @@
import React, {PropTypes} from 'react';
import _ from 'lodash';
import HostsTable from '../components/HostsTable';
import SourceIndicator from '../../shared/components/SourceIndicator'
import {getCpuAndLoadForHosts, getMappings, getAppsForHosts} from '../apis';
export const HostsPage = React.createClass({
@ -44,6 +45,7 @@ export const HostsPage = React.createClass({
},
render() {
const {source} = this.props;
return (
<div className="page">
<div className="page-header">
@ -53,13 +55,16 @@ export const HostsPage = React.createClass({
Host List
</h1>
</div>
<div className="page-header__right">
<SourceIndicator sourceName={source.name} />
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid">
<div className="row">
<div className="col-md-12">
<HostsTable source={this.props.source} hosts={_.values(this.state.hosts)} up={this.state.up} />
<HostsTable source={source} hosts={_.values(this.state.hosts)} up={this.state.up} />
</div>
</div>
</div>

View File

@ -20,7 +20,7 @@ import configureStore from 'src/store/configureStore';
import {getMe, getSources} from 'shared/apis';
import {receiveMe} from 'shared/actions/me';
import {receiveAuth} from 'shared/actions/auth';
import {disablePresentationMode} from 'shared/actions/ui';
import {disablePresentationMode} from 'shared/actions/app';
import {loadLocalStorage} from './localStorage';
import 'src/style/chronograf.scss';

View File

@ -163,12 +163,11 @@ export function deleteRule(rule) {
};
}
export function updateRuleStatus(rule, {status}) {
export function updateRuleStatus(rule, status) {
return (dispatch) => {
updateRuleStatusAPI(rule, status).then(() => {
dispatch(publishNotification('success', `${rule.name} ${status} successfully`));
}).catch(() => {
dispatch(updateRuleStatusSuccess(rule.id, status));
dispatch(publishNotification('error', `${rule.name} could not be ${status}`));
});
};

View File

@ -105,7 +105,7 @@ const AlertOutputs = React.createClass({
return (
<div className="panel panel-minimal">
<div className="panel-body">
<h4 className="text-center">Configure Alert Endpoints</h4>
<h4 className="text-center no-user-select">Configure Alert Endpoints</h4>
<br/>
<div className="row">
<div className="form-group col-xs-12 col-sm-8 col-sm-offset-2">

View File

@ -31,10 +31,10 @@ const AlertaConfig = React.createClass({
return (
<div className="col-xs-12">
<h4 className="text-center">Alerta Alert</h4>
<h4 className="text-center no-user-select">Alerta Alert</h4>
<br/>
<form onSubmit={this.handleSaveAlert}>
<p>
<p className="no-user-select">
Have alerts sent to Alerta
</p>

View File

@ -30,9 +30,9 @@ const HipchatConfig = React.createClass({
return (
<div>
<h4 className="text-center">HipChat Alert</h4>
<h4 className="text-center no-user-select">HipChat Alert</h4>
<br/>
<p>Have alerts sent to HipChat.</p>
<p className="no-user-select">Have alerts sent to HipChat.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="url">HipChat URL</label>

View File

@ -45,13 +45,13 @@ const KapacitorForm = React.createClass({
<div className="col-md-8 col-md-offset-2">
<div className="panel panel-minimal">
<div className="panel-body">
<p>
<p className="no-user-select">
Kapacitor is used as the monitoring and alerting agent.
This page will let you configure which Kapacitor to use and
set up alert end points like email, Slack, and others.
</p>
<hr/>
<h4 className="text-center">Connect Kapacitor to Source</h4>
<h4 className="text-center no-user-select">Connect Kapacitor to Source</h4>
<h4 className="text-center">{source.url}</h4>
<br/>
<form onSubmit={onSubmit}>

View File

@ -47,6 +47,7 @@ export const KapacitorRule = React.createClass({
onChooseTimeRange={this.handleChooseTimeRange}
validationError={this.validationError()}
timeRange={timeRange}
source={source}
/>
<div className="page-contents page-contents--green-scrollbar">
<div className="container-fluid">

View File

@ -0,0 +1,96 @@
import React, {PropTypes} from 'react'
import {Link} from 'react-router'
import NoKapacitorError from '../../shared/components/NoKapacitorError'
import SourceIndicator from '../../shared/components/SourceIndicator'
import KapacitorRulesTable from 'src/kapacitor/components/KapacitorRulesTable'
const KapacitorRules = ({
source,
rules,
hasKapacitor,
loading,
onDelete,
onChangeRuleStatus,
}) => {
if (!hasKapacitor) {
return (
<PageContents>
<NoKapacitorError source={source} />
</PageContents>
)
}
if (loading) {
return (
<PageContents>
<h2>Loading...</h2>
</PageContents>
)
}
return (
<PageContents source={source} >
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">Alert Rules</h2>
<Link to={`/sources/${source.id}/alert-rules/new`} className="btn btn-sm btn-primary">Create New Rule</Link>
</div>
<KapacitorRulesTable
source={source}
rules={rules}
onDelete={onDelete}
onChangeRuleStatus={onChangeRuleStatus}
/>
</PageContents>
)
}
const PageContents = ({children, source}) => (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1>Kapacitor Rules</h1>
</div>
<div className="page-header__right">
<SourceIndicator sourceName={source && source.name} />
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid">
<div className="row">
<div className="col-md-12">
<div className="panel panel-minimal">
{children}
</div>
</div>
</div>
</div>
</div>
</div>
)
const {
arrayOf,
bool,
func,
shape,
node,
} = PropTypes
KapacitorRules.propTypes = {
source: shape(),
rules: arrayOf(shape()),
hasKapacitor: bool,
loading: bool,
onChangeRuleStatus: func,
onDelete: func,
}
PageContents.propTypes = {
children: node,
source: shape(),
}
export default KapacitorRules

View File

@ -0,0 +1,74 @@
import React, {PropTypes} from 'react';
import {Link} from 'react-router';
const KapacitorRulesTable = ({source, rules, onDelete, onChangeRuleStatus}) => {
return (
<div className="panel-body">
<table className="table v-center">
<thead>
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Message</th>
<th>Alerts</th>
<th className="text-center">Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{
rules.map((rule) => {
return <RuleRow key={rule.id} rule={rule} source={source} onDelete={onDelete} onChangeRuleStatus={onChangeRuleStatus} />
})
}
</tbody>
</table>
</div>
)
}
const RuleRow = ({rule, source, onDelete, onChangeRuleStatus}) => {
return (
<tr key={rule.id}>
<td className="monotype"><Link to={`/sources/${source.id}/alert-rules/${rule.id}`}>{rule.name}</Link></td>
<td className="monotype">{rule.trigger}</td>
<td className="monotype">{rule.message}</td>
<td className="monotype">{rule.alerts.join(', ')}</td>
<td className="monotype text-center">
<div className="dark-checkbox">
<input
id={`kapacitor-enabled ${rule.id}`}
className="form-control-static"
type="checkbox"
defaultChecked={rule.status === "enabled"}
onClick={() => onChangeRuleStatus(rule)}
/>
<label htmlFor={`kapacitor-enabled ${rule.id}`}></label>
</div>
</td>
<td className="text-right"><button className="btn btn-danger btn-xs" onClick={() => onDelete(rule)}>Delete</button></td>
</tr>
)
}
const {
arrayOf,
func,
shape,
} = PropTypes
KapacitorRulesTable.propTypes = {
source: shape(),
rules: arrayOf(shape()),
onChangeRuleStatus: func,
onDelete: func,
}
RuleRow.propTypes = {
rule: shape(),
source: shape(),
onChangeRuleStatus: func,
onDelete: func,
}
export default KapacitorRulesTable

View File

@ -65,9 +65,9 @@ const OpsGenieConfig = React.createClass({
return (
<div>
<h4 className="text-center">OpsGenie Alert</h4>
<h4 className="text-center no-user-select">OpsGenie Alert</h4>
<br/>
<p>Have alerts sent to OpsGenie.</p>
<p className="no-user-select">Have alerts sent to OpsGenie.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="api-key">API Key</label>

View File

@ -29,9 +29,9 @@ const PagerDutyConfig = React.createClass({
return (
<div>
<h4 className="text-center">PagerDuty Alert</h4>
<h4 className="text-center no-user-select">PagerDuty Alert</h4>
<br/>
<p>You can have alerts sent to PagerDuty by entering info below.</p>
<p className="no-user-select">You can have alerts sent to PagerDuty by entering info below.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="service-key">Service Key</label>

View File

@ -1,9 +1,11 @@
import React, {PropTypes} from 'react';
import ReactTooltip from 'react-tooltip';
import TimeRangeDropdown from '../../shared/components/TimeRangeDropdown';
import SourceIndicator from '../../shared/components/SourceIndicator';
export const RuleHeader = React.createClass({
propTypes: {
source: PropTypes.shape({}).isRequired,
onSave: PropTypes.func.isRequired,
rule: PropTypes.shape({}).isRequired,
actions: PropTypes.shape({
@ -56,15 +58,16 @@ export const RuleHeader = React.createClass({
},
renderSave() {
const {validationError, onSave, timeRange, onChooseTimeRange} = this.props;
const {validationError, onSave, timeRange, onChooseTimeRange, source} = this.props;
const saveButton = validationError ?
<button className="btn btn-sm btn-default disabled" data-for="save-kapacitor-tooltip" data-tip={validationError}>
<button className="btn btn-success btn-sm disabled" data-for="save-kapacitor-tooltip" data-tip={validationError}>
Save Rule
</button> :
<button className="btn btn-success btn-sm" onClick={onSave}>Save Rule</button>;
return (
<div className="page-header__right">
<SourceIndicator sourceName={source.name} />
<TimeRangeDropdown onChooseTimeRange={onChooseTimeRange} selected={timeRange.inputValue} />
{saveButton}
<ReactTooltip id="save-kapacitor-tooltip" effect="solid" html={true} offset={{top: 2}} place="bottom" class="influx-tooltip kapacitor-tooltip place-bottom" />

View File

@ -33,9 +33,9 @@ const SMTPConfig = React.createClass({
return (
<div>
<h4 className="text-center">SMTP Alert</h4>
<h4 className="text-center no-user-select">SMTP Alert</h4>
<br/>
<p>You can have alerts sent to an email address by setting up an SMTP endpoint.</p>
<p className="no-user-select">You can have alerts sent to an email address by setting up an SMTP endpoint.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12 col-md-6">
<label htmlFor="smtp-host">SMTP Host</label>

View File

@ -27,9 +27,9 @@ const SensuConfig = React.createClass({
return (
<div>
<h4 className="text-center">Sensu Alert</h4>
<h4 className="text-center no-user-select">Sensu Alert</h4>
<br/>
<p>Have alerts sent to Sensu.</p>
<p className="no-user-select">Have alerts sent to Sensu.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12 col-md-6">
<label htmlFor="source">Source</label>

View File

@ -48,9 +48,9 @@ const SlackConfig = React.createClass({
return (
<div>
<h4 className="text-center">Slack Alert</h4>
<h4 className="text-center no-user-select">Slack Alert</h4>
<br/>
<p>Post alerts to a Slack channel.</p>
<p className="no-user-select">Post alerts to a Slack channel.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="slack-url">Slack Webhook URL (<a href="https://api.slack.com/incoming-webhooks" target="_">see more on Slack webhooks</a>)</label>

View File

@ -34,9 +34,9 @@ const TalkConfig = React.createClass({
return (
<div>
<h4 className="text-center">Talk Alert</h4>
<h4 className="text-center no-user-select">Talk Alert</h4>
<br/>
<p>Have alerts sent to Talk.</p>
<p className="no-user-select">Have alerts sent to Talk.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="url">URL</label>

View File

@ -48,9 +48,9 @@ const TelegramConfig = React.createClass({
return (
<div>
<h4 className="text-center">Telegram Alert</h4>
<h4 className="text-center no-user-select">Telegram Alert</h4>
<br/>
<p>You can have alerts sent to Telegram by entering info below.</p>
<p className="no-user-select">You can have alerts sent to Telegram by entering info below.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="url">Telegram URL</label>

View File

@ -32,9 +32,9 @@ const VictorOpsConfig = React.createClass({
return (
<div>
<h4 className="text-center">VictorOps Alert</h4>
<h4 className="text-center no-user-select">VictorOps Alert</h4>
<br/>
<p>Have alerts sent to VictorOps.</p>
<p className="no-user-select">Have alerts sent to VictorOps.</p>
<form onSubmit={this.handleSaveAlert}>
<div className="form-group col-xs-12">
<label htmlFor="api-key">API Key</label>

View File

@ -1,20 +1,69 @@
import React, {PropTypes} from 'react';
import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import {Link} from 'react-router';
import {getKapacitor} from 'src/shared/apis';
import * as kapacitorActionCreators from '../actions/view';
import NoKapacitorError from '../../shared/components/NoKapacitorError';
import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {getKapacitor} from 'src/shared/apis'
import * as kapacitorActionCreators from '../actions/view'
import KapacitorRules from 'src/kapacitor/components/KapacitorRules'
class KapacitorRulesPage extends Component {
constructor(props) {
super(props);
this.state = {
hasKapacitor: false,
loading: true,
}
this.handleDeleteRule = ::this.handleDeleteRule
this.handleRuleStatus = ::this.handleRuleStatus
}
componentDidMount() {
getKapacitor(this.props.source).then((kapacitor) => {
if (kapacitor) {
this.props.actions.fetchRules(kapacitor);
}
this.setState({loading: false, hasKapacitor: !!kapacitor});
});
}
handleDeleteRule(rule) {
const {actions} = this.props
actions.deleteRule(rule)
}
handleRuleStatus(rule) {
const {actions} = this.props
const status = rule.status === 'enabled' ? 'disabled' : 'enabled'
actions.updateRuleStatus(rule, status)
actions.updateRuleStatusSuccess(rule.id, status)
}
render() {
const {source, rules} = this.props
const {hasKapacitor, loading} = this.state
return (
<KapacitorRules
source={source}
rules={rules}
hasKapacitor={hasKapacitor}
loading={loading}
onDelete={this.handleDeleteRule}
onChangeRuleStatus={this.handleRuleStatus}
/>
)
}
}
const {
arrayOf,
func,
shape,
string,
} = PropTypes;
} = PropTypes
export const KapacitorRulesPage = React.createClass({
propTypes: {
KapacitorRulesPage.propTypes = {
source: shape({
id: string.isRequired,
links: shape({
@ -35,147 +84,18 @@ export const KapacitorRulesPage = React.createClass({
updateRuleStatus: func.isRequired,
}).isRequired,
addFlashMessage: func,
},
}
getInitialState() {
return {
hasKapacitor: false,
loading: true,
};
},
componentDidMount() {
getKapacitor(this.props.source).then((kapacitor) => {
if (kapacitor) {
this.props.actions.fetchRules(kapacitor);
}
this.setState({loading: false, hasKapacitor: !!kapacitor});
});
},
handleDeleteRule(rule) {
const {actions} = this.props;
actions.deleteRule(rule);
},
handleRuleStatus(e, rule) {
const {actions} = this.props;
const status = e.target.checked ? 'enabled' : 'disabled';
actions.updateRuleStatusSuccess(rule.id, status);
actions.updateRuleStatus(rule, {status});
},
renderSubComponent() {
const {source} = this.props;
const {hasKapacitor, loading} = this.state;
let component;
if (loading) {
component = (<p>Loading...</p>);
} else if (hasKapacitor) {
component = (
<div className="panel panel-minimal">
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">Alert Rules</h2>
<Link to={`/sources/${source.id}/alert-rules/new`} className="btn btn-sm btn-primary">Create New Rule</Link>
</div>
<div className="panel-body">
<table className="table v-center">
<thead>
<tr>
<th>Name</th>
<th>Trigger</th>
<th>Message</th>
<th>Alerts</th>
<th className="text-center">Enabled</th>
<th></th>
</tr>
</thead>
<tbody>
{this.renderAlertsTableRows()}
</tbody>
</table>
</div>
</div>
);
} else {
component = <NoKapacitorError source={source} />;
}
return component;
},
render() {
return (
<div className="page">
<div className="page-header">
<div className="page-header__container">
<div className="page-header__left">
<h1>Kapacitor Rules</h1>
</div>
</div>
</div>
<div className="page-contents">
<div className="container-fluid">
{this.renderSubComponent()}
</div>
</div>
</div>
);
},
renderAlertsTableRows() {
const {rules, source} = this.props;
const numRules = rules.length;
if (numRules === 0) {
return (
<tr className="table-empty-state">
<th colSpan="5">
<p>You don&#39;t have any Kapacitor<br/>Rules, why not create one?</p>
<Link to={`/sources/${source.id}/alert-rules/new`} className="btn btn-primary">Create New Rule</Link>
</th>
</tr>
);
}
return rules.map((rule) => {
return (
<tr key={rule.id}>
<td className="monotype"><Link to={`/sources/${source.id}/alert-rules/${rule.id}`}>{rule.name}</Link></td>
<td className="monotype">{rule.trigger}</td>
<td className="monotype">{rule.message}</td>
<td className="monotype">{rule.alerts.join(', ')}</td>
<td className="monotype text-center">
<div className="dark-checkbox">
<input
id="kapacitor-enabled"
className="form-control-static"
type="checkbox"
ref={(r) => this.enabled = r}
defaultChecked={rule.status === "enabled"}
onClick={(e) => this.handleRuleStatus(e, rule)}
/>
<label htmlFor="kapacitor-enabled"></label>
</div>
</td>
<td className="text-right"><button className="btn btn-danger btn-xs" onClick={() => this.handleDeleteRule(rule)}>Delete</button></td>
</tr>
);
});
},
});
function mapStateToProps(state) {
const mapStateToProps = (state) => {
return {
rules: Object.values(state.rules),
};
}
}
function mapDispatchToProps(dispatch) {
const mapDispatchToProps = (dispatch) => {
return {
actions: bindActionCreators(kapacitorActionCreators, dispatch),
};
}
}
export default connect(mapStateToProps, mapDispatchToProps)(KapacitorRulesPage);
export default connect(mapStateToProps, mapDispatchToProps)(KapacitorRulesPage)

View File

@ -6,11 +6,12 @@ import DashboardHeader from 'src/dashboards/components/DashboardHeader';
import timeRanges from 'hson!../../shared/data/timeRanges.hson';
const {
shape,
string,
arrayOf,
bool,
func,
number,
shape,
string,
} = PropTypes
export const KubernetesDashboard = React.createClass({
@ -22,6 +23,8 @@ export const KubernetesDashboard = React.createClass({
telegraf: string.isRequired,
}),
layouts: arrayOf(shape().isRequired).isRequired,
autoRefresh: number.isRequired,
handleChooseAutoRefresh: func.isRequired,
inPresentationMode: bool.isRequired,
handleClickPresentationButton: func,
},
@ -34,9 +37,8 @@ export const KubernetesDashboard = React.createClass({
},
renderLayouts(layouts) {
const autoRefreshMs = 15000;
const {timeRange} = this.state;
const {source} = this.props;
const {source, autoRefresh} = this.props;
let layoutCells = [];
layouts.forEach((layout) => {
@ -56,7 +58,7 @@ export const KubernetesDashboard = React.createClass({
<LayoutRenderer
timeRange={timeRange}
cells={layoutCells}
autoRefreshMs={autoRefreshMs}
autoRefresh={autoRefresh}
source={source.links.proxy}
/>
);
@ -68,7 +70,7 @@ export const KubernetesDashboard = React.createClass({
},
render() {
const {layouts, inPresentationMode, handleClickPresentationButton} = this.props;
const {layouts, autoRefresh, handleChooseAutoRefresh, inPresentationMode, handleClickPresentationButton, source} = this.props;
const {timeRange} = this.state;
const emptyState = (
<div className="generic-empty-state">
@ -81,10 +83,13 @@ export const KubernetesDashboard = React.createClass({
<div className="page">
<DashboardHeader
headerText="Kubernetes Dashboard"
autoRefresh={autoRefresh}
handleChooseAutoRefresh={handleChooseAutoRefresh}
timeRange={timeRange}
handleChooseTimeRange={this.handleChooseTimeRange}
isHidden={inPresentationMode}
handleClickPresentationButton={handleClickPresentationButton}
source={source}
/>
<div className={classnames({
'page-contents': true,

View File

@ -1,15 +1,19 @@
import React, {PropTypes} from 'react';
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import {fetchLayouts} from 'shared/apis';
import KubernetesDashboard from 'src/kubernetes/components/KubernetesDashboard';
import {setAutoRefresh} from 'shared/actions/app'
import {presentationButtonDispatcher} from 'shared/dispatchers'
const {
shape,
string,
bool,
func,
number,
shape,
string,
} = PropTypes
export const KubernetesPage = React.createClass({
@ -19,6 +23,8 @@ export const KubernetesPage = React.createClass({
proxy: string.isRequired,
}).isRequired,
}),
autoRefresh: number.isRequired,
handleChooseAutoRefresh: func.isRequired,
inPresentationMode: bool.isRequired,
handleClickPresentationButton: func,
},
@ -38,12 +44,14 @@ export const KubernetesPage = React.createClass({
render() {
const {layouts} = this.state
const {source, inPresentationMode, handleClickPresentationButton} = this.props
const {source, autoRefresh, handleChooseAutoRefresh, inPresentationMode, handleClickPresentationButton} = this.props
return (
<KubernetesDashboard
layouts={layouts}
source={source}
autoRefresh={autoRefresh}
handleChooseAutoRefresh={handleChooseAutoRefresh}
inPresentationMode={inPresentationMode}
handleClickPresentationButton={handleClickPresentationButton}
/>
@ -51,11 +59,13 @@ export const KubernetesPage = React.createClass({
},
});
const mapStateToProps = (state) => ({
inPresentationMode: state.appUI.presentationMode,
const mapStateToProps = ({app: {ephemeral: {inPresentationMode}, persisted: {autoRefresh}}}) => ({
inPresentationMode,
autoRefresh,
})
const mapDispatchToProps = (dispatch) => ({
handleChooseAutoRefresh: bindActionCreators(setAutoRefresh, dispatch),
handleClickPresentationButton: presentationButtonDispatcher(dispatch),
})

View File

@ -9,9 +9,12 @@ export const loadLocalStorage = () => {
}
};
export const saveToLocalStorage = ({queryConfigs, timeRange, dataExplorer}) => {
export const saveToLocalStorage = ({app: {persisted}, queryConfigs, timeRange, dataExplorer}) => {
try {
const appPersisted = Object.assign({}, {app: {persisted}})
window.localStorage.setItem('state', JSON.stringify({
...appPersisted,
queryConfigs,
timeRange,
dataExplorer,

View File

@ -0,0 +1,22 @@
import {PRESENTATION_MODE_ANIMATION_DELAY} from '../constants'
// ephemeral state action creators
export const enablePresentationMode = () => ({
type: 'ENABLE_PRESENTATION_MODE',
})
export const disablePresentationMode = () => ({
type: 'DISABLE_PRESENTATION_MODE',
})
export const delayEnablePresentationMode = () => (dispatch) => {
setTimeout(() => dispatch(enablePresentationMode()), PRESENTATION_MODE_ANIMATION_DELAY)
}
// persistent state action creators
export const setAutoRefresh = (milliseconds) => ({
type: 'SET_AUTOREFRESH',
payload: {
milliseconds,
},
})

View File

@ -1,19 +0,0 @@
import {PRESENTATION_MODE_ANIMATION_DELAY} from '../constants'
export function enablePresentationMode() {
return {
type: 'ENABLE_PRESENTATION_MODE',
}
}
export function disablePresentationMode() {
return {
type: 'DISABLE_PRESENTATION_MODE',
}
}
export function delayEnablePresentationMode() {
return (dispatch) => {
setTimeout(() => dispatch(enablePresentationMode()), PRESENTATION_MODE_ANIMATION_DELAY)
}
}

View File

@ -6,25 +6,37 @@ function _fetchTimeSeries(source, db, rp, query) {
return proxy({source, db, rp, query});
}
const {
element,
number,
arrayOf,
shape,
oneOfType,
string,
} = PropTypes
export default function AutoRefresh(ComposedComponent) {
const wrapper = React.createClass({
displayName: `AutoRefresh_${ComposedComponent.displayName}`,
propTypes: {
children: PropTypes.element,
autoRefresh: PropTypes.number,
queries: PropTypes.arrayOf(PropTypes.shape({
host: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]),
text: PropTypes.string,
children: element,
autoRefresh: number.isRequired,
queries: arrayOf(shape({
host: oneOfType([string, arrayOf(string)]),
text: string,
}).isRequired).isRequired,
},
getInitialState() {
return {timeSeries: []};
return {
lastQuerySuccessful: false,
timeSeries: [],
};
},
componentDidMount() {
const {queries} = this.props;
const {queries, autoRefresh} = this.props;
this.executeQueries(queries);
if (this.props.autoRefresh) {
this.intervalID = setInterval(() => this.executeQueries(queries), this.props.autoRefresh);
if (autoRefresh) {
this.intervalID = setInterval(() => this.executeQueries(queries), autoRefresh);
}
},
componentWillReceiveProps(nextProps) {
@ -63,7 +75,9 @@ export default function AutoRefresh(ComposedComponent) {
newSeries.push({response: resp.data});
count += 1;
if (count === queries.length) {
const querySuccessful = !this._noResultsForQuery(newSeries);
this.setState({
lastQuerySuccessful: querySuccessful,
isFetching: false,
timeSeries: newSeries,
});
@ -77,11 +91,11 @@ export default function AutoRefresh(ComposedComponent) {
render() {
const {timeSeries} = this.state;
if (this.state.isFetching) {
if (this.state.isFetching && this.state.lastQuerySuccessful) {
return this.renderFetching(timeSeries);
}
if (this._noResultsForQuery(timeSeries)) {
if (this._noResultsForQuery(timeSeries) || !this.state.lastQuerySuccessful) {
return this.renderNoResults();
}

View File

@ -0,0 +1,73 @@
import React, {PropTypes} from 'react';
import classnames from 'classnames';
import OnClickOutside from 'shared/components/OnClickOutside';
import autoRefreshItems from 'hson!../data/autoRefreshes.hson';
const {
number,
func,
} = PropTypes
const AutoRefreshDropdown = React.createClass({
autobind: false,
propTypes: {
selected: number.isRequired,
onChoose: func.isRequired,
},
getInitialState() {
return {
isOpen: false,
};
},
findAutoRefreshItem(milliseconds) {
return autoRefreshItems.find((values) => values.milliseconds === milliseconds)
},
handleClickOutside() {
this.setState({isOpen: false});
},
handleSelection(milliseconds) {
this.props.onChoose(milliseconds);
this.setState({isOpen: false});
},
toggleMenu() {
this.setState({isOpen: !this.state.isOpen});
},
render() {
const self = this;
const {selected} = self.props;
const {isOpen} = self.state;
const {milliseconds, inputValue} = this.findAutoRefreshItem(selected)
return (
<div className="dropdown time-range-dropdown">
<div className="btn btn-sm btn-info dropdown-toggle" onClick={() => self.toggleMenu()}>
<span className={classnames("icon", +milliseconds > 0 ? "refresh" : "pause")}></span>
<span className="selected-time-range">{inputValue}</span>
<span className="caret" />
</div>
<ul className={classnames("dropdown-menu", {show: isOpen})}>
<li className="dropdown-header">AutoRefresh Interval</li>
{autoRefreshItems.map((item) => {
return (
<li key={item.menuOption}>
<a href="#" onClick={() => self.handleSelection(item.milliseconds)}>
{item.menuOption}
</a>
</li>
);
})}
</ul>
</div>
);
},
});
export default OnClickOutside(AutoRefreshDropdown);

View File

@ -1,14 +1,23 @@
import React, {PropTypes} from 'react';
import classnames from 'classnames';
import OnClickOutside from 'shared/components/OnClickOutside';
const {
arrayOf,
shape,
string,
func,
} = PropTypes
const Dropdown = React.createClass({
propTypes: {
items: PropTypes.arrayOf(PropTypes.shape({
text: PropTypes.string.isRequired,
items: arrayOf(shape({
text: string.isRequired,
})).isRequired,
onChoose: PropTypes.func.isRequired,
selected: PropTypes.string.isRequired,
className: PropTypes.string,
onChoose: func.isRequired,
selected: string.isRequired,
iconName: string,
className: string,
},
getInitialState() {
return {
@ -39,11 +48,12 @@ const Dropdown = React.createClass({
},
render() {
const self = this;
const {items, selected, className, actions} = self.props;
const {items, selected, className, iconName, actions} = self.props;
return (
<div onClick={this.toggleMenu} className={`dropdown ${className}`}>
<div className="btn btn-sm btn-info dropdown-toggle">
{iconName ? <span className={classnames("icon", {[iconName]: true})}></span> : null}
<span className="dropdown-selected">{selected}</span>
<span className="caret" />
</div>

View File

@ -18,6 +18,7 @@ const {
export const LayoutRenderer = React.createClass({
propTypes: {
autoRefresh: number.isRequired,
timeRange: shape({
defaultGroupBy: string.isRequired,
queryValue: string.isRequired,
@ -46,7 +47,6 @@ export const LayoutRenderer = React.createClass({
name: string.isRequired,
}).isRequired
),
autoRefreshMs: number.isRequired,
host: string,
source: string,
onPositionChange: func,
@ -84,7 +84,7 @@ export const LayoutRenderer = React.createClass({
},
generateVisualizations() {
const {autoRefreshMs, source, cells} = this.props;
const {autoRefresh, source, cells} = this.props;
return cells.map((cell) => {
const qs = cell.queries.map((q) => {
@ -100,7 +100,7 @@ export const LayoutRenderer = React.createClass({
<div key={cell.i}>
<h2 className="dash-graph--heading">{cell.name || `Graph`}</h2>
<div className="dash-graph--container">
<RefreshingSingleStat queries={[qs[0]]} autoRefresh={autoRefreshMs} />
<RefreshingSingleStat queries={[qs[0]]} autoRefresh={autoRefresh} />
</div>
</div>
);
@ -117,7 +117,7 @@ export const LayoutRenderer = React.createClass({
<div className="dash-graph--container">
<RefreshingLineGraph
queries={qs}
autoRefresh={autoRefreshMs}
autoRefresh={autoRefresh}
showSingleStat={cell.type === "line-plus-single-stat"}
displayOptions={displayOptions}
/>

View File

@ -1,5 +1,5 @@
import React, {PropTypes} from 'react';
import {Link} from 'react-router';
import React, {PropTypes} from 'react'
import {Link} from 'react-router'
const NoKapacitorError = React.createClass({
propTypes: {
@ -9,14 +9,14 @@ const NoKapacitorError = React.createClass({
},
render() {
const path = `/sources/${this.props.source.id}/kapacitor-config`;
const path = `/sources/${this.props.source.id}/kapacitor-config`
return (
<div>
<p>The current source does not have an associated Kapacitor instance, please configure one.</p>
<Link to={path}>Add Kapacitor</Link>
</div>
);
)
},
});
})
export default NoKapacitorError;
export default NoKapacitorError

View File

@ -0,0 +1,22 @@
import React, {PropTypes} from 'react';
const SourceIndicator = React.createClass({
propTypes: {
sourceName: PropTypes.string,
},
render() {
const {sourceName} = this.props;
if (!sourceName) {
return null;
}
return (
<div className="source-indicator">
<span className="icon server"></span>
{sourceName}
</div>
);
},
});
export default SourceIndicator;

View File

@ -1,5 +1,5 @@
import React from 'react';
import cN from 'classnames';
import classnames from 'classnames';
import OnClickOutside from 'shared/components/OnClickOutside';
import timeRanges from 'hson!../data/timeRanges.hson';
@ -48,7 +48,7 @@ const TimeRangeDropdown = React.createClass({
<span className="selected-time-range">{selected}</span>
<span className="caret" />
</div>
<ul className={cN("dropdown-menu", {show: isOpen})}>
<ul className={classnames("dropdown-menu", {show: isOpen})}>
<li className="dropdown-header">Time Range</li>
{timeRanges.map((item) => {
return (

View File

@ -472,3 +472,5 @@ export const PRESENTATION_MODE_ANIMATION_DELAY = 0 // In milliseconds.
export const PRESENTATION_MODE_NOTIFICATION_DELAY = 2000 // In milliseconds.
export const RES_UNAUTHORIZED = 401
export const AUTOREFRESH_DEFAULT = 15000 // in milliseconds

View File

@ -0,0 +1,8 @@
[
{milliseconds: 0, inputValue: 'Paused', menuOption: 'Paused'},
{milliseconds: 5000, inputValue: 'Every 5 seconds', menuOption: 'Every 5 seconds'},
{milliseconds: 10000, inputValue: 'Every 10 seconds', menuOption: 'Every 10 seconds'},
{milliseconds: 15000, inputValue: 'Every 15 seconds', menuOption: 'Every 15 seconds'},
{milliseconds: 30000, inputValue: 'Every 30 seconds', menuOption: 'Every 30 seconds'},
{milliseconds: 60000, inputValue: 'Every 60 seconds', menuOption: 'Every 60 seconds'}
]

View File

@ -1,4 +1,4 @@
import {delayEnablePresentationMode} from 'shared/actions/ui'
import {delayEnablePresentationMode} from 'shared/actions/app'
import {publishNotification, delayDismissNotification} from 'shared/actions/notifications'
import {PRESENTATION_MODE_NOTIFICATION_DELAY} from 'shared/constants'

View File

@ -0,0 +1,57 @@
import {combineReducers} from 'redux';
import {AUTOREFRESH_DEFAULT} from 'src/shared/constants'
const initialState = {
ephemeral: {
inPresentationMode: false,
},
persisted: {
autoRefresh: AUTOREFRESH_DEFAULT,
},
}
const {
ephemeral: initialEphemeralState,
persisted: initialPersistedState,
} = initialState
const ephemeralReducer = (state = initialEphemeralState, action) => {
switch (action.type) {
case 'ENABLE_PRESENTATION_MODE': {
return {
...state,
inPresentationMode: true,
}
}
case 'DISABLE_PRESENTATION_MODE': {
return {
...state,
inPresentationMode: false,
}
}
default:
return state
}
}
const persistedReducer = (state = initialPersistedState, action) => {
switch (action.type) {
case 'SET_AUTOREFRESH': {
return {
...state,
autoRefresh: action.payload.milliseconds,
}
}
default:
return state
}
}
export default combineReducers({
ephemeral: ephemeralReducer,
persisted: persistedReducer,
})

View File

@ -1,13 +1,13 @@
import appUI from './ui';
import me from './me';
import app from './app';
import auth from './auth';
import notifications from './notifications';
import sources from './sources';
export {
appUI,
export default {
me,
app,
auth,
notifications,
sources,
};
}

View File

@ -1,23 +0,0 @@
const initialState = {
presentationMode: false,
};
export default function ui(state = initialState, action) {
switch (action.type) {
case 'ENABLE_PRESENTATION_MODE': {
return {
...state,
presentationMode: true,
}
}
case 'DISABLE_PRESENTATION_MODE': {
return {
...state,
presentationMode: false,
}
}
}
return state
}

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