mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
16 Commits
Author | SHA1 | Date | |
---|---|---|---|
82ed386359 | |||
12cc4cd9cf | |||
2eccb7a468 | |||
08a83af8da | |||
c0d6855546 | |||
0af7d2f8ef | |||
11d1d5bc99 | |||
ada0863f7c | |||
7818f6b094 | |||
f86eb7668d | |||
24469e4922 | |||
4f035b3a63 | |||
0eac9a8854 | |||
0294425de0 | |||
a7c83252ef | |||
07a03ce3ac |
@ -9,7 +9,7 @@ RUN cd /src && go build -o wakapi
|
|||||||
|
|
||||||
# When running the application using `docker run`, you can pass environment variables
|
# When running the application using `docker run`, you can pass environment variables
|
||||||
# to override config values from .env using `-e` syntax.
|
# to override config values from .env using `-e` syntax.
|
||||||
# Available options are:
|
# Available options are:
|
||||||
# – WAKAPI_DB_TYPE
|
# – WAKAPI_DB_TYPE
|
||||||
# – WAKAPI_DB_USER
|
# – WAKAPI_DB_USER
|
||||||
# – WAKAPI_DB_PASSWORD
|
# – WAKAPI_DB_PASSWORD
|
||||||
@ -17,8 +17,7 @@ RUN cd /src && go build -o wakapi
|
|||||||
# – WAKAPI_DB_PORT
|
# – WAKAPI_DB_PORT
|
||||||
# – WAKAPI_DB_NAME
|
# – WAKAPI_DB_NAME
|
||||||
# – WAKAPI_PASSWORD_SALT
|
# – WAKAPI_PASSWORD_SALT
|
||||||
# – WAKAPI_DEFAULT_USER_NAME
|
# – WAKAPI_BASE_PATH
|
||||||
# – WAKAPI_DEFAULT_USER_PASSWORD
|
|
||||||
|
|
||||||
FROM debian
|
FROM debian
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
@ -30,8 +29,6 @@ ENV WAKAPI_DB_PASSWORD ''
|
|||||||
ENV WAKAPI_DB_HOST ''
|
ENV WAKAPI_DB_HOST ''
|
||||||
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
||||||
ENV WAKAPI_PASSWORD_SALT ''
|
ENV WAKAPI_PASSWORD_SALT ''
|
||||||
ENV WAKAPI_DEFAULT_USER_NAME admin
|
|
||||||
ENV WAKAPI_DEFAULT_USER_PASSWORD admin
|
|
||||||
|
|
||||||
COPY --from=build-env /src/wakapi /app/
|
COPY --from=build-env /src/wakapi /app/
|
||||||
COPY --from=build-env /src/config.ini /app/
|
COPY --from=build-env /src/config.ini /app/
|
||||||
|
@ -80,6 +80,7 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
|
|||||||
* Language ~ type **1**
|
* Language ~ type **1**
|
||||||
* Editor ~ type **2**
|
* Editor ~ type **2**
|
||||||
* OS ~ type **3**
|
* OS ~ type **3**
|
||||||
|
* Machine ~ type **4**
|
||||||
|
|
||||||
**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi.
|
**NOTE:** In order for the aliases to take effect for non-live statistics, you would either have to wait 24 hours for the cache to be invalidated or restart Wakapi.
|
||||||
|
|
||||||
|
34
main.go
34
main.go
@ -43,6 +43,11 @@ func main() {
|
|||||||
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
log.SetFlags(log.LstdFlags | log.Lshortfile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show data loss warning
|
||||||
|
if config.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.DbDialect, utils.MakeConnectionString(config))
|
db, err = gorm.Open(config.DbDialect, utils.MakeConnectionString(config))
|
||||||
@ -85,11 +90,13 @@ func main() {
|
|||||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
|
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService)
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||||
healthHandler := routes.NewHealthHandler(db)
|
healthHandler := routes.NewHealthHandler(db)
|
||||||
|
settingsHandler := routes.NewSettingsHandler(userService)
|
||||||
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
publicHandler := routes.NewIndexHandler(userService, keyValueService)
|
||||||
|
|
||||||
// Setup Routers
|
// Setup Routers
|
||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
publicRouter := router.PathPrefix("/").Subrouter()
|
publicRouter := router.PathPrefix("/").Subrouter()
|
||||||
|
settingsRouter := publicRouter.PathPrefix("/settings").Subrouter()
|
||||||
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
|
summaryRouter := publicRouter.PathPrefix("/summary").Subrouter()
|
||||||
apiRouter := router.PathPrefix("/api").Subrouter()
|
apiRouter := router.PathPrefix("/api").Subrouter()
|
||||||
|
|
||||||
@ -105,17 +112,24 @@ func main() {
|
|||||||
// Router configs
|
// Router configs
|
||||||
router.Use(loggingMiddleware, recoveryMiddleware)
|
router.Use(loggingMiddleware, recoveryMiddleware)
|
||||||
summaryRouter.Use(authenticateMiddleware)
|
summaryRouter.Use(authenticateMiddleware)
|
||||||
|
settingsRouter.Use(authenticateMiddleware)
|
||||||
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
apiRouter.Use(corsMiddleware, authenticateMiddleware)
|
||||||
|
|
||||||
// Public Routes
|
// Public Routes
|
||||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.Index)
|
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(publicHandler.GetIndex)
|
||||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.Login)
|
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogin)
|
||||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.Logout)
|
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(publicHandler.PostLogout)
|
||||||
publicRouter.Path("/signup").Methods(http.MethodGet, http.MethodPost).HandlerFunc(publicHandler.Signup)
|
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(publicHandler.GetSignup)
|
||||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.Imprint)
|
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(publicHandler.PostSignup)
|
||||||
|
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(publicHandler.GetImprint)
|
||||||
|
|
||||||
// Summary Routes
|
// Summary Routes
|
||||||
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.Index)
|
summaryRouter.Methods(http.MethodGet).HandlerFunc(summaryHandler.GetIndex)
|
||||||
|
|
||||||
|
// Settings Routes
|
||||||
|
settingsRouter.Methods(http.MethodGet).HandlerFunc(settingsHandler.GetIndex)
|
||||||
|
settingsRouter.Path("/credentials").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostCredentials)
|
||||||
|
settingsRouter.Path("/reset").Methods(http.MethodPost).HandlerFunc(settingsHandler.PostResetApiKey)
|
||||||
|
|
||||||
// API Routes
|
// API Routes
|
||||||
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
|
||||||
@ -163,3 +177,11 @@ func migrateLanguages() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
11
migrations/sqlite3/4_machine_column.sql
Normal file
11
migrations/sqlite3/4_machine_column.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- +migrate Up
|
||||||
|
-- SQL in section 'Up' is executed when this migration is applied
|
||||||
|
|
||||||
|
alter table users
|
||||||
|
add `machine` varchar(255);
|
||||||
|
|
||||||
|
-- +migrate Down
|
||||||
|
-- SQL section 'Down' is executed when this migration is rolled back
|
||||||
|
|
||||||
|
alter table users
|
||||||
|
drop column `machine`;
|
@ -161,7 +161,11 @@ func readConfig() *Config {
|
|||||||
port = cfg.Section("server").Key("port").MustInt()
|
port = cfg.Section("server").Key("port").MustInt()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
basePathEnv, basePathEnvExists := os.LookupEnv("WAKAPI_BASE_PATH")
|
||||||
basePath := cfg.Section("server").Key("base_path").MustString("/")
|
basePath := cfg.Section("server").Key("base_path").MustString("/")
|
||||||
|
if basePathEnvExists {
|
||||||
|
basePath = basePathEnv
|
||||||
|
}
|
||||||
if strings.HasSuffix(basePath, "/") {
|
if strings.HasSuffix(basePath, "/") {
|
||||||
basePath = basePath[:len(basePath)-1]
|
basePath = basePath[:len(basePath)-1]
|
||||||
}
|
}
|
||||||
|
@ -20,6 +20,7 @@ type Heartbeat struct {
|
|||||||
IsWrite bool `json:"is_write"`
|
IsWrite bool `json:"is_write"`
|
||||||
Editor string `json:"editor"`
|
Editor string `json:"editor"`
|
||||||
OperatingSystem string `json:"operating_system"`
|
OperatingSystem string `json:"operating_system"`
|
||||||
|
Machine string `json:"machine"`
|
||||||
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"`
|
||||||
languageRegex *regexp.Regexp
|
languageRegex *regexp.Regexp
|
||||||
}
|
}
|
||||||
|
@ -5,13 +5,16 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
NSummaryTypes uint8 = 4
|
NSummaryTypes uint8 = 99
|
||||||
SummaryProject uint8 = 0
|
SummaryProject uint8 = 0
|
||||||
SummaryLanguage uint8 = 1
|
SummaryLanguage uint8 = 1
|
||||||
SummaryEditor uint8 = 2
|
SummaryEditor uint8 = 2
|
||||||
SummaryOS uint8 = 3
|
SummaryOS uint8 = 3
|
||||||
|
SummaryMachine uint8 = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const UnknownSummaryKey = "unknown"
|
||||||
|
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
ID uint `json:"-" gorm:"primary_key"`
|
ID uint `json:"-" gorm:"primary_key"`
|
||||||
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"`
|
||||||
@ -21,6 +24,7 @@ type Summary struct {
|
|||||||
Languages []*SummaryItem `json:"languages"`
|
Languages []*SummaryItem `json:"languages"`
|
||||||
Editors []*SummaryItem `json:"editors"`
|
Editors []*SummaryItem `json:"editors"`
|
||||||
OperatingSystems []*SummaryItem `json:"operating_systems"`
|
OperatingSystems []*SummaryItem `json:"operating_systems"`
|
||||||
|
Machines []*SummaryItem `json:"machines"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryItem struct {
|
type SummaryItem struct {
|
||||||
@ -43,3 +47,55 @@ type SummaryViewModel struct {
|
|||||||
Success string
|
Success string
|
||||||
ApiKey string
|
ApiKey string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Augments the summary in a way that at least one item is present for every type.
|
||||||
|
If a summary has zero items for a given type, but one or more for any of the other types,
|
||||||
|
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
||||||
|
for the missing type.
|
||||||
|
For instance, the machine type was introduced post hoc. Accordingly, no "machine"-information is present in
|
||||||
|
the data for old heartbeats and summaries. If a user has two years of data without machine information and
|
||||||
|
one day with such, a "machine"-chart plotted from that data will reference a way smaller absolute total amount
|
||||||
|
of time than the other ones.
|
||||||
|
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
||||||
|
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||||
|
*/
|
||||||
|
func (s *Summary) FillUnknown() {
|
||||||
|
types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||||
|
missingTypes := make([]uint8, 0)
|
||||||
|
typeItems := map[uint8]*[]*SummaryItem{
|
||||||
|
SummaryProject: &s.Projects,
|
||||||
|
SummaryLanguage: &s.Languages,
|
||||||
|
SummaryEditor: &s.Editors,
|
||||||
|
SummaryOS: &s.OperatingSystems,
|
||||||
|
SummaryMachine: &s.Machines,
|
||||||
|
}
|
||||||
|
var somePresentType uint8
|
||||||
|
|
||||||
|
for _, t := range types {
|
||||||
|
if len(*typeItems[t]) == 0 {
|
||||||
|
missingTypes = append(missingTypes, t)
|
||||||
|
} else {
|
||||||
|
somePresentType = t
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// can't proceed if entire summary is empty
|
||||||
|
if len(missingTypes) == len(types) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculate total duration from any of the present sets of items
|
||||||
|
var timeSum time.Duration
|
||||||
|
for _, item := range *typeItems[somePresentType] {
|
||||||
|
timeSum += item.Total
|
||||||
|
}
|
||||||
|
|
||||||
|
// construct dummy item for all missing types
|
||||||
|
for _, t := range missingTypes {
|
||||||
|
*typeItems[t] = append(*typeItems[t], &SummaryItem{
|
||||||
|
Type: t,
|
||||||
|
Key: UnknownSummaryKey,
|
||||||
|
Total: timeSum,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -19,8 +19,27 @@ type Signup struct {
|
|||||||
PasswordRepeat string `schema:"password_repeat"`
|
PasswordRepeat string `schema:"password_repeat"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CredentialsReset struct {
|
||||||
|
PasswordOld string `schema:"password_old"`
|
||||||
|
PasswordNew string `schema:"password_new"`
|
||||||
|
PasswordRepeat string `schema:"password_repeat"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *CredentialsReset) IsValid() bool {
|
||||||
|
return validatePassword(c.PasswordNew) &&
|
||||||
|
c.PasswordNew == c.PasswordRepeat
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Signup) IsValid() bool {
|
func (s *Signup) IsValid() bool {
|
||||||
return len(s.Username) >= 3 &&
|
return validateUsername(s.Username) &&
|
||||||
len(s.Password) >= 6 &&
|
validatePassword(s.Password) &&
|
||||||
s.Password == s.PasswordRepeat
|
s.Password == s.PasswordRepeat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateUsername(username string) bool {
|
||||||
|
return len(username) >= 3
|
||||||
|
}
|
||||||
|
|
||||||
|
func validatePassword(password string) bool {
|
||||||
|
return len(password) >= 6
|
||||||
|
}
|
||||||
|
@ -23,10 +23,15 @@ func NewHeartbeatHandler(heartbeatService *services.HeartbeatService) *Heartbeat
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type heartbeatResponseVm struct {
|
||||||
|
Responses [][]interface{} `json:"responses"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
||||||
var heartbeats []*models.Heartbeat
|
var heartbeats []*models.Heartbeat
|
||||||
user := r.Context().Value(models.UserKey).(*models.User)
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
|
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
|
||||||
|
machineName := r.Header.Get("X-Machine-Name")
|
||||||
|
|
||||||
dec := json.NewDecoder(r.Body)
|
dec := json.NewDecoder(r.Body)
|
||||||
if err := dec.Decode(&heartbeats); err != nil {
|
if err := dec.Decode(&heartbeats); err != nil {
|
||||||
@ -38,6 +43,7 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
for _, hb := range heartbeats {
|
for _, hb := range heartbeats {
|
||||||
hb.OperatingSystem = opSys
|
hb.OperatingSystem = opSys
|
||||||
hb.Editor = editor
|
hb.Editor = editor
|
||||||
|
hb.Machine = machineName
|
||||||
hb.User = user
|
hb.User = user
|
||||||
hb.UserID = user.ID
|
hb.UserID = user.ID
|
||||||
hb.Augment(h.config.CustomLanguages)
|
hb.Augment(h.config.CustomLanguages)
|
||||||
@ -55,5 +61,23 @@ func (h *HeartbeatHandler) ApiPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
|
||||||
|
// to make the cli consider all heartbeats to having been successfully saved
|
||||||
|
// response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] }
|
||||||
|
func constructSuccessResponse(n int) *heartbeatResponseVm {
|
||||||
|
responses := make([][]interface{}, n)
|
||||||
|
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
r := make([]interface{}, 2)
|
||||||
|
r[0] = nil
|
||||||
|
r[1] = http.StatusCreated
|
||||||
|
responses[i] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
return &heartbeatResponseVm{
|
||||||
|
Responses: responses,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -29,7 +29,7 @@ func NewIndexHandler(userService *services.UserService, keyValueService *service
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
|
func (h *IndexHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -43,18 +43,10 @@ func (h *IndexHandler) Index(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: make this more generic and reusable
|
|
||||||
if success := r.URL.Query().Get("success"); success != "" {
|
|
||||||
templates["index.tpl.html"].Execute(w, struct {
|
|
||||||
Success string
|
|
||||||
Error string
|
|
||||||
}{Success: success})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
templates["index.tpl.html"].Execute(w, nil)
|
templates["index.tpl.html"].Execute(w, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) {
|
func (h *IndexHandler) GetImprint(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -69,7 +61,7 @@ func (h *IndexHandler) Imprint(w http.ResponseWriter, r *http.Request) {
|
|||||||
}{HtmlText: text})
|
}{HtmlText: text})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
|
func (h *IndexHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -121,7 +113,7 @@ func (h *IndexHandler) Login(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
func (h *IndexHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -130,27 +122,7 @@ func (h *IndexHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
|
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.BasePath), http.StatusFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) Signup(w http.ResponseWriter, r *http.Request) {
|
func (h *IndexHandler) GetSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
|
||||||
loadTemplates()
|
|
||||||
}
|
|
||||||
|
|
||||||
if cookie, err := r.Cookie(models.AuthCookieKey); err == nil && cookie.Value != "" {
|
|
||||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.BasePath), http.StatusFound)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodPost:
|
|
||||||
h.handlePostSignup(w, r)
|
|
||||||
return
|
|
||||||
default:
|
|
||||||
h.handleGetSignup(w, r)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -167,7 +139,7 @@ func (h *IndexHandler) handleGetSignup(w http.ResponseWriter, r *http.Request) {
|
|||||||
templates["signup.tpl.html"].Execute(w, nil)
|
templates["signup.tpl.html"].Execute(w, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IndexHandler) handlePostSignup(w http.ResponseWriter, r *http.Request) {
|
func (h *IndexHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
@ -64,7 +64,7 @@ func respondAlert(w http.ResponseWriter, error, success, tplName string, status
|
|||||||
templates[tplName].Execute(w, struct {
|
templates[tplName].Execute(w, struct {
|
||||||
Error string
|
Error string
|
||||||
Success string
|
Success string
|
||||||
}{Error: error})
|
}{Error: error, Success: success})
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: do better
|
// TODO: do better
|
||||||
|
112
routes/settings.go
Normal file
112
routes/settings.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package routes
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gorilla/schema"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/services"
|
||||||
|
"github.com/muety/wakapi/utils"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsHandler struct {
|
||||||
|
config *models.Config
|
||||||
|
userSrvc *services.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
var credentialsDecoder = schema.NewDecoder()
|
||||||
|
|
||||||
|
func NewSettingsHandler(userService *services.UserService) *SettingsHandler {
|
||||||
|
return &SettingsHandler{
|
||||||
|
config: models.GetConfig(),
|
||||||
|
userSrvc: userService,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
if handleAlerts(w, r, "settings.tpl.html") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
templates["settings.tpl.html"].Execute(w, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
|
|
||||||
|
var credentials models.CredentialsReset
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := credentialsDecoder.Decode(&credentials, r.PostForm); err != nil {
|
||||||
|
respondAlert(w, "missing parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !utils.CheckPasswordBcrypt(user, credentials.PasswordOld, h.config.PasswordSalt) {
|
||||||
|
respondAlert(w, "invalid credentials", "", "settings.tpl.html", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !credentials.IsValid() {
|
||||||
|
respondAlert(w, "invalid parameters", "", "settings.tpl.html", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Password = credentials.PasswordNew
|
||||||
|
if err := utils.HashPassword(user, h.config.PasswordSalt); err != nil {
|
||||||
|
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.userSrvc.Update(user); err != nil {
|
||||||
|
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
login := &models.Login{
|
||||||
|
Username: user.ID,
|
||||||
|
Password: user.Password,
|
||||||
|
}
|
||||||
|
encoded, err := h.config.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||||
|
if err != nil {
|
||||||
|
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie := &http.Cookie{
|
||||||
|
Name: models.AuthCookieKey,
|
||||||
|
Value: encoded,
|
||||||
|
Path: "/",
|
||||||
|
Secure: !h.config.InsecureCookies,
|
||||||
|
HttpOnly: true,
|
||||||
|
}
|
||||||
|
http.SetCookie(w, cookie)
|
||||||
|
|
||||||
|
msg := url.QueryEscape("password was updated successfully")
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
user := r.Context().Value(models.UserKey).(*models.User)
|
||||||
|
if _, err := h.userSrvc.ResetApiKey(user); err != nil {
|
||||||
|
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
|
||||||
|
}
|
@ -42,7 +42,7 @@ func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
|
|||||||
utils.RespondJSON(w, http.StatusOK, summary)
|
utils.RespondJSON(w, http.StatusOK, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SummaryHandler) Index(w http.ResponseWriter, r *http.Request) {
|
func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
@ -65,12 +65,13 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
|||||||
heartbeats = append(heartbeats, hb...)
|
heartbeats = append(heartbeats, hb...)
|
||||||
}
|
}
|
||||||
|
|
||||||
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS}
|
types := []uint8{models.SummaryProject, models.SummaryLanguage, models.SummaryEditor, models.SummaryOS, models.SummaryMachine}
|
||||||
|
|
||||||
var projectItems []*models.SummaryItem
|
var 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
|
||||||
|
|
||||||
if err := srv.AliasService.LoadUserAliases(user.ID); err != nil {
|
if err := srv.AliasService.LoadUserAliases(user.ID); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -92,18 +93,40 @@ func (srv *SummaryService) Construct(from, to time.Time, user *models.User, reco
|
|||||||
editorItems = item.Items
|
editorItems = item.Items
|
||||||
case models.SummaryOS:
|
case models.SummaryOS:
|
||||||
osItems = item.Items
|
osItems = item.Items
|
||||||
|
case models.SummaryMachine:
|
||||||
|
machineItems = item.Items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
close(c)
|
close(c)
|
||||||
|
|
||||||
|
realFrom, realTo := from, to
|
||||||
|
if len(existingSummaries) > 0 {
|
||||||
|
realFrom = existingSummaries[0].FromTime
|
||||||
|
realTo = existingSummaries[len(existingSummaries)-1].ToTime
|
||||||
|
|
||||||
|
for _, summary := range existingSummaries {
|
||||||
|
summary.FillUnknown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(heartbeats) > 0 {
|
||||||
|
t1, t2 := time.Time(heartbeats[0].Time), time.Time(heartbeats[len(heartbeats)-1].Time)
|
||||||
|
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{
|
aggregatedSummary := &models.Summary{
|
||||||
UserID: user.ID,
|
UserID: user.ID,
|
||||||
FromTime: from,
|
FromTime: realFrom,
|
||||||
ToTime: to,
|
ToTime: realTo,
|
||||||
Projects: projectItems,
|
Projects: projectItems,
|
||||||
Languages: languageItems,
|
Languages: languageItems,
|
||||||
Editors: editorItems,
|
Editors: editorItems,
|
||||||
OperatingSystems: osItems,
|
OperatingSystems: osItems,
|
||||||
|
Machines: machineItems,
|
||||||
}
|
}
|
||||||
|
|
||||||
allSummaries := []*models.Summary{aggregatedSummary}
|
allSummaries := []*models.Summary{aggregatedSummary}
|
||||||
@ -134,10 +157,12 @@ func (srv *SummaryService) GetByUserWithin(user *models.User, from, to time.Time
|
|||||||
Where(&models.Summary{UserID: user.ID}).
|
Where(&models.Summary{UserID: user.ID}).
|
||||||
Where("from_time >= ?", from).
|
Where("from_time >= ?", from).
|
||||||
Where("to_time <= ?", to).
|
Where("to_time <= ?", to).
|
||||||
|
Order("from_time asc").
|
||||||
Preload("Projects", "type = ?", models.SummaryProject).
|
Preload("Projects", "type = ?", models.SummaryProject).
|
||||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||||
|
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||||
Find(&summaries).Error; err != nil {
|
Find(&summaries).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -171,10 +196,12 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
|||||||
key = h.Language
|
key = h.Language
|
||||||
case models.SummaryOS:
|
case models.SummaryOS:
|
||||||
key = h.OperatingSystem
|
key = h.OperatingSystem
|
||||||
|
case models.SummaryMachine:
|
||||||
|
key = h.Machine
|
||||||
}
|
}
|
||||||
|
|
||||||
if key == "" {
|
if key == "" {
|
||||||
key = "unknown"
|
key = models.UnknownSummaryKey
|
||||||
}
|
}
|
||||||
|
|
||||||
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
|
if aliasedKey, err := srv.AliasService.GetAliasOrDefault(user.ID, summaryType, key); err == nil {
|
||||||
@ -224,7 +251,16 @@ func getMissingIntervals(from, to time.Time, existingSummaries []*models.Summary
|
|||||||
|
|
||||||
// Between
|
// Between
|
||||||
for i := 0; i < len(existingSummaries)-1; i++ {
|
for i := 0; i < len(existingSummaries)-1; i++ {
|
||||||
if existingSummaries[i].ToTime.Before(existingSummaries[i+1].FromTime) {
|
t1, t2 := existingSummaries[i].ToTime, existingSummaries[i+1].FromTime
|
||||||
|
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, existingSummaries[i+1].FromTime})
|
intervals = append(intervals, &Interval{existingSummaries[i].ToTime, existingSummaries[i+1].FromTime})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,6 +287,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
|||||||
Languages: make([]*models.SummaryItem, 0),
|
Languages: make([]*models.SummaryItem, 0),
|
||||||
Editors: make([]*models.SummaryItem, 0),
|
Editors: make([]*models.SummaryItem, 0),
|
||||||
OperatingSystems: make([]*models.SummaryItem, 0),
|
OperatingSystems: make([]*models.SummaryItem, 0),
|
||||||
|
Machines: make([]*models.SummaryItem, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, s := range summaries {
|
for _, s := range summaries {
|
||||||
@ -270,6 +307,7 @@ func mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
|||||||
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
|
finalSummary.Languages = mergeSummaryItems(finalSummary.Languages, s.Languages)
|
||||||
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
|
finalSummary.Editors = mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||||
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
finalSummary.OperatingSystems = mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||||
|
finalSummary.Machines = mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||||
}
|
}
|
||||||
|
|
||||||
finalSummary.FromTime = minTime
|
finalSummary.FromTime = minTime
|
||||||
|
@ -82,6 +82,11 @@ func (srv *UserService) Update(user *models.User) (*models.User, error) {
|
|||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
|
||||||
|
user.ApiKey = uuid.NewV4().String()
|
||||||
|
return srv.Update(user)
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
func (srv *UserService) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||||
user.Password = login.Password
|
user.Password = login.Password
|
||||||
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
|
if err := utils.HashPassword(user, srv.Config.PasswordSalt); err != nil {
|
||||||
|
@ -5,6 +5,7 @@ const projectsCanvas = document.getElementById('chart-projects')
|
|||||||
const osCanvas = document.getElementById('chart-os')
|
const osCanvas = document.getElementById('chart-os')
|
||||||
const editorsCanvas = document.getElementById('chart-editor')
|
const editorsCanvas = document.getElementById('chart-editor')
|
||||||
const languagesCanvas = document.getElementById('chart-language')
|
const languagesCanvas = document.getElementById('chart-language')
|
||||||
|
const machinesCanvas = document.getElementById('chart-machine')
|
||||||
|
|
||||||
let charts = []
|
let charts = []
|
||||||
let resizeCount = 0
|
let resizeCount = 0
|
||||||
@ -56,7 +57,7 @@ function draw() {
|
|||||||
.map(p => {
|
.map(p => {
|
||||||
return {
|
return {
|
||||||
label: p.key,
|
label: p.key,
|
||||||
data: [parseInt(p.total)],
|
data: [parseInt(p.total) / 60],
|
||||||
backgroundColor: getRandomColor(p.key)
|
backgroundColor: getRandomColor(p.key)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -67,6 +68,14 @@ function draw() {
|
|||||||
legend: {
|
legend: {
|
||||||
display: false
|
display: false
|
||||||
},
|
},
|
||||||
|
scales: {
|
||||||
|
xAxes: [{
|
||||||
|
scaleLabel: {
|
||||||
|
display: true,
|
||||||
|
labelString: 'Minutes'
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
},
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
onResize: onChartResize
|
onResize: onChartResize
|
||||||
}
|
}
|
||||||
@ -135,10 +144,31 @@ function draw() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
let machineChart = new Chart(machinesCanvas.getContext('2d'), {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
datasets: [{
|
||||||
|
data: wakapiData.machines
|
||||||
|
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||||
|
.map(p => parseInt(p.total)),
|
||||||
|
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key))
|
||||||
|
}],
|
||||||
|
labels: wakapiData.machines
|
||||||
|
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||||
|
.map(p => p.key)
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
title: Object.assign(titleOptions, {text: `Machines (top ${SHOW_TOP_N})`}),
|
||||||
|
tooltips: getTooltipOptions('machines', 'pie'),
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
onResize: onChartResize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
getTotal(wakapiData.operatingSystems)
|
getTotal(wakapiData.operatingSystems)
|
||||||
document.getElementById('grid-container').style.visibility = 'visible'
|
document.getElementById('grid-container').style.visibility = 'visible'
|
||||||
|
|
||||||
charts = [projectChart, osChart, editorChart, languageChart]
|
charts = [projectChart, osChart, editorChart, languageChart, machineChart]
|
||||||
|
|
||||||
charts.forEach(c => c.options.onResize(c.chart))
|
charts.forEach(c => c.options.onResize(c.chart))
|
||||||
equalizeHeights()
|
equalizeHeights()
|
||||||
|
@ -15,12 +15,12 @@ func StartOfWeek() time.Time {
|
|||||||
|
|
||||||
func StartOfMonth() time.Time {
|
func StartOfMonth() time.Time {
|
||||||
ref := time.Now()
|
ref := time.Now()
|
||||||
return time.Date(ref.Year(), ref.Month(), 0, 0, 0, 0, 0, ref.Location())
|
return time.Date(ref.Year(), ref.Month(), 1, 0, 0, 0, 0, ref.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
func StartOfYear() time.Time {
|
func StartOfYear() time.Time {
|
||||||
ref := time.Now()
|
ref := time.Now()
|
||||||
return time.Date(ref.Year(), time.January, 0, 0, 0, 0, 0, ref.Location())
|
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
|
||||||
}
|
}
|
||||||
|
|
||||||
// https://stackoverflow.com/a/18632496
|
// https://stackoverflow.com/a/18632496
|
||||||
|
@ -1 +1 @@
|
|||||||
1.6.3
|
1.8.1
|
@ -2,11 +2,13 @@
|
|||||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
const languageColors = {{ .LanguageColors | json }}
|
||||||
|
|
||||||
let wakapiData = {}
|
let wakapiData = {}
|
||||||
let languageColors = {{ .LanguageColors | json }}
|
wakapiData.projects = {{ .Projects | json }}
|
||||||
wakapiData.projects = {{ .Projects | json }}
|
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
wakapiData.editors = {{ .Editors | json }}
|
||||||
wakapiData.editors = {{ .Editors | json }}
|
wakapiData.languages = {{ .Languages | json }}
|
||||||
wakapiData.languages = {{ .Languages | json }}
|
wakapiData.machines = {{ .Machines | json }}
|
||||||
</script>
|
</script>
|
||||||
<script src="assets/app.js"></script>
|
<script src="assets/app.js"></script>
|
@ -5,7 +5,7 @@
|
|||||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||||
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div>
|
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
|
||||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1>
|
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Imprint & Data Privacy</h1>
|
||||||
</div>
|
</div>
|
||||||
<div></div>
|
<div></div>
|
||||||
|
75
views/settings.tpl.html
Normal file
75
views/settings.tpl.html
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<html>
|
||||||
|
|
||||||
|
{{ template "head.tpl.html" . }}
|
||||||
|
|
||||||
|
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||||
|
<div class="w-full flex justify-center">
|
||||||
|
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||||
|
<div><a href="" class="text-gray-500 text-sm">← Go back</a></div>
|
||||||
|
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ template "alerts.tpl.html" . }}
|
||||||
|
|
||||||
|
<main class="mt-4 flex-grow flex justify-center w-full">
|
||||||
|
<div class="flex flex-col flex-grow max-w-lg mt-8">
|
||||||
|
<div class="w-full my-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">
|
||||||
|
Change Password
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-10" action="settings/credentials" method="post">
|
||||||
|
<div class="mb-8">
|
||||||
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_old">Current Password</label>
|
||||||
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
|
type="password" id="password_old"
|
||||||
|
name="password_old" placeholder="Enter your old password" minlength="6" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-8">
|
||||||
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_new">New Password</label>
|
||||||
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
|
type="password" id="password_new"
|
||||||
|
name="password_new" placeholder="Choose a password" minlength="6" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-8">
|
||||||
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
|
||||||
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
|
type="password" id="password_repeat"
|
||||||
|
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between float-right">
|
||||||
|
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="w-full mt-4 mb-8 pb-8">
|
||||||
|
<div class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
|
Reset API Key
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-6" action="settings/reset" method="post">
|
||||||
|
<div class="text-gray-300 text-sm mb-4">
|
||||||
|
<strong>⚠️ Caution:</strong> Resetting your API key requires you to update your <span class="font-mono">.wakatime.cfg</span> files on all of your computers to make the WakaTime client send heartbeats again.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-between float-right">
|
||||||
|
<button type="submit" class="py-1 px-3 rounded bg-red-500 hover:bg-red-600 text-white text-sm">
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{{ template "footer.tpl.html" . }}
|
||||||
|
|
||||||
|
{{ template "foot.tpl.html" . }}
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
@ -5,7 +5,7 @@
|
|||||||
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
<body class="bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||||
<div class="w-full flex justify-center">
|
<div class="w-full flex justify-center">
|
||||||
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
<div class="flex items-center justify-between max-w-4xl flex-grow">
|
||||||
<div><a href="" class="text-gray-500 text-sm">🠐 Go back</a></div>
|
<div><a href="" class="text-gray-500 text-sm">&larr Go back</a></div>
|
||||||
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
|
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Sign Up</h1></div>
|
||||||
<div></div>
|
<div></div>
|
||||||
</div>
|
</div>
|
||||||
@ -41,7 +41,7 @@
|
|||||||
name="password" placeholder="Choose a password" minlength="6" required>
|
name="password" placeholder="Choose a password" minlength="6" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">And again ...</label>
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password_repeat">And again ...</label>
|
||||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
type="password" id="password_repeat"
|
type="password" id="password_repeat"
|
||||||
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
name="password_repeat" placeholder="Repeat your password" minlength="6" required>
|
||||||
|
@ -4,21 +4,32 @@
|
|||||||
|
|
||||||
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-xl mx-auto justify-center">
|
||||||
|
|
||||||
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup" id="api-key-popup">
|
<div class="hidden flex bg-gray-800 shadow-md z-10 p-2 absolute top-0 right-0 mt-10 mr-8 border border-green-700 rounded popup"
|
||||||
|
id="api-key-popup">
|
||||||
<div class="flex-grow flex flex-col px-2">
|
<div class="flex-grow flex flex-col px-2">
|
||||||
<span class="text-xs text-gray-500 mx-1">API Key</span>
|
<span class="text-xs text-gray-500 mx-1">API Key</span>
|
||||||
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly value="{{ .ApiKey }}" style="min-width: 330px">
|
<input type="text" class="bg-transparent text-sm text-white mx-1 font-mono" id="api-key-container" readonly
|
||||||
|
value="{{ .ApiKey }}" style="min-width: 330px">
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center px-2 border-l border-gray-700">
|
<div class="flex items-center px-2 border-l border-gray-700">
|
||||||
<button title="Copy to clipboard" onclick="copyApiKey(event)">📋</button>
|
<button title="Copy to clipboard" onclick="copyApiKey(event)">📋</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="absolute top-0 right-0 mr-8 mt-10 py-2">
|
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||||
<form action="logout" method="post">
|
<div class="mx-1">
|
||||||
<button type="button" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm" onclick="showApiKeyPopup(event)">🔐</button>
|
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm"
|
||||||
<button type="submit" class="mx-1 py-1 px-3 rounded border border-green-700 text-white text-sm">Logout</button>
|
onclick="showApiKeyPopup(event)">🔐
|
||||||
</form>
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mx-1">
|
||||||
|
<a href="settings" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">⚙️</a>
|
||||||
|
</div>
|
||||||
|
<div class="mx-1">
|
||||||
|
<form action="logout" method="post">
|
||||||
|
<button type="submit" class="py-1 px-3 h-8 rounded border border-green-700 text-white text-sm">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex items-center justify-center">
|
<div class="flex items-center justify-center">
|
||||||
@ -67,6 +78,11 @@
|
|||||||
<canvas id="chart-editor"></canvas>
|
<canvas id="chart-editor"></canvas>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full lg:w-1/2 p-1">
|
||||||
|
<div class="p-4 bg-white rounded shadow m-2" id="machine-container" style="height: 300px">
|
||||||
|
<canvas id="chart-machine"></canvas>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user