mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
63 Commits
Author | SHA1 | Date | |
---|---|---|---|
81835a3d88 | |||
30de96950b | |||
11291b0d6c | |||
f0ac0f6153 | |||
6aad1633e1 | |||
c07a4d71a0 | |||
dff0b742fc | |||
4f65f94766 | |||
825663acde | |||
f399fd4ea7 | |||
87fadf46f7 | |||
69f5d510dc | |||
0542813ed6 | |||
c962a3891d | |||
2088987a0c | |||
9e3203ac41 | |||
58719182c4 | |||
a8df25be08 | |||
391cc1e5b4 | |||
3bb22e5e84 | |||
93bdb48d95 | |||
533b5d62fc | |||
0af5fab75f | |||
fecc8b3b5f | |||
24b8ff6381 | |||
180e75a5eb | |||
f48b49d26e | |||
47b9cacb26 | |||
23fc1b62cc | |||
74f6a255a8 | |||
7a5dce29bd | |||
0e1596fe70 | |||
48513b660d | |||
69f73fc0ea | |||
0e788b0777 | |||
181aefa2f9 | |||
407925ec53 | |||
5e96e2a601 | |||
4d2a160ccb | |||
c3957ec0c8 | |||
312dfb36d8 | |||
c66605d463 | |||
3c12df52d9 | |||
dd6a040171 | |||
9f1266957b | |||
466f2e1786 | |||
82b8951437 | |||
25464e9519 | |||
650fffa344 | |||
69627fbe11 | |||
561198b203 | |||
7c4a2024b6 | |||
7bcd6890d1 | |||
1e4e530c21 | |||
490cca05eb | |||
3780ae4255 | |||
628ea0b9dd | |||
0d64858721 | |||
c1c78d8d5b | |||
538b9d2463 | |||
f4612fd542 | |||
fb643571d2 | |||
a4d47fb566 |
19
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
19
.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
name: Bug
|
||||||
|
about: Create a report to help us improve
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Describe the bug**
|
||||||
|
A clear and concise description of what the bug is. Please briefly describe how to reproduce the bug as well as _expected_ vs. _actual_ behavior. Optionally include screenshots and server logs, if helpful.
|
||||||
|
|
||||||
|
**System information**
|
||||||
|
Please provide information on:
|
||||||
|
* Wakapi version
|
||||||
|
* Operating system
|
||||||
|
* If Linux: which distro?
|
||||||
|
* If Docker: which image and tag?
|
||||||
|
* Database (SQLite, MySQL, ... ?)
|
10
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/other.md
vendored
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
name: Other (feature request, question, ...)
|
||||||
|
about: Anything else
|
||||||
|
title: ''
|
||||||
|
labels: ''
|
||||||
|
assignees: ''
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
|
16
.github/workflows/docker.yml
vendored
16
.github/workflows/docker.yml
vendored
@ -14,6 +14,9 @@ jobs:
|
|||||||
- name: Get version
|
- name: Get version
|
||||||
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
run: echo "GIT_TAG=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v1
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
@ -38,5 +41,18 @@ jobs:
|
|||||||
tags: |
|
tags: |
|
||||||
n1try/wakapi:${{ env.GIT_TAG }}
|
n1try/wakapi:${{ env.GIT_TAG }}
|
||||||
n1try/wakapi:latest
|
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-from: type=local,src=/tmp/.buildx-cache
|
||||||
cache-to: type=local,dest=/tmp/.buildx-cache
|
cache-to: type=local,dest=/tmp/.buildx-cache
|
||||||
|
@ -29,7 +29,8 @@ FROM debian
|
|||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apt update && \
|
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
|
# See README.md and config.default.yml for all config options
|
||||||
ENV ENVIRONMENT prod
|
ENV ENVIRONMENT prod
|
||||||
|
52
Dockerfile.alpine
Normal file
52
Dockerfile.alpine
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
# Build Stage
|
||||||
|
|
||||||
|
FROM golang:1.16-alpine AS build-env
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Required for go-sqlite3
|
||||||
|
RUN apk add gcc musl-dev
|
||||||
|
|
||||||
|
ADD ./go.mod .
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
RUN wget "https://raw.githubusercontent.com/vishnubob/wait-for-it/master/wait-for-it.sh" -O wait-for-it.sh && \
|
||||||
|
chmod +x wait-for-it.sh
|
||||||
|
|
||||||
|
ADD . .
|
||||||
|
RUN go build -o wakapi
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
RUN cp /src/wakapi . && \
|
||||||
|
cp /src/config.default.yml config.yml && \
|
||||||
|
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
|
||||||
|
cp /src/wait-for-it.sh . && \
|
||||||
|
cp /src/entrypoint.sh .
|
||||||
|
|
||||||
|
# Run Stage
|
||||||
|
|
||||||
|
# When running the application using `docker run`, you can pass environment variables
|
||||||
|
# to override config values using `-e` syntax.
|
||||||
|
# Available options can be found in [README.md#-configuration](README.md#-configuration)
|
||||||
|
|
||||||
|
FROM alpine:3
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk update && apk add bash ca-certificates tzdata && rm -rf /var/cache/apk
|
||||||
|
|
||||||
|
# See README.md and config.default.yml for all config options
|
||||||
|
ENV ENVIRONMENT prod
|
||||||
|
ENV WAKAPI_DB_TYPE sqlite3
|
||||||
|
ENV WAKAPI_DB_USER ''
|
||||||
|
ENV WAKAPI_DB_PASSWORD ''
|
||||||
|
ENV WAKAPI_DB_HOST ''
|
||||||
|
ENV WAKAPI_DB_NAME=/data/wakapi.db
|
||||||
|
ENV WAKAPI_PASSWORD_SALT ''
|
||||||
|
ENV WAKAPI_LISTEN_IPV4 '0.0.0.0'
|
||||||
|
ENV WAKAPI_INSECURE_COOKIES 'true'
|
||||||
|
ENV WAKAPI_ALLOW_SIGNUP 'true'
|
||||||
|
|
||||||
|
COPY --from=build-env /app .
|
||||||
|
|
||||||
|
VOLUME /data
|
||||||
|
|
||||||
|
ENTRYPOINT /app/entrypoint.sh
|
19
README.md
19
README.md
@ -4,14 +4,13 @@
|
|||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
||||||
<a href="https://saythanks.io/to/n1try"><img src="https://badges.fw-web.space/badge/SayThanks.io-%E2%98%BC-1EAEDB.svg"></a>
|
|
||||||
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
|
<a href="https://liberapay.com/muety/"><img src="https://badges.fw-web.space/liberapay/receives/muety.svg?logo=liberapay"></a>
|
||||||
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
|
<img src="https://badges.fw-web.space/endpoint?url=https://wakapi.dev/api/compat/shields/v1/n1try/interval:any/project:wakapi&color=blue&label=wakapi">
|
||||||
|
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></a>
|
||||||
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
|
||||||
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
|
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=sqale_index"></a>
|
||||||
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
|
<a href="https://sonarcloud.io/dashboard?id=muety_wakapi"><img src="https://sonarcloud.io/api/project_badges/measure?project=muety_wakapi&metric=ncloc"></a>
|
||||||
</p>
|
</p>
|
||||||
@ -133,8 +132,8 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
|
|||||||
```ini
|
```ini
|
||||||
[settings]
|
[settings]
|
||||||
|
|
||||||
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
|
# Your Wakapi server URL or 'https://wakapi.dev' when using the cloud server
|
||||||
api_url = http://localhost:3000/api
|
api_url = http://localhost:3000/api/heartbeat
|
||||||
|
|
||||||
# Your Wakapi API key (get it from the web interface after having created an account)
|
# Your Wakapi API key (get it from the web interface after having created an account)
|
||||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||||
@ -152,6 +151,8 @@ You can specify configuration options either via a config file (default: `config
|
|||||||
| `server.port` | `WAKAPI_PORT` | `3000` | Port to listen on |
|
| `server.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_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_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_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.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) |
|
| `server.base_path` | `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
||||||
@ -173,8 +174,8 @@ You can specify configuration options either via a config file (default: `config
|
|||||||
| `mail.enabled` | `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
| `mail.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.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.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.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.yaml) 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.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.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 |
|
| `sentry.sample_rate` | `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
||||||
@ -411,7 +412,11 @@ Wakapi adds a "padding" of two minutes before the third heartbeat. This is why t
|
|||||||
</details>
|
</details>
|
||||||
|
|
||||||
## 🙏 Thanks
|
## 🙏 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.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## 📓 License
|
## 📓 License
|
||||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
||||||
|
@ -3,6 +3,8 @@ env: production
|
|||||||
server:
|
server:
|
||||||
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
listen_ipv4: 127.0.0.1 # leave blank to disable ipv4
|
||||||
listen_ipv6: ::1 # leave blank to disable ipv6
|
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_cert_path: # leave blank to not use https
|
||||||
tls_key_path: # leave blank to not use https
|
tls_key_path: # leave blank to not use https
|
||||||
port: 3000
|
port: 3000
|
||||||
|
@ -33,6 +33,7 @@ const (
|
|||||||
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
||||||
|
|
||||||
ErrUnauthorized = "401 unauthorized"
|
ErrUnauthorized = "401 unauthorized"
|
||||||
|
ErrBadRequest = "400 bad request"
|
||||||
ErrInternalServerError = "500 internal server error"
|
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"`
|
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"`
|
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
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"`
|
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||||
Colors map[string]map[string]string `yaml:"-"`
|
Colors map[string]map[string]string `yaml:"-"`
|
||||||
}
|
}
|
||||||
@ -95,13 +97,15 @@ type dbConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type serverConfig struct {
|
type serverConfig struct {
|
||||||
Port int `default:"3000" env:"WAKAPI_PORT"`
|
Port int `default:"3000" env:"WAKAPI_PORT"`
|
||||||
ListenIpV4 string `yaml:"listen_ipv4" default:"127.0.0.1" env:"WAKAPI_LISTEN_IPV4"`
|
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"`
|
ListenIpV6 string `yaml:"listen_ipv6" default:"::1" env:"WAKAPI_LISTEN_IPV6"`
|
||||||
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
ListenSocket string `yaml:"listen_socket" default:"" env:"WAKAPI_LISTEN_SOCKET"`
|
||||||
PublicUrl string `yaml:"public_url" default:"http://localhost:3000" env:"WAKAPI_PUBLIC_URL"`
|
TimeoutSec int `yaml:"timeout_sec" default:"30" env:"WAKAPI_TIMEOUT_SEC"`
|
||||||
TlsCertPath string `yaml:"tls_cert_path" default:"" env:"WAKAPI_TLS_CERT_PATH"`
|
BasePath string `yaml:"base_path" default:"/" env:"WAKAPI_BASE_PATH"`
|
||||||
TlsKeyPath string `yaml:"tls_key_path" default:"" env:"WAKAPI_TLS_KEY_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 {
|
type sentryConfig struct {
|
||||||
@ -197,6 +201,12 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
|||||||
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
if err := db.AutoMigrate(&models.LanguageMapping{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
if err := db.AutoMigrate(&models.ProjectLabel{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -347,8 +357,8 @@ func Load(version string) *Config {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// some validation checks
|
// some validation checks
|
||||||
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" {
|
if config.Server.ListenIpV4 == "" && config.Server.ListenIpV6 == "" && config.Server.ListenSocket == "" {
|
||||||
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 must be set")
|
logbuch.Fatal("either of listen_ipv4 or listen_ipv6 or listen_socket must be set")
|
||||||
}
|
}
|
||||||
if config.Db.MaxConn <= 0 {
|
if config.Db.MaxConn <= 0 {
|
||||||
logbuch.Fatal("you must allow at least one database connection")
|
logbuch.Fatal("you must allow at least one database connection")
|
||||||
|
@ -8,9 +8,17 @@ type ApplicationEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
TopicUser = "user.*"
|
TopicUser = "user.*"
|
||||||
EventUserUpdate = "user.update"
|
TopicHeartbeat = "heartbeat.*"
|
||||||
FieldPayload = "payload"
|
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
|
var eventHub *hub.Hub
|
||||||
|
File diff suppressed because it is too large
Load Diff
22
go.mod
22
go.mod
@ -3,37 +3,35 @@ module github.com/muety/wakapi
|
|||||||
go 1.16
|
go 1.16
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/BurntSushi/toml v0.4.1 // indirect
|
||||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||||
github.com/emersion/go-smtp v0.15.0
|
github.com/emersion/go-smtp v0.15.0
|
||||||
github.com/emvi/logbuch v1.2.0
|
github.com/emvi/logbuch v1.2.0
|
||||||
github.com/felixge/httpsnoop v1.0.2 // indirect
|
github.com/felixge/httpsnoop v1.0.2 // indirect
|
||||||
github.com/getsentry/sentry-go v0.10.0
|
github.com/getsentry/sentry-go v0.11.0
|
||||||
github.com/go-co-op/gocron v1.5.0
|
github.com/go-co-op/gocron v1.6.2
|
||||||
github.com/go-openapi/spec v0.20.2 // indirect
|
github.com/go-openapi/spec v0.20.2 // indirect
|
||||||
github.com/gorilla/handlers v1.5.1
|
github.com/gorilla/handlers v1.5.1
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/schema v1.2.0
|
github.com/gorilla/schema v1.2.0
|
||||||
github.com/gorilla/securecookie v1.1.1
|
github.com/gorilla/securecookie v1.1.1
|
||||||
github.com/jackc/pgproto3/v2 v2.0.7 // indirect
|
github.com/jackc/pgx/v4 v4.13.0 // indirect
|
||||||
github.com/jackc/pgx/v4 v4.11.0 // indirect
|
|
||||||
github.com/jinzhu/configor v1.2.1
|
github.com/jinzhu/configor v1.2.1
|
||||||
github.com/leandro-lugaresi/hub v1.1.1
|
github.com/leandro-lugaresi/hub v1.1.1
|
||||||
github.com/mailru/easyjson v0.7.7 // indirect
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // 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/patrickmn/go-cache v2.1.0+incompatible
|
||||||
github.com/satori/go.uuid v1.2.0
|
github.com/satori/go.uuid v1.2.0
|
||||||
github.com/stretchr/testify v1.7.0
|
github.com/stretchr/testify v1.7.0
|
||||||
github.com/swaggo/swag v1.7.0
|
github.com/swaggo/swag v1.7.0
|
||||||
go.uber.org/atomic v1.7.0
|
go.uber.org/atomic v1.9.0
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b
|
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
|
||||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
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
|
golang.org/x/tools v0.1.0 // indirect
|
||||||
gorm.io/driver/mysql v1.0.6
|
gorm.io/driver/mysql v1.1.1
|
||||||
gorm.io/driver/postgres v1.0.8
|
gorm.io/driver/postgres v1.1.0
|
||||||
gorm.io/driver/sqlite v1.1.4
|
gorm.io/driver/sqlite v1.1.4
|
||||||
gorm.io/gorm v1.21.9
|
gorm.io/gorm v1.21.12
|
||||||
)
|
)
|
||||||
|
80
go.sum
80
go.sum
@ -1,8 +1,9 @@
|
|||||||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
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=
|
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/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.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/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/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
|
||||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
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/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/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/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.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8=
|
||||||
github.com/getsentry/sentry-go v0.10.0/go.mod h1:kELm/9iCblqUYh+ZRML7PNdCvEuw24wBvJPYyi86cws=
|
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/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-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/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-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.6.2 h1:x5g1tWnWcXIZesdosJJcbziRi4XG6tKB92yKLUpoBkU=
|
||||||
github.com/go-co-op/gocron v1.5.0/go.mod h1:7MgKum7jD7YgIRj7Zx7K1iJKAf1MlSIsEieRl18+KyU=
|
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 h1:LUHzmkK3GUKUrL/1gfBUxAHzcev3apQlezX/+O7ma6w=
|
||||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
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.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.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/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.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.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
|
||||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
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/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/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
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 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/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.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
github.com/gogo/protobuf v1.2.0/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.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.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.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.4.0 h1:xsAVV57WRhGj6kEIi8ReJzQlHHqcBYCElAvkovg3B/4=
|
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
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/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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
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.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.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.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.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 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
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-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 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgproto3 v1.1.0 h1:FYYE4yRw+AgI8wXIinMlNjBbp/UitDJwfj5LqqewP1A=
|
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.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.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.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.7 h1:6Pwi1b3QdY65cuv6SyVO0FgPd5J3Bl7wf/nQQjinHMA=
|
github.com/jackc/pgproto3/v2 v2.1.1 h1:7PQ/4gLoqnl87ZxL7xjO0DR5gYuviDCZxQJsUlFW1eI=
|
||||||
github.com/jackc/pgproto3/v2 v2.0.7/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
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-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 h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
|
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.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.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.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.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-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-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.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.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.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.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.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-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 v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||||
github.com/jackc/puddle v1.1.0/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.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.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.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.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-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/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=
|
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-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/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/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.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.1/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
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/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 v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||||
github.com/mitchellh/mapstructure v1.1.2/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/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/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-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 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/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.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
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.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.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||||
go.uber.org/atomic v1.6.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.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||||
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
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.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
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-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-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-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-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
|
golang.org/x/crypto v0.0.0-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
|
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/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-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
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-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-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-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-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-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-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-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-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-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-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
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/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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
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-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 h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.1.1 h1:yr1bpyqiwuSPJ4aGGUX9nu46RHXlF8RASQVb1QQNcvo=
|
||||||
gorm.io/driver/mysql v1.0.6/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU=
|
gorm.io/driver/mysql v1.1.1/go.mod h1:KdrTanmfLPPyAOeYGyG+UpDys7/7eeWT1zCq+oekYnU=
|
||||||
gorm.io/driver/postgres v1.0.8 h1:PAgM+PaHOSAeroTjHkCHCBIHHoBIf9RgPWGo8dF2DA8=
|
gorm.io/driver/postgres v1.1.0 h1:afBljg7PtJ5lA6YUWluV2+xovIPhS+YiInuL3kUjrbk=
|
||||||
gorm.io/driver/postgres v1.0.8/go.mod h1:4eOzrI1MUfm6ObJU/UcmbXyiHSs8jSwH95G5P5dxcAg=
|
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 h1:PDzwYE+sI6De2+mxAneV9Xs11+ZyKV6oxD3wDGkaNvM=
|
||||||
gorm.io/driver/sqlite v1.1.4/go.mod h1:mJCeTFr7+crvS+TRnWc5Z3UvwxUN1BGBLMrf5LA9DYw=
|
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.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.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-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-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
99
main.go
99
main.go
@ -2,8 +2,10 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"embed"
|
"embed"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -49,8 +51,10 @@ var (
|
|||||||
heartbeatRepository repositories.IHeartbeatRepository
|
heartbeatRepository repositories.IHeartbeatRepository
|
||||||
userRepository repositories.IUserRepository
|
userRepository repositories.IUserRepository
|
||||||
languageMappingRepository repositories.ILanguageMappingRepository
|
languageMappingRepository repositories.ILanguageMappingRepository
|
||||||
|
projectLabelRepository repositories.IProjectLabelRepository
|
||||||
summaryRepository repositories.ISummaryRepository
|
summaryRepository repositories.ISummaryRepository
|
||||||
keyValueRepository repositories.IKeyValueRepository
|
keyValueRepository repositories.IKeyValueRepository
|
||||||
|
diagnosticsRepository repositories.IDiagnosticsRepository
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -58,11 +62,13 @@ var (
|
|||||||
heartbeatService services.IHeartbeatService
|
heartbeatService services.IHeartbeatService
|
||||||
userService services.IUserService
|
userService services.IUserService
|
||||||
languageMappingService services.ILanguageMappingService
|
languageMappingService services.ILanguageMappingService
|
||||||
|
projectLabelService services.IProjectLabelService
|
||||||
summaryService services.ISummaryService
|
summaryService services.ISummaryService
|
||||||
aggregationService services.IAggregationService
|
aggregationService services.IAggregationService
|
||||||
mailService services.IMailService
|
mailService services.IMailService
|
||||||
keyValueService services.IKeyValueService
|
keyValueService services.IKeyValueService
|
||||||
reportService services.IReportService
|
reportService services.IReportService
|
||||||
|
diagnosticsService services.IDiagnosticsService
|
||||||
miscService services.IMiscService
|
miscService services.IMiscService
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -113,6 +119,7 @@ func main() {
|
|||||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||||
if config.Db.Dialect == "sqlite3" {
|
if config.Db.Dialect == "sqlite3" {
|
||||||
db.Raw("PRAGMA foreign_keys = ON;")
|
db.Raw("PRAGMA foreign_keys = ON;")
|
||||||
|
db.DisableForeignKeyConstraintWhenMigrating = true
|
||||||
}
|
}
|
||||||
|
|
||||||
if config.IsDev() {
|
if config.IsDev() {
|
||||||
@ -135,19 +142,23 @@ func main() {
|
|||||||
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
heartbeatRepository = repositories.NewHeartbeatRepository(db)
|
||||||
userRepository = repositories.NewUserRepository(db)
|
userRepository = repositories.NewUserRepository(db)
|
||||||
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
||||||
|
projectLabelRepository = repositories.NewProjectLabelRepository(db)
|
||||||
summaryRepository = repositories.NewSummaryRepository(db)
|
summaryRepository = repositories.NewSummaryRepository(db)
|
||||||
keyValueRepository = repositories.NewKeyValueRepository(db)
|
keyValueRepository = repositories.NewKeyValueRepository(db)
|
||||||
|
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
aliasService = services.NewAliasService(aliasRepository)
|
|
||||||
userService = services.NewUserService(userRepository)
|
|
||||||
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
|
||||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
|
||||||
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService)
|
|
||||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
|
||||||
mailService = mail.NewMailService()
|
mailService = mail.NewMailService()
|
||||||
|
aliasService = services.NewAliasService(aliasRepository)
|
||||||
|
userService = services.NewUserService(mailService, userRepository)
|
||||||
|
languageMappingService = services.NewLanguageMappingService(languageMappingRepository)
|
||||||
|
projectLabelService = services.NewProjectLabelService(projectLabelRepository)
|
||||||
|
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||||
|
summaryService = services.NewSummaryService(summaryRepository, heartbeatService, aliasService, projectLabelService)
|
||||||
|
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||||
reportService = services.NewReportService(summaryService, userService, mailService)
|
reportService = services.NewReportService(summaryService, userService, mailService)
|
||||||
|
diagnosticsService = services.NewDiagnosticsService(diagnosticsRepository)
|
||||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||||
|
|
||||||
// Schedule background tasks
|
// Schedule background tasks
|
||||||
@ -162,6 +173,7 @@ func main() {
|
|||||||
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
|
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
|
||||||
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
||||||
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
||||||
|
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
|
||||||
|
|
||||||
// Compat Handlers
|
// Compat Handlers
|
||||||
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
|
wakatimeV1AllHandler := wtV1Routes.NewAllTimeHandler(userService, summaryService)
|
||||||
@ -173,7 +185,7 @@ func main() {
|
|||||||
|
|
||||||
// MVC Handlers
|
// MVC Handlers
|
||||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, keyValueService, mailService)
|
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||||
@ -183,9 +195,17 @@ func main() {
|
|||||||
rootRouter := router.PathPrefix("/").Subrouter()
|
rootRouter := router.PathPrefix("/").Subrouter()
|
||||||
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
|
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
|
// Globally used middlewares
|
||||||
router.Use(middlewares.NewPrincipalMiddleware())
|
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())
|
router.Use(handlers.RecoveryHandler())
|
||||||
if config.Sentry.Dsn != "" {
|
if config.Sentry.Dsn != "" {
|
||||||
router.Use(middlewares.NewSentryMiddleware())
|
router.Use(middlewares.NewSentryMiddleware())
|
||||||
@ -204,6 +224,7 @@ func main() {
|
|||||||
healthApiHandler.RegisterRoutes(apiRouter)
|
healthApiHandler.RegisterRoutes(apiRouter)
|
||||||
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
||||||
metricsHandler.RegisterRoutes(apiRouter)
|
metricsHandler.RegisterRoutes(apiRouter)
|
||||||
|
diagnosticsHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
||||||
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
|
wakatimeV1StatsHandler.RegisterRoutes(apiRouter)
|
||||||
@ -223,12 +244,24 @@ func main() {
|
|||||||
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(fileServer),
|
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 HTTP
|
||||||
listen(router)
|
listen(router)
|
||||||
}
|
}
|
||||||
|
|
||||||
func listen(handler http.Handler) {
|
func listen(handler http.Handler) {
|
||||||
var s4, s6 *http.Server
|
var s4, s6, sSocket *http.Server
|
||||||
|
|
||||||
// IPv4
|
// IPv4
|
||||||
if config.Server.ListenIpV4 != "" {
|
if config.Server.ListenIpV4 != "" {
|
||||||
@ -236,8 +269,8 @@ func listen(handler http.Handler) {
|
|||||||
s4 = &http.Server{
|
s4 = &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
Addr: bindString4,
|
Addr: bindString4,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,8 +280,24 @@ func listen(handler http.Handler) {
|
|||||||
s6 = &http.Server{
|
s6 = &http.Server{
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
Addr: bindString6,
|
Addr: bindString6,
|
||||||
ReadTimeout: 10 * time.Second,
|
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
WriteTimeout: 10 * time.Second,
|
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UNIX domain socket
|
||||||
|
if config.Server.ListenSocket != "" {
|
||||||
|
// Remove if exists
|
||||||
|
if _, err := os.Stat(config.Server.ListenSocket); err == nil {
|
||||||
|
logbuch.Info("--> Removing unix socket %s", config.Server.ListenSocket)
|
||||||
|
if err := os.Remove(config.Server.ListenSocket); err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sSocket = &http.Server{
|
||||||
|
Handler: handler,
|
||||||
|
ReadTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
|
WriteTimeout: time.Duration(config.Server.TimeoutSec) * time.Second,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -269,6 +318,18 @@ func listen(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if sSocket != nil {
|
||||||
|
logbuch.Info("--> Listening for HTTPS on %s... ✅", config.Server.ListenSocket)
|
||||||
|
go func() {
|
||||||
|
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
if err := sSocket.ServeTLS(unixListener, config.Server.TlsCertPath, config.Server.TlsKeyPath); err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
if s4 != nil {
|
if s4 != nil {
|
||||||
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
logbuch.Info("--> Listening for HTTP on %s... ✅", s4.Addr)
|
||||||
@ -286,6 +347,18 @@ func listen(handler http.Handler) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
if sSocket != nil {
|
||||||
|
logbuch.Info("--> Listening for HTTP on %s... ✅", config.Server.ListenSocket)
|
||||||
|
go func() {
|
||||||
|
unixListener, err := net.Listen("unix", config.Server.ListenSocket)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
if err := sSocket.Serve(unixListener); err != nil {
|
||||||
|
logbuch.Fatal(err.Error())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
<-make(chan interface{}, 1)
|
<-make(chan interface{}, 1)
|
||||||
|
@ -5,17 +5,24 @@ import (
|
|||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/leandro-lugaresi/hub"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/middlewares"
|
"github.com/muety/wakapi/middlewares"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/patrickmn/go-cache"
|
||||||
"io"
|
"io"
|
||||||
"io/ioutil"
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const maxFailuresPerDay = 100
|
||||||
|
|
||||||
/* Middleware to conditionally relay heartbeats to Wakatime */
|
/* Middleware to conditionally relay heartbeats to Wakatime */
|
||||||
type WakatimeRelayMiddleware struct {
|
type WakatimeRelayMiddleware struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
|
failureCache *cache.Cache
|
||||||
|
eventBus *hub.Hub
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
||||||
@ -23,6 +30,8 @@ func NewWakatimeRelayMiddleware() *WakatimeRelayMiddleware {
|
|||||||
httpClient: &http.Client{
|
httpClient: &http.Client{
|
||||||
Timeout: 10 * time.Second,
|
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,
|
config.WakatimeApiUrl+config.WakatimeApiHeartbeatsBulkUrl,
|
||||||
bytes.NewReader(body),
|
bytes.NewReader(body),
|
||||||
headers,
|
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)
|
request, err := http.NewRequest(method, url, body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Warn("error constructing relayed request – %v", err)
|
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 {
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,7 +11,7 @@ func init() {
|
|||||||
f := migrationFunc{
|
f := migrationFunc{
|
||||||
name: name,
|
name: name,
|
||||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
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'")
|
logbuch.Info("dropped table 'gorp_migrations'")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
50
migrations/20210806_remove_persisted_project_labels.go
Normal file
50
migrations/20210806_remove_persisted_project_labels.go
Normal 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)
|
||||||
|
}
|
35
mocks/project_label_service.go
Normal file
35
mocks/project_label_service.go
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
package mocks
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/stretchr/testify/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectLabelServiceMock struct {
|
||||||
|
mock.Mock
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetById(u uint) (*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(u)
|
||||||
|
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetByUser(s string) ([]*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(s)
|
||||||
|
return args.Get(0).([]*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) GetByUserGrouped(s string) (map[string][]*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(s)
|
||||||
|
return args.Get(0).(map[string][]*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) Create(l *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||||
|
args := p.Called(l)
|
||||||
|
return args.Get(0).(*models.ProjectLabel), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ProjectLabelServiceMock) Delete(l *models.ProjectLabel) error {
|
||||||
|
args := p.Called(l)
|
||||||
|
return args.Error(0)
|
||||||
|
}
|
@ -39,8 +39,8 @@ func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
|
|||||||
return args.Get(0).([]*models.User), args.Error(1)
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *UserServiceMock) GetActive() ([]*models.User, error) {
|
func (m *UserServiceMock) GetActive(b bool) ([]*models.User, error) {
|
||||||
args := m.Called()
|
args := m.Called(b)
|
||||||
return args.Get(0).([]*models.User), args.Error(1)
|
return args.Get(0).([]*models.User), args.Error(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
13
models/diagnostics.go
Normal file
13
models/diagnostics.go
Normal 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"`
|
||||||
|
}
|
@ -6,6 +6,7 @@ type Filters struct {
|
|||||||
Language string
|
Language string
|
||||||
Editor string
|
Editor string
|
||||||
Machine string
|
Machine string
|
||||||
|
Label string
|
||||||
}
|
}
|
||||||
|
|
||||||
type FilterElement struct {
|
type FilterElement struct {
|
||||||
@ -25,6 +26,8 @@ func NewFiltersWith(entity uint8, key string) *Filters {
|
|||||||
return &Filters{Editor: key}
|
return &Filters{Editor: key}
|
||||||
case SummaryMachine:
|
case SummaryMachine:
|
||||||
return &Filters{Machine: key}
|
return &Filters{Machine: key}
|
||||||
|
case SummaryLabel:
|
||||||
|
return &Filters{Label: key}
|
||||||
}
|
}
|
||||||
return &Filters{}
|
return &Filters{}
|
||||||
}
|
}
|
||||||
@ -40,6 +43,8 @@ func (f *Filters) One() (bool, uint8, string) {
|
|||||||
return true, SummaryEditor, f.Editor
|
return true, SummaryEditor, f.Editor
|
||||||
} else if f.Machine != "" {
|
} else if f.Machine != "" {
|
||||||
return true, SummaryMachine, f.Machine
|
return true, SummaryMachine, f.Machine
|
||||||
|
} else if f.Label != "" {
|
||||||
|
return true, SummaryLabel, f.Label
|
||||||
}
|
}
|
||||||
return false, 0, ""
|
return false, 0, ""
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,7 @@ type Heartbeat struct {
|
|||||||
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
|
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
|
||||||
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
|
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
|
||||||
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
|
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
|
||||||
|
UserAgent string `json:"user_agent" hash:"ignore"`
|
||||||
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
|
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
|
||||||
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
||||||
Origin string `json:"-" hash:"ignore"`
|
Origin string `json:"-" hash:"ignore"`
|
||||||
|
13
models/project_label.go
Normal file
13
models/project_label.go
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
package models
|
||||||
|
|
||||||
|
type ProjectLabel struct {
|
||||||
|
ID uint `json:"id" gorm:"primary_key"`
|
||||||
|
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
UserID string `json:"-" gorm:"not null; index:idx_project_label_user"`
|
||||||
|
ProjectKey string `json:"project"`
|
||||||
|
Label string `json:"label" gorm:"type:varchar(64)"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ProjectLabel) IsValid() bool {
|
||||||
|
return l.ProjectKey != "" && l.Label != ""
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -12,9 +13,11 @@ const (
|
|||||||
SummaryEditor uint8 = 2
|
SummaryEditor uint8 = 2
|
||||||
SummaryOS uint8 = 3
|
SummaryOS uint8 = 3
|
||||||
SummaryMachine uint8 = 4
|
SummaryMachine uint8 = 4
|
||||||
|
SummaryLabel uint8 = 5
|
||||||
)
|
)
|
||||||
|
|
||||||
const UnknownSummaryKey = "unknown"
|
const UnknownSummaryKey = "unknown"
|
||||||
|
const DefaultProjectLabel = "default"
|
||||||
|
|
||||||
type Summary struct {
|
type Summary struct {
|
||||||
ID uint `json:"-" gorm:"primary_key"`
|
ID uint `json:"-" gorm:"primary_key"`
|
||||||
@ -27,6 +30,7 @@ type Summary struct {
|
|||||||
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Editors SummaryItems `json:"editors" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
OperatingSystems SummaryItems `json:"operating_systems" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
Machines SummaryItems `json:"machines" gorm:"constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||||
|
Labels SummaryItems `json:"labels" gorm:"-"` // labels are not persisted, but calculated at runtime, i.e. when summary is retrieved
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryItems []*SummaryItem
|
type SummaryItems []*SummaryItem
|
||||||
@ -68,6 +72,10 @@ type SummaryParams struct {
|
|||||||
type AliasResolver func(t uint8, k string) string
|
type AliasResolver func(t uint8, k string) string
|
||||||
|
|
||||||
func SummaryTypes() []uint8 {
|
func SummaryTypes() []uint8 {
|
||||||
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine, SummaryLabel}
|
||||||
|
}
|
||||||
|
|
||||||
|
func NativeSummaryTypes() []uint8 {
|
||||||
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
return []uint8{SummaryProject, SummaryLanguage, SummaryEditor, SummaryOS, SummaryMachine}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +85,7 @@ func (s *Summary) Sorted() *Summary {
|
|||||||
sort.Sort(sort.Reverse(s.OperatingSystems))
|
sort.Sort(sort.Reverse(s.OperatingSystems))
|
||||||
sort.Sort(sort.Reverse(s.Languages))
|
sort.Sort(sort.Reverse(s.Languages))
|
||||||
sort.Sort(sort.Reverse(s.Editors))
|
sort.Sort(sort.Reverse(s.Editors))
|
||||||
|
sort.Sort(sort.Reverse(s.Labels))
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +100,7 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
|||||||
SummaryEditor: &s.Editors,
|
SummaryEditor: &s.Editors,
|
||||||
SummaryOS: &s.OperatingSystems,
|
SummaryOS: &s.OperatingSystems,
|
||||||
SummaryMachine: &s.Machines,
|
SummaryMachine: &s.Machines,
|
||||||
|
SummaryLabel: &s.Labels,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,7 +119,7 @@ of time than the other ones.
|
|||||||
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
To avoid having to modify persisted data retrospectively, i.e. inserting a dummy SummaryItem for the new type,
|
||||||
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
such is generated dynamically here, considering the "machine" for all old heartbeats "unknown".
|
||||||
*/
|
*/
|
||||||
func (s *Summary) FillUnknown() {
|
func (s *Summary) FillMissing() {
|
||||||
types := s.Types()
|
types := s.Types()
|
||||||
typeItems := s.MappedItems()
|
typeItems := s.MappedItems()
|
||||||
missingTypes := make([]uint8, 0)
|
missingTypes := make([]uint8, 0)
|
||||||
@ -125,15 +135,46 @@ func (s *Summary) FillUnknown() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
timeSum := s.TotalTime()
|
|
||||||
|
|
||||||
// construct dummy item for all missing types
|
// construct dummy item for all missing types
|
||||||
|
presentType, err := s.findFirstPresentType()
|
||||||
|
if err != nil {
|
||||||
|
return // all types are either zero or missing entirely, nothing to fill
|
||||||
|
}
|
||||||
for _, t := range missingTypes {
|
for _, t := range missingTypes {
|
||||||
*typeItems[t] = append(*typeItems[t], &SummaryItem{
|
s.FillBy(presentType, t)
|
||||||
Type: t,
|
}
|
||||||
Key: UnknownSummaryKey,
|
}
|
||||||
Total: timeSum,
|
|
||||||
})
|
// inplace!
|
||||||
|
func (s *Summary) FillBy(fromType uint8, toType uint8) {
|
||||||
|
typeItems := s.MappedItems()
|
||||||
|
totalWanted := s.TotalTimeBy(fromType)
|
||||||
|
totalActual := s.TotalTimeBy(toType)
|
||||||
|
|
||||||
|
key := UnknownSummaryKey
|
||||||
|
if toType == SummaryLabel {
|
||||||
|
key = DefaultProjectLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
existingEntryIdx := -1
|
||||||
|
for i, item := range *typeItems[toType] {
|
||||||
|
if item.Key == key {
|
||||||
|
existingEntryIdx = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := (totalWanted - totalActual) / time.Second // workaround
|
||||||
|
if total > 0 {
|
||||||
|
if existingEntryIdx >= 0 {
|
||||||
|
(*typeItems[toType])[existingEntryIdx].Total = total
|
||||||
|
} else {
|
||||||
|
*typeItems[toType] = append(*typeItems[toType], &SummaryItem{
|
||||||
|
Type: toType,
|
||||||
|
Key: key,
|
||||||
|
Total: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -141,14 +182,12 @@ func (s *Summary) TotalTime() time.Duration {
|
|||||||
var timeSum time.Duration
|
var timeSum time.Duration
|
||||||
|
|
||||||
mappedItems := s.MappedItems()
|
mappedItems := s.MappedItems()
|
||||||
// calculate total duration from any of the present sets of items
|
t, err := s.findFirstPresentType()
|
||||||
for _, t := range s.Types() {
|
if err != nil {
|
||||||
if items := mappedItems[t]; len(*items) > 0 {
|
return 0
|
||||||
for _, item := range *items {
|
}
|
||||||
timeSum += item.Total
|
for _, item := range *mappedItems[t] {
|
||||||
}
|
timeSum += item.Total
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return timeSum * time.Second
|
return timeSum * time.Second
|
||||||
@ -231,10 +270,20 @@ func (s *Summary) WithResolvedAliases(resolve AliasResolver) *Summary {
|
|||||||
s.Languages = processAliases(s.Languages)
|
s.Languages = processAliases(s.Languages)
|
||||||
s.OperatingSystems = processAliases(s.OperatingSystems)
|
s.OperatingSystems = processAliases(s.OperatingSystems)
|
||||||
s.Machines = processAliases(s.Machines)
|
s.Machines = processAliases(s.Machines)
|
||||||
|
s.Labels = processAliases(s.Labels)
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Summary) findFirstPresentType() (uint8, error) {
|
||||||
|
for _, t := range s.Types() {
|
||||||
|
if s.TotalTimeBy(t) != 0 {
|
||||||
|
return t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 127, errors.New("no type present")
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SummaryItem) TotalFixed() time.Duration {
|
func (s *SummaryItem) TotalFixed() time.Duration {
|
||||||
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
// this is a workaround, since currently, the total time of a summary item is mistakenly represented in seconds
|
||||||
// TODO: fix some day, while migrating persisted summary items
|
// TODO: fix some day, while migrating persisted summary items
|
||||||
|
@ -6,7 +6,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestSummary_FillUnknown(t *testing.T) {
|
func TestSummary_FillMissing(t *testing.T) {
|
||||||
testDuration := 10 * time.Minute
|
testDuration := 10 * time.Minute
|
||||||
|
|
||||||
sut := &Summary{
|
sut := &Summary{
|
||||||
@ -20,7 +20,7 @@ func TestSummary_FillUnknown(t *testing.T) {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
sut.FillUnknown()
|
sut.FillMissing()
|
||||||
|
|
||||||
itemLists := [][]*SummaryItem{
|
itemLists := [][]*SummaryItem{
|
||||||
sut.Machines,
|
sut.Machines,
|
||||||
@ -31,8 +31,12 @@ func TestSummary_FillUnknown(t *testing.T) {
|
|||||||
for _, l := range itemLists {
|
for _, l := range itemLists {
|
||||||
assert.Len(t, l, 1)
|
assert.Len(t, l, 1)
|
||||||
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
assert.Equal(t, UnknownSummaryKey, l[0].Key)
|
||||||
assert.Equal(t, testDuration, l[0].Total)
|
assert.Equal(t, testDuration, l[0].TotalFixed())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
assert.Len(t, sut.Labels, 1)
|
||||||
|
assert.Equal(t, DefaultProjectLabel, sut.Labels[0].Key)
|
||||||
|
assert.Equal(t, testDuration, sut.Labels[0].TotalFixed())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSummary_TotalTimeBy(t *testing.T) {
|
func TestSummary_TotalTimeBy(t *testing.T) {
|
||||||
|
@ -23,6 +23,7 @@ type User struct {
|
|||||||
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
ShareProjects bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
ShareOSs bool `json:"-" gorm:"default:false; type:bool; column:share_oss"`
|
||||||
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
ShareMachines bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
|
ShareLabels bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
IsAdmin bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
HasData bool `json:"-" gorm:"default:false; type:bool"`
|
||||||
WakatimeApiKey string `json:"-"`
|
WakatimeApiKey string `json:"-"`
|
||||||
|
@ -6,6 +6,8 @@ type SettingsViewModel struct {
|
|||||||
User *models.User
|
User *models.User
|
||||||
LanguageMappings []*models.LanguageMapping
|
LanguageMappings []*models.LanguageMapping
|
||||||
Aliases []*SettingsVMCombinedAlias
|
Aliases []*SettingsVMCombinedAlias
|
||||||
|
Labels []*SettingsVMCombinedLabel
|
||||||
|
Projects []string
|
||||||
Success string
|
Success string
|
||||||
Error string
|
Error string
|
||||||
}
|
}
|
||||||
@ -16,6 +18,11 @@ type SettingsVMCombinedAlias struct {
|
|||||||
Values []string
|
Values []string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsVMCombinedLabel struct {
|
||||||
|
Key string
|
||||||
|
Values []string
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
func (s *SettingsViewModel) WithSuccess(m string) *SettingsViewModel {
|
||||||
s.Success = m
|
s.Success = m
|
||||||
return s
|
return s
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"_postman_id": "3dcc346d-a9a8-4699-8a52-459eb978b382",
|
"_postman_id": "46168002-34d8-48a5-95fa-4a8600450cbd",
|
||||||
"name": "Wakapi",
|
"name": "Wakapi",
|
||||||
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
@ -49,6 +49,50 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"response": []
|
"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": []
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
18
repositories/diagnostics.go
Normal file
18
repositories/diagnostics.go
Normal 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
|
||||||
|
}
|
60
repositories/project_label.go
Normal file
60
repositories/project_label.go
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
package repositories
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectLabelRepository struct {
|
||||||
|
config *config.Config
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjectLabelRepository(db *gorm.DB) *ProjectLabelRepository {
|
||||||
|
return &ProjectLabelRepository{config: config.Get(), db: db}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLabelRepository) GetAll() ([]*models.ProjectLabel, error) {
|
||||||
|
var labels []*models.ProjectLabel
|
||||||
|
if err := r.db.Find(&labels).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLabelRepository) GetById(id uint) (*models.ProjectLabel, error) {
|
||||||
|
label := &models.ProjectLabel{}
|
||||||
|
if err := r.db.Where(&models.ProjectLabel{ID: id}).First(label).Error; err != nil {
|
||||||
|
return label, err
|
||||||
|
}
|
||||||
|
return label, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLabelRepository) GetByUser(userId string) ([]*models.ProjectLabel, error) {
|
||||||
|
var labels []*models.ProjectLabel
|
||||||
|
if err := r.db.
|
||||||
|
Where(&models.ProjectLabel{UserID: userId}).
|
||||||
|
Find(&labels).Error; err != nil {
|
||||||
|
return labels, err
|
||||||
|
}
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLabelRepository) Insert(label *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||||
|
if !label.IsValid() {
|
||||||
|
return nil, errors.New("invalid label")
|
||||||
|
}
|
||||||
|
result := r.db.Create(label)
|
||||||
|
if err := result.Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return label, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ProjectLabelRepository) Delete(id uint) error {
|
||||||
|
return r.db.
|
||||||
|
Where("id = ?", id).
|
||||||
|
Delete(models.ProjectLabel{}).Error
|
||||||
|
}
|
@ -31,6 +31,10 @@ type IHeartbeatRepository interface {
|
|||||||
DeleteBefore(time.Time) error
|
DeleteBefore(time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IDiagnosticsRepository interface {
|
||||||
|
Insert(diagnostics *models.Diagnostics) (*models.Diagnostics, error)
|
||||||
|
}
|
||||||
|
|
||||||
type IKeyValueRepository interface {
|
type IKeyValueRepository interface {
|
||||||
GetAll() ([]*models.KeyStringValue, error)
|
GetAll() ([]*models.KeyStringValue, error)
|
||||||
GetString(string) (*models.KeyStringValue, error)
|
GetString(string) (*models.KeyStringValue, error)
|
||||||
@ -46,6 +50,14 @@ type ILanguageMappingRepository interface {
|
|||||||
Delete(uint) error
|
Delete(uint) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IProjectLabelRepository interface {
|
||||||
|
GetAll() ([]*models.ProjectLabel, error)
|
||||||
|
GetById(uint) (*models.ProjectLabel, error)
|
||||||
|
GetByUser(string) ([]*models.ProjectLabel, error)
|
||||||
|
Insert(*models.ProjectLabel) (*models.ProjectLabel, error)
|
||||||
|
Delete(uint) error
|
||||||
|
}
|
||||||
|
|
||||||
type ISummaryRepository interface {
|
type ISummaryRepository interface {
|
||||||
Insert(*models.Summary) error
|
Insert(*models.Summary) error
|
||||||
GetAll() ([]*models.Summary, error)
|
GetAll() ([]*models.Summary, error)
|
||||||
|
@ -147,6 +147,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
|||||||
"share_oss": user.ShareOSs,
|
"share_oss": user.ShareOSs,
|
||||||
"share_projects": user.ShareProjects,
|
"share_projects": user.ShareProjects,
|
||||||
"share_machines": user.ShareMachines,
|
"share_machines": user.ShareMachines,
|
||||||
|
"share_labels": user.ShareLabels,
|
||||||
"wakatime_api_key": user.WakatimeApiKey,
|
"wakatime_api_key": user.WakatimeApiKey,
|
||||||
"has_data": user.HasData,
|
"has_data": user.HasData,
|
||||||
"reset_token": user.ResetToken,
|
"reset_token": user.ResetToken,
|
||||||
@ -159,10 +160,6 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if result.RowsAffected != 1 {
|
|
||||||
return nil, errors.New("nothing updated")
|
|
||||||
}
|
|
||||||
|
|
||||||
return user, nil
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
71
routes/api/diagnostics.go
Normal file
71
routes/api/diagnostics.go
Normal 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{}{})
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"github.com/gorilla/mux"
|
"github.com/gorilla/mux"
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
@ -9,6 +10,7 @@ import (
|
|||||||
routeutils "github.com/muety/wakapi/routes/utils"
|
routeutils "github.com/muety/wakapi/routes/utils"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
|
"io/ioutil"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
@ -40,16 +42,22 @@ func (h *HeartbeatApiHandler) RegisterRoutes(router *mux.Router) {
|
|||||||
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
middlewares.NewAuthenticateMiddleware(h.userSrvc).Handler,
|
||||||
customMiddleware.NewWakatimeRelayMiddleware().Handler,
|
customMiddleware.NewWakatimeRelayMiddleware().Handler,
|
||||||
)
|
)
|
||||||
r.PathPrefix("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
|
// see https://github.com/muety/wakapi/issues/203
|
||||||
r.PathPrefix("/v1/users/{user}/heartbeats").Methods(http.MethodPost).HandlerFunc(h.Post)
|
r.Path("/heartbeat").Methods(http.MethodPost).HandlerFunc(h.Post)
|
||||||
r.PathPrefix("/compat/wakatime/v1/users/{user}/heartbeats").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
|
// @Summary Push a new heartbeat
|
||||||
// @ID post-heartbeat
|
// @ID post-heartbeat
|
||||||
// @Tags heartbeat
|
// @Tags heartbeat
|
||||||
// @Accept json
|
// @Accept json
|
||||||
// @Param heartbeat body models.Heartbeat true "A heartbeat"
|
// @Param heartbeat body models.Heartbeat true "A single heartbeat"
|
||||||
// @Security ApiKeyAuth
|
// @Security ApiKeyAuth
|
||||||
// @Success 201
|
// @Success 201
|
||||||
// @Router /heartbeat [post]
|
// @Router /heartbeat [post]
|
||||||
@ -60,22 +68,28 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
var heartbeats []*models.Heartbeat
|
var heartbeats []*models.Heartbeat
|
||||||
opSys, editor, _ := utils.ParseUserAgent(r.Header.Get("User-Agent"))
|
heartbeats, err = h.tryParseBulk(r)
|
||||||
machineName := r.Header.Get("X-Machine-Name")
|
if err != nil {
|
||||||
|
heartbeats, err = h.tryParseSingle(r)
|
||||||
dec := json.NewDecoder(r.Body)
|
if err != nil {
|
||||||
if err := dec.Decode(&heartbeats); err != nil {
|
conf.Log().Request(r).Error(err.Error())
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
w.Write([]byte(err.Error()))
|
w.Write([]byte(err.Error()))
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
userAgent := r.Header.Get("User-Agent")
|
||||||
|
opSys, editor, _ := utils.ParseUserAgent(userAgent)
|
||||||
|
machineName := r.Header.Get("X-Machine-Name")
|
||||||
|
|
||||||
for _, hb := range heartbeats {
|
for _, hb := range heartbeats {
|
||||||
hb.OperatingSystem = opSys
|
hb.OperatingSystem = opSys
|
||||||
hb.Editor = editor
|
hb.Editor = editor
|
||||||
hb.Machine = machineName
|
hb.Machine = machineName
|
||||||
hb.User = user
|
hb.User = user
|
||||||
hb.UserID = user.ID
|
hb.UserID = user.ID
|
||||||
|
hb.UserAgent = userAgent
|
||||||
|
|
||||||
if !hb.Valid() {
|
if !hb.Valid() {
|
||||||
w.WriteHeader(http.StatusBadRequest)
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
@ -103,12 +117,47 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defer func() {}()
|
||||||
|
|
||||||
utils.RespondJSON(w, r, http.StatusCreated, constructSuccessResponse(len(heartbeats)))
|
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)
|
// 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
|
// 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 {
|
func constructSuccessResponse(n int) *heartbeatResponseVm {
|
||||||
responses := make([][]interface{}, n)
|
responses := make([][]interface{}, n)
|
||||||
|
|
||||||
@ -123,3 +172,75 @@ func constructSuccessResponse(n int) *heartbeatResponseVm {
|
|||||||
Responses: responses,
|
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() {}
|
||||||
|
@ -27,6 +27,7 @@ const (
|
|||||||
DescLanguages = "Total seconds for each language."
|
DescLanguages = "Total seconds for each language."
|
||||||
DescOperatingSystems = "Total seconds for each operating system."
|
DescOperatingSystems = "Total seconds for each operating system."
|
||||||
DescMachines = "Total seconds for each machine."
|
DescMachines = "Total seconds for each machine."
|
||||||
|
DescLabels = "Total seconds for each project label."
|
||||||
|
|
||||||
DescAdminTotalTime = "Total seconds (all users, all time)."
|
DescAdminTotalTime = "Total seconds (all users, all time)."
|
||||||
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
|
DescAdminTotalHeartbeats = "Total number of tracked heartbeats (all users, all time)"
|
||||||
@ -198,6 +199,15 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for _, m := range summaryToday.Labels {
|
||||||
|
metrics = append(metrics, &mm.CounterMetric{
|
||||||
|
Name: MetricsPrefix + "_label_seconds_total",
|
||||||
|
Desc: DescLabels,
|
||||||
|
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
|
||||||
|
Labels: []mm.Label{{Key: "name", Value: m.Key}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return &metrics, nil
|
return &metrics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -218,7 +228,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
|||||||
totalUsers, _ := h.userSrvc.Count()
|
totalUsers, _ := h.userSrvc.Count()
|
||||||
totalHeartbeats, _ := h.heartbeatSrvc.Count()
|
totalHeartbeats, _ := h.heartbeatSrvc.Count()
|
||||||
|
|
||||||
activeUsers, err := h.userSrvc.GetActive()
|
activeUsers, err := h.userSrvc.GetActive(false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logbuch.Error("failed to retrieve active users for metric – %v", err)
|
logbuch.Error("failed to retrieve active users for metric – %v", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
|
@ -16,7 +16,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
intervalPattern = `interval:([a-z0-9_]+)`
|
intervalPattern = `interval:([a-z0-9_]+)`
|
||||||
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-]+)`
|
entityFilterPattern = `(project|os|editor|language|machine):([_a-zA-Z0-9-\s]+)`
|
||||||
)
|
)
|
||||||
|
|
||||||
type BadgeHandler struct {
|
type BadgeHandler struct {
|
||||||
@ -75,7 +75,7 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays)))
|
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
|
||||||
// negative value means no limit
|
// negative value means no limit
|
||||||
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
@ -83,22 +83,38 @@ func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var permitEntity bool
|
||||||
var filters *models.Filters
|
var filters *models.Filters
|
||||||
switch filterEntity {
|
switch filterEntity {
|
||||||
case "project":
|
case "project":
|
||||||
|
permitEntity = user.ShareProjects
|
||||||
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
||||||
case "os":
|
case "os":
|
||||||
|
permitEntity = user.ShareOSs
|
||||||
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
||||||
case "editor":
|
case "editor":
|
||||||
|
permitEntity = user.ShareEditors
|
||||||
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
||||||
case "language":
|
case "language":
|
||||||
|
permitEntity = user.ShareLanguages
|
||||||
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
||||||
case "machine":
|
case "machine":
|
||||||
|
permitEntity = user.ShareMachines
|
||||||
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
||||||
|
case "label":
|
||||||
|
permitEntity = user.ShareLabels
|
||||||
|
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
|
||||||
default:
|
default:
|
||||||
|
permitEntity = true
|
||||||
filters = &models.Filters{}
|
filters = &models.Filters{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !permitEntity {
|
||||||
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
w.Write([]byte("user did not opt in to share entity-specific data"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
||||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||||
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||||
|
@ -79,7 +79,7 @@ func (h *StatsHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
minStart := utils.StartOfDay(rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays)))
|
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
|
||||||
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
|
if (authorizedUser == nil || requestedUser.ID != authorizedUser.ID) &&
|
||||||
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||||
w.WriteHeader(http.StatusForbidden)
|
w.WriteHeader(http.StatusForbidden)
|
||||||
|
@ -121,17 +121,16 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
|||||||
end = utils.EndOfDay(end).Add(-1 * time.Second)
|
end = utils.EndOfDay(end).Add(-1 * time.Second)
|
||||||
|
|
||||||
overallParams := &models.SummaryParams{
|
overallParams := &models.SummaryParams{
|
||||||
From: start,
|
From: start,
|
||||||
To: end,
|
To: end,
|
||||||
User: user,
|
User: user,
|
||||||
Recompute: false,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
intervals := utils.SplitRangeByDays(overallParams.From, overallParams.To)
|
||||||
summaries := make([]*models.Summary, len(intervals))
|
summaries := make([]*models.Summary, len(intervals))
|
||||||
|
|
||||||
for i, interval := range 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 {
|
if err != nil {
|
||||||
return nil, err, http.StatusInternalServerError
|
return nil, err, http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
@ -2,27 +2,24 @@ package routes
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/muety/wakapi/views"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
|
||||||
"io/ioutil"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"path"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/utils"
|
"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)
|
type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
|
||||||
|
|
||||||
var templates map[string]*template.Template
|
var templates map[string]*template.Template
|
||||||
|
|
||||||
|
func Init() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
func DefaultTemplateFuncs() template.FuncMap {
|
func DefaultTemplateFuncs() template.FuncMap {
|
||||||
return template.FuncMap{
|
return template.FuncMap{
|
||||||
"json": utils.Json,
|
"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 {
|
func typeName(t uint8) string {
|
||||||
if t == models.SummaryProject {
|
if t == models.SummaryProject {
|
||||||
return "project"
|
return "project"
|
||||||
@ -112,9 +71,22 @@ func typeName(t uint8) string {
|
|||||||
if t == models.SummaryMachine {
|
if t == models.SummaryMachine {
|
||||||
return "machine"
|
return "machine"
|
||||||
}
|
}
|
||||||
|
if t == models.SummaryLabel {
|
||||||
|
return "label"
|
||||||
|
}
|
||||||
return "unknown"
|
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 {
|
func defaultErrorRedirectTarget() string {
|
||||||
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
|
return fmt.Sprintf("%s/?error=unauthorized", config.Get().Server.BasePath)
|
||||||
}
|
}
|
||||||
|
@ -14,10 +14,14 @@ import (
|
|||||||
"github.com/muety/wakapi/services/imports"
|
"github.com/muety/wakapi/services/imports"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const criticalError = "a critical error has occurred, sorry"
|
||||||
|
|
||||||
type SettingsHandler struct {
|
type SettingsHandler struct {
|
||||||
config *conf.Config
|
config *conf.Config
|
||||||
userSrvc services.IUserService
|
userSrvc services.IUserService
|
||||||
@ -26,6 +30,7 @@ type SettingsHandler struct {
|
|||||||
aliasSrvc services.IAliasService
|
aliasSrvc services.IAliasService
|
||||||
aggregationSrvc services.IAggregationService
|
aggregationSrvc services.IAggregationService
|
||||||
languageMappingSrvc services.ILanguageMappingService
|
languageMappingSrvc services.ILanguageMappingService
|
||||||
|
projectLabelSrvc services.IProjectLabelService
|
||||||
keyValueSrvc services.IKeyValueService
|
keyValueSrvc services.IKeyValueService
|
||||||
mailSrvc services.IMailService
|
mailSrvc services.IMailService
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
@ -40,6 +45,7 @@ func NewSettingsHandler(
|
|||||||
aliasService services.IAliasService,
|
aliasService services.IAliasService,
|
||||||
aggregationService services.IAggregationService,
|
aggregationService services.IAggregationService,
|
||||||
languageMappingService services.ILanguageMappingService,
|
languageMappingService services.ILanguageMappingService,
|
||||||
|
projectLabelService services.IProjectLabelService,
|
||||||
keyValueService services.IKeyValueService,
|
keyValueService services.IKeyValueService,
|
||||||
mailService services.IMailService,
|
mailService services.IMailService,
|
||||||
) *SettingsHandler {
|
) *SettingsHandler {
|
||||||
@ -49,6 +55,7 @@ func NewSettingsHandler(
|
|||||||
aliasSrvc: aliasService,
|
aliasSrvc: aliasService,
|
||||||
aggregationSrvc: aggregationService,
|
aggregationSrvc: aggregationService,
|
||||||
languageMappingSrvc: languageMappingService,
|
languageMappingSrvc: languageMappingService,
|
||||||
|
projectLabelSrvc: projectLabelService,
|
||||||
userSrvc: userService,
|
userSrvc: userService,
|
||||||
heartbeatSrvc: heartbeatService,
|
heartbeatSrvc: heartbeatService,
|
||||||
keyValueSrvc: keyValueService,
|
keyValueSrvc: keyValueService,
|
||||||
@ -70,7 +77,6 @@ func (h *SettingsHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
|||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
|
|
||||||
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
templates[conf.SettingsTemplate].Execute(w, h.buildViewModel(r))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -128,6 +134,10 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
|||||||
return h.actionDeleteAlias
|
return h.actionDeleteAlias
|
||||||
case "add_alias":
|
case "add_alias":
|
||||||
return h.actionAddAlias
|
return h.actionAddAlias
|
||||||
|
case "add_label":
|
||||||
|
return h.actionAddLabel
|
||||||
|
case "delete_label":
|
||||||
|
return h.actionDeleteLabel
|
||||||
case "delete_mapping":
|
case "delete_mapping":
|
||||||
return h.actionDeleteLanguageMapping
|
return h.actionDeleteLanguageMapping
|
||||||
case "add_mapping":
|
case "add_mapping":
|
||||||
@ -137,7 +147,7 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
|||||||
case "toggle_wakatime":
|
case "toggle_wakatime":
|
||||||
return h.actionSetWakatimeApiKey
|
return h.actionSetWakatimeApiKey
|
||||||
case "import_wakatime":
|
case "import_wakatime":
|
||||||
return h.actionImportWaktime
|
return h.actionImportWakatime
|
||||||
case "regenerate_summaries":
|
case "regenerate_summaries":
|
||||||
return h.actionRegenerateSummaries
|
return h.actionRegenerateSummaries
|
||||||
case "delete_account":
|
case "delete_account":
|
||||||
@ -252,6 +262,7 @@ func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Req
|
|||||||
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
|
user.ShareEditors, err = strconv.ParseBool(r.PostFormValue("share_editors"))
|
||||||
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
|
user.ShareOSs, err = strconv.ParseBool(r.PostFormValue("share_oss"))
|
||||||
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
|
user.ShareMachines, err = strconv.ParseBool(r.PostFormValue("share_machines"))
|
||||||
|
user.ShareLabels, err = strconv.ParseBool(r.PostFormValue("share_labels"))
|
||||||
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
|
user.ShareDataMaxDays, err = strconv.Atoi(r.PostFormValue("max_days"))
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -313,6 +324,59 @@ func (h *SettingsHandler) actionAddAlias(w http.ResponseWriter, r *http.Request)
|
|||||||
return http.StatusOK, "alias added successfully", ""
|
return http.StatusOK, "alias added successfully", ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) actionAddLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
user := middlewares.GetPrincipal(r)
|
||||||
|
|
||||||
|
label := &models.ProjectLabel{
|
||||||
|
UserID: user.ID,
|
||||||
|
ProjectKey: r.PostFormValue("key"),
|
||||||
|
Label: r.PostFormValue("value"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if !label.IsValid() {
|
||||||
|
return http.StatusBadRequest, "", "invalid input"
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.projectLabelSrvc.Create(label); err != nil {
|
||||||
|
// TODO: distinguish between bad request, conflict and server error
|
||||||
|
return http.StatusBadRequest, "", "invalid input"
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusOK, "label added successfully", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *SettingsHandler) actionDeleteLabel(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
|
if h.config.IsDev() {
|
||||||
|
loadTemplates()
|
||||||
|
}
|
||||||
|
|
||||||
|
user := middlewares.GetPrincipal(r)
|
||||||
|
labelKey := r.PostFormValue("key")
|
||||||
|
labelValue := r.PostFormValue("value")
|
||||||
|
|
||||||
|
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return http.StatusInternalServerError, "", "could not delete label"
|
||||||
|
}
|
||||||
|
|
||||||
|
if projectLabels, ok := labelMap[labelKey]; ok {
|
||||||
|
for _, l := range projectLabels {
|
||||||
|
if l.Label == labelValue {
|
||||||
|
if err := h.projectLabelSrvc.Delete(l); err != nil {
|
||||||
|
return http.StatusInternalServerError, "", "could not delete label"
|
||||||
|
}
|
||||||
|
return http.StatusOK, "label deleted successfully", ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return http.StatusNotFound, "", "label not found"
|
||||||
|
} else {
|
||||||
|
return http.StatusNotFound, "", "project not found"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
func (h *SettingsHandler) actionDeleteLanguageMapping(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||||
if h.config.IsDev() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
@ -383,7 +447,7 @@ func (h *SettingsHandler) actionSetWakatimeApiKey(w http.ResponseWriter, r *http
|
|||||||
return http.StatusOK, "Wakatime API Key updated successfully", ""
|
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() {
|
if h.config.IsDev() {
|
||||||
loadTemplates()
|
loadTemplates()
|
||||||
}
|
}
|
||||||
@ -553,8 +617,16 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
|||||||
|
|
||||||
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewModel {
|
||||||
user := middlewares.GetPrincipal(r)
|
user := middlewares.GetPrincipal(r)
|
||||||
|
|
||||||
|
// mappings
|
||||||
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
mappings, _ := h.languageMappingSrvc.GetByUser(user.ID)
|
||||||
aliases, _ := h.aliasSrvc.GetByUser(user.ID)
|
|
||||||
|
// aliases
|
||||||
|
aliases, err := h.aliasSrvc.GetByUser(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
conf.Log().Request(r).Error("error while building alias map - %v", err)
|
||||||
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
|
}
|
||||||
aliasMap := make(map[string][]*models.Alias)
|
aliasMap := make(map[string][]*models.Alias)
|
||||||
for _, a := range aliases {
|
for _, a := range aliases {
|
||||||
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
k := fmt.Sprintf("%s_%d", a.Key, a.Type)
|
||||||
@ -578,10 +650,42 @@ func (h *SettingsHandler) buildViewModel(r *http.Request) *view.SettingsViewMode
|
|||||||
combinedAliases = append(combinedAliases, ca)
|
combinedAliases = append(combinedAliases, ca)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// labels
|
||||||
|
labelMap, err := h.projectLabelSrvc.GetByUserGrouped(user.ID)
|
||||||
|
if err != nil {
|
||||||
|
conf.Log().Request(r).Error("error while building settings project label map - %v", err)
|
||||||
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedLabels := make([]*view.SettingsVMCombinedLabel, 0)
|
||||||
|
for _, l := range labelMap {
|
||||||
|
cl := &view.SettingsVMCombinedLabel{
|
||||||
|
Key: l[0].ProjectKey,
|
||||||
|
Values: make([]string, len(l)),
|
||||||
|
}
|
||||||
|
for i, l1 := range l {
|
||||||
|
cl.Values[i] = l1.Label
|
||||||
|
}
|
||||||
|
combinedLabels = append(combinedLabels, cl)
|
||||||
|
}
|
||||||
|
sort.Slice(combinedLabels, func(i, j int) bool {
|
||||||
|
return strings.Compare(combinedLabels[i].Key, combinedLabels[j].Key) < 0
|
||||||
|
})
|
||||||
|
|
||||||
|
// projects
|
||||||
|
projects, err := h.heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||||
|
if err != nil {
|
||||||
|
conf.Log().Request(r).Error("error while fetching projects - %v", err)
|
||||||
|
return &view.SettingsViewModel{Error: criticalError}
|
||||||
|
}
|
||||||
|
sort.Strings(projects)
|
||||||
|
|
||||||
return &view.SettingsViewModel{
|
return &view.SettingsViewModel{
|
||||||
User: user,
|
User: user,
|
||||||
LanguageMappings: mappings,
|
LanguageMappings: mappings,
|
||||||
Aliases: combinedAliases,
|
Aliases: combinedAliases,
|
||||||
|
Labels: combinedLabels,
|
||||||
|
Projects: projects,
|
||||||
Success: r.URL.Query().Get("success"),
|
Success: r.URL.Query().Get("success"),
|
||||||
Error: r.URL.Query().Get("error"),
|
Error: r.URL.Query().Get("error"),
|
||||||
}
|
}
|
||||||
|
23
services/diagnostics.go
Normal file
23
services/diagnostics.go
Normal 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)
|
||||||
|
}
|
@ -2,10 +2,12 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"github.com/leandro-lugaresi/hub"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
@ -14,17 +16,35 @@ import (
|
|||||||
type HeartbeatService struct {
|
type HeartbeatService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
|
cache2 *cache.Cache
|
||||||
|
eventBus *hub.Hub
|
||||||
repository repositories.IHeartbeatRepository
|
repository repositories.IHeartbeatRepository
|
||||||
languageMappingSrvc ILanguageMappingService
|
languageMappingSrvc ILanguageMappingService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
func NewHeartbeatService(heartbeatRepo repositories.IHeartbeatRepository, languageMappingService ILanguageMappingService) *HeartbeatService {
|
||||||
return &HeartbeatService{
|
srv := &HeartbeatService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||||
|
cache2: cache.New(cache.NoExpiration, cache.NoExpiration),
|
||||||
|
eventBus: config.EventBus(),
|
||||||
repository: heartbeatRepo,
|
repository: heartbeatRepo,
|
||||||
languageMappingSrvc: languageMappingService,
|
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 {
|
func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
|
||||||
@ -45,19 +65,64 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
|||||||
srv.updateEntityUserCacheByHeartbeat(hb)
|
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) {
|
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) {
|
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) {
|
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) {
|
func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||||
@ -82,7 +147,7 @@ func (srv *HeartbeatService) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
|||||||
|
|
||||||
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||||
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
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
|
return utils.SetToStrings(results.(map[string]bool)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,8 +155,16 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
srv.cache.Set(cacheKey, utils.StringsToSet(results), cache.DefaultExpiration)
|
|
||||||
return results, nil
|
filtered := make([]string, 0, len(results))
|
||||||
|
for _, r := range results {
|
||||||
|
if strings.TrimSpace(r) != "" {
|
||||||
|
filtered = append(filtered, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.cache2.Set(cacheKey, utils.StringsToSet(filtered), cache.DefaultExpiration)
|
||||||
|
return filtered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||||
@ -117,11 +190,11 @@ func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *model
|
|||||||
|
|
||||||
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
|
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
|
||||||
cacheKey := srv.getEntityUserCacheKey(entityType, 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 {
|
if _, ok := entities.(map[string]bool)[entityKey]; !ok {
|
||||||
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
|
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
|
||||||
// -> invalidate cache
|
// -> invalidate cache
|
||||||
srv.cache.Delete(cacheKey)
|
srv.cache2.Delete(cacheKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -133,3 +206,24 @@ func (srv *HeartbeatService) updateEntityUserCacheByHeartbeat(hb *models.Heartbe
|
|||||||
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
|
srv.updateEntityUserCache(models.SummaryOS, hb.OperatingSystem, hb.User)
|
||||||
srv.updateEntityUserCache(models.SummaryMachine, hb.Machine, 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
|
||||||
|
}
|
||||||
|
@ -106,6 +106,7 @@ func (w *WakatimeHeartbeatImporter) ImportAll(user *models.User) <-chan *models.
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
// https://wakatime.com/api/v1/users/current/heartbeats?date=2021-02-05
|
||||||
|
// https://pastr.de/p/b5p4od5s8w0pfntmwoi117jy
|
||||||
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.HeartbeatEntry, error) {
|
func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.HeartbeatEntry, error) {
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
@ -134,6 +135,7 @@ func (w *WakatimeHeartbeatImporter) fetchHeartbeats(day string) ([]*wakatime.Hea
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://wakatime.com/api/v1/users/current/all_time_since_today
|
// https://wakatime.com/api/v1/users/current/all_time_since_today
|
||||||
|
// https://pastr.de/p/w8xb4biv575pu32pox7jj2gr
|
||||||
func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
|
func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
@ -168,6 +170,7 @@ func (w *WakatimeHeartbeatImporter) fetchRange() (time.Time, time.Time, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://wakatime.com/api/v1/users/current/user_agents
|
// https://wakatime.com/api/v1/users/current/user_agents
|
||||||
|
// https://pastr.de/p/05k5do8q108k94lic4lfl3pc
|
||||||
func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.UserAgentEntry, error) {
|
func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.UserAgentEntry, error) {
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
@ -195,6 +198,7 @@ func (w *WakatimeHeartbeatImporter) fetchUserAgents() (map[string]*wakatime.User
|
|||||||
}
|
}
|
||||||
|
|
||||||
// https://wakatime.com/api/v1/users/current/machine_names
|
// https://wakatime.com/api/v1/users/current/machine_names
|
||||||
|
// https://pastr.de/p/v58cv0xrupp3zvyyv8o6973j
|
||||||
func (w *WakatimeHeartbeatImporter) fetchMachineNames() (map[string]*wakatime.MachineEntry, error) {
|
func (w *WakatimeHeartbeatImporter) fetchMachineNames() (map[string]*wakatime.MachineEntry, error) {
|
||||||
httpClient := &http.Client{Timeout: 10 * time.Second}
|
httpClient := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
|
||||||
@ -261,6 +265,7 @@ func mapHeartbeat(
|
|||||||
Editor: ua.Editor,
|
Editor: ua.Editor,
|
||||||
OperatingSystem: ua.Os,
|
OperatingSystem: ua.Os,
|
||||||
Machine: ma.Value,
|
Machine: ma.Value,
|
||||||
|
UserAgent: ua.Value,
|
||||||
Time: entry.Time,
|
Time: entry.Time,
|
||||||
Origin: OriginWakatime,
|
Origin: OriginWakatime,
|
||||||
OriginId: entry.Id,
|
OriginId: entry.Id,
|
||||||
|
@ -7,21 +7,21 @@ import (
|
|||||||
"github.com/muety/wakapi/routes"
|
"github.com/muety/wakapi/routes"
|
||||||
"github.com/muety/wakapi/services"
|
"github.com/muety/wakapi/services"
|
||||||
"github.com/muety/wakapi/utils"
|
"github.com/muety/wakapi/utils"
|
||||||
"html/template"
|
"github.com/muety/wakapi/views/mail"
|
||||||
"io/ioutil"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
conf "github.com/muety/wakapi/config"
|
conf "github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/views"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
tplNamePasswordReset = "reset_password"
|
tplNamePasswordReset = "reset_password"
|
||||||
tplNameImportNotification = "import_finished"
|
tplNameImportNotification = "import_finished"
|
||||||
tplNameReport = "report"
|
tplNameWakatimeFailureNotification = "wakatime_connection_failure"
|
||||||
subjectPasswordReset = "Wakapi - Password Reset"
|
tplNameReport = "report"
|
||||||
subjectImportNotification = "Wakapi - Data Import Finished"
|
subjectPasswordReset = "Wakapi - Password Reset"
|
||||||
subjectReport = "Wakapi - Report from %s"
|
subjectImportNotification = "Wakapi - Data Import Finished"
|
||||||
|
subjectWakatimeFailureNotification = "Wakapi - WakaTime Connection Failure"
|
||||||
|
subjectReport = "Wakapi - Report from %s"
|
||||||
)
|
)
|
||||||
|
|
||||||
type SendingService interface {
|
type SendingService interface {
|
||||||
@ -31,6 +31,7 @@ type SendingService interface {
|
|||||||
type MailService struct {
|
type MailService struct {
|
||||||
config *conf.Config
|
config *conf.Config
|
||||||
sendingService SendingService
|
sendingService SendingService
|
||||||
|
templates utils.TemplateMap
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewMailService() services.IMailService {
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -64,8 +72,25 @@ func (m *MailService) SendPasswordReset(recipient *models.User, resetLink string
|
|||||||
return m.sendingService.Send(mail)
|
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 {
|
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,
|
PublicUrl: m.config.Server.PublicUrl,
|
||||||
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
|
Duration: fmt.Sprintf("%.0f seconds", duration.Seconds()),
|
||||||
NumHeartbeats: numHeartbeats,
|
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 {
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -96,56 +121,38 @@ func (m *MailService) SendReport(recipient *models.User, report *models.Report)
|
|||||||
return m.sendingService.Send(mail)
|
return m.sendingService.Send(mail)
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
|
func (m *MailService) getPasswordResetTemplate(data PasswordResetTplData) (*bytes.Buffer, error) {
|
||||||
tpl, err := loadTemplate(tplNamePasswordReset)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var rendered bytes.Buffer
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &rendered, nil
|
return &rendered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffer, error) {
|
func (m *MailService) getWakatimeFailureNotificationTemplate(data WakatimeFailureNotificationNotificationTplData) (*bytes.Buffer, error) {
|
||||||
tpl, err := loadTemplate(tplNameImportNotification)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var rendered bytes.Buffer
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &rendered, nil
|
return &rendered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func getReportTemplate(data ReportTplData) (*bytes.Buffer, error) {
|
func (m *MailService) getImportNotificationTemplate(data ImportNotificationTplData) (*bytes.Buffer, error) {
|
||||||
tpl, err := loadTemplate(tplNameReport)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
var rendered bytes.Buffer
|
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 nil, err
|
||||||
}
|
}
|
||||||
return &rendered, nil
|
return &rendered, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadTemplate(tplName string) (*template.Template, error) {
|
func (m *MailService) getReportTemplate(data ReportTplData) (*bytes.Buffer, error) {
|
||||||
tplFile, err := views.TemplateFiles.Open(fmt.Sprintf("mail/%s.tpl.html", tplName))
|
var rendered bytes.Buffer
|
||||||
if err != nil {
|
if err := m.templates[m.fmtName(tplNameReport)].Execute(&rendered, data); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer tplFile.Close()
|
return &rendered, nil
|
||||||
|
}
|
||||||
tplData, err := ioutil.ReadAll(tplFile)
|
|
||||||
if err != nil {
|
func (m *MailService) fmtName(name string) string {
|
||||||
return nil, err
|
return fmt.Sprintf("%s.tpl.html", name)
|
||||||
}
|
|
||||||
|
|
||||||
return template.
|
|
||||||
New(tplName).
|
|
||||||
Funcs(routes.DefaultTemplateFuncs()).
|
|
||||||
Parse(string(tplData))
|
|
||||||
}
|
}
|
||||||
|
@ -12,6 +12,11 @@ type ImportNotificationTplData struct {
|
|||||||
NumHeartbeats int
|
NumHeartbeats int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type WakatimeFailureNotificationNotificationTplData struct {
|
||||||
|
PublicUrl string
|
||||||
|
NumFailures int
|
||||||
|
}
|
||||||
|
|
||||||
type ReportTplData struct {
|
type ReportTplData struct {
|
||||||
Report *models.Report
|
Report *models.Report
|
||||||
}
|
}
|
||||||
|
94
services/project_label.go
Normal file
94
services/project_label.go
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/leandro-lugaresi/hub"
|
||||||
|
"github.com/muety/wakapi/config"
|
||||||
|
"github.com/muety/wakapi/models"
|
||||||
|
"github.com/muety/wakapi/repositories"
|
||||||
|
"github.com/patrickmn/go-cache"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ProjectLabelService struct {
|
||||||
|
config *config.Config
|
||||||
|
cache *cache.Cache
|
||||||
|
eventBus *hub.Hub
|
||||||
|
repository repositories.IProjectLabelRepository
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewProjectLabelService(projectLabelRepository repositories.IProjectLabelRepository) *ProjectLabelService {
|
||||||
|
return &ProjectLabelService{
|
||||||
|
config: config.Get(),
|
||||||
|
eventBus: config.EventBus(),
|
||||||
|
repository: projectLabelRepository,
|
||||||
|
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *ProjectLabelService) GetById(id uint) (*models.ProjectLabel, error) {
|
||||||
|
return srv.repository.GetById(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel, error) {
|
||||||
|
if labels, found := srv.cache.Get(userId); found {
|
||||||
|
return labels.([]*models.ProjectLabel), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
labels, err := srv.repository.GetByUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
srv.cache.Set(userId, labels, cache.DefaultExpiration)
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
|
||||||
|
labels := make(map[string][]*models.ProjectLabel)
|
||||||
|
userLabels, err := srv.GetByUser(userId)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, l := range userLabels {
|
||||||
|
if _, ok := labels[l.ProjectKey]; !ok {
|
||||||
|
labels[l.ProjectKey] = []*models.ProjectLabel{l}
|
||||||
|
} else {
|
||||||
|
labels[l.ProjectKey] = append(labels[l.ProjectKey], l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return labels, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||||
|
result, err := srv.repository.Insert(label)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
srv.cache.Delete(result.UserID)
|
||||||
|
srv.notifyUpdate(label, false)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *ProjectLabelService) Delete(label *models.ProjectLabel) error {
|
||||||
|
if label.UserID == "" {
|
||||||
|
return errors.New("no user id specified")
|
||||||
|
}
|
||||||
|
err := srv.repository.Delete(label.ID)
|
||||||
|
srv.cache.Delete(label.UserID)
|
||||||
|
srv.notifyUpdate(label, true)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (srv *ProjectLabelService) notifyUpdate(label *models.ProjectLabel, isDelete bool) {
|
||||||
|
name := config.EventProjectLabelCreate
|
||||||
|
if isDelete {
|
||||||
|
name = config.EventProjectLabelDelete
|
||||||
|
}
|
||||||
|
srv.eventBus.Publish(hub.Message{
|
||||||
|
Name: name,
|
||||||
|
Fields: map[string]interface{}{config.FieldPayload: label, config.FieldUserId: label.UserID},
|
||||||
|
})
|
||||||
|
}
|
@ -39,6 +39,10 @@ type IHeartbeatService interface {
|
|||||||
DeleteBefore(time.Time) error
|
DeleteBefore(time.Time) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IDiagnosticsService interface {
|
||||||
|
Create(*models.Diagnostics) (*models.Diagnostics, error)
|
||||||
|
}
|
||||||
|
|
||||||
type IKeyValueService interface {
|
type IKeyValueService interface {
|
||||||
GetString(string) (*models.KeyStringValue, error)
|
GetString(string) (*models.KeyStringValue, error)
|
||||||
MustGetString(string) *models.KeyStringValue
|
MustGetString(string) *models.KeyStringValue
|
||||||
@ -54,8 +58,17 @@ type ILanguageMappingService interface {
|
|||||||
Delete(mapping *models.LanguageMapping) error
|
Delete(mapping *models.LanguageMapping) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type IProjectLabelService interface {
|
||||||
|
GetById(uint) (*models.ProjectLabel, error)
|
||||||
|
GetByUser(string) ([]*models.ProjectLabel, error)
|
||||||
|
GetByUserGrouped(string) (map[string][]*models.ProjectLabel, error)
|
||||||
|
Create(*models.ProjectLabel) (*models.ProjectLabel, error)
|
||||||
|
Delete(*models.ProjectLabel) error
|
||||||
|
}
|
||||||
|
|
||||||
type IMailService interface {
|
type IMailService interface {
|
||||||
SendPasswordReset(*models.User, string) error
|
SendPasswordReset(*models.User, string) error
|
||||||
|
SendWakatimeFailureNotification(*models.User, int) error
|
||||||
SendImportNotification(*models.User, time.Duration, int) error
|
SendImportNotification(*models.User, time.Duration, int) error
|
||||||
SendReport(*models.User, *models.Report) error
|
SendReport(*models.User, *models.Report) error
|
||||||
}
|
}
|
||||||
@ -82,7 +95,7 @@ type IUserService interface {
|
|||||||
GetUserByResetToken(string) (*models.User, error)
|
GetUserByResetToken(string) (*models.User, error)
|
||||||
GetAll() ([]*models.User, error)
|
GetAll() ([]*models.User, error)
|
||||||
GetAllByReports(bool) ([]*models.User, error)
|
GetAllByReports(bool) ([]*models.User, error)
|
||||||
GetActive() ([]*models.User, error)
|
GetActive(bool) ([]*models.User, error)
|
||||||
Count() (int64, error)
|
Count() (int64, error)
|
||||||
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
|
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
|
||||||
Update(*models.User) (*models.User, error)
|
Update(*models.User) (*models.User, error)
|
||||||
|
@ -1,42 +1,63 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/md5"
|
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
"github.com/emvi/logbuch"
|
"github.com/emvi/logbuch"
|
||||||
|
"github.com/leandro-lugaresi/hub"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
"github.com/muety/wakapi/repositories"
|
"github.com/muety/wakapi/repositories"
|
||||||
"github.com/patrickmn/go-cache"
|
"github.com/patrickmn/go-cache"
|
||||||
"math"
|
"math"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const HeartbeatDiffThreshold = 2 * time.Minute
|
const HeartbeatDiffThreshold = 2 * time.Minute
|
||||||
|
|
||||||
type SummaryService struct {
|
type SummaryService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
repository repositories.ISummaryRepository
|
eventBus *hub.Hub
|
||||||
heartbeatService IHeartbeatService
|
repository repositories.ISummaryRepository
|
||||||
aliasService IAliasService
|
heartbeatService IHeartbeatService
|
||||||
|
aliasService IAliasService
|
||||||
|
projectLabelService IProjectLabelService
|
||||||
}
|
}
|
||||||
|
|
||||||
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
type SummaryRetriever func(f, t time.Time, u *models.User) (*models.Summary, error)
|
||||||
|
|
||||||
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService) *SummaryService {
|
func NewSummaryService(summaryRepo repositories.ISummaryRepository, heartbeatService IHeartbeatService, aliasService IAliasService, projectLabelService IProjectLabelService) *SummaryService {
|
||||||
return &SummaryService{
|
srv := &SummaryService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
cache: cache.New(24*time.Hour, 24*time.Hour),
|
cache: cache.New(24*time.Hour, 24*time.Hour),
|
||||||
repository: summaryRepo,
|
eventBus: config.EventBus(),
|
||||||
heartbeatService: heartbeatService,
|
repository: summaryRepo,
|
||||||
aliasService: aliasService,
|
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
|
// 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) {
|
func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f SummaryRetriever, skipCache bool) (*models.Summary, error) {
|
||||||
// Check cache
|
// Check cache
|
||||||
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
cacheKey := srv.getHash(from.String(), to.String(), user.ID, "--aliased")
|
||||||
@ -63,17 +84,15 @@ func (srv *SummaryService) Aliased(from, to time.Time, user *models.User, f Summ
|
|||||||
|
|
||||||
// Post-process summary and cache it
|
// Post-process summary and cache it
|
||||||
summary := s.WithResolvedAliases(resolve)
|
summary := s.WithResolvedAliases(resolve)
|
||||||
|
summary = srv.withProjectLabels(summary)
|
||||||
|
summary.FillBy(models.SummaryProject, models.SummaryLabel) // first fill up labels from projects
|
||||||
|
summary.FillMissing() // then, full up types which are entirely missing
|
||||||
|
|
||||||
srv.cache.SetDefault(cacheKey, summary)
|
srv.cache.SetDefault(cacheKey, summary)
|
||||||
return summary.Sorted(), nil
|
return summary.Sorted(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*models.Summary, error) {
|
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
|
// Get all already existing, pre-generated summaries that fall into the requested interval
|
||||||
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
summaries, err := srv.repository.GetByUserWithin(user, from, to)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -96,8 +115,6 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User) (*mod
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cache 'em
|
|
||||||
srv.cache.SetDefault(cacheKey, summary)
|
|
||||||
return summary.Sorted(), nil
|
return summary.Sorted(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +127,7 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
types := models.SummaryTypes()
|
types := models.NativeSummaryTypes()
|
||||||
|
|
||||||
typedAggregations := make(chan models.SummaryItemContainer)
|
typedAggregations := make(chan models.SummaryItemContainer)
|
||||||
defer close(typedAggregations)
|
defer close(typedAggregations)
|
||||||
@ -157,8 +174,6 @@ func (srv *SummaryService) Summarize(from, to time.Time, user *models.User) (*mo
|
|||||||
Machines: machineItems,
|
Machines: machineItems,
|
||||||
}
|
}
|
||||||
|
|
||||||
//summary.FillUnknown()
|
|
||||||
|
|
||||||
return summary.Sorted(), nil
|
return summary.Sorted(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -169,10 +184,12 @@ func (srv *SummaryService) GetLatestByUser() ([]*models.TimeByUser, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) DeleteByUser(userId string) error {
|
func (srv *SummaryService) DeleteByUser(userId string) error {
|
||||||
|
srv.invalidateUserCache(userId)
|
||||||
return srv.repository.DeleteByUser(userId)
|
return srv.repository.DeleteByUser(userId)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
func (srv *SummaryService) Insert(summary *models.Summary) error {
|
||||||
|
srv.invalidateUserCache(summary.UserID)
|
||||||
return srv.repository.Insert(summary)
|
return srv.repository.Insert(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +237,49 @@ func (srv *SummaryService) aggregateBy(heartbeats []*models.Heartbeat, summaryTy
|
|||||||
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
c <- models.SummaryItemContainer{Type: summaryType, Items: items}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (srv *SummaryService) withProjectLabels(summary *models.Summary) *models.Summary {
|
||||||
|
newEntry := func(key string, total time.Duration) *models.SummaryItem {
|
||||||
|
return &models.SummaryItem{
|
||||||
|
Type: models.SummaryLabel,
|
||||||
|
Key: key,
|
||||||
|
Total: total,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
allLabels, err := srv.projectLabelService.GetByUser(summary.UserID)
|
||||||
|
if err != nil {
|
||||||
|
logbuch.Error("failed to retrieve project labels for user summary ('%s', '%s', '%s')", summary.UserID, summary.FromTime.String(), summary.ToTime.String())
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
|
mappedProjects := make(map[string]*models.SummaryItem, len(summary.Projects))
|
||||||
|
for _, p := range summary.Projects {
|
||||||
|
mappedProjects[p.Key] = p
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalLabelTime time.Duration
|
||||||
|
labelMap := make(map[string]*models.SummaryItem, 0)
|
||||||
|
for _, l := range allLabels {
|
||||||
|
if p, ok := mappedProjects[l.ProjectKey]; ok {
|
||||||
|
if _, ok2 := labelMap[l.Label]; !ok2 {
|
||||||
|
labelMap[l.Label] = newEntry(l.Label, 0)
|
||||||
|
}
|
||||||
|
labelMap[l.Label].Total += p.Total
|
||||||
|
totalLabelTime += p.Total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
//labelMap[models.DefaultProjectLabel] = newEntry(models.DefaultProjectLabel, summary.TotalTimeBy(models.SummaryProject) / time.Second-totalLabelTime)
|
||||||
|
|
||||||
|
labels := make([]*models.SummaryItem, 0, len(labelMap))
|
||||||
|
for _, v := range labelMap {
|
||||||
|
if v.Total > 0 {
|
||||||
|
labels = append(labels, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summary.Labels = labels
|
||||||
|
return summary
|
||||||
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.Summary, error) {
|
||||||
if len(summaries) < 1 {
|
if len(summaries) < 1 {
|
||||||
return nil, errors.New("no summaries given")
|
return nil, errors.New("no summaries given")
|
||||||
@ -235,6 +295,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
|||||||
Editors: make([]*models.SummaryItem, 0),
|
Editors: make([]*models.SummaryItem, 0),
|
||||||
OperatingSystems: make([]*models.SummaryItem, 0),
|
OperatingSystems: make([]*models.SummaryItem, 0),
|
||||||
Machines: make([]*models.SummaryItem, 0),
|
Machines: make([]*models.SummaryItem, 0),
|
||||||
|
Labels: make([]*models.SummaryItem, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
var processed = map[time.Time]bool{}
|
var processed = map[time.Time]bool{}
|
||||||
@ -263,6 +324,7 @@ func (srv *SummaryService) mergeSummaries(summaries []*models.Summary) (*models.
|
|||||||
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
finalSummary.Editors = srv.mergeSummaryItems(finalSummary.Editors, s.Editors)
|
||||||
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
finalSummary.OperatingSystems = srv.mergeSummaryItems(finalSummary.OperatingSystems, s.OperatingSystems)
|
||||||
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
finalSummary.Machines = srv.mergeSummaryItems(finalSummary.Machines, s.Machines)
|
||||||
|
finalSummary.Labels = srv.mergeSummaryItems(finalSummary.Labels, s.Labels)
|
||||||
|
|
||||||
processed[hash] = true
|
processed[hash] = true
|
||||||
}
|
}
|
||||||
@ -349,9 +411,13 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (srv *SummaryService) getHash(args ...string) string {
|
func (srv *SummaryService) getHash(args ...string) string {
|
||||||
digest := md5.New()
|
return strings.Join(args, "__")
|
||||||
for _, a := range args {
|
}
|
||||||
digest.Write([]byte(a))
|
|
||||||
}
|
func (srv *SummaryService) invalidateUserCache(userId string) {
|
||||||
return string(digest.Sum(nil))
|
for key := range srv.cache.Items() {
|
||||||
|
if strings.Contains(key, userId) {
|
||||||
|
srv.cache.Delete(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -16,6 +16,9 @@ const (
|
|||||||
TestUserId = "muety"
|
TestUserId = "muety"
|
||||||
TestProject1 = "test-project-1"
|
TestProject1 = "test-project-1"
|
||||||
TestProject2 = "test-project-2"
|
TestProject2 = "test-project-2"
|
||||||
|
TestProjectLabel1 = "private"
|
||||||
|
TestProjectLabel2 = "work"
|
||||||
|
TestProjectLabel3 = "non-existing"
|
||||||
TestLanguageGo = "Go"
|
TestLanguageGo = "Go"
|
||||||
TestLanguageJava = "Java"
|
TestLanguageJava = "Java"
|
||||||
TestLanguagePython = "Python"
|
TestLanguagePython = "Python"
|
||||||
@ -31,12 +34,14 @@ const (
|
|||||||
|
|
||||||
type SummaryServiceTestSuite struct {
|
type SummaryServiceTestSuite struct {
|
||||||
suite.Suite
|
suite.Suite
|
||||||
TestUser *models.User
|
TestUser *models.User
|
||||||
TestStartTime time.Time
|
TestStartTime time.Time
|
||||||
TestHeartbeats []*models.Heartbeat
|
TestHeartbeats []*models.Heartbeat
|
||||||
SummaryRepository *mocks.SummaryRepositoryMock
|
TestLabels []*models.ProjectLabel
|
||||||
HeartbeatService *mocks.HeartbeatServiceMock
|
SummaryRepository *mocks.SummaryRepositoryMock
|
||||||
AliasService *mocks.AliasServiceMock
|
HeartbeatService *mocks.HeartbeatServiceMock
|
||||||
|
AliasService *mocks.AliasServiceMock
|
||||||
|
ProjectLabelService *mocks.ProjectLabelServiceMock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) SetupSuite() {
|
func (suite *SummaryServiceTestSuite) SetupSuite() {
|
||||||
@ -75,12 +80,27 @@ func (suite *SummaryServiceTestSuite) SetupSuite() {
|
|||||||
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
Time: models.CustomTime(suite.TestStartTime.Add(3 * time.Minute)),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
suite.TestLabels = []*models.ProjectLabel{
|
||||||
|
{
|
||||||
|
ID: uint(rand.Uint32()),
|
||||||
|
UserID: TestUserId,
|
||||||
|
ProjectKey: TestProject1,
|
||||||
|
Label: TestProjectLabel1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: uint(rand.Uint32()),
|
||||||
|
UserID: TestUserId,
|
||||||
|
ProjectKey: TestProjectLabel3,
|
||||||
|
Label: "blaahh",
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
|
func (suite *SummaryServiceTestSuite) BeforeTest(suiteName, testName string) {
|
||||||
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
suite.SummaryRepository = new(mocks.SummaryRepositoryMock)
|
||||||
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
suite.HeartbeatService = new(mocks.HeartbeatServiceMock)
|
||||||
suite.AliasService = new(mocks.AliasServiceMock)
|
suite.AliasService = new(mocks.AliasServiceMock)
|
||||||
|
suite.ProjectLabelService = new(mocks.ProjectLabelServiceMock)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSummaryServiceTestSuite(t *testing.T) {
|
func TestSummaryServiceTestSuite(t *testing.T) {
|
||||||
@ -88,7 +108,7 @@ func TestSummaryServiceTestSuite(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
||||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
from time.Time
|
from time.Time
|
||||||
@ -141,7 +161,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Summarize() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
summaries []*models.Summary
|
summaries []*models.Summary
|
||||||
@ -292,7 +312,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
|
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
|
||||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||||
|
|
||||||
|
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
summaries []*models.Summary
|
summaries []*models.Summary
|
||||||
@ -338,7 +360,9 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSumma
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
||||||
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService)
|
sut := NewSummaryService(suite.SummaryRepository, suite.HeartbeatService, suite.AliasService, suite.ProjectLabelService)
|
||||||
|
|
||||||
|
suite.ProjectLabelService.On("GetByUser", suite.TestUser.ID).Return([]*models.ProjectLabel{}, nil)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
from time.Time
|
from time.Time
|
||||||
@ -348,10 +372,25 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
from, to = suite.TestStartTime, suite.TestStartTime.Add(1*time.Hour)
|
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("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.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)
|
result, err = sut.Aliased(from, to, suite.TestUser, sut.Summarize, false)
|
||||||
|
|
||||||
@ -361,6 +400,44 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Aliased() {
|
|||||||
assert.NotZero(suite.T(), result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
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 {
|
func filter(from, to time.Time, heartbeats []*models.Heartbeat) []*models.Heartbeat {
|
||||||
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
|
filtered := make([]*models.Heartbeat, 0, len(heartbeats))
|
||||||
for _, h := range heartbeats {
|
for _, h := range heartbeats {
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/emvi/logbuch"
|
||||||
"github.com/leandro-lugaresi/hub"
|
"github.com/leandro-lugaresi/hub"
|
||||||
"github.com/muety/wakapi/config"
|
"github.com/muety/wakapi/config"
|
||||||
"github.com/muety/wakapi/models"
|
"github.com/muety/wakapi/models"
|
||||||
@ -12,19 +14,45 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type UserService struct {
|
type UserService struct {
|
||||||
config *config.Config
|
config *config.Config
|
||||||
cache *cache.Cache
|
cache *cache.Cache
|
||||||
eventBus *hub.Hub
|
eventBus *hub.Hub
|
||||||
repository repositories.IUserRepository
|
mailService IMailService
|
||||||
|
repository repositories.IUserRepository
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewUserService(userRepo repositories.IUserRepository) *UserService {
|
func NewUserService(mailService IMailService, userRepo repositories.IUserRepository) *UserService {
|
||||||
return &UserService{
|
srv := &UserService{
|
||||||
config: config.Get(),
|
config: config.Get(),
|
||||||
eventBus: config.EventBus(),
|
eventBus: config.EventBus(),
|
||||||
cache: cache.New(1*time.Hour, 2*time.Hour),
|
cache: cache.New(1*time.Hour, 2*time.Hour),
|
||||||
repository: userRepo,
|
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) {
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
srv.cache.Set(u.ID, u, cache.DefaultExpiration)
|
srv.cache.SetDefault(u.ID, u)
|
||||||
return u, nil
|
return u, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -71,9 +99,24 @@ func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, er
|
|||||||
return srv.repository.GetAllByReports(reportsEnabled)
|
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))
|
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) {
|
func (srv *UserService) Count() (int64, error) {
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**
|
sonar.exclusions=**/*_test.go,.idea/**,.vscode/**,mocks/**,static/**,views/mail/**
|
||||||
sonar.tests=.
|
sonar.tests=.
|
||||||
sonar.go.coverage.reportPaths=coverage/coverage.out
|
sonar.go.coverage.reportPaths=coverage/coverage.out
|
@ -5,16 +5,18 @@ const osCanvas = document.getElementById('chart-os')
|
|||||||
const editorsCanvas = document.getElementById('chart-editor')
|
const editorsCanvas = document.getElementById('chart-editor')
|
||||||
const languagesCanvas = document.getElementById('chart-language')
|
const languagesCanvas = document.getElementById('chart-language')
|
||||||
const machinesCanvas = document.getElementById('chart-machine')
|
const machinesCanvas = document.getElementById('chart-machine')
|
||||||
|
const labelsCanvas = document.getElementById('chart-label')
|
||||||
|
|
||||||
const projectContainer = document.getElementById('project-container')
|
const projectContainer = document.getElementById('project-container')
|
||||||
const osContainer = document.getElementById('os-container')
|
const osContainer = document.getElementById('os-container')
|
||||||
const editorContainer = document.getElementById('editor-container')
|
const editorContainer = document.getElementById('editor-container')
|
||||||
const languageContainer = document.getElementById('language-container')
|
const languageContainer = document.getElementById('language-container')
|
||||||
const machineContainer = document.getElementById('machine-container')
|
const machineContainer = document.getElementById('machine-container')
|
||||||
|
const labelContainer = document.getElementById('label-container')
|
||||||
|
|
||||||
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer]
|
const containers = [projectContainer, osContainer, editorContainer, languageContainer, machineContainer, labelContainer]
|
||||||
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas]
|
const canvases = [projectsCanvas, osCanvas, editorsCanvas, languagesCanvas, machinesCanvas, labelsCanvas]
|
||||||
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines]
|
const data = [wakapiData.projects, wakapiData.operatingSystems, wakapiData.editors, wakapiData.languages, wakapiData.machines, wakapiData.labels]
|
||||||
|
|
||||||
let topNPickers = [...document.getElementsByClassName('top-picker')]
|
let topNPickers = [...document.getElementsByClassName('top-picker')]
|
||||||
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
topNPickers.sort(((a, b) => parseInt(a.attributes['data-entity'].value) - parseInt(b.attributes['data-entity'].value)))
|
||||||
@ -32,10 +34,10 @@ Chart.defaults.global.defaultFontColor = "#E2E8F0"
|
|||||||
Chart.defaults.global.defaultColor = "#E2E8F0"
|
Chart.defaults.global.defaultColor = "#E2E8F0"
|
||||||
|
|
||||||
String.prototype.toHHMMSS = function () {
|
String.prototype.toHHMMSS = function () {
|
||||||
var sec_num = parseInt(this, 10)
|
const sec_num = parseInt(this, 10)
|
||||||
var hours = Math.floor(sec_num / 3600)
|
let hours = Math.floor(sec_num / 3600)
|
||||||
var minutes = Math.floor((sec_num - (hours * 3600)) / 60)
|
let minutes = Math.floor((sec_num - (hours * 3600)) / 60)
|
||||||
var seconds = sec_num - (hours * 3600) - (minutes * 60)
|
let seconds = sec_num - (hours * 3600) - (minutes * 60)
|
||||||
|
|
||||||
if (hours < 10) {
|
if (hours < 10) {
|
||||||
hours = '0' + hours
|
hours = '0' + hours
|
||||||
@ -46,14 +48,14 @@ String.prototype.toHHMMSS = function () {
|
|||||||
if (seconds < 10) {
|
if (seconds < 10) {
|
||||||
seconds = '0' + seconds
|
seconds = '0' + seconds
|
||||||
}
|
}
|
||||||
return hours + ':' + minutes + ':' + seconds
|
return `${hours}:${minutes}:${seconds}`
|
||||||
}
|
}
|
||||||
|
|
||||||
String.prototype.toHHMM = function () {
|
String.prototype.toHHMM = function () {
|
||||||
const sec_num = parseInt(this, 10)
|
const sec_num = parseInt(this, 10)
|
||||||
const hours = Math.floor(sec_num / 3600)
|
const hours = Math.floor(sec_num / 3600)
|
||||||
const minutes = Math.floor((sec_num - (hours * 3600)) / 60)
|
const minutes = Math.floor((sec_num - (hours * 3600)) / 60)
|
||||||
return hours + ':' + minutes
|
return `${hours}:${minutes}`
|
||||||
}
|
}
|
||||||
|
|
||||||
function draw(subselection) {
|
function draw(subselection) {
|
||||||
@ -78,7 +80,7 @@ function draw(subselection) {
|
|||||||
.filter((c, i) => shouldUpdate(i))
|
.filter((c, i) => shouldUpdate(i))
|
||||||
.forEach(c => c.destroy())
|
.forEach(c => c.destroy())
|
||||||
|
|
||||||
let projectChart = !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
|
let projectChart = projectsCanvas && !projectsCanvas.classList.contains('hidden') && shouldUpdate(0)
|
||||||
? new Chart(projectsCanvas.getContext('2d'), {
|
? new Chart(projectsCanvas.getContext('2d'), {
|
||||||
type: 'horizontalBar',
|
type: 'horizontalBar',
|
||||||
data: {
|
data: {
|
||||||
@ -112,7 +114,10 @@ function draw(subselection) {
|
|||||||
xAxes: [{
|
xAxes: [{
|
||||||
scaleLabel: {
|
scaleLabel: {
|
||||||
display: true,
|
display: true,
|
||||||
labelString: 'Seconds'
|
labelString: 'Duration (hh:mm:ss)'
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
callback: (label) => label.toString().toHHMMSS()
|
||||||
}
|
}
|
||||||
}]
|
}]
|
||||||
},
|
},
|
||||||
@ -123,7 +128,7 @@ function draw(subselection) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
let osChart = !osCanvas.classList.contains('hidden') && shouldUpdate(1)
|
let osChart = osCanvas && !osCanvas.classList.contains('hidden') && shouldUpdate(1)
|
||||||
? new Chart(osCanvas.getContext('2d'), {
|
? new Chart(osCanvas.getContext('2d'), {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: {
|
data: {
|
||||||
@ -156,7 +161,7 @@ function draw(subselection) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
let editorChart = !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
|
let editorChart = editorsCanvas && !editorsCanvas.classList.contains('hidden') && shouldUpdate(2)
|
||||||
? new Chart(editorsCanvas.getContext('2d'), {
|
? new Chart(editorsCanvas.getContext('2d'), {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: {
|
data: {
|
||||||
@ -189,7 +194,7 @@ function draw(subselection) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
let languageChart = !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
|
let languageChart = languagesCanvas && !languagesCanvas.classList.contains('hidden') && shouldUpdate(3)
|
||||||
? new Chart(languagesCanvas.getContext('2d'), {
|
? new Chart(languagesCanvas.getContext('2d'), {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: {
|
data: {
|
||||||
@ -222,7 +227,7 @@ function draw(subselection) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
let machineChart = !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
|
let machineChart = machinesCanvas && !machinesCanvas.classList.contains('hidden') && shouldUpdate(4)
|
||||||
? new Chart(machinesCanvas.getContext('2d'), {
|
? new Chart(machinesCanvas.getContext('2d'), {
|
||||||
type: 'pie',
|
type: 'pie',
|
||||||
data: {
|
data: {
|
||||||
@ -255,9 +260,42 @@ function draw(subselection) {
|
|||||||
})
|
})
|
||||||
: null
|
: null
|
||||||
|
|
||||||
|
let labelChart = labelsCanvas && !labelsCanvas.classList.contains('hidden') && shouldUpdate(5)
|
||||||
|
? new Chart(labelsCanvas.getContext('2d'), {
|
||||||
|
type: 'pie',
|
||||||
|
data: {
|
||||||
|
datasets: [{
|
||||||
|
data: wakapiData.labels
|
||||||
|
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
|
||||||
|
.map(p => parseInt(p.total)),
|
||||||
|
backgroundColor: wakapiData.labels.map(p => {
|
||||||
|
const c = hexToRgb(getRandomColor(p.key))
|
||||||
|
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.6)`
|
||||||
|
}),
|
||||||
|
hoverBackgroundColor: wakapiData.labels.map(p => {
|
||||||
|
const c = hexToRgb(getRandomColor(p.key))
|
||||||
|
return `rgba(${c.r}, ${c.g}, ${c.b}, 0.8)`
|
||||||
|
}),
|
||||||
|
borderColor: wakapiData.labels.map(p => {
|
||||||
|
const c = hexToRgb(getRandomColor(p.key))
|
||||||
|
return `rgba(${c.r}, ${c.g}, ${c.b}, 1)`
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
labels: wakapiData.labels
|
||||||
|
.slice(0, Math.min(showTopN[5], wakapiData.labels.length))
|
||||||
|
.map(p => p.key)
|
||||||
|
},
|
||||||
|
options: {
|
||||||
|
tooltips: getTooltipOptions('labels'),
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
onResize: onChartResize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
: null
|
||||||
|
|
||||||
getTotal(wakapiData.operatingSystems)
|
getTotal(wakapiData.operatingSystems)
|
||||||
|
|
||||||
charts = [projectChart, osChart, editorChart, languageChart, machineChart].filter(c => !!c)
|
charts = [projectChart, osChart, editorChart, languageChart, machineChart, labelChart].filter(c => !!c)
|
||||||
|
|
||||||
if (!subselection) {
|
if (!subselection) {
|
||||||
charts.forEach(c => c.options.onResize(c.chart))
|
charts.forEach(c => c.options.onResize(c.chart))
|
||||||
@ -270,9 +308,12 @@ function parseTopN() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function togglePlaceholders(mask) {
|
function togglePlaceholders(mask) {
|
||||||
const placeholderElements = containers.map(c => c.querySelector('.placeholder-container'))
|
const placeholderElements = containers.map(c => c ? c.querySelector('.placeholder-container'): null)
|
||||||
|
|
||||||
for (let i = 0; i < mask.length; i++) {
|
for (let i = 0; i < mask.length; i++) {
|
||||||
|
if (placeholderElements[i] === null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (!mask[i]) {
|
if (!mask[i]) {
|
||||||
canvases[i].classList.add('hidden')
|
canvases[i].classList.add('hidden')
|
||||||
placeholderElements[i].classList.remove('hidden')
|
placeholderElements[i].classList.remove('hidden')
|
||||||
|
BIN
static/assets/images/jetbrains-logo.png
Normal file
BIN
static/assets/images/jetbrains-logo.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
26
static/assets/vendor/tailwind.dist.css
vendored
26
static/assets/vendor/tailwind.dist.css
vendored
@ -641,6 +641,12 @@ video {
|
|||||||
background-color: rgba(47, 133, 90, var(--bg-opacity));
|
background-color: rgba(47, 133, 90, var(--bg-opacity));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hover\:bg-gray-700:hover {
|
||||||
|
--bg-opacity: 1;
|
||||||
|
background-color: #4a5568;
|
||||||
|
background-color: rgba(74, 85, 104, var(--bg-opacity));
|
||||||
|
}
|
||||||
|
|
||||||
.hover\:bg-red-600:hover {
|
.hover\:bg-red-600:hover {
|
||||||
--bg-opacity: 1;
|
--bg-opacity: 1;
|
||||||
background-color: #e53e3e;
|
background-color: #e53e3e;
|
||||||
@ -713,6 +719,10 @@ video {
|
|||||||
border-radius: 0.375rem;
|
border-radius: 0.375rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: 9999px;
|
||||||
|
}
|
||||||
|
|
||||||
.border {
|
.border {
|
||||||
border-width: 1px;
|
border-width: 1px;
|
||||||
}
|
}
|
||||||
@ -753,6 +763,10 @@ video {
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-flex {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
.table {
|
.table {
|
||||||
display: table;
|
display: table;
|
||||||
}
|
}
|
||||||
@ -821,6 +835,10 @@ video {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.h-4 {
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.h-8 {
|
.h-8 {
|
||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
@ -853,6 +871,10 @@ video {
|
|||||||
font-size: 2.25rem;
|
font-size: 2.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.leading-none {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.list-inside {
|
.list-inside {
|
||||||
list-style-position: inside;
|
list-style-position: inside;
|
||||||
}
|
}
|
||||||
@ -1186,6 +1208,10 @@ video {
|
|||||||
overflow-wrap: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.w-4 {
|
||||||
|
width: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.w-1\/2 {
|
.w-1\/2 {
|
||||||
width: 50%;
|
width: 50%;
|
||||||
}
|
}
|
||||||
|
@ -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": {
|
"/compat/wakatime/v1/users/{user}/projects": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@ -361,7 +430,7 @@ var doc = `{
|
|||||||
"operationId": "post-heartbeat",
|
"operationId": "post-heartbeat",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "A heartbeat",
|
"description": "A single heartbeat",
|
||||||
"name": "heartbeat",
|
"name": "heartbeat",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"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": {
|
"/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"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": {
|
"models.Heartbeat": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -506,6 +808,13 @@ var doc = `{
|
|||||||
"format": "date",
|
"format": "date",
|
||||||
"example": "2006-01-02 15:04:05.000"
|
"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": {
|
"languages": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
@ -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": {
|
"/compat/wakatime/v1/users/{user}/projects": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"security": [
|
||||||
@ -345,7 +414,7 @@
|
|||||||
"operationId": "post-heartbeat",
|
"operationId": "post-heartbeat",
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
"description": "A heartbeat",
|
"description": "A single heartbeat",
|
||||||
"name": "heartbeat",
|
"name": "heartbeat",
|
||||||
"in": "body",
|
"in": "body",
|
||||||
"required": true,
|
"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": {
|
"/summary": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"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": {
|
"models.Heartbeat": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@ -490,6 +792,13 @@
|
|||||||
"format": "date",
|
"format": "date",
|
||||||
"example": "2006-01-02 15:04:05.000"
|
"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": {
|
"languages": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
|
@ -1,5 +1,22 @@
|
|||||||
basePath: /api
|
basePath: /api
|
||||||
definitions:
|
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:
|
models.Heartbeat:
|
||||||
properties:
|
properties:
|
||||||
branch:
|
branch:
|
||||||
@ -43,6 +60,12 @@ definitions:
|
|||||||
example: "2006-01-02 15:04:05.000"
|
example: "2006-01-02 15:04:05.000"
|
||||||
format: date
|
format: date
|
||||||
type: string
|
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:
|
languages:
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/models.SummaryItem'
|
$ref: '#/definitions/models.SummaryItem'
|
||||||
@ -408,6 +431,48 @@ paths:
|
|||||||
summary: Retrieve summary for all time
|
summary: Retrieve summary for all time
|
||||||
tags:
|
tags:
|
||||||
- wakatime
|
- 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:
|
/compat/wakatime/v1/users/{user}/projects:
|
||||||
get:
|
get:
|
||||||
description: Mimics https://wakatime.com/developers#projects
|
description: Mimics https://wakatime.com/developers#projects
|
||||||
@ -540,7 +605,7 @@ paths:
|
|||||||
- application/json
|
- application/json
|
||||||
operationId: post-heartbeat
|
operationId: post-heartbeat
|
||||||
parameters:
|
parameters:
|
||||||
- description: A heartbeat
|
- description: A single heartbeat
|
||||||
in: body
|
in: body
|
||||||
name: heartbeat
|
name: heartbeat
|
||||||
required: true
|
required: true
|
||||||
@ -554,6 +619,48 @@ paths:
|
|||||||
summary: Push a new heartbeat
|
summary: Push a new heartbeat
|
||||||
tags:
|
tags:
|
||||||
- heartbeat
|
- 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:
|
/summary:
|
||||||
get:
|
get:
|
||||||
operationId: get-summary
|
operationId: get-summary
|
||||||
@ -599,6 +706,90 @@ paths:
|
|||||||
summary: Retrieve a summary
|
summary: Retrieve a summary
|
||||||
tags:
|
tags:
|
||||||
- summary
|
- 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:
|
securityDefinitions:
|
||||||
ApiKeyAuth:
|
ApiKeyAuth:
|
||||||
in: header
|
in: header
|
||||||
|
File diff suppressed because it is too large
Load Diff
@ -35,7 +35,7 @@ security:
|
|||||||
insecure_cookies: true
|
insecure_cookies: true
|
||||||
cookie_max_age: 172800
|
cookie_max_age: 172800
|
||||||
allow_signup: true
|
allow_signup: true
|
||||||
expose_metrics: false
|
expose_metrics: true
|
||||||
|
|
||||||
sentry:
|
sentry:
|
||||||
dsn:
|
dsn:
|
||||||
|
@ -1,4 +1,14 @@
|
|||||||
BEGIN TRANSACTION;
|
BEGIN TRANSACTION;
|
||||||
INSERT INTO "users" ("id","api_key","email","location","password","created_at","last_logged_in_at","share_data_max_days","share_editors","share_languages","share_projects","share_oss","share_machines","is_admin","has_data","wakatime_api_key","reset_token","reports_weekly") VALUES ('readuser','33e7f538-0dce-4eba-8ffe-53db6814ed42','','Europe/Berlin','$2a$10$RCyfAFdlZdFJVWbxKz4f2uJ/MospiE1EFAIjvRizC4Nop9GfjgKzW','2021-05-28 12:34:25','2021-05-28 14:34:34.178+02:00',0,0,0,0,0,0,0,0,'','',0);
|
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
|
||||||
INSERT INTO "users" ("id","api_key","email","location","password","created_at","last_logged_in_at","share_data_max_days","share_editors","share_languages","share_projects","share_oss","share_machines","is_admin","has_data","wakatime_api_key","reset_token","reports_weekly") VALUES ('writeuser','f7aa255c-8647-4d0b-b90f-621c58fd580f','','Europe/Berlin','$2a$10$vsksPpiXZE9/xG9pRrZP.eKkbe/bGWW4wpPoXqvjiImZqMbN5c4Km','2021-05-28 12:34:56','2021-05-28 14:35:05.118+02:00',0,0,0,0,0,0,0,1,'','',0);
|
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
|
||||||
|
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
|
||||||
|
VALUES ('readuser', '33e7f538-0dce-4eba-8ffe-53db6814ed42', '', 'Europe/Berlin',
|
||||||
|
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:25',
|
||||||
|
'2021-05-28 14:34:34.178+02:00', 0, 0, 0, 0, 0, 0, 1, 0, '', '', 0);
|
||||||
|
INSERT INTO "users" ("id", "api_key", "email", "location", "password", "created_at", "last_logged_in_at",
|
||||||
|
"share_data_max_days", "share_editors", "share_languages", "share_projects", "share_oss",
|
||||||
|
"share_machines", "is_admin", "has_data", "wakatime_api_key", "reset_token", "reports_weekly")
|
||||||
|
VALUES ('writeuser', 'f7aa255c-8647-4d0b-b90f-621c58fd580f', '', 'Europe/Berlin',
|
||||||
|
'$2a$10$93CAptdjLGRtc1D3xrZJcu8B/YBAPSjCZOHZRId.xpyrsLAeHOoA.', '2021-05-28 12:34:56',
|
||||||
|
'2021-05-28 14:35:05.118+02:00', 7, 0, 0, 1, 0, 0, 0, 1, '', '', 0);
|
||||||
COMMIT;
|
COMMIT;
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
if [ ! -f "wakapi" ]; then
|
if [ ! -f "wakapi" ]; then
|
||||||
echo "Wakapi executable not found. Run 'go build' first."
|
echo "Wakapi executable not found. Compiling."
|
||||||
exit 1
|
go build
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v newman &> /dev/null
|
if ! command -v newman &> /dev/null
|
||||||
|
9
utils/collection.go
Normal file
9
utils/collection.go
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
package utils
|
||||||
|
|
||||||
|
func GetMapValues(m map[string]interface{}) []interface{} {
|
||||||
|
values := make([]interface{}, 0, len(m))
|
||||||
|
for _, v := range m {
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
@ -46,7 +46,7 @@ func Add(i, j int) int {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ParseUserAgent(ua string) (string, string, error) {
|
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)
|
groups := re.FindAllStringSubmatch(ua, -1)
|
||||||
if len(groups) == 0 || len(groups[0]) != 3 {
|
if len(groups) == 0 || len(groups[0]) != 3 {
|
||||||
return "", "", errors.New("failed to parse user agent string")
|
return "", "", errors.New("failed to parse user agent string")
|
||||||
|
@ -37,6 +37,12 @@ func TestCommon_ParseUserAgent(t *testing.T) {
|
|||||||
"",
|
"",
|
||||||
errors.New(""),
|
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 {
|
for _, test := range tests {
|
||||||
|
@ -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())
|
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)
|
// 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 {
|
func CeilDate(date time.Time) time.Time {
|
||||||
floored := FloorDate(date)
|
floored := FloorDate(date)
|
||||||
|
@ -30,7 +30,8 @@ func ResolveIntervalRawTZ(interval string, tz *time.Location) (err error, from,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err error, from, to time.Time) {
|
||||||
to = time.Now().In(tz)
|
now := time.Now().In(tz)
|
||||||
|
to = now
|
||||||
|
|
||||||
switch interval {
|
switch interval {
|
||||||
case models.IntervalToday:
|
case models.IntervalToday:
|
||||||
@ -51,16 +52,16 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
|
|||||||
case models.IntervalThisYear:
|
case models.IntervalThisYear:
|
||||||
from = StartOfThisYear(tz)
|
from = StartOfThisYear(tz)
|
||||||
case models.IntervalPast7Days:
|
case models.IntervalPast7Days:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -7)
|
from = now.AddDate(0, 0, -7)
|
||||||
case models.IntervalPast7DaysYesterday:
|
case models.IntervalPast7DaysYesterday:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
from = StartOfToday(tz).AddDate(0, 0, -1).AddDate(0, 0, -7)
|
||||||
to = StartOfToday(tz).AddDate(0, 0, -1)
|
to = StartOfToday(tz).AddDate(0, 0, -1)
|
||||||
case models.IntervalPast14Days:
|
case models.IntervalPast14Days:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -14)
|
from = now.AddDate(0, 0, -14)
|
||||||
case models.IntervalPast30Days:
|
case models.IntervalPast30Days:
|
||||||
from = StartOfToday(tz).AddDate(0, 0, -30)
|
from = now.AddDate(0, 0, -30)
|
||||||
case models.IntervalPast12Months:
|
case models.IntervalPast12Months:
|
||||||
from = StartOfToday(tz).AddDate(0, -12, 0)
|
from = now.AddDate(0, -12, 0)
|
||||||
case models.IntervalAny:
|
case models.IntervalAny:
|
||||||
from = time.Time{}
|
from = time.Time{}
|
||||||
default:
|
default:
|
||||||
|
@ -3,8 +3,13 @@ package utils
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"io/fs"
|
||||||
|
"io/ioutil"
|
||||||
|
"path"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type TemplateMap map[string]*template.Template
|
||||||
|
|
||||||
func Json(data interface{}) template.JS {
|
func Json(data interface{}) template.JS {
|
||||||
d, err := json.Marshal(data)
|
d, err := json.Marshal(data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -19,3 +24,40 @@ func ToRunes(s string) (r []string) {
|
|||||||
}
|
}
|
||||||
return r
|
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
|
||||||
|
}
|
||||||
|
@ -1 +1 @@
|
|||||||
1.27.3
|
1.29.5
|
||||||
|
@ -23,13 +23,13 @@
|
|||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
|
<label class="inline-block text-sm mb-1 text-gray-500" for="username">Username</label>
|
||||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
type="text" id="username"
|
type="text" id="username" autocomplete="username"
|
||||||
name="username" placeholder="Enter your username" minlength="1" required autofocus>
|
name="username" placeholder="Enter your username" minlength="1" required autofocus>
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-8">
|
<div class="mb-8">
|
||||||
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
|
<label class="inline-block text-sm mb-1 text-gray-500" for="password">Password</label>
|
||||||
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
<input class="shadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded w-full py-1 px-3"
|
||||||
type="password" id="password"
|
type="password" id="password" autocomplete="current-password"
|
||||||
name="password" placeholder="******" minlength="6" required>
|
name="password" placeholder="******" minlength="6" required>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
|
88
views/mail/head.tpl.html
Normal file
88
views/mail/head.tpl.html
Normal 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>
|
@ -1,107 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------
|
{{ template "head.tpl.html" . }}
|
||||||
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>
|
|
||||||
<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%;">
|
<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;">
|
<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>
|
<tr>
|
||||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </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;">
|
<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%;">
|
{{ template "theader.tpl.html" . }}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
|
<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;">
|
<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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
|
{{ template "tfooter.tpl.html" . }}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||||
|
6
views/mail/mail.go
Normal file
6
views/mail/mail.go
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
package mail
|
||||||
|
|
||||||
|
import "embed"
|
||||||
|
|
||||||
|
//go:embed *.html
|
||||||
|
var TemplateFiles embed.FS
|
@ -1,107 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------
|
{{ template "head.tpl.html" . }}
|
||||||
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>
|
|
||||||
<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%;">
|
<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;">
|
<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>
|
<tr>
|
||||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </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;">
|
<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%;">
|
{{ template "theader.tpl.html" . }}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
|
<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;">
|
<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>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</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%;">
|
{{ template "tfooter.tpl.html" . }}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||||
|
@ -1,107 +1,14 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* -------------------------------------
|
{{ template "head.tpl.html" . }}
|
||||||
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>
|
|
||||||
<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%;">
|
<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;">
|
<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>
|
<tr>
|
||||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </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;">
|
<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%;">
|
{{ template "theader.tpl.html" . }}
|
||||||
<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>
|
|
||||||
|
|
||||||
<div class="content" style="box-sizing: border-box; display: block; Margin: 0 auto; max-width: 580px; padding: 10px;">
|
<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;">
|
<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>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="footer" style="clear: both; Margin-top: 10px; text-align: center; width: 100%;">
|
{{ template "tfooter.tpl.html" . }}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
<td style="font-family: sans-serif; font-size: 14px; vertical-align: top;"> </td>
|
||||||
|
9
views/mail/tfooter.tpl.html
Normal file
9
views/mail/tfooter.tpl.html
Normal 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>
|
9
views/mail/theader.tpl.html
Normal file
9
views/mail/theader.tpl.html
Normal 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>
|
51
views/mail/wakatime_connection_failure.tpl.html
Normal file
51
views/mail/wakatime_connection_failure.tpl.html
Normal 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;"> </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;"> </td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>
|
@ -35,7 +35,7 @@
|
|||||||
<main class="mt-4 flex-grow flex justify-center w-full">
|
<main class="mt-4 flex-grow flex justify-center w-full">
|
||||||
<div class="flex flex-col flex-grow max-w-2xl mt-8">
|
<div class="flex flex-col flex-grow max-w-2xl mt-8">
|
||||||
|
|
||||||
<details class="my-8 pb-8 border-b border-gray-700">
|
<details class="my-8 pb-8 border-b border-gray-700" id="details-account">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
id="preferences-heading">
|
id="preferences-heading">
|
||||||
@ -88,7 +88,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
<details class="mb-8 pb-8 border-b border-gray-700" id="details-password">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="password">
|
||||||
Change Password
|
Change Password
|
||||||
@ -127,7 +127,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
<details class="mb-8 pb-8 border-b border-gray-700" id="details-aliases">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
Aliases
|
Aliases
|
||||||
@ -203,7 +203,80 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
<details class="mb-8 pb-8 border-b border-gray-700" id="details-labels">
|
||||||
|
<summary class="cursor-pointer">
|
||||||
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
|
Project Labels
|
||||||
|
</h2>
|
||||||
|
</summary>
|
||||||
|
<div class="w-full" id="project-labels">
|
||||||
|
<div class="text-gray-300 text-sm mb-4 mt-6">
|
||||||
|
You can assign labels (aka. tags) to projects to group them together, e.g. by <span class="inline-block mb-1 text-gray-500 italic">private</span> and <span
|
||||||
|
class="inline-block mb-1 text-gray-500 italic">work</span>.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ if .Labels }}
|
||||||
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white">Labels</h3>
|
||||||
|
{{ range $i, $label := .Labels }}
|
||||||
|
<div class="flex items-center" action="" method="post">
|
||||||
|
<div class="text-gray-500 border-1 w-full border-green-700 inline-block my-1 py-1 text-align text-sm"
|
||||||
|
style="line-height: 1.8">
|
||||||
|
▸ <span class="font-semibold text-white">{{ $label.Key }}:</span>
|
||||||
|
{{ range $j, $value := $label.Values }}
|
||||||
|
<form action="" method="post" class="text-white text-xs bg-gray-900 rounded-full py-1 px-2 font-mono inline-flex justify-between items-center space-x-2">
|
||||||
|
<input type="hidden" name="action" value="delete_label">
|
||||||
|
<input type="hidden" name="key" value="{{ $label.Key }}">
|
||||||
|
<input type="hidden" name="value" value="{{ $value }}">
|
||||||
|
<span>{{- $value -}}</span>
|
||||||
|
<button type="submit" class="bg-gray-800 text-center hover:bg-gray-700 rounded-full w-4 h-4 leading-none" title="Delete label">x</button>
|
||||||
|
</form>
|
||||||
|
{{ if lt $j (add (len $label.Values) -1) }}
|
||||||
|
<span class="-ml-1">{{- ", " | capitalize -}}</span>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
<div class="mb-8"></div>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{ if .Projects }}
|
||||||
|
<h3 class="inline-block font-semibold text-md border-b border-green-700 text-white mb-2">Add Label</h3>
|
||||||
|
<form action="" method="post">
|
||||||
|
<input type="hidden" name="action" value="add_label">
|
||||||
|
<div class="flex flex-col space-y-4">
|
||||||
|
<div class="flex justify-between items-center mt-2 w-full text-gray-500 text-sm space-x-4">
|
||||||
|
<div class="w-1/2 flex flex-col flex-grow">
|
||||||
|
<span>Project</span>
|
||||||
|
<select name="key" id="select-project"
|
||||||
|
class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3 cursor-pointer">
|
||||||
|
{{ range $i, $p := .Projects }}
|
||||||
|
<option value="{{ $p }}">{{ $p }}</option>
|
||||||
|
{{ end }}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="w-1/2 flex flex-col flex-grow">
|
||||||
|
<span>Label</span>
|
||||||
|
<input class="shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3"
|
||||||
|
type="text" id="label-value"
|
||||||
|
name="value" placeholder="work" minlength="1" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow flex justify-end">
|
||||||
|
<button type="submit"
|
||||||
|
class="py-1 px-3 rounded bg-green-700 hover:bg-green-800 text-white text-sm">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{{ else }}
|
||||||
|
<div class="text-gray-300 text-sm mb-4 mt-6">You don't have any projects, yet. Start out by sending a few heartbeats before you can then assign labels.</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<details class="mb-8 pb-8 border-b border-gray-700" id="details-mappings">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
id="languages">
|
id="languages">
|
||||||
@ -263,7 +336,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8 border-b border-gray-700" id="public_data">
|
<details class="mb-8 pb-8 border-b border-gray-700" id="details-public-data">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block">
|
||||||
Public Data
|
Public Data
|
||||||
@ -298,11 +371,9 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_projects"
|
<select autocomplete="off" name="share_projects"
|
||||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareProjects }} selected {{ end }}>No
|
||||||
{{ end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end
|
<option value="true" class="cursor-pointer" {{ if .User.ShareProjects }} selected {{ end }}>Yes
|
||||||
}}>Yes
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -314,11 +385,9 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_languages"
|
<select autocomplete="off" name="share_languages"
|
||||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareLanguages }} selected {{ end }}>No
|
||||||
{{ end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{
|
<option value="true" class="cursor-pointer" {{ if .User.ShareLanguages }} selected {{ end }}>Yes
|
||||||
end }}>Yes
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -330,11 +399,9 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_editors"
|
<select autocomplete="off" name="share_editors"
|
||||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareEditors }} selected {{ end }}>No
|
||||||
end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end
|
<option value="true" class="cursor-pointer" {{ if .User.ShareEditors }} selected {{ end }}>Yes
|
||||||
}}>Yes
|
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -346,8 +413,7 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_oss"
|
<select autocomplete="off" name="share_oss"
|
||||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareOSs }} selected {{ end }}>No
|
||||||
}}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
|
<option value="true" class="cursor-pointer" {{ if .User.ShareOSs }} selected {{ end }}>
|
||||||
Yes
|
Yes
|
||||||
@ -362,11 +428,23 @@
|
|||||||
<div class="flex justify-end">
|
<div class="flex justify-end">
|
||||||
<select autocomplete="off" name="share_machines"
|
<select autocomplete="off" name="share_machines"
|
||||||
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareMachines }} selected {{ end }}>No
|
||||||
{{ end }}>No
|
|
||||||
</option>
|
</option>
|
||||||
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end
|
<option value="true" class="cursor-pointer" {{ if .User.ShareMachines }} selected {{ end }}>Yes
|
||||||
}}>Yes
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center w-full text-gray-300 text-sm justify-between my-2">
|
||||||
|
<div class="flex justify-start">
|
||||||
|
<span class="mr-2">Share project labels: </span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<select autocomplete="off" name="share_labels"
|
||||||
|
class="cursor-pointer shadow appearance-nonshadow appearance-none bg-gray-800 focus:bg-gray-700 text-gray-300 border-green-700 focus:border-gray-500 border rounded py-1 px-3">
|
||||||
|
<option value="false" class="cursor-pointer" {{ if not .User.ShareLabels }} selected {{ end }}>No
|
||||||
|
</option>
|
||||||
|
<option value="true" class="cursor-pointer" {{ if .User.ShareLabels }} selected {{ end }}>Yes
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@ -383,7 +461,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8 border-b border-gray-700">
|
<details class="mb-8 pb-8 border-b border-gray-700" id="details-integrations">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block"
|
||||||
id="integrations">
|
id="integrations">
|
||||||
@ -506,7 +584,7 @@
|
|||||||
<p>You have the ability to create badges from your coding statistics using <a
|
<p>You have the ability to create badges from your coding statistics using <a
|
||||||
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
href="https://shields.io" target="_blank" class="border-b border-green-800"
|
||||||
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
rel="noopener noreferrer">Shields.io</a>. To do so, you need to grant public, unauthorized
|
||||||
access to the respective endpoint. See <a href="settings#public_data" class="underline">Public
|
access to the respective endpoint. See <a href="settings#details-public-data" class="underline">Public
|
||||||
Data</a> setting.</p>
|
Data</a> setting.</p>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
@ -528,7 +606,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col mb-4 mt-2">
|
<div class="flex flex-col mb-4 mt-2">
|
||||||
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
|
<img src="https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact"
|
||||||
class="with-url-src-no-scheme">
|
class="with-url-src-no-scheme" alt="Readme Stats Card">
|
||||||
<p class="mt-2"><strong>Source URL:</strong>
|
<p class="mt-2"><strong>Source URL:</strong>
|
||||||
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
|
<span class="break-words text-xs bg-gray-900 rounded py-1 px-2 font-mono with-url-inner-no-scheme">
|
||||||
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
|
https://github-readme-stats.vercel.app/api/wakatime?username={{ .User.ID }}&api_domain=%s&bg_color=2D3748&title_color=2F855A&icon_color=2F855A&text_color=ffffff&custom_title=Wakapi%20Week%20Stats&layout=compact
|
||||||
@ -540,7 +618,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details class="mb-8 pb-8">
|
<details class="mb-8 pb-8" id="details-danger-zone">
|
||||||
<summary class="cursor-pointer">
|
<summary class="cursor-pointer">
|
||||||
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
|
<h2 class="font-semibold text-lg text-white m-0 border-b-2 border-green-700 inline-block" id="danger">
|
||||||
<span class="iconify inline" data-icon="emojione-v1:warning"></span> Danger Zone
|
<span class="iconify inline" data-icon="emojione-v1:warning"></span> Danger Zone
|
||||||
@ -677,6 +755,12 @@
|
|||||||
tzs.sort()
|
tzs.sort()
|
||||||
.map(createTzOption)
|
.map(createTzOption)
|
||||||
.forEach(o => selectTimezone.appendChild(o))
|
.forEach(o => selectTimezone.appendChild(o))
|
||||||
|
|
||||||
|
const hash = location.hash.replace('#', '')
|
||||||
|
if (hash) {
|
||||||
|
const elem = document.getElementById(hash)
|
||||||
|
if (elem) elem.open = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{{ template "footer.tpl.html" . }}
|
{{ template "footer.tpl.html" . }}
|
||||||
|
@ -170,6 +170,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-full lg:w-1/2 p-1" style="max-width: 100vw;">
|
||||||
|
<div class="p-4 pb-10 bg-gray-900 border border-gray-700 text-gray-300 rounded-md shadow m-2 flex flex-col" id="label-container" style="height: 300px">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<div class="w-1/4 flex-1">
|
||||||
|
<a href="settings#details-labels" class="h-8 inline">
|
||||||
|
<span class="iconify inline" data-icon="twemoji:gear"></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<span class="font-semibold w-1/2 text-center flex-1 whitespace-no-wrap">Labels</span>
|
||||||
|
<div class="flex justify-end flex-1 text-xs items-center">
|
||||||
|
<label for="label-top-picker" class="mr-1">Show: </label>
|
||||||
|
<input type="number" min="1" id="label-top-picker" data-entity="5" class="w-1/4 top-picker bg-gray-800 rounded-md text-center" value="10">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<canvas id="chart-label"></canvas>
|
||||||
|
<div class="hidden placeholder-container flex items-center justify-center h-full flex-col">
|
||||||
|
<span class="text-md font-semibold text-gray-500 mt-4">No data</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ else }}
|
{{ else }}
|
||||||
@ -192,7 +212,8 @@
|
|||||||
# <strong>Step 2:</strong> Adapt your config<br>
|
# <strong>Step 2:</strong> Adapt your config<br>
|
||||||
$ vi ~/.wakatime.cfg<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>
|
# Set <em>api_key = <span id="api-key-instruction"></span></em><br><br>
|
||||||
|
|
||||||
# <strong>Step 3:</strong> Start coding and then check back here!
|
# <strong>Step 3:</strong> Start coding and then check back here!
|
||||||
@ -211,12 +232,6 @@
|
|||||||
|
|
||||||
{{ template "foot.tpl.html" . }}
|
{{ template "foot.tpl.html" . }}
|
||||||
|
|
||||||
<script>
|
|
||||||
window.addEventListener('load', function() {
|
|
||||||
document.getElementById('api-key-instruction').innerHTML = document.getElementById('api-key-container').value
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const languageColors = {{ .LanguageColors | json }}
|
const languageColors = {{ .LanguageColors | json }}
|
||||||
const editorColors = {{ .EditorColors | json }}
|
const editorColors = {{ .EditorColors | json }}
|
||||||
@ -228,15 +243,20 @@
|
|||||||
wakapiData.editors = {{ .Editors | json }}
|
wakapiData.editors = {{ .Editors | json }}
|
||||||
wakapiData.languages = {{ .Languages | json }}
|
wakapiData.languages = {{ .Languages | json }}
|
||||||
wakapiData.machines = {{ .Machines | json }}
|
wakapiData.machines = {{ .Machines | json }}
|
||||||
|
wakapiData.labels = {{ .Labels | json }}
|
||||||
|
|
||||||
document.getElementById("to-date-picker").onchange = function () {
|
if (document.getElementById('to-date-picker') !== null) {
|
||||||
var input = document.getElementById("from-date-picker");
|
document.getElementById("to-date-picker").onchange = function () {
|
||||||
input.setAttribute("max", this.value);
|
var input = document.getElementById("from-date-picker");
|
||||||
}
|
input.setAttribute("max", this.value);
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById("from-date-picker").onchange = function () {
|
document.getElementById("from-date-picker").onchange = function () {
|
||||||
var input = document.getElementById("to-date-picker");
|
var input = document.getElementById("to-date-picker");
|
||||||
input.setAttribute("min", this.value);
|
input.setAttribute("min", this.value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById('api-key-instruction').innerHTML = document.getElementById('api-key-container').value
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
@ -2,5 +2,5 @@ package views
|
|||||||
|
|
||||||
import "embed"
|
import "embed"
|
||||||
|
|
||||||
//go:embed *.html mail/*.html
|
//go:embed *.html
|
||||||
var TemplateFiles embed.FS
|
var TemplateFiles embed.FS
|
||||||
|
Reference in New Issue
Block a user