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 |
|
| YAML Key | Environment Variable | Default | Description |
|
||||||
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
|---------------------------|---------------------------|--------------|---------------------------------------------------------------------|
|
||||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
| `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 |
|
| `app.custom_languages` | - | - | Map from file endings to language names |
|
||||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | Network address 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: /
|
base_path: /
|
||||||
|
|
||||||
app:
|
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
|
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||||
custom_languages:
|
custom_languages:
|
||||||
vue: Vue
|
vue: Vue
|
||||||
|
@ -22,6 +22,10 @@ const (
|
|||||||
defaultConfigPath = "config.yml"
|
defaultConfigPath = "config.yml"
|
||||||
defaultConfigPathLegacy = "config.ini"
|
defaultConfigPathLegacy = "config.ini"
|
||||||
defaultEnvConfigPathLegacy = ".env"
|
defaultEnvConfigPathLegacy = ".env"
|
||||||
|
|
||||||
|
SQLDialectMysql = "mysql"
|
||||||
|
SQLDialectPostgres = "postgres"
|
||||||
|
SQLDialectSqlite = "sqlite3"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -30,7 +34,6 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type appConfig struct {
|
type appConfig struct {
|
||||||
CleanUp bool `default:"false" env:"WAKAPI_CLEANUP"`
|
|
||||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||||
LanguageColors map[string]string `yaml:"-"`
|
LanguageColors map[string]string `yaml:"-"`
|
||||||
@ -113,16 +116,16 @@ func (c *Config) GetFixturesFunc(dbDialect string) models.MigrationFunc {
|
|||||||
|
|
||||||
func (c *dbConfig) GetDialector() gorm.Dialector {
|
func (c *dbConfig) GetDialector() gorm.Dialector {
|
||||||
switch c.Dialect {
|
switch c.Dialect {
|
||||||
case "mysql":
|
case SQLDialectMysql:
|
||||||
return mysql.New(mysql.Config{
|
return mysql.New(mysql.Config{
|
||||||
DriverName: c.Dialect,
|
DriverName: c.Dialect,
|
||||||
DSN: mysqlConnectionString(c),
|
DSN: mysqlConnectionString(c),
|
||||||
})
|
})
|
||||||
case "postgres":
|
case SQLDialectPostgres:
|
||||||
return postgres.New(postgres.Config{
|
return postgres.New(postgres.Config{
|
||||||
DSN: postgresConnectionString(c),
|
DSN: postgresConnectionString(c),
|
||||||
})
|
})
|
||||||
case "sqlite3":
|
case SQLDialectSqlite:
|
||||||
return sqlite.Open(sqliteConnectionString(c))
|
return sqlite.Open(sqliteConnectionString(c))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@ -225,7 +228,6 @@ func Load() *Config {
|
|||||||
|
|
||||||
config.Version = readVersion()
|
config.Version = readVersion()
|
||||||
config.App.LanguageColors = readLanguageColors()
|
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(
|
config.Security.SecureCookie = securecookie.New(
|
||||||
securecookie.GenerateRandomKey(64),
|
securecookie.GenerateRandomKey(64),
|
||||||
securecookie.GenerateRandomKey(32),
|
securecookie.GenerateRandomKey(32),
|
||||||
|
@ -59,7 +59,7 @@ func migrateLegacyConfig() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if dbType == "" {
|
if dbType == "" {
|
||||||
dbType = "sqlite3"
|
dbType = SQLDialectSqlite
|
||||||
}
|
}
|
||||||
|
|
||||||
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(2)
|
dbMaxConn := cfg.Section("database").Key("max_connections").MustUint(2)
|
||||||
@ -76,8 +76,6 @@ func migrateLegacyConfig() error {
|
|||||||
basePath = basePathEnv
|
basePath = basePathEnv
|
||||||
}
|
}
|
||||||
|
|
||||||
cleanUp := cfg.Section("app").Key("cleanup").MustBool(false)
|
|
||||||
|
|
||||||
// Read custom languages
|
// Read custom languages
|
||||||
customLangs := make(map[string]string)
|
customLangs := make(map[string]string)
|
||||||
languageKeys := cfg.Section("languages").Keys()
|
languageKeys := cfg.Section("languages").Keys()
|
||||||
@ -89,7 +87,6 @@ func migrateLegacyConfig() error {
|
|||||||
config := &Config{
|
config := &Config{
|
||||||
Env: env,
|
Env: env,
|
||||||
App: appConfig{
|
App: appConfig{
|
||||||
CleanUp: cleanUp,
|
|
||||||
CustomLanguages: customLangs,
|
CustomLanguages: customLangs,
|
||||||
},
|
},
|
||||||
Security: securityConfig{
|
Security: securityConfig{
|
||||||
|
40
main.go
40
main.go
@ -56,11 +56,6 @@ func main() {
|
|||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
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
|
// Connect to database
|
||||||
var err error
|
var err error
|
||||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
|
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{})
|
||||||
@ -68,6 +63,9 @@ func main() {
|
|||||||
db.Raw("PRAGMA foreign_keys = ON;")
|
db.Raw("PRAGMA foreign_keys = ON;")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if config.IsDev() {
|
||||||
|
db = db.Debug()
|
||||||
|
}
|
||||||
sqlDb, _ := db.DB()
|
sqlDb, _ := db.DB()
|
||||||
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
sqlDb.SetMaxIdleConns(int(config.Db.MaxConn))
|
||||||
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
sqlDb.SetMaxOpenConns(int(config.Db.MaxConn))
|
||||||
@ -102,18 +100,15 @@ func main() {
|
|||||||
// Aggregate heartbeats to summaries and persist them
|
// Aggregate heartbeats to summaries and persist them
|
||||||
go aggregationService.Schedule()
|
go aggregationService.Schedule()
|
||||||
|
|
||||||
if config.App.CleanUp {
|
|
||||||
go heartbeatService.ScheduleCleanUp()
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: move endpoint registration to the respective routes files
|
// TODO: move endpoint registration to the respective routes files
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||||
healthHandler := routes.NewHealthHandler(db)
|
healthHandler := routes.NewHealthHandler(db)
|
||||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
||||||
settingsHandler := routes.NewSettingsHandler(userService, languageMappingService)
|
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
|
||||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
homeHandler := routes.NewHomeHandler(userService)
|
||||||
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||||
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
|
||||||
@ -144,12 +139,12 @@ func main() {
|
|||||||
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
||||||
|
|
||||||
// Public Routes
|
// Public Routes
|
||||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
|
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
|
||||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
|
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogin)
|
||||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
|
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogout)
|
||||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
|
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(homeHandler.GetSignup)
|
||||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
|
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(homeHandler.PostSignup)
|
||||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
|
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
|
||||||
|
|
||||||
// Summary Routes
|
// Summary Routes
|
||||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
||||||
@ -157,10 +152,11 @@ func main() {
|
|||||||
// Settings Routes
|
// Settings Routes
|
||||||
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||||
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
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("/language_mappings/delete").Methods(http.MethodPost).HandlerFunc(settingsHandler.DeleteLanguageMapping)
|
||||||
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||||
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
settingsRouter.Path("/badges").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostToggleBadges)
|
||||||
|
settingsRouter.Path("/regenerate").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostRegenerateSummaries)
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||||
@ -194,11 +190,3 @@ func runDatabaseMigrations() {
|
|||||||
log.Fatal(err)
|
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",
|
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 {
|
type Alias struct {
|
||||||
ID uint `gorm:"primary_key"`
|
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"`
|
User *User `json:"-" gorm:"not null"`
|
||||||
UserID string `gorm:"not null; index:idx_alias_user"`
|
UserID string `gorm:"not null; index:idx_alias_user"`
|
||||||
Key string `gorm:"not null; index:idx_alias_type_key"`
|
Key string `gorm:"not null; index:idx_alias_type_key"`
|
||||||
|
@ -18,13 +18,13 @@ func NewFiltersWith(entity uint8, key string) *Filters {
|
|||||||
case SummaryProject:
|
case SummaryProject:
|
||||||
return &Filters{Project: key}
|
return &Filters{Project: key}
|
||||||
case SummaryOS:
|
case SummaryOS:
|
||||||
return &Filters{Project: key}
|
return &Filters{OS: key}
|
||||||
case SummaryLanguage:
|
case SummaryLanguage:
|
||||||
return &Filters{Project: key}
|
return &Filters{Language: key}
|
||||||
case SummaryEditor:
|
case SummaryEditor:
|
||||||
return &Filters{Project: key}
|
return &Filters{Editor: key}
|
||||||
case SummaryMachine:
|
case SummaryMachine:
|
||||||
return &Filters{Project: key}
|
return &Filters{Machine: key}
|
||||||
}
|
}
|
||||||
return &Filters{}
|
return &Filters{}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
type Heartbeat struct {
|
type Heartbeat struct {
|
||||||
ID uint `gorm:"primary_key"`
|
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"`
|
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
|
||||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
@ -41,3 +41,24 @@ func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
|||||||
}
|
}
|
||||||
h.Language, _ = languageMappings[ending]
|
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 {
|
type LanguageMapping struct {
|
||||||
ID uint `json:"id" gorm:"primary_key"`
|
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"`
|
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"`
|
Extension string `json:"extension" gorm:"uniqueIndex:idx_language_mapping_composite; type:varchar(16)"`
|
||||||
Language string `json:"language"`
|
Language string `json:"language" gorm:"type:varchar(64)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateLanguage(language string) bool {
|
func (m *LanguageMapping) IsValid() bool {
|
||||||
return len(language) >= 1
|
return m.validateLanguage() && m.validateExtension()
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateExtension(extension string) bool {
|
func (m *LanguageMapping) validateLanguage() bool {
|
||||||
return len(extension) >= 1
|
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"`
|
Value string `gorm:"type:text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Interval struct {
|
||||||
|
Start time.Time
|
||||||
|
End time.Time
|
||||||
|
}
|
||||||
|
|
||||||
type CustomTime time.Time
|
type CustomTime time.Time
|
||||||
|
|
||||||
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
func (j *CustomTime) UnmarshalJSON(b []byte) error {
|
||||||
@ -79,3 +84,7 @@ func (j CustomTime) String() string {
|
|||||||
func (j CustomTime) T() time.Time {
|
func (j CustomTime) T() time.Time {
|
||||||
return time.Time(j)
|
return time.Time(j)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (j CustomTime) Valid() bool {
|
||||||
|
return j.T().Unix() >= 0
|
||||||
|
}
|
||||||
|
@ -35,20 +35,20 @@ const UnknownSummaryKey = "unknown"
|
|||||||
|
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
ID uint `json:"-" gorm:"primary_key"`
|
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"`
|
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"`
|
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"`
|
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||||
Projects []*SummaryItem `json:"projects"`
|
Projects []*SummaryItem `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Languages []*SummaryItem `json:"languages"`
|
Languages []*SummaryItem `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Editors []*SummaryItem `json:"editors"`
|
Editors []*SummaryItem `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
OperatingSystems []*SummaryItem `json:"operating_systems"`
|
OperatingSystems []*SummaryItem `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Machines []*SummaryItem `json:"machines"`
|
Machines []*SummaryItem `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryItem struct {
|
type SummaryItem struct {
|
||||||
ID uint `json:"-" gorm:"primary_key"`
|
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:"-"`
|
SummaryID uint `json:"-"`
|
||||||
Type uint8 `json:"-"`
|
Type uint8 `json:"-"`
|
||||||
Key string `json:"key"`
|
Key string `json:"key"`
|
||||||
@ -75,6 +75,8 @@ type SummaryParams struct {
|
|||||||
Recompute bool
|
Recompute bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AliasResolver func(t uint8, k string) string
|
||||||
|
|
||||||
func SummaryTypes() []uint8 {
|
func SummaryTypes() []uint8 {
|
||||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||||
}
|
}
|
||||||
@ -178,3 +180,53 @@ func (s *Summary) TotalTimeByFilters(filter *Filters) (timeSum time.Duration) {
|
|||||||
}
|
}
|
||||||
return timeSum
|
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"`
|
PasswordRepeat string `schema:"password_repeat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TimeByUser struct {
|
||||||
|
User string
|
||||||
|
Time CustomTime
|
||||||
|
}
|
||||||
|
|
||||||
func (c *CredentialsReset) IsValid() bool {
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
return validatePassword(c.PasswordNew) &&
|
return validatePassword(c.PasswordNew) &&
|
||||||
c.PasswordNew == c.PasswordRepeat
|
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
|
return heartbeats, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will return *models.Heartbeat object with only user_id and time fields filled
|
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||||
func (r *HeartbeatRepository) GetFirstByUsers(userIds []string) ([]*models.Heartbeat, error) {
|
var result []*models.TimeByUser
|
||||||
var heartbeats []*models.Heartbeat
|
r.db.Model(&models.User{}).
|
||||||
if err := r.db.
|
Select("users.id as user, min(time) as time").
|
||||||
Table("heartbeats").
|
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||||
Select("user_id, min(time) as time").
|
Group("user").
|
||||||
Where("user_id IN (?)", userIds).
|
Scan(&result)
|
||||||
Group("user_id").
|
return result, nil
|
||||||
Scan(&heartbeats).Error; err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
return heartbeats, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"gorm.io/gorm"
|
"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) {
|
func (r *LanguageMappingRepository) Insert(mapping *models.LanguageMapping) (*models.LanguageMapping, error) {
|
||||||
|
if !mapping.IsValid() {
|
||||||
|
return nil, errors.New("invalid mapping")
|
||||||
|
}
|
||||||
result := r.db.Create(mapping)
|
result := r.db.Create(mapping)
|
||||||
if err := result.Error; err != nil {
|
if err := result.Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -39,15 +39,21 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
|
|||||||
return summaries, nil
|
return summaries, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Will return *models.Index objects with only user_id and to_time filled
|
func (r *SummaryRepository) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||||
func (r *SummaryRepository) GetLatestByUser() ([]*models.Summary, error) {
|
var result []*models.TimeByUser
|
||||||
var summaries []*models.Summary
|
r.db.Model(&models.User{}).
|
||||||
if err := r.db.
|
Select("users.id as user, max(to_time) as time").
|
||||||
Table("summaries").
|
Joins("left join summaries on users.id = summaries.user_id").
|
||||||
Select("user_id, max(to_time) as to_time").
|
Group("user").
|
||||||
Group("user_id").
|
Scan(&result)
|
||||||
Scan(&summaries).Error; err != nil {
|
return result, nil
|
||||||
return nil, err
|
}
|
||||||
}
|
|
||||||
return summaries, 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) {
|
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 {
|
if err := result.Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -97,9 +97,12 @@ func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*mod
|
|||||||
User: user,
|
User: user,
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute),
|
if summaryParams.Recompute {
|
||||||
)
|
retrieveSummary = h.summarySrvc.Summarize
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -55,9 +55,12 @@ func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, er
|
|||||||
Recompute: false,
|
Recompute: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
if summaryParams.Recompute {
|
||||||
)
|
retrieveSummary = h.summarySrvc.Summarize
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -86,9 +86,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
|||||||
summaries := make([]*models.Summary, len(intervals))
|
summaries := make([]*models.Summary, len(intervals))
|
||||||
|
|
||||||
for i, interval := range intervals {
|
for i, interval := range intervals {
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve)
|
||||||
h.summarySrvc.Construct(interval[0], interval[1], user, false), // 'to' is always constant
|
|
||||||
)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -43,13 +43,6 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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 {
|
for _, hb := range heartbeats {
|
||||||
hb.OperatingSystem = opSys
|
hb.OperatingSystem = opSys
|
||||||
hb.Editor = editor
|
hb.Editor = editor
|
||||||
|
@ -6,6 +6,7 @@ import (
|
|||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/models/view"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -13,24 +14,22 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type IndexHandler struct {
|
type HomeHandler struct {
|
||||||
config *conf.Config
|
config *conf.Config
|
||||||
userSrvc *services.UserService
|
userSrvc *services.UserService
|
||||||
keyValueSrvc *services.KeyValueService
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var loginDecoder = schema.NewDecoder()
|
var loginDecoder = schema.NewDecoder()
|
||||||
var signupDecoder = schema.NewDecoder()
|
var signupDecoder = schema.NewDecoder()
|
||||||
|
|
||||||
func NewIndexHandler(userService *services.UserService, keyValueService *services.KeyValueService) *IndexHandler {
|
func NewHomeHandler(userService *services.UserService) *HomeHandler {
|
||||||
return &IndexHandler{
|
return &HomeHandler{
|
||||||
config: conf.Get(),
|
config: conf.Get(),
|
||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
keyValueSrvc: keyValueService,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -40,29 +39,10 @@ func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if handleAlerts(w, r, "") {
|
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templates[conf.IndexTemplate].Execute(w, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
func (h *HomeHandler) PostLogin(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) {
|
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -74,29 +54,34 @@ func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var login models.Login
|
var login models.Login
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := h.userSrvc.GetUserById(login.Username)
|
user, err := h.userSrvc.GetUserById(login.Username)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: depending on middleware package here is a hack
|
// TODO: depending on middleware package here is a hack
|
||||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||||
if err != nil {
|
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
|
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)
|
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() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
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)
|
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() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -133,14 +118,10 @@ func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if handleAlerts(w, r, conf.SignupTemplate) {
|
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
templates[conf.SignupTemplate].Execute(w, nil)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
func (h *HomeHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -152,29 +133,41 @@ func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
var signup models.Signup
|
var signup models.Signup
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !signup.IsValid() {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if !created {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := url.QueryEscape("account created successfully")
|
msg := url.QueryEscape("account created successfully")
|
||||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
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"
|
"github.com/muety/wakapi/utils"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
|
||||||
"path"
|
"path"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -58,33 +57,3 @@ func loadTemplates() {
|
|||||||
templates[tplName] = tpl
|
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"
|
"github.com/gorilla/schema"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/models/view"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -15,14 +17,18 @@ import (
|
|||||||
type SettingsHandler struct {
|
type SettingsHandler struct {
|
||||||
config *conf.Config
|
config *conf.Config
|
||||||
userSrvc *services.UserService
|
userSrvc *services.UserService
|
||||||
|
summarySrvc *services.SummaryService
|
||||||
|
aggregationSrvc *services.AggregationService
|
||||||
languageMappingSrvc *services.LanguageMappingService
|
languageMappingSrvc *services.LanguageMappingService
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialsDecoder = schema.NewDecoder()
|
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{
|
return &SettingsHandler{
|
||||||
config: conf.Get(),
|
config: conf.Get(),
|
||||||
|
summarySrvc: summaryService,
|
||||||
|
aggregationSrvc: aggregationService,
|
||||||
languageMappingSrvc: languageMappingService,
|
languageMappingSrvc: languageMappingService,
|
||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
}
|
}
|
||||||
@ -33,16 +39,7 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value(models.UserKey).(*models.User)
|
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
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
|
var credentials models.CredentialsReset
|
||||||
if err := r.ParseForm(); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !utils.CompareBcrypt(user.Password, credentials.PasswordOld, h.config.Security.PasswordSalt) {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if !credentials.IsValid() {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user.Password = credentials.PasswordNew
|
user.Password = credentials.PasswordNew
|
||||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
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
|
return
|
||||||
} else {
|
} else {
|
||||||
user.Password = hash
|
user.Password = hash
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := h.userSrvc.Update(user); err != nil {
|
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
|
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)
|
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,7 +108,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
|||||||
}
|
}
|
||||||
http.SetCookie(w, cookie)
|
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) {
|
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)
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
|
id, err := strconv.Atoi(r.PostFormValue("mapping_id"))
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -126,14 +131,15 @@ func (h *SettingsHandler) DeleteLanguageMapping(w http.ResponseWriter, r *http.R
|
|||||||
|
|
||||||
err = h.languageMappingSrvc.Delete(mapping)
|
err = h.languageMappingSrvc.Delete(mapping)
|
||||||
if err != nil {
|
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
|
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() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -152,11 +158,12 @@ func (h *SettingsHandler) PostCreateLanguageMapping(w http.ResponseWriter, r *ht
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err := h.languageMappingSrvc.Create(mapping); err != nil {
|
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
|
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) {
|
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)
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
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) {
|
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)
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
|
|
||||||
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
|
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
|
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 (
|
import (
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/models/view"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
@ -44,13 +45,15 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
summary, err, status := h.loadUserSummary(r)
|
summary, err, status := h.loadUserSummary(r)
|
||||||
if err != nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user := r.Context().Value(models.UserKey).(*models.User)
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
if user == nil {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,12 +72,22 @@ func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, erro
|
|||||||
return nil, err, http.StatusBadRequest
|
return nil, err, http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
summary, err := h.summarySrvc.PostProcessWrapped(
|
var retrieveSummary services.SummaryRetriever = h.summarySrvc.Retrieve
|
||||||
h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute), // 'to' is always constant
|
if summaryParams.Recompute {
|
||||||
)
|
retrieveSummary = h.summarySrvc.Summarize
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := h.summarySrvc.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
return summary, nil, http.StatusOK
|
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
|
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||||
func (srv *AggregationService) Schedule() {
|
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)
|
jobs := make(chan *AggregationJob)
|
||||||
summaries := make(chan *models.Summary)
|
summaries := make(chan *models.Summary)
|
||||||
defer close(jobs)
|
|
||||||
defer close(summaries)
|
|
||||||
|
|
||||||
for i := 0; i < runtime.NumCPU(); i++ {
|
for i := 0; i < runtime.NumCPU(); i++ {
|
||||||
go srv.summaryWorker(jobs, summaries)
|
go srv.summaryWorker(jobs, summaries)
|
||||||
@ -51,16 +59,19 @@ func (srv *AggregationService) Schedule() {
|
|||||||
go srv.persistWorker(summaries)
|
go srv.persistWorker(summaries)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run once initially
|
// don't leak open channels
|
||||||
srv.trigger(jobs)
|
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)
|
return srv.trigger(jobs, userIds)
|
||||||
<-gocron.Start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||||
for job := range jobs {
|
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)
|
log.Printf("Failed to generate summary (%v, %v, %s) – %v.\n", job.From, job.To, job.UserID, err)
|
||||||
} else {
|
} else {
|
||||||
log.Printf("Successfully generated summary (%v, %v, %s).\n", job.From, job.To, job.UserID)
|
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.")
|
log.Println("Generating summaries.")
|
||||||
|
|
||||||
users, err := srv.userService.GetAll()
|
var users []*models.User
|
||||||
if err != nil {
|
if allUsers, err := srv.userService.GetAll(); err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
return err
|
return err
|
||||||
}
|
} else if userIds != nil && len(userIds) > 0 {
|
||||||
|
users = make([]*models.User, len(userIds))
|
||||||
latestSummaries, err := srv.summaryService.GetLatestByUser()
|
for i, u := range allUsers {
|
||||||
if err != nil {
|
if yes, ok := userIds[u.ID]; yes && ok {
|
||||||
log.Println(err)
|
users[i] = u
|
||||||
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 {
|
||||||
|
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 {
|
if err != nil {
|
||||||
log.Println(err)
|
log.Println(err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for id, t := range userSummaryTimes {
|
// Get a map from user ids to the time of their earliest heartbeats or nil if none exists yet
|
||||||
generateUserJobs(id, t, jobs)
|
firstUserHeartbeatTimes, err := srv.heartbeatService.GetFirstByUsers()
|
||||||
|
if err != nil {
|
||||||
|
log.Println(err)
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, h := range firstHeartbeats {
|
// Build actual lookup table from it
|
||||||
generateUserJobs(h.UserID, time.Time(h.Time), jobs)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func generateUserJobs(userId string, lastAggregation time.Time, jobs chan<- *AggregationJob) {
|
func generateUserJobs(userId string, from time.Time, jobs chan<- *AggregationJob) {
|
||||||
var from, to time.Time
|
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)
|
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) {
|
for from.Before(end) && to.Before(end) {
|
||||||
to = time.Date(
|
to = time.Date(
|
||||||
from.Year(),
|
from.Year(),
|
||||||
|
@ -1,20 +1,13 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/jasonlvhit/gocron"
|
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/muety/wakapi/utils"
|
|
||||||
"log"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
|
||||||
cleanUpInterval = time.Duration(aggregateIntervalDays) * 2 * 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
type HeartbeatService struct {
|
type HeartbeatService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
repository *repositories.HeartbeatRepository
|
repository *repositories.HeartbeatRepository
|
||||||
@ -41,31 +34,14 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
|
|||||||
return srv.augmented(heartbeats, user.ID)
|
return srv.augmented(heartbeats, user.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) GetFirstUserHeartbeats(userIds []string) ([]*models.Heartbeat, error) {
|
func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||||
return srv.repository.GetFirstByUsers(userIds)
|
return srv.repository.GetFirstByUsers()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||||
return srv.repository.DeleteBefore(t)
|
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) {
|
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
@ -4,14 +4,12 @@ import (
|
|||||||
"crypto/md5"
|
"crypto/md5"
|
||||||
"errors"
|
"errors"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||||
@ -24,6 +22,8 @@ type SummaryService struct {
|
|||||||
aliasService *AliasService
|
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 {
|
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||||
return &SummaryService{
|
return &SummaryService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
@ -34,60 +34,98 @@ func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatSer
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Interval struct {
|
// Public summary generation methods
|
||||||
Start time.Time
|
|
||||||
End time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: simplify!
|
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever) (*models.Summary, error) {
|
||||||
func (srv *SummaryService) Construct(from, to time.Time, user *models.User, recompute bool) (*models.Summary, error) {
|
// Check cache
|
||||||
var existingSummaries []*models.Summary
|
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||||
var cacheKey string
|
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||||
|
return cacheResult.(*models.Summary), nil
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
for _, interval := range missingIntervals {
|
||||||
hb, err := srv.heartbeatService.GetAllWithin(interval.Start, interval.End, user)
|
if s, err := srv.Summarize(interval.Start, interval.End, user); err == nil {
|
||||||
if err != nil {
|
summaries = append(summaries, s)
|
||||||
|
} else {
|
||||||
return nil, err
|
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()
|
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 projectItems []*models.SummaryItem
|
||||||
var languageItems []*models.SummaryItem
|
var languageItems []*models.SummaryItem
|
||||||
var editorItems []*models.SummaryItem
|
var editorItems []*models.SummaryItem
|
||||||
var osItems []*models.SummaryItem
|
var osItems []*models.SummaryItem
|
||||||
var machineItems []*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++ {
|
for i := 0; i < len(types); i++ {
|
||||||
item := <-c
|
item := <-typedAggregations
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
case models.SummaryProject:
|
case models.SummaryProject:
|
||||||
projectItems = item.Items
|
projectItems = item.Items
|
||||||
@ -101,31 +139,16 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
|||||||
machineItems = item.Items
|
machineItems = item.Items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close(c)
|
|
||||||
|
|
||||||
realFrom, realTo := from, to
|
if heartbeats.Len() > 0 {
|
||||||
if len(existingSummaries) > 0 {
|
from = time.Time(heartbeats.First().Time)
|
||||||
realFrom = existingSummaries[0].FromTime.T()
|
to = time.Time(heartbeats.Last().Time)
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
aggregatedSummary := &models.Summary{
|
summary := &models.Summary{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
FromTime: models.CustomTime(realFrom),
|
FromTime: models.CustomTime(from),
|
||||||
ToTime: models.CustomTime(realTo),
|
ToTime: models.CustomTime(to),
|
||||||
Projects: projectItems,
|
Projects: projectItems,
|
||||||
Languages: languageItems,
|
Languages: languageItems,
|
||||||
Editors: editorItems,
|
Editors: editorItems,
|
||||||
@ -133,119 +156,32 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
|||||||
Machines: machineItems,
|
Machines: machineItems,
|
||||||
}
|
}
|
||||||
|
|
||||||
allSummaries := []*models.Summary{aggregatedSummary}
|
summary.FillUnknown()
|
||||||
allSummaries = append(allSummaries, existingSummaries...)
|
|
||||||
|
|
||||||
summary, err := mergeSummaries(allSummaries)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if cacheKey != "" {
|
|
||||||
srv.cache.SetDefault(cacheKey, summary)
|
|
||||||
}
|
|
||||||
|
|
||||||
return summary, nil
|
return summary, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) PostProcessWrapped(summary *models.Summary, err error) (*models.Summary, error) {
|
// CRUD methods
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
||||||
}
|
return srv.repository.GetLastByUser()
|
||||||
return srv.PostProcess(summary), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) PostProcess(summary *models.Summary) *models.Summary {
|
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||||
updatedSummary := &models.Summary{
|
return srv.repository.DeleteByUser(userId)
|
||||||
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) Insert(summary *models.Summary) error {
|
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||||
return srv.repository.Insert(summary)
|
return srv.repository.Insert(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
// Private summary generation and utility methods
|
||||||
return srv.repository.GetByUserWithin(user, from, to)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Will return *models.Index objects with only user_id and to_time filled
|
func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryType uint8, c chan models.SummaryItemContainer) {
|
||||||
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) {
|
|
||||||
durations := make(map[string]time.Duration)
|
durations := make(map[string]time.Duration)
|
||||||
|
|
||||||
for i, h := range heartbeats {
|
for i, h := range heartbeats {
|
||||||
var key string
|
key := h.GetKey(summaryType)
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, ok := durations[key]; !ok {
|
if _, ok := durations[key]; !ok {
|
||||||
durations[key] = time.Duration(0)
|
durations[key] = time.Duration(0)
|
||||||
@ -283,43 +219,7 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
|||||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
||||||
}
|
}
|
||||||
|
|
||||||
func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary) []*Interval {
|
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||||
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) {
|
|
||||||
if len(summaries) < 1 {
|
if len(summaries) < 1 {
|
||||||
return nil, errors.New("no summaries given")
|
return nil, errors.New("no summaries given")
|
||||||
}
|
}
|
||||||
@ -349,11 +249,11 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
|||||||
maxTime = s.ToTime.T()
|
maxTime = s.ToTime.T()
|
||||||
}
|
}
|
||||||
|
|
||||||
finalSummary.Projects = mergeSummaryItems(finalSummary.Projects, s.Projects)
|
finalSummary.Projects = srv.mergeSummaryItems(finalSummary.Projects, s.Projects)
|
||||||
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
|
finalSummary.Languages = srv.mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||||
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
|
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||||
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||||
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
|
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalSummary.FromTime = models.CustomTime(minTime)
|
finalSummary.FromTime = models.CustomTime(minTime)
|
||||||
@ -362,7 +262,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
|||||||
return finalSummary, nil
|
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)
|
items := make(map[string]*models.SummaryItem)
|
||||||
|
|
||||||
// Build map from existing
|
// Build map from existing
|
||||||
@ -392,11 +292,46 @@ func mergeSummaryItems(existing []*models.SummaryItem, new []*models.SummaryItem
|
|||||||
return itemList
|
return itemList
|
||||||
}
|
}
|
||||||
|
|
||||||
func getHash(times []time.Time, user *models.User) string {
|
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
|
||||||
digest := md5.New()
|
if len(summaries) == 0 {
|
||||||
for _, t := range times {
|
return []*models.Interval{{from, to}}
|
||||||
digest.Write([]byte(strconv.Itoa(int(t.Unix()))))
|
}
|
||||||
|
|
||||||
|
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))
|
return string(digest.Sum(nil))
|
||||||
}
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
1.14.1
|
1.16.2
|
||||||
|
@ -48,7 +48,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
Reset API Key
|
Reset API Key
|
||||||
</div>
|
</div>
|
||||||
@ -66,7 +66,7 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</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">
|
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
Language Mappings
|
Language Mappings
|
||||||
</div>
|
</div>
|
||||||
@ -119,7 +119,7 @@
|
|||||||
|
|
||||||
</div>
|
</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">
|
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
Badges
|
Badges
|
||||||
</div>
|
</div>
|
||||||
@ -169,6 +169,34 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@ -182,6 +210,14 @@
|
|||||||
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
|
||||||
e.classList.remove('hidden')
|
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>
|
</script>
|
||||||
|
|
||||||
{{ template "footer.tpl.html" . }}
|
{{ template "footer.tpl.html" . }}
|
||||||
|
Reference in New Issue
Block a user