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

Compare commits

...

21 Commits

Author SHA1 Message Date
56800be8e8 chore: change info message in migration (resolve #128) 2021-02-16 17:18:56 +01:00
c149766ecc fix: drop_badges_column migration for sqlite 2021-02-16 22:07:41 +11:00
759e8e4dfd chore: change logging middleware to use different output 2021-02-14 16:41:02 +01:00
708863fd33 fix: broken migration on postgres (resolve #127) 2021-02-14 16:02:05 +01:00
e2f046a83d fix: add migration for newly introduced has data field 2021-02-13 13:04:47 +01:00
30510591eb feat: custom time intervals (resolve #115) 2021-02-13 12:59:59 +01:00
daf67b844a refctor: change active users query 2021-02-13 11:23:58 +01:00
6b0b3bddda fix: include overall total number of heartbeats again 2021-02-12 23:16:20 +01:00
ef17d06763 fix: tests [ci skip] 2021-02-12 23:10:25 +01:00
301cab4be4 feat: per-user heartbeats count metrics 2021-02-12 23:06:48 +01:00
703805412b chore: code smell [ci skip] 2021-02-12 19:26:23 +01:00
88eb68b1a9 feat: add prometheus metrics without external standalone exporter 2021-02-12 18:50:13 +01:00
8191a52ce1 chore: make very first user have admin privileges 2021-02-12 18:49:47 +01:00
5b3e88247e chore: introduce user admin flag 2021-02-12 18:13:49 +01:00
59b85863cc chore: accept bearer prefix in auth header 2021-02-12 18:12:46 +01:00
22fbfceca2 fix: support default range for stats endpoint (resolve #125) 2021-02-12 11:25:21 +01:00
4d7fc6bff9 fix: commit missing asset 2021-02-12 10:28:31 +01:00
218c571859 feat: display setup instructions on startup (resolve #120) 2021-02-12 10:10:44 +01:00
e4c413a33c fix: include machine names when importing wakatime data 2021-02-10 22:08:00 +01:00
66b01c2797 docs: update readme toc 2021-02-07 17:30:14 +01:00
0cee7496e0 fix(ui): convert logo text to path (resolve #121) 2021-02-07 17:29:26 +01:00
50 changed files with 1374 additions and 538 deletions

View File

@ -44,7 +44,6 @@
* [Configuration Options](#-configuration-options) * [Configuration Options](#-configuration-options)
* [API Endpoints](#-api-endpoints) * [API Endpoints](#-api-endpoints)
* [Integrations](#-integrations) * [Integrations](#-integrations)
* [WakaTime Integration](#%EF%B8%8F-wakatime-integration)
* [Best Practices](#-best-practices) * [Best Practices](#-best-practices)
* [Developer Notes](#-developer-notes) * [Developer Notes](#-developer-notes)
* [Support](#-support) * [Support](#-support)
@ -62,6 +61,7 @@ I'd love to get some community feedback from active Wakapi users. If you want, p
* ✅ Partially compatible with WakaTime * ✅ Partially compatible with WakaTime
* ✅ WakaTime integration * ✅ WakaTime integration
* ✅ Support for Prometheus exports * ✅ Support for Prometheus exports
* ✅ Lightning fast
* ✅ Self-hosted * ✅ Self-hosted
## 🚧 Roadmap ## 🚧 Roadmap
@ -164,7 +164,9 @@ You can specify configuration options either via a config file (default: `config
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) | | `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.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.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 | | `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 |
| `security.allow_signup` | `WAKAPI_ALLOW_SIGNUP` | `true` | Whether to enable user registration |
| `security.expose_metrics` | `WAKAPI_EXPOSE_METRICS` | `false` | Whether to expose Prometheus metrics under `/api/metrics` |
| `db.host` | `WAKAPI_DB_HOST` | - | Database host | | `db.host` | `WAKAPI_DB_HOST` | - | Database host |
| `db.port` | `WAKAPI_DB_PORT` | - | Database port | | `db.port` | `WAKAPI_DB_PORT` | - | Database port |
| `db.user` | `WAKAPI_DB_USER` | - | Database user | | `db.user` | `WAKAPI_DB_USER` | - | Database user |
@ -196,13 +198,37 @@ $ swag init -o static/docs
## 🤝 Integrations ## 🤝 Integrations
### Prometheus Export ### Prometheus Export
If you want to export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so please refer to an excellent tool called **[wakatime_exporter](https://github.com/MacroPower/wakatime_exporter)**. You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
[![](https://github-readme-stats.vercel.app/api/pin/?username=MacroPower&repo=wakatime_exporter&show_owner=true&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff)](https://github.com/MacroPower/wakatime_exporter) ```bash
# 1. Start Wakapi with the feature enabled
$ export WAKAPI_EXPOSE_METRICS=true
$ ./wakapi
It is a standalone webserver that connects to your Wakapi instance and exposes the data as Prometheus metrics. Although originally developed to scrape data from WakaTime, it will mostly for with Wakapi as well, as the APIs are partially compatible. # 2. Get your API key and hash it
$ echo "<YOUR_API_KEY>" | base64
Simply configure the exporter with `WAKA_SCRAPE_URI` to equal `"https://wakapi.your-server.com/api/compat/wakatime/v1"` and set your API key accordingly. # 3. Add a Prometheus scrape config to your prometheus.yml (see below)
```
#### Scrape config example
```yml
# prometheus.yml
# (assuming your Wakapi instance listens at localhost, port 3000)
scrape_configs:
- job_name: 'wakapi'
scrape_interval: 1m
metrics_path: '/api/metrics'
bearer_token: '<YOUR_BASE64_HASHED_TOKEN>'
static_configs:
- targets: ['localhost:3000']
```
#### Grafana
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
![](https://grafana.com/api/dashboards/12790/images/8741/image)
### WakaTime Integration ### WakaTime Integration
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page. Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.

View File

@ -10,7 +10,7 @@ server:
app: app:
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
counting_time: '05:15' # time at which to run daily job to count total hours tracked in the system inactive_days: 7 # time of previous days within a user must have logged in to be considered active
custom_languages: custom_languages:
vue: Vue vue: Vue
jsx: JSX jsx: JSX
@ -27,6 +27,7 @@ db:
security: security:
password_salt: # CHANGE ! password_salt: # CHANGE !
insecure_cookies: false insecure_cookies: false # You need to set this to 'true' when on localhost
cookie_max_age: 172800 cookie_max_age: 172800
allow_signup: true allow_signup: true
expose_metrics: false

View File

@ -30,6 +30,12 @@ const (
KeyLatestTotalTime = "latest_total_time" KeyLatestTotalTime = "latest_total_time"
KeyLatestTotalUsers = "latest_total_users" KeyLatestTotalUsers = "latest_total_users"
KeyLastImportImport = "last_import" KeyLastImportImport = "last_import"
SimpleDateFormat = "2006-01-02"
SimpleDateTimeFormat = "2006-01-02 15:04:05"
ErrUnauthorized = "401 unauthorized"
ErrInternalServerError = "500 internal server error"
) )
const ( const (
@ -39,6 +45,7 @@ const (
WakatimeApiHeartbeatsUrl = "/users/current/heartbeats" WakatimeApiHeartbeatsUrl = "/users/current/heartbeats"
WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk" WakatimeApiHeartbeatsBulkUrl = "/users/current/heartbeats.bulk"
WakatimeApiUserAgentsUrl = "/users/current/user_agents" WakatimeApiUserAgentsUrl = "/users/current/user_agents"
WakatimeApiMachineNamesUrl = "/users/current/machine_names"
) )
var cfg *Config var cfg *Config
@ -46,15 +53,16 @@ var cFlag = flag.String("config", defaultConfigPath, "config file location")
type appConfig struct { type appConfig struct {
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"` AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
CountingTime string `yaml:"counting_time" default:"05:15" env:"WAKAPI_COUNTING_TIME"`
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"` ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"` ImportBatchSize int `yaml:"import_batch_size" default:"100" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CustomLanguages map[string]string `yaml:"custom_languages"` CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"` Colors map[string]map[string]string `yaml:"-"`
} }
type securityConfig struct { type securityConfig struct {
AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"` AllowSignup bool `yaml:"allow_signup" default:"true" env:"WAKAPI_ALLOW_SIGNUP"`
ExposeMetrics bool `yaml:"expose_metrics" default:"false" env:"WAKAPI_EXPOSE_METRICS"`
// this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography)) // this is actually a pepper (https://en.wikipedia.org/wiki/Pepper_(cryptography))
PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"` PasswordSalt string `yaml:"password_salt" default:"" env:"WAKAPI_PASSWORD_SALT"`
InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"` InsecureCookies bool `yaml:"insecure_cookies" default:"false" env:"WAKAPI_INSECURE_COOKIES"`

View File

@ -1,9 +1,4 @@
mode: set mode: set
github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0
github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0
github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0
github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0
github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0
github.com/muety/wakapi/models/heartbeat.go:32.34,34.2 1 1 github.com/muety/wakapi/models/heartbeat.go:32.34,34.2 1 1
github.com/muety/wakapi/models/heartbeat.go:36.65,37.28 1 1 github.com/muety/wakapi/models/heartbeat.go:36.65,37.28 1 1
github.com/muety/wakapi/models/heartbeat.go:40.2,41.45 2 1 github.com/muety/wakapi/models/heartbeat.go:40.2,41.45 2 1
@ -38,71 +33,65 @@ github.com/muety/wakapi/models/heartbeats.go:34.18,36.3 1 0
github.com/muety/wakapi/models/language_mapping.go:11.42,13.2 1 0 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: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/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/summary.go:68.29,70.2 1 1
github.com/muety/wakapi/models/shared.go:34.52,37.16 3 0 github.com/muety/wakapi/models/summary.go:72.37,79.2 6 1
github.com/muety/wakapi/models/shared.go:40.2,42.12 3 0 github.com/muety/wakapi/models/summary.go:81.35,83.2 1 1
github.com/muety/wakapi/models/shared.go:37.16,39.3 1 0 github.com/muety/wakapi/models/summary.go:85.57,93.2 1 1
github.com/muety/wakapi/models/shared.go:46.52,52.22 2 0 github.com/muety/wakapi/models/summary.go:95.64,97.2 1 0
github.com/muety/wakapi/models/shared.go:68.2,71.12 3 0 github.com/muety/wakapi/models/summary.go:110.33,115.26 4 1
github.com/muety/wakapi/models/shared.go:53.14,55.17 2 0 github.com/muety/wakapi/models/summary.go:122.2,122.37 1 1
github.com/muety/wakapi/models/shared.go:58.13,60.8 2 0 github.com/muety/wakapi/models/summary.go:126.2,129.33 2 1
github.com/muety/wakapi/models/shared.go:61.17,63.8 2 0 github.com/muety/wakapi/models/summary.go:115.26,116.30 1 1
github.com/muety/wakapi/models/shared.go:64.10,65.64 1 0 github.com/muety/wakapi/models/summary.go:116.30,118.4 1 1
github.com/muety/wakapi/models/shared.go:55.17,57.4 1 0 github.com/muety/wakapi/models/summary.go:122.37,124.3 1 0
github.com/muety/wakapi/models/shared.go:74.45,76.2 1 0 github.com/muety/wakapi/models/summary.go:129.33,135.3 1 1
github.com/muety/wakapi/models/shared.go:78.51,81.2 2 0 github.com/muety/wakapi/models/summary.go:138.45,143.30 3 1
github.com/muety/wakapi/models/shared.go:83.37,86.2 2 0 github.com/muety/wakapi/models/summary.go:152.2,152.30 1 1
github.com/muety/wakapi/models/shared.go:88.35,90.2 1 0 github.com/muety/wakapi/models/summary.go:143.30,144.47 1 1
github.com/muety/wakapi/models/shared.go:92.34,94.2 1 0 github.com/muety/wakapi/models/summary.go:144.47,145.32 1 1
github.com/muety/wakapi/models/summary.go:67.29,69.2 1 1 github.com/muety/wakapi/models/summary.go:148.4,148.9 1 1
github.com/muety/wakapi/models/summary.go:71.37,78.2 6 1 github.com/muety/wakapi/models/summary.go:145.32,147.5 1 1
github.com/muety/wakapi/models/summary.go:80.35,82.2 1 1 github.com/muety/wakapi/models/summary.go:155.73,157.55 2 1
github.com/muety/wakapi/models/summary.go:84.57,92.2 1 1 github.com/muety/wakapi/models/summary.go:162.2,162.16 1 1
github.com/muety/wakapi/models/summary.go:105.33,110.26 4 1 github.com/muety/wakapi/models/summary.go:157.55,158.31 1 1
github.com/muety/wakapi/models/summary.go:117.2,117.37 1 1 github.com/muety/wakapi/models/summary.go:158.31,160.4 1 1
github.com/muety/wakapi/models/summary.go:121.2,124.33 2 1 github.com/muety/wakapi/models/summary.go:165.88,167.55 2 1
github.com/muety/wakapi/models/summary.go:110.26,111.30 1 1 github.com/muety/wakapi/models/summary.go:175.2,175.16 1 1
github.com/muety/wakapi/models/summary.go:111.30,113.4 1 1 github.com/muety/wakapi/models/summary.go:167.55,168.31 1 1
github.com/muety/wakapi/models/summary.go:117.37,119.3 1 0 github.com/muety/wakapi/models/summary.go:168.31,169.23 1 1
github.com/muety/wakapi/models/summary.go:124.33,130.3 1 1 github.com/muety/wakapi/models/summary.go:172.4,172.46 1 1
github.com/muety/wakapi/models/summary.go:133.45,138.30 3 1 github.com/muety/wakapi/models/summary.go:169.23,170.13 1 1
github.com/muety/wakapi/models/summary.go:147.2,147.30 1 1 github.com/muety/wakapi/models/summary.go:178.70,180.8 2 1
github.com/muety/wakapi/models/summary.go:138.30,139.47 1 1 github.com/muety/wakapi/models/summary.go:183.2,183.10 1 1
github.com/muety/wakapi/models/summary.go:139.47,140.32 1 1 github.com/muety/wakapi/models/summary.go:180.8,182.3 1 1
github.com/muety/wakapi/models/summary.go:143.4,143.9 1 1 github.com/muety/wakapi/models/summary.go:186.71,187.63 1 1
github.com/muety/wakapi/models/summary.go:140.32,142.5 1 1 github.com/muety/wakapi/models/summary.go:227.2,233.10 6 1
github.com/muety/wakapi/models/summary.go:150.73,152.55 2 1 github.com/muety/wakapi/models/summary.go:187.63,190.45 2 1
github.com/muety/wakapi/models/summary.go:157.2,157.16 1 1 github.com/muety/wakapi/models/summary.go:199.3,199.31 1 1
github.com/muety/wakapi/models/summary.go:152.55,153.31 1 1 github.com/muety/wakapi/models/summary.go:206.3,206.31 1 1
github.com/muety/wakapi/models/summary.go:153.31,155.4 1 1 github.com/muety/wakapi/models/summary.go:223.3,223.16 1 1
github.com/muety/wakapi/models/summary.go:160.88,162.55 2 1 github.com/muety/wakapi/models/summary.go:190.45,191.32 1 1
github.com/muety/wakapi/models/summary.go:170.2,170.16 1 1 github.com/muety/wakapi/models/summary.go:196.4,196.14 1 1
github.com/muety/wakapi/models/summary.go:162.55,163.31 1 1 github.com/muety/wakapi/models/summary.go:191.32,192.24 1 1
github.com/muety/wakapi/models/summary.go:163.31,164.23 1 1 github.com/muety/wakapi/models/summary.go:192.24,194.6 1 1
github.com/muety/wakapi/models/summary.go:167.4,167.46 1 1 github.com/muety/wakapi/models/summary.go:199.31,201.60 1 1
github.com/muety/wakapi/models/summary.go:164.23,165.13 1 1 github.com/muety/wakapi/models/summary.go:201.60,203.5 1 1
github.com/muety/wakapi/models/summary.go:173.70,175.8 2 1 github.com/muety/wakapi/models/summary.go:206.31,208.60 1 1
github.com/muety/wakapi/models/summary.go:178.2,178.10 1 1 github.com/muety/wakapi/models/summary.go:208.60,209.55 1 1
github.com/muety/wakapi/models/summary.go:175.8,177.3 1 1 github.com/muety/wakapi/models/summary.go:209.55,211.6 1 1
github.com/muety/wakapi/models/summary.go:181.71,182.63 1 1 github.com/muety/wakapi/models/summary.go:211.11,219.6 1 1
github.com/muety/wakapi/models/summary.go:222.2,228.10 6 1 github.com/muety/wakapi/models/summary.go:236.33,238.2 1 1
github.com/muety/wakapi/models/summary.go:182.63,185.45 2 1 github.com/muety/wakapi/models/summary.go:240.43,242.2 1 1
github.com/muety/wakapi/models/summary.go:194.3,194.31 1 1 github.com/muety/wakapi/models/summary.go:244.38,246.2 1 1
github.com/muety/wakapi/models/summary.go:201.3,201.31 1 1 github.com/muety/wakapi/models/user.go:46.43,49.2 1 0
github.com/muety/wakapi/models/summary.go:218.3,218.16 1 1 github.com/muety/wakapi/models/user.go:51.33,55.2 1 0
github.com/muety/wakapi/models/summary.go:185.45,186.32 1 1 github.com/muety/wakapi/models/user.go:57.45,59.2 1 0
github.com/muety/wakapi/models/summary.go:191.4,191.14 1 1 github.com/muety/wakapi/models/user.go:61.45,63.2 1 0
github.com/muety/wakapi/models/summary.go:186.32,187.24 1 1 github.com/muety/wakapi/models/alias.go:12.32,14.2 1 0
github.com/muety/wakapi/models/summary.go:187.24,189.6 1 1 github.com/muety/wakapi/models/alias.go:16.37,17.35 1 0
github.com/muety/wakapi/models/summary.go:194.31,196.60 1 1 github.com/muety/wakapi/models/alias.go:22.2,22.14 1 0
github.com/muety/wakapi/models/summary.go:196.60,198.5 1 1 github.com/muety/wakapi/models/alias.go:17.35,18.18 1 0
github.com/muety/wakapi/models/summary.go:201.31,203.60 1 1 github.com/muety/wakapi/models/alias.go:18.18,20.4 1 0
github.com/muety/wakapi/models/summary.go:203.60,204.55 1 1
github.com/muety/wakapi/models/summary.go:204.55,206.6 1 1
github.com/muety/wakapi/models/summary.go:206.11,214.6 1 1
github.com/muety/wakapi/models/summary.go:231.33,233.2 1 1
github.com/muety/wakapi/models/summary.go:235.43,237.2 1 1
github.com/muety/wakapi/models/summary.go:239.38,241.2 1 1
github.com/muety/wakapi/models/filters.go:16.56,17.16 1 0 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: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:18.22,19.32 1 0
@ -125,93 +114,34 @@ github.com/muety/wakapi/models/interval.go:39.47,40.23 1 0
github.com/muety/wakapi/models/interval.go:45.2,45.14 1 0 github.com/muety/wakapi/models/interval.go:45.2,45.14 1 0
github.com/muety/wakapi/models/interval.go:40.23,41.13 1 0 github.com/muety/wakapi/models/interval.go:40.23,41.13 1 0
github.com/muety/wakapi/models/interval.go:41.13,43.4 1 0 github.com/muety/wakapi/models/interval.go:41.13,43.4 1 0
github.com/muety/wakapi/models/user.go:40.43,43.2 1 0 github.com/muety/wakapi/models/models.go:3.14,5.2 0 1
github.com/muety/wakapi/models/user.go:45.33,49.2 1 0 github.com/muety/wakapi/models/shared.go:35.52,37.2 1 0
github.com/muety/wakapi/models/user.go:51.45,53.2 1 0 github.com/muety/wakapi/models/shared.go:39.52,42.16 3 0
github.com/muety/wakapi/models/user.go:55.45,57.2 1 0 github.com/muety/wakapi/models/shared.go:45.2,47.12 3 0
github.com/muety/wakapi/config/config.go:95.70,97.2 1 0 github.com/muety/wakapi/models/shared.go:42.16,44.3 1 0
github.com/muety/wakapi/config/config.go:99.65,101.2 1 0 github.com/muety/wakapi/models/shared.go:51.52,57.22 2 0
github.com/muety/wakapi/config/config.go:103.82,113.2 1 0 github.com/muety/wakapi/models/shared.go:73.2,76.12 3 0
github.com/muety/wakapi/config/config.go:115.31,117.2 1 0 github.com/muety/wakapi/models/shared.go:58.14,60.17 2 0
github.com/muety/wakapi/config/config.go:119.32,121.2 1 0 github.com/muety/wakapi/models/shared.go:63.13,65.8 2 0
github.com/muety/wakapi/config/config.go:123.74,124.19 1 0 github.com/muety/wakapi/models/shared.go:66.17,68.8 2 0
github.com/muety/wakapi/config/config.go:125.10,126.34 1 0 github.com/muety/wakapi/models/shared.go:69.10,70.64 1 0
github.com/muety/wakapi/config/config.go:126.34,135.4 8 0 github.com/muety/wakapi/models/shared.go:60.17,62.4 1 0
github.com/muety/wakapi/config/config.go:139.73,140.33 1 0 github.com/muety/wakapi/models/shared.go:79.45,81.2 1 0
github.com/muety/wakapi/config/config.go:140.33,148.17 5 0 github.com/muety/wakapi/models/shared.go:83.51,86.2 2 0
github.com/muety/wakapi/config/config.go:152.3,153.13 2 0 github.com/muety/wakapi/models/shared.go:88.37,91.2 2 0
github.com/muety/wakapi/config/config.go:148.17,150.4 1 0 github.com/muety/wakapi/models/shared.go:93.35,95.2 1 0
github.com/muety/wakapi/config/config.go:157.50,158.19 1 0 github.com/muety/wakapi/models/shared.go:97.34,99.2 1 0
github.com/muety/wakapi/config/config.go:171.2,171.12 1 0 github.com/muety/wakapi/utils/color.go:8.90,10.32 2 0
github.com/muety/wakapi/config/config.go:159.23,163.5 1 0 github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0
github.com/muety/wakapi/config/config.go:164.26,167.5 1 0 github.com/muety/wakapi/utils/color.go:10.32,11.50 1 0
github.com/muety/wakapi/config/config.go:168.24,169.48 1 0 github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0
github.com/muety/wakapi/config/config.go:174.53,184.2 1 1 github.com/muety/wakapi/utils/common.go:10.48,12.2 1 0
github.com/muety/wakapi/config/config.go:186.56,188.16 2 1 github.com/muety/wakapi/utils/common.go:14.40,16.2 1 0
github.com/muety/wakapi/config/config.go:192.2,199.3 1 1 github.com/muety/wakapi/utils/common.go:18.45,20.2 1 0
github.com/muety/wakapi/config/config.go:188.16,190.3 1 0 github.com/muety/wakapi/utils/common.go:22.24,24.2 1 0
github.com/muety/wakapi/config/config.go:202.54,204.2 1 1 github.com/muety/wakapi/utils/common.go:26.56,29.45 3 1
github.com/muety/wakapi/config/config.go:206.60,208.2 1 0 github.com/muety/wakapi/utils/common.go:32.2,32.40 1 1
github.com/muety/wakapi/config/config.go:210.59,212.2 1 0 github.com/muety/wakapi/utils/common.go:29.45,31.3 1 1
github.com/muety/wakapi/config/config.go:214.57,216.2 1 0
github.com/muety/wakapi/config/config.go:218.53,220.2 1 0
github.com/muety/wakapi/config/config.go:222.29,224.2 1 1
github.com/muety/wakapi/config/config.go:226.27,228.16 2 0
github.com/muety/wakapi/config/config.go:231.2,234.16 3 0
github.com/muety/wakapi/config/config.go:238.2,238.41 1 0
github.com/muety/wakapi/config/config.go:228.16,230.3 1 0
github.com/muety/wakapi/config/config.go:234.16,236.3 1 0
github.com/muety/wakapi/config/config.go:241.48,253.16 3 0
github.com/muety/wakapi/config/config.go:256.2,258.16 3 0
github.com/muety/wakapi/config/config.go:262.2,262.55 1 0
github.com/muety/wakapi/config/config.go:266.2,266.15 1 0
github.com/muety/wakapi/config/config.go:253.16,255.3 1 0
github.com/muety/wakapi/config/config.go:258.16,260.3 1 0
github.com/muety/wakapi/config/config.go:262.55,264.3 1 0
github.com/muety/wakapi/config/config.go:269.38,270.43 1 0
github.com/muety/wakapi/config/config.go:273.2,273.15 1 0
github.com/muety/wakapi/config/config.go:270.43,272.3 1 0
github.com/muety/wakapi/config/config.go:276.45,277.27 1 0
github.com/muety/wakapi/config/config.go:280.2,280.15 1 0
github.com/muety/wakapi/config/config.go:277.27,279.3 1 0
github.com/muety/wakapi/config/config.go:283.26,285.2 1 0
github.com/muety/wakapi/config/config.go:287.20,289.2 1 0
github.com/muety/wakapi/config/config.go:291.21,296.96 3 0
github.com/muety/wakapi/config/config.go:300.2,308.52 5 0
github.com/muety/wakapi/config/config.go:312.2,312.47 1 0
github.com/muety/wakapi/config/config.go:318.2,318.70 1 0
github.com/muety/wakapi/config/config.go:322.2,322.28 1 0
github.com/muety/wakapi/config/config.go:326.2,327.14 2 0
github.com/muety/wakapi/config/config.go:296.96,298.3 1 0
github.com/muety/wakapi/config/config.go:308.52,310.3 1 0
github.com/muety/wakapi/config/config.go:312.47,313.14 1 0
github.com/muety/wakapi/config/config.go:313.14,315.4 1 0
github.com/muety/wakapi/config/config.go:318.70,320.3 1 0
github.com/muety/wakapi/config/config.go:322.28,324.3 1 0
github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0
github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0
github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0
github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0
github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0
github.com/muety/wakapi/utils/auth.go:16.79,18.54 2 0
github.com/muety/wakapi/utils/auth.go:22.2,24.16 3 0
github.com/muety/wakapi/utils/auth.go:28.2,30.45 3 0
github.com/muety/wakapi/utils/auth.go:33.2,34.32 2 0
github.com/muety/wakapi/utils/auth.go:18.54,20.3 1 0
github.com/muety/wakapi/utils/auth.go:24.16,26.3 1 0
github.com/muety/wakapi/utils/auth.go:30.45,32.3 1 0
github.com/muety/wakapi/utils/auth.go:37.65,39.54 2 0
github.com/muety/wakapi/utils/auth.go:43.2,44.30 2 0
github.com/muety/wakapi/utils/auth.go:39.54,41.3 1 0
github.com/muety/wakapi/utils/auth.go:47.94,49.16 2 0
github.com/muety/wakapi/utils/auth.go:53.2,53.107 1 0
github.com/muety/wakapi/utils/auth.go:57.2,57.22 1 0
github.com/muety/wakapi/utils/auth.go:49.16,51.3 1 0
github.com/muety/wakapi/utils/auth.go:53.107,55.3 1 0
github.com/muety/wakapi/utils/auth.go:60.56,64.2 3 0
github.com/muety/wakapi/utils/auth.go:66.55,69.16 3 0
github.com/muety/wakapi/utils/auth.go:72.2,72.16 1 0
github.com/muety/wakapi/utils/auth.go:69.16,71.3 1 0
github.com/muety/wakapi/utils/date.go:8.31,10.2 1 0 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: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:16.30,20.2 3 0
@ -230,28 +160,33 @@ 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: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:63.21,66.3 2 0
github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0 github.com/muety/wakapi/utils/date.go:67.21,70.3 2 0
github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0 github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0
github.com/muety/wakapi/utils/strings.go:12.77,13.29 1 0 github.com/muety/wakapi/utils/http.go:12.58,14.3 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/utils/template.go:8.41,10.16 2 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:13.2,13.23 1 0
github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0 github.com/muety/wakapi/utils/template.go:10.16,12.3 1 0
github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0 github.com/muety/wakapi/utils/template.go:16.37,17.30 1 0
github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0 github.com/muety/wakapi/utils/template.go:20.2,20.10 1 0
github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0 github.com/muety/wakapi/utils/template.go:17.30,19.3 1 0
github.com/muety/wakapi/utils/color.go:8.90,10.32 2 0 github.com/muety/wakapi/utils/auth.go:16.79,18.54 2 0
github.com/muety/wakapi/utils/color.go:15.2,15.15 1 0 github.com/muety/wakapi/utils/auth.go:22.2,24.16 3 0
github.com/muety/wakapi/utils/color.go:10.32,11.50 1 0 github.com/muety/wakapi/utils/auth.go:28.2,30.45 3 0
github.com/muety/wakapi/utils/color.go:11.50,13.4 1 0 github.com/muety/wakapi/utils/auth.go:33.2,34.32 2 0
github.com/muety/wakapi/utils/common.go:9.48,11.2 1 0 github.com/muety/wakapi/utils/auth.go:18.54,20.3 1 0
github.com/muety/wakapi/utils/common.go:13.40,15.2 1 0 github.com/muety/wakapi/utils/auth.go:24.16,26.3 1 0
github.com/muety/wakapi/utils/common.go:17.45,19.2 1 0 github.com/muety/wakapi/utils/auth.go:30.45,32.3 1 0
github.com/muety/wakapi/utils/common.go:21.24,23.2 1 0 github.com/muety/wakapi/utils/auth.go:37.65,39.85 2 0
github.com/muety/wakapi/utils/common.go:25.56,28.45 3 1 github.com/muety/wakapi/utils/auth.go:43.2,44.30 2 0
github.com/muety/wakapi/utils/common.go:31.2,31.40 1 1 github.com/muety/wakapi/utils/auth.go:39.85,41.3 1 0
github.com/muety/wakapi/utils/common.go:28.45,30.3 1 1 github.com/muety/wakapi/utils/auth.go:47.94,49.16 2 0
github.com/muety/wakapi/utils/auth.go:53.2,53.107 1 0
github.com/muety/wakapi/utils/auth.go:57.2,57.22 1 0
github.com/muety/wakapi/utils/auth.go:49.16,51.3 1 0
github.com/muety/wakapi/utils/auth.go:53.107,55.3 1 0
github.com/muety/wakapi/utils/auth.go:60.56,64.2 3 0
github.com/muety/wakapi/utils/auth.go:66.55,69.16 3 0
github.com/muety/wakapi/utils/auth.go:72.2,72.16 1 0
github.com/muety/wakapi/utils/auth.go:69.16,71.3 1 0
github.com/muety/wakapi/utils/filesystem.go:14.68,16.16 2 0 github.com/muety/wakapi/utils/filesystem.go:14.68,16.16 2 0
github.com/muety/wakapi/utils/filesystem.go:20.2,21.15 2 0 github.com/muety/wakapi/utils/filesystem.go:20.2,21.15 2 0
github.com/muety/wakapi/utils/filesystem.go:33.2,33.15 1 0 github.com/muety/wakapi/utils/filesystem.go:33.2,33.15 1 0
@ -260,156 +195,191 @@ github.com/muety/wakapi/utils/filesystem.go:21.15,23.47 2 0
github.com/muety/wakapi/utils/filesystem.go:23.47,25.23 2 0 github.com/muety/wakapi/utils/filesystem.go:23.47,25.23 2 0
github.com/muety/wakapi/utils/filesystem.go:29.4,29.19 1 0 github.com/muety/wakapi/utils/filesystem.go:29.4,29.19 1 0
github.com/muety/wakapi/utils/filesystem.go:25.23,27.5 1 0 github.com/muety/wakapi/utils/filesystem.go:25.23,27.5 1 0
github.com/muety/wakapi/utils/http.go:9.73,12.58 3 0 github.com/muety/wakapi/utils/strings.go:8.34,10.2 1 0
github.com/muety/wakapi/utils/http.go:12.58,14.3 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/utils/summary.go:10.66,11.40 1 0 github.com/muety/wakapi/utils/summary.go:10.66,11.40 1 0
github.com/muety/wakapi/utils/summary.go:16.2,16.48 1 0 github.com/muety/wakapi/utils/summary.go:16.2,16.48 1 0
github.com/muety/wakapi/utils/summary.go:11.40,12.27 1 0 github.com/muety/wakapi/utils/summary.go:11.40,12.27 1 0
github.com/muety/wakapi/utils/summary.go:12.27,14.4 1 0 github.com/muety/wakapi/utils/summary.go:12.27,14.4 1 0
github.com/muety/wakapi/utils/summary.go:19.74,21.16 2 0 github.com/muety/wakapi/utils/summary.go:19.67,22.2 2 0
github.com/muety/wakapi/utils/summary.go:24.2,24.32 1 0 github.com/muety/wakapi/utils/summary.go:24.74,26.16 2 0
github.com/muety/wakapi/utils/summary.go:21.16,23.3 1 0 github.com/muety/wakapi/utils/summary.go:29.2,29.32 1 0
github.com/muety/wakapi/utils/summary.go:27.84,30.18 2 0 github.com/muety/wakapi/utils/summary.go:26.16,28.3 1 0
github.com/muety/wakapi/utils/summary.go:65.2,65.22 1 0 github.com/muety/wakapi/utils/summary.go:32.84,35.18 2 0
github.com/muety/wakapi/utils/summary.go:31.28,32.24 1 0 github.com/muety/wakapi/utils/summary.go:70.2,70.22 1 0
github.com/muety/wakapi/utils/summary.go:33.32,35.22 2 0 github.com/muety/wakapi/utils/summary.go:36.28,37.24 1 0
github.com/muety/wakapi/utils/summary.go:36.31,37.23 1 0 github.com/muety/wakapi/utils/summary.go:38.32,40.22 2 0
github.com/muety/wakapi/utils/summary.go:38.31,40.21 2 0 github.com/muety/wakapi/utils/summary.go:41.31,42.23 1 0
github.com/muety/wakapi/utils/summary.go:41.32,42.24 1 0 github.com/muety/wakapi/utils/summary.go:43.31,45.21 2 0
github.com/muety/wakapi/utils/summary.go:43.32,45.22 2 0 github.com/muety/wakapi/utils/summary.go:46.32,47.24 1 0
github.com/muety/wakapi/utils/summary.go:46.31,47.23 1 0 github.com/muety/wakapi/utils/summary.go:48.32,50.22 2 0
github.com/muety/wakapi/utils/summary.go:48.32,49.42 1 0 github.com/muety/wakapi/utils/summary.go:51.31,52.23 1 0
github.com/muety/wakapi/utils/summary.go:50.41,52.40 2 0 github.com/muety/wakapi/utils/summary.go:53.32,54.42 1 0
github.com/muety/wakapi/utils/summary.go:53.33,54.43 1 0 github.com/muety/wakapi/utils/summary.go:55.41,57.40 2 0
github.com/muety/wakapi/utils/summary.go:55.33,56.43 1 0 github.com/muety/wakapi/utils/summary.go:58.33,59.43 1 0
github.com/muety/wakapi/utils/summary.go:57.35,58.43 1 0 github.com/muety/wakapi/utils/summary.go:60.33,61.43 1 0
github.com/muety/wakapi/utils/summary.go:59.26,60.21 1 0 github.com/muety/wakapi/utils/summary.go:62.35,63.43 1 0
github.com/muety/wakapi/utils/summary.go:61.10,62.39 1 0 github.com/muety/wakapi/utils/summary.go:64.26,65.21 1 0
github.com/muety/wakapi/utils/summary.go:68.73,75.56 5 0 github.com/muety/wakapi/utils/summary.go:66.10,67.39 1 0
github.com/muety/wakapi/utils/summary.go:89.2,96.8 2 0 github.com/muety/wakapi/utils/summary.go:73.73,80.56 5 0
github.com/muety/wakapi/utils/summary.go:75.56,77.3 1 0 github.com/muety/wakapi/utils/summary.go:96.2,103.8 2 0
github.com/muety/wakapi/utils/summary.go:77.8,79.17 2 0 github.com/muety/wakapi/utils/summary.go:80.56,82.3 1 0
github.com/muety/wakapi/utils/summary.go:83.3,84.17 2 0 github.com/muety/wakapi/utils/summary.go:82.8,82.54 1 0
github.com/muety/wakapi/utils/summary.go:79.17,81.4 1 0 github.com/muety/wakapi/utils/summary.go:82.54,84.3 1 0
github.com/muety/wakapi/utils/summary.go:84.17,86.4 1 0 github.com/muety/wakapi/utils/summary.go:84.8,86.17 2 0
github.com/muety/wakapi/utils/summary.go:90.3,91.17 2 0
github.com/muety/wakapi/utils/summary.go:86.17,88.4 1 0
github.com/muety/wakapi/utils/summary.go:91.17,93.4 1 0
github.com/muety/wakapi/config/config.go:103.70,105.2 1 0
github.com/muety/wakapi/config/config.go:107.65,109.2 1 0
github.com/muety/wakapi/config/config.go:111.82,121.2 1 0
github.com/muety/wakapi/config/config.go:123.31,125.2 1 0
github.com/muety/wakapi/config/config.go:127.32,129.2 1 0
github.com/muety/wakapi/config/config.go:131.74,132.19 1 0
github.com/muety/wakapi/config/config.go:133.10,134.34 1 0
github.com/muety/wakapi/config/config.go:134.34,143.4 8 0
github.com/muety/wakapi/config/config.go:147.73,148.33 1 0
github.com/muety/wakapi/config/config.go:148.33,156.17 5 0
github.com/muety/wakapi/config/config.go:160.3,161.13 2 0
github.com/muety/wakapi/config/config.go:156.17,158.4 1 0
github.com/muety/wakapi/config/config.go:165.50,166.19 1 0
github.com/muety/wakapi/config/config.go:179.2,179.12 1 0
github.com/muety/wakapi/config/config.go:167.23,171.5 1 0
github.com/muety/wakapi/config/config.go:172.26,175.5 1 0
github.com/muety/wakapi/config/config.go:176.24,177.48 1 0
github.com/muety/wakapi/config/config.go:182.53,192.2 1 1
github.com/muety/wakapi/config/config.go:194.56,196.16 2 1
github.com/muety/wakapi/config/config.go:200.2,207.3 1 1
github.com/muety/wakapi/config/config.go:196.16,198.3 1 0
github.com/muety/wakapi/config/config.go:210.54,212.2 1 1
github.com/muety/wakapi/config/config.go:214.60,216.2 1 0
github.com/muety/wakapi/config/config.go:218.59,220.2 1 0
github.com/muety/wakapi/config/config.go:222.57,224.2 1 0
github.com/muety/wakapi/config/config.go:226.53,228.2 1 0
github.com/muety/wakapi/config/config.go:230.29,232.2 1 1
github.com/muety/wakapi/config/config.go:234.27,236.16 2 0
github.com/muety/wakapi/config/config.go:239.2,242.16 3 0
github.com/muety/wakapi/config/config.go:246.2,246.41 1 0
github.com/muety/wakapi/config/config.go:236.16,238.3 1 0
github.com/muety/wakapi/config/config.go:242.16,244.3 1 0
github.com/muety/wakapi/config/config.go:249.48,261.16 3 0
github.com/muety/wakapi/config/config.go:264.2,266.16 3 0
github.com/muety/wakapi/config/config.go:270.2,270.55 1 0
github.com/muety/wakapi/config/config.go:274.2,274.15 1 0
github.com/muety/wakapi/config/config.go:261.16,263.3 1 0
github.com/muety/wakapi/config/config.go:266.16,268.3 1 0
github.com/muety/wakapi/config/config.go:270.55,272.3 1 0
github.com/muety/wakapi/config/config.go:277.38,278.43 1 0
github.com/muety/wakapi/config/config.go:281.2,281.15 1 0
github.com/muety/wakapi/config/config.go:278.43,280.3 1 0
github.com/muety/wakapi/config/config.go:284.45,285.27 1 0
github.com/muety/wakapi/config/config.go:288.2,288.15 1 0
github.com/muety/wakapi/config/config.go:285.27,287.3 1 0
github.com/muety/wakapi/config/config.go:291.26,293.2 1 0
github.com/muety/wakapi/config/config.go:295.20,297.2 1 0
github.com/muety/wakapi/config/config.go:299.21,304.96 3 0
github.com/muety/wakapi/config/config.go:308.2,316.52 5 0
github.com/muety/wakapi/config/config.go:320.2,320.47 1 0
github.com/muety/wakapi/config/config.go:326.2,326.70 1 0
github.com/muety/wakapi/config/config.go:330.2,330.28 1 0
github.com/muety/wakapi/config/config.go:334.2,335.14 2 0
github.com/muety/wakapi/config/config.go:304.96,306.3 1 0
github.com/muety/wakapi/config/config.go:316.52,318.3 1 0
github.com/muety/wakapi/config/config.go:320.47,321.14 1 0
github.com/muety/wakapi/config/config.go:321.14,323.4 1 0
github.com/muety/wakapi/config/config.go:326.70,328.3 1 0
github.com/muety/wakapi/config/config.go:330.28,332.3 1 0
github.com/muety/wakapi/config/utils.go:5.78,7.22 2 0
github.com/muety/wakapi/config/utils.go:13.2,13.11 1 0
github.com/muety/wakapi/config/utils.go:7.22,8.18 1 0
github.com/muety/wakapi/config/utils.go:11.3,11.12 1 0
github.com/muety/wakapi/config/utils.go:8.18,10.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:20.91,26.2 1 1 github.com/muety/wakapi/middlewares/authenticate.go:20.91,26.2 1 1
github.com/muety/wakapi/middlewares/authenticate.go:28.90,31.2 2 0 github.com/muety/wakapi/middlewares/authenticate.go:28.90,31.2 2 0
github.com/muety/wakapi/middlewares/authenticate.go:33.71,34.71 1 0 github.com/muety/wakapi/middlewares/authenticate.go:33.90,36.2 2 0
github.com/muety/wakapi/middlewares/authenticate.go:34.71,36.3 1 0 github.com/muety/wakapi/middlewares/authenticate.go:38.71,39.71 1 0
github.com/muety/wakapi/middlewares/authenticate.go:39.107,43.16 3 0 github.com/muety/wakapi/middlewares/authenticate.go:39.71,41.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:47.2,47.31 1 0 github.com/muety/wakapi/middlewares/authenticate.go:44.107,48.16 3 0
github.com/muety/wakapi/middlewares/authenticate.go:62.2,63.29 2 0 github.com/muety/wakapi/middlewares/authenticate.go:52.2,52.31 1 0
github.com/muety/wakapi/middlewares/authenticate.go:43.16,45.3 1 0 github.com/muety/wakapi/middlewares/authenticate.go:68.2,69.29 2 0
github.com/muety/wakapi/middlewares/authenticate.go:47.31,48.31 1 0 github.com/muety/wakapi/middlewares/authenticate.go:48.16,50.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:53.3,53.44 1 0 github.com/muety/wakapi/middlewares/authenticate.go:52.31,53.31 1 0
github.com/muety/wakapi/middlewares/authenticate.go:59.3,59.9 1 0 github.com/muety/wakapi/middlewares/authenticate.go:58.3,58.29 1 0
github.com/muety/wakapi/middlewares/authenticate.go:48.31,51.4 2 0 github.com/muety/wakapi/middlewares/authenticate.go:65.3,65.9 1 0
github.com/muety/wakapi/middlewares/authenticate.go:53.44,55.4 1 0 github.com/muety/wakapi/middlewares/authenticate.go:53.31,56.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:55.9,58.4 2 0 github.com/muety/wakapi/middlewares/authenticate.go:58.29,61.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:66.70,67.39 1 0 github.com/muety/wakapi/middlewares/authenticate.go:61.9,64.4 2 0
github.com/muety/wakapi/middlewares/authenticate.go:72.2,72.14 1 0 github.com/muety/wakapi/middlewares/authenticate.go:72.70,73.39 1 0
github.com/muety/wakapi/middlewares/authenticate.go:67.39,68.60 1 0 github.com/muety/wakapi/middlewares/authenticate.go:78.2,78.14 1 0
github.com/muety/wakapi/middlewares/authenticate.go:68.60,70.4 1 0 github.com/muety/wakapi/middlewares/authenticate.go:73.39,74.60 1 0
github.com/muety/wakapi/middlewares/authenticate.go:75.92,77.16 2 1 github.com/muety/wakapi/middlewares/authenticate.go:74.60,76.4 1 0
github.com/muety/wakapi/middlewares/authenticate.go:81.2,84.16 4 1 github.com/muety/wakapi/middlewares/authenticate.go:81.92,83.16 2 1
github.com/muety/wakapi/middlewares/authenticate.go:87.2,87.18 1 1 github.com/muety/wakapi/middlewares/authenticate.go:87.2,90.16 4 1
github.com/muety/wakapi/middlewares/authenticate.go:77.16,79.3 1 1 github.com/muety/wakapi/middlewares/authenticate.go:93.2,93.18 1 1
github.com/muety/wakapi/middlewares/authenticate.go:84.16,86.3 1 0 github.com/muety/wakapi/middlewares/authenticate.go:83.16,85.3 1 1
github.com/muety/wakapi/middlewares/authenticate.go:90.92,92.16 2 0 github.com/muety/wakapi/middlewares/authenticate.go:90.16,92.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:96.2,97.16 2 0 github.com/muety/wakapi/middlewares/authenticate.go:96.92,98.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:104.2,104.18 1 0 github.com/muety/wakapi/middlewares/authenticate.go:102.2,103.16 2 0
github.com/muety/wakapi/middlewares/authenticate.go:92.16,94.3 1 0 github.com/muety/wakapi/middlewares/authenticate.go:110.2,110.18 1 0
github.com/muety/wakapi/middlewares/authenticate.go:97.16,99.3 1 0 github.com/muety/wakapi/middlewares/authenticate.go:98.16,100.3 1 0
github.com/muety/wakapi/middlewares/authenticate.go:103.16,105.3 1 0
github.com/muety/wakapi/middlewares/filetype.go:13.83,14.43 1 0 github.com/muety/wakapi/middlewares/filetype.go:13.83,14.43 1 0
github.com/muety/wakapi/middlewares/filetype.go:14.43,19.3 1 0 github.com/muety/wakapi/middlewares/filetype.go:14.43,19.3 1 0
github.com/muety/wakapi/middlewares/filetype.go:22.84,24.34 2 0 github.com/muety/wakapi/middlewares/filetype.go:22.84,24.34 2 0
github.com/muety/wakapi/middlewares/filetype.go:31.2,31.27 1 0 github.com/muety/wakapi/middlewares/filetype.go:31.2,31.27 1 0
github.com/muety/wakapi/middlewares/filetype.go:24.34,25.50 1 0 github.com/muety/wakapi/middlewares/filetype.go:24.34,25.50 1 0
github.com/muety/wakapi/middlewares/filetype.go:25.50,29.4 3 0 github.com/muety/wakapi/middlewares/filetype.go:25.50,29.4 3 0
github.com/muety/wakapi/middlewares/logging.go:17.79,18.43 1 0 github.com/muety/wakapi/middlewares/logging.go:19.105,20.43 1 0
github.com/muety/wakapi/middlewares/logging.go:18.43,23.3 1 0 github.com/muety/wakapi/middlewares/logging.go:20.43,26.3 1 0
github.com/muety/wakapi/middlewares/logging.go:26.80,44.2 6 0 github.com/muety/wakapi/middlewares/logging.go:29.80,38.44 7 0
github.com/muety/wakapi/middlewares/logging.go:46.41,48.14 2 0 github.com/muety/wakapi/middlewares/logging.go:44.2,53.3 1 0
github.com/muety/wakapi/middlewares/logging.go:51.2,51.14 1 0 github.com/muety/wakapi/middlewares/logging.go:38.44,39.38 1 0
github.com/muety/wakapi/middlewares/logging.go:54.2,54.11 1 0 github.com/muety/wakapi/middlewares/logging.go:39.38,41.4 1 0
github.com/muety/wakapi/middlewares/logging.go:48.14,50.3 1 0 github.com/muety/wakapi/middlewares/logging.go:56.41,58.14 2 0
github.com/muety/wakapi/middlewares/logging.go:51.14,53.3 1 0 github.com/muety/wakapi/middlewares/logging.go:61.2,61.14 1 0
github.com/muety/wakapi/middlewares/logging.go:85.52,87.2 1 0 github.com/muety/wakapi/middlewares/logging.go:64.2,64.11 1 0
github.com/muety/wakapi/middlewares/logging.go:99.45,100.20 1 0 github.com/muety/wakapi/middlewares/logging.go:58.14,60.3 1 0
github.com/muety/wakapi/middlewares/logging.go:100.20,104.3 3 0 github.com/muety/wakapi/middlewares/logging.go:61.14,63.3 1 0
github.com/muety/wakapi/middlewares/logging.go:106.54,109.18 3 0 github.com/muety/wakapi/middlewares/logging.go:95.52,97.2 1 0
github.com/muety/wakapi/middlewares/logging.go:116.2,117.15 2 0 github.com/muety/wakapi/middlewares/logging.go:109.45,110.20 1 0
github.com/muety/wakapi/middlewares/logging.go:109.18,112.17 2 0 github.com/muety/wakapi/middlewares/logging.go:110.20,114.3 3 0
github.com/muety/wakapi/middlewares/logging.go:112.17,114.4 1 0 github.com/muety/wakapi/middlewares/logging.go:116.54,119.18 3 0
github.com/muety/wakapi/middlewares/logging.go:119.42,120.20 1 0 github.com/muety/wakapi/middlewares/logging.go:126.2,127.15 2 0
github.com/muety/wakapi/middlewares/logging.go:120.20,122.3 1 0 github.com/muety/wakapi/middlewares/logging.go:119.18,122.17 2 0
github.com/muety/wakapi/middlewares/logging.go:124.36,126.2 1 0 github.com/muety/wakapi/middlewares/logging.go:122.17,124.4 1 0
github.com/muety/wakapi/middlewares/logging.go:127.42,129.2 1 0 github.com/muety/wakapi/middlewares/logging.go:129.42,130.20 1 0
github.com/muety/wakapi/middlewares/logging.go:130.40,132.2 1 0 github.com/muety/wakapi/middlewares/logging.go:130.20,132.3 1 0
github.com/muety/wakapi/middlewares/logging.go:133.52,135.2 1 0 github.com/muety/wakapi/middlewares/logging.go:134.36,136.2 1 0
github.com/muety/wakapi/middlewares/logging.go:137.42,139.2 1 0
github.com/muety/wakapi/middlewares/logging.go:140.40,142.2 1 0
github.com/muety/wakapi/middlewares/logging.go:143.52,145.2 1 0
github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0 github.com/muety/wakapi/services/heartbeat.go:17.141,23.2 1 0
github.com/muety/wakapi/services/heartbeat.go:25.72,27.2 1 0 github.com/muety/wakapi/services/heartbeat.go:25.72,27.2 1 0
github.com/muety/wakapi/services/heartbeat.go:29.80,31.2 1 0 github.com/muety/wakapi/services/heartbeat.go:29.80,31.2 1 0
github.com/muety/wakapi/services/heartbeat.go:33.76,35.2 1 0 github.com/muety/wakapi/services/heartbeat.go:33.53,35.2 1 0
github.com/muety/wakapi/services/heartbeat.go:37.111,39.16 2 0 github.com/muety/wakapi/services/heartbeat.go:37.76,39.2 1 0
github.com/muety/wakapi/services/heartbeat.go:42.2,42.43 1 0 github.com/muety/wakapi/services/heartbeat.go:41.96,43.2 1 0
github.com/muety/wakapi/services/heartbeat.go:39.16,41.3 1 0 github.com/muety/wakapi/services/heartbeat.go:45.111,47.16 2 0
github.com/muety/wakapi/services/heartbeat.go:45.116,47.2 1 0 github.com/muety/wakapi/services/heartbeat.go:50.2,50.43 1 0
github.com/muety/wakapi/services/heartbeat.go:49.78,51.2 1 0 github.com/muety/wakapi/services/heartbeat.go:47.16,49.3 1 0
github.com/muety/wakapi/services/heartbeat.go:53.62,55.2 1 0 github.com/muety/wakapi/services/heartbeat.go:53.116,55.2 1 0
github.com/muety/wakapi/services/heartbeat.go:57.116,59.16 2 0 github.com/muety/wakapi/services/heartbeat.go:57.78,59.2 1 0
github.com/muety/wakapi/services/heartbeat.go:63.2,63.28 1 0 github.com/muety/wakapi/services/heartbeat.go:61.62,63.2 1 0
github.com/muety/wakapi/services/heartbeat.go:67.2,67.24 1 0 github.com/muety/wakapi/services/heartbeat.go:65.116,67.16 2 0
github.com/muety/wakapi/services/heartbeat.go:59.16,61.3 1 0 github.com/muety/wakapi/services/heartbeat.go:71.2,71.28 1 0
github.com/muety/wakapi/services/heartbeat.go:63.28,65.3 1 0 github.com/muety/wakapi/services/heartbeat.go:75.2,75.24 1 0
github.com/muety/wakapi/services/misc.go:23.126,30.2 1 0 github.com/muety/wakapi/services/heartbeat.go:67.16,69.3 1 0
github.com/muety/wakapi/services/misc.go:42.50,44.48 1 0 github.com/muety/wakapi/services/heartbeat.go:71.28,73.3 1 0
github.com/muety/wakapi/services/misc.go:48.2,50.19 3 0 github.com/muety/wakapi/services/key_value.go:14.89,19.2 1 0
github.com/muety/wakapi/services/misc.go:44.48,46.3 1 0 github.com/muety/wakapi/services/key_value.go:21.83,23.2 1 0
github.com/muety/wakapi/services/misc.go:53.51,59.40 4 0 github.com/muety/wakapi/services/key_value.go:25.78,27.16 2 0
github.com/muety/wakapi/services/misc.go:63.2,66.56 2 0 github.com/muety/wakapi/services/key_value.go:33.2,33.11 1 0
github.com/muety/wakapi/services/misc.go:77.2,77.12 1 0 github.com/muety/wakapi/services/key_value.go:27.16,32.3 1 0
github.com/muety/wakapi/services/misc.go:59.40,61.3 1 0 github.com/muety/wakapi/services/key_value.go:36.72,38.2 1 0
github.com/muety/wakapi/services/misc.go:66.56,67.27 1 0 github.com/muety/wakapi/services/key_value.go:40.60,42.2 1 0
github.com/muety/wakapi/services/misc.go:67.27,72.4 1 0
github.com/muety/wakapi/services/misc.go:73.8,75.3 1 0
github.com/muety/wakapi/services/misc.go:80.116,81.24 1 0
github.com/muety/wakapi/services/misc.go:81.24,82.144 1 0
github.com/muety/wakapi/services/misc.go:91.3,91.48 1 0
github.com/muety/wakapi/services/misc.go:82.144,84.4 1 0
github.com/muety/wakapi/services/misc.go:84.9,90.4 2 0
github.com/muety/wakapi/services/misc.go:91.48,94.4 2 0
github.com/muety/wakapi/services/misc.go:98.86,101.30 3 0
github.com/muety/wakapi/services/misc.go:106.2,109.17 1 0
github.com/muety/wakapi/services/misc.go:113.2,116.17 1 0
github.com/muety/wakapi/services/misc.go:101.30,104.3 2 0
github.com/muety/wakapi/services/misc.go:109.17,111.3 1 0
github.com/muety/wakapi/services/misc.go:116.17,118.3 1 0
github.com/muety/wakapi/services/user.go:19.73,25.2 1 0
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
github.com/muety/wakapi/services/user.go:55.58,57.2 1 0
github.com/muety/wakapi/services/user.go:59.88,66.93 2 0
github.com/muety/wakapi/services/user.go:72.2,72.38 1 0
github.com/muety/wakapi/services/user.go:66.93,68.3 1 0
github.com/muety/wakapi/services/user.go:68.8,70.3 1 0
github.com/muety/wakapi/services/user.go:75.73,78.2 2 0
github.com/muety/wakapi/services/user.go:80.78,84.2 3 0
github.com/muety/wakapi/services/user.go:86.99,89.2 2 0
github.com/muety/wakapi/services/user.go:91.106,94.96 3 0
github.com/muety/wakapi/services/user.go:99.2,99.68 1 0
github.com/muety/wakapi/services/user.go:94.96,96.3 1 0
github.com/muety/wakapi/services/user.go:96.8,98.3 1 0
github.com/muety/wakapi/services/user.go:102.57,105.2 2 0
github.com/muety/wakapi/services/user.go:107.38,109.2 1 0
github.com/muety/wakapi/services/aggregation.go:24.142,31.2 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: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:46.2,48.19 3 0
@ -485,32 +455,6 @@ github.com/muety/wakapi/services/alias.go:95.21,97.4 1 0
github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0 github.com/muety/wakapi/services/alias.go:104.31,106.3 1 0
github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0 github.com/muety/wakapi/services/alias.go:111.52,112.51 1 0
github.com/muety/wakapi/services/alias.go:112.51,114.3 1 0 github.com/muety/wakapi/services/alias.go:112.51,114.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.78,27.16 2 0
github.com/muety/wakapi/services/key_value.go:33.2,33.11 1 0
github.com/muety/wakapi/services/key_value.go:27.16,32.3 1 0
github.com/muety/wakapi/services/key_value.go:36.72,38.2 1 0
github.com/muety/wakapi/services/key_value.go:40.60,42.2 1 0
github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0
github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0
github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0
github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0
github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0
github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0
github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0
github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0
github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0
github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0
github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0
github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0
github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0
github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0
github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0
github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0
github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0
github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0
github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0
github.com/muety/wakapi/services/summary.go:27.149,35.2 1 1 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: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:47.2,47.44 1 1
@ -600,3 +544,72 @@ 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: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:336.2,336.32 1 1
github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1 github.com/muety/wakapi/services/summary.go:333.25,335.3 1 1
github.com/muety/wakapi/services/user.go:19.73,25.2 1 0
github.com/muety/wakapi/services/user.go:27.74,28.40 1 0
github.com/muety/wakapi/services/user.go:32.2,33.16 2 0
github.com/muety/wakapi/services/user.go:37.2,38.15 2 0
github.com/muety/wakapi/services/user.go:28.40,30.3 1 0
github.com/muety/wakapi/services/user.go:33.16,35.3 1 0
github.com/muety/wakapi/services/user.go:41.72,42.37 1 0
github.com/muety/wakapi/services/user.go:46.2,47.16 2 0
github.com/muety/wakapi/services/user.go:51.2,52.15 2 0
github.com/muety/wakapi/services/user.go:42.37,44.3 1 0
github.com/muety/wakapi/services/user.go:47.16,49.3 1 0
github.com/muety/wakapi/services/user.go:55.58,57.2 1 0
github.com/muety/wakapi/services/user.go:59.61,62.2 2 0
github.com/muety/wakapi/services/user.go:64.48,66.2 1 0
github.com/muety/wakapi/services/user.go:68.102,76.93 2 0
github.com/muety/wakapi/services/user.go:82.2,82.38 1 0
github.com/muety/wakapi/services/user.go:76.93,78.3 1 0
github.com/muety/wakapi/services/user.go:78.8,80.3 1 0
github.com/muety/wakapi/services/user.go:85.73,88.2 2 0
github.com/muety/wakapi/services/user.go:90.78,94.2 3 0
github.com/muety/wakapi/services/user.go:96.99,99.2 2 0
github.com/muety/wakapi/services/user.go:101.106,104.96 3 0
github.com/muety/wakapi/services/user.go:109.2,109.68 1 0
github.com/muety/wakapi/services/user.go:104.96,106.3 1 0
github.com/muety/wakapi/services/user.go:106.8,108.3 1 0
github.com/muety/wakapi/services/user.go:112.57,115.2 2 0
github.com/muety/wakapi/services/user.go:117.38,119.2 1 0
github.com/muety/wakapi/services/language_mapping.go:18.118,24.2 1 0
github.com/muety/wakapi/services/language_mapping.go:26.86,28.2 1 0
github.com/muety/wakapi/services/language_mapping.go:30.96,31.53 1 0
github.com/muety/wakapi/services/language_mapping.go:35.2,36.16 2 0
github.com/muety/wakapi/services/language_mapping.go:39.2,40.22 2 0
github.com/muety/wakapi/services/language_mapping.go:31.53,33.3 1 0
github.com/muety/wakapi/services/language_mapping.go:36.16,38.3 1 0
github.com/muety/wakapi/services/language_mapping.go:43.92,46.16 3 0
github.com/muety/wakapi/services/language_mapping.go:50.2,50.33 1 0
github.com/muety/wakapi/services/language_mapping.go:53.2,53.22 1 0
github.com/muety/wakapi/services/language_mapping.go:46.16,48.3 1 0
github.com/muety/wakapi/services/language_mapping.go:50.33,52.3 1 0
github.com/muety/wakapi/services/language_mapping.go:56.109,58.16 2 0
github.com/muety/wakapi/services/language_mapping.go:62.2,63.20 2 0
github.com/muety/wakapi/services/language_mapping.go:58.16,60.3 1 0
github.com/muety/wakapi/services/language_mapping.go:66.82,67.26 1 0
github.com/muety/wakapi/services/language_mapping.go:70.2,72.12 3 0
github.com/muety/wakapi/services/language_mapping.go:67.26,69.3 1 0
github.com/muety/wakapi/services/language_mapping.go:75.74,78.2 1 0
github.com/muety/wakapi/services/misc.go:23.126,30.2 1 0
github.com/muety/wakapi/services/misc.go:42.50,44.48 1 0
github.com/muety/wakapi/services/misc.go:48.2,50.19 3 0
github.com/muety/wakapi/services/misc.go:44.48,46.3 1 0
github.com/muety/wakapi/services/misc.go:53.51,59.40 4 0
github.com/muety/wakapi/services/misc.go:63.2,66.56 2 0
github.com/muety/wakapi/services/misc.go:77.2,77.12 1 0
github.com/muety/wakapi/services/misc.go:59.40,61.3 1 0
github.com/muety/wakapi/services/misc.go:66.56,67.27 1 0
github.com/muety/wakapi/services/misc.go:67.27,72.4 1 0
github.com/muety/wakapi/services/misc.go:73.8,75.3 1 0
github.com/muety/wakapi/services/misc.go:80.116,81.24 1 0
github.com/muety/wakapi/services/misc.go:81.24,82.144 1 0
github.com/muety/wakapi/services/misc.go:91.3,91.48 1 0
github.com/muety/wakapi/services/misc.go:82.144,84.4 1 0
github.com/muety/wakapi/services/misc.go:84.9,90.4 2 0
github.com/muety/wakapi/services/misc.go:91.48,94.4 2 0
github.com/muety/wakapi/services/misc.go:98.86,101.30 3 0
github.com/muety/wakapi/services/misc.go:106.2,109.17 1 0
github.com/muety/wakapi/services/misc.go:113.2,116.17 1 0
github.com/muety/wakapi/services/misc.go:101.30,104.3 2 0
github.com/muety/wakapi/services/misc.go:109.17,111.3 1 0
github.com/muety/wakapi/services/misc.go:116.17,118.3 1 0

View File

@ -49,6 +49,7 @@
"DataWeave": "#003a52", "DataWeave": "#003a52",
"DM": "#447265", "DM": "#447265",
"Dockerfile": "#384d54", "Dockerfile": "#384d54",
"Docker": "#384d54",
"Dogescript": "#cca760", "Dogescript": "#cca760",
"Dylan": "#6c616e", "Dylan": "#6c616e",
"E": "#ccce35", "E": "#ccce35",

View File

@ -149,6 +149,7 @@ func main() {
healthApiHandler := api.NewHealthApiHandler(db) healthApiHandler := api.NewHealthApiHandler(db)
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService) heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService) summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
// Compat Handlers // Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService) wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
@ -170,10 +171,7 @@ func main() {
// Globally used middlewares // Globally used middlewares
recoveryMiddleware := handlers.RecoveryHandler() recoveryMiddleware := handlers.RecoveryHandler()
loggingMiddleware := middlewares.NewLoggingMiddleware( loggingMiddleware := middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"})
log.New(os.Stdout, "", log.LstdFlags),
[]string{"/assets"},
)
// Router configs // Router configs
router.Use(loggingMiddleware, recoveryMiddleware) router.Use(loggingMiddleware, recoveryMiddleware)
@ -189,6 +187,7 @@ func main() {
summaryApiHandler.RegisterRoutes(apiRouter) summaryApiHandler.RegisterRoutes(apiRouter)
healthApiHandler.RegisterRoutes(apiRouter) healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter) heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter) wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter) wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter) wakatimeV1StatsHandler.RegisterRoutes(apiRouter)

View File

@ -2,7 +2,6 @@ package middlewares
import ( import (
"context" "context"
"fmt"
conf "github.com/muety/wakapi/config" conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"github.com/muety/wakapi/services" "github.com/muety/wakapi/services"
@ -15,6 +14,7 @@ type AuthenticateMiddleware struct {
config *conf.Config config *conf.Config
userSrvc services.IUserService userSrvc services.IUserService
optionalForPaths []string optionalForPaths []string
redirectTarget string // optional
} }
func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware { func NewAuthenticateMiddleware(userService services.IUserService) *AuthenticateMiddleware {
@ -30,6 +30,11 @@ func (m *AuthenticateMiddleware) WithOptionalFor(paths []string) *AuthenticateMi
return m return m
} }
func (m *AuthenticateMiddleware) WithRedirectTarget(path string) *AuthenticateMiddleware {
m.redirectTarget = path
return m
}
func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler { func (m *AuthenticateMiddleware) Handler(h http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
m.ServeHTTP(w, r, h.ServeHTTP) m.ServeHTTP(w, r, h.ServeHTTP)
@ -50,11 +55,12 @@ func (m *AuthenticateMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reques
return return
} }
if strings.HasPrefix(r.URL.Path, "/api") { if m.redirectTarget == "" {
w.WriteHeader(http.StatusUnauthorized) w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
} else { } else {
http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/")) http.SetCookie(w, m.config.GetClearCookie(models.AuthCookieKey, "/"))
http.Redirect(w, r, fmt.Sprintf("%s/?error=unauthorized", m.config.Server.BasePath), http.StatusFound) http.Redirect(w, r, m.redirectTarget, http.StatusFound)
} }
return return
} }

View File

@ -4,23 +4,24 @@ package middlewares
import ( import (
"io" "io"
"log"
"net/http" "net/http"
"strings" "strings"
"time" "time"
) )
type logFunc func(string, ...interface{})
type LoggingMiddleware struct { type LoggingMiddleware struct {
handler http.Handler handler http.Handler
output *log.Logger logFunc logFunc
excludePrefixes []string excludePrefixes []string
} }
func NewLoggingMiddleware(output *log.Logger, excludePrefixes []string) func(http.Handler) http.Handler { func NewLoggingMiddleware(logFunc logFunc, excludePrefixes []string) func(http.Handler) http.Handler {
return func(h http.Handler) http.Handler { return func(h http.Handler) http.Handler {
return &LoggingMiddleware{ return &LoggingMiddleware{
handler: h, handler: h,
output: output, logFunc: logFunc,
excludePrefixes: excludePrefixes, excludePrefixes: excludePrefixes,
} }
} }
@ -41,9 +42,8 @@ func (lg *LoggingMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Request) {
} }
} }
lg.output.Printf( lg.logFunc(
"%v status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s\n", "[request] status=%d, method=%s, uri=%s, duration=%v, bytes=%d, addr=%s, user=%s\n",
time.Now().Format(time.RFC3339Nano),
ww.Status(), ww.Status(),
r.Method, r.Method,
r.URL.String(), r.URL.String(),

View File

@ -14,7 +14,7 @@ func init() {
migrator := db.Migrator() migrator := db.Migrator()
if !migrator.HasColumn(&models.User{}, "badges_enabled") { if !migrator.HasColumn(&models.User{}, "badges_enabled") {
// empty database, nothing to migrate // empty database or already migrated, nothing to migrate
return nil return nil
} }
@ -37,11 +37,15 @@ func init() {
return err return err
} }
if cfg.Db.Dialect == config.SQLDialectSqlite {
logbuch.Info("not attempting to drop column 'badges_enabled' on sqlite")
return nil
}
if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil { if err := migrator.DropColumn(&models.User{}, "badges_enabled"); err != nil {
return err return err
} else {
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
} }
logbuch.Info("dropped column 'badges_enabled' after substituting it by sharing indicators")
return nil return nil
}, },

View File

@ -0,0 +1,41 @@
package migrations
import (
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20210213-add_has_data_field"
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
condition := "key = ?"
if cfg.Db.Dialect == config.SQLDialectMysql {
condition = "`key` = ?"
}
lookupResult := db.Where(condition, name).First(&models.KeyStringValue{})
if lookupResult.Error == nil && lookupResult.RowsAffected > 0 {
logbuch.Info("no need to migrate '%s'", name)
return nil
}
if err := db.Exec("UPDATE users SET has_data = TRUE WHERE TRUE").Error; err != nil {
return err
}
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
}
registerPostMigration(f)
}

View File

@ -20,11 +20,21 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error
return args.Error(0) return args.Error(0)
} }
func (m *HeartbeatServiceMock) Count() (int64, error) {
args := m.Called()
return int64(args.Int(0)), args.Error(1)
}
func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) { func (m *HeartbeatServiceMock) CountByUser(user *models.User) (int64, error) {
args := m.Called(user) args := m.Called(user)
return args.Get(0).(int64), args.Error(0) return args.Get(0).(int64), args.Error(0)
} }
func (m *HeartbeatServiceMock) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
args := m.Called(users)
return args.Get(0).([]*models.CountByUser), args.Error(0)
}
func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) { func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
args := m.Called(time, time2, user) args := m.Called(time, time2, user)
return args.Get(0).([]*models.Heartbeat), args.Error(1) return args.Get(0).([]*models.Heartbeat), args.Error(1)

View File

@ -24,8 +24,18 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
return args.Get(0).([]*models.User), args.Error(1) return args.Get(0).([]*models.User), args.Error(1)
} }
func (m *UserServiceMock) CreateOrGet(signup *models.Signup) (*models.User, bool, error) { func (m *UserServiceMock) GetActive() ([]*models.User, error) {
args := m.Called(signup) args := m.Called()
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) Count() (int64, error) {
args := m.Called()
return int64(args.Int(0)), args.Error(1)
}
func (m *UserServiceMock) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
args := m.Called(signup, isAdmin)
return args.Get(0).(*models.User), args.Bool(1), args.Error(2) return args.Get(0).(*models.User), args.Bool(1), args.Error(2)
} }

View File

@ -13,9 +13,18 @@ type AllTimeViewModel struct {
} }
type AllTimeData struct { type AllTimeData struct {
TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created TotalSeconds float32 `json:"total_seconds"` // total number of seconds logged since account created
Text string `json:"text"` // total time logged since account created as human readable string> Text string `json:"text"` // total time logged since account created as human readable string>
IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon> IsUpToDate bool `json:"is_up_to_date"` // true if the stats are up to date; when false, a 202 response code is returned and stats will be refreshed soon>
Range *AllTimeRange `json:"range"`
}
type AllTimeRange struct {
End string `json:"end"`
EndDate string `json:"end_date"`
Start string `json:"start"`
StartDate string `json:"start_date"`
Timezone string `json:"timezone"`
} }
func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel { func NewAllTimeFrom(summary *models.Summary, filters *models.Filters) *AllTimeViewModel {

View File

@ -0,0 +1,12 @@
package v1
// https://wakatime.com/api/v1/users/current/machine_names
type MachineViewModel struct {
Data []*MachineEntry `json:"data"`
}
type MachineEntry struct {
Id string `json:"id"`
Value string `json:"value"`
}

View File

@ -0,0 +1,22 @@
package metrics
import "fmt"
type CounterMetric struct {
Name string
Value int
Desc string
Labels Labels
}
func (c CounterMetric) Key() string {
return c.Name
}
func (c CounterMetric) Print() string {
return fmt.Sprintf("%s%s %d", c.Name, c.Labels.Print(), c.Value)
}
func (c CounterMetric) Header() string {
return fmt.Sprintf("# HELP %s %s\n# TYPE %s counter", c.Name, c.Desc, c.Name)
}

28
models/metrics/label.go Normal file
View File

@ -0,0 +1,28 @@
package metrics
import (
"fmt"
"strings"
)
type Labels []Label
type Label struct {
Key string
Value string
}
func (l Labels) Print() string {
printedLabels := make([]string, len(l))
for i, e := range l {
printedLabels[i] = e.Print()
}
if len(l) == 0 {
return ""
}
return fmt.Sprintf("{%s}", strings.Join(printedLabels, ","))
}
func (l Label) Print() string {
return fmt.Sprintf("%s=\"%s\"", l.Key, l.Value)
}

43
models/metrics/metric.go Normal file
View File

@ -0,0 +1,43 @@
package metrics
import (
"fmt"
"strings"
)
// Hand-crafted Prometheus metrics
// Since we're only using very simple counters in this application,
// we don't actually need the official client SDK as a dependency
type Metrics []Metric
func (m Metrics) Print() (output string) {
printedMetrics := make(map[string]bool)
for _, m := range m {
if _, ok := printedMetrics[m.Key()]; !ok {
output += fmt.Sprintf("%s\n", m.Header())
printedMetrics[m.Key()] = true
}
output += fmt.Sprintf("%s\n", m.Print())
}
return output
}
func (m Metrics) Len() int {
return len(m)
}
func (m Metrics) Less(i, j int) bool {
return strings.Compare(m[i].Key(), m[j].Key()) < 0
}
func (m Metrics) Swap(i, j int) {
m[i], m[j] = m[j], m[i]
}
type Metric interface {
Key() string
Header() string
Print() string
}

View File

@ -47,12 +47,14 @@ type SummaryItemContainer struct {
type SummaryViewModel struct { type SummaryViewModel struct {
*Summary *Summary
User *User
LanguageColors map[string]string LanguageColors map[string]string
EditorColors map[string]string EditorColors map[string]string
OSColors map[string]string OSColors map[string]string
Error string Error string
Success string Success string
ApiKey string ApiKey string
RawQuery string
} }
type SummaryParams struct { type SummaryParams struct {
@ -91,6 +93,10 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
} }
} }
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
return s.MappedItems()[summaryType]
}
/* Augments the summary in a way that at least one item is present for every type. /* Augments the summary in a way that at least one item is present for every type.
If a summary has zero items for a given type, but one or more for any of the other types, If a summary has zero items for a given type, but one or more for any of the other types,
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown" the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"

View File

@ -12,6 +12,8 @@ type User struct {
ShareProjects bool `json:"-" gorm:"default:false; type:bool"` ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"` ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"` ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
HasData bool `json:"-" gorm:"default:false; type:bool"`
WakatimeApiKey string `json:"-"` WakatimeApiKey string `json:"-"`
} }
@ -37,6 +39,11 @@ type TimeByUser struct {
Time CustomTime Time CustomTime
} }
type CountByUser struct {
User string
Count int64
}
func (c *CredentialsReset) IsValid() bool { func (c *CredentialsReset) IsValid() bool {
return validatePassword(c.PasswordNew) && return validatePassword(c.PasswordNew) &&
c.PasswordNew == c.PasswordRepeat c.PasswordNew == c.PasswordRepeat

View File

@ -1,8 +1,9 @@
package view package view
type LoginViewModel struct { type LoginViewModel struct {
Success string Success string
Error string Error string
TotalUsers int
} }
func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel { func (s *LoginViewModel) WithSuccess(m string) *LoginViewModel {

View File

@ -26,17 +26,6 @@ func (r *HeartbeatRepository) InsertBatch(heartbeats []*models.Heartbeat) error
return nil return nil
} }
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{UserID: user.ID}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) { func (r *HeartbeatRepository) GetLatestByOriginAndUser(origin string, user *models.User) (*models.Heartbeat, error) {
var heartbeat models.Heartbeat var heartbeat models.Heartbeat
if err := r.db. if err := r.db.
@ -75,6 +64,57 @@ func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
return result, nil return result, nil
} }
func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
var result []*models.TimeByUser
r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user").
Scan(&result)
return result, nil
}
func (r *HeartbeatRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUser(user *models.User) (int64, error) {
var count int64
if err := r.db.
Model(&models.Heartbeat{}).
Where(&models.Heartbeat{UserID: user.ID}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
var counts []*models.CountByUser
userIds := make([]string, len(users))
for i, u := range users {
userIds[i] = u.ID
}
if err := r.db.
Model(&models.User{}).
Select("users.id as user, count(heartbeats.id) as count").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Where("user_id in ?", userIds).
Group("user").
Find(&counts).Error; err != nil {
return counts, err
}
return counts, nil
}
func (r *HeartbeatRepository) DeleteBefore(t time.Time) error { func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
if err := r.db. if err := r.db.
Where("time <= ?", t). Where("time <= ?", t).

View File

@ -17,10 +17,13 @@ type IAliasRepository interface {
type IHeartbeatRepository interface { type IHeartbeatRepository interface {
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
CountByUser(*models.User) (int64, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLastByUsers() ([]*models.TimeByUser, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error) GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
Count() (int64, error)
CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
DeleteBefore(time.Time) error DeleteBefore(time.Time) error
} }
@ -46,8 +49,12 @@ type ISummaryRepository interface {
type IUserRepository interface { type IUserRepository interface {
GetById(string) (*models.User, error) GetById(string) (*models.User, error)
GetByIds([]string) ([]*models.User, error)
GetByApiKey(string) (*models.User, error) GetByApiKey(string) (*models.User, error)
GetAll() ([]*models.User, error) GetAll() ([]*models.User, error)
GetByLoggedInAfter(time.Time) ([]*models.User, error)
GetByLastActiveAfter(time.Time) ([]*models.User, error)
Count() (int64, error)
InsertOrGet(*models.User) (*models.User, bool, error) InsertOrGet(*models.User) (*models.User, bool, error)
Update(*models.User) (*models.User, error) Update(*models.User) (*models.User, error)
UpdateField(*models.User, string, interface{}) (*models.User, error) UpdateField(*models.User, string, interface{}) (*models.User, error)

View File

@ -4,6 +4,7 @@ import (
"errors" "errors"
"github.com/muety/wakapi/models" "github.com/muety/wakapi/models"
"gorm.io/gorm" "gorm.io/gorm"
"time"
) )
type UserRepository struct { type UserRepository struct {
@ -22,6 +23,17 @@ func (r *UserRepository) GetById(userId string) (*models.User, error) {
return u, nil return u, nil
} }
func (r *UserRepository) GetByIds(userIds []string) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Model(&models.User{}).
Where("id in ?", userIds).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func (r *UserRepository) GetByApiKey(key string) (*models.User, error) { func (r *UserRepository) GetByApiKey(key string) (*models.User, error) {
u := &models.User{} u := &models.User{}
if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil { if err := r.db.Where(&models.User{ApiKey: key}).First(u).Error; err != nil {
@ -40,6 +52,46 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
return users, nil return users, nil
} }
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
var users []*models.User
if err := r.db.
Where("last_logged_in_at >= ?", t).
Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
// Returns a list of user ids, whose last heartbeat is not older than t
// NOTE: Only ID field will be populated
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
subQuery1 := r.db.Model(&models.User{}).
Select("users.id as user, max(time) as time").
Joins("left join heartbeats on users.id = heartbeats.user_id").
Group("user")
var userIds []string
if err := r.db.
Select("user as id").
Table("(?) as q", subQuery1).
Where("time >= ?", t).
Scan(&userIds).Error; err != nil {
return nil, err
}
return r.GetByIds(userIds)
}
func (r *UserRepository) Count() (int64, error) {
var count int64
if err := r.db.
Model(&models.User{}).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) { func (r *UserRepository) InsertOrGet(user *models.User) (*models.User, bool, error) {
result := r.db.FirstOrCreate(user, &models.User{ID: user.ID}) result := r.db.FirstOrCreate(user, &models.User{ID: user.ID})
if err := result.Error; err != nil { if err := result.Error; err != nil {
@ -65,6 +117,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"share_projects": user.ShareProjects, "share_projects": user.ShareProjects,
"share_machines": user.ShareMachines, "share_machines": user.ShareMachines,
"wakatime_api_key": user.WakatimeApiKey, "wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData,
} }
result := r.db.Model(user).Updates(updateMap) result := r.db.Model(user).Updates(updateMap)

View File

@ -73,7 +73,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if !hb.Valid() { if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("Invalid heartbeat object.")) w.Write([]byte("invalid heartbeat object"))
return return
} }
@ -82,10 +82,21 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil { if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
logbuch.Error(err.Error()) w.Write([]byte(conf.ErrInternalServerError))
logbuch.Error("failed to batch-insert heartbeats %v", err)
return return
} }
if !user.HasData {
user.HasData = true
if _, err := h.userSrvc.Update(user); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
logbuch.Error("failed to update user %v", err)
return
}
}
utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats))) utils.RespondJSON(w, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
} }

271
routes/api/metrics.go Normal file
View File

@ -0,0 +1,271 @@
package api
import (
"errors"
"github.com/emvi/logbuch"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
mm "github.com/muety/wakapi/models/metrics"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"sort"
"time"
)
const (
MetricsPrefix = "wakatime"
DescHeartbeats = "Total number of tracked heartbeats."
DescAllTime = "Total seconds (all time)."
DescTotal = "Total seconds."
DescEditors = "Total seconds for each editor."
DescProjects = "Total seconds for each project."
DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine."
DescAdminTotalTime = "Total seconds (all users, all time)."
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
DescAdminUserHeartbeats = "Total number of tracked heartbeats by user (all time)."
DescAdminTotalUsers = "Total number of registered users."
DescAdminActiveUsers = "Number of active users."
)
type MetricsHandler struct {
config *conf.Config
userSrvc services.IUserService
summarySrvc services.ISummaryService
heartbeatSrvc services.IHeartbeatService
keyValueSrvc services.IKeyValueService
}
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler {
return &MetricsHandler{
userSrvc: userService,
summarySrvc: summaryService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
config: conf.Get(),
}
}
func (h *MetricsHandler) RegisterRoutes(router *mux.Router) {
if !h.config.Security.ExposeMetrics {
return
}
logbuch.Info("exposing prometheus metrics under /api/metrics")
r := router.PathPrefix("/metrics").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Methods(http.MethodGet).HandlerFunc(h.Get)
}
func (h *MetricsHandler) Get(w http.ResponseWriter, r *http.Request) {
reqUser := r.Context().Value(models.UserKey).(*models.User)
if reqUser == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
return
}
var metrics mm.Metrics
if userMetrics, err := h.getUserMetrics(reqUser); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
return
} else {
for _, m := range *userMetrics {
metrics = append(metrics, m)
}
}
if reqUser.IsAdmin {
if adminMetrics, err := h.getAdminMetrics(reqUser); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
return
} else {
for _, m := range *adminMetrics {
metrics = append(metrics, m)
}
}
}
sort.Sort(metrics)
w.Header().Set("content-type", "text/plain; charset=utf-8")
w.Write([]byte(metrics.Print()))
}
func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error) {
var metrics mm.Metrics
summaryAllTime, err := h.summarySrvc.Aliased(time.Time{}, time.Now(), user, h.summarySrvc.Retrieve)
if err != nil {
logbuch.Error("failed to retrieve all time summary for user '%s' for metric", user.ID)
return nil, err
}
from, to := utils.MustResolveIntervalRaw("today")
summaryToday, err := h.summarySrvc.Aliased(from, to, user, h.summarySrvc.Retrieve)
if err != nil {
logbuch.Error("failed to retrieve today's summary for user '%s' for metric", user.ID)
return nil, err
}
heartbeatCount, err := h.heartbeatSrvc.CountByUser(user)
if err != nil {
logbuch.Error("failed to count heartbeats for user '%s' for metric", user.ID)
return nil, err
}
// User Metrics
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_cumulative_seconds_total",
Desc: DescAllTime,
Value: int(v1.NewAllTimeFrom(summaryAllTime, &models.Filters{}).Data.TotalSeconds),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_seconds_total",
Desc: DescTotal,
Value: int(summaryToday.TotalTime().Seconds()),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_heartbeats_total",
Desc: DescHeartbeats,
Value: int(heartbeatCount),
Labels: []mm.Label{},
})
for _, p := range summaryToday.Projects {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_project_seconds_total",
Desc: DescProjects,
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: p.Key}},
})
}
for _, l := range summaryToday.Languages {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_language_seconds_total",
Desc: DescLanguages,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: l.Key}},
})
}
for _, e := range summaryToday.Editors {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_editor_seconds_total",
Desc: DescEditors,
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: e.Key}},
})
}
for _, o := range summaryToday.OperatingSystems {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_operating_system_seconds_total",
Desc: DescOperatingSystems,
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: o.Key}},
})
}
for _, m := range summaryToday.Machines {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_machine_seconds_total",
Desc: DescMachines,
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
return &metrics, nil
}
func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error) {
var metrics mm.Metrics
if !user.IsAdmin {
return nil, errors.New("unauthorized")
}
var totalSeconds int
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
if d, err := time.ParseDuration(t.Value); err == nil {
totalSeconds = int(d.Seconds())
}
}
totalUsers, _ := h.userSrvc.Count()
totalHeartbeats, _ := h.heartbeatSrvc.Count()
activeUsers, err := h.userSrvc.GetActive()
if err != nil {
logbuch.Error("failed to retrieve active users for metric %v", err)
return nil, err
}
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_seconds_total",
Desc: DescAdminTotalTime,
Value: totalSeconds,
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_heartbeats_total",
Desc: DescAdminTotalHeartbeats,
Value: int(totalHeartbeats),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_total",
Desc: DescAdminTotalUsers,
Value: int(totalUsers),
Labels: []mm.Label{},
})
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_users_active_total",
Desc: DescAdminActiveUsers,
Value: len(activeUsers),
Labels: []mm.Label{},
})
// Count per-user heartbeats
userCounts, err := h.heartbeatSrvc.CountByUsers(activeUsers)
if err != nil {
logbuch.Error("failed to count heartbeats for active users", err.Error())
return nil, err
}
for _, uc := range userCounts {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_admin_user_heartbeats_total",
Desc: DescAdminUserHeartbeats,
Value: int(uc.Count),
Labels: []mm.Label{{Key: "user", Value: uc.User}},
})
}
return &metrics, nil
}

View File

@ -33,6 +33,10 @@ func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
) )
r.Path("/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get) r.Path("/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
r.Path("/compat/wakatime/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get) r.Path("/compat/wakatime/v1/users/{user}/stats/{range}").Methods(http.MethodGet).HandlerFunc(h.Get)
// Also works without range, see https://github.com/anuraghazra/github-readme-stats/issues/865#issuecomment-776186592
r.Path("/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
r.Path("/compat/wakatime/v1/users/{user}/stats").Methods(http.MethodGet).HandlerFunc(h.Get)
} }
// TODO: support filtering (requires https://github.com/muety/wakapi/issues/108) // TODO: support filtering (requires https://github.com/muety/wakapi/issues/108)
@ -56,7 +60,12 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return return
} }
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(vars["range"]) rangeParam := vars["range"]
if rangeParam == "" {
rangeParam = (*models.IntervalPast7Days)[0]
}
err, rangeFrom, rangeTo := utils.ResolveIntervalRaw(rangeParam)
if err != nil { if err != nil {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
w.Write([]byte("invalid range")) w.Write([]byte("invalid range"))

View File

@ -150,7 +150,9 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
return return
} }
_, created, err := h.userSrvc.CreateOrGet(&signup) numUsers, _ := h.userSrvc.Count()
_, created, err := h.userSrvc.CreateOrGet(&signup, numUsers == 0)
if err != nil { if err != nil {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user")) templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
@ -166,8 +168,11 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
} }
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel { func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
numUsers, _ := h.userSrvc.Count()
return &view.LoginViewModel{ return &view.LoginViewModel{
Success: r.URL.Query().Get("success"), Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"), Error: r.URL.Query().Get("error"),
TotalUsers: int(numUsers),
} }
} }

View File

@ -24,15 +24,17 @@ var templates map[string]*template.Template
func loadTemplates() { func loadTemplates() {
const tplPath = "/views" const tplPath = "/views"
tpls := template.New("").Funcs(template.FuncMap{ tpls := template.New("").Funcs(template.FuncMap{
"json": utils.Json, "json": utils.Json,
"date": utils.FormatDateHuman, "date": utils.FormatDateHuman,
"title": strings.Title, "simpledate": utils.FormatDate,
"join": strings.Join, "simpledatetime": utils.FormatDateTime,
"add": utils.Add, "title": strings.Title,
"capitalize": utils.Capitalize, "join": strings.Join,
"toRunes": utils.ToRunes, "add": utils.Add,
"entityTypes": models.SummaryTypes, "capitalize": utils.Capitalize,
"typeName": typeName, "toRunes": utils.ToRunes,
"entityTypes": models.SummaryTypes,
"typeName": typeName,
"getBasePath": func() string { "getBasePath": func() string {
return config.Get().Server.BasePath return config.Get().Server.BasePath
}, },
@ -102,3 +104,7 @@ func typeName(t uint8) string {
} }
return "unknown" return "unknown"
} }
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
}

View File

@ -57,7 +57,7 @@ func NewSettingsHandler(
func (h *SettingsHandler) RegisterRoutes(router *mux.Router) { func (h *SettingsHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/settings").Subrouter() r := router.PathPrefix("/settings").Subrouter()
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex) r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
r.Methods(http.MethodPost).HandlerFunc(h.PostIndex) r.Methods(http.MethodPost).HandlerFunc(h.PostIndex)
@ -166,13 +166,13 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
user.Password = credentials.PasswordNew user.Password = credentials.PasswordNew
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil { if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
return http.StatusInternalServerError, "", "internal server error" return http.StatusInternalServerError, "", conf.ErrInternalServerError
} else { } else {
user.Password = hash user.Password = hash
} }
if _, err := h.userSrvc.Update(user); err != nil { if _, err := h.userSrvc.Update(user); err != nil {
return http.StatusInternalServerError, "", "internal server error" return http.StatusInternalServerError, "", conf.ErrInternalServerError
} }
login := &models.Login{ login := &models.Login{
@ -181,7 +181,7 @@ func (h *SettingsHandler) actionChangePassword(w http.ResponseWriter, r *http.Re
} }
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username) encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
if err != nil { if err != nil {
return http.StatusInternalServerError, "", "internal server error" return http.StatusInternalServerError, "", conf.ErrInternalServerError
} }
http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/")) http.SetCookie(w, h.config.CreateCookie(models.AuthCookieKey, encoded, "/"))
@ -195,7 +195,7 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
user := r.Context().Value(models.UserKey).(*models.User) user := r.Context().Value(models.UserKey).(*models.User)
if _, err := h.userSrvc.ResetApiKey(user); err != nil { if _, err := h.userSrvc.ResetApiKey(user); err != nil {
return http.StatusInternalServerError, "", "internal server error" return http.StatusInternalServerError, "", conf.ErrInternalServerError
} }
msg := fmt.Sprintf("your new api key is: %s", user.ApiKey) msg := fmt.Sprintf("your new api key is: %s", user.ApiKey)
@ -341,7 +341,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
} }
if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil { if _, err := h.userSrvc.SetWakatimeApiKey(user, apiKey); err != nil {
return http.StatusInternalServerError, "", "internal server error" return http.StatusInternalServerError, "", conf.ErrInternalServerError
} }
return http.StatusOK, "Wakatime API Key updated successfully", "" return http.StatusOK, "Wakatime API Key updated successfully", ""

View File

@ -29,7 +29,7 @@ func NewSummaryHandler(summaryService services.ISummaryService, userService serv
func (h *SummaryHandler) RegisterRoutes(router *mux.Router) { func (h *SummaryHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/summary").Subrouter() r := router.PathPrefix("/summary").Subrouter()
r.Use( r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler, middlewares.NewAuthenticateMiddleware(h.userSrvc).WithRedirectTarget(defaultErrorRedirectTarget()).Handler,
) )
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex) r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
} }
@ -39,6 +39,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
loadTemplates() loadTemplates()
} }
rawQuery := r.URL.RawQuery
q := r.URL.Query() q := r.URL.Query()
if q.Get("interval") == "" && q.Get("from") == "" { if q.Get("interval") == "" && q.Get("from") == "" {
q.Set("interval", "today") q.Set("interval", "today")
@ -61,10 +62,12 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
vm := models.SummaryViewModel{ vm := models.SummaryViewModel{
Summary: summary, Summary: summary,
User: user,
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages), LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors), EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems), OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
ApiKey: user.ApiKey, ApiKey: user.ApiKey,
RawQuery: rawQuery,
} }
templates[conf.SummaryTemplate].Execute(w, vm) templates[conf.SummaryTemplate].Execute(w, vm)

View File

@ -30,10 +30,18 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
return srv.repository.InsertBatch(heartbeats) return srv.repository.InsertBatch(heartbeats)
} }
func (srv *HeartbeatService) Count() (int64, error) {
return srv.repository.Count()
}
func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) { func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) {
return srv.repository.CountByUser(user) return srv.repository.CountByUser(user)
} }
func (srv *HeartbeatService) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
return srv.repository.CountByUsers(users)
}
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) { func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
heartbeats, err := srv.repository.GetAllWithin(from, to, user) heartbeats, err := srv.repository.GetAllWithin(from, to, user)
if err != nil { if err != nil {

View File

@ -4,7 +4,6 @@ import (
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"github.com/emvi/logbuch" "github.com/emvi/logbuch"
"github.com/muety/wakapi/config" "github.com/muety/wakapi/config"
@ -53,6 +52,12 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
return return
} }
machinesNames, err := w.fetchMachineNames()
if err != nil {
logbuch.Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' %v", user.ID, err)
return
}
days := generateDays(startDate, endDate) days := generateDays(startDate, endDate)
c := atomic.NewUint32(uint32(len(days))) c := atomic.NewUint32(uint32(len(days)))
@ -68,14 +73,14 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
go func(day time.Time) { go func(day time.Time) {
defer sem.Release(1) defer sem.Release(1)
d := day.Format("2006-01-02") d := day.Format(config.SimpleDateFormat)
heartbeats, err := w.fetchHeartbeats(d) heartbeats, err := w.fetchHeartbeats(d)
if err != nil { if err != nil {
logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' &v", day, user.ID, err) logbuch.Error("failed to fetch heartbeats for day '%s' and user '%s' &v", day, user.ID, err)
} }
for _, h := range heartbeats { for _, h := range heartbeats {
out <- mapHeartbeat(h, userAgents, user) out <- mapHeartbeat(h, userAgents, machinesNames, user)
} }
if c.Dec() == 0 { if c.Dec() == 0 {
@ -134,27 +139,17 @@ func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
return notime, notime, err return notime, notime, err
} }
var allTimeData map[string]interface{} var allTimeData wakatime.AllTimeViewModel
if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil { if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil {
return notime, notime, err return notime, notime, err
} }
data := allTimeData["data"].(map[string]interface{}) startDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.StartDate)
if data == nil {
return notime, notime, errors.New("invalid response")
}
dataRange := data["range"].(map[string]interface{})
if dataRange == nil {
return notime, notime, errors.New("invalid response")
}
startDate, err := time.Parse("2006-01-02", dataRange["start_date"].(string))
if err != nil { if err != nil {
return notime, notime, err return notime, notime, err
} }
endDate, err := time.Parse("2006-01-02", dataRange["end_date"].(string)) endDate, err := time.Parse(config.SimpleDateFormat, allTimeData.Data.Range.EndDate)
if err != nil { if err != nil {
return notime, notime, err return notime, notime, err
} }
@ -189,6 +184,33 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.User
return userAgents, nil return userAgents, nil
} }
// https://wakatime.com/api/v1/users/current/machine_names
func (w *WakatimeHeartbeatImporter) fetchMachineNames() (map[string]*wakatime.MachineEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
req, err := http.NewRequest(http.MethodGet, config.WakatimeApiUrl+config.WakatimeApiMachineNamesUrl, nil)
if err != nil {
return nil, err
}
res, err := httpClient.Do(w.withHeaders(req))
if err != nil {
return nil, err
}
var machineData wakatime.MachineViewModel
if err := json.NewDecoder(res.Body).Decode(&machineData); err != nil {
return nil, err
}
machines := make(map[string]*wakatime.MachineEntry)
for _, ma := range machineData.Data {
machines[ma.Id] = ma
}
return machines, nil
}
func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request { func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request {
req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.ApiKey)))) req.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(w.ApiKey))))
return req return req
@ -197,6 +219,7 @@ func (w *WakatimeHeartbeatImporter) withHeaders(req *http.Request) *http.Request
func mapHeartbeat( func mapHeartbeat(
entry *wakatime.HeartbeatEntry, entry *wakatime.HeartbeatEntry,
userAgents map[string]*wakatime.UserAgentEntry, userAgents map[string]*wakatime.UserAgentEntry,
machineNames map[string]*wakatime.MachineEntry,
user *models.User, user *models.User,
) *models.Heartbeat { ) *models.Heartbeat {
ua := userAgents[entry.UserAgentId] ua := userAgents[entry.UserAgentId]
@ -207,6 +230,14 @@ func mapHeartbeat(
} }
} }
ma := machineNames[entry.MachineNameId]
if ma == nil {
ma = &wakatime.MachineEntry{
Id: entry.MachineNameId,
Value: entry.MachineNameId,
}
}
return (&models.Heartbeat{ return (&models.Heartbeat{
User: user, User: user,
UserID: user.ID, UserID: user.ID,
@ -219,7 +250,7 @@ func mapHeartbeat(
IsWrite: entry.IsWrite, IsWrite: entry.IsWrite,
Editor: ua.Editor, Editor: ua.Editor,
OperatingSystem: ua.Os, OperatingSystem: ua.Os,
Machine: entry.MachineNameId, // TODO Machine: ma.Value,
Time: entry.Time, Time: entry.Time,
Origin: OriginWakatime, Origin: OriginWakatime,
OriginId: entry.Id, OriginId: entry.Id,

View File

@ -46,7 +46,7 @@ func (srv *MiscService) ScheduleCountTotalTime() {
} }
s := gocron.NewScheduler(time.Local) s := gocron.NewScheduler(time.Local)
s.Every(1).Day().At(srv.config.App.CountingTime).Do(srv.runCountTotalTime) s.Every(1).Hour().Do(srv.runCountTotalTime)
s.StartBlocking() s.StartBlocking()
} }

View File

@ -28,7 +28,9 @@ type IAliasService interface {
type IHeartbeatService interface { type IHeartbeatService interface {
Insert(*models.Heartbeat) error Insert(*models.Heartbeat) error
InsertBatch([]*models.Heartbeat) error InsertBatch([]*models.Heartbeat) error
Count() (int64, error)
CountByUser(*models.User) (int64, error) CountByUser(*models.User) (int64, error)
CountByUsers([]*models.User) ([]*models.CountByUser, error)
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error) GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
GetFirstByUsers() ([]*models.TimeByUser, error) GetFirstByUsers() ([]*models.TimeByUser, error)
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error) GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
@ -63,7 +65,9 @@ type IUserService interface {
GetUserById(string) (*models.User, error) GetUserById(string) (*models.User, error)
GetUserByKey(string) (*models.User, error) GetUserByKey(string) (*models.User, error)
GetAll() ([]*models.User, error) GetAll() ([]*models.User, error)
CreateOrGet(*models.Signup) (*models.User, bool, error) GetActive() ([]*models.User, error)
Count() (int64, error)
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
Update(*models.User) (*models.User, error) Update(*models.User) (*models.User, error)
Delete(*models.User) error Delete(*models.User) error
ResetApiKey(*models.User) (*models.User, error) ResetApiKey(*models.User) (*models.User, error)

View File

@ -56,11 +56,21 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
return srv.repository.GetAll() return srv.repository.GetAll()
} }
func (srv *UserService) CreateOrGet(signup *models.Signup) (*models.User, bool, error) { func (srv *UserService) GetActive() ([]*models.User, error) {
minDate := time.Now().Add(-24 * time.Hour * time.Duration(srv.Config.App.InactiveDays))
return srv.repository.GetByLastActiveAfter(minDate)
}
func (srv *UserService) Count() (int64, error) {
return srv.repository.Count()
}
func (srv *UserService) CreateOrGet(signup *models.Signup, isAdmin bool) (*models.User, bool, error) {
u := &models.User{ u := &models.User{
ID: signup.Username, ID: signup.Username,
ApiKey: uuid.NewV4().String(), ApiKey: uuid.NewV4().String(),
Password: signup.Password, Password: signup.Password,
IsAdmin: isAdmin,
} }
if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil { if hash, err := utils.HashBcrypt(u.Password, srv.Config.Security.PasswordSalt); err != nil {

View File

@ -4,4 +4,9 @@ body {
.bg-gray-850 { .bg-gray-850 {
background-color: #242b3a; background-color: #242b3a;
}
::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;
} }

View File

@ -49,6 +49,13 @@ String.prototype.toHHMMSS = function () {
return hours + ':' + minutes + ':' + seconds return hours + ':' + minutes + ':' + seconds
} }
String.prototype.toHHMM = function () {
const sec_num = parseInt(this, 10)
const hours = Math.floor(sec_num / 3600)
const minutes = Math.floor((sec_num - (hours * 3600)) / 60)
return hours + ':' + minutes
}
function draw(subselection) { function draw(subselection) {
function getTooltipOptions(key) { function getTooltipOptions(key) {
return { return {
@ -317,8 +324,11 @@ function equalizeHeights() {
} }
function getTotal(items) { function getTotal(items) {
let total = items.reduce((acc, d) => acc + d.total, 0) const el = document.getElementById('total-span')
document.getElementById('total-span').innerText = total.toString().toHHMMSS() if (!el) return
const total = items.reduce((acc, d) => acc + d.total, 0)
const formatted = total.toString().toHHMM()
el.innerText = `${formatted.split(':')[0]} hours, ${formatted.split(':')[1]} minutes`
} }
function getRandomColor(seed) { function getRandomColor(seed) {

View File

@ -7,15 +7,15 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="271.70874mm" width="275.9418mm"
height="107.06042mm" height="107.06042mm"
viewBox="0 0 271.70874 107.06042" viewBox="0 0 275.9418 107.06042"
version="1.1" version="1.1"
id="svg4151" id="svg1621"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="logo-text-only.svg"> sodipodi:docname="logo-text-only.svg">
<defs <defs
id="defs4145"> id="defs1615">
<clipPath <clipPath
clipPathUnits="userSpaceOnUse" clipPathUnits="userSpaceOnUse"
id="clipPath20"> id="clipPath20">
@ -32,19 +32,19 @@
inkscape:pageopacity="0.0" inkscape:pageopacity="0.0"
inkscape:pageshadow="2" inkscape:pageshadow="2"
inkscape:zoom="0.35" inkscape:zoom="0.35"
inkscape:cx="367.75106" inkscape:cx="-275.67803"
inkscape:cy="930.89032" inkscape:cy="605.17604"
inkscape:document-units="mm" inkscape:document-units="mm"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
inkscape:document-rotation="0" inkscape:document-rotation="0"
showgrid="false" showgrid="false"
inkscape:window-width="1346" inkscape:window-width="1346"
inkscape:window-height="1198" inkscape:window-height="1198"
inkscape:window-x="99" inkscape:window-x="149"
inkscape:window-y="63" inkscape:window-y="113"
inkscape:window-maximized="0" /> inkscape:window-maximized="0" />
<metadata <metadata
id="metadata4148"> id="metadata1618">
<rdf:RDF> <rdf:RDF>
<cc:Work <cc:Work
rdf:about=""> rdf:about="">
@ -59,10 +59,10 @@
inkscape:label="Layer 1" inkscape:label="Layer 1"
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer1" id="layer1"
transform="translate(-8.5325331,98.131398)"> transform="translate(-178.77314,11.952827)">
<g <g
id="g14" id="g14"
transform="matrix(0.35277777,0,0,-0.35277777,-65.229351,79.309389)"> transform="matrix(0.35277777,0,0,-0.35277777,105.01126,165.48796)">
<g <g
id="g16" id="g16"
clip-path="url(#clipPath20)"> clip-path="url(#clipPath20)">
@ -120,18 +120,31 @@
</g> </g>
</g> </g>
</g> </g>
<text <g
xml:space="preserve" aria-label="akapi"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:62.089px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:1.5875px;fill:#2f855a;fill-opacity:1;stroke:none;stroke-width:0.264583" transform="matrix(0.35277777,0,0,0.35277777,121.30914,170.32937)"
x="127.21814" id="text856"
y="-26.413651" style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:7.5px;fill:#2f855a;fill-opacity:1;stroke:none;stroke-width:0.75">
id="text856"><tspan <path
sodipodi:role="line" d="m 565.57956,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00781,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03906,-11.51563 -4.03907,-4.38281 -12.28907,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55468,3.52343 -4.55468,9.02343 h -20.88282 q 0,-7.64843 5.07032,-14.26562 5.07031,-6.70313 13.75,-10.48438 8.76562,-3.78125 19.50781,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94531,-15.03906 q 6.1875,0 11.60156,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 H 551.228 q -11.60157,0 -17.44532,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74219,3.52344 z"
id="tspan854" style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
x="127.21814" id="path851" />
y="-26.413651" <path
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:62.089px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.264583" d="m 642.94675,-353.28607 -9.28125,9.53906 v 30.33594 h -20.88282 v -132 h 20.88282 v 76.14062 l 6.53125,-8.16406 25.69531,-28.96094 h 25.09375 l -34.54688,38.75782 38.24219,54.22656 h -24.14844 z"
dx="0" style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
rotate="0 0 0 0 0 0">akapi</tspan></text> id="path853" />
<path
d="m 767.6655,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00782,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03907,-11.51563 -4.03906,-4.38281 -12.28906,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55469,3.52343 -4.55469,9.02343 h -20.88281 q 0,-7.64843 5.07031,-14.26562 5.07032,-6.70313 13.75,-10.48438 8.76563,-3.78125 19.50782,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94532,-15.03906 q 6.1875,0 11.60157,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 h -11.25782 q -11.60156,0 -17.44531,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74218,3.52344 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path855" />
<path
d="m 896.25143,-358.95795 q 0,21.57032 -9.79687,34.46094 -9.79688,12.80469 -26.29688,12.80469 -15.29687,0 -24.49218,-10.05469 v 44.08594 h -20.88282 v -128.73438 h 19.25 l 0.85938,9.45313 q 9.19531,-11.17188 25.00781,-11.17188 17.01563,0 26.64063,12.71875 9.71093,12.63282 9.71093,35.14844 z m -20.79687,-1.80468 q 0,-13.92188 -5.58594,-22.08594 -5.5,-8.16406 -15.8125,-8.16406 -12.80469,0 -18.39062,10.57031 v 41.25 q 5.67187,10.82812 18.5625,10.82812 9.96875,0 15.55468,-7.99218 5.67188,-8.07813 5.67188,-24.40625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path857" />
<path
d="m 943.62643,-313.41107 h -20.88281 v -92.98438 h 20.88281 z m -22.17187,-117.13281 q 0,-4.8125 3.00781,-7.99219 3.09375,-3.17969 8.76563,-3.17969 5.67187,0 8.76562,3.17969 3.09375,3.17969 3.09375,7.99219 0,4.72656 -3.09375,7.90625 -3.09375,3.09375 -8.76562,3.09375 -5.67188,0 -8.76563,-3.09375 -3.00781,-3.17969 -3.00781,-7.90625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#2f855a;fill-opacity:1;stroke-width:0.75"
id="path859" />
</g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

View File

@ -7,15 +7,15 @@
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="271.70874mm" width="275.9418mm"
height="107.06042mm" height="107.06042mm"
viewBox="0 0 271.70874 107.06042" viewBox="0 0 275.9418 107.06042"
version="1.1" version="1.1"
id="svg2814" id="svg900"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)" inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
sodipodi:docname="logo-dark-bg.svg"> sodipodi:docname="logo-dark-bg.svg">
<defs <defs
id="defs2808"> id="defs894">
<clipPath <clipPath
clipPathUnits="userSpaceOnUse" clipPathUnits="userSpaceOnUse"
id="clipPath20"> id="clipPath20">
@ -32,19 +32,19 @@
inkscape:pageopacity="0.0" inkscape:pageopacity="0.0"
inkscape:pageshadow="2" inkscape:pageshadow="2"
inkscape:zoom="0.35" inkscape:zoom="0.35"
inkscape:cx="-43.677516" inkscape:cx="501.46484"
inkscape:cy="622.31888" inkscape:cy="865.17604"
inkscape:document-units="mm" inkscape:document-units="mm"
inkscape:current-layer="layer1" inkscape:current-layer="layer1"
inkscape:document-rotation="0" inkscape:document-rotation="0"
showgrid="false" showgrid="false"
inkscape:window-width="1346" inkscape:window-width="1346"
inkscape:window-height="1198" inkscape:window-height="1198"
inkscape:window-x="99" inkscape:window-x="149"
inkscape:window-y="63" inkscape:window-y="113"
inkscape:window-maximized="0" /> inkscape:window-maximized="0" />
<metadata <metadata
id="metadata2811"> id="metadata897">
<rdf:RDF> <rdf:RDF>
<cc:Work <cc:Work
rdf:about=""> rdf:about="">
@ -59,10 +59,10 @@
inkscape:label="Layer 1" inkscape:label="Layer 1"
inkscape:groupmode="layer" inkscape:groupmode="layer"
id="layer1" id="layer1"
transform="translate(-117.38968,16.488537)"> transform="translate(26.845906,80.744493)">
<g <g
id="g14" id="g14"
transform="matrix(0.35277777,0,0,-0.35277777,43.627792,160.95225)"> transform="matrix(0.35277777,0,0,-0.35277777,-100.60779,96.696294)">
<g <g
id="g16" id="g16"
clip-path="url(#clipPath20)"> clip-path="url(#clipPath20)">
@ -120,18 +120,31 @@
</g> </g>
</g> </g>
</g> </g>
<text <g
xml:space="preserve" aria-label="akapi"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:62.089px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:1.5875px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.264583" transform="matrix(0.35277777,0,0,0.35277777,-84.30991,101.5377)"
x="236.07529" id="text856"
y="55.229206" style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;line-height:1.25;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;letter-spacing:7.5px;fill:#000000;fill-opacity:1;stroke:none;stroke-width:0.75">
id="text856"><tspan <path
sodipodi:role="line" d="m 565.57956,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00781,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03906,-11.51563 -4.03907,-4.38281 -12.28907,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55468,3.52343 -4.55468,9.02343 h -20.88282 q 0,-7.64843 5.07032,-14.26562 5.07031,-6.70313 13.75,-10.48438 8.76562,-3.78125 19.50781,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94531,-15.03906 q 6.1875,0 11.60156,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 H 551.228 q -11.60157,0 -17.44532,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74219,3.52344 z"
id="tspan854" style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
x="236.07529" id="path851" />
y="55.229206" <path
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:62.089px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.264583" d="m 642.94675,-353.28607 -9.28125,9.53906 v 30.33594 h -20.88282 v -132 h 20.88282 v 76.14062 l 6.53125,-8.16406 25.69531,-28.96094 h 25.09375 l -34.54688,38.75782 38.24219,54.22656 h -24.14844 z"
dx="0" style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
rotate="0 0 0 0 0 0">akapi</tspan></text> id="path853" />
<path
d="m 767.6655,-313.41107 q -1.375,-2.66406 -2.40625,-8.67969 -9.96875,10.39844 -24.40625,10.39844 -14.00782,0 -22.85938,-7.99219 -8.85156,-7.99219 -8.85156,-19.76562 0,-14.86719 11,-22.77344 11.08594,-7.99219 31.625,-7.99219 h 12.80469 v -6.10156 q 0,-7.21875 -4.03907,-11.51563 -4.03906,-4.38281 -12.28906,-4.38281 -7.13281,0 -11.6875,3.60938 -4.55469,3.52343 -4.55469,9.02343 h -20.88281 q 0,-7.64843 5.07031,-14.26562 5.07032,-6.70313 13.75,-10.48438 8.76563,-3.78125 19.50782,-3.78125 16.32812,0 26.03906,8.25 9.71094,8.16407 9.96875,23.03125 v 41.9375 q 0,12.54688 3.52344,20.02344 v 1.46094 z m -22.94532,-15.03906 q 6.1875,0 11.60157,-3.00782 5.5,-3.00781 8.25,-8.07812 v -17.53125 h -11.25782 q -11.60156,0 -17.44531,4.03906 -5.84375,4.03906 -5.84375,11.42969 0,6.01562 3.95313,9.625 4.03906,3.52344 10.74218,3.52344 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path855" />
<path
d="m 896.25143,-358.95795 q 0,21.57032 -9.79687,34.46094 -9.79688,12.80469 -26.29688,12.80469 -15.29687,0 -24.49218,-10.05469 v 44.08594 h -20.88282 v -128.73438 h 19.25 l 0.85938,9.45313 q 9.19531,-11.17188 25.00781,-11.17188 17.01563,0 26.64063,12.71875 9.71093,12.63282 9.71093,35.14844 z m -20.79687,-1.80468 q 0,-13.92188 -5.58594,-22.08594 -5.5,-8.16406 -15.8125,-8.16406 -12.80469,0 -18.39062,10.57031 v 41.25 q 5.67187,10.82812 18.5625,10.82812 9.96875,0 15.55468,-7.99218 5.67188,-8.07813 5.67188,-24.40625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path857" />
<path
d="m 943.62643,-313.41107 h -20.88281 v -92.98438 h 20.88281 z m -22.17187,-117.13281 q 0,-4.8125 3.00781,-7.99219 3.09375,-3.17969 8.76563,-3.17969 5.67187,0 8.76562,3.17969 3.09375,3.17969 3.09375,7.99219 0,4.72656 -3.09375,7.90625 -3.09375,3.09375 -8.76562,3.09375 -5.67188,0 -8.76563,-3.09375 -3.00781,-3.17969 -3.00781,-7.90625 z"
style="font-style:normal;font-variant:normal;font-weight:500;font-stretch:normal;font-size:176px;font-family:Roboto;-inkscape-font-specification:'Roboto, Medium';font-variant-ligatures:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-east-asian:normal;fill:#ffffff;fill-opacity:1;stroke-width:0.75"
id="path859" />
</g>
</g> </g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 10 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 12 KiB

View File

@ -36,7 +36,7 @@ func ExtractBasicAuth(r *http.Request) (username, password string, err error) {
func ExtractBearerAuth(r *http.Request) (key string, err error) { func ExtractBearerAuth(r *http.Request) (key string, err error) {
authHeader := strings.Split(r.Header.Get("Authorization"), " ") authHeader := strings.Split(r.Header.Get("Authorization"), " ")
if len(authHeader) != 2 || authHeader[0] != "Basic" { if len(authHeader) != 2 || (authHeader[0] != "Basic" && authHeader[0] != "Bearer") {
return key, errors.New("failed to extract API key") return key, errors.New("failed to extract API key")
} }

View File

@ -2,16 +2,25 @@ package utils
import ( import (
"errors" "errors"
"github.com/muety/wakapi/config"
"regexp" "regexp"
"time" "time"
) )
func ParseDate(date string) (time.Time, error) { func ParseDate(date string) (time.Time, error) {
return time.Parse("2006-01-02 15:04:05", date) return time.Parse(config.SimpleDateFormat, date)
}
func ParseDateTime(date string) (time.Time, error) {
return time.Parse(config.SimpleDateTimeFormat, date)
} }
func FormatDate(date time.Time) string { func FormatDate(date time.Time) string {
return date.Format("2006-01-02 15:04:05") return date.Format(config.SimpleDateFormat)
}
func FormatDateTime(date time.Time) string {
return date.Format(config.SimpleDateTimeFormat)
} }
func FormatDateHuman(date time.Time) string { func FormatDateHuman(date time.Time) string {

View File

@ -16,6 +16,11 @@ func ParseInterval(interval string) (*models.IntervalKey, error) {
return nil, errors.New("not a valid interval") return nil, errors.New("not a valid interval")
} }
func MustResolveIntervalRaw(interval string) (from, to time.Time) {
_, from, to = ResolveIntervalRaw(interval)
return from, to
}
func ResolveIntervalRaw(interval string) (err error, from, to time.Time) { func ResolveIntervalRaw(interval string) (err error, from, to time.Time) {
parsed, err := ParseInterval(interval) parsed, err := ParseInterval(interval)
if err != nil { if err != nil {
@ -74,15 +79,23 @@ func ParseSummaryParams(r *http.Request) (*models.SummaryParams, error) {
if interval := params.Get("interval"); interval != "" { if interval := params.Get("interval"); interval != "" {
err, from, to = ResolveIntervalRaw(interval) err, from, to = ResolveIntervalRaw(interval)
} else if start := params.Get("start"); start != "" {
err, from, to = ResolveIntervalRaw(start)
} else { } else {
from, err = ParseDate(params.Get("from")) from, err = ParseDateTime(params.Get("from"))
if err != nil { if err != nil {
return nil, errors.New("missing 'from' parameter") from, err = ParseDate(params.Get("from"))
if err != nil {
return nil, errors.New("missing 'from' parameter")
}
} }
to, err = ParseDate(params.Get("to")) to, err = ParseDateTime(params.Get("to"))
if err != nil { if err != nil {
return nil, errors.New("missing 'to' parameter") to, err = ParseDate(params.Get("to"))
if err != nil {
return nil, errors.New("missing 'to' parameter")
}
} }
} }

View File

@ -1 +1 @@
1.23.3 1.24.3

View File

@ -9,4 +9,26 @@
<div> <div>
<a href="imprint" class="border-b border-green-700">Imprint, Cookies & Data Privacy</a> <a href="imprint" class="border-b border-green-700">Imprint, Cookies & Data Privacy</a>
</div> </div>
</footer> </footer>
<script type="text/javascript">
const baseUrl = location.href.substring(0, location.href.lastIndexOf('/'))
document.querySelectorAll('.with-url-src').forEach(e => {
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-src-no-scheme').forEach(e => {
const strippedUrl = baseUrl.replace(/https?:\/\//, '')
e.setAttribute('src', e.getAttribute('src').replace('%s', strippedUrl))
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-inner').forEach(e => {
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-inner-no-scheme').forEach(e => {
const strippedUrl = baseUrl.replace(/https?:\/\//, '')
e.innerHTML = e.innerHTML.replace('%s', strippedUrl)
e.classList.remove('hidden')
})
</script>

View File

@ -1,5 +1,5 @@
<header class="flex justify-between mb-10"> <header class="flex justify-between mb-10">
<a id="logo-container" class="text-2xl font-semibold text-white inline-block" href=""> <a id="logo-container" class="text-2xl font-semibold text-white inline-block" href="">
<img src="assets/images/logo.svg" width="110px"> <img src="assets/images/logo.svg" width="110px" alt="Logo">
</a> </a>
</header> </header>

View File

@ -27,11 +27,11 @@
<p class="text-center text-gray-500 text-xl my-4"> <p class="text-center text-gray-500 text-xl my-4">
<span class="mr-1">💡 The system has tracked a total of </span> <span class="mr-1">💡 The system has tracked a total of </span>
{{ range $d := .TotalHours | printf "%d" | toRunes }} {{ range $d := .TotalHours | printf "%d" | toRunes }}
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalHours }} hours (updated once a day)">{{ $d }}</span> <span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalHours }} hours (updated every hour)">{{ $d }}</span>
{{ end }} {{ end }}
<span class="mx-1">hours of coding from</span> <span class="mx-1">hours of coding from</span>
{{ range $d := .TotalUsers | printf "%d" | toRunes }} {{ range $d := .TotalUsers | printf "%d" | toRunes }}
<span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalUsers }} users (updated once a day)">{{ $d }}</span> <span class="bg-gray-900 rounded-sm p-1 border border-gray-700 font-mono" style="margin: auto -2px;" title="{{ $.TotalUsers }} users (updated every hour)">{{ $d }}</span>
{{ end }} {{ end }}
<span class="ml-1">users.</span> <span class="ml-1">users.</span>
</p> </p>
@ -72,6 +72,7 @@
class="underline">Prometheus</a> metrics via <a class="underline">Prometheus</a> metrics via <a
href="https://github.com/MacroPower/wakatime_exporter" target="_blank" href="https://github.com/MacroPower/wakatime_exporter" target="_blank"
rel="noopener noreferrer" class="underline">exporter</a></li> rel="noopener noreferrer" class="underline">exporter</a></li>
<li>&nbsp; Lightning fast</li>
<li>&nbsp; Self-hosted</li> <li>&nbsp; Self-hosted</li>
</ul> </ul>
</div> </div>

View File

@ -22,7 +22,7 @@
{{ template "header.tpl.html" . }} {{ template "header.tpl.html" . }}
<div class="w-full flex justify-center"> <div class="w-full flex justify-center">
<div class="flex items-center justify-between max-w-xl flex-grow"> <div class="flex items-center justify-between max-w-2xl flex-grow">
<div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div> <div><a href="" class="text-gray-500 text-sm">&larr; Go back</a></div>
<div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div> <div><h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Settings</h1></div>
<div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div> <div>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;</div>
@ -32,7 +32,7 @@
{{ template "alerts.tpl.html" . }} {{ template "alerts.tpl.html" . }}
<main class="mt-4 flex-grow flex justify-center w-full"> <main class="mt-4 flex-grow flex justify-center w-full">
<div class="flex flex-col flex-grow max-w-xl mt-8"> <div class="flex flex-col flex-grow max-w-2xl mt-8">
<details class="my-8 pb-8 border-b border-gray-700"> <details class="my-8 pb-8 border-b border-gray-700">
<summary class="cursor-pointer"> <summary class="cursor-pointer">
@ -534,26 +534,6 @@
</main> </main>
<script type="text/javascript"> <script type="text/javascript">
const baseUrl = location.href.substring(0, location.href.indexOf('/settings'))
document.querySelectorAll('.with-url-src').forEach(e => {
e.setAttribute('src', e.getAttribute('src').replace('%s', baseUrl))
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-src-no-scheme').forEach(e => {
const strippedUrl = baseUrl.replace(/https?:\/\//, '')
e.setAttribute('src', e.getAttribute('src').replace('%s', strippedUrl))
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-inner').forEach(e => {
e.innerHTML = e.innerHTML.replace('%s', baseUrl)
e.classList.remove('hidden')
})
document.querySelectorAll('.with-url-inner-no-scheme').forEach(e => {
const strippedUrl = baseUrl.replace(/https?:\/\//, '')
e.innerHTML = e.innerHTML.replace('%s', strippedUrl)
e.classList.remove('hidden')
})
const btnRegenerate = document.querySelector('#btn-regenerate-summaries') const btnRegenerate = document.querySelector('#btn-regenerate-summaries')
const formRegenerate = document.querySelector('#form-regenerate-summaries') const formRegenerate = document.querySelector('#form-regenerate-summaries')
btnRegenerate.addEventListener('click', () => { btnRegenerate.addEventListener('click', () => {

View File

@ -49,6 +49,13 @@
type="password" id="password_repeat" type="password" id="password_repeat"
name="password_repeat" placeholder="Repeat your password" minlength="6" required> name="password_repeat" placeholder="Repeat your password" minlength="6" required>
</div> </div>
{{ if eq .TotalUsers 0 }}
<p class="text-sm text-gray-300 mt-4 mb-8">
⚠️ <strong>Please note: </strong> Since there are no users registered in the system, yet, the first user will have administrative privileges, while additional users won't.
</p>
{{ end }}
<div class="flex justify-between float-right"> <div class="flex justify-between float-right">
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm"> <button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Create Account Create Account

View File

@ -36,33 +36,57 @@
</div> </div>
<div class="flex items-center 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">Your Coding Statistics 🤓</h1> <h1 class="font-semibold text-2xl text-white m-0 border-b-4 border-green-700">Summary</h1>
</div> </div>
<div class="text-white text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap"> {{ if .User.HasData }}
<a href="summary?interval=today" class="mx-2 my-1 border-b border-green-700">Today</a>
<a href="summary?interval=yesterday" class="mx-2 my-1 border-b border-green-700">Yesterday</a> <div class="self-center border border-gray-700 shadow mt-8 rounded-md p-4 bg-gray-900">
<a href="summary?interval=week" class="mx-2 my-1 border-b border-green-700">This Week</a> <form class="text-white flex flex-nowrap items-center justify-center self-center max-w-xl flex-wrap space-x-8">
<a href="summary?interval=month" class="mx-2 my-1 border-b border-green-700">This Month</a> <div class="flex space-x-1">
<a href="summary?interval=year" class="mx-2 my-1 border-b border-green-700">This Year</a> <label for="from-date-picker" class="text-gray-300 pl-1">▶️ Start:</label>
<a href="summary?interval=last_7_days" class="mx-2 my-1 border-b border-green-700">Past 7 Days</a> <input id="from-date-picker" type="date" name="from" class="text-sm text-gray-300 bg-gray-800 rounded-md text-center cursor-pointer"
<a href="summary?interval=last_30_days" class="mx-2 my-1 border-b border-green-700">Past 30 Days</a> value="{{ .FromTime.T | simpledate }}" required>
<a href="summary?interval=last_12_months" class="mx-2 my-1 border-b border-green-700">Past 12 Months</a> </div>
<a href="summary?interval=any" class="mx-2 my-1 border-b border-green-700">All Time</a> <div class="flex space-x-1">
<label for="to-date-picker" class="text-gray-300 pl-1">⏹️ End:</label>
<input id="to-date-picker" type="date" name="to" class="text-sm text-gray-300 bg-gray-800 rounded-md text-center cursor-pointer"
value="{{ .ToTime.T | simpledate }}" required>
</div>
<div>
<button type="submit" class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">Show</button>
</div>
</form>
<div class="text-gray-300 text-sm flex items-center justify-center mt-4 self-center max-w-lg flex-wrap">
<a href="summary?interval=today" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Today</a>
<a href="summary?interval=yesterday" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Yesterday</a>
<a href="summary?interval=week" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">This Week</a>
<a href="summary?interval=month" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">This Month</a>
<a href="summary?interval=year" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">This Year</a>
<a href="summary?interval=last_7_days" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Past 7 Days</a>
<a href="summary?interval=last_30_days" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Past 30 Days</a>
<a href="summary?interval=last_12_months" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">Past 12 Months</a>
<a href="summary?interval=any" class="px-1 my-1 mx-1 border-b hover:border-b-2 border-gray-700 hover:bg-green-700 rounded hover:border-none">All Time</a>
</div>
</div> </div>
{{ end }}
{{ template "alerts.tpl.html" . }} {{ template "alerts.tpl.html" . }}
<main class="mt-10 flex-grow"> <main class="flex flex-col items-center mt-10 flex-grow">
<div class="flex justify-center">
<div class="p-1"> {{ if .User.HasData }}
<div class="flex justify-center p-4 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow">
<p class="mx-2"><strong>▶️</strong> <span title="Start Time">{{ .FromTime.T | date }}</span></p> <span class="text-white text-lg text-gray-300 text-center mb-4">
<p class="mx-2"><strong>⏹️</strong> <span title="End Time">{{ .ToTime.T | date }}</span></p> <span class="text-xl">⏱️&nbsp;</span>
<p class="mx-2"><strong>⏱️</strong> <span id="total-span" title="Total Hours"></span></p> Showing a total of <span id="total-span" title="Total Hours" class="text-white text-xl font-semibold border-b-2 border-green-700"></span>
</div> <span class="text-sm my-2">
</div> (from <span title="Start Time" class="border-b border-gray-700">{{ .FromTime.T | date }}</span> to <span title="End Time" class="border-b border-gray-700">{{ .ToTime.T | date }}</span>)
</div> </span>
</span>
<div class="flex flex-wrap justify-center"> <div class="flex flex-wrap justify-center">
<div class="w-full lg:w-1/2 p-1"> <div class="w-full lg:w-1/2 p-1">
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="project-container" style="height: 300px"> <div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="project-container" style="height: 300px">
@ -76,8 +100,7 @@
</div> </div>
<canvas id="chart-projects" class="mt-2"></canvas> <canvas id="chart-projects" class="mt-2"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/> <span class="text-md font-semibold text-gray-500 mt-4">No data</span>
<span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
</div> </div>
@ -93,8 +116,7 @@
</div> </div>
<canvas id="chart-os"></canvas> <canvas id="chart-os"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/> <span class="text-md font-semibold text-gray-500 mt-4">No data</span>
<span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
</div> </div>
@ -110,8 +132,7 @@
</div> </div>
<canvas id="chart-language"></canvas> <canvas id="chart-language"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/> <span class="text-md font-semibold text-gray-500 mt-4">No data</span>
<span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
</div> </div>
@ -127,8 +148,7 @@
</div> </div>
<canvas id="chart-editor"></canvas> <canvas id="chart-editor"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/> <span class="text-md font-semibold text-gray-500 mt-4">No data</span>
<span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
</div> </div>
@ -144,18 +164,55 @@
</div> </div>
<canvas id="chart-machine"></canvas> <canvas id="chart-machine"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col"> <div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<img src="assets/images/no_data.svg" class="w-20" alt="No data"/> <span class="text-md font-semibold text-gray-500 mt-4">No data</span>
<span class="text-sm mt-4">No data available ...</span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
{{ else }}
<div class="max-w-screen-sm flex flex-col items-center mt-12 space-y-8 text-gray-300 text-center">
<div class="pb-4">
<img src="assets/images/welcome.svg" width="200px" alt="User welcome illustration">
</div>
<p class="text-sm">
<strong>Welcome to Wakapi! 👋</strong> It looks like there is no data available for the specified time range.<br>If you logged in to Wakapi for the first time, see the setup instructions below on how to get started.
</p>
<div class="w-full pt-10 flex flex-col space-y-4">
<div>
<h3 class="inline-block font-semibold text-md border-b border-green-700">Setup Instructions</h3>
</div>
<div class="w-full bg-gray-900 text-left rounded-md py-4 px-8 text-xs font-mono shadow-md">
# <strong>Step 1:</strong> Download WakaTime plugin for your IDE<br>
# See: https://wakatime.com/plugins<br><br>
# <strong>Step 2:</strong> Adapt your config<br>
$ vi ~/.wakatime.cfg<br>
# Set <em>api_url = <span class="with-url-inner">%s/api/heartbeat</span></em><br>
# Set <em>api_key = <span id="api-key-instruction"></span></em><br><br>
# <strong>Step 3:</strong> Start coding and then check back here!
</div>
<p class="pt-4 text-sm">
More at <a href="https://github.com/muety/wakapi" target="_blank" rel="noreferrer noopener" class="font-mono border-b border-green-700">github.com/muety/wakapi</a>.
</p>
</div>
</div>
{{ end }}
</main> </main>
{{ template "footer.tpl.html" . }} {{ template "footer.tpl.html" . }}
{{ template "foot.tpl.html" . }} {{ template "foot.tpl.html" . }}
<script type="text/javascript">
document.querySelector('#api-key-instruction').innerHTML = document.querySelector('#api-key-container').value
</script>
<script> <script>
const languageColors = {{ .LanguageColors | json }} const languageColors = {{ .LanguageColors | json }}
const editorColors = {{ .EditorColors | json }} const editorColors = {{ .EditorColors | json }}