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

Compare commits

..

41 Commits

Author SHA1 Message Date
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
58 changed files with 3085 additions and 1194 deletions

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 && 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

@ -132,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
@ -151,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) |
@ -172,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 |
@ -410,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 {
@ -200,6 +204,9 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
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
}
}
@ -350,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=

85
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"
@ -52,6 +54,7 @@ var (
projectLabelRepository repositories.IProjectLabelRepository
summaryRepository repositories.ISummaryRepository
keyValueRepository repositories.IKeyValueRepository
diagnosticsRepository repositories.IDiagnosticsRepository
)
var (
@ -65,6 +68,7 @@ var (
mailService services.IMailService
keyValueService services.IKeyValueService
reportService services.IReportService
diagnosticsService services.IDiagnosticsService
miscService services.IMiscService
)
@ -115,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() {
@ -140,18 +145,20 @@ func main() {
projectLabelRepository = repositories.NewProjectLabelRepository(db)
summaryRepository = repositories.NewSummaryRepository(db)
keyValueRepository = repositories.NewKeyValueRepository(db)
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
// Services
mailService = mail.NewMailService()
aliasService = services.NewAliasService(aliasRepository)
userService = services.NewUserService(userRepository)
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)
mailService = mail.NewMailService()
keyValueService = services.NewKeyValueService(keyValueRepository)
reportService = services.NewReportService(summaryService, userService, mailService)
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
miscService = services.NewMiscService(userService, summaryService, keyValueService)
// Schedule background tasks
@ -166,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)
@ -187,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())
@ -208,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)
@ -227,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 != "" {
@ -240,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,
}
}
@ -251,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,
}
}
@ -273,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)
@ -290,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

@ -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

@ -3,7 +3,7 @@ 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_language_mapping_user; index:idx_project_label_user"`
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
ProjectKey string `json:"project"`
Label string `json:"label" gorm:"type:varchar(64)"`
}

View File

@ -30,7 +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:"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

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

@ -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)

View File

@ -23,7 +23,6 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil {
return nil, err
}
@ -49,7 +48,6 @@ func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Tim
Preload("Editors", "type = ?", models.SummaryEditor).
Preload("OperatingSystems", "type = ?", models.SummaryOS).
Preload("Machines", "type = ?", models.SummaryMachine).
Preload("Labels", "type = ?", models.SummaryLabel).
Find(&summaries).Error; err != nil {
return nil, err
}

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,16 +68,20 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
}
var heartbeats []*models.Heartbeat
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
}
}
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
}
for _, hb := range heartbeats {
hb.OperatingSystem = opSys
hb.Editor = editor
@ -103,12 +115,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 +170,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

@ -228,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

@ -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"
@ -118,6 +77,16 @@ func typeName(t uint8) string {
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

@ -147,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":
@ -447,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()
}

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,6 +2,7 @@ package services
import (
"fmt"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/repositories"
"github.com/muety/wakapi/utils"
@ -15,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 {
@ -46,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) {
@ -83,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
}
@ -99,7 +163,7 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
}
}
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
srv.cache2.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
return filtered, nil
}
@ -126,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)
}
}
}
@ -142,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

@ -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
}

View File

@ -2,6 +2,7 @@ package services
import (
"errors"
"github.com/leandro-lugaresi/hub"
"github.com/muety/wakapi/config"
"github.com/muety/wakapi/models"
"github.com/muety/wakapi/repositories"
@ -12,12 +13,14 @@ import (
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),
}
@ -65,6 +68,7 @@ func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.Proj
}
srv.cache.Delete(result.UserID)
srv.notifyUpdate(label, false)
return result, nil
}
@ -74,5 +78,17 @@ func (srv *ProjectLabelService) Delete(label *models.ProjectLabel) error {
}
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
@ -64,6 +68,7 @@ type IProjectLabelService interface {
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
}
@ -90,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

@ -2,7 +2,9 @@ package services
import (
"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"
@ -18,6 +20,7 @@ const HeartbeatDiffThreshold = 2 * time.Minute
type SummaryService struct {
config *config.Config
cache *cache.Cache
eventBus *hub.Hub
repository repositories.ISummaryRepository
heartbeatService IHeartbeatService
aliasService IAliasService
@ -27,18 +30,34 @@ type SummaryService struct {
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
return &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")
@ -65,6 +84,7 @@ 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
@ -73,12 +93,6 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
}
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 {
@ -101,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
}
@ -161,7 +173,6 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
OperatingSystems: osItems,
Machines: machineItems,
}
summary = srv.withProjectLabels(summary)
return summary.Sorted(), nil
}

View File

@ -120,7 +120,6 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 1 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(-1*time.Minute)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser)
@ -134,7 +133,6 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 2 */
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Second)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser)
@ -148,7 +146,6 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
/* TEST 3 */
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filter(from, to, suite.TestHeartbeats), nil)
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return(suite.TestLabels, nil).Once()
result, err = sut.Summarize(from, to, suite.TestUser)
@ -159,8 +156,6 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
assert.Equal(suite.T(), 150*time.Second, result.TotalTime())
assert.Equal(suite.T(), 30*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorGoland))
assert.Equal(suite.T(), 120*time.Second, result.TotalTimeByKey(models.SummaryEditor, TestEditorVscode))
assert.Equal(suite.T(), 150*time.Second, result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel1))
assert.Zero(suite.T(), result.TotalTimeByKey(models.SummaryLabel, TestProjectLabel3))
assert.Len(suite.T(), result.Editors, 2)
assertNumAllItems(suite.T(), 1, result, "e")
}
@ -168,8 +163,6 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
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
from time.Time
@ -379,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)
@ -392,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) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

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

View File

@ -299,7 +299,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{tsNowMinus1Min}}\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{tsNowMinus2Min}}\n}]",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts3}}\n}]",
"options": {
"raw": {
"language": "json"
@ -326,11 +326,18 @@
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" // only check if endpoint is present (non-404), 400 is fine because invalid body sent",
" pm.response.to.have.status(400);",
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
""
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(2);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[1].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
" pm.expect(jsonData.responses[1][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
@ -354,7 +361,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts2}}\n}]",
"options": {
"raw": {
"language": "json"
@ -384,11 +391,18 @@
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 400\", function () {",
" // only check if endpoint is present (non-404), 400 is fine because invalid body sent",
" pm.response.to.have.status(400);",
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
""
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(2);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[1].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
" pm.expect(jsonData.responses[1][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
@ -412,7 +426,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts2}}\n}]",
"options": {
"raw": {
"language": "json"
@ -437,6 +451,376 @@
},
"response": []
},
{
"name": "Create heartbeats (alt 3)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(2);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[1].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
" pm.expect(jsonData.responses[1][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts2}}\n}]",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/users/current/heartbeats.bulk",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"users",
"current",
"heartbeats.bulk"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 4, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/heartbeat",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"heartbeat"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 5, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/v1/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"v1",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 6, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/compat/wakatime/v1/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"compat",
"wakatime",
"v1",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{
"name": "Create heartbeats (alt 7, single)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});",
"",
"pm.test(\"Response body is correct\", function () {",
" var jsonData = pm.response.json();",
" pm.expect(jsonData.responses.length).to.eql(1);",
" pm.expect(jsonData.responses[0].length).to.eql(2);",
" pm.expect(jsonData.responses[0][1]).to.eql(201);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{
"name": "Create heartbeats (non-matching)",
"event": [
{
"listen": "test",
"script": {
"exec": [
"pm.test(\"Status code is 404\", function () {",
" pm.response.to.have.status(404);",
"});"
],
"type": "text/javascript"
}
}
],
"protocolProfileBehavior": {
"disableCookies": true
},
"request": {
"auth": {
"type": "bearer",
"bearer": [
{
"key": "token",
"value": "{{WRITEUSER_TOKEN}}",
"type": "string"
}
]
},
"method": "POST",
"header": [],
"body": {
"mode": "raw",
"raw": "{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": {{ts1}}\n}",
"options": {
"raw": {
"language": "json"
}
}
},
"url": {
"raw": "{{BASE_URL}}/api/v2/users/current/heartbeats",
"host": [
"{{BASE_URL}}"
],
"path": [
"api",
"v2",
"users",
"current",
"heartbeats"
]
}
},
"response": []
},
{
"name": "Create heartbeats (unauthorized)",
"event": [
@ -732,8 +1116,8 @@
"",
"pm.test(\"Correct dates\", function () {",
" const jsonData = pm.response.json();",
" pm.expect(moment(jsonData.from).unix()).to.gt(moment(pm.variables.get('tsStartOfDayDate')).unix())",
" pm.expect(moment(jsonData.to).unix()).to.gt(moment(pm.variables.get('tsEndOfDayDate')).unix())",
" pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayDate')).unix())",
" pm.expect(moment(jsonData.to).unix()).to.gte(moment(pm.variables.get('tsEndOfDayDate')).unix())",
"});",
""
],
@ -2469,7 +2853,10 @@
"pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
"pm.variables.set('tsStartOfDayDate', startOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfDayDate', endOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfTomorrowDate', endOfTomorrow.format('YYYY-MM-DD'))"
"pm.variables.set('tsEndOfTomorrowDate', endOfTomorrow.format('YYYY-MM-DD'))",
"pm.variables.set('ts1', now.startOf('hour').format('x') / 1000)",
"pm.variables.set('ts2', now.startOf('hour').add(1, 'm').format('x') / 1000)",
"pm.variables.set('ts3', now.startOf('hour').add(2, 'm').format('x') / 1000)"
]
}
},

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

@ -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.28.0
1.29.4

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

@ -212,7 +212,7 @@
<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>. Please note that labels are only applied to new data, not to existing summaries in retrospect. To label historic data, you will need to clear and regenerate your summaries (see down below).
class="inline-block mb-1 text-gray-500 italic">work</span>.
</div>
{{ if .Labels }}

View File

@ -212,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!

View File

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