mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
25 Commits
Author | SHA1 | Date | |
---|---|---|---|
82b8951437 | |||
25464e9519 | |||
650fffa344 | |||
69627fbe11 | |||
561198b203 | |||
7c4a2024b6 | |||
7bcd6890d1 | |||
1e4e530c21 | |||
490cca05eb | |||
3780ae4255 | |||
628ea0b9dd | |||
0d64858721 | |||
c1c78d8d5b | |||
538b9d2463 | |||
f4612fd542 | |||
fb643571d2 | |||
101fdfb957 | |||
a4d47fb566 | |||
1a808f9197 | |||
ee31212cdd | |||
712949afc7 | |||
9dbc2039fc | |||
f3b738b250 | |||
cf3d293688 | |||
0fbb554fc3 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ build
|
||||
*.db
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
!testing/config.testing.yml
|
||||
pkged.go
|
||||
package.json
|
||||
yarn.lock
|
||||
|
45
README.md
45
README.md
@ -4,14 +4,13 @@
|
||||
|
||||
<p align="center">
|
||||
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
||||
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
|
||||
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
|
||||
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
|
||||
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
||||
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
||||
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
|
||||
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
|
||||
</p>
|
||||
@ -24,7 +23,7 @@
|
||||
<span> | </span>
|
||||
<a href="#-features">Features</a>
|
||||
<span> | </span>
|
||||
<a href="#-how-to-use">How to use</a>
|
||||
<a href="#%EF%B8%8F-how-to-use">How to use</a>
|
||||
<span> | </span>
|
||||
<a href="https://github.com/muety/wakapi/issues">Issues</a>
|
||||
<span> | </span>
|
||||
@ -45,6 +44,7 @@
|
||||
* [API Endpoints](#-api-endpoints)
|
||||
* [Integrations](#-integrations)
|
||||
* [Best Practices](#-best-practices)
|
||||
* [Tests](#-tests)
|
||||
* [Developer Notes](#-developer-notes)
|
||||
* [Support](#-support)
|
||||
* [FAQs](#-faqs)
|
||||
@ -59,6 +59,7 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
|
||||
* ✅ Built by developers for developers
|
||||
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
||||
* ✅ Badges
|
||||
* ✅ Weekly E-Mail Reports
|
||||
* ✅ REST API
|
||||
* ✅ Partially compatible with WakaTime
|
||||
* ✅ WakaTime integration
|
||||
@ -131,8 +132,8 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
|
||||
```ini
|
||||
[settings]
|
||||
|
||||
# Your Wakapi server URL or 'https://wakapi.dev/api/heartbeat' when using the cloud server
|
||||
api_url = http://localhost:3000/api/heartbeat
|
||||
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
|
||||
api_url = http://localhost:3000/api
|
||||
|
||||
# Your Wakapi API key (get it from the web interface after having created an account)
|
||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||
@ -282,12 +283,40 @@ Preview:
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
|
||||
|
||||
## 🤓 Developer Notes
|
||||
### Running tests
|
||||
## 🧪 Tests
|
||||
### Unit Tests
|
||||
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
|
||||
|
||||
#### How to run
|
||||
```bash
|
||||
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
$ CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
```
|
||||
|
||||
### API Tests
|
||||
API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned.
|
||||
|
||||
Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner.
|
||||
|
||||
To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded.
|
||||
|
||||
#### Prerequisites (Linux only)
|
||||
```bash
|
||||
# 1. sqlite (cli)
|
||||
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
|
||||
|
||||
# 2. screen
|
||||
$ sudo apt install screen # Fedora: sudo dnf install screen
|
||||
|
||||
# 3. newman
|
||||
$ npm install -g newman
|
||||
```
|
||||
|
||||
#### How to run (Linux only)
|
||||
```bash
|
||||
$ ./testing/run_api_tests.sh
|
||||
```
|
||||
|
||||
## 🤓 Developer Notes
|
||||
### Building web assets
|
||||
To keep things minimal, Wakapi does not contain a `package.json`, `node_modules` or any sort of frontend build step. Instead, all JS and CSS assets are included as static files and checked in to Git. This way we can avoid requiring NodeJS to build Wakapi. However, for [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) it makes sense to run it through a "build" step to benefit from purging and significantly reduce it in size. To only require this at the time of development, the compiled asset is checked in to Git as well. Similarly, [Iconify](https://iconify.design/docs/icon-bundles/) bundles are also created at development time and checked in to the repo.
|
||||
|
||||
|
@ -13,6 +13,7 @@ app:
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
|
||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
|
@ -64,7 +64,7 @@ type appConfig struct {
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
||||
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
||||
ImportBatchSize int `yaml:"import_batch_size" default:"100" 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"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
Colors map[string]map[string]string `yaml:"-"`
|
||||
@ -197,6 +197,9 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -341,22 +344,27 @@ func Load(version string) *Config {
|
||||
}
|
||||
}
|
||||
|
||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
|
||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
|
||||
}
|
||||
|
||||
if config.Db.MaxConn <= 0 {
|
||||
logbuch.Fatal("you must allow at least one database connection")
|
||||
}
|
||||
|
||||
if config.Sentry.Dsn != "" {
|
||||
logbuch.Info("enabling sentry integration")
|
||||
initSentry(config.Sentry, config.IsDev())
|
||||
}
|
||||
|
||||
// some validation checks
|
||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
|
||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
|
||||
}
|
||||
if config.Db.MaxConn <= 0 {
|
||||
logbuch.Fatal("you must allow at least one database connection")
|
||||
}
|
||||
if config.Mail.Provider != "" && findString(config.Mail.Provider, emailProviders, "") == "" {
|
||||
logbuch.Fatal("unknown mail provider '%s'", config.Mail.Provider)
|
||||
}
|
||||
if _, err := time.Parse("15:04", config.App.GetWeeklyReportTime()); err != nil {
|
||||
logbuch.Fatal("invalid interval set for report_time_weekly")
|
||||
}
|
||||
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
|
||||
logbuch.Fatal("invalid interval set for aggregation_time")
|
||||
}
|
||||
|
||||
Set(config)
|
||||
return Get()
|
||||
|
File diff suppressed because it is too large
Load Diff
11
main.go
11
main.go
@ -49,6 +49,7 @@ var (
|
||||
heartbeatRepository repositories.IHeartbeatRepository
|
||||
userRepository repositories.IUserRepository
|
||||
languageMappingRepository repositories.ILanguageMappingRepository
|
||||
projectLabelRepository repositories.IProjectLabelRepository
|
||||
summaryRepository repositories.ISummaryRepository
|
||||
keyValueRepository repositories.IKeyValueRepository
|
||||
)
|
||||
@ -58,6 +59,7 @@ var (
|
||||
heartbeatService services.IHeartbeatService
|
||||
userService services.IUserService
|
||||
languageMappingService services.ILanguageMappingService
|
||||
projectLabelService services.IProjectLabelService
|
||||
summaryService services.ISummaryService
|
||||
aggregationService services.IAggregationService
|
||||
mailService services.IMailService
|
||||
@ -113,6 +115,7 @@ func main() {
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||
if config.Db.Dialect == "sqlite3" {
|
||||
db.Raw("PRAGMA foreign_keys = ON;")
|
||||
db.DisableForeignKeyConstraintWhenMigrating = true
|
||||
}
|
||||
|
||||
if config.IsDev() {
|
||||
@ -135,6 +138,7 @@ func main() {
|
||||
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
||||
userRepository = repositories.NewUserRepository(db)
|
||||
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
||||
projectLabelRepository = repositories.NewProjectLabelRepository(db)
|
||||
summaryRepository = repositories.NewSummaryRepository(db)
|
||||
keyValueRepository = repositories.NewKeyValueRepository(db)
|
||||
|
||||
@ -142,8 +146,9 @@ func main() {
|
||||
aliasService = services.NewAliasService(aliasRepository)
|
||||
userService = services.NewUserService(userRepository)
|
||||
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
||||
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
|
||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
|
||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||
mailService = mail.NewMailService()
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
@ -168,11 +173,12 @@ func main() {
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(userService, summaryService)
|
||||
wakatimeV1StatsHandler := wtV1Routes.NewStatsHandler(userService, summaryService)
|
||||
wakatimeV1UsersHandler := wtV1Routes.NewUsersHandler(userService, heartbeatService)
|
||||
wakatimeV1ProjectsHandler := wtV1Routes.NewProjectsHandler(userService, heartbeatService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
|
||||
// MVC Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
@ -207,6 +213,7 @@ func main() {
|
||||
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1UsersHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1ProjectsHandler.RegisterRoutes(apiRouter)
|
||||
shieldV1BadgeHandler.RegisterRoutes(apiRouter)
|
||||
|
||||
// Static Routes
|
||||
|
@ -55,6 +55,11 @@ func (m *HeartbeatServiceMock) GetLatestByOriginAndUser(s string, user *models.U
|
||||
return args.Get(0).(*models.Heartbeat), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetEntitySetByUser(u uint8, user *models.User) ([]string, error) {
|
||||
args := m.Called(u, user)
|
||||
return args.Get(0).([]string), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
|
||||
args := m.Called(time)
|
||||
return args.Error(0)
|
||||
|
35
mocks/project_label_service.go
Normal file
35
mocks/project_label_service.go
Normal file
@ -0,0 +1,35 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type ProjectLabelServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (p *ProjectLabelServiceMock) GetById(u uint) (*models.ProjectLabel, error) {
|
||||
args := p.Called(u)
|
||||
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
||||
}
|
||||
|
||||
func (p *ProjectLabelServiceMock) GetByUser(s string) ([]*models.ProjectLabel, error) {
|
||||
args := p.Called(s)
|
||||
return args.Get(0).([]*models.ProjectLabel), args.Error(1)
|
||||
}
|
||||
|
||||
func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*models.ProjectLabel, error) {
|
||||
args := p.Called(s)
|
||||
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
|
||||
}
|
||||
|
||||
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||
args := p.Called(l)
|
||||
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
||||
}
|
||||
|
||||
func (p *ProjectLabelServiceMock) Delete(l *models.ProjectLabel) error {
|
||||
args := p.Called(l)
|
||||
return args.Error(0)
|
||||
}
|
11
models/compat/wakatime/v1/project.go
Normal file
11
models/compat/wakatime/v1/project.go
Normal file
@ -0,0 +1,11 @@
|
||||
package v1
|
||||
|
||||
type ProjectsViewModel struct {
|
||||
Data []*Project `json:"data"`
|
||||
}
|
||||
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Repository string `json:"repository"`
|
||||
}
|
@ -31,9 +31,6 @@ type StatsData struct {
|
||||
func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewModel {
|
||||
totalTime := summary.TotalTime()
|
||||
numDays := int(summary.ToTime.T().Sub(summary.FromTime.T()).Hours() / 24)
|
||||
if math.IsInf(float64(numDays), 0) {
|
||||
numDays = 0
|
||||
}
|
||||
|
||||
data := &StatsData{
|
||||
Username: summary.UserID,
|
||||
@ -45,6 +42,10 @@ func NewStatsFrom(summary *models.Summary, filters *models.Filters) *StatsViewMo
|
||||
DaysIncludingHolidays: numDays,
|
||||
}
|
||||
|
||||
if math.IsInf(data.DailyAverage, 0) || math.IsNaN(data.DailyAverage) {
|
||||
data.DailyAverage = 0
|
||||
}
|
||||
|
||||
editors := make([]*SummariesEntry, len(summary.Editors))
|
||||
for i, e := range summary.Editors {
|
||||
editors[i] = convertEntry(e, summary.TotalTimeBy(models.SummaryEditor))
|
||||
|
@ -6,6 +6,7 @@ type Filters struct {
|
||||
Language string
|
||||
Editor string
|
||||
Machine string
|
||||
Label string
|
||||
}
|
||||
|
||||
type FilterElement struct {
|
||||
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
return &Filters{Editor: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Machine: key}
|
||||
case SummaryLabel:
|
||||
return &Filters{Label: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
|
||||
return true, SummaryEditor, f.Editor
|
||||
} else if f.Machine != "" {
|
||||
return true, SummaryMachine, f.Machine
|
||||
} else if f.Label != "" {
|
||||
return true, SummaryLabel, f.Label
|
||||
}
|
||||
return false, 0, ""
|
||||
}
|
||||
|
13
models/project_label.go
Normal file
13
models/project_label.go
Normal file
@ -0,0 +1,13 @@
|
||||
package models
|
||||
|
||||
type ProjectLabel struct {
|
||||
ID uint `json:"id" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
|
||||
ProjectKey string `json:"project"`
|
||||
Label string `json:"label" gorm:"type:varchar(64)"`
|
||||
}
|
||||
|
||||
func (l *ProjectLabel) IsValid() bool {
|
||||
return l.ProjectKey != "" && l.Label != ""
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
@ -12,9 +13,11 @@ const (
|
||||
SummaryEditor uint8 = 2
|
||||
SummaryOS uint8 = 3
|
||||
SummaryMachine uint8 = 4
|
||||
SummaryLabel uint8 = 5
|
||||
)
|
||||
|
||||
const UnknownSummaryKey = "unknown"
|
||||
const DefaultProjectLabel = "default"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
@ -27,6 +30,7 @@ type Summary struct {
|
||||
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Labels SummaryItems `json:"labels" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type SummaryItems []*SummaryItem
|
||||
@ -68,6 +72,10 @@ type SummaryParams struct {
|
||||
type AliasResolver func(t uint8, k string) string
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
|
||||
}
|
||||
|
||||
func NativeSummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
|
||||
@ -77,6 +85,7 @@ func (s *Summary) Sorted() *Summary {
|
||||
sort.Sort(sort.Reverse(s.OperatingSystems))
|
||||
sort.Sort(sort.Reverse(s.Languages))
|
||||
sort.Sort(sort.Reverse(s.Editors))
|
||||
sort.Sort(sort.Reverse(s.Labels))
|
||||
return s
|
||||
}
|
||||
|
||||
@ -91,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
||||
SummaryEditor: &s.Editors,
|
||||
SummaryOS: &s.OperatingSystems,
|
||||
SummaryMachine: &s.Machines,
|
||||
SummaryLabel: &s.Labels,
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,7 +119,7 @@ of time than the other ones.
|
||||
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
||||
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||
*/
|
||||
func (s *Summary) FillUnknown() {
|
||||
func (s *Summary) FillMissing() {
|
||||
types := s.Types()
|
||||
typeItems := s.MappedItems()
|
||||
missingTypes := make([]uint8, 0)
|
||||
@ -125,15 +135,46 @@ func (s *Summary) FillUnknown() {
|
||||
return
|
||||
}
|
||||
|
||||
timeSum := s.TotalTime()
|
||||
|
||||
// construct dummy item for all missing types
|
||||
presentType, err := s.findFirstPresentType()
|
||||
if err != nil {
|
||||
return // all types are either zero or missing entirely, nothing to fill
|
||||
}
|
||||
for _, t := range missingTypes {
|
||||
*typeItems[t] = append(*typeItems[t], &SummaryItem{
|
||||
Type: t,
|
||||
Key: UnknownSummaryKey,
|
||||
Total: timeSum,
|
||||
})
|
||||
s.FillBy(presentType, t)
|
||||
}
|
||||
}
|
||||
|
||||
// inplace!
|
||||
func (s *Summary) FillBy(fromType uint8, toType uint8) {
|
||||
typeItems := s.MappedItems()
|
||||
totalWanted := s.TotalTimeBy(fromType)
|
||||
totalActual := s.TotalTimeBy(toType)
|
||||
|
||||
key := UnknownSummaryKey
|
||||
if toType == SummaryLabel {
|
||||
key = DefaultProjectLabel
|
||||
}
|
||||
|
||||
existingEntryIdx := -1
|
||||
for i, item := range *typeItems[toType] {
|
||||
if item.Key == key {
|
||||
existingEntryIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
total := (totalWanted - totalActual) / time.Second // workaround
|
||||
if total > 0 {
|
||||
if existingEntryIdx >= 0 {
|
||||
(*typeItems[toType])[existingEntryIdx].Total = total
|
||||
} else {
|
||||
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
|
||||
Type: toType,
|
||||
Key: key,
|
||||
Total: total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -141,14 +182,12 @@ func (s *Summary) TotalTime() time.Duration {
|
||||
var timeSum time.Duration
|
||||
|
||||
mappedItems := s.MappedItems()
|
||||
// calculate total duration from any of the present sets of items
|
||||
for _, t := range s.Types() {
|
||||
if items := mappedItems[t]; len(*items) > 0 {
|
||||
for _, item := range *items {
|
||||
timeSum += item.Total
|
||||
}
|
||||
break
|
||||
}
|
||||
t, err := s.findFirstPresentType()
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
for _, item := range *mappedItems[t] {
|
||||
timeSum += item.Total
|
||||
}
|
||||
|
||||
return timeSum * time.Second
|
||||
@ -231,10 +270,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
s.Languages = processAliases(s.Languages)
|
||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||
s.Machines = processAliases(s.Machines)
|
||||
s.Labels = processAliases(s.Labels)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Summary) findFirstPresentType() (uint8, error) {
|
||||
for _, t := range s.Types() {
|
||||
if s.TotalTimeBy(t) != 0 {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return 127, errors.New("no type present")
|
||||
}
|
||||
|
||||
func (s *SummaryItem) TotalFixed() time.Duration {
|
||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
||||
// TODO: fix some day, while migrating persisted summary items
|
||||
|
@ -6,7 +6,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSummary_FillUnknown(t *testing.T) {
|
||||
func TestSummary_FillMissing(t *testing.T) {
|
||||
testDuration := 10 * time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
sut.FillUnknown()
|
||||
sut.FillMissing()
|
||||
|
||||
itemLists := [][]*SummaryItem{
|
||||
sut.Machines,
|
||||
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
|
||||
for _, l := range itemLists {
|
||||
assert.Len(t, l, 1)
|
||||
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
||||
assert.Equal(t, testDuration, l[0].Total)
|
||||
assert.Equal(t, testDuration, l[0].TotalFixed())
|
||||
}
|
||||
|
||||
assert.Len(t, sut.Labels, 1)
|
||||
assert.Equal(t, DefaultProjectLabel, sut.Labels[0].Key)
|
||||
assert.Equal(t, testDuration, sut.Labels[0].TotalFixed())
|
||||
}
|
||||
|
||||
func TestSummary_TotalTimeBy(t *testing.T) {
|
||||
|
@ -23,6 +23,7 @@ type User struct {
|
||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||
WakatimeApiKey string `json:"-"`
|
||||
|
@ -6,6 +6,8 @@ type SettingsViewModel struct {
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Aliases []*SettingsVMCombinedAlias
|
||||
Labels []*SettingsVMCombinedLabel
|
||||
Projects []string
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
|
||||
Values []string
|
||||
}
|
||||
|
||||
type SettingsVMCombinedLabel struct {
|
||||
Key string
|
||||
Values []string
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
|
@ -1,6 +1,7 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@ -63,6 +64,7 @@ func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *mode
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
// https://stackoverflow.com/a/20765152/3112139
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
@ -125,9 +127,8 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
}
|
||||
|
||||
if err := r.db.
|
||||
Model(&models.User{}).
|
||||
Select("users.id as user, count(heartbeats.id) as count").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Model(&models.Heartbeat{}).
|
||||
Select("user_id as user, count(id) as count").
|
||||
Where("user_id in ?", userIds).
|
||||
Group("user").
|
||||
Find(&counts).Error; err != nil {
|
||||
@ -136,6 +137,24 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
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]).
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
if err := r.db.
|
||||
Where("time <= ?", t.Local()).
|
||||
|
60
repositories/project_label.go
Normal file
60
repositories/project_label.go
Normal file
@ -0,0 +1,60 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ProjectLabelRepository struct {
|
||||
config *config.Config
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewProjectLabelRepository(db *gorm.DB) *ProjectLabelRepository {
|
||||
return &ProjectLabelRepository{config: config.Get(), db: db}
|
||||
}
|
||||
|
||||
func (r *ProjectLabelRepository) GetAll() ([]*models.ProjectLabel, error) {
|
||||
var labels []*models.ProjectLabel
|
||||
if err := r.db.Find(&labels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error) {
|
||||
label := &models.ProjectLabel{}
|
||||
if err := r.db.Where(&models.ProjectLabel{ID: id}).First(label).Error; err != nil {
|
||||
return label, err
|
||||
}
|
||||
return label, nil
|
||||
}
|
||||
|
||||
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
|
||||
var labels []*models.ProjectLabel
|
||||
if err := r.db.
|
||||
Where(&models.ProjectLabel{UserID: userId}).
|
||||
Find(&labels).Error; err != nil {
|
||||
return labels, err
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (r *ProjectLabelRepository) Insert(label *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||
if !label.IsValid() {
|
||||
return nil, errors.New("invalid label")
|
||||
}
|
||||
result := r.db.Create(label)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return label, nil
|
||||
}
|
||||
|
||||
func (r *ProjectLabelRepository) Delete(id uint) error {
|
||||
return r.db.
|
||||
Where("id = ?", id).
|
||||
Delete(models.ProjectLabel{}).Error
|
||||
}
|
@ -27,6 +27,7 @@ type IHeartbeatRepository interface {
|
||||
Count() (int64, error)
|
||||
CountByUser(*models.User) (int64, error)
|
||||
CountByUsers([]*models.User) ([]*models.CountByUser, error)
|
||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||
DeleteBefore(time.Time) error
|
||||
}
|
||||
|
||||
@ -45,6 +46,14 @@ type ILanguageMappingRepository interface {
|
||||
Delete(uint) error
|
||||
}
|
||||
|
||||
type IProjectLabelRepository interface {
|
||||
GetAll() ([]*models.ProjectLabel, error)
|
||||
GetById(uint) (*models.ProjectLabel, error)
|
||||
GetByUser(string) ([]*models.ProjectLabel, error)
|
||||
Insert(*models.ProjectLabel) (*models.ProjectLabel, error)
|
||||
Delete(uint) error
|
||||
}
|
||||
|
||||
type ISummaryRepository interface {
|
||||
Insert(*models.Summary) error
|
||||
GetAll() ([]*models.Summary, error)
|
||||
|
@ -23,6 +23,7 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Preload("Labels", "type = ?", models.SummaryLabel).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@ -48,6 +49,7 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
Preload("Labels", "type = ?", models.SummaryLabel).
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -147,6 +147,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
"share_oss": user.ShareOSs,
|
||||
"share_projects": user.ShareProjects,
|
||||
"share_machines": user.ShareMachines,
|
||||
"share_labels": user.ShareLabels,
|
||||
"wakatime_api_key": user.WakatimeApiKey,
|
||||
"has_data": user.HasData,
|
||||
"reset_token": user.ResetToken,
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
customMiddleware "github.com/muety/wakapi/middlewares/custom"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -34,12 +35,14 @@ type heartbeatResponseVm struct {
|
||||
}
|
||||
|
||||
func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/heartbeat").Subrouter()
|
||||
r := router.PathPrefix("").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
customMiddleware.NewWakatimeRelayMiddleware().Handler,
|
||||
)
|
||||
r.Path("").Methods(http.MethodPost).HandlerFunc(h.Post)
|
||||
r.PathPrefix("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
|
||||
r.PathPrefix("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
|
||||
r.PathPrefix("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
|
||||
}
|
||||
|
||||
// @Summary Push a new heartbeat
|
||||
@ -51,8 +54,12 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @Success 201
|
||||
// @Router /heartbeat [post]
|
||||
func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||
if err != nil {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
var heartbeats []*models.Heartbeat
|
||||
user := middlewares.GetPrincipal(r)
|
||||
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
|
||||
machineName := r.Header.Get("X-Machine-Name")
|
||||
|
||||
|
@ -27,6 +27,7 @@ const (
|
||||
DescLanguages = "Total seconds for each language."
|
||||
DescOperatingSystems = "Total seconds for each operating system."
|
||||
DescMachines = "Total seconds for each machine."
|
||||
DescLabels = "Total seconds for each project label."
|
||||
|
||||
DescAdminTotalTime = "Total seconds (all users, all time)."
|
||||
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
|
||||
@ -198,6 +199,15 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
})
|
||||
}
|
||||
|
||||
for _, m := range summaryToday.Labels {
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_label_seconds_total",
|
||||
Desc: DescLabels,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: m.Key}},
|
||||
})
|
||||
}
|
||||
|
||||
return &metrics, nil
|
||||
}
|
||||
|
||||
|
@ -16,7 +16,7 @@ import (
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
@ -75,7 +75,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
|
||||
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
|
||||
// negative value means no limit
|
||||
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
@ -83,22 +83,38 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
var permitEntity bool
|
||||
var filters *models.Filters
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
permitEntity = user.ShareProjects
|
||||
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
||||
case "os":
|
||||
permitEntity = user.ShareOSs
|
||||
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
||||
case "editor":
|
||||
permitEntity = user.ShareEditors
|
||||
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
||||
case "language":
|
||||
permitEntity = user.ShareLanguages
|
||||
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
||||
case "machine":
|
||||
permitEntity = user.ShareMachines
|
||||
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
||||
case "label":
|
||||
permitEntity = user.ShareLabels
|
||||
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
|
||||
default:
|
||||
permitEntity = true
|
||||
filters = &models.Filters{}
|
||||
}
|
||||
|
||||
if !permitEntity {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("user did not opt in to share entity-specific data"))
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -45,18 +46,14 @@ func (h *AllTimeHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @Success 200 {object} v1.AllTimeViewModel
|
||||
// @Router /compat/wakatime/v1/users/{user}/all_time_since_today [get]
|
||||
func (h *AllTimeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
values, _ := url.ParseQuery(r.URL.RawQuery)
|
||||
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := middlewares.GetPrincipal(r)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||
if err != nil {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(authorizedUser)
|
||||
summary, err, status := h.loadUserSummary(user)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
|
73
routes/compat/wakatime/v1/projects.go
Normal file
73
routes/compat/wakatime/v1/projects.go
Normal file
@ -0,0 +1,73 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type ProjectsHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
}
|
||||
|
||||
func NewProjectsHandler(userService services.IUserService, heartbeatsService services.IHeartbeatService) *ProjectsHandler {
|
||||
return &ProjectsHandler{
|
||||
userSrvc: userService,
|
||||
heartbeatSrvc: heartbeatsService,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ProjectsHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/compat/wakatime/v1/users/{user}/projects").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||
)
|
||||
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// @Summary Retrieve and fitler the user's projects
|
||||
// @Description Mimics https://wakatime.com/developers#projects
|
||||
// @ID get-wakatime-projects
|
||||
// @Tags wakatime
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||
// @Param q query string true "Query to filter projects by"
|
||||
// @Security ApiKeyAuth
|
||||
// @Success 200 {object} v1.ProjectsViewModel
|
||||
// @Router /compat/wakatime/v1/users/{user}/projects [get]
|
||||
func (h *ProjectsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||
if err != nil {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
results, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("something went wrong"))
|
||||
conf.Log().Request(r).Error(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query().Get("q")
|
||||
|
||||
projects := make([]*v1.Project, 0, len(results))
|
||||
for _, p := range results {
|
||||
if strings.HasPrefix(p, q) {
|
||||
projects = append(projects, &v1.Project{ID: p, Name: p})
|
||||
}
|
||||
}
|
||||
|
||||
vm := &v1.ProjectsViewModel{Data: projects}
|
||||
utils.RespondJSON(w, r, http.StatusOK, vm)
|
||||
}
|
@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
|
||||
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
|
||||
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
|
||||
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
|
@ -7,6 +7,7 @@ import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -36,7 +37,7 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
|
||||
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
// TODO: Support parameters: project, branches, timeout, writes_only, timezone
|
||||
// TODO: Support parameters: project, branches, timeout, writes_only
|
||||
// See https://wakatime.com/developers#summaries.
|
||||
// Timezone can be specified via an offset suffix (e.g. +02:00) in date strings.
|
||||
// Requires https://github.com/muety/wakapi/issues/108.
|
||||
@ -54,13 +55,9 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @Success 200 {object} v1.SummariesViewModel
|
||||
// @Router /compat/wakatime/v1/users/{user}/summaries [get]
|
||||
func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := middlewares.GetPrincipal(r)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
_, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||
if err != nil {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
summaries, err, status := h.loadUserSummaries(r)
|
||||
@ -82,35 +79,42 @@ func (h *SummariesHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
params := r.URL.Query()
|
||||
rangeParam, startParam, endParam := params.Get("range"), params.Get("start"), params.Get("end")
|
||||
rangeParam, startParam, endParam, tzParam := params.Get("range"), params.Get("start"), params.Get("end"), params.Get("timezone")
|
||||
|
||||
timezone := user.TZ()
|
||||
if tzParam != "" {
|
||||
if tz, err := time.LoadLocation(tzParam); err == nil {
|
||||
timezone = tz
|
||||
}
|
||||
}
|
||||
|
||||
var start, end time.Time
|
||||
if rangeParam != "" {
|
||||
// range param takes precedence
|
||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, user.TZ()); err == nil {
|
||||
if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(rangeParam, timezone); err == nil {
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
return nil, errors.New("invalid 'range' parameter"), http.StatusBadRequest
|
||||
}
|
||||
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, user.TZ()); err == nil && startParam == endParam {
|
||||
} else if err, parsedFrom, parsedTo := utils.ResolveIntervalRawTZ(startParam, timezone); err == nil && startParam == endParam {
|
||||
// also accept start param to be a range param
|
||||
start, end = parsedFrom, parsedTo
|
||||
} else {
|
||||
// eventually, consider start and end params a date
|
||||
var err error
|
||||
|
||||
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), user.TZ())
|
||||
start, err = utils.ParseDateTimeTZ(strings.Replace(startParam, " ", "+", 1), timezone)
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
|
||||
}
|
||||
|
||||
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), user.TZ())
|
||||
end, err = utils.ParseDateTimeTZ(strings.Replace(endParam, " ", "+", 1), timezone)
|
||||
if err != nil {
|
||||
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
// wakatime iterprets end date as "inclusive", wakapi usually as "exclusive"
|
||||
// wakatime interprets end date as "inclusive", wakapi usually as "exclusive"
|
||||
// i.e. for wakatime, an interval 2021-04-29 - 2021-04-29 is actually 2021-04-29 - 2021-04-30,
|
||||
// while for wakapi it would be empty
|
||||
// see https://github.com/muety/wakapi/issues/192
|
||||
|
@ -5,6 +5,7 @@ import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -42,18 +43,13 @@ func (h *UsersHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @Success 200 {object} v1.UserViewModel
|
||||
// @Router /compat/wakatime/v1/users/{user} [get]
|
||||
func (h *UsersHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
vars := mux.Vars(r)
|
||||
|
||||
requestedUser := vars["user"]
|
||||
authorizedUser := middlewares.GetPrincipal(r)
|
||||
|
||||
if requestedUser != authorizedUser.ID && requestedUser != "current" {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
return
|
||||
wakapiUser, err := routeutils.CheckEffectiveUser(w, r, h.userSrvc, "current")
|
||||
if err != nil {
|
||||
return // response was already sent by util function
|
||||
}
|
||||
|
||||
user := v1.NewFromUser(authorizedUser)
|
||||
if hb, err := h.heartbeatSrvc.GetLatestByUser(authorizedUser); err == nil {
|
||||
user := v1.NewFromUser(wakapiUser)
|
||||
if hb, err := h.heartbeatSrvc.GetLatestByUser(wakapiUser); err == nil {
|
||||
user = user.WithLatestHeartbeat(hb)
|
||||
} else {
|
||||
conf.Log().Request(r).Error("%v", err)
|
||||
|
@ -112,6 +112,9 @@ func typeName(t uint8) string {
|
||||
if t == models.SummaryMachine {
|
||||
return "machine"
|
||||
}
|
||||
if t == models.SummaryLabel {
|
||||
return "label"
|
||||
}
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
|
@ -14,10 +14,14 @@ import (
|
||||
"github.com/muety/wakapi/services/imports"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const criticalError = "a critical error has occurred, sorry"
|
||||
|
||||
type SettingsHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
@ -26,6 +30,7 @@ type SettingsHandler struct {
|
||||
aliasSrvc services.IAliasService
|
||||
aggregationSrvc services.IAggregationService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
projectLabelSrvc services.IProjectLabelService
|
||||
keyValueSrvc services.IKeyValueService
|
||||
mailSrvc services.IMailService
|
||||
httpClient *http.Client
|
||||
@ -40,6 +45,7 @@ func NewSettingsHandler(
|
||||
aliasService services.IAliasService,
|
||||
aggregationService services.IAggregationService,
|
||||
languageMappingService services.ILanguageMappingService,
|
||||
projectLabelService services.IProjectLabelService,
|
||||
keyValueService services.IKeyValueService,
|
||||
mailService services.IMailService,
|
||||
) *SettingsHandler {
|
||||
@ -49,6 +55,7 @@ func NewSettingsHandler(
|
||||
aliasSrvc: aliasService,
|
||||
aggregationSrvc: aggregationService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
projectLabelSrvc: projectLabelService,
|
||||
userSrvc: userService,
|
||||
heartbeatSrvc: heartbeatService,
|
||||
keyValueSrvc: keyValueService,
|
||||
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
||||
return h.actionDeleteAlias
|
||||
case "add_alias":
|
||||
return h.actionAddAlias
|
||||
case "add_label":
|
||||
return h.actionAddLabel
|
||||
case "delete_label":
|
||||
return h.actionDeleteLabel
|
||||
case "delete_mapping":
|
||||
return h.actionDeleteLanguageMapping
|
||||
case "add_mapping":
|
||||
@ -252,6 +262,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
|
||||
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
|
||||
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
|
||||
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
|
||||
user.ShareLabels, err = strconv.ParseBool(r.PostFormValue("share_labels"))
|
||||
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
|
||||
|
||||
if err != nil {
|
||||
@ -313,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
|
||||
return http.StatusOK, "alias added successfully", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionAddLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
user := middlewares.GetPrincipal(r)
|
||||
|
||||
label := &models.ProjectLabel{
|
||||
UserID: user.ID,
|
||||
ProjectKey: r.PostFormValue("key"),
|
||||
Label: r.PostFormValue("value"),
|
||||
}
|
||||
|
||||
if !label.IsValid() {
|
||||
return http.StatusBadRequest, "", "invalid input"
|
||||
}
|
||||
|
||||
if _, err := h.projectLabelSrvc.Create(label); err != nil {
|
||||
// TODO: distinguish between bad request, conflict and server error
|
||||
return http.StatusBadRequest, "", "invalid input"
|
||||
}
|
||||
|
||||
return http.StatusOK, "label added successfully", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := middlewares.GetPrincipal(r)
|
||||
labelKey := r.PostFormValue("key")
|
||||
labelValue := r.PostFormValue("value")
|
||||
|
||||
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, "", "could not delete label"
|
||||
}
|
||||
|
||||
if projectLabels, ok := labelMap[labelKey]; ok {
|
||||
for _, l := range projectLabels {
|
||||
if l.Label == labelValue {
|
||||
if err := h.projectLabelSrvc.Delete(l); err != nil {
|
||||
return http.StatusInternalServerError, "", "could not delete label"
|
||||
}
|
||||
return http.StatusOK, "label deleted successfully", ""
|
||||
}
|
||||
}
|
||||
return http.StatusNotFound, "", "label not found"
|
||||
} else {
|
||||
return http.StatusNotFound, "", "project not found"
|
||||
}
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
@ -450,6 +514,13 @@ func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Req
|
||||
|
||||
h.regenerateSummaries(user)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@ -546,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
||||
|
||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
|
||||
// mappings
|
||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
|
||||
|
||||
// aliases
|
||||
aliases, err := h.aliasSrvc.GetByUser(user.ID)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while building alias map - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
}
|
||||
aliasMap := make(map[string][]*models.Alias)
|
||||
for _, a := range aliases {
|
||||
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
||||
@ -571,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
||||
combinedAliases = append(combinedAliases, ca)
|
||||
}
|
||||
|
||||
// labels
|
||||
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
}
|
||||
|
||||
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
|
||||
for _, l := range labelMap {
|
||||
cl := &view.SettingsVMCombinedLabel{
|
||||
Key: l[0].ProjectKey,
|
||||
Values: make([]string, len(l)),
|
||||
}
|
||||
for i, l1 := range l {
|
||||
cl.Values[i] = l1.Label
|
||||
}
|
||||
combinedLabels = append(combinedLabels, cl)
|
||||
}
|
||||
sort.Slice(combinedLabels, func(i, j int) bool {
|
||||
return strings.Compare(combinedLabels[i].Key, combinedLabels[j].Key) < 0
|
||||
})
|
||||
|
||||
// projects
|
||||
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching projects - %v", err)
|
||||
return &view.SettingsViewModel{Error: criticalError}
|
||||
}
|
||||
sort.Strings(projects)
|
||||
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Aliases: combinedAliases,
|
||||
Labels: combinedLabels,
|
||||
Projects: projects,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
|
46
routes/utils/user_utils.go
Normal file
46
routes/utils/user_utils.go
Normal file
@ -0,0 +1,46 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// CheckEffectiveUser extracts the requested user from a URL (like '/users/{user}'), compares it with the currently authorized user and writes an HTTP error if they differ.
|
||||
// Fallback can be used to manually set a value for '{user}' if none is present.
|
||||
func CheckEffectiveUser(w http.ResponseWriter, r *http.Request, userService services.IUserService, fallback string) (*models.User, error) {
|
||||
var vars = mux.Vars(r)
|
||||
var authorizedUser, requestedUser *models.User
|
||||
|
||||
if vars["user"] == "" {
|
||||
vars["user"] = fallback
|
||||
}
|
||||
|
||||
authorizedUser = middlewares.GetPrincipal(r)
|
||||
if authorizedUser != nil {
|
||||
if vars["user"] == "current" {
|
||||
vars["user"] = authorizedUser.ID
|
||||
}
|
||||
}
|
||||
|
||||
requestedUser, err := userService.GetUserById(vars["user"])
|
||||
if err != nil {
|
||||
err := errors.New("user not found")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
w.Write([]byte(err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID {
|
||||
err := errors.New(conf.ErrUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authorizedUser, nil
|
||||
}
|
@ -1,3 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:5
|
||||
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:8
|
@ -1,8 +1,12 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
@ -10,6 +14,7 @@ import (
|
||||
|
||||
type HeartbeatService struct {
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository repositories.IHeartbeatRepository
|
||||
languageMappingSrvc ILanguageMappingService
|
||||
}
|
||||
@ -17,12 +22,14 @@ type HeartbeatService struct {
|
||||
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
||||
return &HeartbeatService{
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
repository: heartbeatRepo,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
|
||||
srv.updateEntityUserCacheByHeartbeat(heartbeat)
|
||||
return srv.repository.InsertBatch([]*models.Heartbeat{heartbeat})
|
||||
}
|
||||
|
||||
@ -36,6 +43,7 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
filteredHeartbeats = append(filteredHeartbeats, hb)
|
||||
hashes[hb.Hash] = true
|
||||
}
|
||||
srv.updateEntityUserCacheByHeartbeat(hb)
|
||||
}
|
||||
|
||||
return srv.repository.InsertBatch(filteredHeartbeats)
|
||||
@ -73,6 +81,28 @@ func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetFirstByUsers()
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
||||
if results, found := srv.cache.Get(cacheKey); found {
|
||||
return utils.SetToStrings(results.(map[string]bool)), nil
|
||||
}
|
||||
|
||||
results, err := srv.repository.GetEntitySetByUser(entityType, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filtered := make([]string, 0, len(results))
|
||||
for _, r := range results {
|
||||
if strings.TrimSpace(r) != "" {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
|
||||
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
return srv.repository.DeleteBefore(t)
|
||||
}
|
||||
@ -89,3 +119,26 @@ func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId st
|
||||
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *models.User) string {
|
||||
return fmt.Sprintf("entity_set_%d_%s", entityType, user.ID)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
|
||||
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
||||
if entities, found := srv.cache.Get(cacheKey); found {
|
||||
if _, ok := entities.(map[string]bool)[entityKey]; !ok {
|
||||
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
|
||||
// -> invalidate cache
|
||||
srv.cache.Delete(cacheKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbeat) {
|
||||
srv.updateEntityUserCache(models.SummaryProject, hb.Project, hb.User)
|
||||
srv.updateEntityUserCache(models.SummaryLanguage, hb.Language, hb.User)
|
||||
srv.updateEntityUserCache(models.SummaryEditor, hb.Editor, hb.User)
|
||||
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
|
||||
srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
|
||||
}
|
||||
|
78
services/project_label.go
Normal file
78
services/project_label.go
Normal file
@ -0,0 +1,78 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ProjectLabelService struct {
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository repositories.IProjectLabelRepository
|
||||
}
|
||||
|
||||
func NewProjectLabelService(projectLabelRepository repositories.IProjectLabelRepository) *ProjectLabelService {
|
||||
return &ProjectLabelService{
|
||||
config: config.Get(),
|
||||
repository: projectLabelRepository,
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *ProjectLabelService) GetById(id uint) (*models.ProjectLabel, error) {
|
||||
return srv.repository.GetById(id)
|
||||
}
|
||||
|
||||
func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel, error) {
|
||||
if labels, found := srv.cache.Get(userId); found {
|
||||
return labels.([]*models.ProjectLabel), nil
|
||||
}
|
||||
|
||||
labels, err := srv.repository.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
srv.cache.Set(userId, labels, cache.DefaultExpiration)
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
|
||||
labels := make(map[string][]*models.ProjectLabel)
|
||||
userLabels, err := srv.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, l := range userLabels {
|
||||
if _, ok := labels[l.ProjectKey]; !ok {
|
||||
labels[l.ProjectKey] = []*models.ProjectLabel{l}
|
||||
} else {
|
||||
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
|
||||
}
|
||||
}
|
||||
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||
result, err := srv.repository.Insert(label)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
srv.cache.Delete(result.UserID)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (srv *ProjectLabelService) Delete(label *models.ProjectLabel) error {
|
||||
if label.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
err := srv.repository.Delete(label.ID)
|
||||
srv.cache.Delete(label.UserID)
|
||||
return err
|
||||
}
|
@ -6,31 +6,40 @@ import (
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"math/rand"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var reportLock = sync.Mutex{}
|
||||
|
||||
// range for random offset to add / subtract when scheduling a new job
|
||||
// to avoid all mails being sent at once, but distributed over 2*offsetIntervalMin minutes
|
||||
const offsetIntervalMin = 15
|
||||
|
||||
type ReportService struct {
|
||||
config *config.Config
|
||||
eventBus *hub.Hub
|
||||
summaryService ISummaryService
|
||||
userService IUserService
|
||||
mailService IMailService
|
||||
schedulersWeekly map[string]*gocron.Scheduler // user id -> scheduler
|
||||
config *config.Config
|
||||
eventBus *hub.Hub
|
||||
summaryService ISummaryService
|
||||
userService IUserService
|
||||
mailService IMailService
|
||||
scheduler *gocron.Scheduler
|
||||
rand *rand.Rand
|
||||
}
|
||||
|
||||
func NewReportService(summaryService ISummaryService, userService IUserService, mailService IMailService) *ReportService {
|
||||
srv := &ReportService{
|
||||
config: config.Get(),
|
||||
eventBus: config.EventBus(),
|
||||
summaryService: summaryService,
|
||||
userService: userService,
|
||||
mailService: mailService,
|
||||
schedulersWeekly: map[string]*gocron.Scheduler{},
|
||||
config: config.Get(),
|
||||
eventBus: config.EventBus(),
|
||||
summaryService: summaryService,
|
||||
userService: userService,
|
||||
mailService: mailService,
|
||||
scheduler: gocron.NewScheduler(time.Local),
|
||||
rand: rand.New(rand.NewSource(time.Now().Unix())),
|
||||
}
|
||||
|
||||
srv.scheduler.StartAsync()
|
||||
|
||||
sub := srv.eventBus.Subscribe(0, config.EventUserUpdate)
|
||||
go func(sub *hub.Subscription) {
|
||||
for m := range sub.Receiver {
|
||||
@ -62,24 +71,24 @@ func (srv *ReportService) SyncSchedule(u *models.User) bool {
|
||||
defer reportLock.Unlock()
|
||||
|
||||
// unschedule
|
||||
if s, ok := srv.schedulersWeekly[u.ID]; ok && !u.ReportsWeekly {
|
||||
s.Stop()
|
||||
s.Clear()
|
||||
delete(srv.schedulersWeekly, u.ID)
|
||||
if !u.ReportsWeekly {
|
||||
_ = srv.scheduler.RemoveByTag(u.ID)
|
||||
return false
|
||||
}
|
||||
|
||||
// schedule
|
||||
if _, ok := srv.schedulersWeekly[u.ID]; !ok && u.ReportsWeekly {
|
||||
s := gocron.NewScheduler(u.TZ())
|
||||
s.
|
||||
if j := srv.getJobByTag(u.ID); j == nil && u.ReportsWeekly {
|
||||
t, _ := time.ParseInLocation("15:04", srv.config.App.GetWeeklyReportTime(), u.TZ())
|
||||
t = t.Add(time.Duration(srv.rand.Intn(offsetIntervalMin)*srv.rand.Intn(2)) * time.Minute)
|
||||
if _, err := srv.scheduler.
|
||||
Every(1).
|
||||
Week().
|
||||
Weekday(srv.config.App.GetWeeklyReportDay()).
|
||||
At(srv.config.App.GetWeeklyReportTime()).
|
||||
Do(srv.Run, u, 7*24*time.Hour)
|
||||
s.StartAsync()
|
||||
srv.schedulersWeekly[u.ID] = s
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
return u.ReportsWeekly
|
||||
@ -119,3 +128,14 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
||||
logbuch.Info("sent report to user '%s'", user.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *ReportService) getJobByTag(tag string) *gocron.Job {
|
||||
for _, j := range srv.scheduler.Jobs() {
|
||||
for _, t := range j.Tags() {
|
||||
if t == tag {
|
||||
return j
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -35,6 +35,7 @@ type IHeartbeatService interface {
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
GetLatestByUser(*models.User) (*models.Heartbeat, error)
|
||||
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
|
||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||
DeleteBefore(time.Time) error
|
||||
}
|
||||
|
||||
@ -53,6 +54,14 @@ type ILanguageMappingService interface {
|
||||
Delete(mapping *models.LanguageMapping) error
|
||||
}
|
||||
|
||||
type IProjectLabelService interface {
|
||||
GetById(uint) (*models.ProjectLabel, error)
|
||||
GetByUser(string) ([]*models.ProjectLabel, error)
|
||||
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
|
||||
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
|
||||
Delete(*models.ProjectLabel) error
|
||||
}
|
||||
|
||||
type IMailService interface {
|
||||
SendPasswordReset(*models.User, string) error
|
||||
SendImportNotification(*models.User, time.Duration, int) error
|
||||
|
@ -1,7 +1,6 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
@ -10,28 +9,31 @@ import (
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
|
||||
type SummaryService struct {
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository repositories.ISummaryRepository
|
||||
heartbeatService IHeartbeatService
|
||||
aliasService IAliasService
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository repositories.ISummaryRepository
|
||||
heartbeatService IHeartbeatService
|
||||
aliasService IAliasService
|
||||
projectLabelService IProjectLabelService
|
||||
}
|
||||
|
||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||
|
||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
|
||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
|
||||
return &SummaryService{
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
repository: summaryRepo,
|
||||
heartbeatService: heartbeatService,
|
||||
aliasService: aliasService,
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
repository: summaryRepo,
|
||||
heartbeatService: heartbeatService,
|
||||
aliasService: aliasService,
|
||||
projectLabelService: projectLabelService,
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,6 +65,9 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
|
||||
|
||||
// Post-process summary and cache it
|
||||
summary := s.WithResolvedAliases(resolve)
|
||||
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
|
||||
summary.FillMissing() // then, full up types which are entirely missing
|
||||
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
@ -110,7 +115,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
||||
return nil, err
|
||||
}
|
||||
|
||||
types := models.SummaryTypes()
|
||||
types := models.NativeSummaryTypes()
|
||||
|
||||
typedAggregations := make(chan models.SummaryItemContainer)
|
||||
defer close(typedAggregations)
|
||||
@ -156,8 +161,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
||||
OperatingSystems: osItems,
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
//summary.FillUnknown()
|
||||
summary = srv.withProjectLabels(summary)
|
||||
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
@ -169,10 +173,12 @@ func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
||||
}
|
||||
|
||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||
srv.invalidateUserCache(userId)
|
||||
return srv.repository.DeleteByUser(userId)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||
srv.invalidateUserCache(summary.UserID)
|
||||
return srv.repository.Insert(summary)
|
||||
}
|
||||
|
||||
@ -220,6 +226,49 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
||||
}
|
||||
|
||||
func (srv *SummaryService) withProjectLabels(summary *models.Summary) *models.Summary {
|
||||
newEntry := func(key string, total time.Duration) *models.SummaryItem {
|
||||
return &models.SummaryItem{
|
||||
Type: models.SummaryLabel,
|
||||
Key: key,
|
||||
Total: total,
|
||||
}
|
||||
}
|
||||
|
||||
allLabels, err := srv.projectLabelService.GetByUser(summary.UserID)
|
||||
if err != nil {
|
||||
logbuch.Error("failed to retrieve project labels for user summary ('%s', '%s', '%s')", summary.UserID, summary.FromTime.String(), summary.ToTime.String())
|
||||
return summary
|
||||
}
|
||||
|
||||
mappedProjects := make(map[string]*models.SummaryItem, len(summary.Projects))
|
||||
for _, p := range summary.Projects {
|
||||
mappedProjects[p.Key] = p
|
||||
}
|
||||
|
||||
var totalLabelTime time.Duration
|
||||
labelMap := make(map[string]*models.SummaryItem, 0)
|
||||
for _, l := range allLabels {
|
||||
if p, ok := mappedProjects[l.ProjectKey]; ok {
|
||||
if _, ok2 := labelMap[l.Label]; !ok2 {
|
||||
labelMap[l.Label] = newEntry(l.Label, 0)
|
||||
}
|
||||
labelMap[l.Label].Total += p.Total
|
||||
totalLabelTime += p.Total
|
||||
}
|
||||
}
|
||||
//labelMap[models.DefaultProjectLabel] = newEntry(models.DefaultProjectLabel, summary.TotalTimeBy(models.SummaryProject) / time.Second-totalLabelTime)
|
||||
|
||||
labels := make([]*models.SummaryItem, 0, len(labelMap))
|
||||
for _, v := range labelMap {
|
||||
if v.Total > 0 {
|
||||
labels = append(labels, v)
|
||||
}
|
||||
}
|
||||
summary.Labels = labels
|
||||
return summary
|
||||
}
|
||||
|
||||
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
if len(summaries) < 1 {
|
||||
return nil, errors.New("no summaries given")
|
||||
@ -235,6 +284,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
||||
Editors: make([]*models.SummaryItem, 0),
|
||||
OperatingSystems: make([]*models.SummaryItem, 0),
|
||||
Machines: make([]*models.SummaryItem, 0),
|
||||
Labels: make([]*models.SummaryItem, 0),
|
||||
}
|
||||
|
||||
var processed = map[time.Time]bool{}
|
||||
@ -263,6 +313,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
||||
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
|
||||
|
||||
processed[hash] = true
|
||||
}
|
||||
@ -349,9 +400,13 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
|
||||
}
|
||||
|
||||
func (srv *SummaryService) getHash(args ...string) string {
|
||||
digest := md5.New()
|
||||
for _, a := range args {
|
||||
digest.Write([]byte(a))
|
||||
}
|
||||
return string(digest.Sum(nil))
|
||||
return strings.Join(args, "__")
|
||||
}
|
||||
|
||||
func (srv *SummaryService) invalidateUserCache(userId string) {
|
||||
for key := range srv.cache.Items() {
|
||||
if strings.Contains(key, userId) {
|
||||
srv.cache.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -16,6 +16,9 @@ const (
|
||||
TestUserId = "muety"
|
||||
TestProject1 = "test-project-1"
|
||||
TestProject2 = "test-project-2"
|
||||
TestProjectLabel1 = "private"
|
||||
TestProjectLabel2 = "work"
|
||||
TestProjectLabel3 = "non-existing"
|
||||
TestLanguageGo = "Go"
|
||||
TestLanguageJava = "Java"
|
||||
TestLanguagePython = "Python"
|
||||
@ -31,12 +34,14 @@ const (
|
||||
|
||||
type SummaryServiceTestSuite struct {
|
||||
suite.Suite
|
||||
TestUser *models.User
|
||||
TestStartTime time.Time
|
||||
TestHeartbeats []*models.Heartbeat
|
||||
SummaryRepository *mocks.SummaryRepositoryMock
|
||||
HeartbeatService *mocks.HeartbeatServiceMock
|
||||
AliasService *mocks.AliasServiceMock
|
||||
TestUser *models.User
|
||||
TestStartTime time.Time
|
||||
TestHeartbeats []*models.Heartbeat
|
||||
TestLabels []*models.ProjectLabel
|
||||
SummaryRepository *mocks.SummaryRepositoryMock
|
||||
HeartbeatService *mocks.HeartbeatServiceMock
|
||||
AliasService *mocks.AliasServiceMock
|
||||
ProjectLabelService *mocks.ProjectLabelServiceMock
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) SetupSuite() {
|
||||
@ -75,12 +80,27 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
|
||||
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
||||
},
|
||||
}
|
||||
suite.TestLabels = []*models.ProjectLabel{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
ProjectKey: TestProject1,
|
||||
Label: TestProjectLabel1,
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
ProjectKey: TestProjectLabel3,
|
||||
Label: "blaahh",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
|
||||
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
||||
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
||||
suite.AliasService = new(mocks.AliasServiceMock)
|
||||
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
|
||||
}
|
||||
|
||||
func TestSummaryServiceTestSuite(t *testing.T) {
|
||||
@ -88,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
@ -100,6 +120,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
/* TEST 1 */
|
||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
@ -113,6 +134,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
/* TEST 2 */
|
||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
@ -126,6 +148,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
/* TEST 3 */
|
||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
@ -136,12 +159,16 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
|
||||
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
|
||||
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
|
||||
assert.Equal(suite.T(), 150*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
|
||||
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel3))
|
||||
assert.Len(suite.T(), result.Editors, 2)
|
||||
assertNumAllItems(suite.T(), 1, result, "e")
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||
|
||||
var (
|
||||
summaries []*models.Summary
|
||||
@ -292,7 +319,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||
|
||||
var (
|
||||
summaries []*models.Summary
|
||||
@ -338,7 +367,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
|
@ -1,3 +1,3 @@
|
||||
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**
|
||||
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**,views/mail/**
|
||||
sonar.tests=.
|
||||
sonar.go.coverage.reportPaths=coverage/coverage.out
|
@ -5,16 +5,18 @@ const osCanvas = document.getElementById('chart-os')
|
||||
const editorsCanvas = document.getElementById('chart-editor')
|
||||
const languagesCanvas = document.getElementById('chart-language')
|
||||
const machinesCanvas = document.getElementById('chart-machine')
|
||||
const labelsCanvas = document.getElementById('chart-label')
|
||||
|
||||
const projectContainer = document.getElementById('project-container')
|
||||
const osContainer = document.getElementById('os-container')
|
||||
const editorContainer = document.getElementById('editor-container')
|
||||
const languageContainer = document.getElementById('language-container')
|
||||
const machineContainer = document.getElementById('machine-container')
|
||||
const labelContainer = document.getElementById('label-container')
|
||||
|
||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
|
||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
|
||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
|
||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
|
||||
|
||||
let topNPickers = [...document.getElementsByClassName('top-picker')]
|
||||
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
||||
@ -255,9 +257,42 @@ function draw(subselection) {
|
||||
})
|
||||
: null
|
||||
|
||||
let labelChart = !labelsCanvas.classList.contains('hidden') && shouldUpdate(5)
|
||||
? new Chart(labelsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.labels
|
||||
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
|
||||
.map(p => parseInt(p.total)),
|
||||
backgroundColor: wakapiData.labels.map(p => {
|
||||
const c = hexToRgb(getRandomColor(p.key))
|
||||
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
|
||||
}),
|
||||
hoverBackgroundColor: wakapiData.labels.map(p => {
|
||||
const c = hexToRgb(getRandomColor(p.key))
|
||||
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
|
||||
}),
|
||||
borderColor: wakapiData.labels.map(p => {
|
||||
const c = hexToRgb(getRandomColor(p.key))
|
||||
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
|
||||
}),
|
||||
}],
|
||||
labels: wakapiData.labels
|
||||
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
tooltips: getTooltipOptions('labels'),
|
||||
maintainAspectRatio: false,
|
||||
onResize: onChartResize
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
getTotal(wakapiData.operatingSystems)
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
|
||||
|
||||
if (!subselection) {
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
|
26
static/assets/vendor/tailwind.dist.css
vendored
26
static/assets/vendor/tailwind.dist.css
vendored
@ -641,6 +641,12 @@ video {
|
||||
background-color: rgba(47, 133, 90, var(--bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-gray-700:hover {
|
||||
--bg-opacity: 1;
|
||||
background-color: #4a5568;
|
||||
background-color: rgba(74, 85, 104, var(--bg-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-red-600:hover {
|
||||
--bg-opacity: 1;
|
||||
background-color: #e53e3e;
|
||||
@ -713,6 +719,10 @@ video {
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.rounded-full {
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.border {
|
||||
border-width: 1px;
|
||||
}
|
||||
@ -753,6 +763,10 @@ video {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
@ -821,6 +835,10 @@ video {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.h-4 {
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.h-8 {
|
||||
height: 2rem;
|
||||
}
|
||||
@ -853,6 +871,10 @@ video {
|
||||
font-size: 2.25rem;
|
||||
}
|
||||
|
||||
.leading-none {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.list-inside {
|
||||
list-style-position: inside;
|
||||
}
|
||||
@ -1186,6 +1208,10 @@ video {
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.w-4 {
|
||||
width: 1rem;
|
||||
}
|
||||
|
||||
.w-1\/2 {
|
||||
width: 50%;
|
||||
}
|
||||
|
@ -160,6 +160,48 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/compat/wakatime/v1/users/{user}/projects": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Mimics https://wakatime.com/developers#projects",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"wakatime"
|
||||
],
|
||||
"summary": "Retrieve and fitler the user's projects",
|
||||
"operationId": "get-wakatime-projects",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID to fetch data for (or 'current')",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Query to filter projects by",
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.ProjectsViewModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/compat/wakatime/v1/users/{user}/stats/{range}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@ -574,6 +616,31 @@ var doc = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.ProjectsViewModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.StatsData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -144,6 +144,48 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/compat/wakatime/v1/users/{user}/projects": {
|
||||
"get": {
|
||||
"security": [
|
||||
{
|
||||
"ApiKeyAuth": []
|
||||
}
|
||||
],
|
||||
"description": "Mimics https://wakatime.com/developers#projects",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"wakatime"
|
||||
],
|
||||
"summary": "Retrieve and fitler the user's projects",
|
||||
"operationId": "get-wakatime-projects",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "User ID to fetch data for (or 'current')",
|
||||
"name": "user",
|
||||
"in": "path",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"type": "string",
|
||||
"description": "Query to filter projects by",
|
||||
"name": "q",
|
||||
"in": "query",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.ProjectsViewModel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/compat/wakatime/v1/users/{user}/stats/{range}": {
|
||||
"get": {
|
||||
"security": [
|
||||
@ -558,6 +600,31 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Project": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"repository": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.ProjectsViewModel": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v1.Project"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.StatsData": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
@ -117,6 +117,22 @@ definitions:
|
||||
schemaVersion:
|
||||
type: integer
|
||||
type: object
|
||||
v1.Project:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
name:
|
||||
type: string
|
||||
repository:
|
||||
type: string
|
||||
type: object
|
||||
v1.ProjectsViewModel:
|
||||
properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/v1.Project'
|
||||
type: array
|
||||
type: object
|
||||
v1.StatsData:
|
||||
properties:
|
||||
daily_average:
|
||||
@ -392,6 +408,33 @@ paths:
|
||||
summary: Retrieve summary for all time
|
||||
tags:
|
||||
- wakatime
|
||||
/compat/wakatime/v1/users/{user}/projects:
|
||||
get:
|
||||
description: Mimics https://wakatime.com/developers#projects
|
||||
operationId: get-wakatime-projects
|
||||
parameters:
|
||||
- description: User ID to fetch data for (or 'current')
|
||||
in: path
|
||||
name: user
|
||||
required: true
|
||||
type: string
|
||||
- description: Query to filter projects by
|
||||
in: query
|
||||
name: q
|
||||
required: true
|
||||
type: string
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/v1.ProjectsViewModel'
|
||||
security:
|
||||
- ApiKeyAuth: []
|
||||
summary: Retrieve and fitler the user's projects
|
||||
tags:
|
||||
- wakatime
|
||||
/compat/wakatime/v1/users/{user}/stats/{range}:
|
||||
get:
|
||||
description: Mimics https://wakatime.com/developers#stats
|
||||
|
2516
testing/Wakapi API Tests.postman_collection.json
Normal file
2516
testing/Wakapi API Tests.postman_collection.json
Normal file
File diff suppressed because it is too large
Load Diff
61
testing/config.testing.yml
Normal file
61
testing/config.testing.yml
Normal file
@ -0,0 +1,61 @@
|
||||
env: production
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1
|
||||
listen_ipv6:
|
||||
tls_cert_path:
|
||||
tls_key_path:
|
||||
port: 3000
|
||||
base_path: /
|
||||
public_url: http://localhost:3000
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15'
|
||||
report_time_weekly: 'fri,18:00'
|
||||
inactive_days: 7
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
svelte: Svelte
|
||||
|
||||
db:
|
||||
host:
|
||||
port:
|
||||
user:
|
||||
password:
|
||||
name: wakapi_testing.db
|
||||
dialect: sqlite3
|
||||
charset:
|
||||
max_conn: 2
|
||||
ssl: false
|
||||
automgirate_fail_silently: false
|
||||
|
||||
security:
|
||||
password_salt:
|
||||
insecure_cookies: true
|
||||
cookie_max_age: 172800
|
||||
allow_signup: true
|
||||
expose_metrics: true
|
||||
|
||||
sentry:
|
||||
dsn:
|
||||
enable_tracing: false
|
||||
sample_rate:
|
||||
sample_rate_heartbeats:
|
||||
|
||||
mail:
|
||||
enabled: false
|
||||
provider: smtp
|
||||
sender: Wakapi <noreply@wakapi.dev>
|
||||
|
||||
smtp:
|
||||
host:
|
||||
port:
|
||||
username:
|
||||
password:
|
||||
tls:
|
||||
|
||||
mailwhale:
|
||||
url:
|
||||
client_id:
|
||||
client_secret:
|
14
testing/data.sql
Normal file
14
testing/data.sql
Normal file
@ -0,0 +1,14 @@
|
||||
BEGIN TRANSACTION;
|
||||
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
|
||||
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
|
||||
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
|
||||
VALUES ('readuser', '33e7f538-0dce-4eba-8ffe-53db6814ed42', '', 'Europe/Berlin',
|
||||
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:25',
|
||||
'2021-05-28 14:34:34.178+02:00', 0, 0, 0, 0, 0, 0, 1, 0, '', '', 0);
|
||||
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
|
||||
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
|
||||
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
|
||||
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
|
||||
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
|
||||
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
|
||||
COMMIT;
|
40
testing/run_api_tests.sh
Executable file
40
testing/run_api_tests.sh
Executable file
@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
|
||||
if [ ! -f "wakapi" ]; then
|
||||
echo "Wakapi executable not found. Compiling."
|
||||
go build
|
||||
fi
|
||||
|
||||
if ! command -v newman &> /dev/null
|
||||
then
|
||||
echo "Newman could not be found. Run 'npm install -g newman' first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
echo "Creating database and schema ..."
|
||||
sqlite3 wakapi_testing.db < schema.sql
|
||||
|
||||
echo "Importing seed data ..."
|
||||
sqlite3 wakapi_testing.db < data.sql
|
||||
|
||||
echo "Running Wakapi testing instance in background ..."
|
||||
screen -S wakapi_testing -dm bash -c "../wakapi -config config.testing.yml"
|
||||
|
||||
echo "Waiting for Wakapi to come up ..."
|
||||
until $(curl --output /dev/null --silent --get --fail http://localhost:3000/api/health); do
|
||||
printf '.'
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo ""
|
||||
|
||||
echo "Running test collection ..."
|
||||
newman run "Wakapi API Tests.postman_collection.json"
|
||||
|
||||
echo "Shutting down Wakapi ..."
|
||||
screen -S wakapi_testing -X quit
|
||||
|
||||
echo "Deleting database ..."
|
||||
rm wakapi_testing.db
|
147
testing/schema.sql
Normal file
147
testing/schema.sql
Normal file
@ -0,0 +1,147 @@
|
||||
BEGIN TRANSACTION;
|
||||
DROP TABLE IF EXISTS "users";
|
||||
CREATE TABLE IF NOT EXISTS "users" (
|
||||
"id" text,
|
||||
"api_key" text UNIQUE,
|
||||
"email" text,
|
||||
"password" text,
|
||||
"created_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
"last_logged_in_at" timestamp DEFAULT CURRENT_TIMESTAMP,
|
||||
"share_data_max_days" integer DEFAULT 0,
|
||||
"share_editors" numeric DEFAULT false,
|
||||
"share_languages" numeric DEFAULT false,
|
||||
"share_projects" numeric DEFAULT false,
|
||||
"share_oss" numeric DEFAULT false,
|
||||
"share_machines" numeric DEFAULT false,
|
||||
"is_admin" numeric DEFAULT false,
|
||||
"has_data" numeric DEFAULT false,
|
||||
"wakatime_api_key" text,
|
||||
"reset_token" text,
|
||||
"location" text,
|
||||
"reports_weekly" numeric DEFAULT false,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
DROP TABLE IF EXISTS "key_string_values";
|
||||
CREATE TABLE IF NOT EXISTS "key_string_values" (
|
||||
"key" text,
|
||||
"value" text,
|
||||
PRIMARY KEY("key")
|
||||
);
|
||||
DROP TABLE IF EXISTS "summary_items";
|
||||
CREATE TABLE IF NOT EXISTS "summary_items" (
|
||||
"id" integer,
|
||||
"summary_id" integer,
|
||||
"type" integer,
|
||||
"key" text,
|
||||
"total" integer,
|
||||
CONSTRAINT "fk_summaries_languages" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "fk_summary_items_summary" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "fk_summaries_machines" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "fk_summaries_projects" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "fk_summaries_operating_systems" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "fk_summaries_editors" FOREIGN KEY("summary_id") REFERENCES "summaries"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
DROP TABLE IF EXISTS "aliases";
|
||||
CREATE TABLE IF NOT EXISTS "aliases" (
|
||||
"id" integer,
|
||||
"type" integer NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"key" text NOT NULL,
|
||||
"value" text NOT NULL,
|
||||
CONSTRAINT "fk_aliases_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
DROP TABLE IF EXISTS "heartbeats";
|
||||
CREATE TABLE IF NOT EXISTS "heartbeats" (
|
||||
"id" integer,
|
||||
"user_id" text NOT NULL,
|
||||
"entity" text NOT NULL,
|
||||
"type" text,
|
||||
"category" text,
|
||||
"project" text,
|
||||
"branch" text,
|
||||
"language" text,
|
||||
"is_write" numeric,
|
||||
"editor" text,
|
||||
"operating_system" text,
|
||||
"machine" text,
|
||||
"time" timestamp,
|
||||
"hash" varchar(17),
|
||||
"origin" text,
|
||||
"origin_id" text,
|
||||
"created_at" timestamp,
|
||||
CONSTRAINT "fk_heartbeats_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
DROP TABLE IF EXISTS "summaries";
|
||||
CREATE TABLE IF NOT EXISTS "summaries" (
|
||||
"id" integer,
|
||||
"user_id" text NOT NULL,
|
||||
"from_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"to_time" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "fk_summaries_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
DROP TABLE IF EXISTS "language_mappings";
|
||||
CREATE TABLE IF NOT EXISTS "language_mappings" (
|
||||
"id" integer,
|
||||
"user_id" text NOT NULL,
|
||||
"extension" varchar(16),
|
||||
"language" varchar(64),
|
||||
CONSTRAINT "fk_language_mappings_user" FOREIGN KEY("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
PRIMARY KEY("id")
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_user_email";
|
||||
CREATE INDEX IF NOT EXISTS "idx_user_email" ON "users" (
|
||||
"email"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_type";
|
||||
CREATE INDEX IF NOT EXISTS "idx_type" ON "summary_items" (
|
||||
"type"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_alias_type_key";
|
||||
CREATE INDEX IF NOT EXISTS "idx_alias_type_key" ON "aliases" (
|
||||
"type",
|
||||
"key"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_alias_user";
|
||||
CREATE INDEX IF NOT EXISTS "idx_alias_user" ON "aliases" (
|
||||
"user_id"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_time";
|
||||
CREATE INDEX IF NOT EXISTS "idx_time" ON "heartbeats" (
|
||||
"time"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_heartbeats_hash";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_heartbeats_hash" ON "heartbeats" (
|
||||
"hash"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_time_user";
|
||||
CREATE INDEX IF NOT EXISTS "idx_time_user" ON "heartbeats" (
|
||||
"user_id"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_entity";
|
||||
CREATE INDEX IF NOT EXISTS "idx_entity" ON "heartbeats" (
|
||||
"entity"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_language";
|
||||
CREATE INDEX IF NOT EXISTS "idx_language" ON "heartbeats" (
|
||||
"language"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_time_summary_user";
|
||||
CREATE INDEX IF NOT EXISTS "idx_time_summary_user" ON "summaries" (
|
||||
"user_id",
|
||||
"from_time",
|
||||
"to_time"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_language_mapping_composite";
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_language_mapping_composite" ON "language_mappings" (
|
||||
"user_id",
|
||||
"extension"
|
||||
);
|
||||
DROP INDEX IF EXISTS "idx_language_mapping_user";
|
||||
CREATE INDEX IF NOT EXISTS "idx_language_mapping_user" ON "language_mappings" (
|
||||
"user_id"
|
||||
);
|
||||
COMMIT;
|
9
utils/collection.go
Normal file
9
utils/collection.go
Normal file
@ -0,0 +1,9 @@
|
||||
package utils
|
||||
|
||||
func GetMapValues(m map[string]interface{}) []interface{} {
|
||||
values := make([]interface{}, 0, len(m))
|
||||
for _, v := range m {
|
||||
values = append(values, v)
|
||||
}
|
||||
return values
|
||||
}
|
17
utils/set.go
Normal file
17
utils/set.go
Normal file
@ -0,0 +1,17 @@
|
||||
package utils
|
||||
|
||||
func StringsToSet(slice []string) map[string]bool {
|
||||
set := make(map[string]bool, len(slice))
|
||||
for _, e := range slice {
|
||||
set[e] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func SetToStrings(set map[string]bool) []string {
|
||||
slice := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
slice = append(slice, k)
|
||||
}
|
||||
return slice
|
||||
}
|
@ -30,7 +30,8 @@ func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from,
|
||||
}
|
||||
|
||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||
to = time.Now().In(tz)
|
||||
now := time.Now().In(tz)
|
||||
to = now
|
||||
|
||||
switch interval {
|
||||
case models.IntervalToday:
|
||||
@ -51,16 +52,16 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
|
||||
case models.IntervalThisYear:
|
||||
from = StartOfThisYear(tz)
|
||||
case models.IntervalPast7Days:
|
||||
from = StartOfToday(tz).AddDate(0, 0, -7)
|
||||
from = now.AddDate(0, 0, -7)
|
||||
case models.IntervalPast7DaysYesterday:
|
||||
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||
to = StartOfToday(tz).AddDate(0, 0, -1)
|
||||
case models.IntervalPast14Days:
|
||||
from = StartOfToday(tz).AddDate(0, 0, -14)
|
||||
from = now.AddDate(0, 0, -14)
|
||||
case models.IntervalPast30Days:
|
||||
from = StartOfToday(tz).AddDate(0, 0, -30)
|
||||
from = now.AddDate(0, 0, -30)
|
||||
case models.IntervalPast12Months:
|
||||
from = StartOfToday(tz).AddDate(0, -12, 0)
|
||||
from = now.AddDate(0, -12, 0)
|
||||
case models.IntervalAny:
|
||||
from = time.Time{}
|
||||
default:
|
||||
|
@ -1 +1 @@
|
||||
1.27.0
|
||||
1.28.1
|
||||
|
@ -69,6 +69,7 @@
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Built by developers for developers</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Fancy statistics and plots</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Cool badges for readmes</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Weekly e-mail reports</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Intuitive REST API</li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li><span class="iconify inline text-green-700" data-icon="ant-design:check-square-filled"></span> <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics</li>
|
||||
|
@ -35,7 +35,7 @@
|
||||
<main class="mt-4 flex-grow flex justify-center w-full">
|
||||
<div class="flex flex-col flex-grow max-w-2xl mt-8">
|
||||
|
||||
<details class="my-8 pb-8 border-b border-gray-700">
|
||||
<details class="my-8 pb-8 border-b border-gray-700" id="details-account">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||
id="preferences-heading">
|
||||
@ -88,7 +88,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="details-password">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
|
||||
Change Password
|
||||
@ -127,7 +127,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="details-aliases">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Aliases
|
||||
@ -203,7 +203,80 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="details-labels">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Project Labels
|
||||
</h2>
|
||||
</summary>
|
||||
<div class="w-full" id="project-labels">
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||
You can assign labels (aka. tags) to projects to group them together, e.g. by <span class="inline-block mb-1 text-gray-500 italic">private</span> and <span
|
||||
class="inline-block mb-1 text-gray-500 italic">work</span>. Please note that labels are only applied to new data, not to existing summaries in retrospect. To label historic data, you will need to clear and regenerate your summaries (see down below).
|
||||
</div>
|
||||
|
||||
{{ if .Labels }}
|
||||
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Labels</h3>
|
||||
{{ range $i, $label := .Labels }}
|
||||
<div class="flex items-center" action="" method="post">
|
||||
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
|
||||
style="line-height: 1.8">
|
||||
▸ <span class="font-semibold text-white">{{ $label.Key }}:</span>
|
||||
{{ range $j, $value := $label.Values }}
|
||||
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 font-mono inline-flex justify-between items-center space-x-2">
|
||||
<input type="hidden" name="action" value="delete_label">
|
||||
<input type="hidden" name="key" value="{{ $label.Key }}">
|
||||
<input type="hidden" name="value" value="{{ $value }}">
|
||||
<span>{{- $value -}}</span>
|
||||
<button type="submit" class="bg-gray-800 text-center hover:bg-gray-700 rounded-full w-4 h-4 leading-none" title="Delete label">x</button>
|
||||
</form>
|
||||
{{ if lt $j (add (len $label.Values) -1) }}
|
||||
<span class="-ml-1">{{- ", " | capitalize -}}</span>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="mb-8"></div>
|
||||
{{end}}
|
||||
|
||||
{{ if .Projects }}
|
||||
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Label</h3>
|
||||
<form action="" method="post">
|
||||
<input type="hidden" name="action" value="add_label">
|
||||
<div class="flex flex-col space-y-4">
|
||||
<div class="flex justify-between items-center mt-2 w-full text-gray-500 text-sm space-x-4">
|
||||
<div class="w-1/2 flex flex-col flex-grow">
|
||||
<span>Project</span>
|
||||
<select name="key" id="select-project"
|
||||
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
|
||||
{{ range $i, $p := .Projects }}
|
||||
<option value="{{ $p }}">{{ $p }}</option>
|
||||
{{ end }}
|
||||
</select>
|
||||
</div>
|
||||
<div class="w-1/2 flex flex-col flex-grow">
|
||||
<span>Label</span>
|
||||
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||
type="text" id="label-value"
|
||||
name="value" placeholder="work" minlength="1" required>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-grow flex justify-end">
|
||||
<button type="submit"
|
||||
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{ else }}
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="details-mappings">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||
id="languages">
|
||||
@ -263,7 +336,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="details-public-data">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Public Data
|
||||
@ -298,11 +371,9 @@
|
||||
<div class="flex justify-end">
|
||||
<select autocomplete="off" name="share_projects"
|
||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected
|
||||
{{ end }}>No
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end
|
||||
}}>Yes
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -314,11 +385,9 @@
|
||||
<div class="flex justify-end">
|
||||
<select autocomplete="off" name="share_languages"
|
||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected
|
||||
{{ end }}>No
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{
|
||||
end }}>Yes
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -330,11 +399,9 @@
|
||||
<div class="flex justify-end">
|
||||
<select autocomplete="off" name="share_editors"
|
||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{
|
||||
end }}>No
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end
|
||||
}}>Yes
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -346,8 +413,7 @@
|
||||
<div class="flex justify-end">
|
||||
<select autocomplete="off" name="share_oss"
|
||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end
|
||||
}}>No
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
|
||||
Yes
|
||||
@ -362,11 +428,23 @@
|
||||
<div class="flex justify-end">
|
||||
<select autocomplete="off" name="share_machines"
|
||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected
|
||||
{{ end }}>No
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end
|
||||
}}>Yes
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||
<div class="flex justify-start">
|
||||
<span class="mr-2">Share project labels: </span>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<select autocomplete="off" name="share_labels"
|
||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareLabels }} selected {{ end }}>No
|
||||
</option>
|
||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLabels }} selected {{ end }}>Yes
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
@ -383,7 +461,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
||||
<details class="mb-8 pb-8 border-b border-gray-700" id="details-integrations">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||
id="integrations">
|
||||
@ -506,7 +584,7 @@
|
||||
<p>You have the ability to create badges from your coding statistics using <a
|
||||
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
||||
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
||||
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public
|
||||
access to the respective endpoint. See <a href="settings#details-public-data" class="underline">Public
|
||||
Data</a> setting.</p>
|
||||
{{ end }}
|
||||
</div>
|
||||
@ -528,7 +606,7 @@
|
||||
</div>
|
||||
<div class="flex flex-col mb-4 mt-2">
|
||||
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
|
||||
class="with-url-src-no-scheme">
|
||||
class="with-url-src-no-scheme" alt="Readme Stats Card">
|
||||
<p class="mt-2"><strong>Source URL:</strong>
|
||||
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
|
||||
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
|
||||
@ -540,7 +618,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<details class="mb-8 pb-8">
|
||||
<details class="mb-8 pb-8" id="details-danger-zone">
|
||||
<summary class="cursor-pointer">
|
||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
|
||||
<span class="iconify inline" data-icon="emojione-v1:warning"></span> Danger Zone
|
||||
@ -677,6 +755,12 @@
|
||||
tzs.sort()
|
||||
.map(createTzOption)
|
||||
.forEach(o => selectTimezone.appendChild(o))
|
||||
|
||||
const hash = location.hash.replace('#', '')
|
||||
if (hash) {
|
||||
const elem = document.getElementById(hash)
|
||||
if (elem) elem.open = true
|
||||
}
|
||||
</script>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
@ -170,6 +170,26 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
|
||||
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="label-container" style="height: 300px">
|
||||
<div class="flex justify-between">
|
||||
<div class="w-1/4 flex-1">
|
||||
<a href="settings#details-labels" class="h-8 inline">
|
||||
<span class="iconify inline" data-icon="twemoji:gear"></span>
|
||||
</a>
|
||||
</div>
|
||||
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Labels</span>
|
||||
<div class="flex justify-end flex-1 text-xs items-center">
|
||||
<label for="label-top-picker" class="mr-1">Show: </label>
|
||||
<input type="number" min="1" id="label-top-picker" data-entity="5" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="chart-label"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ else }}
|
||||
@ -192,7 +212,7 @@
|
||||
# <strong>Step 2:</strong> Adapt your config<br>
|
||||
$ vi ~/.wakatime.cfg<br>
|
||||
|
||||
# Set <em>api_url = <span class="with-url-inner">%s/api/heartbeat</span></em><br>
|
||||
# Set <em>api_url = <span class="with-url-inner">%s/api</span></em><br>
|
||||
# Set <em>api_key = <span id="api-key-instruction"></span></em><br><br>
|
||||
|
||||
# <strong>Step 3:</strong> Start coding and then check back here!
|
||||
@ -228,6 +248,7 @@
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.machines = {{ .Machines | json }}
|
||||
wakapiData.labels = {{ .Labels | json }}
|
||||
|
||||
document.getElementById("to-date-picker").onchange = function () {
|
||||
var input = document.getElementById("from-date-picker");
|
||||
|
Reference in New Issue
Block a user