mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
8 Commits
Author | SHA1 | Date | |
---|---|---|---|
664714de8f | |||
7befb82814 | |||
2f12d8efde | |||
8ddd9904a0 | |||
78874566a4 | |||
e269b37b0e | |||
e6a04cc76d | |||
cb8f68df82 |
@ -64,7 +64,6 @@ You can specify configuration options either via a config file (default: `config
|
||||
| YAML Key | Environment Variable | Default | Description |
|
||||
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||
| `app.cleanup` | `WAKAPI_CLEANUP` | `false` | Whether or not to clean up old heartbeats (be careful!) |
|
||||
| `app.custom_languages` | - | - | Map from file endings to language names |
|
||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | Network address to listen on |
|
||||
|
@ -6,7 +6,6 @@ server:
|
||||
base_path: /
|
||||
|
||||
app:
|
||||
cleanup: false # only edit, if you know what you're doing
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
|
@ -22,6 +22,10 @@ const (
|
||||
defaultConfigPath = "config.yml"
|
||||
defaultConfigPathLegacy = "config.ini"
|
||||
defaultEnvConfigPathLegacy = ".env"
|
||||
|
||||
SQLDialectMysql = "mysql"
|
||||
SQLDialectPostgres = "postgres"
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
@ -30,7 +34,6 @@ var (
|
||||
)
|
||||
|
||||
type appConfig struct {
|
||||
CleanUp bool `default:"false" env:"WAKAPI_CLEANUP"`
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
LanguageColors map[string]string `yaml:"-"`
|
||||
@ -113,16 +116,16 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
||||
|
||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||
switch c.Dialect {
|
||||
case "mysql":
|
||||
case SQLDialectMysql:
|
||||
return mysql.New(mysql.Config{
|
||||
DriverName: c.Dialect,
|
||||
DSN: mysqlConnectionString(c),
|
||||
})
|
||||
case "postgres":
|
||||
case SQLDialectPostgres:
|
||||
return postgres.New(postgres.Config{
|
||||
DSN: postgresConnectionString(c),
|
||||
})
|
||||
case "sqlite3":
|
||||
case SQLDialectSqlite:
|
||||
return sqlite.Open(sqliteConnectionString(c))
|
||||
}
|
||||
return nil
|
||||
@ -225,7 +228,6 @@ func Load() *Config {
|
||||
|
||||
config.Version = readVersion()
|
||||
config.App.LanguageColors = readLanguageColors()
|
||||
// TODO: Read keys from env, so that users are not logged out every time the server is restarted
|
||||
config.Security.SecureCookie = securecookie.New(
|
||||
securecookie.GenerateRandomKey(64),
|
||||
securecookie.GenerateRandomKey(32),
|
||||
|
@ -59,7 +59,7 @@ func migrateLegacyConfig() error {
|
||||
}
|
||||
|
||||
if dbType == "" {
|
||||
dbType = "sqlite3"
|
||||
dbType = SQLDialectSqlite
|
||||
}
|
||||
|
||||
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(2)
|
||||
@ -76,8 +76,6 @@ func migrateLegacyConfig() error {
|
||||
basePath = basePathEnv
|
||||
}
|
||||
|
||||
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
|
||||
|
||||
// Read custom languages
|
||||
customLangs := make(map[string]string)
|
||||
languageKeys := cfg.Section("languages").Keys()
|
||||
@ -89,7 +87,6 @@ func migrateLegacyConfig() error {
|
||||
config := &Config{
|
||||
Env: env,
|
||||
App: appConfig{
|
||||
CleanUp: cleanUp,
|
||||
CustomLanguages: customLangs,
|
||||
},
|
||||
Security: securityConfig{
|
||||
|
40
main.go
40
main.go
@ -56,11 +56,6 @@ func main() {
|
||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||
}
|
||||
|
||||
// Show data loss warning
|
||||
if config.App.CleanUp {
|
||||
promptAbort("`CLEANUP` is set to `true`, which may cause data loss. Are you sure to continue?", 5)
|
||||
}
|
||||
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
|
||||
@ -68,6 +63,9 @@ func main() {
|
||||
db.Raw("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
|
||||
if config.IsDev() {
|
||||
db = db.Debug()
|
||||
}
|
||||
sqlDb, _ := db.DB()
|
||||
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
||||
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
||||
@ -102,18 +100,15 @@ func main() {
|
||||
// Aggregate heartbeats to summaries and persist them
|
||||
go aggregationService.Schedule()
|
||||
|
||||
if config.App.CleanUp {
|
||||
go heartbeatService.ScheduleCleanUp()
|
||||
}
|
||||
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
|
||||
// Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, languageMappingService)
|
||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
|
||||
homeHandler := routes.NewHomeHandler(userService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||
@ -144,12 +139,12 @@ func main() {
|
||||
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
||||
|
||||
// Public Routes
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(homeHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(homeHandler.PostSignup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
|
||||
|
||||
// Summary Routes
|
||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
||||
@ -157,10 +152,11 @@ func main() {
|
||||
// Settings Routes
|
||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCreateLanguageMapping)
|
||||
settingsRouter.Path("/language_mappings").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostLanguageMapping)
|
||||
settingsRouter.Path("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
|
||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
||||
settingsRouter.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostRegenerateSummaries)
|
||||
|
||||
// API Routes
|
||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||
@ -194,11 +190,3 @@ func runDatabaseMigrations() {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func promptAbort(message string, timeoutSec int) {
|
||||
log.Printf("[WARNING] %s.\nTo abort server startup, press Ctrl+C.\n", message)
|
||||
for i := timeoutSec; i > 0; i-- {
|
||||
log.Printf("Starting in %d seconds ...\n", i)
|
||||
time.Sleep(1 * time.Second)
|
||||
}
|
||||
}
|
||||
|
@ -30,6 +30,71 @@ func init() {
|
||||
},
|
||||
name: "rename language mappings table",
|
||||
},
|
||||
{
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
// drop all already existing foreign key constraints
|
||||
// afterwards let them be re-created by auto migrate with the newly introduced cascade settings,
|
||||
|
||||
migrator := db.Migrator()
|
||||
const lookupKey = "20201106-migration_cascade_constraints"
|
||||
|
||||
if cfg.Db.Dialect == config.SQLDialectSqlite {
|
||||
// https://stackoverflow.com/a/1884893/3112139
|
||||
// unfortunately, we can't migrate existing sqlite databases to the newly introduced cascade settings
|
||||
// things like deleting all summaries won't work in those cases unless an entirely new db is created
|
||||
log.Println("not attempting to drop and regenerate constraints on sqlite")
|
||||
return nil
|
||||
}
|
||||
|
||||
if !migrator.HasTable(&models.KeyStringValue{}) {
|
||||
log.Println("key-value table not yet existing")
|
||||
return nil
|
||||
}
|
||||
|
||||
condition := "key = ?"
|
||||
if cfg.Db.Dialect == config.SQLDialectMysql {
|
||||
condition = "`key` = ?"
|
||||
}
|
||||
lookupResult := db.Where(condition, lookupKey).First(&models.KeyStringValue{})
|
||||
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
|
||||
log.Println("no need to migrate cascade constraints")
|
||||
return nil
|
||||
}
|
||||
|
||||
// SELECT * FROM INFORMATION_SCHEMA.table_constraints;
|
||||
constraints := map[string]interface{}{
|
||||
"fk_summaries_editors": &models.SummaryItem{},
|
||||
"fk_summaries_languages": &models.SummaryItem{},
|
||||
"fk_summaries_machines": &models.SummaryItem{},
|
||||
"fk_summaries_operating_systems": &models.SummaryItem{},
|
||||
"fk_summaries_projects": &models.SummaryItem{},
|
||||
"fk_summary_items_summary": &models.SummaryItem{},
|
||||
"fk_summaries_user": &models.Summary{},
|
||||
"fk_language_mappings_user": &models.LanguageMapping{},
|
||||
"fk_heartbeats_user": &models.Heartbeat{},
|
||||
"fk_aliases_user": &models.Alias{},
|
||||
}
|
||||
|
||||
for name, table := range constraints {
|
||||
if migrator.HasConstraint(table, name) {
|
||||
log.Printf("dropping constraint '%s'", name)
|
||||
if err := migrator.DropConstraint(table, name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Create(&models.KeyStringValue{
|
||||
Key: lookupKey,
|
||||
Value: "done",
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
name: "add cascade constraints",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@ package models
|
||||
|
||||
type Alias struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
Type uint8 `gorm:"not null; index:idx_alias_type_key"`
|
||||
Type uint8 `gorm:"not null; index:idx_alias_type_key; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
UserID string `gorm:"not null; index:idx_alias_user"`
|
||||
Key string `gorm:"not null; index:idx_alias_type_key"`
|
||||
|
@ -18,13 +18,13 @@ func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{OS: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Language: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Editor: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Machine: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
@ -7,7 +7,7 @@ import (
|
||||
|
||||
type Heartbeat struct {
|
||||
ID uint `gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
|
||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
||||
Type string `json:"type"`
|
||||
@ -41,3 +41,24 @@ func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
}
|
||||
h.Language, _ = languageMappings[ending]
|
||||
}
|
||||
|
||||
func (h *Heartbeat) GetKey(t uint8) (key string) {
|
||||
switch t {
|
||||
case SummaryProject:
|
||||
key = h.Project
|
||||
case SummaryEditor:
|
||||
key = h.Editor
|
||||
case SummaryLanguage:
|
||||
key = h.Language
|
||||
case SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = UnknownSummaryKey
|
||||
}
|
||||
|
||||
return key
|
||||
}
|
||||
|
38
models/heartbeats.go
Normal file
38
models/heartbeats.go
Normal file
@ -0,0 +1,38 @@
|
||||
package models
|
||||
|
||||
import "sort"
|
||||
|
||||
type Heartbeats []*Heartbeat
|
||||
|
||||
func (h Heartbeats) Len() int {
|
||||
return len(h)
|
||||
}
|
||||
|
||||
func (h Heartbeats) Less(i, j int) bool {
|
||||
return h[i].Time.T().Before(h[j].Time.T())
|
||||
}
|
||||
|
||||
func (h Heartbeats) Swap(i, j int) {
|
||||
h[i], h[j] = h[j], h[i]
|
||||
}
|
||||
|
||||
func (h *Heartbeats) Sorted() *Heartbeats {
|
||||
sort.Sort(h)
|
||||
return h
|
||||
}
|
||||
|
||||
func (h *Heartbeats) First() *Heartbeat {
|
||||
// assumes slice to be sorted
|
||||
if h.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*h)[0]
|
||||
}
|
||||
|
||||
func (h *Heartbeats) Last() *Heartbeat {
|
||||
// assumes slice to be sorted
|
||||
if h.Len() == 0 {
|
||||
return nil
|
||||
}
|
||||
return (*h)[h.Len()-1]
|
||||
}
|
@ -2,16 +2,20 @@ package models
|
||||
|
||||
type LanguageMapping struct {
|
||||
ID uint `json:"id" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_language_mapping_user; uniqueIndex:idx_language_mapping_composite"`
|
||||
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite"`
|
||||
Language string `json:"language"`
|
||||
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite; type:varchar(16)"`
|
||||
Language string `json:"language" gorm:"type:varchar(64)"`
|
||||
}
|
||||
|
||||
func validateLanguage(language string) bool {
|
||||
return len(language) >= 1
|
||||
func (m *LanguageMapping) IsValid() bool {
|
||||
return m.validateLanguage() && m.validateExtension()
|
||||
}
|
||||
|
||||
func validateExtension(extension string) bool {
|
||||
return len(extension) >= 1
|
||||
func (m *LanguageMapping) validateLanguage() bool {
|
||||
return len(m.Language) >= 1
|
||||
}
|
||||
|
||||
func (m *LanguageMapping) validateExtension() bool {
|
||||
return len(m.Extension) >= 1
|
||||
}
|
||||
|
@ -24,6 +24,11 @@ type KeyStringValue struct {
|
||||
Value string `gorm:"type:text"`
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type CustomTime time.Time
|
||||
|
||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||
@ -79,3 +84,7 @@ func (j CustomTime) String() string {
|
||||
func (j CustomTime) T() time.Time {
|
||||
return time.Time(j)
|
||||
}
|
||||
|
||||
func (j CustomTime) Valid() bool {
|
||||
return j.T().Unix() >= 0
|
||||
}
|
||||
|
@ -35,20 +35,20 @@ const UnknownSummaryKey = "unknown"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; 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"`
|
||||
Projects []*SummaryItem `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Languages []*SummaryItem `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Editors []*SummaryItem `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
OperatingSystems []*SummaryItem `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Machines []*SummaryItem `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type SummaryItem struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
Summary *Summary `json:"-" gorm:"not null"`
|
||||
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
SummaryID uint `json:"-"`
|
||||
Type uint8 `json:"-"`
|
||||
Key string `json:"key"`
|
||||
@ -75,6 +75,8 @@ type SummaryParams struct {
|
||||
Recompute bool
|
||||
}
|
||||
|
||||
type AliasResolver func(t uint8, k string) string
|
||||
|
||||
func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
@ -178,3 +180,53 @@ func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
|
||||
}
|
||||
return timeSum
|
||||
}
|
||||
|
||||
func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
processAliases := func(origin []*SummaryItem) []*SummaryItem {
|
||||
target := make([]*SummaryItem, 0)
|
||||
|
||||
findItem := func(key string) *SummaryItem {
|
||||
for _, item := range target {
|
||||
if item.Key == key {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all "top-level" items, i.e. such without aliases
|
||||
if key := resolve(item.Type, item.Key); key == item.Key {
|
||||
target = append(target, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all remaining projects and merge with their alias
|
||||
if key := resolve(item.Type, item.Key); key != item.Key {
|
||||
if targetItem := findItem(key); targetItem != nil {
|
||||
targetItem.Total += item.Total
|
||||
} else {
|
||||
target = append(target, &SummaryItem{
|
||||
ID: item.ID,
|
||||
SummaryID: item.SummaryID,
|
||||
Type: item.Type,
|
||||
Key: key,
|
||||
Total: item.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// Resolve aliases
|
||||
s.Projects = processAliases(s.Projects)
|
||||
s.Editors = processAliases(s.Editors)
|
||||
s.Languages = processAliases(s.Languages)
|
||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||
s.Machines = processAliases(s.Machines)
|
||||
|
||||
return s
|
||||
}
|
||||
|
@ -26,6 +26,11 @@ type CredentialsReset struct {
|
||||
PasswordRepeat string `schema:"password_repeat"`
|
||||
}
|
||||
|
||||
type TimeByUser struct {
|
||||
User string
|
||||
Time CustomTime
|
||||
}
|
||||
|
||||
func (c *CredentialsReset) IsValid() bool {
|
||||
return validatePassword(c.PasswordNew) &&
|
||||
c.PasswordNew == c.PasswordRepeat
|
||||
|
16
models/view/home.go
Normal file
16
models/view/home.go
Normal file
@ -0,0 +1,16 @@
|
||||
package view
|
||||
|
||||
type HomeViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithError(m string) *HomeViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
22
models/view/imprint.go
Normal file
22
models/view/imprint.go
Normal file
@ -0,0 +1,22 @@
|
||||
package view
|
||||
|
||||
type ImprintViewModel struct {
|
||||
HtmlText string
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithSuccess(m string) *ImprintViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithError(m string) *ImprintViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *ImprintViewModel) WithHtmlText(t string) *ImprintViewModel {
|
||||
s.HtmlText = t
|
||||
return s
|
||||
}
|
20
models/view/settings.go
Normal file
20
models/view/settings.go
Normal file
@ -0,0 +1,20 @@
|
||||
package view
|
||||
|
||||
import "github.com/muety/wakapi/models"
|
||||
|
||||
type SettingsViewModel struct {
|
||||
User *models.User
|
||||
LanguageMappings []*models.LanguageMapping
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SettingsViewModel) WithError(m string) *SettingsViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
16
models/view/summary.go
Normal file
16
models/view/summary.go
Normal file
@ -0,0 +1,16 @@
|
||||
package view
|
||||
|
||||
type SummaryViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithSuccess(m string) *SummaryViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *SummaryViewModel) WithError(m string) *SummaryViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
@ -34,18 +34,14 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
// Will return *models.Heartbeat object with only user_id and time fields filled
|
||||
func (r *HeartbeatRepository) GetFirstByUsers(userIds []string) ([]*models.Heartbeat, error) {
|
||||
var heartbeats []*models.Heartbeat
|
||||
if err := r.db.
|
||||
Table("heartbeats").
|
||||
Select("user_id, min(time) as time").
|
||||
Where("user_id IN (?)", userIds).
|
||||
Group("user_id").
|
||||
Scan(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, min(time) as time").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
|
@ -1,6 +1,7 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
@ -34,6 +35,9 @@ func (r *LanguageMappingRepository) GetByUser(userId string) ([]*models.Language
|
||||
}
|
||||
|
||||
func (r *LanguageMappingRepository) Insert(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
|
||||
if !mapping.IsValid() {
|
||||
return nil, errors.New("invalid mapping")
|
||||
}
|
||||
result := r.db.Create(mapping)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
|
@ -39,15 +39,21 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
// Will return *models.Index objects with only user_id and to_time filled
|
||||
func (r *SummaryRepository) GetLatestByUser() ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Table("summaries").
|
||||
Select("user_id, max(to_time) as to_time").
|
||||
Group("user_id").
|
||||
Scan(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return summaries, nil
|
||||
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(to_time) as time").
|
||||
Joins("left join summaries on users.id = summaries.user_id").
|
||||
Group("user").
|
||||
Scan(&result)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", userId).
|
||||
Delete(models.Summary{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
@ -54,7 +54,7 @@ func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, err
|
||||
}
|
||||
|
||||
func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
result := r.db.Model(&models.User{}).Updates(user)
|
||||
result := r.db.Model(user).Updates(user)
|
||||
if err := result.Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -97,9 +97,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
|
||||
User: user,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute),
|
||||
)
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -55,9 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
|
||||
Recompute: false,
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
||||
)
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -86,9 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
summaries := make([]*models.Summary, len(intervals))
|
||||
|
||||
for i, interval := range intervals {
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(interval[0], interval[1], user, false), // 'to' is always constant
|
||||
)
|
||||
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
@ -43,13 +43,6 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
/*languageMappings, err := h.languageMappingSrvc.ResolveByUser(user.ID)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}*/
|
||||
|
||||
for _, hb := range heartbeats {
|
||||
hb.OperatingSystem = opSys
|
||||
hb.Editor = editor
|
||||
|
@ -6,6 +6,7 @@ import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -13,24 +14,22 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type IndexHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
keyValueSrvc *services.KeyValueService
|
||||
type HomeHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
}
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
|
||||
return &IndexHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
keyValueSrvc: keyValueService,
|
||||
func NewHomeHandler(userService *services.UserService) *HomeHandler {
|
||||
return &HomeHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -40,29 +39,10 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, "") {
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.IndexTemplate].Execute(w, nil)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
text := "failed to load content"
|
||||
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
|
||||
text = data.Value
|
||||
}
|
||||
|
||||
templates[conf.ImprintTemplate].Execute(w, &struct {
|
||||
HtmlText string
|
||||
}{HtmlText: text})
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -74,29 +54,34 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", "", http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
respondAlert(w, "resource not found", "", "", http.StatusNotFound)
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: depending on middleware package here is a hack
|
||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
|
||||
respondAlert(w, "invalid credentials", "", "", http.StatusUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
respondAlert(w, "internal server error", "", "", http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -114,7 +99,7 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -123,7 +108,7 @@ func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -133,14 +118,10 @@ func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
if handleAlerts(w, r, conf.SignupTemplate) {
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SignupTemplate].Execute(w, nil)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -152,29 +133,41 @@ func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
respondAlert(w, "missing parameters", "", conf.SignupTemplate, http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
respondAlert(w, "invalid parameters", "", conf.SignupTemplate, http.StatusBadRequest)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
respondAlert(w, "failed to create new user", "", conf.SignupTemplate, http.StatusInternalServerError)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
respondAlert(w, "user already existing", "", conf.SignupTemplate, http.StatusConflict)
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape("account created successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
return &view.HomeViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
41
routes/imprint.go
Normal file
41
routes/imprint.go
Normal file
@ -0,0 +1,41 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type ImprintHandler struct {
|
||||
config *conf.Config
|
||||
keyValueSrvc *services.KeyValueService
|
||||
}
|
||||
|
||||
func NewImprintHandler(keyValueService *services.KeyValueService) *ImprintHandler {
|
||||
return &ImprintHandler{
|
||||
config: conf.Get(),
|
||||
keyValueSrvc: keyValueService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
text := "failed to load content"
|
||||
if data, err := h.keyValueSrvc.GetString(models.ImprintKey); err == nil {
|
||||
text = data.Value
|
||||
}
|
||||
|
||||
templates[conf.ImprintTemplate].Execute(w, h.buildViewModel(r).WithHtmlText(text))
|
||||
}
|
||||
|
||||
func (h *ImprintHandler) buildViewModel(r *http.Request) *view.ImprintViewModel {
|
||||
return &view.ImprintViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
@ -6,7 +6,6 @@ import (
|
||||
"github.com/muety/wakapi/utils"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
)
|
||||
@ -58,33 +57,3 @@ func loadTemplates() {
|
||||
templates[tplName] = tpl
|
||||
}
|
||||
}
|
||||
|
||||
func respondAlert(w http.ResponseWriter, error, success, tplName string, status int) {
|
||||
w.WriteHeader(status)
|
||||
if tplName == "" {
|
||||
tplName = config.IndexTemplate
|
||||
}
|
||||
templates[tplName].Execute(w, struct {
|
||||
Error string
|
||||
Success string
|
||||
}{Error: error, Success: success})
|
||||
}
|
||||
|
||||
// TODO: do better
|
||||
func handleAlerts(w http.ResponseWriter, r *http.Request, tplName string) bool {
|
||||
if err := r.URL.Query().Get("error"); err != "" {
|
||||
if err == "unauthorized" {
|
||||
respondAlert(w, err, "", tplName, http.StatusUnauthorized)
|
||||
} else {
|
||||
respondAlert(w, err, "", tplName, http.StatusInternalServerError)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if success := r.URL.Query().Get("success"); success != "" {
|
||||
respondAlert(w, "", success, tplName, http.StatusOK)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
@ -5,8 +5,10 @@ import (
|
||||
"github.com/gorilla/schema"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
@ -15,14 +17,18 @@ import (
|
||||
type SettingsHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
aggregationSrvc *services.AggregationService
|
||||
languageMappingSrvc *services.LanguageMappingService
|
||||
}
|
||||
|
||||
var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService *services.UserService, languageMappingService *services.LanguageMappingService) *SettingsHandler {
|
||||
func NewSettingsHandler(userService *services.UserService, summaryService *services.SummaryService, aggregationService *services.AggregationService, languageMappingService *services.LanguageMappingService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: conf.Get(),
|
||||
summarySrvc: summaryService,
|
||||
aggregationSrvc: aggregationService,
|
||||
languageMappingSrvc: languageMappingService,
|
||||
userSrvc: userService,
|
||||
}
|
||||
@ -33,16 +39,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||
data := map[string]interface{}{
|
||||
"User": user,
|
||||
"LanguageMappings": mappings,
|
||||
"Success": r.FormValue("success"),
|
||||
"Error": r.FormValue("error"),
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, data)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||
@ -54,34 +51,40 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
|
||||
var credentials models.CredentialsReset
|
||||
if err := r.ParseForm(); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("missing parameters")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("missing parameters")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("invalid credentials")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
if !credentials.IsValid() {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("invalid parameters")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user.Password = credentials.PasswordNew
|
||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
} else {
|
||||
user.Password = hash
|
||||
}
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -91,7 +94,8 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -104,7 +108,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("password was updated successfully")), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
@ -115,7 +119,8 @@ func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.R
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("could not delete mapping")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -126,14 +131,15 @@ func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.R
|
||||
|
||||
err = h.languageMappingSrvc.Delete(mapping)
|
||||
if err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("could not delete mapping")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("could not delete mapping"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("mapping deleted successfully")), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping deleted successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostCreateLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *SettingsHandler) PostLanguageMapping(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
@ -152,11 +158,12 @@ func (h *SettingsHandler) PostCreateLanguageMapping(w http.ResponseWriter, r *ht
|
||||
}
|
||||
|
||||
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("mapping already exists")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("mapping already exists"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, url.QueryEscape("mapping added successfully")), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("mapping added successfully"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||
@ -166,12 +173,13 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
|
||||
@ -180,11 +188,47 @@ func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Reques
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings?error=%s", h.config.Server.BasePath, url.QueryEscape("internal server error")), http.StatusFound)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.Server.BasePath), http.StatusFound)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) PostRegenerateSummaries(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
|
||||
log.Printf("clearing summaries for user '%s'\n", user.ID)
|
||||
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
|
||||
log.Printf("failed to clear summaries: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to delete old summaries"))
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
|
||||
log.Printf("failed to regenerate summaries: %v\n", err)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate aggregations"))
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("summaries are being regenerated – this may take a few seconds"))
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||
return &view.SettingsViewModel{
|
||||
User: user,
|
||||
LanguageMappings: mappings,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -44,13 +45,15 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
summary, err, status := h.loadUserSummary(r)
|
||||
if err != nil {
|
||||
respondAlert(w, err.Error(), "", conf.SummaryTemplate, status)
|
||||
w.WriteHeader(status)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
user := r.Context().Value(models.UserKey).(*models.User)
|
||||
if user == nil {
|
||||
respondAlert(w, "unauthorized", "", conf.SummaryTemplate, http.StatusUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError("unauthorized"))
|
||||
return
|
||||
}
|
||||
|
||||
@ -69,12 +72,22 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
||||
)
|
||||
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
retrieveSummary = h.summarySrvc.Summarize
|
||||
}
|
||||
|
||||
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
||||
|
||||
func (h *SummaryHandler) buildViewModel(r *http.Request) *view.SummaryViewModel {
|
||||
return &view.SummaryViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
||||
|
3
scripts/docker_mysql.sh
Normal file
3
scripts/docker_mysql.sh
Normal file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 3306:3306 -e MYSQL_ROOT_PASSWORD=secretpassword -e MYSQL_DATABASE=wakapi_local -e MYSQL_USER=wakapi_user -e MYSQL_PASSWORD=wakapi --name wakapi-mysql mysql:5
|
3
scripts/docker_postgres.sh
Normal file
3
scripts/docker_postgres.sh
Normal file
@ -0,0 +1,3 @@
|
||||
#!/bin/bash
|
||||
|
||||
docker run -d -p 5432:5432 -e POSTGRES_DATABASE=wakapi_local -e POSTGRES_USER=wakapi_user -e POSTGRES_PASSWORD=wakapi --name wakapi-postgres postgres
|
@ -38,10 +38,18 @@ type AggregationJob struct {
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
func (srv *AggregationService) Schedule() {
|
||||
// Run once initially
|
||||
if err := srv.Run(nil); err != nil {
|
||||
log.Fatalf("failed to run aggregation jobs: %v\n", err)
|
||||
}
|
||||
|
||||
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, nil)
|
||||
<-gocron.Start()
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
jobs := make(chan *AggregationJob)
|
||||
summaries := make(chan *models.Summary)
|
||||
defer close(jobs)
|
||||
defer close(summaries)
|
||||
|
||||
for i := 0; i < runtime.NumCPU(); i++ {
|
||||
go srv.summaryWorker(jobs, summaries)
|
||||
@ -51,16 +59,19 @@ func (srv *AggregationService) Schedule() {
|
||||
go srv.persistWorker(summaries)
|
||||
}
|
||||
|
||||
// Run once initially
|
||||
srv.trigger(jobs)
|
||||
// don't leak open channels
|
||||
go func(c1 chan *AggregationJob, c2 chan *models.Summary) {
|
||||
defer close(c1)
|
||||
defer close(c2)
|
||||
time.Sleep(1 * time.Hour)
|
||||
}(jobs, summaries)
|
||||
|
||||
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.trigger, jobs)
|
||||
<-gocron.Start()
|
||||
return srv.trigger(jobs, userIds)
|
||||
}
|
||||
|
||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||
for job := range jobs {
|
||||
if summary, err := srv.summaryService.Construct(job.From, job.To, &models.User{ID: job.UserID}, true); err != nil {
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}); err != nil {
|
||||
log.Printf("Failed to generate summary (%v, %v, %s) – %v.\n", job.From, job.To, job.UserID, err)
|
||||
} else {
|
||||
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
|
||||
@ -77,66 +88,77 @@ func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob) error {
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
|
||||
log.Println("Generating summaries.")
|
||||
|
||||
users, err := srv.userService.GetAll()
|
||||
if err != nil {
|
||||
var users []*models.User
|
||||
if allUsers, err := srv.userService.GetAll(); err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
latestSummaries, err := srv.summaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
userSummaryTimes := make(map[string]time.Time)
|
||||
for _, s := range latestSummaries {
|
||||
userSummaryTimes[s.UserID] = s.ToTime.T()
|
||||
}
|
||||
|
||||
missingUserIDs := make([]string, 0)
|
||||
for _, u := range users {
|
||||
if _, ok := userSummaryTimes[u.ID]; !ok {
|
||||
missingUserIDs = append(missingUserIDs, u.ID)
|
||||
} else if userIds != nil && len(userIds) > 0 {
|
||||
users = make([]*models.User, len(userIds))
|
||||
for i, u := range allUsers {
|
||||
if yes, ok := userIds[u.ID]; yes && ok {
|
||||
users[i] = u
|
||||
}
|
||||
}
|
||||
} else {
|
||||
users = allUsers
|
||||
}
|
||||
|
||||
firstHeartbeats, err := srv.heartbeatService.GetFirstUserHeartbeats(missingUserIDs)
|
||||
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
||||
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
for id, t := range userSummaryTimes {
|
||||
generateUserJobs(id, t, jobs)
|
||||
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
|
||||
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
for _, h := range firstHeartbeats {
|
||||
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
|
||||
// Build actual lookup table from it
|
||||
firstUserHeartbeatLookup := make(map[string]models.CustomTime)
|
||||
for _, e := range firstUserHeartbeatTimes {
|
||||
firstUserHeartbeatLookup[e.User] = e.Time
|
||||
}
|
||||
|
||||
// Generate summary aggregation jobs
|
||||
for _, e := range lastUserSummaryTimes {
|
||||
if e.Time.Valid() {
|
||||
// Case 1: User has aggregated summaries already
|
||||
// -> Spawn jobs to create summaries from their latest aggregation to now
|
||||
generateUserJobs(e.User, e.Time.T(), jobs)
|
||||
} else if t := firstUserHeartbeatLookup[e.User]; t.Valid() {
|
||||
// Case 2: User has no aggregated summaries, yet, but has heartbeats
|
||||
// -> Spawn jobs to create summaries from their first heartbeat to now
|
||||
generateUserJobs(e.User, t.T(), jobs)
|
||||
}
|
||||
// Case 3: User doesn't have heartbeats at all
|
||||
// -> Nothing to do
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
|
||||
var from, to time.Time
|
||||
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||
var to time.Time
|
||||
|
||||
// Go to next day of either user's first heartbeat or latest aggregation
|
||||
from.Add(-1 * time.Second)
|
||||
from = time.Date(
|
||||
from.Year(),
|
||||
from.Month(),
|
||||
from.Day()+aggregateIntervalDays,
|
||||
0, 0, 0, 0,
|
||||
from.Location(),
|
||||
)
|
||||
|
||||
// Iteratively aggregate per-day summaries until end of yesterday is reached
|
||||
end := getStartOfToday().Add(-1 * time.Second)
|
||||
|
||||
if lastAggregation.Hour() == 0 {
|
||||
from = lastAggregation
|
||||
} else {
|
||||
from = time.Date(
|
||||
lastAggregation.Year(),
|
||||
lastAggregation.Month(),
|
||||
lastAggregation.Day()+aggregateIntervalDays,
|
||||
0, 0, 0, 0,
|
||||
lastAggregation.Location(),
|
||||
)
|
||||
}
|
||||
|
||||
for from.Before(end) && to.Before(end) {
|
||||
to = time.Date(
|
||||
from.Year(),
|
||||
|
@ -1,20 +1,13 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const (
|
||||
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
|
||||
)
|
||||
|
||||
type HeartbeatService struct {
|
||||
config *config.Config
|
||||
repository *repositories.HeartbeatRepository
|
||||
@ -41,31 +34,14 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
|
||||
return srv.augmented(heartbeats, user.ID)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
|
||||
return srv.repository.GetFirstByUsers(userIds)
|
||||
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetFirstByUsers()
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
return srv.repository.DeleteBefore(t)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) CleanUp() error {
|
||||
refTime := utils.StartOfToday().Add(-cleanUpInterval)
|
||||
if err := srv.DeleteBefore(refTime); err != nil {
|
||||
log.Printf("Failed to clean up heartbeats older than %v – %v\n", refTime, err)
|
||||
return err
|
||||
}
|
||||
log.Printf("Successfully cleaned up heartbeats older than %v\n", refTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) ScheduleCleanUp() {
|
||||
srv.CleanUp()
|
||||
|
||||
gocron.Every(1).Day().At("02:30").Do(srv.CleanUp)
|
||||
<-gocron.Start()
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||
if err != nil {
|
||||
|
@ -4,14 +4,12 @@ import (
|
||||
"crypto/md5"
|
||||
"errors"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
@ -24,6 +22,8 @@ type SummaryService struct {
|
||||
aliasService *AliasService
|
||||
}
|
||||
|
||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||
|
||||
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||
return &SummaryService{
|
||||
config: config.Get(),
|
||||
@ -34,60 +34,98 @@ func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatSer
|
||||
}
|
||||
}
|
||||
|
||||
type Interval struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
}
|
||||
// Public summary generation methods
|
||||
|
||||
// TODO: simplify!
|
||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
||||
var existingSummaries []*models.Summary
|
||||
var cacheKey string
|
||||
|
||||
if recompute {
|
||||
existingSummaries = make([]*models.Summary, 0)
|
||||
} else {
|
||||
cacheKey = getHash([]time.Time{from, to}, user)
|
||||
if result, ok := srv.cache.Get(cacheKey); ok {
|
||||
return result.(*models.Summary), nil
|
||||
}
|
||||
summaries, err := srv.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existingSummaries = summaries
|
||||
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
|
||||
// Check cache
|
||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.(*models.Summary), nil
|
||||
}
|
||||
|
||||
missingIntervals := getMissingIntervals(from, to, existingSummaries)
|
||||
// Wrap alias resolution
|
||||
resolve := func(t uint8, k string) string {
|
||||
s, _ := srv.aliasService.GetAliasOrDefault(user.ID, t, k)
|
||||
return s
|
||||
}
|
||||
|
||||
heartbeats := make([]*models.Heartbeat, 0)
|
||||
// Initialize alias resolver service
|
||||
if err := srv.aliasService.LoadUserAliases(user.ID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get actual summary
|
||||
s, err := f(from, to, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Post-process summary and cache it
|
||||
summary := s.WithResolvedAliases(resolve)
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
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, "--aliased")
|
||||
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
|
||||
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate missing slots (especially before and after existing summaries) from raw heartbeats
|
||||
missingIntervals := srv.getMissingIntervals(from, to, summaries)
|
||||
for _, interval := range missingIntervals {
|
||||
hb, err := srv.heartbeatService.GetAllWithin(interval.Start, interval.End, user)
|
||||
if err != nil {
|
||||
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
|
||||
summaries = append(summaries, s)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
heartbeats = append(heartbeats, hb...)
|
||||
}
|
||||
|
||||
// Merge existing and newly generated summary snippets
|
||||
summary, err := srv.mergeSummaries(summaries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache 'em
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
// Initialize and fetch data
|
||||
var heartbeats models.Heartbeats
|
||||
if rawHeartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user); err == nil {
|
||||
heartbeats = rawHeartbeats
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
types := models.SummaryTypes()
|
||||
|
||||
typedAggregations := make(chan models.SummaryItemContainer)
|
||||
defer close(typedAggregations)
|
||||
for _, t := range types {
|
||||
go srv.aggregateBy(heartbeats, t, typedAggregations)
|
||||
}
|
||||
|
||||
// Aggregate raw heartbeats by types in parallel and collect them
|
||||
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
|
||||
}
|
||||
|
||||
c := make(chan models.SummaryItemContainer)
|
||||
for _, t := range types {
|
||||
go srv.aggregateBy(heartbeats, t, user, c)
|
||||
}
|
||||
|
||||
for i := 0; i < len(types); i++ {
|
||||
item := <-c
|
||||
item := <-typedAggregations
|
||||
switch item.Type {
|
||||
case models.SummaryProject:
|
||||
projectItems = item.Items
|
||||
@ -101,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
machineItems = item.Items
|
||||
}
|
||||
}
|
||||
close(c)
|
||||
|
||||
realFrom, realTo := from, to
|
||||
if len(existingSummaries) > 0 {
|
||||
realFrom = existingSummaries[0].FromTime.T()
|
||||
realTo = existingSummaries[len(existingSummaries)-1].ToTime.T()
|
||||
|
||||
for _, summary := range existingSummaries {
|
||||
summary.FillUnknown()
|
||||
}
|
||||
}
|
||||
if len(heartbeats) > 0 {
|
||||
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
|
||||
if t1.After(realFrom) && t1.Before(time.Date(realFrom.Year(), realFrom.Month(), realFrom.Day()+1, 0, 0, 0, 0, realFrom.Location())) {
|
||||
realFrom = t1
|
||||
}
|
||||
if t2.Before(realTo) && t2.After(time.Date(realTo.Year(), realTo.Month(), realTo.Day()-1, 0, 0, 0, 0, realTo.Location())) {
|
||||
realTo = t2
|
||||
}
|
||||
if heartbeats.Len() > 0 {
|
||||
from = time.Time(heartbeats.First().Time)
|
||||
to = time.Time(heartbeats.Last().Time)
|
||||
}
|
||||
|
||||
aggregatedSummary := &models.Summary{
|
||||
summary := &models.Summary{
|
||||
UserID: user.ID,
|
||||
FromTime: models.CustomTime(realFrom),
|
||||
ToTime: models.CustomTime(realTo),
|
||||
FromTime: models.CustomTime(from),
|
||||
ToTime: models.CustomTime(to),
|
||||
Projects: projectItems,
|
||||
Languages: languageItems,
|
||||
Editors: editorItems,
|
||||
@ -133,119 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
allSummaries := []*models.Summary{aggregatedSummary}
|
||||
allSummaries = append(allSummaries, existingSummaries...)
|
||||
|
||||
summary, err := mergeSummaries(allSummaries)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cacheKey != "" {
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
}
|
||||
summary.FillUnknown()
|
||||
|
||||
return summary, nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) PostProcessWrapped(summary *models.Summary, err error) (*models.Summary, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srv.PostProcess(summary), nil
|
||||
// CRUD methods
|
||||
|
||||
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
||||
return srv.repository.GetLastByUser()
|
||||
}
|
||||
|
||||
func (srv *SummaryService) PostProcess(summary *models.Summary) *models.Summary {
|
||||
updatedSummary := &models.Summary{
|
||||
ID: summary.ID,
|
||||
UserID: summary.UserID,
|
||||
FromTime: summary.FromTime,
|
||||
ToTime: summary.ToTime,
|
||||
}
|
||||
|
||||
processAliases := func(origin []*models.SummaryItem) []*models.SummaryItem {
|
||||
target := make([]*models.SummaryItem, 0)
|
||||
|
||||
findItem := func(key string) *models.SummaryItem {
|
||||
for _, item := range target {
|
||||
if item.Key == key {
|
||||
return item
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all "top-level" items, i.e. such without aliases
|
||||
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key == item.Key {
|
||||
target = append(target, item)
|
||||
}
|
||||
}
|
||||
|
||||
for _, item := range origin {
|
||||
// Add all remaining projects and merge with their alias
|
||||
if key, _ := srv.aliasService.GetAliasOrDefault(summary.UserID, item.Type, item.Key); key != item.Key {
|
||||
if targetItem := findItem(key); targetItem != nil {
|
||||
targetItem.Total += item.Total
|
||||
} else {
|
||||
target = append(target, &models.SummaryItem{
|
||||
ID: item.ID,
|
||||
SummaryID: item.SummaryID,
|
||||
Type: item.Type,
|
||||
Key: key,
|
||||
Total: item.Total,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
// Resolve aliases
|
||||
updatedSummary.Projects = processAliases(summary.Projects)
|
||||
updatedSummary.Editors = processAliases(summary.Editors)
|
||||
updatedSummary.Languages = processAliases(summary.Languages)
|
||||
updatedSummary.OperatingSystems = processAliases(summary.OperatingSystems)
|
||||
updatedSummary.Machines = processAliases(summary.Machines)
|
||||
|
||||
return updatedSummary
|
||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||
return srv.repository.DeleteByUser(userId)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||
return srv.repository.Insert(summary)
|
||||
}
|
||||
|
||||
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
||||
return srv.repository.GetByUserWithin(user, from, to)
|
||||
}
|
||||
// Private summary generation and utility methods
|
||||
|
||||
// Will return *models.Index objects with only user_id and to_time filled
|
||||
func (srv *SummaryService) GetLatestByUser() ([]*models.Summary, error) {
|
||||
return srv.repository.GetLatestByUser()
|
||||
}
|
||||
|
||||
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, user *models.User, c chan models.SummaryItemContainer) {
|
||||
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
|
||||
durations := make(map[string]time.Duration)
|
||||
|
||||
for i, h := range heartbeats {
|
||||
var key string
|
||||
switch summaryType {
|
||||
case models.SummaryProject:
|
||||
key = h.Project
|
||||
case models.SummaryEditor:
|
||||
key = h.Editor
|
||||
case models.SummaryLanguage:
|
||||
key = h.Language
|
||||
case models.SummaryOS:
|
||||
key = h.OperatingSystem
|
||||
case models.SummaryMachine:
|
||||
key = h.Machine
|
||||
}
|
||||
|
||||
if key == "" {
|
||||
key = models.UnknownSummaryKey
|
||||
}
|
||||
key := h.GetKey(summaryType)
|
||||
|
||||
if _, ok := durations[key]; !ok {
|
||||
durations[key] = time.Duration(0)
|
||||
@ -283,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
||||
}
|
||||
|
||||
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval {
|
||||
if len(existingSummaries) == 0 {
|
||||
return []*Interval{{from, to}}
|
||||
}
|
||||
|
||||
intervals := make([]*Interval, 0)
|
||||
|
||||
// Pre
|
||||
if from.Before(existingSummaries[0].FromTime.T()) {
|
||||
intervals = append(intervals, &Interval{from, existingSummaries[0].FromTime.T()})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(existingSummaries)-1; i++ {
|
||||
t1, t2 := existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[i].ToTime.T(), existingSummaries[i+1].FromTime.T()})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(existingSummaries[len(existingSummaries)-1].ToTime.T()) {
|
||||
intervals = append(intervals, &Interval{existingSummaries[len(existingSummaries)-1].ToTime.T(), to})
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
if len(summaries) < 1 {
|
||||
return nil, errors.New("no summaries given")
|
||||
}
|
||||
@ -349,11 +249,11 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
maxTime = s.ToTime.T()
|
||||
}
|
||||
|
||||
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||
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.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||
finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||
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.FromTime = models.CustomTime(minTime)
|
||||
@ -362,7 +262,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||
return finalSummary, nil
|
||||
}
|
||||
|
||||
func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
||||
func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem) []*models.SummaryItem {
|
||||
items := make(map[string]*models.SummaryItem)
|
||||
|
||||
// Build map from existing
|
||||
@ -392,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
|
||||
return itemList
|
||||
}
|
||||
|
||||
func getHash(times []time.Time, user *models.User) string {
|
||||
digest := md5.New()
|
||||
for _, t := range times {
|
||||
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
|
||||
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
|
||||
if len(summaries) == 0 {
|
||||
return []*models.Interval{{from, to}}
|
||||
}
|
||||
|
||||
intervals := make([]*models.Interval, 0)
|
||||
|
||||
// Pre
|
||||
if from.Before(summaries[0].FromTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(summaries)-1; i++ {
|
||||
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
|
||||
if t1.Equal(t2) {
|
||||
continue
|
||||
}
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(summaries[len(summaries)-1].ToTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
|
||||
}
|
||||
|
||||
return intervals
|
||||
}
|
||||
|
||||
func (srv *SummaryService) getHash(args ...string) string {
|
||||
digest := md5.New()
|
||||
for _, a := range args {
|
||||
digest.Write([]byte(a))
|
||||
}
|
||||
digest.Write([]byte(user.ID))
|
||||
return string(digest.Sum(nil))
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
1.14.1
|
||||
1.16.2
|
||||
|
@ -48,7 +48,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Reset API Key
|
||||
</div>
|
||||
@ -66,7 +66,7 @@
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Language Mappings
|
||||
</div>
|
||||
@ -119,7 +119,7 @@
|
||||
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="w-full mt-4 mb-8 pb-8 border-b border-gray-700">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
Badges
|
||||
</div>
|
||||
@ -169,6 +169,34 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="w-full mt-4 mb-8 pb-8">
|
||||
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||
⚠️ Danger Zone
|
||||
</div>
|
||||
<div class="mt-10 text-gray-300 text-sm">
|
||||
<h3 class="font-semibold text-md mb-4 border-b border-green-700 inline-block">
|
||||
Regenerate summaries
|
||||
</h3>
|
||||
<p>
|
||||
Wakapi improves its efficiency and speed by automatically aggregating individual heartbeats to summaries on a per-day basis.
|
||||
That is, historic summaries, i.e. such from past days, are generated once and only fetched from the database in a static fashion afterwards, unless you pass <span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">&recompute=true</span> with your request.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
If, for some reason, these aggregated summaries are faulty or preconditions have change (e.g. you modified language mappings retrospectively), you may want to re-generate them from raw heartbeats.
|
||||
</p>
|
||||
<p class="mt-2">
|
||||
<strong>Note:</strong> Only run this action if you know what you are doing. Data might be lost is case heartbeats were deleted after the respective summaries had been generated.
|
||||
</p>
|
||||
</div>
|
||||
<div class="mt-10 flex justify-center">
|
||||
<form action="settings/regenerate" method="post" id="form-regenerate-summaries">
|
||||
<button type="button" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm" id="btn-regenerate-summaries">
|
||||
Clear & Regenerate
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@ -182,6 +210,14 @@
|
||||
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
||||
e.classList.remove('hidden')
|
||||
})
|
||||
|
||||
const btnRegenerate = document.querySelector("#btn-regenerate-summaries")
|
||||
const formRegenerate = document.querySelector('#form-regenerate-summaries')
|
||||
btnRegenerate.addEventListener('click', () => {
|
||||
if (confirm('Are you sure?')) {
|
||||
formRegenerate.submit()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
Reference in New Issue
Block a user