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

Compare commits

..

63 Commits

Author SHA1 Message Date
81835a3d88 chore: bump version 2021-08-29 10:54:26 +02:00
30de96950b chore: persist raw user agent value 2021-08-29 10:54:00 +02:00
11291b0d6c fix: properly format x axis for durations (see #232) 2021-08-29 10:32:23 +02:00
f0ac0f6153 fix: ui errors from conditional HasData on summary 2021-08-29 11:10:54 +10:00
6aad1633e1 chore: update issue templates [ci skip] 2021-08-21 09:35:45 +02:00
c07a4d71a0 fix: include tzdata package in alpine docker image [ci-skip] 2021-08-21 09:16:45 +02:00
dff0b742fc Merge branch 'sdvcrx-master' 2021-08-19 09:01:27 +02:00
4f65f94766 chore: bump version 2021-08-19 09:01:19 +02:00
825663acde fix: compatible with new wakatime-cli 2021-08-19 14:48:26 +08:00
f399fd4ea7 docs: readme [ci-skip] 2021-08-12 09:25:19 +02:00
87fadf46f7 chore: use partial includes in mail templates to avoid code duplication 2021-08-08 12:33:40 +02:00
69f5d510dc chore: exclude health endpoint from logging 2021-08-07 10:31:26 +02:00
0542813ed6 docs: update backwards-compatible api url in readme 2021-08-07 10:23:27 +02:00
c962a3891d chore: update postman collection 2021-08-07 10:18:33 +02:00
2088987a0c chore: implement diagnostics endpoint (resolve #225) 2021-08-07 10:16:50 +02:00
9e3203ac41 fix: tests 2021-08-07 00:12:45 +02:00
58719182c4 chore: notify users about failing wakatime connection 2021-08-06 23:28:03 +02:00
a8df25be08 chore: more verbose logging 2021-08-06 22:38:57 +02:00
391cc1e5b4 chore: fix syntax for postgres 2021-08-06 17:17:06 +02:00
3bb22e5e84 Merge remote-tracking branch 'origin/master' 2021-08-06 17:08:28 +02:00
93bdb48d95 fix: resolve project labels before resolving aliases (resolve #222) 2021-08-06 17:08:11 +02:00
533b5d62fc fix: speed up settings page (resolve #226) 2021-08-06 16:37:01 +02:00
0af5fab75f refactor: resolve project labels at runtime (resolve #227) 2021-08-06 16:36:56 +02:00
fecc8b3b5f fix: remove unix socket if exists (#220) 2021-08-06 16:36:44 +02:00
24b8ff6381 feat: build/push arm64 Docker image 2021-08-06 16:36:44 +02:00
180e75a5eb fix: README link to 'config.default.yml' 2021-08-06 16:36:44 +02:00
f48b49d26e chore: upgrade dependencies 2021-08-06 14:26:03 +02:00
47b9cacb26 fix: remove unix socket if exists (#220) 2021-07-10 09:10:55 +00:00
23fc1b62cc Merge pull request #219 from muety/docker-arm
Build and push arm64 Docker image
2021-07-09 09:47:31 +02:00
74f6a255a8 feat: build/push arm64 Docker image 2021-07-09 16:17:50 +10:00
7a5dce29bd Merge pull request #218 from donPabloNow/master
fix: README link to 'config.default.yml'
2021-07-07 11:36:26 +02:00
0e1596fe70 fix: README link to 'config.default.yml' 2021-07-06 23:44:08 +02:00
48513b660d chore: configurable count cache ttl 2021-06-27 12:08:11 +02:00
69f73fc0ea chore: dependency upgrades 2021-06-27 11:46:08 +02:00
0e788b0777 chore: bump version 2021-06-27 11:37:54 +02:00
181aefa2f9 chore: further optimizations and caching to speed up metrics endpoint (resolve #215) 2021-06-27 11:33:14 +02:00
407925ec53 feat: add alpine image 2021-06-27 18:01:43 +10:00
5e96e2a601 chore: cache active users with hourly precision 2021-06-26 12:42:51 +02:00
4d2a160ccb chore: configurable request timeout 2021-06-24 21:56:47 +02:00
c3957ec0c8 chore: log unmatched requests 2021-06-24 21:40:51 +02:00
312dfb36d8 chore: add default config param 2021-06-23 18:45:58 +02:00
c66605d463 chore: bump version 2021-06-23 18:43:54 +02:00
3c12df52d9 feat: 🎸 add support for using a UNIX domain socket 2021-06-23 11:44:00 -04:00
dd6a040171 chore: add api tests for all alternative heartbeat endpoints 2021-06-22 00:27:46 +02:00
9f1266957b fix: single heartbeat endpoint (resolve #212)
docs: swagger docs for all available heartbeat endpoints
2021-06-21 21:53:47 +02:00
466f2e1786 fix: summary caching (resolve #211) 2021-06-19 12:47:35 +02:00
82b8951437 fix: attempt to fix failing sqlite migrations (resolve #210) 2021-06-13 11:43:24 +02:00
25464e9519 chore: code smells 2021-06-13 10:14:15 +02:00
650fffa344 fix: exclude zero entries again 2021-06-12 12:06:24 +02:00
69627fbe11 fix: exclude zero entries 2021-06-12 12:04:38 +02:00
561198b203 chore: minor ui improvements 2021-06-12 12:01:20 +02:00
7c4a2024b6 chore: link to labels settings 2021-06-12 11:40:13 +02:00
7bcd6890d1 chore: adapt tests and bump version 2021-06-12 11:26:15 +02:00
1e4e530c21 chore: adapt tests 2021-06-12 11:09:24 +02:00
490cca05eb feat: ui for managing project labels 2021-06-12 10:44:19 +02:00
3780ae4255 fix: invalidate user summary cache (fix #209) 2021-06-12 10:43:56 +02:00
628ea0b9dd fix: nil pointer dereference
chore: allow to share labels publicly on settings page
2021-06-12 09:12:28 +02:00
0d64858721 feat: implement project labels (resolve #204) 2021-06-11 20:59:34 +02:00
c1c78d8d5b test: add more api tests 2021-06-11 17:47:33 +02:00
538b9d2463 fix: permissions for stats endpoint 2021-06-11 17:41:45 +02:00
f4612fd542 fix: badge endpoint permission fixes (resolve #205)
fix: reference past x days intervals from now instead of start of day
2021-06-11 16:02:28 +02:00
fb643571d2 Merge remote-tracking branch 'origin/master' 2021-06-10 23:22:58 +02:00
a4d47fb566 test: more api tests [ci skip] 2021-05-29 09:52:26 +02:00
79 changed files with 5569 additions and 1255 deletions

19
.github/ISSUE_TEMPLATE/bug.md vendored Normal file
View File

@ -0,0 +1,19 @@
---
name: Bug
about: Create a report to help us improve
title: ''
labels: ''
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is. Please briefly describe how to reproduce the bug as well as _expected_ vs. _actual_ behavior. Optionally include screenshots and server logs, if helpful.
**System information**
Please provide information on:
* Wakapi version
* Operating system
* If Linux: which distro?
* If Docker: which image and tag?
* Database (SQLite, MySQL, ... ?)

10
.github/ISSUE_TEMPLATE/other.md vendored Normal file
View File

@ -0,0 +1,10 @@
---
name: Other (feature request, question, ...)
about: Anything else
title: ''
labels: ''
assignees: ''
---

View File

@ -14,6 +14,9 @@ jobs:
- name: Get version
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
@ -38,5 +41,18 @@ jobs:
tags: |
n1try/wakapi:${{ env.GIT_TAG }}
n1try/wakapi:latest
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache
- name: Build and push to Docker Hub (Alpine)
uses: docker/build-push-action@v2
with:
file: Dockerfile.alpine
push: true
tags: |
n1try/wakapi:${{ env.GIT_TAG }}-alpine
n1try/wakapi:latest-alpine
platforms: linux/amd64,linux/arm64
cache-from: type=local,src=/tmp/.buildx-cache
cache-to: type=local,dest=/tmp/.buildx-cache

View File

@ -29,7 +29,8 @@ FROM debian
WORKDIR /app
RUN apt update && \
apt install -y ca-certificates
apt install -y ca-certificates && \
rm -rf /var/lib/apt/lists/*
# See README.md and config.default.yml for all config options
ENV ENVIRONMENT prod

52
Dockerfile.alpine Normal file
View File

@ -0,0 +1,52 @@
# Build Stage
FROM golang:1.16-alpine AS build-env
WORKDIR /src
# Required for go-sqlite3
RUN apk add gcc musl-dev
ADD ./go.mod .
RUN go mod download
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
chmod +x wait-for-it.sh
ADD . .
RUN go build -o wakapi
WORKDIR /app
RUN cp /src/wakapi . && \
cp /src/config.default.yml config.yml && \
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
cp /src/wait-for-it.sh . && \
cp /src/entrypoint.sh .
# Run Stage
# When running the application using `docker run`, you can pass environment variables
# to override config values using `-e` syntax.
# Available options can be found in [README.md#-configuration](README.md#-configuration)
FROM alpine:3
WORKDIR /app
RUN apk update && apk add bash ca-certificates tzdata && rm -rf /var/cache/apk
# See README.md and config.default.yml for all config options
ENV ENVIRONMENT prod
ENV WAKAPI_DB_TYPE sqlite3
ENV WAKAPI_DB_USER ''
ENV WAKAPI_DB_PASSWORD ''
ENV WAKAPI_DB_HOST ''
ENV WAKAPI_DB_NAME=/data/wakapi.db
ENV WAKAPI_PASSWORD_SALT ''
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
ENV WAKAPI_INSECURE_COOKIES 'true'
ENV WAKAPI_ALLOW_SIGNUP 'true'
COPY --from=build-env /app .
VOLUME /data
ENTRYPOINT /app/entrypoint.sh

View File

@ -4,14 +4,13 @@
<p align="center">
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
</p>
<p align="center">
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
</p>
@ -133,8 +132,8 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
```ini
[settings]
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
api_url = http://localhost:3000/api
# Your Wakapi server URL or 'https://wakapi.dev' when using the cloud server
api_url = http://localhost:3000/api/heartbeat
# Your Wakapi API key (get it from the web interface after having created an account)
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
@ -152,6 +151,8 @@ You can specify configuration options either via a config file (default: `config
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
| `server.listen_ipv4` | `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
| `server.listen_ipv6` | `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
| `server.listen_socket` | `WAKAPI_LISTEN_SOCKET` | - | UNIX socket to listen on (leave blank to disable UNIX socket) |
| `server.timeout_sec` | `WAKAPI_TIMEOUT_SEC` | `30` | Request timeout in seconds |
| `server.tls_cert_path` | `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
| `server.tls_key_path` | `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
@ -173,8 +174,8 @@ You can specify configuration options either via a config file (default: `config
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
| `mail.sender` | `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
| `mail.provider` | `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yaml) for details |
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yaml) for details |
| `mail.smtp.*` | `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details |
| `mail.mailwhale.*` | `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yml) for details |
| `sentry.dsn` | `WAKAPI_SENTRY_DSN` | | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
| `sentry.enable_tracing` | `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
@ -411,7 +412,11 @@ Wakapi adds a "padding" of two minutes before the third heartbeat. This is why t
</details>
## 🙏 Thanks
I highly appreciate the efforts of [@alanhamlett](https://github.com/alanhamlett) and the WakaTime team and am thankful for their software being open source.
I highly appreciate the efforts of **[@alanhamlett](https://github.com/alanhamlett)** and the WakaTime team and am thankful for their software being open source.
Moreover, thanks to **[JetBrains](https://jb.gg/OpenSource)** for supporting this project as part of their open-source program.
![](static/assets/images/jetbrains-logo.png)
## 📓 License
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)

View File

@ -3,6 +3,8 @@ env: production
server:
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
listen_ipv6: ::1 # leave blank to disable ipv6
listen_socket: # leave blank to disable unix sockets
timeout_sec: 30 # request timeout
tls_cert_path: # leave blank to not use https
tls_key_path: # leave blank to not use https
port: 3000

View File

@ -33,6 +33,7 @@ const (
SimpleDateTimeFormat = "2006-01-02 15:04:05"
ErrUnauthorized = "401 unauthorized"
ErrBadRequest = "400 bad request"
ErrInternalServerError = "500 internal server error"
)
@ -66,6 +67,7 @@ type appConfig struct {
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
CustomLanguages map[string]string `yaml:"custom_languages"`
Colors map[string]map[string]string `yaml:"-"`
}
@ -95,13 +97,15 @@ type dbConfig struct {
}
type serverConfig struct {
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
Port int `default:"3000" env:"WAKAPI_PORT"`
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_PATH"`
}
type sentryConfig struct {
@ -197,6 +201,12 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
return err
}
return nil
}
}
@ -347,8 +357,8 @@ func Load(version string) *Config {
}
// some validation checks
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
}
if config.Db.MaxConn <= 0 {
logbuch.Fatal("you must allow at least one database connection")

View File

@ -8,9 +8,17 @@ type ApplicationEvent struct {
}
const (
TopicUser = "user.*"
EventUserUpdate = "user.update"
FieldPayload = "payload"
TopicUser = "user.*"
TopicHeartbeat = "heartbeat.*"
TopicProjectLabel = "project_label.*"
EventUserUpdate = "user.update"
EventHeartbeatCreate = "heartbeat.create"
EventProjectLabelCreate = "project_label.create"
EventProjectLabelDelete = "project_label.delete"
EventWakatimeFailure = "wakatime.failure"
FieldPayload = "payload"
FieldUser = "user"
FieldUserId = "user.id"
)
var eventHub *hub.Hub

File diff suppressed because it is too large Load Diff

22
go.mod
View File

@ -3,37 +3,35 @@ module github.com/muety/wakapi
go 1.16
require (
github.com/BurntSushi/toml v0.4.1 // indirect
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.15.0
github.com/emvi/logbuch v1.2.0
github.com/felixge/httpsnoop v1.0.2 // indirect
github.com/getsentry/sentry-go v0.10.0
github.com/go-co-op/gocron v1.5.0
github.com/getsentry/sentry-go v0.11.0
github.com/go-co-op/gocron v1.6.2
github.com/go-openapi/spec v0.20.2 // indirect
github.com/gorilla/handlers v1.5.1
github.com/gorilla/mux v1.8.0
github.com/gorilla/schema v1.2.0
github.com/gorilla/securecookie v1.1.1
github.com/jackc/pgproto3/v2 v2.0.7 // indirect
github.com/jackc/pgx/v4 v4.11.0 // indirect
github.com/jackc/pgx/v4 v4.13.0 // indirect
github.com/jinzhu/configor v1.2.1
github.com/leandro-lugaresi/hub v1.1.1
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
github.com/mitchellh/hashstructure/v2 v2.0.1
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/satori/go.uuid v1.2.0
github.com/stretchr/testify v1.7.0
github.com/swaggo/swag v1.7.0
go.uber.org/atomic v1.7.0
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
go.uber.org/atomic v1.9.0
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c // indirect
golang.org/x/text v0.3.6 // indirect
golang.org/x/tools v0.1.0 // indirect
gorm.io/driver/mysql v1.0.6
gorm.io/driver/postgres v1.0.8
gorm.io/driver/mysql v1.1.1
gorm.io/driver/postgres v1.1.0
gorm.io/driver/sqlite v1.1.4
gorm.io/gorm v1.21.9
gorm.io/gorm v1.21.12
)

80
go.sum
View File

@ -1,8 +1,9 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
@ -96,19 +97,20 @@ github.com/franela/goblin v0.0.0-20200105215937-c9ffbefa60db/go.mod h1:7dvUGVsVB
github.com/franela/goreq v0.0.0-20171204163338-bcd34c9993f8/go.mod h1:ZhphrRTfi2rbfLwlschooIH4+wKKDR4Pdxhh+TRoA20=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
github.com/getsentry/sentry-go v0.10.0 h1:6gwY+66NHKqyZrdi6O2jGdo7wGdo9b3B69E01NFgT5g=
github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws=
github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8=
github.com/getsentry/sentry-go v0.11.0/go.mod h1:KBQIxiZAetw62Cj8Ri964vAEWVdgfaUCn30Q3bCvANo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
github.com/go-co-op/gocron v1.5.0 h1:tIiwAPwKGcazVFJTNmGe0wE73UpZSEHovoahqGGx9+c=
github.com/go-co-op/gocron v1.5.0/go.mod h1:7MgKum7jD7YgIRj7Zx7K1iJKAf1MlSIsEieRl18+KyU=
github.com/go-co-op/gocron v1.6.2 h1:x5g1tWnWcXIZesdosJJcbziRi4XG6tKB92yKLUpoBkU=
github.com/go-co-op/gocron v1.6.2/go.mod h1:DbJm9kdgr1sEvWpHCA7dFFs/PGHPMil9/97EXCRPr4k=
github.com/go-errors/errors v1.0.1 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.10.0/go.mod h1:xUsJbQ/Fp4kEt7AFgCuvyX4a71u8h9jB8tj/ORgOZ7o=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
@ -133,8 +135,9 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
github.com/gofrs/uuid v3.2.0+incompatible h1:y12jRkkFxsd7GpqdSZ+/KCs/fJbqpEXSGd4+jfEaewE=
github.com/gofrs/uuid v3.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw=
github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gogo/googleapis v1.1.0/go.mod h1:gf4bu3Q80BeJ6H1S1vYPm8/ELATdvryBaNFGgqEef3s=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
@ -153,8 +156,8 @@ github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
@ -219,12 +222,17 @@ github.com/jackc/pgconn v1.4.0/go.mod h1:Y2O3ZDF0q4mMacyWV3AstPJpeHXWGEetiFttmq5
github.com/jackc/pgconn v1.5.0/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.5.1-0.20200601181101-fa742c524853/go.mod h1:QeD3lBfpTFe8WUnPZWN5KY/mB8FGMIYRdd8P8Jr0fAI=
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
github.com/jackc/pgconn v1.8.1 h1:ySBX7Q87vOMqKU2bbmKbUvtYhauDFclYbNDYIE1/h6s=
github.com/jackc/pgconn v1.8.1/go.mod h1:JV6m6b6jhjdmzchES0drzCcYcAHS1OPD5xu3OZ/lE2g=
github.com/jackc/pgconn v1.9.0/go.mod h1:YctiPyvzfU11JFxoXokUOOKQXQmDMoJL9vJzHH8/2JY=
github.com/jackc/pgconn v1.9.1-0.20210724152538-d89c8390a530/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgconn v1.10.0 h1:4EYhlDVEMsJ30nNj0mmgwIUXoq7e9sMJrVC2ED6QlCU=
github.com/jackc/pgconn v1.10.0/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2 h1:JVX6jT/XfzNqIjye4717ITLaNwV9mWbJx0dLCpcRzdA=
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
github.com/jackc/pgmock v0.0.0-20201204152224-4fe30f7445fd/go.mod h1:hrBW0Enj2AZTNpt/7Y5rr2xe/9Mn757Wtb2xeBzPv2c=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65 h1:DadwsjnMwFjfWc9y5Wi/+Zz7xoE5ALHsRQlOctkOiHc=
github.com/jackc/pgmock v0.0.0-20210724152146-4ad1a8207f65/go.mod h1:5R2h2EEX+qri8jOWMbJCtaPWkrrNc7OHwsp2TCqp7ak=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
@ -235,8 +243,8 @@ github.com/jackc/pgproto3/v2 v2.0.0-rc3/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvW
github.com/jackc/pgproto3/v2 v2.0.0-rc3.0.20190831210041-4c03ce451f29/go.mod h1:ryONWYqW6dqSg1Lw6vXNMXoBJhpzvWKnT95C46ckYeM=
github.com/jackc/pgproto3/v2 v2.0.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.0.7 h1:6Pwi1b3QdY65cuv6SyVO0FgPd5J3Bl7wf/nQQjinHMA=
github.com/jackc/pgproto3/v2 v2.0.7/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgproto3/v2 v2.1.1 h1:7PQ/4gLoqnl87ZxL7xjO0DR5gYuviDCZxQJsUlFW1eI=
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200307190119-3430c5407db8/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
@ -246,18 +254,20 @@ github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrU
github.com/jackc/pgtype v1.2.0/go.mod h1:5m2OfMh1wTK7x+Fk952IDmI4nw3nPrvtQdM0ZT4WpC0=
github.com/jackc/pgtype v1.3.1-0.20200510190516-8cd94a14c75a/go.mod h1:vaogEUkALtxZMCH411K+tKzNpwzCKU+AnPzBKZ+I+Po=
github.com/jackc/pgtype v1.3.1-0.20200606141011-f6355165a91c/go.mod h1:cvk9Bgu/VzJ9/lxTO5R5sf80p0DiucVtN7ZxvaC4GmQ=
github.com/jackc/pgtype v1.6.2/go.mod h1:JCULISAZBFGrHaOXIIFiyfzW5VY0GRitRr8NeJsrdig=
github.com/jackc/pgtype v1.7.0 h1:6f4kVsW01QftE38ufBYxKciO6gyioXSC0ABIRLcZrGs=
github.com/jackc/pgtype v1.7.0/go.mod h1:ZnHF+rMePVqDKaOfJVI4Q8IVvAQMryDlDkZnKOI75BE=
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
github.com/jackc/pgtype v1.8.1 h1:9k0IXtdJXHJbyAWQgbWr1lU+MEhPXZz6RIXxfR5oxXs=
github.com/jackc/pgtype v1.8.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
github.com/jackc/pgx/v4 v4.5.0/go.mod h1:EpAKPLdnTorwmPUUsqrPxy5fphV18j9q3wrfRXgo+kA=
github.com/jackc/pgx/v4 v4.6.1-0.20200510190926-94ba730bb1e9/go.mod h1:t3/cdRQl6fOLDxqtlyhe9UWgfIi9R8+8v8GKV5TRA/o=
github.com/jackc/pgx/v4 v4.6.1-0.20200606145419-4e5062306904/go.mod h1:ZDaNWkt9sW1JMiNn0kdYBaLelIhw7Pg4qd+Vk6tw7Hg=
github.com/jackc/pgx/v4 v4.10.1/go.mod h1:QlrWebbs3kqEZPHCTGyxecvzG6tvIsYu+A5b1raylkA=
github.com/jackc/pgx/v4 v4.11.0 h1:J86tSWd3Y7nKjwT/43xZBvpi04keQWx8gNC2YkdJhZI=
github.com/jackc/pgx/v4 v4.11.0/go.mod h1:i62xJgdrtVDsnL3U8ekyrQXEwGNTRoG7/8r+CIdYfcc=
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
github.com/jackc/pgx/v4 v4.13.0 h1:JCjhT5vmhMAf/YwBHLvrBn4OGdIQBiFG6ym8Zmdx570=
github.com/jackc/pgx/v4 v4.13.0/go.mod h1:9P4X524sErlaxj0XSGZk7s+LD0eOyu1ZDUrrpznYDF0=
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
github.com/jackc/puddle v1.1.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
@ -307,8 +317,9 @@ github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJ
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.1.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.3.0 h1:/qkRGz8zljWiDcFvgpwUpwIAPu3r07TDvs3Rws+o/pU=
github.com/lib/pq v1.3.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/lib/pq v1.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lightstep/lightstep-tracer-common/golang/gogo v0.0.0-20190605223551-bc2310a04743/go.mod h1:qklhhLq1aX+mtWk9cPHPzaBjWImj5ULL6C7HFJtXQMM=
github.com/lightstep/lightstep-tracer-go v0.18.1/go.mod h1:jlF1pusYV4pidLvZ+XD0UBX0ZE6WURAspgAczcDHrL4=
github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
@ -343,8 +354,8 @@ github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrk
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/gox v0.4.0/go.mod h1:Sd9lOJ0+aimLBi73mGofS1ycjY8lL3uZM3JPS42BGNg=
github.com/mitchellh/hashstructure/v2 v2.0.1 h1:L60q1+q7cXE4JeEJJKMnh2brFIe3rZxCihYAB61ypAY=
github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
github.com/mitchellh/iochan v1.0.0/go.mod h1:JwYml1nuB7xOzsp52dPpHFffvOCDupsG0QubkSMEySY=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
@ -433,8 +444,9 @@ github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtm
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc h1:jUIKcSPO9MoMJBbEoyE/RJoE8vz7Mb8AjvifMMwSyvY=
github.com/shopspring/decimal v0.0.0-20200227202807-02e2044944cc/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
@ -500,8 +512,8 @@ go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
@ -522,9 +534,11 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200323165209-0ec3e9974c59/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 h1:/UOmuWzQfxxo9UtlXMwuQU8CMgg1eZXqTRwkSQJWKOI=
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
@ -590,14 +604,16 @@ golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191220142924-d4481acd189f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c h1:VwygUrnw9jn88c4u8GD3rZQbqrP/tgas88tPUbBxQrk=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
@ -684,16 +700,16 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.0.6 h1:mA0XRPjIKi4bkE9nv+NKs6qj6QWOchqUSdWOcpd3x1E=
gorm.io/driver/mysql v1.0.6/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU=
gorm.io/driver/postgres v1.0.8 h1:PAgM+PaHOSAeroTjHkCHCBIHHoBIf9RgPWGo8dF2DA8=
gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
gorm.io/driver/mysql v1.1.1 h1:yr1bpyqiwuSPJ4aGGUX9nu46RHXlF8RASQVb1QQNcvo=
gorm.io/driver/mysql v1.1.1/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU=
gorm.io/driver/postgres v1.1.0 h1:afBljg7PtJ5lA6YUWluV2+xovIPhS+YiInuL3kUjrbk=
gorm.io/driver/postgres v1.1.0/go.mod h1:hXQIwafeRjJvUm+OMxcFWyswJ/vevcpPLlGocwAwuqw=
gorm.io/driver/sqlite v1.1.4 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
gorm.io/gorm v1.20.7/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.20.12/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
gorm.io/gorm v1.21.9 h1:INieZtn4P2Pw6xPJ8MzT0G4WUOsHq3RhfuDF1M6GW0E=
gorm.io/gorm v1.21.9/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
gorm.io/gorm v1.21.12 h1:3fQM0Eiz7jcJEhPggHEpoYnsGZqynMzverL77DV40RM=
gorm.io/gorm v1.21.12/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

99
main.go
View File

@ -2,8 +2,10 @@ package main
import (
"embed"
"github.com/muety/wakapi/models"
"io/fs"
"log"
"net"
"net/http"
"os"
"strconv"
@ -49,8 +51,10 @@ var (
heartbeatRepository repositories.IHeartbeatRepository
userRepository repositories.IUserRepository
languageMappingRepository repositories.ILanguageMappingRepository
projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
diagnosticsRepository repositories.IDiagnosticsRepository
)
var (
@ -58,11 +62,13 @@ var (
heartbeatService services.IHeartbeatService
userService services.IUserService
languageMappingService services.ILanguageMappingService
projectLabelService services.IProjectLabelService
summaryService services.ISummaryService
aggregationService services.IAggregationService
mailService services.IMailService
keyValueService services.IKeyValueService
reportService services.IReportService
diagnosticsService services.IDiagnosticsService
miscService services.IMiscService
)
@ -113,6 +119,7 @@ func main() {
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
if config.Db.Dialect == "sqlite3" {
db.Raw("PRAGMA foreign_keys = ON;")
db.DisableForeignKeyConstraintWhenMigrating = true
}
if config.IsDev() {
@ -135,19 +142,23 @@ func main() {
heartbeatRepository = repositories.NewHeartbeatRepository(db)
userRepository = repositories.NewUserRepository(db)
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
// Services
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
mailService = mail.NewMailService()
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(mailService, userRepository)
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Schedule background tasks
@ -162,6 +173,7 @@ func main() {
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
// Compat Handlers
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
@ -173,7 +185,7 @@ func main() {
// MVC Handlers
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
homeHandler := routes.NewHomeHandler(keyValueService)
loginHandler := routes.NewLoginHandler(userService, mailService)
imprintHandler := routes.NewImprintHandler(keyValueService)
@ -183,9 +195,17 @@ func main() {
rootRouter := router.PathPrefix("/").Subrouter()
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
// https://github.com/gorilla/mux/issues/416
router.NotFoundHandler = router.NewRoute().BuildOnly().HandlerFunc(http.NotFound).GetHandler()
router.NotFoundHandler = middlewares.NewLoggingMiddleware(logbuch.Info, []string{
"/assets",
"/favicon",
"/service-worker.js",
})(router.NotFoundHandler)
// Globally used middlewares
router.Use(middlewares.NewPrincipalMiddleware())
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets"}))
router.Use(middlewares.NewLoggingMiddleware(logbuch.Info, []string{"/assets", "/api/health"}))
router.Use(handlers.RecoveryHandler())
if config.Sentry.Dsn != "" {
router.Use(middlewares.NewSentryMiddleware())
@ -204,6 +224,7 @@ func main() {
healthApiHandler.RegisterRoutes(apiRouter)
heartbeatApiHandler.RegisterRoutes(apiRouter)
metricsHandler.RegisterRoutes(apiRouter)
diagnosticsHandler.RegisterRoutes(apiRouter)
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
@ -223,12 +244,24 @@ func main() {
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(fileServer),
)
// Miscellaneous
// Pre-warm projects cache
if !config.IsDev() {
allUsers, err := userService.GetAll()
if err == nil {
logbuch.Info("pre-warming user project cache")
for _, u := range allUsers {
go heartbeatService.GetEntitySetByUser(models.SummaryProject, u)
}
}
}
// Listen HTTP
listen(router)
}
func listen(handler http.Handler) {
var s4, s6 *http.Server
var s4, s6, sSocket *http.Server
// IPv4
if config.Server.ListenIpV4 != "" {
@ -236,8 +269,8 @@ func listen(handler http.Handler) {
s4 = &http.Server{
Handler: handler,
Addr: bindString4,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
}
}
@ -247,8 +280,24 @@ func listen(handler http.Handler) {
s6 = &http.Server{
Handler: handler,
Addr: bindString6,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
}
}
// UNIX domain socket
if config.Server.ListenSocket != "" {
// Remove if exists
if _, err := os.Stat(config.Server.ListenSocket); err == nil {
logbuch.Info("--> Removing unix socket %s", config.Server.ListenSocket)
if err := os.Remove(config.Server.ListenSocket); err != nil {
logbuch.Fatal(err.Error())
}
}
sSocket = &http.Server{
Handler: handler,
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
}
}
@ -269,6 +318,18 @@ func listen(handler http.Handler) {
}
}()
}
if sSocket != nil {
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
} else {
if s4 != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
@ -286,6 +347,18 @@ func listen(handler http.Handler) {
}
}()
}
if sSocket != nil {
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
go func() {
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
if err != nil {
logbuch.Fatal(err.Error())
}
if err := sSocket.Serve(unixListener); err != nil {
logbuch.Fatal(err.Error())
}
}()
}
}
<-make(chan interface{}, 1)

View File

@ -5,17 +5,24 @@ import (
"encoding/base64"
"fmt"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/models"
"github.com/patrickmn/go-cache"
"io"
"io/ioutil"
"net/http"
"time"
)
const maxFailuresPerDay = 100
/* Middleware to conditionally relay heartbeats to Wakatime */
type WakatimeRelayMiddleware struct {
httpClient *http.Client
httpClient *http.Client
failureCache *cache.Cache
eventBus *hub.Hub
}
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
@ -23,6 +30,8 @@ func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
failureCache: cache.New(24*time.Hour, 1*time.Hour),
eventBus: config.EventBus(),
}
}
@ -66,10 +75,11 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
bytes.NewReader(body),
headers,
user,
)
}
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header) {
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) {
request, err := http.NewRequest(method, url, body)
if err != nil {
logbuch.Warn("error constructing relayed request %v", err)
@ -89,6 +99,19 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
logbuch.Warn("failed to relay request, got status %d", response.StatusCode)
logbuch.Warn("failed to relay request for user %s, got status %d", forUser.ID, response.StatusCode)
// TODO: use leaky bucket instead of expiring cache?
if _, found := m.failureCache.Get(forUser.ID); !found {
m.failureCache.SetDefault(forUser.ID, 0)
}
if n, _ := m.failureCache.IncrementInt(forUser.ID, 1); n == maxFailuresPerDay {
m.eventBus.Publish(hub.Message{
Name: config.EventWakatimeFailure,
Fields: map[string]interface{}{config.FieldUser: forUser, config.FieldPayload: n},
})
} else if n%10 == 0 {
logbuch.Warn("%d / %d failed wakatime heartbeat relaying attempts for user %s within last 24 hours", n, maxFailuresPerDay, forUser.ID)
}
}
}

View File

@ -11,7 +11,7 @@ func init() {
f := migrationFunc{
name: name,
f: func(db *gorm.DB, cfg *config.Config) error {
if err := db.Migrator().DropTable("gorp_migrations"); err != nil {
if err := db.Migrator().DropTable("gorp_migrations"); err == nil {
logbuch.Info("dropped table 'gorp_migrations'")
}
return nil

View File

@ -0,0 +1,50 @@
package migrations
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
func init() {
const name = "20210806-remove_persisted_project_labels"
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
}
rawDb, err := db.DB()
if err != nil {
logbuch.Error("failed to retrieve raw sql db instance")
return err
}
if _, err := rawDb.Exec(fmt.Sprintf("delete from summary_items where type = %d", models.SummaryLabel)); err != nil {
logbuch.Error("failed to delete project label summary items")
return err
}
logbuch.Info("successfully deleted project label summary items")
if err := db.Create(&models.KeyStringValue{
Key: name,
Value: "done",
}).Error; err != nil {
return err
}
return nil
},
}
registerPostMigration(f)
}

View File

@ -0,0 +1,35 @@
package mocks
import (
"github.com/muety/wakapi/models"
"github.com/stretchr/testify/mock"
)
type ProjectLabelServiceMock struct {
mock.Mock
}
func (p *ProjectLabelServiceMock) GetById(u uint) (*models.ProjectLabel, error) {
args := p.Called(u)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUser(s string) ([]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).([]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*models.ProjectLabel, error) {
args := p.Called(s)
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
args := p.Called(l)
return args.Get(0).(*models.ProjectLabel), args.Error(1)
}
func (p *ProjectLabelServiceMock) Delete(l *models.ProjectLabel) error {
args := p.Called(l)
return args.Error(0)
}

View File

@ -39,8 +39,8 @@ func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
return args.Get(0).([]*models.User), args.Error(1)
}
func (m *UserServiceMock) GetActive() ([]*models.User, error) {
args := m.Called()
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
args := m.Called(b)
return args.Get(0).([]*models.User), args.Error(1)
}

13
models/diagnostics.go Normal file
View File

@ -0,0 +1,13 @@
package models
type Diagnostics struct {
ID uint `gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_diagnostics_user"`
Platform string `json:"platform"`
Architecture string `json:"architecture"`
Plugin string `json:"plugin"`
CliVersion string `json:"cli_version"`
Logs string `json:"logs" gorm:"type:text"`
StackTrace string `json:"stacktrace" gorm:"type:text"`
}

View File

@ -6,6 +6,7 @@ type Filters struct {
Language string
Editor string
Machine string
Label string
}
type FilterElement struct {
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
return &Filters{Editor: key}
case SummaryMachine:
return &Filters{Machine: key}
case SummaryLabel:
return &Filters{Label: key}
}
return &Filters{}
}
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
return true, SummaryEditor, f.Editor
} else if f.Machine != "" {
return true, SummaryMachine, f.Machine
} else if f.Label != "" {
return true, SummaryLabel, f.Label
}
return false, 0, ""
}

View File

@ -22,6 +22,7 @@ type Heartbeat struct {
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
UserAgent string `json:"user_agent" hash:"ignore"`
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
Origin string `json:"-" hash:"ignore"`

13
models/project_label.go Normal file
View File

@ -0,0 +1,13 @@
package models
type ProjectLabel struct {
ID uint `json:"id" gorm:"primary_key"`
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
ProjectKey string `json:"project"`
Label string `json:"label" gorm:"type:varchar(64)"`
}
func (l *ProjectLabel) IsValid() bool {
return l.ProjectKey != "" && l.Label != ""
}

View File

@ -1,6 +1,7 @@
package models
import (
"errors"
"sort"
"time"
)
@ -12,9 +13,11 @@ const (
SummaryEditor uint8 = 2
SummaryOS uint8 = 3
SummaryMachine uint8 = 4
SummaryLabel uint8 = 5
)
const UnknownSummaryKey = "unknown"
const DefaultProjectLabel = "default"
type Summary struct {
ID uint `json:"-" gorm:"primary_key"`
@ -27,6 +30,7 @@ type Summary struct {
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
}
type SummaryItems []*SummaryItem
@ -68,6 +72,10 @@ type SummaryParams struct {
type AliasResolver func(t uint8, k string) string
func SummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
}
func NativeSummaryTypes() []uint8 {
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
}
@ -77,6 +85,7 @@ func (s *Summary) Sorted() *Summary {
sort.Sort(sort.Reverse(s.OperatingSystems))
sort.Sort(sort.Reverse(s.Languages))
sort.Sort(sort.Reverse(s.Editors))
sort.Sort(sort.Reverse(s.Labels))
return s
}
@ -91,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
SummaryEditor: &s.Editors,
SummaryOS: &s.OperatingSystems,
SummaryMachine: &s.Machines,
SummaryLabel: &s.Labels,
}
}
@ -109,7 +119,7 @@ of time than the other ones.
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
*/
func (s *Summary) FillUnknown() {
func (s *Summary) FillMissing() {
types := s.Types()
typeItems := s.MappedItems()
missingTypes := make([]uint8, 0)
@ -125,15 +135,46 @@ func (s *Summary) FillUnknown() {
return
}
timeSum := s.TotalTime()
// construct dummy item for all missing types
presentType, err := s.findFirstPresentType()
if err != nil {
return // all types are either zero or missing entirely, nothing to fill
}
for _, t := range missingTypes {
*typeItems[t] = append(*typeItems[t], &SummaryItem{
Type: t,
Key: UnknownSummaryKey,
Total: timeSum,
})
s.FillBy(presentType, t)
}
}
// inplace!
func (s *Summary) FillBy(fromType uint8, toType uint8) {
typeItems := s.MappedItems()
totalWanted := s.TotalTimeBy(fromType)
totalActual := s.TotalTimeBy(toType)
key := UnknownSummaryKey
if toType == SummaryLabel {
key = DefaultProjectLabel
}
existingEntryIdx := -1
for i, item := range *typeItems[toType] {
if item.Key == key {
existingEntryIdx = i
break
}
}
total := (totalWanted - totalActual) / time.Second // workaround
if total > 0 {
if existingEntryIdx >= 0 {
(*typeItems[toType])[existingEntryIdx].Total = total
} else {
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
Type: toType,
Key: key,
Total: total,
})
}
}
}
@ -141,14 +182,12 @@ func (s *Summary) TotalTime() time.Duration {
var timeSum time.Duration
mappedItems := s.MappedItems()
// calculate total duration from any of the present sets of items
for _, t := range s.Types() {
if items := mappedItems[t]; len(*items) > 0 {
for _, item := range *items {
timeSum += item.Total
}
break
}
t, err := s.findFirstPresentType()
if err != nil {
return 0
}
for _, item := range *mappedItems[t] {
timeSum += item.Total
}
return timeSum * time.Second
@ -231,10 +270,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
s.Languages = processAliases(s.Languages)
s.OperatingSystems = processAliases(s.OperatingSystems)
s.Machines = processAliases(s.Machines)
s.Labels = processAliases(s.Labels)
return s
}
func (s *Summary) findFirstPresentType() (uint8, error) {
for _, t := range s.Types() {
if s.TotalTimeBy(t) != 0 {
return t, nil
}
}
return 127, errors.New("no type present")
}
func (s *SummaryItem) TotalFixed() time.Duration {
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
// TODO: fix some day, while migrating persisted summary items

View File

@ -6,7 +6,7 @@ import (
"time"
)
func TestSummary_FillUnknown(t *testing.T) {
func TestSummary_FillMissing(t *testing.T) {
testDuration := 10 * time.Minute
sut := &Summary{
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
},
}
sut.FillUnknown()
sut.FillMissing()
itemLists := [][]*SummaryItem{
sut.Machines,
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
for _, l := range itemLists {
assert.Len(t, l, 1)
assert.Equal(t, UnknownSummaryKey, l[0].Key)
assert.Equal(t, testDuration, l[0].Total)
assert.Equal(t, testDuration, l[0].TotalFixed())
}
assert.Len(t, sut.Labels, 1)
assert.Equal(t, DefaultProjectLabel, sut.Labels[0].Key)
assert.Equal(t, testDuration, sut.Labels[0].TotalFixed())
}
func TestSummary_TotalTimeBy(t *testing.T) {

View File

@ -23,6 +23,7 @@ type User struct {
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
ShareLabels 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:"-"`

View File

@ -6,6 +6,8 @@ type SettingsViewModel struct {
User *models.User
LanguageMappings []*models.LanguageMapping
Aliases []*SettingsVMCombinedAlias
Labels []*SettingsVMCombinedLabel
Projects []string
Success string
Error string
}
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
Values []string
}
type SettingsVMCombinedLabel struct {
Key string
Values []string
}
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
s.Success = m
return s

View File

@ -1,6 +1,6 @@
{
"info": {
"_postman_id": "3dcc346d-a9a8-4699-8a52-459eb978b382",
"_postman_id": "46168002-34d8-48a5-95fa-4a8600450cbd",
"name": "Wakapi",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
@ -49,6 +49,50 @@
}
},
"response": []
},
{
"name": "Send diagnostics",
"request": {
"method": "POST",
"header": [
{
"key": "Authorization",
"value": "Basic {{TOKEN}}",
"type": "text"
},
{
"key": "X-Machine-Name",
"value": "devmachine",
"type": "text"
},
{
"key": "User-Agent",
"value": "wakatime/13.0.7 (Linux-4.15.0-91-generic-x86_64-with-glibc2.4) Python3.8.0.final.0 generator/1.42.1 generator-wakatime/4.0.0",
"type": "text"
}
],
"body": {
"mode": "raw",
"raw": "{\n \"platform\": \"unset\",\n \"architecture\": \"unset\",\n \"plugin\": \"\",\n \"cli_version\": \"unset\",\n \"logs\": \"{\\\"caller\\\":\\\"/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:189\\\",\\\"func\\\":\\\"runCmd\\\",\\\"level\\\":\\\"error\\\",\\\"message\\\":\\\"failed to run command: failed to send heartbeat(s) due to api error: failed to send heartbeats via api client: invalid response status from \\\\\\\"https://bin.muetsch.io/n7jnywu/users/current/heartbeats.bulk\\\\\\\". got: 404, want: 201/202. body: \\\\\\\"\\\\\\\"\\\",\\\"now\\\":\\\"2021-08-07T00:33:26+02:00\\\",\\\"version\\\":\\\"unset\\\"}\\n\",\n \"stacktrace\": \"goroutine 1 [running]:\\nruntime/debug.Stack(0x0, 0xc0001f8680, 0x196)\\n\\t/opt/go/src/runtime/debug/stack.go:24 +0x9f\\ngithub.com/wakatime/wakatime-cli/cmd/legacy.runCmd(0xc000103680, 0xc33c60, 0x0)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:194 +0x26c\\ngithub.com/wakatime/wakatime-cli/cmd/legacy.RunCmdWithOfflineSync(0xc000103680, 0xc33c60)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:163 +0x35\\ngithub.com/wakatime/wakatime-cli/cmd/legacy.Run(0xc0000be2c0, 0xc000103680)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/legacy/run.go:90 +0x62e\\ngithub.com/wakatime/wakatime-cli/cmd.NewRootCMD.func1(0xc0000be2c0, 0xc00028bd40, 0x0, 0x2)\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/root.go:31 +0x34\\ngithub.com/spf13/cobra.(*Command).execute(0xc0000be2c0, 0xc000020190, 0x2, 0x2, 0xc0000be2c0, 0xc000020190)\\n\\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:854 +0x2c2\\ngithub.com/spf13/cobra.(*Command).ExecuteC(0xc0000be2c0, 0xc000000180, 0xc0006bff78, 0x407d65)\\n\\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:958 +0x375\\ngithub.com/spf13/cobra.(*Command).Execute(...)\\n\\t/home/ferdinand/go/pkg/mod/github.com/spf13/cobra@v1.1.1/command.go:895\\ngithub.com/wakatime/wakatime-cli/cmd.Execute()\\n\\t/home/ferdinand/dev/wakatime-cli/cmd/root.go:227 +0x2b\\nmain.main()\\n\\t/home/ferdinand/dev/wakatime-cli/main.go:6 +0x25\\n\"\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/plugins/errors",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"plugins",
"errors"
]
}
},
"response": []
}
]
},

View File

@ -0,0 +1,18 @@
package repositories
import (
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type DiagnosticsRepository struct {
db *gorm.DB
}
func NewDiagnosticsRepository(db *gorm.DB) *DiagnosticsRepository {
return &DiagnosticsRepository{db: db}
}
func (r *DiagnosticsRepository) Insert(diagnostics *models.Diagnostics) (*models.Diagnostics, error) {
return diagnostics, r.db.Create(diagnostics).Error
}

View File

@ -0,0 +1,60 @@
package repositories
import (
"errors"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"gorm.io/gorm"
)
type ProjectLabelRepository struct {
config *config.Config
db *gorm.DB
}
func NewProjectLabelRepository(db *gorm.DB) *ProjectLabelRepository {
return &ProjectLabelRepository{config: config.Get(), db: db}
}
func (r *ProjectLabelRepository) GetAll() ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.Find(&labels).Error; err != nil {
return nil, err
}
return labels, nil
}
func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error) {
label := &models.ProjectLabel{}
if err := r.db.Where(&models.ProjectLabel{ID: id}).First(label).Error; err != nil {
return label, err
}
return label, nil
}
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
var labels []*models.ProjectLabel
if err := r.db.
Where(&models.ProjectLabel{UserID: userId}).
Find(&labels).Error; err != nil {
return labels, err
}
return labels, nil
}
func (r *ProjectLabelRepository) Insert(label *models.ProjectLabel) (*models.ProjectLabel, error) {
if !label.IsValid() {
return nil, errors.New("invalid label")
}
result := r.db.Create(label)
if err := result.Error; err != nil {
return nil, err
}
return label, nil
}
func (r *ProjectLabelRepository) Delete(id uint) error {
return r.db.
Where("id = ?", id).
Delete(models.ProjectLabel{}).Error
}

View File

@ -31,6 +31,10 @@ type IHeartbeatRepository interface {
DeleteBefore(time.Time) error
}
type IDiagnosticsRepository interface {
Insert(diagnostics *models.Diagnostics) (*models.Diagnostics, error)
}
type IKeyValueRepository interface {
GetAll() ([]*models.KeyStringValue, error)
GetString(string) (*models.KeyStringValue, error)
@ -46,6 +50,14 @@ type ILanguageMappingRepository interface {
Delete(uint) error
}
type IProjectLabelRepository interface {
GetAll() ([]*models.ProjectLabel, error)
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
Insert(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(uint) error
}
type ISummaryRepository interface {
Insert(*models.Summary) error
GetAll() ([]*models.Summary, error)

View File

@ -147,6 +147,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
"share_oss": user.ShareOSs,
"share_projects": user.ShareProjects,
"share_machines": user.ShareMachines,
"share_labels": user.ShareLabels,
"wakatime_api_key": user.WakatimeApiKey,
"has_data": user.HasData,
"reset_token": user.ResetToken,
@ -159,10 +160,6 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
return nil, err
}
if result.RowsAffected != 1 {
return nil, errors.New("nothing updated")
}
return user, nil
}

71
routes/api/diagnostics.go Normal file
View File

@ -0,0 +1,71 @@
package api
import (
"encoding/json"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/middlewares"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"net/http"
"github.com/muety/wakapi/models"
)
type DiagnosticsApiHandler struct {
config *conf.Config
userSrvc services.IUserService
diagnosticsSrvc services.IDiagnosticsService
}
func NewDiagnosticsApiHandler(userService services.IUserService, diagnosticsService services.IDiagnosticsService) *DiagnosticsApiHandler {
return &DiagnosticsApiHandler{
config: conf.Get(),
userSrvc: userService,
diagnosticsSrvc: diagnosticsService,
}
}
func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
r := router.PathPrefix("/plugins/errors").Subrouter()
r.Use(
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
)
r.Path("").Methods(http.MethodPost).HandlerFunc(h.Post)
}
// @Summary Push a new diagnostics object
// @ID post-diagnostics
// @Tags diagnostics
// @Accept json
// @Param diagnostics body models.Diagnostics true "A single diagnostics object sent by WakaTime CLI"
// @Security ApiKeyAuth
// @Success 201
// @Router /plugins/errors [post]
func (h *DiagnosticsApiHandler) Post(w http.ResponseWriter, r *http.Request) {
var diagnostics models.Diagnostics
user := middlewares.GetPrincipal(r)
if user == nil {
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(conf.ErrUnauthorized))
return
}
if err := json.NewDecoder(r.Body).Decode(&diagnostics); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(conf.ErrBadRequest))
conf.Log().Request(r).Error("failed to parse diagnostics for user %s - %v", err)
return
}
diagnostics.UserID = user.ID
if _, err := h.diagnosticsSrvc.Create(&diagnostics); err != nil {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(conf.ErrInternalServerError))
conf.Log().Request(r).Error("failed to insert diagnostics for user %s - %v", err)
return
}
utils.RespondJSON(w, r, http.StatusCreated, struct{}{})
}

View File

@ -1,6 +1,7 @@
package api
import (
"bytes"
"encoding/json"
"github.com/gorilla/mux"
conf "github.com/muety/wakapi/config"
@ -9,6 +10,7 @@ import (
routeutils "github.com/muety/wakapi/routes/utils"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"io/ioutil"
"net/http"
"github.com/muety/wakapi/models"
@ -40,16 +42,22 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
customMiddleware.NewWakatimeRelayMiddleware().Handler,
)
r.PathPrefix("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.PathPrefix("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
// see https://github.com/muety/wakapi/issues/203
r.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/users/{user}/heartbeats.bulk").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/v1/users/{user}/heartbeats.bulk").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/compat/wakatime/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
r.Path("/compat/wakatime/v1/users/{user}/heartbeats.bulk").Methods(http.MethodPost).HandlerFunc(h.Post)
}
// @Summary Push a new heartbeat
// @ID post-heartbeat
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A heartbeat"
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /heartbeat [post]
@ -60,22 +68,28 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
}
var heartbeats []*models.Heartbeat
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
machineName := r.Header.Get("X-Machine-Name")
dec := json.NewDecoder(r.Body)
if err := dec.Decode(&heartbeats); err != nil {
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
heartbeats, err = h.tryParseBulk(r)
if err != nil {
heartbeats, err = h.tryParseSingle(r)
if err != nil {
conf.Log().Request(r).Error(err.Error())
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
return
}
}
userAgent := r.Header.Get("User-Agent")
opSys, editor, _ := utils.ParseUserAgent(userAgent)
machineName := r.Header.Get("X-Machine-Name")
for _, hb := range heartbeats {
hb.OperatingSystem = opSys
hb.Editor = editor
hb.Machine = machineName
hb.User = user
hb.UserID = user.ID
hb.UserAgent = userAgent
if !hb.Valid() {
w.WriteHeader(http.StatusBadRequest)
@ -103,12 +117,47 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
}
}
defer func() {}()
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
}
func (h *HeartbeatApiHandler) tryParseBulk(r *http.Request) ([]*models.Heartbeat, error) {
var heartbeats []*models.Heartbeat
body, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
if err := dec.Decode(&heartbeats); err != nil {
return nil, err
}
return heartbeats, nil
}
func (h *HeartbeatApiHandler) tryParseSingle(r *http.Request) ([]*models.Heartbeat, error) {
var heartbeat models.Heartbeat
body, _ := ioutil.ReadAll(r.Body)
r.Body.Close()
r.Body = ioutil.NopCloser(bytes.NewBuffer(body))
dec := json.NewDecoder(ioutil.NopCloser(bytes.NewBuffer(body)))
if err := dec.Decode(&heartbeat); err != nil {
return nil, err
}
return []*models.Heartbeat{&heartbeat}, nil
}
// construct weird response format (see https://github.com/wakatime/wakatime/blob/2e636d389bf5da4e998e05d5285a96ce2c181e3d/wakatime/api.py#L288)
// to make the cli consider all heartbeats to having been successfully saved
// response looks like: { "responses": [ [ { "data": {...} }, 201 ], ... ] }
// response looks like: { "responses": [ [ null, 201 ], ... ] }
// this was probably a temporary bug at wakatime, responses actually looks like so: https://pastr.de/p/nyf6kj2e6843fbw4xkj4h4pj
// TODO: adapt response format some time
// however, wakatime-cli is still able to parse the response (see https://github.com/wakatime/wakatime-cli/blob/c2076c0e1abc1449baf5b7ac7db391b06041c719/pkg/api/heartbeat.go#L127), so no urgent need for action
func constructSuccessResponse(n int) *heartbeatResponseVm {
responses := make([][]interface{}, n)
@ -123,3 +172,75 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
Responses: responses,
}
}
// Only for Swagger
// @Summary Push a new heartbeat
// @ID post-heartbeat-2
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /v1/users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias1() {}
// @Summary Push a new heartbeat
// @ID post-heartbeat-3
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /compat/wakatime/v1/users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias2() {}
// @Summary Push a new heartbeat
// @ID post-heartbeat-4
// @Tags heartbeat
// @Accept json
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/heartbeats [post]
func (h *HeartbeatApiHandler) postAlias3() {}
// @Summary Push new heartbeats
// @ID post-heartbeat-5
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /heartbeats [post]
func (h *HeartbeatApiHandler) postAlias4() {}
// @Summary Push new heartbeats
// @ID post-heartbeat-6
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /v1/users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias5() {}
// @Summary Push new heartbeats
// @ID post-heartbeat-7
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /compat/wakatime/v1/users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias6() {}
// @Summary Push new heartbeats
// @ID post-heartbeat-8
// @Tags heartbeat
// @Accept json
// @Param heartbeat body []models.Heartbeat true "Multiple heartbeats"
// @Security ApiKeyAuth
// @Success 201
// @Router /users/{user}/heartbeats.bulk [post]
func (h *HeartbeatApiHandler) postAlias7() {}

View File

@ -27,6 +27,7 @@ const (
DescLanguages = "Total seconds for each language."
DescOperatingSystems = "Total seconds for each operating system."
DescMachines = "Total seconds for each machine."
DescLabels = "Total seconds for each project label."
DescAdminTotalTime = "Total seconds (all users, all time)."
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
@ -198,6 +199,15 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
})
}
for _, m := range summaryToday.Labels {
metrics = append(metrics, &mm.CounterMetric{
Name: MetricsPrefix + "_label_seconds_total",
Desc: DescLabels,
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
Labels: []mm.Label{{Key: "name", Value: m.Key}},
})
}
return &metrics, nil
}
@ -218,7 +228,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
totalUsers, _ := h.userSrvc.Count()
totalHeartbeats, _ := h.heartbeatSrvc.Count()
activeUsers, err := h.userSrvc.GetActive()
activeUsers, err := h.userSrvc.GetActive(false)
if err != nil {
logbuch.Error("failed to retrieve active users for metric %v", err)
return nil, err

View File

@ -16,7 +16,7 @@ import (
const (
intervalPattern = `interval:([a-z0-9_]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
)
type BadgeHandler struct {
@ -75,7 +75,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
}
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
// negative value means no limit
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)
@ -83,22 +83,38 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
var permitEntity bool
var filters *models.Filters
switch filterEntity {
case "project":
permitEntity = user.ShareProjects
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
case "os":
permitEntity = user.ShareOSs
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
case "editor":
permitEntity = user.ShareEditors
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
case "language":
permitEntity = user.ShareLanguages
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
case "machine":
permitEntity = user.ShareMachines
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
case "label":
permitEntity = user.ShareLabels
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
default:
permitEntity = true
filters = &models.Filters{}
}
if !permitEntity {
w.WriteHeader(http.StatusForbidden)
w.Write([]byte("user did not opt in to share entity-specific data"))
return
}
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
if cacheResult, ok := h.cache.Get(cacheKey); ok {
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))

View File

@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
return
}
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
w.WriteHeader(http.StatusForbidden)

View File

@ -121,17 +121,16 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
end = utils.EndOfDay(end).Add(-1 * time.Second)
overallParams := &models.SummaryParams{
From: start,
To: end,
User: user,
Recompute: false,
From: start,
To: end,
User: user,
}
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
summaries := make([]*models.Summary, len(intervals))
for i, interval := range intervals {
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, false)
summary, err := h.summarySrvc.Aliased(interval[0], interval[1], user, h.summarySrvc.Retrieve, end.After(time.Now()))
if err != nil {
return nil, err, http.StatusInternalServerError
}

View File

@ -2,27 +2,24 @@ package routes
import (
"fmt"
"github.com/muety/wakapi/views"
"html/template"
"io/fs"
"io/ioutil"
"net/http"
"path"
"strings"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/utils"
"github.com/muety/wakapi/views"
)
func Init() {
loadTemplates()
}
type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
var templates map[string]*template.Template
func Init() {
loadTemplates()
}
func DefaultTemplateFuncs() template.FuncMap {
return template.FuncMap{
"json": utils.Json,
@ -58,44 +55,6 @@ func DefaultTemplateFuncs() template.FuncMap {
}
}
func loadTemplates() {
tpls := template.New("").Funcs(DefaultTemplateFuncs())
templates = make(map[string]*template.Template)
// Use local file system when in 'dev' environment, go embed file system otherwise
templateFs := config.ChooseFS("views", views.TemplateFiles)
files, err := fs.ReadDir(templateFs, ".")
if err != nil {
panic(err)
}
for _, file := range files {
tplName := file.Name()
if file.IsDir() || path.Ext(tplName) != ".html" {
continue
}
templateFile, err := templateFs.Open(tplName)
if err != nil {
panic(err)
}
templateData, err := ioutil.ReadAll(templateFile)
if err != nil {
panic(err)
}
templateFile.Close()
tpl, err := tpls.New(tplName).Parse(string(templateData))
if err != nil {
panic(err)
}
templates[tplName] = tpl
}
}
func typeName(t uint8) string {
if t == models.SummaryProject {
return "project"
@ -112,9 +71,22 @@ func typeName(t uint8) string {
if t == models.SummaryMachine {
return "machine"
}
if t == models.SummaryLabel {
return "label"
}
return "unknown"
}
func loadTemplates() {
// Use local file system when in 'dev' environment, go embed file system otherwise
templateFs := config.ChooseFS("views", views.TemplateFiles)
if tpls, err := utils.LoadTemplates(templateFs, DefaultTemplateFuncs()); err == nil {
templates = tpls
} else {
panic(err)
}
}
func defaultErrorRedirectTarget() string {
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
}

View File

@ -14,10 +14,14 @@ import (
"github.com/muety/wakapi/services/imports"
"github.com/muety/wakapi/utils"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
const criticalError = "a critical error has occurred, sorry"
type SettingsHandler struct {
config *conf.Config
userSrvc services.IUserService
@ -26,6 +30,7 @@ type SettingsHandler struct {
aliasSrvc services.IAliasService
aggregationSrvc services.IAggregationService
languageMappingSrvc services.ILanguageMappingService
projectLabelSrvc services.IProjectLabelService
keyValueSrvc services.IKeyValueService
mailSrvc services.IMailService
httpClient *http.Client
@ -40,6 +45,7 @@ func NewSettingsHandler(
aliasService services.IAliasService,
aggregationService services.IAggregationService,
languageMappingService services.ILanguageMappingService,
projectLabelService services.IProjectLabelService,
keyValueService services.IKeyValueService,
mailService services.IMailService,
) *SettingsHandler {
@ -49,6 +55,7 @@ func NewSettingsHandler(
aliasSrvc: aliasService,
aggregationSrvc: aggregationService,
languageMappingSrvc: languageMappingService,
projectLabelSrvc: projectLabelService,
userSrvc: userService,
heartbeatSrvc: heartbeatService,
keyValueSrvc: keyValueService,
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
if h.config.IsDev() {
loadTemplates()
}
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
}
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
return h.actionDeleteAlias
case "add_alias":
return h.actionAddAlias
case "add_label":
return h.actionAddLabel
case "delete_label":
return h.actionDeleteLabel
case "delete_mapping":
return h.actionDeleteLanguageMapping
case "add_mapping":
@ -137,7 +147,7 @@ func (h *SettingsHandler) dispatchAction(action string) action {
case "toggle_wakatime":
return h.actionSetWakatimeApiKey
case "import_wakatime":
return h.actionImportWaktime
return h.actionImportWakatime
case "regenerate_summaries":
return h.actionRegenerateSummaries
case "delete_account":
@ -252,6 +262,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
user.ShareLabels, err = strconv.ParseBool(r.PostFormValue("share_labels"))
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
if err != nil {
@ -313,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
return http.StatusOK, "alias added successfully", ""
}
func (h *SettingsHandler) actionAddLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
label := &models.ProjectLabel{
UserID: user.ID,
ProjectKey: r.PostFormValue("key"),
Label: r.PostFormValue("value"),
}
if !label.IsValid() {
return http.StatusBadRequest, "", "invalid input"
}
if _, err := h.projectLabelSrvc.Create(label); err != nil {
// TODO: distinguish between bad request, conflict and server error
return http.StatusBadRequest, "", "invalid input"
}
return http.StatusOK, "label added successfully", ""
}
func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
user := middlewares.GetPrincipal(r)
labelKey := r.PostFormValue("key")
labelValue := r.PostFormValue("value")
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
if projectLabels, ok := labelMap[labelKey]; ok {
for _, l := range projectLabels {
if l.Label == labelValue {
if err := h.projectLabelSrvc.Delete(l); err != nil {
return http.StatusInternalServerError, "", "could not delete label"
}
return http.StatusOK, "label deleted successfully", ""
}
}
return http.StatusNotFound, "", "label not found"
} else {
return http.StatusNotFound, "", "project not found"
}
}
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
@ -383,7 +447,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
return http.StatusOK, "Wakatime API Key updated successfully", ""
}
func (h *SettingsHandler) actionImportWaktime(w http.ResponseWriter, r *http.Request) (int, string, string) {
func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Request) (int, string, string) {
if h.config.IsDev() {
loadTemplates()
}
@ -553,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
user := middlewares.GetPrincipal(r)
// mappings
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
// aliases
aliases, err := h.aliasSrvc.GetByUser(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building alias map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
aliasMap := make(map[string][]*models.Alias)
for _, a := range aliases {
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
@ -578,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
combinedAliases = append(combinedAliases, ca)
}
// labels
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
if err != nil {
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
for _, l := range labelMap {
cl := &view.SettingsVMCombinedLabel{
Key: l[0].ProjectKey,
Values: make([]string, len(l)),
}
for i, l1 := range l {
cl.Values[i] = l1.Label
}
combinedLabels = append(combinedLabels, cl)
}
sort.Slice(combinedLabels, func(i, j int) bool {
return strings.Compare(combinedLabels[i].Key, combinedLabels[j].Key) < 0
})
// projects
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
if err != nil {
conf.Log().Request(r).Error("error while fetching projects - %v", err)
return &view.SettingsViewModel{Error: criticalError}
}
sort.Strings(projects)
return &view.SettingsViewModel{
User: user,
LanguageMappings: mappings,
Aliases: combinedAliases,
Labels: combinedLabels,
Projects: projects,
Success: r.URL.Query().Get("success"),
Error: r.URL.Query().Get("error"),
}

23
services/diagnostics.go Normal file
View File

@ -0,0 +1,23 @@
package services
import (
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
)
type DiagnosticsService struct {
config *config.Config
repository repositories.IDiagnosticsRepository
}
func NewDiagnosticsService(diagnosticsRepo repositories.IDiagnosticsRepository) *DiagnosticsService {
return &DiagnosticsService{
config: config.Get(),
repository: diagnosticsRepo,
}
}
func (srv *DiagnosticsService) Create(diagnostics *models.Diagnostics) (*models.Diagnostics, error) {
return srv.repository.Insert(diagnostics)
}

View File

@ -2,10 +2,12 @@ package services
import (
"fmt"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
"github.com/patrickmn/go-cache"
"strings"
"time"
"github.com/muety/wakapi/models"
@ -14,17 +16,35 @@ import (
type HeartbeatService struct {
config *config.Config
cache *cache.Cache
cache2 *cache.Cache
eventBus *hub.Hub
repository repositories.IHeartbeatRepository
languageMappingSrvc ILanguageMappingService
}
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
return &HeartbeatService{
srv := &HeartbeatService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
cache2: cache.New(cache.NoExpiration, cache.NoExpiration),
eventBus: config.EventBus(),
repository: heartbeatRepo,
languageMappingSrvc: languageMappingService,
}
// using event hub is an unnecessary indirection here, however, we might
// potentially need heartbeat events elsewhere throughout the application some time
// so it's more consistent to already have it this way
sub1 := srv.eventBus.Subscribe(0, config.EventHeartbeatCreate)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
heartbeat := m.Fields[config.FieldPayload].(*models.Heartbeat)
srv.cache.IncrementInt64(srv.countByUserCacheKey(heartbeat.UserID), 1) // increment doesn't update expiration time
srv.cache.IncrementInt64(srv.countTotalCacheKey(), 1)
}
}(&sub1)
return srv
}
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
@ -45,19 +65,64 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
srv.updateEntityUserCacheByHeartbeat(hb)
}
return srv.repository.InsertBatch(filteredHeartbeats)
err := srv.repository.InsertBatch(filteredHeartbeats)
if err == nil {
go srv.notifyBatch(filteredHeartbeats)
}
return err
}
func (srv *HeartbeatService) Count() (int64, error) {
return srv.repository.Count()
result, ok := srv.cache.Get(srv.countTotalCacheKey())
if ok {
return result.(int64), nil
}
count, err := srv.repository.Count()
if err == nil {
srv.cache.Set(srv.countTotalCacheKey(), count, srv.countCacheTtl())
}
return count, err
}
func (srv *HeartbeatService) CountByUser(user *models.User) (int64, error) {
return srv.repository.CountByUser(user)
key := srv.countByUserCacheKey(user.ID)
result, ok := srv.cache.Get(key)
if ok {
return result.(int64), nil
}
count, err := srv.repository.CountByUser(user)
if err == nil {
srv.cache.Set(key, count, srv.countCacheTtl())
}
return count, err
}
func (srv *HeartbeatService) CountByUsers(users []*models.User) ([]*models.CountByUser, error) {
return srv.repository.CountByUsers(users)
missingUsers := make([]*models.User, 0, len(users))
userCounts := make([]*models.CountByUser, 0, len(users))
for _, u := range users {
key := srv.countByUserCacheKey(u.ID)
result, ok := srv.cache.Get(key)
if ok {
userCounts = append(userCounts, &models.CountByUser{User: u.ID, Count: result.(int64)})
} else {
missingUsers = append(missingUsers, u)
}
}
counts, err := srv.repository.CountByUsers(missingUsers)
if err != nil {
return nil, err
}
for _, uc := range counts {
key := srv.countByUserCacheKey(uc.User)
srv.cache.Set(key, uc.Count, srv.countCacheTtl())
userCounts = append(userCounts, uc)
}
return userCounts, nil
}
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
@ -82,7 +147,7 @@ func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if results, found := srv.cache.Get(cacheKey); found {
if results, found := srv.cache2.Get(cacheKey); found {
return utils.SetToStrings(results.(map[string]bool)), nil
}
@ -90,8 +155,16 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
if err != nil {
return nil, err
}
srv.cache.Set(cacheKey, utils.StringsToSet(results), cache.DefaultExpiration)
return results, nil
filtered := make([]string, 0, len(results))
for _, r := range results {
if strings.TrimSpace(r) != "" {
filtered = append(filtered, r)
}
}
srv.cache2.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
return filtered, nil
}
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
@ -117,11 +190,11 @@ func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *model
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
cacheKey := srv.getEntityUserCacheKey(entityType, user)
if entities, found := srv.cache.Get(cacheKey); found {
if entities, found := srv.cache2.Get(cacheKey); found {
if _, ok := entities.(map[string]bool)[entityKey]; !ok {
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
// -> invalidate cache
srv.cache.Delete(cacheKey)
srv.cache2.Delete(cacheKey)
}
}
}
@ -133,3 +206,24 @@ func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbe
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, hb.User)
}
func (srv *HeartbeatService) notifyBatch(heartbeats []*models.Heartbeat) {
for _, hb := range heartbeats {
srv.eventBus.Publish(hub.Message{
Name: config.EventHeartbeatCreate,
Fields: map[string]interface{}{config.FieldPayload: hb},
})
}
}
func (srv *HeartbeatService) countByUserCacheKey(userId string) string {
return fmt.Sprintf("%s--hearbeat-count", userId)
}
func (srv *HeartbeatService) countTotalCacheKey() string {
return "heartbeat-count"
}
func (srv *HeartbeatService) countCacheTtl() time.Duration {
return time.Duration(srv.config.App.CountCacheTTLMin) * time.Minute
}

View File

@ -106,6 +106,7 @@ func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.
}
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.HeartbeatEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
@ -134,6 +135,7 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.Hea
}
// https://wakatime.com/api/v1/users/current/all_time_since_today
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
@ -168,6 +170,7 @@ func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
}
// https://wakatime.com/api/v1/users/current/user_agents
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.UserAgentEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
@ -195,6 +198,7 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.User
}
// https://wakatime.com/api/v1/users/current/machine_names
// https://pastr.de/p/v58cv0xrupp3zvyyv8o6973j
func (w *WakatimeHeartbeatImporter) fetchMachineNames() (map[string]*wakatime.MachineEntry, error) {
httpClient := &http.Client{Timeout: 10 * time.Second}
@ -261,6 +265,7 @@ func mapHeartbeat(
Editor: ua.Editor,
OperatingSystem: ua.Os,
Machine: ma.Value,
UserAgent: ua.Value,
Time: entry.Time,
Origin: OriginWakatime,
OriginId: entry.Id,

View File

@ -7,21 +7,21 @@ import (
"github.com/muety/wakapi/routes"
"github.com/muety/wakapi/services"
"github.com/muety/wakapi/utils"
"html/template"
"io/ioutil"
"github.com/muety/wakapi/views/mail"
"time"
conf "github.com/muety/wakapi/config"
"github.com/muety/wakapi/views"
)
const (
tplNamePasswordReset = "reset_password"
tplNameImportNotification = "import_finished"
tplNameReport = "report"
subjectPasswordReset = "Wakapi - Password Reset"
subjectImportNotification = "Wakapi - Data Import Finished"
subjectReport = "Wakapi - Report from %s"
tplNamePasswordReset = "reset_password"
tplNameImportNotification = "import_finished"
tplNameWakatimeFailureNotification = "wakatime_connection_failure"
tplNameReport = "report"
subjectPasswordReset = "Wakapi - Password Reset"
subjectImportNotification = "Wakapi - Data Import Finished"
subjectWakatimeFailureNotification = "Wakapi - WakaTime Connection Failure"
subjectReport = "Wakapi - Report from %s"
)
type SendingService interface {
@ -31,6 +31,7 @@ type SendingService interface {
type MailService struct {
config *conf.Config
sendingService SendingService
templates utils.TemplateMap
}
func NewMailService() services.IMailService {
@ -47,11 +48,18 @@ func NewMailService() services.IMailService {
}
}
return &MailService{sendingService: sendingService, config: config}
// Use local file system when in 'dev' environment, go embed file system otherwise
templateFs := conf.ChooseFS("views/mail", mail.TemplateFiles)
templates, err := utils.LoadTemplates(templateFs, routes.DefaultTemplateFuncs())
if err != nil {
panic(err)
}
return &MailService{sendingService: sendingService, config: config, templates: templates}
}
func (m *MailService) SendPasswordReset(recipient *models.User, resetLink string) error {
tpl, err := getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
tpl, err := m.getPasswordResetTemplate(PasswordResetTplData{ResetLink: resetLink})
if err != nil {
return err
}
@ -64,8 +72,25 @@ func (m *MailService) SendPasswordReset(recipient *models.User, resetLink string
return m.sendingService.Send(mail)
}
func (m *MailService) SendWakatimeFailureNotification(recipient *models.User, numFailures int) error {
tpl, err := m.getWakatimeFailureNotificationTemplate(WakatimeFailureNotificationNotificationTplData{
PublicUrl: m.config.Server.PublicUrl,
NumFailures: numFailures,
})
if err != nil {
return err
}
mail := &models.Mail{
From: models.MailAddress(m.config.Mail.Sender),
To: models.MailAddresses([]models.MailAddress{models.MailAddress(recipient.Email)}),
Subject: subjectWakatimeFailureNotification,
}
mail.WithHTML(tpl.String())
return m.sendingService.Send(mail)
}
func (m *MailService) SendImportNotification(recipient *models.User, duration time.Duration, numHeartbeats int) error {
tpl, err := getImportNotificationTemplate(ImportNotificationTplData{
tpl, err := m.getImportNotificationTemplate(ImportNotificationTplData{
PublicUrl: m.config.Server.PublicUrl,
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
NumHeartbeats: numHeartbeats,
@ -83,7 +108,7 @@ func (m *MailService) SendImportNotification(recipient *models.User, duration ti
}
func (m *MailService) SendReport(recipient *models.User, report *models.Report) error {
tpl, err := getReportTemplate(ReportTplData{report})
tpl, err := m.getReportTemplate(ReportTplData{report})
if err != nil {
return err
}
@ -96,56 +121,38 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report)
return m.sendingService.Send(mail)
}
func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
tpl, err := loadTemplate(tplNamePasswordReset)
if err != nil {
return nil, err
}
func (m *MailService) getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
var rendered bytes.Buffer
if err := tpl.Execute(&rendered, data); err != nil {
if err := m.templates[m.fmtName(tplNamePasswordReset)].Execute(&rendered, data); err != nil {
return nil, err
}
return &rendered, nil
}
func getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffer, error) {
tpl, err := loadTemplate(tplNameImportNotification)
if err != nil {
return nil, err
}
func (m *MailService) getWakatimeFailureNotificationTemplate(data WakatimeFailureNotificationNotificationTplData) (*bytes.Buffer, error) {
var rendered bytes.Buffer
if err := tpl.Execute(&rendered, data); err != nil {
if err := m.templates[m.fmtName(tplNameWakatimeFailureNotification)].Execute(&rendered, data); err != nil {
return nil, err
}
return &rendered, nil
}
func getReportTemplate(data ReportTplData) (*bytes.Buffer, error) {
tpl, err := loadTemplate(tplNameReport)
if err != nil {
return nil, err
}
func (m *MailService) getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffer, error) {
var rendered bytes.Buffer
if err := tpl.Execute(&rendered, data); err != nil {
if err := m.templates[m.fmtName(tplNameImportNotification)].Execute(&rendered, data); err != nil {
return nil, err
}
return &rendered, nil
}
func loadTemplate(tplName string) (*template.Template, error) {
tplFile, err := views.TemplateFiles.Open(fmt.Sprintf("mail/%s.tpl.html", tplName))
if err != nil {
func (m *MailService) getReportTemplate(data ReportTplData) (*bytes.Buffer, error) {
var rendered bytes.Buffer
if err := m.templates[m.fmtName(tplNameReport)].Execute(&rendered, data); err != nil {
return nil, err
}
defer tplFile.Close()
tplData, err := ioutil.ReadAll(tplFile)
if err != nil {
return nil, err
}
return template.
New(tplName).
Funcs(routes.DefaultTemplateFuncs()).
Parse(string(tplData))
return &rendered, nil
}
func (m *MailService) fmtName(name string) string {
return fmt.Sprintf("%s.tpl.html", name)
}

View File

@ -12,6 +12,11 @@ type ImportNotificationTplData struct {
NumHeartbeats int
}
type WakatimeFailureNotificationNotificationTplData struct {
PublicUrl string
NumFailures int
}
type ReportTplData struct {
Report *models.Report
}

94
services/project_label.go Normal file
View File

@ -0,0 +1,94 @@
package services
import (
"errors"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"time"
)
type ProjectLabelService struct {
config *config.Config
cache *cache.Cache
eventBus *hub.Hub
repository repositories.IProjectLabelRepository
}
func NewProjectLabelService(projectLabelRepository repositories.IProjectLabelRepository) *ProjectLabelService {
return &ProjectLabelService{
config: config.Get(),
eventBus: config.EventBus(),
repository: projectLabelRepository,
cache: cache.New(24*time.Hour, 24*time.Hour),
}
}
func (srv *ProjectLabelService) GetById(id uint) (*models.ProjectLabel, error) {
return srv.repository.GetById(id)
}
func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel, error) {
if labels, found := srv.cache.Get(userId); found {
return labels.([]*models.ProjectLabel), nil
}
labels, err := srv.repository.GetByUser(userId)
if err != nil {
return nil, err
}
srv.cache.Set(userId, labels, cache.DefaultExpiration)
return labels, nil
}
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
labels := make(map[string][]*models.ProjectLabel)
userLabels, err := srv.GetByUser(userId)
if err != nil {
return nil, err
}
for _, l := range userLabels {
if _, ok := labels[l.ProjectKey]; !ok {
labels[l.ProjectKey] = []*models.ProjectLabel{l}
} else {
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
}
}
return labels, nil
}
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
result, err := srv.repository.Insert(label)
if err != nil {
return nil, err
}
srv.cache.Delete(result.UserID)
srv.notifyUpdate(label, false)
return result, nil
}
func (srv *ProjectLabelService) Delete(label *models.ProjectLabel) error {
if label.UserID == "" {
return errors.New("no user id specified")
}
err := srv.repository.Delete(label.ID)
srv.cache.Delete(label.UserID)
srv.notifyUpdate(label, true)
return err
}
func (srv *ProjectLabelService) notifyUpdate(label *models.ProjectLabel, isDelete bool) {
name := config.EventProjectLabelCreate
if isDelete {
name = config.EventProjectLabelDelete
}
srv.eventBus.Publish(hub.Message{
Name: name,
Fields: map[string]interface{}{config.FieldPayload: label, config.FieldUserId: label.UserID},
})
}

View File

@ -39,6 +39,10 @@ type IHeartbeatService interface {
DeleteBefore(time.Time) error
}
type IDiagnosticsService interface {
Create(*models.Diagnostics) (*models.Diagnostics, error)
}
type IKeyValueService interface {
GetString(string) (*models.KeyStringValue, error)
MustGetString(string) *models.KeyStringValue
@ -54,8 +58,17 @@ type ILanguageMappingService interface {
Delete(mapping *models.LanguageMapping) error
}
type IProjectLabelService interface {
GetById(uint) (*models.ProjectLabel, error)
GetByUser(string) ([]*models.ProjectLabel, error)
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
Delete(*models.ProjectLabel) error
}
type IMailService interface {
SendPasswordReset(*models.User, string) error
SendWakatimeFailureNotification(*models.User, int) error
SendImportNotification(*models.User, time.Duration, int) error
SendReport(*models.User, *models.Report) error
}
@ -82,7 +95,7 @@ type IUserService interface {
GetUserByResetToken(string) (*models.User, error)
GetAll() ([]*models.User, error)
GetAllByReports(bool) ([]*models.User, error)
GetActive() ([]*models.User, error)
GetActive(bool) ([]*models.User, error)
Count() (int64, error)
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
Update(*models.User) (*models.User, error)

View File

@ -1,42 +1,63 @@
package services
import (
"crypto/md5"
"errors"
"fmt"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
"github.com/patrickmn/go-cache"
"math"
"sort"
"strings"
"time"
)
const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct {
config *config.Config
cache *cache.Cache
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
config *config.Config
cache *cache.Cache
eventBus *hub.Hub
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
projectLabelService IProjectLabelService
}
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
return &SummaryService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
srv := &SummaryService{
config: config.Get(),
cache: cache.New(24*time.Hour, 24*time.Hour),
eventBus: config.EventBus(),
repository: summaryRepo,
heartbeatService: heartbeatService,
aliasService: aliasService,
projectLabelService: projectLabelService,
}
sub1 := srv.eventBus.Subscribe(0, config.TopicProjectLabel)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
userId := m.Fields[config.FieldUserId].(string)
for key := range srv.cache.Items() {
if strings.HasSuffix(key, fmt.Sprintf("__%s__--aliased", userId)) {
srv.cache.Delete(key)
}
}
}
}(&sub1)
return srv
}
// Public summary generation methods
// Aliased retrieves or computes a new summary based on the given SummaryRetriever and augments it with entity aliases and project labels
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever, skipCache bool) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
@ -63,17 +84,15 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
// Post-process summary and cache it
summary := s.WithResolvedAliases(resolve)
summary = srv.withProjectLabels(summary)
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
summary.FillMissing() // then, full up types which are entirely missing
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
// Check cache
cacheKey := srv.getHash(from.String(), to.String(), user.ID)
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
return cacheResult.(*models.Summary), nil
}
// Get all already existing, pre-generated summaries that fall into the requested interval
summaries, err := srv.repository.GetByUserWithin(user, from, to)
if err != nil {
@ -96,8 +115,6 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
return nil, err
}
// Cache 'em
srv.cache.SetDefault(cacheKey, summary)
return summary.Sorted(), nil
}
@ -110,7 +127,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
return nil, err
}
types := models.SummaryTypes()
types := models.NativeSummaryTypes()
typedAggregations := make(chan models.SummaryItemContainer)
defer close(typedAggregations)
@ -157,8 +174,6 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
Machines: machineItems,
}
//summary.FillUnknown()
return summary.Sorted(), nil
}
@ -169,10 +184,12 @@ func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
}
func (srv *SummaryService) DeleteByUser(userId string) error {
srv.invalidateUserCache(userId)
return srv.repository.DeleteByUser(userId)
}
func (srv *SummaryService) Insert(summary *models.Summary) error {
srv.invalidateUserCache(summary.UserID)
return srv.repository.Insert(summary)
}
@ -220,6 +237,49 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
}
func (srv *SummaryService) withProjectLabels(summary *models.Summary) *models.Summary {
newEntry := func(key string, total time.Duration) *models.SummaryItem {
return &models.SummaryItem{
Type: models.SummaryLabel,
Key: key,
Total: total,
}
}
allLabels, err := srv.projectLabelService.GetByUser(summary.UserID)
if err != nil {
logbuch.Error("failed to retrieve project labels for user summary ('%s', '%s', '%s')", summary.UserID, summary.FromTime.String(), summary.ToTime.String())
return summary
}
mappedProjects := make(map[string]*models.SummaryItem, len(summary.Projects))
for _, p := range summary.Projects {
mappedProjects[p.Key] = p
}
var totalLabelTime time.Duration
labelMap := make(map[string]*models.SummaryItem, 0)
for _, l := range allLabels {
if p, ok := mappedProjects[l.ProjectKey]; ok {
if _, ok2 := labelMap[l.Label]; !ok2 {
labelMap[l.Label] = newEntry(l.Label, 0)
}
labelMap[l.Label].Total += p.Total
totalLabelTime += p.Total
}
}
//labelMap[models.DefaultProjectLabel] = newEntry(models.DefaultProjectLabel, summary.TotalTimeBy(models.SummaryProject) / time.Second-totalLabelTime)
labels := make([]*models.SummaryItem, 0, len(labelMap))
for _, v := range labelMap {
if v.Total > 0 {
labels = append(labels, v)
}
}
summary.Labels = labels
return summary
}
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
if len(summaries) < 1 {
return nil, errors.New("no summaries given")
@ -235,6 +295,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
Editors: make([]*models.SummaryItem, 0),
OperatingSystems: make([]*models.SummaryItem, 0),
Machines: make([]*models.SummaryItem, 0),
Labels: make([]*models.SummaryItem, 0),
}
var processed = map[time.Time]bool{}
@ -263,6 +324,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
processed[hash] = true
}
@ -349,9 +411,13 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
}
func (srv *SummaryService) getHash(args ...string) string {
digest := md5.New()
for _, a := range args {
digest.Write([]byte(a))
}
return string(digest.Sum(nil))
return strings.Join(args, "__")
}
func (srv *SummaryService) invalidateUserCache(userId string) {
for key := range srv.cache.Items() {
if strings.Contains(key, userId) {
srv.cache.Delete(key)
}
}
}

View File

@ -16,6 +16,9 @@ const (
TestUserId = "muety"
TestProject1 = "test-project-1"
TestProject2 = "test-project-2"
TestProjectLabel1 = "private"
TestProjectLabel2 = "work"
TestProjectLabel3 = "non-existing"
TestLanguageGo = "Go"
TestLanguageJava = "Java"
TestLanguagePython = "Python"
@ -31,12 +34,14 @@ const (
type SummaryServiceTestSuite struct {
suite.Suite
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
TestUser *models.User
TestStartTime time.Time
TestHeartbeats []*models.Heartbeat
TestLabels []*models.ProjectLabel
SummaryRepository *mocks.SummaryRepositoryMock
HeartbeatService *mocks.HeartbeatServiceMock
AliasService *mocks.AliasServiceMock
ProjectLabelService *mocks.ProjectLabelServiceMock
}
func (suite *SummaryServiceTestSuite) SetupSuite() {
@ -75,12 +80,27 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
},
}
suite.TestLabels = []*models.ProjectLabel{
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProject1,
Label: TestProjectLabel1,
},
{
ID: uint(rand.Uint32()),
UserID: TestUserId,
ProjectKey: TestProjectLabel3,
Label: "blaahh",
},
}
}
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
suite.AliasService = new(mocks.AliasServiceMock)
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
}
func TestSummaryServiceTestSuite(t *testing.T) {
@ -88,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
var (
from time.Time
@ -141,7 +161,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
var (
summaries []*models.Summary
@ -292,7 +312,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var (
summaries []*models.Summary
@ -338,7 +360,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
var (
from time.Time
@ -348,10 +372,25 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
)
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
heartbeats := filter(from, to, suite.TestHeartbeats)
heartbeats = append(heartbeats, &models.Heartbeat{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject2,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(heartbeats[len(heartbeats)-1].Time.T().Add(10 * time.Second)),
})
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(heartbeats, nil)
suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, models.SummaryProject, TestProject1).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject2, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, false)
@ -361,6 +400,44 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
}
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased_ProjectLabels() {
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
var (
from time.Time
to time.Time
result *models.Summary
err error
)
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
heartbeats := filter(from, to, suite.TestHeartbeats)
heartbeats = append(heartbeats, &models.Heartbeat{
ID: uint(rand.Uint32()),
UserID: TestUserId,
Project: TestProject2,
Language: TestLanguageGo,
Editor: TestEditorGoland,
OperatingSystem: TestOsLinux,
Machine: TestMachine1,
Time: models.CustomTime(heartbeats[len(heartbeats)-1].Time.T().Add(10 * time.Second)),
})
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(heartbeats, nil)
suite.AliasService.On("InitializeUser", TestUserId).Return(nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject1).Return(TestProject1, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, TestProject2).Return(TestProject1, nil)
suite.AliasService.On("GetAliasOrDefault", TestUserId, mock.Anything, mock.Anything).Return("", nil)
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, false)
assert.Nil(suite.T(), err)
assert.NotNil(suite.T(), result)
assert.Equal(suite.T(), 160*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
}
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
for _, h := range heartbeats {

View File

@ -1,6 +1,8 @@
package services
import (
"fmt"
"github.com/emvi/logbuch"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
@ -12,19 +14,45 @@ import (
)
type UserService struct {
config *config.Config
cache *cache.Cache
eventBus *hub.Hub
repository repositories.IUserRepository
config *config.Config
cache *cache.Cache
eventBus *hub.Hub
mailService IMailService
repository repositories.IUserRepository
}
func NewUserService(userRepo repositories.IUserRepository) *UserService {
return &UserService{
config: config.Get(),
eventBus: config.EventBus(),
cache: cache.New(1*time.Hour, 2*time.Hour),
repository: userRepo,
func NewUserService(mailService IMailService, userRepo repositories.IUserRepository) *UserService {
srv := &UserService{
config: config.Get(),
eventBus: config.EventBus(),
cache: cache.New(1*time.Hour, 2*time.Hour),
mailService: mailService,
repository: userRepo,
}
sub1 := srv.eventBus.Subscribe(0, config.EventWakatimeFailure)
go func(sub *hub.Subscription) {
for m := range sub.Receiver {
user := m.Fields[config.FieldUser].(*models.User)
n := m.Fields[config.FieldPayload].(int)
logbuch.Warn("resetting wakatime api key for user %s, because of too many failures (%d)", user.ID, n)
if _, err := srv.SetWakatimeApiKey(user, ""); err != nil {
logbuch.Error("failed to set wakatime api key for user %s", user.ID)
}
if user.Email != "" {
if err := mailService.SendWakatimeFailureNotification(user, n); err != nil {
logbuch.Error("failed to send wakatime failure notification mail to user %s", user.ID)
} else {
logbuch.Info("sent wakatime connection failure mail to %s", user.ID)
}
}
}
}(&sub1)
return srv
}
func (srv *UserService) GetUserById(userId string) (*models.User, error) {
@ -51,7 +79,7 @@ func (srv *UserService) GetUserByKey(key string) (*models.User, error) {
return nil, err
}
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
srv.cache.SetDefault(u.ID, u)
return u, nil
}
@ -71,9 +99,24 @@ func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, er
return srv.repository.GetAllByReports(reportsEnabled)
}
func (srv *UserService) GetActive() ([]*models.User, error) {
func (srv *UserService) GetActive(exact bool) ([]*models.User, error) {
minDate := time.Now().Add(-24 * time.Hour * time.Duration(srv.config.App.InactiveDays))
return srv.repository.GetByLastActiveAfter(minDate)
if !exact {
minDate = utils.FloorDateHour(minDate)
}
cacheKey := fmt.Sprintf("%s--active", minDate.String())
if u, ok := srv.cache.Get(cacheKey); ok {
return u.([]*models.User), nil
}
results, err := srv.repository.GetByLastActiveAfter(minDate)
if err != nil {
return nil, err
}
srv.cache.SetDefault(cacheKey, results)
return results, nil
}
func (srv *UserService) Count() (int64, error) {

View File

@ -1,3 +1,3 @@
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**,views/mail/**
sonar.tests=.
sonar.go.coverage.reportPaths=coverage/coverage.out

View File

@ -5,16 +5,18 @@ const osCanvas = document.getElementById('chart-os')
const editorsCanvas = document.getElementById('chart-editor')
const languagesCanvas = document.getElementById('chart-language')
const machinesCanvas = document.getElementById('chart-machine')
const labelsCanvas = document.getElementById('chart-label')
const projectContainer = document.getElementById('project-container')
const osContainer = document.getElementById('os-container')
const editorContainer = document.getElementById('editor-container')
const languageContainer = document.getElementById('language-container')
const machineContainer = document.getElementById('machine-container')
const labelContainer = document.getElementById('label-container')
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
let topNPickers = [...document.getElementsByClassName('top-picker')]
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
@ -32,10 +34,10 @@ Chart.defaults.global.defaultFontColor = "#E2E8F0"
Chart.defaults.global.defaultColor = "#E2E8F0"
String.prototype.toHHMMSS = function () {
var sec_num = parseInt(this, 10)
var hours = Math.floor(sec_num / 3600)
var minutes = Math.floor((sec_num - (hours * 3600)) / 60)
var seconds = sec_num - (hours * 3600) - (minutes * 60)
const sec_num = parseInt(this, 10)
let hours = Math.floor(sec_num / 3600)
let minutes = Math.floor((sec_num - (hours * 3600)) / 60)
let seconds = sec_num - (hours * 3600) - (minutes * 60)
if (hours < 10) {
hours = '0' + hours
@ -46,14 +48,14 @@ String.prototype.toHHMMSS = function () {
if (seconds < 10) {
seconds = '0' + seconds
}
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
return `${hours}:${minutes}`
}
function draw(subselection) {
@ -78,7 +80,7 @@ function draw(subselection) {
.filter((c, i) => shouldUpdate(i))
.forEach(c => c.destroy())
let projectChart = !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
let projectChart = projectsCanvas && !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
? new Chart(projectsCanvas.getContext('2d'), {
type: 'horizontalBar',
data: {
@ -112,7 +114,10 @@ function draw(subselection) {
xAxes: [{
scaleLabel: {
display: true,
labelString: 'Seconds'
labelString: 'Duration (hh:mm:ss)'
},
ticks: {
callback: (label) => label.toString().toHHMMSS()
}
}]
},
@ -123,7 +128,7 @@ function draw(subselection) {
})
: null
let osChart = !osCanvas.classList.contains('hidden') && shouldUpdate(1)
let osChart = osCanvas && !osCanvas.classList.contains('hidden') && shouldUpdate(1)
? new Chart(osCanvas.getContext('2d'), {
type: 'pie',
data: {
@ -156,7 +161,7 @@ function draw(subselection) {
})
: null
let editorChart = !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
let editorChart = editorsCanvas && !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
? new Chart(editorsCanvas.getContext('2d'), {
type: 'pie',
data: {
@ -189,7 +194,7 @@ function draw(subselection) {
})
: null
let languageChart = !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
let languageChart = languagesCanvas && !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
? new Chart(languagesCanvas.getContext('2d'), {
type: 'pie',
data: {
@ -222,7 +227,7 @@ function draw(subselection) {
})
: null
let machineChart = !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
let machineChart = machinesCanvas && !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
? new Chart(machinesCanvas.getContext('2d'), {
type: 'pie',
data: {
@ -255,9 +260,42 @@ function draw(subselection) {
})
: null
let labelChart = labelsCanvas && !labelsCanvas.classList.contains('hidden') && shouldUpdate(5)
? new Chart(labelsCanvas.getContext('2d'), {
type: 'pie',
data: {
datasets: [{
data: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => parseInt(p.total)),
backgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
}),
hoverBackgroundColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
}),
borderColor: wakapiData.labels.map(p => {
const c = hexToRgb(getRandomColor(p.key))
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
}),
}],
labels: wakapiData.labels
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
.map(p => p.key)
},
options: {
tooltips: getTooltipOptions('labels'),
maintainAspectRatio: false,
onResize: onChartResize
}
})
: null
getTotal(wakapiData.operatingSystems)
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
if (!subselection) {
charts.forEach(c => c.options.onResize(c.chart))
@ -270,9 +308,12 @@ function parseTopN() {
}
function togglePlaceholders(mask) {
const placeholderElements = containers.map(c => c.querySelector('.placeholder-container'))
const placeholderElements = containers.map(c => c ? c.querySelector('.placeholder-container'): null)
for (let i = 0; i < mask.length; i++) {
if (placeholderElements[i] === null) {
continue;
}
if (!mask[i]) {
canvases[i].classList.add('hidden')
placeholderElements[i].classList.remove('hidden')

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View File

@ -641,6 +641,12 @@ video {
background-color: rgba(47, 133, 90, var(--bg-opacity));
}
.hover\:bg-gray-700:hover {
--bg-opacity: 1;
background-color: #4a5568;
background-color: rgba(74, 85, 104, var(--bg-opacity));
}
.hover\:bg-red-600:hover {
--bg-opacity: 1;
background-color: #e53e3e;
@ -713,6 +719,10 @@ video {
border-radius: 0.375rem;
}
.rounded-full {
border-radius: 9999px;
}
.border {
border-width: 1px;
}
@ -753,6 +763,10 @@ video {
display: flex;
}
.inline-flex {
display: inline-flex;
}
.table {
display: table;
}
@ -821,6 +835,10 @@ video {
font-weight: 600;
}
.h-4 {
height: 1rem;
}
.h-8 {
height: 2rem;
}
@ -853,6 +871,10 @@ video {
font-size: 2.25rem;
}
.leading-none {
line-height: 1;
}
.list-inside {
list-style-position: inside;
}
@ -1186,6 +1208,10 @@ video {
overflow-wrap: break-word;
}
.w-4 {
width: 1rem;
}
.w-1\/2 {
width: 50%;
}

View File

@ -160,6 +160,75 @@ var doc = `{
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-3",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-7",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
@ -361,7 +430,7 @@ var doc = `{
"operationId": "post-heartbeat",
"parameters": [
{
"description": "A heartbeat",
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
@ -377,6 +446,75 @@ var doc = `{
}
}
},
"/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-5",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/plugins/errors": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"diagnostics"
],
"summary": "Push a new diagnostics object",
"operationId": "post-diagnostics",
"parameters": [
{
"description": "A single diagnostics object sent by WakaTime CLI",
"name": "diagnostics",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Diagnostics"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/summary": {
"get": {
"security": [
@ -441,9 +579,173 @@ var doc = `{
}
}
}
},
"/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-4",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-8",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/v1/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-2",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/v1/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-6",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
}
},
"definitions": {
"models.Diagnostics": {
"type": "object",
"properties": {
"architecture": {
"type": "string"
},
"cli_version": {
"type": "string"
},
"id": {
"type": "integer"
},
"logs": {
"type": "string"
},
"platform": {
"type": "string"
},
"plugin": {
"type": "string"
},
"stacktrace": {
"type": "string"
}
}
},
"models.Heartbeat": {
"type": "object",
"properties": {
@ -506,6 +808,13 @@ var doc = `{
"format": "date",
"example": "2006-01-02 15:04:05.000"
},
"labels": {
"description": "labels are not persisted, but calculated at runtime, i.e. when summary is retrieved",
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"languages": {
"type": "array",
"items": {

View File

@ -144,6 +144,75 @@
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-3",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/compat/wakatime/v1/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-7",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/compat/wakatime/v1/users/{user}/projects": {
"get": {
"security": [
@ -345,7 +414,7 @@
"operationId": "post-heartbeat",
"parameters": [
{
"description": "A heartbeat",
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
@ -361,6 +430,75 @@
}
}
},
"/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-5",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/plugins/errors": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"diagnostics"
],
"summary": "Push a new diagnostics object",
"operationId": "post-diagnostics",
"parameters": [
{
"description": "A single diagnostics object sent by WakaTime CLI",
"name": "diagnostics",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Diagnostics"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/summary": {
"get": {
"security": [
@ -425,9 +563,173 @@
}
}
}
},
"/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-4",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-8",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/v1/users/{user}/heartbeats": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push a new heartbeat",
"operationId": "post-heartbeat-2",
"parameters": [
{
"description": "A single heartbeat",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/models.Heartbeat"
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
},
"/v1/users/{user}/heartbeats.bulk": {
"post": {
"security": [
{
"ApiKeyAuth": []
}
],
"consumes": [
"application/json"
],
"tags": [
"heartbeat"
],
"summary": "Push new heartbeats",
"operationId": "post-heartbeat-6",
"parameters": [
{
"description": "Multiple heartbeats",
"name": "heartbeat",
"in": "body",
"required": true,
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/models.Heartbeat"
}
}
}
],
"responses": {
"201": {
"description": ""
}
}
}
}
},
"definitions": {
"models.Diagnostics": {
"type": "object",
"properties": {
"architecture": {
"type": "string"
},
"cli_version": {
"type": "string"
},
"id": {
"type": "integer"
},
"logs": {
"type": "string"
},
"platform": {
"type": "string"
},
"plugin": {
"type": "string"
},
"stacktrace": {
"type": "string"
}
}
},
"models.Heartbeat": {
"type": "object",
"properties": {
@ -490,6 +792,13 @@
"format": "date",
"example": "2006-01-02 15:04:05.000"
},
"labels": {
"description": "labels are not persisted, but calculated at runtime, i.e. when summary is retrieved",
"type": "array",
"items": {
"$ref": "#/definitions/models.SummaryItem"
}
},
"languages": {
"type": "array",
"items": {

View File

@ -1,5 +1,22 @@
basePath: /api
definitions:
models.Diagnostics:
properties:
architecture:
type: string
cli_version:
type: string
id:
type: integer
logs:
type: string
platform:
type: string
plugin:
type: string
stacktrace:
type: string
type: object
models.Heartbeat:
properties:
branch:
@ -43,6 +60,12 @@ definitions:
example: "2006-01-02 15:04:05.000"
format: date
type: string
labels:
description: labels are not persisted, but calculated at runtime, i.e. when
summary is retrieved
items:
$ref: '#/definitions/models.SummaryItem'
type: array
languages:
items:
$ref: '#/definitions/models.SummaryItem'
@ -408,6 +431,48 @@ paths:
summary: Retrieve summary for all time
tags:
- wakatime
/compat/wakatime/v1/users/{user}/heartbeats:
post:
consumes:
- application/json
operationId: post-heartbeat-3
parameters:
- description: A single heartbeat
in: body
name: heartbeat
required: true
schema:
$ref: '#/definitions/models.Heartbeat'
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push a new heartbeat
tags:
- heartbeat
/compat/wakatime/v1/users/{user}/heartbeats.bulk:
post:
consumes:
- application/json
operationId: post-heartbeat-7
parameters:
- description: Multiple heartbeats
in: body
name: heartbeat
required: true
schema:
items:
$ref: '#/definitions/models.Heartbeat'
type: array
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push new heartbeats
tags:
- heartbeat
/compat/wakatime/v1/users/{user}/projects:
get:
description: Mimics https://wakatime.com/developers#projects
@ -540,7 +605,7 @@ paths:
- application/json
operationId: post-heartbeat
parameters:
- description: A heartbeat
- description: A single heartbeat
in: body
name: heartbeat
required: true
@ -554,6 +619,48 @@ paths:
summary: Push a new heartbeat
tags:
- heartbeat
/heartbeats:
post:
consumes:
- application/json
operationId: post-heartbeat-5
parameters:
- description: Multiple heartbeats
in: body
name: heartbeat
required: true
schema:
items:
$ref: '#/definitions/models.Heartbeat'
type: array
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push new heartbeats
tags:
- heartbeat
/plugins/errors:
post:
consumes:
- application/json
operationId: post-diagnostics
parameters:
- description: A single diagnostics object sent by WakaTime CLI
in: body
name: diagnostics
required: true
schema:
$ref: '#/definitions/models.Diagnostics'
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push a new diagnostics object
tags:
- diagnostics
/summary:
get:
operationId: get-summary
@ -599,6 +706,90 @@ paths:
summary: Retrieve a summary
tags:
- summary
/users/{user}/heartbeats:
post:
consumes:
- application/json
operationId: post-heartbeat-4
parameters:
- description: A single heartbeat
in: body
name: heartbeat
required: true
schema:
$ref: '#/definitions/models.Heartbeat'
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push a new heartbeat
tags:
- heartbeat
/users/{user}/heartbeats.bulk:
post:
consumes:
- application/json
operationId: post-heartbeat-8
parameters:
- description: Multiple heartbeats
in: body
name: heartbeat
required: true
schema:
items:
$ref: '#/definitions/models.Heartbeat'
type: array
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push new heartbeats
tags:
- heartbeat
/v1/users/{user}/heartbeats:
post:
consumes:
- application/json
operationId: post-heartbeat-2
parameters:
- description: A single heartbeat
in: body
name: heartbeat
required: true
schema:
$ref: '#/definitions/models.Heartbeat'
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push a new heartbeat
tags:
- heartbeat
/v1/users/{user}/heartbeats.bulk:
post:
consumes:
- application/json
operationId: post-heartbeat-6
parameters:
- description: Multiple heartbeats
in: body
name: heartbeat
required: true
schema:
items:
$ref: '#/definitions/models.Heartbeat'
type: array
responses:
"201":
description: ""
security:
- ApiKeyAuth: []
summary: Push new heartbeats
tags:
- heartbeat
securityDefinitions:
ApiKeyAuth:
in: header

File diff suppressed because it is too large Load Diff

View File

@ -35,7 +35,7 @@ security:
insecure_cookies: true
cookie_max_age: 172800
allow_signup: true
expose_metrics: false
expose_metrics: true
sentry:
dsn:

View File

@ -1,4 +1,14 @@
BEGIN TRANSACTION;
INSERT INTO "users" ("id","api_key","email","location","password","created_at","last_logged_in_at","share_data_max_days","share_editors","share_languages","share_projects","share_oss","share_machines","is_admin","has_data","wakatime_api_key","reset_token","reports_weekly") VALUES ('readuser','33e7f538-0dce-4eba-8ffe-53db6814ed42','','Europe/Berlin','$2a$10$RCyfAFdlZdFJVWbxKz4f2uJ/MospiE1EFAIjvRizC4Nop9GfjgKzW','2021-05-28 12:34:25','2021-05-28 14:34:34.178+02:00',0,0,0,0,0,0,0,0,'','',0);
INSERT INTO "users" ("id","api_key","email","location","password","created_at","last_logged_in_at","share_data_max_days","share_editors","share_languages","share_projects","share_oss","share_machines","is_admin","has_data","wakatime_api_key","reset_token","reports_weekly") VALUES ('writeuser','f7aa255c-8647-4d0b-b90f-621c58fd580f','','Europe/Berlin','$2a$10$vsksPpiXZE9/xG9pRrZP.eKkbe/bGWW4wpPoXqvjiImZqMbN5c4Km','2021-05-28 12:34:56','2021-05-28 14:35:05.118+02:00',0,0,0,0,0,0,0,1,'','',0);
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('readuser', '33e7f538-0dce-4eba-8ffe-53db6814ed42', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:25',
'2021-05-28 14:34:34.178+02:00', 0, 0, 0, 0, 0, 0, 1, 0, '', '', 0);
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
COMMIT;

View File

@ -1,8 +1,8 @@
#!/bin/bash
if [ ! -f "wakapi" ]; then
echo "Wakapi executable not found. Run 'go build' first."
exit 1
echo "Wakapi executable not found. Compiling."
go build
fi
if ! command -v newman &> /dev/null

9
utils/collection.go Normal file
View File

@ -0,0 +1,9 @@
package utils
func GetMapValues(m map[string]interface{}) []interface{} {
values := make([]interface{}, 0, len(m))
for _, v := range m {
values = append(values, v)
}
return values
}

View File

@ -46,7 +46,7 @@ func Add(i, j int) int {
}
func ParseUserAgent(ua string) (string, string, error) {
re := regexp.MustCompile(`(?iU)^wakatime\/[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
re := regexp.MustCompile(`(?iU)^wakatime\/v?[\d+.]+\s\((\w+)-.*\)\s.+\s([^\/\s]+)-wakatime\/.+$`)
groups := re.FindAllStringSubmatch(ua, -1)
if len(groups) == 0 || len(groups[0]) != 3 {
return "", "", errors.New("failed to parse user agent string")

View File

@ -37,6 +37,12 @@ func TestCommon_ParseUserAgent(t *testing.T) {
"",
errors.New(""),
},
{
"wakatime/v1.18.11 (linux-5.13.8-200.fc34.x86_64-x86_64) go1.16.7 emacs-wakatime/1.0.2",
"linux",
"emacs",
nil,
},
}
for _, test := range tests {

View File

@ -55,6 +55,11 @@ func FloorDate(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
}
// FloorDateHour rounds date down to the start of the current hour and keeps the time zone
func FloorDateHour(date time.Time) time.Time {
return time.Date(date.Year(), date.Month(), date.Day(), date.Hour(), 0, 0, 0, date.Location())
}
// CeilDate rounds date up to the start of next day if date is not already a start (00:00:00)
func CeilDate(date time.Time) time.Time {
floored := FloorDate(date)

View File

@ -30,7 +30,8 @@ func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from,
}
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
to = time.Now().In(tz)
now := time.Now().In(tz)
to = now
switch interval {
case models.IntervalToday:
@ -51,16 +52,16 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
case models.IntervalThisYear:
from = StartOfThisYear(tz)
case models.IntervalPast7Days:
from = StartOfToday(tz).AddDate(0, 0, -7)
from = now.AddDate(0, 0, -7)
case models.IntervalPast7DaysYesterday:
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
to = StartOfToday(tz).AddDate(0, 0, -1)
case models.IntervalPast14Days:
from = StartOfToday(tz).AddDate(0, 0, -14)
from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days:
from = StartOfToday(tz).AddDate(0, 0, -30)
from = now.AddDate(0, 0, -30)
case models.IntervalPast12Months:
from = StartOfToday(tz).AddDate(0, -12, 0)
from = now.AddDate(0, -12, 0)
case models.IntervalAny:
from = time.Time{}
default:

View File

@ -3,8 +3,13 @@ package utils
import (
"encoding/json"
"html/template"
"io/fs"
"io/ioutil"
"path"
)
type TemplateMap map[string]*template.Template
func Json(data interface{}) template.JS {
d, err := json.Marshal(data)
if err != nil {
@ -19,3 +24,40 @@ func ToRunes(s string) (r []string) {
}
return r
}
func LoadTemplates(templateFs fs.FS, funcs template.FuncMap) (TemplateMap, error) {
tpls := template.New("").Funcs(funcs)
templates := make(map[string]*template.Template)
files, err := fs.ReadDir(templateFs, ".")
if err != nil {
return nil, err
}
for _, file := range files {
tplName := file.Name()
if file.IsDir() || path.Ext(tplName) != ".html" {
continue
}
templateFile, err := templateFs.Open(tplName)
if err != nil {
return nil, err
}
templateData, err := ioutil.ReadAll(templateFile)
if err != nil {
return nil, err
}
templateFile.Close()
tpl, err := tpls.New(tplName).Parse(string(templateData))
if err != nil {
return nil, err
}
templates[tplName] = tpl
}
return templates, nil
}

View File

@ -1 +1 @@
1.27.3
1.29.5

View File

@ -23,13 +23,13 @@
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="text" id="username"
type="text" id="username" autocomplete="username"
name="username" placeholder="Enter your username" minlength="1" required autofocus>
</div>
<div class="mb-8">
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
type="password" id="password"
type="password" id="password" autocomplete="current-password"
name="password" placeholder="******" minlength="6" required>
</div>
<div class="flex justify-between items-center">

88
views/mail/head.tpl.html Normal file
View File

@ -0,0 +1,88 @@
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Wakapi</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #047857 !important;
}
.btn-primary a:hover {
background-color: #047857 !important;
border-color: #047857 !important;
}
}
</style>
</head>

View File

@ -1,107 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Wakapi Data Import Finished</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #047857 !important;
}
.btn-primary a:hover {
background-color: #047857 !important;
border-color: #047857 !important;
}
}
</style>
</head>
{{ template "head.tpl.html" . }}
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
<div class="header" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
<img src="https://wakapi.dev/assets/images/android-chrome-192x192.png?utm_source=mail" alt="Wakapi Logo" width="96" style="width: 96px">
</td>
</tr>
</table>
</div>
{{ template "theader.tpl.html" . }}
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
@ -134,15 +41,7 @@
</tr>
</table>
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
Powered by <a href="https://wakapi.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">Wakapi.dev</a>.
</td>
</tr>
</table>
</div>
{{ template "tfooter.tpl.html" . }}
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>

6
views/mail/mail.go Normal file
View File

@ -0,0 +1,6 @@
package mail
import "embed"
//go:embed *.html
var TemplateFiles embed.FS

View File

@ -1,107 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Wakapi Report</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #047857 !important;
}
.btn-primary a:hover {
background-color: #047857 !important;
border-color: #047857 !important;
}
}
</style>
</head>
{{ template "head.tpl.html" . }}
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
<div class="header" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
<img src="https://wakapi.dev/assets/images/android-chrome-192x192.png?utm_source=mail" alt="Wakapi Logo" width="96" style="width: 96px">
</td>
</tr>
</table>
</div>
{{ template "theader.tpl.html" . }}
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
@ -180,15 +87,8 @@
</td>
</tr>
</table>
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
Powered by <a href="https://wakapi.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">Wakapi.dev</a>.
</td>
</tr>
</table>
</div>
{{ template "tfooter.tpl.html" . }}
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>

View File

@ -1,107 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta name="viewport" content="width=device-width">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<title>Wakapi Reset Password</title>
<style>
@media only screen and (max-width: 620px) {
table[class=body] h1 {
font-size: 28px !important;
margin-bottom: 10px !important;
}
table[class=body] p,
table[class=body] ul,
table[class=body] ol,
table[class=body] td,
table[class=body] span,
table[class=body] a {
font-size: 16px !important;
}
table[class=body] .wrapper,
table[class=body] .article {
padding: 10px !important;
}
table[class=body] .content {
padding: 0 !important;
}
table[class=body] .container {
padding: 0 !important;
width: 100% !important;
}
table[class=body] .main {
border-left-width: 0 !important;
border-radius: 0 !important;
border-right-width: 0 !important;
}
table[class=body] .btn table {
width: 100% !important;
}
table[class=body] .btn a {
width: 100% !important;
}
table[class=body] .img-responsive {
height: auto !important;
max-width: 100% !important;
width: auto !important;
}
}
/* -------------------------------------
PRESERVE THESE STYLES IN THE HEAD
------------------------------------- */
@media all {
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 100%;
}
.apple-link a {
color: inherit !important;
font-family: inherit !important;
font-size: inherit !important;
font-weight: inherit !important;
line-height: inherit !important;
text-decoration: none !important;
}
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
.btn-primary table td:hover {
background-color: #047857 !important;
}
.btn-primary a:hover {
background-color: #047857 !important;
border-color: #047857 !important;
}
}
</style>
</head>
{{ template "head.tpl.html" . }}
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
<div class="header" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
<img src="https://wakapi.dev/assets/images/android-chrome-192x192.png?utm_source=mail" alt="Wakapi Logo" width="96" style="width: 96px">
</td>
</tr>
</table>
</div>
{{ template "theader.tpl.html" . }}
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
@ -135,15 +42,7 @@
</tr>
</table>
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
Powered by <a href="https://wakapi.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">Wakapi.dev</a>.
</td>
</tr>
</table>
</div>
{{ template "tfooter.tpl.html" . }}
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>

View File

@ -0,0 +1,9 @@
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block powered-by" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
Powered by <a href="https://wakapi.dev" style="color: #999999; font-size: 12px; text-align: center; text-decoration: none;">Wakapi.dev</a>.
</td>
</tr>
</table>
</div>

View File

@ -0,0 +1,9 @@
<div class="header" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td class="content-block" style="font-family: sans-serif; vertical-align: top; padding-bottom: 10px; padding-top: 10px; font-size: 12px; color: #999999; text-align: center;">
<img src="https://wakapi.dev/assets/images/android-chrome-192x192.png?utm_source=mail" alt="Wakapi Logo" width="96" style="width: 96px">
</td>
</tr>
</table>
</div>

View File

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
{{ template "head.tpl.html" . }}
<body class="" style="background-color: #f6f6f6; font-family: sans-serif; -webkit-font-smoothing: antialiased; font-size: 14px; line-height: 1.4; margin: 0; padding: 0; -ms-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;">
<table border="0" cellpadding="0" cellspacing="0" class="body" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background-color: #f6f6f6;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
<td class="container" style="font-family: sans-serif; font-size: 14px; vertical-align: top; display: block; Margin: 0 auto; max-width: 580px; padding: 10px; width: 580px;">
{{ template "theader.tpl.html" . }}
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
<table class="main" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; background: #ffffff; border-radius: 3px;">
<tr>
<td class="wrapper" style="font-family: sans-serif; font-size: 14px; vertical-align: top; box-sizing: border-box; padding: 20px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;">
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">
<p style="font-family: sans-serif; font-size: 18px; font-weight: 500; margin: 0; Margin-bottom: 15px;">WakaTime Connection Failure</p>
<p style="font-family: sans-serif; font-size: 14px; font-weight: normal; margin: 0; Margin-bottom: 15px;">You have configured Wakapi to relay your heartbeats to WakaTime's API. However, requests for the last {{ .NumFailures }} heartbeats have failed. This is most likely an authentication issue. WakaTime connection is paused for now. To resume it, please re-enter your WakaTime API token under <a href="{{ .PublicUrl }}/settings">Settings</a>.</p>
<table border="0" cellpadding="0" cellspacing="0" class="btn btn-primary" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; box-sizing: border-box;">
<tbody>
<tr>
<td align="left" style="font-family: sans-serif; font-size: 14px; vertical-align: top; padding-bottom: 15px;">
<table border="0" cellpadding="0" cellspacing="0" style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top; background-color: #2F855A; border-radius: 5px; text-align: center;"> <a href="{{ .PublicUrl }}/settings" target="_blank" style="display: inline-block; color: #ffffff; background-color: #2F855A; border: solid 1px #2F855A; border-radius: 5px; box-sizing: border-box; cursor: pointer; text-decoration: none; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-transform: capitalize; border-color: #2F855A;">Go to Settings</a> </td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>
{{ template "tfooter.tpl.html" . }}
</div>
</td>
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;">&nbsp;</td>
</tr>
</table>
</body>
</html>

View File

@ -35,7 +35,7 @@
<main class="mt-4 flex-grow flex justify-center w-full">
<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" id="details-account">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="preferences-heading">
@ -88,7 +88,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-password">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
Change Password
@ -127,7 +127,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-aliases">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Aliases
@ -203,7 +203,80 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-labels">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Project Labels
</h2>
</summary>
<div class="w-full" id="project-labels">
<div class="text-gray-300 text-sm mb-4 mt-6">
You can assign labels (aka. tags) to projects to group them together, e.g. by <span class="inline-block mb-1 text-gray-500 italic">private</span> and <span
class="inline-block mb-1 text-gray-500 italic">work</span>.
</div>
{{ if .Labels }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Labels</h3>
{{ range $i, $label := .Labels }}
<div class="flex items-center" action="" method="post">
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
style="line-height: 1.8">
&#9656;&nbsp;<span class="font-semibold text-white">{{ $label.Key }}:</span>
{{ range $j, $value := $label.Values }}
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 font-mono inline-flex justify-between items-center space-x-2">
<input type="hidden" name="action" value="delete_label">
<input type="hidden" name="key" value="{{ $label.Key }}">
<input type="hidden" name="value" value="{{ $value }}">
<span>{{- $value -}}</span>
<button type="submit" class="bg-gray-800 text-center hover:bg-gray-700 rounded-full w-4 h-4 leading-none" title="Delete label">x</button>
</form>
{{ if lt $j (add (len $label.Values) -1) }}
<span class="-ml-1">{{- ", " | capitalize -}}</span>
{{ end }}
{{ end }}
</div>
</div>
{{end}}
<div class="mb-8"></div>
{{end}}
{{ if .Projects }}
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Label</h3>
<form action="" method="post">
<input type="hidden" name="action" value="add_label">
<div class="flex flex-col space-y-4">
<div class="flex justify-between items-center mt-2 w-full text-gray-500 text-sm space-x-4">
<div class="w-1/2 flex flex-col flex-grow">
<span>Project</span>
<select name="key" id="select-project"
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
{{ range $i, $p := .Projects }}
<option value="{{ $p }}">{{ $p }}</option>
{{ end }}
</select>
</div>
<div class="w-1/2 flex flex-col flex-grow">
<span>Label</span>
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
type="text" id="label-value"
name="value" placeholder="work" minlength="1" required>
</div>
</div>
<div class="flex-grow flex justify-end">
<button type="submit"
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
Add
</button>
</div>
</div>
</form>
{{ else }}
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
{{ end }}
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700" id="details-mappings">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="languages">
@ -263,7 +336,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-public-data">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
Public Data
@ -298,11 +371,9 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_projects"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected
{{ end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end
}}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -314,11 +385,9 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_languages"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected
{{ end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{
end }}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -330,11 +399,9 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_editors"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{
end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end
}}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -346,8 +413,7 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_oss"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end
}}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
Yes
@ -362,11 +428,23 @@
<div class="flex justify-end">
<select autocomplete="off" name="share_machines"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected
{{ end }}>No
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end
}}>Yes
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes
</option>
</select>
</div>
</div>
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
<div class="flex justify-start">
<span class="mr-2">Share project labels: </span>
</div>
<div class="flex justify-end">
<select autocomplete="off" name="share_labels"
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
<option value="false" class="cursor-pointer" {{ if not .User.ShareLabels }} selected {{ end }}>No
</option>
<option value="true" class="cursor-pointer" {{ if .User.ShareLabels }} selected {{ end }}>Yes
</option>
</select>
</div>
@ -383,7 +461,7 @@
</div>
</details>
<details class="mb-8 pb-8 border-b border-gray-700">
<details class="mb-8 pb-8 border-b border-gray-700" id="details-integrations">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
id="integrations">
@ -506,7 +584,7 @@
<p>You have the ability to create badges from your coding statistics using <a
href="https://shields.io" target="_blank" class="border-b border-green-800"
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public
access to the respective endpoint. See <a href="settings#details-public-data" class="underline">Public
Data</a> setting.</p>
{{ end }}
</div>
@ -528,7 +606,7 @@
</div>
<div class="flex flex-col mb-4 mt-2">
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
class="with-url-src-no-scheme">
class="with-url-src-no-scheme" alt="Readme Stats Card">
<p class="mt-2"><strong>Source URL:</strong>
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
@ -540,7 +618,7 @@
</div>
</details>
<details class="mb-8 pb-8">
<details class="mb-8 pb-8" id="details-danger-zone">
<summary class="cursor-pointer">
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
<span class="iconify inline" data-icon="emojione-v1:warning"></span>&nbsp; Danger Zone
@ -677,6 +755,12 @@
tzs.sort()
.map(createTzOption)
.forEach(o => selectTimezone.appendChild(o))
const hash = location.hash.replace('#', '')
if (hash) {
const elem = document.getElementById(hash)
if (elem) elem.open = true
}
</script>
{{ template "footer.tpl.html" . }}

View File

@ -170,6 +170,26 @@
</div>
</div>
</div>
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
<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="label-container" style="height: 300px">
<div class="flex justify-between">
<div class="w-1/4 flex-1">
<a href="settings#details-labels" class="h-8 inline">
<span class="iconify inline" data-icon="twemoji:gear"></span>
</a>
</div>
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Labels</span>
<div class="flex justify-end flex-1 text-xs items-center">
<label for="label-top-picker" class="mr-1">Show:&nbsp;</label>
<input type="number" min="1" id="label-top-picker" data-entity="5" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
</div>
</div>
<canvas id="chart-label"></canvas>
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
</div>
</div>
</div>
</div>
{{ else }}
@ -192,7 +212,8 @@
# <strong>Step 2:</strong> Adapt your config<br>
$ vi ~/.wakatime.cfg<br>
# Set <em>api_url = <span class="with-url-inner">%s/api</span></em><br>
<!-- https://github.com/muety/wakapi/issues/224#issuecomment-890855563 -->
# 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!
@ -211,12 +232,6 @@
{{ template "foot.tpl.html" . }}
<script>
window.addEventListener('load', function() {
document.getElementById('api-key-instruction').innerHTML = document.getElementById('api-key-container').value
})
</script>
<script>
const languageColors = {{ .LanguageColors | json }}
const editorColors = {{ .EditorColors | json }}
@ -228,15 +243,20 @@
wakapiData.editors = {{ .Editors | json }}
wakapiData.languages = {{ .Languages | json }}
wakapiData.machines = {{ .Machines | json }}
wakapiData.labels = {{ .Labels | json }}
document.getElementById("to-date-picker").onchange = function () {
var input = document.getElementById("from-date-picker");
input.setAttribute("max", this.value);
}
if (document.getElementById('to-date-picker') !== null) {
document.getElementById("to-date-picker").onchange = function () {
var input = document.getElementById("from-date-picker");
input.setAttribute("max", this.value);
}
document.getElementById("from-date-picker").onchange = function () {
var input = document.getElementById("to-date-picker");
input.setAttribute("min", this.value);
document.getElementById("from-date-picker").onchange = function () {
var input = document.getElementById("to-date-picker");
input.setAttribute("min", this.value);
}
} else {
document.getElementById('api-key-instruction').innerHTML = document.getElementById('api-key-container').value
}
</script>

View File

@ -2,5 +2,5 @@ package views
import "embed"
//go:embed *.html mail/*.html
//go:embed *.html
var TemplateFiles embed.FS