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
EXPOSE 3000
ENTRYPOINT /app/entrypoint.sh

View File

@ -66,10 +66,12 @@ $ curl -L https://wakapi.dev/get | bash
# Create a persistent volume
$ 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
$ docker run -d \
-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 \
--name wakapi \
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)
# 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:
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"`
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"`
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"`
Colors map[string]map[string]string `yaml:"-"`
}
@ -269,12 +269,12 @@ func IsDev(env string) bool {
func readColors() map[string]map[string]string {
// Read language colors
// Source:
// https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
// https://wakatime.com/colors/operating_systems
// - https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
// - https://wakatime.com/colors/operating_systems
// - https://wakatime.com/colors/editors
// 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"]/div[1]/text()').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)
raw := data.ColorsFile
if IsDev(env) {

View File

@ -140,7 +140,7 @@ func initSentry(config sentryConfig, debug bool) {
return event
},
}); 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
require (
codeberg.org/Codeberg/avatars v0.0.0-20211228163022-8da63012fe69
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
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/schema v1.2.0
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/jinzhu/configor v1.2.1
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/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
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/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/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/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=

View File

@ -180,6 +180,7 @@ func main() {
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
avatarHandler := api.NewAvatarHandler()
// Compat Handlers
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
@ -237,6 +238,7 @@ func main() {
heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter)
diagnosticsHandler.RegisterRoutes(apiRouter)
avatarHandler.RegisterRoutes(apiRouter)
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.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) {
request, err := http.NewRequest(method, url, body)
if err != nil {
logbuch.Warn("error constructing relayed request %v", err)
logbuch.Warn("error constructing relayed request - %v", err)
return
}
@ -117,7 +117,7 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
response, err := m.httpClient.Do(request)
if err != nil {
logbuch.Warn("error executing relayed request %v", err)
logbuch.Warn("error executing relayed request - %v", err)
return
}

View File

@ -46,7 +46,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
for _, m := range preMigrations {
logbuch.Info("potentially running migration '%s'", m.name)
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 {
logbuch.Info("potentially running migration '%s'", m.name)
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 {
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, 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)
return d

View File

@ -103,7 +103,7 @@ func (f *Filters) IsEmpty() bool {
func (f *Filters) Hash() string {
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, 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"
}

View File

@ -94,7 +94,7 @@ func (h *Heartbeat) String() string {
func (h *Heartbeat) Hashed() *Heartbeat {
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, 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"
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 {
w.WriteHeader(http.StatusInternalServerError)
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
}
@ -107,7 +107,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
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
}
}

View File

@ -260,7 +260,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
activeUsers, err := h.userSrvc.GetActive(false)
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
}

View File

@ -284,7 +284,7 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
go func(user *models.User) {
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
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 {
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 {
numUsers, _ := h.userSrvc.Count()
allowSignup := h.config.Security.AllowSignup
return &view.LoginViewModel{
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
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) {
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 {
user.HasData = true
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 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 {
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) {
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))
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) {
@ -563,7 +563,7 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
logbuch.Info("deleting user '%s' shortly", user.ID)
time.Sleep(5 * time.Minute)
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 {
logbuch.Info("successfully deleted user '%s'", user.ID)
}

View File

@ -21,6 +21,7 @@ LANGUAGES = {
'PHP': 'php',
'Blade': 'blade.php'
}
BRANCHES = ['master', 'feature-1', 'feature-2']
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)
l: str = random.choice(languages)
f: str = randomword(random.randint(2, 8))
b: str = random.choice(BRANCHES)
delta: timedelta = timedelta(
hours=random.randint(0, n_past_hours - 1),
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]}',
project=p,
language=l,
branch=b,
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) {
for job := range jobs {
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 {
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
summaries <- summary
@ -95,7 +95,7 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
for summary := range summaries {
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)
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
}
@ -58,13 +58,13 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
userAgents, err := w.fetchUserAgents(baseUrl)
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
}
machinesNames, err := w.fetchMachineNames(baseUrl)
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
}
@ -76,7 +76,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
for _, d := range days {
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
}
@ -87,7 +87,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
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 {

View File

@ -89,7 +89,7 @@ func (srv *ReportService) SyncSchedule(u *models.User) bool {
At(t).
Tag(u.ID).
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 {
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)
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
}
@ -126,7 +126,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
}
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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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