diff --git a/README.md b/README.md index 92be3da..2c88daa 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,7 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam * Language ~ type **1** * Editor ~ type **2** * OS ~ type **3** +* Machine ~ type **4** **NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi. diff --git a/go.mod b/go.mod index 07d0d67..5a5c4df 100644 --- a/go.mod +++ b/go.mod @@ -14,7 +14,7 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc github.com/satori/go.uuid v1.2.0 - github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f + github.com/t-tiger/gorm-bulk-insert v1.3.0 golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c gopkg.in/ini.v1 v1.50.0 ) diff --git a/go.sum b/go.sum index 525940d..2d244d4 100644 --- a/go.sum +++ b/go.sum @@ -318,6 +318,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8= github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c= +github.com/t-tiger/gorm-bulk-insert v1.3.0 h1:9k7BaVEhw/3fsvh6GTOBwJ2RXk3asc5xs5m6hwozq20= +github.com/t-tiger/gorm-bulk-insert v1.3.0/go.mod h1:ruDlk8xDl+8sX4bA7PQuYly9YEb3pbp1eP2LCyeRrFY= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= diff --git a/migrations/sqlite3/4_machine_column.sql b/migrations/sqlite3/4_machine_column.sql index 1b8aac2..4d090e1 100644 --- a/migrations/sqlite3/4_machine_column.sql +++ b/migrations/sqlite3/4_machine_column.sql @@ -1,11 +1,11 @@ -- +migrate Up -- SQL in section 'Up' is executed when this migration is applied -alter table users - add `machine` varchar(255); +alter table heartbeats + add column `machine` varchar(255); -- +migrate Down -- SQL section 'Down' is executed when this migration is rolled back -alter table users +alter table heartbeats drop column `machine`; \ No newline at end of file diff --git a/models/heartbeat.go b/models/heartbeat.go index c1cb872..8c4088f 100644 --- a/models/heartbeat.go +++ b/models/heartbeat.go @@ -5,8 +5,6 @@ import ( "time" ) -type CustomTime time.Time - type Heartbeat struct { ID uint `gorm:"primary_key"` User *User `json:"-" gorm:"not null"` @@ -21,7 +19,7 @@ type Heartbeat struct { Editor string `json:"editor"` OperatingSystem string `json:"operating_system"` Machine string `json:"machine"` - Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"` + Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"` languageRegex *regexp.Regexp } diff --git a/models/shared.go b/models/shared.go index 01d820f..8cb5712 100644 --- a/models/shared.go +++ b/models/shared.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "github.com/jinzhu/gorm" + "math" "strconv" "strings" "time" @@ -23,13 +24,15 @@ type KeyStringValue struct { Value string `gorm:"type:text"` } +type CustomTime time.Time + func (j *CustomTime) UnmarshalJSON(b []byte) error { - s := strings.Split(strings.Trim(string(b), "\""), ".")[0] + s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1) // TODO: not always three decimal points! i, err := strconv.ParseInt(s, 10, 64) if err != nil { return err } - t := time.Unix(i, 0) + t := time.Unix(0, i*int64(math.Pow10(19-len(s)))) *j = CustomTime(t) return nil } @@ -60,7 +63,7 @@ func (j CustomTime) Value() (driver.Value, error) { func (j CustomTime) String() string { t := time.Time(j) - return t.Format("2006-01-02 15:04:05") + return t.Format("2006-01-02 15:04:05.000") } func (j CustomTime) Time() time.Time { diff --git a/models/summary.go b/models/summary.go index b862451..8bfb6f0 100644 --- a/models/summary.go +++ b/models/summary.go @@ -5,22 +5,26 @@ import ( ) const ( - NSummaryTypes uint8 = 4 + NSummaryTypes uint8 = 99 SummaryProject uint8 = 0 SummaryLanguage uint8 = 1 SummaryEditor uint8 = 2 SummaryOS uint8 = 3 + SummaryMachine uint8 = 4 ) +const UnknownSummaryKey = "unknown" + type Summary struct { ID uint `json:"-" gorm:"primary_key"` UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"` - FromTime time.Time `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"` - ToTime time.Time `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"` + FromTime time.Time `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"` + ToTime time.Time `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"` Projects []*SummaryItem `json:"projects"` Languages []*SummaryItem `json:"languages"` Editors []*SummaryItem `json:"editors"` OperatingSystems []*SummaryItem `json:"operating_systems"` + Machines []*SummaryItem `json:"machines"` } type SummaryItem struct { @@ -43,3 +47,55 @@ type SummaryViewModel struct { Success string ApiKey string } + +/* Augments the summary in a way that at least one item is present for every type. +If a summary has zero items for a given type, but one or more for any of the other types, +the total summary duration can be derived from those and inserted as a dummy-item with key "unknown" +for the missing type. +For instance, the machine type was introduced post hoc. Accordingly, no "machine"-information is present in +the data for old heartbeats and summaries. If a user has two years of data without machine information and +one day with such, a "machine"-chart plotted from that data will reference a way smaller absolute total amount +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() { + types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} + missingTypes := make([]uint8, 0) + typeItems := map[uint8]*[]*SummaryItem{ + SummaryProject: &s.Projects, + SummaryLanguage: &s.Languages, + SummaryEditor: &s.Editors, + SummaryOS: &s.OperatingSystems, + SummaryMachine: &s.Machines, + } + var somePresentType uint8 + + for _, t := range types { + if len(*typeItems[t]) == 0 { + missingTypes = append(missingTypes, t) + } else { + somePresentType = t + } + } + + // can't proceed if entire summary is empty + if len(missingTypes) == len(types) { + return + } + + // calculate total duration from any of the present sets of items + var timeSum time.Duration + for _, item := range *typeItems[somePresentType] { + timeSum += item.Total + } + + // construct dummy item for all missing types + for _, t := range missingTypes { + *typeItems[t] = append(*typeItems[t], &SummaryItem{ + Type: t, + Key: UnknownSummaryKey, + Total: timeSum, + }) + } +} diff --git a/models/user.go b/models/user.go index f55c7d4..9a3101d 100644 --- a/models/user.go +++ b/models/user.go @@ -37,7 +37,7 @@ func (s *Signup) IsValid() bool { } func validateUsername(username string) bool { - return len(username) >= 3 + return len(username) >= 3 && username != "current" } func validatePassword(password string) bool { diff --git a/services/summary.go b/services/summary.go index 4553b6c..574498f 100644 --- a/services/summary.go +++ b/services/summary.go @@ -65,12 +65,13 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco heartbeats = append(heartbeats, hb...) } - types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS} + types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS, models.SummaryMachine} var projectItems []*models.SummaryItem var languageItems []*models.SummaryItem var editorItems []*models.SummaryItem var osItems []*models.SummaryItem + var machineItems []*models.SummaryItem if err := srv.AliasService.LoadUserAliases(user.ID); err != nil { return nil, err @@ -92,6 +93,8 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco editorItems = item.Items case models.SummaryOS: osItems = item.Items + case models.SummaryMachine: + machineItems = item.Items } } close(c) @@ -100,6 +103,10 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco if len(existingSummaries) > 0 { realFrom = existingSummaries[0].FromTime realTo = existingSummaries[len(existingSummaries)-1].ToTime + + for _, summary := range existingSummaries { + summary.FillUnknown() + } } if len(heartbeats) > 0 { t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time) @@ -119,6 +126,7 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco Languages: languageItems, Editors: editorItems, OperatingSystems: osItems, + Machines: machineItems, } allSummaries := []*models.Summary{aggregatedSummary} @@ -154,6 +162,7 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time Preload("Languages", "type = ?", models.SummaryLanguage). Preload("Editors", "type = ?", models.SummaryEditor). Preload("OperatingSystems", "type = ?", models.SummaryOS). + Preload("Machines", "type = ?", models.SummaryMachine). Find(&summaries).Error; err != nil { return nil, err } @@ -187,10 +196,12 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy key = h.Language case models.SummaryOS: key = h.OperatingSystem + case models.SummaryMachine: + key = h.Machine } if key == "" { - key = "unknown" + key = models.UnknownSummaryKey } if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil { @@ -276,6 +287,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) { Languages: make([]*models.SummaryItem, 0), Editors: make([]*models.SummaryItem, 0), OperatingSystems: make([]*models.SummaryItem, 0), + Machines: make([]*models.SummaryItem, 0), } for _, s := range summaries { @@ -295,6 +307,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) { finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages) finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors) finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems) + finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines) } finalSummary.FromTime = minTime diff --git a/static/assets/app.js b/static/assets/app.js index 8f10f95..453d3ec 100644 --- a/static/assets/app.js +++ b/static/assets/app.js @@ -5,6 +5,7 @@ const projectsCanvas = document.getElementById('chart-projects') const osCanvas = document.getElementById('chart-os') const editorsCanvas = document.getElementById('chart-editor') const languagesCanvas = document.getElementById('chart-language') +const machinesCanvas = document.getElementById('chart-machine') let charts = [] let resizeCount = 0 @@ -56,7 +57,7 @@ function draw() { .map(p => { return { label: p.key, - data: [parseInt(p.total)], + data: [parseInt(p.total) / 60], backgroundColor: getRandomColor(p.key) } }) @@ -67,6 +68,14 @@ function draw() { legend: { display: false }, + scales: { + xAxes: [{ + scaleLabel: { + display: true, + labelString: 'Minutes' + } + }] + }, maintainAspectRatio: false, onResize: onChartResize } @@ -135,10 +144,31 @@ function draw() { } }) + let machineChart = new Chart(machinesCanvas.getContext('2d'), { + type: 'pie', + data: { + datasets: [{ + data: wakapiData.machines + .slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) + .map(p => parseInt(p.total)), + backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key)) + }], + labels: wakapiData.machines + .slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) + .map(p => p.key) + }, + options: { + title: Object.assign(titleOptions, {text: `Machines (top ${SHOW_TOP_N})`}), + tooltips: getTooltipOptions('machines', 'pie'), + maintainAspectRatio: false, + onResize: onChartResize + } + }) + getTotal(wakapiData.operatingSystems) document.getElementById('grid-container').style.visibility = 'visible' - charts = [projectChart, osChart, editorChart, languageChart] + charts = [projectChart, osChart, editorChart, languageChart, machineChart] charts.forEach(c => c.options.onResize(c.chart)) equalizeHeights() diff --git a/version.txt b/version.txt index d263485..fe4e75f 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.7.6 \ No newline at end of file +1.8.3 \ No newline at end of file diff --git a/views/foot.tpl.html b/views/foot.tpl.html index e0f451e..906ab16 100644 --- a/views/foot.tpl.html +++ b/views/foot.tpl.html @@ -2,11 +2,13 @@ \ No newline at end of file diff --git a/views/summary.tpl.html b/views/summary.tpl.html index 3512935..3ef2340 100644 --- a/views/summary.tpl.html +++ b/views/summary.tpl.html @@ -78,6 +78,11 @@ +
+
+ +
+