Merge pull request #1029 from influxdata/feature/db-manager

Feature/db manager
pull/1061/head
Andrew Watkins 2017-03-24 10:57:12 -07:00 committed by GitHub
commit 01ec21b483
30 changed files with 2886 additions and 68 deletions

View File

@ -325,6 +325,36 @@ type UsersStore interface {
Update(context.Context, *User) error Update(context.Context, *User) error
} }
// Database represents a database in a time series source
type Database struct {
Name string `json:"name"` // a unique string identifier for the database
Duration string `json:"duration,omitempty"` // the duration (when creating a default retention policy)
Replication int32 `json:"replication,omitempty"` // the replication factor (when creating a default retention policy)
ShardDuration string `json:"shardDuration,omitempty"` // the shard duration (when creating a default retention policy)
}
// RetentionPolicy represents a retention policy in a time series source
type RetentionPolicy struct {
Name string `json:"name"` // a unique string identifier for the retention policy
Duration string `json:"duration,omitempty"` // the duration
Replication int32 `json:"replication,omitempty"` // the replication factor
ShardDuration string `json:"shardDuration,omitempty"` // the shard duration
Default bool `json:"isDefault,omitempty"` // whether the RP should be the default
}
// Databases represents a databases in a time series source
type Databases interface {
// All lists all databases
AllDB(context.Context) ([]Database, error)
Connect(context.Context, *Source) error
CreateDB(context.Context, *Database) (*Database, error)
DropDB(context.Context, string) error
AllRP(context.Context, string) ([]RetentionPolicy, error)
CreateRP(context.Context, string, *RetentionPolicy) (*RetentionPolicy, error)
UpdateRP(context.Context, string, string, *RetentionPolicy) (*RetentionPolicy, error)
DropRP(context.Context, string, string) error
}
// DashboardID is the dashboard ID // DashboardID is the dashboard ID
type DashboardID int type DashboardID int

204
influx/databases.go Normal file
View File

@ -0,0 +1,204 @@
package influx
import (
"bytes"
"context"
"encoding/json"
"fmt"
"github.com/influxdata/chronograf"
)
// AllDB returns all databases from within Influx
func (c *Client) AllDB(ctx context.Context) ([]chronograf.Database, error) {
databases, err := c.showDatabases(ctx)
if err != nil {
return nil, err
}
return databases, nil
}
// CreateDB creates a database within Influx
func (c *Client) CreateDB(ctx context.Context, db *chronograf.Database) (*chronograf.Database, error) {
_, err := c.Query(ctx, chronograf.Query{
Command: fmt.Sprintf(`CREATE DATABASE "%s"`, db.Name),
})
if err != nil {
return nil, err
}
res := &chronograf.Database{Name: db.Name}
return res, nil
}
// DropDB drops a database within Influx
func (c *Client) DropDB(ctx context.Context, database string) error {
_, err := c.Query(ctx, chronograf.Query{
Command: fmt.Sprintf(`DROP DATABASE "%s"`, database),
DB: database,
})
if err != nil {
return err
}
return nil
}
// AllRP returns all the retention policies for a specific database
func (c *Client) AllRP(ctx context.Context, database string) ([]chronograf.RetentionPolicy, error) {
retentionPolicies, err := c.showRetentionPolicies(ctx, database)
if err != nil {
return nil, err
}
return retentionPolicies, nil
}
func (c *Client) getRP(ctx context.Context, db, name string) (chronograf.RetentionPolicy, error) {
rps, err := c.AllRP(ctx, db)
if err != nil {
return chronograf.RetentionPolicy{}, err
}
for _, rp := range rps {
if rp.Name == name {
return rp, nil
}
}
return chronograf.RetentionPolicy{}, fmt.Errorf("unknown retention policy")
}
// CreateRP creates a retention policy for a specific database
func (c *Client) CreateRP(ctx context.Context, database string, rp *chronograf.RetentionPolicy) (*chronograf.RetentionPolicy, error) {
query := fmt.Sprintf(`CREATE RETENTION POLICY "%s" ON "%s" DURATION %s REPLICATION %d`, rp.Name, database, rp.Duration, rp.Replication)
if len(rp.ShardDuration) != 0 {
query = fmt.Sprintf(`%s SHARD DURATION %s`, query, rp.ShardDuration)
}
if rp.Default {
query = fmt.Sprintf(`%s DEFAULT`, query)
}
_, err := c.Query(ctx, chronograf.Query{
Command: query,
DB: database,
})
if err != nil {
return nil, err
}
res, err := c.getRP(ctx, database, rp.Name)
if err != nil {
return nil, err
}
return &res, nil
}
// UpdateRP updates a specific retention policy for a specific database
func (c *Client) UpdateRP(ctx context.Context, database string, name string, rp *chronograf.RetentionPolicy) (*chronograf.RetentionPolicy, error) {
var buffer bytes.Buffer
buffer.WriteString(fmt.Sprintf(`ALTER RETENTION POLICY "%s" ON "%s"`, name, database))
if len(rp.Duration) > 0 {
buffer.WriteString(" DURATION " + rp.Duration)
}
if rp.Replication > 0 {
buffer.WriteString(" REPLICATION " + fmt.Sprint(rp.Replication))
}
if len(rp.ShardDuration) > 0 {
buffer.WriteString(" SHARD DURATION " + rp.ShardDuration)
}
if rp.Default == true {
buffer.WriteString(" DEFAULT")
}
queryRes, err := c.Query(ctx, chronograf.Query{
Command: buffer.String(),
DB: database,
RP: name,
})
if err != nil {
return nil, err
}
// The ALTER RETENTION POLICIES statements puts the error within the results itself
// So, we have to crack open the results to see what happens
octets, err := queryRes.MarshalJSON()
if err != nil {
return nil, err
}
results := make([]struct{ Error string }, 0)
if err := json.Unmarshal(octets, &results); err != nil {
return nil, err
}
// At last, we can check if there are any error strings
for _, r := range results {
if r.Error != "" {
return nil, fmt.Errorf(r.Error)
}
}
res, err := c.getRP(ctx, database, rp.Name)
if err != nil {
return nil, err
}
return &res, nil
}
// DropRP removes a specific retention policy for a specific database
func (c *Client) DropRP(ctx context.Context, database string, rp string) error {
_, err := c.Query(ctx, chronograf.Query{
Command: fmt.Sprintf(`DROP RETENTION POLICY "%s" ON "%s"`, rp, database),
DB: database,
RP: rp,
})
if err != nil {
return err
}
return nil
}
func (c *Client) showDatabases(ctx context.Context) ([]chronograf.Database, error) {
res, err := c.Query(ctx, chronograf.Query{
Command: `SHOW DATABASES`,
})
if err != nil {
return nil, err
}
octets, err := res.MarshalJSON()
if err != nil {
return nil, err
}
results := showResults{}
if err := json.Unmarshal(octets, &results); err != nil {
return nil, err
}
return results.Databases(), nil
}
func (c *Client) showRetentionPolicies(ctx context.Context, name string) ([]chronograf.RetentionPolicy, error) {
retentionPolicies, err := c.Query(ctx, chronograf.Query{
Command: fmt.Sprintf(`SHOW RETENTION POLICIES ON "%s"`, name),
DB: name,
})
if err != nil {
return nil, err
}
octets, err := retentionPolicies.MarshalJSON()
if err != nil {
return nil, err
}
results := showResults{}
if err := json.Unmarshal(octets, &results); err != nil {
return nil, err
}
return results.RetentionPolicies(), nil
}

View File

@ -15,6 +15,7 @@ import (
var _ chronograf.TimeSeries = &Client{} var _ chronograf.TimeSeries = &Client{}
var _ chronograf.TSDBStatus = &Client{} var _ chronograf.TSDBStatus = &Client{}
var _ chronograf.Databases = &Client{}
// Shared transports for all clients to prevent leaking connections // Shared transports for all clients to prevent leaking connections
var ( var (

View File

@ -75,6 +75,55 @@ func (r *showResults) Users() []chronograf.User {
return res return res
} }
// Databases converts SHOW DATABASES to chronograf Databases
func (r *showResults) Databases() []chronograf.Database {
res := []chronograf.Database{}
for _, u := range *r {
for _, s := range u.Series {
for _, v := range s.Values {
if name, ok := v[0].(string); !ok {
continue
} else {
d := chronograf.Database{Name: name}
res = append(res, d)
}
}
}
}
return res
}
func (r *showResults) RetentionPolicies() []chronograf.RetentionPolicy {
res := []chronograf.RetentionPolicy{}
for _, u := range *r {
for _, s := range u.Series {
for _, v := range s.Values {
if name, ok := v[0].(string); !ok {
continue
} else if duration, ok := v[1].(string); !ok {
continue
} else if sduration, ok := v[2].(string); !ok {
continue
} else if replication, ok := v[3].(float64); !ok {
continue
} else if def, ok := v[4].(bool); !ok {
continue
} else {
d := chronograf.RetentionPolicy{
Name: name,
Duration: duration,
ShardDuration: sduration,
Replication: int32(replication),
Default: def,
}
res = append(res, d)
}
}
}
}
return res
}
// Permissions converts SHOW GRANTS to chronograf.Permissions // Permissions converts SHOW GRANTS to chronograf.Permissions
func (r *showResults) Permissions() chronograf.Permissions { func (r *showResults) Permissions() chronograf.Permissions {
res := []chronograf.Permission{} res := []chronograf.Permission{}

View File

@ -4,9 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"strconv"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf" "github.com/influxdata/chronograf"
"github.com/influxdata/chronograf/influx" "github.com/influxdata/chronograf/influx"
"github.com/influxdata/chronograf/uuid" "github.com/influxdata/chronograf/uuid"
@ -160,7 +158,7 @@ func (s *Service) RemoveDashboard(w http.ResponseWriter, r *http.Request) {
// ReplaceDashboard completely replaces a dashboard // ReplaceDashboard completely replaces a dashboard
func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) { func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id")) idParam, err := paramID("id", r)
if err != nil { if err != nil {
msg := fmt.Sprintf("Could not parse dashboard ID: %s", err) msg := fmt.Sprintf("Could not parse dashboard ID: %s", err)
Error(w, http.StatusInternalServerError, msg, s.Logger) Error(w, http.StatusInternalServerError, msg, s.Logger)
@ -198,10 +196,11 @@ func (s *Service) ReplaceDashboard(w http.ResponseWriter, r *http.Request) {
// UpdateDashboard completely updates either the dashboard name or the cells // UpdateDashboard completely updates either the dashboard name or the cells
func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) { func (s *Service) UpdateDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()
idParam, err := strconv.Atoi(httprouter.GetParamFromContext(ctx, "id")) idParam, err := paramID("id", r)
if err != nil { if err != nil {
msg := fmt.Sprintf("Could not parse dashboard ID: %s", err) msg := fmt.Sprintf("Could not parse dashboard ID: %s", err)
Error(w, http.StatusInternalServerError, msg, s.Logger) Error(w, http.StatusInternalServerError, msg, s.Logger)
return
} }
id := chronograf.DashboardID(idParam) id := chronograf.DashboardID(idParam)

411
server/databases.go Normal file
View File

@ -0,0 +1,411 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/bouk/httprouter"
"github.com/influxdata/chronograf"
)
type dbLinks struct {
Self string `json:"self"` // Self link mapping to this resource
RPs string `json:"retentionPolicies"` // URL for retention policies for this database
}
type dbResponse struct {
Name string `json:"name"` // a unique string identifier for the database
Duration string `json:"duration,omitempty"` // the duration (when creating a default retention policy)
Replication int32 `json:"replication,omitempty"` // the replication factor (when creating a default retention policy)
ShardDuration string `json:"shardDuration,omitempty"` // the shard duration (when creating a default retention policy)
RPs []rpResponse `json:"retentionPolicies,omitempty"` // RPs are the retention policies for a database
Links dbLinks `json:"links"` // Links are URI locations related to the database
}
// newDBResponse creates the response for the /databases endpoint
func newDBResponse(srcID int, name string, rps []rpResponse) dbResponse {
base := "/chronograf/v1/sources"
return dbResponse{
Name: name,
RPs: rps,
Links: dbLinks{
Self: fmt.Sprintf("%s/%d/dbs/%s", base, srcID, name),
RPs: fmt.Sprintf("%s/%d/dbs/%s/rps", base, srcID, name),
},
}
}
type dbsResponse struct {
Databases []dbResponse `json:"databases"`
}
type rpLinks struct {
Self string `json:"self"` // Self link mapping to this resource
}
type rpResponse struct {
Name string `json:"name"` // a unique string identifier for the retention policy
Duration string `json:"duration"` // the duration
Replication int32 `json:"replication"` // the replication factor
ShardDuration string `json:"shardDuration"` // the shard duration
Default bool `json:"isDefault"` // whether the RP should be the default
Links rpLinks `json:"links"` // Links are URI locations related to the database
}
// WithLinks adds links to an rpResponse in place
func (r *rpResponse) WithLinks(srcID int, dbName string) {
base := "/chronograf/v1/sources"
r.Links = rpLinks{
Self: fmt.Sprintf("%s/%d/dbs/%s/rps/%s", base, srcID, dbName, r.Name),
}
}
type rpsResponse struct {
RetentionPolicies []rpResponse `json:"retentionPolicies"`
}
// GetDatabases queries the list of all databases for a source
func (h *Service) GetDatabases(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
}
db := h.Databases
if err = db.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
}
databases, err := db.AllDB(ctx)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
dbs := make([]dbResponse, len(databases))
for i, d := range databases {
rps, err := h.allRPs(ctx, db, srcID, d.Name)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
dbs[i] = newDBResponse(srcID, d.Name, rps)
}
res := dbsResponse{
Databases: dbs,
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
// NewDatabase creates a new database within the datastore
func (h *Service) NewDatabase(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
}
db := h.Databases
if err = db.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
}
postedDB := &chronograf.Database{}
if err := json.NewDecoder(r.Body).Decode(postedDB); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := ValidDatabaseRequest(postedDB); err != nil {
invalidData(w, err, h.Logger)
return
}
database, err := db.CreateDB(ctx, postedDB)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
rps, err := h.allRPs(ctx, db, srcID, database.Name)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := newDBResponse(srcID, database.Name, rps)
encodeJSON(w, http.StatusCreated, res, h.Logger)
}
// DropDatabase removes a database from a data source
func (h *Service) DropDatabase(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
}
db := h.Databases
if err = db.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
}
dbID := httprouter.GetParamFromContext(ctx, "dbid")
dropErr := db.DropDB(ctx, dbID)
if dropErr != nil {
Error(w, http.StatusBadRequest, dropErr.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// RetentionPolicies lists retention policies within a database
func (h *Service) RetentionPolicies(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
}
db := h.Databases
if err = db.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
}
dbID := httprouter.GetParamFromContext(ctx, "dbid")
res, err := h.allRPs(ctx, db, srcID, dbID)
if err != nil {
msg := fmt.Sprintf("Unable to connect get RPs %d: %v", srcID, err)
Error(w, http.StatusBadRequest, msg, h.Logger)
return
}
encodeJSON(w, http.StatusOK, res, h.Logger)
}
func (h *Service) allRPs(ctx context.Context, db chronograf.Databases, srcID int, dbID string) ([]rpResponse, error) {
allRP, err := db.AllRP(ctx, dbID)
if err != nil {
return nil, err
}
rps := make([]rpResponse, len(allRP))
for i, rp := range allRP {
rp := rpResponse{
Name: rp.Name,
Duration: rp.Duration,
Replication: rp.Replication,
ShardDuration: rp.ShardDuration,
Default: rp.Default,
}
rp.WithLinks(srcID, dbID)
rps[i] = rp
}
return rps, nil
}
// NewRetentionPolicy creates a new retention policy for a database
func (h *Service) NewRetentionPolicy(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
}
db := h.Databases
if err = db.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
}
postedRP := &chronograf.RetentionPolicy{}
if err := json.NewDecoder(r.Body).Decode(postedRP); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := ValidRetentionPolicyRequest(postedRP); err != nil {
invalidData(w, err, h.Logger)
return
}
dbID := httprouter.GetParamFromContext(ctx, "dbid")
rp, err := db.CreateRP(ctx, dbID, postedRP)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := rpResponse{
Name: rp.Name,
Duration: rp.Duration,
Replication: rp.Replication,
ShardDuration: rp.ShardDuration,
Default: rp.Default,
}
res.WithLinks(srcID, dbID)
encodeJSON(w, http.StatusCreated, res, h.Logger)
}
// UpdateRetentionPolicy modifies an existing retention policy for a database
func (h *Service) UpdateRetentionPolicy(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
}
db := h.Databases
if err = db.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
}
postedRP := &chronograf.RetentionPolicy{}
if err := json.NewDecoder(r.Body).Decode(postedRP); err != nil {
invalidJSON(w, h.Logger)
return
}
if err := ValidRetentionPolicyRequest(postedRP); err != nil {
invalidData(w, err, h.Logger)
return
}
dbID := httprouter.GetParamFromContext(ctx, "dbid")
rpID := httprouter.GetParamFromContext(ctx, "rpid")
rp, err := db.UpdateRP(ctx, dbID, rpID, postedRP)
if err != nil {
Error(w, http.StatusBadRequest, err.Error(), h.Logger)
return
}
res := rpResponse{
Name: rp.Name,
Duration: rp.Duration,
Replication: rp.Replication,
ShardDuration: rp.ShardDuration,
Default: rp.Default,
}
res.WithLinks(srcID, dbID)
encodeJSON(w, http.StatusCreated, res, h.Logger)
}
// DropRetentionPolicy removes a retention policy from a database
func (h *Service) DropRetentionPolicy(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
}
db := h.Databases
if err = db.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
}
dbID := httprouter.GetParamFromContext(ctx, "dbid")
rpID := httprouter.GetParamFromContext(ctx, "rpid")
dropErr := db.DropRP(ctx, dbID, rpID)
if dropErr != nil {
Error(w, http.StatusBadRequest, dropErr.Error(), h.Logger)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ValidDatabaseRequest checks if the database posted is valid
func ValidDatabaseRequest(d *chronograf.Database) error {
if len(d.Name) == 0 {
return fmt.Errorf("name is required")
}
return nil
}
// ValidRetentionPolicyRequest checks if a retention policy is valid on POST
func ValidRetentionPolicyRequest(rp *chronograf.RetentionPolicy) error {
if len(rp.Name) == 0 {
return fmt.Errorf("name is required")
}
if len(rp.Duration) == 0 {
return fmt.Errorf("duration is required")
}
if rp.Replication == 0 {
return fmt.Errorf("replication factor is invalid")
}
return nil
}

349
server/databases_test.go Normal file
View File

@ -0,0 +1,349 @@
package server
import (
"net/http"
"testing"
"github.com/influxdata/chronograf"
)
func TestService_GetDatabases(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
Databases chronograf.Databases
}
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
ServersStore: tt.fields.ServersStore,
LayoutStore: tt.fields.LayoutStore,
AlertRulesStore: tt.fields.AlertRulesStore,
UsersStore: tt.fields.UsersStore,
DashboardsStore: tt.fields.DashboardsStore,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
Databases: tt.fields.Databases,
}
h.GetDatabases(tt.args.w, tt.args.r)
})
}
}
func TestService_NewDatabase(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
Databases chronograf.Databases
}
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
ServersStore: tt.fields.ServersStore,
LayoutStore: tt.fields.LayoutStore,
AlertRulesStore: tt.fields.AlertRulesStore,
UsersStore: tt.fields.UsersStore,
DashboardsStore: tt.fields.DashboardsStore,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
Databases: tt.fields.Databases,
}
h.NewDatabase(tt.args.w, tt.args.r)
})
}
}
func TestService_DropDatabase(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
Databases chronograf.Databases
}
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
ServersStore: tt.fields.ServersStore,
LayoutStore: tt.fields.LayoutStore,
AlertRulesStore: tt.fields.AlertRulesStore,
UsersStore: tt.fields.UsersStore,
DashboardsStore: tt.fields.DashboardsStore,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
Databases: tt.fields.Databases,
}
h.DropDatabase(tt.args.w, tt.args.r)
})
}
}
func TestService_RetentionPolicies(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
Databases chronograf.Databases
}
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
ServersStore: tt.fields.ServersStore,
LayoutStore: tt.fields.LayoutStore,
AlertRulesStore: tt.fields.AlertRulesStore,
UsersStore: tt.fields.UsersStore,
DashboardsStore: tt.fields.DashboardsStore,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
Databases: tt.fields.Databases,
}
h.RetentionPolicies(tt.args.w, tt.args.r)
})
}
}
func TestService_NewRetentionPolicy(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
Databases chronograf.Databases
}
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
ServersStore: tt.fields.ServersStore,
LayoutStore: tt.fields.LayoutStore,
AlertRulesStore: tt.fields.AlertRulesStore,
UsersStore: tt.fields.UsersStore,
DashboardsStore: tt.fields.DashboardsStore,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
Databases: tt.fields.Databases,
}
h.NewRetentionPolicy(tt.args.w, tt.args.r)
})
}
}
func TestService_UpdateRetentionPolicy(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
Databases chronograf.Databases
}
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
ServersStore: tt.fields.ServersStore,
LayoutStore: tt.fields.LayoutStore,
AlertRulesStore: tt.fields.AlertRulesStore,
UsersStore: tt.fields.UsersStore,
DashboardsStore: tt.fields.DashboardsStore,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
Databases: tt.fields.Databases,
}
h.UpdateRetentionPolicy(tt.args.w, tt.args.r)
})
}
}
func TestService_DropRetentionPolicy(t *testing.T) {
type fields struct {
SourcesStore chronograf.SourcesStore
ServersStore chronograf.ServersStore
LayoutStore chronograf.LayoutStore
AlertRulesStore chronograf.AlertRulesStore
UsersStore chronograf.UsersStore
DashboardsStore chronograf.DashboardsStore
TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger
UseAuth bool
Databases chronograf.Databases
}
type args struct {
w http.ResponseWriter
r *http.Request
}
tests := []struct {
name string
fields fields
args args
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := &Service{
SourcesStore: tt.fields.SourcesStore,
ServersStore: tt.fields.ServersStore,
LayoutStore: tt.fields.LayoutStore,
AlertRulesStore: tt.fields.AlertRulesStore,
UsersStore: tt.fields.UsersStore,
DashboardsStore: tt.fields.DashboardsStore,
TimeSeriesClient: tt.fields.TimeSeriesClient,
Logger: tt.fields.Logger,
UseAuth: tt.fields.UseAuth,
Databases: tt.fields.Databases,
}
h.DropRetentionPolicy(tt.args.w, tt.args.r)
})
}
}
func TestValidDatabaseRequest(t *testing.T) {
type args struct {
d *chronograf.Database
}
tests := []struct {
name string
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ValidDatabaseRequest(tt.args.d); (err != nil) != tt.wantErr {
t.Errorf("ValidDatabaseRequest() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}
func TestValidRetentionPolicyRequest(t *testing.T) {
type args struct {
rp *chronograf.RetentionPolicy
}
tests := []struct {
name string
args args
wantErr bool
}{
// TODO: Add test cases.
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := ValidRetentionPolicyRequest(tt.args.rp); (err != nil) != tt.wantErr {
t.Errorf("ValidRetentionPolicyRequest() error = %v, wantErr %v", err, tt.wantErr)
}
})
}
}

View File

@ -138,6 +138,19 @@ func NewMux(opts MuxOpts, service Service) http.Handler {
router.DELETE("/chronograf/v1/dashboards/:id/cells/:cid", service.RemoveDashboardCell) router.DELETE("/chronograf/v1/dashboards/:id/cells/:cid", service.RemoveDashboardCell)
router.PUT("/chronograf/v1/dashboards/:id/cells/:cid", service.ReplaceDashboardCell) router.PUT("/chronograf/v1/dashboards/:id/cells/:cid", service.ReplaceDashboardCell)
// Databases
router.GET("/chronograf/v1/sources/:id/dbs", service.GetDatabases)
router.POST("/chronograf/v1/sources/:id/dbs", service.NewDatabase)
router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid", service.DropDatabase)
// Retention Policies
router.GET("/chronograf/v1/sources/:id/dbs/:dbid/rps", service.RetentionPolicies)
router.POST("/chronograf/v1/sources/:id/dbs/:dbid/rps", service.NewRetentionPolicy)
router.PUT("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", service.UpdateRetentionPolicy)
router.DELETE("/chronograf/v1/sources/:id/dbs/:dbid/rps/:rpid", service.DropRetentionPolicy)
var authRoutes AuthRoutes var authRoutes AuthRoutes
var out http.Handler var out http.Handler

View File

@ -22,6 +22,7 @@ import (
client "github.com/influxdata/usage-client/v1" client "github.com/influxdata/usage-client/v1"
flags "github.com/jessevdk/go-flags" flags "github.com/jessevdk/go-flags"
"github.com/tylerb/graceful" "github.com/tylerb/graceful"
"github.com/influxdata/chronograf/influx"
) )
var ( var (
@ -293,6 +294,7 @@ func openService(ctx context.Context, boltPath, cannedPath string, logger chrono
AlertRulesStore: db.AlertsStore, AlertRulesStore: db.AlertsStore,
Logger: logger, Logger: logger,
UseAuth: useAuth, UseAuth: useAuth,
Databases: &influx.Client{Logger: logger},
} }
} }

View File

@ -20,6 +20,7 @@ type Service struct {
TimeSeriesClient TimeSeriesClient TimeSeriesClient TimeSeriesClient
Logger chronograf.Logger Logger chronograf.Logger
UseAuth bool UseAuth bool
Databases chronograf.Databases
} }
// TimeSeriesClient returns the correct client for a time series database. // TimeSeriesClient returns the correct client for a time series database.

View File

@ -18,6 +18,7 @@ type sourceLinks struct {
Permissions string `json:"permissions"` // URL for all allowed permissions for this source Permissions string `json:"permissions"` // URL for all allowed permissions for this source
Users string `json:"users"` // URL for all users associated with this source Users string `json:"users"` // URL for all users associated with this source
Roles string `json:"roles,omitempty"` // URL for all users associated with this source Roles string `json:"roles,omitempty"` // URL for all users associated with this source
Databases string `json:"databases"` // URL for the databases contained within this soure
} }
type sourceResponse struct { type sourceResponse struct {
@ -43,6 +44,7 @@ func newSourceResponse(src chronograf.Source) sourceResponse {
Kapacitors: fmt.Sprintf("%s/%d/kapacitors", httpAPISrcs, src.ID), Kapacitors: fmt.Sprintf("%s/%d/kapacitors", httpAPISrcs, src.ID),
Permissions: fmt.Sprintf("%s/%d/permissions", httpAPISrcs, src.ID), Permissions: fmt.Sprintf("%s/%d/permissions", httpAPISrcs, src.ID),
Users: fmt.Sprintf("%s/%d/users", httpAPISrcs, src.ID), Users: fmt.Sprintf("%s/%d/users", httpAPISrcs, src.ID),
Databases: fmt.Sprintf("%s/%d/dbs", httpAPISrcs, src.ID),
}, },
} }

View File

@ -30,6 +30,7 @@ func Test_newSourceResponse(t *testing.T) {
Kapacitors: "/chronograf/v1/sources/1/kapacitors", Kapacitors: "/chronograf/v1/sources/1/kapacitors",
Users: "/chronograf/v1/sources/1/users", Users: "/chronograf/v1/sources/1/users",
Permissions: "/chronograf/v1/sources/1/permissions", Permissions: "/chronograf/v1/sources/1/permissions",
Databases: "/chronograf/v1/sources/1/dbs",
}, },
}, },
}, },
@ -50,6 +51,7 @@ func Test_newSourceResponse(t *testing.T) {
Kapacitors: "/chronograf/v1/sources/1/kapacitors", Kapacitors: "/chronograf/v1/sources/1/kapacitors",
Users: "/chronograf/v1/sources/1/users", Users: "/chronograf/v1/sources/1/users",
Permissions: "/chronograf/v1/sources/1/permissions", Permissions: "/chronograf/v1/sources/1/permissions",
Databases: "/chronograf/v1/sources/1/dbs",
}, },
}, },
}, },

View File

@ -769,6 +769,329 @@
} }
} }
}, },
"/sources/{id}/dbs/": {
"get": {
"tags": [
"databases"
],
"summary": "Retrieve all databases for a source",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
}
],
"responses": {
"200": {
"description": "Listing of all databases for a source",
"schema": {
"$ref": "#/definitions/Databases"
}
},
"404": {
"description": "Data source id does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"tags": [
"databases"
],
"summary": "Create new database for a source",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
},
{
"name": "database",
"in": "body",
"description": "Configuration options for a database",
"schema": {
"$ref": "#/definitions/Database"
},
"required": true
}
],
"responses": {
"201": {
"description": "Database successfully created.",
"schema": {
"$ref": "#/definitions/Database"
}
},
"404": {
"description": "Data source id does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/sources/{id}/dbs/{db_id}": {
"delete": {
"tags": [
"databases"
],
"summary": "Delete database for a source",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
},
{
"name": "db_id",
"in": "path",
"type": "string",
"description": "ID of the database",
"required": true
}
],
"responses": {
"204": {
"description": "Database has been deleted",
},
"404": {
"description": "Data source id does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/sources/{id}/dbs/{db_id}/rps": {
"get": {
"tags": [
"retention policies"
],
"summary": "Retrieve all retention policies for a database",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
},
{
"name": "db_id",
"in": "path",
"type": "string",
"description": "ID of the database",
"required": true
}
],
"responses": {
"200": {
"description": "Listing of all retention policies for a database",
"schema": {
"$ref": "#/definitions/RetentionPolicies"
}
},
"404": {
"description": "Specified retention policy does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"post": {
"tags": [
"retention policies"
],
"summary": "Create new retention policy for a database",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
},
{
"name": "db_id",
"in": "path",
"type": "string",
"description": "ID of the database",
"required": true
},
{
"name": "rp",
"in": "body",
"description": "Configuration options for the retention policy",
"schema": {
"$ref": "#/definitions/RetentionPolicy"
},
"required": true
}
],
"responses": {
"201": {
"description": "Retention Policy successfully created.",
"schema": {
"$ref": "#/definitions/RetentionPolicy"
}
},
"404": {
"description": "Data source id does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/sources/{id}/dbs/{db_id}/rps/{rp_id}": {
"patch": {
"tags": [
"retention policies"
],
"summary": "Alter retention policy for a database",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
},
{
"name": "db_id",
"in": "path",
"type": "string",
"description": "ID of the database",
"required": true
},
{
"name": "rp_id",
"in": "path",
"type": "string",
"description": "ID of the retention policy",
"required": true
},
{
"name": "rp",
"in": "body",
"description": "Configuration options for the retention policy",
"schema": {
"$ref": "#/definitions/RetentionPolicy"
},
"required": true
}
],
"responses": {
"200": {
"description": "Retention Policy was altered",
"schema": {
"$ref": "#/definitions/RetentionPolicy"
}
},
"404": {
"description": "Database or source does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
},
"delete": {
"tags": [
"retention policies"
],
"summary": "Delete retention policy for a database",
"parameters": [
{
"name": "id",
"in": "path",
"type": "string",
"description": "ID of the data source",
"required": true
},
{
"name": "db_id",
"in": "path",
"type": "string",
"description": "ID of the database",
"required": true
},
{
"name": "rp_id",
"in": "path",
"type": "string",
"description": "ID of the retention policy",
"required": true
}
],
"responses": {
"204": {
"description": "Retention Policy has been deleted",
},
"404": {
"description": "Data source id does not exist.",
"schema": {
"$ref": "#/definitions/Error"
}
},
"default": {
"description": "A processing or an unexpected error.",
"schema": {
"$ref": "#/definitions/Error"
}
}
}
}
},
"/sources/{id}/kapacitors": { "/sources/{id}/kapacitors": {
"get": { "get": {
"tags": [ "tags": [
@ -1912,6 +2235,70 @@
} }
}, },
"definitions": { "definitions": {
"Databases": {
"type": "object",
"required": [
"databases"
],
"properties": {
"databases": {
"type": "array",
"items": {
"$ref": "#/definitions/Database"
}
}
}
},
"Database": {
"type": "object",
"required": [
"name"
],
"example": {
"name": "NOAA_water_database",
"duration": "3d",
"replication": 3,
"shardDuration": "3h",
"links": {
"self": "/chronograf/v1/sources/1/dbs/NOAA_water_database",
"rps": "/chronograf/v1/sources/1/dbs/NOAA_water_database/rps"
}
},
"properties": {
"name": {
"type": "string",
"description": "The identifying name of the database",
},
"duration": {
"type": "string",
"description": "the duration of the default retention policy"
},
"replication": {
"type": "integer",
"format": "int32",
"description": "how many copies of the data are stored in the cluster"
},
"shardDuration": {
"type": "string",
"description": "the interval spanned by each shard group"
},
"links": {
"type": "object",
"properties": {
"self": {
"type": "string",
"description": "Self link mapping to this resource",
"format": "url"
},
"rps": {
"type": "string",
"description": "Link to retention policies for this database",
"format": "url"
}
}
}
}
},
"Kapacitors": { "Kapacitors": {
"type": "object", "type": "object",
"required": [ "required": [
@ -2150,6 +2537,71 @@
} }
} }
}, },
"RetentionPolicies": {
"type": "object",
"required": [
"retentionPolicies"
],
"properties": {
"retentionPolicies": {
"type": "array",
"items": {
"$ref": "#/definitions/RetentionPolicy"
}
}
}
},
"RetentionPolicy": {
"type": "object",
"required": [
"name",
"duration",
"replication"
],
"example": {
"name": "weekly",
"duration": "7d",
"replication": 1,
"shardDuration": "7d",
"default": true,
"links": {
"self": "/chronograf/v1/ousrces/1/dbs/NOAA_water_database/rps/liquid"
}
},
"properties": {
"name": {
"type": "string",
"description": "The identifying name of the retention policy",
},
"duration": {
"type": "string",
"description": "the duration of the retention policy"
},
"replication": {
"type": "integer",
"format": "int32",
"description": "how many copies of the data are stored in the cluster"
},
"shardDuration": {
"type": "string",
"description": "the interval spanned by each shard group"
},
"default": {
"type": "boolean",
"description": "Indicates whether this retention policy should be the default"
},
"links": {
"type": "object",
"properties": {
"self": {
"type": "string",
"description": "Self link mapping to this resource",
"format": "url"
}
}
}
}
},
"Rule": { "Rule": {
"type": "object", "type": "object",
"example": { "example": {

View File

@ -3,18 +3,33 @@ import reducer from 'src/admin/reducers/admin'
import { import {
addUser, addUser,
addRole, addRole,
addDatabase,
addRetentionPolicy,
syncUser, syncUser,
syncRole, syncRole,
editUser, editUser,
editRole, editRole,
editDatabase,
editRetentionPolicy,
loadRoles, loadRoles,
loadPermissions, loadPermissions,
deleteRole, deleteRole,
deleteUser, deleteUser,
removeDatabase,
removeRetentionPolicy,
filterRoles, filterRoles,
filterUsers, filterUsers,
addDatabaseDeleteCode,
removeDatabaseDeleteCode,
} from 'src/admin/actions' } from 'src/admin/actions'
import {
NEW_DEFAULT_USER,
NEW_DEFAULT_ROLE,
NEW_DEFAULT_DATABASE,
NEW_EMPTY_RP,
} from 'src/admin/constants'
let state = undefined let state = undefined
// Users // Users
@ -58,14 +73,6 @@ const u2 = {
links: {self: '/chronograf/v1/sources/1/users/zerocool'}, links: {self: '/chronograf/v1/sources/1/users/zerocool'},
} }
const users = [u1, u2] const users = [u1, u2]
const newDefaultUser = {
name: '',
password: '',
roles: [],
permissions: [],
links: {self: ''},
isNew: true,
}
// Roles // Roles
const r1 = { const r1 = {
@ -103,20 +110,118 @@ const r2 = {
links: {self: '/chronograf/v1/sources/1/roles/l33tus3r'} links: {self: '/chronograf/v1/sources/1/roles/l33tus3r'}
} }
const roles = [r1, r2] const roles = [r1, r2]
const newDefaultRole = {
name: '',
users: [],
permissions: [],
links: {self: ''},
isNew: true,
}
// Permissions // Permissions
const global = {scope: 'all', allowed: ['p1', 'p2']} const global = {scope: 'all', allowed: ['p1', 'p2']}
const scoped = {scope: 'db1', allowed: ['p1', 'p3']} const scoped = {scope: 'db1', allowed: ['p1', 'p3']}
const permissions = [global, scoped] const permissions = [global, scoped]
// Databases && Retention Policies
const rp1 = {
name: 'rp1',
duration: '0',
replication: 2,
isDefault: true,
links: {self: '/chronograf/v1/sources/1/db/db1/rp/rp1'},
}
const db1 = {
name: 'db1',
links: {self: '/chronograf/v1/sources/1/db/db1'},
retentionPolicies: [rp1],
}
const db2 = {
name: 'db2',
links: {self: '/chronograf/v1/sources/1/db/db2'},
retentionPolicies: [],
deleteCode: 'DELETE',
}
describe('Admin.Reducers', () => { describe('Admin.Reducers', () => {
describe('Databases', () => {
const state = {databases: [db1, db2]}
it('can add a database', () => {
const actual = reducer(state, addDatabase())
const expected = [
{...NEW_DEFAULT_DATABASE, isEditing: true},
db1,
db2,
]
expect(actual.databases).to.deep.equal(expected)
})
it('can edit a database', () => {
const updates = {name: 'dbOne'}
const actual = reducer(state, editDatabase(db1, updates))
const expected = [{...db1, ...updates}, db2]
expect(actual.databases).to.deep.equal(expected)
})
it('can remove a database', () => {
const actual = reducer(state, removeDatabase(db1))
const expected = [db2]
expect(actual.databases).to.deep.equal(expected)
})
it('can add a database delete code', () => {
const actual = reducer(state, addDatabaseDeleteCode(db1))
const expected = [
{...db1, deleteCode: ''},
db2,
]
expect(actual.databases).to.deep.equal(expected)
})
it('can remove the delete code', () => {
const actual = reducer(state, removeDatabaseDeleteCode(db2))
delete db2.deleteCode
const expected = [
db1,
db2,
]
expect(actual.databases).to.deep.equal(expected)
})
})
describe('Retention Policies', () => {
const state = {databases: [db1]}
it('can add a retention policy', () => {
const actual = reducer(state, addRetentionPolicy(db1))
const expected = [
{...db1, retentionPolicies: [NEW_EMPTY_RP, rp1]},
]
expect(actual.databases).to.deep.equal(expected)
})
it('can remove a retention policy', () => {
const actual = reducer(state, removeRetentionPolicy(db1, rp1))
const expected = [
{...db1, retentionPolicies: []},
]
expect(actual.databases).to.deep.equal(expected)
})
it('can edit a retention policy', () => {
const updates = {name: 'rpOne', duration: '100y', replication: '42'}
const actual = reducer(state, editRetentionPolicy(db1, rp1, updates))
const expected = [
{...db1, retentionPolicies: [{...rp1, ...updates}]},
]
expect(actual.databases).to.deep.equal(expected)
})
})
it('it can add a user', () => { it('it can add a user', () => {
state = { state = {
users: [ users: [
@ -127,7 +232,7 @@ describe('Admin.Reducers', () => {
const actual = reducer(state, addUser()) const actual = reducer(state, addUser())
const expected = { const expected = {
users: [ users: [
{...newDefaultUser, isEditing: true}, {...NEW_DEFAULT_USER, isEditing: true},
u1, u1,
], ],
} }
@ -171,7 +276,7 @@ describe('Admin.Reducers', () => {
const actual = reducer(state, addRole()) const actual = reducer(state, addRole())
const expected = { const expected = {
roles: [ roles: [
{...newDefaultRole, isEditing: true}, {...NEW_DEFAULT_ROLE, isEditing: true},
r1, r1,
], ],
} }

View File

@ -25,7 +25,7 @@ describe('Formatting helpers', () => {
it("returns 'infinite' for a retention policy with a value of '0'", () => { it("returns 'infinite' for a retention policy with a value of '0'", () => {
const actual = formatRPDuration('0') const actual = formatRPDuration('0')
expect(actual).to.equal('infinite'); expect(actual).to.equal('');
}); });
it('correctly formats retention policy durations', () => { it('correctly formats retention policy durations', () => {

View File

@ -2,15 +2,24 @@ import {
getUsers as getUsersAJAX, getUsers as getUsersAJAX,
getRoles as getRolesAJAX, getRoles as getRolesAJAX,
getPermissions as getPermissionsAJAX, getPermissions as getPermissionsAJAX,
getDbsAndRps as getDbsAndRpsAJAX,
createUser as createUserAJAX, createUser as createUserAJAX,
createRole as createRoleAJAX, createRole as createRoleAJAX,
createDatabase as createDatabaseAJAX,
createRetentionPolicy as createRetentionPolicyAJAX,
deleteUser as deleteUserAJAX, deleteUser as deleteUserAJAX,
deleteRole as deleteRoleAJAX, deleteRole as deleteRoleAJAX,
deleteDatabase as deleteDatabaseAJAX,
deleteRetentionPolicy as deleteRetentionPolicyAJAX,
updateRole as updateRoleAJAX, updateRole as updateRoleAJAX,
updateUser as updateUserAJAX, updateUser as updateUserAJAX,
updateRetentionPolicy as updateRetentionPolicyAJAX,
} from 'src/admin/apis' } from 'src/admin/apis'
import {killQuery as killQueryProxy} from 'shared/apis/metaQuery' import {
killQuery as killQueryProxy,
} from 'shared/apis/metaQuery'
import {publishNotification} from 'src/shared/actions/notifications'; import {publishNotification} from 'src/shared/actions/notifications';
import {ADMIN_NOTIFICATION_DELAY} from 'src/admin/constants' import {ADMIN_NOTIFICATION_DELAY} from 'src/admin/constants'
@ -35,6 +44,13 @@ export const loadPermissions = ({permissions}) => ({
}, },
}) })
export const loadDatabases = (databases) => ({
type: 'LOAD_DATABASES',
payload: {
databases,
},
})
export const addUser = () => ({ export const addUser = () => ({
type: 'ADD_USER', type: 'ADD_USER',
}) })
@ -43,6 +59,17 @@ export const addRole = () => ({
type: 'ADD_ROLE', type: 'ADD_ROLE',
}) })
export const addDatabase = () => ({
type: 'ADD_DATABASE',
})
export const addRetentionPolicy = (database) => ({
type: 'ADD_RETENTION_POLICY',
payload: {
database,
},
})
export const syncUser = (staleUser, syncedUser) => ({ export const syncUser = (staleUser, syncedUser) => ({
type: 'SYNC_USER', type: 'SYNC_USER',
payload: { payload: {
@ -59,6 +86,24 @@ export const syncRole = (staleRole, syncedRole) => ({
}, },
}) })
export const syncDatabase = (stale, synced) => ({
type: 'SYNC_DATABASE',
payload: {
stale,
synced,
},
})
export const syncRetentionPolicy = (database, stale, synced) => ({
type: 'SYNC_RETENTION_POLICY',
payload: {
database,
stale,
synced,
},
})
export const editUser = (user, updates) => ({ export const editUser = (user, updates) => ({
type: 'EDIT_USER', type: 'EDIT_USER',
payload: { payload: {
@ -75,6 +120,14 @@ export const editRole = (role, updates) => ({
}, },
}) })
export const editDatabase = (database, updates) => ({
type: 'EDIT_DATABASE',
payload: {
database,
updates,
},
})
export const killQuery = (queryID) => ({ export const killQuery = (queryID) => ({
type: 'KILL_QUERY', type: 'KILL_QUERY',
payload: { payload: {
@ -96,6 +149,7 @@ export const loadQueries = (queries) => ({
}, },
}) })
// TODO: change to 'removeUser'
export const deleteUser = (user) => ({ export const deleteUser = (user) => ({
type: 'DELETE_USER', type: 'DELETE_USER',
payload: { payload: {
@ -103,6 +157,7 @@ export const deleteUser = (user) => ({
}, },
}) })
// TODO: change to 'removeRole'
export const deleteRole = (role) => ({ export const deleteRole = (role) => ({
type: 'DELETE_ROLE', type: 'DELETE_ROLE',
payload: { payload: {
@ -110,6 +165,21 @@ export const deleteRole = (role) => ({
}, },
}) })
export const removeDatabase = (database) => ({
type: 'REMOVE_DATABASE',
payload: {
database,
},
})
export const removeRetentionPolicy = (database, retentionPolicy) => ({
type: 'REMOVE_RETENTION_POLICY',
payload: {
database,
retentionPolicy,
},
})
export const filterUsers = (text) => ({ export const filterUsers = (text) => ({
type: 'FILTER_USERS', type: 'FILTER_USERS',
payload: { payload: {
@ -124,6 +194,29 @@ export const filterRoles = (text) => ({
}, },
}) })
export const addDatabaseDeleteCode = (database) => ({
type: 'ADD_DATABASE_DELETE_CODE',
payload: {
database,
},
})
export const removeDatabaseDeleteCode = (database) => ({
type: 'REMOVE_DATABASE_DELETE_CODE',
payload: {
database,
},
})
export const editRetentionPolicy = (database, retentionPolicy, updates) => ({
type: 'EDIT_RETENTION_POLICY',
payload: {
database,
retentionPolicy,
updates,
},
})
// async actions // async actions
export const loadUsersAsync = (url) => async (dispatch) => { export const loadUsersAsync = (url) => async (dispatch) => {
const {data} = await getUsersAJAX(url) const {data} = await getUsersAJAX(url)
@ -140,6 +233,11 @@ export const loadPermissionsAsync = (url) => async (dispatch) => {
dispatch(loadPermissions(data)) dispatch(loadPermissions(data))
} }
export const loadDBsAndRPsAsync = (url) => async (dispatch) => {
const {data: {databases}} = await getDbsAndRpsAJAX(url)
dispatch(loadDatabases(databases))
}
export const createUserAsync = (url, user) => async (dispatch) => { export const createUserAsync = (url, user) => async (dispatch) => {
try { try {
const {data} = await createUserAJAX(url, user) const {data} = await createUserAJAX(url, user)
@ -164,6 +262,41 @@ export const createRoleAsync = (url, role) => async (dispatch) => {
} }
} }
export const createDatabaseAsync = (url, database) => async (dispatch) => {
try {
const {data} = await createDatabaseAJAX(url, database)
dispatch(syncDatabase(database, data))
dispatch(publishNotification('success', 'Database created successfully'))
} catch (error) {
// undo optimistic update
dispatch(publishNotification('error', `Failed to create database: ${error.data.message}`))
setTimeout(() => dispatch(removeDatabase(database)), ADMIN_NOTIFICATION_DELAY)
}
}
export const createRetentionPolicyAsync = (database, retentionPolicy) => async (dispatch) => {
try {
const {data} = await createRetentionPolicyAJAX(database.links.retentionPolicies, retentionPolicy)
dispatch(publishNotification('success', 'Retention policy created successfully'))
dispatch(syncRetentionPolicy(database, retentionPolicy, data))
} catch (error) {
// undo optimistic update
dispatch(publishNotification('error', `Failed to create retention policy: ${error.data.message}`))
setTimeout(() => dispatch(removeRetentionPolicy(database, retentionPolicy)), ADMIN_NOTIFICATION_DELAY)
}
}
export const updateRetentionPolicyAsync = (database, retentionPolicy, updates) => async (dispatch) => {
try {
dispatch(editRetentionPolicy(database, retentionPolicy, updates))
const {data} = await updateRetentionPolicyAJAX(retentionPolicy.links.self, updates)
dispatch(publishNotification('success', 'Retention policy updated successfully'))
dispatch(syncRetentionPolicy(database, retentionPolicy, data))
} catch (error) {
dispatch(publishNotification('error', `Failed to update retention policy: ${error.data.message}`))
}
}
export const killQueryAsync = (source, queryID) => (dispatch) => { export const killQueryAsync = (source, queryID) => (dispatch) => {
// optimistic update // optimistic update
dispatch(killQuery(queryID)) dispatch(killQuery(queryID))
@ -189,6 +322,26 @@ export const deleteUserAsync = (user, addFlashMessage) => (dispatch) => {
deleteUserAJAX(user.links.self, addFlashMessage, user.name) deleteUserAJAX(user.links.self, addFlashMessage, user.name)
} }
export const deleteDatabaseAsync = (database) => async (dispatch) => {
dispatch(removeDatabase(database))
dispatch(publishNotification('success', 'Database deleted'))
try {
await deleteDatabaseAJAX(database.links.self)
} catch (error) {
dispatch(publishNotification('error', `Failed to delete database: ${error.data.message}`))
}
}
export const deleteRetentionPolicyAsync = (database, retentionPolicy) => async (dispatch) => {
dispatch(removeRetentionPolicy(database, retentionPolicy))
dispatch(publishNotification('success', `Retention policy ${retentionPolicy.name} deleted`))
try {
await deleteRetentionPolicyAJAX(retentionPolicy.links.self)
} catch (error) {
dispatch(publishNotification('error', `Failed to delete retentionPolicy: ${error.data.message}`))
}
}
export const updateRoleUsersAsync = (role, users) => async (dispatch) => { export const updateRoleUsersAsync = (role, users) => async (dispatch) => {
try { try {
const {data} = await updateRoleAJAX(role.links.self, users, role.permissions) const {data} = await updateRoleAJAX(role.links.self, users, role.permissions)

View File

@ -36,6 +36,18 @@ export const getPermissions = async (url) => {
} }
} }
export const getDbsAndRps = async (url) => {
try {
return await AJAX({
method: 'GET',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const createUser = async (url, user) => { export const createUser = async (url, user) => {
try { try {
return await AJAX({ return await AJAX({
@ -60,6 +72,41 @@ export const createRole = async (url, role) => {
} }
} }
export const createDatabase = async (url, database) => {
try {
return await AJAX({
method: 'POST',
url,
data: database,
})
} catch (error) {
throw error
}
}
export const createRetentionPolicy = async (url, retentionPolicy) => {
try {
return await AJAX({
method: 'POST',
url,
data: retentionPolicy,
})
} catch (error) {
throw error
}
}
export const deleteRetentionPolicy = async (url) => {
try {
return await AJAX({
method: 'DELETE',
url,
})
} catch (error) {
throw error
}
}
export const deleteRole = async (url, addFlashMessage, rolename) => { export const deleteRole = async (url, addFlashMessage, rolename) => {
try { try {
const response = await AJAX({ const response = await AJAX({
@ -100,6 +147,18 @@ export const deleteUser = async (url, addFlashMessage, username) => {
} }
} }
export const deleteDatabase = async (url) => {
try {
return await AJAX({
method: 'DELETE',
url,
})
} catch (error) {
console.error(error)
throw error
}
}
export const updateRole = async (url, users, permissions) => { export const updateRole = async (url, users, permissions) => {
try { try {
return await AJAX({ return await AJAX({
@ -131,3 +190,18 @@ export const updateUser = async (url, roles, permissions) => {
throw error throw error
} }
} }
export const updateRetentionPolicy = async (url, retentionPolicy) => {
try {
return await AJAX({
method: 'PUT',
url,
data: {
...retentionPolicy,
},
})
} catch (error) {
console.error(error)
throw error
}
}

View File

@ -1,8 +1,9 @@
import React, {PropTypes} from 'react' import React, {PropTypes} from 'react'
import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'src/shared/components/Tabs'; import {Tab, Tabs, TabPanel, TabPanels, TabList} from 'src/shared/components/Tabs'
import UsersTable from 'src/admin/components/UsersTable' import UsersTable from 'src/admin/components/UsersTable'
import RolesTable from 'src/admin/components/RolesTable' import RolesTable from 'src/admin/components/RolesTable'
import QueriesPage from 'src/admin/containers/QueriesPage' import QueriesPage from 'src/admin/containers/QueriesPage'
import DatabaseManagerPage from 'src/admin/containers/DatabaseManagerPage'
const AdminTabs = ({ const AdminTabs = ({
users, users,
@ -29,6 +30,10 @@ const AdminTabs = ({
onUpdateUserPermissions, onUpdateUserPermissions,
}) => { }) => {
let tabs = [ let tabs = [
{
type: 'DB Management',
component: (<DatabaseManagerPage source={source} />),
},
{ {
type: 'Users', type: 'Users',
component: ( component: (

View File

@ -0,0 +1,89 @@
import React, {PropTypes} from 'react'
import DatabaseTable from 'src/admin/components/DatabaseTable'
const DatabaseManager = ({
databases,
notify,
isRFDisplayed,
isAddDBDisabled,
addDatabase,
onEditDatabase,
onKeyDownDatabase,
onCancelDatabase,
onConfirmDatabase,
onStartDeleteDatabase,
onDatabaseDeleteConfirm,
onAddRetentionPolicy,
onStopEditRetentionPolicy,
onCancelRetentionPolicy,
onCreateRetentionPolicy,
onUpdateRetentionPolicy,
onRemoveRetentionPolicy,
onDeleteRetentionPolicy,
}) => {
return (
<div className="panel panel-info">
<div className="panel-heading u-flex u-ai-center u-jc-space-between">
<h2 className="panel-title">{databases.length === 1 ? '1 Database' : `${databases.length} Databases`}</h2>
<div className="btn btn-sm btn-primary" disabled={isAddDBDisabled} onClick={addDatabase}>Create Database</div>
</div>
<div className="panel-body">
{
databases.map(db =>
<DatabaseTable
key={db.links.self}
database={db}
notify={notify}
isRFDisplayed={isRFDisplayed}
onEditDatabase={onEditDatabase}
onKeyDownDatabase={onKeyDownDatabase}
onCancelDatabase={onCancelDatabase}
onConfirmDatabase={onConfirmDatabase}
onStartDeleteDatabase={onStartDeleteDatabase}
onDatabaseDeleteConfirm={onDatabaseDeleteConfirm}
onAddRetentionPolicy={onAddRetentionPolicy}
onStopEditRetentionPolicy={onStopEditRetentionPolicy}
onCancelRetentionPolicy={onCancelRetentionPolicy}
onCreateRetentionPolicy={onCreateRetentionPolicy}
onUpdateRetentionPolicy={onUpdateRetentionPolicy}
onRemoveRetentionPolicy={onRemoveRetentionPolicy}
onDeleteRetentionPolicy={onDeleteRetentionPolicy}
/>
)
}
</div>
</div>
)
}
const {
arrayOf,
bool,
func,
shape,
} = PropTypes
DatabaseManager.propTypes = {
databases: arrayOf(shape()),
notify: func,
addDatabase: func,
isRFDisplayed: bool,
isAddDBDisabled: bool,
onEditDatabase: func,
onKeyDownDatabase: func,
onCancelDatabase: func,
onConfirmDatabase: func,
onStartDeleteDatabase: func,
onDatabaseDeleteConfirm: func,
onAddRetentionPolicy: func,
onEditRetentionPolicy: func,
onStopEditRetentionPolicy: func,
onCancelRetentionPolicy: func,
onCreateRetentionPolicy: func,
onUpdateRetentionPolicy: func,
onRemoveRetentionPolicy: func,
onDeleteRetentionPolicy: func,
}
export default DatabaseManager

View File

@ -0,0 +1,251 @@
import React, {PropTypes, Component} from 'react'
import {formatRPDuration} from 'utils/formatting'
import YesNoButtons from 'src/shared/components/YesNoButtons'
import onClickOutside from 'react-onclickoutside'
class DatabaseRow extends Component {
constructor(props) {
super(props)
this.state = {
isEditing: false,
isDeleting: false,
}
this.handleKeyDown = ::this.handleKeyDown
this.handleClickOutside = ::this.handleClickOutside
this.handleStartEdit = ::this.handleStartEdit
this.handleEndEdit = ::this.handleEndEdit
this.handleCreate = ::this.handleCreate
this.handleUpdate = ::this.handleUpdate
this.getInputValues = ::this.getInputValues
this.handleStartDelete = ::this.handleStartDelete
this.handleEndDelete = ::this.handleEndDelete
}
componentWillMount() {
if (this.props.retentionPolicy.isNew) {
this.setState({isEditing: true})
}
}
render() {
const {
onRemove,
retentionPolicy: {name, duration, replication, isDefault, isNew},
retentionPolicy,
database,
onDelete,
isRFDisplayed,
} = this.props
const {isEditing, isDeleting} = this.state
const formattedDuration = formatRPDuration(duration)
if (isEditing) {
return (
<tr>
<td>{
isNew ?
<div className="admin-table--edit-cell">
<input
className="form-control"
type="text"
defaultValue={name}
placeholder="give it a name"
onKeyDown={(e) => this.handleKeyDown(e, database)}
ref={(r) => this.name = r}
autoFocus={true}
/>
</div> :
<div className="admin-table--edit-cell">
{name}
</div>
}
</td>
<td>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="text"
defaultValue={formattedDuration}
placeholder="how long should data last"
onKeyDown={(e) => this.handleKeyDown(e, database)}
ref={(r) => this.duration = r}
autoFocus={!isNew}
/>
</div>
</td>
<td style={isRFDisplayed ? {} : {display: 'none'}}>
<div className="admin-table--edit-cell">
<input
className="form-control"
name="name"
type="number"
min="1"
defaultValue={replication || 1}
placeholder="how many nodes do you have"
onKeyDown={(e) => this.handleKeyDown(e, database)}
ref={(r) => this.replication = r}
/>
</div>
</td>
<td className="text-right">
<YesNoButtons
onConfirm={isNew ? this.handleCreate : this.handleUpdate}
onCancel={isNew ? () => onRemove(database, retentionPolicy) : this.handleEndEdit}
/>
</td>
</tr>
)
}
return (
<tr>
<td>{name} {isDefault ? <span className="default-source-label">default</span> : null}</td>
<td onClick={this.handleStartEdit}>{formattedDuration}</td>
{isRFDisplayed ? <td onClick={this.handleStartEdit}>{replication}</td> : null}
<td className="text-right">
{
isDeleting ?
<YesNoButtons onConfirm={() => onDelete(database, retentionPolicy)} onCancel={this.handleEndDelete} /> :
this.renderDeleteButton()
}
</td>
</tr>
)
}
renderDeleteButton() {
if (!this.props.isDeletable) {
return
}
return (
<button className="btn btn-xs btn-danger admin-table--delete" onClick={this.handleStartDelete}>
{`Delete ${name}`}
</button>
)
}
handleClickOutside() {
const {database, retentionPolicy, onRemove} = this.props
if (retentionPolicy.isNew) {
onRemove(database, retentionPolicy)
}
this.handleEndEdit()
this.handleEndDelete()
}
handleStartEdit() {
this.setState({isEditing: true})
}
handleEndEdit() {
this.setState({isEditing: false})
}
handleStartDelete() {
this.setState({isDeleting: true})
}
handleEndDelete() {
this.setState({isDeleting: false})
}
handleCreate() {
const {database, retentionPolicy, onCreate} = this.props
const validInputs = this.getInputValues()
if (!validInputs) {
return
}
onCreate(database, {...retentionPolicy, ...validInputs})
this.handleEndEdit()
}
handleUpdate() {
const {database, retentionPolicy, onUpdate} = this.props
const validInputs = this.getInputValues()
if (!validInputs) {
return
}
onUpdate(database, retentionPolicy, validInputs)
this.handleEndEdit()
}
handleKeyDown(e) {
const {key} = e
const {retentionPolicy, database, onRemove} = this.props
if (key === 'Escape') {
if (retentionPolicy.isNew) {
onRemove(database, retentionPolicy)
return
}
this.handleEndEdit()
}
if (key === 'Enter') {
if (retentionPolicy.isNew) {
this.handleCreate()
return
}
this.handleUpdate()
}
}
getInputValues() {
let duration = this.duration.value.trim()
const replication = +this.replication.value.trim()
const {notify, retentionPolicy: {name}} = this.props
if (!duration || !replication) {
notify('error', 'Fields cannot be empty')
return
}
if (duration === '∞') {
duration = 'INF'
}
return {
name,
duration,
replication,
}
}
}
const {
bool,
func,
number,
shape,
string,
} = PropTypes
DatabaseRow.propTypes = {
retentionPolicy: shape({
name: string,
duration: string,
replication: number,
isDefault: bool,
isEditing: bool,
}),
isDeletable: bool,
database: shape(),
onRemove: func,
onCreate: func,
onUpdate: func,
onDelete: func,
notify: func,
isRFDisplayed: bool,
}
export default onClickOutside(DatabaseRow)

View File

@ -0,0 +1,234 @@
import React, {PropTypes} from 'react'
import DatabaseRow from 'src/admin/components/DatabaseRow'
import ConfirmButtons from 'src/admin/components/ConfirmButtons'
const {
func,
shape,
bool,
} = PropTypes
const DatabaseTable = ({
database,
notify,
isRFDisplayed,
onEditDatabase,
onKeyDownDatabase,
onCancelDatabase,
onConfirmDatabase,
onStartDeleteDatabase,
onDatabaseDeleteConfirm,
onAddRetentionPolicy,
onCreateRetentionPolicy,
onUpdateRetentionPolicy,
onRemoveRetentionPolicy,
onDeleteRetentionPolicy,
}) => {
return (
<div className="db-manager">
<DatabaseTableHeader
database={database}
isAddRPDisabled={!!database.retentionPolicies.some(rp => rp.isNew)}
onEdit={onEditDatabase}
onKeyDown={onKeyDownDatabase}
onCancel={onCancelDatabase}
onConfirm={onConfirmDatabase}
onStartDelete={onStartDeleteDatabase}
onDatabaseDeleteConfirm={onDatabaseDeleteConfirm}
onAddRetentionPolicy={onAddRetentionPolicy}
onDeleteRetentionPolicy={onDeleteRetentionPolicy}
/>
<div className="db-manager-table">
<table className="table v-center admin-table">
<thead>
<tr>
<th>Retention Policy</th>
<th>Duration</th>
{isRFDisplayed ? <th>Replication Factor</th> : null}
<th></th>
</tr>
</thead>
<tbody>
{
database.retentionPolicies.map(rp => {
return (
<DatabaseRow
key={rp.links.self}
notify={notify}
database={database}
retentionPolicy={rp}
onCreate={onCreateRetentionPolicy}
onUpdate={onUpdateRetentionPolicy}
onRemove={onRemoveRetentionPolicy}
onDelete={onDeleteRetentionPolicy}
isRFDisplayed={isRFDisplayed}
isDeletable={database.retentionPolicies.length > 1}
/>
)
})
}
</tbody>
</table>
</div>
</div>
)
}
DatabaseTable.propTypes = {
onEditDatabase: func,
database: shape(),
notify: func,
isRFDisplayed: bool,
isAddRPDisabled: bool,
onKeyDownDatabase: func,
onCancelDatabase: func,
onConfirmDatabase: func,
onStartDeleteDatabase: func,
onDatabaseDeleteConfirm: func,
onAddRetentionPolicy: func,
onCancelRetentionPolicy: func,
onCreateRetentionPolicy: func,
onUpdateRetentionPolicy: func,
onRemoveRetentionPolicy: func,
onDeleteRetentionPolicy: func,
}
const DatabaseTableHeader = ({
database,
onEdit,
onKeyDown,
onConfirm,
onCancel,
onStartDelete,
onDatabaseDeleteConfirm,
onAddRetentionPolicy,
isAddRPDisabled,
}) => {
if (database.isEditing) {
return (
<EditHeader
database={database}
onEdit={onEdit}
onKeyDown={onKeyDown}
onConfirm={onConfirm}
onCancel={onCancel}
/>
)
}
return (
<Header
database={database}
onStartDelete={onStartDelete}
onDatabaseDeleteConfirm={onDatabaseDeleteConfirm}
onAddRetentionPolicy={onAddRetentionPolicy}
onConfirm={onConfirm}
onCancel={onCancel}
isAddRPDisabled={isAddRPDisabled}
/>
)
}
DatabaseTableHeader.propTypes = {
onEdit: func,
database: shape(),
onKeyDown: func,
onCancel: func,
onConfirm: func,
onStartDelete: func,
onDatabaseDeleteConfirm: func,
onAddRetentionPolicy: func,
isAddRPDisabled: bool,
}
const Header = ({
database,
onStartDelete,
onDatabaseDeleteConfirm,
onAddRetentionPolicy,
isAddRPDisabled,
onCancel,
onConfirm,
}) => {
const confirmStyle = {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}
const buttons = (
<div className="text-right">
{
database.name === '_internal' ? null :
<button className="btn btn-xs btn-danger" onClick={() => onStartDelete(database)}>
Delete
</button>
}
<button className="btn btn-xs btn-primary" disabled={isAddRPDisabled} onClick={() => onAddRetentionPolicy(database)}>
Add retention policy
</button>
</div>
)
const deleteConfirm = (
<div style={confirmStyle}>
<div className="admin-table--delete-cell">
<input
className="form-control"
name="name"
type="text"
value={database.deleteCode || ''}
placeholder={`DELETE ${database.name}`}
onChange={(e) => onDatabaseDeleteConfirm(database, e)}
onKeyDown={(e) => onDatabaseDeleteConfirm(database, e)}
autoFocus={true}
/>
</div>
<ConfirmButtons item={database} onConfirm={onConfirm} onCancel={onCancel} />
</div>
)
return (
<div className="db-manager-header">
<h4>{database.name}</h4>
{database.hasOwnProperty('deleteCode') ? deleteConfirm : buttons}
</div>
)
}
Header.propTypes = {
database: shape(),
onStartDelete: func,
onDatabaseDeleteConfirm: func,
onAddRetentionPolicy: func,
isAddRPDisabled: bool,
onConfirm: func,
onCancel: func,
}
const EditHeader = ({database, onEdit, onKeyDown, onConfirm, onCancel}) => (
<div className="db-manager-header-edit">
<input
className="form-control"
name="name"
type="text"
value={database.name}
placeholder="Database name"
onChange={(e) => onEdit(database, {name: e.target.value})}
onKeyDown={(e) => onKeyDown(e, database)}
autoFocus={true}
/>
<ConfirmButtons item={database} onConfirm={onConfirm} onCancel={onCancel} />
</div>
)
EditHeader.propTypes = {
database: shape(),
onEdit: func,
onKeyDown: func,
onCancel: func,
onConfirm: func,
isRFDisplayed: bool,
}
export default DatabaseTable

View File

@ -9,3 +9,44 @@ export const TIMES = [
]; ];
export const ADMIN_NOTIFICATION_DELAY = 1500 // milliseconds export const ADMIN_NOTIFICATION_DELAY = 1500 // milliseconds
export const NEW_DEFAULT_USER = {
name: '',
password: '',
roles: [],
permissions: [],
links: {self: ''},
isNew: true,
}
export const NEW_DEFAULT_ROLE = {
name: '',
permissions: [],
users: [],
links: {self: ''},
isNew: true,
}
export const NEW_DEFAULT_RP = {
name: 'autogen',
duration: '0',
replication: 2,
isDefault: true,
links: {self: ''},
}
export const NEW_EMPTY_RP = {
name: '',
duration: '',
replication: 0,
links: {self: ''},
isNew: true,
}
export const NEW_DEFAULT_DATABASE = {
name: '',
isNew: true,
retentionPolicies: [NEW_DEFAULT_RP],
links: {self: ''},
}

View File

@ -0,0 +1,145 @@
import React, {PropTypes, Component} from 'react'
import {connect} from 'react-redux'
import {bindActionCreators} from 'redux'
import * as adminActionCreators from 'src/admin/actions'
import DatabaseManager from 'src/admin/components/DatabaseManager'
import {publishNotification} from 'src/shared/actions/notifications';
class DatabaseManagerPage extends Component {
constructor(props) {
super(props)
this.handleKeyDownDatabase = ::this.handleKeyDownDatabase
this.handleDatabaseDeleteConfirm = ::this.handleDatabaseDeleteConfirm
this.handleCreateDatabase = ::this.handleCreateDatabase
}
componentDidMount() {
const {source: {links: {databases}}, actions} = this.props
actions.loadDBsAndRPsAsync(databases)
}
render() {
const {source, databases, actions, notify} = this.props
return (
<DatabaseManager
databases={databases}
notify={notify}
isRFDisplayed={!!source.metaUrl}
isAddDBDisabled={!!databases.some(db => db.isEditing)}
onKeyDownDatabase={this.handleKeyDownDatabase}
onDatabaseDeleteConfirm={this.handleDatabaseDeleteConfirm}
addDatabase={actions.addDatabase}
onEditDatabase={actions.editDatabase}
onCancelDatabase={actions.removeDatabase}
onConfirmDatabase={this.handleCreateDatabase}
onStartDeleteDatabase={actions.addDatabaseDeleteCode}
onAddRetentionPolicy={actions.addRetentionPolicy}
onCreateRetentionPolicy={actions.createRetentionPolicyAsync}
onUpdateRetentionPolicy={actions.updateRetentionPolicyAsync}
onRemoveRetentionPolicy={actions.removeRetentionPolicy}
onDeleteRetentionPolicy={actions.deleteRetentionPolicyAsync}
/>
)
}
handleCreateDatabase(database) {
const {actions, notify, source} = this.props
if (!database.name) {
return notify('error', 'Database name cannot be blank')
}
actions.createDatabaseAsync(source.links.databases, database)
}
handleKeyDownDatabase(e, database) {
const {key} = e
const {actions, notify, source} = this.props
if (key === 'Escape') {
actions.removeDatabase(database)
}
if (key === 'Enter') {
if (!database.name) {
return notify('error', 'Database name cannot be blank')
}
actions.createDatabaseAsync(source.links.databases, database)
}
}
handleDatabaseDeleteConfirm(database, e) {
const {key, target: {value}} = e
const {actions, notify} = this.props
if (key === 'Escape') {
return actions.removeDatabaseDeleteCode(database)
}
if (key === 'Enter') {
if (database.deleteCode !== `DELETE ${database.name}`) {
return notify('error', `Please type DELETE ${database.name} to confirm`)
}
return actions.deleteDatabaseAsync(database)
}
actions.editDatabase(database, {deleteCode: value})
}
}
const {
arrayOf,
bool,
func,
number,
shape,
string,
} = PropTypes
DatabaseManagerPage.propTypes = {
source: shape({
links: shape({
proxy: string,
}),
}),
databases: arrayOf(shape({
name: string,
isEditing: bool,
})),
retentionPolicies: arrayOf(arrayOf(shape({
name: string,
duration: string,
replication: number,
isDefault: bool,
}))),
actions: shape({
addRetentionPolicy: func,
loadDBsAndRPsAsync: func,
createDatabaseAsync: func,
createRetentionPolicyAsync: func,
addDatabase: func,
removeDatabase: func,
startDeleteDatabase: func,
removeDatabaseDeleteCode: func,
removeRetentionPolicy: func,
deleteRetentionPolicyAsync: func,
}),
notify: func,
}
const mapStateToProps = ({admin: {databases, retentionPolicies}}) => ({
databases,
retentionPolicies,
})
const mapDispatchToProps = (dispatch) => ({
actions: bindActionCreators(adminActionCreators, dispatch),
notify: bindActionCreators(publishNotification, dispatch),
})
export default connect(mapStateToProps, mapDispatchToProps)(DatabaseManagerPage)

View File

@ -1,20 +1,10 @@
import reject from 'lodash/reject' import reject from 'lodash/reject'
import {
const newDefaultUser = { NEW_DEFAULT_USER,
name: '', NEW_DEFAULT_ROLE,
password: '', NEW_DEFAULT_DATABASE,
roles: [], NEW_EMPTY_RP,
permissions: [], } from 'src/admin/constants'
links: {self: ''},
isNew: true,
}
const newDefaultRole = {
name: '',
permissions: [],
users: [],
links: {self: ''},
isNew: true,
}
const initialState = { const initialState = {
users: null, users: null,
@ -22,6 +12,7 @@ const initialState = {
permissions: [], permissions: [],
queries: [], queries: [],
queryIDToKill: null, queryIDToKill: null,
databases: [],
} }
export default function admin(state = initialState, action) { export default function admin(state = initialState, action) {
@ -38,8 +29,12 @@ export default function admin(state = initialState, action) {
return {...state, ...action.payload} return {...state, ...action.payload}
} }
case 'LOAD_DATABASES': {
return {...state, ...action.payload}
}
case 'ADD_USER': { case 'ADD_USER': {
const newUser = {...newDefaultUser, isEditing: true} const newUser = {...NEW_DEFAULT_USER, isEditing: true}
return { return {
...state, ...state,
users: [ users: [
@ -50,7 +45,7 @@ export default function admin(state = initialState, action) {
} }
case 'ADD_ROLE': { case 'ADD_ROLE': {
const newRole = {...newDefaultRole, isEditing: true} const newRole = {...NEW_DEFAULT_ROLE, isEditing: true}
return { return {
...state, ...state,
roles: [ roles: [
@ -60,6 +55,29 @@ export default function admin(state = initialState, action) {
} }
} }
case 'ADD_DATABASE': {
const newDatabase = {...NEW_DEFAULT_DATABASE, isEditing: true}
return {
...state,
databases: [
newDatabase,
...state.databases,
],
}
}
case 'ADD_RETENTION_POLICY': {
const {database} = action.payload
const databases = state.databases.map(db =>
db.links.self === database.links.self ?
{...database, retentionPolicies: [{...NEW_EMPTY_RP}, ...database.retentionPolicies]}
: db
)
return {...state, databases}
}
case 'SYNC_USER': { case 'SYNC_USER': {
const {staleUser, syncedUser} = action.payload const {staleUser, syncedUser} = action.payload
const newState = { const newState = {
@ -76,6 +94,27 @@ export default function admin(state = initialState, action) {
return {...state, ...newState} return {...state, ...newState}
} }
case 'SYNC_DATABASE': {
const {stale, synced} = action.payload
const newState = {
databases: state.databases.map(db => db.links.self === stale.links.self ? {...synced} : db),
}
return {...state, ...newState}
}
case 'SYNC_RETENTION_POLICY': {
const {database, stale, synced} = action.payload
const newState = {
databases: state.databases.map(db => db.links.self === database.links.self ? {
...db,
retentionPolicies: db.retentionPolicies.map(rp => rp.links.self === stale.links.self ? {...synced} : rp),
} : db),
}
return {...state, ...newState}
}
case 'EDIT_USER': { case 'EDIT_USER': {
const {user, updates} = action.payload const {user, updates} = action.payload
const newState = { const newState = {
@ -92,6 +131,28 @@ export default function admin(state = initialState, action) {
return {...state, ...newState} return {...state, ...newState}
} }
case 'EDIT_DATABASE': {
const {database, updates} = action.payload
const newState = {
databases: state.databases.map(db => db.links.self === database.links.self ? {...db, ...updates} : db),
}
return {...state, ...newState}
}
case 'EDIT_RETENTION_POLICY': {
const {database, retentionPolicy, updates} = action.payload
const newState = {
databases: state.databases.map(db => db.links.self === database.links.self ? {
...db,
retentionPolicies: db.retentionPolicies.map(rp => rp.links.self === retentionPolicy.links.self ? {...rp, ...updates} : rp),
} : db),
}
return {...state, ...newState}
}
case 'DELETE_USER': { case 'DELETE_USER': {
const {user} = action.payload const {user} = action.payload
const newState = { const newState = {
@ -110,6 +171,48 @@ export default function admin(state = initialState, action) {
return {...state, ...newState} return {...state, ...newState}
} }
case 'REMOVE_DATABASE': {
const {database} = action.payload
const newState = {
databases: state.databases.filter(db => db.links.self !== database.links.self),
}
return {...state, ...newState}
}
case 'REMOVE_RETENTION_POLICY': {
const {database, retentionPolicy} = action.payload
const newState = {
databases: state.databases.map(db => db.links.self === database.links.self ? {
...db,
retentionPolicies: db.retentionPolicies.filter(rp => rp.links.self !== retentionPolicy.links.self),
}
: db),
}
return {...state, ...newState}
}
case 'ADD_DATABASE_DELETE_CODE': {
const {database} = action.payload
const newState = {
databases: state.databases.map(db => db.links.self === database.links.self ? {...db, deleteCode: ''} : db),
}
return {...state, ...newState}
}
case 'REMOVE_DATABASE_DELETE_CODE': {
const {database} = action.payload
delete database.deleteCode
const newState = {
databases: state.databases.map(db => db.links.self === database.links.self ? {...database} : db),
}
return {...state, ...newState}
}
case 'LOAD_QUERIES': { case 'LOAD_QUERIES': {
return {...state, ...action.payload} return {...state, ...action.payload}
} }

View File

@ -1,10 +1,20 @@
import AJAX from 'utils/ajax'; import AJAX from 'utils/ajax';
import {buildInfluxUrl, proxy} from 'utils/queryUrlGenerator'; import {buildInfluxUrl, proxy} from 'utils/queryUrlGenerator';
export function showDatabases(source) { export const showDatabases = async (source) => {
const query = `SHOW DATABASES`; const query = `SHOW DATABASES`
return await proxy({source, query})
}
return proxy({source, query}); export const showRetentionPolicies = async (source, databases) => {
let query
if (Array.isArray(databases)) {
query = databases.map((db) => `SHOW RETENTION POLICIES ON "${db}"`).join(';')
} else {
query = `SHOW RETENTION POLICIES ON "${databases}"`
}
return await proxy({source, query})
} }
export function showQueries(source, db) { export function showQueries(source, db) {
@ -38,17 +48,6 @@ export function showTagValues({source, database, retentionPolicy, measurement, t
return proxy({source, db: database, rp: retentionPolicy, query}); return proxy({source, db: database, rp: retentionPolicy, query});
} }
export function showRetentionPolicies(source, databases) {
let query;
if (Array.isArray(databases)) {
query = databases.map((db) => `SHOW RETENTION POLICIES ON "${db}"`).join(';');
} else {
query = `SHOW RETENTION POLICIES ON "${databases}"`;
}
return proxy({source, query});
}
export function showShards() { export function showShards() {
return AJAX({ return AJAX({
url: `/api/int/v1/show-shards`, url: `/api/int/v1/show-shards`,

View File

@ -0,0 +1,23 @@
import React, {PropTypes} from 'react'
const YesNoButtons = ({onConfirm, onCancel}) => (
<div>
<button className="btn btn-xs btn-info" onClick={onCancel}>
<span className="icon remove"></span>
</button>
<button className="btn btn-xs btn-success" onClick={onConfirm}>
<span className="icon checkmark"></span>
</button>
</div>
)
const {
func,
} = PropTypes
YesNoButtons.propTypes = {
onConfirm: func.isRequired,
onCancel: func.isRequired,
}
export default YesNoButtons

View File

@ -1,21 +1,23 @@
export default function parseShowDatabases(response) { const parseShowDatabases = (response) => {
const results = response.results[0]; const results = response.results[0];
if (results.error) { if (results.error) {
return {errors: [results.error], databases: []}; return {errors: [results.error], databases: []}
} }
const series = results.series[0]; const series = results.series[0]
if (!series.values) { if (!series.values) {
return {errors: [], databases: []}; return {errors: [], databases: []}
} }
const databases = series.values.map((s) => { const databases = series.values.map((s) => {
return s[0]; return s[0]
}); });
if (!databases.length) { if (!databases.length) {
alert('No databases were found.'); // eslint-disable-line no-alert alert('No databases were found.'); // eslint-disable-line no-alert
} }
return {errors: [], databases}; return {errors: [], databases}
} }
export default parseShowDatabases

View File

@ -126,4 +126,69 @@
&:first-child {margin-left: 0;} &:first-child {margin-left: 0;}
&:last-child {margin-right: 0;} &:last-child {margin-right: 0;}
} }
} }
.admin-table--delete-cell {
margin: 0 !important;
display: flex !important;
justify-content: space-between;
> input {
height: 30px;
padding: 0 9px;
flex-grow: 1;
margin: 0 2px;
min-width: 110px;
&:first-child {margin-left: 0;}
&:last-child {margin-right: 0;}
}
}
.db-manager-header {
height: 32px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 11px;
h4 {
margin: 0px;
color: $g15-platinum;
}
& .btn {display: none;}
&:hover .btn {display: inline-block;}
}
.db-manager-header-edit {
height: 32px;
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: 11px;
> .form-control {
width: 300px;
margin-right: 8px;
}
.btn {
height: 40px;
width: 40px;
padding: 0;
margin: 0 2px 0 0;
font-size: 18px;
}
}
.db-manager {
margin-top: 18px;
}
.db-manager:first-child {
margin-top: 0;
}
.db-manager-table {
background-color: $g4-onyx;
padding: 9px 11px;
border-radius: $radius-small;
}

View File

@ -52,7 +52,7 @@
background-color: transparent; background-color: transparent;
} }
.panel-body { .panel-body {
padding: 30px; padding: 0px 30px 30px 30px;
} }
.panel-heading { .panel-heading {
padding: 0 30px; padding: 0 30px;

View File

@ -1,4 +1,4 @@
export function formatBytes(bytes) { export const formatBytes = (bytes) => {
if (bytes === 0) { if (bytes === 0) {
return '0 Bytes'; return '0 Bytes';
} }
@ -15,9 +15,15 @@ export function formatBytes(bytes) {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`; return `${parseFloat((bytes / Math.pow(k, i)).toFixed(dm))} ${sizes[i]}`;
} }
export function formatRPDuration(duration) { // Using InfluxDB 1.2+ we should no longer need this formatter.
if (duration === '0') { // Times can now be submitted using multiple units i.e. 1d2h3m
return 'infinite'; export const formatRPDuration = (duration) => {
if (!duration) {
return
}
if (duration === '0' || duration === '0s') {
return '∞';
} }
let adjustedTime = duration; let adjustedTime = duration;
@ -38,3 +44,11 @@ export function formatRPDuration(duration) {
return adjustedTime; return adjustedTime;
} }
export const formatInfiniteDuration = (duration) => {
if (duration === '0' || duration === '0s' || duration === 'INF') {
return '∞';
}
return duration
}