Merge pull request #98 from hashicorp/f-web-ui

Adding the Web UI
This commit is contained in:
Armon Dadgar 2014-05-01 10:24:14 -07:00
commit de427cbda2
58 changed files with 63651 additions and 15 deletions

6
.gitignore vendored
View File

@ -30,3 +30,9 @@ website/build/
website/npm-debug.log
*.old
*.attr
ui/.sass-cache
ui/static/base.css
ui/static/application.min.js
ui/dist/

View File

@ -49,6 +49,7 @@ func (c *Command) readConfig() *Config {
cmdFlags.StringVar(&cmdConfig.NodeName, "node", "", "node name")
cmdFlags.StringVar(&cmdConfig.Datacenter, "dc", "", "node datacenter")
cmdFlags.StringVar(&cmdConfig.DataDir, "data-dir", "", "path to the data directory")
cmdFlags.StringVar(&cmdConfig.UiDir, "ui-dir", "", "path to the web UI directory")
cmdFlags.BoolVar(&cmdConfig.Server, "server", false, "run agent as server")
cmdFlags.BoolVar(&cmdConfig.Bootstrap, "bootstrap", false, "enable server bootstrap mode")
@ -179,7 +180,7 @@ func (c *Command) setupAgent(config *Config, logOutput io.Writer, logWriter *log
return err
}
server, err := NewHTTPServer(agent, config.EnableDebug, logOutput, httpAddr.String())
server, err := NewHTTPServer(agent, config.UiDir, config.EnableDebug, logOutput, httpAddr.String())
if err != nil {
agent.Shutdown()
c.Ui.Error(fmt.Sprintf("Error starting http server: %s", err))
@ -483,6 +484,7 @@ Options:
-node=hostname Name of this node. Must be unique in the cluster
-protocol=N Sets the protocol version. Defaults to latest.
-server Switches agent to server mode.
-ui-dir=path Path to directory containing the Web UI resources
`
return strings.TrimSpace(helpText)

View File

@ -124,6 +124,10 @@ type Config struct {
// addresses, then the agent will error and exit.
StartJoin []string `mapstructure:"start_join"`
// UiDir is the directory containing the Web UI resources.
// If provided, the UI endpoints will be enabled.
UiDir string `mapstructure:"ui_dir"`
// AEInterval controls the anti-entropy interval. This is how often
// the agent attempts to reconcile it's local state with the server'
// representation of our state. Defaults to every 60s.
@ -416,6 +420,9 @@ func MergeConfig(a, b *Config) *Config {
if b.Ports.Server != 0 {
result.Ports.Server = b.Ports.Server
}
if b.UiDir != "" {
result.UiDir = b.UiDir
}
// Copy the start join addresses
result.StartJoin = make([]string, 0, len(a.StartJoin)+len(b.StartJoin))

View File

@ -246,6 +246,17 @@ func TestDecodeConfig(t *testing.T) {
if config.StartJoin[1] != "2.2.2.2" {
t.Fatalf("bad: %#v", config)
}
// UI Dir
input = `{"ui_dir": "/opt/consul-ui"}`
config, err = DecodeConfig(bytes.NewReader([]byte(input)))
if err != nil {
t.Fatalf("err: %s", err)
}
if config.UiDir != "/opt/consul-ui" {
t.Fatalf("bad: %#v", config)
}
}
func TestDecodeConfig_Service(t *testing.T) {
@ -377,6 +388,7 @@ func TestMergeConfig(t *testing.T) {
Checks: []*CheckDefinition{nil},
Services: []*ServiceDefinition{nil},
StartJoin: []string{"1.1.1.1"},
UiDir: "/opt/consul-ui",
}
c := MergeConfig(a, b)

View File

@ -21,11 +21,12 @@ type HTTPServer struct {
mux *http.ServeMux
listener net.Listener
logger *log.Logger
uiDir string
}
// NewHTTPServer starts a new HTTP server to provide an interface to
// the agent.
func NewHTTPServer(agent *Agent, enableDebug bool, logOutput io.Writer, bind string) (*HTTPServer, error) {
func NewHTTPServer(agent *Agent, uiDir string, enableDebug bool, logOutput io.Writer, bind string) (*HTTPServer, error) {
// Create the mux
mux := http.NewServeMux()
@ -41,6 +42,7 @@ func NewHTTPServer(agent *Agent, enableDebug bool, logOutput io.Writer, bind str
mux: mux,
listener: list,
logger: log.New(logOutput, "", log.LstdFlags),
uiDir: uiDir,
}
srv.registerHandlers(enableDebug)
@ -97,6 +99,17 @@ func (s *HTTPServer) registerHandlers(enableDebug bool) {
s.mux.HandleFunc("/debug/pprof/profile", pprof.Profile)
s.mux.HandleFunc("/debug/pprof/symbol", pprof.Symbol)
}
// Enable the UI + special endpoints
if s.uiDir != "" {
// Static file serving done from /ui/
s.mux.Handle("/ui/", http.StripPrefix("/ui/", http.FileServer(http.Dir(s.uiDir))))
// API's are under /internal/ui/ to avoid conflict
s.mux.HandleFunc("/v1/internal/ui/nodes", s.wrap(s.UINodes))
s.mux.HandleFunc("/v1/internal/ui/node/", s.wrap(s.UINodeInfo))
s.mux.HandleFunc("/v1/internal/ui/services", s.wrap(s.UIServices))
}
}
// wrap is used to wrap functions to make them more convenient
@ -134,11 +147,20 @@ func (s *HTTPServer) wrap(handler func(resp http.ResponseWriter, req *http.Reque
// Renders a simple index page
func (s *HTTPServer) Index(resp http.ResponseWriter, req *http.Request) {
if req.URL.Path == "/" {
resp.Write([]byte("Consul Agent"))
} else {
// Check if this is a non-index path
if req.URL.Path != "/" {
resp.WriteHeader(404)
return
}
// Check if we have no UI configured
if s.uiDir == "" {
resp.Write([]byte("Consul Agent"))
return
}
// Redirect to the UI endpoint
http.Redirect(resp, req, "/ui/", 301)
}
// decodeBody is used to decode a JSON request body

View File

@ -9,6 +9,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"testing"
"time"
@ -17,8 +18,12 @@ import (
func makeHTTPServer(t *testing.T) (string, *HTTPServer) {
conf := nextConfig()
dir, agent := makeAgent(t, conf)
uiDir := filepath.Join(dir, "ui")
if err := os.Mkdir(uiDir, 755); err != nil {
t.Fatalf("err: %v", err)
}
addr, _ := agent.config.ClientListener(agent.config.Ports.HTTP)
server, err := NewHTTPServer(agent, true, agent.logOutput, addr.String())
server, err := NewHTTPServer(agent, uiDir, true, agent.logOutput, addr.String())
if err != nil {
t.Fatalf("err: %v", err)
}

View File

@ -19,10 +19,21 @@ func (s *HTTPServer) KVSEndpoint(resp http.ResponseWriter, req *http.Request) (i
// Pull out the key name, validation left to each sub-handler
args.Key = strings.TrimPrefix(req.URL.Path, "/v1/kv/")
// Check for a key list
keyList := false
params := req.URL.Query()
if _, ok := params["keys"]; ok {
keyList = true
}
// Switch on the method
switch req.Method {
case "GET":
return s.KVSGet(resp, req, &args)
if keyList {
return s.KVSGetKeys(resp, req, &args)
} else {
return s.KVSGet(resp, req, &args)
}
case "PUT":
return s.KVSPut(resp, req, &args)
case "DELETE":
@ -60,6 +71,44 @@ func (s *HTTPServer) KVSGet(resp http.ResponseWriter, req *http.Request, args *s
return out.Entries, nil
}
// KVSGetKeys handles a GET request for keys
func (s *HTTPServer) KVSGetKeys(resp http.ResponseWriter, req *http.Request, args *structs.KeyRequest) (interface{}, error) {
// Check for a seperator
var sep string
params := req.URL.Query()
if _, ok := params["seperator"]; ok {
sep = params.Get("seperator")
}
// Construct the args
listArgs := structs.KeyListRequest{
Datacenter: args.Datacenter,
Prefix: args.Key,
Seperator: sep,
QueryOptions: args.QueryOptions,
}
// Make the RPC
var out structs.IndexedKeyList
if err := s.agent.RPC("KVS.ListKeys", &listArgs, &out); err != nil {
return nil, err
}
setMeta(resp, &out.QueryMeta)
// Check if we get a not found. We do not generate
// not found for the root, but just provide the empty list
if len(out.Keys) == 0 && listArgs.Prefix != "" {
resp.WriteHeader(404)
return nil, nil
}
// Use empty list instead of null
if out.Keys == nil {
out.Keys = []string{}
}
return out.Keys, nil
}
// KVSPut handles a PUT request
func (s *HTTPServer) KVSPut(resp http.ResponseWriter, req *http.Request, args *structs.KeyRequest) (interface{}, error) {
if missingKey(resp, args) {

View File

@ -7,6 +7,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"reflect"
"testing"
"time"
)
@ -281,3 +282,64 @@ func TestKVSEndpoint_CAS(t *testing.T) {
t.Fatalf("bad: %v", d)
}
}
func TestKVSEndpoint_ListKeys(t *testing.T) {
dir, srv := makeHTTPServer(t)
defer os.RemoveAll(dir)
defer srv.Shutdown()
defer srv.agent.Shutdown()
// Wait for a leader
time.Sleep(100 * time.Millisecond)
keys := []string{
"bar",
"baz",
"foo/sub1",
"foo/sub2",
"zip",
}
for _, key := range keys {
buf := bytes.NewBuffer([]byte("test"))
req, err := http.NewRequest("PUT", "/v1/kv/"+key, buf)
if err != nil {
t.Fatalf("err: %v", err)
}
resp := httptest.NewRecorder()
obj, err := srv.KVSEndpoint(resp, req)
if err != nil {
t.Fatalf("err: %v", err)
}
if res := obj.(bool); !res {
t.Fatalf("should work")
}
}
{
// Get all the keys
req, err := http.NewRequest("GET", "/v1/kv/?keys&seperator=/", nil)
if err != nil {
t.Fatalf("err: %v", err)
}
resp := httptest.NewRecorder()
obj, err := srv.KVSEndpoint(resp, req)
if err != nil {
t.Fatalf("err: %v", err)
}
assertIndex(t, resp)
res, ok := obj.([]string)
if !ok {
t.Fatalf("should work")
}
expect := []string{"bar", "baz", "foo/", "zip"}
if !reflect.DeepEqual(res, expect) {
t.Fatalf("bad: %v", res)
}
}
}

View File

@ -0,0 +1,169 @@
package agent
import (
"github.com/hashicorp/consul/consul/structs"
"net/http"
"sort"
"strings"
)
// ServiceSummary is used to summarize a service
type ServiceSummary struct {
Name string
Nodes []string
ChecksPassing int
ChecksWarning int
ChecksCritical int
}
// UINodes is used to list the nodes in a given datacenter. We return a
// NodeDump which provides overview information for all the nodes
func (s *HTTPServer) UINodes(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Get the datacenter
var dc string
s.parseDC(req, &dc)
// Try to ge ta node dump
var dump structs.NodeDump
if err := s.getNodeDump(resp, dc, "", &dump); err != nil {
return nil, err
}
return dump, nil
}
// UINodeInfo is used to get info on a single node in a given datacenter. We return a
// NodeInfo which provides overview information for the node
func (s *HTTPServer) UINodeInfo(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Get the datacenter
var dc string
s.parseDC(req, &dc)
// Verify we have some DC, or use the default
node := strings.TrimPrefix(req.URL.Path, "/v1/internal/ui/node/")
if node == "" {
resp.WriteHeader(400)
resp.Write([]byte("Missing node name"))
return nil, nil
}
// Try to get a node dump
var dump structs.NodeDump
if err := s.getNodeDump(resp, dc, node, &dump); err != nil {
return nil, err
}
// Return only the first entry
if len(dump) > 0 {
return dump[0], nil
}
return nil, nil
}
// getNodeDump is used to get a dump of all node data. We make a best effort by
// reading stale data in the case of an availability outage.
func (s *HTTPServer) getNodeDump(resp http.ResponseWriter, dc, node string, dump *structs.NodeDump) error {
var args interface{}
var method string
var allowStale *bool
if node == "" {
raw := structs.DCSpecificRequest{Datacenter: dc}
method = "Internal.NodeDump"
allowStale = &raw.AllowStale
args = &raw
} else {
raw := &structs.NodeSpecificRequest{Datacenter: dc, Node: node}
method = "Internal.NodeInfo"
allowStale = &raw.AllowStale
args = &raw
}
var out structs.IndexedNodeDump
defer setMeta(resp, &out.QueryMeta)
START:
if err := s.agent.RPC(method, args, &out); err != nil {
// Retry the request allowing stale data if no leader. The UI should continue
// to function even during an outage
if strings.Contains(err.Error(), structs.ErrNoLeader.Error()) && !*allowStale {
*allowStale = true
goto START
}
return err
}
// Set the result
*dump = out.Dump
return nil
}
// UIServices is used to list the services in a given datacenter. We return a
// ServiceSummary which provides overview information for the service
func (s *HTTPServer) UIServices(resp http.ResponseWriter, req *http.Request) (interface{}, error) {
// Get the datacenter
var dc string
s.parseDC(req, &dc)
// Get the full node dump...
var dump structs.NodeDump
if err := s.getNodeDump(resp, dc, "", &dump); err != nil {
return nil, err
}
// Generate the summary
return summarizeServices(dump), nil
}
func summarizeServices(dump structs.NodeDump) []*ServiceSummary {
// Collect the summary information
var services []string
summary := make(map[string]*ServiceSummary)
getService := func(service string) *ServiceSummary {
serv, ok := summary[service]
if !ok {
serv = &ServiceSummary{Name: service}
summary[service] = serv
services = append(services, service)
}
return serv
}
// Aggregate all the node information
for _, node := range dump {
nodeServices := make([]*ServiceSummary, len(node.Services))
for idx, service := range node.Services {
sum := getService(service.Service)
sum.Nodes = append(sum.Nodes, node.Node)
nodeServices[idx] = sum
}
for _, check := range node.Checks {
var services []*ServiceSummary
if check.ServiceName == "" {
services = nodeServices
} else {
services = []*ServiceSummary{getService(check.ServiceName)}
}
for _, sum := range services {
switch check.Status {
case structs.HealthPassing:
sum.ChecksPassing++
case structs.HealthWarning:
sum.ChecksWarning++
case structs.HealthCritical:
sum.ChecksCritical++
}
}
}
}
// Return the services in sorted order
sort.Strings(services)
output := make([]*ServiceSummary, len(summary))
for idx, service := range services {
// Sort the nodes
sum := summary[service]
sort.Strings(sum.Nodes)
output[idx] = sum
}
return output
}

View File

@ -0,0 +1,205 @@
package agent
import (
"bytes"
"fmt"
"github.com/hashicorp/consul/consul/structs"
"io"
"io/ioutil"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"testing"
"time"
)
func TestUiIndex(t *testing.T) {
dir, srv := makeHTTPServer(t)
defer os.RemoveAll(dir)
defer srv.Shutdown()
defer srv.agent.Shutdown()
// Create file
path := filepath.Join(srv.uiDir, "my-file")
if err := ioutil.WriteFile(path, []byte("test"), 777); err != nil {
t.Fatalf("err: %v", err)
}
// Register node
req, err := http.NewRequest("GET", "/ui/my-file", nil)
if err != nil {
t.Fatalf("err: %v", err)
}
req.URL.Scheme = "http"
req.URL.Host = srv.listener.Addr().String()
// Make the request
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("err: %v", err)
}
// Verify teh response
if resp.StatusCode != 200 {
t.Fatalf("bad: %v", resp)
}
// Verify the body
out := bytes.NewBuffer(nil)
io.Copy(out, resp.Body)
if string(out.Bytes()) != "test" {
t.Fatalf("bad: %s", out.Bytes())
}
}
func TestUiNodes(t *testing.T) {
dir, srv := makeHTTPServer(t)
defer os.RemoveAll(dir)
defer srv.Shutdown()
defer srv.agent.Shutdown()
// Wait for leader
time.Sleep(100 * time.Millisecond)
req, err := http.NewRequest("GET", "/v1/internal/ui/nodes/dc1", nil)
if err != nil {
t.Fatalf("err: %v", err)
}
resp := httptest.NewRecorder()
obj, err := srv.UINodes(resp, req)
if err != nil {
t.Fatalf("err: %v", err)
}
assertIndex(t, resp)
// Should be 1 node for the server
nodes := obj.(structs.NodeDump)
if len(nodes) != 1 {
t.Fatalf("bad: %v", obj)
}
}
func TestUiNodeInfo(t *testing.T) {
dir, srv := makeHTTPServer(t)
defer os.RemoveAll(dir)
defer srv.Shutdown()
defer srv.agent.Shutdown()
// Wait for leader
time.Sleep(100 * time.Millisecond)
req, err := http.NewRequest("GET",
fmt.Sprintf("/v1/internal/ui/node/%s", srv.agent.config.NodeName), nil)
if err != nil {
t.Fatalf("err: %v", err)
}
resp := httptest.NewRecorder()
obj, err := srv.UINodeInfo(resp, req)
if err != nil {
t.Fatalf("err: %v", err)
}
assertIndex(t, resp)
// Should be 1 node for the server
node := obj.(*structs.NodeInfo)
if node.Node != srv.agent.config.NodeName {
t.Fatalf("bad: %v", node)
}
}
func TestSummarizeServices(t *testing.T) {
dump := structs.NodeDump{
&structs.NodeInfo{
Node: "foo",
Address: "127.0.0.1",
Services: []*structs.NodeService{
&structs.NodeService{
Service: "api",
},
&structs.NodeService{
Service: "web",
},
},
Checks: []*structs.HealthCheck{
&structs.HealthCheck{
Status: structs.HealthPassing,
ServiceName: "",
},
&structs.HealthCheck{
Status: structs.HealthPassing,
ServiceName: "web",
},
&structs.HealthCheck{
Status: structs.HealthWarning,
ServiceName: "api",
},
},
},
&structs.NodeInfo{
Node: "bar",
Address: "127.0.0.2",
Services: []*structs.NodeService{
&structs.NodeService{
Service: "web",
},
},
Checks: []*structs.HealthCheck{
&structs.HealthCheck{
Status: structs.HealthCritical,
ServiceName: "web",
},
},
},
&structs.NodeInfo{
Node: "zip",
Address: "127.0.0.3",
Services: []*structs.NodeService{
&structs.NodeService{
Service: "cache",
},
},
},
}
summary := summarizeServices(dump)
if len(summary) != 3 {
t.Fatalf("bad: %v", summary)
}
expectAPI := &ServiceSummary{
Name: "api",
Nodes: []string{"foo"},
ChecksPassing: 1,
ChecksWarning: 1,
ChecksCritical: 0,
}
if !reflect.DeepEqual(summary[0], expectAPI) {
t.Fatalf("bad: %v", summary[0])
}
expectCache := &ServiceSummary{
Name: "cache",
Nodes: []string{"zip"},
ChecksPassing: 0,
ChecksWarning: 0,
ChecksCritical: 0,
}
if !reflect.DeepEqual(summary[1], expectCache) {
t.Fatalf("bad: %v", summary[1])
}
expectWeb := &ServiceSummary{
Name: "web",
Nodes: []string{"bar", "foo"},
ChecksPassing: 2,
ChecksWarning: 0,
ChecksCritical: 1,
}
if !reflect.DeepEqual(summary[2], expectWeb) {
t.Fatalf("bad: %v", summary[2])
}
}

View File

@ -0,0 +1,48 @@
package consul
import (
"github.com/hashicorp/consul/consul/structs"
)
// Internal endpoint is used to query the miscellaneous info that
// does not necessarily fit into the other systems. It is also
// used to hold undocumented APIs that users should not rely on.
type Internal struct {
srv *Server
}
// ChecksInState is used to get all the checks in a given state
func (m *Internal) NodeInfo(args *structs.NodeSpecificRequest,
reply *structs.IndexedNodeDump) error {
if done, err := m.srv.forward("Internal.NodeInfo", args, args, reply); done {
return err
}
// Get the state specific checks
state := m.srv.fsm.State()
return m.srv.blockingRPC(&args.QueryOptions,
&reply.QueryMeta,
state.QueryTables("NodeInfo"),
func() error {
reply.Index, reply.Dump = state.NodeInfo(args.Node)
return nil
})
}
// ChecksInState is used to get all the checks in a given state
func (m *Internal) NodeDump(args *structs.DCSpecificRequest,
reply *structs.IndexedNodeDump) error {
if done, err := m.srv.forward("Internal.NodeDump", args, args, reply); done {
return err
}
// Get the state specific checks
state := m.srv.fsm.State()
return m.srv.blockingRPC(&args.QueryOptions,
&reply.QueryMeta,
state.QueryTables("NodeDump"),
func() error {
reply.Index, reply.Dump = state.NodeDump()
return nil
})
}

View File

@ -0,0 +1,154 @@
package consul
import (
"github.com/hashicorp/consul/consul/structs"
"os"
"testing"
"time"
)
func TestInternal_NodeInfo(t *testing.T) {
dir1, s1 := testServer(t)
defer os.RemoveAll(dir1)
defer s1.Shutdown()
client := rpcClient(t, s1)
defer client.Close()
// Wait for leader
time.Sleep(100 * time.Millisecond)
arg := structs.RegisterRequest{
Datacenter: "dc1",
Node: "foo",
Address: "127.0.0.1",
Service: &structs.NodeService{
ID: "db",
Service: "db",
Tags: []string{"master"},
},
Check: &structs.HealthCheck{
Name: "db connect",
Status: structs.HealthPassing,
ServiceID: "db",
},
}
var out struct{}
if err := client.Call("Catalog.Register", &arg, &out); err != nil {
t.Fatalf("err: %v", err)
}
var out2 structs.IndexedNodeDump
req := structs.NodeSpecificRequest{
Datacenter: "dc1",
Node: "foo",
}
if err := client.Call("Internal.NodeInfo", &req, &out2); err != nil {
t.Fatalf("err: %v", err)
}
nodes := out2.Dump
if len(nodes) != 1 {
t.Fatalf("Bad: %v", nodes)
}
if nodes[0].Node != "foo" {
t.Fatalf("Bad: %v", nodes[0])
}
if !strContains(nodes[0].Services[0].Tags, "master") {
t.Fatalf("Bad: %v", nodes[0])
}
if nodes[0].Checks[0].Status != structs.HealthPassing {
t.Fatalf("Bad: %v", nodes[0])
}
}
func TestInternal_NodeDump(t *testing.T) {
dir1, s1 := testServer(t)
defer os.RemoveAll(dir1)
defer s1.Shutdown()
client := rpcClient(t, s1)
defer client.Close()
// Wait for leader
time.Sleep(100 * time.Millisecond)
arg := structs.RegisterRequest{
Datacenter: "dc1",
Node: "foo",
Address: "127.0.0.1",
Service: &structs.NodeService{
ID: "db",
Service: "db",
Tags: []string{"master"},
},
Check: &structs.HealthCheck{
Name: "db connect",
Status: structs.HealthPassing,
ServiceID: "db",
},
}
var out struct{}
if err := client.Call("Catalog.Register", &arg, &out); err != nil {
t.Fatalf("err: %v", err)
}
arg = structs.RegisterRequest{
Datacenter: "dc1",
Node: "bar",
Address: "127.0.0.2",
Service: &structs.NodeService{
ID: "db",
Service: "db",
Tags: []string{"slave"},
},
Check: &structs.HealthCheck{
Name: "db connect",
Status: structs.HealthWarning,
ServiceID: "db",
},
}
if err := client.Call("Catalog.Register", &arg, &out); err != nil {
t.Fatalf("err: %v", err)
}
var out2 structs.IndexedNodeDump
req := structs.DCSpecificRequest{
Datacenter: "dc1",
}
if err := client.Call("Internal.NodeDump", &req, &out2); err != nil {
t.Fatalf("err: %v", err)
}
nodes := out2.Dump
if len(nodes) != 3 {
t.Fatalf("Bad: %v", nodes)
}
var foundFoo, foundBar bool
for _, node := range nodes {
switch node.Node {
case "foo":
foundFoo = true
if !strContains(node.Services[0].Tags, "master") {
t.Fatalf("Bad: %v", nodes[0])
}
if node.Checks[0].Status != structs.HealthPassing {
t.Fatalf("Bad: %v", nodes[0])
}
case "bar":
foundBar = true
if !strContains(node.Services[0].Tags, "slave") {
t.Fatalf("Bad: %v", nodes[1])
}
if node.Checks[0].Status != structs.HealthWarning {
t.Fatalf("Bad: %v", nodes[1])
}
default:
continue
}
}
if !foundFoo || !foundBar {
t.Fatalf("missing foo or bar")
}
}

View File

@ -115,3 +115,21 @@ func (k *KVS) List(args *structs.KeyRequest, reply *structs.IndexedDirEntries) e
return nil
})
}
// ListKeys is used to list all keys with a given prefix to a seperator
func (k *KVS) ListKeys(args *structs.KeyListRequest, reply *structs.IndexedKeyList) error {
if done, err := k.srv.forward("KVS.ListKeys", args, args, reply); done {
return err
}
// Get the local state
state := k.srv.fsm.State()
return k.srv.blockingRPC(&args.QueryOptions,
&reply.QueryMeta,
state.QueryTables("KVSListKeys"),
func() error {
var err error
reply.Index, reply.Keys, err = state.KVSListKeys(args.Prefix, args.Seperator)
return err
})
}

View File

@ -171,3 +171,62 @@ func TestKVSEndpoint_List(t *testing.T) {
}
}
}
func TestKVSEndpoint_ListKeys(t *testing.T) {
dir1, s1 := testServer(t)
defer os.RemoveAll(dir1)
defer s1.Shutdown()
client := rpcClient(t, s1)
defer client.Close()
// Wait for leader
time.Sleep(100 * time.Millisecond)
keys := []string{
"/test/key1",
"/test/key2",
"/test/sub/key3",
}
for _, key := range keys {
arg := structs.KVSRequest{
Datacenter: "dc1",
Op: structs.KVSSet,
DirEnt: structs.DirEntry{
Key: key,
Flags: 1,
},
}
var out bool
if err := client.Call("KVS.Apply", &arg, &out); err != nil {
t.Fatalf("err: %v", err)
}
}
getR := structs.KeyListRequest{
Datacenter: "dc1",
Prefix: "/test/",
Seperator: "/",
}
var dirent structs.IndexedKeyList
if err := client.Call("KVS.ListKeys", &getR, &dirent); err != nil {
t.Fatalf("err: %v", err)
}
if dirent.Index == 0 {
t.Fatalf("Bad: %v", dirent)
}
if len(dirent.Keys) != 3 {
t.Fatalf("Bad: %v", dirent.Keys)
}
if dirent.Keys[0] != "/test/key1" {
t.Fatalf("Bad: %v", dirent.Keys)
}
if dirent.Keys[1] != "/test/key2" {
t.Fatalf("Bad: %v", dirent.Keys)
}
if dirent.Keys[2] != "/test/sub/" {
t.Fatalf("Bad: %v", dirent.Keys)
}
}

View File

@ -107,11 +107,12 @@ type Server struct {
// Holds the RPC endpoints
type endpoints struct {
Catalog *Catalog
Health *Health
Raft *Raft
Status *Status
KVS *KVS
Catalog *Catalog
Health *Health
Raft *Raft
Status *Status
KVS *KVS
Internal *Internal
}
// NewServer is used to construct a new Consul server from the
@ -311,6 +312,7 @@ func (s *Server) setupRPC(tlsConfig *tls.Config) error {
s.endpoints.Catalog = &Catalog{s}
s.endpoints.Health = &Health{s}
s.endpoints.KVS = &KVS{s}
s.endpoints.Internal = &Internal{s}
// Register the handlers
s.rpcServer.Register(s.endpoints.Status)
@ -318,6 +320,7 @@ func (s *Server) setupRPC(tlsConfig *tls.Config) error {
s.rpcServer.Register(s.endpoints.Catalog)
s.rpcServer.Register(s.endpoints.Health)
s.rpcServer.Register(s.endpoints.KVS)
s.rpcServer.Register(s.endpoints.Internal)
list, err := net.ListenTCP("tcp", s.config.RPCAddr)
if err != nil {

View File

@ -9,6 +9,7 @@ import (
"log"
"os"
"runtime"
"strings"
)
const (
@ -241,8 +242,11 @@ func (s *StateStore) initialize() error {
"NodeChecks": MDBTables{s.checkTable},
"ServiceChecks": MDBTables{s.checkTable},
"CheckServiceNodes": MDBTables{s.nodeTable, s.serviceTable, s.checkTable},
"NodeInfo": MDBTables{s.nodeTable, s.serviceTable, s.checkTable},
"NodeDump": MDBTables{s.nodeTable, s.serviceTable, s.checkTable},
"KVSGet": MDBTables{s.kvsTable},
"KVSList": MDBTables{s.kvsTable},
"KVSListKeys": MDBTables{s.kvsTable},
}
return nil
}
@ -743,6 +747,94 @@ func (s *StateStore) parseCheckServiceNodes(tx *MDBTxn, res []interface{}, err e
return nodes
}
// NodeInfo is used to generate the full info about a node.
func (s *StateStore) NodeInfo(node string) (uint64, structs.NodeDump) {
tables := s.queryTables["NodeInfo"]
tx, err := tables.StartTxn(true)
if err != nil {
panic(fmt.Errorf("Failed to start txn: %v", err))
}
defer tx.Abort()
idx, err := tables.LastIndexTxn(tx)
if err != nil {
panic(fmt.Errorf("Failed to get last index: %v", err))
}
res, err := s.nodeTable.GetTxn(tx, "id", node)
return idx, s.parseNodeInfo(tx, res, err)
}
// NodeDump is used to generate the NodeInfo for all nodes. This is very expensive,
// and should generally be avoided for programatic access.
func (s *StateStore) NodeDump() (uint64, structs.NodeDump) {
tables := s.queryTables["NodeDump"]
tx, err := tables.StartTxn(true)
if err != nil {
panic(fmt.Errorf("Failed to start txn: %v", err))
}
defer tx.Abort()
idx, err := tables.LastIndexTxn(tx)
if err != nil {
panic(fmt.Errorf("Failed to get last index: %v", err))
}
res, err := s.nodeTable.GetTxn(tx, "id")
return idx, s.parseNodeInfo(tx, res, err)
}
// parseNodeInfo is used to scan over the results of a node
// iteration and generate a NodeDump
func (s *StateStore) parseNodeInfo(tx *MDBTxn, res []interface{}, err error) structs.NodeDump {
dump := make(structs.NodeDump, 0, len(res))
if err != nil {
s.logger.Printf("[ERR] consul.state: Failed to get nodes: %v", err)
return dump
}
for _, r := range res {
// Copy the address and node
node := r.(*structs.Node)
info := &structs.NodeInfo{
Node: node.Node,
Address: node.Address,
}
// Get any services of the node
res, err = s.serviceTable.GetTxn(tx, "id", node.Node)
if err != nil {
s.logger.Printf("[ERR] consul.state: Failed to get node services: %v", err)
}
info.Services = make([]*structs.NodeService, 0, len(res))
for _, r := range res {
service := r.(*structs.ServiceNode)
srv := &structs.NodeService{
ID: service.ServiceID,
Service: service.ServiceName,
Tags: service.ServiceTags,
Port: service.ServicePort,
}
info.Services = append(info.Services, srv)
}
// Get any checks of the node
res, err = s.checkTable.GetTxn(tx, "node", node.Node)
if err != nil {
s.logger.Printf("[ERR] consul.state: Failed to get node checks: %v", err)
}
info.Checks = make([]*structs.HealthCheck, 0, len(res))
for _, r := range res {
chk := r.(*structs.HealthCheck)
info.Checks = append(info.Checks, chk)
}
// Add the node info
dump = append(dump, info)
}
return dump
}
// KVSSet is used to create or update a KV entry
func (s *StateStore) KVSSet(index uint64, d *structs.DirEntry) error {
// Start a new txn
@ -812,6 +904,57 @@ func (s *StateStore) KVSList(prefix string) (uint64, structs.DirEntries, error)
return idx, ents, err
}
// KVSListKeys is used to list keys with a prefix, and up to a given seperator
func (s *StateStore) KVSListKeys(prefix, seperator string) (uint64, []string, error) {
tx, err := s.kvsTable.StartTxn(true, nil)
if err != nil {
return 0, nil, err
}
defer tx.Abort()
idx, err := s.kvsTable.LastIndexTxn(tx)
if err != nil {
return 0, nil, err
}
// Aggregate the stream
stream := make(chan interface{}, 128)
done := make(chan struct{})
var keys []string
go func() {
prefixLen := len(prefix)
sepLen := len(seperator)
last := ""
for raw := range stream {
ent := raw.(*structs.DirEntry)
after := ent.Key[prefixLen:]
// If there is no seperator, always accumulate
if sepLen == 0 {
keys = append(keys, ent.Key)
continue
}
// Check for the seperator
if idx := strings.Index(after, seperator); idx >= 0 {
toSep := ent.Key[:prefixLen+idx+sepLen]
if last != toSep {
keys = append(keys, toSep)
last = toSep
}
} else {
keys = append(keys, ent.Key)
}
}
close(done)
}()
// Start the stream, and wait for completion
err = s.kvsTable.StreamTxn(stream, tx, "id_prefix", prefix)
<-done
return idx, keys, err
}
// KVSDelete is used to delete a KVS entry
func (s *StateStore) KVSDelete(index uint64, key string) error {
return s.kvsDeleteWithIndex(index, "id", key)

View File

@ -1068,6 +1068,109 @@ func TestSS_Register_Deregister_Query(t *testing.T) {
}
}
func TestNodeInfo(t *testing.T) {
store, err := testStateStore()
if err != nil {
t.Fatalf("err: %v", err)
}
defer store.Close()
if err := store.EnsureNode(1, structs.Node{"foo", "127.0.0.1"}); err != nil {
t.Fatalf("err: %v", err)
}
if err := store.EnsureService(2, "foo", &structs.NodeService{"db1", "db", []string{"master"}, 8000}); err != nil {
t.Fatalf("err: %v")
}
check := &structs.HealthCheck{
Node: "foo",
CheckID: "db",
Name: "Can connect",
Status: structs.HealthPassing,
ServiceID: "db1",
}
if err := store.EnsureCheck(3, check); err != nil {
t.Fatalf("err: %v")
}
check = &structs.HealthCheck{
Node: "foo",
CheckID: SerfCheckID,
Name: SerfCheckName,
Status: structs.HealthPassing,
}
if err := store.EnsureCheck(4, check); err != nil {
t.Fatalf("err: %v")
}
idx, dump := store.NodeInfo("foo")
if idx != 4 {
t.Fatalf("bad: %v", idx)
}
if len(dump) != 1 {
t.Fatalf("Bad: %v", dump)
}
info := dump[0]
if info.Node != "foo" {
t.Fatalf("Bad: %v", info)
}
if info.Services[0].ID != "db1" {
t.Fatalf("Bad: %v", info)
}
if len(info.Checks) != 2 {
t.Fatalf("Bad: %v", info)
}
if info.Checks[0].CheckID != "db" {
t.Fatalf("Bad: %v", info)
}
if info.Checks[1].CheckID != SerfCheckID {
t.Fatalf("Bad: %v", info)
}
}
func TestNodeDump(t *testing.T) {
store, err := testStateStore()
if err != nil {
t.Fatalf("err: %v", err)
}
defer store.Close()
if err := store.EnsureNode(1, structs.Node{"foo", "127.0.0.1"}); err != nil {
t.Fatalf("err: %v", err)
}
if err := store.EnsureService(2, "foo", &structs.NodeService{"db1", "db", []string{"master"}, 8000}); err != nil {
t.Fatalf("err: %v")
}
if err := store.EnsureNode(3, structs.Node{"baz", "127.0.0.2"}); err != nil {
t.Fatalf("err: %v", err)
}
if err := store.EnsureService(4, "baz", &structs.NodeService{"db1", "db", []string{"master"}, 8000}); err != nil {
t.Fatalf("err: %v")
}
idx, dump := store.NodeDump()
if idx != 4 {
t.Fatalf("bad: %v", idx)
}
if len(dump) != 2 {
t.Fatalf("Bad: %v", dump)
}
info := dump[0]
if info.Node != "baz" {
t.Fatalf("Bad: %v", info)
}
if info.Services[0].ID != "db1" {
t.Fatalf("Bad: %v", info)
}
info = dump[1]
if info.Node != "foo" {
t.Fatalf("Bad: %v", info)
}
if info.Services[0].ID != "db1" {
t.Fatalf("Bad: %v", info)
}
}
func TestKVSSet_Get(t *testing.T) {
store, err := testStateStore()
if err != nil {
@ -1298,6 +1401,121 @@ func TestKVS_List(t *testing.T) {
}
}
func TestKVS_ListKeys(t *testing.T) {
store, err := testStateStore()
if err != nil {
t.Fatalf("err: %v", err)
}
defer store.Close()
// Should not exist
idx, keys, err := store.KVSListKeys("", "/")
if err != nil {
t.Fatalf("err: %v", err)
}
if idx != 0 {
t.Fatalf("bad: %v", idx)
}
if len(keys) != 0 {
t.Fatalf("bad: %v", keys)
}
// Create the entries
d := &structs.DirEntry{Key: "/web/a", Flags: 42, Value: []byte("test")}
if err := store.KVSSet(1000, d); err != nil {
t.Fatalf("err: %v", err)
}
d = &structs.DirEntry{Key: "/web/b", Flags: 42, Value: []byte("test")}
if err := store.KVSSet(1001, d); err != nil {
t.Fatalf("err: %v", err)
}
d = &structs.DirEntry{Key: "/web/sub/c", Flags: 42, Value: []byte("test")}
if err := store.KVSSet(1002, d); err != nil {
t.Fatalf("err: %v", err)
}
// Should list
idx, keys, err = store.KVSListKeys("", "/")
if err != nil {
t.Fatalf("err: %v", err)
}
if idx != 1002 {
t.Fatalf("bad: %v", idx)
}
if len(keys) != 1 {
t.Fatalf("bad: %v", keys)
}
if keys[0] != "/" {
t.Fatalf("bad: %v", keys)
}
// Should list just web
idx, keys, err = store.KVSListKeys("/", "/")
if err != nil {
t.Fatalf("err: %v", err)
}
if idx != 1002 {
t.Fatalf("bad: %v", idx)
}
if len(keys) != 1 {
t.Fatalf("bad: %v", keys)
}
if keys[0] != "/web/" {
t.Fatalf("bad: %v", keys)
}
// Should list a, b, sub/
idx, keys, err = store.KVSListKeys("/web/", "/")
if err != nil {
t.Fatalf("err: %v", err)
}
if idx != 1002 {
t.Fatalf("bad: %v", idx)
}
if len(keys) != 3 {
t.Fatalf("bad: %v", keys)
}
if keys[0] != "/web/a" {
t.Fatalf("bad: %v", keys)
}
if keys[1] != "/web/b" {
t.Fatalf("bad: %v", keys)
}
if keys[2] != "/web/sub/" {
t.Fatalf("bad: %v", keys)
}
// Should list c
idx, keys, err = store.KVSListKeys("/web/sub/", "/")
if err != nil {
t.Fatalf("err: %v", err)
}
if idx != 1002 {
t.Fatalf("bad: %v", idx)
}
if len(keys) != 1 {
t.Fatalf("bad: %v", keys)
}
if keys[0] != "/web/sub/c" {
t.Fatalf("bad: %v", keys)
}
// Should list all
idx, keys, err = store.KVSListKeys("/web/", "")
if err != nil {
t.Fatalf("err: %v", err)
}
if idx != 1002 {
t.Fatalf("bad: %v", idx)
}
if len(keys) != 3 {
t.Fatalf("bad: %v", keys)
}
if keys[2] != "/web/sub/c" {
t.Fatalf("bad: %v", keys)
}
}
func TestKVSDeleteTree(t *testing.T) {
store, err := testStateStore()
if err != nil {

View File

@ -220,6 +220,21 @@ type CheckServiceNode struct {
}
type CheckServiceNodes []CheckServiceNode
// NodeInfo is used to dump all associated information about
// a node. This is currently used for the UI only, as it is
// rather expensive to generate.
type NodeInfo struct {
Node string
Address string
Services []*NodeService
Checks []*HealthCheck
}
// NodeDump is used to dump all the nodes with all their
// associated data. This is currently used for the UI only,
// as it is rather expensive to generate.
type NodeDump []*NodeInfo
type IndexedNodes struct {
Nodes Nodes
QueryMeta
@ -250,6 +265,11 @@ type IndexedCheckServiceNodes struct {
QueryMeta
}
type IndexedNodeDump struct {
Dump NodeDump
QueryMeta
}
// DirEntry is used to represent a directory entry. This is
// used for values in our Key-Value store.
type DirEntry struct {
@ -293,11 +313,28 @@ func (r *KeyRequest) RequestDatacenter() string {
return r.Datacenter
}
// KeyListRequest is used to list keys
type KeyListRequest struct {
Datacenter string
Prefix string
Seperator string
QueryOptions
}
func (r *KeyListRequest) RequestDatacenter() string {
return r.Datacenter
}
type IndexedDirEntries struct {
Entries DirEntries
QueryMeta
}
type IndexedKeyList struct {
Keys []string
QueryMeta
}
// Decode is used to decode a MsgPack encoded object
func Decode(buf []byte, out interface{}) error {
var handle codec.MsgpackHandle

6
ui/Gemfile Normal file
View File

@ -0,0 +1,6 @@
# A sample Gemfile
source "https://rubygems.org"
gem "uglifier"
gem "sass"
gem "therubyracer"

22
ui/Gemfile.lock Normal file
View File

@ -0,0 +1,22 @@
GEM
remote: https://rubygems.org/
specs:
execjs (2.0.2)
json (1.8.1)
libv8 (3.16.14.3)
ref (1.0.5)
sass (3.3.6)
therubyracer (0.12.1)
libv8 (~> 3.16.14.0)
ref
uglifier (2.5.0)
execjs (>= 0.3.0)
json (>= 1.8.0)
PLATFORMS
ruby
DEPENDENCIES
sass
therubyracer
uglifier

17
ui/Makefile Normal file
View File

@ -0,0 +1,17 @@
server:
python -m SimpleHTTPServer
watch:
sass styles:static --watch
dist:
@echo clean dist
@rm -rf dist/index.html
@rm -rf dist/static
@echo "compile styles/*.scss"
@sass styles/base.scss static/base.css
@ruby scripts/compile.rb
cp -R ./static dist/static/
cp index.html dist/index.html
.PHONY: server watch dist

55
ui/README.md Normal file
View File

@ -0,0 +1,55 @@
## Consul Web UI
This directory contains the Consul Web UI. Consul contains a built-in
HTTP server that serves this directoy, but any common HTTP server
is capable of serving it.
It uses JavaScript and [Ember](http://emberjs.com) to communicate with
the [Consul API](http://www.consul.io/docs/agent/http.html). The basic
features it provides are:
- Service view. A list of your registered services, their
health and the nodes they run on.
- Node view. A list of your registered nodes, the services running
on each and the health of the node.
- Key/value view and update
It's aware of multiple data centers, so you can get a quick global
overview before drilling into specific data-centers for detailed
views.
The UI uses some internal undocumented HTTP APIs to optimize
performance and usability.
### Development
Improvements and bug fixes are welcome and encouraged for the Web UI.
You'll need sass to compile CSS stylesheets. Install that with
bundler:
cd ui/
bundle
Reloading compilation for development:
make watch
Consul ships with an HTTP server for the API and UI. By default, when
you run the agent, it is off. However, if you pass a `-ui-dir` flag
with a path to this directoy, you'll be able to access the UI via the
Consul HTTP server address, which defaults to `localhost:8500/ui`.
An example of this command, from inside the `ui/` directory, would be:
consul agent -bootstrap -server -data-dir /tmp/ -ui-dir .
### Releasing
These steps are slightly manual at the moment.
1. Build with `make dist`
2. In `dist/index.html`, replace the JS files between `<!-- ASSETS -->` tags with:
<script src="static/application.min.js"></script>

0
ui/dist/.gitkeep vendored Normal file
View File

441
ui/index.html Normal file
View File

@ -0,0 +1,441 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=0.5">
<title>Consul</title>
<link rel="stylesheet" href="static/bootstrap.min.css">
<link rel="stylesheet" href="static/base.css">
</head>
<body>
<div class="container">
<div class="col-md-12">
<div id="app">
</div>
</div>
</div>
<script type="text/x-handlebars">
{{outlet}}
</script>
<script type="text/x-handlebars" data-template-name="error">
<div class="row">
<div class="col-md-8 col-md-offset-2 col-sm-12 col-xs-12">
<div class="text-center vertical-center">
{{#if controller.model.statusText }}
<p class="bold">HTTP error code from Consul: <code>{{controller.model.status}} {{controller.model.statusText}}</code></p>
{{/if}}
<p>This is an error page for the Consul web UI. You may have visited a URL that is loading an
unknown resource, so you can try going back to the <a href="#">root</a>.</p>
<p>Otherwise, please report any unexpected
issues on the <a href="https://github.com/hashicorp/consul">GitHub page</a>.</p>
</div>
</div>
</div>
</script>
<script type="text/x-handlebars" data-template-name="loading">
<div class="row">
<div class="col-md-8 col-md-offset-2 col-sm-12 col-xs-12">
<div class="text-center vertical-center">
<img src="static/loading-cylon-purple.svg" width="384" height="48">
<p><small>Loading...</small></p>
</div>
</div>
</div>
</script>
<script type="text/x-handlebars" data-template-name="dc">
<div class="row">
<div class="col-md-12 col-sm-12 col-xs-12 topbar">
<div class="col-md-1 col-sm-2 col-xs-10 col-sm-offset-0 col-xs-offset-1">
<a href="#"><div class="top-brand"></div></a>
</div>
<div class="col-md-2 col-sm-3 col-xs-10 col-sm-offset-0 col-xs-offset-1">
{{#link-to 'services' class='btn btn-default col-xs-12'}}Services{{/link-to}}
</div>
<div class="col-md-2 col-sm-3 col-xs-10 col-sm-offset-0 col-xs-offset-1">
{{#link-to 'nodes' class='btn btn-default col-xs-12'}}Nodes{{/link-to}}
</div>
<div class="col-md-2 col-sm-3 col-xs-10 col-sm-offset-0 col-xs-offset-1">
{{#link-to 'kv' class='btn btn-default col-xs-12'}}Key/Value{{/link-to}}
</div>
<div class="col-md-2 col-md-offset-1 col-sm-3 col-sm-offset-5 col-xs-10 col-xs-offset-1">
{{#link-to 'services' tagName="div" href=false}}<a {{bind-attr class=":col-xs-12 :btn hasFailingChecks:btn-warning:btn-success"}}>{{ checkMessage }}</a>{{/link-to}}
</div>
<div class="col-md-2 col-sm-3 col-xs-10 col-sm-offset-0 col-xs-offset-1">
<a {{bind-attr class=":col-xs-12 :btn isDropDownVisible:btn-primary:btn-default"}} {{action "toggle"}}> {{model}} <span class="caret"></span> </a>
{{#if isDropdownVisible}}
<ul class="dropdown-menu col-xs-8" style="display:block;">
{{#each dc in dcs}}
<li {{action "hideDrop"}}>{{#link-to 'services' dc}}{{dc}}{{/link-to}}</li>
{{/each}}
</ul>
{{/if}}
</div>
</div>
</div>
{{outlet}}
</script>
<script type="text/x-handlebars" data-template-name="kv/show">
<div class="row">
<h4 class="breadcrumbs"><a href="" {{action 'linkToKey' grandParentKey }}>{{parentKey}}</a></h4>
</div>
<div class="row">
<div class="col-md-5">
<div class="row">
{{#each item in model }}
{{#link-to item.linkToRoute item.urlSafeKey href=false tagName="div" class="panel panel-link panel-short"}}
<div {{bind-attr class=":panel-bar item.isFolder:bg-gray:bg-light-gray" }}></div>
<div class="panel-heading">
<h3 class="panel-title">
{{item.keyWithoutParent}}
</h3>
</div>
{{/link-to}}
{{/each}}
</div>
</div>
<div class="col-md-1">
<div class="border-left hidden-xs hidden-sm">
<div class="line"></div>
</div>
</div>
<div class="col-md-6">
<div class="row">
<div class="panel">
<div {{ bind-attr class=":panel-bar isLoading:bg-orange:bg-light-gray" }}></div>
<div class="panel-heading">
<h3 class="panel-title">
Create Key
</h3>
</div>
<div class="panel-body">
<form class="form">
<div {{ bind-attr class=":form-group newKey.keyValid:valid" }}>
<div class="input-group">
<span class="input-group-addon">{{parentKey}}</span>
{{ input value=newKey.Key class="form-control" required=true }}
</div>
<span class="help-block">To create a folder, end the key with <code>/</code></span>
</div>
{{#if newKey.isFolder }}
<p>No value needed for nested keys.</p>
{{else}}
<div class="form-group">
{{ textarea value=newKey.Value class="form-control"}}
<span class="help-block">Value can be any format and length</span>
</div>
{{/if}}
<button {{ action "createKey"}} {{bind-attr disabled=newKey.isInvalid }} {{ bind-attr class=":btn newKey.isValid:btn-success:btn-default" }}>Create</button>
</form>
</div>
</div>
</div>
</div>
</div>
</script>
<script type="text/x-handlebars" data-template-name="kv/edit">
<div class="row">
<div class="col-md-5">
<div class="row">
<h4 class="breadcrumbs"><a href="" {{action 'linkToKey' grandParentKey }}>{{parentKey}}</a></h4>
</div>
</div>
</div>
<div class="row">
<div class="col-md-5">
<div class="row">
{{#each item in siblings }}
{{#link-to item.linkToRoute item.urlSafeKey href=false tagName="div" class="panel panel-link panel-short"}}
<div {{bind-attr class=":panel-bar item.isFolder:bg-gray:bg-light-gray" }}></div>
<div class="panel-heading">
<h3 class="panel-title">
{{item.keyWithoutParent}}
</h3>
</div>
{{/link-to}}
{{/each}}
</div>
</div>
<div class="col-md-1">
<div class="border-left hidden-xs hidden-sm">
<div class="line"></div>
</div>
</div>
<div class="col-md-6">
<div class="row">
<div class="panel">
<div {{ bind-attr class=":panel-bar isLoading:bg-orange:bg-green" }}></div>
<div class="panel-heading">
<h3 class="panel-title">
{{model.Key}}
</h3>
</div>
<div class="panel-body">
<div class="form-group">
{{errorMessage}}
</div>
<form class="form">
<div class="form-group">
{{ textarea value=model.valueDecoded class="form-control"}}
</div>
<button {{ action "updateKey"}} {{bind-attr disabled=isLoading }} {{ bind-attr class=":btn isLoading:btn-warning:btn-success" }}>Update</button>
<button {{ action "deleteKey"}} {{bind-attr disabled=isLoading }} {{ bind-attr class=":btn :pull-right isLoading:btn-warning:btn-danger" }}>Delete</button>
</form>
</div>
</div>
</div>
</div>
</div>
</script>
<script type="text/x-handlebars" data-template-name="item/loading">
<div class="row">
<div class="col-md-8 col-md-offset-2 col-sm-12 col-xs-12">
<div class="text-center vertical-center">
<img src="static/loading-cylon-purple.svg" width="384" height="48">
<p><small>Loading...</small></p>
</div>
</div>
</div>
</script>
<script type="text/x-handlebars" id="services">
<div {{ bind-attr class=":col-md-5" }}>
{{#each service in services}}
<div class="row">
{{#link-to 'services.show' service.Name tagName="div" href=false class="list-group-item list-link" }}
<div {{bind-attr class="service.hasFailingChecks:bg-orange:bg-green :list-bar"}}></div>
<h4 class="list-group-item-heading">
{{#link-to 'services.show' service.Name class='subtle'}}{{service.Name}}{{/link-to}}
<div class="heading-helper">
<a class="subtle" href="#">{{service.checkMessage}}</a>
</div>
</h4>
<ul class="list-inline">
{{#each node in service.Nodes }}
<li class="bold">{{node}}</li>
{{/each}}
</ul>
{{/link-to}}
</div>
{{/each}}
</div>
<div class="col-md-1">
<div class="border-left hidden-xs hidden-sm">
<div class="line"></div>
</div>
</div>
<div class="visible-xs visible-sm">
<hr>
</div>
<div class="col-md-6">
<div class="row">
{{outlet}}
</div>
</div>
</script>
<script type="text/x-handlebars" id="service">
<h2 class="no-margin">{{ model.0.Service.Service }}</h2>
<hr>
<h5>Nodes</h5>
{{#each node in model }}
{{#link-to 'nodes.show' node.Node.Node tagName="div" href=false class="panel panel-link" }}
<div {{ bind-attr class=":panel-bar node.hasFailingChecks:bg-orange:bg-green" }}></div>
<div class="panel-heading">
<h3 class="panel-title">
{{node.Node.Node}}
<small>{{node.Node.Address}}</small>
<span class="panel-note">{{node.checkMessage}}</span>
</h3>
</div>
<div class="panel-body">
<ul class="list-unstyled list-broken">
{{#each check in node.Checks }}
<li>
<h4 class="check">{{ check.Name }} <small>{{ check.CheckID }}</small> <span class="pull-right"><small>{{check.Status}}</small></h4>
</li>
{{/each}}
</ul>
</div>
{{/link-to}}
{{/each}}
{{#link-to "services" class="btn btn-default col-xs-12 visible-xs" }}All Services{{/link-to}}
</script>
<script type="text/x-handlebars" id="nodes">
<div class="col-md-5">
{{#each node in nodes}}
<div class="row">
{{#link-to 'nodes.show' node.Node tagName="div" href=false class="list-group-item list-link" }}
<div {{bind-attr class="node.hasFailingChecks:bg-orange:bg-green :list-bar"}}></div>
<h4 class="list-group-item-heading">
{{node.Node}}
<small>{{node.Address}}</small>
<div class="heading-helper">
<a class="subtle" href="#">{{node.checkMessage}}</a>
</div>
</h4>
<ul class="list-inline">
{{#each service in node.Services }}
<li class="bold">{{service.Service}}</li>
{{/each}}
</ul>
</div>
{{/link-to}}
{{/each}}
</div>
<div class="col-md-1">
<div class="border-left hidden-xs hidden-sm">
<div class="line"></div>
</div>
</div>
<div class="visible-xs visible-sm">
<hr>
</div>
<div class="col-md-6">
<div class="row">
{{outlet}}
</div>
</div>
</script>
<script type="text/x-handlebars" id="node">
<h2 class="no-margin">{{ model.Node }} <small> {{ model.Address }}</small></h2>
<hr>
<h5>Services</h5>
{{#each service in model.Services }}
{{#link-to 'services.show' service.Service }}
<div class="panel panel-link panel-short">
<div class="panel-bar bg-light-gray"></div>
<div class="panel-heading">
<h3 class="panel-title">
{{service.Service}}
<small>{{sevice.ID}}</small>
<span class="panel-note">:{{service.Port}}</span>
</h3>
</div>
</div>
{{/link-to}}
{{/each}}
<h5>Checks</h5>
{{#each check in model.Checks }}
<div class="panel">
{{ panelBar check.Status }}
<div class="panel-heading">
<h3 class="panel-title">
{{check.Name}}
<small>{{check.CheckID}}</small>
<span class="panel-note">{{check.Status}}</span>
</h3>
</div>
<div class="panel-body">
<h5>Notes</h5>
<p>{{ check.Notes }}</p>
<h5>Output</h5>
<pre>{{check.Output}}</pre>
</div>
</div>
{{/each}}
{{#link-to "nodes" class="btn btn-default col-xs-12 visible-xs" }}All Nodes{{/link-to}}
</script>
<script type="text/x-handlebars" id="index">
<div class="col-md-8 col-md-offset-2 col-xs-offset-0 col-sm-offset-0 col-xs-12 col-sm-12 vertical-center">
<h5>Select a datacenter</h5>
{{#each item in model}}
{{#link-to 'services' item }}
<div class="panel panel-link panel-short">
<div class="panel-bar bg-light-gray"></div>
<div class="panel-heading">
<h3 class="panel-title">
{{item}}
<span class="panel-note"></span>
</h3>
</div>
</div>
{{/link-to}}
{{/each}}
</div>
</script>
<!-- ASSETS -->
<script src="javascripts/libs/jquery-1.10.2.js"></script>
<script src="javascripts/libs/handlebars-1.1.2.js"></script>
<script src="javascripts/libs/ember-1.5.1.js"></script>
<script src="javascripts/libs/ember-validations.js"></script>
<script src="javascripts/fixtures.js"></script>
<script src="javascripts/app/router.js"></script>
<script src="javascripts/app/routes.js"></script>
<script src="javascripts/app/models.js"></script>
<script src="javascripts/app/views.js"></script>
<script src="javascripts/app/controllers.js"></script>
<script src="javascripts/app/helpers.js"></script>
<!-- to activate the test runner, add the "?test" query string parameter -->
<script src="tests/runner.js"></script>
<!-- <script src="static/application.min.js"></script> -->
<!-- /ASSETS -->
</body>
</html>

View File

@ -0,0 +1,172 @@
App.DcController = Ember.Controller.extend({
// Whether or not the dropdown menu can be seen
isDropdownVisible: false,
datacenter: function() {
return this.get('content')
}.property('Content'),
checks: function() {
var nodes = this.get('nodes');
var checks = Ember.A()
// Combine the checks from all of our nodes
// into one.
nodes.forEach(function(item) {
checks = checks.concat(item.Checks)
});
return checks
}.property('nodes'),
// Returns the total number of failing checks.
//
// We treat any non-passing checks as failing
//
totalChecksFailing: function() {
var checks = this.get('checks')
return (checks.filterBy('Status', 'critical').get('length') +
checks.filterBy('Status', 'warning').get('length'))
}.property('nodes'),
//
// Returns the human formatted message for the button state
//
checkMessage: function() {
var checks = this.get('checks')
var failingChecks = this.get('totalChecksFailing');
var passingChecks = checks.filterBy('Status', 'passing').get('length');
if (this.get('hasFailingChecks') == true) {
return failingChecks + ' checks failing';
} else {
return passingChecks + ' checks passing';
}
}.property('nodes'),
//
// Boolean if the datacenter has any failing checks.
//
hasFailingChecks: function() {
var failingChecks = this.get('totalChecksFailing')
return (failingChecks > 0);
}.property('nodes'),
actions: {
// Hide and show the dropdown menu
toggle: function(item){
this.toggleProperty('isDropdownVisible');
},
// Just hide the dropdown menu
hideDrop: function(item){
this.set('isDropdownVisible', false);
}
}
})
// Add mixins
App.KvShowController = Ember.ObjectController.extend(Ember.Validations.Mixin);
App.KvShowController.reopen({
needs: ["dc"],
dc: Ember.computed.alias("controllers.dc"),
isLoading: false,
actions: {
// Creates the key from the newKey model
// set on the route.
createKey: function() {
this.set('isLoading', true);
var newKey = this.get('newKey');
var parentKey = this.get('parentKey');
var grandParentKey = this.get('grandParentKey');
var controller = this;
var dc = this.get('dc').get('datacenter');
// If we don't have a previous model to base
// on our parent, or we're not at the root level,
// add the prefix
if (parentKey != undefined && parentKey != "/") {
newKey.set('Key', (parentKey + newKey.get('Key')));
}
// Put the Key and the Value retrieved from the form
Ember.$.ajax({
url: ("/v1/kv/" + newKey.get('Key') + '?dc=' + dc),
type: 'PUT',
data: newKey.get('Value')
}).then(function(response) {
// transition to the right place
if (newKey.get('isFolder') == true) {
controller.transitionToRoute('kv.show', newKey.get('urlSafeKey'));
} else {
controller.transitionToRoute('kv.edit', newKey.get('urlSafeKey'));
}
controller.set('isLoading', false)
}).fail(function(response) {
// Render the error message on the form if the request failed
controller.set('errorMessage', 'Received error while processing: ' + response.statusText)
});
}
}
});
App.KvEditController = Ember.Controller.extend({
isLoading: false,
needs: ["dc"],
dc: Ember.computed.alias("controllers.dc"),
actions: {
// Updates the key set as the model on the route.
updateKey: function() {
this.set('isLoading', true);
var dc = this.get('dc').get('datacenter');
var key = this.get("model");
var controller = this;
// Put the key and the decoded (plain text) value
// from the form.
Ember.$.ajax({
url: ("/v1/kv/" + key.get('Key') + '?dc=' + dc),
type: 'PUT',
data: key.get('valueDecoded')
}).then(function(response) {
// If success, just reset the loading state.
controller.set('isLoading', false)
}).fail(function(response) {
// Render the error message on the form if the request failed
controller.set('errorMessage', 'Received error while processing: ' + response.statusText)
})
},
deleteKey: function() {
this.set('isLoading', true);
var key = this.get("model");
var controller = this;
var dc = this.get('dc').get('datacenter');
// Get the parent for the transition back up a level
// after the delete
var parent = key.get('urlSafeParentKey');
// Delete the key
Ember.$.ajax({
url: ("/v1/kv/" + key.get('Key') + '?dc=' + dc),
type: 'DELETE'
}).then(function(response) {
// Tranisiton back up a level
controller.transitionToRoute('kv.show', parent);
controller.set('isLoading', false);
}).fail(function(response) {
// Render the error message on the form if the request failed
controller.set('errorMessage', 'Received error while processing: ' + response.statusText)
})
}
}
});

View File

@ -0,0 +1,10 @@
Ember.Handlebars.helper('panelBar', function(status) {
var highlightClass;
if (status == "passing") {
highlightClass = "bg-green";
} else {
highlightClass = "bg-orange";
}
return new Handlebars.SafeString('<div class="panel-bar ' + highlightClass + '"></div>');
});

View File

@ -0,0 +1,215 @@
//
// A Consul service.
//
App.Service = Ember.Object.extend({
//
// The number of failing checks within the service.
//
failingChecks: function() {
// If the service was returned from `/v1/internal/ui/services`
// then we have a aggregated value which we can just grab
if (this.get('ChecksCritical') != undefined) {
return (this.get('ChecksCritical') + this.get('ChecksWarning'))
// Otherwise, we need to filter the child checks by both failing
// states
} else {
return (checks.filterBy('Status', 'critical').get('length') +
checks.filterBy('Status', 'warning').get('length'))
}
}.property('Checks'),
//
// The number of passing checks within the service.
//
passingChecks: function() {
// If the service was returned from `/v1/internal/ui/services`
// then we have a aggregated value which we can just grab
if (this.get('ChecksPassing') != undefined) {
return this.get('ChecksPassing')
// Otherwise, we need to filter the child checks by both failing
// states
} else {
return this.get('Checks').filterBy('Status', 'passing').get('length');
}
}.property('Checks'),
//
// The formatted message returned for the user which represents the
// number of checks failing or passing. Returns `1 passing` or `2 failing`
//
checkMessage: function() {
if (this.get('hasFailingChecks') === false) {
return this.get('passingChecks') + ' passing';
} else {
return this.get('failingChecks') + ' failing';
}
}.property('Checks'),
//
// Boolean of whether or not there are failing checks in the service.
// This is used to set color backgrounds and so on.
//
hasFailingChecks: function() {
return (this.get('failingChecks') > 0);
}.property('Checks')
});
//
// A Consul Node
//
App.Node = Ember.Object.extend({
//
// The number of failing checks within the service.
//
failingChecks: function() {
var checks = this.get('Checks');
// We view both warning and critical as failing
return (checks.filterBy('Status', 'critical').get('length') +
checks.filterBy('Status', 'warning').get('length'))
}.property('Checks'),
//
// The number of passing checks within the service.
//
passingChecks: function() {
return this.get('Checks').filterBy('Status', 'passing').get('length');
}.property('Checks'),
//
// The formatted message returned for the user which represents the
// number of checks failing or passing. Returns `1 passing` or `2 failing`
//
checkMessage: function() {
if (this.get('hasFailingChecks') === false) {
return this.get('passingChecks') + ' passing';
} else {
return this.get('failingChecks') + ' failing';
}
}.property('Checks'),
//
// Boolean of whether or not there are failing checks in the service.
// This is used to set color backgrounds and so on.
//
hasFailingChecks: function() {
return (this.get('failingChecks') > 0);
}.property('Checks')
});
//
// A key/value object
//
App.Key = Ember.Object.extend(Ember.Validations.Mixin, {
// Validates using the Ember.Valdiations library
validations: {
Key: { presence: true }
},
// Boolean if the key is valid
keyValid: Ember.computed.empty('errors.Key'),
// Boolean if the value is valid
valueValid: Ember.computed.empty('errors.Value'),
// The key with the parent removed.
// This is only for display purposes, and used for
// showing the key name inside of a nested key.
keyWithoutParent: function() {
return (this.get('Key').replace(this.get('parentKey'), ''));
}.property('Key'),
// Boolean if the key is a "folder" or not, i.e is a nested key
// that feels like a folder. Used for UI
isFolder: function() {
if (this.get('Key') === undefined) {
return false;
};
return (this.get('Key').slice(-1) == "/")
}.property('Key'),
// The dasherized URL safe version of the key for routing
urlSafeKey: function() {
return this.get('Key').replace(/\//g, "-")
}.property('Key'),
// The dasherized URL safe version of the parent key for routing
urlSafeParentKey: function() {
return this.get('parentKey').replace(/\//g, "-")
}.property('Key'),
// Determines what route to link to. If it's a folder,
// it will link to kv.show. Otherwise, kv.edit
linkToRoute: function() {
var key = this.get('urlSafeKey')
// If the key ends in - it's a folder
if (key.slice(-1) === "-") {
return 'kv.show'
} else {
return 'kv.edit'
}
}.property('Key'),
// The base64 decoded value of the key.
// if you set on this key, it will update
// the key.Value
valueDecoded: function(key, value) {
// setter
if (arguments.length > 1) {
this.set('Value', value);
return value;
}
// getter
// If the value is null, we don't
// want to try and base64 decode it, so just return
if (this.get('Value') === null) {
return "";
}
// base64 decode the value
return window.atob(this.get('Value'));
}.property('Value'),
// An array of the key broken up by the /
keyParts: function() {
var key = this.get('Key');
// If the key is a folder, remove the last
// slash to split properly
if (key.slice(-1) == "/") {
key = key.substring(0, key.length - 1);
}
return key.split('/');
}.property('Key'),
// The parent Key is the key one level above this.Key
// key: baz/bar/foobar/
// grandParent: baz/bar/
parentKey: function() {
var parts = this.get('keyParts').toArray();
// Remove the last item, essentially going up a level
// in hiearchy
parts.pop();
return parts.join("/") + "/";
}.property('Key'),
// The grandParent Key is the key two levels above this.Key
// key: baz/bar/foobar/
// grandParent: baz/
grandParentKey: function() {
var parts = this.get('keyParts').toArray();
// Remove the last two items, jumping two levels back
parts.pop();
parts.pop();
return parts.join("/") + "/";
}.property('Key')
});

View File

@ -0,0 +1,35 @@
window.App = Ember.Application.create({
rootElement: "#app"
});
App.Router.map(function() {
// Our parent datacenter resource sets the namespace
// for the entire application
this.resource("dc", {path: "/:dc"}, function() {
// Services represent a consul service
this.resource("services", { path: "/services" }, function(){
// Show an individual service
this.route("show", { path: "/:name" });
});
// Nodes represent a consul node
this.resource("nodes", { path: "/nodes" }, function() {
// Show an individual node
this.route("show", { path: "/:name" });
});
// Key/Value
this.resource("kv", { path: "/kv" }, function(){
// This route just redirects to /-
this.route("index", { path: "/" });
// List keys. This is more like an index
this.route("show", { path: "/:key" });
// Edit a specific key
this.route("edit", { path: "/:key/edit" });
})
});
// Shows a datacenter picker. If you only have one
// it just redirects you through.
this.route("index", { path: "/" });
});

View File

@ -0,0 +1,253 @@
//
// Superclass to be used by all of the main routes below.
//
App.BaseRoute = Ember.Route.extend({
getParentAndGrandparent: function(key) {
var parentKey, grandParentKey, isFolder;
parts = key.split('/');
// If we are the root, set the parent and grandparent to the
// root.
if (key == "/") {
parentKey = "/";
grandParentKey ="/"
} else {
// Go one level up
parts.pop();
parentKey = parts.join("/") + "/";
// Go two levels up
parts.pop();
grandParentKey = parts.join("/") + "/";
}
return {grandParent: grandParentKey, parent: parentKey}
},
removeDuplicateKeys: function(keys, matcher) {
// Loop over the keys
keys.forEach(function(item, index) {
if (item.get('Key') == matcher) {
// If we are in a nested folder and the folder
// name matches our position, remove it
keys.splice(index, 1);
}
});
return keys;
},
actions: {
// Used to link to keys that are not objects,
// like parents and grandParents
linkToKey: function(key) {
key = key.replace(/\//g, "-")
if (key.slice(-1) === "-") {
this.transitionTo('kv.show', key)
} else {
this.transitionTo('kv.edit', key)
}
}
}
});
//
// The route for choosing datacenters, typically the first route loaded.
//
App.IndexRoute = App.BaseRoute.extend({
// Retrieve the list of datacenters
model: function(params) {
return Ember.$.getJSON('/v1/catalog/datacenters').then(function(data) {
return data
})
},
afterModel: function(model, transition) {
// If we only have one datacenter, jump
// straight to it and bypass the global
// view
if (model.get('length') === 1) {
this.transitionTo('services', model[0]);
}
}
});
// The parent route for all resources. This keeps the top bar
// functioning, as well as the per-dc requests.
App.DcRoute = App.BaseRoute.extend({
model: function(params) {
// Return a promise hash to retreieve the
// dcs and nodes used in the header
return Ember.RSVP.hash({
dc: params.dc,
dcs: Ember.$.getJSON('/v1/catalog/datacenters'),
nodes: Ember.$.getJSON('/v1/internal/ui/nodes?dc=' + params.dc).then(function(data) {
objs = [];
// Merge the nodes into a list and create objects out of them
data.map(function(obj){
objs.push(App.Node.create(obj));
});
return objs;
})
});
},
setupController: function(controller, models) {
controller.set('content', models.dc);
controller.set('nodes', models.nodes);
controller.set('dcs', models.dcs);
controller.set('isDropdownVisible', false);
},
});
App.KvIndexRoute = App.BaseRoute.extend({
// If they hit /kv we want to just move them to /kv/-
beforeModel: function() {
this.transitionTo('kv.show', '-')
}
});
App.KvShowRoute = App.BaseRoute.extend({
model: function(params) {
// Convert the key back to the format consul understands
var key = params.key.replace(/-/g, "/")
var dc = this.modelFor('dc').dc;
// Return a promise has with the ?keys for that namespace
// and the original key requested in params
return Ember.RSVP.hash({
key: key,
keys: Ember.$.getJSON('/v1/kv/' + key + '?keys&seperator=' + '/&dc=' + dc).then(function(data) {
objs = [];
data.map(function(obj){
objs.push(App.Key.create({Key: obj}));
});
return objs;
})
});
},
setupController: function(controller, models) {
var key = models.key;
var parentKeys = this.getParentAndGrandparent(key);
models.keys = this.removeDuplicateKeys(models.keys, models.key);
controller.set('content', models.keys);
controller.set('parentKey', parentKeys.parent);
controller.set('grandParentKey', parentKeys.grandParent);
controller.set('newKey', App.Key.create());
}
});
App.KvEditRoute = App.BaseRoute.extend({
model: function(params) {
var key = params.key.replace(/-/g, "/");
var dc = this.modelFor('dc').dc;
var parentKeys = this.getParentAndGrandparent(key)
// Return a promise hash to get the data for both columns
return Ember.RSVP.hash({
key: Ember.$.getJSON('/v1/kv/' + key + '?dc=' + dc).then(function(data) {
// Convert the returned data to a Key
return App.Key.create().setProperties(data[0]);
}),
keys: keysPromise = Ember.$.getJSON('/v1/kv/' + parentKeys.parent + '?keys&seperator=' + '/' + '&dc=' + dc).then(function(data) {
objs = [];
data.map(function(obj){
objs.push(App.Key.create({Key: obj}));
});
return objs;
}),
});
},
setupController: function(controller, models) {
var key = models.key;
var parentKeys = this.getParentAndGrandparent(key.get('Key'));
models.keys = this.removeDuplicateKeys(models.keys, parentKeys.parent);
controller.set('content', models.key);
controller.set('parentKey', parentKeys.parent);
controller.set('grandParentKey', parentKeys.grandParent);
controller.set('siblings', models.keys);
}
});
App.ServicesRoute = App.BaseRoute.extend({
model: function(params) {
var dc = this.modelFor('dc').dc
// Return a promise to retrieve all of the services
return Ember.$.getJSON('/v1/internal/ui/services?dc=' + dc).then(function(data) {
objs = [];
data.map(function(obj){
objs.push(App.Service.create(obj));
});
return objs
});
},
setupController: function(controller, model) {
controller.set('services', model);
}
});
App.ServicesShowRoute = App.BaseRoute.extend({
model: function(params) {
var dc = this.modelFor('dc').dc
// Here we just use the built-in health endpoint, as it gives us everything
// we need.
return Ember.$.getJSON('/v1/health/service/' + params.name + '?dc=' + dc).then(function(data) {
objs = [];
data.map(function(obj){
objs.push(App.Node.create(obj));
});
return objs;
});
}
});
App.NodesShowRoute = App.BaseRoute.extend({
model: function(params) {
var dc = this.modelFor('dc').dc
// Return a promise hash of the node and nodes
return Ember.RSVP.hash({
node: Ember.$.getJSON('/v1/internal/ui/node/' + params.name + '?dc=' + dc).then(function(data) {
return App.Node.create(data)
}),
nodes: Ember.$.getJSON('/v1/internal/ui/node/' + params.name + '?dc=' + dc).then(function(data) {
return App.Node.create(data)
})
});
},
setupController: function(controller, models) {
controller.set('content', models.node);
//
// Since we have 2 column layout, we need to also display the
// list of nodes on the left. Hence setting the attribute
// {{nodes}} on the controller.
//
controller.set('nodes', models.nodes);
}
});
App.NodesRoute = App.BaseRoute.extend({
model: function(params) {
var dc = this.modelFor('dc').dc
// Return a promise containing the nodes
return Ember.$.getJSON('/v1/internal/ui/nodes?dc=' + dc).then(function(data) {
objs = [];
data.map(function(obj){
objs.push(App.Node.create(obj));
});
return objs
});
},
setupController: function(controller, model) {
controller.set('nodes', model);
}
});

View File

@ -0,0 +1,50 @@
//
// DC
//
App.DcView = Ember.View.extend({
templateName: 'dc',
classNames: 'dropdowns',
click: function(e){
if ($(e.target).is('.dropdowns')){
$('ul.dropdown-menu').hide();
}
}
})
//
// Services
//
App.ServicesView = Ember.View.extend({
templateName: 'services',
})
App.ServicesShowView = Ember.View.extend({
templateName: 'service'
})
App.ServicesLoadingView = Ember.View.extend({
templateName: 'item/loading'
})
//
// Nodes
//
App.NodesView = Ember.View.extend({
templateName: 'nodes'
})
App.NodesShowView = Ember.View.extend({
templateName: 'node'
})
App.NodesLoadingView = Ember.View.extend({
templateName: 'item/loading'
})
App.KvListView = Ember.View.extend({
templateName: 'kv'
})

317
ui/javascripts/fixtures.js Normal file
View File

@ -0,0 +1,317 @@
//
// I intentionally am not using ember-data and the fixture
// adapter. I'm not confident the Consul UI API will be compatible
// without a bunch of wrangling, and it's really not enough updating
// of the models to justify the use of such a big component. getJSON
// *should* be enough.
//
window.fixtures = {}
//
// The array route, i.e /ui/<dc>/services, should return _all_ services
// in the DC
//
fixtures.services = [
{
"Name": "vagrant-cloud-http",
"Checks": [
{
"Name": "serfHealth",
"Status": "passing"
},
{
"Name": "fooHealth",
"Status": "critical"
},
{
"Name": "bazHealth",
"Status": "passing"
}
],
"Nodes": [
"node-10-0-1-109",
"node-10-0-1-102"
]
},
{
"Name": "vagrant-share-mux",
"Checks": [
{
"Name": "serfHealth",
"Status": "critical"
},
{
"Name": "fooHealth",
"Status": "passing"
},
{
"Name": "bazHealth",
"Status": "passing"
}
],
"Nodes": [
"node-10-0-1-109",
"node-10-0-1-102"
]
},
]
//
// This one is slightly more complicated to allow more UI interaction.
// It represents the route /ui/<dc>/services/<service> BUT it's what is
// BELOW the top-level key.
//
// So, what is actually returned should be similar to the /catalog/service/<service>
// endpoint.
fixtures.services_full = {
"vagrant-cloud-http":
// This array is what is actually expected from the API.
[
{
"ServicePort": 80,
"ServiceTags": null,
"ServiceName": "vagrant-cloud-http",
"ServiceID": "vagrant-cloud-http",
"Address": "10.0.1.109",
"Node": "node-10-0-1-109",
"Checks": [
{
"ServiceName": "",
"ServiceID": "",
"Notes": "",
"Status": "critical",
"Name": "Serf Health Status",
"CheckID": "serfHealth",
"Node": "node-10-0-1-109"
}
]
},
// A node
{
"ServicePort": 80,
"ServiceTags": null,
"ServiceName": "vagrant-cloud-http",
"ServiceID": "vagrant-cloud-http",
"Address": "10.0.1.102",
"Node": "node-10-0-1-102",
"Checks": [
{
"ServiceName": "",
"ServiceID": "",
"Notes": "",
"Status": "passing",
"Name": "Serf Health Status",
"CheckID": "serfHealth",
"Node": "node-10-0-1-102"
}
]
}
],
"vagrant-share-mux": [
// A node
{
"ServicePort": 80,
"ServiceTags": null,
"ServiceName": "vagrant-share-mux",
"ServiceID": "vagrant-share-mux",
"Address": "10.0.1.102",
"Node": "node-10-0-1-102",
"Checks": [
{
"ServiceName": "vagrant-share-mux",
"ServiceID": "vagrant-share-mux",
"Notes": "",
"Output": "200 ok",
"Status": "passing",
"Name": "Foo Heathly",
"CheckID": "fooHealth",
"Node": "node-10-0-1-102"
}
]
},
// A node
{
"ServicePort": 80,
"ServiceTags": null,
"ServiceName": "vagrant-share-mux",
"ServiceID": "vagrant-share-mux",
"Address": "10.0.1.109",
"Node": "node-10-0-1-109",
"Checks": [
{
"ServiceName": "",
"ServiceID": "",
"Notes": "",
"Output": "foobar baz",
"Status": "passing",
"Name": "Baz Status",
"CheckID": "bazHealth",
"Node": "node-10-0-1-109"
},
{
"ServiceName": "",
"ServiceID": "",
"Notes": "",
"Output": "foobar baz",
"Status": "critical",
"Name": "Serf Health Status",
"CheckID": "serfHealth",
"Node": "node-10-0-1-109"
}
]
}
]
}
//
// /ui/<dc>/nodes
// all the nodes
//
fixtures.nodes = [
{
"Address": "10.0.1.109",
"Name": "node-10-0-1-109",
"Services": [
"vagrant-share-mux",
"vagrant-cloud-http"
],
"Checks": [
{
"Name": "serfHealth",
"Status": "critical"
},
{
"Name": "bazHealth",
"Status": "passing"
}
]
},
{
"Address": "10.0.1.102",
"Name": "node-10-0-1-102",
"Services": [
"vagrant-share-mux",
"vagrant-cloud-http"
],
"Checks": [
{
"Name": "fooHealth",
"Status": "passing"
}
],
}
]
// These are for retrieving individual nodes. Same story as services,
// the top level key is just for the demo.
fixtures.nodes_full = {
"node-10-0-1-109":
// This is what would be returned.
{
"Services": [
{
"Port": 0,
"Tags": null,
"Service": "vagrant-share-mux",
"ID": "vagrant-share-mux"
},
{
"Port": 80,
"Tags": null,
"Service": "vagrant-cloud-http",
"ID": "vagrant-cloud-http"
}
],
"Node": {
"Address": "10.0.1.109",
"Node": "node-10-0-1-109"
},
"Checks": [
{
"ServiceName": "",
"ServiceID": "",
"Notes": "Checks the status of the serf agent",
"Status": "critical",
"Name": "Serf Health Status",
"CheckID": "serfHealth",
"Node": "node-10-0-1-109"
},
{
"ServiceName": "",
"ServiceID": "",
"Notes": "",
"Output": "foobar baz",
"Status": "passing",
"Name": "Baz Status",
"CheckID": "bazHealth",
"Node": "node-10-0-1-109"
}
]
},
"node-10-0-1-102": {
"Services": [
{
"Port": 0,
"Tags": null,
"Service": "vagrant-share-mux",
"ID": "vagrant-share-mux"
},
{
"Port": 80,
"Tags": null,
"Service": "vagrant-cloud-http",
"ID": "vagrant-cloud-http"
}
],
"Node": {
"Address": "10.0.1.102",
"Node": "node-10-0-1-102"
},
"Checks": [
{
"ServiceName": "",
"ServiceID": "",
"Notes": "Checks if the food is healthy",
"Output": "foobar baz",
"Status": "passing",
"Name": "Foo Healthy",
"CheckID": "fooStatus",
"Node": "node-10-0-1-102"
}
]
}
}
fixtures.dcs = ['nyc1', 'sf1', 'sg1']
fixtures.keys_full = {
"/": [
'foobar',
'application',
'web/'
],
"application": {
'key': 'application',
'value': 'foobarz'
},
"foobar": {
'key': 'foobar',
'value': 'baz'
},
"web/foo/bar": {
'key': 'web/foo/bar',
'value': 'baz'
},
"web/foo/baz": {
'key': 'web/foo/baz',
'value': 'test'
},
"web/": [
"web/foo/"
],
"web/foo/": [
"web/foo/bar",
"web/foo/baz"
]
};

44267
ui/javascripts/libs/ember-1.5.1.js Executable file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,778 @@
// ==========================================================================
// Project: Ember Validations
// Copyright: Copyright 2013 DockYard, LLC. and contributors.
// License: Licensed under MIT license (see license.js)
// ==========================================================================
// Version: 1.0.0.beta.2
(function() {
Ember.Validations = Ember.Namespace.create({
VERSION: '1.0.0.beta.2'
});
})();
(function() {
Ember.Validations.messages = {
render: function(attribute, context) {
if (Ember.I18n) {
return Ember.I18n.t('errors.' + attribute, context);
} else {
var regex = new RegExp("{{(.*?)}}"),
attributeName = "";
if (regex.test(this.defaults[attribute])) {
attributeName = regex.exec(this.defaults[attribute])[1];
}
return this.defaults[attribute].replace(regex, context[attributeName]);
}
},
defaults: {
inclusion: "is not included in the list",
exclusion: "is reserved",
invalid: "is invalid",
confirmation: "doesn't match {{attribute}}",
accepted: "must be accepted",
empty: "can't be empty",
blank: "can't be blank",
present: "must be blank",
tooLong: "is too long (maximum is {{count}} characters)",
tooShort: "is too short (minimum is {{count}} characters)",
wrongLength: "is the wrong length (should be {{count}} characters)",
notANumber: "is not a number",
notAnInteger: "must be an integer",
greaterThan: "must be greater than {{count}}",
greaterThanOrEqualTo: "must be greater than or equal to {{count}}",
equalTo: "must be equal to {{count}}",
lessThan: "must be less than {{count}}",
lessThanOrEqualTo: "must be less than or equal to {{count}}",
otherThan: "must be other than {{count}}",
odd: "must be odd",
even: "must be even",
url: "is not a valid URL"
}
};
})();
(function() {
Ember.Validations.Errors = Ember.Object.extend({
unknownProperty: function(property) {
this.set(property, Ember.makeArray());
return this.get(property);
}
});
})();
(function() {
var setValidityMixin = Ember.Mixin.create({
isValid: function() {
return this.get('validators').compact().filterBy('isValid', false).get('length') === 0;
}.property('validators.@each.isValid'),
isInvalid: Ember.computed.not('isValid')
});
var pushValidatableObject = function(model, property) {
var content = model.get(property);
model.removeObserver(property, pushValidatableObject);
if (Ember.isArray(content)) {
model.validators.pushObject(ArrayValidatorProxy.create({model: model, property: property, contentBinding: 'model.' + property}));
} else {
model.validators.pushObject(content);
}
};
var findValidator = function(validator) {
var klass = validator.classify();
return Ember.Validations.validators.local[klass] || Ember.Validations.validators.remote[klass];
};
var ArrayValidatorProxy = Ember.ArrayProxy.extend(setValidityMixin, {
validate: function() {
return this._validate();
},
_validate: function() {
var promises = this.get('content').invoke('_validate').without(undefined);
return Ember.RSVP.all(promises);
}.on('init'),
validators: Ember.computed.alias('content')
});
Ember.Validations.Mixin = Ember.Mixin.create(setValidityMixin, {
init: function() {
this._super();
this.errors = Ember.Validations.Errors.create();
this._dependentValidationKeys = {};
this.validators = Ember.makeArray();
if (this.get('validations') === undefined) {
this.validations = {};
}
this.buildValidators();
this.validators.forEach(function(validator) {
validator.addObserver('errors.[]', this, function(sender, key, value, context, rev) {
var errors = Ember.makeArray();
this.validators.forEach(function(validator) {
if (validator.property === sender.property) {
errors = errors.concat(validator.errors);
}
}, this);
this.set('errors.' + sender.property, errors);
});
}, this);
},
buildValidators: function() {
var property, validator;
for (property in this.validations) {
if (this.validations[property].constructor === Object) {
this.buildRuleValidator(property);
} else {
this.buildObjectValidator(property);
}
}
},
buildRuleValidator: function(property) {
var validator;
for (validator in this.validations[property]) {
if (this.validations[property].hasOwnProperty(validator)) {
this.validators.pushObject(findValidator(validator).create({model: this, property: property, options: this.validations[property][validator]}));
}
}
},
buildObjectValidator: function(property) {
if (Ember.isNone(this.get(property))) {
this.addObserver(property, this, pushValidatableObject);
} else {
pushValidatableObject(this, property);
}
},
validate: function() {
var self = this;
return this._validate().then(function(vals) {
var errors = self.get('errors');
if (vals.contains(false)) {
return Ember.RSVP.reject(errors);
}
return errors;
});
},
_validate: function() {
var promises = this.validators.invoke('_validate').without(undefined);
return Ember.RSVP.all(promises);
}.on('init')
});
})();
(function() {
Ember.Validations.patterns = Ember.Namespace.create({
numericality: /^(-|\+)?(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d*)?$/,
blank: /^\s*$/
});
})();
(function() {
Ember.Validations.validators = Ember.Namespace.create();
Ember.Validations.validators.local = Ember.Namespace.create();
Ember.Validations.validators.remote = Ember.Namespace.create();
})();
(function() {
Ember.Validations.validators.Base = Ember.Object.extend({
init: function() {
this.set('errors', Ember.makeArray());
this._dependentValidationKeys = Ember.makeArray();
this.conditionals = {
'if': this.get('options.if'),
unless: this.get('options.unless')
};
this.model.addObserver(this.property, this, this._validate);
},
addObserversForDependentValidationKeys: function() {
this._dependentValidationKeys.forEach(function(key) {
this.model.addObserver(key, this, this._validate);
}, this);
}.on('init'),
pushDependentValidationKeyToModel: function() {
var model = this.get('model');
if (model._dependentValidationKeys[this.property] === undefined) {
model._dependentValidationKeys[this.property] = Ember.makeArray();
}
model._dependentValidationKeys[this.property].addObjects(this._dependentValidationKeys);
}.on('init'),
call: function () {
throw 'Not implemented!';
},
unknownProperty: function(key) {
var model = this.get('model');
if (model) {
return model.get(key);
}
},
isValid: Ember.computed.empty('errors.[]'),
validate: function() {
var self = this;
return this._validate().then(function(success) {
// Convert validation failures to rejects.
var errors = self.get('model.errors');
if (success) {
return errors;
} else {
return Ember.RSVP.reject(errors);
}
});
},
_validate: function() {
this.errors.clear();
if (this.canValidate()) {
this.call();
}
if (this.get('isValid')) {
return Ember.RSVP.resolve(true);
} else {
return Ember.RSVP.resolve(false);
}
}.on('init'),
canValidate: function() {
if (typeof(this.conditionals) === 'object') {
if (this.conditionals['if']) {
if (typeof(this.conditionals['if']) === 'function') {
return this.conditionals['if'](this.model, this.property);
} else if (typeof(this.conditionals['if']) === 'string') {
if (typeof(this.model[this.conditionals['if']]) === 'function') {
return this.model[this.conditionals['if']]();
} else {
return this.model.get(this.conditionals['if']);
}
}
} else if (this.conditionals.unless) {
if (typeof(this.conditionals.unless) === 'function') {
return !this.conditionals.unless(this.model, this.property);
} else if (typeof(this.conditionals.unless) === 'string') {
if (typeof(this.model[this.conditionals.unless]) === 'function') {
return !this.model[this.conditionals.unless]();
} else {
return !this.model.get(this.conditionals.unless);
}
}
} else {
return true;
}
} else {
return true;
}
}
});
})();
(function() {
Ember.Validations.validators.local.Absence = Ember.Validations.validators.Base.extend({
init: function() {
this._super();
/*jshint expr:true*/
if (this.options === true) {
this.set('options', {});
}
if (this.options.message === undefined) {
this.set('options.message', Ember.Validations.messages.render('present', this.options));
}
},
call: function() {
if (!Ember.isEmpty(this.model.get(this.property))) {
this.errors.pushObject(this.options.message);
}
}
});
})();
(function() {
Ember.Validations.validators.local.Acceptance = Ember.Validations.validators.Base.extend({
init: function() {
this._super();
/*jshint expr:true*/
if (this.options === true) {
this.set('options', {});
}
if (this.options.message === undefined) {
this.set('options.message', Ember.Validations.messages.render('accepted', this.options));
}
},
call: function() {
if (this.options.accept) {
if (this.model.get(this.property) !== this.options.accept) {
this.errors.pushObject(this.options.message);
}
} else if (this.model.get(this.property) !== '1' && this.model.get(this.property) !== 1 && this.model.get(this.property) !== true) {
this.errors.pushObject(this.options.message);
}
}
});
})();
(function() {
Ember.Validations.validators.local.Confirmation = Ember.Validations.validators.Base.extend({
init: function() {
this.originalProperty = this.property;
this.property = this.property + 'Confirmation';
this._super();
this._dependentValidationKeys.pushObject(this.originalProperty);
/*jshint expr:true*/
if (this.options === true) {
this.set('options', { attribute: this.originalProperty });
this.set('options', { message: Ember.Validations.messages.render('confirmation', this.options) });
}
},
call: function() {
if (this.model.get(this.originalProperty) !== this.model.get(this.property)) {
this.errors.pushObject(this.options.message);
}
}
});
})();
(function() {
Ember.Validations.validators.local.Exclusion = Ember.Validations.validators.Base.extend({
init: function() {
this._super();
if (this.options.constructor === Array) {
this.set('options', { 'in': this.options });
}
if (this.options.message === undefined) {
this.set('options.message', Ember.Validations.messages.render('exclusion', this.options));
}
},
call: function() {
/*jshint expr:true*/
var message, lower, upper;
if (Ember.isEmpty(this.model.get(this.property))) {
if (this.options.allowBlank === undefined) {
this.errors.pushObject(this.options.message);
}
} else if (this.options['in']) {
if (Ember.$.inArray(this.model.get(this.property), this.options['in']) !== -1) {
this.errors.pushObject(this.options.message);
}
} else if (this.options.range) {
lower = this.options.range[0];
upper = this.options.range[1];
if (this.model.get(this.property) >= lower && this.model.get(this.property) <= upper) {
this.errors.pushObject(this.options.message);
}
}
}
});
})();
(function() {
Ember.Validations.validators.local.Format = Ember.Validations.validators.Base.extend({
init: function() {
this._super();
if (this.options.constructor === RegExp) {
this.set('options', { 'with': this.options });
}
if (this.options.message === undefined) {
this.set('options.message', Ember.Validations.messages.render('invalid', this.options));
}
},
call: function() {
if (Ember.isEmpty(this.model.get(this.property))) {
if (this.options.allowBlank === undefined) {
this.errors.pushObject(this.options.message);
}
} else if (this.options['with'] && !this.options['with'].test(this.model.get(this.property))) {
this.errors.pushObject(this.options.message);
} else if (this.options.without && this.options.without.test(this.model.get(this.property))) {
this.errors.pushObject(this.options.message);
}
}
});
})();
(function() {
Ember.Validations.validators.local.Inclusion = Ember.Validations.validators.Base.extend({
init: function() {
this._super();
if (this.options.constructor === Array) {
this.set('options', { 'in': this.options });
}
if (this.options.message === undefined) {
this.set('options.message', Ember.Validations.messages.render('inclusion', this.options));
}
},
call: function() {
var message, lower, upper;
if (Ember.isEmpty(this.model.get(this.property))) {
if (this.options.allowBlank === undefined) {
this.errors.pushObject(this.options.message);
}
} else if (this.options['in']) {
if (Ember.$.inArray(this.model.get(this.property), this.options['in']) === -1) {
this.errors.pushObject(this.options.message);
}
} else if (this.options.range) {
lower = this.options.range[0];
upper = this.options.range[1];
if (this.model.get(this.property) < lower || this.model.get(this.property) > upper) {
this.errors.pushObject(this.options.message);
}
}
}
});
})();
(function() {
Ember.Validations.validators.local.Length = Ember.Validations.validators.Base.extend({
init: function() {
var index, key;
this._super();
/*jshint expr:true*/
if (typeof(this.options) === 'number') {
this.set('options', { 'is': this.options });
}
if (this.options.messages === undefined) {
this.set('options.messages', {});
}
for (index = 0; index < this.messageKeys().length; index++) {
key = this.messageKeys()[index];
if (this.options[key] !== undefined && this.options[key].constructor === String) {
this.model.addObserver(this.options[key], this, this._validate);
}
}
this.options.tokenizer = this.options.tokenizer || function(value) { return value.split(''); };
// if (typeof(this.options.tokenizer) === 'function') {
// debugger;
// // this.tokenizedLength = new Function('value', 'return '
// } else {
// this.tokenizedLength = new Function('value', 'return (value || "").' + (this.options.tokenizer || 'split("")') + '.length');
// }
},
CHECKS: {
'is' : '==',
'minimum' : '>=',
'maximum' : '<='
},
MESSAGES: {
'is' : 'wrongLength',
'minimum' : 'tooShort',
'maximum' : 'tooLong'
},
getValue: function(key) {
if (this.options[key].constructor === String) {
return this.model.get(this.options[key]) || 0;
} else {
return this.options[key];
}
},
messageKeys: function() {
return Ember.keys(this.MESSAGES);
},
checkKeys: function() {
return Ember.keys(this.CHECKS);
},
renderMessageFor: function(key) {
var options = {count: this.getValue(key)}, _key;
for (_key in this.options) {
options[_key] = this.options[_key];
}
return this.options.messages[this.MESSAGES[key]] || Ember.Validations.messages.render(this.MESSAGES[key], options);
},
renderBlankMessage: function() {
if (this.options.is) {
return this.renderMessageFor('is');
} else if (this.options.minimum) {
return this.renderMessageFor('minimum');
}
},
call: function() {
var check, fn, message, operator, key;
if (Ember.isEmpty(this.model.get(this.property))) {
if (this.options.allowBlank === undefined && (this.options.is || this.options.minimum)) {
this.errors.pushObject(this.renderBlankMessage());
}
} else {
for (key in this.CHECKS) {
operator = this.CHECKS[key];
if (!this.options[key]) {
continue;
}
fn = new Function('return ' + this.options.tokenizer(this.model.get(this.property)).length + ' ' + operator + ' ' + this.getValue(key));
if (!fn()) {
this.errors.pushObject(this.renderMessageFor(key));
}
}
}
}
});
})();
(function() {
Ember.Validations.validators.local.Numericality = Ember.Validations.validators.Base.extend({
init: function() {
/*jshint expr:true*/
var index, keys, key;
this._super();
if (this.options === true) {
this.options = {};
} else if (this.options.constructor === String) {
key = this.options;
this.options = {};
this.options[key] = true;
}
if (this.options.messages === undefined || this.options.messages.numericality === undefined) {
this.options.messages = this.options.messages || {};
this.options.messages = { numericality: Ember.Validations.messages.render('notANumber', this.options) };
}
if (this.options.onlyInteger !== undefined && this.options.messages.onlyInteger === undefined) {
this.options.messages.onlyInteger = Ember.Validations.messages.render('notAnInteger', this.options);
}
keys = Ember.keys(this.CHECKS).concat(['odd', 'even']);
for(index = 0; index < keys.length; index++) {
key = keys[index];
if (isNaN(this.options[key])) {
this.model.addObserver(this.options[key], this, this._validate);
}
if (this.options[key] !== undefined && this.options.messages[key] === undefined) {
if (Ember.$.inArray(key, Ember.keys(this.CHECKS)) !== -1) {
this.options.count = this.options[key];
}
this.options.messages[key] = Ember.Validations.messages.render(key, this.options);
if (this.options.count !== undefined) {
delete this.options.count;
}
}
}
},
CHECKS: {
equalTo :'===',
greaterThan : '>',
greaterThanOrEqualTo : '>=',
lessThan : '<',
lessThanOrEqualTo : '<='
},
call: function() {
var check, checkValue, fn, form, operator, val;
if (Ember.isEmpty(this.model.get(this.property))) {
if (this.options.allowBlank === undefined) {
this.errors.pushObject(this.options.messages.numericality);
}
} else if (!Ember.Validations.patterns.numericality.test(this.model.get(this.property))) {
this.errors.pushObject(this.options.messages.numericality);
} else if (this.options.onlyInteger === true && !(/^[+\-]?\d+$/.test(this.model.get(this.property)))) {
this.errors.pushObject(this.options.messages.onlyInteger);
} else if (this.options.odd && parseInt(this.model.get(this.property), 10) % 2 === 0) {
this.errors.pushObject(this.options.messages.odd);
} else if (this.options.even && parseInt(this.model.get(this.property), 10) % 2 !== 0) {
this.errors.pushObject(this.options.messages.even);
} else {
for (check in this.CHECKS) {
operator = this.CHECKS[check];
if (this.options[check] === undefined) {
continue;
}
if (!isNaN(parseFloat(this.options[check])) && isFinite(this.options[check])) {
checkValue = this.options[check];
} else if (this.model.get(this.options[check]) !== undefined) {
checkValue = this.model.get(this.options[check]);
}
fn = new Function('return ' + this.model.get(this.property) + ' ' + operator + ' ' + checkValue);
if (!fn()) {
this.errors.pushObject(this.options.messages[check]);
}
}
}
}
});
})();
(function() {
Ember.Validations.validators.local.Presence = Ember.Validations.validators.Base.extend({
init: function() {
this._super();
/*jshint expr:true*/
if (this.options === true) {
this.options = {};
}
if (this.options.message === undefined) {
this.options.message = Ember.Validations.messages.render('blank', this.options);
}
},
call: function() {
if (Ember.isEmpty(this.model.get(this.property))) {
this.errors.pushObject(this.options.message);
}
}
});
})();
(function() {
Ember.Validations.validators.local.Url = Ember.Validations.validators.Base.extend({
regexp: null,
regexp_ip: null,
init: function() {
this._super();
if (this.get('options.message') === undefined) {
this.set('options.message', Ember.Validations.messages.render('url', this.options));
}
if (this.get('options.protocols') === undefined) {
this.set('options.protocols', ['http', 'https']);
}
// Regular Expression Parts
var dec_octet = '(25[0-5]|2[0-4][0-9]|[0-1][0-9][0-9]|[1-9][0-9]|[0-9])'; // 0-255
var ipaddress = '(' + dec_octet + '(\\.' + dec_octet + '){3})';
var hostname = '([a-zA-Z0-9\\-]+\\.)+([a-zA-Z]{2,})';
var encoded = '%[0-9a-fA-F]{2}';
var characters = 'a-zA-Z0-9$\\-_.+!*\'(),;:@&=';
var segment = '([' + characters + ']|' + encoded + ')*';
// Build Regular Expression
var regex_str = '^';
if (this.get('options.domainOnly') === true) {
regex_str += hostname;
} else {
regex_str += '(' + this.get('options.protocols').join('|') + '):\\/\\/'; // Protocol
// Username and password
if (this.get('options.allowUserPass') === true) {
regex_str += '(([a-zA-Z0-9$\\-_.+!*\'(),;:&=]|' + encoded + ')+@)?'; // Username & passwords
}
// IP Addresses?
if (this.get('options.allowIp') === true) {
regex_str += '(' + hostname + '|' + ipaddress + ')'; // Hostname OR IP
} else {
regex_str += '(' + hostname + ')'; // Hostname only
}
// Ports
if (this.get('options.allowPort') === true) {
regex_str += '(:[0-9]+)?'; // Port
}
regex_str += '(\\/';
regex_str += '(' + segment + '(\\/' + segment + ')*)?'; // Path
regex_str += '(\\?' + '([' + characters + '/?]|' + encoded + ')*)?'; // Query
regex_str += '(\\#' + '([' + characters + '/?]|' + encoded + ')*)?'; // Anchor
regex_str += ')?';
}
regex_str += '$';
// RegExp
this.regexp = new RegExp(regex_str);
this.regexp_ip = new RegExp(ipaddress);
},
call: function() {
var url = this.model.get(this.property);
if (Ember.isEmpty(url)) {
if (this.get('options.allowBlank') !== true) {
this.errors.pushObject(this.get('options.message'));
}
} else {
if (this.get('options.allowIp') !== true) {
if (this.regexp_ip.test(url)) {
this.errors.pushObject(this.get('options.message'));
return;
}
}
if (!this.regexp.test(url)) {
this.errors.pushObject(this.get('options.message'));
}
}
}
});
})();
(function() {
})();
(function() {
})();

File diff suppressed because it is too large Load Diff

9789
ui/javascripts/libs/jquery-1.10.2.js vendored Executable file

File diff suppressed because it is too large Load Diff

33
ui/scripts/compile.rb Normal file
View File

@ -0,0 +1,33 @@
require 'uglifier'
File.open("static/application.min.js", "w") {|file| file.truncate(0) }
libs = [
"javascripts/libs/jquery-1.10.2.js",
"javascripts/libs/handlebars-1.1.2.js",
"javascripts/libs/ember-1.5.1.js",
"javascripts/libs/ember-validations.js",
]
app = [
"javascripts/app/router.js",
"javascripts/app/models.js",
"javascripts/app/routes.js",
"javascripts/app/controllers.js",
"javascripts/app/views.js",
"javascripts/app/helpers.js",
]
libs.each do |js_file|
File.open("static/application.min.js", "a") do |f|
puts "compile #{js_file}"
f << Uglifier.compile(File.read(js_file))
end
end
app.each do |js_file|
File.open("static/application.min.js", "a") do |f|
puts "compile #{js_file}"
f << Uglifier.compile(File.read(js_file))
end
end

7
ui/static/bootstrap.min.css vendored Normal file

File diff suppressed because one or more lines are too long

BIN
ui/static/consul-logo.png (Stored with Git LFS) Normal file

Binary file not shown.

View File

@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 14 32 18" width="32" height="4" fill="#9e84c5" preserveAspectRatio="none">
<path opacity="0.8" transform="translate(0 0)" d="M2 14 V18 H6 V14z">
<animateTransform attributeName="transform" type="translate" values="0 0; 24 0; 0 0" dur="2s" begin="0" repeatCount="indefinite" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" calcMode="spline" />
</path>
<path opacity="0.5" transform="translate(0 0)" d="M0 14 V18 H8 V14z">
<animateTransform attributeName="transform" type="translate" values="0 0; 24 0; 0 0" dur="2s" begin="0.1s" repeatCount="indefinite" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" calcMode="spline" />
</path>
<path opacity="0.25" transform="translate(0 0)" d="M0 14 V18 H8 V14z">
<animateTransform attributeName="transform" type="translate" values="0 0; 24 0; 0 0" dur="2s" begin="0.2s" repeatCount="indefinite" keySplines="0.2 0.2 0.4 0.8;0.2 0.2 0.4 0.8" calcMode="spline" />
</path>
</svg>

After

Width:  |  Height:  |  Size: 983 B

320
ui/style-guide.html Normal file
View File

@ -0,0 +1,320 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Consul Web UI Style Guide</title>
<link rel="stylesheet" href="static/bootstrap.min.css">
<link rel="stylesheet" href="static/base.css">
</head>
<body>
<div class="container">
<div class="col-md-10 col-md-offset-1">
<div class="row">
<div class="col-md-12">
<h2>Consul Web UI Style Guide</h2>
<p>This is style guide for the <a href="http://www.consul.io">Consul</a> Web UI. When possible,
it's best to follow this guide modifying the UI.</p>
<p>Some reasoning behind choices:
<ul>
<li>Colors. Bright colors were chosen to allow for easy
"scanning" of information.</li>
<li>Icons will accompany most "actions", those are still
pending</li>
<li>Layout. The layout will be primarily 2 columns with the
header at the top for navigation.</li>
</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Header</h2>
<hr>
</div>
</div>
</div>
</div>
<div class="container">
<div class="col-md-12">
<div class="row">
<div class="col-md-12 col-sm-12 topbar">
<div class="col-md-1 col-sm-2">
<a href="#"><div class="top-brand"></div></a>
</div>
<div class="col-md-2 col-sm-3">
<a class="btn btn-primary" href="#">Services</a>
</div>
<div class="col-md-2 col-sm-3">
<a class="btn btn-default" href="#">Nodes</a>
</div>
<div class="col-md-2 col-sm-3">
<a class="btn btn-default" href="#">Key/Value</a>
</div>
<div class="col-md-2 col-md-offset-1 col-sm-3 col-sm-offset-0">
<a class="btn btn-warning" href="#">5 checks failing</a>
</div>
<div class="col-md-2 col-sm-3">
<a class="btn btn-dropdown btn-default" href="#">
us-east-1
<span class="caret"></span>
</a>
</div>
</div>
</div>
</div>
</div>
<div class="container">
<div class="col-md-10 col-md-offset-1">
<div class="row">
<div class="col-md-6">
<h2>Colors</h2>
<hr>
<ul class="list-unstyled">
<li>
<div style="width: 75px; height: 75px; display:inline-block;" class="bg-purple"></div>
<div style="width: 75px; height: 75px; display:inline-block" class="bg-light-purple"></div>
</li>
<li>
<div style="width: 75px; height: 75px; display:inline-block" class="bg-red"></div>
</li>
<li>
<div style="width: 75px; height: 75px; display:inline-block" class="bg-orange"></div>
</li>
<li>
<div style="width: 75px; height: 75px; display:inline-block" class="bg-dark-green"></div>
<div style="width: 75px; height: 75px; display:inline-block" class="bg-green"></div>
</li>
<li>
<div style="width: 75px; height: 75px; display:inline-block" class="bg-gray"></div>
<div style="width: 75px; height: 75px; display:inline-block" class="bg-light-gray"></div>
</li>
</ul>
</div>
<div class="col-md-6">
<h2>Headings</h2>
<hr>
<h1>Heading 1</h1>
<h2>Heading 2</h2>
<h3>Heading 3</h3>
<h4>Heading 4</h4>
<h5>Heading 5</h5>
<p>Paragraph text. Consul makes it simple for services to
register themselves and to discover other services via a
DNS or HTTP interface. Register external services such as
SaaS providers as well.</p>
<small>Small note text, if you need to include anything extra.</small>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Panels</h2>
<hr>
<p>Panels are for displaying data in the 2nd (right) column.
They show extensive information and are flexible, but also
use the highlight colors to allow for scanning.</p>
<hr>
<div class="panel panel-danger">
<div class="panel-bar"></div>
<div class="panel-heading">
<h3 class="panel-title">
HTTP Server Accessible
<small>httpAccess</small>
<span class="panel-note">critical</span>
</h3>
</div>
<div class="panel-body">
<p>Sends an HTTP request to the HTTP routers /health endpoint.
This should return 200 OK. If it returns anything else,
the headers are dumped.</p>
<h5>OUTPUT</h5>
<pre>
HTTP/1.1 503 SERVICE UNAVAILABLE
Content-Type: text/html; charset=utf-8
Date: Sun, 20 Apr 2014 15:40:03 GMT
Server: gunicorn/0.17.4
Content-Length: 0
Connection: keep-alive
</pre>
</div>
</div>
<div class="panel panel-success">
<div class="panel-bar"></div>
<div class="panel-heading">
<h3 class="panel-title">
Mux Accessible
<small>muxAccess</small>
<span class="panel-note">passing</span>
</h3>
</div>
<div class="panel-body">
<p>Makes a TCP connection to the muxer, dumps a relevant error if the connection fails.</p>
<h5>OUTPUT</h5>
<pre>
Socket connect Successful
</pre>
</div>
</div>
<div class="panel panel-warning">
<div class="panel-bar"></div>
<div class="panel-heading">
<h3 class="panel-title">
Router Accessible
<small>routerAccess</small>
<span class="panel-note">warning</span>
</h3>
</div>
<div class="panel-body">
<p>Makes a TCP connection to the router, dumps a relevant error if the connection fails.</p>
<h5>OUTPUT</h5>
<pre>
Socket connect timed out
</pre>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<h2>Loaders</h2>
<hr>
<p>Pending...</p>
</div>
<div class="col-md-6">
<h2>Icons</h2>
<hr>
<p>Pending...</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Buttons</h2>
<hr>
<a href="#" class="btn btn-default">Default button</a>
<a href="#" class="btn btn-primary">Primary button</a>
<a href="#" class="btn btn-success">Success button</a>
<a href="#" class="btn btn-warning">Warning button</a>
<a href="#" class="btn btn-danger">Danger button</a>
</div>
</div>
<div class="row">
<div class="col-md-12">
<h2>Lists</h2>
<hr>
<p>Lists are used primarily for the first (left) column
view. They are designed as a quick summary, with links
embedded for the top-level item as well as sub-items (
such as a list of nodes, as below).</p>
<hr>
<div class="list-group">
<div class="list-group-item">
<div class="list-bar bg-green"></div>
<h4 class="list-group-item-heading">
<a href="#" class="subtle">vagrant-cloud-http</a>
<small>vagrant-cloud-http</small>
<div class="heading-helper">
<a class="subtle" href="#">5 passing</a>
</div>
</h4>
<ul class="list-inline">
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
</ul>
</div>
<div class="list-group-item">
<div class="list-bar bg-green"></div>
<h4 class="list-group-item-heading">
<a href="#" class="subtle">vagrant-cloud-http</a>
<small>vagrant-cloud-http</small>
<div class="heading-helper">
<a class="subtle" href="#">5 passing</a>
</div>
</h4>
<ul class="list-inline">
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
</ul>
</div>
<div class="list-group-item">
<div class="list-bar bg-orange"></div>
<h4 class="list-group-item-heading">
<a href="#" class="subtle">vagrant-cloud-http</a>
<small>vagrant-cloud-http</small>
<div class="heading-helper">
<a class="subtle" href="#">1 failing</a>
</div>
</h4>
<ul class="list-inline">
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
</ul>
</div>
<div class="list-group-item">
<div class="list-bar bg-red"></div>
<h4 class="list-group-item-heading">
<a href="#" class="subtle">vagrant-cloud-http</a>
<small>vagrant-cloud-http</small>
<div class="heading-helper">
<a class="subtle" href="#">2 failing</a>
</div>
</h4>
<ul class="list-inline">
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
<li><a class="subtle" href="#">node-10-0-109</a></li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</body>
</html>

80
ui/styles/_buttons.scss Normal file
View File

@ -0,0 +1,80 @@
.btn {
text-transform: uppercase;
font-weight: 600;
font-size: 12px;
border-width: 2px;
color: $gray;
@include transition(background-color .2s ease-in-out);
@include transition(border-color .2s ease-in-out);
@include transition(color .2s ease-in-out);
outline: none;
outline-color: white;
&:hover {
color: darken($gray, 10%);
background-color: lighten($gray-background, 5%);
}
&:focus {
outline: none;
outline-color: white;
outline: none;
box-shadow: none;
}
&.active {
outline-color: white;
outline: none;
box-shadow: none;
}
&.btn-primary, &.active {
color: $purple-dark;
background-color: transparent;
border: 2px solid $purple;
&:hover {
background-color: $light-purple;
color: darken($purple, 10%);
}
}
&.btn-warning {
color: $orange-faded;
background-color: transparent;
border: 2px solid $orange-faded;
&:hover {
background-color: lighten($orange-faded, 29%);
color: darken($orange-faded, 10%);
}
}
&.btn-success {
color: $green-dark;
background-color: transparent;
border: 2px solid $green-dark;
&:hover {
background-color: lighten($green-faded, 24%);
color: darken($green-dark, 10%);
}
}
&.btn-danger {
color: $red;
background-color: transparent;
border: 2px solid $red;
&:hover {
background-color: lighten($red, 38%);
color: darken($red, 10%);
}
}
}

18
ui/styles/_forms.scss Normal file
View File

@ -0,0 +1,18 @@
.form-group {
.form-control {
@include transition(border-color .2s ease-in-out);
@include transition(box-shadow .2s ease-in-out);
@include transition(border-color .2s ease-in-out);
}
&.valid {
.form-control {
border-color: $green-faded;
box-shadow: 0 0 5px $green-faded;
}
}
.input-group-addon {
background-color: $gray-background;
}
}

66
ui/styles/_lists.scss Normal file
View File

@ -0,0 +1,66 @@
.list-group-item {
padding: 0;
border-width: 2px;
border-bottom-width: 2px;
border-radius: 0px;
margin-bottom: 15px;
margin-top: 15px;
@include transition(background-color .3s ease-in-out);
.list-group-item-heading, .list-inline {
margin: 10px 15px 10px 15px;
padding: 0px 5px 10px 5px;
}
.list-inline {
padding-left: 0px;
color: $gray;
font-size: 13px;
}
.list-group-item-heading {
border-bottom: 2px solid #eee;
color: $gray-darker;
.heading-helper {
float: right;
font-weight: 600;
color: $gray-light;
font-size: 14px;
}
}
.list-bar {
width: 100%;
height: 20px;
border-top-right-radius: 2px;
border-top-left-radius: 2px;
}
&.list-link:hover {
cursor: pointer;
background-color: lighten($gray-background, 8%);
}
&.active {
@include transition(border-color .1s linear);
border-color: $purple;
.list-bar {
@include transition(background-color .1s linear);
background-color: $purple;
}
}
}
ul.list-broken {
li {
// border-top: 2px lighten($gray-background, 5%) solid;
}
&:last-child {
// border-bottom: 2px lighten($gray-background, 5%) solid;
}
}

7
ui/styles/_mixins.scss Normal file
View File

@ -0,0 +1,7 @@
@mixin transition($transition) {
-webkit-transition: $transition;
-moz-transition: $transition;
-ms-transition: $transition;
-o-transition: $transition;
transition: $transition;
}

45
ui/styles/_nav.scss Normal file
View File

@ -0,0 +1,45 @@
.top-brand {
margin-top: 20px;
background: transparent url('consul-logo.png') 0 no-repeat;
background-size: 30px 30px;
width: 30px;
height: 30px;
}
.topbar {
padding: 30px;
margin-bottom: 20px;
min-height: 100px;
border-bottom: 1px #eee solid;
.btn {
margin-top: 20px;
min-width: 140px;
}
.btn-dropdown {
width: auto;
}
ul.dropdown-menu {
li {
a {
text-transform: uppercase;
font-weight: 600;
font-size: 12px;
color: $gray;
@include transition(background-color .1s ease-in-out);
&:hover {
color: darken($gray, 10%);
background-color: lighten($gray-background, 5%);
}
}
}
}
}

78
ui/styles/_panels.scss Normal file
View File

@ -0,0 +1,78 @@
.panel {
border-width: 2px;
border-color: $gray-background;
@include transition(background-color .3s ease-in-out);
.panel-heading {
background-color: transparent;
border-width: 2px;
border-color: $gray-background;
}
h3.panel-title {
padding: 4px 0px 4px 0px;
font-size: 20px;
color: $gray-light;
color: $gray-darker;
border-radius: 3px;
small {
font-size: 14px;
margin-left: 5px;
}
.panel-note {
margin-top: 5px;
float: right;
font-weight: 600;
color: $gray-light;
font-size: 14px;
}
}
.panel-body {
p {
font-size: 14px;
color: $text-color;
}
h5 {
font-size: 12px;
}
h4.check {
font-size: 16px;
}
}
.panel-bar {
width: 100%;
height: 20px;
@include transition(background-color .1s linear);
}
&.panel-link {
border-bottom-width: 2px;
}
&.panel-short {
border-bottom-width: 0px;
}
&.panel-link:hover {
cursor: pointer;
background-color: lighten($gray-background, 8%);
}
&.active {
>.panel-heading {
border-color: $purple;
}
@include transition(border-color .1s linear);
border-color: $purple;
.panel-bar {
@include transition(background-color .1s linear);
background-color: $purple;
}
}
}

67
ui/styles/_type.scss Normal file
View File

@ -0,0 +1,67 @@
body {
-webkit-font-smoothing:antialiased;
font-size: 16px;
color: $text-color;
}
a {
color: $purple;
font-weight: 600;
@include transition(color .2s ease-in-out);
&:hover {
text-decoration: none;
color: darken($purple, 10%);
}
&.subtle {
color: inherit;
&:hover {
color: $purple;
}
}
}
code {
color: $purple-dark;
background-color: $gray-background;
}
.help-block {
font-size: 14px;
color: $gray-light;
}
small {
color: $gray;
}
h1, h2, h3, h4, h5 {
color: $gray-darker;
}
h5 {
text-transform: uppercase;
font-weight: 700;
color: $gray-light;
}
h4.breadcrumbs {
padding-bottom: 5px;
text-transform: uppercase;
a {
color: $gray-light;
}
}
pre {
background-color: $gray;
color: white;
font-weight: 700;
font-size: 12px;
}
.bold {
font-weight: 700;
}

26
ui/styles/_variables.scss Normal file
View File

@ -0,0 +1,26 @@
// Colors
$gray-light: lighten(gray, 50%);
$black: #242424;
$gray-darker: #555;
$gray: #777;
$gray-light: #939393;
$gray-background: #E6E6E6;
$red: #dd4e58;
$red-dark: #c5454e;
$red-darker: #b03c44;
$tan: #f0f0e5;
$consul-gray: #909090;
$consul-footer-gray: #d7d4d7;
$purple-dark: #69499a;
$purple: lighten($purple-dark, 20%);
$light-purple: #f7f3f9;
$green-faded: #BBF085;
$green-dark: #86B457;
$red-faded: $red;
$white-faded: darken(white, 2%);
$orange-faded: #FFAC5E;
// Type
$text-color: #555;

97
ui/styles/base.scss Normal file
View File

@ -0,0 +1,97 @@
@import "mixins";
@import "variables";
@import "type";
@import "panels";
@import "nav";
@import "buttons";
@import "lists";
@import "forms";
@media (min-width: 768px) { // + 18
.container {
width: 750px;
}
}
@media (min-width: 992px) { // + 22
.container {
width: 970px;
}
}
@media (min-width: 1200px) { // + 30
.container {
width: 1400px;
}
}
a {
button:active {
outline: none;
}
}
.buffer-small {
height: 50px;
}
.border-left {
display: block;
height: 700px;
.line {
margin: 0 auto;
background-color: $gray-background;
height: 100%;
width: 1px;
}
}
.no-margin {
margin: 0;
}
.vertical-center {
margin-top: 200px;
}
.row {
&.colored {
background-color: $light-purple;
}
}
.bordered {
border-left: 2px solid $gray-background;
}
.bg-purple {
background-color: $purple;
}
.bg-light-purple {
background-color: $light-purple;
}
.bg-orange {
background-color: $orange-faded;
}
.bg-green {
background-color: $green-faded;
}
.bg-dark-green {
background-color: $green-dark;
}
.bg-red {
background-color: $red-faded;
}
.bg-gray {
background-color: $gray-light;
}
.bg-light-gray {
background-color: $gray-background;
}

14
ui/tests/runner.css Executable file
View File

@ -0,0 +1,14 @@
#ember-testing-container {
position: absolute;
bottom: 0;
right: 0;
width: 640px;
height: 384px;
overflow: auto;
z-index: 9999;
border: 1px solid #ccc;
background: white;
}
#ember-testing {
zoom: 50%;
}

13
ui/tests/runner.js Executable file
View File

@ -0,0 +1,13 @@
if (window.location.search.indexOf("?test") !== -1) {
document.write(
'<div id="qunit"></div>' +
'<div id="qunit-fixture"></div>' +
'<div id="ember-testing-container">' +
' <div id="ember-testing"></div>' +
'</div>' +
'<link rel="stylesheet" href="tests/runner.css">' +
'<link rel="stylesheet" href="tests/vendor/qunit-1.12.0.css">' +
'<script src="tests/vendor/qunit-1.12.0.js"></script>' +
'<script src="tests/tests.js"></script>'
)
}

31
ui/tests/tests.js Executable file
View File

@ -0,0 +1,31 @@
// in order to see the app running inside the QUnit runner
App.rootElement = '#ember-testing';
// Common test setup
App.setupForTesting();
App.injectTestHelpers();
// common QUnit module declaration
module("Integration tests", {
setup: function() {
// before each test, ensure the application is ready to run.
Ember.run(App, App.advanceReadiness);
},
teardown: function() {
// reset the application state between each test
App.reset();
}
});
// QUnit test case
test("/", function() {
// async helper telling the application to go to the '/' route
visit("/");
// helper waiting the application is idle before running the callback
andThen(function() {
equal(find("h1").text(), "Base", "Application header is rendered");
equal(find("li").length, 3, "There are three items in the list");
});
});

244
ui/tests/vendor/qunit-1.12.0.css vendored Executable file
View File

@ -0,0 +1,244 @@
/**
* QUnit v1.12.0 - A JavaScript Unit Testing Framework
*
* http://qunitjs.com
*
* Copyright 2012 jQuery Foundation and other contributors
* Released under the MIT license.
* http://jquery.org/license
*/
/** Font Family and Sizes */
#qunit-tests, #qunit-header, #qunit-banner, #qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult {
font-family: "Helvetica Neue Light", "HelveticaNeue-Light", "Helvetica Neue", Calibri, Helvetica, Arial, sans-serif;
}
#qunit-testrunner-toolbar, #qunit-userAgent, #qunit-testresult, #qunit-tests li { font-size: small; }
#qunit-tests { font-size: smaller; }
/** Resets */
#qunit-tests, #qunit-header, #qunit-banner, #qunit-userAgent, #qunit-testresult, #qunit-modulefilter {
margin: 0;
padding: 0;
}
/** Header */
#qunit-header {
padding: 0.5em 0 0.5em 1em;
color: #8699a4;
background-color: #0d3349;
font-size: 1.5em;
line-height: 1em;
font-weight: normal;
border-radius: 5px 5px 0 0;
-moz-border-radius: 5px 5px 0 0;
-webkit-border-top-right-radius: 5px;
-webkit-border-top-left-radius: 5px;
}
#qunit-header a {
text-decoration: none;
color: #c2ccd1;
}
#qunit-header a:hover,
#qunit-header a:focus {
color: #fff;
}
#qunit-testrunner-toolbar label {
display: inline-block;
padding: 0 .5em 0 .1em;
}
#qunit-banner {
height: 5px;
}
#qunit-testrunner-toolbar {
padding: 0.5em 0 0.5em 2em;
color: #5E740B;
background-color: #eee;
overflow: hidden;
}
#qunit-userAgent {
padding: 0.5em 0 0.5em 2.5em;
background-color: #2b81af;
color: #fff;
text-shadow: rgba(0, 0, 0, 0.5) 2px 2px 1px;
}
#qunit-modulefilter-container {
float: right;
}
/** Tests: Pass/Fail */
#qunit-tests {
list-style-position: inside;
}
#qunit-tests li {
padding: 0.4em 0.5em 0.4em 2.5em;
border-bottom: 1px solid #fff;
list-style-position: inside;
}
#qunit-tests.hidepass li.pass, #qunit-tests.hidepass li.running {
display: none;
}
#qunit-tests li strong {
cursor: pointer;
}
#qunit-tests li a {
padding: 0.5em;
color: #c2ccd1;
text-decoration: none;
}
#qunit-tests li a:hover,
#qunit-tests li a:focus {
color: #000;
}
#qunit-tests li .runtime {
float: right;
font-size: smaller;
}
.qunit-assert-list {
margin-top: 0.5em;
padding: 0.5em;
background-color: #fff;
border-radius: 5px;
-moz-border-radius: 5px;
-webkit-border-radius: 5px;
}
.qunit-collapsed {
display: none;
}
#qunit-tests table {
border-collapse: collapse;
margin-top: .2em;
}
#qunit-tests th {
text-align: right;
vertical-align: top;
padding: 0 .5em 0 0;
}
#qunit-tests td {
vertical-align: top;
}
#qunit-tests pre {
margin: 0;
white-space: pre-wrap;
word-wrap: break-word;
}
#qunit-tests del {
background-color: #e0f2be;
color: #374e0c;
text-decoration: none;
}
#qunit-tests ins {
background-color: #ffcaca;
color: #500;
text-decoration: none;
}
/*** Test Counts */
#qunit-tests b.counts { color: black; }
#qunit-tests b.passed { color: #5E740B; }
#qunit-tests b.failed { color: #710909; }
#qunit-tests li li {
padding: 5px;
background-color: #fff;
border-bottom: none;
list-style-position: inside;
}
/*** Passing Styles */
#qunit-tests li li.pass {
color: #3c510c;
background-color: #fff;
border-left: 10px solid #C6E746;
}
#qunit-tests .pass { color: #528CE0; background-color: #D2E0E6; }
#qunit-tests .pass .test-name { color: #366097; }
#qunit-tests .pass .test-actual,
#qunit-tests .pass .test-expected { color: #999999; }
#qunit-banner.qunit-pass { background-color: #C6E746; }
/*** Failing Styles */
#qunit-tests li li.fail {
color: #710909;
background-color: #fff;
border-left: 10px solid #EE5757;
white-space: pre;
}
#qunit-tests > li:last-child {
border-radius: 0 0 5px 5px;
-moz-border-radius: 0 0 5px 5px;
-webkit-border-bottom-right-radius: 5px;
-webkit-border-bottom-left-radius: 5px;
}
#qunit-tests .fail { color: #000000; background-color: #EE5757; }
#qunit-tests .fail .test-name,
#qunit-tests .fail .module-name { color: #000000; }
#qunit-tests .fail .test-actual { color: #EE5757; }
#qunit-tests .fail .test-expected { color: green; }
#qunit-banner.qunit-fail { background-color: #EE5757; }
/** Result */
#qunit-testresult {
padding: 0.5em 0.5em 0.5em 2.5em;
color: #2b81af;
background-color: #D2E0E6;
border-bottom: 1px solid white;
}
#qunit-testresult .module-name {
font-weight: bold;
}
/** Fixture */
#qunit-fixture {
position: absolute;
top: -10000px;
left: -10000px;
width: 1000px;
height: 1000px;
}

2212
ui/tests/vendor/qunit-1.12.0.js vendored Executable file

File diff suppressed because it is too large Load Diff

View File

@ -10,13 +10,14 @@ The main interface to Consul is a RESTful HTTP API. The API can be
used for CRUD for nodes, services, checks, and configuration. The endpoints are
versioned to enable changes without breaking backwards compatibility.
All endpoints fall into one of 5 categories:
All endpoints fall into one of 6 categories:
* kv - Key/Value store
* agent - Agent control
* catalog - Manages nodes and services
* health - Manages health checks
* status - Consul system status
* internal - Internal APIs. Purposely undocumented, subject to change.
Each of the categories and their respective endpoints are documented below.
@ -146,6 +147,21 @@ keys sharing a prefix. If the "?recurse" query parameter is provided,
then all keys with the prefix are deleted, otherwise only the specified
key.
It is possible to also only list keys without any values by using the
"?keys" query parameter along with a `GET` request. This will return
a list of the keys under the given prefix. The optional "?seperator="
can be used to list only up to a given seperator.
For example, listing "/web/" with a "/" seperator may return:
[
"/web/bar",
"/web/foo",
"/web/subdir/"
]
Using the key listing method may be suitable when you do not need
the values or flags, or want to implement a key-space explorer.
## Agent

View File

@ -93,6 +93,9 @@ The options below are all specified on the command-line.
participate in a WAN gossip pool with server nodes in other datacenters. Servers act as gateways
to other datacenters and forward traffic as appropriate.
* `-ui-dir` - This flag provides a the directory containing the Web UI resources
for Consul. This must be provided to enable the Web UI. Directory must be readable.
## Configuration Files
In addition to the command-line options, configuration can be put into
@ -142,6 +145,8 @@ definitions support being updated during a reload.
* `server` - Equivalent to the `-server` command-line flag.
* `ui_dir` - Equivalent to the `-ui-dir` command-line flag.
* `advertise_addr` - The advertise address is used to change the address that we
advertise to other nodes in the cluster. By default, the `-bind` address is
advertised. However, in some cases, there may be a routable address that cannot

View File

@ -4,7 +4,8 @@
<ul class="main-links nav navbar-nav rls-sb">
<li class="li-under"><a href="/intro/index.html">Intro</a></li>
<li class="active li-under"><a href="/docs/index.html">Docs</a></li>
<li class="li-under"><a href="/community.html">Community</a></li>
<li class="li-under"><a href="/community.html">Community</a></li>
<li class="li-under"><a href="http://demo.consul.io/">Demo</a></li>
</ul>
<ul class="buttons nav navbar-nav rls-sb">

View File

@ -63,7 +63,8 @@
<ul class="main-links nav navbar-nav navbar-right rls-sb">
<li class="first li-under"><a href="/intro/index.html">Intro</a></li>
<li class="li-under"><a href="/docs/index.html">Docs</a></li>
<li class="li-under"><a href="/community.html">Community</a></li>
<li class="li-under"><a href="/community.html">Community</a></li>
<li class="li-under"><a href="http://demo.consul.io/">Demo</a></li>
</ul>
</nav>