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.scsspull/993/head
commit
4e2617ea24
|
@ -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
|
||||
|
|
5
LICENSE
5
LICENSE
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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": []
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
586
server/admin.go
586
server/admin.go
|
@ -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)
|
||||
}
|
1482
server/admin_test.go
1482
server/admin_test.go
File diff suppressed because it is too large
Load Diff
|
@ -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)
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
370
server/users.go
370
server/users.go
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
})
|
||||
})
|
|
@ -158,7 +158,7 @@ class AdminPage extends Component {
|
|||
<div className="container-fluid">
|
||||
<div className="row">
|
||||
{
|
||||
users.length ?
|
||||
users ?
|
||||
<AdminTabs
|
||||
users={users}
|
||||
roles={roles}
|
||||
|
|
|
@ -17,7 +17,7 @@ const newDefaultRole = {
|
|||
}
|
||||
|
||||
const initialState = {
|
||||
users: [],
|
||||
users: null,
|
||||
roles: [],
|
||||
permissions: [],
|
||||
queries: [],
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -2,8 +2,8 @@ import queryConfigs from './queryConfigs';
|
|||
import timeRange from './timeRange';
|
||||
import dataExplorer from './ui';
|
||||
|
||||
export {
|
||||
export default {
|
||||
queryConfigs,
|
||||
timeRange,
|
||||
dataExplorer,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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}`));
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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" />
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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'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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
})
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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;
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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'}
|
||||
]
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -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,
|
||||
})
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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
Loading…
Reference in New Issue