diff --git a/.gitignore b/.gitignore index 5fe629f6a..c924add8b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ default.etcd/ *.test vendor .protoc-gen-gofast +.DS_Store diff --git a/Makefile b/Makefile index 78c3e71f6..d5f1022d6 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ -.PHONY: glide vendor-update docker pilosa crossbuild install generate +.PHONY: glide vendor-update docker pilosa crossbuild install generate statik GLIDE := $(shell command -v glide 2>/dev/null) +STATIK := $(shell command -v statik 2>/dev/null) PROTOC := $(shell command -v protoc 2>/dev/null) VERSION := $(shell git describe --tags 2> /dev/null || echo unknown) IDENTIFIER := $(VERSION)-$(GOOS)-$(GOARCH) @@ -41,14 +42,24 @@ install: vendor .protoc-gen-gofast: vendor ifndef PROTOC - $(error "protoc is not available please install protoc from https://github.com/google/protobuf/releases") + $(error "protoc is not available. please install protoc from https://github.com/google/protobuf/releases") endif go build -o .protoc-gen-gofast ./vendor/github.com/gogo/protobuf/protoc-gen-gofast cp ./.protoc-gen-gofast $(GOPATH)/bin/protoc-gen-gofast -generate: .protoc-gen-gofast +generate-protoc: .protoc-gen-gofast go generate github.com/pilosa/pilosa/internal +generate-statik: statik + go generate github.com/pilosa/pilosa + +generate: generate-protoc generate-statik + +statik: +ifndef STATIK + go get github.com/rakyll/statik +endif + docker: docker build -t "pilosa:$(VERSION)" \ --build-arg ldflags="-X main.Version=$(VERSION) -X main.BuildTime=$(BUILD_TIME)" . diff --git a/glide.lock b/glide.lock index c142737ff..a615eac54 100644 --- a/glide.lock +++ b/glide.lock @@ -1,5 +1,5 @@ -hash: 4bdea17c62dcd469584382515052e7a246fae6deefc2d62da70bf768e18e1f0c -updated: 2017-04-19T10:51:10.409094081-05:00 +hash: 5066da70d6f312d032800619798684a693851eb77991b3788f68e89dfaf93925 +updated: 2017-04-25T15:31:49.712775923-05:00 imports: - name: github.com/armon/go-metrics version: 97c69685293dce4c0a2d0b19535179bbc976e4d2 @@ -66,6 +66,10 @@ imports: version: c37440a7cf42ac63b919c752ca73a85067e05992 - name: github.com/pelletier/go-toml version: 13d49d4606eb801b8f01ae542b4afc4c6ee3d84a +- name: github.com/rakyll/statik + version: 89fe3459b5c829c32e89bdff9c43f18aad728f2f + subpackages: + - fs - name: github.com/satori/go.uuid version: 879c5887cd475cd7864858769793b2ceb0d44feb - name: github.com/spf13/afero diff --git a/glide.yaml b/glide.yaml index a7c0e98eb..166b2a4dc 100644 --- a/glide.yaml +++ b/glide.yaml @@ -31,5 +31,9 @@ import: - package: github.com/spf13/viper - package: github.com/gorilla/mux version: ^1.3.0 +- package: github.com/rakyll/statik + version: 89fe3459b5c829c32e89bdff9c43f18aad728f2f + subpackages: + - fs - package: github.com/hashicorp/memberlist - package: golang.org/x/sync diff --git a/handler.go b/handler.go index 343127d29..67cc14869 100644 --- a/handler.go +++ b/handler.go @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +//go:generate statik -src=./webui + package pilosa import ( @@ -37,6 +39,9 @@ import ( "github.com/gorilla/mux" "github.com/pilosa/pilosa/internal" "github.com/pilosa/pilosa/pql" + + _ "github.com/pilosa/pilosa/statik" + "github.com/rakyll/statik/fs" ) // Handler represents an HTTP handler. @@ -75,6 +80,8 @@ func NewHandler() *Handler { // NewRouter creates a Gorilla Mux http router. func NewRouter(handler *Handler) *mux.Router { router := mux.NewRouter() + router.HandleFunc("/", handler.handleWebUI).Methods("GET") + router.HandleFunc("/assets/{file}", handler.handleWebUI).Methods("GET") router.HandleFunc("/index", handler.handleGetIndexes).Methods("GET") router.HandleFunc("/index/{index}", handler.handleGetIndex).Methods("GET") router.HandleFunc("/index/{index}", handler.handlePostIndex).Methods("POST") @@ -122,6 +129,21 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.Router.ServeHTTP(w, r) } +func (h *Handler) handleWebUI(w http.ResponseWriter, r *http.Request) { + // If user is using curl, don't chuck HTML at them + if strings.HasPrefix(r.UserAgent(), "curl") { + http.Error(w, "Welcome. Pilosa is running. Visit https://www.pilosa.com/docs/ for more information or try the WebUI by visiting this URL in your browser.", http.StatusNotFound) + return + } + statikFS, err := fs.New() + if err != nil { + h.writeQueryResponse(w, r, &QueryResponse{Err: err}) + fmt.Println("Pilosa WebUI is not available. Please run `make generate-statik` before building Pilosa with `make install`.") + return + } + http.FileServer(statikFS).ServeHTTP(w, r) +} + // handleGetSchema handles GET /schema requests. func (h *Handler) handleGetSchema(w http.ResponseWriter, r *http.Request) { if err := json.NewEncoder(w).Encode(getSchemaResponse{ diff --git a/statik/.gitignore b/statik/.gitignore new file mode 100644 index 000000000..514ee40a1 --- /dev/null +++ b/statik/.gitignore @@ -0,0 +1 @@ +statik.go diff --git a/statik/doc.go b/statik/doc.go new file mode 100644 index 000000000..85edd9e6f --- /dev/null +++ b/statik/doc.go @@ -0,0 +1,3 @@ +// Package statik contains static assets for the Web UI. `go generate` will +// produce statik.go, which is ignored by git. +package statik diff --git a/webui/assets/chevron-down.png b/webui/assets/chevron-down.png new file mode 100644 index 000000000..3312489a2 Binary files /dev/null and b/webui/assets/chevron-down.png differ diff --git a/webui/assets/main.js b/webui/assets/main.js new file mode 100644 index 000000000..4e17db19a --- /dev/null +++ b/webui/assets/main.js @@ -0,0 +1,390 @@ +class REPL { + constructor(input, output, button) { + this.input = input + this.output = output + this.button = button + this.history = [] + this.history_index = 0 + this.history_buffer = '' + this.result_number = 0 + } + bind_events() { + var repl = this + var keys = { + TAB: 9, + ENTER: 13, + UP_ARROW: 38, + DOWN_ARROW: 40 + } + var keywords = { + // keyword: length of substring that comes after cursor + "SetBit()": 1, + "ClearBit()": 1, + "SetBitmapAttrs()": 1, + "Bitmap()": 1, + "Union()": 1, + "Intersect()": 1, + "Difference()": 1, + "Count()": 1, + "Range()": 1, + "TopN()": 1, + "frame=": 0, + } + + this.input.addEventListener("keydown", function(e) { + if (e.keyCode == keys.UP_ARROW) { + e.preventDefault() + if (repl.input.value.substring(0, repl.input.selectionStart).indexOf('\n') == '-1') { + if (repl.history_index == 0) { + return + } else { + if (repl.history_index == repl.history.length) { + repl.history_buffer = repl.input.value + } + repl.history_index-- + repl.input.value = repl.history[repl.history_index] + repl.input.setSelectionRange(repl.input.value.length, repl.input.value.length) + } + } + } + if (e.keyCode == keys.DOWN_ARROW) { + e.preventDefault() + if (repl.input.value.substring(repl.input.selectionEnd, repl.input.length).indexOf('\n') == '-1') { + if (repl.history_index == repl.history.length) { + return + } else { + repl.history_index++ + if (repl.history_index == repl.history.length) { + repl.input.value = repl.history_buffer + } else { + repl.input.value = repl.history[repl.history_index] + } + repl.input.setSelectionRange(repl.input.value.length, repl.input.value.length) + } + } + } + if (e.keyCode == keys.ENTER && !e.shiftKey) { + e.preventDefault() + repl.submit(); + } + if (e.keyCode == keys.TAB) { + e.preventDefault() + + // extract word fragment ending at cursor. a word fragment: + // - starts with last nonalpha character before cursor (or beginning of string) + // - ends at cursor + var word_start = repl.input.selectionEnd-1 + while(word_start>0) { + var c = repl.input.value.charCodeAt(word_start) + if(!((c>64 && c<91) || (c>96 && c<123))) { + word_start++ + break + } + word_start-- + } + var input_word = repl.input.value.substring(word_start, repl.input.selectionEnd) + + // check for keyword match and insert + // this just stops at the first match + for(var keyword in keywords) { + if(keyword.startsWith(input_word)){ + var cursor_pos = repl.input.selectionEnd + var completion = keyword.substring(input_word.length) + var before = repl.input.value.substring(0, cursor_pos) + var after = repl.input.value.substring(cursor_pos) + repl.input.value = before + completion + after + var new_pos = cursor_pos + completion.length - keywords[keyword] + repl.input.setSelectionRange(new_pos, new_pos) + break + } + } + } + }) + repl.button.onclick = function() { + repl.submit(); + }; + } + + submit() { + this.history_buffer = '' + this.history_index = this.history.length + this.history[this.history_index] = this.input.value + this.history_index++ + this.process_query(this.input.value) + this.input.value = "" + } + + process_query(query) { + var xhr = new XMLHttpRequest(); + var e = document.getElementById("index-dropdown"); + var indexname = e.options[e.selectedIndex].text; + xhr.open('POST', '/index/' + indexname + '/query'); + xhr.setRequestHeader('Content-Type', 'application/text'); + + var repl = this + var start_time = new Date().getTime(); + xhr.send(query) + xhr.onload = function() { + var end_time = new Date().getTime() + repl.result_number++ + repl.createSingleOutput({ + "input": query, + "output": xhr.responseText, + "indexname": indexname, + "querytime_ms": end_time - start_time, + }) + } + + } + + createSingleOutput(res) { + var node = document.createElement("div"); + node.classList.add('output'); + var output_string = res['output'] + var output_json = JSON.parse(output_string) + var result_class = "result-output" + var getting_started_errors = [ + 'index not found', + 'frame not found', + ] + + if("error" in output_json) { + result_class = "result-error" + if(getting_started_errors.indexOf(output_json['error']) >= 0) { + output_string += `
+
+ Just getting started? Try this:
+ $ curl -XPOST "http://127.0.0.1:10101/index/test" -d '{"options": {"columnLabel": "col"}}' # create index "test"
+ $ curl -XPOST "http://127.0.0.1:10101/index/test/frame/foo" -d '{"options": {"rowLabel": "row"}}' # create frame "foo"
+ # Select "test" in the index dropdown above
+ SetBit(row=0, col=0, frame=foo) # Use PQL to set a bit + ` + } + } + + + var markup =` +
+
+
+
+
Input
+        + Source: ${res.indexname} +
+
+ ${res.input} +
+
+
+
+
output
+        + ${res.querytime_ms} ms +
+
+ ${output_string} +
+
+
+
+ +
+
+ ` + node.innerHTML = markup; + this.output.insertBefore(node, this.output.firstChild) + } + + populate_index_dropdown() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/schema') + var select = document.getElementById('index-dropdown') + + xhr.onload = function() { + var schema = JSON.parse(xhr.responseText) + for(var i=0; i 0) { + select.value = 1; + } + } + xhr.send(null) + } + +} + +function populate_version() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/version') + var node = document.getElementById('server-version') + + xhr.onload = function() { + version = JSON.parse(xhr.responseText)['version'] + node.innerHTML = version + } + xhr.send(null) +} + +function handle_nav_click(e) { + // e.id = "nav-xxx" + name = e.id.substring(4) + set_active_pane_by_name(name) + window.location.hash = name +} + +function set_active_pane_by_name(name) { + // toggle the nav buttons + document.getElementsByClassName("nav-active")[0].classList.remove("nav-active") + document.getElementById("nav-" + name).classList.add("nav-active") + + // toggle the main interface content divs + document.getElementsByClassName("interface-active")[0].classList.remove("interface-active") + document.getElementById('interface-' + name).classList.add("interface-active") + + // hack hack + switch(name) { + case "cluster": + update_cluster_status() + break + case "documentation": + open_external_docs() + break + } +} + + +function update_cluster_status() { + var xhr = new XMLHttpRequest(); + xhr.open('GET', '/status') + status_node = document.getElementById('status') + xhr.onload = function() { + var status = JSON.parse(xhr.responseText) + render_status(status) + } + xhr.send(null) +} + +function render_status(status) { + // render node table + var nodes_div = document.getElementById("status-nodes") + while (nodes_div.firstChild) { + nodes_div.removeChild(nodes_div.firstChild); + } + + var nodes = status["status"]["Nodes"] + table = document.createElement("table") + tbody = document.createElement("tbody") + table.appendChild(tbody) + var caption = document.createElement("caption") + caption.innerHTML = "(" + nodes.length + ")" + table.appendChild(caption) + + var header = document.createElement('tr') + markup = `Host + State` + header.innerHTML = markup + tbody.appendChild(header) + for(var n=0; n${nodes[n]["Host"]} + ${nodes[n]["State"]}` + row.innerHTML = markup + tbody.appendChild(row) + } + nodes_div.appendChild(table) + + // render index tables + var indexes_div = document.getElementById("status-indexes") + while (indexes_div.firstChild) { + indexes_div.removeChild(indexes_div.firstChild); + } + + var indexes = nodes[0]["Indexes"] // TODO currently comes from only node 0 + for(var n=0; nName + Row Label + Cache Type + Cache Size` + header.innerHTML = markup + tbody.appendChild(header) + + var frames = indexes[n]["Frames"] + if(frames) { + for(var m=0; m${frames[m]["Name"]} + ${frames[m]["Meta"]["RowLabel"]} + ${frames[m]["Meta"]["CacheType"]} + ${frames[m]["Meta"]["CacheSize"]}` + tbody.appendChild(row) + } + } + indexes_div.appendChild(table) + } + + // render slice tables + // TODO enable when Slices element is present in status response + /* + var slices_div = document.getElementById("status-slices") + data = "" + for(var n=0; n" + } + } + slices_div.innerHTML = data + */ + +} + +function open_external_docs() { + window.open("https://www.pilosa.com/docs"); +} + +function check_anchor_uri() { + var pane_names = {"console": 0, "cluster": 0, "documentation": 0} + var anchor = window.location.hash.substr(1); + if(anchor in pane_names) { + set_active_pane_by_name(anchor) + } +} + +Date.prototype.today = function () { + return this.getFullYear() +"/"+ (((this.getMonth()+1) < 10)?"0":"") + (this.getMonth()+1) +"/"+ ((this.getDate() < 10)?"0":"") + this.getDate(); +} + +Date.prototype.timeNow = function () { + return ((this.getHours() < 10)?"0":"") + this.getHours() +":"+ ((this.getMinutes() < 10)?"0":"") + this.getMinutes() +":"+ ((this.getSeconds() < 10)?"0":"") + this.getSeconds(); +} + +populate_version() + +var input = document.getElementById('query') +var output = document.getElementById('outputs') +var button = document.getElementById('query-btn') + +repl = new REPL(input, output, button) +repl.populate_index_dropdown() +repl.bind_events() + +input.focus() + +check_anchor_uri() diff --git a/webui/assets/nav-cluster-active.svg b/webui/assets/nav-cluster-active.svg new file mode 100644 index 000000000..871d6f2af --- /dev/null +++ b/webui/assets/nav-cluster-active.svg @@ -0,0 +1 @@ +nav_cluster_1 diff --git a/webui/assets/nav-cluster.svg b/webui/assets/nav-cluster.svg new file mode 100644 index 000000000..baf6310ea --- /dev/null +++ b/webui/assets/nav-cluster.svg @@ -0,0 +1 @@ +nav_cluster_1 \ No newline at end of file diff --git a/webui/assets/nav-console-active.svg b/webui/assets/nav-console-active.svg new file mode 100644 index 000000000..2263c2a69 --- /dev/null +++ b/webui/assets/nav-console-active.svg @@ -0,0 +1 @@ +nav_console \ No newline at end of file diff --git a/webui/assets/nav-console.svg b/webui/assets/nav-console.svg new file mode 100644 index 000000000..e9d2a9569 --- /dev/null +++ b/webui/assets/nav-console.svg @@ -0,0 +1 @@ +nav_console diff --git a/webui/assets/nav-documentation-active.svg b/webui/assets/nav-documentation-active.svg new file mode 100644 index 000000000..b6e994980 --- /dev/null +++ b/webui/assets/nav-documentation-active.svg @@ -0,0 +1 @@ +documentation diff --git a/webui/assets/nav-documentation.svg b/webui/assets/nav-documentation.svg new file mode 100644 index 000000000..12cd2108c --- /dev/null +++ b/webui/assets/nav-documentation.svg @@ -0,0 +1 @@ +documentation \ No newline at end of file diff --git a/webui/assets/nav_item1.svg b/webui/assets/nav_item1.svg new file mode 100644 index 000000000..d6d7f779e --- /dev/null +++ b/webui/assets/nav_item1.svg @@ -0,0 +1 @@ +nav_item1 \ No newline at end of file diff --git a/webui/assets/style.css b/webui/assets/style.css new file mode 100644 index 000000000..a3a493e16 --- /dev/null +++ b/webui/assets/style.css @@ -0,0 +1,293 @@ +*{ + -webkit-box-sizing: border-box; + -moz-box-sizing: border-box; + box-sizing: border-box; +} +body{ + font-family: sans-serif; + background-color: #fbfcfd; + margin: 0; + color: #102445; +} +h2{ + margin-bottom: 30px; +} + +h5{ + text-transform: uppercase; + letter-spacing: 2px; + line-height: 1.21; + margin: 0; +} +a{ + line-height: 1.38; + letter-spacing: 0.2px; + text-decoration: none; + color: #102445; +} + + +a:hover{ + color: #1db598; +} + +textarea{ + width: 100%; + margin-bottom: 10px; + border-radius: 2px; + background-color: #fbfcfd; + border: solid 1.5px #e4eff4; + font-family: monospace; + font-size: 16px; + line-height: 1.5; + letter-spacing: 1.1px; + outline: none; + padding: 30px; +} + + +select{ + /*-webkit-appearance: none; + -moz-appearance: none; + appearance: none; + background: url("img/chevron-down.png") no-repeat calc(100% - 10px) !important;*/ + border-radius: 3px; + background-color: #fbfcfd; + width: 187px; + height: 50px; + border: solid 1.5px #e4eff4; + font-size: 18px; + font-weight: bold; + line-height: 1.39; + letter-spacing: 0.2px; + color: #102445; + padding: 10.5px; + +} + +button{ + width: 165px; + height: 50px; + border-radius: 3px; + background-color: #1db598; + outline: none; + border: none; + font-size: 16px; + color: white; +} + +em{ + font-style: normal; + opacity: 0.5; + font-size: 14px; + font-weight: 500; + letter-spacing: 0.2px; + color: #102445; +} + +.header{ + height: 92px; + display: flex; + align-items: center; + justify-content: space-between; + width: 90%; + margin: auto; +} + +.container{ + display: flex; + height:100%; + min-height: 100vh; +} +.nav{ + color: white; + display: flex; + flex-direction: column; + width: 150px; + background: #3c5f8d; +} + +.nav-item{ + height:150px; + display: flex; + align-items: center; + justify-content: center; + flex-direction: column; + border-bottom: 3px solid #2a4871; + cursor: pointer; +} + +.nav-active{ + background: #f2f7f9; + font-weight: bold; + color: #1db598; +} + +.nav-item > .nav-image { + display: flex; +} + +.nav-item > .nav-image-active { + display: none; +} + +.nav-active > .nav-image { + display: none; +} + +.nav-active > .nav-image-active { + display: flex; +} + + +.interface{ + display: none; + flex: 1; + flex-direction: column; + align-items: center; + background: #f2f7f9; +} + +.interface-active{ + display: flex; +} + +.query{ + margin-bottom: 30px; +} +.query, +.output-container, +.status-container{ + width: 75%; +} + +.output{ + margin-bottom: 30px; +} + +.input-controls{ + display: flex; + justify-content: flex-end; +} + +.tabs{ + display: flex; + background: #eaf2f6; +} +.active-tab{ + background: white; + font-weight: bold; + color: #1db598; + +} + +.tab{ + height:60px; + width: 100px; + border-top-right-radius: 5px; + display: flex; + align-items: center; + justify-content: center; + visibility: visible; + cursor: pointer; + +} + +.pane{ + background: white; + padding: 30px; + display: none; +} + +.active{ + display: block; +} + + + +.result-io-header{ + display: flex; + align-items: center; + margin-bottom: 15px; +} + +.result-input, +.result-output, +.result-error{ + border-radius: 2px; + background-color: #fafafa; + border: solid 1.5px #e4eff4; + font-family: monospace; + font-size: 16px; + line-height: 1.5; + letter-spacing: 1.1px; + color: #102445; + padding: 15px; + margin-bottom: 15px; +} + + +.result-output{ + background-color: #edf9f7; + border-left: solid 4px #1db598; +} + +.result-error{ + background-color: #fbf1f0; + border-left: solid 4px #fa3035; + color: #fa3035; +} + +.raw{ + height: 253px; + display: flex; + align-items: center; + justify-content: center; +} + + +.result-table > table { + border-left: solid 4px #1db598; +} + +table{ + border: solid 0.5px #e0e0e0; + width: 100%; + margin-bottom: 30px; + /*color:#3c5f8d;*/ +} +caption{ + text-align:left; + font-size: 16px; + font-weight: bold; + line-height: 1.21; + letter-spacing: 2px; + text-align: left; +} +th{ + font-size: 14px; + font-weight: bold; + line-height: 1.21; + letter-spacing: 2px; + color: #102445; + text-transform: uppercase; + text-align: left; + padding: 21px 30px; + background-color: white; +} +tr{ + border: solid 0.5px #e0e0e0; + background-color: white; +} +tr:nth-child(even) { + background-color: #f2f7f9; +} +td{ + padding: 21px 30px; +} + +.string { color: green; } +.number { color: darkorange; } +.boolean { color: blue; } +.null { color: magenta; } +.key { color: red; } \ No newline at end of file diff --git a/webui/index.html b/webui/index.html new file mode 100644 index 000000000..3967b8f02 --- /dev/null +++ b/webui/index.html @@ -0,0 +1,89 @@ + + + + + + + + Pilosa Console + + + +
+ +
+
+
+ +
+ +
+

Query

+ +
+ + +     + +
+
+ + +
+ +

Output

+
+ + +
+ +
+ +
+ +
+
+

Nodes

+
+
+
+
+

Indexes

+
+
+
+
+

Slice Distribution

+
+ ... +
+
+ +
+ +
+ docs! +
+ +
+ + +