1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

..

24 Commits
1.6.2 ... 1.8.1

Author SHA1 Message Date
82ed386359 fix: generate dummy items for missing types in historic summary data 2020-08-29 23:16:21 +02:00
12cc4cd9cf feat: introduce machine summaries (resolve #48) 2020-08-29 22:03:01 +02:00
2eccb7a468 Merge branch 'stable' 2020-08-29 21:23:39 +02:00
08a83af8da feat: extract machine name from heartbeat requests (resolve #47) 2020-08-29 21:20:23 +02:00
c0d6855546 fix: return proper response to heartbeat requests (resolve #46) 2020-08-29 21:13:56 +02:00
0af7d2f8ef feat: show data loss warning (resolve #38) 2020-08-23 13:30:04 +02:00
11d1d5bc99 fix: adapt summary generation to new summary bounds 2020-08-23 13:21:23 +02:00
ada0863f7c fix: make summary include timestamp of first and last heartbeat (resolve #18) 2020-08-23 13:11:01 +02:00
7818f6b094 fix: return created status code for heartbeats (resolve #43) 2020-08-19 23:17:34 +02:00
f86eb7668d Merge pull request #35 from YC/master
feat: add base_path environment variable
2020-07-14 20:14:23 +02:00
24469e4922 feat: add base_path environment variable 2020-07-14 21:22:30 +10:00
4f035b3a63 chore: minor code improvement 2020-06-07 20:01:31 +02:00
0eac9a8854 feat: add ability to reset api key (resolve #29) 2020-06-07 19:58:06 +02:00
0294425de0 feat: add ability to change passwords (resolve #30) 2020-06-07 19:28:32 +02:00
a7c83252ef debug: re-add workflows again 2020-05-31 10:29:15 +02:00
07a03ce3ac debug: remove workflows 2020-05-31 10:28:55 +02:00
160c2f713e chore: update version count 2020-05-31 09:54:02 +02:00
05b740c87d chore: minor changes to the github actions 2020-05-31 09:52:13 +02:00
274be6caf8 chore: run in dev mode by default 2020-05-31 09:51:55 +02:00
58fef96f22 Merge branch 'LightPOS-win-gh-actions-build' 2020-05-31 09:38:38 +02:00
629a3212c7 feat: persist user creation date (resolve #31) 2020-05-31 09:38:26 +02:00
0a513e959b Automatically build the project for Linux users
Add a GitHub Action to build on Linux when a release is created
2020-05-30 21:50:16 +01:00
c68ee0a81e Remove upload of artifact to Actions' artifacts
There is no need to upload the artifact to the Action itself since it will be uploaded to the release.
2020-05-30 21:04:22 +01:00
e4a2fbd51a Automatically build the project for Windows users
This change makes it simpler for Windows users to use the project by automatically building the project with GitHub Actions on every release.

This allows for an easier way to use the project by automatically adding a zip file with the built executables to new releases.
2020-05-30 21:01:54 +01:00
26 changed files with 553 additions and 77 deletions

View File

@ -1,4 +1,4 @@
ENV=prod
ENV=dev
WAKAPI_DB_TYPE=sqlite3 # mysql, postgres, sqlite3
WAKAPI_DB_NAME=wakapi_db.db # database name for mysql / postgres or file path for sqlite (e.g. /tmp/wakapi.db)
WAKAPI_DB_USER=myuser # ignored when using sqlite

View File

@ -0,0 +1,43 @@
name: Build Wakapi on Linux
on:
release:
types:
- created
jobs:
build-and-release:
name: Build and add to Release
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get -v -t -d ./...
- name: Build
run: GO111MODULE=on go build -v .
- name: Zip Release
uses: TheDoctor0/zip-release@v0.3.0
with:
filename: release.zip
- name: Upload built executable to Release
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: release.zip
asset_name: wakapi_linux_amd64.zip
asset_content_type: application/gzip

View File

@ -0,0 +1,44 @@
name: Build Wakapi on Windows
on:
release:
types:
- created
jobs:
build-and-release:
name: Build and add to release
runs-on: windows-latest
steps:
- name: Set up Go 1.x
uses: actions/setup-go@v2
with:
go-version: ^1.13
id: go
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: |
go get -v -t -d ./...
- name: Enable Go 1.11 modules
run: cmd /c "set GO111MODULE=on"
- name: Build
run: go build -v .
- name: Compress working folder
run: Compress-Archive -Path .\* -DestinationPath release.zip
- name: Upload built executable to Release
uses: actions/upload-release-asset@v1.0.2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ github.event.release.upload_url }}
asset_path: release.zip
asset_name: wakapi_win_amd64.zip
asset_content_type: application/gzip

View File

@ -9,7 +9,7 @@ RUN cd /src && go build -o wakapi
# When running the application using `docker run`, you can pass environment variables
# to override config values from .env using `-e` syntax.
# Available options are:
# Available options are:
# WAKAPI_DB_TYPE
# WAKAPI_DB_USER
# WAKAPI_DB_PASSWORD
@ -17,8 +17,7 @@ RUN cd /src && go build -o wakapi
# WAKAPI_DB_PORT
# WAKAPI_DB_NAME
# WAKAPI_PASSWORD_SALT
# WAKAPI_DEFAULT_USER_NAME
# WAKAPI_DEFAULT_USER_PASSWORD
# WAKAPI_BASE_PATH
FROM debian
WORKDIR /app
@ -30,8 +29,6 @@ ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_DEFAULT_USER_NAME admin
ENV WAKAPI_DEFAULT_USER_PASSWORD admin
COPY --from=build-env /src/wakapi /app/
COPY --from=build-env /src/config.ini /app/

View File

@ -40,6 +40,10 @@ To use the demo version set `api_url = https://apps.muetsch.io/wakapi/api/heartb
1. Build executable: `GO111MODULE=on go build`
1. Run server: `./wakapi`
**As an alternative** to building from source you can also grab a pre-built [release](https://github.com/muety/wakapi/releases). Steps 2, 3 and 5 apply analogously.
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` in `.env` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](i#best-practices)) or set `insecure_cookies = true` in `config.ini`.
### Run with Docker
```
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
@ -76,6 +80,7 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
* Language ~ type **1**
* Editor ~ type **2**
* OS ~ type **3**
* Machine ~ type **4**
**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi.

34
main.go
View File

@ -43,6 +43,11 @@ func main() {
log.SetFlags(log.LstdFlags | log.Lshortfile)
}
// Show data loss warning
if config.CleanUp {
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
}
// Connect to database
var err error
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
@ -85,11 +90,13 @@ func main() {
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
summaryHandler := routes.NewSummaryHandler(summaryService)
healthHandler := routes.NewHealthHandler(db)
settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService)
// Setup Routers
router := mux.NewRouter()
publicRouter := router.PathPrefix("/").Subrouter()
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter()
@ -105,17 +112,24 @@ func main() {
// Router configs
router.Use(loggingMiddleware, recoveryMiddleware)
summaryRouter.Use(authenticateMiddleware)
settingsRouter.Use(authenticateMiddleware)
apiRouter.Use(corsMiddleware, authenticateMiddleware)
// Public Routes
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.Index)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.Login)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.Logout)
publicRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(publicHandler.Signup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.Imprint)
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
// Summary Routes
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
// Settings Routes
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
// API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
@ -163,3 +177,11 @@ func migrateLanguages() {
}
}
}
func promptAbort(message string, timeoutSec int) {
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
for i := timeoutSec; i > 0; i-- {
log.Printf("Starting in %d seconds ...\n", i)
time.Sleep(1 * time.Second)
}
}

View File

@ -0,0 +1,11 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table users
add `machine` varchar(255);
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table users
drop column `machine`;

View File

@ -161,7 +161,11 @@ func readConfig() *Config {
port = cfg.Section("server").Key("port").MustInt()
}
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
basePath := cfg.Section("server").Key("base_path").MustString("/")
if basePathEnvExists {
basePath = basePathEnv
}
if strings.HasSuffix(basePath, "/") {
basePath = basePath[:len(basePath)-1]
}

View File

@ -20,6 +20,7 @@ type Heartbeat struct {
IsWrite bool `json:"is_write"`
Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"`
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
languageRegex *regexp.Regexp
}

View File

@ -5,13 +5,16 @@ import (
)
const (
NSummaryTypes uint8 = 4
NSummaryTypes uint8 = 99
SummaryProject uint8 = 0
SummaryLanguage uint8 = 1
SummaryEditor uint8 = 2
SummaryOS uint8 = 3
SummaryMachine uint8 = 4
)
const UnknownSummaryKey = "unknown"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
@ -21,6 +24,7 @@ type Summary struct {
Languages []*SummaryItem `json:"languages"`
Editors []*SummaryItem `json:"editors"`
OperatingSystems []*SummaryItem `json:"operating_systems"`
Machines []*SummaryItem `json:"machines"`
}
type SummaryItem struct {
@ -43,3 +47,55 @@ type SummaryViewModel struct {
Success string
ApiKey string
}
/* Augments the summary in a way that at least one item is present for every type.
If a summary has zero items for a given type, but one or more for any of the other types,
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
for the missing type.
For instance, the machine type was introduced post hoc. Accordingly, no "machine"-information is present in
the data for old heartbeats and summaries. If a user has two years of data without machine information and
one day with such, a "machine"-chart plotted from that data will reference a way smaller absolute total amount
of time than the other ones.
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
*/
func (s *Summary) FillUnknown() {
types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
missingTypes := make([]uint8, 0)
typeItems := map[uint8]*[]*SummaryItem{
SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines,
}
var somePresentType uint8
for _, t := range types {
if len(*typeItems[t]) == 0 {
missingTypes = append(missingTypes, t)
} else {
somePresentType = t
}
}
// can't proceed if entire summary is empty
if len(missingTypes) == len(types) {
return
}
// calculate total duration from any of the present sets of items
var timeSum time.Duration
for _, item := range *typeItems[somePresentType] {
timeSum += item.Total
}
// construct dummy item for all missing types
for _, t := range missingTypes {
*typeItems[t] = append(*typeItems[t], &SummaryItem{
Type: t,
Key: UnknownSummaryKey,
Total: timeSum,
})
}
}

View File

@ -19,8 +19,27 @@ type Signup struct {
PasswordRepeat string `schema:"password_repeat"`
}
type CredentialsReset struct {
PasswordOld string `schema:"password_old"`
PasswordNew string `schema:"password_new"`
PasswordRepeat string `schema:"password_repeat"`
}
func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat
}
func (s *Signup) IsValid() bool {
return len(s.Username) >= 3 &&
len(s.Password) >= 6 &&
return validateUsername(s.Username) &&
validatePassword(s.Password) &&
s.Password == s.PasswordRepeat
}
func validateUsername(username string) bool {
return len(username) >= 3
}
func validatePassword(password string) bool {
return len(password) >= 6
}

View File

@ -23,10 +23,15 @@ func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *Heartbeat
}
}
type heartbeatResponseVm struct {
Responses [][]interface{} `json:"responses"`
}
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
var heartbeats []*models.Heartbeat
user := r.Context().Value(models.UserKey).(*models.User)
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
machineName := r.Header.Get("X-Machine-Name")
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&heartbeats); err != nil {
@ -38,6 +43,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
for _, hb := range heartbeats {
hb.OperatingSystem = opSys
hb.Editor = editor
hb.Machine = machineName
hb.User = user
hb.UserID = user.ID
hb.Augment(h.config.CustomLanguages)
@ -55,5 +61,23 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
return
}
w.WriteHeader(http.StatusOK)
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
}
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
// to make the cli consider all heartbeats to having been successfully saved
// response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] }
func constructSuccessResponse(n int) *heartbeatResponseVm {
responses := make([][]interface{}, n)
for i := 0; i < n; i++ {
r := make([]interface{}, 2)
r[0] = nil
r[1] = http.StatusCreated
responses[i] = r
}
return &heartbeatResponseVm{
Responses: responses,
}
}

View File

@ -29,7 +29,7 @@ func NewIndexHandler(userService *services.UserService, keyValueService *service
}
}
func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
@ -43,18 +43,10 @@ func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
return
}
// TODO: make this more generic and reusable
if success := r.URL.Query().Get("success"); success != "" {
templates["index.tpl.html"].Execute(w, struct {
Success string
Error string
}{Success: success})
return
}
templates["index.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) {
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
@ -69,7 +61,7 @@ func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) {
}{HtmlText: text})
}
func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
@ -121,7 +113,7 @@ func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
}
func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
@ -130,27 +122,7 @@ func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
}
func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
return
}
switch r.Method {
case http.MethodPost:
h.handlePostSignup(w, r)
return
default:
h.handleGetSignup(w, r)
return
}
}
func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
@ -167,7 +139,7 @@ func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
templates["signup.tpl.html"].Execute(w, nil)
}
func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) {
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}

View File

@ -64,7 +64,7 @@ func respondAlert(w http.ResponseWriter, error, success, tplName string, status
templates[tplName].Execute(w, struct {
Error string
Success string
}{Error: error})
}{Error: error, Success: success})
}
// TODO: do better

112
routes/settings.go Normal file
View File

@ -0,0 +1,112 @@
package routes
import (
"fmt"
"github.com/gorilla/schema"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
)
type SettingsHandler struct {
config *models.Config
userSrvc *services.UserService
}
var credentialsDecoder = schema.NewDecoder()
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
return &SettingsHandler{
config: models.GetConfig(),
userSrvc: userService,
}
}
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
if handleAlerts(w, r, "settings.tpl.html") {
return
}
templates["settings.tpl.html"].Execute(w, nil)
}
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
var credentials models.CredentialsReset
if err := r.ParseForm(); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
return
}
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
return
}
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) {
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized)
return
}
if !credentials.IsValid() {
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
return
}
user.Password = credentials.PasswordNew
if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
if _, err := h.userSrvc.Update(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
login := &models.Login{
Username: user.ID,
Password: user.Password,
}
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
if err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
cookie := &http.Cookie{
Name: models.AuthCookieKey,
Value: encoded,
Path: "/",
Secure: !h.config.InsecureCookies,
HttpOnly: true,
}
http.SetCookie(w, cookie)
msg := url.QueryEscape("password was updated successfully")
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
}
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
}

View File

@ -42,7 +42,7 @@ func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
utils.RespondJSON(w, http.StatusOK, summary)
}
func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}

View File

@ -65,12 +65,13 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
heartbeats = append(heartbeats, hb...)
}
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS}
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS, models.SummaryMachine}
var projectItems []*models.SummaryItem
var languageItems []*models.SummaryItem
var editorItems []*models.SummaryItem
var osItems []*models.SummaryItem
var machineItems []*models.SummaryItem
if err := srv.AliasService.LoadUserAliases(user.ID); err != nil {
return nil, err
@ -92,18 +93,40 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
editorItems = item.Items
case models.SummaryOS:
osItems = item.Items
case models.SummaryMachine:
machineItems = item.Items
}
}
close(c)
realFrom, realTo := from, to
if len(existingSummaries) > 0 {
realFrom = existingSummaries[0].FromTime
realTo = existingSummaries[len(existingSummaries)-1].ToTime
for _, summary := range existingSummaries {
summary.FillUnknown()
}
}
if len(heartbeats) > 0 {
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
realFrom = t1
}
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
realTo = t2
}
}
aggregatedSummary := &models.Summary{
UserID: user.ID,
FromTime: from,
ToTime: to,
FromTime: realFrom,
ToTime: realTo,
Projects: projectItems,
Languages: languageItems,
Editors: editorItems,
OperatingSystems: osItems,
Machines: machineItems,
}
allSummaries := []*models.Summary{aggregatedSummary}
@ -134,10 +157,12 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
Where(&models.Summary{UserID: user.ID}).
Where("from_time >= ?", from).
Where("to_time <= ?", to).
Order("from_time asc").
Preload("Projects", "type = ?", models.SummaryProject).
Preload("Languages", "type = ?", models.SummaryLanguage).
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Find(&summaries).Error; err != nil {
return nil, err
}
@ -171,10 +196,12 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
key = h.Language
case models.SummaryOS:
key = h.OperatingSystem
case models.SummaryMachine:
key = h.Machine
}
if key == "" {
key = "unknown"
key = models.UnknownSummaryKey
}
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
@ -224,7 +251,16 @@ func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary
// Between
for i := 0; i < len(existingSummaries)-1; i++ {
if existingSummaries[i].ToTime.Before(existingSummaries[i+1].FromTime) {
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
if t1.Equal(t2) {
continue
}
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
// one or more day missing in between?
if td1.Before(td2) {
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
}
}
@ -251,6 +287,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
Languages: make([]*models.SummaryItem, 0),
Editors: make([]*models.SummaryItem, 0),
OperatingSystems: make([]*models.SummaryItem, 0),
Machines: make([]*models.SummaryItem, 0),
}
for _, s := range summaries {
@ -270,6 +307,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
}
finalSummary.FromTime = minTime

View File

@ -82,6 +82,11 @@ func (srv *UserService) Update(user *models.User) (*models.User, error) {
return user, nil
}
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
user.ApiKey = uuid.NewV4().String()
return srv.Update(user)
}
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
user.Password = login.Password
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {

View File

@ -5,6 +5,7 @@ const projectsCanvas = document.getElementById('chart-projects')
const osCanvas = document.getElementById('chart-os')
const editorsCanvas = document.getElementById('chart-editor')
const languagesCanvas = document.getElementById('chart-language')
const machinesCanvas = document.getElementById('chart-machine')
let charts = []
let resizeCount = 0
@ -56,7 +57,7 @@ function draw() {
.map(p => {
return {
label: p.key,
data: [parseInt(p.total)],
data: [parseInt(p.total) / 60],
backgroundColor: getRandomColor(p.key)
}
})
@ -67,6 +68,14 @@ function draw() {
legend: {
display: false
},
scales: {
xAxes: [{
scaleLabel: {
display: true,
labelString: 'Minutes'
}
}]
},
maintainAspectRatio: false,
onResize: onChartResize
}
@ -135,10 +144,31 @@ function draw() {
}
})
let machineChart = new Chart(machinesCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
}],
labels: wakapiData.machines
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
.map(p => p.key)
},
options: {
title: Object.assign(titleOptions, {text: `Machines (top ${SHOW_TOP_N})`}),
tooltips: getTooltipOptions('machines', 'pie'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
getTotal(wakapiData.operatingSystems)
document.getElementById('grid-container').style.visibility = 'visible'
charts = [projectChart, osChart, editorChart, languageChart]
charts = [projectChart, osChart, editorChart, languageChart, machineChart]
charts.forEach(c => c.options.onResize(c.chart))
equalizeHeights()

View File

@ -15,12 +15,12 @@ func StartOfWeek() time.Time {
func StartOfMonth() time.Time {
ref := time.Now()
return time.Date(ref.Year(), ref.Month(), 0, 0, 0, 0, 0, ref.Location())
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
}
func StartOfYear() time.Time {
ref := time.Now()
return time.Date(ref.Year(), time.January, 0, 0, 0, 0, 0, ref.Location())
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
}
// https://stackoverflow.com/a/18632496

View File

@ -1 +1 @@
1.6.2
1.8.1

View File

@ -2,11 +2,13 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
<script>
const languageColors = {{ .LanguageColors | json }}
let wakapiData = {}
let languageColors = {{ .LanguageColors | json }}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.projects = {{ .Projects | json }}
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
</script>
<script src="assets/app.js"></script>

View File

@ -5,7 +5,7 @@
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div>
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1>
</div>
<div></div>

75
views/settings.tpl.html Normal file
View File

@ -0,0 +1,75 @@
<html>
{{ template "head.tpl.html" . }}
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
<div></div>
</div>
</div>
{{ template "alerts.tpl.html" . }}
<main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-lg mt-8">
<div class="w-full my-8 pb-8 border-b border-gray-700">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Change Password
</div>
<form class="mt-10" action="settings/credentials" method="post">
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_old"
name="password_old" placeholder="Enter your old password" minlength="6" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_new"
name="password_new" placeholder="Choose a password" minlength="6" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Save
</button>
</div>
</form>
</div>
<div class="w-full mt-4 mb-8 pb-8">
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Reset API Key
</div>
<form class="mt-6" action="settings/reset" method="post">
<div class="text-gray-300 text-sm mb-4">
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span class="font-mono">.wakatime.cfg</span> files on all of your computers to make the WakaTime client send heartbeats again.
</div>
<div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
Reset
</button>
</div>
</form>
</div>
</div>
</main>
{{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }}
</body>
</html>

View File

@ -5,7 +5,7 @@
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-4xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div>
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
<div></div>
</div>
@ -41,7 +41,7 @@
name="password" placeholder="Choose a password" minlength="6" required>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">And again ...</label>
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required>

View File

@ -4,21 +4,32 @@
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup" id="api-key-popup">
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup"
id="api-key-popup">
<div class="flex-grow flex flex-col px-2">
<span class="text-xs text-gray-500 mx-1">API Key</span>
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly value="{{ .ApiKey }}" style="min-width: 330px">
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
value="{{ .ApiKey }}" style="min-width: 330px">
</div>
<div class="flex items-center px-2 border-l border-gray-700">
<button title="Copy to clipboard" onclick="copyApiKey(event)">📋</button>
</div>
</div>
<div class="absolute top-0 right-0 mr-8 mt-10 py-2">
<form action="logout" method="post">
<button type="button" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm" onclick="showApiKeyPopup(event)">🔐</button>
<button type="submit" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm">Logout</button>
</form>
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="mx-1">
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm"
onclick="showApiKeyPopup(event)">🔐
</button>
</div>
<div class="mx-1">
<a href="settings" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">⚙️</a>
</div>
<div class="mx-1">
<form action="logout" method="post">
<button type="submit" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm">Logout</button>
</form>
</div>
</div>
<div class="flex items-center justify-center">
@ -67,6 +78,11 @@
<canvas id="chart-editor"></canvas>
</div>
</div>
<div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="machine-container" style="height: 300px">
<canvas id="chart-machine"></canvas>
</div>
</div>
</div>
</main>