1
0
mirror of https://github.com/muety/wakapi.git synced 2023-08-10 21:12:56 +03:00

Compare commits

...

15 Commits

Author SHA1 Message Date
6e2f3e6731 fix: attempt to fix invalid fixture query for postgres (fix #52) 2020-09-12 16:32:43 +02:00
d60dddb550 feat: implement badges endpoint and sharing functionality 2020-09-12 16:09:23 +02:00
19a8c61f77 feat: add more pre-configured intervals (resolve #51) 2020-09-12 12:40:38 +02:00
fde8c35362 docs: include github project card for exporter repo 2020-09-12 09:21:04 +02:00
8dca9f5cc0 chore: parallel summary conversion 2020-09-12 00:20:16 +02:00
570aeebe01 docs: document prometheus export 2020-09-11 23:51:46 +02:00
21567e7601 feat: implement summaries compat endpoint (resolve #44)
fix: fix all time view model
2020-09-11 23:24:51 +02:00
a8009e107d fix: support project query param for alltime endpoint 2020-09-11 20:22:33 +02:00
84e9559860 fix: all time data model 2020-09-06 17:20:37 +02:00
7c8ea86d4e docs: document api endpoints in readme 2020-09-06 12:25:12 +02:00
587ac6a330 feat: add wakatime-compatible alltime endpoint 2020-09-06 12:15:46 +02:00
97cb29ee4d feat: show placeholders when no data is available (resolve #42)
feat: add jsx as custom language by default (resolve #50)
2020-08-30 16:51:37 +02:00
cecb5e113c chore: remove debug comments 2020-08-30 01:45:01 +02:00
75b33d5e42 fix: save heartbeats and summaries with millisecond time precision (resolve #49) 2020-08-30 01:42:00 +02:00
50b7a9ec3d fix: column definition in migration 2020-08-30 01:24:27 +02:00
34 changed files with 1134 additions and 223 deletions

View File

@ -4,9 +4,7 @@
[![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try) [![Say thanks](https://img.shields.io/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg?style=flat-square)](https://saythanks.io/to/n1try)
![](https://img.shields.io/github/license/muety/wakapi?style=flat-square) ![](https://img.shields.io/github/license/muety/wakapi?style=flat-square)
[![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi) [![Go Report Card](https://goreportcard.com/badge/github.com/muety/wakapi?style=flat-square)](https://goreportcard.com/report/github.com/muety/wakapi)
![Coding Activity](https://img.shields.io/endpoint?url=https://apps.muetsch.io/wakapi/api/compat/shields/v1/n1try/interval:any/project:wakapi&style=flat-square&color=blue)
[![Buy me a coffee](https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png)](https://buymeacoff.ee/n1try)
--- ---
**A minimalist, self-hosted WakaTime-compatible backend for coding statistics** **A minimalist, self-hosted WakaTime-compatible backend for coding statistics**
@ -82,7 +80,27 @@ INSERT INTO aliases (`type`, `user_id`, `key`, `value`) VALUES (0, 'your_usernam
* OS ~ type **3** * OS ~ type **3**
* Machine ~ type **4** * 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. ## API Endpoints
The following API endpoints are available. A more detailed Swagger documentation is about to come ([#40](https://github.com/muety/wakapi/issues/40)).
* `POST /api/heartbeat`
* `GET /api/summary`
* `string` parameter `interval`: One of `today`, `day`, `week`, `month`, `year`, `any`
* `GET /api/compat/v1/users/current/all_time_since_today` (see [Wakatime API docs](https://wakatime.com/developers#all_time_since_today))
* `GET /api/compat/v1/users/current/summaries` (see [Wakatime API docs](https://wakatime.com/developers#summaries))
* `GET /api/health`
## Prometheus Export
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true)](https://github.com/MacroPower/wakatime_exporter)
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible.
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly.
## Badges
## Best Practices ## Best Practices
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS). It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).

View File

@ -11,4 +11,5 @@ cleanup = false
max_connections = 2 max_connections = 2
[languages] [languages]
vue = Vue vue = Vue
jsx = JSX

2
go.mod
View File

@ -14,7 +14,7 @@ require (
github.com/patrickmn/go-cache v2.1.0+incompatible github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
github.com/satori/go.uuid v1.2.0 github.com/satori/go.uuid v1.2.0
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f github.com/t-tiger/gorm-bulk-insert v1.3.0
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c
gopkg.in/ini.v1 v1.50.0 gopkg.in/ini.v1 v1.50.0
) )

2
go.sum
View File

@ -318,6 +318,8 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8= github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f h1:Op5lFYUNE7tPxu6gJfwkgY8HMIWpLqiLApBJfGs71U8=
github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c= github.com/t-tiger/gorm-bulk-insert v0.0.0-20191014134946-beb77b81825f/go.mod h1:SK1RZT4TR1aMUNGtbk6YxTPgx2D/gfbxB571QGnAV+c=
github.com/t-tiger/gorm-bulk-insert v1.3.0 h1:9k7BaVEhw/3fsvh6GTOBwJ2RXk3asc5xs5m6hwozq20=
github.com/t-tiger/gorm-bulk-insert v1.3.0/go.mod h1:ruDlk8xDl+8sX4bA7PQuYly9YEb3pbp1eP2LCyeRrFY=
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0= github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA= github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=

20
main.go
View File

@ -15,6 +15,8 @@ import (
"github.com/muety/wakapi/middlewares" "github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/routes" "github.com/muety/wakapi/routes"
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
) )
@ -86,12 +88,17 @@ func main() {
go heartbeatService.ScheduleCleanUp() go heartbeatService.ScheduleCleanUp()
} }
// TODO: move endpoint registration to the respective routes files
// Handlers // Handlers
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) settingsHandler := routes.NewSettingsHandler(userService)
publicHandler := routes.NewIndexHandler(userService, keyValueService) publicHandler := routes.NewIndexHandler(userService, keyValueService)
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
shieldV1BadgeHandler := shieldsV1Routes.NewBadgeHandler(summaryService, userService)
// Setup Routers // Setup Routers
router := mux.NewRouter() router := mux.NewRouter()
@ -99,6 +106,9 @@ func main() {
settingsRouter := publicRouter.PathPrefix("/settings").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()
compatRouter := apiRouter.PathPrefix("/compat").Subrouter()
wakatimeV1Router := compatRouter.PathPrefix("/wakatime/v1").Subrouter()
shieldsV1Router := compatRouter.PathPrefix("/shields/v1").Subrouter()
// Middlewares // Middlewares
recoveryMiddleware := handlers.RecoveryHandler() recoveryMiddleware := handlers.RecoveryHandler()
@ -106,7 +116,7 @@ func main() {
corsMiddleware := handlers.CORS() corsMiddleware := handlers.CORS()
authenticateMiddleware := middlewares.NewAuthenticateMiddleware( authenticateMiddleware := middlewares.NewAuthenticateMiddleware(
userService, userService,
[]string{"/api/health"}, []string{"/api/health", "/api/compat/shields/v1"},
).Handler ).Handler
// Router configs // Router configs
@ -130,12 +140,20 @@ func main() {
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("/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)
// API Routes // API Routes
apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost) apiRouter.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(heartbeatHandler.ApiPost)
apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet) apiRouter.Path("/summary").Methods(http.MethodGet).HandlerFunc(summaryHandler.ApiGet)
apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet) apiRouter.Path("/health").Methods(http.MethodGet).HandlerFunc(healthHandler.ApiGet)
// Wakatime compat V1 API Routes
wakatimeV1Router.Path("/users/{user}/all_time_since_today").Methods(http.MethodGet).HandlerFunc(wakatimeV1AllHandler.ApiGet)
wakatimeV1Router.Path("/users/{user}/summaries").Methods(http.MethodGet).HandlerFunc(wakatimeV1SummariesHandler.ApiGet)
// Shields.io compat API Routes
shieldsV1Router.PathPrefix("/{user}").Methods(http.MethodGet).HandlerFunc(shieldV1BadgeHandler.ApiGet)
// Static Routes // Static Routes
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static"))) router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))

View File

@ -1,7 +1,8 @@
-- +migrate Up -- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied -- SQL in section 'Up' is executed when this migration is applied
insert into key_string_values (`key`, `value`) values ('imprint', 'no content here'); insert into key_string_values ("key", "value") values ('imprint', 'no content here');
-- +migrate Down -- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back -- SQL section 'Down' is executed when this migration is rolled back
delete from key_string_values where `key` = 'imprint'; SET SQL_MODE=ANSI_QUOTES;
delete from key_string_values where key = 'imprint';

View File

@ -1,11 +1,11 @@
-- +migrate Up -- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied -- SQL in section 'Up' is executed when this migration is applied
alter table users alter table heartbeats
add `machine` varchar(255); add column `machine` varchar(255);
-- +migrate Down -- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back -- SQL section 'Down' is executed when this migration is rolled back
alter table users alter table heartbeats
drop column `machine`; drop column `machine`;

View File

@ -0,0 +1,11 @@
-- +migrate Up
-- SQL in section 'Up' is executed when this migration is applied
alter table users
add column `badges_enabled` tinyint(1) default 0 not null;
-- +migrate Down
-- SQL section 'Down' is executed when this migration is rolled back
alter table users
drop column `badges_enabled`;

View File

@ -0,0 +1,37 @@
package v1
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
// https://shields.io/endpoint
const (
defaultLabel = "coding time"
defaultColor = "#2D3748" // not working
)
type BadgeData struct {
SchemaVersion int `json:"schemaVersion"`
Label string `json:"label"`
Message string `json:"message"`
Color string `json:"color"`
}
func NewBadgeDataFrom(summary *models.Summary, filters *models.Filters) *BadgeData {
var total time.Duration
if hasFilter, filterType, filterKey := filters.First(); hasFilter {
total = summary.TotalTimeByKey(filterType, filterKey)
} else {
total = summary.TotalTime()
}
return &BadgeData{
SchemaVersion: 1,
Label: defaultLabel,
Message: utils.FmtWakatimeDuration(total),
Color: defaultColor,
}
}

View File

@ -0,0 +1,36 @@
package v1
import (
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"time"
)
// https://wakatime.com/developers#all_time_since_today
type AllTimeViewModel struct {
Data *allTimeData `json:"data"`
}
type allTimeData struct {
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
Text string `json:"text"` // total time logged since account created as human readable string>
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
}
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {
var total time.Duration
if key := filters.Project; key != "" {
total = summary.TotalTimeByKey(models.SummaryProject, key)
} else {
total = summary.TotalTime()
}
return &AllTimeViewModel{
Data: &allTimeData{
TotalSeconds: float32(total.Seconds()),
Text: utils.FmtWakatimeDuration(total),
IsUpToDate: true,
},
}
}

View File

@ -0,0 +1,172 @@
package v1
import (
"fmt"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"math"
"sync"
"time"
)
// https://wakatime.com/developers#summaries
// https://pastr.de/v/736450
type SummariesViewModel struct {
Data []*summariesData `json:"data"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
}
type summariesData struct {
Categories []*summariesEntry `json:"categories"`
Dependencies []*summariesEntry `json:"dependencies"`
Editors []*summariesEntry `json:"editors"`
Languages []*summariesEntry `json:"languages"`
Machines []*summariesEntry `json:"machines"`
OperatingSystems []*summariesEntry `json:"operating_systems"`
Projects []*summariesEntry `json:"projects"`
GrandTotal *summariesGrandTotal `json:"grand_total"`
Range *summariesRange `json:"range"`
}
type summariesEntry struct {
Digital string `json:"digital"`
Hours int `json:"hours"`
Minutes int `json:"minutes"`
Name string `json:"name"`
Percent float64 `json:"percent"`
Seconds int `json:"seconds"`
Text string `json:"text"`
TotalSeconds float64 `json:"total_seconds"`
}
type summariesGrandTotal struct {
Digital string `json:"digital"`
Hours int `json:"hours"`
Minutes int `json:"minutes"`
Text string `json:"text"`
TotalSeconds float64 `json:"total_seconds"`
}
type summariesRange struct {
Date string `json:"date"`
End time.Time `json:"end"`
Start time.Time `json:"start"`
Text string `json:"text"`
Timezone string `json:"timezone"`
}
func NewSummariesFrom(summaries []*models.Summary, filters *models.Filters) *SummariesViewModel {
data := make([]*summariesData, len(summaries))
minDate, maxDate := time.Now().Add(1*time.Second), time.Time{}
for i, s := range summaries {
data[i] = newDataFrom(s)
if s.FromTime.Before(minDate) {
minDate = s.FromTime
}
if s.ToTime.After(maxDate) {
maxDate = s.ToTime
}
}
return &SummariesViewModel{
Data: data,
End: maxDate,
Start: minDate,
}
}
func newDataFrom(s *models.Summary) *summariesData {
zone, _ := time.Now().Zone()
total := s.TotalTime()
totalHrs, totalMins := int(total.Hours()), int((total - time.Duration(total.Hours())*time.Hour).Minutes())
data := &summariesData{
Categories: make([]*summariesEntry, 0),
Dependencies: make([]*summariesEntry, 0),
Editors: make([]*summariesEntry, len(s.Editors)),
Languages: make([]*summariesEntry, len(s.Languages)),
Machines: make([]*summariesEntry, len(s.Machines)),
OperatingSystems: make([]*summariesEntry, len(s.OperatingSystems)),
Projects: make([]*summariesEntry, len(s.Projects)),
GrandTotal: &summariesGrandTotal{
Digital: fmt.Sprintf("%d:%d", totalHrs, totalMins),
Hours: totalHrs,
Minutes: totalMins,
Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),
},
Range: &summariesRange{
Date: time.Now().Format(time.RFC3339),
End: s.ToTime,
Start: s.FromTime,
Text: "",
Timezone: zone,
},
}
var wg sync.WaitGroup
wg.Add(5)
go func(data *summariesData) {
defer wg.Done()
for i, e := range s.Projects {
data.Projects[i] = convertEntry(e, s.TotalTimeBy(models.SummaryProject))
}
}(data)
go func(data *summariesData) {
defer wg.Done()
for i, e := range s.Editors {
data.Editors[i] = convertEntry(e, s.TotalTimeBy(models.SummaryEditor))
}
}(data)
go func(data *summariesData) {
defer wg.Done()
for i, e := range s.Languages {
data.Languages[i] = convertEntry(e, s.TotalTimeBy(models.SummaryLanguage))
}
}(data)
go func(data *summariesData) {
defer wg.Done()
for i, e := range s.OperatingSystems {
data.OperatingSystems[i] = convertEntry(e, s.TotalTimeBy(models.SummaryOS))
}
}(data)
go func(data *summariesData) {
defer wg.Done()
for i, e := range s.Machines {
data.Machines[i] = convertEntry(e, s.TotalTimeBy(models.SummaryMachine))
}
}(data)
wg.Wait()
return data
}
func convertEntry(e *models.SummaryItem, entityTotal time.Duration) *summariesEntry {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items
total := e.Total * time.Second
hrs := int(total.Hours())
mins := int((total - time.Duration(hrs)*time.Hour).Minutes())
secs := int((total - time.Duration(hrs)*time.Hour - time.Duration(mins)*time.Minute).Seconds())
return &summariesEntry{
Digital: fmt.Sprintf("%d:%d:%d", hrs, mins, secs),
Hours: hrs,
Minutes: mins,
Name: e.Key,
Percent: math.Round((total.Seconds()/entityTotal.Seconds())*1e4) / 100,
Seconds: secs,
Text: utils.FmtWakatimeDuration(total),
TotalSeconds: total.Seconds(),
}
}

View File

@ -5,8 +5,6 @@ import (
"time" "time"
) )
type CustomTime time.Time
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"`
@ -21,7 +19,7 @@ type Heartbeat struct {
Editor string `json:"editor"` Editor string `json:"editor"`
OperatingSystem string `json:"operating_system"` OperatingSystem string `json:"operating_system"`
Machine string `json:"machine"` Machine string `json:"machine"`
Time CustomTime `json:"time" gorm:"type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time,idx_time_user"` Time CustomTime `json:"time" gorm:"type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time,idx_time_user"`
languageRegex *regexp.Regexp languageRegex *regexp.Regexp
} }

View File

@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"github.com/jinzhu/gorm" "github.com/jinzhu/gorm"
"math"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@ -23,13 +24,54 @@ type KeyStringValue struct {
Value string `gorm:"type:text"` Value string `gorm:"type:text"`
} }
type Filters struct {
Project string
OS string
Language string
Editor string
Machine string
}
func NewFiltersWith(entity uint8, key string) *Filters {
switch entity {
case SummaryProject:
return &Filters{Project: key}
case SummaryOS:
return &Filters{Project: key}
case SummaryLanguage:
return &Filters{Project: key}
case SummaryEditor:
return &Filters{Project: key}
case SummaryMachine:
return &Filters{Project: key}
}
return &Filters{}
}
func (f *Filters) First() (bool, uint8, string) {
if f.Project != "" {
return true, SummaryProject, f.Project
} else if f.OS != "" {
return true, SummaryOS, f.OS
} else if f.Language != "" {
return true, SummaryLanguage, f.Language
} else if f.Editor != "" {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
}
return false, 0, ""
}
type CustomTime time.Time
func (j *CustomTime) UnmarshalJSON(b []byte) error { func (j *CustomTime) UnmarshalJSON(b []byte) error {
s := strings.Split(strings.Trim(string(b), "\""), ".")[0] s := strings.Replace(strings.Trim(string(b), "\""), ".", "", 1)
i, err := strconv.ParseInt(s, 10, 64) i, err := strconv.ParseInt(s, 10, 64)
if err != nil { if err != nil {
return err return err
} }
t := time.Unix(i, 0) t := time.Unix(0, i*int64(math.Pow10(19-len(s))))
*j = CustomTime(t) *j = CustomTime(t)
return nil return nil
} }
@ -60,7 +102,7 @@ func (j CustomTime) Value() (driver.Value, error) {
func (j CustomTime) String() string { func (j CustomTime) String() string {
t := time.Time(j) t := time.Time(j)
return t.Format("2006-01-02 15:04:05") return t.Format("2006-01-02 15:04:05.000")
} }
func (j CustomTime) Time() time.Time { func (j CustomTime) Time() time.Time {

View File

@ -13,13 +13,31 @@ const (
SummaryMachine uint8 = 4 SummaryMachine uint8 = 4
) )
const (
IntervalToday string = "today"
IntervalYesterday string = "day"
IntervalThisWeek string = "week"
IntervalThisMonth string = "month"
IntervalThisYear string = "year"
IntervalPast7Days string = "7_days"
IntervalPast30Days string = "30_days"
IntervalPast12Months string = "12_months"
IntervalAny string = "any"
)
func Intervals() []string {
return []string{
IntervalToday, IntervalYesterday, IntervalThisWeek, IntervalThisMonth, IntervalThisYear, IntervalPast7Days, IntervalPast30Days, IntervalPast12Months, IntervalAny,
}
}
const UnknownSummaryKey = "unknown" 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"`
FromTime time.Time `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"` FromTime time.Time `json:"from" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
ToTime time.Time `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"` ToTime time.Time `json:"to" gorm:"not null; type:timestamp(3); default:CURRENT_TIMESTAMP(3); index:idx_time_summary_user"`
Projects []*SummaryItem `json:"projects"` Projects []*SummaryItem `json:"projects"`
Languages []*SummaryItem `json:"languages"` Languages []*SummaryItem `json:"languages"`
Editors []*SummaryItem `json:"editors"` Editors []*SummaryItem `json:"editors"`
@ -48,6 +66,31 @@ type SummaryViewModel struct {
ApiKey string ApiKey string
} }
type SummaryParams struct {
From time.Time
To time.Time
User *User
Recompute bool
}
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
func (s *Summary) Types() []uint8 {
return SummaryTypes()
}
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
return map[uint8]*[]*SummaryItem{
SummaryProject: &s.Projects,
SummaryLanguage: &s.Languages,
SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines,
}
}
/* Augments the summary in a way that at least one item is present for every type. /* 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, 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" the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
@ -60,22 +103,13 @@ To avoid having to modify persisted data retrospectively, i.e. inserting a dummy
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown". such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
*/ */
func (s *Summary) FillUnknown() { func (s *Summary) FillUnknown() {
types := []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine} types := s.Types()
typeItems := s.MappedItems()
missingTypes := make([]uint8, 0) 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 { for _, t := range types {
if len(*typeItems[t]) == 0 { if len(*typeItems[t]) == 0 {
missingTypes = append(missingTypes, t) missingTypes = append(missingTypes, t)
} else {
somePresentType = t
} }
} }
@ -84,11 +118,7 @@ func (s *Summary) FillUnknown() {
return return
} }
// calculate total duration from any of the present sets of items timeSum := s.TotalTime()
var timeSum time.Duration
for _, item := range *typeItems[somePresentType] {
timeSum += item.Total
}
// construct dummy item for all missing types // construct dummy item for all missing types
for _, t := range missingTypes { for _, t := range missingTypes {
@ -99,3 +129,49 @@ func (s *Summary) FillUnknown() {
}) })
} }
} }
func (s *Summary) TotalTime() time.Duration {
var timeSum time.Duration
mappedItems := s.MappedItems()
// calculate total duration from any of the present sets of items
for _, t := range s.Types() {
if items := mappedItems[t]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
}
break
}
}
return timeSum * time.Second
}
func (s *Summary) TotalTimeBy(entityType uint8) time.Duration {
var timeSum time.Duration
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
}
}
return timeSum * time.Second
}
func (s *Summary) TotalTimeByKey(entityType uint8, key string) time.Duration {
var timeSum time.Duration
mappedItems := s.MappedItems()
if items := mappedItems[entityType]; len(*items) > 0 {
for _, item := range *items {
if item.Key != key {
continue
}
timeSum += item.Total
}
}
return timeSum * time.Second
}

View File

@ -6,6 +6,7 @@ type User struct {
Password string `json:"-"` Password string `json:"-"`
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"` LastLoggedInAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP"`
BadgesEnabled bool `json:"-" gorm:"not null; default:false; type: bool"`
} }
type Login struct { type Login struct {
@ -37,7 +38,7 @@ func (s *Signup) IsValid() bool {
} }
func validateUsername(username string) bool { func validateUsername(username string) bool {
return len(username) >= 3 return len(username) >= 3 && username != "current"
} }
func validatePassword(password string) bool { func validatePassword(password string) bool {

View File

@ -0,0 +1,96 @@
package v1
import (
"github.com/gorilla/mux"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/shields/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"regexp"
)
const (
intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
)
type BadgeHandler struct {
userSrvc *services.UserService
summarySrvc *services.SummaryService
config *models.Config
}
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
return &BadgeHandler{
summarySrvc: summaryService,
userSrvc: userService,
config: models.GetConfig(),
}
}
func (h *BadgeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
intervalReg := regexp.MustCompile(intervalPattern)
entityFilterReg := regexp.MustCompile(entityFilterPattern)
requestedUserId := mux.Vars(r)["user"]
user, err := h.userSrvc.GetUserById(requestedUserId)
if err != nil || !user.BadgesEnabled {
w.WriteHeader(http.StatusUnauthorized)
return
}
var filterEntity, filterKey string
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
filterEntity, filterKey = groups[1], groups[2]
}
var interval = models.IntervalPast30Days
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
interval = groups[1]
}
filters := &models.Filters{}
switch filterEntity {
case "project":
filters.Project = filterKey
case "os":
filters.OS = filterKey
case "editor":
filters.Editor = filterKey
case "language":
filters.Language = filterKey
case "machine":
filters.Machine = filterKey
}
summary, err, status := h.loadUserSummary(user, interval)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
vm := v1.NewBadgeDataFrom(summary, filters)
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *BadgeHandler) loadUserSummary(user *models.User, interval string) (*models.Summary, error, int) {
err, from, to := utils.ResolveInterval(interval)
if err != nil {
return nil, err, http.StatusBadRequest
}
summaryParams := &models.SummaryParams{
From: from,
To: to,
User: user,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute)
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

View File

@ -0,0 +1,63 @@
package v1
import (
"github.com/gorilla/mux"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"net/url"
"time"
)
type AllTimeHandler struct {
summarySrvc *services.SummaryService
config *models.Config
}
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
return &AllTimeHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
}
}
func (h *AllTimeHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
values, _ := url.ParseQuery(r.URL.RawQuery)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
}
summary, err, status := h.loadUserSummary(authorizedUser)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
vm := v1.NewAllTimeFrom(summary, &models.Filters{Project: values.Get("project")})
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *AllTimeHandler) loadUserSummary(user *models.User) (*models.Summary, error, int) {
summaryParams := &models.SummaryParams{
From: time.Time{},
To: time.Now(),
User: user,
Recompute: false,
}
summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
if err != nil {
return nil, err, http.StatusInternalServerError
}
return summary, nil, http.StatusOK
}

View File

@ -0,0 +1,96 @@
package v1
import (
"errors"
"github.com/gorilla/mux"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"strings"
"time"
)
type SummariesHandler struct {
summarySrvc *services.SummaryService
config *models.Config
}
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
return &SummariesHandler{
summarySrvc: summaryService,
config: models.GetConfig(),
}
}
/*
TODO: support parameters: project, branches, timeout, writes_only, timezone
https://wakatime.com/developers#summaries
timezone can be specified via an offset suffix (e.g. +02:00) in date strings
*/
func (h *SummariesHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
requestedUser := vars["user"]
authorizedUser := r.Context().Value(models.UserKey).(*models.User)
if requestedUser != authorizedUser.ID && requestedUser != "current" {
w.WriteHeader(http.StatusForbidden)
return
}
summaries, err, status := h.loadUserSummaries(r)
if err != nil {
w.WriteHeader(status)
w.Write([]byte(err.Error()))
return
}
vm := v1.NewSummariesFrom(summaries, &models.Filters{})
utils.RespondJSON(w, http.StatusOK, vm)
}
func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query()
var start, end time.Time
// TODO: find out what other special dates are supported by wakatime (e.g. tomorrow, yesterday, ...?)
if startKey, endKey := params.Get("start"), params.Get("end"); startKey == "today" && startKey == endKey {
start = utils.StartOfToday()
end = time.Now()
} else {
var err error
start, err = time.Parse(time.RFC3339, strings.Replace(startKey, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'start' parameter"), http.StatusBadRequest
}
end, err = time.Parse(time.RFC3339, strings.Replace(endKey, " ", "+", 1))
if err != nil {
return nil, errors.New("missing required 'end' parameter"), http.StatusBadRequest
}
}
overallParams := &models.SummaryParams{
From: start,
To: end,
User: user,
Recompute: false,
}
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.Construct(interval[0], interval[1], user, false) // 'to' is always constant
if err != nil {
return nil, err, http.StatusInternalServerError
}
summaries[i] = summary
}
return summaries, nil, http.StatusOK
}

View File

@ -29,10 +29,16 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates() loadTemplates()
} }
user := r.Context().Value(models.UserKey).(*models.User)
data := map[string]interface{}{
"User": user,
}
// TODO: when alerts are present, other data will not be passed to the template
if handleAlerts(w, r, "settings.tpl.html") { if handleAlerts(w, r, "settings.tpl.html") {
return return
} }
templates["settings.tpl.html"].Execute(w, nil) templates["settings.tpl.html"].Execute(w, data)
} }
func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) { func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request) {
@ -110,3 +116,18 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
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.BasePath, msg), http.StatusFound) http.Redirect(w, r, fmt.Sprintf("%s/settings?success=%s", h.config.BasePath, msg), http.StatusFound)
} }
func (h *SettingsHandler) PostToggleBadges(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ToggleBadges(user); err != nil {
respondAlert(w, "internal server error", "", "settings.tpl.html", http.StatusInternalServerError)
return
}
http.Redirect(w, r, fmt.Sprintf("%s/settings", h.config.BasePath), http.StatusFound)
}

View File

@ -1,22 +1,10 @@
package routes package routes
import ( import (
"errors"
"net/http"
"time"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils" "github.com/muety/wakapi/utils"
) "net/http"
const (
IntervalToday string = "today"
IntervalLastDay string = "day"
IntervalLastWeek string = "week"
IntervalLastMonth string = "month"
IntervalLastYear string = "year"
IntervalAny string = "any"
) )
type SummaryHandler struct { type SummaryHandler struct {
@ -32,7 +20,7 @@ func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler
} }
func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) { func (h *SummaryHandler) ApiGet(w http.ResponseWriter, r *http.Request) {
summary, err, status := loadUserSummary(r, h.summarySrvc) summary, err, status := h.loadUserSummary(r)
if err != nil { if err != nil {
w.WriteHeader(status) w.WriteHeader(status)
w.Write([]byte(err.Error())) w.Write([]byte(err.Error()))
@ -53,7 +41,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
r.URL.RawQuery = q.Encode() r.URL.RawQuery = q.Encode()
} }
summary, err, status := loadUserSummary(r, h.summarySrvc) summary, err, status := h.loadUserSummary(r)
if err != nil { if err != nil {
respondAlert(w, err.Error(), "", "summary.tpl.html", status) respondAlert(w, err.Error(), "", "summary.tpl.html", status)
return return
@ -74,39 +62,13 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
templates["summary.tpl.html"].Execute(w, vm) templates["summary.tpl.html"].Execute(w, vm)
} }
func loadUserSummary(r *http.Request, summaryService *services.SummaryService) (*models.Summary, error, int) { func (h *SummaryHandler) loadUserSummary(r *http.Request) (*models.Summary, error, int) {
user := r.Context().Value(models.UserKey).(*models.User) summaryParams, err := utils.ParseSummaryParams(r)
params := r.URL.Query()
interval := params.Get("interval")
from, err := utils.ParseDate(params.Get("from"))
if err != nil { if err != nil {
switch interval { return nil, err, http.StatusBadRequest
case IntervalToday:
from = utils.StartOfDay()
case IntervalLastDay:
from = utils.StartOfDay().Add(-24 * time.Hour)
case IntervalLastWeek:
from = utils.StartOfWeek()
case IntervalLastMonth:
from = utils.StartOfMonth()
case IntervalLastYear:
from = utils.StartOfYear()
case IntervalAny:
from = time.Time{}
default:
return nil, errors.New("missing 'from' parameter"), http.StatusBadRequest
}
} }
live := (params.Get("live") != "" && params.Get("live") != "false") || interval == IntervalToday summary, err := h.summarySrvc.Construct(summaryParams.From, summaryParams.To, summaryParams.User, summaryParams.Recompute) // 'to' is always constant
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
to := utils.StartOfDay()
if live {
to = time.Now()
}
var summary *models.Summary
summary, err = summaryService.Construct(from, to, user, recompute) // 'to' is always constant
if err != nil { if err != nil {
return nil, err, http.StatusInternalServerError return nil, err, http.StatusInternalServerError
} }

View File

@ -77,7 +77,7 @@ func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
} }
func (srv *HeartbeatService) CleanUp() error { func (srv *HeartbeatService) CleanUp() error {
refTime := utils.StartOfDay().Add(-cleanUpInterval) refTime := utils.StartOfToday().Add(-cleanUpInterval)
if err := srv.DeleteBefore(refTime); err != nil { if err := srv.DeleteBefore(refTime); err != nil {
log.Printf("Failed to clean up heartbeats older than %v %v\n", refTime, err) log.Printf("Failed to clean up heartbeats older than %v %v\n", refTime, err)
return err return err

View File

@ -65,7 +65,7 @@ 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, models.SummaryMachine} types := models.SummaryTypes()
var projectItems []*models.SummaryItem var projectItems []*models.SummaryItem
var languageItems []*models.SummaryItem var languageItems []*models.SummaryItem

View File

@ -87,6 +87,19 @@ func (srv *UserService) ResetApiKey(user *models.User) (*models.User, error) {
return srv.Update(user) return srv.Update(user)
} }
func (srv *UserService) ToggleBadges(user *models.User) (*models.User, error) {
result := srv.Db.Model(user).Update("badges_enabled", !user.BadgesEnabled)
if err := result.Error; err != nil {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
}
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 {

View File

@ -1,5 +1,5 @@
const SHOW_TOP_N = 10 const SHOW_TOP_N = 10
const CHART_TARGET_SIZE = 170 const CHART_TARGET_SIZE = 200
const projectsCanvas = document.getElementById('chart-projects') const projectsCanvas = document.getElementById('chart-projects')
const osCanvas = document.getElementById('chart-os') const osCanvas = document.getElementById('chart-os')
@ -7,6 +7,16 @@ const editorsCanvas = document.getElementById('chart-editor')
const languagesCanvas = document.getElementById('chart-language') const languagesCanvas = document.getElementById('chart-language')
const machinesCanvas = document.getElementById('chart-machine') const machinesCanvas = document.getElementById('chart-machine')
const projectContainer = document.getElementById('project-container')
const osContainer = document.getElementById('os-container')
const editorContainer = document.getElementById('editor-container')
const languageContainer = document.getElementById('language-container')
const machineContainer = document.getElementById('machine-container')
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
let charts = [] let charts = []
let resizeCount = 0 let resizeCount = 0
@ -29,11 +39,6 @@ String.prototype.toHHMMSS = function () {
} }
function draw() { function draw() {
let titleOptions = {
display: true,
fontSize: 16
}
function getTooltipOptions(key, type) { function getTooltipOptions(key, type) {
return { return {
mode: 'single', mode: 'single',
@ -49,131 +54,158 @@ function draw() {
charts.forEach(c => c.destroy()) charts.forEach(c => c.destroy())
let projectChart = new Chart(projectsCanvas.getContext('2d'), { let projectChart = !projectsCanvas.classList.contains('hidden')
type: 'horizontalBar', ? new Chart(projectsCanvas.getContext('2d'), {
data: { type: 'horizontalBar',
datasets: wakapiData.projects data: {
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length)) datasets: wakapiData.projects
.map(p => { .slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
return { .map(p => {
label: p.key, return {
data: [parseInt(p.total) / 60], label: p.key,
backgroundColor: getRandomColor(p.key) data: [parseInt(p.total) / 60],
} backgroundColor: getRandomColor(p.key)
}) }
}, })
options: {
title: Object.assign(titleOptions, {text: `Projects (top ${SHOW_TOP_N})`}),
tooltips: getTooltipOptions('projects', 'bar'),
legend: {
display: false
}, },
scales: { options: {
xAxes: [{ tooltips: getTooltipOptions('projects', 'bar'),
scaleLabel: { legend: {
display: true, display: false
labelString: 'Minutes' },
} scales: {
}] xAxes: [{
}, scaleLabel: {
maintainAspectRatio: false, display: true,
onResize: onChartResize labelString: 'Minutes'
} }
}) }]
},
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
let osChart = new Chart(osCanvas.getContext('2d'), { let osChart = !osCanvas.classList.contains('hidden')
type: 'pie', ? new Chart(osCanvas.getContext('2d'), {
data: { type: 'pie',
datasets: [{ data: {
data: wakapiData.operatingSystems datasets: [{
data: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key))
}],
labels: wakapiData.operatingSystems
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) .slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
.map(p => parseInt(p.total)), .map(p => p.key)
backgroundColor: wakapiData.operatingSystems.map(p => getRandomColor(p.key)) },
}], options: {
labels: wakapiData.operatingSystems tooltips: getTooltipOptions('operatingSystems', 'pie'),
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length)) maintainAspectRatio: false,
.map(p => p.key) onResize: onChartResize
}, }
options: { })
title: Object.assign(titleOptions, {text: `Operating Systems (top ${SHOW_TOP_N})`}), : null
tooltips: getTooltipOptions('operatingSystems', 'pie'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
let editorChart = new Chart(editorsCanvas.getContext('2d'), { let editorChart = !editorsCanvas.classList.contains('hidden')
type: 'pie', ? new Chart(editorsCanvas.getContext('2d'), {
data: { type: 'pie',
datasets: [{ data: {
data: wakapiData.editors datasets: [{
data: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key))
}],
labels: wakapiData.editors
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) .slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
.map(p => parseInt(p.total)), .map(p => p.key)
backgroundColor: wakapiData.editors.map(p => getRandomColor(p.key)) },
}], options: {
labels: wakapiData.editors tooltips: getTooltipOptions('editors', 'pie'),
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length)) maintainAspectRatio: false,
.map(p => p.key) onResize: onChartResize
}, }
options: { })
title: Object.assign(titleOptions, {text: `Editors (top ${SHOW_TOP_N})`}), : null
tooltips: getTooltipOptions('editors', 'pie'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
let languageChart = new Chart(languagesCanvas.getContext('2d'), { let languageChart = !languagesCanvas.classList.contains('hidden')
type: 'pie', ? new Chart(languagesCanvas.getContext('2d'), {
data: { type: 'pie',
datasets: [{ data: {
data: wakapiData.languages datasets: [{
data: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key))
}],
labels: wakapiData.languages
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) .slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
.map(p => parseInt(p.total)), .map(p => p.key)
backgroundColor: wakapiData.languages.map(p => languageColors[p.key.toLowerCase()] || getRandomColor(p.key)) },
}], options: {
labels: wakapiData.languages tooltips: getTooltipOptions('languages', 'pie'),
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length)) maintainAspectRatio: false,
.map(p => p.key) onResize: onChartResize
}, }
options: { })
title: Object.assign(titleOptions, {text: `Languages (top ${SHOW_TOP_N})`}), : null
tooltips: getTooltipOptions('languages', 'pie'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
let machineChart = new Chart(machinesCanvas.getContext('2d'), { let machineChart = !machinesCanvas.classList.contains('hidden')
type: 'pie', ? new Chart(machinesCanvas.getContext('2d'), {
data: { type: 'pie',
datasets: [{ data: {
data: wakapiData.machines 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)) .slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
.map(p => parseInt(p.total)), .map(p => p.key)
backgroundColor: wakapiData.machines.map(p => getRandomColor(p.key)) },
}], options: {
labels: wakapiData.machines tooltips: getTooltipOptions('machines', 'pie'),
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length)) maintainAspectRatio: false,
.map(p => p.key) onResize: onChartResize
}, }
options: { })
title: Object.assign(titleOptions, {text: `Machines (top ${SHOW_TOP_N})`}), : null
tooltips: getTooltipOptions('machines', 'pie'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
getTotal(wakapiData.operatingSystems) getTotal(wakapiData.operatingSystems)
document.getElementById('grid-container').style.visibility = 'visible'
charts = [projectChart, osChart, editorChart, languageChart, machineChart] charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
charts.forEach(c => c.options.onResize(c.chart)) charts.forEach(c => c.options.onResize(c.chart))
equalizeHeights() equalizeHeights()
} }
function setTopLabels() {
[...document.getElementsByClassName('top-label')]
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
}
function togglePlaceholders(mask) {
const placeholderElements = containers.map(c => c.querySelector('.placeholder-container'))
for (let i = 0; i < mask.length; i++) {
if (!mask[i]) {
canvases[i].classList.add('hidden')
placeholderElements[i].classList.remove('hidden')
} else {
canvases[i].classList.remove('hidden')
placeholderElements[i].classList.add('hidden')
}
}
}
function getPresentDataMask() {
return data.map(list => list.reduce((acc, e) => acc + e.total, 0) > 0)
}
function getContainer(chart) { function getContainer(chart) {
return chart.canvas.parentNode return chart.canvas.parentNode
} }
@ -257,7 +289,7 @@ if (favicon) {
} }
// Click outside // Click outside
window.addEventListener('click', function(event) { window.addEventListener('click', function (event) {
if (event.target.classList.contains('popup')) { if (event.target.classList.contains('popup')) {
return return
} }
@ -268,5 +300,7 @@ window.addEventListener('click', function(event) {
}) })
window.addEventListener('load', function () { window.addEventListener('load', function () {
setTopLabels()
togglePlaceholders(getPresentDataMask())
draw() draw()
}) })

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@ -43,15 +43,14 @@ func MakeConnectionString(config *models.Config) string {
} }
func mySqlConnectionString(config *models.Config) string { func mySqlConnectionString(config *models.Config) string {
location, _ := time.LoadLocation("Local") //location, _ := time.LoadLocation("Local")
return fmt.Sprintf( return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s",
config.DbUser, config.DbUser,
config.DbPassword, config.DbPassword,
config.DbHost, config.DbHost,
config.DbPort, config.DbPort,
config.DbName, config.DbName,
location.String(), "Local",
) )
} }

View File

@ -1,10 +1,16 @@
package utils package utils
import "time" import (
"fmt"
"time"
)
func StartOfDay() time.Time { func StartOfToday() time.Time {
ref := time.Now() return StartOfDay(time.Now())
return time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, ref.Location()) }
func StartOfDay(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
} }
func StartOfWeek() time.Time { func StartOfWeek() time.Time {
@ -23,6 +29,29 @@ func StartOfYear() time.Time {
return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location()) return time.Date(ref.Year(), time.January, 1, 0, 0, 0, 0, ref.Location())
} }
func SplitRangeByDays(from time.Time, to time.Time) [][]time.Time {
intervals := make([][]time.Time, 0)
for t1 := from; t1.Before(to); {
t2 := StartOfDay(t1).Add(24 * time.Hour)
if t2.After(to) {
t2 = to
}
intervals = append(intervals, []time.Time{t1, t2})
t1 = t2
}
return intervals
}
func FmtWakatimeDuration(d time.Duration) string {
d = d.Round(time.Minute)
h := d / time.Hour
d -= h * time.Hour
m := d / time.Minute
return fmt.Sprintf("%d hrs %d mins", h, m)
}
// https://stackoverflow.com/a/18632496 // https://stackoverflow.com/a/18632496
func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time { func firstDayOfISOWeek(year int, week int, timezone *time.Location) time.Time {
date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone) date := time.Date(year, 0, 0, 0, 0, 0, 0, timezone)

View File

@ -8,3 +8,12 @@ import (
func Capitalize(s string) string { func Capitalize(s string) string {
return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:]) return fmt.Sprintf("%s%s", strings.ToUpper(s[:1]), s[1:])
} }
func FindString(needle string, haystack []string, defaultVal string) string {
for _, s := range haystack {
if s == needle {
return s
}
}
return defaultVal
}

69
utils/summary.go Normal file
View File

@ -0,0 +1,69 @@
package utils
import (
"errors"
"github.com/muety/wakapi/models"
"net/http"
"time"
)
func ResolveInterval(interval string) (err error, from, to time.Time) {
to = time.Now()
switch interval {
case models.IntervalToday:
from = StartOfToday()
case models.IntervalYesterday:
from = StartOfToday().Add(-24 * time.Hour)
to = StartOfToday()
case models.IntervalThisWeek:
from = StartOfWeek()
case models.IntervalThisMonth:
from = StartOfMonth()
case models.IntervalThisYear:
from = StartOfYear()
case models.IntervalPast7Days:
from = StartOfToday().AddDate(0, 0, -7)
case models.IntervalPast30Days:
from = StartOfToday().AddDate(0, 0, -30)
case models.IntervalPast12Months:
from = StartOfToday().AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
default:
err = errors.New("invalid interval")
}
return err, from, to
}
func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
user := r.Context().Value(models.UserKey).(*models.User)
params := r.URL.Query()
var err error
var from, to time.Time
if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveInterval(interval)
} else {
from, err = ParseDate(params.Get("from"))
if err != nil {
return nil, errors.New("missing 'from' parameter")
}
to, err = ParseDate(params.Get("to"))
if err != nil {
return nil, errors.New("missing 'to' parameter")
}
}
recompute := params.Get("recompute") != "" && params.Get("recompute") != "false"
return &models.SummaryParams{
From: from,
To: to,
User: user,
Recompute: recompute,
}, nil
}

View File

@ -1 +1 @@
1.8.1 1.11.1

View File

@ -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">&larr 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>

View File

@ -64,9 +64,72 @@
</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">
Badges
</div>
<form class="mt-6" action="settings/badges" method="post">
<div class="text-gray-300 text-sm mb-4">
{{ if .User.BadgesEnabled }}
<p>Badges are currently enabled. You can disable the feature by deactivating the respective API endpoint.</p>
<div class="flex justify-around mt-4">
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
<button type="submit" class="py-1 px-2 rounded bg-orange-700 hover:bg-orange-800 text-white text-xs" title="Disable support for badges to secure endpoint">
Status: public
</button>
</div>
<h3 class="font-semibold mb-2 mt-8">Examples</h3>
<div class="flex flex-col mb-4">
<div class="flex justify-between my-2">
<div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today"/>
</div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:today&style=flat-square&color=blue&label=today
</span>
</div>
<div class="flex justify-between my-2">
<div>
<img class="with-url-src" src="https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d"/>
</div>
<span class="with-url-inner text-xs bg-gray-900 rounded py-1 px-2 font-mono whitespace-no-wrap overflow-auto" style="max-width: 300px;">
https://img.shields.io/endpoint?url=%s/api/compat/shields/v1/{{ .User.ID }}/interval:30_days&style=flat-square&color=blue&label=last 30d
</span>
</div>
</div>
<p>You can also add <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">/project:your-cool-project</span> to the URL to filter by project.</p>
{{ else }}
<p>You have the ability to create badges from your coding statistics using <a href="https://shields.io" target="_blank" class="border-b border-green-800">Shields.io</a>. To do so, you need to grant public, unauthorized access to the respective endpoint.</p>
<div class="flex justify-around mt-4">
<span class="font-mono font-normal bg-gray-900 p-1 rounded whitespace-no-wrap">GET /api/compat/shields/v1</span>
<button type="submit" class="py-1 px-2 rounded bg-green-700 hover:bg-green-800 text-white text-xs" title="Make endpoint public to enable badges">
Status: protected
</button>
</div>
{{ end }}
</div>
</form>
</div>
</div> </div>
</main> </main>
<script type="text/javascript">
const baseUrl = location.href.substring(0, location.href.indexOf('/settings'))
document.querySelectorAll('.with-url-src').forEach(e => {
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-inner').forEach(e => {
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
e.classList.remove('hidden')
})
</script>
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }} {{ template "foot.tpl.html" . }}

View File

@ -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">&larr 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>

View File

@ -36,13 +36,16 @@
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1> <h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Your Coding Statistics 🤓</h1>
</div> </div>
<div class="text-white text-sm flex items-center justify-center mt-4"> <div class="text-white text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap">
<a href="summary?interval=today" class="m-1 border-b border-green-700">Today (live)</a> <a href="summary?interval=today" class="mx-2 my-1 border-b border-green-700">Today</a>
<a href="summary?interval=day" class="m-1 border-b border-green-700">Yesterday</a> <a href="summary?interval=day" class="mx-2 my-1 border-b border-green-700">Yesterday</a>
<a href="summary?interval=week" class="m-1 border-b border-green-700">This Week</a> <a href="summary?interval=week" class="mx-2 my-1 border-b border-green-700">This Week</a>
<a href="summary?interval=month" class="m-1 border-b border-green-700">This Month</a> <a href="summary?interval=month" class="mx-2 my-1 border-b border-green-700">This Month</a>
<a href="summary?interval=year" class="m-1 border-b border-green-700">This Year</a> <a href="summary?interval=year" class="mx-2 my-1 border-b border-green-700">This Year</a>
<a href="summary?interval=any" class="m-1 border-b border-green-700">All Time</a> <a href="summary?interval=7_days" class="mx-2 my-1 border-b border-green-700">Past 7 Days</a>
<a href="summary?interval=30_days" class="mx-2 my-1 border-b border-green-700">Past 30 Days</a>
<a href="summary?interval=12_months" class="mx-2 my-1 border-b border-green-700">Past 12 Months</a>
<a href="summary?interval=any" class="mx-2 my-1 border-b border-green-700">All Time</a>
</div> </div>
{{ template "alerts.tpl.html" . }} {{ template "alerts.tpl.html" . }}
@ -59,28 +62,68 @@
</div> </div>
<div class="flex flex-wrap justify-center"> <div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="projects-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="project-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Projects</span>
<span id="project-top-label" class="top-label"></span>
</div>
<canvas id="chart-projects"></canvas> <canvas id="chart-projects"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div> </div>
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="os-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="os-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Operating Systems</span>
<span id="os-top-label" class="top-label"></span>
</div>
<canvas id="chart-os"></canvas> <canvas id="chart-os"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div> </div>
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2 relative" id="language-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col relative" id="language-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Languages</span>
<span id="language-top-label" class="top-label"></span>
</div>
<canvas id="chart-language"></canvas> <canvas id="chart-language"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div> </div>
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 bg-white rounded shadow m-2" id="editor-container" style="height: 300px"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="editor-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Editors</span>
<span id="editor-top-label" class="top-label"></span>
</div>
<canvas id="chart-editor"></canvas> <canvas id="chart-editor"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div> </div>
</div> </div>
<div class="w-full lg:w-1/2 p-1"> <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"> <div class="p-4 pb-10 bg-white rounded shadow m-2 flex flex-col" id="machine-container" style="height: 300px">
<div class="self-center flex">
<span class="font-semibold mr-1">Machines</span>
<span id="machine-top-label" class="top-label"></span>
</div>
<canvas id="chart-machine"></canvas> <canvas id="chart-machine"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20"/>
<span class="text-sm mt-4">No data available ...</span>
</div>
</div> </div>
</div> </div>
</div> </div>