├── docs └── consul-kv-dashboard.png ├── assets ├── css │ └── style.css ├── index.html └── scripts │ └── dashboard.js ├── status_string.go ├── .gitignore ├── Makefile ├── http.go ├── assets.go ├── LICENSE ├── README.md └── dashboard.go /docs/consul-kv-dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fujiwara/consul-kv-dashboard/HEAD/docs/consul-kv-dashboard.png -------------------------------------------------------------------------------- /assets/css/style.css: -------------------------------------------------------------------------------- 1 | pre { 2 | white-space: pre-wrap; 3 | word-wrap: break-word; 4 | overflow: auto; 5 | } 6 | .item_body { 7 | max-height: 6em; 8 | font-size: 0.8em; 9 | cursor: pointer; 10 | } 11 | .item_body_expanded { 12 | font-size: 0.8em; 13 | cursor: pointer; 14 | } 15 | .item_data_col { 16 | width: 50%; 17 | } 18 | .item_timestamp_col { 19 | width: 15em; 20 | } 21 | -------------------------------------------------------------------------------- /status_string.go: -------------------------------------------------------------------------------- 1 | // Code generated by "stringer -type=Status"; DO NOT EDIT 2 | 3 | package main 4 | 5 | import "fmt" 6 | 7 | const _Status_name = "SuccessWarningDangerInfo" 8 | 9 | var _Status_index = [...]uint8{0, 7, 14, 20, 24} 10 | 11 | func (i Status) String() string { 12 | if i < 0 || i >= Status(len(_Status_index)-1) { 13 | return fmt.Sprintf("Status(%d)", i) 14 | } 15 | return _Status_name[_Status_index[i]:_Status_index[i+1]] 16 | } 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | consul-kv-dashboard 2 | pkg/ 3 | 4 | # auto generated by go-bindata 5 | bindata.go 6 | 7 | # Compiled Object files, Static and Dynamic libs (Shared Objects) 8 | *.o 9 | *.a 10 | *.so 11 | 12 | # Folders 13 | _obj 14 | _test 15 | 16 | # Architecture specific extensions/prefixes 17 | *.[568vq] 18 | [568vq].out 19 | 20 | *.cgo1.go 21 | *.cgo2.c 22 | _cgo_defun.c 23 | _cgo_gotypes.go 24 | _cgo_export.* 25 | 26 | _testmain.go 27 | 28 | *.exe 29 | *.test 30 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | GIT_VER := $(shell git describe --tags) 2 | 3 | .PHONY: packages clean 4 | 5 | consul-kv-dashboard: dashboard.go bindata.go 6 | stringer -type=Status 7 | go build 8 | 9 | bindata.go: assets/index.html assets/scripts/dashboard.js assets/css/style.css 10 | go-bindata -prefix=assets assets/... 11 | 12 | packages: bindata.go dashboard.go 13 | gox -os="linux darwin windows" -arch="amd64 386" -output "pkg/{{.Dir}}-${GIT_VER}-{{.OS}}-{{.Arch}}" -ldflags "-w -s -X main.Version=${GIT_VER}" 14 | cd pkg && find . -name "*${GIT_VER}*" -type f -exec zip {}.zip {} \; 15 | 16 | clean: 17 | rm -fr pkg/* consul-kv-dashboard 18 | -------------------------------------------------------------------------------- /assets/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Consul KV Dashboard 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 |
14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /http.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "compress/gzip" 5 | "io" 6 | "net/http" 7 | "strings" 8 | ) 9 | 10 | type gzipResponseWriter struct { 11 | io.Writer 12 | http.ResponseWriter 13 | } 14 | 15 | func (w gzipResponseWriter) Write(b []byte) (int, error) { 16 | return w.Writer.Write(b) 17 | } 18 | 19 | func (w gzipResponseWriter) Header() http.Header { 20 | return w.ResponseWriter.Header() 21 | } 22 | 23 | func (w gzipResponseWriter) WriteHeader(code int) { 24 | w.ResponseWriter.WriteHeader(code) 25 | } 26 | 27 | func makeGzipHandler(fn http.HandlerFunc) http.HandlerFunc { 28 | return func(w http.ResponseWriter, r *http.Request) { 29 | if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") { 30 | fn(w, r) 31 | return 32 | } 33 | w.Header().Set("Content-Encoding", "gzip") 34 | gz := gzip.NewWriter(w) 35 | defer gz.Close() 36 | gzr := gzipResponseWriter{Writer: gz, ResponseWriter: w} 37 | fn(gzr, r) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /assets.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "log" 6 | "net/http" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | type AssetFileSystem struct { 12 | Prefix string 13 | } 14 | 15 | func NewAssetFileSystem(prefix string) AssetFileSystem { 16 | if prefix == "" { 17 | prefix = "/" 18 | } 19 | return AssetFileSystem{Prefix: prefix} 20 | } 21 | 22 | type AssetFile struct { 23 | *bytes.Reader 24 | os.FileInfo 25 | } 26 | 27 | func (fs AssetFileSystem) Open(name string) (http.File, error) { 28 | path := name 29 | path = strings.TrimPrefix(path, fs.Prefix) 30 | data, err := Asset(path) 31 | if err != nil { 32 | log.Println(err) 33 | return nil, err 34 | } 35 | info, _ := AssetInfo(path) 36 | file := &AssetFile{ 37 | bytes.NewReader(data), 38 | info, 39 | } 40 | return file, nil 41 | } 42 | 43 | func (f *AssetFile) Close() error { 44 | return nil 45 | } 46 | 47 | func (f *AssetFile) Readdir(count int) ([]os.FileInfo, error) { 48 | return []os.FileInfo{}, nil 49 | } 50 | 51 | func (f *AssetFile) Stat() (os.FileInfo, error) { 52 | return f.FileInfo, nil 53 | } 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 FUJIWARA Shunichiro 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # consul-kv-dashboard 2 | 3 | A dashboard web console based on Consul's key value store. 4 | 5 | ![screenshot](docs/consul-kv-dashboard.png) 6 | 7 | ## Build 8 | 9 | $ make 10 | 11 | ## Usage 12 | 13 | ``` 14 | Usase of ./consul-kv-dashboard: 15 | -asset="": Serve files located in /assets from local directory. If not specified, use built-in asset. 16 | -namespace="dashboard": Consul kv top level key name. (/v1/kv/{namespace}/...) 17 | -port=3000: http listen port 18 | -trigger="": trigger command 19 | -v=false: show vesion 20 | -version=false: show vesion 21 | ``` 22 | 23 | ## Quick start 24 | 25 | 1. Run consul cluster. 26 | 1. Run consul-kv-dashboard. 27 | 2. Access to `http://myhost.example.com:3000/` 28 | 3. Put a dashboard event to Consul KV. 29 | ``` 30 | $ curl -X PUT -d "content" localhost:8500/v1/kv/dashboard/example/myhost?flags=1422597159000 31 | ``` 32 | 33 | ### Consul KV's key name specification 34 | 35 | ``` 36 | /v1/kv/{namespace}/{category}/{node}(/{key})?flags={flags} 37 | ``` 38 | 39 | * {namespace} : namespace. (default: `dashboard`) 40 | * {category} : dashboard category (e.g. `chef`, `serverspec`, `deploy`...) 41 | * {node} : consul node name. How to get a self node name using consul API, `curl -s localhost:8500/v1/agent/self | jq -r .Member.Name` 42 | * {key} : sub event name's key. (optional) 43 | * {flags} : timestamp(unix\_time) * 1000 + {status} 44 | * {status} 45 | * 0 : Success 46 | * 1 : Warning 47 | * 2 : Danger 48 | * 3 : Info 49 | 50 | ## Trigger 51 | 52 | ``` 53 | $ consul-kv-dashboard -trigger /path/to/command 54 | ``` 55 | 56 | Invoke trigger command when dashboard item's status was changed. 57 | 58 | Pass a changed item (encoded as json) to command's stdin. 59 | 60 | ```json 61 | {"category":"testing","node":"web01","address":"192.168.1.10","timestamp":"2015-01-21 11:22:33 +0900","status":"danger","key":"","data":"failure!!"} 62 | ``` 63 | 64 | ## LICENSE 65 | 66 | MIT 67 | -------------------------------------------------------------------------------- /assets/scripts/dashboard.js: -------------------------------------------------------------------------------- 1 | var StatusSelector = React.createClass({ 2 | handleChange: function(event) { 3 | this.props.updateStatusFilter(event.target.value) 4 | }, 5 | handleNodeGrep: function(event) { 6 | this.props.updateNodeFilter(event.target.value) 7 | }, 8 | handleKeyGrep: function(event) { 9 | this.props.updateKeyFilter(event.target.value) 10 | }, 11 | render: function() { 12 | return ( 13 |
14 | 27 |
28 | ); 29 | } 30 | }); 31 | 32 | var Title = React.createClass({ 33 | render: function() { 34 | return ( 35 | : {this.props.category} 36 | ); 37 | } 38 | }); 39 | 40 | var Category = React.createClass({ 41 | render: function() { 42 | var active = this.props.currentCategory == this.props.name ? "active" : ""; 43 | var href = "/" + this.props.name; 44 | return ( 45 |
  • {this.props.name}
  • 46 | ); 47 | } 48 | }); 49 | 50 | var Categories = React.createClass({ 51 | render: function() { 52 | var currentCategory = this.props.currentCategory 53 | var handleChange = this.handleChange 54 | var cats = this.props.data.map(function(cat, index) { 55 | return ( 56 | 57 | ); 58 | }); 59 | return ( 60 | 63 | ); 64 | } 65 | }); 66 | 67 | var Item = React.createClass({ 68 | render: function() { 69 | var item = this.props.item; 70 | var icon = "glyphicon"; 71 | var status = item.status; 72 | if (item.status != "success" && item.status != "info") { 73 | icon += " glyphicon-alert"; 74 | status += " alert-" + item.status; 75 | } 76 | return ( 77 | 78 | 79 | {item.node} 80 | {item.address} 81 | {item.key} 82 | {item.timestamp} 83 | 84 | 85 | {item.data} 86 | 87 | 88 | ); 89 | } 90 | }); 91 | 92 | var ItemBody = React.createClass({ 93 | handleClick: function() { 94 | if ( this.state.expanded && window.getSelection().toString() != "" ) { 95 | return; 96 | } 97 | this.setState({ expanded: !this.state.expanded }) 98 | }, 99 | getInitialState: function() { 100 | return { expanded: false }; 101 | }, 102 | render: function() { 103 | var classString = "item_body" 104 | if (this.state.expanded) { 105 | classString = "item_body_expanded" 106 | } 107 | return ( 108 |
    {this.props.children}
    109 | ); 110 | } 111 | }); 112 | 113 | var Dashboard = React.createClass({ 114 | loadCategoriesFromServer: function() { 115 | $.ajax({ 116 | url: "/api/?keys", 117 | dataType: 'json', 118 | success: function(data, textStatus, request) { 119 | if (!this.state.currentCategory) { 120 | location.pathname = "/" + data[0] 121 | } else { 122 | this.setState({categories: data}) 123 | } 124 | }.bind(this), 125 | error: function(xhr, status, err) { 126 | console.error("/api/?keys", status, err.toString()); 127 | }.bind(this) 128 | }); 129 | }, 130 | loadDashboardFromServer: function() { 131 | if (!this.state.currentCategory) { 132 | setTimeout(this.loadDashboardFromServer, this.props.pollWait / 5); 133 | return; 134 | } 135 | var statusFilter = this.state.statusFilter; 136 | var ajax = $.ajax({ 137 | url: "/api/" + this.state.currentCategory + "?recurse&wait=55s&index=" + this.state.index || 0, 138 | dataType: 'json', 139 | success: function(data, textStatus, request) { 140 | var timer = setTimeout(this.loadDashboardFromServer, this.props.pollWait); 141 | var index = request.getResponseHeader('X-Consul-Index') 142 | this.setState({ 143 | items: data, 144 | index: index, 145 | timer: timer, 146 | }); 147 | }.bind(this), 148 | error: function(xhr, status, err) { 149 | console.log("ajax error:" + err) 150 | var wait = this.props.pollWait * 5 151 | if (err == "abort") { 152 | wait = 0 153 | } 154 | var timer = setTimeout(this.loadDashboardFromServer, wait); 155 | this.setState({ timer: timer }) 156 | }.bind(this) 157 | }); 158 | this.setState({ajax: ajax}) 159 | }, 160 | getInitialState: function() { 161 | var cat = location.pathname.replace(/^\//,"") 162 | if (cat == "") { 163 | cat = undefined 164 | } 165 | return { 166 | items: [], 167 | categories: [], 168 | index: 0, 169 | ajax: undefined, 170 | timer: undefined, 171 | statusFilter: "", 172 | nodeFilter: "", 173 | keyFilter: "", 174 | currentCategory: cat 175 | }; 176 | }, 177 | componentDidMount: function() { 178 | this.loadCategoriesFromServer(); 179 | this.loadDashboardFromServer(); 180 | }, 181 | updateStatusFilter: function(filter) { 182 | this.setState({ statusFilter: filter }); 183 | }, 184 | updateNodeFilter: function(filter) { 185 | this.setState({ nodeFilter: filter }); 186 | }, 187 | updateKeyFilter: function(filter) { 188 | this.setState({ keyFilter: filter }); 189 | }, 190 | render: function() { 191 | var statusFilter = this.state.statusFilter; 192 | var nodeFilter = this.state.nodeFilter; 193 | var keyFilter = this.state.keyFilter; 194 | var items = this.state.items.map(function(item, index) { 195 | if ((statusFilter == "" || item.status == statusFilter) && (nodeFilter == "" || item.node.indexOf(nodeFilter) != -1 ) && (keyFilter == "" || item.key.indexOf(keyFilter) != -1 )) { 196 | return ( 197 | 198 | ); 199 | } else { 200 | return; 201 | } 202 | }); 203 | return ( 204 |
    205 |

    Dashboard </h1> 206 | <Categories data={this.state.categories} currentCategory={this.state.currentCategory} /> 207 | <StatusSelector status={this.state.statusFilter} updateStatusFilter={this.updateStatusFilter} updateNodeFilter={this.updateNodeFilter} updateKeyFilter={this.updateKeyFilter} /> 208 | <table className="table table-bordered"> 209 | <thead> 210 | <tr> 211 | <th>node | service</th> 212 | <th>address</th> 213 | <th>key</th> 214 | <th className="item_timestamp_col">timestamp</th> 215 | </tr> 216 | </thead> 217 | {items} 218 | </table> 219 | </div> 220 | ); 221 | } 222 | }); 223 | 224 | React.render( 225 | <Dashboard pollWait={1000} />, 226 | document.getElementById('content') 227 | ); 228 | -------------------------------------------------------------------------------- /dashboard.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "log" 11 | "net/http" 12 | "os" 13 | "os/exec" 14 | "sort" 15 | "strconv" 16 | "strings" 17 | "sync" 18 | "time" 19 | ) 20 | 21 | var ( 22 | Namespace = "dashboard" 23 | ConsulAddr = "127.0.0.1:8500" 24 | Version string 25 | ExtAssetDir string 26 | Nodes []Node 27 | Services map[string][]string 28 | mutex sync.RWMutex 29 | ) 30 | 31 | type KVPair struct { 32 | Key string 33 | CreateIndex int64 34 | ModifyIndex int64 35 | LockIndex int64 36 | Flags int64 37 | Value []byte 38 | } 39 | 40 | type Status int64 41 | 42 | const ( 43 | Success Status = iota 44 | Warning 45 | Danger 46 | Info 47 | ) 48 | 49 | func (s Status) MarshalText() ([]byte, error) { 50 | if s <= Info { 51 | return []byte(strings.ToLower(s.String())), nil 52 | } else { 53 | return []byte(strconv.FormatInt(int64(s), 10)), nil 54 | } 55 | } 56 | 57 | type Item struct { 58 | Category string `json:"category"` 59 | Node string `json:"node"` 60 | Address string `json:"address"` 61 | Timestamp string `json:"timestamp"` 62 | Status Status `json:"status"` 63 | Key string `json:"key"` 64 | Data string `json:"data"` 65 | } 66 | 67 | func (kv *KVPair) NewItem() Item { 68 | item := Item{ 69 | Data: string(kv.Value), 70 | Timestamp: time.Unix(kv.Flags/1000, 0).Format("2006-01-02 15:04:05 -0700"), 71 | } 72 | item.Status = Status(kv.Flags % 1000) 73 | 74 | // kv.Key : {namespace}/{category}/{node}/{key} 75 | path := strings.Split(kv.Key, "/") 76 | item.Category = path[1] 77 | if len(path) >= 3 { 78 | item.Node = path[2] 79 | } 80 | if len(path) >= 4 { 81 | item.Key = path[3] 82 | } 83 | return item 84 | } 85 | 86 | type Node struct { 87 | Node string 88 | Address string 89 | } 90 | 91 | func main() { 92 | var ( 93 | port int 94 | showVersion bool 95 | trigger string 96 | ) 97 | flag.StringVar(&Namespace, "namespace", Namespace, "Consul kv top level key name. (/v1/kv/{namespace}/...)") 98 | flag.IntVar(&port, "port", 3000, "http listen port") 99 | flag.StringVar(&ExtAssetDir, "asset", "", "Serve files located in /assets from local directory. If not specified, use built-in asset.") 100 | flag.BoolVar(&showVersion, "v", false, "show vesion") 101 | flag.BoolVar(&showVersion, "version", false, "show vesion") 102 | flag.StringVar(&trigger, "trigger", "", "trigger command") 103 | flag.Parse() 104 | 105 | if showVersion { 106 | fmt.Println("consul-kv-dashboard: version:", Version) 107 | return 108 | } 109 | 110 | mux := http.NewServeMux() 111 | mux.HandleFunc("/", makeGzipHandler(indexPage)) 112 | mux.HandleFunc("/api/", makeGzipHandler(kvApiProxy)) 113 | 114 | if ExtAssetDir != "" { 115 | mux.Handle("/assets/", 116 | http.StripPrefix("/assets/", http.FileServer(http.Dir(ExtAssetDir)))) 117 | } else { 118 | mux.Handle("/assets/", 119 | http.FileServer(NewAssetFileSystem("/assets/"))) 120 | } 121 | http.Handle("/", mux) 122 | 123 | log.Println("listen port:", port) 124 | log.Println("asset directory:", ExtAssetDir) 125 | log.Println("namespace:", Namespace) 126 | if trigger != "" { 127 | log.Println("trigger:", trigger) 128 | go watchForTrigger(trigger) 129 | } 130 | go updateNodes() 131 | go updateServices() 132 | 133 | log.Fatal(http.ListenAndServe(":"+strconv.Itoa(port), nil)) 134 | } 135 | 136 | func indexPage(w http.ResponseWriter, r *http.Request) { 137 | var ( 138 | data []byte 139 | err error 140 | ) 141 | if ExtAssetDir == "" { 142 | data, err = Asset("index.html") 143 | } else { 144 | var f *os.File 145 | f, err = os.Open(ExtAssetDir + "/index.html") 146 | data, err = ioutil.ReadAll(f) 147 | } 148 | if err != nil { 149 | log.Println(err) 150 | } 151 | w.Header().Set("Content-Type", "text/html; charset=utf-8") 152 | fmt.Fprint(w, string(data)) 153 | } 154 | 155 | func kvApiProxy(w http.ResponseWriter, r *http.Request) { 156 | r.ParseForm() 157 | path := strings.TrimPrefix(r.URL.Path, "/api/") 158 | resp, _, err := callConsulAPI( 159 | "/v1/kv/" + Namespace + "/" + path + "?" + r.URL.RawQuery, 160 | ) 161 | if err != nil { 162 | http.Error(w, fmt.Sprintf("%s", err), http.StatusInternalServerError) 163 | return 164 | } 165 | defer resp.Body.Close() 166 | if resp.StatusCode == http.StatusNotFound { 167 | w.Header().Set("Content-Type", "application/json") 168 | http.Error(w, "[]", resp.StatusCode) 169 | return 170 | } 171 | if resp.StatusCode != http.StatusOK { 172 | http.Error(w, "", resp.StatusCode) 173 | io.Copy(w, resp.Body) 174 | return 175 | } 176 | // copy response header to client 177 | for name, value := range resp.Header { 178 | if strings.HasPrefix(name, "X-") || name == "Content-Type" { 179 | for _, v := range value { 180 | w.Header().Set(name, v) 181 | } 182 | } 183 | } 184 | 185 | // keys or values 186 | dec := json.NewDecoder(resp.Body) 187 | enc := json.NewEncoder(w) 188 | if _, t := r.Form["keys"]; t { 189 | var keys []string 190 | uniqKeyMap := make(map[string]bool) 191 | dec.Decode(&keys) 192 | for _, key := range keys { 193 | path := strings.Split(key, "/") 194 | if len(path) >= 2 { 195 | uniqKeyMap[path[1]] = true 196 | } 197 | } 198 | uniqKeys := make([]string, 0, len(uniqKeyMap)) 199 | for key, _ := range uniqKeyMap { 200 | uniqKeys = append(uniqKeys, key) 201 | } 202 | sort.Strings(uniqKeys) 203 | enc.Encode(uniqKeys) 204 | } else { 205 | var kvps []*KVPair 206 | dec.Decode(&kvps) 207 | items := make([]Item, 0, len(kvps)) 208 | for _, kv := range kvps { 209 | item := kv.NewItem() 210 | if itemInCatalog(&item) { 211 | items = append(items, item) 212 | } 213 | } 214 | enc.Encode(items) 215 | } 216 | } 217 | 218 | func watchForTrigger(command string) { 219 | var index int64 220 | lastStatus := make(map[string]Status) 221 | prevItem := make(map[Item]Status) 222 | for { 223 | resp, newIndex, err := callConsulAPI( 224 | "/v1/kv/" + Namespace + "/?recurse&wait=55s&index=" + strconv.FormatInt(index, 10), 225 | ) 226 | if err != nil { 227 | log.Println("[error]", err) 228 | time.Sleep(10 * time.Second) 229 | continue 230 | } 231 | index = newIndex 232 | var kvps []*KVPair 233 | dec := json.NewDecoder(resp.Body) 234 | dec.Decode(&kvps) 235 | resp.Body.Close() 236 | 237 | // find each current item of category 238 | currentItem := make(map[string]Item) 239 | for _, kv := range kvps { 240 | item := kv.NewItem() 241 | if !itemInCatalog(&item) { 242 | continue 243 | } 244 | 245 | current := compactItem(item) 246 | _, exist := prevItem[current] 247 | if exist && prevItem[current] != item.Status { 248 | currentItem[item.Category] = item 249 | } 250 | } 251 | for _, kv := range kvps { 252 | item := kv.NewItem() 253 | if !itemInCatalog(&item) { 254 | continue 255 | } 256 | if _, exist := currentItem[item.Category]; !exist { 257 | currentItem[item.Category] = item 258 | } else if currentItem[item.Category].Status < item.Status { 259 | currentItem[item.Category] = item 260 | } 261 | } 262 | 263 | // invoke trigger when a category status was changed 264 | for category, item := range currentItem { 265 | if _, exist := lastStatus[category]; !exist { 266 | // at first initialize 267 | lastStatus[category] = item.Status 268 | log.Printf("[info] %s: status %s", category, item.Status) 269 | } else if lastStatus[category] != item.Status { 270 | // status changed. invoking trigger. 271 | log.Printf("[info] %s: status %s -> %s", category, lastStatus[category], item.Status) 272 | lastStatus[category] = item.Status 273 | b, _ := json.Marshal(item) 274 | err := invokePipe(command, bytes.NewReader(b)) 275 | if err != nil { 276 | log.Println("[error]", err) 277 | } 278 | } 279 | } 280 | 281 | // update previous item status 282 | for _, kv := range kvps { 283 | item := kv.NewItem() 284 | prev := compactItem(item) 285 | prevItem[prev] = item.Status 286 | } 287 | 288 | time.Sleep(1 * time.Second) 289 | } 290 | } 291 | 292 | // compactItem builds `Item` struct that has only `Category`, `Key`, and `Node` fields. 293 | func compactItem(item Item) Item { 294 | return Item{ 295 | Key: item.Key, 296 | Category: item.Category, 297 | Node: item.Node, 298 | } 299 | } 300 | 301 | func invokePipe(command string, src io.Reader) error { 302 | log.Println("[info] Invoking command:", command) 303 | cmd := exec.Command("sh", "-c", command) 304 | stdin, err := cmd.StdinPipe() 305 | if err != nil { 306 | return err 307 | } 308 | stdout, err := cmd.StdoutPipe() 309 | if err != nil { 310 | return err 311 | } 312 | stderr, err := cmd.StderrPipe() 313 | if err != nil { 314 | return err 315 | } 316 | 317 | err = cmd.Start() 318 | if err != nil { 319 | return err 320 | } 321 | cmdCh := make(chan error) 322 | // src => stdin 323 | go func() { 324 | _, err := io.Copy(stdin, src) 325 | if err != nil { 326 | cmdCh <- err 327 | } 328 | stdin.Close() 329 | }() 330 | // wait for command exit 331 | go func() { 332 | cmdCh <- cmd.Wait() 333 | }() 334 | go io.Copy(os.Stdout, stdout) 335 | go io.Copy(os.Stderr, stderr) 336 | 337 | cmdErr := <-cmdCh 338 | return cmdErr 339 | } 340 | 341 | func updateNodes() { 342 | var index int64 343 | for { 344 | resp, newIndex, err := callConsulAPI( 345 | "/v1/catalog/nodes?index=" + strconv.FormatInt(index, 10) + "&wait=55s", 346 | ) 347 | if err != nil { 348 | log.Println("[error]", err) 349 | time.Sleep(10 * time.Second) 350 | continue 351 | } 352 | index = newIndex 353 | dec := json.NewDecoder(resp.Body) 354 | mutex.Lock() 355 | dec.Decode(&Nodes) 356 | log.Println("[info]", Nodes) 357 | mutex.Unlock() 358 | time.Sleep(1 * time.Second) 359 | resp.Body.Close() 360 | } 361 | } 362 | 363 | func updateServices() { 364 | var index int64 365 | for { 366 | resp, newIndex, err := callConsulAPI( 367 | "/v1/catalog/services?index=" + strconv.FormatInt(index, 10) + "&wait=55s", 368 | ) 369 | if err != nil { 370 | log.Println("[error]", err) 371 | time.Sleep(10 * time.Second) 372 | continue 373 | } 374 | index = newIndex 375 | dec := json.NewDecoder(resp.Body) 376 | mutex.Lock() 377 | dec.Decode(&Services) 378 | mutex.Unlock() 379 | time.Sleep(1 * time.Second) 380 | resp.Body.Close() 381 | } 382 | } 383 | 384 | func itemInCatalog(item *Item) bool { 385 | mutex.RLock() 386 | defer mutex.RUnlock() 387 | for _, node := range Nodes { 388 | if item.Node == node.Node { 389 | item.Address = node.Address 390 | return true 391 | } 392 | } 393 | for name, tags := range Services { 394 | if item.Node == name { 395 | item.Address = "service" 396 | return true 397 | } 398 | for _, tag := range tags { 399 | if item.Node == fmt.Sprintf("%s.%s", tag, name) { 400 | item.Address = "service" 401 | return true 402 | } 403 | } 404 | } 405 | return false 406 | } 407 | 408 | func callConsulAPI(path string) (*http.Response, int64, error) { 409 | var index int64 410 | _url := "http://" + ConsulAddr + path 411 | log.Println("[info] get", _url) 412 | resp, err := http.Get(_url) 413 | if err != nil { 414 | log.Println("[error]", err) 415 | return nil, index, err 416 | } 417 | _indexes := resp.Header["X-Consul-Index"] 418 | if len(_indexes) > 0 { 419 | index, _ = strconv.ParseInt(_indexes[0], 10, 64) 420 | } 421 | return resp, index, nil 422 | } 423 | --------------------------------------------------------------------------------