├── static ├── js │ ├── confirm.coffee │ ├── web_socket.coffee │ ├── view.coffee │ ├── ajax_search.coffee │ ├── init.js │ ├── live_logs.coffee │ └── hotkeys.js ├── fonts │ ├── glyphicons-halflings-regular.eot │ ├── glyphicons-halflings-regular.ttf │ ├── glyphicons-halflings-regular.woff │ ├── glyphicons-halflings-regular.woff2 │ └── glyphicons-halflings-regular.svg ├── bower.json ├── less │ ├── live_logs.less │ └── custom.less └── build │ ├── app.min.css │ └── app.min.js ├── screenshots ├── dashboard.png └── live-logs.png ├── .travis.yml ├── .gitignore ├── common ├── log_record.go ├── validation.go ├── cached_user.go ├── common.go ├── common_test.go ├── elastic_test.go ├── elastic.go └── user.go ├── web ├── routers │ ├── live │ │ └── live.go │ ├── projects │ │ ├── types.go │ │ └── projects.go │ ├── home │ │ ├── logs.go │ │ └── home.go │ ├── profile │ │ └── profile.go │ └── users │ │ ├── login.go │ │ └── register.go ├── templates │ ├── layouts │ │ ├── simple.tmpl │ │ └── main.tmpl │ ├── shared │ │ ├── error.tmpl │ │ └── head.tmpl │ ├── home │ │ ├── no_records.tmpl │ │ ├── empty_project.tmpl │ │ ├── index.tmpl │ │ └── table.tmpl │ ├── maintenance.tmpl │ ├── live │ │ └── index.tmpl │ ├── projects │ │ ├── types.tmpl │ │ ├── new.tmpl │ │ └── index.tmpl │ ├── profile │ │ └── index.tmpl │ └── users │ │ ├── register.tmpl │ │ └── login.tmpl ├── widgets │ ├── log_message.go │ ├── log_message_test.go │ └── pagination.go ├── middleware │ └── middleware.go ├── context │ └── context.go └── web.go ├── package.json ├── main.go ├── backend ├── tcp_server.go ├── http_server.go ├── per_second.go ├── backlog.go └── backend.go ├── LICENSE ├── Gruntfile.js ├── web_socket └── web_socket.go ├── commands └── commands.go └── readme.md /static/js/confirm.coffee: -------------------------------------------------------------------------------- 1 | $ -> 2 | $(".confirm").click -> 3 | confirm "Are you sure?" -------------------------------------------------------------------------------- /screenshots/dashboard.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firstrow/logvoyage/HEAD/screenshots/dashboard.png -------------------------------------------------------------------------------- /screenshots/live-logs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firstrow/logvoyage/HEAD/screenshots/live-logs.png -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | go: 3 | - 1.4 4 | script: 5 | - go get github.com/smartystreets/goconvey 6 | - go test -v ./... 7 | -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.eot: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firstrow/logvoyage/HEAD/static/fonts/glyphicons-halflings-regular.eot -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firstrow/logvoyage/HEAD/static/fonts/glyphicons-halflings-regular.ttf -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firstrow/logvoyage/HEAD/static/fonts/glyphicons-halflings-regular.woff -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/firstrow/logvoyage/HEAD/static/fonts/glyphicons-halflings-regular.woff2 -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | gin-bin 2 | node_modules 3 | static/bower_components 4 | backend/backend 5 | cmd/cmd 6 | client/client 7 | web/web 8 | web/tmp 9 | backend/tmp 10 | -------------------------------------------------------------------------------- /common/log_record.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type LogRecord struct { 8 | Datetime time.Time `json:"datetime"` 9 | Message string `json:"message"` 10 | } 11 | -------------------------------------------------------------------------------- /web/routers/live/live.go: -------------------------------------------------------------------------------- 1 | package live 2 | 3 | import ( 4 | "github.com/firstrow/logvoyage/web/context" 5 | ) 6 | 7 | func Index(ctx *context.Context) { 8 | ctx.HTML("live/index", context.ViewData{}, "layouts/simple") 9 | } 10 | -------------------------------------------------------------------------------- /web/templates/layouts/simple.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LogVoyage - Live 6 | {{template "shared/head" .}} 7 | 8 | 9 | 10 | {{yield}} 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/templates/shared/error.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |
7 |
8 |
9 | -------------------------------------------------------------------------------- /web/templates/home/no_records.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 6 |
7 |
8 |
9 | -------------------------------------------------------------------------------- /common/validation.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/astaxie/beego/validation" 5 | ) 6 | 7 | type EnableValidation struct { 8 | Valid validation.Validation 9 | } 10 | 11 | func (this *EnableValidation) GetError(key string) string { 12 | for _, err := range this.Valid.Errors { 13 | if err.Key == key { 14 | return err.Message 15 | } 16 | } 17 | return "" 18 | } 19 | -------------------------------------------------------------------------------- /web/templates/home/empty_project.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | 9 |
10 |
11 |
12 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pts", 3 | "version": "0.1.0", 4 | "devDependencies": { 5 | "grunt": "~0.4.5", 6 | "grunt-cli": "^0.1.13", 7 | "grunt-contrib-coffee": "~0.12.0", 8 | "grunt-contrib-concat": "~0.5.0", 9 | "grunt-contrib-jshint": "~0.10.0", 10 | "grunt-contrib-less": "~0.11.1", 11 | "grunt-contrib-uglify": "~0.5.0", 12 | "grunt-contrib-watch": "~0.6.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /static/js/web_socket.coffee: -------------------------------------------------------------------------------- 1 | class WSocket 2 | constructor: (@apiKey) -> 3 | @ws = new WebSocket("ws://" + window.location.hostname + ":12345/ws") 4 | @ws.onopen = (=> this.register()) 5 | @ws.onmessage = (=> this.onMessage(event)) 6 | 7 | register: -> 8 | @ws.send @apiKey 9 | console.log "registered user " + @apiKey 10 | 11 | onMessage: (event) -> 12 | data = JSON.parse event.data 13 | PubSub.publish data.type, data 14 | 15 | $ -> 16 | new WSocket(options.apiKey) 17 | -------------------------------------------------------------------------------- /common/cached_user.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | var cachedUsers = make(map[string]*User) 4 | 5 | // Search user and store record in memory 6 | func FindCachedUser(email string) (*User, error) { 7 | if u, ok := cachedUsers[email]; ok { 8 | return u, nil 9 | } else { 10 | user, err := FindUserByEmail(email) 11 | if err != nil { 12 | return nil, err 13 | } 14 | if user != nil { 15 | cachedUsers[email] = user 16 | return cachedUsers[email], nil 17 | } 18 | } 19 | return nil, nil 20 | } 21 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | 7 | "github.com/codegangsta/cli" 8 | "github.com/firstrow/logvoyage/commands" 9 | ) 10 | 11 | func main() { 12 | runtime.GOMAXPROCS(runtime.NumCPU()) 13 | app := cli.NewApp() 14 | app.Name = "LogVoyage" 15 | app.Commands = []cli.Command{ 16 | commands.StartBackendServer, 17 | commands.StartWebServer, 18 | commands.StartAll, 19 | commands.CreateUsersIndex, 20 | commands.DeleteIndex, 21 | commands.CreateIndex, 22 | } 23 | app.Run(os.Args) 24 | } 25 | -------------------------------------------------------------------------------- /static/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "LogVoyage web ui", 3 | "version": "0.0.1", 4 | "dependencies": { 5 | "bootstrap": "3.3.4", 6 | "jquery": "2.1.3", 7 | "jquery-jsonview": "1.2.0", 8 | "datetimepicker": "2.4.1", 9 | "bootstrap-multiselect": "v0.9.10", 10 | "less": "v2.0.0", 11 | "ladda-bootstrap": "v0.1.0", 12 | "jquery-cookie": "v1.4.1", 13 | "pubsub-js": "v1.5.0", 14 | "bootstrap-material-design": "0.3.0", 15 | "bootstrap-select": "v1.6.4" 16 | }, 17 | "private": true 18 | } 19 | -------------------------------------------------------------------------------- /web/templates/maintenance.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | LogVoyage 7 | 8 | 9 | 10 | 11 |
12 | Don't worry, all your logs are stored and will not be lost! 13 |
14 | We are working on it! 15 |
16 |
17 | Try again 18 |
19 | 20 | -------------------------------------------------------------------------------- /web/routers/projects/types.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "github.com/firstrow/logvoyage/common" 5 | "github.com/firstrow/logvoyage/web/context" 6 | "github.com/go-martini/martini" 7 | ) 8 | 9 | // Display list of ES types available to user. 10 | func Types(ctx *context.Context) { 11 | ctx.HTML("projects/types", context.ViewData{ 12 | "docCounter": common.CountTypeDocs, 13 | }) 14 | } 15 | 16 | func DeleteType(ctx *context.Context, params martini.Params) { 17 | common.DeleteType(ctx.User.GetIndexName(), params["name"]) 18 | ctx.Render.Redirect("/projects/types") 19 | } 20 | -------------------------------------------------------------------------------- /backend/tcp_server.go: -------------------------------------------------------------------------------- 1 | package backend 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/firstrow/tcp_server" 7 | ) 8 | 9 | func initTcpServer() { 10 | server := tcp_server.New(tcpDsn) 11 | server.OnNewClient(func(c *tcp_server.Client) { 12 | log.Println("New client") 13 | }) 14 | 15 | // Receives new message and send it to Elastic server 16 | server.OnNewMessage(func(c *tcp_server.Client, message string) { 17 | log.Println("New message") 18 | processMessage(message) 19 | }) 20 | 21 | server.OnClientConnectionClosed(func(c *tcp_server.Client, err error) { 22 | log.Println("Client disconnected") 23 | }) 24 | server.Listen() 25 | } 26 | -------------------------------------------------------------------------------- /web/widgets/log_message.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | // Prepares source to be rendered in logs table 9 | // Return it in next format: 10 | // message {json} 11 | // TODO: Bench. Improve speed. 12 | func BuildLogLine(s map[string]interface{}) string { 13 | var message string 14 | if _, ok := s["message"]; ok { 15 | message = fmt.Sprintf("%v", s["message"]) 16 | } else { 17 | message = "" 18 | } 19 | delete(s, "datetime") 20 | delete(s, "message") 21 | // If records has additional json attributes 22 | if len(s) > 0 { 23 | j, err := json.Marshal(s) 24 | if err != nil { 25 | return "Error rendering message" 26 | } 27 | 28 | return fmt.Sprintf("%s %s", message, string(j)) 29 | } 30 | return fmt.Sprintf("%s", message) 31 | } 32 | -------------------------------------------------------------------------------- /web/templates/shared/head.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 13 | 14 | {{if not .context.IsGuest}} 15 | 20 | {{end}} 21 | 22 | -------------------------------------------------------------------------------- /web/middleware/middleware.go: -------------------------------------------------------------------------------- 1 | package middleware 2 | 3 | import ( 4 | "github.com/codegangsta/martini-contrib/render" 5 | "github.com/firstrow/logvoyage/common" 6 | "github.com/martini-contrib/sessions" 7 | ) 8 | 9 | // Check user authentication 10 | func Authorize(r render.Render, sess sessions.Session) { 11 | email := sess.Get("email") 12 | if email != nil { 13 | user, err := common.FindCachedUser(email.(string)) 14 | if err == common.ErrSendingElasticSearchRequest { 15 | r.Redirect("/maintenance") 16 | return 17 | } 18 | if user == nil { 19 | r.Redirect("/login") 20 | return 21 | } 22 | } else { 23 | r.Redirect("/login") 24 | } 25 | } 26 | 27 | // Redirect user to Dashboard if authorized 28 | func RedirectIfAuthorized(r render.Render, sess sessions.Session) { 29 | email := sess.Get("email") 30 | if email != nil { 31 | r.Redirect("/") 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/http_server.go: -------------------------------------------------------------------------------- 1 | // Accept http messages. 2 | // bulk?apiKey=XXX&type=XXX - accepts bulk of messages separated by newline. 3 | package backend 4 | 5 | import ( 6 | "bufio" 7 | "fmt" 8 | "log" 9 | "net/http" 10 | ) 11 | 12 | func httpHandler(w http.ResponseWriter, r *http.Request) { 13 | apiKey := r.URL.Query().Get("apiKey") 14 | if apiKey == "" { 15 | return 16 | } 17 | logType := r.URL.Query().Get("type") 18 | if logType == "" { 19 | return 20 | } 21 | 22 | key := fmt.Sprintf("%s@%s", apiKey, logType) 23 | 24 | reader := bufio.NewReader(r.Body) 25 | for { 26 | line, err := reader.ReadString('\n') 27 | if line != "" { 28 | processMessage(fmt.Sprintf("%s %s", key, line)) 29 | } 30 | if err != nil { 31 | return 32 | } 33 | } 34 | } 35 | 36 | func initHttpServer() { 37 | log.Print("Starting HTTP server at " + httpDsn) 38 | http.HandleFunc("/bulk", httpHandler) 39 | http.ListenAndServe(httpDsn, nil) 40 | } 41 | -------------------------------------------------------------------------------- /static/js/view.coffee: -------------------------------------------------------------------------------- 1 | # Log view popup logic 2 | $ -> 3 | $("body").on "click", "a.view", (e) -> 4 | e.preventDefault() 5 | el = this 6 | $("#recordViewLabel").html $(this).data("type") 7 | $("#recordViewDateTime").html $(this).data("datetime") 8 | $("#viewRecordModal .btn-danger").unbind("click").click -> 9 | if confirm("Are you sure want to delete this event?") 10 | $.ajax 11 | url: $(el).attr("href") 12 | type: "DELETE" 13 | success: -> 14 | $(".modal .close").click() 15 | $(el).parents("tr").css "opacity", "0.2" 16 | error: -> 17 | alert "Error: Record not deleted." 18 | else 19 | e.preventDefault() 20 | 21 | $.getJSON($(this).attr("href"), (data) -> 22 | $(".modal-body").JSONView data 23 | $("#viewRecordModal").modal() 24 | ).fail -> 25 | $(".modal-body").html "Error: Record not found or wrong JSON structure." 26 | $("#viewRecordModal").modal() 27 | -------------------------------------------------------------------------------- /static/js/ajax_search.coffee: -------------------------------------------------------------------------------- 1 | $ -> 2 | # On submit log search form send ajax request 3 | # Enable ajax search only on start page. 4 | # If user is on other page will work regular GET request 5 | p = window.location.pathname 6 | if p != "/" and p.substring(0, 9) != "/project/" 7 | return 8 | 9 | $("#searchForm").attr 'action', p 10 | $("#searchForm").submit (e) -> 11 | e.preventDefault() 12 | $.ajax 13 | type: "GET" 14 | url: $(this).attr("action") 15 | data: $(this).serialize() 16 | success: (data) -> 17 | $("#logTableContainer").html data 18 | complete: -> 19 | # Search is really fast, we should add delay 20 | setTimeout (-> 21 | Ladda.stopAll() 22 | $("html, body").animate(scrollTop: 0, "fast") 23 | ), 300 24 | 25 | $("body").on "click", "#pagination a", (e) -> 26 | e.preventDefault() 27 | $("#logTableContainer").load $(this).attr("href"), -> 28 | $("html, body").animate 29 | scrollTop: 0 30 | , "fast" 31 | -------------------------------------------------------------------------------- /static/js/init.js: -------------------------------------------------------------------------------- 1 | // Initialize components 2 | $(function() { 3 | jQuery('#time_start, #time_stop').datetimepicker(); 4 | // Multiselect 5 | $("#time").multiselect(); 6 | $("#logType").multiselect({ 7 | enableClickableOptGroups: true, 8 | buttonWidth: '200px', 9 | numberDisplayed: 2, 10 | nonSelectedText: 'All projects' 11 | }); 12 | 13 | $(".bootstrap-select").selectpicker(); 14 | 15 | $("#time").change(function(){ 16 | if ($(this).val() == 'custom') { 17 | $(".timebox").show(100); 18 | //$("#time_start").focus(); 19 | } else { 20 | $(".timebox").hide(100); 21 | } 22 | }); 23 | $("#time").change(); 24 | 25 | $('[data-toggle="tooltip"]').tooltip() 26 | 27 | Ladda.bind('#searchButton'); 28 | }); 29 | 30 | $(window).resize(function(){ 31 | $("#pagination").center(); 32 | }); 33 | 34 | jQuery.fn.center = function () { 35 | var left = Math.max(0, (($(window).width() - $(this).outerWidth()) / 2) + $(window).scrollLeft()); 36 | this.css("left", left + "px"); 37 | return this; 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-present Andrii Bubis. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /web/routers/home/logs.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/belogik/goes" 6 | "net/http" 7 | "net/url" 8 | 9 | "github.com/firstrow/logvoyage/common" 10 | "github.com/firstrow/logvoyage/web/context" 11 | "github.com/go-martini/martini" 12 | ) 13 | 14 | // View log record 15 | func View(res http.ResponseWriter, ctx *context.Context, params martini.Params) { 16 | conn := common.GetConnection() 17 | response, err := conn.Get(ctx.User.GetIndexName(), params["type"], params["id"], url.Values{}) 18 | 19 | if err != nil { 20 | res.WriteHeader(404) 21 | } 22 | 23 | j, err := json.Marshal(response.Source) 24 | 25 | if err != nil { 26 | res.WriteHeader(503) 27 | } 28 | 29 | res.Write(j) 30 | } 31 | 32 | // Delete log record 33 | func Delete(res http.ResponseWriter, ctx *context.Context, params martini.Params) { 34 | conn := common.GetConnection() 35 | d := goes.Document{ 36 | Index: ctx.User.GetIndexName(), 37 | Type: params["type"], 38 | Id: params["id"], 39 | } 40 | _, err := conn.Delete(d, url.Values{}) 41 | 42 | if err != nil { 43 | res.WriteHeader(503) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /static/less/live_logs.less: -------------------------------------------------------------------------------- 1 | #liveLogsContainer { 2 | width: 100%; 3 | overflow: auto; 4 | 5 | p { 6 | margin: 0 0 0 5px; 7 | padding: 0; 8 | font-size: 12px; 9 | word-wrap: break-word; 10 | } 11 | 12 | span.highlight { 13 | background-color: rgb(165, 119, 5); 14 | color: #000; 15 | } 16 | 17 | .type { 18 | padding-right: 5px; 19 | } 20 | 21 | &.dark { 22 | background: rgb(29, 29, 29); 23 | color: #859900; 24 | .type { 25 | color: #268bd2; 26 | } 27 | } 28 | &.light { 29 | background: rgb(253, 245, 219); 30 | color: rgb(31, 146, 131); 31 | .type { 32 | color: #268bd2; 33 | } 34 | } 35 | } 36 | 37 | #liveLogsSearch { 38 | position: fixed; 39 | float: auto; 40 | width: 300px; 41 | bottom: 0; 42 | width: 100%; 43 | background: #171717; 44 | padding: 3px; 45 | 46 | form { 47 | input[type=text] { 48 | background: rgb(34, 34, 34); 49 | border: 0; 50 | outline: none; 51 | width: 500px; 52 | color: #999999; 53 | } 54 | button { 55 | background: transparent; 56 | color: #999999; 57 | outline: none; 58 | &:hover { 59 | color: #fff; 60 | } 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /web/templates/live/index.tmpl: -------------------------------------------------------------------------------- 1 | 8 |
9 |
10 |
11 |
12 | 13 | 16 | 19 |
20 | 23 |
24 |
25 |
26 |
27 | -------------------------------------------------------------------------------- /web/templates/home/index.tmpl: -------------------------------------------------------------------------------- 1 |
2 | {{if .total}} 3 | {{template "home/table" .}} 4 | {{else}} 5 | {{template "home/no_records"}} 6 | {{end}} 7 |
8 | 9 | 10 | 28 | 29 | -------------------------------------------------------------------------------- /web/templates/projects/types.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

Types

6 | {{if isEmpty .context.User.GetLogTypes}} 7 | No types found. 8 | {{end}} 9 | {{range .context.User.GetLogTypes}} 10 |
11 |
12 | 13 | 14 | 15 |
16 |
17 |
18 | 19 |
20 | {{.}} 21 |
22 |
23 |
24 | 25 |
26 | {{call $.docCounter $.context.User.GetIndexName .}} 27 |
28 |
29 |
30 |
31 | {{end}} 32 |
33 |
34 |
35 |
-------------------------------------------------------------------------------- /common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "path/filepath" 7 | "regexp" 8 | "strings" 9 | ) 10 | 11 | const ( 12 | // Api key and log type regex 13 | ApiKeyFormat = `^([a-z0-9]{8}-[a-z0-9]{4}-[1-5][a-z0-9]{3}-[a-z0-9]{4}-[a-z0-9]{12})@([a-z0-9\_]{1,20})` 14 | ) 15 | 16 | var ( 17 | ErrExtractingKey = errors.New("Error extracting key and type") 18 | ) 19 | 20 | // Extracts api key and log type from string 21 | func ExtractApiKey(message string) (string, string, error) { 22 | re := regexp.MustCompile(ApiKeyFormat) 23 | result := re.FindAllStringSubmatch(message, -1) 24 | 25 | if result == nil { 26 | return "", "", ErrExtractingKey 27 | } 28 | 29 | return result[0][1], result[0][2], nil 30 | } 31 | 32 | // Removes api key and log type from string 33 | func RemoveApiKey(message string) string { 34 | re := regexp.MustCompile(ApiKeyFormat) 35 | result := re.ReplaceAll([]byte(message), []byte("")) 36 | return strings.TrimSpace(string(result)) 37 | } 38 | 39 | // Builds full path to application based on $GOPATH 40 | func AppPath(elem ...string) string { 41 | app_path := filepath.Join(os.Getenv("GOPATH"), "src", "github.com", "firstrow", "logvoyage") 42 | dir_path := append([]string{app_path}, elem...) 43 | return filepath.Join(dir_path...) 44 | } 45 | -------------------------------------------------------------------------------- /web/widgets/log_message_test.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | . "github.com/smartystreets/goconvey/convey" 5 | "testing" 6 | ) 7 | 8 | func TestBuildLogLine(t *testing.T) { 9 | Convey("It should build simple log line", t, func() { 10 | data := make(map[string]interface{}) 11 | data["message"] = "test message" 12 | r := BuildLogLine(data) 13 | So(r, ShouldEqual, "test message") 14 | }) 15 | Convey("It should build simple log and json data", t, func() { 16 | data := make(map[string]interface{}) 17 | data["message"] = "test" 18 | data["amount"] = 10 19 | r := BuildLogLine(data) 20 | So(r, ShouldEqual, `test {"amount":10}`) 21 | }) 22 | Convey("It should properly display line with no message", t, func() { 23 | data := make(map[string]interface{}) 24 | r := BuildLogLine(data) 25 | So(r, ShouldEqual, "") 26 | }) 27 | Convey("It should properly display line with no message and json", t, func() { 28 | data := make(map[string]interface{}) 29 | data["amount"] = 10 30 | r := BuildLogLine(data) 31 | So(r, ShouldEqual, ` {"amount":10}`) 32 | }) 33 | Convey("It should properly display message as integer", t, func() { 34 | data := make(map[string]interface{}) 35 | data["message"] = 10 36 | r := BuildLogLine(data) 37 | So(r, ShouldEqual, `10`) 38 | }) 39 | } 40 | -------------------------------------------------------------------------------- /backend/per_second.go: -------------------------------------------------------------------------------- 1 | // Help to track per-second user statistics. 2 | // Each second time will collect data and send it to 3 | // redis channel. 4 | package backend 5 | 6 | import ( 7 | "sync" 8 | "time" 9 | 10 | "github.com/firstrow/logvoyage/web_socket" 11 | ) 12 | 13 | type perSecondStorage struct { 14 | sync.Mutex 15 | Logs map[string]int // Logs per second map[apiKey]logsPerSecond 16 | } 17 | 18 | var prs = perSecondStorage{Logs: make(map[string]int)} 19 | 20 | func initTimers() { 21 | ticker := time.NewTicker(1 * time.Second) 22 | 23 | defer ticker.Stop() 24 | 25 | for range ticker.C { 26 | prs.Lock() 27 | 28 | var message web_socket.RedisMessage 29 | for apiKey, logsPerSecond := range prs.Logs { 30 | if logsPerSecond > 0 { 31 | message = web_socket.RedisMessage{ApiKey: apiKey, Data: map[string]interface{}{ 32 | "type": "logs_per_second", 33 | "count": logsPerSecond, 34 | }} 35 | 36 | message.Send(redisConn) 37 | } 38 | } 39 | 40 | prs.Logs = make(map[string]int) 41 | prs.Unlock() 42 | } 43 | } 44 | 45 | // Increases counter of number of logs send to elastic 46 | func increaseCounter(apiKey string) { 47 | prs.Lock() 48 | defer prs.Unlock() 49 | if _, ok := prs.Logs[apiKey]; ok { 50 | prs.Logs[apiKey] += 1 51 | } else { 52 | prs.Logs[apiKey] = 1 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /web/routers/profile/profile.go: -------------------------------------------------------------------------------- 1 | package profile 2 | 3 | import ( 4 | "github.com/firstrow/logvoyage/common" 5 | "github.com/firstrow/logvoyage/web/context" 6 | ) 7 | 8 | type profileForm struct { 9 | *common.EnableValidation 10 | Email string 11 | FirstName string 12 | LastName string 13 | } 14 | 15 | func (this *profileForm) SetupValidation() { 16 | this.Valid.Required(this.FirstName, "FirstName") 17 | this.Valid.Required(this.LastName, "LastName") 18 | this.Valid.MaxSize(this.FirstName, 25, "FirstName") 19 | this.Valid.MaxSize(this.LastName, 25, "LastName") 20 | } 21 | 22 | func Index(ctx *context.Context) { 23 | ctx.Request.ParseForm() 24 | form := &profileForm{ 25 | EnableValidation: &common.EnableValidation{}, 26 | FirstName: ctx.User.FirstName, 27 | LastName: ctx.User.LastName, 28 | } 29 | 30 | if ctx.Request.Method == "POST" { 31 | form.FirstName = ctx.Request.Form.Get("firstName") 32 | form.LastName = ctx.Request.Form.Get("lastName") 33 | form.SetupValidation() 34 | if !form.EnableValidation.Valid.HasErrors() { 35 | ctx.User.FirstName = form.FirstName 36 | ctx.User.LastName = form.LastName 37 | ctx.User.Save() 38 | 39 | ctx.Session.AddFlash("Your data has been successfully saved.", "success") 40 | ctx.Render.Redirect("/profile") 41 | } 42 | } 43 | 44 | ctx.HTML("profile/index", context.ViewData{ 45 | "form": form, 46 | }) 47 | } 48 | -------------------------------------------------------------------------------- /web/context/context.go: -------------------------------------------------------------------------------- 1 | package context 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/codegangsta/martini-contrib/render" 7 | "github.com/firstrow/logvoyage/common" 8 | "github.com/go-martini/martini" 9 | "github.com/martini-contrib/sessions" 10 | ) 11 | 12 | type ViewData map[string]interface{} 13 | 14 | type Context struct { 15 | Session sessions.Session 16 | User *common.User 17 | Request *http.Request 18 | Render render.Render 19 | IsGuest bool 20 | } 21 | 22 | func (c *Context) HTML(view string, data ViewData, layout ...string) { 23 | data["context"] = c 24 | if c.Request.Header.Get("X-Requested-With") == "XMLHttpRequest" { 25 | // Disable layout for ajax requests 26 | c.Render.HTML(200, view, data, render.HTMLOptions{Layout: ""}) 27 | } else { 28 | var l string 29 | if len(layout) > 0 { 30 | l = layout[0] 31 | } else { 32 | l = "layouts/main" 33 | } 34 | c.Render.HTML(200, view, data, render.HTMLOptions{Layout: l}) 35 | } 36 | } 37 | 38 | func Contexter(c martini.Context, r render.Render, sess sessions.Session, req *http.Request) { 39 | email := sess.Get("email") 40 | var user *common.User 41 | 42 | if email != nil { 43 | user, _ = common.FindCachedUser(email.(string)) 44 | } else { 45 | user = nil 46 | } 47 | 48 | ctx := &Context{ 49 | Session: sess, 50 | IsGuest: user == nil, 51 | User: user, 52 | Request: req, 53 | Render: r, 54 | } 55 | c.Map(ctx) 56 | } 57 | -------------------------------------------------------------------------------- /web/templates/home/table.tmpl: -------------------------------------------------------------------------------- 1 | {{if .total}} 2 |
3 |
4 |
5 |
6 | Found {{.total}} events in {{.took}}ms 7 |
8 |
9 | 10 | 11 | {{range .logs}} 12 | 13 | 14 | 20 | 21 | {{end}} 22 | 23 |
{{.Source.datetime | FormatTimeToHuman}} 15 | 16 | 17 | 18 | {{.Source | buildLogLine}} 19 |
24 |
25 | {{if .pagination.HasPages}} 26 | 31 | {{end}} 32 |
33 |
34 |
35 | {{else}} {{template "home/no_records"}} {{end}} 36 | -------------------------------------------------------------------------------- /web/routers/users/login.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "github.com/firstrow/logvoyage/common" 5 | "github.com/firstrow/logvoyage/web/context" 6 | "errors" 7 | "log" 8 | ) 9 | 10 | type loginForm struct { 11 | *common.EnableValidation 12 | Email string 13 | Password string 14 | } 15 | 16 | func (this *loginForm) SetupValidation() { 17 | this.Valid.Required(this.Email, "Email") 18 | this.Valid.Email(this.Email, "Email") 19 | this.Valid.Required(this.Password, "Password") 20 | this.Valid.MinSize(this.Password, 5, "Password") 21 | this.Valid.MaxSize(this.Password, 25, "Password") 22 | 23 | } 24 | 25 | // Check of user exists by email and password 26 | func userExists(form *loginForm) error { 27 | user, _ := common.FindUserByEmail(form.Email) 28 | 29 | if user == nil { 30 | return errors.New("User not found") 31 | 32 | } 33 | 34 | err := common.CompareHashAndPassword(user.Password, form.Password) 35 | if err != nil { 36 | return err 37 | } 38 | 39 | return nil 40 | } 41 | 42 | func Login(ctx *context.Context) { 43 | message := "" 44 | ctx.Request.ParseForm() 45 | form := &loginForm{ 46 | EnableValidation: &common.EnableValidation{}, 47 | } 48 | 49 | if ctx.Request.Method == "POST" { 50 | form.Email = ctx.Request.Form.Get("email") 51 | form.Password = ctx.Request.Form.Get("password") 52 | form.SetupValidation() 53 | 54 | if !form.EnableValidation.Valid.HasErrors() { 55 | // find user 56 | err := userExists(form) 57 | if err != nil { 58 | log.Println(err.Error()) 59 | message = "User not found or wrong password" 60 | 61 | } else { 62 | ctx.Session.Set("email", form.Email) 63 | ctx.Render.Redirect("/") 64 | } 65 | } 66 | } 67 | 68 | ctx.HTML("users/login", context.ViewData{ 69 | "form": form, 70 | "message": message, 71 | }) 72 | 73 | } 74 | 75 | func Logout(ctx *context.Context) { 76 | ctx.Session.Clear() 77 | ctx.Render.Redirect("/") 78 | 79 | } 80 | -------------------------------------------------------------------------------- /backend/backlog.go: -------------------------------------------------------------------------------- 1 | // Backlog - holds clients messages that were not delivered to ES. 2 | // Every 10sec backlog tries to resend messages to ES if backlog file isn't empty. 3 | package backend 4 | 5 | import ( 6 | "log" 7 | "os" 8 | "sync" 9 | "time" 10 | ) 11 | 12 | const ( 13 | fallbackFileName = "back.log" 14 | checkFreq = 10 // Number of seconds to run fallback check 15 | ) 16 | 17 | var ( 18 | backFilePath = "" 19 | backlogManager = &backlog{} 20 | numStoreMsg = 10000 // Hold in mem only N number of messages, otherwise write to file 21 | ) 22 | 23 | type backlog struct { 24 | sync.RWMutex 25 | lines []string 26 | count int64 27 | } 28 | 29 | // Add new message to queue 30 | func (b *backlog) AddMessage(m string) { 31 | log.Println("Adding new message to backlog") 32 | b.Lock() 33 | if len(b.lines) <= numStoreMsg { 34 | b.lines = append(b.lines, m) 35 | b.count++ 36 | } else { 37 | log.Println("Backlog memory is full.") 38 | saveMessageToFile(m) 39 | } 40 | b.Unlock() 41 | } 42 | 43 | // Tries to resend messages 44 | func (b *backlog) Resend() { 45 | b.Lock() 46 | processing := b.lines 47 | b.lines = []string{} 48 | 49 | b.count = 0 50 | b.Unlock() 51 | for _, msg := range processing { 52 | processMessage(msg) 53 | } 54 | } 55 | 56 | func toBacklog(m string) { 57 | backlogManager.AddMessage(m) 58 | } 59 | 60 | func saveMessageToFile(m string) { 61 | file, err := os.OpenFile(getFallbackFile(), os.O_CREATE|os.O_RDWR|os.O_APPEND, 0660) 62 | if err != nil { 63 | log.Println("Error opening file", err) 64 | } 65 | defer file.Close() 66 | file.WriteString(m) 67 | } 68 | 69 | func getFallbackFile() string { 70 | path, err := os.Getwd() 71 | if err != nil { 72 | return "" 73 | } 74 | return path + string(os.PathSeparator) + fallbackFileName 75 | } 76 | 77 | func initBacklog() { 78 | ticker := time.NewTicker(checkFreq * time.Second) 79 | defer ticker.Stop() 80 | 81 | for _ = range ticker.C { 82 | backlogManager.Resend() 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /web/routers/users/register.go: -------------------------------------------------------------------------------- 1 | package users 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/belogik/goes" 9 | "github.com/nu7hatch/gouuid" 10 | 11 | "github.com/firstrow/logvoyage/common" 12 | "github.com/firstrow/logvoyage/web/context" 13 | ) 14 | 15 | type registerForm struct { 16 | *common.EnableValidation 17 | Email string 18 | Password string 19 | } 20 | 21 | func (r *registerForm) IsValid() bool { 22 | user, _ := common.FindUserByEmail(r.Email) 23 | if user != nil { 24 | r.Valid.SetError("Email", "This email is already taken") 25 | return false 26 | } 27 | return true 28 | } 29 | 30 | func (r *registerForm) SetupValidation() { 31 | r.Valid.Required(r.Email, "Email") 32 | r.Valid.Email(r.Email, "Email") 33 | r.Valid.Required(r.Password, "Password") 34 | r.Valid.MinSize(r.Password, 5, "Password") 35 | r.Valid.MaxSize(r.Password, 25, "Password") 36 | } 37 | 38 | func Register(ctx *context.Context) { 39 | ctx.Request.ParseForm() 40 | form := ®isterForm{ 41 | EnableValidation: &common.EnableValidation{}, 42 | } 43 | 44 | if ctx.Request.Method == "POST" { 45 | form.Email = ctx.Request.Form.Get("email") 46 | form.Password = ctx.Request.Form.Get("password") 47 | form.SetupValidation() 48 | 49 | if !form.EnableValidation.Valid.HasErrors() && form.IsValid() { 50 | password, err := common.HashPassword(form.Password) 51 | 52 | if err != nil { 53 | panic(err.Error()) 54 | } 55 | 56 | doc := goes.Document{ 57 | Index: "users", 58 | Type: "user", 59 | Fields: map[string]string{ 60 | "email": form.Email, 61 | "password": password, 62 | "apiKey": buildApiKey(form.Email), 63 | }, 64 | } 65 | extraArgs := make(url.Values, 0) 66 | common.GetConnection().Index(doc, extraArgs) 67 | ctx.Render.Redirect("/login") 68 | } 69 | } 70 | 71 | ctx.HTML("users/register", context.ViewData{ 72 | "form": form, 73 | }) 74 | } 75 | 76 | func buildApiKey(email string) string { 77 | t := fmt.Sprintf("%%", email, time.Now().Nanosecond()) 78 | apiKey, _ := uuid.NewV5(uuid.NamespaceURL, []byte(t)) 79 | return apiKey.String() 80 | } 81 | -------------------------------------------------------------------------------- /web/templates/projects/new.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 | New project 8 |
9 | 10 |
11 | 12 |
13 | {{.form.GetError "Name"}} 14 |
15 |
16 |
17 |
18 | 19 |
20 | 21 |
22 | {{.form.GetError "Description"}} 23 |
24 |
25 |
26 |
27 | 28 |
29 | 34 |
35 | {{.form.GetError "Types"}} 36 |
37 |
38 |
39 |
40 |
41 | 42 | or 43 | Cancel 44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 | -------------------------------------------------------------------------------- /web/templates/profile/index.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 |
7 | Profile 8 |
9 | 10 |
11 | 12 |
13 | {{.form.GetError "FirstName"}} 14 |
15 |
16 |
17 |
18 | 19 |
20 | 21 |
22 | {{.form.GetError "LastName"}} 23 |
24 |
25 |
26 |
27 | 28 |
29 | 30 |
31 |
32 |
33 | 34 |
35 | 36 |
37 |
38 |
39 |
40 | 41 | or 42 | Cancel 43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 | -------------------------------------------------------------------------------- /common/common_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | . "github.com/smartystreets/goconvey/convey" 5 | "testing" 6 | ) 7 | 8 | func TestItShouldReturnErrorIfApiKeyNotFound(t *testing.T) { 9 | logMessage := "0b1305-31-5f5b-5832-6a This is test logmessage" 10 | 11 | _, _, err := ExtractApiKey(logMessage) 12 | 13 | if err == nil { 14 | t.Fatal("It should return error") 15 | } 16 | } 17 | 18 | func TestExtractUserApiKeyAndTypeId(t *testing.T) { 19 | expectedKey := "0b137205-3291-5f5b-5832-ab2458b9936a" 20 | expectedType := "123" 21 | logMessage := "0b137205-3291-5f5b-5832-ab2458b9936a@123 This is test logmessage" 22 | 23 | key, logType, _ := ExtractApiKey(logMessage) 24 | 25 | if expectedKey != key { 26 | t.Fatal("Error extracting key") 27 | } 28 | if expectedType != logType { 29 | t.Fatal("Error extracting type") 30 | } 31 | 32 | // Test extraxt logType as string 33 | expectedType = "nginx_1" 34 | logMessage = "0b137205-3291-5f5b-5832-ab2458b9936a@nginx_1 This is test logmessage" 35 | 36 | key, logType, _ = ExtractApiKey(logMessage) 37 | 38 | if expectedKey != key { 39 | t.Fatal("Error extracting key 2") 40 | } 41 | if expectedType != logType { 42 | t.Fatal("Error extracting type 2") 43 | } 44 | 45 | } 46 | 47 | func TestRemoveApiKey(t *testing.T) { 48 | Convey("It should populate user from goes search response", t, func() { 49 | logMessage := "0b137205-3291-5f5b-5832-ab2458b9936a@2111 This is test logmessage" 50 | m := RemoveApiKey(logMessage) 51 | 52 | So(m, ShouldEqual, "This is test logmessage") 53 | }) 54 | Convey("It should remove api key and logType only one time", t, func() { 55 | logMessage := "0b137205-3291-5f5b-5832-ab2458b9936a@2111 This is test logmessage 0b137205-3291-5f5b-5832-ab2458b9936a@2111" 56 | m := RemoveApiKey(logMessage) 57 | 58 | So(m, ShouldEqual, "This is test logmessage 0b137205-3291-5f5b-5832-ab2458b9936a@2111") 59 | }) 60 | } 61 | 62 | func TestAppPath(t *testing.T) { 63 | Convey("It should return app path", t, func() { 64 | expected := AppPath() 65 | So(expected, ShouldContainSubstring, "src/github.com/firstrow/logvoyage") 66 | }) 67 | Convey("It should return app path plus dir", t, func() { 68 | expected := AppPath("static/js") 69 | So(expected, ShouldContainSubstring, "src/github.com/firstrow/logvoyage/static/js") 70 | }) 71 | } 72 | -------------------------------------------------------------------------------- /static/less/custom.less: -------------------------------------------------------------------------------- 1 | .navbar-inverse { 2 | background-color: #3d4a57; 3 | border-color: #333; 4 | } 5 | .navbar-inverse .navbar-nav > li > a, 6 | .navbar .navbar-brand { 7 | color: #fbfbfb; 8 | text-decoration: none; 9 | } 10 | .main { 11 | font-size: 12px; 12 | } 13 | 14 | #logstable td { 15 | word-wrap: break-word; 16 | word-break: break-all; 17 | font-size: 12px; 18 | vertical-align: text-top; 19 | } 20 | 21 | #logstable tr:hover td { 22 | cursor: pointer; 23 | background: #E3F2FD; 24 | } 25 | 26 | #pagination { 27 | margin-left: 10px; 28 | } 29 | 30 | .error { 31 | color: red; 32 | } 33 | 34 | .no-padding { 35 | padding: 0; 36 | } 37 | 38 | #time_start, #time_stop { 39 | width:130px; 40 | text-align: center; 41 | border:1px; 42 | } 43 | 44 | .timebox { 45 | display: none; 46 | outline: none; 47 | font-size:12px; 48 | } 49 | 50 | h2 .actions { 51 | padding-right: 15px; 52 | padding-top: 5px; 53 | font-size:14px; 54 | } 55 | 56 | .search_info { 57 | padding: 8px 8px 8px 5px; 58 | background-color: #cfd8dc; 59 | } 60 | 61 | #searchBox { 62 | -webkit-box-shadow: none; 63 | -moz-box-shadow: none; 64 | box-shadow: none; 65 | border: 1px solid #3e3f3a; 66 | background: rgb(47,48,44); 67 | color: #e0d8c6; 68 | width:500px; 69 | } 70 | 71 | #searchBox:focus { 72 | border-top: 2px solid #2765A1; 73 | border-left: 2px solid #2765A1; 74 | border-bottom: 2px solid #2765A1; 75 | } 76 | 77 | .jsonview { 78 | font-size:13px; 79 | word-wrap: break-word; 80 | } 81 | 82 | a.view { 83 | text-decoration: none; 84 | } 85 | 86 | .list_actions { 87 | position:absolute; 88 | right:40px; 89 | z-index:100; 90 | display: none; 91 | } 92 | 93 | .list_actions a { 94 | text-decoration: none; 95 | } 96 | 97 | .well:hover .list_actions { 98 | display: block; 99 | } 100 | 101 | .top50px { 102 | margin-top: 50px; 103 | } 104 | 105 | /* Bootstrap overrides */ 106 | .modal.modal-wide .modal-dialog { 107 | width: 950px; 108 | } 109 | 110 | .modal-wide .modal-body { 111 | overflow-y: auto; 112 | } 113 | 114 | .modal-backdrop { 115 | height: 100%; 116 | z-index: 1030; 117 | } 118 | 119 | a#current-project { 120 | color:#c4c4c4; 121 | cursor:default; 122 | } 123 | -------------------------------------------------------------------------------- /web/templates/projects/index.tmpl: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |

6 | Projects 7 | 8 | 9 | New 10 | 11 | 12 |

13 | {{if isEmpty .context.User.Projects}} 14 | No projects found. 15 | {{end}} 16 | {{range .context.User.Projects}} 17 |
18 | 26 |
27 |
28 | 29 |
30 | {{.Name}} 31 |
32 |
33 |
34 | 35 |
36 | {{if len .Description}} 37 | {{.Description}} 38 | {{else}} 39 | None 40 | {{end}} 41 |
42 |
43 |
44 | 45 |
46 | {{if len .Types}} 47 | {{range .Types}} 48 | {{.}} 49 | {{end}} 50 | {{else}} 51 | Empty types list. You should add at least one type. 52 | {{end}} 53 |
54 |
55 |
56 |
57 | {{end}} 58 |
59 |
60 |
61 |
-------------------------------------------------------------------------------- /common/elastic_test.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | // import ( 4 | // _ "log" 5 | // "net/url" 6 | // "testing" 7 | // "time" 8 | 9 | // "github.com/belogik/goes" 10 | // "github.com/mitchellh/mapstructure" 11 | // . "github.com/smartystreets/goconvey/convey" 12 | // ) 13 | 14 | // func TestElasticResponseToStruct(t *testing.T) { 15 | // indexname := "testingindex" 16 | // conn := GetConnection() 17 | // defer conn.DeleteIndex(indexname) 18 | 19 | // settings := `{ 20 | // "settings": { 21 | // "index": { 22 | // "number_of_shards": 5, 23 | // "number_of_replicas": 1 24 | // } 25 | // }, 26 | // "mappings": { 27 | // "user" : { 28 | // "_source" : {"enabled" : true}, 29 | // "properties" : { 30 | // "email" : {"type" : "string", "index": "not_analyzed" }, 31 | // "password" : {"type" : "string", "index": "not_analyzed" }, 32 | // "tokens" : {"type" : "string", "index": "not_analyzed" } 33 | // } 34 | // } 35 | // } 36 | // }` 37 | // SendToElastic(indexname, "PUT", []byte(settings)) 38 | 39 | // doc := goes.Document{ 40 | // Index: indexname, 41 | // Type: "user", 42 | // Fields: map[string]string{ 43 | // "email": "test@localhost.loc", 44 | // "password": "password", 45 | // "apiKey": "api_key_123", 46 | // }, 47 | // } 48 | // conn.Index(doc, url.Values{}) 49 | 50 | // time.Sleep(2 * time.Second) 51 | 52 | // // Search user 53 | // var query = map[string]interface{}{ 54 | // "query": map[string]interface{}{ 55 | // "bool": map[string]interface{}{ 56 | // "must": map[string]interface{}{ 57 | // "term": map[string]interface{}{ 58 | // "email": map[string]interface{}{ 59 | // "value": "test@localhost.loc", 60 | // }, 61 | // }, 62 | // }, 63 | // }, 64 | // }, 65 | // } 66 | 67 | // searchResults, err := conn.Search(query, []string{indexname}, []string{"user"}, url.Values{}) 68 | 69 | // if err != nil { 70 | // t.Fatal(err.Error()) 71 | // } 72 | 73 | // if searchResults.Hits.Total == 0 { 74 | // t.Fatal("User not found. Probably insert error.") 75 | // } 76 | 77 | // user := &User{} 78 | 79 | // mapstructure.Decode(searchResults.Hits.Hits[0].Source, user) 80 | 81 | // Convey("It should populate user from goes search response", t, func() { 82 | // So(user.Email, ShouldEqual, "test@localhost.loc") 83 | // So(user.Password, ShouldEqual, "password") 84 | // So(user.ApiKey, ShouldEqual, "api_key_123") 85 | // }) 86 | // } 87 | -------------------------------------------------------------------------------- /web/templates/users/register.tmpl: -------------------------------------------------------------------------------- 1 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 | 17 |
18 |
19 |
20 |
21 |
22 |
23 |

Sign Up

24 |
25 |
26 | 27 |
28 | 29 |
30 | {{.form.GetError "Email"}} 31 |
32 |
33 |
34 |
35 | 36 |
37 | 38 |
39 | {{.form.GetError "Password"}} 40 |
41 |
42 |
43 |
44 |
45 | 46 |
47 |
48 |
49 |
50 | Login 51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 | -------------------------------------------------------------------------------- /web/templates/users/login.tmpl: -------------------------------------------------------------------------------- 1 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |

Login

23 |
24 |
25 | 26 |
27 | 28 |
29 | {{.form.GetError "Email"}} {{.message}} 30 |
31 |
32 |
33 |
34 | 35 |
36 | 37 |
38 | {{.form.GetError "Password"}} 39 |
40 |
41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 |
49 | Create account 50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 | -------------------------------------------------------------------------------- /web/routers/projects/projects.go: -------------------------------------------------------------------------------- 1 | package projects 2 | 3 | import ( 4 | "github.com/firstrow/logvoyage/common" 5 | "github.com/firstrow/logvoyage/web/context" 6 | "github.com/Unknwon/com" 7 | "github.com/go-martini/martini" 8 | ) 9 | 10 | type projectForm struct { 11 | *common.EnableValidation 12 | Name string 13 | Description string 14 | Types []string 15 | Id string 16 | } 17 | 18 | func (s *projectForm) HasType(typeName string) bool { 19 | return com.IsSliceContainsStr(s.Types, typeName) 20 | } 21 | 22 | func (s *projectForm) SetupValidation() { 23 | s.Valid.Required(s.Name, "Name") 24 | s.Valid.MaxSize(s.Name, 25, "Name") 25 | s.Valid.MaxSize(s.Description, 250, "Description") 26 | } 27 | 28 | func Index(ctx *context.Context) { 29 | ctx.HTML("projects/index", context.ViewData{}) 30 | } 31 | 32 | func New(ctx *context.Context) { 33 | form := buildForm(ctx) 34 | update(ctx, form) 35 | } 36 | 37 | func Edit(ctx *context.Context, params martini.Params) { 38 | form := buildForm(ctx) 39 | group, err := ctx.User.GetProject(params["id"]) 40 | 41 | if err != nil { 42 | ctx.Render.Error(404) 43 | } 44 | 45 | form.Id = group.Id 46 | form.Name = group.Name 47 | form.Description = group.Description 48 | form.Types = group.Types 49 | update(ctx, form) 50 | } 51 | 52 | func Delete(ctx *context.Context, params martini.Params) { 53 | ctx.User.DeleteProject(params["id"]) 54 | ctx.User.Save() 55 | ctx.Session.AddFlash("Project has been successfully deleted.", "success") 56 | ctx.Render.Redirect("/projects") 57 | } 58 | 59 | func buildForm(ctx *context.Context) *projectForm { 60 | ctx.Request.ParseForm() 61 | form := &projectForm{ 62 | EnableValidation: &common.EnableValidation{}, 63 | } 64 | return form 65 | } 66 | 67 | func update(ctx *context.Context, form *projectForm) { 68 | if ctx.Request.Method == "POST" { 69 | form.Name = ctx.Request.Form.Get("name") 70 | form.Description = ctx.Request.Form.Get("description") 71 | form.Types = ctx.Request.PostForm["types"] 72 | form.SetupValidation() 73 | 74 | if !form.EnableValidation.Valid.HasErrors() { 75 | group := &common.Project{ 76 | Id: form.Id, 77 | Name: form.Name, 78 | Description: form.Description, 79 | Types: form.Types, 80 | } 81 | ctx.User.AddProject(group).Save() 82 | ctx.Session.AddFlash("Project has been successfully saved.", "success") 83 | ctx.Render.Redirect("/projects") 84 | } 85 | } 86 | 87 | ctx.HTML("projects/new", context.ViewData{ 88 | "form": form, 89 | }) 90 | } 91 | -------------------------------------------------------------------------------- /static/js/live_logs.coffee: -------------------------------------------------------------------------------- 1 | class window.LiveLogs 2 | opts: { 3 | # Root container of all elements 4 | container: "#liveLogsContainer" 5 | filterContainer: "#liveLogsSearch" 6 | stackLimit: 2000 7 | } 8 | # Root container 9 | container: null 10 | autoScroll: true 11 | messages: [] 12 | filter: null 13 | 14 | constructor: -> 15 | @container = $(@opts.container) 16 | @filterContainer = $(@opts.filterContainer) 17 | @setTheme $.cookie("livelogstheme") 18 | 19 | init: -> 20 | # On browser resize keep root container size equal 21 | @container.height $(window).height() - 36 22 | $(window).resize => 23 | @container.height $(window).height() - 36 24 | @container.scroll @_detectAutoScroll 25 | # Subscribe to new log event 26 | PubSub.subscribe "log_message", (type, data) => 27 | # TODO: Find out some way to limit number of displayed messages 28 | @messages.push data 29 | @appendMessage data.log_type, data.message 30 | # Filter events 31 | @filterContainer.find("input.query").keyup @_filter 32 | 33 | appendMessage: (type, message) -> 34 | message = @escapeHtml message 35 | if @filter 36 | message = @_filterMessage message 37 | if message 38 | @container.append "

#{type}#{message}

" 39 | @scrollToBottom() if @autoScroll 40 | 41 | clear: -> 42 | @container.html '' 43 | @messages = [] 44 | 45 | scrollToBottom: -> 46 | @container.scrollTop @container.prop('scrollHeight') 47 | 48 | switchTheme: -> 49 | if @container.hasClass "dark" 50 | @setTheme "light" 51 | else 52 | @setTheme "dark" 53 | 54 | setTheme: (t) => 55 | if t == "dark" or t == "light" 56 | @container.removeClass().addClass(t) 57 | $.cookie("livelogstheme", t) 58 | 59 | _detectAutoScroll: (e) => 60 | @autoScroll = (@container.height() + @container.scrollTop()) == @container.prop('scrollHeight') 61 | 62 | _filter: (e) => 63 | wait = => 64 | @filter = $(e.target).val() 65 | @_filterAllMessages() 66 | setTimeout wait, 500 67 | 68 | _filterAllMessages: => 69 | @container.html '' 70 | for data in @messages 71 | @appendMessage data.type, data.message 72 | 73 | # Returns highlighted text if mached search 74 | # or false if not 75 | _filterMessage: (text) => 76 | re = new RegExp("(#{@filter})", 'ig') 77 | if text.match(re) 78 | return text.replace(re, '$1') 79 | false 80 | 81 | escapeHtml: (unsafe) => 82 | unsafe.replace(/&/g, "&") 83 | .replace(//g, ">") 85 | .replace(/"/g, """) 86 | .replace(/'/g, "'") 87 | -------------------------------------------------------------------------------- /common/elastic.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io/ioutil" 9 | "log" 10 | "net/http" 11 | 12 | "github.com/belogik/goes" 13 | ) 14 | 15 | const ( 16 | ES_HOST = "127.0.0.1" 17 | ES_PORT = "9200" 18 | ) 19 | 20 | var ( 21 | ErrSendingElasticSearchRequest = errors.New("Error sending request to ES.") 22 | ErrCreatingHttpRequest = errors.New("Could not create http.NewRequest") 23 | ErrReadResponse = errors.New("Could not read ES response") 24 | ErrDecodingJson = errors.New("Error decoding ES response") 25 | ) 26 | 27 | func GetConnection() *goes.Connection { 28 | return goes.NewConnection(ES_HOST, ES_PORT) 29 | } 30 | 31 | type IndexMapping map[string]map[string]map[string]interface{} 32 | 33 | // Retuns list of types available in search index 34 | func GetTypes(index string) ([]string, error) { 35 | var mapping IndexMapping 36 | result, err := SendToElastic(index+"/_mapping", "GET", []byte{}) 37 | if err != nil { 38 | return nil, ErrSendingElasticSearchRequest 39 | } 40 | err = json.Unmarshal([]byte(result), &mapping) 41 | if err != nil { 42 | return nil, ErrDecodingJson 43 | } 44 | keys := []string{} 45 | for k := range mapping[index]["mappings"] { 46 | keys = append(keys, k) 47 | } 48 | return keys, nil 49 | } 50 | 51 | // Count documents in collection 52 | func CountTypeDocs(index string, logType string) float64 { 53 | result, err := SendToElastic(fmt.Sprintf("%s/%s/_count", index, logType), "GET", nil) 54 | if err != nil { 55 | return 0 56 | } 57 | 58 | var m map[string]interface{} 59 | err = json.Unmarshal([]byte(result), &m) 60 | if err != nil { 61 | return 0 62 | } 63 | return m["count"].(float64) 64 | } 65 | 66 | func DeleteType(index string, logType string) { 67 | _, err := SendToElastic(fmt.Sprintf("%s/%s", index, logType), "DELETE", nil) 68 | if err != nil { 69 | log.Println(err.Error()) 70 | } 71 | } 72 | 73 | // Send raw bytes to elastic search server 74 | // TODO: Bulk processing 75 | func SendToElastic(url string, method string, b []byte) (string, error) { 76 | eurl := fmt.Sprintf("http://%s:%s/%s", ES_HOST, ES_PORT, url) 77 | 78 | req, err := http.NewRequest(method, eurl, bytes.NewBuffer(b)) 79 | if err != nil { 80 | return "", err 81 | } 82 | 83 | client := &http.Client{} 84 | resp, err := client.Do(req) 85 | if err != nil { 86 | return "", ErrSendingElasticSearchRequest 87 | } 88 | defer resp.Body.Close() 89 | 90 | // Read body to close connection 91 | // If dont read body golang will keep connection open 92 | r, err := ioutil.ReadAll(resp.Body) 93 | if err != nil { 94 | return "", ErrReadResponse 95 | } 96 | 97 | return string(r), nil 98 | } 99 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | grunt.initConfig({ 4 | pkg: grunt.file.readJSON('package.json'), 5 | concat: { 6 | options: { 7 | separator: "\n" 8 | }, 9 | javascripts: { 10 | src: [ 11 | 'static/bower_components/jquery/dist/jquery.min.js', 12 | 'static/bower_components/bootstrap/dist/js/bootstrap.min.js', 13 | 'static/bower_components/jquery-jsonview/dist/jquery.jsonview.js', 14 | 'static/bower_components/jquery-cookie/jquery.cookie.js', 15 | 'static/bower_components/datetimepicker/jquery.datetimepicker.js', 16 | 'static/bower_components/bootstrap-multiselect/dist/js/bootstrap-multiselect.js', 17 | 'static/bower_components/ladda-bootstrap/dist/spin.min.js', 18 | 'static/bower_components/ladda-bootstrap/dist/ladda.min.js', 19 | 'static/bower_components/jquery.hotkeys/jquery.hotkeys.js', 20 | 'static/bower_components/sockjs-client/dist/sockjs.js', 21 | 'static/bower_components/pubsub-js/src/pubsub.js', 22 | 'static/bower_components/bootstrap-select/dist/js/bootstrap-select.min.js', 23 | 'static/js/*.js', 24 | ], 25 | dest: 'static/build/all.min.js' 26 | }, 27 | css: { 28 | src: [ 29 | 'static/bower_components/bootstrap/dist/css/bootstrap.css', 30 | 'static/bower_components/jquery-jsonview/dist/jquery.jsonview.css', 31 | 'static/bower_components/datetimepicker/jquery.datetimepicker.css', 32 | 'static/bower_components/bootstrap-multiselect/dist/css/bootstrap-multiselect.css', 33 | 'static/bower_components/epoch/epoch.min.css', 34 | 'static/bower_components/chosen/chosen.min.css', 35 | 'static/bower_components/ladda-bootstrap/dist/ladda-themeless.min.css', 36 | 'static/bower_components/bootstrap-select/dist/css/bootstrap-select.min.css', 37 | ], 38 | dest: 'static/build/all.min.css' 39 | } 40 | }, 41 | less: { 42 | development: { 43 | files: { 44 | "static/build/app.min.css": "static/less/*.less" 45 | } 46 | } 47 | }, 48 | coffee: { 49 | compileJoined: { 50 | options: { 51 | join: true 52 | }, 53 | files: { 54 | 'static/build/app.min.js': ['static/js/*.coffee'] 55 | } 56 | } 57 | }, 58 | watch: { 59 | scripts: { 60 | files: ['static/js/*.*'], 61 | tasks: ['js'], 62 | options: { 63 | spawn: false 64 | } 65 | }, 66 | css: { 67 | files: ['static/less/*.less'], 68 | tasks: ['css'], 69 | options: { 70 | spawn: false 71 | } 72 | } 73 | } 74 | }); 75 | 76 | grunt.loadNpmTasks('grunt-contrib-concat'); 77 | grunt.loadNpmTasks('grunt-contrib-coffee'); 78 | grunt.loadNpmTasks('grunt-contrib-less'); 79 | grunt.loadNpmTasks('grunt-contrib-watch'); 80 | 81 | // Default task(s). 82 | grunt.registerTask('default', ['all']); 83 | grunt.registerTask('all', ['js', 'css']); 84 | grunt.registerTask('js', ['concat:javascripts', 'coffee']); 85 | grunt.registerTask('css', ['concat:css', 'less']); 86 | 87 | }; 88 | -------------------------------------------------------------------------------- /static/build/app.min.css: -------------------------------------------------------------------------------- 1 | .navbar-inverse { 2 | background-color: #3d4a57; 3 | border-color: #333; 4 | } 5 | .navbar-inverse .navbar-nav > li > a, 6 | .navbar .navbar-brand { 7 | color: #fbfbfb; 8 | text-decoration: none; 9 | } 10 | .main { 11 | font-size: 12px; 12 | } 13 | #logstable td { 14 | word-wrap: break-word; 15 | word-break: break-all; 16 | font-size: 12px; 17 | vertical-align: text-top; 18 | } 19 | #logstable tr:hover td { 20 | cursor: pointer; 21 | background: #E3F2FD; 22 | } 23 | #pagination { 24 | margin-left: 10px; 25 | } 26 | .error { 27 | color: red; 28 | } 29 | .no-padding { 30 | padding: 0; 31 | } 32 | #time_start, 33 | #time_stop { 34 | width: 130px; 35 | text-align: center; 36 | border: 1px; 37 | } 38 | .timebox { 39 | display: none; 40 | outline: none; 41 | font-size: 12px; 42 | } 43 | h2 .actions { 44 | padding-right: 15px; 45 | padding-top: 5px; 46 | font-size: 14px; 47 | } 48 | .search_info { 49 | padding: 8px 8px 8px 5px; 50 | background-color: #cfd8dc; 51 | } 52 | #searchBox { 53 | -webkit-box-shadow: none; 54 | -moz-box-shadow: none; 55 | box-shadow: none; 56 | border: 1px solid #3e3f3a; 57 | background: #2f302c; 58 | color: #e0d8c6; 59 | width: 500px; 60 | } 61 | #searchBox:focus { 62 | border-top: 2px solid #2765A1; 63 | border-left: 2px solid #2765A1; 64 | border-bottom: 2px solid #2765A1; 65 | } 66 | .jsonview { 67 | font-size: 13px; 68 | word-wrap: break-word; 69 | } 70 | a.view { 71 | text-decoration: none; 72 | } 73 | .list_actions { 74 | position: absolute; 75 | right: 40px; 76 | z-index: 100; 77 | display: none; 78 | } 79 | .list_actions a { 80 | text-decoration: none; 81 | } 82 | .well:hover .list_actions { 83 | display: block; 84 | } 85 | .top50px { 86 | margin-top: 50px; 87 | } 88 | /* Bootstrap overrides */ 89 | .modal.modal-wide .modal-dialog { 90 | width: 950px; 91 | } 92 | .modal-wide .modal-body { 93 | overflow-y: auto; 94 | } 95 | .modal-backdrop { 96 | height: 100%; 97 | z-index: 1030; 98 | } 99 | a#current-project { 100 | color: #c4c4c4; 101 | cursor: default; 102 | } 103 | 104 | #liveLogsContainer { 105 | width: 100%; 106 | overflow: auto; 107 | } 108 | #liveLogsContainer p { 109 | margin: 0 0 0 5px; 110 | padding: 0; 111 | font-size: 12px; 112 | word-wrap: break-word; 113 | } 114 | #liveLogsContainer span.highlight { 115 | background-color: #a57705; 116 | color: #000; 117 | } 118 | #liveLogsContainer .type { 119 | padding-right: 5px; 120 | } 121 | #liveLogsContainer.dark { 122 | background: #1d1d1d; 123 | color: #859900; 124 | } 125 | #liveLogsContainer.dark .type { 126 | color: #268bd2; 127 | } 128 | #liveLogsContainer.light { 129 | background: #fdf5db; 130 | color: #1f9283; 131 | } 132 | #liveLogsContainer.light .type { 133 | color: #268bd2; 134 | } 135 | #liveLogsSearch { 136 | position: fixed; 137 | float: auto; 138 | width: 300px; 139 | bottom: 0; 140 | width: 100%; 141 | background: #171717; 142 | padding: 3px; 143 | } 144 | #liveLogsSearch form input[type=text] { 145 | background: #222222; 146 | border: 0; 147 | outline: none; 148 | width: 500px; 149 | color: #999999; 150 | } 151 | #liveLogsSearch form button { 152 | background: transparent; 153 | color: #999999; 154 | outline: none; 155 | } 156 | #liveLogsSearch form button:hover { 157 | color: #fff; 158 | } 159 | -------------------------------------------------------------------------------- /web/widgets/pagination.go: -------------------------------------------------------------------------------- 1 | package widgets 2 | 3 | import ( 4 | "fmt" 5 | "html/template" 6 | _ "log" 7 | "math" 8 | "net/http" 9 | "net/url" 10 | "strconv" 11 | ) 12 | 13 | // Help to paginate elastic search 14 | type pagination struct { 15 | totalRecords uint64 16 | perPage int 17 | padding int // How many links display before and after current 18 | req *http.Request 19 | } 20 | 21 | func (this *pagination) SetTotalRecords(total uint64) { 22 | this.totalRecords = total 23 | } 24 | 25 | func (this *pagination) GetTotalRecords() uint64 { 26 | return this.totalRecords 27 | } 28 | 29 | func (this *pagination) GetTotalPages() int { 30 | r := float64(this.GetTotalRecords()) / float64(this.GetPerPage()) 31 | return int(math.Ceil(r)) 32 | } 33 | 34 | func (this *pagination) SetPerPage(limit int) { 35 | this.perPage = limit 36 | } 37 | 38 | func (this *pagination) GetPerPage() int { 39 | return this.perPage 40 | } 41 | 42 | // Detect `from` number for elastic 43 | func (this *pagination) DetectFrom() int { 44 | page, _ := strconv.Atoi(this.req.URL.Query().Get("p")) 45 | if page > 1 { 46 | return this.GetPerPage()*page - this.GetPerPage() 47 | } 48 | return 0 49 | } 50 | 51 | // Get current page number 52 | func (this *pagination) GetPageNumber() int { 53 | number, err := strconv.Atoi(this.req.URL.Query().Get("p")) 54 | if err != nil { 55 | return 1 56 | } else { 57 | return number 58 | } 59 | } 60 | 61 | // Checks if pagination has one or more pages 62 | func (this *pagination) HasPages() bool { 63 | return this.GetTotalPages() > 1 64 | } 65 | 66 | func (this *pagination) Render() template.HTML { 67 | currentPage := this.GetPageNumber() 68 | startPage := currentPage - this.padding 69 | 70 | if startPage < 1 { 71 | startPage = 1 72 | } 73 | stopPage := startPage + (this.padding * 2) - 1 74 | 75 | if stopPage > this.GetTotalPages() { 76 | stopPage = this.GetTotalPages() 77 | } 78 | 79 | prevPage := currentPage - 1 80 | nextPage := currentPage + 1 81 | var next string 82 | var prev string 83 | if prevPage < 1 { 84 | prevPage = 1 85 | prev = fmt.Sprintf("
  • «
  • ") 86 | } else { 87 | prev = fmt.Sprintf("
  • «
  • ", this.buildUrl(prevPage)) 88 | } 89 | if nextPage > this.GetTotalPages() { 90 | nextPage = this.GetTotalPages() 91 | next = "
  • »
  • " 92 | } else { 93 | next = fmt.Sprintf("
  • »
  • ", this.buildUrl(nextPage)) 94 | } 95 | 96 | var active string 97 | var result string 98 | for i := startPage; i <= stopPage; i++ { 99 | if i == currentPage { 100 | active = "active" 101 | } else { 102 | active = "" 103 | } 104 | result += fmt.Sprintf("
  • %v
  • ", active, this.buildUrl(i), i) 105 | } 106 | 107 | return template.HTML(prev + result + next) 108 | } 109 | 110 | func (this *pagination) buildUrl(p int) string { 111 | u := this.req.RequestURI 112 | v, _ := url.Parse(u) 113 | values := v.Query() 114 | values.Set("p", strconv.Itoa(p)) 115 | v.RawQuery = values.Encode() 116 | 117 | return v.String() 118 | } 119 | 120 | // Create new pagination object. 121 | // Pass http request to detect current page from request uri. 122 | func NewPagination(req *http.Request) *pagination { 123 | return &pagination{req: req, padding: 5} 124 | } 125 | -------------------------------------------------------------------------------- /web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "html/template" 5 | "reflect" 6 | "time" 7 | 8 | "github.com/Unknwon/com" 9 | "github.com/codegangsta/martini-contrib/render" 10 | "github.com/go-martini/martini" 11 | "github.com/martini-contrib/sessions" 12 | 13 | "github.com/firstrow/logvoyage/common" 14 | "github.com/firstrow/logvoyage/web/context" 15 | "github.com/firstrow/logvoyage/web/middleware" 16 | "github.com/firstrow/logvoyage/web/routers/home" 17 | "github.com/firstrow/logvoyage/web/routers/live" 18 | "github.com/firstrow/logvoyage/web/routers/profile" 19 | "github.com/firstrow/logvoyage/web/routers/projects" 20 | "github.com/firstrow/logvoyage/web/routers/users" 21 | "github.com/firstrow/logvoyage/web/widgets" 22 | "github.com/firstrow/logvoyage/web_socket" 23 | ) 24 | 25 | var ( 26 | // Host and port for martini 27 | webuiDsn = ":3000" 28 | ) 29 | 30 | func Start(customWebuiDsn string) { 31 | if customWebuiDsn != "" { 32 | webuiDsn = customWebuiDsn 33 | } 34 | // Template methods 35 | templateFunc := template.FuncMap{ 36 | "FormatTimeToHuman": func(s ...string) string { 37 | if len(s) > 0 { 38 | t, _ := time.Parse(time.RFC3339Nano, s[0]) 39 | return t.Format("2006-01-02 15:04:05") + " UTC" 40 | } else { 41 | return "Unknown" 42 | } 43 | }, 44 | "isEmpty": func(i interface{}) bool { 45 | switch reflect.TypeOf(i).Kind() { 46 | case reflect.Slice: 47 | v := reflect.ValueOf(i) 48 | return v.Len() == 0 49 | } 50 | return true 51 | }, 52 | "eq": reflect.DeepEqual, 53 | "isSliceContainsStr": com.IsSliceContainsStr, 54 | "buildLogLine": widgets.BuildLogLine, 55 | } 56 | 57 | m := martini.Classic() 58 | // Template 59 | m.Use(render.Renderer(render.Options{ 60 | Directory: common.AppPath("web/templates"), 61 | Funcs: []template.FuncMap{templateFunc}, 62 | Layout: "layouts/main", 63 | })) 64 | // Serve static files 65 | m.Use(martini.Static(common.AppPath("static"), martini.StaticOptions{ 66 | Prefix: "static", 67 | SkipLogging: true, 68 | })) 69 | // Sessions 70 | store := sessions.NewCookieStore([]byte("super_secret_key")) 71 | m.Use(sessions.Sessions("default", store)) 72 | 73 | m.Use(context.Contexter) 74 | 75 | // Routes 76 | m.Any("/register", middleware.RedirectIfAuthorized, users.Register) 77 | m.Any("/login", middleware.RedirectIfAuthorized, users.Login) 78 | m.Any("/logout", middleware.Authorize, users.Logout) 79 | m.Get("/maintenance", func(ctx *context.Context) { 80 | ctx.HTML("maintenance", context.ViewData{}, "layouts/simple") 81 | }) 82 | // Auth routes 83 | m.Any("/", middleware.Authorize, home.ProjectSearch) 84 | m.Any("/project/:id", middleware.Authorize, home.ProjectSearch) 85 | m.Any("/profile", middleware.Authorize, profile.Index) 86 | m.Any("/live", middleware.Authorize, live.Index) 87 | // Logs 88 | m.Get("/log/:id/type/:type", middleware.Authorize, home.View) 89 | m.Delete("/log/:id/type/:type", middleware.Authorize, home.Delete) 90 | // Projects 91 | m.Group("/projects", func(r martini.Router) { 92 | r.Any("", projects.Index) 93 | r.Any("/new", projects.New) 94 | r.Any("/edit/:id", projects.Edit) 95 | r.Any("/delete/:id", projects.Delete) 96 | // Types 97 | r.Any("/types", projects.Types) 98 | r.Any("/types/delete/:name", projects.DeleteType) 99 | }, middleware.Authorize) 100 | 101 | go web_socket.StartServer() 102 | m.RunOnAddr(webuiDsn) 103 | } 104 | -------------------------------------------------------------------------------- /web_socket/web_socket.go: -------------------------------------------------------------------------------- 1 | // Websocket server package. 2 | // This package starts separate websocket server and transfers all 3 | // messages from redis channel "ws" to client browser. 4 | // 5 | // Running server 6 | // web_socket.StartServer() 7 | // Example code to send data to redis: 8 | // c, _ := redis.Dial("tcp", ":6379") 9 | // msg := web_socket.RedisMessage{"apiKey", map[string]interface{}{ 10 | // "log_per_second": 24, 11 | // "kbs_per_second": 128, 12 | // }} 13 | // msg.Send(c) 14 | package web_socket 15 | 16 | import ( 17 | "encoding/json" 18 | "log" 19 | "net/http" 20 | 21 | "github.com/firstrow/logvoyage/common" 22 | 23 | "code.google.com/p/go.net/websocket" 24 | "github.com/garyburd/redigo/redis" 25 | ) 26 | 27 | const ( 28 | redisChannel = "ws" 29 | ) 30 | 31 | // Represents data to be sent to user by its apiKey 32 | type RedisMessage struct { 33 | ApiKey string 34 | Data interface{} 35 | } 36 | 37 | func (m *RedisMessage) Send(r redis.Conn) (interface{}, error) { 38 | j, err := json.Marshal(m) 39 | if err != nil { 40 | return nil, err 41 | } 42 | return r.Do("PUBLISH", redisChannel, string(j)) 43 | } 44 | 45 | // Store connected clients: [apikey]Connection 46 | var clients = make(map[string]*websocket.Conn) 47 | 48 | func StartServer() { 49 | go startListetingRedis() 50 | 51 | http.Handle("/ws", websocket.Handler(wsHandler)) 52 | err := http.ListenAndServe(":12345", nil) 53 | checkError(err) 54 | } 55 | 56 | func checkError(err error) { 57 | if err != nil { 58 | log.Fatal(err) 59 | } 60 | } 61 | 62 | // Listen to Redis and send messages to clients 63 | func startListetingRedis() { 64 | c, err := redis.Dial("tcp", ":6379") 65 | checkError(err) 66 | c.Send("SUBSCRIBE", redisChannel) 67 | c.Flush() 68 | 69 | log.Println("Started server and connected to redis") 70 | 71 | psc := redis.PubSubConn{c} 72 | go func() { 73 | for { 74 | switch v := psc.Receive().(type) { 75 | case redis.Message: 76 | log.Printf("%s: message: %s\n", v.Channel, v.Data) 77 | 78 | // We must recive new json-encoded RedisMessage 79 | var message RedisMessage 80 | err = json.Unmarshal(v.Data, &message) 81 | 82 | if err != nil { 83 | continue 84 | } 85 | 86 | // If client found by apiKey from message 87 | if wsClient, ok := clients[message.ApiKey]; ok { 88 | // Marshal messaged data back to json 89 | // and send to client 90 | j, _ := json.Marshal(message.Data) 91 | if err = websocket.Message.Send(wsClient, string(j)); err != nil { 92 | wsClient.Close() 93 | delete(clients, message.ApiKey) 94 | log.Println("Could not send message to ", wsClient, err.Error()) 95 | } 96 | 97 | } 98 | case error: 99 | log.Println("Error occured with redis.", v) 100 | } 101 | } 102 | }() 103 | } 104 | 105 | // Connection handler. This function called after new client 106 | // connected to websocket server. 107 | // Also this method performs register user - client must send valid apiKey 108 | // to receive messages from redis. 109 | func wsHandler(ws *websocket.Conn) { 110 | log.Println("New client") 111 | defer ws.Close() 112 | // websocket.Message.Send(ws, "Hello dear user!") 113 | 114 | for { 115 | // Message received from client 116 | var message string 117 | 118 | // Read messages from client 119 | // Code blocks here, after any message received 120 | // will resume execution. 121 | if err := websocket.Message.Receive(ws, &message); err != nil { 122 | log.Println("Error receiving message. Closing connection.") 123 | return 124 | } 125 | 126 | // Register user 127 | // TODO: Cache user 128 | user, _ := common.FindUserByApiKey(message) 129 | if user != nil { 130 | log.Println("Registering apiKey", user.ApiKey) 131 | clients[user.ApiKey] = ws 132 | } else { 133 | log.Println("Error registering user", message) 134 | } 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /commands/commands.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "github.com/codegangsta/cli" 5 | "log" 6 | 7 | "github.com/firstrow/logvoyage/backend" 8 | "github.com/firstrow/logvoyage/common" 9 | "github.com/firstrow/logvoyage/web" 10 | ) 11 | 12 | var CreateUsersIndex = cli.Command{ 13 | Name: "create_users_index", 14 | Usage: "Will create `user` index in ES", 15 | Description: "", 16 | Action: createUsersIndexFunc, 17 | Flags: []cli.Flag{}, 18 | } 19 | 20 | var DeleteIndex = cli.Command{ 21 | Name: "delete_index", 22 | Usage: "Will delete elastic search index", 23 | Description: "", 24 | Action: deleteIndexFunc, 25 | Flags: []cli.Flag{}, 26 | } 27 | 28 | var CreateIndex = cli.Command{ 29 | Name: "create_index", 30 | Usage: "Create search index", 31 | Description: "", 32 | Action: createIndexFunc, 33 | Flags: []cli.Flag{}, 34 | } 35 | 36 | var StartBackendServer = cli.Command{ 37 | Name: "backend", 38 | Usage: "Starts TCP server to accept logs from clients", 39 | Action: startBackendServer, 40 | Flags: []cli.Flag{ 41 | cli.StringFlag{ 42 | Name: "tcp-dsn", 43 | Usage: "Use different TCP host and port. Default is :27077", 44 | }, 45 | cli.StringFlag{ 46 | Name: "http-dsn", 47 | Usage: "Use different HTTP host and port. Default is :27078", 48 | }, 49 | }, 50 | } 51 | 52 | var StartWebServer = cli.Command{ 53 | Name: "web", 54 | Usage: "Starts web-ui server", 55 | Description: "", 56 | Action: startWebServer, 57 | Flags: []cli.Flag{ 58 | cli.StringFlag{ 59 | Name: "webui-dsn", 60 | Usage: "Use different host and port for webio. Default is :3000", 61 | }, 62 | }, 63 | } 64 | 65 | var StartAll = cli.Command{ 66 | Name: "start-all", 67 | Usage: "Starts backend and web server", 68 | Description: "", 69 | Action: startAll, 70 | Flags: []cli.Flag{ 71 | cli.StringFlag{ 72 | Name: "tcp-dsn", 73 | Usage: "Use different TCP host and port. Default is :27077", 74 | }, 75 | cli.StringFlag{ 76 | Name: "http-dsn", 77 | Usage: "Use different HTTP host and port. Default is :27078", 78 | }, 79 | cli.StringFlag{ 80 | Name: "webui-dsn", 81 | Usage: "Use different host and port for webio. Default is :3000", 82 | }, 83 | }, 84 | } 85 | 86 | func startBackendServer(c *cli.Context) { 87 | backend.Start(c.String("tcp-dsn"), c.String("http-dsn")) 88 | } 89 | 90 | func startWebServer(c *cli.Context) { 91 | web.Start(c.String("webui-dsn")) 92 | } 93 | 94 | func startAll(c *cli.Context) { 95 | go backend.Start(c.String("tcp-dsn"), c.String("http-dsn")) 96 | web.Start(c.String("webui-dsn")) 97 | } 98 | 99 | func createUsersIndexFunc(c *cli.Context) { 100 | log.Println("Creating users index in ElasticSearch") 101 | settings := `{ 102 | "settings": { 103 | "index": { 104 | "number_of_shards": 5, 105 | "number_of_replicas": 1, 106 | "refresh_interval" : "1s" 107 | } 108 | }, 109 | "mappings": { 110 | "user" : { 111 | "_source" : {"enabled" : true}, 112 | "properties" : { 113 | "email" : {"type" : "string", "index": "not_analyzed" }, 114 | "password" : {"type" : "string", "index": "not_analyzed" }, 115 | "apiKey" : {"type" : "string", "index": "not_analyzed" } 116 | } 117 | } 118 | } 119 | }` 120 | result, _ := common.SendToElastic("users", "PUT", []byte(settings)) 121 | log.Println(result) 122 | } 123 | 124 | func createIndexFunc(c *cli.Context) { 125 | settings := `{ 126 | "settings": { 127 | "index": { 128 | "number_of_shards": 5, 129 | "number_of_replicas": 1, 130 | "refresh_interval" : "2s" 131 | } 132 | } 133 | }` 134 | result, _ := common.SendToElastic(c.Args()[0], "PUT", []byte(settings)) 135 | log.Println(result) 136 | } 137 | 138 | func deleteIndexFunc(c *cli.Context) { 139 | if len(c.Args()) > 0 { 140 | for _, name := range c.Args() { 141 | result, _ := common.SendToElastic(name, "DELETE", nil) 142 | log.Println(result) 143 | } 144 | } else { 145 | log.Println("Provide index name. e.g: index1, index2, ...") 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /static/js/hotkeys.js: -------------------------------------------------------------------------------- 1 | /* 2 | * jQuery Hotkey Plugin 3 | * Copyright 2013, Realdanielbyrne 4 | * Licensed under the MIT 5 | */ 6 | 7 | (function ($) { 8 | $.fn.hotKey = function (options, callback) { 9 | var $self = this; // preserve the calling context 10 | var keys = { 11 | 12 | "backspace":8,"tab":9, "return":13, "pause":19, 13 | "capslock":20, "esc":27, "space":32, "pageup":33, "pagedown":34, "end":35, "home":36, 14 | "left":37, "up":38, "right":39, "down":40, "insert":45, "del":46, 15 | "num0":96, "num1":97, "num2":98, "num3":99, "num4":100, "num5":101, "num6":102, "num7":103, 16 | "num8":104, "num9":105, "num*":106, "num+":107, "num-":109, "num.":110, "num/":111, 17 | "f1":112, "f2":113, "f3":114, "f4":115, "f5":116, "f6":117,"f7":118,"f8":119, 18 | "f9":120, "f10":121, "f11":122,"f12":123, "numlock":144, "scroll":145, ";":186, "/":191, 19 | "\\":220, "'":222 20 | }; 21 | 22 | var modifiers = { 'alt': 'alt', 'ctrl': 'ctrl', 'shift': 'shift' }; 23 | 24 | for (var i = 65; i < 91; i++) { 25 | keys[String.fromCharCode(i).toLowerCase()] = i; 26 | } 27 | 28 | return this.each(function () { 29 | var cb, hotKeyCode = 0, modifier = "", 30 | settings = $.extend({ 31 | // These are the defaults. 32 | key: "", 33 | modifier: "" 34 | }, options); 35 | 36 | // parameter checking 37 | if ((settings.key === "") || 38 | (typeof settings.key !== "string")) 39 | return; 40 | 41 | settings.key = settings.key.toLowerCase(); 42 | 43 | // check to make sure keymodifer is handled 44 | if ((settings.modifier !== "") && (typeof settings.modifier === "string")) { 45 | modifier = (modifiers[settings.modifier.toLowerCase()]) ? modifiers[settings.modifier.toLowerCase()] : 0; 46 | if (!modifier) 47 | return; 48 | } 49 | 50 | // check for existance of callback function 51 | cb = callback; 52 | if (Object.prototype.toString.call(cb) != '[object Function]') 53 | return; 54 | 55 | 56 | // check to see if key is in captureable keys array 57 | hotKeyCode = (keys[settings.key]) ? keys[settings.key] : 0; 58 | if (!hotKeyCode) 59 | return; 60 | 61 | /* The event registration method */ 62 | $self.keydown(function (event) { 63 | 64 | var keyCode = (event.which) ? event.which : event.keyCode; // keycode attached to event 65 | 66 | // check for alt Key combinations 67 | if (((modifier == 'alt') && event.altKey) || 68 | ((modifier == 'ctrl') && event.ctrlKey) || 69 | ((modifier == 'shift') && event.shiftKey) || 70 | (modifier === "")) 71 | { 72 | checkAndExecute(this); 73 | } 74 | 75 | /* This function handles checking the typed character against the registered hotkey 76 | * and then then if there is a match calling the registered callback. 77 | */ 78 | function checkAndExecute(self) { 79 | if (keyCode == hotKeyCode) { 80 | cb.apply(self, arguments); // call callback 81 | event.preventDefault(); 82 | event.stopPropagation(); 83 | event.stopImmediatePropagation(); 84 | } 85 | } 86 | }); 87 | }); 88 | }; 89 | 90 | 91 | })(jQuery); 92 | 93 | $(function(){ 94 | // Alt+s - close all popups and focus on search box 95 | $('body').hotKey({ key: 's', modifier: 'alt' }, function(){ 96 | $(".modal .close").click(); 97 | $("#searchBox").focus(); 98 | }); 99 | }); 100 | -------------------------------------------------------------------------------- /backend/backend.go: -------------------------------------------------------------------------------- 1 | // Backend server - main part of LogVoyage service. 2 | // It accepts connections from "Client", parses string and pushes it to ElasticSearch index 3 | package backend 4 | 5 | import ( 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "log" 10 | "time" 11 | 12 | "github.com/firstrow/logvoyage/common" 13 | "github.com/firstrow/logvoyage/web_socket" 14 | "github.com/garyburd/redigo/redis" 15 | ) 16 | 17 | var ( 18 | tcpDsn = ":27077" 19 | httpDsn = ":27078" 20 | redisConn redis.Conn 21 | 22 | errUserNotFound = errors.New("Error. User not found") 23 | ) 24 | 25 | func Start(userTcpDsn, userHttpDsn string) { 26 | if userTcpDsn != "" { 27 | tcpDsn = userTcpDsn 28 | } 29 | if userHttpDsn != "" { 30 | httpDsn = userHttpDsn 31 | } 32 | 33 | log.Println("Initializing server") 34 | 35 | initRedis() 36 | go initTimers() 37 | go initBacklog() 38 | go initTcpServer() 39 | initHttpServer() 40 | } 41 | 42 | func initRedis() { 43 | r, err := redis.Dial("tcp", ":6379") 44 | if err != nil { 45 | log.Fatal("Cannot connect to redis") 46 | } 47 | r.Flush() 48 | redisConn = r 49 | } 50 | 51 | // Process text message from tcp or http client 52 | // Extract user api key, check send message to search index. 53 | // Message examples: 54 | // apiKey@logType Some text 55 | // apiKey@logType {message: "Some text", field:"value", ...} 56 | func processMessage(message string) { 57 | origMessage := message 58 | indexName, logType, err := extractIndexAndType(message) 59 | if err != nil { 60 | log.Println("Error extracting index name and type", err.Error()) 61 | switch err { 62 | case common.ErrSendingElasticSearchRequest: 63 | toBacklog(origMessage) 64 | case errUserNotFound: 65 | log.Println("Backend: user not found.") 66 | } 67 | } else { 68 | message = common.RemoveApiKey(message) 69 | 70 | log.Println("Sending message to elastic") 71 | 72 | err = toElastic(indexName, logType, buildMessageStruct(message)) 73 | if err == common.ErrSendingElasticSearchRequest { 74 | toBacklog(origMessage) 75 | } else { 76 | increaseCounter(indexName) 77 | } 78 | toRedis(indexName, logType, message) 79 | } 80 | } 81 | 82 | // Stores [apiKey]indexName 83 | var userIndexNameCache = make(map[string]string) 84 | 85 | // Get users index name by apiKey 86 | func extractIndexAndType(message string) (string, string, error) { 87 | key, logType, err := common.ExtractApiKey(message) 88 | if err != nil { 89 | return "", "", err 90 | } 91 | 92 | if indexName, ok := userIndexNameCache[key]; ok { 93 | return indexName, logType, nil 94 | } 95 | 96 | user, err := common.FindUserByApiKey(key) 97 | if err != nil { 98 | return "", "", err 99 | } 100 | if user == nil { 101 | return "", "", errUserNotFound 102 | } 103 | userIndexNameCache[user.GetIndexName()] = user.GetIndexName() 104 | return user.GetIndexName(), logType, nil 105 | } 106 | 107 | // Prepares message to be inserted into ES. 108 | // Builds struct based on message. 109 | func buildMessageStruct(message string) interface{} { 110 | var data map[string]interface{} 111 | err := json.Unmarshal([]byte(message), &data) 112 | 113 | if err == nil { 114 | // Save parsed json 115 | data["datetime"] = time.Now().UTC() 116 | return data 117 | } else { 118 | // Could not parse json, save entire message. 119 | return common.LogRecord{ 120 | Message: message, 121 | Datetime: time.Now().UTC(), 122 | } 123 | } 124 | } 125 | 126 | // Sends data to elastic index 127 | func toElastic(indexName string, logType string, record interface{}) error { 128 | j, err := json.Marshal(record) 129 | if err != nil { 130 | log.Println("Error encoding message to JSON") 131 | } else { 132 | _, err := common.SendToElastic(fmt.Sprintf("%s/%s", indexName, logType), "POST", j) 133 | if err != nil { 134 | return err 135 | } 136 | } 137 | return nil 138 | } 139 | 140 | func toRedis(indexName string, logType string, msg string) { 141 | var message web_socket.RedisMessage 142 | log.Println("Sending message to redis") 143 | message = web_socket.RedisMessage{ApiKey: indexName, Data: map[string]string{ 144 | "type": "log_message", 145 | "log_type": logType, 146 | "message": msg, 147 | }} 148 | message.Send(redisConn) 149 | } 150 | -------------------------------------------------------------------------------- /common/user.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "net/url" 6 | "sort" 7 | 8 | "github.com/Unknwon/com" 9 | "github.com/belogik/goes" 10 | "github.com/mitchellh/mapstructure" 11 | "github.com/xlab/handysort" 12 | "golang.org/x/crypto/bcrypt" 13 | ) 14 | 15 | type User struct { 16 | Id string `json:"id"` 17 | Email string `json:"email"` 18 | FirstName string `json:"firstName"` 19 | LastName string `json:"lastName"` 20 | Password string `json:"password"` 21 | ApiKey string `json:"apiKey"` 22 | Projects []*Project `json:"projects"` 23 | } 24 | 25 | // Returns index name to use in Elastic 26 | func (u *User) GetIndexName() string { 27 | return u.ApiKey 28 | } 29 | 30 | // Returns elastic search types 31 | func (u *User) GetLogTypes() []string { 32 | t, err := GetTypes(u.GetIndexName()) 33 | if err != nil { 34 | sort.Sort(handysort.Strings(t)) 35 | } 36 | return t 37 | } 38 | 39 | func HashPassword(password string) (string, error) { 40 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 10) 41 | if err != nil { 42 | return "", errors.New("Error crypt password") 43 | } 44 | return string(hashedPassword), nil 45 | } 46 | 47 | func CompareHashAndPassword(hash, password string) error { 48 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) 49 | } 50 | 51 | //////////////////////// 52 | // Projects 53 | //////////////////////// 54 | 55 | // Project represent group of log types. 56 | // Each log type can be in various groups at the same time. 57 | type Project struct { 58 | Id string `json:"id"` 59 | Name string `json:"name"` 60 | Description string `json:"description"` 61 | Types []string `json:"types"` 62 | } 63 | 64 | func (u *User) AddProject(p *Project) *User { 65 | if p.Id == "" { 66 | key := com.RandomCreateBytes(10) 67 | // TODO: Check key already exists 68 | p.Id = string(key) 69 | u.Projects = append(u.Projects, p) 70 | } else { 71 | u.UpdateProject(p) 72 | } 73 | return u 74 | } 75 | 76 | func (u *User) UpdateProject(p *Project) { 77 | for key, g := range u.Projects { 78 | if p.Id == g.Id { 79 | u.Projects[key] = p 80 | } 81 | } 82 | } 83 | 84 | func (u *User) DeleteProject(id string) { 85 | for i, val := range u.Projects { 86 | if val.Id == id { 87 | copy(u.Projects[i:], u.Projects[i+1:]) 88 | u.Projects[len(u.Projects)-1] = nil 89 | u.Projects = u.Projects[:len(u.Projects)-1] 90 | return 91 | } 92 | } 93 | } 94 | 95 | func (u *User) GetProject(id string) (*Project, error) { 96 | for _, val := range u.Projects { 97 | if val.Id == id { 98 | return val, nil 99 | } 100 | } 101 | return nil, errors.New("Project not found") 102 | } 103 | 104 | //////////////////////// 105 | // Finders 106 | //////////////////////// 107 | 108 | func FindUserByEmail(email string) (*User, error) { 109 | return FindUserBy("email", email) 110 | } 111 | 112 | func FindUserByApiKey(apiKey string) (*User, error) { 113 | return FindUserBy("apiKey", apiKey) 114 | } 115 | 116 | func (this *User) Save() { 117 | doc := goes.Document{ 118 | Index: "users", 119 | Type: "user", 120 | Id: this.Id, 121 | Fields: this, 122 | } 123 | extraArgs := make(url.Values, 0) 124 | GetConnection().Index(doc, extraArgs) 125 | } 126 | 127 | // Find user by any param. 128 | // Returns err if ES can't perform/accept query, 129 | // and nil if user not found. 130 | func FindUserBy(key string, value string) (*User, error) { 131 | var query = map[string]interface{}{ 132 | "query": map[string]interface{}{ 133 | "bool": map[string]interface{}{ 134 | "must": map[string]interface{}{ 135 | "term": map[string]interface{}{ 136 | key: map[string]interface{}{ 137 | "value": value, 138 | }, 139 | }, 140 | }, 141 | }, 142 | }, 143 | } 144 | 145 | searchResults, err := GetConnection().Search(query, []string{"users"}, []string{"user"}, url.Values{}) 146 | 147 | if err != nil { 148 | return nil, ErrSendingElasticSearchRequest 149 | } 150 | if searchResults.Hits.Total == 0 { 151 | return nil, nil 152 | } 153 | 154 | user := &User{} 155 | mapstructure.Decode(searchResults.Hits.Hits[0].Source, user) 156 | user.Id = searchResults.Hits.Hits[0].Id 157 | return user, nil 158 | } 159 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | No longer maintained, sorry. 2 | Completely rewritten v2 is going to be released soon. Please follow http://github.com/logvoyage 3 | 4 | 5 | # LogVoyage - fast and simple open-source logging service 6 | 7 | LogVoyage allows you to store and explore your logs in real-time with friendly web ui. 8 | 9 | 10 | ![Dashboard](https://raw.githubusercontent.com/firstrow/logvoyage/master/screenshots/dashboard.png) 11 | ![Live logs](https://raw.githubusercontent.com/firstrow/logvoyage/master/screenshots/live-logs.png) 12 | 13 | * [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/firstrow/logvoyage?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 14 | * Click here to lend your support to: LogVoyage and make a donation at pledgie.com ! 15 | * ![TravisCI](https://api.travis-ci.org/firstrow/logvoyage.svg?branch=master) 16 | 17 | 18 | 19 | 20 | **Table of Contents** *generated with [DocToc](https://github.com/thlorenz/doctoc)* 21 | 22 | - [Installation](#installation) 23 | - [Pre-Requirements.](#pre-requirements) 24 | - [Installing](#installing) 25 | - [Usage](#usage) 26 | - [Sending data to storage](#sending-data-to-storage) 27 | - [Telnet](#telnet) 28 | - [Curl](#curl) 29 | - [Search data](#search-data) 30 | - [Third-party clients](#third-party-clients) 31 | - [Submitting a Pull Request](#submitting-a-pull-request) 32 | - [Front-end development](#front-end-development) 33 | - [Bower](#bower) 34 | - [Building](#building) 35 | - [Auto rebuild](#auto-rebuild) 36 | - [WebSocket messages](#websocket-messages) 37 | - [Roadmap v0.1](#roadmap-v01) 38 | - [License](#license) 39 | 40 | 41 | 42 | ## Installation 43 | 44 | ### Pre-Requirements. 45 | - [ElasticSearch](https://gist.github.com/firstrow/f57bc873cfd6839b6ea8) 46 | - [Redis](http://redis.io/topics/quickstart) 47 | 48 | ### Installing 49 | Installing LogVoyage is as easy as installing any other go package: 50 | ``` bash 51 | go get github.com/firstrow/logvoyage 52 | logvoyage create_users_index 53 | ``` 54 | 55 | ## Usage 56 | Once you installed LogVoyage you need to start backend and web servers. 57 | ``` bash 58 | logvoyage start-all 59 | ``` 60 | Or you can start/stop servers separately 61 | ``` bash 62 | logvoyage backend 63 | logvoyage web 64 | ``` 65 | Once server started you can access it at [http://localhost:3000](http://localhost:3000). 66 | Execute `logvoyage help` for more info about available commands. 67 | 68 | ### Sending data to storage 69 | By default LogVoyage opens two backend ports accesible to the outsise world. 70 | 71 | 1. 27077 - TCP port 72 | 2. 27078 - HTTP port 73 | 74 | #### Telnet 75 | 76 | ``` 77 | NOTE: Keep in mind to change `API_KEY` and `LOG_TYPE`. 78 | You can find your api key at http://localhost:3000/profile page. 79 | ``` 80 | 81 | ``` bash 82 | telnet 127.0.0.1 27077 83 | API_KEY@LOG_TYPE {"message": "login", "user_id": 1} 84 | API_KEY@LOG_TYPE simple text message 85 | ``` 86 | 87 | Now you can see your messages at http://localhost:3000 and try some queries 88 | 89 | #### Curl 90 | 91 | Or we can use curl POST request to send messages. Each message should be separated by new line. 92 | 93 | ``` bash 94 | echo 'This is simple text message' | curl -d @- http://localhost:27078/bulk\?apiKey\=API_KEY\&type\=LOG_TYPE 95 | echo '{"message": "JSON format also supported", "action":"test"}' | curl -d @- http://localhost:27078/bulk\?apiKey\=API_KEY\&type\=LOG_TYPE 96 | ``` 97 | 98 | ## Search data 99 | Refer to [Query String Syntax](http://www.elastic.co/guide/en/elasticsearch/reference/1.x/query-dsl-query-string-query.html#query-string-syntax) 100 | for more info about text queries available. 101 | 102 | Examples: 103 | 104 | ``` bash 105 | user_id:1 106 | simple* 107 | amount:>10 and status:completed 108 | ``` 109 | 110 | ## Third-party clients 111 | If you know any programming language, you can join our project and implement 112 | LogVoyage client. 113 | 114 | ## Submitting a Pull Request 115 | 116 | 1. Propose a change by opening an issue. 117 | 2. Fork the project. 118 | 3. Create a topic branch. 119 | 4. Implement your feature or bug fix. 120 | 5. Commit and push your changes. 121 | 6. Submit a pull request. 122 | 123 | ## Front-end development 124 | ### Bower 125 | To manage 3rd-party libraries simply add it to static/bower.json and run 126 | ``` 127 | bower install 128 | ``` 129 | 130 | ### Building 131 | We are using grunt to build project js and css files. 132 | Execute next commands to setup environment: 133 | ``` 134 | npm install 135 | grunt 136 | ``` 137 | After grunt is done, you can find result files in static/build directory. 138 | 139 | ### Auto rebuild 140 | To automatically rebuild js, css, coffee, less files simply run in console 141 | ``` 142 | grunt watch 143 | ``` 144 | 145 | ### WebSocket messages 146 | ``` coffee 147 | // Sample coffescript code 148 | PubSub.subscribe "log_message", (type, data) -> 149 | console.log data.message 150 | ``` 151 | 152 | Sample messages: 153 | 154 | ``` json 155 | { 156 | "type": "log_message", 157 | "log_type": "nginx_access", 158 | "message": "test received log message goes here..." 159 | } 160 | ``` 161 | 162 | ``` json 163 | { 164 | "type": "logs_per_second", 165 | "count": 5 166 | } 167 | ``` 168 | 169 | ## Roadmap v0.1 170 | - Daemons 171 | - Zero-downtime deployment 172 | - Finish web ui 173 | - Docker image 174 | - Docs 175 | 176 | ## License 177 | LogVoyage is available without any costs under an MIT license. See LICENSE file 178 | for details. 179 | -------------------------------------------------------------------------------- /web/routers/home/home.go: -------------------------------------------------------------------------------- 1 | package home 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/url" 7 | "strconv" 8 | "time" 9 | 10 | "github.com/belogik/goes" 11 | "github.com/go-martini/martini" 12 | 13 | "github.com/firstrow/logvoyage/common" 14 | "github.com/firstrow/logvoyage/web/context" 15 | "github.com/firstrow/logvoyage/web/widgets" 16 | ) 17 | 18 | const ( 19 | timeLayout = "2006/01/02 15:04" // Users input time format 20 | perPage = 100 21 | ) 22 | 23 | type DateTimeRange struct { 24 | Start string 25 | Stop string 26 | } 27 | 28 | func (this *DateTimeRange) IsValid() bool { 29 | return this.Start != "" || this.Stop != "" 30 | } 31 | 32 | // Represents search request to perform in ES 33 | type SearchRequest struct { 34 | Text string // test to search 35 | Indexes []string // ES indexeses to perform search 36 | Types []string // search types 37 | Size int // home much objects ES must return 38 | From int // how much objects should ES skip from first 39 | TimeRange DateTimeRange 40 | } 41 | 42 | func buildSearchRequest(text string, indexes []string, types []string, size int, from int, datetime DateTimeRange) SearchRequest { 43 | return SearchRequest{ 44 | Text: text, 45 | Indexes: indexes, 46 | From: from, 47 | Types: types, 48 | Size: perPage, 49 | TimeRange: datetime, 50 | } 51 | } 52 | 53 | // Detects time range from request and returns 54 | // elastic compatible format string 55 | func buildTimeRange(req *http.Request) DateTimeRange { 56 | var timeRange DateTimeRange 57 | 58 | switch req.URL.Query().Get("time") { 59 | case "15m": 60 | timeRange.Start = "now-15m" 61 | case "30m": 62 | timeRange.Start = "now-30m" 63 | case "60m": 64 | timeRange.Start = "now-60m" 65 | case "12h": 66 | timeRange.Start = "now-12h" 67 | case "24h": 68 | timeRange.Start = "now-24h" 69 | case "week": 70 | timeRange.Start = "now-7d" 71 | case "custom": 72 | timeStart, err := time.Parse(timeLayout, req.URL.Query().Get("time_start")) 73 | if err == nil { 74 | timeRange.Start = timeStart.Format(time.RFC3339) 75 | } 76 | timeStop, err := time.Parse(timeLayout, req.URL.Query().Get("time_stop")) 77 | if err == nil { 78 | timeRange.Stop = timeStop.Format(time.RFC3339) 79 | } 80 | } 81 | 82 | return timeRange 83 | } 84 | 85 | // Search logs in elastic. 86 | func search(searchRequest SearchRequest) (goes.Response, error) { 87 | conn := common.GetConnection() 88 | 89 | var query = map[string]interface{}{ 90 | "from": searchRequest.From, 91 | "size": searchRequest.Size, 92 | "sort": map[string]string{ 93 | "datetime": "desc", 94 | }, 95 | } 96 | 97 | if len(searchRequest.Text) > 0 { 98 | strconv.Quote(searchRequest.Text) 99 | query["query"] = map[string]interface{}{ 100 | "query_string": map[string]string{ 101 | "default_field": "message", 102 | "query": searchRequest.Text, 103 | }, 104 | } 105 | } 106 | 107 | // Build time range query 108 | if searchRequest.TimeRange.IsValid() { 109 | datetime := make(map[string]string) 110 | if searchRequest.TimeRange.Start != "" { 111 | datetime["gte"] = searchRequest.TimeRange.Start 112 | } 113 | if searchRequest.TimeRange.Stop != "" { 114 | datetime["lte"] = searchRequest.TimeRange.Stop 115 | } 116 | query["filter"] = map[string]interface{}{ 117 | "range": map[string]interface{}{ 118 | "datetime": datetime, 119 | }, 120 | } 121 | } 122 | 123 | extraArgs := make(url.Values, 0) 124 | searchResults, err := conn.Search(query, searchRequest.Indexes, searchRequest.Types, extraArgs) 125 | 126 | if err != nil { 127 | return goes.Response{}, errors.New("No records found.") 128 | } else { 129 | return *searchResults, nil 130 | } 131 | } 132 | 133 | // This function handles two routes "/" and "/project/:id" 134 | func ProjectSearch(ctx *context.Context, params martini.Params) { 135 | var types []string 136 | var project *common.Project 137 | 138 | query_text := ctx.Request.URL.Query().Get("q") 139 | selected_types := ctx.Request.URL.Query()["types"] 140 | 141 | // Project scope 142 | if _, err := params["id"]; err { 143 | project, err := ctx.User.GetProject(params["id"]) 144 | if err != nil { 145 | ctx.HTML("shared/error", context.ViewData{ 146 | "message": "Project not found", 147 | }) 148 | return 149 | } 150 | if len(project.Types) == 0 { 151 | ctx.HTML("home/empty_project", context.ViewData{ 152 | "project": project, 153 | }) 154 | return 155 | } 156 | if len(selected_types) > 0 { 157 | types = selected_types 158 | } else { 159 | types = project.Types 160 | } 161 | } 162 | 163 | // Pagination 164 | pagination := widgets.NewPagination(ctx.Request) 165 | pagination.SetPerPage(perPage) 166 | 167 | // Load records 168 | searchRequest := buildSearchRequest( 169 | query_text, 170 | []string{ctx.User.GetIndexName()}, 171 | types, 172 | pagination.GetPerPage(), 173 | pagination.DetectFrom(), 174 | buildTimeRange(ctx.Request), 175 | ) 176 | // Search data in elastic 177 | data, _ := search(searchRequest) 178 | 179 | pagination.SetTotalRecords(data.Hits.Total) 180 | 181 | var viewName string 182 | viewData := context.ViewData{ 183 | "project": project, 184 | "logs": data.Hits.Hits, 185 | "total": data.Hits.Total, 186 | "took": data.Took, 187 | "types": types, 188 | "time": ctx.Request.URL.Query().Get("time"), 189 | "time_start": ctx.Request.URL.Query().Get("time_start"), 190 | "time_stop": ctx.Request.URL.Query().Get("time_stop"), 191 | "query_text": query_text, 192 | "pagination": pagination, 193 | } 194 | 195 | if ctx.Request.Header.Get("X-Requested-With") == "XMLHttpRequest" { 196 | viewName = "home/table" 197 | } else { 198 | viewName = "home/index" 199 | } 200 | 201 | ctx.HTML(viewName, viewData) 202 | } 203 | -------------------------------------------------------------------------------- /web/templates/layouts/main.tmpl: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | LogVoyage 6 | {{template "shared/head" .}} 7 | 8 | 9 | 10 | {{if .context.IsGuest}} 11 | 12 | {{else}} 13 | 88 | {{end}} 89 |
    90 | {{range .context.Session.Flashes "success"}} 91 | 95 | {{end}} 96 | 97 | {{yield}} 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /static/build/app.min.js: -------------------------------------------------------------------------------- 1 | (function() { 2 | var WSocket, 3 | __bind = function(fn, me){ return function(){ return fn.apply(me, arguments); }; }; 4 | 5 | $(function() { 6 | var p; 7 | p = window.location.pathname; 8 | if (p !== "/" && p.substring(0, 9) !== "/project/") { 9 | return; 10 | } 11 | $("#searchForm").attr('action', p); 12 | $("#searchForm").submit(function(e) { 13 | e.preventDefault(); 14 | return $.ajax({ 15 | type: "GET", 16 | url: $(this).attr("action"), 17 | data: $(this).serialize(), 18 | success: function(data) { 19 | return $("#logTableContainer").html(data); 20 | }, 21 | complete: function() { 22 | return setTimeout((function() { 23 | Ladda.stopAll(); 24 | return $("html, body").animate({ 25 | scrollTop: 0 26 | }, "fast"); 27 | }), 300); 28 | } 29 | }); 30 | }); 31 | return $("body").on("click", "#pagination a", function(e) { 32 | e.preventDefault(); 33 | return $("#logTableContainer").load($(this).attr("href"), function() { 34 | return $("html, body").animate({ 35 | scrollTop: 0 36 | }, "fast"); 37 | }); 38 | }); 39 | }); 40 | 41 | $(function() { 42 | return $(".confirm").click(function() { 43 | return confirm("Are you sure?"); 44 | }); 45 | }); 46 | 47 | window.LiveLogs = (function() { 48 | LiveLogs.prototype.opts = { 49 | container: "#liveLogsContainer", 50 | filterContainer: "#liveLogsSearch", 51 | stackLimit: 2000 52 | }; 53 | 54 | LiveLogs.prototype.container = null; 55 | 56 | LiveLogs.prototype.autoScroll = true; 57 | 58 | LiveLogs.prototype.messages = []; 59 | 60 | LiveLogs.prototype.filter = null; 61 | 62 | function LiveLogs() { 63 | this.escapeHtml = __bind(this.escapeHtml, this); 64 | this._filterMessage = __bind(this._filterMessage, this); 65 | this._filterAllMessages = __bind(this._filterAllMessages, this); 66 | this._filter = __bind(this._filter, this); 67 | this._detectAutoScroll = __bind(this._detectAutoScroll, this); 68 | this.setTheme = __bind(this.setTheme, this); 69 | this.container = $(this.opts.container); 70 | this.filterContainer = $(this.opts.filterContainer); 71 | this.setTheme($.cookie("livelogstheme")); 72 | } 73 | 74 | LiveLogs.prototype.init = function() { 75 | this.container.height($(window).height() - 36); 76 | $(window).resize((function(_this) { 77 | return function() { 78 | return _this.container.height($(window).height() - 36); 79 | }; 80 | })(this)); 81 | this.container.scroll(this._detectAutoScroll); 82 | PubSub.subscribe("log_message", (function(_this) { 83 | return function(type, data) { 84 | _this.messages.push(data); 85 | return _this.appendMessage(data.log_type, data.message); 86 | }; 87 | })(this)); 88 | return this.filterContainer.find("input.query").keyup(this._filter); 89 | }; 90 | 91 | LiveLogs.prototype.appendMessage = function(type, message) { 92 | message = this.escapeHtml(message); 93 | if (this.filter) { 94 | message = this._filterMessage(message); 95 | } 96 | if (message) { 97 | this.container.append("

    " + type + "" + message + "

    "); 98 | } 99 | if (this.autoScroll) { 100 | return this.scrollToBottom(); 101 | } 102 | }; 103 | 104 | LiveLogs.prototype.clear = function() { 105 | this.container.html(''); 106 | return this.messages = []; 107 | }; 108 | 109 | LiveLogs.prototype.scrollToBottom = function() { 110 | return this.container.scrollTop(this.container.prop('scrollHeight')); 111 | }; 112 | 113 | LiveLogs.prototype.switchTheme = function() { 114 | if (this.container.hasClass("dark")) { 115 | return this.setTheme("light"); 116 | } else { 117 | return this.setTheme("dark"); 118 | } 119 | }; 120 | 121 | LiveLogs.prototype.setTheme = function(t) { 122 | if (t === "dark" || t === "light") { 123 | this.container.removeClass().addClass(t); 124 | return $.cookie("livelogstheme", t); 125 | } 126 | }; 127 | 128 | LiveLogs.prototype._detectAutoScroll = function(e) { 129 | return this.autoScroll = (this.container.height() + this.container.scrollTop()) === this.container.prop('scrollHeight'); 130 | }; 131 | 132 | LiveLogs.prototype._filter = function(e) { 133 | var wait; 134 | wait = (function(_this) { 135 | return function() { 136 | _this.filter = $(e.target).val(); 137 | return _this._filterAllMessages(); 138 | }; 139 | })(this); 140 | return setTimeout(wait, 500); 141 | }; 142 | 143 | LiveLogs.prototype._filterAllMessages = function() { 144 | var data, _i, _len, _ref, _results; 145 | this.container.html(''); 146 | _ref = this.messages; 147 | _results = []; 148 | for (_i = 0, _len = _ref.length; _i < _len; _i++) { 149 | data = _ref[_i]; 150 | _results.push(this.appendMessage(data.type, data.message)); 151 | } 152 | return _results; 153 | }; 154 | 155 | LiveLogs.prototype._filterMessage = function(text) { 156 | var re; 157 | re = new RegExp("(" + this.filter + ")", 'ig'); 158 | if (text.match(re)) { 159 | return text.replace(re, '$1'); 160 | } 161 | return false; 162 | }; 163 | 164 | LiveLogs.prototype.escapeHtml = function(unsafe) { 165 | return unsafe.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); 166 | }; 167 | 168 | return LiveLogs; 169 | 170 | })(); 171 | 172 | $(function() { 173 | return $("body").on("click", "a.view", function(e) { 174 | var el; 175 | e.preventDefault(); 176 | el = this; 177 | $("#recordViewLabel").html($(this).data("type")); 178 | $("#recordViewDateTime").html($(this).data("datetime")); 179 | $("#viewRecordModal .btn-danger").unbind("click").click(function() { 180 | if (confirm("Are you sure want to delete this event?")) { 181 | return $.ajax({ 182 | url: $(el).attr("href"), 183 | type: "DELETE", 184 | success: function() { 185 | $(".modal .close").click(); 186 | return $(el).parents("tr").css("opacity", "0.2"); 187 | }, 188 | error: function() { 189 | return alert("Error: Record not deleted."); 190 | } 191 | }); 192 | } else { 193 | return e.preventDefault(); 194 | } 195 | }); 196 | return $.getJSON($(this).attr("href"), function(data) { 197 | $(".modal-body").JSONView(data); 198 | return $("#viewRecordModal").modal(); 199 | }).fail(function() { 200 | $(".modal-body").html("Error: Record not found or wrong JSON structure."); 201 | return $("#viewRecordModal").modal(); 202 | }); 203 | }); 204 | }); 205 | 206 | WSocket = (function() { 207 | function WSocket(apiKey) { 208 | this.apiKey = apiKey; 209 | this.ws = new WebSocket("ws://" + window.location.hostname + ":12345/ws"); 210 | this.ws.onopen = ((function(_this) { 211 | return function() { 212 | return _this.register(); 213 | }; 214 | })(this)); 215 | this.ws.onmessage = ((function(_this) { 216 | return function() { 217 | return _this.onMessage(event); 218 | }; 219 | })(this)); 220 | } 221 | 222 | WSocket.prototype.register = function() { 223 | this.ws.send(this.apiKey); 224 | return console.log("registered user " + this.apiKey); 225 | }; 226 | 227 | WSocket.prototype.onMessage = function(event) { 228 | var data; 229 | data = JSON.parse(event.data); 230 | return PubSub.publish(data.type, data); 231 | }; 232 | 233 | return WSocket; 234 | 235 | })(); 236 | 237 | $(function() { 238 | return new WSocket(options.apiKey); 239 | }); 240 | 241 | }).call(this); 242 | -------------------------------------------------------------------------------- /static/fonts/glyphicons-halflings-regular.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | --------------------------------------------------------------------------------