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 {
return err
}
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}

View File

@ -49,6 +49,7 @@ var (
heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository
projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
)
@ -58,6 +59,7 @@ var (
heartbeatService services.IHeartbeatService
userService services.IUserService
languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService
summaryService services.ISummaryService
aggregationService services.IAggregationService
mailService services.IMailService
@ -135,6 +137,7 @@ func main() {
heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
@ -142,8 +145,9 @@ func main() {
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
mailService = mail.NewMailService()
keyValueService = services.NewKeyValueService(keyValueRepository)

View File

@ -6,6 +6,7 @@ type Filters struct {
Language string
Editor string
Machine string
Label string
}
type FilterElement struct {
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Machine: key}
case SummaryLabel:
return &Filters{Label: key}
}
return &Filters{}
}
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
} else if f.Machine != "" {
return true, SummaryLabel, f.Label
}
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
import (
"errors"
"sort"
"time"
)
@ -12,9 +13,11 @@ const (
SummaryEditor uint8 = 2
SummaryOS uint8 = 3
SummaryMachine uint8 = 4
SummaryLabel uint8 = 5
)
const UnknownSummaryKey = "unknown"
const DefaultProjectLabel = "default"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
@ -27,6 +30,7 @@ type Summary struct {
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Labels SummaryItems `json:"labels" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
}
type SummaryItems []*SummaryItem
@ -68,6 +72,10 @@ type SummaryParams struct {
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
}
func NativeSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
@ -77,6 +85,7 @@ func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors))
sort.Sort(sort.Reverse(s.Labels))
return s
}
@ -91,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines,
SummaryLabel: &s.Labels,
}
}
@ -109,7 +119,7 @@ of time than the other ones.
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
*/
func (s *Summary) FillUnknown() {
func (s *Summary) FillMissing() {
types := s.Types()
typeItems := s.MappedItems()
missingTypes := make([]uint8, 0)
@ -125,14 +135,38 @@ func (s *Summary) FillUnknown() {
return
}
timeSum := s.TotalTime()
// construct dummy item for all missing types
presentType, _ := s.findFirstPresentType()
for _, t := range missingTypes {
*typeItems[t] = append(*typeItems[t], &SummaryItem{
Type: t,
Key: UnknownSummaryKey,
Total: timeSum,
s.FillBy(presentType, t)
}
}
// inplace!
func (s *Summary) FillBy(fromType uint8, toType uint8) {
typeItems := s.MappedItems()
totalWanted := s.TotalTimeBy(fromType) / 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
mappedItems := s.MappedItems()
// calculate total duration from any of the present sets of items
for _, t := range s.Types() {
if items := mappedItems[t]; len(*items) > 0 {
for _, item := range *items {
t, err := s.findFirstPresentType()
if err != nil {
return 0
}
for _, item := range *mappedItems[t] {
timeSum += item.Total
}
break
}
}
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) {
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
@ -231,6 +272,7 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
s.Labels = processAliases(s.Labels)
return s
}

View File

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

View File

@ -23,6 +23,7 @@ type User struct {
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"`

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
}
type IProjectLabelRepository interface {
GetAll() ([]*models.ProjectLabel, error)
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
Insert(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(uint) error
}
type ISummaryRepository interface {
Insert(*models.Summary) error
GetAll() ([]*models.Summary, error)

View File

@ -23,6 +23,7 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil {
return nil, err
}
@ -48,6 +49,7 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil {
return nil, err
}

View File

@ -27,6 +27,7 @@ const (
DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine."
DescLabels = "Total seconds for each project label."
DescAdminTotalTime = "Total seconds (all users, all time)."
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
@ -198,6 +199,15 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
})
}
for _, m := range summaryToday.Labels {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
return &metrics, nil
}

View File

@ -101,6 +101,9 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
case "machine":
permitEntity = user.ShareMachines
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
case "label":
permitEntity = user.ShareLabels
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
default:
permitEntity = true
filters = &models.Filters{}

View File

@ -112,6 +112,9 @@ func typeName(t uint8) string {
if t == models.SummaryMachine {
return "machine"
}
if t == models.SummaryLabel {
return "label"
}
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
}
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 {
SendPasswordReset(*models.User, string) error
SendImportNotification(*models.User, time.Duration, int) error

View File

@ -21,17 +21,19 @@ type SummaryService struct {
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
projectLabelService IProjectLabelService
}
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
return &SummaryService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
projectLabelService: projectLabelService,
}
}
@ -63,6 +65,9 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
// Post-process summary and cache it
summary := s.WithResolvedAliases(resolve)
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
summary.FillMissing() // then, full up types which are entirely missing
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
@ -110,7 +115,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
return nil, err
}
types := models.SummaryTypes()
types := models.NativeSummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations)
@ -156,8 +161,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
OperatingSystems: osItems,
Machines: machineItems,
}
//summary.FillUnknown()
summary = srv.withProjectLabels(summary)
return summary.Sorted(), nil
}
@ -220,6 +224,47 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
}
func (srv *SummaryService) withProjectLabels(summary *models.Summary) *models.Summary {
newEntry := func(key string, total time.Duration) *models.SummaryItem {
return &models.SummaryItem{
Type: models.SummaryLabel,
Key: key,
Total: total,
}
}
allLabels, err := srv.projectLabelService.GetByUser(summary.UserID)
if err != nil {
logbuch.Error("failed to retrieve project labels for user summary ('%s', '%s', '%s')", summary.UserID, summary.FromTime.String(), summary.ToTime.String())
return summary
}
mappedProjects := make(map[string]*models.SummaryItem, len(summary.Projects))
for _, p := range summary.Projects {
mappedProjects[p.Key] = p
}
var totalLabelTime time.Duration
labelMap := make(map[string]*models.SummaryItem, 0)
for _, l := range allLabels {
if p, ok := mappedProjects[l.ProjectKey]; ok {
if _, ok2 := labelMap[l.Label]; !ok2 {
labelMap[l.Label] = newEntry(l.Label, 0)
}
labelMap[l.Label].Total += p.Total
totalLabelTime += p.Total
}
}
//labelMap[models.DefaultProjectLabel] = newEntry(models.DefaultProjectLabel, summary.TotalTimeBy(models.SummaryProject) / time.Second-totalLabelTime)
labels := make([]*models.SummaryItem, 0, len(labelMap))
for _, v := range labelMap {
labels = append(labels, v)
}
summary.Labels = labels
return summary
}
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 {
return nil, errors.New("no summaries given")
@ -235,6 +280,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
Editors: make([]*models.SummaryItem, 0),
OperatingSystems: make([]*models.SummaryItem, 0),
Machines: make([]*models.SummaryItem, 0),
Labels: make([]*models.SummaryItem, 0),
}
var processed = map[time.Time]bool{}
@ -263,6 +309,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
processed[hash] = true
}

View File

@ -5,16 +5,18 @@ const osCanvas = document.getElementById('chart-os')
const editorsCanvas = document.getElementById('chart-editor')
const languagesCanvas = document.getElementById('chart-language')
const machinesCanvas = document.getElementById('chart-machine')
const labelsCanvas = document.getElementById('chart-label')
const projectContainer = document.getElementById('project-container')
const osContainer = document.getElementById('os-container')
const editorContainer = document.getElementById('editor-container')
const languageContainer = document.getElementById('language-container')
const machineContainer = document.getElementById('machine-container')
const labelContainer = document.getElementById('label-container')
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
@ -255,9 +257,42 @@ function draw(subselection) {
})
: null
let labelChart = !labelsCanvas.classList.contains('hidden') && shouldUpdate(5)
? new Chart(labelsCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('labels'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
getTotal(wakapiData.operatingSystems)
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
if (!subselection) {
charts.forEach(c => c.options.onResize(c.chart))

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 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>
{{ else }}
@ -228,6 +244,7 @@
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
wakapiData.labels = {{ .Labels | json }}
document.getElementById("to-date-picker").onchange = function () {
var input = document.getElementById("from-date-picker");