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

Compare commits

..

22 Commits
2.2.0 ... 0.2.6

Author SHA1 Message Date
d1bd7b96b8 fix: hotfix for #337 (resolve #33) 2022-03-16 18:29:19 +01:00
8c65da9031 chore: remove entity index again
chore: add migration note
2022-03-13 09:42:51 +01:00
647bf1781d chore: apply filters in database query (see #335) 2022-03-13 08:49:03 +01:00
85515d6cb5 Merge branch 'patch-1' 2022-03-06 12:00:29 +01:00
1258ec0438 docs: add smtp and mailwhale config details to readme 2022-03-06 12:00:19 +01:00
965d8e22b3 chore: fix typo in error message 2022-03-06 11:52:03 +01:00
ed6e51b4df add error when no authentication is configured 2022-03-04 17:03:04 +01:00
af879f8d57 fix: example for mail sender 2022-03-04 16:58:52 +01:00
f15efcd6f2 chore: bump version 2022-03-02 17:58:53 +01:00
22e91ad362 Merge pull request #327 from daief/patch-2
fix: wrong key
2022-03-02 17:56:53 +01:00
932ba111cc fix: wrong key 2022-03-02 22:29:09 +08:00
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
43 changed files with 1239 additions and 946 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
@ -152,10 +154,16 @@ You can specify configuration options either via a config file (default: `config
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
| `db.automgirate_fail_silently` /<br> `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
| `mail.enabled` /<br> `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `Wakapi <noreply@wakapi.dev>` | Default sender address for outgoing mails (ignored for MailWhale) |
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
| `mail.smtp.*` /<br> `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details |
| `mail.mailwhale.*` /<br> `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yml) for details |
| `mail.smtp.host` /<br> `WAKAPI_MAIL_SMTP_HOST` | - | SMTP server address for sending mail (if using `smtp` mail provider) |
| `mail.smtp.port` /<br> `WAKAPI_MAIL_SMTP_PORT` | - | SMTP server port (usually 465) |
| `mail.smtp.username` /<br> `WAKAPI_MAIL_SMTP_USER` | - | SMTP server authentication username |
| `mail.smtp.password` /<br> `WAKAPI_MAIL_SMTP_PASS` | - | SMTP server authentication password |
| `mail.smtp.tls` /<br> `WAKAPI_MAIL_SMTP_TLS` | `false` | Whether the SMTP server requires TLS encryption (`false` for STARTTLS or no encryption) |
| `mail.mailwhale.url` /<br> `WAKAPI_MAIL_MAILWHALE_URL` | - | URL of [MailWhale](https://mailwhale.dev) instance (e.g. `https://mailwhale.dev`) (if using `mailwhale` mail provider`) |
| `mail.mailwhale.client_id` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_ID` | - | MailWhale API client ID |
| `mail.mailwhale.client_secret` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET` | - | MailWhale API client secret |
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `sentry.sample_rate` /<br> `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |

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)
}
}

File diff suppressed because it is too large Load Diff

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,6 +40,11 @@ func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, use
return args.Get(0).([]*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetAllWithinByFilters(time time.Time, time2 time.Time, user *models.User, filters *models.Filters) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user, filters)
return args.Get(0).([]*models.Heartbeat), args.Error(1)
}
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
args := m.Called()
return args.Get(0).([]*models.TimeByUser), args.Error(1)

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

@ -92,7 +92,7 @@ func (f *Filters) OneOrEmpty() FilterElement {
if ok, t, of := f.One(); ok {
return FilterElement{entity: t, filter: of}
}
return FilterElement{}
return FilterElement{entity: SummaryUnknown, filter: []string{}}
}
func (f *Filters) IsEmpty() bool {
@ -100,10 +100,53 @@ func (f *Filters) IsEmpty() bool {
return !nonEmpty
}
func (f *Filters) Count() int {
var count int
for i := SummaryProject; i <= SummaryBranch; i++ {
count += f.CountByEntity(i)
}
return count
}
func (f *Filters) CountByEntity(entity uint8) int {
return len(*f.ResolveEntity(entity))
}
func (f *Filters) EntityCount() int {
var count int
for i := SummaryProject; i <= SummaryBranch; i++ {
if c := f.CountByEntity(i); c > 0 {
count++
}
}
return count
}
func (f *Filters) ResolveEntity(entityId uint8) *OrFilter {
switch entityId {
case SummaryProject:
return &f.Project
case SummaryLanguage:
return &f.Language
case SummaryEditor:
return &f.Editor
case SummaryOS:
return &f.OS
case SummaryMachine:
return &f.Machine
case SummaryLabel:
return &f.Label
case SummaryBranch:
return &f.Branch
default:
return &OrFilter{}
}
}
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

@ -12,21 +12,21 @@ type Heartbeat struct {
ID uint64 `gorm:"primary_key" hash:"ignore"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
Entity string `json:"entity" gorm:"not null"`
Type string `json:"type"`
Category string `json:"category"`
Project string `json:"project"`
Branch string `json:"branch"`
Project string `json:"project" gorm:"index:idx_project"`
Branch string `json:"branch" gorm:"index:idx_branch"`
Language string `json:"language" gorm:"index:idx_language"`
IsWrite bool `json:"is_write"`
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore"`
Editor string `json:"editor" gorm:"index:idx_editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore"`
OriginId string `json:"-" hash:"ignore"`
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
}
@ -94,8 +94,20 @@ 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
}
func GetEntityColumn(t uint8) string {
return []string{
"project",
"language",
"editor",
"operating_system",
"machine",
"label",
"branch",
}[t]
}

View File

@ -8,6 +8,7 @@ import (
const (
NSummaryTypes uint8 = 99
SummaryUnknown uint8 = 98
SummaryProject uint8 = 0
SummaryLanguage uint8 = 1
SummaryEditor uint8 = 2
@ -103,6 +104,20 @@ func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return s.MappedItems()[summaryType]
}
func (s *Summary) KeepOnly(types map[uint8]bool) *Summary {
if len(types) == 0 {
return s
}
for _, t := range SummaryTypes() {
if keep, ok := types[t]; !keep || !ok {
*s.ItemsByType(t) = []*SummaryItem{}
}
}
return s
}
/* 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"

View File

@ -168,6 +168,66 @@ func TestSummary_WithResolvedAliases(t *testing.T) {
assert.Empty(t, sut.Machines)
}
func TestSummary_KeepOnly(t *testing.T) {
newSummary := func() *Summary {
return &Summary{
Projects: []*SummaryItem{
{
Type: SummaryProject,
Key: "wakapi",
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
Total: 10 * time.Minute / time.Second,
},
{
Type: SummaryProject,
Key: "anchr",
Total: 10 * time.Minute / time.Second,
},
},
Languages: []*SummaryItem{
{
Type: SummaryLanguage,
Key: "Go",
Total: 10 * time.Minute / time.Second,
},
},
Editors: []*SummaryItem{
{
Type: SummaryEditor,
Key: "VSCode",
Total: 10 * time.Minute / time.Second,
},
},
}
}
var sut *Summary
sut = newSummary().KeepOnly(map[uint8]bool{}) // keep all
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
assert.Equal(t, 20*time.Minute, sut.TotalTime())
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
assert.Zero(t, sut.TotalTimeBy(SummaryLanguage))
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
assert.Equal(t, 20*time.Minute, sut.TotalTime())
sut = newSummary().KeepOnly(map[uint8]bool{SummaryEditor: true, SummaryLanguage: true})
assert.Zero(t, sut.TotalTimeBy(SummaryProject))
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
assert.Equal(t, 10*time.Minute, sut.TotalTime())
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
sut.FillMissing()
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
}
func TestSummaryItems_Sorted(t *testing.T) {
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute

View File

@ -1,7 +1,6 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@ -77,6 +76,26 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
return heartbeats, nil
}
func (r *HeartbeatRepository) GetAllWithinByFilters(from, to time.Time, user *models.User, filterMap map[string][]string) ([]*models.Heartbeat, error) {
// https://stackoverflow.com/a/20765152/3112139
var heartbeats []*models.Heartbeat
q := r.db.
Where(&models.Heartbeat{UserID: user.ID}).
Where("time >= ?", from.Local()).
Where("time < ?", to.Local()).
Order("time asc")
for col, vals := range filterMap {
q = q.Where(col+" in ?", vals)
}
if err := q.Find(&heartbeats).Error; err != nil {
return nil, err
}
return heartbeats, nil
}
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
@ -138,16 +157,10 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
}
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
columns := []string{"project", "language", "editor", "operating_system", "machine"}
if int(entityType) >= len(columns) {
// invalid entity type
return nil, errors.New("invalid entity type")
}
var results []string
if err := r.db.
Model(&models.Heartbeat{}).
Distinct(columns[entityType]).
Distinct(models.GetEntityColumn(entityType)).
Where(&models.Heartbeat{UserID: user.ID}).
Find(&results).Error; err != nil {
return nil, err

View File

@ -20,6 +20,7 @@ type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error
GetAll() ([]*models.Heartbeat, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetAllWithinByFilters(time.Time, time.Time, *models.User, map[string][]string) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
GetLastByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error)

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

@ -22,7 +22,15 @@ func NewDurationService(heartbeatService IHeartbeatService) *DurationService {
}
func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *models.Filters) (models.Durations, error) {
heartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user)
get := srv.heartbeatService.GetAllWithin
if filters != nil && !filters.IsEmpty() {
get = func(t1 time.Time, t2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
return srv.heartbeatService.GetAllWithinByFilters(t1, t2, user, filters)
}
}
heartbeats, err := get(from, to, user)
if err != nil {
return nil, err
}

View File

@ -4,6 +4,7 @@ import (
"github.com/muety/wakapi/mocks"
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/suite"
"math/rand"
"testing"
@ -175,7 +176,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get_Filtered() {
)
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
suite.HeartbeatService.On("GetAllWithinByFilters", from, to, suite.TestUser, mock.Anything).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
durations, err = sut.Get(from, to, suite.TestUser, models.NewFiltersWith(models.SummaryEditor, TestEditorGoland))
assert.Nil(suite.T(), err)

View File

@ -134,6 +134,14 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
return srv.augmented(heartbeats, user.ID)
}
func (srv *HeartbeatService) GetAllWithinByFilters(from, to time.Time, user *models.User, filters *models.Filters) ([]*models.Heartbeat, error) {
heartbeats, err := srv.repository.GetAllWithinByFilters(from, to, user, srv.filtersToColumnMap(filters))
if err != nil {
return nil, err
}
return srv.augmented(heartbeats, user.ID)
}
func (srv *HeartbeatService) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
return srv.repository.GetLatestByUser(user)
}
@ -237,3 +245,14 @@ func (srv *HeartbeatService) countTotalCacheKey() string {
func (srv *HeartbeatService) countCacheTtl() time.Duration {
return time.Duration(srv.config.App.CountCacheTTLMin) * time.Minute
}
func (srv *HeartbeatService) filtersToColumnMap(filters *models.Filters) map[string][]string {
columnMap := map[string][]string{}
for _, t := range models.SummaryTypes() {
f := filters.ResolveEntity(t)
if len(*f) > 0 {
columnMap[models.GetEntityColumn(t)] = *f
}
}
return columnMap
}

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

@ -49,6 +49,11 @@ func (s *SMTPSendingService) Send(mail *models.Mail) error {
if ok, _ := c.Extension("AUTH"); !ok {
return errors.New("smtp: server doesn't support AUTH")
}
if len(s.config.Username) == 0 || len(s.config.Password) == 0 {
return errors.New("smtp: server requires authentication, but no authentication is provided")
}
if err = c.Auth(s.auth); err != nil {
return err
}

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

@ -33,6 +33,7 @@ type IHeartbeatService interface {
CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetAllWithinByFilters(time.Time, time.Time, *models.User, *models.Filters) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByUser(*models.User) (*models.Heartbeat, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)

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('project', 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.6

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>