mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
466f2e1786 | |||
82b8951437 | |||
25464e9519 | |||
650fffa344 | |||
69627fbe11 | |||
561198b203 | |||
7c4a2024b6 | |||
7bcd6890d1 | |||
1e4e530c21 | |||
490cca05eb | |||
3780ae4255 | |||
628ea0b9dd | |||
0d64858721 | |||
c1c78d8d5b | |||
538b9d2463 | |||
f4612fd542 | |||
fb643571d2 | |||
a4d47fb566 |
@ -4,14 +4,13 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
<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>
|
<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/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>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
<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=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>
|
<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>
|
</p>
|
||||||
|
@ -197,6 +197,9 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
|||||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
9
main.go
9
main.go
@ -49,6 +49,7 @@ var (
|
|||||||
heartbeatRepository repositories.IHeartbeatRepository
|
heartbeatRepository repositories.IHeartbeatRepository
|
||||||
userRepository repositories.IUserRepository
|
userRepository repositories.IUserRepository
|
||||||
languageMappingRepository repositories.ILanguageMappingRepository
|
languageMappingRepository repositories.ILanguageMappingRepository
|
||||||
|
projectLabelRepository repositories.IProjectLabelRepository
|
||||||
summaryRepository repositories.ISummaryRepository
|
summaryRepository repositories.ISummaryRepository
|
||||||
keyValueRepository repositories.IKeyValueRepository
|
keyValueRepository repositories.IKeyValueRepository
|
||||||
)
|
)
|
||||||
@ -58,6 +59,7 @@ var (
|
|||||||
heartbeatService services.IHeartbeatService
|
heartbeatService services.IHeartbeatService
|
||||||
userService services.IUserService
|
userService services.IUserService
|
||||||
languageMappingService services.ILanguageMappingService
|
languageMappingService services.ILanguageMappingService
|
||||||
|
projectLabelService services.IProjectLabelService
|
||||||
summaryService services.ISummaryService
|
summaryService services.ISummaryService
|
||||||
aggregationService services.IAggregationService
|
aggregationService services.IAggregationService
|
||||||
mailService services.IMailService
|
mailService services.IMailService
|
||||||
@ -113,6 +115,7 @@ func main() {
|
|||||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||||
if config.Db.Dialect == "sqlite3" {
|
if config.Db.Dialect == "sqlite3" {
|
||||||
db.Raw("PRAGMA foreign_keys = ON;")
|
db.Raw("PRAGMA foreign_keys = ON;")
|
||||||
|
db.DisableForeignKeyConstraintWhenMigrating = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDev() {
|
if config.IsDev() {
|
||||||
@ -135,6 +138,7 @@ func main() {
|
|||||||
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
||||||
userRepository = repositories.NewUserRepository(db)
|
userRepository = repositories.NewUserRepository(db)
|
||||||
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
||||||
|
projectLabelRepository = repositories.NewProjectLabelRepository(db)
|
||||||
summaryRepository = repositories.NewSummaryRepository(db)
|
summaryRepository = repositories.NewSummaryRepository(db)
|
||||||
keyValueRepository = repositories.NewKeyValueRepository(db)
|
keyValueRepository = repositories.NewKeyValueRepository(db)
|
||||||
|
|
||||||
@ -142,8 +146,9 @@ func main() {
|
|||||||
aliasService = services.NewAliasService(aliasRepository)
|
aliasService = services.NewAliasService(aliasRepository)
|
||||||
userService = services.NewUserService(userRepository)
|
userService = services.NewUserService(userRepository)
|
||||||
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
||||||
|
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
|
||||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
|
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
|
||||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||||
mailService = mail.NewMailService()
|
mailService = mail.NewMailService()
|
||||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||||
@ -173,7 +178,7 @@ func main() {
|
|||||||
|
|
||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
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)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
|
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)
|
||||||
|
}
|
@ -6,6 +6,7 @@ type Filters struct {
|
|||||||
Language string
|
Language string
|
||||||
Editor string
|
Editor string
|
||||||
Machine string
|
Machine string
|
||||||
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterElement struct {
|
type FilterElement struct {
|
||||||
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
|
|||||||
return &Filters{Editor: key}
|
return &Filters{Editor: key}
|
||||||
case SummaryMachine:
|
case SummaryMachine:
|
||||||
return &Filters{Machine: key}
|
return &Filters{Machine: key}
|
||||||
|
case SummaryLabel:
|
||||||
|
return &Filters{Label: key}
|
||||||
}
|
}
|
||||||
return &Filters{}
|
return &Filters{}
|
||||||
}
|
}
|
||||||
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
|
|||||||
return true, SummaryEditor, f.Editor
|
return true, SummaryEditor, f.Editor
|
||||||
} else if f.Machine != "" {
|
} else if f.Machine != "" {
|
||||||
return true, SummaryMachine, f.Machine
|
return true, SummaryMachine, f.Machine
|
||||||
|
} else if f.Label != "" {
|
||||||
|
return true, SummaryLabel, f.Label
|
||||||
}
|
}
|
||||||
return false, 0, ""
|
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
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -12,9 +13,11 @@ const (
|
|||||||
SummaryEditor uint8 = 2
|
SummaryEditor uint8 = 2
|
||||||
SummaryOS uint8 = 3
|
SummaryOS uint8 = 3
|
||||||
SummaryMachine uint8 = 4
|
SummaryMachine uint8 = 4
|
||||||
|
SummaryLabel uint8 = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
const UnknownSummaryKey = "unknown"
|
const UnknownSummaryKey = "unknown"
|
||||||
|
const DefaultProjectLabel = "default"
|
||||||
|
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
ID uint `json:"-" gorm:"primary_key"`
|
ID uint `json:"-" gorm:"primary_key"`
|
||||||
@ -27,6 +30,7 @@ type Summary struct {
|
|||||||
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
OperatingSystems SummaryItems `json:"operating_systems" 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"`
|
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
Labels SummaryItems `json:"labels" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryItems []*SummaryItem
|
type SummaryItems []*SummaryItem
|
||||||
@ -68,6 +72,10 @@ type SummaryParams struct {
|
|||||||
type AliasResolver func(t uint8, k string) string
|
type AliasResolver func(t uint8, k string) string
|
||||||
|
|
||||||
func SummaryTypes() []uint8 {
|
func SummaryTypes() []uint8 {
|
||||||
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NativeSummaryTypes() []uint8 {
|
||||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
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.OperatingSystems))
|
||||||
sort.Sort(sort.Reverse(s.Languages))
|
sort.Sort(sort.Reverse(s.Languages))
|
||||||
sort.Sort(sort.Reverse(s.Editors))
|
sort.Sort(sort.Reverse(s.Editors))
|
||||||
|
sort.Sort(sort.Reverse(s.Labels))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
|||||||
SummaryEditor: &s.Editors,
|
SummaryEditor: &s.Editors,
|
||||||
SummaryOS: &s.OperatingSystems,
|
SummaryOS: &s.OperatingSystems,
|
||||||
SummaryMachine: &s.Machines,
|
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,
|
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".
|
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||||
*/
|
*/
|
||||||
func (s *Summary) FillUnknown() {
|
func (s *Summary) FillMissing() {
|
||||||
types := s.Types()
|
types := s.Types()
|
||||||
typeItems := s.MappedItems()
|
typeItems := s.MappedItems()
|
||||||
missingTypes := make([]uint8, 0)
|
missingTypes := make([]uint8, 0)
|
||||||
@ -125,15 +135,46 @@ func (s *Summary) FillUnknown() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
timeSum := s.TotalTime()
|
|
||||||
|
|
||||||
// construct dummy item for all missing types
|
// 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 {
|
for _, t := range missingTypes {
|
||||||
*typeItems[t] = append(*typeItems[t], &SummaryItem{
|
s.FillBy(presentType, t)
|
||||||
Type: t,
|
}
|
||||||
Key: UnknownSummaryKey,
|
}
|
||||||
Total: timeSum,
|
|
||||||
})
|
// 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
|
var timeSum time.Duration
|
||||||
|
|
||||||
mappedItems := s.MappedItems()
|
mappedItems := s.MappedItems()
|
||||||
// calculate total duration from any of the present sets of items
|
t, err := s.findFirstPresentType()
|
||||||
for _, t := range s.Types() {
|
if err != nil {
|
||||||
if items := mappedItems[t]; len(*items) > 0 {
|
return 0
|
||||||
for _, item := range *items {
|
}
|
||||||
timeSum += item.Total
|
for _, item := range *mappedItems[t] {
|
||||||
}
|
timeSum += item.Total
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return timeSum * time.Second
|
return timeSum * time.Second
|
||||||
@ -231,10 +270,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
|||||||
s.Languages = processAliases(s.Languages)
|
s.Languages = processAliases(s.Languages)
|
||||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||||
s.Machines = processAliases(s.Machines)
|
s.Machines = processAliases(s.Machines)
|
||||||
|
s.Labels = processAliases(s.Labels)
|
||||||
|
|
||||||
return s
|
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 {
|
func (s *SummaryItem) TotalFixed() time.Duration {
|
||||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
// 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
|
// TODO: fix some day, while migrating persisted summary items
|
||||||
|
@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSummary_FillUnknown(t *testing.T) {
|
func TestSummary_FillMissing(t *testing.T) {
|
||||||
testDuration := 10 * time.Minute
|
testDuration := 10 * time.Minute
|
||||||
|
|
||||||
sut := &Summary{
|
sut := &Summary{
|
||||||
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
sut.FillUnknown()
|
sut.FillMissing()
|
||||||
|
|
||||||
itemLists := [][]*SummaryItem{
|
itemLists := [][]*SummaryItem{
|
||||||
sut.Machines,
|
sut.Machines,
|
||||||
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
|
|||||||
for _, l := range itemLists {
|
for _, l := range itemLists {
|
||||||
assert.Len(t, l, 1)
|
assert.Len(t, l, 1)
|
||||||
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
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) {
|
func TestSummary_TotalTimeBy(t *testing.T) {
|
||||||
|
@ -23,6 +23,7 @@ type User struct {
|
|||||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
WakatimeApiKey string `json:"-"`
|
WakatimeApiKey string `json:"-"`
|
||||||
|
@ -6,6 +6,8 @@ type SettingsViewModel struct {
|
|||||||
User *models.User
|
User *models.User
|
||||||
LanguageMappings []*models.LanguageMapping
|
LanguageMappings []*models.LanguageMapping
|
||||||
Aliases []*SettingsVMCombinedAlias
|
Aliases []*SettingsVMCombinedAlias
|
||||||
|
Labels []*SettingsVMCombinedLabel
|
||||||
|
Projects []string
|
||||||
Success string
|
Success string
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
|
|||||||
Values []string
|
Values []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsVMCombinedLabel struct {
|
||||||
|
Key string
|
||||||
|
Values []string
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||||
s.Success = m
|
s.Success = m
|
||||||
return s
|
return s
|
||||||
|
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
|
||||||
|
}
|
@ -46,6 +46,14 @@ type ILanguageMappingRepository interface {
|
|||||||
Delete(uint) error
|
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 {
|
type ISummaryRepository interface {
|
||||||
Insert(*models.Summary) error
|
Insert(*models.Summary) error
|
||||||
GetAll() ([]*models.Summary, error)
|
GetAll() ([]*models.Summary, error)
|
||||||
|
@ -23,6 +23,7 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
|
|||||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||||
|
Preload("Labels", "type = ?", models.SummaryLabel).
|
||||||
Find(&summaries).Error; err != nil {
|
Find(&summaries).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -48,6 +49,7 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
|||||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||||
|
Preload("Labels", "type = ?", models.SummaryLabel).
|
||||||
Find(&summaries).Error; err != nil {
|
Find(&summaries).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -147,6 +147,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
|||||||
"share_oss": user.ShareOSs,
|
"share_oss": user.ShareOSs,
|
||||||
"share_projects": user.ShareProjects,
|
"share_projects": user.ShareProjects,
|
||||||
"share_machines": user.ShareMachines,
|
"share_machines": user.ShareMachines,
|
||||||
|
"share_labels": user.ShareLabels,
|
||||||
"wakatime_api_key": user.WakatimeApiKey,
|
"wakatime_api_key": user.WakatimeApiKey,
|
||||||
"has_data": user.HasData,
|
"has_data": user.HasData,
|
||||||
"reset_token": user.ResetToken,
|
"reset_token": user.ResetToken,
|
||||||
|
@ -27,6 +27,7 @@ const (
|
|||||||
DescLanguages = "Total seconds for each language."
|
DescLanguages = "Total seconds for each language."
|
||||||
DescOperatingSystems = "Total seconds for each operating system."
|
DescOperatingSystems = "Total seconds for each operating system."
|
||||||
DescMachines = "Total seconds for each machine."
|
DescMachines = "Total seconds for each machine."
|
||||||
|
DescLabels = "Total seconds for each project label."
|
||||||
|
|
||||||
DescAdminTotalTime = "Total seconds (all users, all time)."
|
DescAdminTotalTime = "Total seconds (all users, all time)."
|
||||||
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (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
|
return &metrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
intervalPattern = `interval:([a-z0-9_]+)`
|
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 {
|
type BadgeHandler struct {
|
||||||
@ -75,7 +75,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
_, 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
|
// negative value means no limit
|
||||||
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
@ -83,22 +83,38 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var permitEntity bool
|
||||||
var filters *models.Filters
|
var filters *models.Filters
|
||||||
switch filterEntity {
|
switch filterEntity {
|
||||||
case "project":
|
case "project":
|
||||||
|
permitEntity = user.ShareProjects
|
||||||
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
||||||
case "os":
|
case "os":
|
||||||
|
permitEntity = user.ShareOSs
|
||||||
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
||||||
case "editor":
|
case "editor":
|
||||||
|
permitEntity = user.ShareEditors
|
||||||
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
||||||
case "language":
|
case "language":
|
||||||
|
permitEntity = user.ShareLanguages
|
||||||
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
||||||
case "machine":
|
case "machine":
|
||||||
|
permitEntity = user.ShareMachines
|
||||||
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
||||||
|
case "label":
|
||||||
|
permitEntity = user.ShareLabels
|
||||||
|
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
|
||||||
default:
|
default:
|
||||||
|
permitEntity = true
|
||||||
filters = &models.Filters{}
|
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)
|
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
||||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||||
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||||
|
@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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) &&
|
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
|
||||||
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
@ -121,17 +121,16 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
|||||||
end = utils.EndOfDay(end).Add(-1 * time.Second)
|
end = utils.EndOfDay(end).Add(-1 * time.Second)
|
||||||
|
|
||||||
overallParams := &models.SummaryParams{
|
overallParams := &models.SummaryParams{
|
||||||
From: start,
|
From: start,
|
||||||
To: end,
|
To: end,
|
||||||
User: user,
|
User: user,
|
||||||
Recompute: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
||||||
summaries := make([]*models.Summary, len(intervals))
|
summaries := make([]*models.Summary, len(intervals))
|
||||||
|
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervals {
|
||||||
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, false)
|
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, end.After(time.Now()))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -112,6 +112,9 @@ func typeName(t uint8) string {
|
|||||||
if t == models.SummaryMachine {
|
if t == models.SummaryMachine {
|
||||||
return "machine"
|
return "machine"
|
||||||
}
|
}
|
||||||
|
if t == models.SummaryLabel {
|
||||||
|
return "label"
|
||||||
|
}
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -14,10 +14,14 @@ import (
|
|||||||
"github.com/muety/wakapi/services/imports"
|
"github.com/muety/wakapi/services/imports"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const criticalError = "a critical error has occurred, sorry"
|
||||||
|
|
||||||
type SettingsHandler struct {
|
type SettingsHandler struct {
|
||||||
config *conf.Config
|
config *conf.Config
|
||||||
userSrvc services.IUserService
|
userSrvc services.IUserService
|
||||||
@ -26,6 +30,7 @@ type SettingsHandler struct {
|
|||||||
aliasSrvc services.IAliasService
|
aliasSrvc services.IAliasService
|
||||||
aggregationSrvc services.IAggregationService
|
aggregationSrvc services.IAggregationService
|
||||||
languageMappingSrvc services.ILanguageMappingService
|
languageMappingSrvc services.ILanguageMappingService
|
||||||
|
projectLabelSrvc services.IProjectLabelService
|
||||||
keyValueSrvc services.IKeyValueService
|
keyValueSrvc services.IKeyValueService
|
||||||
mailSrvc services.IMailService
|
mailSrvc services.IMailService
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@ -40,6 +45,7 @@ func NewSettingsHandler(
|
|||||||
aliasService services.IAliasService,
|
aliasService services.IAliasService,
|
||||||
aggregationService services.IAggregationService,
|
aggregationService services.IAggregationService,
|
||||||
languageMappingService services.ILanguageMappingService,
|
languageMappingService services.ILanguageMappingService,
|
||||||
|
projectLabelService services.IProjectLabelService,
|
||||||
keyValueService services.IKeyValueService,
|
keyValueService services.IKeyValueService,
|
||||||
mailService services.IMailService,
|
mailService services.IMailService,
|
||||||
) *SettingsHandler {
|
) *SettingsHandler {
|
||||||
@ -49,6 +55,7 @@ func NewSettingsHandler(
|
|||||||
aliasSrvc: aliasService,
|
aliasSrvc: aliasService,
|
||||||
aggregationSrvc: aggregationService,
|
aggregationSrvc: aggregationService,
|
||||||
languageMappingSrvc: languageMappingService,
|
languageMappingSrvc: languageMappingService,
|
||||||
|
projectLabelSrvc: projectLabelService,
|
||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
heartbeatSrvc: heartbeatService,
|
heartbeatSrvc: heartbeatService,
|
||||||
keyValueSrvc: keyValueService,
|
keyValueSrvc: keyValueService,
|
||||||
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
|||||||
return h.actionDeleteAlias
|
return h.actionDeleteAlias
|
||||||
case "add_alias":
|
case "add_alias":
|
||||||
return h.actionAddAlias
|
return h.actionAddAlias
|
||||||
|
case "add_label":
|
||||||
|
return h.actionAddLabel
|
||||||
|
case "delete_label":
|
||||||
|
return h.actionDeleteLabel
|
||||||
case "delete_mapping":
|
case "delete_mapping":
|
||||||
return h.actionDeleteLanguageMapping
|
return h.actionDeleteLanguageMapping
|
||||||
case "add_mapping":
|
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.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
|
||||||
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
|
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
|
||||||
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
|
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"))
|
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -313,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
|
|||||||
return http.StatusOK, "alias added successfully", ""
|
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) {
|
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@ -553,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
|||||||
|
|
||||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||||
user := middlewares.GetPrincipal(r)
|
user := middlewares.GetPrincipal(r)
|
||||||
|
|
||||||
|
// mappings
|
||||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
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)
|
aliasMap := make(map[string][]*models.Alias)
|
||||||
for _, a := range aliases {
|
for _, a := range aliases {
|
||||||
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
||||||
@ -578,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
combinedAliases = append(combinedAliases, ca)
|
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{
|
return &view.SettingsViewModel{
|
||||||
User: user,
|
User: user,
|
||||||
LanguageMappings: mappings,
|
LanguageMappings: mappings,
|
||||||
Aliases: combinedAliases,
|
Aliases: combinedAliases,
|
||||||
|
Labels: combinedLabels,
|
||||||
|
Projects: projects,
|
||||||
Success: r.URL.Query().Get("success"),
|
Success: r.URL.Query().Get("success"),
|
||||||
Error: r.URL.Query().Get("error"),
|
Error: r.URL.Query().Get("error"),
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
@ -90,8 +91,16 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
srv.cache.Set(cacheKey, utils.StringsToSet(results), cache.DefaultExpiration)
|
|
||||||
return results, nil
|
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 {
|
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||||
|
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
|
||||||
|
}
|
@ -54,6 +54,14 @@ type ILanguageMappingService interface {
|
|||||||
Delete(mapping *models.LanguageMapping) error
|
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 {
|
type IMailService interface {
|
||||||
SendPasswordReset(*models.User, string) error
|
SendPasswordReset(*models.User, string) error
|
||||||
SendImportNotification(*models.User, time.Duration, int) error
|
SendImportNotification(*models.User, time.Duration, int) error
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
@ -10,28 +9,31 @@ import (
|
|||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||||
|
|
||||||
type SummaryService struct {
|
type SummaryService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
repository repositories.ISummaryRepository
|
repository repositories.ISummaryRepository
|
||||||
heartbeatService IHeartbeatService
|
heartbeatService IHeartbeatService
|
||||||
aliasService IAliasService
|
aliasService IAliasService
|
||||||
|
projectLabelService IProjectLabelService
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
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{
|
return &SummaryService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||||
repository: summaryRepo,
|
repository: summaryRepo,
|
||||||
heartbeatService: heartbeatService,
|
heartbeatService: heartbeatService,
|
||||||
aliasService: aliasService,
|
aliasService: aliasService,
|
||||||
|
projectLabelService: projectLabelService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -63,17 +65,14 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
|
|||||||
|
|
||||||
// Post-process summary and cache it
|
// Post-process summary and cache it
|
||||||
summary := s.WithResolvedAliases(resolve)
|
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)
|
srv.cache.SetDefault(cacheKey, summary)
|
||||||
return summary.Sorted(), nil
|
return summary.Sorted(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||||
// Check cache
|
|
||||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
|
|
||||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
|
||||||
return cacheResult.(*models.Summary), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get all already existing, pre-generated summaries that fall into the requested interval
|
// Get all already existing, pre-generated summaries that fall into the requested interval
|
||||||
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -96,8 +95,6 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache 'em
|
|
||||||
srv.cache.SetDefault(cacheKey, summary)
|
|
||||||
return summary.Sorted(), nil
|
return summary.Sorted(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +107,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
types := models.SummaryTypes()
|
types := models.NativeSummaryTypes()
|
||||||
|
|
||||||
typedAggregations := make(chan models.SummaryItemContainer)
|
typedAggregations := make(chan models.SummaryItemContainer)
|
||||||
defer close(typedAggregations)
|
defer close(typedAggregations)
|
||||||
@ -156,8 +153,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
|||||||
OperatingSystems: osItems,
|
OperatingSystems: osItems,
|
||||||
Machines: machineItems,
|
Machines: machineItems,
|
||||||
}
|
}
|
||||||
|
summary = srv.withProjectLabels(summary)
|
||||||
//summary.FillUnknown()
|
|
||||||
|
|
||||||
return summary.Sorted(), nil
|
return summary.Sorted(), nil
|
||||||
}
|
}
|
||||||
@ -169,10 +165,12 @@ func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||||
|
srv.invalidateUserCache(userId)
|
||||||
return srv.repository.DeleteByUser(userId)
|
return srv.repository.DeleteByUser(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||||
|
srv.invalidateUserCache(summary.UserID)
|
||||||
return srv.repository.Insert(summary)
|
return srv.repository.Insert(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +218,49 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
|||||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
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) {
|
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||||
if len(summaries) < 1 {
|
if len(summaries) < 1 {
|
||||||
return nil, errors.New("no summaries given")
|
return nil, errors.New("no summaries given")
|
||||||
@ -235,6 +276,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
|||||||
Editors: make([]*models.SummaryItem, 0),
|
Editors: make([]*models.SummaryItem, 0),
|
||||||
OperatingSystems: make([]*models.SummaryItem, 0),
|
OperatingSystems: make([]*models.SummaryItem, 0),
|
||||||
Machines: make([]*models.SummaryItem, 0),
|
Machines: make([]*models.SummaryItem, 0),
|
||||||
|
Labels: make([]*models.SummaryItem, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
var processed = map[time.Time]bool{}
|
var processed = map[time.Time]bool{}
|
||||||
@ -263,6 +305,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
|||||||
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||||
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||||
|
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
|
||||||
|
|
||||||
processed[hash] = true
|
processed[hash] = true
|
||||||
}
|
}
|
||||||
@ -349,9 +392,13 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) getHash(args ...string) string {
|
func (srv *SummaryService) getHash(args ...string) string {
|
||||||
digest := md5.New()
|
return strings.Join(args, "__")
|
||||||
for _, a := range args {
|
}
|
||||||
digest.Write([]byte(a))
|
|
||||||
}
|
func (srv *SummaryService) invalidateUserCache(userId string) {
|
||||||
return string(digest.Sum(nil))
|
for key := range srv.cache.Items() {
|
||||||
|
if strings.Contains(key, userId) {
|
||||||
|
srv.cache.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,9 @@ const (
|
|||||||
TestUserId = "muety"
|
TestUserId = "muety"
|
||||||
TestProject1 = "test-project-1"
|
TestProject1 = "test-project-1"
|
||||||
TestProject2 = "test-project-2"
|
TestProject2 = "test-project-2"
|
||||||
|
TestProjectLabel1 = "private"
|
||||||
|
TestProjectLabel2 = "work"
|
||||||
|
TestProjectLabel3 = "non-existing"
|
||||||
TestLanguageGo = "Go"
|
TestLanguageGo = "Go"
|
||||||
TestLanguageJava = "Java"
|
TestLanguageJava = "Java"
|
||||||
TestLanguagePython = "Python"
|
TestLanguagePython = "Python"
|
||||||
@ -31,12 +34,14 @@ const (
|
|||||||
|
|
||||||
type SummaryServiceTestSuite struct {
|
type SummaryServiceTestSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
TestUser *models.User
|
TestUser *models.User
|
||||||
TestStartTime time.Time
|
TestStartTime time.Time
|
||||||
TestHeartbeats []*models.Heartbeat
|
TestHeartbeats []*models.Heartbeat
|
||||||
SummaryRepository *mocks.SummaryRepositoryMock
|
TestLabels []*models.ProjectLabel
|
||||||
HeartbeatService *mocks.HeartbeatServiceMock
|
SummaryRepository *mocks.SummaryRepositoryMock
|
||||||
AliasService *mocks.AliasServiceMock
|
HeartbeatService *mocks.HeartbeatServiceMock
|
||||||
|
AliasService *mocks.AliasServiceMock
|
||||||
|
ProjectLabelService *mocks.ProjectLabelServiceMock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) SetupSuite() {
|
func (suite *SummaryServiceTestSuite) SetupSuite() {
|
||||||
@ -75,12 +80,27 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
|
|||||||
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
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) {
|
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
|
||||||
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
||||||
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
||||||
suite.AliasService = new(mocks.AliasServiceMock)
|
suite.AliasService = new(mocks.AliasServiceMock)
|
||||||
|
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSummaryServiceTestSuite(t *testing.T) {
|
func TestSummaryServiceTestSuite(t *testing.T) {
|
||||||
@ -88,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
from time.Time
|
from time.Time
|
||||||
@ -100,6 +120,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
|||||||
/* TEST 1 */
|
/* TEST 1 */
|
||||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
|
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.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)
|
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||||
|
|
||||||
@ -113,6 +134,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
|||||||
/* TEST 2 */
|
/* TEST 2 */
|
||||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
|
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.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)
|
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||||
|
|
||||||
@ -126,6 +148,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
|||||||
/* TEST 3 */
|
/* TEST 3 */
|
||||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
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.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)
|
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(), 150*time.Second, result.TotalTime())
|
||||||
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
|
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(), 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)
|
assert.Len(suite.T(), result.Editors, 2)
|
||||||
assertNumAllItems(suite.T(), 1, result, "e")
|
assertNumAllItems(suite.T(), 1, result, "e")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
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 (
|
var (
|
||||||
summaries []*models.Summary
|
summaries []*models.Summary
|
||||||
@ -292,7 +319,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
|
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 (
|
var (
|
||||||
summaries []*models.Summary
|
summaries []*models.Summary
|
||||||
@ -338,7 +367,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
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 (
|
var (
|
||||||
from time.Time
|
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.tests=.
|
||||||
sonar.go.coverage.reportPaths=coverage/coverage.out
|
sonar.go.coverage.reportPaths=coverage/coverage.out
|
@ -5,16 +5,18 @@ const osCanvas = document.getElementById('chart-os')
|
|||||||
const editorsCanvas = document.getElementById('chart-editor')
|
const editorsCanvas = document.getElementById('chart-editor')
|
||||||
const languagesCanvas = document.getElementById('chart-language')
|
const languagesCanvas = document.getElementById('chart-language')
|
||||||
const machinesCanvas = document.getElementById('chart-machine')
|
const machinesCanvas = document.getElementById('chart-machine')
|
||||||
|
const labelsCanvas = document.getElementById('chart-label')
|
||||||
|
|
||||||
const projectContainer = document.getElementById('project-container')
|
const projectContainer = document.getElementById('project-container')
|
||||||
const osContainer = document.getElementById('os-container')
|
const osContainer = document.getElementById('os-container')
|
||||||
const editorContainer = document.getElementById('editor-container')
|
const editorContainer = document.getElementById('editor-container')
|
||||||
const languageContainer = document.getElementById('language-container')
|
const languageContainer = document.getElementById('language-container')
|
||||||
const machineContainer = document.getElementById('machine-container')
|
const machineContainer = document.getElementById('machine-container')
|
||||||
|
const labelContainer = document.getElementById('label-container')
|
||||||
|
|
||||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
|
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
|
||||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
|
||||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
|
||||||
|
|
||||||
let topNPickers = [...document.getElementsByClassName('top-picker')]
|
let topNPickers = [...document.getElementsByClassName('top-picker')]
|
||||||
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
||||||
@ -255,9 +257,42 @@ function draw(subselection) {
|
|||||||
})
|
})
|
||||||
: null
|
: 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)
|
getTotal(wakapiData.operatingSystems)
|
||||||
|
|
||||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
|
||||||
|
|
||||||
if (!subselection) {
|
if (!subselection) {
|
||||||
charts.forEach(c => c.options.onResize(c.chart))
|
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));
|
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 {
|
.hover\:bg-red-600:hover {
|
||||||
--bg-opacity: 1;
|
--bg-opacity: 1;
|
||||||
background-color: #e53e3e;
|
background-color: #e53e3e;
|
||||||
@ -713,6 +719,10 @@ video {
|
|||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
@ -753,6 +763,10 @@ video {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
@ -821,6 +835,10 @@ video {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-4 {
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-8 {
|
.h-8 {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
@ -853,6 +871,10 @@ video {
|
|||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leading-none {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.list-inside {
|
.list-inside {
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
}
|
}
|
||||||
@ -1186,6 +1208,10 @@ video {
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-4 {
|
||||||
|
width: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-1\/2 {
|
.w-1\/2 {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@ security:
|
|||||||
insecure_cookies: true
|
insecure_cookies: true
|
||||||
cookie_max_age: 172800
|
cookie_max_age: 172800
|
||||||
allow_signup: true
|
allow_signup: true
|
||||||
expose_metrics: false
|
expose_metrics: true
|
||||||
|
|
||||||
sentry:
|
sentry:
|
||||||
dsn:
|
dsn:
|
||||||
|
@ -1,4 +1,14 @@
|
|||||||
BEGIN TRANSACTION;
|
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$RCyfAFdlZdFJVWbxKz4f2uJ/MospiE1EFAIjvRizC4Nop9GfjgKzW','2021-05-28 12:34:25','2021-05-28 14:34:34.178+02:00',0,0,0,0,0,0,0,0,'','',0);
|
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
|
||||||
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$vsksPpiXZE9/xG9pRrZP.eKkbe/bGWW4wpPoXqvjiImZqMbN5c4Km','2021-05-28 12:34:56','2021-05-28 14:35:05.118+02:00',0,0,0,0,0,0,0,1,'','',0);
|
"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;
|
COMMIT;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [ ! -f "wakapi" ]; then
|
if [ ! -f "wakapi" ]; then
|
||||||
echo "Wakapi executable not found. Run 'go build' first."
|
echo "Wakapi executable not found. Compiling."
|
||||||
exit 1
|
go build
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v newman &> /dev/null
|
if ! command -v newman &> /dev/null
|
||||||
|
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
|
||||||
|
}
|
@ -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) {
|
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 {
|
switch interval {
|
||||||
case models.IntervalToday:
|
case models.IntervalToday:
|
||||||
@ -51,16 +52,16 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
|
|||||||
case models.IntervalThisYear:
|
case models.IntervalThisYear:
|
||||||
from = StartOfThisYear(tz)
|
from = StartOfThisYear(tz)
|
||||||
case models.IntervalPast7Days:
|
case models.IntervalPast7Days:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -7)
|
from = now.AddDate(0, 0, -7)
|
||||||
case models.IntervalPast7DaysYesterday:
|
case models.IntervalPast7DaysYesterday:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||||
to = StartOfToday(tz).AddDate(0, 0, -1)
|
to = StartOfToday(tz).AddDate(0, 0, -1)
|
||||||
case models.IntervalPast14Days:
|
case models.IntervalPast14Days:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -14)
|
from = now.AddDate(0, 0, -14)
|
||||||
case models.IntervalPast30Days:
|
case models.IntervalPast30Days:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -30)
|
from = now.AddDate(0, 0, -30)
|
||||||
case models.IntervalPast12Months:
|
case models.IntervalPast12Months:
|
||||||
from = StartOfToday(tz).AddDate(0, -12, 0)
|
from = now.AddDate(0, -12, 0)
|
||||||
case models.IntervalAny:
|
case models.IntervalAny:
|
||||||
from = time.Time{}
|
from = time.Time{}
|
||||||
default:
|
default:
|
||||||
|
@ -1 +1 @@
|
|||||||
1.27.3
|
1.28.2
|
||||||
|
@ -35,7 +35,7 @@
|
|||||||
<main class="mt-4 flex-grow flex justify-center w-full">
|
<main class="mt-4 flex-grow flex justify-center w-full">
|
||||||
<div class="flex flex-col flex-grow max-w-2xl mt-8">
|
<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">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
id="preferences-heading">
|
id="preferences-heading">
|
||||||
@ -88,7 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
|
||||||
Change Password
|
Change Password
|
||||||
@ -127,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
Aliases
|
Aliases
|
||||||
@ -203,7 +203,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
id="languages">
|
id="languages">
|
||||||
@ -263,7 +336,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
Public Data
|
Public Data
|
||||||
@ -298,11 +371,9 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_projects"
|
<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">
|
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
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
|
||||||
{{ end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end
|
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes
|
||||||
}}>Yes
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -314,11 +385,9 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_languages"
|
<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">
|
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
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
|
||||||
{{ end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{
|
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes
|
||||||
end }}>Yes
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -330,11 +399,9 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_editors"
|
<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">
|
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 {{
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
|
||||||
end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end
|
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes
|
||||||
}}>Yes
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -346,8 +413,7 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_oss"
|
<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">
|
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
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
|
||||||
}}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
|
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
|
||||||
Yes
|
Yes
|
||||||
@ -362,11 +428,23 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_machines"
|
<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">
|
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
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
|
||||||
{{ end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end
|
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes
|
||||||
}}>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>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -383,7 +461,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
id="integrations">
|
id="integrations">
|
||||||
@ -506,7 +584,7 @@
|
|||||||
<p>You have the ability to create badges from your coding statistics using <a
|
<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"
|
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
|
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>
|
Data</a> setting.</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
@ -528,7 +606,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col mb-4 mt-2">
|
<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"
|
<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>
|
<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">
|
<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
|
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>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8">
|
<details class="mb-8 pb-8" id="details-danger-zone">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
|
<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
|
<span class="iconify inline" data-icon="emojione-v1:warning"></span> Danger Zone
|
||||||
@ -677,6 +755,12 @@
|
|||||||
tzs.sort()
|
tzs.sort()
|
||||||
.map(createTzOption)
|
.map(createTzOption)
|
||||||
.forEach(o => selectTimezone.appendChild(o))
|
.forEach(o => selectTimezone.appendChild(o))
|
||||||
|
|
||||||
|
const hash = location.hash.replace('#', '')
|
||||||
|
if (hash) {
|
||||||
|
const elem = document.getElementById(hash)
|
||||||
|
if (elem) elem.open = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{ template "footer.tpl.html" . }}
|
{{ template "footer.tpl.html" . }}
|
||||||
|
@ -170,6 +170,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@ -228,6 +248,7 @@
|
|||||||
wakapiData.editors = {{ .Editors | json }}
|
wakapiData.editors = {{ .Editors | json }}
|
||||||
wakapiData.languages = {{ .Languages | json }}
|
wakapiData.languages = {{ .Languages | json }}
|
||||||
wakapiData.machines = {{ .Machines | json }}
|
wakapiData.machines = {{ .Machines | json }}
|
||||||
|
wakapiData.labels = {{ .Labels | json }}
|
||||||
|
|
||||||
document.getElementById("to-date-picker").onchange = function () {
|
document.getElementById("to-date-picker").onchange = function () {
|
||||||
var input = document.getElementById("from-date-picker");
|
var input = document.getElementById("from-date-picker");
|
||||||
|
Reference in New Issue
Block a user