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

feat: implement project labels (resolve #204)

This commit is contained in:
Ferdinand Mütsch 2021-06-11 20:59:34 +02:00
parent c1c78d8d5b
commit 0d64858721
19 changed files with 378 additions and 35 deletions

View File

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

View File

@ -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
@ -135,6 +137,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 +145,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)

View File

@ -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.Machine != "" {
return true, SummaryLabel, f.Label
} }
return false, 0, "" return false, 0, ""
} }

13
models/project_label.go Normal file
View 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_language_mapping_user; 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 != ""
}

View File

@ -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,14 +135,38 @@ func (s *Summary) FillUnknown() {
return return
} }
timeSum := s.TotalTime()
// construct dummy item for all missing types // construct dummy item for all missing types
presentType, _ := s.findFirstPresentType()
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) / time.Second
totalActual := s.TotalTimeBy(toType) / time.Second
key := UnknownSummaryKey
if toType == SummaryLabel {
key = DefaultProjectLabel
}
existingEntryIdx := -1
for i, item := range *typeItems[toType] {
if item.Key == key {
existingEntryIdx = i
break
}
}
if existingEntryIdx >= 0 {
(*typeItems[toType])[existingEntryIdx].Total = totalWanted - totalActual
} else {
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
Type: toType,
Key: key,
Total: totalWanted - totalActual,
}) })
} }
} }
@ -141,19 +175,26 @@ 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
} }
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 *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) { func (s *Summary) TotalTimeBy(entityType uint8) (timeSum time.Duration) {
mappedItems := s.MappedItems() mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 { if items := mappedItems[entityType]; len(*items) > 0 {
@ -231,6 +272,7 @@ 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
} }

View File

@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
}, },
} }
sut.FillUnknown() sut.FillMissing()
itemLists := [][]*SummaryItem{ itemLists := [][]*SummaryItem{
sut.Machines, sut.Machines,

View File

@ -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:"-"`

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

View File

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

View File

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

View File

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

View File

@ -101,6 +101,9 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
case "machine": case "machine":
permitEntity = user.ShareMachines 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 permitEntity = true
filters = &models.Filters{} filters = &models.Filters{}

View File

@ -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"
} }

73
services/project_label.go Normal file
View File

@ -0,0 +1,73 @@
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) ResolveByUser(userId string) (map[string]string, error) {
labels := make(map[string]string)
userLabels, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, m := range userLabels {
labels[m.ProjectKey] = m.Label
}
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
}

View File

@ -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)
ResolveByUser(string) (map[string]string, error)
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(mapping *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

View File

@ -16,22 +16,24 @@ import (
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,6 +65,9 @@ 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
} }
@ -110,7 +115,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 +161,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
} }
@ -220,6 +224,47 @@ 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 {
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 +280,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 +309,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
} }

View File

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

9
utils/collection.go Normal file
View 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
}

View File

@ -170,6 +170,22 @@
</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"></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:&nbsp;</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 +244,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");