diff --git a/main.go b/main.go index 9fe971f..a53c928 100644 --- a/main.go +++ b/main.go @@ -108,6 +108,11 @@ func main() { summarySrvc := &services.SummaryService{Config: config, Db: db, HeartbeatService: heartbeatSrvc, AliasService: aliasSrvc} aggregationSrvc := &services.AggregationService{Config: config, Db: db, UserService: userSrvc, SummaryService: summarySrvc, HeartbeatService: heartbeatSrvc} + services := []services.Initializable{aliasSrvc, heartbeatSrvc, summarySrvc, userSrvc, aggregationSrvc} + for _, s := range services { + s.Init() + } + // Aggregate heartbeats to summaries and persist them go aggregationSrvc.Schedule() @@ -117,6 +122,7 @@ func main() { // Middlewares authenticateMiddleware := &middlewares.AuthenticateMiddleware{UserSrvc: userSrvc} + basicAuthMiddleware := &middlewares.RequireBasicAuthMiddleware{} corsMiddleware := cors.New(cors.Options{ AllowedOrigins: []string{"*"}, AllowedHeaders: []string{"*"}, @@ -125,23 +131,36 @@ func main() { // Setup Routing router := mux.NewRouter() + mainRouter := mux.NewRouter().PathPrefix("").Subrouter() apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter() + // Main Routes + index := mainRouter.Path("/").Subrouter() + index.Methods(http.MethodGet).Path("/").HandlerFunc(summaryHandler.Index) + // API Routes heartbeats := apiRouter.Path("/heartbeat").Subrouter() heartbeats.Methods(http.MethodPost).HandlerFunc(heartbeatHandler.Post) + // Static Routes + router.PathPrefix("/assets").Handler(negroni.Classic().With(negroni.Wrap(http.FileServer(http.Dir("./static"))))) + aggreagations := apiRouter.Path("/summary").Subrouter() aggreagations.Methods(http.MethodGet).HandlerFunc(summaryHandler.Get) // Sub-Routes Setup + router.PathPrefix("/").Handler(negroni.Classic(). + With(negroni.HandlerFunc(basicAuthMiddleware.Handle), + negroni.HandlerFunc(authenticateMiddleware.Handle), + negroni.Wrap(mainRouter), + )) + router.PathPrefix("/api").Handler(negroni.Classic(). With(corsMiddleware). With( negroni.HandlerFunc(authenticateMiddleware.Handle), negroni.Wrap(apiRouter), )) - router.PathPrefix("/").Handler(negroni.Classic().With(negroni.Wrap(http.FileServer(http.Dir("./static"))))) // Listen HTTP portString := config.Addr + ":" + strconv.Itoa(config.Port) diff --git a/middlewares/basicauth.go b/middlewares/basicauth.go new file mode 100644 index 0000000..6ab5b72 --- /dev/null +++ b/middlewares/basicauth.go @@ -0,0 +1,14 @@ +package middlewares + +import ( + "net/http" +) + +type RequireBasicAuthMiddleware struct{} + +func (m *RequireBasicAuthMiddleware) Init() {} + +func (m *RequireBasicAuthMiddleware) Handle(w http.ResponseWriter, r *http.Request, next http.HandlerFunc) { + w.Header().Set("WWW-Authenticate", `Basic realm="Restricted"`) + next(w, r) +} diff --git a/routes/summary.go b/routes/summary.go index 0f61ced..80ff93a 100644 --- a/routes/summary.go +++ b/routes/summary.go @@ -1,16 +1,15 @@ package routes import ( - "crypto/md5" + "errors" + "html/template" "net/http" - "strconv" + "path" "time" "github.com/n1try/wakapi/models" "github.com/n1try/wakapi/services" "github.com/n1try/wakapi/utils" - cache "github.com/patrickmn/go-cache" - uuid "github.com/satori/go.uuid" ) const ( @@ -23,15 +22,23 @@ const ( ) type SummaryHandler struct { - SummarySrvc *services.SummaryService - Cache *cache.Cache - Initialized bool + SummarySrvc *services.SummaryService + Initialized bool + indexTemplate *template.Template } func (m *SummaryHandler) Init() { - if m.Cache == nil { - m.Cache = cache.New(24*time.Hour, 24*time.Hour) + indexTplPath := "views/index.tpl.html" + indexTpl, err := template.New(path.Base(indexTplPath)).Funcs(template.FuncMap{ + "json": utils.Json, + }).ParseFiles(indexTplPath) + + if err != nil { + panic(err) } + + m.indexTemplate = indexTpl + m.Initialized = true } @@ -45,6 +52,37 @@ func (h *SummaryHandler) Get(w http.ResponseWriter, r *http.Request) { h.Init() } + summary, err, status := loadUserSummary(r, h.SummarySrvc) + if err != nil { + w.WriteHeader(status) + w.Write([]byte(err.Error())) + return + } + + utils.RespondJSON(w, http.StatusOK, summary) +} + +func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + + if !h.Initialized { + h.Init() + } + + summary, err, status := loadUserSummary(r, h.SummarySrvc) + if err != nil { + w.WriteHeader(status) + w.Write([]byte(err.Error())) + return + } + + h.indexTemplate.Execute(w, summary) +} + +func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) { user := r.Context().Value(models.UserKey).(*models.User) params := r.URL.Query() interval := params.Get("interval") @@ -64,9 +102,7 @@ func (h *SummaryHandler) Get(w http.ResponseWriter, r *http.Request) { case IntervalAny: from = time.Time{} default: - w.WriteHeader(http.StatusBadRequest) - w.Write([]byte("missing 'from' parameter")) - return + return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest } } @@ -78,34 +114,10 @@ func (h *SummaryHandler) Get(w http.ResponseWriter, r *http.Request) { } var summary *models.Summary - var cacheKey string - if !recompute { - cacheKey = getHash([]time.Time{from, to}, user) - } else { - cacheKey = uuid.NewV4().String() - } - if cachedSummary, ok := h.Cache.Get(cacheKey); !ok { - // Cache Miss - summary, err = h.SummarySrvc.Construct(from, to, user, recompute) // 'to' is always constant - if err != nil { - w.WriteHeader(http.StatusInternalServerError) - return - } - if !live && !recompute { - h.Cache.Set(cacheKey, summary, cache.DefaultExpiration) - } - } else { - summary = cachedSummary.(*models.Summary) + summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant + if err != nil { + return nil, err, http.StatusInternalServerError } - utils.RespondJSON(w, http.StatusOK, summary) -} - -func getHash(times []time.Time, user *models.User) string { - digest := md5.New() - for _, t := range times { - digest.Write([]byte(strconv.Itoa(int(t.Unix())))) - } - digest.Write([]byte(user.ID)) - return string(digest.Sum(nil)) + return summary, nil, http.StatusOK } diff --git a/services/aggregation.go b/services/aggregation.go index d56973a..b8bbe71 100644 --- a/services/aggregation.go +++ b/services/aggregation.go @@ -28,6 +28,8 @@ type AggregationJob struct { To time.Time } +func (srv *AggregationService) Init() {} + // Schedule a job to (re-)generate summaries every day shortly after midnight // TODO: Make configurable func (srv *AggregationService) Schedule() { diff --git a/services/alias.go b/services/alias.go index 76608be..e5fd6a8 100644 --- a/services/alias.go +++ b/services/alias.go @@ -15,6 +15,8 @@ type AliasService struct { var userAliases sync.Map +func (srv *AliasService) Init() {} + func (srv *AliasService) LoadUserAliases(userId string) error { var aliases []*models.Alias if err := srv.Db. diff --git a/services/common.go b/services/common.go new file mode 100644 index 0000000..436ee6f --- /dev/null +++ b/services/common.go @@ -0,0 +1,5 @@ +package services + +type Initializable interface { + Init() +} diff --git a/services/heartbeat.go b/services/heartbeat.go index 6ef38d8..4333b97 100644 --- a/services/heartbeat.go +++ b/services/heartbeat.go @@ -15,6 +15,8 @@ type HeartbeatService struct { Db *gorm.DB } +func (srv *HeartbeatService) Init() {} + func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error { var batch []interface{} for _, h := range heartbeats { diff --git a/services/summary.go b/services/summary.go index 79f37a4..efdcaa3 100644 --- a/services/summary.go +++ b/services/summary.go @@ -1,9 +1,12 @@ package services import ( + "crypto/md5" "errors" + "github.com/patrickmn/go-cache" "math" "sort" + "strconv" "time" "github.com/jinzhu/gorm" @@ -12,6 +15,7 @@ import ( type SummaryService struct { Config *models.Config + Cache *cache.Cache Db *gorm.DB HeartbeatService *HeartbeatService AliasService *AliasService @@ -22,11 +26,21 @@ type Interval struct { End time.Time } +func (srv *SummaryService) Init() { + srv.Cache = cache.New(24*time.Hour, 24*time.Hour) +} + func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) { var existingSummaries []*models.Summary + var cacheKey string + if recompute { existingSummaries = make([]*models.Summary, 0) } else { + cacheKey = getHash([]time.Time{from, to}, user) + if result, ok := srv.Cache.Get(cacheKey); ok { + return result.(*models.Summary), nil + } summaries, err := srv.GetByUserWithin(user, from, to) if err != nil { return nil, err @@ -94,6 +108,10 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco return nil, err } + if cacheKey != "" { + srv.Cache.SetDefault(cacheKey, summary) + } + return summary, nil } @@ -283,3 +301,12 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem return itemList } + +func getHash(times []time.Time, user *models.User) string { + digest := md5.New() + for _, t := range times { + digest.Write([]byte(strconv.Itoa(int(t.Unix())))) + } + digest.Write([]byte(user.ID)) + return string(digest.Sum(nil)) +} diff --git a/services/user.go b/services/user.go index d4ff1ab..df02f18 100644 --- a/services/user.go +++ b/services/user.go @@ -10,6 +10,8 @@ type UserService struct { Db *gorm.DB } +func (srv *UserService) Init() {} + func (srv *UserService) GetUserById(userId string) (*models.User, error) { u := &models.User{} if err := srv.Db.Where(&models.User{ID: userId}).First(u).Error; err != nil { diff --git a/static/assets/app.css b/static/assets/app.css new file mode 100644 index 0000000..30f4b24 --- /dev/null +++ b/static/assets/app.css @@ -0,0 +1,44 @@ +body { + font-family: 'Helvetica Neue', 'Helvetica', 'Arial', sans-serif; + color: #666; +} + +h1 { + margin-bottom: 10px; +} + +h3 { + margin: 10px 0 20px 0; +} + +.grid-container { + width: 75%; + display: grid; + grid-template-areas: 'header header header' 'sec1 sec1 sec2' 'sec1 sec1 sec3' 'sec1 sec1 sec4' 'footer footer footer'; + grid-gap: 10px; + visibility: hidden; +} + +.projects-container { + grid-area: sec1 +} + +.os-container { + grid-area: sec2 +} + +.editor-container { + grid-area: sec3 +} + +.language-container { + grid-area: sec4 +} + +.header-container { + grid-area: header +} + +.input { + width: 300px; +} \ No newline at end of file diff --git a/static/assets/app.js b/static/assets/app.js new file mode 100644 index 0000000..058ad99 --- /dev/null +++ b/static/assets/app.js @@ -0,0 +1,167 @@ +const SHOW_TOP_N = 10 + +const projectsCanvas = document.getElementById("chart-projects") +const osCanvas = document.getElementById("chart-os") +const editorsCanvas = document.getElementById("chart-editor") +const languagesCanvas = document.getElementById("chart-language") + +let charts = [] + +String.prototype.toHHMMSS = function () { + var sec_num = parseInt(this, 10) + var hours = Math.floor(sec_num / 3600) + var minutes = Math.floor((sec_num - (hours * 3600)) / 60) + var seconds = sec_num - (hours * 3600) - (minutes * 60) + + if (hours < 10) { + hours = '0' + hours + } + if (minutes < 10) { + minutes = '0' + minutes + } + if (seconds < 10) { + seconds = '0' + seconds + } + return hours + ':' + minutes + ':' + seconds +} + +function draw() { + let titleOptions = { + display: true, + fontSize: 16 + } + + function getTooltipOptions(key, type) { + return { + mode: 'single', + callbacks: { + label: (item) => { + let idx = type === 'pie' ? item.index : item.datasetIndex + let d = wakapiData[key][idx] + return `${d.key}: ${d.total.toString().toHHMMSS()}` + } + } + } + } + + charts.forEach(c => c.destroy()) + + let projectChart = new Chart(projectsCanvas.getContext('2d'), { + type: 'horizontalBar', + data: { + datasets: wakapiData.projects + .slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length)) + .map(p => { + return { + label: p.key, + data: [parseInt(p.total)], + backgroundColor: getRandomColor(p.key) + } + }) + }, + options: { + title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}), + tooltips: getTooltipOptions('projects', 'bar'), + legend: { + display: false + } + } + }) + + let osChart = new Chart(osCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.operatingSystems + .slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key)) + }], + labels: wakapiData.operatingSystems + .slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) + .map(p => p.key) + }, + options: { + title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}), + tooltips: getTooltipOptions('operatingSystems', 'pie') + } + }) + + let editorChart = new Chart(editorsCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.editors + .slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key)) + }], + labels: wakapiData.editors + .slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) + .map(p => p.key) + }, + options: { + title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}), + tooltips: getTooltipOptions('editors', 'pie') + } + }) + + let languageChart = new Chart(languagesCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.languages + .slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.languages.map(p => getRandomColor(p.key)) + }], + labels: wakapiData.languages + .slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) + .map(p => p.key) + }, + options: { + title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}), + tooltips: getTooltipOptions('languages', 'pie') + } + }) + + getTotal(wakapiData.operatingSystems) + document.getElementById('grid-container').style.visibility = 'visible' + + charts = [projectChart, osChart, editorChart, languageChart] +} + +function getTotal(data) { + let total = data.reduce((acc, d) => acc + d.total, 0) + document.getElementById("total-span").innerText = total.toString().toHHMMSS() +} + +function getRandomColor(seed) { + seed = seed ? seed : '1234567'; + Math.seedrandom(seed); + var letters = '0123456789ABCDEF'.split(''); + var color = '#'; + for (var i = 0; i < 6; i++) { + color += letters[Math.floor(Math.random() * 16)]; + } + return color; +} + +// https://koddsson.com/posts/emoji-favicon/ +const favicon = document.querySelector("link[rel=icon]"); +if (favicon) { + const emoji = favicon.getAttribute("data-emoji"); + if (emoji) { + const canvas = document.createElement("canvas"); + canvas.height = 64; + canvas.width = 64; + const ctx = canvas.getContext("2d"); + ctx.font = "64px serif"; + ctx.fillText(emoji, 0, 64); + favicon.href = canvas.toDataURL(); + } +} + +window.addEventListener('load', function() { + draw() +}) \ No newline at end of file diff --git a/static/index.html b/static/index.html deleted file mode 100644 index ebce1da..0000000 --- a/static/index.html +++ /dev/null @@ -1,288 +0,0 @@ - - - - Coding Stats - - - - - - -

Wakapi

-

Your Coding Statistics Dashboard

-
-
- - - - -
-
- Shortcuts: - - - - - - -
-
-
-
-

- Total:
-

-
-
- -
-
- -
-
- -
-
- -
-
- - - - - - - - \ No newline at end of file diff --git a/utils/template.go b/utils/template.go new file mode 100644 index 0000000..396b4f1 --- /dev/null +++ b/utils/template.go @@ -0,0 +1,14 @@ +package utils + +import ( + "encoding/json" + "html/template" +) + +func Json(data interface{}) template.JS { + d, err := json.Marshal(data) + if err != nil { + return "" + } + return template.JS(d) +} diff --git a/views/index.tpl.html b/views/index.tpl.html new file mode 100644 index 0000000..3fa35b2 --- /dev/null +++ b/views/index.tpl.html @@ -0,0 +1,54 @@ + + + + Coding Stats + + + + + + +

Wakapi

+

Your Coding Statistics Dashboard

+
+ Today (live) + Yesterday + This Week + This Month + This Year + All Time +
+
+
+

+ Total:
+

+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + + + + + + + + \ No newline at end of file