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

Compare commits

..

11 Commits
2.2.0 ... 2.2.3

Author SHA1 Message Date
23d00d574b chore: easier setup instructions (resolve #325) 2022-03-02 08:55:58 +01:00
d4b15e7959 fix: href 2022-03-02 08:51:27 +01:00
42808fa38a fix: href to a 404 when service on a subpath
click project detail will redirect to a not exist page, when the service runs with a base path.

For example, the base path is `wakatime`,  and the dashboard uri will be `/wakatime/summary`. When click project detail, page will be redirect to `/wakatime/wakatime/summary?project=demo` but the correct detail page is `/wakatime/summary?project=demo`.

And i think `pushing a history stack` is better than `replace`, so that can back to dashboard by backwards.
2022-03-02 11:35:40 +08:00
52269c780f add missing expose to Dockerfile 2022-03-01 18:19:47 +11:00
302eb33b1b fix: branches chart (resolve #322) 2022-02-22 08:19:51 +01:00
784adec3c1 docs: update readme 2022-02-21 19:49:43 +01:00
d2cdd35fff Merge pull request #320 from muety/gitattributes
ref: add gitattributes file, remove unnecessary unicode characters
2022-02-20 21:27:02 +01:00
33d65fb33a ref: add .gitattributes file for line normalisation 2022-02-18 19:53:04 +11:00
6d762f5fd6 ref: remove unnecessary unicode characters 2022-02-18 19:52:55 +11:00
222024dabb chore: cache avatars in memory 2022-02-17 10:34:33 +01:00
660a09475e chore: include avatar rendering into wakapi itself 2022-02-17 09:53:37 +01:00
32 changed files with 130 additions and 60 deletions

6
.gitattributes vendored Normal file
View File

@ -0,0 +1,6 @@
* text=auto
*.db -text
*.png -text
*.br -text
*.ico -text
*.woff2 -text

View File

@ -56,4 +56,6 @@ COPY --from=build-env /app .
VOLUME /data VOLUME /data
EXPOSE 3000
ENTRYPOINT /app/entrypoint.sh ENTRYPOINT /app/entrypoint.sh

View File

@ -66,10 +66,12 @@ $ curl -L https://wakapi.dev/get | bash
# Create a persistent volume # Create a persistent volume
$ docker volume create wakapi-data $ docker volume create wakapi-data
$ SALT="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)"
# Run the container # Run the container
$ docker run -d \ $ docker run -d \
-p 3000:3000 \ -p 3000:3000 \
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \ -e "WAKAPI_PASSWORD_SALT=$SALT" \
-v wakapi-data:/data \ -v wakapi-data:/data \
--name wakapi \ --name wakapi \
ghcr.io/muety/wakapi:latest ghcr.io/muety/wakapi:latest

View File

@ -23,7 +23,8 @@ app:
# url template for user avatar images (to be used with services like gravatar or dicebear) # url template for user avatar images (to be used with services like gravatar or dicebear)
# available variable placeholders are: username, username_hash, email, email_hash # available variable placeholders are: username, username_hash, email, email_hash
avatar_url_template: https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg # defaults to wakapi's internal avatar rendering powered by https://codeberg.org/Codeberg/avatars
avatar_url_template: api/avatar/{username_hash}.svg
db: db:
host: # leave blank when using sqlite3 host: # leave blank when using sqlite3

View File

@ -69,7 +69,7 @@ type appConfig struct {
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"` ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"` InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"` CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
AvatarURLTemplate string `yaml:"avatar_url_template" default:"https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg"` AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg"`
CustomLanguages map[string]string `yaml:"custom_languages"` CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"` Colors map[string]map[string]string `yaml:"-"`
} }
@ -269,12 +269,12 @@ func IsDev(env string) bool {
func readColors() map[string]map[string]string { func readColors() map[string]map[string]string {
// Read language colors // Read language colors
// Source: // Source:
// https://raw.githubusercontent.com/ozh/github-colors/master/colors.json // - https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
// https://wakatime.com/colors/operating_systems // - https://wakatime.com/colors/operating_systems
// - https://wakatime.com/colors/editors // - https://wakatime.com/colors/editors
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after. // Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
// $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue) // - $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
// $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue) // - $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
raw := data.ColorsFile raw := data.ColorsFile
if IsDev(env) { if IsDev(env) {

View File

@ -140,7 +140,7 @@ func initSentry(config sentryConfig, debug bool) {
return event return event
}, },
}); err != nil { }); err != nil {
logbuch.Fatal("failed to initialized sentry %v", err) logbuch.Fatal("failed to initialized sentry - %v", err)
} }
} }

2
go.mod
View File

@ -3,6 +3,7 @@ module github.com/muety/wakapi
go 1.16 go 1.16
require ( require (
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69
github.com/BurntSushi/toml v0.4.1 // indirect github.com/BurntSushi/toml v0.4.1 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
@ -16,6 +17,7 @@ require (
github.com/gorilla/mux v1.8.0 github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0 github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1 github.com/gorilla/securecookie v1.1.1
github.com/hashicorp/golang-lru v0.5.4
github.com/jackc/pgx/v4 v4.14.1 // indirect github.com/jackc/pgx/v4 v4.14.1 // indirect
github.com/jinzhu/configor v1.2.1 github.com/jinzhu/configor v1.2.1
github.com/jinzhu/now v1.1.4 // indirect github.com/jinzhu/now v1.1.4 // indirect

4
go.sum
View File

@ -1,3 +1,5 @@
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69 h1:/XvI42KX57UTpeIOIt7IfM+pmEFTL8FGtiIUGcGDOIU=
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8= github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw= github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
@ -107,6 +109,8 @@ github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyC
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA= github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=

View File

@ -180,6 +180,7 @@ func main() {
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService) summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService) metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService) diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
avatarHandler := api.NewAvatarHandler()
// Compat Handlers // Compat Handlers
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService) wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
@ -237,6 +238,7 @@ func main() {
heartbeatApiHandler.RegisterRoutes(apiRouter) heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter) metricsHandler.RegisterRoutes(apiRouter)
diagnosticsHandler.RegisterRoutes(apiRouter) diagnosticsHandler.RegisterRoutes(apiRouter)
avatarHandler.RegisterRoutes(apiRouter)
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter) wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter) wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter) wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)

View File

@ -105,7 +105,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) { func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) {
request, err := http.NewRequest(method, url, body) request, err := http.NewRequest(method, url, body)
if err != nil { if err != nil {
logbuch.Warn("error constructing relayed request %v", err) logbuch.Warn("error constructing relayed request - %v", err)
return return
} }
@ -117,7 +117,7 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
response, err := m.httpClient.Do(request) response, err := m.httpClient.Do(request)
if err != nil { if err != nil {
logbuch.Warn("error executing relayed request %v", err) logbuch.Warn("error executing relayed request - %v", err)
return return
} }

View File

@ -46,7 +46,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range preMigrations { for _, m := range preMigrations {
logbuch.Info("potentially running migration '%s'", m.name) logbuch.Info("potentially running migration '%s'", m.name)
if err := m.f(db, cfg); err != nil { if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed %v", m.name, err) logbuch.Fatal("migration '%s' failed - %v", m.name, err)
} }
} }
} }
@ -57,7 +57,7 @@ func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range postMigrations { for _, m := range postMigrations {
logbuch.Info("potentially running migration '%s'", m.name) logbuch.Info("potentially running migration '%s'", m.name)
if err := m.f(db, cfg); err != nil { if err := m.f(db, cfg); err != nil {
logbuch.Fatal("migration '%s' failed %v", m.name, err) logbuch.Fatal("migration '%s' failed - %v", m.name, err)
} }
} }
} }

View File

@ -40,7 +40,7 @@ func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
func (d *Duration) Hashed() *Duration { func (d *Duration) Hashed() *Duration {
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil) hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
if err != nil { if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct %v", err) logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
} }
d.GroupHash = fmt.Sprintf("%x", hash) d.GroupHash = fmt.Sprintf("%x", hash)
return d return d

View File

@ -103,7 +103,7 @@ func (f *Filters) IsEmpty() bool {
func (f *Filters) Hash() string { func (f *Filters) Hash() string {
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil) hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil)
if err != nil { if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct %v", err) logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
} }
return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported" return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
} }

View File

@ -94,7 +94,7 @@ func (h *Heartbeat) String() string {
func (h *Heartbeat) Hashed() *Heartbeat { func (h *Heartbeat) Hashed() *Heartbeat {
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil) hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
if err != nil { if err != nil {
logbuch.Error("CRITICAL ERROR: failed to hash struct %v", err) logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
} }
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported" h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
return h return h

45
routes/api/avatar.go Normal file
View File

@ -0,0 +1,45 @@
package api
import (
"codeberg.org/Codeberg/avatars"
"github.com/gorilla/mux"
lru "github.com/hashicorp/golang-lru"
conf "github.com/muety/wakapi/config"
"net/http"
)
type AvatarHandler struct {
config *conf.Config
cache *lru.Cache
}
func NewAvatarHandler() *AvatarHandler {
cache, err := lru.New(1 * 1000 * 64) // assuming an avatar is 1 kb, allocate up to 64 mb of memory for avatars cache
if err != nil {
panic(err)
}
return &AvatarHandler{
config: conf.Get(),
cache: cache,
}
}
func (h *AvatarHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/avatar/{hash}.svg").Subrouter()
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
hash := mux.Vars(r)["hash"]
if !h.cache.Contains(hash) {
h.cache.Add(hash, avatars.MakeMaleAvatar(hash))
}
data, _ := h.cache.Get(hash)
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "max-age=2592000")
w.WriteHeader(http.StatusOK)
w.Write([]byte(data.(string)))
}

View File

@ -98,7 +98,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil { if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to batch-insert heartbeats %v", err) conf.Log().Request(r).Error("failed to batch-insert heartbeats - %v", err)
return return
} }
@ -107,7 +107,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError)) w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to update user %v", err) conf.Log().Request(r).Error("failed to update user - %v", err)
return return
} }
} }

View File

@ -260,7 +260,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
activeUsers, err := h.userSrvc.GetActive(false) activeUsers, err := h.userSrvc.GetActive(false)
if err != nil { if err != nil {
logbuch.Error("failed to retrieve active users for metric %v", err) logbuch.Error("failed to retrieve active users for metric - %v", err)
return nil, err return nil, err
} }

View File

@ -284,7 +284,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
go func(user *models.User) { go func(user *models.User) {
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken) link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
if err := h.mailSrvc.SendPasswordReset(user, link); err != nil { if err := h.mailSrvc.SendPasswordReset(user, link); err != nil {
conf.Log().Request(r).Error("failed to send password reset mail to %s %v", user.ID, err) conf.Log().Request(r).Error("failed to send password reset mail to %s - %v", user.ID, err)
} else { } else {
logbuch.Info("sent password reset mail to %s", user.ID) logbuch.Info("sent password reset mail to %s", user.ID)
} }
@ -299,12 +299,11 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel { func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
numUsers, _ := h.userSrvc.Count() numUsers, _ := h.userSrvc.Count()
allowSignup := h.config.Security.AllowSignup
return &view.LoginViewModel{ return &view.LoginViewModel{
Success: r.URL.Query().Get("success"), Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
TotalUsers: int(numUsers), TotalUsers: int(numUsers),
AllowSignup: allowSignup, AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
} }
} }

View File

@ -492,7 +492,7 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
insert := func(batch []*models.Heartbeat) { insert := func(batch []*models.Heartbeat) {
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil { if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
logbuch.Warn("failed to insert imported heartbeat, already existing? %v", err) logbuch.Warn("failed to insert imported heartbeat, already existing? - %v", err)
} }
} }
@ -518,13 +518,13 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
if !user.HasData { if !user.HasData {
user.HasData = true user.HasData = true
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s %v", user.ID, err) conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s - %v", user.ID, err)
} }
} }
if user.Email != "" { if user.Email != "" {
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil { if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
conf.Log().Request(r).Error("failed to send import notification mail to %s %v", user.ID, err) conf.Log().Request(r).Error("failed to send import notification mail to %s - %v", user.ID, err)
} else { } else {
logbuch.Info("sent import notification mail to %s", user.ID) logbuch.Info("sent import notification mail to %s", user.ID)
} }
@ -546,11 +546,11 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
go func(user *models.User) { go func(user *models.User) {
if err := h.regenerateSummaries(user); err != nil { if err := h.regenerateSummaries(user); err != nil {
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' %v", user.ID, err) conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' - %v", user.ID, err)
} }
}(middlewares.GetPrincipal(r)) }(middlewares.GetPrincipal(r))
return http.StatusAccepted, "summaries are being regenerated this may take a up to a couple of minutes, please come back later", "" return http.StatusAccepted, "summaries are being regenerated - this may take a up to a couple of minutes, please come back later", ""
} }
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) { func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
@ -563,7 +563,7 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
logbuch.Info("deleting user '%s' shortly", user.ID) logbuch.Info("deleting user '%s' shortly", user.ID)
time.Sleep(5 * time.Minute) time.Sleep(5 * time.Minute)
if err := h.userSrvc.Delete(user); err != nil { if err := h.userSrvc.Delete(user); err != nil {
conf.Log().Request(r).Error("failed to delete user '%s' %v", user.ID, err) conf.Log().Request(r).Error("failed to delete user '%s' - %v", user.ID, err)
} else { } else {
logbuch.Info("successfully deleted user '%s'", user.ID) logbuch.Info("successfully deleted user '%s'", user.ID)
} }

View File

@ -21,6 +21,7 @@ LANGUAGES = {
'PHP': 'php', 'PHP': 'php',
'Blade': 'blade.php' 'Blade': 'blade.php'
} }
BRANCHES = ['master', 'feature-1', 'feature-2']
class Heartbeat: class Heartbeat:
@ -65,6 +66,7 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
p: str = random.choice(projects) p: str = random.choice(projects)
l: str = random.choice(languages) l: str = random.choice(languages)
f: str = randomword(random.randint(2, 8)) f: str = randomword(random.randint(2, 8))
b: str = random.choice(BRANCHES)
delta: timedelta = timedelta( delta: timedelta = timedelta(
hours=random.randint(0, n_past_hours - 1), hours=random.randint(0, n_past_hours - 1),
minutes=random.randint(0, 59), minutes=random.randint(0, 59),
@ -77,6 +79,7 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
entity=f'/home/me/dev/{p}/{f}.{LANGUAGES[l]}', entity=f'/home/me/dev/{p}/{f}.{LANGUAGES[l]}',
project=p, project=p,
language=l, language=l,
branch=b,
time=(now - delta).timestamp() time=(now - delta).timestamp()
)) ))

View File

@ -84,7 +84,7 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) { func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
for job := range jobs { for job := range jobs {
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil { if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
config.Log().Error("failed to generate summary (%v, %v, %s) %v", job.From, job.To, job.UserID, err) config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
} else { } else {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID) logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
summaries <- summary summaries <- summary
@ -95,7 +95,7 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) { func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries { for summary := range summaries {
if err := srv.summaryService.Insert(summary); err != nil { if err := srv.summaryService.Insert(summary); err != nil {
config.Log().Error("failed to save summary (%v, %v, %s) %v", summary.UserID, summary.FromTime, summary.ToTime, err) config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
} }
} }
} }

View File

@ -45,7 +45,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
startDate, endDate, err := w.fetchRange(baseUrl) startDate, endDate, err := w.fetchRange(baseUrl)
if err != nil { if err != nil {
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' %v", user.ID, err) config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
return return
} }
@ -58,13 +58,13 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
userAgents, err := w.fetchUserAgents(baseUrl) userAgents, err := w.fetchUserAgents(baseUrl)
if err != nil { if err != nil {
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' %v", user.ID, err) config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
return return
} }
machinesNames, err := w.fetchMachineNames(baseUrl) machinesNames, err := w.fetchMachineNames(baseUrl)
if err != nil { if err != nil {
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' %v", user.ID, err) config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
return return
} }
@ -76,7 +76,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
for _, d := range days { for _, d := range days {
if err := sem.Acquire(ctx, 1); err != nil { if err := sem.Acquire(ctx, 1); err != nil {
logbuch.Error("failed to acquire semaphore %v", err) logbuch.Error("failed to acquire semaphore - %v", err)
break break
} }
@ -87,7 +87,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
d := day.Format(config.SimpleDateFormat) d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d, baseUrl) heartbeats, err := w.fetchHeartbeats(d, baseUrl)
if err != nil { if err != nil {
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' &v", d, user.ID, err) config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - &v", d, user.ID, err)
} }
for _, h := range heartbeats { for _, h := range heartbeats {

View File

@ -89,7 +89,7 @@ func (srv *ReportService) SyncSchedule(u *models.User) bool {
At(t). At(t).
Tag(u.ID). Tag(u.ID).
Do(srv.Run, u, 7*24*time.Hour); err != nil { Do(srv.Run, u, 7*24*time.Hour); err != nil {
config.Log().Error("failed to schedule report job for user '%s' %v", u.ID, err) config.Log().Error("failed to schedule report job for user '%s' - %v", u.ID, err)
} else { } else {
logbuch.Info("next report for user %s is scheduled for %v", u.ID, job.NextRun()) logbuch.Info("next report for user %s is scheduled for %v", u.ID, job.NextRun())
} }
@ -114,7 +114,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false) summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false)
if err != nil { if err != nil {
config.Log().Error("failed to generate report for '%s' %v", user.ID, err) config.Log().Error("failed to generate report for '%s' - %v", user.ID, err)
return err return err
} }
@ -126,7 +126,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
} }
if err := srv.mailService.SendReport(user, report); err != nil { if err := srv.mailService.SendReport(user, report); err != nil {
config.Log().Error("failed to send report for '%s' %v", user.ID, err) config.Log().Error("failed to send report for '%s' - %v", user.ID, err)
return err return err
} }

View File

@ -132,9 +132,9 @@ function draw(subselection) {
onClick: (event, data) => { onClick: (event, data) => {
const idx = data[0].index const idx = data[0].index
const name = wakapiData.projects[idx].key const name = wakapiData.projects[idx].key
const query = new URLSearchParams(window.location.search) const url = new URL(window.location.href)
query.set('project', name) url.searchParams.set('query', name)
window.location.replace(`${window.location.pathname.slice(1)}?${query.toString()}`) window.location.href = url.href
}, },
onHover: (event, elem) => { onHover: (event, elem) => {
event.native.target.style.cursor = elem[0] ? 'pointer' : 'default' event.native.target.style.cursor = elem[0] ? 'pointer' : 'default'
@ -331,13 +331,13 @@ function draw(subselection) {
}) })
: null : null
let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(0) let branchChart = branchesCanvas && !branchesCanvas.classList.contains('hidden') && shouldUpdate(6)
? new Chart(branchesCanvas.getContext('2d'), { ? new Chart(branchesCanvas.getContext('2d'), {
type: "bar", type: "bar",
data: { data: {
datasets: [{ datasets: [{
data: wakapiData.branches data: wakapiData.branches
.slice(0, Math.min(showTopN[0], wakapiData.branches.length)) .slice(0, Math.min(showTopN[6], wakapiData.branches.length))
.map(p => parseInt(p.total)), .map(p => parseInt(p.total)),
backgroundColor: wakapiData.branches.map((p, i) => { backgroundColor: wakapiData.branches.map((p, i) => {
const c = hexToRgb(getColor(p.key, i % baseColors.length)) const c = hexToRgb(getColor(p.key, i % baseColors.length))
@ -349,7 +349,7 @@ function draw(subselection) {
}), }),
}], }],
labels: wakapiData.branches labels: wakapiData.branches
.slice(0, Math.min(showTopN[0], wakapiData.branches.length)) .slice(0, Math.min(showTopN[6], wakapiData.branches.length))
.map(p => p.key) .map(p => p.key)
}, },
options: { options: {

View File

@ -30,7 +30,7 @@ done
echo "" echo ""
echo "Running test collection ..." echo "Running test collection ..."
newman run "Wakapi API Tests.postman_collection.json" newman run "wakapi_api_tests.postman_collection.json"
exit_code=$? exit_code=$?
echo "Shutting down Wakapi ..." echo "Shutting down Wakapi ..."
@ -39,4 +39,4 @@ kill -TERM $pid
echo "Deleting database ..." echo "Deleting database ..."
rm wakapi_testing.db rm wakapi_testing.db
exit $exit_code exit $exit_code

View File

@ -1,23 +1,28 @@
package fs package fs
import ( import (
"github.com/patrickmn/go-cache" lru "github.com/hashicorp/golang-lru"
"io/fs" "io/fs"
"net/http" "net/http"
"strings" "strings"
) )
func NewExistsFS(fs fs.FS) ExistsFS { func NewExistsFS(fs fs.FS) ExistsFS {
cache, err := lru.New(1 << 24)
if err != nil {
panic(err)
}
return ExistsFS{ return ExistsFS{
FS: fs, FS: fs,
cache: cache.New(cache.NoExpiration, cache.NoExpiration), cache: cache,
} }
} }
type ExistsFS struct { type ExistsFS struct {
fs.FS fs.FS
UseCache bool UseCache bool
cache *cache.Cache cache *lru.Cache
} }
func (efs ExistsFS) WithCache(withCache bool) ExistsFS { func (efs ExistsFS) WithCache(withCache bool) ExistsFS {
@ -34,7 +39,7 @@ func (efs ExistsFS) Exists(name string) bool {
_, err := fs.Stat(efs.FS, name) _, err := fs.Stat(efs.FS, name)
result := err == nil result := err == nil
if efs.UseCache { if efs.UseCache {
efs.cache.SetDefault(name, result) efs.cache.Add(name, result)
} }
return result return result
} }

View File

@ -1 +1 @@
2.2.0 2.2.3

View File

@ -12,7 +12,7 @@
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2"> <div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="mx-1"> <div class="mx-1">
<a href="login" class="btn-primary"> <a href="login" class="btn-primary">
<span class="iconify inline" data-icon="fluent:key-24-filled"></span> &nbsp;Login</a> <span class="iconify inline" data-icon="fluent:key-24-filled"></span> &nbsp;Login</a>
</div> </div>
</div> </div>

View File

@ -31,13 +31,13 @@
Forgot password? Forgot password?
</a> </a>
<div class="flex space-x-2"> <div class="flex space-x-2">
{{ if eq .AllowSignup true }} {{ if .AllowSignup }}
<a href="signup"> <a href="signup">
<button type="button" class="btn-default">Sign up</button> <button type="button" class="btn-default">Sign up</button>
</a> </a>
{{ else }} {{ else }}
<a title="The administrator of this instance has disabled sign up."> <a title="The administrator of this instance has disabled sign up.">
<button type="button" class="btn-disabled" disabled > Sign up </button> <button type="button" class="btn-disabled" disabled> Sign up </button>
</a> </a>
{{ end }} {{ end }}
<button type="submit" class="btn-primary">Log in</button> <button type="submit" class="btn-primary">Log in</button>

View File

@ -83,7 +83,7 @@
<a href="login"> <a href="login">
<button type="button" class="btn-default">Log in</button> <button type="button" class="btn-default">Log in</button>
</a> </a>
{{ if eq .AllowSignup true }} {{ if .AllowSignup }}
<button type="submit" class="btn-primary"> <button type="submit" class="btn-primary">
Create Account Create Account
</button> </button>

View File

@ -189,12 +189,11 @@
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br> # <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
# See: https://wakatime.com/plugins<br><br> # See: https://wakatime.com/plugins<br><br>
# <strong>Step 2:</strong> Adapt your config<br> # <strong>Step 2:</strong> Set your ~/.wakatime.cfg to this:<br><br>
$ vi ~/.wakatime.cfg<br>
<!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 --> <!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 -->
# Set <em>api_url = <span class="with-url-inner">%s/api</span></em><br> [settings]<br>
# Set <em>api_key = <span id="api-key-instruction">{{ .ApiKey }}</span></em><br><br> api_url = <span class="with-url-inner">%s/api</span><br>
api_key = <span id="api-key-instruction">{{ .ApiKey }}</span><br><br>
# <strong>Step 3:</strong> Start coding and then check back here! # <strong>Step 3:</strong> Start coding and then check back here!
</div> </div>