Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
9766d8e903 | |||
39c4777fc8 | |||
143c80b7b4 | |||
72e42a9c42 | |||
439a87dec9 | |||
e8067bb13e | |||
219e969957 | |||
e610bb3ee3 | |||
889edd7a33 | |||
4161623c24 | |||
67fe6eea56 | |||
095fef4868 | |||
a0e64ca955 | |||
903defca99 | |||
16b9aa2282 | |||
4a78f66778 | |||
f4328c452f | |||
e806e5455e | |||
97e1fb27eb | |||
ad8168801c | |||
35cdc7b485 | |||
664714de8f |
3
.gitignore
vendored
@ -6,5 +6,6 @@ wakapi
|
||||
build
|
||||
*.exe
|
||||
*.db
|
||||
config.yml
|
||||
config*.yml
|
||||
!config.default.yml
|
||||
config.ini
|
27
README.md
@ -1,6 +1,6 @@
|
||||
# 📈 wakapi
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@ -21,9 +21,10 @@
|
||||
|
||||

|
||||
|
||||
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
|
||||
## 📬 **User Survey**
|
||||
I'd love to get some community feedback from active Wakapi users. If you like, please participate in the recent [user survey](https://github.com/muety/wakapi/issues/82). Thanks a lot!
|
||||
|
||||
## 👀 Hosted Service
|
||||
## 👀 Demo
|
||||
🔥 **New:** Wakapi is available as a hosted service now. Check out **[wakapi.dev](https://wakapi.dev)**. Please use responsibly.
|
||||
|
||||
To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. However, we do not guarantee data persistence, so you might potentially lose your data if the service is taken down some day ❕
|
||||
@ -52,12 +53,17 @@ To use the hosted version set `api_url = https://wakapi.dev/api/heartbeat`. Howe
|
||||
**Note:** By default, the application is running in dev mode. However, it is recommended to set `ENV=production` for enhanced performance and security. To still be able to log in when using production mode, you either have to run Wakapi behind a reverse proxy, that enables for HTTPS encryption (see [best practices](#best-practices)) or set `security.insecure_cookies` to `true` in `config.yml`.
|
||||
|
||||
### Run with Docker
|
||||
```
|
||||
```bash
|
||||
docker run -d -p 3000:3000 --name wakapi n1try/wakapi
|
||||
```
|
||||
|
||||
By default, SQLite is used as a database. To run Wakapi in Docker with MySQL or Postgres, see [Dockerfile](https://github.com/muety/wakapi/blob/master/Dockerfile) and [config.default.yml](https://github.com/muety/wakapi/blob/master/config.default.yml) for further options.
|
||||
|
||||
### Running tests
|
||||
```bash
|
||||
CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
You can specify configuration options either via a config file (default: `config.yml`, customziable through the `-c` argument) or via environment variables. Here is an overview of all options.
|
||||
|
||||
@ -66,10 +72,14 @@ You can specify configuration options either via a config file (default: `config
|
||||
| `env` | `ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||
| `app.custom_languages` | - | - | Map from file endings to language names |
|
||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | Network address to listen on |
|
||||
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
|
||||
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
||||
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
||||
| `security.password_salt` | `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
|
||||
| `security.insecure_cookies` | `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
|
||||
| `security.cookie_max_age` | `WAKAPI_COOKIE_MAX_AGE ` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
|
||||
| `db.host` | `WAKAPI_DB_HOST` | - | Database host |
|
||||
| `db.port` | `WAKAPI_DB_PORT` | - | Database port |
|
||||
| `db.user` | `WAKAPI_DB_USER` | - | Database user |
|
||||
@ -91,6 +101,10 @@ api_key = the_api_key_printed_to_the_console_after_starting_the_server`
|
||||
|
||||
You can view your API Key after logging in to the web interface.
|
||||
|
||||
### Optional: Client-side proxy
|
||||
|
||||
See the [advanced setup instructions](docs/advanced_setup.md).
|
||||
|
||||
## 🔵 Customization
|
||||
|
||||
### Aliases
|
||||
@ -135,6 +149,9 @@ We recently introduced support for [Shields.io](https://shields.io) badges (see
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
|
||||
|
||||
## 🙏 Support
|
||||
If you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts the developers' motivation to keep improving Wakapi!
|
||||
|
||||
## ⚠️ Important Note
|
||||
**This is not an alternative to using WakaTime.** It is just a custom, non-commercial, self-hosted application to collect coding statistics using the already existing editor plugins provided by the WakaTime community. It was created for personal use only and with the purpose of keeping the sovereignity of your own data. However, if you like the official product, **please support the authors and buy an official WakaTime subscription!**
|
||||
|
||||
|
@ -1,7 +1,10 @@
|
||||
env: development
|
||||
|
||||
server:
|
||||
listen_ipv4: 127.0.0.1
|
||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||
listen_ipv6: ::1 # leave blank to disable ipv6
|
||||
tls_cert_path: # leave blank to not use https
|
||||
tls_key_path: # leave blank to not use https
|
||||
port: 3000
|
||||
base_path: /
|
||||
|
||||
@ -23,3 +26,4 @@ db:
|
||||
security:
|
||||
password_salt: # CHANGE !
|
||||
insecure_cookies: false
|
||||
cookie_max_age: 172800
|
@ -14,6 +14,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
@ -28,10 +29,8 @@ const (
|
||||
SQLDialectSqlite = "sqlite3"
|
||||
)
|
||||
|
||||
var (
|
||||
cfg *Config
|
||||
cFlag *string
|
||||
)
|
||||
var cfg *Config
|
||||
var cFlag = flag.String("config", defaultConfigPath, "config file location")
|
||||
|
||||
type appConfig struct {
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
@ -43,6 +42,7 @@ type securityConfig struct {
|
||||
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
|
||||
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
|
||||
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`
|
||||
CookieMaxAgeSec int `yaml:"cookie_max_age" default:"172800" env:"WAKAPI_COOKIE_MAX_AGE"`
|
||||
SecureCookie *securecookie.SecureCookie `yaml:"-"`
|
||||
}
|
||||
|
||||
@ -57,9 +57,12 @@ type dbConfig struct {
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
Port int `default:"3000" env:"WAKAPI_PORT"`
|
||||
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
|
||||
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
||||
Port int `default:"3000" env:"WAKAPI_PORT"`
|
||||
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
|
||||
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
|
||||
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
||||
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
|
||||
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
@ -71,15 +74,34 @@ type Config struct {
|
||||
Server serverConfig
|
||||
}
|
||||
|
||||
func init() {
|
||||
cFlag = flag.String("c", defaultConfigPath, "config file location")
|
||||
flag.Parse()
|
||||
func (c *Config) CreateCookie(name, value, path string) *http.Cookie {
|
||||
return c.createCookie(name, value, path, c.Security.CookieMaxAgeSec)
|
||||
}
|
||||
|
||||
func (c *Config) GetClearCookie(name, path string) *http.Cookie {
|
||||
return c.createCookie(name, "", path, -1)
|
||||
}
|
||||
|
||||
func (c *Config) createCookie(name, value, path string, maxAge int) *http.Cookie {
|
||||
return &http.Cookie{
|
||||
Name: name,
|
||||
Value: value,
|
||||
Path: path,
|
||||
MaxAge: maxAge,
|
||||
Secure: !c.Security.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Config) IsDev() bool {
|
||||
return IsDev(c.Env)
|
||||
}
|
||||
|
||||
func (c *Config) UseTLS() bool {
|
||||
return c.Server.TlsCertPath != "" && c.Server.TlsKeyPath != ""
|
||||
}
|
||||
|
||||
func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
switch dbDialect {
|
||||
default:
|
||||
@ -220,6 +242,8 @@ func Get() *Config {
|
||||
func Load() *Config {
|
||||
config := &Config{}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
maybeMigrateLegacyConfig()
|
||||
|
||||
if err := configor.New(&configor.Config{}).Load(config, mustReadConfigLocation()); err != nil {
|
||||
@ -243,6 +267,10 @@ func Load() *Config {
|
||||
}
|
||||
}
|
||||
|
||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
|
||||
log.Fatalln("either of listen_ipv4 or listen_ipv6 must be set")
|
||||
}
|
||||
|
||||
Set(config)
|
||||
return Get()
|
||||
}
|
||||
|
66
config/config_test.go
Normal file
@ -0,0 +1,66 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_IsDev(t *testing.T) {
|
||||
assert.True(t, IsDev("dev"))
|
||||
assert.True(t, IsDev("development"))
|
||||
assert.False(t, IsDev("prod"))
|
||||
assert.False(t, IsDev("production"))
|
||||
assert.False(t, IsDev("anything else"))
|
||||
}
|
||||
|
||||
func Test_mysqlConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Host: "test_host",
|
||||
Port: 9999,
|
||||
User: "test_user",
|
||||
Password: "test_password",
|
||||
Name: "test_name",
|
||||
Dialect: "mysql",
|
||||
MaxConn: 10,
|
||||
}
|
||||
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"%s:%s@tcp(%s:%d)/%s?charset=utf8&parseTime=true&loc=%s&sql_mode=ANSI_QUOTES",
|
||||
c.User,
|
||||
c.Password,
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.Name,
|
||||
"Local",
|
||||
), mysqlConnectionString(c))
|
||||
}
|
||||
|
||||
func Test_postgresConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Host: "test_host",
|
||||
Port: 9999,
|
||||
User: "test_user",
|
||||
Password: "test_password",
|
||||
Name: "test_name",
|
||||
Dialect: "postgres",
|
||||
MaxConn: 10,
|
||||
}
|
||||
|
||||
assert.Equal(t, fmt.Sprintf(
|
||||
"host=%s port=%d user=%s dbname=%s password=%s sslmode=disable",
|
||||
c.Host,
|
||||
c.Port,
|
||||
c.User,
|
||||
c.Name,
|
||||
c.Password,
|
||||
), postgresConnectionString(c))
|
||||
}
|
||||
|
||||
func Test_sqliteConnectionString(t *testing.T) {
|
||||
c := &dbConfig{
|
||||
Name: "test_name",
|
||||
Dialect: "sqlite3",
|
||||
}
|
||||
assert.Equal(t, c.Name, sqliteConnectionString(c))
|
||||
}
|
@ -2,6 +2,7 @@ package config
|
||||
|
||||
const (
|
||||
IndexTemplate = "index.tpl.html"
|
||||
LoginTemplate = "login.tpl.html"
|
||||
ImprintTemplate = "imprint.tpl.html"
|
||||
SignupTemplate = "signup.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
|
514
coverage/coverage.out
Normal file
@ -0,0 +1,514 @@
|
||||
mode: set
|
||||
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0
|
||||
github.com/muety/wakapi/models/language_mapping.go:15.51,17.2 1 0
|
||||
github.com/muety/wakapi/models/language_mapping.go:19.52,21.2 1 0
|
||||
github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
|
||||
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0
|
||||
github.com/muety/wakapi/models/filters.go:29.2,29.19 1 0
|
||||
github.com/muety/wakapi/models/filters.go:18.22,19.32 1 0
|
||||
github.com/muety/wakapi/models/filters.go:20.17,21.27 1 0
|
||||
github.com/muety/wakapi/models/filters.go:22.23,23.33 1 0
|
||||
github.com/muety/wakapi/models/filters.go:24.21,25.31 1 0
|
||||
github.com/muety/wakapi/models/filters.go:26.22,27.32 1 0
|
||||
github.com/muety/wakapi/models/filters.go:32.49,33.21 1 0
|
||||
github.com/muety/wakapi/models/filters.go:44.2,44.21 1 0
|
||||
github.com/muety/wakapi/models/filters.go:33.21,35.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:35.8,35.23 1 0
|
||||
github.com/muety/wakapi/models/filters.go:35.23,37.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:37.8,37.29 1 0
|
||||
github.com/muety/wakapi/models/filters.go:37.29,39.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:39.8,39.27 1 0
|
||||
github.com/muety/wakapi/models/filters.go:39.27,41.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:41.8,41.28 1 0
|
||||
github.com/muety/wakapi/models/filters.go:41.28,43.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:47.42,50.21 2 1
|
||||
github.com/muety/wakapi/models/filters.go:53.2,53.20 1 1
|
||||
github.com/muety/wakapi/models/filters.go:56.2,56.22 1 1
|
||||
github.com/muety/wakapi/models/filters.go:59.2,59.21 1 1
|
||||
github.com/muety/wakapi/models/filters.go:62.2,62.16 1 1
|
||||
github.com/muety/wakapi/models/filters.go:66.2,66.12 1 1
|
||||
github.com/muety/wakapi/models/filters.go:50.21,52.3 1 1
|
||||
github.com/muety/wakapi/models/filters.go:53.20,55.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:56.22,58.3 1 1
|
||||
github.com/muety/wakapi/models/filters.go:59.21,61.3 1 0
|
||||
github.com/muety/wakapi/models/filters.go:62.16,64.3 1 0
|
||||
github.com/muety/wakapi/models/heartbeat.go:26.34,28.2 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:30.65,31.28 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:34.2,35.45 2 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:38.2,39.44 2 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:42.2,42.42 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:31.28,33.3 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:35.45,37.3 1 0
|
||||
github.com/muety/wakapi/models/heartbeat.go:39.44,41.3 1 0
|
||||
github.com/muety/wakapi/models/heartbeat.go:45.50,46.11 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:59.2,59.15 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:63.2,63.12 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:47.22,48.18 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:49.21,50.17 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:51.23,52.19 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:53.17,54.26 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:55.22,56.18 1 1
|
||||
github.com/muety/wakapi/models/heartbeat.go:59.15,61.3 1 1
|
||||
github.com/muety/wakapi/models/heartbeats.go:7.31,9.2 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:11.41,13.2 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:15.36,17.2 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:19.43,22.2 2 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:24.41,26.18 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:29.2,29.16 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:26.18,28.3 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:32.40,34.18 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:37.2,37.24 1 0
|
||||
github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
|
||||
github.com/muety/wakapi/models/user.go:34.43,37.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:39.33,43.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:45.45,47.2 1 0
|
||||
github.com/muety/wakapi/models/user.go:49.45,51.2 1 0
|
||||
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0
|
||||
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0
|
||||
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0
|
||||
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0
|
||||
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0
|
||||
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0
|
||||
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0
|
||||
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0
|
||||
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0
|
||||
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0
|
||||
github.com/muety/wakapi/models/shared.go:74.51,77.2 2 0
|
||||
github.com/muety/wakapi/models/shared.go:79.37,82.2 2 0
|
||||
github.com/muety/wakapi/models/shared.go:84.35,86.2 1 0
|
||||
github.com/muety/wakapi/models/shared.go:88.34,90.2 1 0
|
||||
github.com/muety/wakapi/models/summary.go:29.27,33.2 1 0
|
||||
github.com/muety/wakapi/models/summary.go:83.29,85.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:87.37,94.2 6 1
|
||||
github.com/muety/wakapi/models/summary.go:96.35,98.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:100.57,108.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:121.33,126.26 4 1
|
||||
github.com/muety/wakapi/models/summary.go:133.2,133.37 1 1
|
||||
github.com/muety/wakapi/models/summary.go:137.2,140.33 2 1
|
||||
github.com/muety/wakapi/models/summary.go:126.26,127.30 1 1
|
||||
github.com/muety/wakapi/models/summary.go:127.30,129.4 1 1
|
||||
github.com/muety/wakapi/models/summary.go:133.37,135.3 1 0
|
||||
github.com/muety/wakapi/models/summary.go:140.33,146.3 1 1
|
||||
github.com/muety/wakapi/models/summary.go:149.45,154.30 3 1
|
||||
github.com/muety/wakapi/models/summary.go:163.2,163.30 1 1
|
||||
github.com/muety/wakapi/models/summary.go:154.30,155.47 1 1
|
||||
github.com/muety/wakapi/models/summary.go:155.47,156.32 1 1
|
||||
github.com/muety/wakapi/models/summary.go:159.4,159.9 1 1
|
||||
github.com/muety/wakapi/models/summary.go:156.32,158.5 1 1
|
||||
github.com/muety/wakapi/models/summary.go:166.73,168.55 2 1
|
||||
github.com/muety/wakapi/models/summary.go:173.2,173.16 1 1
|
||||
github.com/muety/wakapi/models/summary.go:168.55,169.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:169.31,171.4 1 1
|
||||
github.com/muety/wakapi/models/summary.go:176.88,178.55 2 1
|
||||
github.com/muety/wakapi/models/summary.go:186.2,186.16 1 1
|
||||
github.com/muety/wakapi/models/summary.go:178.55,179.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:179.31,180.23 1 1
|
||||
github.com/muety/wakapi/models/summary.go:183.4,183.46 1 1
|
||||
github.com/muety/wakapi/models/summary.go:180.23,181.13 1 1
|
||||
github.com/muety/wakapi/models/summary.go:189.79,190.33 1 1
|
||||
github.com/muety/wakapi/models/summary.go:193.2,193.16 1 1
|
||||
github.com/muety/wakapi/models/summary.go:190.33,192.3 1 1
|
||||
github.com/muety/wakapi/models/summary.go:196.71,197.63 1 1
|
||||
github.com/muety/wakapi/models/summary.go:237.2,243.10 6 1
|
||||
github.com/muety/wakapi/models/summary.go:197.63,200.45 2 1
|
||||
github.com/muety/wakapi/models/summary.go:209.3,209.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:216.3,216.31 1 1
|
||||
github.com/muety/wakapi/models/summary.go:233.3,233.16 1 1
|
||||
github.com/muety/wakapi/models/summary.go:200.45,201.32 1 1
|
||||
github.com/muety/wakapi/models/summary.go:206.4,206.14 1 1
|
||||
github.com/muety/wakapi/models/summary.go:201.32,202.24 1 1
|
||||
github.com/muety/wakapi/models/summary.go:202.24,204.6 1 1
|
||||
github.com/muety/wakapi/models/summary.go:209.31,211.60 1 1
|
||||
github.com/muety/wakapi/models/summary.go:211.60,213.5 1 1
|
||||
github.com/muety/wakapi/models/summary.go:216.31,218.60 1 1
|
||||
github.com/muety/wakapi/models/summary.go:218.60,219.55 1 1
|
||||
github.com/muety/wakapi/models/summary.go:219.55,221.6 1 1
|
||||
github.com/muety/wakapi/models/summary.go:221.11,229.6 1 1
|
||||
github.com/muety/wakapi/models/summary.go:246.33,248.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:250.43,252.2 1 1
|
||||
github.com/muety/wakapi/models/summary.go:254.38,256.2 1 1
|
||||
github.com/muety/wakapi/utils/summary.go:10.71,13.18 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:37.2,37.22 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:14.28,15.24 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:16.32,18.22 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:19.31,20.23 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:21.32,22.24 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:23.31,24.23 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:25.32,26.42 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:27.33,28.43 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:29.35,30.43 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:31.26,32.21 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:33.10,34.39 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:40.73,47.56 5 0
|
||||
github.com/muety/wakapi/utils/summary.go:61.2,68.8 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:47.56,49.3 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:49.8,51.17 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:55.3,56.17 2 0
|
||||
github.com/muety/wakapi/utils/summary.go:51.17,53.4 1 0
|
||||
github.com/muety/wakapi/utils/summary.go:56.17,58.4 1 0
|
||||
github.com/muety/wakapi/utils/template.go:8.41,10.16 2 0
|
||||
github.com/muety/wakapi/utils/template.go:13.2,13.23 1 0
|
||||
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:18.79,20.54 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:24.2,26.16 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:30.2,32.45 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:35.2,36.32 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:20.54,22.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:26.16,28.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:32.45,34.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:39.65,41.54 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:45.2,46.30 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:41.54,43.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:49.97,51.16 2 0
|
||||
github.com/muety/wakapi/utils/auth.go:55.2,55.104 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:59.2,59.19 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:51.16,53.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:55.104,57.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:62.30,64.2 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:66.56,70.2 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:73.53,75.2 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:77.55,80.16 3 0
|
||||
github.com/muety/wakapi/utils/auth.go:83.2,83.16 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:80.16,82.3 1 0
|
||||
github.com/muety/wakapi/utils/auth.go:86.43,91.2 4 0
|
||||
github.com/muety/wakapi/utils/color.go:8.93,10.41 2 0
|
||||
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
|
||||
github.com/muety/wakapi/utils/color.go:10.41,11.50 1 0
|
||||
github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
|
||||
github.com/muety/wakapi/utils/common.go:9.48,11.2 1 0
|
||||
github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0
|
||||
github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0
|
||||
github.com/muety/wakapi/utils/common.go:21.56,24.45 3 1
|
||||
github.com/muety/wakapi/utils/common.go:27.2,27.40 1 1
|
||||
github.com/muety/wakapi/utils/common.go:24.45,26.3 1 1
|
||||
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:12.43,14.2 1 0
|
||||
github.com/muety/wakapi/utils/date.go:16.30,20.2 3 0
|
||||
github.com/muety/wakapi/utils/date.go:22.31,25.2 2 0
|
||||
github.com/muety/wakapi/utils/date.go:27.30,30.2 2 0
|
||||
github.com/muety/wakapi/utils/date.go:32.67,35.33 2 0
|
||||
github.com/muety/wakapi/utils/date.go:44.2,44.18 1 0
|
||||
github.com/muety/wakapi/utils/date.go:35.33,37.19 2 0
|
||||
github.com/muety/wakapi/utils/date.go:40.3,41.10 2 0
|
||||
github.com/muety/wakapi/utils/date.go:37.19,39.4 1 0
|
||||
github.com/muety/wakapi/utils/date.go:47.50,53.2 5 0
|
||||
github.com/muety/wakapi/utils/date.go:56.79,59.36 3 0
|
||||
github.com/muety/wakapi/utils/date.go:63.2,63.21 1 0
|
||||
github.com/muety/wakapi/utils/date.go:67.2,67.21 1 0
|
||||
github.com/muety/wakapi/utils/date.go:71.2,71.13 1 0
|
||||
github.com/muety/wakapi/utils/date.go:59.36,62.3 2 0
|
||||
github.com/muety/wakapi/utils/date.go:63.21,66.3 2 0
|
||||
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
|
||||
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
|
||||
github.com/muety/wakapi/utils/http.go:12.58,14.3 1 0
|
||||
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
|
||||
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0
|
||||
github.com/muety/wakapi/utils/strings.go:18.2,18.19 1 0
|
||||
github.com/muety/wakapi/utils/strings.go:13.29,14.18 1 0
|
||||
github.com/muety/wakapi/utils/strings.go:14.18,16.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:77.70,79.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:81.65,83.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:85.82,95.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:97.31,99.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:101.32,103.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:105.74,106.19 1 0
|
||||
github.com/muety/wakapi/config/config.go:107.10,108.34 1 0
|
||||
github.com/muety/wakapi/config/config.go:108.34,117.4 8 0
|
||||
github.com/muety/wakapi/config/config.go:121.73,122.33 1 0
|
||||
github.com/muety/wakapi/config/config.go:122.33,130.17 5 0
|
||||
github.com/muety/wakapi/config/config.go:134.3,135.13 2 0
|
||||
github.com/muety/wakapi/config/config.go:130.17,132.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:139.50,140.19 1 0
|
||||
github.com/muety/wakapi/config/config.go:153.2,153.12 1 0
|
||||
github.com/muety/wakapi/config/config.go:141.23,145.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:146.26,149.5 1 0
|
||||
github.com/muety/wakapi/config/config.go:150.24,151.48 1 0
|
||||
github.com/muety/wakapi/config/config.go:156.53,166.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:168.56,176.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:178.54,180.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:182.29,184.2 1 1
|
||||
github.com/muety/wakapi/config/config.go:186.27,188.16 2 0
|
||||
github.com/muety/wakapi/config/config.go:191.2,194.16 3 0
|
||||
github.com/muety/wakapi/config/config.go:198.2,198.22 1 0
|
||||
github.com/muety/wakapi/config/config.go:188.16,190.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:194.16,196.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:201.45,211.16 4 0
|
||||
github.com/muety/wakapi/config/config.go:215.2,215.57 1 0
|
||||
github.com/muety/wakapi/config/config.go:219.2,219.30 1 0
|
||||
github.com/muety/wakapi/config/config.go:223.2,223.15 1 0
|
||||
github.com/muety/wakapi/config/config.go:211.16,213.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:215.57,217.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:219.30,221.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:226.38,227.43 1 0
|
||||
github.com/muety/wakapi/config/config.go:231.2,231.15 1 0
|
||||
github.com/muety/wakapi/config/config.go:227.43,229.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:234.26,236.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:238.20,240.2 1 0
|
||||
github.com/muety/wakapi/config/config.go:242.21,249.96 4 0
|
||||
github.com/muety/wakapi/config/config.go:253.2,260.52 4 0
|
||||
github.com/muety/wakapi/config/config.go:264.2,264.47 1 0
|
||||
github.com/muety/wakapi/config/config.go:270.2,270.70 1 0
|
||||
github.com/muety/wakapi/config/config.go:274.2,275.14 2 0
|
||||
github.com/muety/wakapi/config/config.go:249.96,251.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:260.52,262.3 1 0
|
||||
github.com/muety/wakapi/config/config.go:264.47,265.14 1 0
|
||||
github.com/muety/wakapi/config/config.go:265.14,267.4 1 0
|
||||
github.com/muety/wakapi/config/config.go:270.70,272.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:13.33,14.57 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:14.57,16.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:16.8,16.16 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:16.16,18.47 2 0
|
||||
github.com/muety/wakapi/config/legacy.go:21.3,21.128 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:18.47,20.4 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:25.48,26.54 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:31.2,31.18 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:26.54,28.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:28.8,28.32 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:28.32,30.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:34.34,37.16 2 0
|
||||
github.com/muety/wakapi/config/legacy.go:40.2,41.16 2 0
|
||||
github.com/muety/wakapi/config/legacy.go:45.2,57.16 11 0
|
||||
github.com/muety/wakapi/config/legacy.go:61.2,61.18 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:65.2,69.16 5 0
|
||||
github.com/muety/wakapi/config/legacy.go:73.2,75.23 3 0
|
||||
github.com/muety/wakapi/config/legacy.go:80.2,82.33 3 0
|
||||
github.com/muety/wakapi/config/legacy.go:87.2,114.16 3 0
|
||||
github.com/muety/wakapi/config/legacy.go:119.2,119.78 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:123.2,123.12 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:37.16,39.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:41.16,43.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:57.16,59.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:61.18,63.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:69.16,71.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:75.23,77.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:82.33,84.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:114.16,116.3 1 0
|
||||
github.com/muety/wakapi/config/legacy.go:119.78,121.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:27.116,34.2 1 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:36.71,37.71 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:37.71,39.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:42.107,43.37 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:50.2,53.16 3 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:57.2,57.16 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:67.2,70.29 3 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:43.37,44.58 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:44.58,47.4 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:53.16,55.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:57.16,58.44 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:64.3,64.9 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:58.44,60.4 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:60.9,63.4 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:73.92,75.16 2 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:79.2,82.9 4 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:90.2,90.18 1 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:75.16,77.3 1 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:82.9,84.17 2 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:84.17,86.4 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:87.8,89.3 1 1
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:93.92,95.16 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:99.2,101.8 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:105.2,106.16 2 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.88 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:114.2,114.18 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:95.16,97.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:101.8,103.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:106.16,108.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:110.88,112.3 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:118.127,119.32 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:127.2,127.65 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:119.32,120.58 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:125.3,125.15 1 0
|
||||
github.com/muety/wakapi/middlewares/authenticate.go:120.58,124.4 3 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:11.48,13.2 1 0
|
||||
github.com/muety/wakapi/middlewares/logging.go:15.66,17.2 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:17.118,23.2 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:25.86,27.2 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:29.96,30.53 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:34.2,35.16 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:38.2,39.22 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:30.53,32.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:35.16,37.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:42.92,45.16 3 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:49.2,49.33 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:52.2,52.22 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:45.16,47.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:49.33,51.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:55.109,57.16 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:61.2,62.20 2 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:57.16,59.3 1 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:65.82,69.2 3 0
|
||||
github.com/muety/wakapi/services/language_mapping.go:71.73,73.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1
|
||||
github.com/muety/wakapi/services/summary.go:39.120,42.52 2 1
|
||||
github.com/muety/wakapi/services/summary.go:47.2,47.44 1 1
|
||||
github.com/muety/wakapi/services/summary.go:53.2,53.66 1 1
|
||||
github.com/muety/wakapi/services/summary.go:58.2,59.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:64.2,66.30 3 1
|
||||
github.com/muety/wakapi/services/summary.go:42.52,44.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:47.44,50.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:53.66,55.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:59.16,61.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:69.101,72.52 2 1
|
||||
github.com/muety/wakapi/services/summary.go:77.2,78.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:83.2,84.44 2 1
|
||||
github.com/muety/wakapi/services/summary.go:93.2,94.16 2 1
|
||||
github.com/muety/wakapi/services/summary.go:99.2,100.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:72.52,74.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:78.16,80.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:84.44,85.78 1 1
|
||||
github.com/muety/wakapi/services/summary.go:85.78,87.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:87.9,89.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:94.16,96.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:103.102,106.89 2 1
|
||||
github.com/muety/wakapi/services/summary.go:112.2,116.26 4 1
|
||||
github.com/muety/wakapi/services/summary.go:121.2,127.34 6 1
|
||||
github.com/muety/wakapi/services/summary.go:143.2,143.26 1 1
|
||||
github.com/muety/wakapi/services/summary.go:148.2,161.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:106.89,108.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:108.8,110.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:116.26,118.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:127.34,129.20 2 1
|
||||
github.com/muety/wakapi/services/summary.go:130.30,131.29 1 1
|
||||
github.com/muety/wakapi/services/summary.go:132.31,133.30 1 1
|
||||
github.com/muety/wakapi/services/summary.go:134.29,135.28 1 1
|
||||
github.com/muety/wakapi/services/summary.go:136.25,137.24 1 1
|
||||
github.com/muety/wakapi/services/summary.go:138.30,139.29 1 1
|
||||
github.com/muety/wakapi/services/summary.go:143.26,146.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:166.76,168.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:170.62,172.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:174.66,176.2 1 0
|
||||
github.com/muety/wakapi/services/summary.go:180.127,183.31 2 1
|
||||
github.com/muety/wakapi/services/summary.go:206.2,207.30 2 1
|
||||
github.com/muety/wakapi/services/summary.go:215.2,215.40 1 1
|
||||
github.com/muety/wakapi/services/summary.go:219.2,219.67 1 1
|
||||
github.com/muety/wakapi/services/summary.go:183.31,186.35 2 1
|
||||
github.com/muety/wakapi/services/summary.go:190.3,190.13 1 1
|
||||
github.com/muety/wakapi/services/summary.go:194.3,199.27 2 1
|
||||
github.com/muety/wakapi/services/summary.go:203.3,203.26 1 1
|
||||
github.com/muety/wakapi/services/summary.go:186.35,188.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:190.13,191.12 1 1
|
||||
github.com/muety/wakapi/services/summary.go:199.27,202.4 2 1
|
||||
github.com/muety/wakapi/services/summary.go:207.30,213.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:215.40,217.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:222.97,223.24 1 1
|
||||
github.com/muety/wakapi/services/summary.go:227.2,239.30 4 1
|
||||
github.com/muety/wakapi/services/summary.go:259.2,262.26 3 1
|
||||
github.com/muety/wakapi/services/summary.go:223.24,225.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:239.30,240.38 1 1
|
||||
github.com/muety/wakapi/services/summary.go:244.3,244.37 1 1
|
||||
github.com/muety/wakapi/services/summary.go:248.3,248.34 1 1
|
||||
github.com/muety/wakapi/services/summary.go:252.3,256.83 5 1
|
||||
github.com/muety/wakapi/services/summary.go:240.38,242.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:244.37,246.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:248.34,250.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:265.127,269.32 2 1
|
||||
github.com/muety/wakapi/services/summary.go:273.2,273.27 1 1
|
||||
github.com/muety/wakapi/services/summary.go:281.2,283.26 3 1
|
||||
github.com/muety/wakapi/services/summary.go:288.2,288.43 1 1
|
||||
github.com/muety/wakapi/services/summary.go:292.2,292.17 1 1
|
||||
github.com/muety/wakapi/services/summary.go:269.32,271.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:273.27,274.37 1 1
|
||||
github.com/muety/wakapi/services/summary.go:274.37,276.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:276.9,278.4 1 1
|
||||
github.com/muety/wakapi/services/summary.go:283.26,286.3 2 1
|
||||
github.com/muety/wakapi/services/summary.go:288.43,290.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:295.116,296.25 1 1
|
||||
github.com/muety/wakapi/services/summary.go:300.2,303.44 2 1
|
||||
github.com/muety/wakapi/services/summary.go:308.2,308.40 1 1
|
||||
github.com/muety/wakapi/services/summary.go:324.2,324.54 1 1
|
||||
github.com/muety/wakapi/services/summary.go:328.2,328.18 1 1
|
||||
github.com/muety/wakapi/services/summary.go:296.25,298.3 1 0
|
||||
github.com/muety/wakapi/services/summary.go:303.44,305.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:308.40,310.19 2 1
|
||||
github.com/muety/wakapi/services/summary.go:315.3,318.22 3 0
|
||||
github.com/muety/wakapi/services/summary.go:310.19,311.12 1 1
|
||||
github.com/muety/wakapi/services/summary.go:318.22,320.4 1 0
|
||||
github.com/muety/wakapi/services/summary.go:324.54,326.3 1 1
|
||||
github.com/muety/wakapi/services/summary.go:331.59,333.25 2 1
|
||||
github.com/muety/wakapi/services/summary.go:336.2,336.32 1 1
|
||||
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
|
||||
github.com/muety/wakapi/services/user.go:16.73,21.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:23.74,25.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:27.72,29.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:31.58,33.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:35.88,42.93 2 0
|
||||
github.com/muety/wakapi/services/user.go:48.2,48.38 1 0
|
||||
github.com/muety/wakapi/services/user.go:42.93,44.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:44.8,46.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:51.73,53.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:55.78,58.2 2 0
|
||||
github.com/muety/wakapi/services/user.go:60.79,62.2 1 0
|
||||
github.com/muety/wakapi/services/user.go:64.106,66.96 2 0
|
||||
github.com/muety/wakapi/services/user.go:71.2,71.68 1 0
|
||||
github.com/muety/wakapi/services/user.go:66.96,68.3 1 0
|
||||
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:40.43,42.37 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:46.2,48.19 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:42.37,44.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:51.67,55.40 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:59.2,59.50 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:64.2,64.60 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:70.2,70.35 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:55.40,57.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:59.50,61.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:64.60,68.3 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:73.109,74.24 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:74.24,75.111 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:75.111,77.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:77.9,80.4 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:84.80,85.33 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:85.33,86.60 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:86.60,88.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:92.100,96.59 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:111.2,112.16 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:118.2,119.16 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:125.2,126.44 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:131.2,131.41 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:145.2,145.12 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:96.59,99.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:99.8,99.47 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:99.47,101.30 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:101.30,102.43 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:102.43,104.5 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:106.8,108.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:112.16,115.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:119.16,122.3 2 0
|
||||
github.com/muety/wakapi/services/aggregation.go:126.44,128.3 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:131.41,132.21 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:132.21,136.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:136.9,136.62 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:136.62,140.4 1 0
|
||||
github.com/muety/wakapi/services/aggregation.go:148.83,163.41 5 0
|
||||
github.com/muety/wakapi/services/aggregation.go:163.41,173.3 3 0
|
||||
github.com/muety/wakapi/services/aggregation.go:176.34,179.2 2 0
|
||||
github.com/muety/wakapi/services/alias.go:16.77,21.2 1 1
|
||||
github.com/muety/wakapi/services/alias.go:25.63,27.16 2 1
|
||||
github.com/muety/wakapi/services/alias.go:30.2,30.12 1 1
|
||||
github.com/muety/wakapi/services/alias.go:27.16,29.3 1 1
|
||||
github.com/muety/wakapi/services/alias.go:33.108,34.32 1 1
|
||||
github.com/muety/wakapi/services/alias.go:40.2,41.46 2 1
|
||||
github.com/muety/wakapi/services/alias.go:46.2,46.19 1 1
|
||||
github.com/muety/wakapi/services/alias.go:34.32,35.53 1 1
|
||||
github.com/muety/wakapi/services/alias.go:35.53,37.4 1 1
|
||||
github.com/muety/wakapi/services/alias.go:41.46,42.48 1 1
|
||||
github.com/muety/wakapi/services/alias.go:42.48,44.4 1 1
|
||||
github.com/muety/wakapi/services/alias.go:49.60,50.43 1 1
|
||||
github.com/muety/wakapi/services/alias.go:53.2,53.14 1 1
|
||||
github.com/muety/wakapi/services/alias.go:50.43,52.3 1 1
|
||||
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:25.80,27.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:29.111,31.16 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:34.2,34.43 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:31.16,33.3 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:37.78,39.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:41.62,43.2 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:45.116,47.16 2 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:51.2,51.28 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:55.2,55.24 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
|
||||
github.com/muety/wakapi/services/heartbeat.go:51.28,53.3 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:25.72,27.2 1 0
|
||||
github.com/muety/wakapi/services/key_value.go:29.60,31.2 1 0
|
29
docs/advanced_setup.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Advanced Setup
|
||||
## Optional: Client-side proxy
|
||||
Most Wakatime plugins work in a way that, for every heartbeat to send, the plugin calls your local [wakatime-cli](https://github.com/wakatime/wakatime) (a small Python program that is automatically installed when installing a Wakatime plugin) with a few command-line arguments, which is then run as a new process. Inside that process, a heartbeat request is forged and sent to the backend API – Wakapi in this case.
|
||||
|
||||
While this is convenient for plugin developers, as they do not have to deal with sending HTTP requests, etc., it comes with a minor drawback. Because the CLI process shuts down after each request, its TCP connection is closed as well. Accordingly, **TCP connections cannot be re-used** and every single heartbeat request is inevitably preceded by the `SYN` + `SYN ACK` + `ACK` sequence for establishing a new TCP connection as well as a handshake for establishing a new TLS session.
|
||||
|
||||
While this certainly does not hurt, it is still a bit of overhead. You can avoid that by setting up a local reverse proxy on your machine, that keeps running as a daemon and can therefore keep a continuous connection.
|
||||
|
||||
In this example, [Caddy](https://caddyserver.com) is used as an easy-to-set-up webserver / reverse proxy.
|
||||
|
||||
1. [Install Caddy](https://caddyserver.com/)
|
||||
* When installing manually, don't forget to set up a systemd service to start Caddy on system startup
|
||||
1. Create a Caddyfile
|
||||
```
|
||||
# /etc/caddy/Caddyfile
|
||||
|
||||
http://localhost:8070 {
|
||||
reverse_proxy * {
|
||||
to https://wakapi.dev # <-- substitute your own Wakapi host here
|
||||
header_up Host {http.reverse_proxy.upstream.host}
|
||||
header_down -Server
|
||||
}
|
||||
}
|
||||
```
|
||||
1. Restart Caddy
|
||||
1. Verify that you can access [`http://localhost:8070/api/health`](http://localhost:8070/api/health)
|
||||
1. Update `~/.wakatime.cfg`
|
||||
* Set `api_url = http://localhost:8070/api/heartbeat`
|
||||
1. Done
|
3
go.mod
@ -3,11 +3,11 @@ module github.com/muety/wakapi
|
||||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/go-co-op/gocron v0.3.3
|
||||
github.com/gorilla/handlers v1.4.2
|
||||
github.com/gorilla/mux v1.7.3
|
||||
github.com/gorilla/schema v1.1.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4
|
||||
github.com/jinzhu/configor v1.2.0
|
||||
github.com/joho/godotenv v1.3.0
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
@ -16,6 +16,7 @@ require (
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/rubenv/sql-migrate v0.0.0-20200402132117-435005d389bc
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f // indirect
|
||||
gopkg.in/ini.v1 v1.50.0
|
||||
|
10
go.sum
@ -64,6 +64,8 @@ github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVB
|
||||
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/go-co-op/gocron v0.3.3 h1:QnarcMZWWKrEP25uCbtDiLsnnGw+PhCjL3wNITdWJOs=
|
||||
github.com/go-co-op/gocron v0.3.3/go.mod h1:Y9PWlYqDChf2Nbgg7kfS+ZsXHDTZbMZYPEQ0MILqH+M=
|
||||
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
|
||||
@ -204,8 +206,6 @@ github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0f
|
||||
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.1/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.2/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4 h1:UbQcOUL8J8EpnhYmLa2v6y5PSOPEdRRSVQxh7imPjHg=
|
||||
github.com/jasonlvhit/gocron v0.0.0-20191106203602-f82992d443f4/go.mod h1:1nXLkt6gXojCECs34KL3+LlZ3gTpZlkPUA8ejW3WeP0=
|
||||
github.com/jinzhu/configor v1.2.0 h1:u78Jsrxw2+3sGbGMgpY64ObKU4xWCNmNRJIjGVqxYQA=
|
||||
github.com/jinzhu/configor v1.2.0/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
@ -382,12 +382,15 @@ github.com/streadway/amqp v0.0.0-20190827072141-edfb9018d271/go.mod h1:AZpEONHx3
|
||||
github.com/streadway/handy v0.0.0-20190108123426-d5acb3125c2a/go.mod h1:qNTQ5P5JnDBl6z3cMAg/SywNDC5ABu5ApDIw6lUbRmI=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20170815181823-89b8d40f7ca8/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijbERA=
|
||||
@ -452,6 +455,7 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR
|
||||
golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@ -554,6 +558,8 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.0.3 h1:+JKBYPfn1tygR1/of/Fh2T8iwuVwzt+PEJmKaXzMQXg=
|
||||
gorm.io/driver/mysql v1.0.3/go.mod h1:twGxftLBlFgNVNakL7F+P/x9oYqoymG3YYT8cAfI9oI=
|
||||
gorm.io/driver/postgres v1.0.5 h1:raX6ezL/ciUmaYTvOq48jq1GE95aMC0CmxQYbxQ4Ufw=
|
||||
|
113
main.go
@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/handlers"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/migrations/common"
|
||||
@ -28,22 +29,22 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
aliasRepository *repositories.AliasRepository
|
||||
heartbeatRepository *repositories.HeartbeatRepository
|
||||
userRepository *repositories.UserRepository
|
||||
languageMappingRepository *repositories.LanguageMappingRepository
|
||||
summaryRepository *repositories.SummaryRepository
|
||||
keyValueRepository *repositories.KeyValueRepository
|
||||
aliasRepository repositories.IAliasRepository
|
||||
heartbeatRepository repositories.IHeartbeatRepository
|
||||
userRepository repositories.IUserRepository
|
||||
languageMappingRepository repositories.ILanguageMappingRepository
|
||||
summaryRepository repositories.ISummaryRepository
|
||||
keyValueRepository repositories.IKeyValueRepository
|
||||
)
|
||||
|
||||
var (
|
||||
aliasService *services.AliasService
|
||||
heartbeatService *services.HeartbeatService
|
||||
userService *services.UserService
|
||||
languageMappingService *services.LanguageMappingService
|
||||
summaryService *services.SummaryService
|
||||
aggregationService *services.AggregationService
|
||||
keyValueService *services.KeyValueService
|
||||
aliasService services.IAliasService
|
||||
heartbeatService services.IHeartbeatService
|
||||
userService services.IUserService
|
||||
languageMappingService services.ILanguageMappingService
|
||||
summaryService services.ISummaryService
|
||||
aggregationService services.IAggregationService
|
||||
keyValueService services.IKeyValueService
|
||||
)
|
||||
|
||||
// TODO: Refactor entire project to be structured after business domains
|
||||
@ -102,12 +103,15 @@ func main() {
|
||||
|
||||
// TODO: move endpoint registration to the respective routes files
|
||||
|
||||
routes.Init()
|
||||
|
||||
// Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService)
|
||||
healthHandler := routes.NewHealthHandler(db)
|
||||
heartbeatHandler := routes.NewHeartbeatHandler(heartbeatService, languageMappingService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, summaryService, aggregationService, languageMappingService)
|
||||
homeHandler := routes.NewHomeHandler(userService)
|
||||
homeHandler := routes.NewHomeHandler()
|
||||
loginHandler := routes.NewLoginHandler(userService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(summaryService)
|
||||
wakatimeV1SummariesHandler := wtV1Routes.NewSummariesHandler(summaryService)
|
||||
@ -140,10 +144,11 @@ func main() {
|
||||
|
||||
// Public Routes
|
||||
publicRouter.Path("/").Methods(http.MethodGet).HandlerFunc(homeHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(homeHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(homeHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(homeHandler.PostSignup)
|
||||
publicRouter.Path("/login").Methods(http.MethodGet).HandlerFunc(loginHandler.GetIndex)
|
||||
publicRouter.Path("/login").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogin)
|
||||
publicRouter.Path("/logout").Methods(http.MethodPost).HandlerFunc(loginHandler.PostLogout)
|
||||
publicRouter.Path("/signup").Methods(http.MethodGet).HandlerFunc(loginHandler.GetSignup)
|
||||
publicRouter.Path("/signup").Methods(http.MethodPost).HandlerFunc(loginHandler.PostSignup)
|
||||
publicRouter.Path("/imprint").Methods(http.MethodGet).HandlerFunc(imprintHandler.GetImprint)
|
||||
|
||||
// Summary Routes
|
||||
@ -174,15 +179,71 @@ func main() {
|
||||
router.PathPrefix("/assets").Handler(http.FileServer(http.Dir("./static")))
|
||||
|
||||
// Listen HTTP
|
||||
portString := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
|
||||
s := &http.Server{
|
||||
Handler: router,
|
||||
Addr: portString,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
listen(router)
|
||||
}
|
||||
|
||||
func listen(handler http.Handler) {
|
||||
var s4, s6 *http.Server
|
||||
|
||||
// IPv4
|
||||
if config.Server.ListenIpV4 != "" {
|
||||
bindString4 := config.Server.ListenIpV4 + ":" + strconv.Itoa(config.Server.Port)
|
||||
s4 = &http.Server{
|
||||
Handler: handler,
|
||||
Addr: bindString4,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
log.Printf("Listening on %+s\n", portString)
|
||||
s.ListenAndServe()
|
||||
|
||||
// IPv6
|
||||
if config.Server.ListenIpV6 != "" {
|
||||
bindString6 := "[" + config.Server.ListenIpV6 + "]:" + strconv.Itoa(config.Server.Port)
|
||||
s6 = &http.Server{
|
||||
Handler: handler,
|
||||
Addr: bindString6,
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
if config.UseTLS() {
|
||||
if s4 != nil {
|
||||
fmt.Printf("Listening for HTTPS on %s.\n", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
fmt.Printf("Listening for HTTPS on %s.\n", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServeTLS(config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
} else {
|
||||
if s4 != nil {
|
||||
fmt.Printf("Listening for HTTP on %s.\n", s4.Addr)
|
||||
go func() {
|
||||
if err := s4.ListenAndServe(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
if s6 != nil {
|
||||
fmt.Printf("Listening for HTTP on %s.\n", s6.Addr)
|
||||
go func() {
|
||||
if err := s6.ListenAndServe(); err != nil {
|
||||
log.Fatalln(err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
<-make(chan interface{}, 1)
|
||||
}
|
||||
|
||||
func runDatabaseMigrations() {
|
||||
|
@ -19,12 +19,12 @@ import (
|
||||
|
||||
type AuthenticateMiddleware struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
cache *cache.Cache
|
||||
userSrvc services.IUserService
|
||||
whitelistPaths []string
|
||||
}
|
||||
|
||||
func NewAuthenticateMiddleware(userService *services.UserService, whitelistPaths []string) *AuthenticateMiddleware {
|
||||
func NewAuthenticateMiddleware(userService services.IUserService, whitelistPaths []string) *AuthenticateMiddleware {
|
||||
return &AuthenticateMiddleware{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
@ -58,7 +58,7 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
|
||||
if strings.HasPrefix(r.URL.Path, "/api") {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
} else {
|
||||
utils.ClearCookie(w, models.AuthCookieKey, !m.config.Security.InsecureCookies)
|
||||
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
return
|
||||
@ -107,7 +107,7 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, m.userSrvc) {
|
||||
if !CheckAndMigratePassword(user, login, m.config.Security.PasswordSalt, &m.userSrvc) {
|
||||
return nil, errors.New("invalid password")
|
||||
}
|
||||
|
||||
@ -115,11 +115,11 @@ func (m *AuthenticateMiddleware) tryGetUserByCookie(r *http.Request) (*models.Us
|
||||
}
|
||||
|
||||
// migrate old md5-hashed passwords to new salted bcrypt hashes for backwards compatibility
|
||||
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.UserService) bool {
|
||||
func CheckAndMigratePassword(user *models.User, login *models.Login, salt string, userServiceRef *services.IUserService) bool {
|
||||
if utils.IsMd5(user.Password) {
|
||||
if utils.CompareMd5(user.Password, login.Password, "") {
|
||||
log.Printf("migrating old md5 password to new bcrypt format for user '%s'", user.ID)
|
||||
userServiceRef.MigrateMd5Password(user, login)
|
||||
(*userServiceRef).MigrateMd5Password(user, login)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
81
middlewares/authenticate_test.go
Normal file
@ -0,0 +1,81 @@
|
||||
package middlewares
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_Success(t *testing.T) {
|
||||
testApiKey := "z5uig69cn9ut93n"
|
||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||
testUser := &models.User{ApiKey: testApiKey}
|
||||
|
||||
mockRequest := &http.Request{
|
||||
Header: http.Header{
|
||||
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
|
||||
},
|
||||
}
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
||||
|
||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, testUser, result)
|
||||
}
|
||||
|
||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_GetFromCache(t *testing.T) {
|
||||
testApiKey := "z5uig69cn9ut93n"
|
||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||
testUser := &models.User{ApiKey: testApiKey}
|
||||
|
||||
mockRequest := &http.Request{
|
||||
Header: http.Header{
|
||||
"Authorization": []string{fmt.Sprintf("Basic %s", testToken)},
|
||||
},
|
||||
}
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
userServiceMock.On("GetUserByKey", testApiKey).Return(testUser, nil)
|
||||
|
||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
||||
sut.cache.SetDefault(testApiKey, testUser)
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, testUser, result)
|
||||
userServiceMock.AssertNotCalled(t, "GetUserByKey", mock.Anything)
|
||||
}
|
||||
|
||||
func TestAuthenticateMiddleware_tryGetUserByApiKey_InvalidHeader(t *testing.T) {
|
||||
testApiKey := "z5uig69cn9ut93n"
|
||||
testToken := base64.StdEncoding.EncodeToString([]byte(testApiKey))
|
||||
|
||||
mockRequest := &http.Request{
|
||||
Header: http.Header{
|
||||
// 'Basic' prefix missing here
|
||||
"Authorization": []string{fmt.Sprintf("%s", testToken)},
|
||||
},
|
||||
}
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
|
||||
sut := NewAuthenticateMiddleware(userServiceMock, []string{})
|
||||
|
||||
result, err := sut.tryGetUserByApiKey(mockRequest)
|
||||
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
// TODO: somehow test cookie auth function
|
@ -22,7 +22,7 @@ func init() {
|
||||
|
||||
func RunCustomPostMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range customPostMigrations {
|
||||
log.Printf("running migration '%s'\n", m.name)
|
||||
log.Printf("potentially running migration '%s'\n", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
log.Fatalf("migration '%s' failed – %v\n", m.name, err)
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ func init() {
|
||||
|
||||
func RunCustomPreMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range customPreMigrations {
|
||||
log.Printf("running migration '%s'\n", m.name)
|
||||
log.Printf("potentially running migration '%s'\n", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
log.Fatalf("migration '%s' failed – %v\n", m.name, err)
|
||||
}
|
||||
|
15
mocks/alias_repository.go
Normal file
@ -0,0 +1,15 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type AliasRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AliasRepositoryMock) GetByUser(s string) ([]*models.Alias, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.Alias), args.Error(1)
|
||||
}
|
24
mocks/alias_service.go
Normal file
@ -0,0 +1,24 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type AliasServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) LoadUserAliases(s string) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) GetAliasOrDefault(s string, u uint8, s2 string) (string, error) {
|
||||
args := m.Called(s, u, s2)
|
||||
return args.String(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *AliasServiceMock) IsInitialized(s string) bool {
|
||||
args := m.Called(s)
|
||||
return args.Bool(0)
|
||||
}
|
31
mocks/heartbeat_service.go
Normal file
@ -0,0 +1,31 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HeartbeatServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
args := m.Called(heartbeats)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
args := m.Called(time, time2, user)
|
||||
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
|
||||
args := m.Called(time)
|
||||
return args.Error(0)
|
||||
}
|
31
mocks/summary_repository.go
Normal file
@ -0,0 +1,31 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"time"
|
||||
)
|
||||
|
||||
type SummaryRepositoryMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) Insert(summary *models.Summary) error {
|
||||
args := m.Called(summary)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetByUserWithin(user *models.User, time time.Time, time2 time.Time) ([]*models.Summary, error) {
|
||||
args := m.Called(user, time, time2)
|
||||
return args.Get(0).([]*models.Summary), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) GetLastByUser() ([]*models.TimeByUser, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *SummaryRepositoryMock) DeleteByUser(s string) error {
|
||||
args := m.Called(s)
|
||||
return args.Error(0)
|
||||
}
|
50
mocks/user_service.go
Normal file
@ -0,0 +1,50 @@
|
||||
package mocks
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type UserServiceMock struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserById(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetUserByKey(s string) (*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetAll() ([]*models.User, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) CreateOrGet(signup *models.Signup) (*models.User, bool, error) {
|
||||
args := m.Called(signup)
|
||||
return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) Update(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) ResetApiKey(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) ToggleBadges(user *models.User) (*models.User, error) {
|
||||
args := m.Called(user)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) MigrateMd5Password(user *models.User, login *models.Login) (*models.User, error) {
|
||||
args := m.Called(user, login)
|
||||
return args.Get(0).(*models.User), args.Error(1)
|
||||
}
|
@ -18,13 +18,13 @@ func NewFiltersWith(entity uint8, key string) *Filters {
|
||||
case SummaryProject:
|
||||
return &Filters{Project: key}
|
||||
case SummaryOS:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{OS: key}
|
||||
case SummaryLanguage:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Language: key}
|
||||
case SummaryEditor:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Editor: key}
|
||||
case SummaryMachine:
|
||||
return &Filters{Project: key}
|
||||
return &Filters{Machine: key}
|
||||
}
|
||||
return &Filters{}
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ type Heartbeat struct {
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Valid() bool {
|
||||
return h.User != nil && h.UserID != "" && h.Time != CustomTime(time.Time{})
|
||||
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
|
53
models/heartbeat_test.go
Normal file
@ -0,0 +1,53 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestHeartbeat_Valid_Success(t *testing.T) {
|
||||
sut := &Heartbeat{
|
||||
User: &User{
|
||||
ID: "johndoe@example.org",
|
||||
},
|
||||
UserID: "johndoe@example.org",
|
||||
Time: CustomTime(time.Now()),
|
||||
}
|
||||
assert.True(t, sut.Valid())
|
||||
}
|
||||
|
||||
func TestHeartbeat_Valid_MissingUser(t *testing.T) {
|
||||
sut := &Heartbeat{
|
||||
Time: CustomTime(time.Now()),
|
||||
}
|
||||
assert.False(t, sut.Valid())
|
||||
}
|
||||
|
||||
func TestHeartbeat_Augment(t *testing.T) {
|
||||
testMappings := map[string]string{
|
||||
"py": "Python3",
|
||||
}
|
||||
|
||||
sut := &Heartbeat{
|
||||
Entity: "~/dev/file.py",
|
||||
Language: "Python",
|
||||
}
|
||||
|
||||
sut.Augment(testMappings)
|
||||
|
||||
assert.Equal(t, "Python3", sut.Language)
|
||||
}
|
||||
|
||||
func TestHeartbeat_GetKey(t *testing.T) {
|
||||
sut := &Heartbeat{
|
||||
Project: "wakapi",
|
||||
}
|
||||
|
||||
assert.Equal(t, "wakapi", sut.GetKey(SummaryProject))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryOS))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryMachine))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryLanguage))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(SummaryEditor))
|
||||
assert.Equal(t, UnknownSummaryKey, sut.GetKey(255))
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -34,18 +35,20 @@ func Intervals() []string {
|
||||
const UnknownSummaryKey = "unknown"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
Projects []*SummaryItem `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Languages []*SummaryItem `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Editors []*SummaryItem `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
OperatingSystems []*SummaryItem `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Machines []*SummaryItem `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
ToTime CustomTime `json:"to" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user"`
|
||||
Projects SummaryItems `json:"projects" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Languages SummaryItems `json:"languages" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type SummaryItems []*SummaryItem
|
||||
|
||||
type SummaryItem struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
@ -81,12 +84,21 @@ func SummaryTypes() []uint8 {
|
||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||
}
|
||||
|
||||
func (s *Summary) Sorted() *Summary {
|
||||
sort.Sort(sort.Reverse(s.Projects))
|
||||
sort.Sort(sort.Reverse(s.Machines))
|
||||
sort.Sort(sort.Reverse(s.OperatingSystems))
|
||||
sort.Sort(sort.Reverse(s.Languages))
|
||||
sort.Sort(sort.Reverse(s.Editors))
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Summary) Types() []uint8 {
|
||||
return SummaryTypes()
|
||||
}
|
||||
|
||||
func (s *Summary) MappedItems() map[uint8]*[]*SummaryItem {
|
||||
return map[uint8]*[]*SummaryItem{
|
||||
func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
||||
return map[uint8]*SummaryItems{
|
||||
SummaryProject: &s.Projects,
|
||||
SummaryLanguage: &s.Languages,
|
||||
SummaryEditor: &s.Editors,
|
||||
@ -230,3 +242,15 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
func (s SummaryItems) Len() int {
|
||||
return len(s)
|
||||
}
|
||||
|
||||
func (s SummaryItems) Less(i, j int) bool {
|
||||
return s[i].Total < s[j].Total
|
||||
}
|
||||
|
||||
func (s SummaryItems) Swap(i, j int) {
|
||||
s[i], s[j] = s[j], s[i]
|
||||
}
|
||||
|
195
models/summary_test.go
Normal file
@ -0,0 +1,195 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSummary_FillUnknown(t *testing.T) {
|
||||
testDuration := 10 * time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
|
||||
Total: testDuration / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sut.FillUnknown()
|
||||
|
||||
itemLists := [][]*SummaryItem{
|
||||
sut.Machines,
|
||||
sut.OperatingSystems,
|
||||
sut.Languages,
|
||||
sut.Editors,
|
||||
}
|
||||
for _, l := range itemLists {
|
||||
assert.Len(t, l, 1)
|
||||
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
||||
assert.Equal(t, testDuration, l[0].Total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummary_TotalTimeBy(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
|
||||
Total: testDuration1 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration2 / time.Second,
|
||||
},
|
||||
},
|
||||
Languages: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryLanguage,
|
||||
Key: "Go",
|
||||
Total: testDuration3 / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeBy(SummaryProject))
|
||||
assert.Equal(t, testDuration3, sut.TotalTimeBy(SummaryLanguage))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryMachine))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryOS))
|
||||
}
|
||||
|
||||
func TestSummary_TotalTimeByFilters(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
|
||||
Total: testDuration1 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration2 / time.Second,
|
||||
},
|
||||
},
|
||||
Languages: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryLanguage,
|
||||
Key: "Go",
|
||||
Total: testDuration3 / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
filters1 := &Filters{Project: "wakapi"}
|
||||
filters2 := &Filters{Project: "wakapi", Language: "Go"} // filters have OR logic
|
||||
filters3 := &Filters{}
|
||||
|
||||
assert.Equal(t, testDuration1, sut.TotalTimeByFilters(filters1))
|
||||
assert.Equal(t, testDuration1+testDuration3, sut.TotalTimeByFilters(filters2))
|
||||
assert.Zero(t, sut.TotalTimeByFilters(filters3))
|
||||
}
|
||||
|
||||
func TestSummary_WithResolvedAliases(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3, testDuration4 := 10*time.Minute, 5*time.Minute, 1*time.Minute, 20*time.Minute
|
||||
|
||||
var resolver AliasResolver = func(t uint8, k string) string {
|
||||
switch t {
|
||||
case SummaryProject:
|
||||
switch k {
|
||||
case "wakapi-mobile":
|
||||
return "wakapi"
|
||||
}
|
||||
case SummaryLanguage:
|
||||
switch k {
|
||||
case "Java 8":
|
||||
return "Java"
|
||||
}
|
||||
}
|
||||
return k
|
||||
}
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
Total: testDuration1 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi-mobile",
|
||||
Total: testDuration2 / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration3 / time.Second,
|
||||
},
|
||||
},
|
||||
Languages: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryLanguage,
|
||||
Key: "Java 8",
|
||||
Total: testDuration4 / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sut = sut.WithResolvedAliases(resolver)
|
||||
|
||||
assert.Equal(t, testDuration1+testDuration2, sut.TotalTimeByKey(SummaryProject, "wakapi"))
|
||||
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "wakapi-mobile"))
|
||||
assert.Equal(t, testDuration3, sut.TotalTimeByKey(SummaryProject, "anchr"))
|
||||
assert.Equal(t, testDuration4, sut.TotalTimeByKey(SummaryLanguage, "Java"))
|
||||
assert.Zero(t, sut.TotalTimeByKey(SummaryLanguage, "wakapi"))
|
||||
assert.Zero(t, sut.TotalTimeByKey(SummaryProject, "Java 8"))
|
||||
assert.Len(t, sut.Projects, 2)
|
||||
assert.Len(t, sut.Languages, 1)
|
||||
assert.Empty(t, sut.Editors)
|
||||
assert.Empty(t, sut.OperatingSystems)
|
||||
assert.Empty(t, sut.Machines)
|
||||
}
|
||||
|
||||
func TestSummaryItems_Sorted(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||
|
||||
sut := &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
Total: testDuration1,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: testDuration2,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr-mobile",
|
||||
Total: testDuration3,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
sut = sut.Sorted()
|
||||
|
||||
assert.Equal(t, testDuration3, sut.Projects[0].Total)
|
||||
assert.Equal(t, testDuration1, sut.Projects[1].Total)
|
||||
assert.Equal(t, testDuration2, sut.Projects[2].Total)
|
||||
}
|
16
models/view/login.go
Normal file
@ -0,0 +1,16 @@
|
||||
package view
|
||||
|
||||
type LoginViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LoginViewModel) WithError(m string) *LoginViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
46
repositories/repositories.go
Normal file
@ -0,0 +1,46 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IAliasRepository interface {
|
||||
GetByUser(string) ([]*models.Alias, error)
|
||||
}
|
||||
|
||||
type IHeartbeatRepository interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
DeleteBefore(time.Time) error
|
||||
}
|
||||
|
||||
type IKeyValueRepository interface {
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
|
||||
type ILanguageMappingRepository interface {
|
||||
GetById(uint) (*models.LanguageMapping, error)
|
||||
GetByUser(string) ([]*models.LanguageMapping, error)
|
||||
Insert(*models.LanguageMapping) (*models.LanguageMapping, error)
|
||||
Delete(uint) error
|
||||
}
|
||||
|
||||
type ISummaryRepository interface {
|
||||
Insert(*models.Summary) error
|
||||
GetByUserWithin(*models.User, time.Time, time.Time) ([]*models.Summary, error)
|
||||
GetLastByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
}
|
||||
|
||||
type IUserRepository interface {
|
||||
GetById(string) (*models.User, error)
|
||||
GetByApiKey(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
InsertOrGet(*models.User) (*models.User, bool, error)
|
||||
Update(*models.User) (*models.User, error)
|
||||
UpdateField(*models.User, string, interface{}) (*models.User, error)
|
||||
}
|
@ -2,7 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -18,17 +18,16 @@ const (
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
aliasSrvc *services.AliasService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewBadgeHandler(summaryService *services.SummaryService, userService *services.UserService) *BadgeHandler {
|
||||
func NewBadgeHandler(summaryService services.ISummaryService, userService services.IUserService) *BadgeHandler {
|
||||
return &BadgeHandler{
|
||||
summarySrvc: summaryService,
|
||||
userSrvc: userService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -13,14 +13,14 @@ import (
|
||||
)
|
||||
|
||||
type AllTimeHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewAllTimeHandler(summaryService *services.SummaryService) *AllTimeHandler {
|
||||
func NewAllTimeHandler(summaryService services.ISummaryService) *AllTimeHandler {
|
||||
return &AllTimeHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,7 +3,7 @@ package v1
|
||||
import (
|
||||
"errors"
|
||||
"github.com/gorilla/mux"
|
||||
config2 "github.com/muety/wakapi/config"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/services"
|
||||
@ -14,14 +14,14 @@ import (
|
||||
)
|
||||
|
||||
type SummariesHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *config2.Config
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewSummariesHandler(summaryService *services.SummaryService) *SummariesHandler {
|
||||
func NewSummariesHandler(summaryService services.ISummaryService) *SummariesHandler {
|
||||
return &SummariesHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: config2.Get(),
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -14,11 +14,11 @@ import (
|
||||
|
||||
type HeartbeatHandler struct {
|
||||
config *conf.Config
|
||||
heartbeatSrvc *services.HeartbeatService
|
||||
languageMappingSrvc *services.LanguageMappingService
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatHandler(heartbeatService *services.HeartbeatService, languageMappingService *services.LanguageMappingService) *HeartbeatHandler {
|
||||
func NewHeartbeatHandler(heartbeatService services.IHeartbeatService, languageMappingService services.ILanguageMappingService) *HeartbeatHandler {
|
||||
return &HeartbeatHandler{
|
||||
config: conf.Get(),
|
||||
heartbeatSrvc: heartbeatService,
|
||||
|
136
routes/home.go
@ -4,28 +4,21 @@ import (
|
||||
"fmt"
|
||||
"github.com/gorilla/schema"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HomeHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
config *conf.Config
|
||||
}
|
||||
|
||||
var loginDecoder = schema.NewDecoder()
|
||||
var signupDecoder = schema.NewDecoder()
|
||||
|
||||
func NewHomeHandler(userService *services.UserService) *HomeHandler {
|
||||
func NewHomeHandler() *HomeHandler {
|
||||
return &HomeHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,129 +35,6 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *HomeHandler) PostLogin(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.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: depending on middleware package here is a hack
|
||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, h.userSrvc) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.IndexTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
user.LastLoggedInAt = models.CustomTime(time.Now())
|
||||
h.userSrvc.Update(user)
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: models.AuthCookieKey,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Secure: !h.config.Security.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
utils.ClearCookie(w, models.AuthCookieKey, !h.config.Security.InsecureCookies)
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) 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.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *HomeHandler) PostSignup(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.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape("account created successfully")
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, msg), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
return &view.HomeViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
|
@ -10,10 +10,10 @@ import (
|
||||
|
||||
type ImprintHandler struct {
|
||||
config *conf.Config
|
||||
keyValueSrvc *services.KeyValueService
|
||||
keyValueSrvc services.IKeyValueService
|
||||
}
|
||||
|
||||
func NewImprintHandler(keyValueService *services.KeyValueService) *ImprintHandler {
|
||||
func NewImprintHandler(keyValueService services.IKeyValueService) *ImprintHandler {
|
||||
return &ImprintHandler{
|
||||
config: conf.Get(),
|
||||
keyValueSrvc: keyValueService,
|
||||
|
159
routes/login.go
Normal file
@ -0,0 +1,159 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LoginHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
}
|
||||
|
||||
func NewLoginHandler(userService services.IUserService) *LoginHandler {
|
||||
return &LoginHandler{
|
||||
config: conf.Get(),
|
||||
userSrvc: userService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LoginHandler) GetIndex(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.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostLogin(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.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var login models.Login
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := loginDecoder.Decode(&login, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userSrvc.GetUserById(login.Username)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("resource not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: depending on middleware package here is a hack
|
||||
if !middlewares.CheckAndMigratePassword(user, &login, h.config.Security.PasswordSalt, &h.userSrvc) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("invalid credentials"))
|
||||
return
|
||||
}
|
||||
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
|
||||
user.LastLoggedInAt = models.CustomTime(time.Now())
|
||||
h.userSrvc.Update(user)
|
||||
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/summary", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
http.SetCookie(w, h.config.GetClearCookie(models.AuthCookieKey, "/"))
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/", h.config.Server.BasePath), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) 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.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r))
|
||||
}
|
||||
|
||||
func (h *LoginHandler) PostSignup(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.Server.BasePath), http.StatusFound)
|
||||
return
|
||||
}
|
||||
|
||||
var signup models.Signup
|
||||
if err := r.ParseForm(); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
if err := signupDecoder.Decode(&signup, r.PostForm); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("missing parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
if !signup.IsValid() {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("invalid parameters"))
|
||||
return
|
||||
}
|
||||
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
|
||||
return
|
||||
}
|
||||
if !created {
|
||||
w.WriteHeader(http.StatusConflict)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("user already existing"))
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("%s/?success=%s", h.config.Server.BasePath, "account created successfully"), http.StatusFound)
|
||||
}
|
||||
|
||||
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
||||
return &view.LoginViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
@ -10,7 +10,7 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
func init() {
|
||||
func Init() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
|
@ -10,21 +10,20 @@ import (
|
||||
"github.com/muety/wakapi/utils"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type SettingsHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc *services.UserService
|
||||
summarySrvc *services.SummaryService
|
||||
aggregationSrvc *services.AggregationService
|
||||
languageMappingSrvc *services.LanguageMappingService
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
aggregationSrvc services.IAggregationService
|
||||
languageMappingSrvc services.ILanguageMappingService
|
||||
}
|
||||
|
||||
var credentialsDecoder = schema.NewDecoder()
|
||||
|
||||
func NewSettingsHandler(userService *services.UserService, summaryService *services.SummaryService, aggregationService *services.AggregationService, languageMappingService *services.LanguageMappingService) *SettingsHandler {
|
||||
func NewSettingsHandler(userService services.IUserService, summaryService services.ISummaryService, aggregationService services.IAggregationService, languageMappingService services.ILanguageMappingService) *SettingsHandler {
|
||||
return &SettingsHandler{
|
||||
config: conf.Get(),
|
||||
summarySrvc: summaryService,
|
||||
@ -99,15 +98,7 @@ func (h *SettingsHandler) PostCredentials(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
cookie := &http.Cookie{
|
||||
Name: models.AuthCookieKey,
|
||||
Value: encoded,
|
||||
Path: "/",
|
||||
Secure: !h.config.Security.InsecureCookies,
|
||||
HttpOnly: true,
|
||||
}
|
||||
http.SetCookie(w, cookie)
|
||||
|
||||
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess("password was updated successfully"))
|
||||
}
|
||||
|
||||
@ -178,7 +169,7 @@ func (h *SettingsHandler) PostResetApiKey(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
msg := url.QueryEscape(fmt.Sprintf("your new api key is: %s", user.ApiKey))
|
||||
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
|
||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r).WithSuccess(msg))
|
||||
}
|
||||
|
||||
|
@ -10,11 +10,11 @@ import (
|
||||
)
|
||||
|
||||
type SummaryHandler struct {
|
||||
summarySrvc *services.SummaryService
|
||||
config *conf.Config
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewSummaryHandler(summaryService *services.SummaryService) *SummaryHandler {
|
||||
func NewSummaryHandler(summaryService services.ISummaryService) *SummaryHandler {
|
||||
return &SummaryHandler{
|
||||
summarySrvc: summaryService,
|
||||
config: conf.Get(),
|
||||
|
@ -6,7 +6,7 @@ import (
|
||||
"runtime"
|
||||
"time"
|
||||
|
||||
"github.com/jasonlvhit/gocron"
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/muety/wakapi/models"
|
||||
)
|
||||
|
||||
@ -16,12 +16,12 @@ const (
|
||||
|
||||
type AggregationService struct {
|
||||
config *config.Config
|
||||
userService *UserService
|
||||
summaryService *SummaryService
|
||||
heartbeatService *HeartbeatService
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
heartbeatService IHeartbeatService
|
||||
}
|
||||
|
||||
func NewAggregationService(userService *UserService, summaryService *SummaryService, heartbeatService *HeartbeatService) *AggregationService {
|
||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||
return &AggregationService{
|
||||
config: config.Get(),
|
||||
userService: userService,
|
||||
@ -43,8 +43,9 @@ func (srv *AggregationService) Schedule() {
|
||||
log.Fatalf("failed to run aggregation jobs: %v\n", err)
|
||||
}
|
||||
|
||||
gocron.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, nil)
|
||||
<-gocron.Start()
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
|
@ -10,10 +10,10 @@ import (
|
||||
|
||||
type AliasService struct {
|
||||
config *config.Config
|
||||
repository *repositories.AliasRepository
|
||||
repository repositories.IAliasRepository
|
||||
}
|
||||
|
||||
func NewAliasService(aliasRepo *repositories.AliasRepository) *AliasService {
|
||||
func NewAliasService(aliasRepo repositories.IAliasRepository) *AliasService {
|
||||
return &AliasService{
|
||||
config: config.Get(),
|
||||
repository: aliasRepo,
|
||||
|
63
services/alias_test.go
Normal file
@ -0,0 +1,63 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type AliasServiceTestSuite struct {
|
||||
suite.Suite
|
||||
TestUserId string
|
||||
AliasRepository *mocks.AliasRepositoryMock
|
||||
}
|
||||
|
||||
func (suite *AliasServiceTestSuite) SetupSuite() {
|
||||
suite.TestUserId = "johndoe@example.org"
|
||||
|
||||
aliases := []*models.Alias{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
UserID: suite.TestUserId,
|
||||
Key: "wakapi",
|
||||
Value: "wakapi-mobile",
|
||||
},
|
||||
}
|
||||
|
||||
aliasRepoMock := new(mocks.AliasRepositoryMock)
|
||||
aliasRepoMock.On("GetByUser", suite.TestUserId).Return(aliases, nil)
|
||||
aliasRepoMock.On("GetByUser", mock.AnythingOfType("string")).Return([]*models.Alias{}, assert.AnError)
|
||||
|
||||
suite.AliasRepository = aliasRepoMock
|
||||
}
|
||||
|
||||
func TestAliasServiceTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(AliasServiceTestSuite))
|
||||
}
|
||||
|
||||
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault() {
|
||||
sut := NewAliasService(suite.AliasRepository)
|
||||
|
||||
result1, err1 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi-mobile")
|
||||
result2, err2 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "wakapi")
|
||||
result3, err3 := sut.GetAliasOrDefault(suite.TestUserId, models.SummaryProject, "anchr")
|
||||
|
||||
assert.Equal(suite.T(), "wakapi", result1)
|
||||
assert.Nil(suite.T(), err1)
|
||||
assert.Equal(suite.T(), "wakapi", result2)
|
||||
assert.Nil(suite.T(), err2)
|
||||
assert.Equal(suite.T(), "anchr", result3)
|
||||
assert.Nil(suite.T(), err3)
|
||||
}
|
||||
|
||||
func (suite *AliasServiceTestSuite) TestAliasService_GetAliasOrDefault_ErrorOnNonExistingUser() {
|
||||
sut := NewAliasService(suite.AliasRepository)
|
||||
|
||||
result, err := sut.GetAliasOrDefault("nonexisting", models.SummaryProject, "wakapi-mobile")
|
||||
|
||||
assert.Empty(suite.T(), result)
|
||||
assert.Error(suite.T(), err)
|
||||
}
|
@ -10,11 +10,11 @@ import (
|
||||
|
||||
type HeartbeatService struct {
|
||||
config *config.Config
|
||||
repository *repositories.HeartbeatRepository
|
||||
languageMappingSrvc *LanguageMappingService
|
||||
repository repositories.IHeartbeatRepository
|
||||
languageMappingSrvc ILanguageMappingService
|
||||
}
|
||||
|
||||
func NewHeartbeatService(heartbeatRepo *repositories.HeartbeatRepository, languageMappingService *LanguageMappingService) *HeartbeatService {
|
||||
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
||||
return &HeartbeatService{
|
||||
config: config.Get(),
|
||||
repository: heartbeatRepo,
|
||||
|
@ -8,10 +8,10 @@ import (
|
||||
|
||||
type KeyValueService struct {
|
||||
config *config.Config
|
||||
repository *repositories.KeyValueRepository
|
||||
repository repositories.IKeyValueRepository
|
||||
}
|
||||
|
||||
func NewKeyValueService(keyValueRepo *repositories.KeyValueRepository) *KeyValueService {
|
||||
func NewKeyValueService(keyValueRepo repositories.IKeyValueRepository) *KeyValueService {
|
||||
return &KeyValueService{
|
||||
config: config.Get(),
|
||||
repository: keyValueRepo,
|
||||
|
@ -10,11 +10,11 @@ import (
|
||||
|
||||
type LanguageMappingService struct {
|
||||
config *config.Config
|
||||
repository *repositories.LanguageMappingRepository
|
||||
cache *cache.Cache
|
||||
repository repositories.ILanguageMappingRepository
|
||||
}
|
||||
|
||||
func NewLanguageMappingService(languageMappingsRepo *repositories.LanguageMappingRepository) *LanguageMappingService {
|
||||
func NewLanguageMappingService(languageMappingsRepo repositories.ILanguageMappingRepository) *LanguageMappingService {
|
||||
return &LanguageMappingService{
|
||||
config: config.Get(),
|
||||
repository: languageMappingsRepo,
|
||||
|
58
services/services.go
Normal file
@ -0,0 +1,58 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IAggregationService interface {
|
||||
Schedule()
|
||||
Run(map[string]bool) error
|
||||
}
|
||||
|
||||
type IAliasService interface {
|
||||
LoadUserAliases(string) error
|
||||
GetAliasOrDefault(string, uint8, string) (string, error)
|
||||
IsInitialized(string) bool
|
||||
}
|
||||
|
||||
type IHeartbeatService interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
DeleteBefore(time.Time) error
|
||||
}
|
||||
|
||||
type IKeyValueService interface {
|
||||
GetString(string) (*models.KeyStringValue, error)
|
||||
PutString(*models.KeyStringValue) error
|
||||
DeleteString(string) error
|
||||
}
|
||||
|
||||
type ILanguageMappingService interface {
|
||||
GetById(uint) (*models.LanguageMapping, error)
|
||||
GetByUser(string) ([]*models.LanguageMapping, error)
|
||||
ResolveByUser(string) (map[string]string, error)
|
||||
Create(*models.LanguageMapping) (*models.LanguageMapping, error)
|
||||
Delete(mapping *models.LanguageMapping) error
|
||||
}
|
||||
|
||||
type ISummaryService interface {
|
||||
Aliased(time.Time, time.Time, *models.User, SummaryRetriever) (*models.Summary, error)
|
||||
Retrieve(time.Time, time.Time, *models.User) (*models.Summary, error)
|
||||
Summarize(time.Time, time.Time, *models.User) (*models.Summary, error)
|
||||
GetLatestByUser() ([]*models.TimeByUser, error)
|
||||
DeleteByUser(string) error
|
||||
Insert(*models.Summary) error
|
||||
}
|
||||
|
||||
type IUserService interface {
|
||||
GetUserById(string) (*models.User, error)
|
||||
GetUserByKey(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
CreateOrGet(*models.Signup) (*models.User, bool, error)
|
||||
Update(*models.User) (*models.User, error)
|
||||
ResetApiKey(*models.User) (*models.User, error)
|
||||
ToggleBadges(*models.User) (*models.User, error)
|
||||
MigrateMd5Password(*models.User, *models.Login) (*models.User, error)
|
||||
}
|
@ -17,14 +17,14 @@ const HeartbeatDiffThreshold = 2 * time.Minute
|
||||
type SummaryService struct {
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
repository *repositories.SummaryRepository
|
||||
heartbeatService *HeartbeatService
|
||||
aliasService *AliasService
|
||||
repository repositories.ISummaryRepository
|
||||
heartbeatService IHeartbeatService
|
||||
aliasService IAliasService
|
||||
}
|
||||
|
||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||
|
||||
func NewSummaryService(summaryRepo *repositories.SummaryRepository, heartbeatService *HeartbeatService, aliasService *AliasService) *SummaryService {
|
||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
|
||||
return &SummaryService{
|
||||
config: config.Get(),
|
||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||
@ -63,7 +63,7 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
|
||||
// Post-process summary and cache it
|
||||
summary := s.WithResolvedAliases(resolve)
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary, nil
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
@ -97,7 +97,7 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
|
||||
|
||||
// Cache 'em
|
||||
srv.cache.SetDefault(cacheKey, summary)
|
||||
return summary, nil
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*models.Summary, error) {
|
||||
@ -156,9 +156,9 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
||||
Machines: machineItems,
|
||||
}
|
||||
|
||||
summary.FillUnknown()
|
||||
//summary.FillUnknown()
|
||||
|
||||
return summary, nil
|
||||
return summary.Sorted(), nil
|
||||
}
|
||||
|
||||
// CRUD methods
|
||||
|
290
services/summary_test.go
Normal file
@ -0,0 +1,290 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TestUserId = "muety"
|
||||
TestProject1 = "test-project-1"
|
||||
TestProject2 = "test-project-2"
|
||||
TestLanguageGo = "Go"
|
||||
TestLanguageJava = "Java"
|
||||
TestLanguagePython = "Python"
|
||||
TestEditorGoland = "GoLand"
|
||||
TestEditorIntellij = "idea"
|
||||
TestEditorVscode = "vscode"
|
||||
TestOsLinux = "Linux"
|
||||
TestOsWin = "Windows"
|
||||
TestMachine1 = "muety-desktop"
|
||||
TestMachine2 = "muety-work"
|
||||
MinUnixTime1 = 1601510400000 * 1e6
|
||||
)
|
||||
|
||||
type SummaryServiceTestSuite struct {
|
||||
suite.Suite
|
||||
TestUser *models.User
|
||||
TestStartTime time.Time
|
||||
TestHeartbeats []*models.Heartbeat
|
||||
SummaryRepository *mocks.SummaryRepositoryMock
|
||||
HeartbeatService *mocks.HeartbeatServiceMock
|
||||
AliasService *mocks.AliasServiceMock
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) SetupSuite() {
|
||||
suite.TestUser = &models.User{ID: TestUserId}
|
||||
|
||||
suite.TestStartTime = time.Unix(0, MinUnixTime1)
|
||||
suite.TestHeartbeats = []*models.Heartbeat{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
Project: TestProject1,
|
||||
Language: TestLanguageGo,
|
||||
Editor: TestEditorGoland,
|
||||
OperatingSystem: TestOsLinux,
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime),
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
Project: TestProject1,
|
||||
Language: TestLanguageGo,
|
||||
Editor: TestEditorGoland,
|
||||
OperatingSystem: TestOsLinux,
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)),
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
Project: TestProject1,
|
||||
Language: TestLanguageGo,
|
||||
Editor: TestEditorVscode,
|
||||
OperatingSystem: TestOsLinux,
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
|
||||
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
||||
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
||||
suite.AliasService = new(mocks.AliasServiceMock)
|
||||
}
|
||||
|
||||
func TestSummaryServiceTestSuite(t *testing.T) {
|
||||
suite.Run(t, new(SummaryServiceTestSuite))
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
to time.Time
|
||||
result *models.Summary
|
||||
err error
|
||||
)
|
||||
|
||||
/* TEST 1 */
|
||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Equal(suite.T(), from, result.FromTime.T())
|
||||
assert.Equal(suite.T(), to, result.ToTime.T())
|
||||
assert.Zero(suite.T(), result.TotalTime())
|
||||
assert.Empty(suite.T(), result.Projects)
|
||||
|
||||
/* TEST 2 */
|
||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.ToTime.T())
|
||||
assert.Zero(suite.T(), result.TotalTime())
|
||||
assertNumAllItems(suite.T(), 1, result, "")
|
||||
|
||||
/* TEST 3 */
|
||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Summarize(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[0].Time.T(), result.FromTime.T())
|
||||
assert.Equal(suite.T(), suite.TestHeartbeats[len(suite.TestHeartbeats)-1].Time.T(), result.ToTime.T())
|
||||
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
|
||||
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
|
||||
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
|
||||
assert.Len(suite.T(), result.Editors, 2)
|
||||
assertNumAllItems(suite.T(), 1, result, "e")
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
var (
|
||||
summaries []*models.Summary
|
||||
from time.Time
|
||||
to time.Time
|
||||
result *models.Summary
|
||||
err error
|
||||
)
|
||||
|
||||
/* TEST 1 */
|
||||
from, to = suite.TestStartTime.Add(-12*time.Hour), suite.TestStartTime.Add(12*time.Hour)
|
||||
summaries = []*models.Summary{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(from.Add(10 * time.Minute)),
|
||||
ToTime: models.CustomTime(to.Add(-10 * time.Minute)),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject1,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
}
|
||||
|
||||
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return([]*models.Heartbeat{}, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", summaries[0].ToTime.T(), to, suite.TestUser).Return([]*models.Heartbeat{}, nil)
|
||||
|
||||
result, err = sut.Retrieve(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Len(suite.T(), result.Projects, 1)
|
||||
assert.Equal(suite.T(), summaries[0].Projects[0].Total*time.Second, result.TotalTime())
|
||||
suite.HeartbeatService.AssertNumberOfCalls(suite.T(), "GetAllWithin", 2)
|
||||
|
||||
/* TEST 2 */
|
||||
from, to = suite.TestStartTime.Add(-10*time.Minute), suite.TestStartTime.Add(12*time.Hour)
|
||||
summaries = []*models.Summary{
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(from.Add(20 * time.Minute)),
|
||||
ToTime: models.CustomTime(to.Add(-6 * time.Hour)),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject1,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
{
|
||||
ID: uint(rand.Uint32()),
|
||||
UserID: TestUserId,
|
||||
FromTime: models.CustomTime(to.Add(-6 * time.Hour)),
|
||||
ToTime: models.CustomTime(to),
|
||||
Projects: []*models.SummaryItem{
|
||||
{
|
||||
Type: models.SummaryProject,
|
||||
Key: TestProject2,
|
||||
Total: 45 * time.Minute / time.Second, // hack
|
||||
},
|
||||
},
|
||||
Languages: []*models.SummaryItem{},
|
||||
Editors: []*models.SummaryItem{},
|
||||
OperatingSystems: []*models.SummaryItem{},
|
||||
Machines: []*models.SummaryItem{},
|
||||
},
|
||||
}
|
||||
|
||||
suite.SummaryRepository.On("GetByUserWithin", suite.TestUser, from, to).Return(summaries, nil)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, summaries[0].FromTime.T(), suite.TestUser).Return(filter(from, summaries[0].FromTime.T(), suite.TestHeartbeats), nil)
|
||||
|
||||
result, err = sut.Retrieve(from, to, suite.TestUser)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Len(suite.T(), result.Projects, 2)
|
||||
assert.Equal(suite.T(), 150*time.Second+90*time.Minute, result.TotalTime())
|
||||
assert.Equal(suite.T(), 150*time.Second+45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
||||
|
||||
var (
|
||||
from time.Time
|
||||
to time.Time
|
||||
result *models.Summary
|
||||
err error
|
||||
)
|
||||
|
||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
|
||||
suite.AliasService.On("LoadUserAliases", TestUserId).Return(nil)
|
||||
suite.AliasService.On("GetAliasOrDefault", TestUserId, models.SummaryProject, TestProject1).Return(TestProject2, nil)
|
||||
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
|
||||
|
||||
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize)
|
||||
|
||||
assert.Nil(suite.T(), err)
|
||||
assert.NotNil(suite.T(), result)
|
||||
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||
}
|
||||
|
||||
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
|
||||
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
|
||||
for _, h := range heartbeats {
|
||||
if (h.Time.T().Equal(from) || h.Time.T().After(from)) && h.Time.T().Before(to) {
|
||||
filtered = append(filtered, h)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func assertNumAllItems(t *testing.T, expected int, summary *models.Summary, except string) {
|
||||
if !strings.Contains(except, "p") {
|
||||
assert.Len(t, summary.Projects, expected)
|
||||
}
|
||||
if !strings.Contains(except, "e") {
|
||||
assert.Len(t, summary.Editors, expected)
|
||||
}
|
||||
if !strings.Contains(except, "l") {
|
||||
assert.Len(t, summary.Languages, expected)
|
||||
}
|
||||
if !strings.Contains(except, "o") {
|
||||
assert.Len(t, summary.OperatingSystems, expected)
|
||||
}
|
||||
if !strings.Contains(except, "m") {
|
||||
assert.Len(t, summary.Machines, expected)
|
||||
}
|
||||
}
|
@ -10,10 +10,10 @@ import (
|
||||
|
||||
type UserService struct {
|
||||
Config *config.Config
|
||||
repository *repositories.UserRepository
|
||||
repository repositories.IUserRepository
|
||||
}
|
||||
|
||||
func NewUserService(userRepo *repositories.UserRepository) *UserService {
|
||||
func NewUserService(userRepo repositories.IUserRepository) *UserService {
|
||||
return &UserService{
|
||||
Config: config.Get(),
|
||||
repository: userRepo,
|
||||
|
3
sonar-project.properties
Normal file
@ -0,0 +1,3 @@
|
||||
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**
|
||||
sonar.tests=.
|
||||
sonar.go.coverage.reportPaths=coverage/coverage.out
|
@ -1,4 +1,3 @@
|
||||
const SHOW_TOP_N = 10
|
||||
const CHART_TARGET_SIZE = 200
|
||||
|
||||
const projectsCanvas = document.getElementById('chart-projects')
|
||||
@ -17,7 +16,16 @@ const containers = [projectContainer, osContainer, editorContainer, languageCont
|
||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
||||
|
||||
let topNPickers = [...document.getElementsByClassName('top-picker')]
|
||||
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
||||
topNPickers.forEach(e => {
|
||||
const idx = parseInt(e.attributes['data-entity'].value)
|
||||
e.max = Math.min(data[idx].length, 10)
|
||||
e.value = e.max
|
||||
})
|
||||
|
||||
let charts = []
|
||||
let showTopN = []
|
||||
let resizeCount = 0
|
||||
|
||||
String.prototype.toHHMMSS = function () {
|
||||
@ -38,7 +46,7 @@ String.prototype.toHHMMSS = function () {
|
||||
return hours + ':' + minutes + ':' + seconds
|
||||
}
|
||||
|
||||
function draw() {
|
||||
function draw(subselection) {
|
||||
function getTooltipOptions(key, type) {
|
||||
return {
|
||||
mode: 'single',
|
||||
@ -47,19 +55,26 @@ function draw() {
|
||||
let idx = type === 'pie' ? item.index : item.datasetIndex
|
||||
let d = wakapiData[key][idx]
|
||||
return `${d.key}: ${d.total.toString().toHHMMSS()}`
|
||||
}
|
||||
},
|
||||
title: () => 'Total Time'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
charts.forEach(c => c.destroy())
|
||||
function shouldUpdate(index) {
|
||||
return !subselection || (subselection.includes(index) && data[index].length >= showTopN[index])
|
||||
}
|
||||
|
||||
let projectChart = !projectsCanvas.classList.contains('hidden')
|
||||
charts
|
||||
.filter((c, i) => shouldUpdate(i))
|
||||
.forEach(c => c.destroy())
|
||||
|
||||
let projectChart = !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
|
||||
? new Chart(projectsCanvas.getContext('2d'), {
|
||||
type: 'horizontalBar',
|
||||
data: {
|
||||
datasets: wakapiData.projects
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.projects.length))
|
||||
.slice(0, Math.min(showTopN[0], wakapiData.projects.length))
|
||||
.map(p => {
|
||||
return {
|
||||
label: p.key,
|
||||
@ -87,18 +102,18 @@ function draw() {
|
||||
})
|
||||
: null
|
||||
|
||||
let osChart = !osCanvas.classList.contains('hidden')
|
||||
let osChart = !osCanvas.classList.contains('hidden') && shouldUpdate(1)
|
||||
? new Chart(osCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.operatingSystems
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.operatingSystems.length))
|
||||
.slice(0, Math.min(showTopN[1], 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(showTopN[1], wakapiData.operatingSystems.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
@ -109,18 +124,18 @@ function draw() {
|
||||
})
|
||||
: null
|
||||
|
||||
let editorChart = !editorsCanvas.classList.contains('hidden')
|
||||
let editorChart = !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
|
||||
? new Chart(editorsCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.editors
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.editors.length))
|
||||
.slice(0, Math.min(showTopN[2], 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(showTopN[2], wakapiData.editors.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
@ -131,18 +146,18 @@ function draw() {
|
||||
})
|
||||
: null
|
||||
|
||||
let languageChart = !languagesCanvas.classList.contains('hidden')
|
||||
let languageChart = !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
|
||||
? new Chart(languagesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.languages
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.languages.length))
|
||||
.slice(0, Math.min(showTopN[3], 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(showTopN[3], wakapiData.languages.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
@ -153,18 +168,18 @@ function draw() {
|
||||
})
|
||||
: null
|
||||
|
||||
let machineChart = !machinesCanvas.classList.contains('hidden')
|
||||
let machineChart = !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
|
||||
? new Chart(machinesCanvas.getContext('2d'), {
|
||||
type: 'pie',
|
||||
data: {
|
||||
datasets: [{
|
||||
data: wakapiData.machines
|
||||
.slice(0, Math.min(SHOW_TOP_N, wakapiData.machines.length))
|
||||
.slice(0, Math.min(showTopN[4], 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(showTopN[4], wakapiData.machines.length))
|
||||
.map(p => p.key)
|
||||
},
|
||||
options: {
|
||||
@ -179,13 +194,14 @@ function draw() {
|
||||
|
||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
||||
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
equalizeHeights()
|
||||
if (!subselection) {
|
||||
charts.forEach(c => c.options.onResize(c.chart))
|
||||
equalizeHeights()
|
||||
}
|
||||
}
|
||||
|
||||
function setTopLabels() {
|
||||
[...document.getElementsByClassName('top-label')]
|
||||
.forEach(e => e.innerText = `(top ${SHOW_TOP_N})`)
|
||||
function parseTopN() {
|
||||
showTopN = topNPickers.map(e => parseInt(e.value))
|
||||
}
|
||||
|
||||
function togglePlaceholders(mask) {
|
||||
@ -203,7 +219,7 @@ function togglePlaceholders(mask) {
|
||||
}
|
||||
|
||||
function getPresentDataMask() {
|
||||
return data.map(list => list ? list.reduce((acc, e) => acc + e.total, 0) : 0 > 0)
|
||||
return data.map(list => (list ? list.reduce((acc, e) => acc + e.total, 0) : 0) > 0)
|
||||
}
|
||||
|
||||
function getContainer(chart) {
|
||||
@ -300,7 +316,12 @@ window.addEventListener('click', function (event) {
|
||||
})
|
||||
|
||||
window.addEventListener('load', function () {
|
||||
setTopLabels()
|
||||
topNPickers.forEach(e => e.addEventListener('change', () => {
|
||||
parseTopN()
|
||||
draw([parseInt(e.attributes['data-entity'].value)])
|
||||
}))
|
||||
|
||||
parseTopN()
|
||||
togglePlaceholders(getPresentDataMask())
|
||||
draw()
|
||||
})
|
||||
|
BIN
static/assets/images/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 3.8 KiB |
BIN
static/assets/images/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 14 KiB |
BIN
static/assets/images/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
static/assets/images/favicon-16x16.png
Normal file
After Width: | Height: | Size: 457 B |
BIN
static/assets/images/favicon-32x32.png
Normal file
After Width: | Height: | Size: 710 B |
BIN
static/assets/images/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
1
static/assets/images/ghicon.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><g fill="#eee"><path fill-rule="evenodd" clip-rule="evenodd" d="M64 5.103c-33.347 0-60.388 27.035-60.388 60.388 0 26.682 17.303 49.317 41.297 57.303 3.017.56 4.125-1.31 4.125-2.905 0-1.44-.056-6.197-.082-11.243-16.8 3.653-20.345-7.125-20.345-7.125-2.747-6.98-6.705-8.836-6.705-8.836-5.48-3.748.413-3.67.413-3.67 6.063.425 9.257 6.223 9.257 6.223 5.386 9.23 14.127 6.562 17.573 5.02.542-3.903 2.107-6.568 3.834-8.076-13.413-1.525-27.514-6.704-27.514-29.843 0-6.593 2.36-11.98 6.223-16.21-.628-1.52-2.695-7.662.584-15.98 0 0 5.07-1.623 16.61 6.19C53.7 35 58.867 34.327 64 34.304c5.13.023 10.3.694 15.127 2.033 11.526-7.813 16.59-6.19 16.59-6.19 3.287 8.317 1.22 14.46.593 15.98 3.872 4.23 6.215 9.617 6.215 16.21 0 23.194-14.127 28.3-27.574 29.796 2.167 1.874 4.097 5.55 4.097 11.183 0 8.08-.07 14.583-.07 16.572 0 1.607 1.088 3.49 4.148 2.897 23.98-7.994 41.263-30.622 41.263-57.294C124.388 32.14 97.35 5.104 64 5.104z"/><path d="M26.484 91.806c-.133.3-.605.39-1.035.185-.44-.196-.685-.605-.543-.906.13-.31.603-.395 1.04-.188.44.197.69.61.537.91zm-.743-.55M28.93 94.535c-.287.267-.85.143-1.232-.28-.396-.42-.47-.983-.177-1.254.298-.266.844-.14 1.24.28.394.426.472.984.17 1.255zm-.575-.618M31.312 98.012c-.37.258-.976.017-1.35-.52-.37-.538-.37-1.183.01-1.44.373-.258.97-.025 1.35.507.368.545.368 1.19-.01 1.452zm0 0M34.573 101.373c-.33.365-1.036.267-1.552-.23-.527-.487-.674-1.18-.343-1.544.336-.366 1.045-.264 1.564.23.527.486.686 1.18.333 1.543zm0 0M39.073 103.324c-.147.473-.825.688-1.51.486-.683-.207-1.13-.76-.99-1.238.14-.477.823-.7 1.512-.485.683.206 1.13.756.988 1.237zm0 0M44.016 103.685c.017.498-.563.91-1.28.92-.723.017-1.308-.387-1.315-.877 0-.503.568-.91 1.29-.924.717-.013 1.306.387 1.306.88zm0 0M48.614 102.903c.086.485-.413.984-1.126 1.117-.7.13-1.35-.172-1.44-.653-.086-.498.422-.997 1.122-1.126.714-.123 1.354.17 1.444.663zm0 0"/></g></svg>
|
After Width: | Height: | Size: 1.9 KiB |
BIN
static/assets/images/screenshot.png
Normal file
After Width: | Height: | Size: 38 KiB |
19
static/assets/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "",
|
||||
"short_name": "",
|
||||
"icons": [
|
||||
{
|
||||
"src": "assets/images/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "assets/images/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"theme_color": "#ffffff",
|
||||
"background_color": "#ffffff",
|
||||
"display": "standalone"
|
||||
}
|
@ -2,10 +2,11 @@ package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParseUserAgent(t *testing.T) {
|
||||
func TestCommon_ParseUserAgent(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
outOs string
|
||||
@ -38,10 +39,11 @@ func TestParseUserAgent(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
for i, test := range tests {
|
||||
if os, editor, err := ParseUserAgent(test.in); os != test.outOs || editor != test.outEditor || !checkErr(test.outError, err) {
|
||||
t.Errorf("[%d] Unexpected result of parsing '%s'; got '%v', '%v', '%v'", i, test.in, os, editor, err)
|
||||
}
|
||||
for _, test := range tests {
|
||||
os, editor, err := ParseUserAgent(test.in)
|
||||
assert.True(t, checkErr(err, test.outError))
|
||||
assert.Equal(t, test.outOs, os)
|
||||
assert.Equal(t, test.outEditor, editor)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -13,13 +13,3 @@ func RespondJSON(w http.ResponseWriter, status int, object interface{}) {
|
||||
log.Printf("error while writing json response: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ClearCookie(w http.ResponseWriter, name string, secure bool) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: name,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
Secure: secure,
|
||||
HttpOnly: true,
|
||||
})
|
||||
}
|
||||
|
@ -1 +1 @@
|
||||
1.16.1
|
||||
1.18.0
|
||||
|
@ -1,14 +1,2 @@
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/2.4.4/seedrandom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.7.3/Chart.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
const languageColors = {{ .LanguageColors | json }}
|
||||
|
||||
let wakapiData = {}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.machines = {{ .Machines | json }}
|
||||
</script>
|
||||
<script src="assets/app.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/seedrandom/3.0.5/seedrandom.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/Chart.js/2.9.4/Chart.bundle.min.js"></script>
|
@ -2,7 +2,10 @@
|
||||
<title>Wakapi – Coding Statistics</title>
|
||||
<base href="{{ getBasePath }}/">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1"/>
|
||||
<link rel="icon" data-emoji="📊" type="image/png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="assets/images/apple-touch-icon.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="assets/images/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="assets/images/favicon-16x16.png">
|
||||
<link rel="manifest" href="assets/site.webmanifest">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto&display=swap" rel="stylesheet">
|
||||
<link href="https://unpkg.com/tailwindcss@^1.4.6/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link href="assets/app.css" rel="stylesheet">
|
||||
|
6
views/header.tpl.html
Normal file
@ -0,0 +1,6 @@
|
||||
<header class="flex justify-between mb-10">
|
||||
<a id="logo-container" class="text-2xl font-semibold text-white inline-block" href="">
|
||||
<span>📊</span>
|
||||
<span>Wakapi</span>
|
||||
</a>
|
||||
</header>
|
@ -4,6 +4,9 @@
|
||||
{{ 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">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<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>
|
||||
|
@ -3,35 +3,62 @@
|
||||
|
||||
{{ 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="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Login</h1>
|
||||
</div>
|
||||
<body class="relative bg-gray-800 text-gray-700 p-4 pt-10 flex flex-col min-h-screen max-w-screen-lg mx-auto justify-center">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
|
||||
<div class="mx-1">
|
||||
<a href="login" class="py-1 px-3 h-8 block rounded border border-green-700 text-white text-sm">🔑 Login️</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex-grow max-w-lg mt-12">
|
||||
<form action="login" method="post">
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</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="text" id="username"
|
||||
name="username" placeholder="Enter your username" minlength="3" required autofocus>
|
||||
<div class="flex flex-col text-white">
|
||||
<h1 class="text-4xl font-semibold antialiased text-center mb-2">Keep Track of <span class="text-green-700">Your</span> Coding Time 🕓</h1>
|
||||
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the time you have spent coding on different projects in different programming languages and more. Ideal for statistics freaks any anyone else.</p>
|
||||
|
||||
<div class="flex justify-center mt-4 mb-8 space-x-2">
|
||||
<a href="login">
|
||||
<button type="button" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white font-semibold">🚀 Try it!</button>
|
||||
</a>
|
||||
<a href="https://github.com/muety/wakapi#%EF%B8%8F-server-setup" target="_blank" rel="noopener noreferrer">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">📡 Host it</button>
|
||||
</a>
|
||||
<a href="https://github.com/muety/wakapi" target="_blank" rel="noopener noreferrer">
|
||||
<button type="button" class="py-1 px-3 h-8 rounded border border-green-700 text-white">
|
||||
<img alt="GitHub Icon" src="assets/images/ghicon.svg" width="22px">
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center my-8">
|
||||
<img alt="App screenshot" src="assets/images/screenshot.png">
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col items-center mt-10">
|
||||
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Features</h1>
|
||||
<div class="mt-4 text-lg">
|
||||
<ul>
|
||||
<li>✅ 100 % free and open-source</li>
|
||||
<li>✅ Built by developers for developers</li>
|
||||
<li>✅ Fancy statistics and plots</li>
|
||||
<li>✅ Cool badges for readmes</li>
|
||||
<li>✅ Intuitive REST API</li>
|
||||
<li>✅ Compatible with <a href="https://wakatime.com" target="_blank" rel="noopener noreferrer" class="underline">Wakatime</a></li>
|
||||
<li>✅ <a href="https://prometheus.io" target="_blank" rel="noopener noreferrer" class="underline">Prometheus</a> metrics via <a href="https://github.com/MacroPower/wakatime_exporter" target="_blank" rel="noopener noreferrer" class="underline">exporter</a></li>
|
||||
<li>✅ Self-hosted</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">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"
|
||||
name="password" placeholder="******" minlength="6" required>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<a href="signup">
|
||||
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
|
||||
</a>
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-center space-x-2 mt-12">
|
||||
<img alt="License badge" src="https://badges.fw-web.space/github/license/muety/wakapi?color=%232F855A&style=flat-square">
|
||||
<img alt="Go version badge" src="https://badges.fw-web.space/github/go-mod/go-version/muety/wakapi?color=%232F855A&style=flat-square">
|
||||
<img alt="Wakapi coding time badge" src="https://badges.fw-web.space/endpoint?color=%232F855A&style=flat-square&label=wakapi&url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi">
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
|
46
views/login.tpl.html
Normal file
@ -0,0 +1,46 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
{{ 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-lg mx-auto justify-center">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-semibold text-xl text-white m-0 border-b-4 border-green-700">Login</h1>
|
||||
</div>
|
||||
|
||||
{{ template "alerts.tpl.html" . }}
|
||||
|
||||
<main class="mt-10 flex-grow flex justify-center w-full">
|
||||
<div class="flex-grow max-w-lg mt-10">
|
||||
<form action="login" method="post">
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</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="text" id="username"
|
||||
name="username" placeholder="Enter your username" minlength="3" required autofocus>
|
||||
</div>
|
||||
<div class="mb-8">
|
||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">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"
|
||||
name="password" placeholder="******" minlength="6" required>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<a href="signup">
|
||||
<button type="button" class="py-1 px-3 rounded border border-green-700 text-white text-sm">Sign up</button>
|
||||
</a>
|
||||
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Log in</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
</body>
|
||||
|
||||
</html>
|
@ -4,18 +4,21 @@
|
||||
{{ 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">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<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-xl 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>
|
||||
</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="flex flex-col flex-grow max-w-xl 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
|
||||
@ -72,7 +75,7 @@
|
||||
</div>
|
||||
|
||||
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.
|
||||
You can specify custom mapping from file extensions to programming languages (e.g. a <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">.jsx</span> file could be mapped to <span class="text-xs bg-gray-900 rounded py-1 px-2 font-mono">React</span>.)
|
||||
</div>
|
||||
|
||||
{{ if .LanguageMappings }}
|
||||
|
@ -5,6 +5,8 @@
|
||||
|
||||
<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">
|
||||
|
||||
{{ template "header.tpl.html" . }}
|
||||
|
||||
<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">
|
||||
@ -64,9 +66,13 @@
|
||||
<div class="flex flex-wrap justify-center">
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<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 class="flex justify-between">
|
||||
<div class="w-1/4 flex-1"></div>
|
||||
<span class="font-semibold w-1/2 text-center flex-1">Projects</span>
|
||||
<div class="flex justify-end flex-1 text-xs items-center">
|
||||
<label for="project-top-picker" class="mr-1">Show: </label>
|
||||
<input type="number" min="1" id="project-top-picker" data-entity="0" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="chart-projects"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
@ -77,9 +83,13 @@
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<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 class="flex justify-between">
|
||||
<div class="w-1/4 flex-1"></div>
|
||||
<span class="font-semibold w-1/2 text-center flex-1">Operating Systems</span>
|
||||
<div class="flex justify-end flex-1 text-xs items-center">
|
||||
<label for="os-top-picker" class="mr-1">Show: </label>
|
||||
<input type="number" min="1" id="os-top-picker" data-entity="1" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="chart-os"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
@ -90,9 +100,13 @@
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<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 class="flex justify-between">
|
||||
<div class="w-1/4 flex-1"></div>
|
||||
<span class="font-semibold w-1/2 text-center flex-1">Languages</span>
|
||||
<div class="flex justify-end flex-1 text-xs items-center">
|
||||
<label for="language-top-picker" class="mr-1">Show: </label>
|
||||
<input type="number" min="1" id="language-top-picker" data-entity="3" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="chart-language"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
@ -103,9 +117,13 @@
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<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 class="flex justify-between">
|
||||
<div class="w-1/4 flex-1"></div>
|
||||
<span class="font-semibold w-1/2 text-center flex-1">Editors</span>
|
||||
<div class="flex justify-end flex-1 text-xs items-center">
|
||||
<label for="editor-top-picker" class="mr-1">Show: </label>
|
||||
<input type="number" min="1" id="editor-top-picker" data-entity="2" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="chart-editor"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
@ -116,9 +134,13 @@
|
||||
</div>
|
||||
<div class="w-full lg:w-1/2 p-1">
|
||||
<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 class="flex justify-between">
|
||||
<div class="w-1/4 flex-1"></div>
|
||||
<span class="font-semibold w-1/2 text-center flex-1">Machines</span>
|
||||
<div class="flex justify-end flex-1 text-xs items-center">
|
||||
<label for="machine-top-picker" class="mr-1">Show: </label>
|
||||
<input type="number" min="1" id="machine-top-picker" data-entity="4" class="w-1/4 top-picker bg-gray-200 rounded-md text-center" value="10">
|
||||
</div>
|
||||
</div>
|
||||
<canvas id="chart-machine"></canvas>
|
||||
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||
@ -133,6 +155,19 @@
|
||||
{{ template "footer.tpl.html" . }}
|
||||
|
||||
{{ template "foot.tpl.html" . }}
|
||||
|
||||
<script>
|
||||
const languageColors = {{ .LanguageColors | json }}
|
||||
|
||||
const wakapiData = {}
|
||||
wakapiData.projects = {{ .Projects | json }}
|
||||
wakapiData.operatingSystems = {{ .OperatingSystems | json }}
|
||||
wakapiData.editors = {{ .Editors | json }}
|
||||
wakapiData.languages = {{ .Languages | json }}
|
||||
wakapiData.machines = {{ .Machines | json }}
|
||||
</script>
|
||||
<script src="assets/app.js"></script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|