mirror of
https://github.com/muety/wakapi.git
synced 2023-08-10 21:12:56 +03:00
Compare commits
151 Commits
Author | SHA1 | Date | |
---|---|---|---|
060a33263a | |||
33d259592c | |||
fbae5f8757 | |||
bc99dc990a | |||
1e9d3f9e80 | |||
2ce720c20f | |||
ef87445e43 | |||
dec5849661 | |||
5609c0ada3 | |||
1632cea949 | |||
23759d526a | |||
82a565738f | |||
1989a69926 | |||
7a07c9d4fc | |||
a27fe04919 | |||
1d7ff4bc2a | |||
b3fa032bde | |||
94377a8dea | |||
dba4da8641 | |||
4a22a19cb0 | |||
13a3d9f03a | |||
beffe71ea6 | |||
0ab7faf7b6 | |||
a2ac049578 | |||
b287c4ca36 | |||
018cc50fb8 | |||
1d4156bdfe | |||
147c79db60 | |||
f204ca888d | |||
e28070b288 | |||
4d217a83c1 | |||
9e0581b311 | |||
ffb529f4cf | |||
c9aac2a273 | |||
dd8658e33e | |||
e399af1f1f | |||
4c1f4ed39b | |||
7e5c00d0ae | |||
cec2a84e2d | |||
ffb0b84d78 | |||
8a7333b899 | |||
dd3b9c9b9c | |||
d2b62e21c5 | |||
9505773165 | |||
4bfc8a9e9f | |||
df5fe6e623 | |||
037ad7b9b1 | |||
ec10cc922c | |||
acb76e1ab1 | |||
252a304ba8 | |||
c863cf6dc5 | |||
373d969734 | |||
99a3e8f5da | |||
4302cfcbd6 | |||
7cca0055fe | |||
20993a1182 | |||
d5a85639b1 | |||
b6a8185957 | |||
c5da5e4622 | |||
a0f69a371f | |||
2f0cb112dd | |||
2173954b84 | |||
991e64b961 | |||
affff0c386 | |||
099cdaddbc | |||
409405117e | |||
af89ecc9c1 | |||
be354fa790 | |||
a1c4c5da6b | |||
33509beaf7 | |||
ab6ccbdfbe | |||
77e6cd9faa | |||
34bc38cecf | |||
69d3e0494b | |||
a3136ebb13 | |||
4a4d0dad4b | |||
3b87511f48 | |||
f5fba04097 | |||
ad566993ad | |||
5f1e498454 | |||
2e0f79df3b | |||
4a4e19fcbd | |||
45d4ba89f5 | |||
29b3e619ca | |||
1a85ebc0f7 | |||
4bd58789f4 | |||
09d1124794 | |||
41584bdd82 | |||
1b7baf6fc9 | |||
a76db3e95f | |||
74a5226e73 | |||
d245b1e5d0 | |||
d5eff46651 | |||
30a65b4de9 | |||
9048a8eb7a | |||
1f19c5e93c | |||
4b0a3cf0d6 | |||
d778612242 | |||
ff7d595a86 | |||
9d7688957f | |||
179042f81b | |||
e6441f124c | |||
15c391d1d4 | |||
91c765202c | |||
5276f68918 | |||
e774039831 | |||
40067d252e | |||
1a47243f70 | |||
a1f6c2884b | |||
977420c68d | |||
3ae66a3898 | |||
5c5c462035 | |||
f6cc489425 | |||
5aae18e241 | |||
8a731a252a | |||
8fc0d78f64 | |||
a675417ab9 | |||
408d9086e7 | |||
8f933d8648 | |||
bbc85de34b | |||
ec70d024fa | |||
eae45baf38 | |||
4cea50b5c8 | |||
e4814431e0 | |||
91b4cb2c13 | |||
a3acdc7041 | |||
e7e5254673 | |||
8e558d8dee | |||
b763c4acc6 | |||
d1bd7b96b8 | |||
8c65da9031 | |||
647bf1781d | |||
85515d6cb5 | |||
1258ec0438 | |||
965d8e22b3 | |||
ed6e51b4df | |||
af879f8d57 | |||
f15efcd6f2 | |||
22e91ad362 | |||
932ba111cc | |||
23d00d574b | |||
d4b15e7959 | |||
42808fa38a | |||
52269c780f | |||
302eb33b1b | |||
784adec3c1 | |||
d2cdd35fff | |||
33d65fb33a | |||
6d762f5fd6 | |||
222024dabb | |||
660a09475e |
6
.gitattributes
vendored
Normal file
6
.gitattributes
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
* text=auto
|
||||
*.db -text
|
||||
*.png -text
|
||||
*.br -text
|
||||
*.ico -text
|
||||
*.woff2 -text
|
103
.github/workflows/ci.yml
vendored
Normal file
103
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,103 @@
|
||||
name: ci
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: 'Unit- & API tests'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get dependencies
|
||||
run: go get
|
||||
|
||||
- name: Unit Tests
|
||||
run: go test ./... -run ./...
|
||||
|
||||
- name: API Tests
|
||||
run: |
|
||||
npm -g install newman
|
||||
./testing/run_api_tests.sh
|
||||
|
||||
mapi:
|
||||
name: 'Automated pen-tests with Mayhem for API'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get dependencies
|
||||
run: go get
|
||||
|
||||
- name: Build
|
||||
run: go build -v .
|
||||
|
||||
- name: start wakapi
|
||||
run: ./wakapi --config config.default.yml &
|
||||
|
||||
- name: create a trivial testing user
|
||||
run: sqlite3 wakapi_db.db "insert into users (id, api_key) values ('mapi', 'test-api-key')"
|
||||
|
||||
- name: Run Mayhem for API
|
||||
uses: ForAllSecure/mapi-action@v1
|
||||
continue-on-error: true
|
||||
with:
|
||||
mapi-token: ${{ secrets.MAPI_TOKEN }}
|
||||
api-url: http://localhost:3000/api/
|
||||
api-spec: static/docs/swagger.yaml
|
||||
target: muety/wakapi
|
||||
duration: 1min
|
||||
sarif-report: mapi.sarif
|
||||
run-args: |
|
||||
--header-auth
|
||||
Authorization: Basic dGVzdC1hcGkta2V5
|
||||
|
||||
- name: Upload SARIF file
|
||||
uses: github/codeql-action/upload-sarif@v1
|
||||
with:
|
||||
sarif_file: mapi.sarif
|
||||
|
||||
build:
|
||||
name: 'Build (Win, Linux, Mac)'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
platform: [ubuntu-latest, macos-latest, windows-latest]
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
|
||||
steps:
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get dependencies
|
||||
run: go get
|
||||
|
||||
- name: Build
|
||||
run: go build -v .
|
11
.github/workflows/docker.yml
vendored
11
.github/workflows/docker.yml
vendored
@ -8,8 +8,18 @@ on:
|
||||
|
||||
jobs:
|
||||
docker-publish:
|
||||
name: 'Build and publish Docker image'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Set version
|
||||
run: |
|
||||
(git describe --tags --exact-match \
|
||||
|| git symbolic-ref -q --short HEAD \
|
||||
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
@ -46,6 +56,7 @@ jobs:
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: true
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
|
55
.github/workflows/linux-build-on-release.yml
vendored
55
.github/workflows/linux-build-on-release.yml
vendored
@ -1,55 +0,0 @@
|
||||
name: Linux
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Linux - Build, Test & Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get dependencies
|
||||
run: go get
|
||||
|
||||
- name: Unit Tests
|
||||
run: go test ./... -run ./...
|
||||
|
||||
- name: API Tests
|
||||
run: |
|
||||
npm -g install newman
|
||||
./testing/run_api_tests.sh
|
||||
|
||||
- name: Build
|
||||
run: GO111MODULE=on go build -v .
|
||||
|
||||
- name: Zip executable and sample config
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
cp config.default.yml config.yml
|
||||
zip -9 release.zip wakapi config.yml
|
||||
|
||||
- name: Upload built executable to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: release.zip
|
||||
asset_name: wakapi_linux_amd64.zip
|
||||
asset_content_type: application/gzip
|
77
.github/workflows/release.yml
vendored
Normal file
77
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,77 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
release:
|
||||
name: 'Build, package and release to GitHub'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: ubuntu-18.04
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
- platform: ubuntu-18.04
|
||||
GOOS: linux
|
||||
GOARCH: arm64
|
||||
- platform: windows-latest
|
||||
GOOS: windows
|
||||
GOARCH: amd64
|
||||
- platform: macos-latest
|
||||
GOOS: darwin
|
||||
GOARCH: amd64
|
||||
- platform: macos-latest
|
||||
GOOS: darwin
|
||||
GOARCH: arm64
|
||||
runs-on: ${{ matrix.platform }}
|
||||
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.18
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
(git describe --tags --exact-match \
|
||||
|| git symbolic-ref -q --short HEAD \
|
||||
|| git rev-parse --short HEAD) > version.txt 2> /dev/null
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
mkdir -p dist/ && cd dist/
|
||||
cp ../config.default.yml config.yml
|
||||
|
||||
- name: Build
|
||||
working-directory: ./dist
|
||||
shell: bash
|
||||
run: |
|
||||
GOOS=${{ matrix.GOOS }} GOARCH=${{ matrix.GOARCH }} CGO_ENABLED=0 \
|
||||
go build -v -ldflags '-w -s' ../
|
||||
|
||||
- name: Compress working folder (Windows PowerShell)
|
||||
working-directory: ./dist
|
||||
if: "${{ matrix.GOOS == 'windows' }}"
|
||||
run: |
|
||||
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip
|
||||
|
||||
- name: Compress working folder
|
||||
working-directory: ./dist
|
||||
if: "${{ matrix.GOOS != 'windows' }}"
|
||||
run: |
|
||||
zip -9 wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip *
|
||||
|
||||
- name: Upload built executable to Release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
files: ./dist/*.zip
|
51
.github/workflows/win-build-on-release.yml
vendored
51
.github/workflows/win-build-on-release.yml
vendored
@ -1,51 +0,0 @@
|
||||
name: Windows
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
pull_request:
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
||||
jobs:
|
||||
build-and-release:
|
||||
name: Windows - Build & Release
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
|
||||
- name: Set up Go 1.x
|
||||
uses: actions/setup-go@v2
|
||||
with:
|
||||
go-version: ^1.16
|
||||
id: go
|
||||
|
||||
- name: Check out code into the Go module directory
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Get dependencies
|
||||
run: |
|
||||
go get
|
||||
|
||||
- name: Enable Go 1.11 modules
|
||||
run: cmd /c "set GO111MODULE=on"
|
||||
|
||||
- name: Build
|
||||
run: go build -v .
|
||||
|
||||
- name: Compress working folder
|
||||
if: github.event_name == 'release'
|
||||
run: |
|
||||
cp .\config.default.yml .\config.yml
|
||||
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath release.zip
|
||||
|
||||
- name: Upload built executable to Release
|
||||
if: github.event_name == 'release'
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
upload_url: ${{ github.event.release.upload_url }}
|
||||
asset_path: release.zip
|
||||
asset_name: wakapi_win_amd64.zip
|
||||
asset_content_type: application/gzip
|
59
Dockerfile
59
Dockerfile
@ -1,33 +1,22 @@
|
||||
# To build locally: docker buildx build . -t wakapi --load
|
||||
|
||||
# Preparation to save some time
|
||||
FROM --platform=$BUILDPLATFORM golang:1.17-alpine AS prep-env
|
||||
FROM golang:1.18-alpine AS build-env
|
||||
WORKDIR /src
|
||||
|
||||
ADD ./go.mod .
|
||||
RUN go mod download
|
||||
ADD . .
|
||||
|
||||
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
|
||||
|
||||
# Build Stage
|
||||
FROM golang:1.17-alpine AS build-env
|
||||
ADD ./go.mod ./go.sum ./
|
||||
RUN go mod download
|
||||
ADD . .
|
||||
|
||||
# Required for go-sqlite3
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
RUN CGO_ENABLED=0 go build -ldflags "-s -w" -v -o wakapi main.go
|
||||
|
||||
WORKDIR /src
|
||||
COPY --from=prep-env /src .
|
||||
|
||||
RUN go build -v -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 .
|
||||
WORKDIR /staging
|
||||
RUN mkdir ./data ./app && \
|
||||
cp /src/wakapi app/ && \
|
||||
cp /src/config.default.yml app/config.yml && \
|
||||
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' app/config.yml && \
|
||||
cp /src/wait-for-it.sh app/ && \
|
||||
cp /src/entrypoint.sh app/
|
||||
|
||||
# Run Stage
|
||||
|
||||
@ -41,19 +30,19 @@ WORKDIR /app
|
||||
RUN apk add --no-cache bash ca-certificates tzdata
|
||||
|
||||
# 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'
|
||||
ENV ENVIRONMENT=prod \
|
||||
WAKAPI_DB_TYPE=sqlite3 \
|
||||
WAKAPI_DB_USER='' \
|
||||
WAKAPI_DB_PASSWORD='' \
|
||||
WAKAPI_DB_HOST='' \
|
||||
WAKAPI_DB_NAME=/data/wakapi.db \
|
||||
WAKAPI_PASSWORD_SALT='' \
|
||||
WAKAPI_LISTEN_IPV4='0.0.0.0' \
|
||||
WAKAPI_INSECURE_COOKIES='true' \
|
||||
WAKAPI_ALLOW_SIGNUP='true'
|
||||
|
||||
COPY --from=build-env /app .
|
||||
COPY --from=build-env /staging /
|
||||
|
||||
VOLUME /data
|
||||
EXPOSE 3000
|
||||
|
||||
ENTRYPOINT /app/entrypoint.sh
|
||||
|
156
README.md
156
README.md
@ -6,7 +6,7 @@
|
||||
<img src="https://badges.fw-web.space/github/license/muety/wakapi">
|
||||
<a href="#-treeware"><img src="https://badges.fw-web.space:/treeware/trees/muety/wakapi?color=%234EC820&label=%F0%9F%8C%B3%20trees"></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://wakapi.dev/api/badge/n1try/interval:any/project:wakapi?label=wakapi">
|
||||
<img src="https://badges.fw-web.space/github/languages/code-size/muety/wakapi">
|
||||
<a href="https://goreportcard.com/report/github.com/muety/wakapi"><img src="https://goreportcard.com/badge/github.com/muety/wakapi"></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>
|
||||
@ -35,11 +35,12 @@
|
||||
Installation instructions can be found below and in the [Wiki](https://github.com/muety/wakapi/wiki).
|
||||
|
||||
## 🚀 Features
|
||||
|
||||
* ✅ 100 % free and open-source
|
||||
* ✅ Built by developers for developers
|
||||
* ✅ Statistics for projects, languages, editors, hosts and operating systems
|
||||
* ✅ Badges
|
||||
* ✅ Weekly E-Mail Reports
|
||||
* ✅ Weekly E-Mail reports
|
||||
* ✅ REST API
|
||||
* ✅ Partially compatible with WakaTime
|
||||
* ✅ WakaTime integration
|
||||
@ -48,28 +49,40 @@ Installation instructions can be found below and in the [Wiki](https://github.co
|
||||
* ✅ Self-hosted
|
||||
|
||||
## 🚧 Roadmap
|
||||
|
||||
Plans for the near future mainly include, besides usual improvements and bug fixes, a UI redesign as well as additional types of charts and statistics (see [#101](https://github.com/muety/wakapi/issues/101), [#76](https://github.com/muety/wakapi/issues/76), [#12](https://github.com/muety/wakapi/issues/12)). If you have feature requests or any kind of improvement proposals feel free to open an issue or share them in our [user survey](https://github.com/muety/wakapi/issues/82).
|
||||
|
||||
## ⌨️ How to use?
|
||||
|
||||
There are different options for how to use Wakapi, ranging from our hosted cloud service to self-hosting it. Regardless of which option choose, you will always have to do the [client setup](#-client-setup) in addition.
|
||||
|
||||
### ☁️ Option 1: Use [wakapi.dev](https://wakapi.dev)
|
||||
|
||||
If you want to try out a free, hosted cloud service, all you need to do is create an account and then set up your client-side tooling (see below).
|
||||
|
||||
### 📦 Option 2: Quick-run a Release
|
||||
### 📦 Option 2: Quick-run a release
|
||||
|
||||
```bash
|
||||
$ curl -L https://wakapi.dev/get | bash
|
||||
```
|
||||
|
||||
**Alternatively** using [eget](https://github.com/zyedidia/eget):
|
||||
```bash
|
||||
$ eget muety/wakapi
|
||||
```
|
||||
|
||||
### 🐳 Option 3: Use Docker
|
||||
|
||||
```bash
|
||||
# Create a persistent volume
|
||||
$ docker volume create wakapi-data
|
||||
|
||||
$ SALT="$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)"
|
||||
|
||||
# Run the container
|
||||
$ docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e "WAKAPI_PASSWORD_SALT=$(cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w ${1:-32} | head -n 1)" \
|
||||
-e "WAKAPI_PASSWORD_SALT=$SALT" \
|
||||
-v wakapi-data:/data \
|
||||
--name wakapi \
|
||||
ghcr.io/muety/wakapi:latest
|
||||
@ -80,39 +93,36 @@ $ docker run -d \
|
||||
If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https://github.com/andreymaznyak/wakapi-helm-chart) for quick and easy deployment.
|
||||
|
||||
### 🧑💻 Option 4: Compile and run from source
|
||||
#### Prerequisites
|
||||
* Go >= 1.16 (with `$GOPATH` properly set)
|
||||
* gcc (to compile [go-sqlite3](https://github.com/mattn/go-sqlite3))
|
||||
* Fedora / RHEL: `dnf install @development-tools`
|
||||
* Ubuntu / Debian: `apt install build-essential`
|
||||
* Windows: See [here](https://github.com/mattn/go-sqlite3/issues/214#issuecomment-253216476)
|
||||
|
||||
#### Compile & Run
|
||||
```bash
|
||||
# Build the executable
|
||||
$ go build -o wakapi
|
||||
# Build and install
|
||||
# Alternatively: go build -o wakapi
|
||||
$ go install github.com/muety/wakapi@latest
|
||||
|
||||
# Adapt config to your needs
|
||||
$ cp config.default.yml config.yml
|
||||
$ vi config.yml
|
||||
# Get default config and customize
|
||||
$ curl -o wakapi.yml https://raw.githubusercontent.com/muety/wakapi/master/config.default.yml
|
||||
$ vi wakapi.yml
|
||||
|
||||
# Run it
|
||||
$ ./wakapi
|
||||
$ ./wakapi -config wakapi.yml
|
||||
```
|
||||
|
||||
**Note:** Check the comments `config.yml` for best practices regarding security configuration and more.
|
||||
**Note:** Check the comments in `config.yml` for best practices regarding security configuration and more.
|
||||
|
||||
💡 When running Wakapi standalone (without Docker), it is recommended to run it as a [SystemD service](etc/wakapi.service).
|
||||
|
||||
### 💻 Client setup
|
||||
|
||||
### 💻 Client Setup
|
||||
Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime) client tools. In order to collect statistics for Wakapi, you need to set them up.
|
||||
|
||||
1. **Set up WakaTime** for your specific IDE or editor. Please refer to the respective [plugin guide](https://wakatime.com/plugins)
|
||||
2. **Editing your local `~/.wakatime.cfg`** file as follows
|
||||
2. **Edit your local `~/.wakatime.cfg`** file as follows.
|
||||
|
||||
```ini
|
||||
[settings]
|
||||
|
||||
# Your Wakapi server URL or 'https://wakapi.dev' when using the cloud server
|
||||
api_url = http://localhost:3000/api/heartbeat
|
||||
# Your Wakapi server URL or 'https://wakapi.dev/api' when using the cloud server
|
||||
api_url = http://localhost:3000/api
|
||||
|
||||
# Your Wakapi API key (get it from the web interface after having created an account)
|
||||
api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||
@ -120,14 +130,20 @@ api_key = 406fe41f-6d69-4183-a4cc-121e0c524c2b
|
||||
|
||||
Optionally, you can set up a [client-side proxy](https://github.com/muety/wakapi/wiki/Advanced-Setup:-Client-side-proxy) in addition.
|
||||
|
||||
## 🔧 Configuration Options
|
||||
## 🔧 Configuration options
|
||||
|
||||
You can specify configuration options either via a config file (default: `config.yml`, customizable through the `-c` argument) or via environment variables. Here is an overview of all options.
|
||||
|
||||
| YAML Key / Env. Variable | Default | Description |
|
||||
| YAML key / Env. variable | Default | Description |
|
||||
|------------------------------------------------------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `env` /<br>`ENVIRONMENT` | `dev` | Whether to use development- or production settings |
|
||||
| `app.aggregation_time` /<br>`WAKAPI_AGGREGATION_TIME` | `02:15` | Time of day at which to periodically run summary generation for all users |
|
||||
| `app.report_time_weekly` /<br>`WAKAPI_REPORT_TIME_WEEKLY` | `fri,18:00` | Week day and time at which to send e-mail reports |
|
||||
| `app.import_batch_size` /<br>`WAKAPI_IMPORT_BATCH_SIZE` | `50` | Size of batches of heartbeats to insert to the database during importing from external services |
|
||||
| `app.inactive_days` /<br>`WAKAPI_INACTIVE_DAYS` | `7` | Number of days after which to consider a user inactive (only for metrics) |
|
||||
| `app.heartbeat_max_age /`<br>`WAKAPI_HEARTBEAT_MAX_AGE` | `4320h` | Maximum acceptable age of a heartbeat (see [`ParseDuration`](https://pkg.go.dev/time#ParseDuration)) |
|
||||
| `app.custom_languages` | - | Map from file endings to language names |
|
||||
| `app.avatar_url_template` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
|
||||
| `app.avatar_url_template` /<br>`WAKAPI_AVATAR_URL_TEMPLATE` | (see [`config.default.yml`](config.default.yml)) | URL template for external user avatar images (e.g. from [Dicebear](https://dicebear.com) or [Gravatar](https://gravatar.com)) |
|
||||
| `server.port` /<br> `WAKAPI_PORT` | `3000` | Port to listen on |
|
||||
| `server.listen_ipv4` /<br> `WAKAPI_LISTEN_IPV4` | `127.0.0.1` | IPv4 network address to listen on (leave blank to disable IPv4) |
|
||||
| `server.listen_ipv6` /<br> `WAKAPI_LISTEN_IPV6` | `::1` | IPv6 network address to listen on (leave blank to disable IPv6) |
|
||||
@ -136,6 +152,7 @@ You can specify configuration options either via a config file (default: `config
|
||||
| `server.tls_cert_path` /<br> `WAKAPI_TLS_CERT_PATH` | - | Path of SSL server certificate (leave blank to not use HTTPS) |
|
||||
| `server.tls_key_path` /<br> `WAKAPI_TLS_KEY_PATH` | - | Path of SSL server private key (leave blank to not use HTTPS) |
|
||||
| `server.base_path` /<br> `WAKAPI_BASE_PATH` | `/` | Web base path (change when running behind a proxy under a sub-path) |
|
||||
| `server.public_url` /<br> `WAKAPI_PUBLIC_URL` | `http://localhost:3000` | URL at which your Wakapi instance can be found publicly |
|
||||
| `security.password_salt` /<br> `WAKAPI_PASSWORD_SALT` | - | Pepper to use for password hashing |
|
||||
| `security.insecure_cookies` /<br> `WAKAPI_INSECURE_COOKIES` | `false` | Whether or not to allow cookies over HTTP |
|
||||
| `security.cookie_max_age` /<br> `WAKAPI_COOKIE_MAX_AGE` | `172800` | Lifetime of authentication cookies in seconds or `0` to use [Session](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#Define_the_lifetime_of_a_cookie) cookies |
|
||||
@ -152,35 +169,47 @@ You can specify configuration options either via a config file (default: `config
|
||||
| `db.ssl` /<br> `WAKAPI_DB_SSL` | `false` | Whether to use TLS encryption for database connection (Postgres and CockroachDB only) |
|
||||
| `db.automgirate_fail_silently` /<br> `WAKAPI_DB_AUTOMIGRATE_FAIL_SILENTLY` | `false` | Whether to ignore schema auto-migration failures when starting up |
|
||||
| `mail.enabled` /<br> `WAKAPI_MAIL_ENABLED` | `true` | Whether to allow Wakapi to send e-mail (e.g. for password resets) |
|
||||
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `noreply@wakapi.dev` | Default sender address for outgoing mails (ignored for MailWhale) |
|
||||
| `mail.sender` /<br> `WAKAPI_MAIL_SENDER` | `Wakapi <noreply@wakapi.dev>` | Default sender address for outgoing mails (ignored for MailWhale) |
|
||||
| `mail.provider` /<br> `WAKAPI_MAIL_PROVIDER` | `smtp` | Implementation to use for sending mails (one of [`smtp`, `mailwhale`]) |
|
||||
| `mail.smtp.*` /<br> `WAKAPI_MAIL_SMTP_*` | `-` | Various options to configure SMTP. See [default config](config.default.yml) for details |
|
||||
| `mail.mailwhale.*` /<br> `WAKAPI_MAIL_MAILWHALE_*` | `-` | Various options to configure [MailWhale](https://mailwhale.dev) sending service. See [default config](config.default.yml) for details |
|
||||
| `mail.smtp.host` /<br> `WAKAPI_MAIL_SMTP_HOST` | - | SMTP server address for sending mail (if using `smtp` mail provider) |
|
||||
| `mail.smtp.port` /<br> `WAKAPI_MAIL_SMTP_PORT` | - | SMTP server port (usually 465) |
|
||||
| `mail.smtp.username` /<br> `WAKAPI_MAIL_SMTP_USER` | - | SMTP server authentication username |
|
||||
| `mail.smtp.password` /<br> `WAKAPI_MAIL_SMTP_PASS` | - | SMTP server authentication password |
|
||||
| `mail.smtp.tls` /<br> `WAKAPI_MAIL_SMTP_TLS` | `false` | Whether the SMTP server requires TLS encryption (`false` for STARTTLS or no encryption) |
|
||||
| `mail.mailwhale.url` /<br> `WAKAPI_MAIL_MAILWHALE_URL` | - | URL of [MailWhale](https://mailwhale.dev) instance (e.g. `https://mailwhale.dev`) (if using `mailwhale` mail provider) |
|
||||
| `mail.mailwhale.client_id` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_ID` | - | MailWhale API client ID |
|
||||
| `mail.mailwhale.client_secret` /<br> `WAKAPI_MAIL_MAILWHALE_CLIENT_SECRET` | - | MailWhale API client secret |
|
||||
| `sentry.dsn` /<br> `WAKAPI_SENTRY_DSN` | – | DSN for to integrate [Sentry](https://sentry.io) for error logging and tracing (leave empty to disable) |
|
||||
| `sentry.enable_tracing` /<br> `WAKAPI_SENTRY_TRACING` | `false` | Whether to enable Sentry request tracing |
|
||||
| `sentry.sample_rate` /<br> `WAKAPI_SENTRY_SAMPLE_RATE` | `0.75` | Probability of tracing a request in Sentry |
|
||||
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeats request in Sentry |
|
||||
| `sentry.sample_rate_heartbeats` /<br> `WAKAPI_SENTRY_SAMPLE_RATE_HEARTBEATS` | `0.1` | Probability of tracing a heartbeat request in Sentry |
|
||||
| `quick_start` /<br> `WAKAPI_QUICK_START` | `false` | Whether to skip initial boot tasks. Use only for development purposes! |
|
||||
|
||||
### Supported databases
|
||||
|
||||
Wakapi uses [GORM](https://gorm.io) as an ORM. As a consequence, a set of different relational databases is supported.
|
||||
|
||||
* [SQLite](https://sqlite.org/) (_default, easy setup_)
|
||||
* [MySQL](https://hub.docker.com/_/mysql) (_recommended, because most extensively tested_)
|
||||
* [MariaDB](https://hub.docker.com/_/mariadb) (_open-source MySQL alternative_)
|
||||
* [Postgres](https://hub.docker.com/_/postgres) (_open-source as well_)
|
||||
* [CockroachDB](https://www.cockroachlabs.com/docs/stable/install-cockroachdb-linux.html) (_cloud-native, distributed, Postgres-compatible API_)
|
||||
|
||||
## 🔧 API Endpoints
|
||||
## 🔧 API endpoints
|
||||
|
||||
See our [Swagger API Documentation](https://wakapi.dev/swagger-ui).
|
||||
|
||||
### Generating Swagger docs
|
||||
|
||||
```bash
|
||||
$ go get -u github.com/swaggo/swag/cmd/swag
|
||||
$ go install github.com/swaggo/swag/cmd/swag@latest
|
||||
$ swag init -o static/docs
|
||||
```
|
||||
|
||||
## 🤝 Integrations
|
||||
### Prometheus Export
|
||||
|
||||
### Prometheus export
|
||||
|
||||
You can export your Wakapi statistics to Prometheus to view them in a Grafana dashboard or so. Here is how.
|
||||
|
||||
```bash
|
||||
@ -195,6 +224,7 @@ $ echo "<YOUR_API_KEY>" | base64
|
||||
```
|
||||
|
||||
#### Scrape config example
|
||||
|
||||
```yml
|
||||
# prometheus.yml
|
||||
# (assuming your Wakapi instance listens at localhost, port 3000)
|
||||
@ -209,14 +239,17 @@ scrape_configs:
|
||||
```
|
||||
|
||||
#### Grafana
|
||||
|
||||
There is also a [nice Grafana dashboard](https://grafana.com/grafana/dashboards/12790), provided by the author of [wakatime_exporter](https://github.com/MacroPower/wakatime_exporter).
|
||||
|
||||

|
||||
|
||||
### WakaTime Integration
|
||||
### WakaTime integration
|
||||
|
||||
Wakapi plays well together with [WakaTime](https://wakatime.com). For one thing, you can **forward heartbeats** from Wakapi to WakaTime to effectively use both services simultaneously. In addition, there is the option to **import historic data** from WakaTime for consistency between both services. Both features can be enabled in the _Integrations_ section of your Wakapi instance's settings page.
|
||||
|
||||
### GitHub Readme Stats Integrations
|
||||
### GitHub Readme Stats integrations
|
||||
|
||||
Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra/github-readme-stats#wakatime-week-stats) to generate fancy cards for you. Here is an example.
|
||||
|
||||

|
||||
@ -224,20 +257,20 @@ Wakapi also integrates with [GitHub Readme Stats](https://github.com/anuraghazra
|
||||
<details>
|
||||
<summary>Click to view code</summary>
|
||||
|
||||
```md
|
||||
```markdown
|
||||

|
||||
```
|
||||
|
||||
</details>
|
||||
<br>
|
||||
|
||||
### Github Readme Metrics integration
|
||||
|
||||
### Github Readme Metrics Integration
|
||||
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi.
|
||||
There is a [WakaTime plugin](https://github.com/lowlighter/metrics/tree/master/source/plugins/wakatime) for GitHub [Metrics](https://github.com/lowlighter/metrics/) that is also compatible with Wakapi.
|
||||
|
||||
Preview:
|
||||
|
||||

|
||||

|
||||
|
||||
<details>
|
||||
<summary>Click to view code</summary>
|
||||
@ -259,20 +292,26 @@ Preview:
|
||||
</details>
|
||||
<br>
|
||||
|
||||
## 👍 Best Practices
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or _nginx_ to enable **TLS encryption** (HTTPS).
|
||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`
|
||||
## 👍 Best practices
|
||||
|
||||
It is recommended to use wakapi behind a **reverse proxy**, like [Caddy](https://caddyserver.com) or [nginx](https://www.nginx.com/), to enable **TLS encryption** (HTTPS).
|
||||
|
||||
However, if you want to expose your wakapi instance to the public anyway, you need to set `server.listen_ipv4` to `0.0.0.0` in `config.yml`.
|
||||
|
||||
## 🧪 Tests
|
||||
### Unit Tests
|
||||
|
||||
### Unit tests
|
||||
|
||||
Unit tests are supposed to test business logic on a fine-grained level. They are implemented as part of the application, using Go's [testing](https://pkg.go.dev/testing?utm_source=godoc) package alongside [stretchr/testify](https://pkg.go.dev/github.com/stretchr/testify).
|
||||
|
||||
#### How to run
|
||||
|
||||
```bash
|
||||
$ CGO_FLAGS="-g -O2 -Wno-return-local-addr" go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
$ CGO_ENABLED=0 go test -json -coverprofile=coverage/coverage.out ./... -run ./...
|
||||
```
|
||||
|
||||
### API Tests
|
||||
### API tests
|
||||
|
||||
API tests are implemented as black box tests, which interact with a fully-fledged, standalone Wakapi through HTTP requests. They are supposed to check Wakapi's web stack and endpoints, including response codes, headers and data on a syntactical level, rather than checking the actual content that is returned.
|
||||
|
||||
Our API (or end-to-end, in some way) tests are implemented as a [Postman](https://www.postman.com/) collection and can be run either from inside Postman, or using [newman](https://www.npmjs.com/package/newman) as a command-line runner.
|
||||
@ -280,6 +319,7 @@ Our API (or end-to-end, in some way) tests are implemented as a [Postman](https:
|
||||
To get a predictable environment, tests are run against a fresh and clean Wakapi instance with a SQLite database that is populated with nothing but some seed data (see [data.sql](testing/data.sql)). It is usually recommended for software tests to be [safe](https://www.restapitutorial.com/lessons/idempotency.html), stateless and without side effects. In contrary to that paradigm, our API tests strictly require a fixed execution order (which Postman assures) and their assertions may rely on specific previous tests having succeeded.
|
||||
|
||||
#### Prerequisites (Linux only)
|
||||
|
||||
```bash
|
||||
# 1. sqlite (cli)
|
||||
$ sudo apt install sqlite # Fedora: sudo dnf install sqlite
|
||||
@ -289,12 +329,15 @@ $ npm install -g newman
|
||||
```
|
||||
|
||||
#### How to run (Linux only)
|
||||
|
||||
```bash
|
||||
$ ./testing/run_api_tests.sh
|
||||
```
|
||||
|
||||
## 🤓 Developer Notes
|
||||
## 🤓 Developer notes
|
||||
|
||||
### Building web assets
|
||||
|
||||
To keep things minimal, all JS and CSS assets are included as static files and checked in to Git. [TailwindCSS](https://tailwindcss.com/docs/installation#building-for-production) and [Iconify](https://iconify.design/docs/icon-bundles/) require an additional build step. To only require this at the time of development, the compiled assets are checked in to Git as well.
|
||||
|
||||
```bash
|
||||
@ -305,9 +348,11 @@ $ yarn build # or: yarn watch
|
||||
New icons can be added by editing the `icons` array in [scripts/bundle_icons.js](scripts/bundle_icons.js).
|
||||
|
||||
#### Precompression
|
||||
|
||||
As explained in [#284](https://github.com/muety/wakapi/issues/284), precompressed (using Brotli) versions of some of the assets are delivered to save additional bandwidth. This was inspired by Caddy's [`precompressed`](https://caddyserver.com/docs/caddyfile/directives/file_server) directive. [`gzipped.FileServer`](https://github.com/muety/wakapi/blob/07a367ce0a97c7738ba8e255e9c72df273fd43a3/main.go#L249) checks for every static file's `.br` or `.gz` equivalents and, if present, delivers those instead of the actual file, alongside `Content-Encoding: br`. Currently, compressed assets are simply checked in to Git. Later we might want to have this be part of a new build step.
|
||||
|
||||
To pre-compress files, run this:
|
||||
|
||||
```bash
|
||||
# Install brotli first
|
||||
$ sudo apt install brotli # or: sudo dnf install brotli
|
||||
@ -323,16 +368,17 @@ $ yarn compress
|
||||
```
|
||||
|
||||
## ❔ FAQs
|
||||
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) apply to Wakapi for large parts as well. You might find answers there.
|
||||
|
||||
Since Wakapi heavily relies on the concepts provided by WakaTime, [their FAQs](https://wakatime.com/faq) largely apply to Wakapi as well. You might find answers there.
|
||||
|
||||
<details>
|
||||
<summary><b>What data is sent to Wakapi?</b></summary>
|
||||
<summary><b>What data are sent to Wakapi?</b></summary>
|
||||
|
||||
<ul>
|
||||
<li>File names</li>
|
||||
<li>Project names</li>
|
||||
<li>Editor names</li>
|
||||
<li>You computer's host name</li>
|
||||
<li>Your computer's host name</li>
|
||||
<li>Timestamps for every action you take in your editor</li>
|
||||
<li>...</li>
|
||||
</ul>
|
||||
@ -345,7 +391,7 @@ If you host Wakapi yourself, you have control over all your data. However, if yo
|
||||
<details>
|
||||
<summary><b>What happens if I'm offline?</b></summary>
|
||||
|
||||
All data is cached locally on your machine and sent in batches once you're online again.
|
||||
All data are cached locally on your machine and sent in batches once you're online again.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@ -364,11 +410,11 @@ Wakapi is a small subset of WakaTime and has a lot less features. Cool WakaTime
|
||||
<li><a href="https://wakatime.com/share/embed">Embeddable Charts</a></li>
|
||||
<li>Personal Goals</li>
|
||||
<li>Team- / Organization Support</li>
|
||||
<li>Integrations (with GitLab, etc.)</li>
|
||||
<li>Additional Integrations (with GitLab, etc.)</li>
|
||||
<li>Richer API</li>
|
||||
</ul>
|
||||
|
||||
WakaTime is worth the price. However, if you only want basic statistics and keep sovereignty over your data, you might want to go with Wakapi.
|
||||
WakaTime is worth the price. However, if you only need basic statistics and like to keep sovereignty over your data, you might want to go with Wakapi.
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@ -378,13 +424,13 @@ Inferring a measure for your coding time from heartbeats works a bit differently
|
||||
|
||||
Here is an example (circles are heartbeats):
|
||||
|
||||
```
|
||||
```text
|
||||
|---o---o--------------o---o---|
|
||||
| |10s| 3m |10s| |
|
||||
|
||||
```
|
||||
|
||||
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break or were just no heartbeats being sent, e.g. because the developer was starring at the screen trying to find a solution, but not actually typing code.
|
||||
It is unclear how to handle the three minutes in between. Did the developer do a 3-minute break, or were just no heartbeats being sent, e.g. because the developer was staring at the screen trying to find a solution, but not actually typing code?
|
||||
|
||||
<ul>
|
||||
<li><b>WakaTime</b> (with 5 min timeout): 3 min 20 sec
|
||||
@ -396,12 +442,15 @@ Wakapi adds a "padding" of two minutes before the third heartbeat. This is why t
|
||||
</details>
|
||||
|
||||
## 🌳 Treeware
|
||||
|
||||
This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**](https://plant.treeware.earth/muety/wakapi) to thank us for our work. By contributing to the Treeware forest you’ll be creating employment for local families and restoring wildlife habitats.
|
||||
|
||||
## 👏 Support
|
||||
|
||||
Coding in open source is my passion and I would love to do it on a full-time basis and make a living from it one day. So if you like this project, please consider supporting it 🙂. You can donate either through [buying me a coffee](https://buymeacoff.ee/n1try) or becoming a GitHub sponsor. Every little donation is highly appreciated and boosts my motivation to keep improving Wakapi!
|
||||
|
||||
## 🙏 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.
|
||||
|
||||
Moreover, thanks to **[JetBrains](https://jb.gg/OpenSource)** for supporting this project as part of their open-source program.
|
||||
@ -409,4 +458,5 @@ Moreover, thanks to **[JetBrains](https://jb.gg/OpenSource)** for supporting thi
|
||||

|
||||
|
||||
## 📓 License
|
||||
|
||||
GPL-v3 @ [Ferdinand Mütsch](https://muetsch.io)
|
||||
|
@ -13,9 +13,11 @@ server:
|
||||
|
||||
app:
|
||||
aggregation_time: '02:15' # time at which to run daily aggregation batch jobs
|
||||
leaderboard_generation_time: '06:00;18:00' # time at which to run daily aggregation batch jobs
|
||||
report_time_weekly: 'fri,18:00' # time at which to fan out weekly reports (format: '<weekday)>,<daytime>')
|
||||
inactive_days: 7 # time of previous days within a user must have logged in to be considered active
|
||||
import_batch_size: 50 # maximum number of heartbeats to insert into the database within one transaction
|
||||
heartbeat_max_age: '4320h' # maximum acceptable age of a heartbeat (see https://pkg.go.dev/time#ParseDuration)
|
||||
custom_languages:
|
||||
vue: Vue
|
||||
jsx: JSX
|
||||
@ -23,7 +25,8 @@ app:
|
||||
|
||||
# url template for user avatar images (to be used with services like gravatar or dicebear)
|
||||
# available variable placeholders are: username, username_hash, email, email_hash
|
||||
avatar_url_template: https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg
|
||||
# defaults to wakapi's internal avatar rendering powered by https://codeberg.org/Codeberg/avatars
|
||||
avatar_url_template: api/avatar/{username_hash}.svg
|
||||
|
||||
db:
|
||||
host: # leave blank when using sqlite3
|
||||
@ -71,3 +74,4 @@ mail:
|
||||
client_secret:
|
||||
|
||||
quick_start: false # whether to skip initial tasks on application startup, like summary generation
|
||||
skip_migrations: false # whether to intentionally not run database migrations, only use for dev purposes
|
@ -4,10 +4,10 @@ import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -16,6 +16,7 @@ import (
|
||||
"github.com/jinzhu/configor"
|
||||
"github.com/muety/wakapi/data"
|
||||
"github.com/muety/wakapi/models"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@ -29,6 +30,7 @@ const (
|
||||
KeyLatestTotalTime = "latest_total_time"
|
||||
KeyLatestTotalUsers = "latest_total_users"
|
||||
KeyLastImportImport = "last_import"
|
||||
KeyNewsbox = "newsbox"
|
||||
|
||||
SimpleDateFormat = "2006-01-02"
|
||||
SimpleDateTimeFormat = "2006-01-02 15:04:05"
|
||||
@ -64,12 +66,14 @@ var env string
|
||||
|
||||
type appConfig struct {
|
||||
AggregationTime string `yaml:"aggregation_time" default:"02:15" env:"WAKAPI_AGGREGATION_TIME"`
|
||||
LeaderboardGenerationTime string `yaml:"leaderboard_generation_time" default:"06:00;18:00" env:"WAKAPI_LEADERBOARD_GENERATION_TIME"`
|
||||
ReportTimeWeekly string `yaml:"report_time_weekly" default:"fri,18:00" env:"WAKAPI_REPORT_TIME_WEEKLY"`
|
||||
ImportBackoffMin int `yaml:"import_backoff_min" default:"5" env:"WAKAPI_IMPORT_BACKOFF_MIN"`
|
||||
ImportBatchSize int `yaml:"import_batch_size" default:"50" env:"WAKAPI_IMPORT_BATCH_SIZE"`
|
||||
InactiveDays int `yaml:"inactive_days" default:"7" env:"WAKAPI_INACTIVE_DAYS"`
|
||||
HeartbeatMaxAge string `yaml:"heartbeat_max_age" default:"4320h" env:"WAKAPI_HEARTBEAT_MAX_AGE"`
|
||||
CountCacheTTLMin int `yaml:"count_cache_ttl_min" default:"30" env:"WAKAPI_COUNT_CACHE_TTL_MIN"`
|
||||
AvatarURLTemplate string `yaml:"avatar_url_template" default:"https://avatars.dicebear.com/api/pixel-art-neutral/{username_hash}.svg"`
|
||||
AvatarURLTemplate string `yaml:"avatar_url_template" default:"api/avatar/{username_hash}.svg" env:"WAKAPI_AVATAR_URL_TEMPLATE"`
|
||||
CustomLanguages map[string]string `yaml:"custom_languages"`
|
||||
Colors map[string]map[string]string `yaml:"-"`
|
||||
}
|
||||
@ -144,6 +148,7 @@ type Config struct {
|
||||
Env string `default:"dev" env:"ENVIRONMENT"`
|
||||
Version string `yaml:"-"`
|
||||
QuickStart bool `yaml:"quick_start" env:"WAKAPI_QUICK_START"`
|
||||
SkipMigrations bool `yaml:"skip_migrations" env:"WAKAPI_SKIP_MIGRATIONS"`
|
||||
InstanceId string `yaml:"-"` // only temporary, changes between runs
|
||||
App appConfig
|
||||
Security securityConfig
|
||||
@ -212,6 +217,9 @@ func (c *Config) GetMigrationFunc(dbDialect string) models.MigrationFunc {
|
||||
if err := db.AutoMigrate(&models.Diagnostics{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
if err := db.AutoMigrate(&models.LeaderboardItem{}); err != nil && !c.Db.AutoMigrateFailSilently {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@ -242,6 +250,11 @@ func (c *appConfig) GetWeeklyReportTime() string {
|
||||
return strings.Split(c.ReportTimeWeekly, ",")[1]
|
||||
}
|
||||
|
||||
func (c *appConfig) HeartbeatsMaxAge() time.Duration {
|
||||
d, _ := time.ParseDuration(c.HeartbeatMaxAge)
|
||||
return d
|
||||
}
|
||||
|
||||
func (c *dbConfig) IsSQLite() bool {
|
||||
return c.Dialect == "sqlite3"
|
||||
}
|
||||
@ -269,12 +282,12 @@ func IsDev(env string) bool {
|
||||
func readColors() map[string]map[string]string {
|
||||
// Read language colors
|
||||
// Source:
|
||||
// – https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
// – https://wakatime.com/colors/operating_systems
|
||||
// - https://raw.githubusercontent.com/ozh/github-colors/master/colors.json
|
||||
// - https://wakatime.com/colors/operating_systems
|
||||
// - https://wakatime.com/colors/editors
|
||||
// Extracted from Wakatime website with XPath (see below) and did a bit of regex magic after.
|
||||
// – $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||
// – $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||
// - $x('//span[@class="editor-icon tip"]/@data-original-title').map(e => e.nodeValue)
|
||||
// - $x('//span[@class="editor-icon tip"]/div[1]/text()').map(e => e.nodeValue)
|
||||
|
||||
raw := data.ColorsFile
|
||||
if IsDev(env) {
|
||||
@ -356,7 +369,13 @@ func Load(version string) *Config {
|
||||
}
|
||||
|
||||
env = config.Env
|
||||
|
||||
config.Version = strings.TrimSpace(version)
|
||||
tagVersionMatch, _ := regexp.MatchString(`\d+\.\d+\.\d+`, version)
|
||||
if tagVersionMatch {
|
||||
config.Version = "v" + config.Version
|
||||
}
|
||||
|
||||
config.InstanceId = uuid.NewV4().String()
|
||||
config.App.Colors = readColors()
|
||||
config.Db.Dialect = resolveDbDialect(config.Db.Type)
|
||||
@ -400,6 +419,9 @@ func Load(version string) *Config {
|
||||
if _, err := time.Parse("15:04", config.App.AggregationTime); err != nil {
|
||||
logbuch.Fatal("invalid interval set for aggregation_time")
|
||||
}
|
||||
if _, err := time.ParseDuration(config.App.HeartbeatMaxAge); err != nil {
|
||||
logbuch.Fatal("invalid duration set for heartbeat_max_age")
|
||||
}
|
||||
|
||||
Set(config)
|
||||
return Get()
|
||||
|
@ -2,9 +2,9 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
@ -111,6 +111,7 @@ func initSentry(config sentryConfig, debug bool) {
|
||||
if err := sentry.Init(sentry.ClientOptions{
|
||||
Dsn: config.Dsn,
|
||||
Debug: debug,
|
||||
AttachStacktrace: true,
|
||||
TracesSampler: sentry.TracesSamplerFunc(func(ctx sentry.SamplingContext) sentry.Sampled {
|
||||
if !config.EnableTracing {
|
||||
return sentry.SampledFalse
|
||||
@ -140,7 +141,7 @@ func initSentry(config sentryConfig, debug bool) {
|
||||
return event
|
||||
},
|
||||
}); err != nil {
|
||||
logbuch.Fatal("failed to initialized sentry – %v", err)
|
||||
logbuch.Fatal("failed to initialized sentry - %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -9,4 +9,5 @@ const (
|
||||
ResetPasswordTemplate = "reset-password.tpl.html"
|
||||
SettingsTemplate = "settings.tpl.html"
|
||||
SummaryTemplate = "summary.tpl.html"
|
||||
LeaderboardTemplate = "leaderboard.tpl.html"
|
||||
)
|
||||
|
File diff suppressed because it is too large
Load Diff
389
data/colors.json
389
data/colors.json
@ -2,246 +2,417 @@
|
||||
"languages": {
|
||||
"1C Enterprise": "#814CCC",
|
||||
"ABAP": "#E8274B",
|
||||
"ActionScript": "#882B0F",
|
||||
"Ada": "#02f88c",
|
||||
"Agda": "#315665",
|
||||
"AGS Script": "#B9D9FF",
|
||||
"Alloy": "#64C800",
|
||||
"AL": "#3AA2B5",
|
||||
"AMPL": "#E6EFBB",
|
||||
"AngelScript": "#C7D7DC",
|
||||
"ANTLR": "#9DC3FF",
|
||||
"API Blueprint": "#2ACCA8",
|
||||
"APL": "#5A8164",
|
||||
"AppleScript": "#101F1F",
|
||||
"Arc": "#aa2afe",
|
||||
"ASP": "#6a40fd",
|
||||
"AspectJ": "#a957b0",
|
||||
"Assembly": "#6E4C13",
|
||||
"Asymptote": "#4a0c0c",
|
||||
"APL": "#8a0707",
|
||||
"ASP.NET": "#9400ff",
|
||||
"ATS": "#1ac620",
|
||||
"ActionScript": "#e3491a",
|
||||
"Ada": "#02f88c",
|
||||
"Agda": "#467C91",
|
||||
"Alloy": "#cc5c24",
|
||||
"AngelScript": "#C7D7DC",
|
||||
"Apex": "#1797c0",
|
||||
"Apollo Guidance Computer": "#0B3D91",
|
||||
"AppleScript": "#101F1F",
|
||||
"Arc": "#ca2afe",
|
||||
"Arduino": "#bd79d1",
|
||||
"AspectJ": "#1957b0",
|
||||
"Assembly": "#6E4C13",
|
||||
"Asymptote": "#ff0000",
|
||||
"Augeas": "#62331f",
|
||||
"AutoHotkey": "#6594b9",
|
||||
"AutoIt": "#1C3552",
|
||||
"AutoIt": "#36699B",
|
||||
"Ballerina": "#FF5000",
|
||||
"Batchfile": "#C1F12E",
|
||||
"Beef": "#a52f4e",
|
||||
"Bison": "#6A463F",
|
||||
"Blade": "#f7523f",
|
||||
"BlitzMax": "#cd6400",
|
||||
"Boo": "#d4bec1",
|
||||
"Boogie": "#c80fa0",
|
||||
"Brainfuck": "#2F2530",
|
||||
"Browserslist": "#ffd539",
|
||||
"C": "#555555",
|
||||
"C#": "#178600",
|
||||
"C Sharp": "#178600",
|
||||
"C#": "#5a25a2",
|
||||
"C++": "#f34b7d",
|
||||
"CSON": "#244776",
|
||||
"CSS": "#563d7c",
|
||||
"Ceylon": "#dfa535",
|
||||
"Chapel": "#8dc63f",
|
||||
"Cirru": "#ccccff",
|
||||
"Cirru": "#aaaaff",
|
||||
"Clarion": "#db901e",
|
||||
"Clean": "#3F85AF",
|
||||
"Classic ASP": "#6a40fd",
|
||||
"Clean": "#3a81ad",
|
||||
"Click": "#E4E6F3",
|
||||
"Clojure": "#db5855",
|
||||
"Closure Templates": "#0d948f",
|
||||
"CoffeeScript": "#244776",
|
||||
"ColdFusion": "#ed2cd6",
|
||||
"ColdFusion CFC": "#ed2cd6",
|
||||
"Common Lisp": "#3fb68b",
|
||||
"Common Workflow Language": "#B5314C",
|
||||
"Component Pascal": "#B0CE4E",
|
||||
"Component Pascal": "#b0ce4e",
|
||||
"Crystal": "#000100",
|
||||
"CSS": "#563d7c",
|
||||
"Cuda": "#3A4E3A",
|
||||
"D": "#ba595e",
|
||||
"Dart": "#00B4AB",
|
||||
"D": "#fcd46d",
|
||||
"DM": "#075ff1",
|
||||
"Dafny": "#FFEC25",
|
||||
"Dart": "#98BAD6",
|
||||
"DataWeave": "#003a52",
|
||||
"DM": "#447265",
|
||||
"Denizen": "#faf094",
|
||||
"Dhall": "#dfafff",
|
||||
"Dockerfile": "#384d54",
|
||||
"Docker": "#384d54",
|
||||
"Dogescript": "#cca760",
|
||||
"Dylan": "#6c616e",
|
||||
"Dylan": "#3ebc27",
|
||||
"E": "#ccce35",
|
||||
"eC": "#913960",
|
||||
"ECL": "#8a1267",
|
||||
"EJS": "#a91e50",
|
||||
"EQ": "#a78649",
|
||||
"Eagle": "#3994bc",
|
||||
"Eiffel": "#946d57",
|
||||
"Elixir": "#6e4a7e",
|
||||
"Elm": "#60B5CC",
|
||||
"Emacs Lisp": "#c065db",
|
||||
"EmberScript": "#FFF4F3",
|
||||
"EQ": "#a78649",
|
||||
"Erlang": "#B83998",
|
||||
"EmberScript": "#f64e3e",
|
||||
"Erlang": "#0faf8d",
|
||||
"F#": "#b845fc",
|
||||
"F*": "#572e30",
|
||||
"FLUX": "#33CCFF",
|
||||
"FORTRAN": "#4d41b1",
|
||||
"Factor": "#636746",
|
||||
"Fancy": "#7b9db4",
|
||||
"Fantom": "#14253c",
|
||||
"FLUX": "#88ccff",
|
||||
"Fantom": "#dbded5",
|
||||
"Faust": "#c37240",
|
||||
"Forth": "#341708",
|
||||
"Fortran": "#4d41b1",
|
||||
"FreeMarker": "#0050b2",
|
||||
"Frege": "#00cafe",
|
||||
"Game Maker Language": "#71b417",
|
||||
"Futhark": "#5f021f",
|
||||
"G-code": "#D08CF2",
|
||||
"GAML": "#FFC766",
|
||||
"GDScript": "#355570",
|
||||
"Game Maker Language": "#8ad353",
|
||||
"Genie": "#fb855d",
|
||||
"Gherkin": "#5B2063",
|
||||
"Glyph": "#c1ac7f",
|
||||
"Glyph": "#e4cc98",
|
||||
"Gnuplot": "#f0a9f0",
|
||||
"Go": "#00ADD8",
|
||||
"Golo": "#88562A",
|
||||
"Go": "#375eab",
|
||||
"Golo": "#f6a51f",
|
||||
"Gosu": "#82937f",
|
||||
"Grammatical Framework": "#79aa7a",
|
||||
"Grammatical Framework": "#ff0000",
|
||||
"GraphQL": "#e10098",
|
||||
"Groovy": "#e69f56",
|
||||
"HTML": "#e44b23",
|
||||
"Hack": "#878787",
|
||||
"Haml": "#ece2a9",
|
||||
"Handlebars": "#f7931e",
|
||||
"Harbour": "#0e60e3",
|
||||
"Haskell": "#5e5086",
|
||||
"Haxe": "#df7900",
|
||||
"Haskell": "#29b544",
|
||||
"Haxe": "#f7941e",
|
||||
"HiveQL": "#dce200",
|
||||
"HTML": "#e34c26",
|
||||
"Hy": "#7790B2",
|
||||
"IDL": "#a3522f",
|
||||
"HolyC": "#ffefaf",
|
||||
"Hy": "#7891b1",
|
||||
"IDL": "#e3592c",
|
||||
"IGOR Pro": "#0000cc",
|
||||
"Idris": "#b30000",
|
||||
"ImageJ Macro": "#99AAFF",
|
||||
"Io": "#a9188d",
|
||||
"Ioke": "#078193",
|
||||
"Isabelle": "#FEFE00",
|
||||
"Isabelle": "#fdcd00",
|
||||
"J": "#9EEDFF",
|
||||
"JFlex": "#DBCA00",
|
||||
"JSONiq": "#40d47e",
|
||||
"Java": "#b07219",
|
||||
"JavaScript": "#f1e05a",
|
||||
"Jolie": "#843179",
|
||||
"JSONiq": "#40d47e",
|
||||
"Jsonnet": "#0064bd",
|
||||
"Julia": "#a270ba",
|
||||
"Jupyter Notebook": "#DA5B0B",
|
||||
"KRL": "#f5c800",
|
||||
"Kaitai Struct": "#773b37",
|
||||
"Kotlin": "#F18E33",
|
||||
"KRL": "#28430A",
|
||||
"Lasso": "#999999",
|
||||
"Lex": "#DBCA00",
|
||||
"LFE": "#4C3023",
|
||||
"LiveScript": "#499886",
|
||||
"LFE": "#004200",
|
||||
"LLVM": "#185619",
|
||||
"LOLCODE": "#cc9900",
|
||||
"LookML": "#652B81",
|
||||
"LSL": "#3d9970",
|
||||
"Lua": "#000080",
|
||||
"Makefile": "#427819",
|
||||
"Mask": "#f97732",
|
||||
"Lark": "#0b130f",
|
||||
"Lasso": "#2584c3",
|
||||
"Latte": "#A8FF97",
|
||||
"Less": "#1d365d",
|
||||
"Lex": "#DBCA00",
|
||||
"Liquid": "#67b8de",
|
||||
"LiveScript": "#499886",
|
||||
"LookML": "#652B81",
|
||||
"Lua": "#fa1fa1",
|
||||
"MATLAB": "#e16737",
|
||||
"Max": "#c4a79c",
|
||||
"MAXScript": "#00a6a6",
|
||||
"mcfunction": "#E22837",
|
||||
"Mercury": "#ff2b2b",
|
||||
"MLIR": "#5EC8DB",
|
||||
"MQL4": "#62A8D6",
|
||||
"MQL5": "#4A76B8",
|
||||
"MTML": "#0095d9",
|
||||
"Macaulay2": "#d8ffff",
|
||||
"Makefile": "#427819",
|
||||
"Markdown": "#083fa1",
|
||||
"Marko": "#42bff2",
|
||||
"Mask": "#f97732",
|
||||
"Matlab": "#bb92ac",
|
||||
"Max": "#ce279c",
|
||||
"Mercury": "#abcdef",
|
||||
"Meson": "#007800",
|
||||
"Metal": "#8f14e9",
|
||||
"Mirah": "#c7a938",
|
||||
"Modula-3": "#223388",
|
||||
"MQL4": "#62A8D6",
|
||||
"MQL5": "#4A76B8",
|
||||
"MTML": "#b7e1f4",
|
||||
"Mustache": "#724b3b",
|
||||
"NCL": "#28431f",
|
||||
"NWScript": "#111522",
|
||||
"Nearley": "#990000",
|
||||
"Nemerle": "#3d3c6e",
|
||||
"nesC": "#94B0C7",
|
||||
"Nemerle": "#0d3c6e",
|
||||
"NetLinx": "#0aa0ff",
|
||||
"NetLinx+ERB": "#747faa",
|
||||
"NetLogo": "#ff6375",
|
||||
"NewLisp": "#87AED7",
|
||||
"NetLogo": "#ff2b2b",
|
||||
"NewLisp": "#eedd66",
|
||||
"Nextflow": "#3ac486",
|
||||
"Nim": "#37775b",
|
||||
"Nit": "#009917",
|
||||
"Nix": "#7e7eff",
|
||||
"Nim": "#ffc200",
|
||||
"Nimrod": "#37775b",
|
||||
"Nit": "#0d8921",
|
||||
"Nix": "#7070ff",
|
||||
"Nu": "#c9df40",
|
||||
"Objective-C": "#438eff",
|
||||
"Objective-C++": "#6866fb",
|
||||
"Objective-J": "#ff0c5a",
|
||||
"NumPy": "#9C8AF9",
|
||||
"Nunjucks": "#3d8137",
|
||||
"OCaml": "#3be133",
|
||||
"ObjectScript": "#424893",
|
||||
"Objective-C": "#438eff",
|
||||
"Objective-C++": "#4886FC",
|
||||
"Objective-J": "#ff0c5a",
|
||||
"Odin": "#60AFFE",
|
||||
"Omgrofl": "#cabbff",
|
||||
"ooc": "#b0b77e",
|
||||
"Opal": "#f7ede0",
|
||||
"Oxygene": "#cdd0e3",
|
||||
"Oz": "#fab738",
|
||||
"OpenQASM": "#AA70FF",
|
||||
"Org": "#77aa99",
|
||||
"Oxygene": "#5a63a3",
|
||||
"Oz": "#fcaf3e",
|
||||
"P4": "#7055b5",
|
||||
"PAWN": "#dbb284",
|
||||
"PHP": "#4F5D95",
|
||||
"PLSQL": "#dad8d8",
|
||||
"Pan": "#cc0000",
|
||||
"Papyrus": "#6600cc",
|
||||
"Parrot": "#f3ca0a",
|
||||
"Pascal": "#E3F171",
|
||||
"Pascal": "#b0ce4e",
|
||||
"Pawn": "#dbb284",
|
||||
"Pep8": "#C76F5B",
|
||||
"Perl": "#0298c3",
|
||||
"Perl 6": "#0000fb",
|
||||
"PHP": "#4F5D95",
|
||||
"Perl6": "#0298c3",
|
||||
"PigLatin": "#fcd7de",
|
||||
"Pike": "#005390",
|
||||
"PLSQL": "#dad8d8",
|
||||
"Pike": "#066ab2",
|
||||
"PogoScript": "#d80074",
|
||||
"PostScript": "#da291c",
|
||||
"PowerBuilder": "#8f0f8d",
|
||||
"PowerShell": "#012456",
|
||||
"Processing": "#0096D8",
|
||||
"Prisma": "#0c344b",
|
||||
"Processing": "#2779ab",
|
||||
"Prolog": "#74283c",
|
||||
"Propeller Spin": "#7fa2a7",
|
||||
"Puppet": "#302B6D",
|
||||
"Propeller Spin": "#2b446d",
|
||||
"Pug": "#a86454",
|
||||
"Puppet": "#cc5555",
|
||||
"Pure Data": "#91de79",
|
||||
"PureBasic": "#5a6986",
|
||||
"PureScript": "#1D222D",
|
||||
"Python": "#3572A5",
|
||||
"q": "#0040cd",
|
||||
"PureScript": "#bcdc53",
|
||||
"Python": "#3581ba",
|
||||
"Q#": "#fed659",
|
||||
"QML": "#44a51c",
|
||||
"Qt Script": "#00b841",
|
||||
"Quake": "#882233",
|
||||
"R": "#198CE7",
|
||||
"Racket": "#3c5caa",
|
||||
"Ragel": "#9d5200",
|
||||
"R": "#198ce7",
|
||||
"RAML": "#77d9fb",
|
||||
"RUNOFF": "#665a4e",
|
||||
"Racket": "#ae17ff",
|
||||
"Ragel": "#9d5200",
|
||||
"Ragel in Ruby Host": "#ff9c2e",
|
||||
"Raku": "#0000fb",
|
||||
"Rascal": "#fffaa0",
|
||||
"ReScript": "#ed5051",
|
||||
"Reason": "#ff5847",
|
||||
"Rebol": "#358a5b",
|
||||
"Red": "#f50000",
|
||||
"Record Jar": "#0673ba",
|
||||
"Red": "#ee0000",
|
||||
"Ren'Py": "#ff7f7f",
|
||||
"Ring": "#2D54CB",
|
||||
"Riot": "#A71E49",
|
||||
"Roff": "#ecdebe",
|
||||
"Rouge": "#cc0088",
|
||||
"Ruby": "#701516",
|
||||
"RUNOFF": "#665a4e",
|
||||
"Rust": "#dea584",
|
||||
"SAS": "#1E90FF",
|
||||
"SCSS": "#c6538c",
|
||||
"SQF": "#FFCB1F",
|
||||
"SRecode Template": "#348a34",
|
||||
"SVG": "#ff9900",
|
||||
"SaltStack": "#646464",
|
||||
"SAS": "#B34936",
|
||||
"Scala": "#c22d40",
|
||||
"Sass": "#a53b70",
|
||||
"Scala": "#7dd3b0",
|
||||
"Scaml": "#bd181a",
|
||||
"Scheme": "#1e4aec",
|
||||
"sed": "#64b970",
|
||||
"Self": "#0579aa",
|
||||
"Shell": "#89e051",
|
||||
"Shell": "#5861ce",
|
||||
"Shen": "#120F14",
|
||||
"Slash": "#007eff",
|
||||
"Slice": "#003fa2",
|
||||
"Slim": "#ff8877",
|
||||
"SmPL": "#c94949",
|
||||
"Smalltalk": "#596706",
|
||||
"Solidity": "#AA6746",
|
||||
"SourcePawn": "#5c7611",
|
||||
"SQF": "#3F3F3F",
|
||||
"SourcePawn": "#f69e1d",
|
||||
"Squirrel": "#800000",
|
||||
"SRecode Template": "#348a34",
|
||||
"Stan": "#b2011d",
|
||||
"Standard ML": "#dc566d",
|
||||
"Starlark": "#76d275",
|
||||
"Stylus": "#ff6347",
|
||||
"SuperCollider": "#46390b",
|
||||
"Svelte": "#ff3e00",
|
||||
"Swift": "#ffac45",
|
||||
"SystemVerilog": "#DAE1C2",
|
||||
"Tcl": "#e4cc98",
|
||||
"Terra": "#00004c",
|
||||
"TeX": "#3D6117",
|
||||
"SystemVerilog": "#343761",
|
||||
"TI Program": "#A0AA87",
|
||||
"Turing": "#cf142b",
|
||||
"TypeScript": "#2b7489",
|
||||
"Tcl": "#e4cc98",
|
||||
"TeX": "#3D6117",
|
||||
"Terra": "#00004c",
|
||||
"Turing": "#45f715",
|
||||
"Twig": "#c1d026",
|
||||
"TypeScript": "#31859c",
|
||||
"Unified Parallel C": "#755223",
|
||||
"Uno": "#9933cc",
|
||||
"UnrealScript": "#a54c4d",
|
||||
"Vala": "#fbe5cd",
|
||||
"VCL": "#148AA8",
|
||||
"Verilog": "#b2b7f8",
|
||||
"VHDL": "#adb2cb",
|
||||
"V": "#4f87c4",
|
||||
"VBA": "#867db1",
|
||||
"VBScript": "#15dcdc",
|
||||
"VCL": "#0298c3",
|
||||
"VHDL": "#543978",
|
||||
"Vala": "#ee7d06",
|
||||
"Verilog": "#848bf3",
|
||||
"Vim script": "#199f4b",
|
||||
"VimL": "#199c4b",
|
||||
"Visual Basic": "#945db7",
|
||||
"Volt": "#1F1F1F",
|
||||
"Visual Basic .NET": "#945db7",
|
||||
"Volt": "#0098db",
|
||||
"Vue": "#2c3e50",
|
||||
"wdl": "#42f1f4",
|
||||
"Web Ontology Language": "#3994bc",
|
||||
"WebAssembly": "#04133b",
|
||||
"wisp": "#7582D1",
|
||||
"Wollok": "#a23738",
|
||||
"X10": "#4B6BEF",
|
||||
"xBase": "#403a40",
|
||||
"XC": "#99DA07",
|
||||
"XQuery": "#5232e7",
|
||||
"XQuery": "#2700e2",
|
||||
"XSLT": "#EB8CEB",
|
||||
"Yacc": "#4B6C4B",
|
||||
"YAML": "#cb171e",
|
||||
"YARA": "#220000",
|
||||
"YASnippet": "#32AB90",
|
||||
"Yacc": "#4B6C4B",
|
||||
"ZAP": "#0d665e",
|
||||
"ZIL": "#dc75e5",
|
||||
"ZenScript": "#00BCD1",
|
||||
"Zephir": "#118f9e",
|
||||
"Zig": "#ec915c",
|
||||
"ZIL": "#dc75e5"
|
||||
"cpp": "#f34b7d",
|
||||
"eC": "#913960",
|
||||
"edn": "#db5855",
|
||||
"mIRC Script": "#3d57c3",
|
||||
"mcfunction": "#E22837",
|
||||
"nesC": "#ffce3b",
|
||||
"ooc": "#b0b77e",
|
||||
"q": "#0040cd",
|
||||
"sed": "#64b970",
|
||||
"wdl": "#42f1f4",
|
||||
"wisp": "#7582D1",
|
||||
"xBase": "#3a4040",
|
||||
"Other": "#1f9aef"
|
||||
},
|
||||
"editors": {
|
||||
"Adobe XD": "#fd27bc",
|
||||
"Android Studio": "#99cd00",
|
||||
"AppCode": "#04dbde",
|
||||
"Aptana": "#ec8623",
|
||||
"Atom": "#49b77e",
|
||||
"Azure Data Studio": "#0271c6",
|
||||
"Blender": "#fb8007",
|
||||
"BlueJ": "#5d89af",
|
||||
"Brackets": "#067dc3",
|
||||
"Chrome": "#fdd308",
|
||||
"CLion": "#14c9a5",
|
||||
"Cloud9": "#25a6d9",
|
||||
"Coda": "#3e8e1c",
|
||||
"Code: :Blocks": "#d0ce71",
|
||||
"Code::Blocks": "#d0ce71",
|
||||
"CodeLite": "#1892e5",
|
||||
"CodeTasty": "#7368a8",
|
||||
"DataGrip": "#907cf2",
|
||||
"DBeaver": "#897363",
|
||||
"Eclipse": "#443582",
|
||||
"Emacs": "#8c76c3",
|
||||
"Embarcadero Delphi": "#d9242a",
|
||||
"EmEditor": "#ed3103",
|
||||
"Eric": "#423f13",
|
||||
"Excel": "#0f753c",
|
||||
"Figma": "#c7b9ff",
|
||||
"Firefox": "#d96527",
|
||||
"Flash Builder": "#aca3a4",
|
||||
"Geany": "#fbec75",
|
||||
"Gedit": "#872114",
|
||||
"GoLand": "#bd4ffc",
|
||||
"HBuilder X": "#1ba334",
|
||||
"IntelliJ IDEA": "#2876e1",
|
||||
"IntelliJ": "#2876e1",
|
||||
"Kakoune": "#dd5f4a",
|
||||
"Kate": "#3f4040",
|
||||
"KDevelop": "#22a273",
|
||||
"Komodo": "#fcb414",
|
||||
"Light Table": "#007ac1",
|
||||
"MacRabbit Espresso": "#e6593f",
|
||||
"Micro": "#2c3494",
|
||||
"MonoDevelop": "#6185b3",
|
||||
"MySQL Workbench": "#245279",
|
||||
"Neovim": "#068304",
|
||||
"NetBeans": "#f1f6e2",
|
||||
"Notepad++": "#9ecf54",
|
||||
"Nova": "#ff054a",
|
||||
"Onivim": "#ee848e",
|
||||
"Photoshop": "#0a0054",
|
||||
"PhpStorm": "#d93ac1",
|
||||
"PowerPoint": "#c6421f",
|
||||
"Processing": "#6a7152",
|
||||
"PyCharm": "#d2ee5c",
|
||||
"Pymakr": "#323d4f",
|
||||
"QtCreator": "#7fc342",
|
||||
"Rider": "#f7a415",
|
||||
"RStudio": "#2369c7",
|
||||
"RubyMine": "#ff6336",
|
||||
"Sketch": "#fdad00",
|
||||
"SlickEdit": "#57ca57",
|
||||
"Spyder": "#ee181e",
|
||||
"SQL Server Management Studio": "#ffb901",
|
||||
"Sublime Text": "#ff9800",
|
||||
"Terminal": "#133f1c",
|
||||
"TeXstudio": "#652d96",
|
||||
"TextMate": "#822b7a",
|
||||
"Unity": "#222d36",
|
||||
"Vim": "#068304",
|
||||
"Visual Studio": "#9460cd",
|
||||
"VS Code": "#027acd",
|
||||
"VSCode": "#027acd",
|
||||
"WebMatrix": "#aeaeae",
|
||||
"WebStorm": "#00c6d7",
|
||||
"Wing": "#b3b3b3",
|
||||
"Word": "#0f4091",
|
||||
"WPS Office": "#fc6143",
|
||||
"Xamarin": "#3598db",
|
||||
"Xcode": "#3fa7e4"
|
||||
},
|
||||
"operating_systems": {
|
||||
"Linux": "#f0b912",
|
||||
"Windows": "#00b7ee",
|
||||
"Mac": "#4d66cb"
|
||||
}
|
||||
}
|
@ -22,3 +22,8 @@ services:
|
||||
POSTGRES_USER: "wakapi"
|
||||
POSTGRES_PASSWORD: "choose-a-password"
|
||||
POSTGRES_DB: "wakapi"
|
||||
volumes:
|
||||
- wakapi-db-data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
wakapi-db-data: {}
|
||||
|
53
etc/wakapi.service
Normal file
53
etc/wakapi.service
Normal file
@ -0,0 +1,53 @@
|
||||
[Unit]
|
||||
Description=Wakapi
|
||||
StartLimitIntervalSec=400
|
||||
StartLimitBurst=3
|
||||
|
||||
# Optional, in case you're running MySQL / Postgres with Systemd, too
|
||||
Requires=mysql.service
|
||||
After=mysql.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
|
||||
# Assuming Wakapi executable is under /opt/wakapi and config file at /etc
|
||||
# Feel free to change this
|
||||
WorkingDirectory=/opt/wakapi
|
||||
ExecStart=/opt/wakapi/wakapi -config /etc/wakapi.yml
|
||||
|
||||
# Environment variables, see README for more
|
||||
Environment=WAKAPI_DB_HOST=localhost
|
||||
Environment=WAKAPI_DB_USER=wakapi
|
||||
Environment=WAKAPI_DB_NAME=wakapi
|
||||
Environment=WAKAPI_DB_PASSWORD=secretpassword
|
||||
Environment=WAKAPI_PASSWORD_SALT=somerandomstring
|
||||
|
||||
# TODO: Use Systemd's credentials management (https://systemd.io/CREDENTIALS/) introduced in v247 (%d syntax in v250) once more established
|
||||
|
||||
# sudo groupadd wakapi
|
||||
# sudo useradd -g wakapi wakapi
|
||||
User=wakapi
|
||||
Group=wakapi
|
||||
|
||||
Restart=on-failure
|
||||
RestartSec=90
|
||||
|
||||
# Security hardening
|
||||
PrivateTmp=true
|
||||
PrivateUsers=true
|
||||
NoNewPrivileges=true
|
||||
ProtectSystem=full
|
||||
ProtectHome=true
|
||||
ProtectKernelTunables=true
|
||||
ProtectKernelModules=true
|
||||
ProtectKernelLogs=true
|
||||
ProtectControlGroups=true
|
||||
PrivateDevices=true
|
||||
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
|
||||
ProtectClock=true
|
||||
RestrictSUIDSGID=true
|
||||
ProtectHostname=true
|
||||
ProtectProc=invisible
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
89
go.mod
89
go.mod
@ -1,39 +1,82 @@
|
||||
module github.com/muety/wakapi
|
||||
|
||||
go 1.16
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v0.4.1 // indirect
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751
|
||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac
|
||||
codeberg.org/Codeberg/avatars v1.0.0
|
||||
github.com/duke-git/lancet/v2 v2.1.6
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead
|
||||
github.com/emersion/go-smtp v0.15.0
|
||||
github.com/emvi/logbuch v1.2.0
|
||||
github.com/felixge/httpsnoop v1.0.2 // indirect
|
||||
github.com/getsentry/sentry-go v0.11.0
|
||||
github.com/go-co-op/gocron v1.11.0
|
||||
github.com/go-openapi/spec v0.20.2 // indirect
|
||||
github.com/getsentry/sentry-go v0.14.0
|
||||
github.com/glebarez/sqlite v1.5.0
|
||||
github.com/go-co-op/gocron v1.17.0
|
||||
github.com/gorilla/handlers v1.5.1
|
||||
github.com/gorilla/mux v1.8.0
|
||||
github.com/gorilla/schema v1.2.0
|
||||
github.com/gorilla/securecookie v1.1.1
|
||||
github.com/jackc/pgx/v4 v4.14.1 // indirect
|
||||
github.com/hashicorp/golang-lru v0.5.4
|
||||
github.com/jinzhu/configor v1.2.1
|
||||
github.com/jinzhu/now v1.1.4 // indirect
|
||||
github.com/leandro-lugaresi/hub v1.1.1
|
||||
github.com/lpar/gzipped/v2 v2.0.2
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/lpar/gzipped/v2 v2.1.0
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2
|
||||
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/satori/go.uuid v1.2.0
|
||||
github.com/stretchr/testify v1.7.0
|
||||
github.com/swaggo/swag v1.7.0
|
||||
go.uber.org/atomic v1.9.0
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
|
||||
golang.org/x/tools v0.1.0 // indirect
|
||||
gorm.io/driver/mysql v1.2.1
|
||||
gorm.io/driver/postgres v1.2.3
|
||||
gorm.io/driver/sqlite v1.2.6
|
||||
gorm.io/gorm v1.22.4
|
||||
github.com/stretchr/testify v1.8.0
|
||||
github.com/swaggo/http-swagger v1.3.3
|
||||
github.com/swaggo/swag v1.8.6
|
||||
go.uber.org/atomic v1.10.0
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0
|
||||
gorm.io/driver/mysql v1.4.1
|
||||
gorm.io/driver/postgres v1.4.4
|
||||
gorm.io/driver/sqlite v1.4.2
|
||||
gorm.io/gorm v1.24.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/BurntSushi/toml v1.2.0 // indirect
|
||||
github.com/KyleBanks/depth v1.2.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.3 // indirect
|
||||
github.com/glebarez/go-sqlite v1.19.1 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.19.5 // indirect
|
||||
github.com/go-openapi/jsonreference v0.20.0 // indirect
|
||||
github.com/go-openapi/spec v0.20.7 // indirect
|
||||
github.com/go-openapi/swag v0.22.3 // indirect
|
||||
github.com/go-sql-driver/mysql v1.6.0 // indirect
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jackc/chunkreader/v2 v2.0.1 // indirect
|
||||
github.com/jackc/pgconn v1.13.0 // indirect
|
||||
github.com/jackc/pgio v1.0.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgproto3/v2 v2.3.1 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b // indirect
|
||||
github.com/jackc/pgtype v1.12.0 // indirect
|
||||
github.com/jackc/pgx/v4 v4.17.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/josharian/intern v1.0.0 // indirect
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 // indirect
|
||||
github.com/mailru/easyjson v0.7.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.16 // indirect
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/stretchr/objx v0.4.0 // indirect
|
||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a // indirect
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 // indirect
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af // indirect
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.12 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.20.3 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.4.0 // indirect
|
||||
modernc.org/sqlite v1.19.1 // indirect
|
||||
)
|
||||
|
399
go.sum
399
go.sum
@ -1,102 +1,76 @@
|
||||
github.com/AndreasBriese/bbloom v0.0.0-20190306092124-e2d15f34fcf9/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||
codeberg.org/Codeberg/avatars v1.0.0 h1:MRx5QxuT/oVCcPvC5rXwgwWKD7hc6J0GnZ0Kl67lYEM=
|
||||
codeberg.org/Codeberg/avatars v1.0.0/go.mod h1:ML/htpPRb3+owhkm4+qG2ZrXnk5WXaQLASOZ5GLCPi8=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/toml v0.4.1 h1:GaI7EiDXDRfa8VshkTj7Fym7ha+y8/XxIgD2okUIjLw=
|
||||
github.com/BurntSushi/toml v0.4.1/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/CloudyKit/fastprinter v0.0.0-20200109182630-33d98a066a53/go.mod h1:+3IMCy2vIlbG1XG/0ggNQv0SvxCAIpPM5b1nCz56Xno=
|
||||
github.com/CloudyKit/jet/v3 v3.0.0/go.mod h1:HKQPgSJmdK8hdoAbKUUWajkHyHo4RaU5rMdUywE7VMo=
|
||||
github.com/Joker/hpp v1.0.0/go.mod h1:8x5n+M1Hp5hC0g8okX3sR3vFQwynaX/UgSOM9MeBKzY=
|
||||
github.com/BurntSushi/toml v1.2.0 h1:Rt8g24XnyGTyglgET/PRUNlrUeu9F5L+7FilkXfZgs0=
|
||||
github.com/BurntSushi/toml v1.2.0/go.mod h1:CxXYINrC8qIiEnFrOxCa7Jy5BFHlXnUU2pbicEuybxQ=
|
||||
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
|
||||
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
|
||||
github.com/Masterminds/semver/v3 v3.1.1 h1:hLg3sBzpNErnxhQtUy/mmLR2I9foDujNK030IGemrRc=
|
||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||
github.com/PuerkitoBio/purell v1.1.1 h1:WEQqlqaGbrPkxLJWfBwQmfEAE1Z7ONdDLqrN38tNFfI=
|
||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 h1:d+Bc7a5rLufV/sSk/8dngufqelfh6jnri85riMAaF/M=
|
||||
github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
|
||||
github.com/Shopify/goreferrer v0.0.0-20181106222321-ec9c9a553398/go.mod h1:a1uqRtAwp2Xwc6WNPJEufxJ7fx3npB4UV/JOLmbu5I0=
|
||||
github.com/ajg/form v1.5.1/go.mod h1:uL1WgH+h2mgNtvBq0339dVnzXdBETtL2LeUXaIv25UY=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM=
|
||||
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
|
||||
github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8=
|
||||
github.com/aymerick/raymond v2.0.3-0.20180322193309-b565731e1464+incompatible/go.mod h1:osfaiScAUVup+UC9Nfq76eWqDhXlp+4UYaA8uhTBO6g=
|
||||
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||
github.com/codegangsta/inject v0.0.0-20150114235600-33e0aa1cb7c0/go.mod h1:4Zcjuz89kmFXt9morQgcfYZAYZ5n8WHjt81YYWIwtTM=
|
||||
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
|
||||
github.com/coreos/go-etcd v2.0.0+incompatible/go.mod h1:Jez6KQU2B/sWsbdaef3ED8NzMklzPG4d5KIOhIy30Tk=
|
||||
github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
|
||||
github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/coreos/go-systemd v0.0.0-20190719114852-fd7a80b32e1f/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dgraph-io/badger v1.6.0/go.mod h1:zwt7syl517jmP8s94KqSxTlM6IMsdhYy6psNgSztDR4=
|
||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
|
||||
github.com/dgryski/go-farm v0.0.0-20190423205320-6a90982ecee2/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/duke-git/lancet/v2 v2.1.6 h1:zRWZkK3IAoGnzEonbrkmUP2NyHqtH9qIlW0AaSQrzmY=
|
||||
github.com/duke-git/lancet/v2 v2.1.6/go.mod h1:5Nawyf/bK783rCiHyVkZLx+jj8028oVVjLOrC21ZONA=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/eknkc/amber v0.0.0-20171010120322-cdade1c07385/go.mod h1:0vRUJqYpeSZifjYj7uP3BG/gKcuzL9xWVV/Y+cK33KM=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac h1:tn/OQ2PmwQ0XFVgAHfjlLyqMewry25Rz7jWnVoh4Ggs=
|
||||
github.com/emersion/go-sasl v0.0.0-20211008083017-0b9dcfb154ac/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead h1:fI1Jck0vUrXT8bnphprS1EoVRe2Q5CKCX8iDlpqjQ/Y=
|
||||
github.com/emersion/go-sasl v0.0.0-20220912192320-0145f2c60ead/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.15.0 h1:3+hMGMGrqP/lqd7qoxZc1hTU8LY8gHV9RFGWlqSDmP8=
|
||||
github.com/emersion/go-smtp v0.15.0/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/emvi/logbuch v1.2.0 h1:Bw0jQH1Dbs+oIygZBNx/2Ub1igXRFtKQrIMRrZdVFJM=
|
||||
github.com/emvi/logbuch v1.2.0/go.mod h1:hFxe0XQOFl76SkE/f0Pt5oQbXRZtyGa8EroBrrbQHuc=
|
||||
github.com/etcd-io/bbolt v1.3.3/go.mod h1:ZF2nL25h33cCyBtcyWeZ2/I3HQOfTP+0PIEvHjkjCrw=
|
||||
github.com/fasthttp-contrib/websocket v0.0.0-20160511215533-1f3b11f56072/go.mod h1:duJ4Jxv5lDcvg4QuQr0oowTf7dz4/CR8NtyCooz9HL8=
|
||||
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
|
||||
github.com/felixge/httpsnoop v1.0.1/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/felixge/httpsnoop v1.0.2 h1:+nS9g82KMXccJ/wp0zyRW9ZBHFETmMGtkk+2CTTrW4o=
|
||||
github.com/felixge/httpsnoop v1.0.2/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/gavv/httpexpect v2.0.0+incompatible/go.mod h1:x+9tiU1YnrOvnB725RkpoLv1M62hOWzwo5OXotisrKc=
|
||||
github.com/getsentry/sentry-go v0.11.0 h1:qro8uttJGvNAMr5CLcFI9CHR0aDzXl0Vs3Pmw/oTPg8=
|
||||
github.com/getsentry/sentry-go v0.11.0/go.mod h1:KBQIxiZAetw62Cj8Ri964vAEWVdgfaUCn30Q3bCvANo=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s=
|
||||
github.com/gin-gonic/gin v1.4.0/go.mod h1:OW2EZn3DO8Ln9oIKOvM++LBO+5UPHJJDH72/q/3rZdM=
|
||||
github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98=
|
||||
github.com/go-co-op/gocron v1.11.0 h1:ujOMubCpGcTxnnR/9vJIPIEpgwuAjbueAYqJRNr+nHg=
|
||||
github.com/go-co-op/gocron v1.11.0/go.mod h1:qtlsoMpHlSdIZ3E/xuZzrrAbeX3u5JtPvWf2TcdutU0=
|
||||
github.com/felixge/httpsnoop v1.0.3 h1:s/nj+GCswXYzN5v2DpNMuMQYe+0DDwt5WVCU6CWBdXk=
|
||||
github.com/felixge/httpsnoop v1.0.3/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/getsentry/sentry-go v0.13.0 h1:20dgTiUSfxRB/EhMPtxcL9ZEbM1ZdR+W/7f7NWD+xWo=
|
||||
github.com/getsentry/sentry-go v0.13.0/go.mod h1:EOsfu5ZdvKPfeHYV6pTVQnsjfp30+XA7//UooKNumH0=
|
||||
github.com/getsentry/sentry-go v0.14.0 h1:rlOBkuFZRKKdUnKO+0U3JclRDQKlRu5vVQtkWSQvC70=
|
||||
github.com/getsentry/sentry-go v0.14.0/go.mod h1:RZPJKSw+adu8PBNygiri/A98FqVr2HtRckJk9XVxJ9I=
|
||||
github.com/glebarez/go-sqlite v1.18.2 h1:ck3PQVaEzzzapP0g7pfhzbB3Jw4rNk+IldLMy/lgdeQ=
|
||||
github.com/glebarez/go-sqlite v1.18.2/go.mod h1:/kOdnnt5T0ztYXqBPdjRVM8JwMpFtyAQp1mtRoNxziM=
|
||||
github.com/glebarez/go-sqlite v1.19.1 h1:o2XhjyR8CQ2m84+bVz10G0cabmG0tY4sIMiCbrcUTrY=
|
||||
github.com/glebarez/go-sqlite v1.19.1/go.mod h1:9AykawGIyIcxoSfpYWiX1SgTNHTNsa/FVc75cDkbp4M=
|
||||
github.com/glebarez/sqlite v1.4.7 h1:tIBxEWLJOPkekuQcwfenNfh13itj9GoVJYxp7GidJAo=
|
||||
github.com/glebarez/sqlite v1.4.7/go.mod h1:UY1smw9rBTSGnJE0He8pVRPvlxCP1C8hlB8Z24K8fG4=
|
||||
github.com/glebarez/sqlite v1.5.0 h1:+8LAEpmywqresSoGlqjjT+I9m4PseIM3NcerIJ/V7mk=
|
||||
github.com/glebarez/sqlite v1.5.0/go.mod h1:0wzXzTvfVJIN2GqRhCdMbnYd+m+aH5/QV7B30rM6NgY=
|
||||
github.com/go-co-op/gocron v1.17.0 h1:IixLXsti+Qo0wMvmn6Kmjp2csk2ykpkcL+EmHmST18w=
|
||||
github.com/go-co-op/gocron v1.17.0/go.mod h1:IpDBSaJOVfFw7hXZuTag3SCSkqazXBBUkbQ1m1aesBs=
|
||||
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.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
|
||||
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
|
||||
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
|
||||
github.com/go-martini/martini v0.0.0-20170121215854-22fa46961aab/go.mod h1:/P9AEU963A2AYjv4d1V5eVL1CQbEJq6aCNHDDjibzu8=
|
||||
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
|
||||
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
|
||||
github.com/go-openapi/jsonreference v0.19.4/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/jsonreference v0.19.5 h1:1WJP/wi4OjB4iV8KVbH73rQaoialJrqv8gitZLxGLtM=
|
||||
github.com/go-openapi/jsonreference v0.19.5/go.mod h1:RdybgQwPxbL4UEjuAruzK1x3nE69AqPYEJeo/TWfEeg=
|
||||
github.com/go-openapi/spec v0.19.14/go.mod h1:gwrgJS15eCUgjLpMjBJmbZezCsw88LmgeEip0M63doA=
|
||||
github.com/go-openapi/spec v0.20.2 h1:pFPUZsiIbZ20kLUcuCGeuQWG735fPMxW7wHF9BWlnQU=
|
||||
github.com/go-openapi/spec v0.20.2/go.mod h1:RW6Xcbs6LOyWLU/mXGdzn2Qc+3aj+ASfI7rvSZh1Vls=
|
||||
github.com/go-openapi/jsonreference v0.20.0 h1:MYlu0sBgChmCfJxxUKZ8g1cPWFOB37YSZqewK7OKeyA=
|
||||
github.com/go-openapi/jsonreference v0.20.0/go.mod h1:Ag74Ico3lPc+zR+qjn4XBUmXymS4zJbYVCZmcgkasdo=
|
||||
github.com/go-openapi/spec v0.20.7 h1:1Rlu/ZrOCCob0n+JKKJAWhNWMPW8bOZRg8FJaY+0SKI=
|
||||
github.com/go-openapi/spec v0.20.7/go.mod h1:2OpW+JddWPrpXSCIX8eOx7lZ5iyuWj3RYR6VaaBKcWA=
|
||||
github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk=
|
||||
github.com/go-openapi/swag v0.19.11/go.mod h1:Uc0gKkdR+ojzsEpjh39QChyu92vPgIr72POcgHMAgSY=
|
||||
github.com/go-openapi/swag v0.19.13 h1:233UVgMy1DlmCYYfOiFpta6e2urloh+sEs5id6lyzog=
|
||||
github.com/go-openapi/swag v0.19.13/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
|
||||
github.com/go-openapi/swag v0.22.3 h1:yMBqmnQ0gyZvEb/+KzuWZOXgllrXT4SADYbvDaXHv/g=
|
||||
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
|
||||
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
|
||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
||||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/gofrs/uuid 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/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/gomodule/redigo v1.7.1-0.20190724094224-574c33c3df38/go.mod h1:B4C85qUVwatsJoIUNIfCRsp7qO0iAmpGFZ4EELWSbC4=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/handlers v1.5.1 h1:9lRY6j8DEeeBT10CvO9hGW0gmky0BprnvDI5vfhUHH4=
|
||||
github.com/gorilla/handlers v1.5.1/go.mod h1:t8XrUpc4KVXb7HGyJ4/cEnwQiaxrX/hz1Zv/4g96P1Q=
|
||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||
@ -105,17 +79,8 @@ github.com/gorilla/schema v1.2.0 h1:YufUaxZYCKGFuAq3c96BOhjgd5nmXiOY9NGzF247Tsc=
|
||||
github.com/gorilla/schema v1.2.0/go.mod h1:kgLaKoK1FELgZqMAVxx/5cbj0kT+57qxUrAlIO2eleU=
|
||||
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/imkira/go-interpol v1.1.0/go.mod h1:z0h2/2T3XF8kyEPpRgJ3kmNv+C43p+I/CoI+jC3w2iA=
|
||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||
github.com/iris-contrib/blackfriday v2.0.0+incompatible/go.mod h1:UzZ2bDEoaSGPbkg6SAB4att1aAwTmVIx/5gCVqeyUdI=
|
||||
github.com/iris-contrib/go.uuid v2.0.0+incompatible/go.mod h1:iz2lgM/1UnEf1kP0L/+fafWORmlnuysV2EMP8MW+qe0=
|
||||
github.com/iris-contrib/jade v1.1.3/go.mod h1:H/geBymxJhShH5kecoiOCSssPX7QWYH7UaeZTSWddIk=
|
||||
github.com/iris-contrib/pongo2 v0.0.1/go.mod h1:Ssh+00+3GAZqSQb30AvBRNxBx7rf0GqwkjqxNd0u65g=
|
||||
github.com/iris-contrib/schema v0.0.1/go.mod h1:urYA3uvUNG1TIIjOSCzHr9/LmbQo8LrOcOqfqxa4hXw=
|
||||
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
|
||||
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
|
||||
github.com/jackc/chunkreader v1.0.0 h1:4s39bBR8ByfqH+DKm8rQA3E1LHZWB9XWcrz8fqaZbe0=
|
||||
github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo=
|
||||
github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
|
||||
@ -127,8 +92,8 @@ github.com/jackc/pgconn v0.0.0-20190831204454-2fabfa3c18b7/go.mod h1:ZJKsE/KZfsU
|
||||
github.com/jackc/pgconn v1.8.0/go.mod h1:1C2Pb36bGIP9QHGBYCjnyhqu7Rv3sGshaQUvmfGIB/o=
|
||||
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.1 h1:DzdIHIjG1AxGwoEEqS+mGsURyjt4enSmqzACXvVzOT8=
|
||||
github.com/jackc/pgconn v1.10.1/go.mod h1:4z2w8XhRbP1hYxkpTuBjTS3ne3J48K83+u0zoyvg2pI=
|
||||
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
|
||||
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
|
||||
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
|
||||
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
|
||||
github.com/jackc/pgmock v0.0.0-20190831213851-13a1b77aafa2/go.mod h1:fGZlG77KXmcq05nJLRkk0+p82V8B8Dw8KN2/V9c/OAE=
|
||||
@ -145,63 +110,48 @@ 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.6/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.1.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.2.0 h1:r7JypeP2D3onoQTCxWdTpCtJ4D+qpKr0TxvoyMhZ5ns=
|
||||
github.com/jackc/pgproto3/v2 v2.2.0/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
|
||||
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
|
||||
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/pgtype v0.0.0-20190421001408-4ed0de4755e0/go.mod h1:hdSHsc1V01CGwFsrv11mJRHWJ6aifDLfdV3aVjFF0zg=
|
||||
github.com/jackc/pgtype v0.0.0-20190824184912-ab885b375b90/go.mod h1:KcahbBH1nCMSo2DXpzsoWOAfFkdEtEJpPbVLq8eE+mc=
|
||||
github.com/jackc/pgtype v0.0.0-20190828014616-a8802b16cc59/go.mod h1:MWlu30kVJrUS8lot6TQqcg7mtthZ9T0EoIBFiJcmcyw=
|
||||
github.com/jackc/pgtype v1.8.1-0.20210724151600-32e20a603178/go.mod h1:C516IlIV9NKqfsMCXTdChteoXmwgUceqaLfjg2e3NlM=
|
||||
github.com/jackc/pgtype v1.9.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgtype v1.9.1 h1:MJc2s0MFS8C3ok1wQTdQxWuXQcB6+HwAm5x1CzW7mf0=
|
||||
github.com/jackc/pgtype v1.9.1/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
|
||||
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190420224344-cc3461e65d96/go.mod h1:mdxmSJJuR08CZQyj1PVQBHy9XOp5p8/SHH6a0psbY9Y=
|
||||
github.com/jackc/pgx/v4 v4.0.0-20190421002000-1b8f0016e912/go.mod h1:no/Y67Jkk/9WuGR0JG/JseM9irFbnEPbuWV2EELPNuM=
|
||||
github.com/jackc/pgx/v4 v4.0.0-pre1.0.20190824185557-6972a5742186/go.mod h1:X+GQnOEnf1dqHGpw7JmHqHc1NxDoalibchSk9/RWuDc=
|
||||
github.com/jackc/pgx/v4 v4.12.1-0.20210724153913-640aa07df17c/go.mod h1:1QD0+tgSXP7iUjYm9C1NxKhny7lq6ee99u/z+IHFcgs=
|
||||
github.com/jackc/pgx/v4 v4.14.0/go.mod h1:jT3ibf/A0ZVCp89rtCIN0zCJxcE74ypROmHEZYsG/j8=
|
||||
github.com/jackc/pgx/v4 v4.14.1 h1:71oo1KAGI6mXhLiTMn6iDFcp3e7+zon/capWjl2OEFU=
|
||||
github.com/jackc/pgx/v4 v4.14.1/go.mod h1:RgDuE4Z34o7XE92RpLsvFiOEfrAUT0Xt2KxvX73W06M=
|
||||
github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
|
||||
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
|
||||
github.com/jackc/puddle v0.0.0-20190413234325-e4ced69a3a2b/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v0.0.0-20190608224051-11cab39313c9/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.1.3/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.2.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jackc/puddle v1.3.0/go.mod h1:m4B5Dj62Y0fbyuIc15OsIqK0+JU8nkqQjsgx7dvjSWk=
|
||||
github.com/jinzhu/configor v1.2.1 h1:OKk9dsR8i6HPOCZR8BcMtcEImAFjIhbJFZNyn5GCZko=
|
||||
github.com/jinzhu/configor v1.2.1/go.mod h1:nX89/MOmDba7ZX7GCyU/VIaQ2Ar2aizBl2d3JLF/rDc=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.3/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.4 h1:tHnRBy1i5F2Dh8BAFxqFzxKqqvezXrL2OW1TnX+Mlas=
|
||||
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||
github.com/k0kubun/colorstring v0.0.0-20150214042306-9440f1994b88/go.mod h1:3w7q1U84EfirKl04SVQ/s7nPm1ZPhiXd34z40TNz36k=
|
||||
github.com/kataras/golog v0.0.10/go.mod h1:yJ8YKCmyL+nWjERB90Qwn+bdyBZsaQwU3bTVFgkFIp8=
|
||||
github.com/kataras/iris/v12 v12.1.8/go.mod h1:LMYy4VlP67TQ3Zgriz8RE2h2kMZV2SgMYbq3UhfoFmE=
|
||||
github.com/kataras/neffos v0.0.14/go.mod h1:8lqADm8PnbeFfL7CLXh1WHw53dG27MC3pgi2R1rmoTE=
|
||||
github.com/kataras/pio v0.0.2/go.mod h1:hAoW0t9UmXi4R5Oyq5Z4irTbaTsOemSrDGUtaTl7Dro=
|
||||
github.com/kataras/sitemap v0.0.5/go.mod h1:KY2eugMKiPwsJgx7+U103YZehfvNGOXURubcGyk0Bz8=
|
||||
github.com/kevinpollet/nego v0.0.0-20200324111829-b3061ca9dd9d h1:BaIpmhcqpBnz4+NZjUjVGxKNA+/E7ovKsjmwqjXcGYc=
|
||||
github.com/kevinpollet/nego v0.0.0-20200324111829-b3061ca9dd9d/go.mod h1:3FSWkzk9h42opyV0o357Fq6gsLF/A6MI/qOca9kKobY=
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43 h1:Pdirg1gwhEcGjMLyuSxGn9664p+P8J9SrfMgpFwrDyg=
|
||||
github.com/kevinpollet/nego v0.0.0-20211010160919-a65cd48cee43/go.mod h1:ahLMuLCUyDdXqtqGyuwGev7/PGtO7r7ocvdwDuEN/3E=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/compress v1.9.7/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
|
||||
github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
|
||||
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/labstack/echo/v4 v4.1.11/go.mod h1:i541M3Fj6f76NZtHSj7TXnyM8n2gaodfvfxNnFqi74g=
|
||||
github.com/labstack/gommon v0.3.0/go.mod h1:MULnywXg0yavhxWKc+lOruYdAhDwPK9wf0OL7NoOu+k=
|
||||
github.com/leandro-lugaresi/hub v1.1.1 h1:zqp0HzFvj4HtqjMBXM2QF17o6PNmR8MJOChgeKl/aw8=
|
||||
github.com/leandro-lugaresi/hub v1.1.1/go.mod h1:XEFWanhHv6Rt3XlteHMxuNDYi8dJcpJjodpqkU+BtIo=
|
||||
github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
|
||||
@ -209,121 +159,80 @@ 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.10.2 h1:AqzbZs4ZoCBp+GtejcpCpcxM3zlSMx29dXbUSeVtJb8=
|
||||
github.com/lib/pq v1.10.2/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lpar/gzipped/v2 v2.0.2 h1:y7FjyTH07f8dX0YQ5o0sg2DTbRnmS3oT1pUvxViQ//o=
|
||||
github.com/lpar/gzipped/v2 v2.0.2/go.mod h1:qb7pLOGFgqz5w9xGGiiRFPxuGZ7GqWEuXUKXSbgonkQ=
|
||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||
github.com/lpar/gzipped/v2 v2.1.0 h1:87/ug239roEqXLVOnXZg6NjDfFvMwmkGTKnFWJPUA9U=
|
||||
github.com/lpar/gzipped/v2 v2.1.0/go.mod h1:G3UlFoFYzjCx6NV4zDmD1BIWMNBaJuKoUvxrEWJuZ3Y=
|
||||
github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.9/go.mod h1:YNRxwqDuOph6SZLI9vUUz6OYw3QyUt7WiY2yME+cCiQ=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
github.com/mattn/go-sqlite3 v1.14.9/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU=
|
||||
github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
|
||||
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
|
||||
github.com/mattn/goveralls v0.0.2/go.mod h1:8d1ZMHsd7fW6IRPKQh46F2WRpyib5/X4FOpevwGNQEw=
|
||||
github.com/mediocregopher/radix/v3 v3.4.2/go.mod h1:8FL3F6UQRXHXIBSPUs5h0RybMF8i4n7wVopoX3x7Bv8=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
|
||||
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/moul/http2curl v1.0.0/go.mod h1:8UbvGypXm98wA/IqH45anm5Y2Z6ep6O31QGOAZ3H0fQ=
|
||||
github.com/nats-io/jwt v0.3.0/go.mod h1:fRYCDE99xlTsqUzISS1Bi75UBJ6ljOJQOAAu5VglpSg=
|
||||
github.com/nats-io/nats.go v1.9.1/go.mod h1:ZjDU1L/7fJ09jvUSRVBR2e7+RnLiiIQyqyzEE/Zbp4w=
|
||||
github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
|
||||
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e h1:bR8DQ4ZfItytLJwRlrLOPUHd5z18V6tECwYQFy8W+8g=
|
||||
github.com/narqo/go-badge v0.0.0-20220127184443-140af28a266e/go.mod h1:m9BzkaxwU4IfPQi9ko23cmuFltayFe8iS0dlRlnEWiM=
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.10.3/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
|
||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
|
||||
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
|
||||
github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa h1:tEkEyxYeZ43TR55QU/hsIt9aRGBxbgGuz9CGykjvogY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20220927061507-ef77025ab5aa/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ=
|
||||
github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU=
|
||||
github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||
github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
|
||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||
github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g=
|
||||
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 v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ=
|
||||
github.com/shopspring/decimal v1.2.0/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFRcu2hWCYk4o=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
|
||||
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
|
||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
|
||||
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
|
||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
|
||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.2.0 h1:Hbg2NidpLE8veEBkEZTL3CvlkUIVzuU9jDplZO54c48=
|
||||
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
|
||||
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/swaggo/swag v1.7.0 h1:5bCA/MTLQoIqDXXyHfOpMeDvL9j68OY/udlK4pQoo4E=
|
||||
github.com/swaggo/swag v1.7.0/go.mod h1:BdPIL73gvS9NBsdi7M1JOxLvlbfvNRaBP8m6WT6Aajo=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/ugorji/go v1.1.7/go.mod h1:kZn38zHttfInRq0xu/PH0az30d+z6vm202qpg1oXVMw=
|
||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||
github.com/ugorji/go/codec v1.1.7/go.mod h1:Ax+UKWsSmolVDwsd+7N3ZtXu+yMGCf907BLYF3GoBXY=
|
||||
github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI=
|
||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.6.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w=
|
||||
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
|
||||
github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio=
|
||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||
github.com/yalp/jsonpath v0.0.0-20180802001716-5cc68e5049a0/go.mod h1:/LWChgwKmvncFJFHJ7Gvn9wZArjbV5/FppcK2fKk/tI=
|
||||
github.com/yudai/gojsondiff v1.0.0/go.mod h1:AY32+k2cwILAkW1fbgxQ5mUmMiZFgLIV+FBNExI05xg=
|
||||
github.com/yudai/golcs v0.0.0-20170316035057-ecda9a501e82/go.mod h1:lgjkn3NuSvDfVJdfcVVdX+jpBxNmX4rDAzaS45IcYoM=
|
||||
github.com/yudai/pp v2.0.1+incompatible/go.mod h1:PuxR/8QJ7cyCkFp/aUDS+JY727OFEZkTdatxwunjIkc=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a h1:kAe4YSu0O0UFn1DowNo2MY5p6xzqtJ/wQ7LZynSvGaY=
|
||||
github.com/swaggo/files v0.0.0-20220728132757-551d4a08d97a/go.mod h1:lKJPbtWzJ9JhsTN1k1gZgleJWY/cqq0psdoMmaThG3w=
|
||||
github.com/swaggo/http-swagger v1.3.3 h1:Hu5Z0L9ssyBLofaama21iYaF2VbWyA8jdohaaCGpHsc=
|
||||
github.com/swaggo/http-swagger v1.3.3/go.mod h1:sE+4PjD89IxMPm77FnkDz0sdO+p5lbXzrVWT6OTVVGo=
|
||||
github.com/swaggo/swag v1.8.6 h1:2rgOaLbonWu1PLP6G+/rYjSvPg0jQE0HtrEKuE380eg=
|
||||
github.com/swaggo/swag v1.8.6/go.mod h1:jMLeXOOmYyjk8PvHTsXBdrubsNd9gUJTTCzL5iBnseg=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
|
||||
go.uber.org/atomic v1.5.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ=
|
||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||
@ -331,82 +240,74 @@ go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9E
|
||||
go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q=
|
||||
go.uber.org/zap v1.13.0/go.mod h1:zwrFLgMcdUuIBviXEYEH1YKNaOBnKXsx2IPda5bBwHM=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190411191339-88737f569e3a/go.mod h1:WFFai1msRO1wXaEeE5yQxYXgSfI8pQAWXbQop6sCtWE=
|
||||
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190701094942-4def268fd1a4/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20190820162420-60c769a6c586/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191227163750-53104e6ec876/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-20210616213533-5ff15b29337e/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b h1:QAqMVf3pSa6eeTsuklijukjXBlj7Es2QQplab+/RbQ4=
|
||||
golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be h1:fmw3UbQh+nxngCAHrDCCztao/kbYFnWjoqop8dHx05A=
|
||||
golang.org/x/crypto v0.0.0-20220926161630-eccd6366d1be/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b h1:huxqepDufQpLLIRXiVkTvnxrzJlpwmIWAObmcCcUFr0=
|
||||
golang.org/x/crypto v0.0.0-20221005025214-4161e89ecf1b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69 h1:Lj6HJGCSn5AjxRAH2+r35Mir4icalbqku+CLUtjnvXY=
|
||||
golang.org/x/image v0.0.0-20220902085622-e7cb96979f69/go.mod h1:doUCurBvlfPMKfmIpRIywoHmhN3VyhnoFDbvIEWF4hY=
|
||||
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
|
||||
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
|
||||
golang.org/x/mod v0.3.0 h1:RM4zey1++hCTbCVQfnWeKs9/IEsaBLA8vTkd0WVtmH4=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181220203305-927f97764cc3/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 h1:6zppjxzCulZykYSLyVDYbneBfbaBIQPYMevg0bEwv2s=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190327091125-710a502c58a2/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 h1:CIJ76btIcR3eFI5EgSo6k1qKw9KJexJuRLI9G7Hp5wE=
|
||||
golang.org/x/net v0.0.0-20210805182204-aaa1db679c0d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc h1:FxpXZdoBqT8RjqTy6i1E8nXHhW21wK7ptQ/EPIGxzPQ=
|
||||
golang.org/x/net v0.0.0-20220927171203-f486391704dc/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af h1:wv66FM3rLZGPdxpYL+ApnDe2HzHcTFta3z5nsc13wI4=
|
||||
golang.org/x/net v0.0.0-20221004154528-8021a29435af/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0 h1:cu5kTvlzcw1Q5S9f5ip1/cpiB4nXvw1XYzFPGgzLUOY=
|
||||
golang.org/x/sync v0.0.0-20220929204114-8fcdb60fdcc0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181205085412-a5c9d58dba9a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190626221950-04f50cda93cb/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1 h1:SrN+KX8Art/Sf4HNj6Zcz06G7VEz+7w9tdXTPOZ7+l4=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec h1:BkDtF2Ih9xZ7le9ndzTA7KJow28VbQW3odyk/8drmuI=
|
||||
golang.org/x/sys v0.0.0-20220928140112-f11e5e49a4ec/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875 h1:AzgQNqF+FKwyQ5LbVrVqOcuuFB67N47F9+htZYH0wFM=
|
||||
golang.org/x/sys v0.0.0-20221006211917-84dc82d7e875/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181221001348-537d06c36207/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190327201419-c70d86f8b7cf/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190425163242-31fd60d6bfdc/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
|
||||
golang.org/x/tools v0.0.0-20190823170909-c4a336ef6a2f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
@ -414,9 +315,9 @@ golang.org/x/tools v0.0.0-20191029041327-9cc4af7d6b2c/go.mod h1:b+2E5dAYhXwXZwtn
|
||||
golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200103221440-774c71fcf114/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
|
||||
golang.org/x/tools v0.0.0-20201120155355-20be4ac4bd6e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY=
|
||||
golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
|
||||
golang.org/x/tools v0.0.0-20201124115921-2c860bdd6e78/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12 h1:VveCTK38A2rkS8ZqFY25HIDFscX5X9OoEhJd3quQmXU=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
@ -426,34 +327,84 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1N
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE=
|
||||
gopkg.in/go-playground/validator.v8 v8.18.2/go.mod h1:RX2a/7Ha8BgOhfk7j780h4/u/RRjR0eouCJSH80/M2Y=
|
||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||
gopkg.in/ini.v1 v1.51.1/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||
gopkg.in/mgo.v2 v2.0.0-20180705113604-9856a29383ce/go.mod h1:yeKp02qBN3iKW1OzL3MGk2IdtZzaj7SFntXj72NppTA=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20191120175047-4206685974f2/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/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/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.2.1 h1:h+3f1l9Ng2C072Y2tIiLgPpWN78r1KXL7bHJ0nTjlhU=
|
||||
gorm.io/driver/mysql v1.2.1/go.mod h1:qsiz+XcAyMrS6QY+X3M9R6b/lKM1imKmcuK9kac5LTo=
|
||||
gorm.io/driver/postgres v1.2.3 h1:f4t0TmNMy9gh3TU2PX+EppoA6YsgFnyq8Ojtddb42To=
|
||||
gorm.io/driver/postgres v1.2.3/go.mod h1:pJV6RgYQPG47aM1f0QeOzFH9HxQc8JcmAgjRCgS0wjs=
|
||||
gorm.io/driver/sqlite v1.2.6 h1:SStaH/b+280M7C8vXeZLz/zo9cLQmIGwwj3cSj7p6l4=
|
||||
gorm.io/driver/sqlite v1.2.6/go.mod h1:gyoX0vHiiwi0g49tv+x2E7l8ksauLK0U/gShcdUsjWY=
|
||||
gorm.io/gorm v1.22.3/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=
|
||||
gorm.io/gorm v1.22.4 h1:8aPcyEJhY0MAt8aY6Dc524Pn+pO29K+ydu+e/cXSpQM=
|
||||
gorm.io/gorm v1.22.4/go.mod h1:1aeVC+pe9ZmvKZban/gW4QPra7PRoTEssyc922qCAkk=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/mysql v1.3.6 h1:BhX1Y/RyALb+T9bZ3t07wLnPZBukt+IRkMn8UZSNbGM=
|
||||
gorm.io/driver/mysql v1.3.6/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
||||
gorm.io/driver/mysql v1.4.1 h1:4InA6SOaYtt4yYpV1NF9B2kvUKe9TbvUd1iWrvxnjic=
|
||||
gorm.io/driver/mysql v1.4.1/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c=
|
||||
gorm.io/driver/postgres v1.3.10 h1:Fsd+pQpFMGlGxxVMUPJhNo8gG8B1lKtk8QQ4/VZZAJw=
|
||||
gorm.io/driver/postgres v1.3.10/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
||||
gorm.io/driver/postgres v1.4.4 h1:zt1fxJ+C+ajparn0SteEnkoPg0BQ6wOWXEQ99bteAmw=
|
||||
gorm.io/driver/postgres v1.4.4/go.mod h1:whNfh5WhhHs96honoLjBAMwJGYEuA3m1hvgUbNXhPCw=
|
||||
gorm.io/driver/sqlite v1.3.6 h1:Fi8xNYCUplOqWiPa3/GuCeowRNBRGTf62DEmhMDHeQQ=
|
||||
gorm.io/driver/sqlite v1.3.6/go.mod h1:Sg1/pvnKtbQ7jLXxfZa+jSHvoX8hoZA8cn4xllOMTgE=
|
||||
gorm.io/driver/sqlite v1.4.2 h1:F6vYJcmR4Cnh0ErLyoY8JSfabBGyR0epIGuhgHJuNws=
|
||||
gorm.io/driver/sqlite v1.4.2/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
|
||||
gorm.io/gorm v1.23.4/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.7/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||
gorm.io/gorm v1.23.10 h1:4Ne9ZbzID9GUxRkllxN4WjJKpsHx8YbKvekVdgyWh24=
|
||||
gorm.io/gorm v1.23.10/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
gorm.io/gorm v1.24.0 h1:j/CoiSm6xpRpmzbFJsQHYj+I8bGYWLXVHeYEyyKlF74=
|
||||
gorm.io/gorm v1.24.0/go.mod h1:DVrVomtaYTbqs7gB/x2uVvqnXzv0nqjB396B8cG4dBA=
|
||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||
lukechampine.com/uint128 v1.1.1/go.mod h1:c4eWIwlEGaxC/+H1VguhU4PHXNWDCDMUlWdIWl2j1gk=
|
||||
modernc.org/cc/v3 v3.36.0/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
modernc.org/cc/v3 v3.36.2/go.mod h1:NFUHyPn4ekoC/JHeZFfZurN6ixxawE1BnVonP/oahEI=
|
||||
modernc.org/cc/v3 v3.37.0/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/cc/v3 v3.38.1/go.mod h1:vtL+3mdHx/wcj3iEGz84rQa8vEqR6XM84v5Lcvfph20=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220428102840-41399a37e894/go.mod h1:eI31LL8EwEBKPpNpA4bU1/i+sKOwOrQy8D87zWUcRZc=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220430103911-bc99d88307be/go.mod h1:bwdAnOoaIt8Ax9YdWGjxWsdkPcZyRPHqrOvJxaKAKGw=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220904174949-82d86e1b6d56/go.mod h1:YSXjPL62P2AMSxBphRHPn7IkzhVHqkvOnRKAKh+W6ZI=
|
||||
modernc.org/ccgo/v3 v3.0.0-20220910160915-348f15de615a/go.mod h1:8p47QxPkdugex9J4n9P2tLZ9bK01yngIVp00g4nomW0=
|
||||
modernc.org/ccgo/v3 v3.16.4/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||
modernc.org/ccgo/v3 v3.16.6/go.mod h1:tGtX0gE9Jn7hdZFeU88slbTh1UtCYKusWOoCJuvkWsQ=
|
||||
modernc.org/ccgo/v3 v3.16.8/go.mod h1:zNjwkizS+fIFDrDjIAgBSCLkWbJuHF+ar3QRn+Z9aws=
|
||||
modernc.org/ccgo/v3 v3.16.9/go.mod h1:zNMzC9A9xeNUepy6KuZBbugn3c0Mc9TeiJO4lgvkJDo=
|
||||
modernc.org/ccorpus v1.11.6/go.mod h1:2gEUTrWqdpH2pXsmTM1ZkjeSrUWDpjMu2T6m29L/ErQ=
|
||||
modernc.org/httpfs v1.0.6/go.mod h1:7dosgurJGp0sPaRanU53W4xZYKh14wfzX420oZADeHM=
|
||||
modernc.org/libc v0.0.0-20220428101251-2d5f3daf273b/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||
modernc.org/libc v1.16.0/go.mod h1:N4LD6DBE9cf+Dzf9buBlzVJndKr/iJHG97vGLHYnb5A=
|
||||
modernc.org/libc v1.16.1/go.mod h1:JjJE0eu4yeK7tab2n4S1w8tlWd9MxXLRzheaRnAKymU=
|
||||
modernc.org/libc v1.16.17/go.mod h1:hYIV5VZczAmGZAnG15Vdngn5HSF5cSkbvfz2B7GRuVU=
|
||||
modernc.org/libc v1.16.19/go.mod h1:p7Mg4+koNjc8jkqwcoFBJx7tXkpj00G77X7A72jXPXA=
|
||||
modernc.org/libc v1.17.0/go.mod h1:XsgLldpP4aWlPlsjqKRdHPqCxCjISdHfM/yeWC5GyW0=
|
||||
modernc.org/libc v1.17.4/go.mod h1:WNg2ZH56rDEwdropAJeZPQkXmDwh+JCA1s/htl6r2fA=
|
||||
modernc.org/libc v1.18.0/go.mod h1:vj6zehR5bfc98ipowQOM2nIDUZnVew/wNC/2tOGS+q0=
|
||||
modernc.org/libc v1.19.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.20.0 h1:MEbCfCKpuDC/LRb3HOCM9fZOqnPx8le3kzTJVmUGDbU=
|
||||
modernc.org/libc v1.20.0/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/libc v1.20.3 h1:BodaDPuUse7taQchAClMmbE/yZp3T2ZBiwCDFyBLEXw=
|
||||
modernc.org/libc v1.20.3/go.mod h1:ZRfIaEkgrYgZDl6pa4W39HgN5G/yDW+NRmNKZBDFrk0=
|
||||
modernc.org/mathutil v1.2.2/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.4.1/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.1.1/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||
modernc.org/memory v1.2.0/go.mod h1:/0wo5ibyrQiaoUoH7f9D8dnglAmILJ5/cxZlRECf+Nw=
|
||||
modernc.org/memory v1.3.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/memory v1.4.0 h1:crykUfNSnMAXaOJnnxcSzbUGMqkLWjklJKkBK2nwZwk=
|
||||
modernc.org/memory v1.4.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/opt v0.1.1/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
|
||||
modernc.org/sqlite v1.18.2/go.mod h1:kvrTLEWgxUcHa2GfHBQtanR1H9ht3hTJNtKpzH9k1u0=
|
||||
modernc.org/sqlite v1.19.1 h1:8xmS5oLnZtAK//vnd4aTVj8VOeTAccEFOtUnIzfSw+4=
|
||||
modernc.org/sqlite v1.19.1/go.mod h1:UfQ83woKMaPW/ZBruK0T7YaFCrI+IE0LeWVY6pmnVms=
|
||||
modernc.org/strutil v1.1.1/go.mod h1:DE+MQQ/hjKBZS2zNInV5hhcipt5rLPWkmpbGeW5mmdw=
|
||||
modernc.org/strutil v1.1.3/go.mod h1:MEHNA7PdEnEwLvspRMtWTNnp2nnyvMfkimT1NKNAGbw=
|
||||
modernc.org/tcl v1.13.2/go.mod h1:7CLiGIPo1M8Rv1Mitpv5akc2+8fxUd2y2UzC/MfMzy0=
|
||||
modernc.org/tcl v1.14.0/go.mod h1:gQ7c1YPMvryCHCcmf8acB6VPabE59QBeuRQLL7cTUlM=
|
||||
modernc.org/token v1.0.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/token v1.0.1/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
modernc.org/z v1.5.1/go.mod h1:eWFB510QWW5Th9YGZT81s+LwvaAs3Q2yr4sP0rmLkv8=
|
||||
modernc.org/z v1.6.0/go.mod h1:hVdgNMh8ggTuRG1rGU8x+xGRFfiQUIAw0ZqlPy8+HyQ=
|
||||
|
69
main.go
69
main.go
@ -2,6 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"github.com/muety/wakapi/static/docs"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net"
|
||||
@ -10,37 +11,39 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/lpar/gzipped/v2"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/routes/relay"
|
||||
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/handlers"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/lpar/gzipped/v2"
|
||||
"github.com/swaggo/http-swagger"
|
||||
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/migrations"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/routes/api"
|
||||
"github.com/muety/wakapi/services/mail"
|
||||
fsutils "github.com/muety/wakapi/utils/fs"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/routes"
|
||||
"github.com/muety/wakapi/routes/api"
|
||||
shieldsV1Routes "github.com/muety/wakapi/routes/compat/shields/v1"
|
||||
wtV1Routes "github.com/muety/wakapi/routes/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/routes/relay"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/services/mail"
|
||||
fsutils "github.com/muety/wakapi/utils/fs"
|
||||
|
||||
_ "gorm.io/driver/mysql"
|
||||
_ "gorm.io/driver/postgres"
|
||||
_ "gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
// Embed version.txt
|
||||
//
|
||||
//go:embed version.txt
|
||||
var version string
|
||||
|
||||
// Embed static files
|
||||
//
|
||||
//go:embed static
|
||||
var staticFiles embed.FS
|
||||
|
||||
@ -56,8 +59,10 @@ var (
|
||||
languageMappingRepository repositories.ILanguageMappingRepository
|
||||
projectLabelRepository repositories.IProjectLabelRepository
|
||||
summaryRepository repositories.ISummaryRepository
|
||||
leaderboardRepository *repositories.LeaderboardRepository
|
||||
keyValueRepository repositories.IKeyValueRepository
|
||||
diagnosticsRepository repositories.IDiagnosticsRepository
|
||||
metricsRepository *repositories.MetricsRepository
|
||||
)
|
||||
|
||||
var (
|
||||
@ -68,6 +73,7 @@ var (
|
||||
projectLabelService services.IProjectLabelService
|
||||
durationService services.IDurationService
|
||||
summaryService services.ISummaryService
|
||||
leaderboardService services.ILeaderboardService
|
||||
aggregationService services.IAggregationService
|
||||
mailService services.IMailService
|
||||
keyValueService services.IKeyValueService
|
||||
@ -97,10 +103,12 @@ var (
|
||||
// @in header
|
||||
// @name Authorization
|
||||
|
||||
// @BasePath /api
|
||||
func main() {
|
||||
config = conf.Load(version)
|
||||
|
||||
// Configure Swagger docs
|
||||
docs.SwaggerInfo.BasePath = config.Server.BasePath + "/api"
|
||||
|
||||
// Set log level
|
||||
if config.IsDev() {
|
||||
logbuch.SetLevel(logbuch.LevelDebug)
|
||||
@ -121,6 +129,10 @@ func main() {
|
||||
// Connect to database
|
||||
var err error
|
||||
db, err = gorm.Open(config.Db.GetDialector(), &gorm.Config{Logger: gormLogger})
|
||||
if err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
logbuch.Fatal("could not open database")
|
||||
}
|
||||
if config.Db.IsSQLite() {
|
||||
db.Exec("PRAGMA foreign_keys = ON;")
|
||||
}
|
||||
@ -138,7 +150,9 @@ func main() {
|
||||
defer sqlDb.Close()
|
||||
|
||||
// Migrate database schema
|
||||
if !config.SkipMigrations {
|
||||
migrations.Run(db, config)
|
||||
}
|
||||
|
||||
// Repositories
|
||||
aliasRepository = repositories.NewAliasRepository(db)
|
||||
@ -147,8 +161,10 @@ func main() {
|
||||
languageMappingRepository = repositories.NewLanguageMappingRepository(db)
|
||||
projectLabelRepository = repositories.NewProjectLabelRepository(db)
|
||||
summaryRepository = repositories.NewSummaryRepository(db)
|
||||
leaderboardRepository = repositories.NewLeaderboardRepository(db)
|
||||
keyValueRepository = repositories.NewKeyValueRepository(db)
|
||||
diagnosticsRepository = repositories.NewDiagnosticsRepository(db)
|
||||
metricsRepository = repositories.NewMetricsRepository(db)
|
||||
|
||||
// Services
|
||||
mailService = mail.NewMailService()
|
||||
@ -159,6 +175,7 @@ func main() {
|
||||
heartbeatService = services.NewHeartbeatService(heartbeatRepository, languageMappingService)
|
||||
durationService = services.NewDurationService(heartbeatService)
|
||||
summaryService = services.NewSummaryService(summaryRepository, durationService, aliasService, projectLabelService)
|
||||
leaderboardService = services.NewLeaderboardService(leaderboardRepository, summaryService, userService)
|
||||
aggregationService = services.NewAggregationService(userService, summaryService, heartbeatService)
|
||||
keyValueService = services.NewKeyValueService(keyValueRepository)
|
||||
reportService = services.NewReportService(summaryService, userService, mailService)
|
||||
@ -166,11 +183,10 @@ func main() {
|
||||
miscService = services.NewMiscService(userService, summaryService, keyValueService)
|
||||
|
||||
// Schedule background tasks
|
||||
if !config.QuickStart {
|
||||
go aggregationService.Schedule()
|
||||
go leaderboardService.ScheduleDefault()
|
||||
go miscService.ScheduleCountTotalTime()
|
||||
go reportService.Schedule()
|
||||
}
|
||||
|
||||
routes.Init()
|
||||
|
||||
@ -178,8 +194,10 @@ func main() {
|
||||
healthApiHandler := api.NewHealthApiHandler(db)
|
||||
heartbeatApiHandler := api.NewHeartbeatApiHandler(userService, heartbeatService, languageMappingService)
|
||||
summaryApiHandler := api.NewSummaryApiHandler(userService, summaryService)
|
||||
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService)
|
||||
metricsHandler := api.NewMetricsHandler(userService, summaryService, heartbeatService, keyValueService, metricsRepository)
|
||||
diagnosticsHandler := api.NewDiagnosticsApiHandler(userService, diagnosticsService)
|
||||
avatarHandler := api.NewAvatarHandler()
|
||||
badgeHandler := api.NewBadgeHandler(userService, summaryService)
|
||||
|
||||
// Compat Handlers
|
||||
wakatimeV1StatusBarHandler := wtV1Routes.NewStatusBarHandler(userService, summaryService)
|
||||
@ -194,6 +212,7 @@ func main() {
|
||||
// MVC Handlers
|
||||
summaryHandler := routes.NewSummaryHandler(summaryService, userService)
|
||||
settingsHandler := routes.NewSettingsHandler(userService, heartbeatService, summaryService, aliasService, aggregationService, languageMappingService, projectLabelService, keyValueService, mailService)
|
||||
leaderboardHandler := routes.NewLeaderboardHandler(userService, leaderboardService)
|
||||
homeHandler := routes.NewHomeHandler(keyValueService)
|
||||
loginHandler := routes.NewLoginHandler(userService, mailService)
|
||||
imprintHandler := routes.NewImprintHandler(keyValueService)
|
||||
@ -228,6 +247,7 @@ func main() {
|
||||
loginHandler.RegisterRoutes(rootRouter)
|
||||
imprintHandler.RegisterRoutes(rootRouter)
|
||||
summaryHandler.RegisterRoutes(rootRouter)
|
||||
leaderboardHandler.RegisterRoutes(rootRouter)
|
||||
settingsHandler.RegisterRoutes(rootRouter)
|
||||
relayHandler.RegisterRoutes(rootRouter)
|
||||
|
||||
@ -237,6 +257,8 @@ func main() {
|
||||
heartbeatApiHandler.RegisterRoutes(apiRouter)
|
||||
metricsHandler.RegisterRoutes(apiRouter)
|
||||
diagnosticsHandler.RegisterRoutes(apiRouter)
|
||||
avatarHandler.RegisterRoutes(apiRouter)
|
||||
badgeHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1StatusBarHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1AllHandler.RegisterRoutes(apiRouter)
|
||||
wakatimeV1SummariesHandler.RegisterRoutes(apiRouter)
|
||||
@ -260,22 +282,7 @@ func main() {
|
||||
|
||||
router.PathPrefix("/contribute.json").Handler(staticFileServer)
|
||||
router.PathPrefix("/assets").Handler(assetsFileServer)
|
||||
router.PathPrefix("/swagger-ui").Handler(staticFileServer)
|
||||
router.PathPrefix("/docs").Handler(
|
||||
middlewares.NewFileTypeFilterMiddleware([]string{".go"})(staticFileServer),
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
router.PathPrefix("/swagger-ui").Handler(httpSwagger.WrapHandler)
|
||||
|
||||
// Listen HTTP
|
||||
listen(router)
|
||||
|
@ -105,7 +105,7 @@ func (m *WakatimeRelayMiddleware) ServeHTTP(w http.ResponseWriter, r *http.Reque
|
||||
func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, headers http.Header, forUser *models.User) {
|
||||
request, err := http.NewRequest(method, url, body)
|
||||
if err != nil {
|
||||
logbuch.Warn("error constructing relayed request – %v", err)
|
||||
logbuch.Warn("error constructing relayed request - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -117,7 +117,7 @@ func (m *WakatimeRelayMiddleware) send(method, url string, body io.Reader, heade
|
||||
|
||||
response, err := m.httpClient.Do(request)
|
||||
if err != nil {
|
||||
logbuch.Warn("error executing relayed request – %v", err)
|
||||
logbuch.Warn("error executing relayed request - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
|
56
migrations/20220317_align_num_heartbeats.go
Normal file
56
migrations/20220317_align_num_heartbeats.go
Normal file
@ -0,0 +1,56 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20220317-align_num_heartbeats"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if hasRun(name, db) {
|
||||
return nil
|
||||
}
|
||||
|
||||
logbuch.Info("this may take a while!")
|
||||
|
||||
// find all summaries whose num_heartbeats is zero even though they have items
|
||||
var faultyIds []uint
|
||||
|
||||
if err := db.Model(&models.Summary{}).
|
||||
Distinct("summaries.id").
|
||||
Joins("INNER JOIN summary_items ON summaries.num_heartbeats = 0 AND summaries.id = summary_items.summary_id").
|
||||
Scan(&faultyIds).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// update their heartbeats counter
|
||||
result := db.
|
||||
Table("summaries AS s1").
|
||||
Where("s1.id IN ?", faultyIds).
|
||||
Update(
|
||||
"num_heartbeats",
|
||||
db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Select("COUNT(*)").
|
||||
Where("user_id = ?", gorm.Expr("s1.user_id")).
|
||||
Where("time BETWEEN ? AND ?", gorm.Expr("s1.from_time"), gorm.Expr("s1.to_time")),
|
||||
)
|
||||
|
||||
if err := result.Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logbuch.Info("corrected heartbeats counter of %d summaries", result.RowsAffected)
|
||||
|
||||
setHasRun(name, db)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
41
migrations/20220318_mysql_timestamp_precision.go
Normal file
41
migrations/20220318_mysql_timestamp_precision.go
Normal file
@ -0,0 +1,41 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20220318-mysql_timestamp_precision"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if hasRun(name, db) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Db.IsMySQL() {
|
||||
logbuch.Info("altering heartbeats table, this may take a while (up to hours)")
|
||||
|
||||
db.Exec("SET foreign_key_checks=0;")
|
||||
db.Exec("SET unique_checks=0;")
|
||||
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `time` TIMESTAMP(3) NOT NULL").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Exec("ALTER TABLE heartbeats MODIFY COLUMN `created_at` TIMESTAMP(3) NOT NULL").Error; err != nil {
|
||||
return err
|
||||
}
|
||||
db.Exec("SET foreign_key_checks=1;")
|
||||
db.Exec("SET unique_checks=1;")
|
||||
|
||||
logbuch.Info("migrated timestamp columns to millisecond precision")
|
||||
}
|
||||
|
||||
setHasRun(name, db)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
39
migrations/202203191_drop_diagnostics_user.go
Normal file
39
migrations/202203191_drop_diagnostics_user.go
Normal file
@ -0,0 +1,39 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "202203191-drop_diagnostics_user"
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if hasRun(name, db) {
|
||||
return nil
|
||||
}
|
||||
|
||||
migrator := db.Migrator()
|
||||
|
||||
if migrator.HasColumn(&models.Diagnostics{}, "user_id") {
|
||||
logbuch.Info("running migration '%s'", name)
|
||||
|
||||
if err := migrator.DropConstraint(&models.Diagnostics{}, "fk_diagnostics_user"); err != nil {
|
||||
logbuch.Warn("failed to drop 'fk_diagnostics_user' constraint (%v)", err)
|
||||
}
|
||||
|
||||
if err := migrator.DropColumn(&models.Diagnostics{}, "user_id"); err != nil {
|
||||
logbuch.Warn("failed to drop user_id column of diagnostics (%v)", err)
|
||||
}
|
||||
}
|
||||
|
||||
setHasRun(name, db)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPostMigration(f)
|
||||
}
|
40
migrations/20220403_drop_user_project_idx.go
Normal file
40
migrations/20220403_drop_user_project_idx.go
Normal file
@ -0,0 +1,40 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// migration to fix https://github.com/muety/wakapi/issues/346
|
||||
// caused by https://github.com/muety/wakapi/blob/2.3.2/migrations/20220319_add_user_project_idx.go in combination with
|
||||
// the wrongly defined index at https://github.com/muety/wakapi/blob/5aae18e2415d9e620f383f98cd8cbdf39cd99f27/models/heartbeat.go#L18
|
||||
// and https://github.com/go-gorm/sqlite/issues/87
|
||||
// -> drop index and let it be auto-created again with properly formatted ddl
|
||||
|
||||
func init() {
|
||||
const name = "20220403-drop_user_project_idx"
|
||||
const idxName = "idx_user_project"
|
||||
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if !db.Migrator().HasTable(&models.KeyStringValue{}) || hasRun(name, db) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if cfg.Db.IsSQLite() && db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
|
||||
logbuch.Info("running migration '%s'", name)
|
||||
if err := db.Migrator().DropIndex(&models.Heartbeat{}, idxName); err != nil {
|
||||
logbuch.Warn("failed to drop %s", idxName)
|
||||
}
|
||||
}
|
||||
|
||||
setHasRun(name, db)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPreMigration(f)
|
||||
}
|
24
migrations/20220930_drop_heartbeats_entity_idx.go
Normal file
24
migrations/20220930_drop_heartbeats_entity_idx.go
Normal file
@ -0,0 +1,24 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func init() {
|
||||
const name = "20220930-drop_heartbeats_entity_idx"
|
||||
const idxName = "idx_entity"
|
||||
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if !db.Migrator().HasTable(&models.Heartbeat{}) || !db.Migrator().HasIndex(&models.Heartbeat{}, idxName) {
|
||||
return nil
|
||||
}
|
||||
return db.Migrator().DropIndex(&models.Heartbeat{}, idxName)
|
||||
},
|
||||
}
|
||||
|
||||
registerPreMigration(f)
|
||||
}
|
88
migrations/20221002_fix_summary_id_types.go
Normal file
88
migrations/20221002_fix_summary_id_types.go
Normal file
@ -0,0 +1,88 @@
|
||||
package migrations
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// fix for https://github.com/muety/wakapi/issues/416
|
||||
|
||||
func init() {
|
||||
const name = "20221002-fix_summary_id_types"
|
||||
|
||||
f := migrationFunc{
|
||||
name: name,
|
||||
f: func(db *gorm.DB, cfg *config.Config) error {
|
||||
if cfg.Db.Dialect != config.SQLDialectMysql {
|
||||
return nil
|
||||
}
|
||||
|
||||
if !db.Migrator().HasTable(&models.Summary{}) || !db.Migrator().HasTable(&models.SummaryItem{}) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var currentType string
|
||||
if err := db.
|
||||
Table("information_schema.columns").
|
||||
Select("data_type").
|
||||
Where("table_name = ?", "summary_items").
|
||||
Where("column_name = ?", "summary_id").
|
||||
Limit(1).
|
||||
Row().Scan(¤tType); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if strings.ToLower(currentType) != "int" {
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_editors") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_editors"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_languages") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_languages"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_machines") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_machines"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_operating_systems"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_projects") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_projects"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
// https://github.com/muety/wakapi/issues/416#issuecomment-1271674792
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summary_items_summary") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summary_items_summary"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if db.Migrator().HasConstraint(&models.SummaryItem{}, "fk_summaries_labels") {
|
||||
if err := db.Migrator().DropConstraint(&models.SummaryItem{}, "fk_summaries_labels"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.Migrator().AlterColumn(&models.Summary{}, "id"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.Migrator().AlterColumn(&models.SummaryItem{}, "summary_id"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
registerPreMigration(f)
|
||||
}
|
@ -46,7 +46,7 @@ func RunPreMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range preMigrations {
|
||||
logbuch.Info("potentially running migration '%s'", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
||||
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -57,7 +57,7 @@ func RunPostMigrations(db *gorm.DB, cfg *config.Config) {
|
||||
for _, m := range postMigrations {
|
||||
logbuch.Info("potentially running migration '%s'", m.name)
|
||||
if err := m.f(db, cfg); err != nil {
|
||||
logbuch.Fatal("migration '%s' failed – %v", m.name, err)
|
||||
logbuch.Fatal("migration '%s' failed - %v", m.name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -20,8 +20,8 @@ func (m *HeartbeatServiceMock) InsertBatch(heartbeats []*models.Heartbeat) error
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) Count() (int64, error) {
|
||||
args := m.Called()
|
||||
func (m *HeartbeatServiceMock) Count(a bool) (int64, error) {
|
||||
args := m.Called(a)
|
||||
return int64(args.Int(0)), args.Error(1)
|
||||
}
|
||||
|
||||
@ -40,6 +40,11 @@ func (m *HeartbeatServiceMock) GetAllWithin(time time.Time, time2 time.Time, use
|
||||
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetAllWithinByFilters(time time.Time, time2 time.Time, user *models.User, filters *models.Filters) ([]*models.Heartbeat, error) {
|
||||
args := m.Called(time, time2, user, filters)
|
||||
return args.Get(0).([]*models.Heartbeat), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*models.TimeByUser), args.Error(1)
|
||||
@ -64,3 +69,8 @@ func (m *HeartbeatServiceMock) DeleteBefore(time time.Time) error {
|
||||
args := m.Called(time)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *HeartbeatServiceMock) DeleteByUser(u *models.User) error {
|
||||
args := m.Called(u)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
@ -34,6 +34,21 @@ func (m *UserServiceMock) GetAll() ([]*models.User, error) {
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetMany(s []string) ([]*models.User, error) {
|
||||
args := m.Called(s)
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetManyMapped(s []string) (map[string]*models.User, error) {
|
||||
args := m.Called()
|
||||
return args.Get(0).(map[string]*models.User), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetAllByLeaderboard(b bool) ([]*models.User, error) {
|
||||
//TODO implement me
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
func (m *UserServiceMock) GetAllByReports(b bool) ([]*models.User, error) {
|
||||
args := m.Called(b)
|
||||
return args.Get(0).([]*models.User), args.Error(1)
|
||||
|
@ -8,8 +8,8 @@ import (
|
||||
// https://shields.io/endpoint
|
||||
|
||||
const (
|
||||
defaultLabel = "coding time"
|
||||
defaultColor = "#2D3748" // not working
|
||||
defaultLabel = "wakapi.dev"
|
||||
defaultColor = "2F855A"
|
||||
)
|
||||
|
||||
type BadgeData struct {
|
||||
|
@ -2,8 +2,6 @@ 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"`
|
||||
|
@ -40,7 +40,7 @@ func NewDurationFromHeartbeat(h *Heartbeat) *Duration {
|
||||
func (d *Duration) Hashed() *Duration {
|
||||
hash, err := hashstructure.Hash(d, hashstructure.FormatV2, nil)
|
||||
if err != nil {
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||
}
|
||||
d.GroupHash = fmt.Sprintf("%x", hash)
|
||||
return d
|
||||
|
@ -92,7 +92,7 @@ func (f *Filters) OneOrEmpty() FilterElement {
|
||||
if ok, t, of := f.One(); ok {
|
||||
return FilterElement{entity: t, filter: of}
|
||||
}
|
||||
return FilterElement{}
|
||||
return FilterElement{entity: SummaryUnknown, filter: []string{}}
|
||||
}
|
||||
|
||||
func (f *Filters) IsEmpty() bool {
|
||||
@ -100,10 +100,53 @@ func (f *Filters) IsEmpty() bool {
|
||||
return !nonEmpty
|
||||
}
|
||||
|
||||
func (f *Filters) Count() int {
|
||||
var count int
|
||||
for i := SummaryProject; i <= SummaryBranch; i++ {
|
||||
count += f.CountByEntity(i)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (f *Filters) CountByEntity(entity uint8) int {
|
||||
return len(*f.ResolveEntity(entity))
|
||||
}
|
||||
|
||||
func (f *Filters) EntityCount() int {
|
||||
var count int
|
||||
for i := SummaryProject; i <= SummaryBranch; i++ {
|
||||
if c := f.CountByEntity(i); c > 0 {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func (f *Filters) ResolveEntity(entityId uint8) *OrFilter {
|
||||
switch entityId {
|
||||
case SummaryProject:
|
||||
return &f.Project
|
||||
case SummaryLanguage:
|
||||
return &f.Language
|
||||
case SummaryEditor:
|
||||
return &f.Editor
|
||||
case SummaryOS:
|
||||
return &f.OS
|
||||
case SummaryMachine:
|
||||
return &f.Machine
|
||||
case SummaryLabel:
|
||||
return &f.Label
|
||||
case SummaryBranch:
|
||||
return &f.Branch
|
||||
default:
|
||||
return &OrFilter{}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Filters) Hash() string {
|
||||
hash, err := hashstructure.Hash(f, hashstructure.FormatV2, nil)
|
||||
if err != nil {
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||
}
|
||||
return fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||
}
|
||||
|
@ -11,29 +11,34 @@ import (
|
||||
type Heartbeat struct {
|
||||
ID uint64 `gorm:"primary_key" hash:"ignore"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" hash:"ignore"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_time_user"`
|
||||
Entity string `json:"entity" gorm:"not null; index:idx_entity"`
|
||||
Type string `json:"type"`
|
||||
Category string `json:"category"`
|
||||
Project string `json:"project"`
|
||||
Branch string `json:"branch"`
|
||||
UserID string `json:"-" gorm:"not null; index:idx_time_user; index:idx_user_project"` // idx_user_project is for quickly fetching a user's project list (settings page)
|
||||
Entity string `json:"entity" gorm:"not null"`
|
||||
Type string `json:"type" gorm:"size:255"`
|
||||
Category string `json:"category" gorm:"size:255"`
|
||||
Project string `json:"project" gorm:"index:idx_project; index:idx_user_project"`
|
||||
Branch string `json:"branch" gorm:"index:idx_branch"`
|
||||
Language string `json:"language" gorm:"index:idx_language"`
|
||||
IsWrite bool `json:"is_write"`
|
||||
Editor string `json:"editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
|
||||
OperatingSystem string `json:"operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
|
||||
Machine string `json:"machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
|
||||
UserAgent string `json:"user_agent" hash:"ignore"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp; index:idx_time,idx_time_user" swaggertype:"primitive,number"`
|
||||
Editor string `json:"editor" gorm:"index:idx_editor" hash:"ignore"` // ignored because editor might be parsed differently by wakatime
|
||||
OperatingSystem string `json:"operating_system" gorm:"index:idx_operating_system" hash:"ignore"` // ignored because os might be parsed differently by wakatime
|
||||
Machine string `json:"machine" gorm:"index:idx_machine" hash:"ignore"` // ignored because wakatime api doesn't return machines currently
|
||||
UserAgent string `json:"user_agent" hash:"ignore" gorm:"type:varchar(255)"`
|
||||
Time CustomTime `json:"time" gorm:"type:timestamp(3); index:idx_time,idx_time_user" swaggertype:"primitive,number"`
|
||||
Hash string `json:"-" gorm:"type:varchar(17); uniqueIndex"`
|
||||
Origin string `json:"-" hash:"ignore"`
|
||||
OriginId string `json:"-" hash:"ignore"`
|
||||
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
|
||||
Origin string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||
OriginId string `json:"-" hash:"ignore" gorm:"type:varchar(255)"`
|
||||
CreatedAt CustomTime `json:"created_at" gorm:"type:timestamp(3)" swaggertype:"primitive,number" hash:"ignore"` // https://gorm.io/docs/conventions.html#CreatedAt
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Valid() bool {
|
||||
return h.User != nil && h.UserID != "" && h.User.ID == h.UserID && h.Time != CustomTime(time.Time{})
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Timely(maxAge time.Duration) bool {
|
||||
now := time.Now()
|
||||
return now.Sub(h.Time.T()) <= maxAge && h.Time.T().Sub(now) < 1*time.Hour
|
||||
}
|
||||
|
||||
func (h *Heartbeat) Augment(languageMappings map[string]string) {
|
||||
maxPrec := -1 // precision / mapping complexity -> more concrete ones shall take precedence
|
||||
for ending, value := range languageMappings {
|
||||
@ -94,8 +99,20 @@ func (h *Heartbeat) String() string {
|
||||
func (h *Heartbeat) Hashed() *Heartbeat {
|
||||
hash, err := hashstructure.Hash(h, hashstructure.FormatV2, nil)
|
||||
if err != nil {
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct – %v", err)
|
||||
logbuch.Error("CRITICAL ERROR: failed to hash struct - %v", err)
|
||||
}
|
||||
h.Hash = fmt.Sprintf("%x", hash) // "uint64 values with high bit set are not supported"
|
||||
return h
|
||||
}
|
||||
|
||||
func GetEntityColumn(t uint8) string {
|
||||
return []string{
|
||||
"project",
|
||||
"language",
|
||||
"editor",
|
||||
"operating_system",
|
||||
"machine",
|
||||
"label",
|
||||
"branch",
|
||||
}[t]
|
||||
}
|
||||
|
@ -14,8 +14,9 @@ var (
|
||||
IntervalPast7DaysYesterday = &IntervalKey{"Last 7 Days from Yesterday"}
|
||||
IntervalPast14Days = &IntervalKey{"Last 14 Days"}
|
||||
IntervalPast30Days = &IntervalKey{"30_days", "last_30_days", "Last 30 Days"}
|
||||
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months"}
|
||||
IntervalAny = &IntervalKey{"any"}
|
||||
IntervalPast6Months = &IntervalKey{"6_months", "last_6_months"}
|
||||
IntervalPast12Months = &IntervalKey{"12_months", "last_12_months", "last_year"}
|
||||
IntervalAny = &IntervalKey{"any", "all_time"}
|
||||
)
|
||||
|
||||
var AllIntervals = []*IntervalKey{
|
||||
@ -30,6 +31,7 @@ var AllIntervals = []*IntervalKey{
|
||||
IntervalPast7DaysYesterday,
|
||||
IntervalPast14Days,
|
||||
IntervalPast30Days,
|
||||
IntervalPast6Months,
|
||||
IntervalPast12Months,
|
||||
IntervalAny,
|
||||
}
|
||||
|
81
models/leaderboard.go
Normal file
81
models/leaderboard.go
Normal file
@ -0,0 +1,81 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LeaderboardItem struct {
|
||||
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_leaderboard_user"`
|
||||
Rank uint `json:"rank" gorm:"->"`
|
||||
Interval string `json:"interval" gorm:"not null; size:32; index:idx_leaderboard_combined"`
|
||||
By *uint8 `json:"aggregated_by" gorm:"index:idx_leaderboard_combined"` // pointer because nullable
|
||||
Total time.Duration `json:"total" gorm:"not null" swaggertype:"primitive,integer"`
|
||||
Key *string `json:"key" gorm:"size:255"` // pointer because nullable
|
||||
CreatedAt CustomTime `gorm:"type:timestamp; default:CURRENT_TIMESTAMP" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
}
|
||||
|
||||
type Leaderboard []*LeaderboardItem
|
||||
|
||||
func (l Leaderboard) UserIDs() []string {
|
||||
return slice.Unique[string](slice.Map[*LeaderboardItem, string](l, func(i int, item *LeaderboardItem) string {
|
||||
return item.UserID
|
||||
}))
|
||||
}
|
||||
|
||||
func (l Leaderboard) TopByKey(by uint8, key string) Leaderboard {
|
||||
return slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
|
||||
return item.By != nil && *item.By == by && item.Key != nil && strings.ToLower(*item.Key) == strings.ToLower(key)
|
||||
})
|
||||
}
|
||||
|
||||
func (l Leaderboard) TopKeys(by uint8) []string {
|
||||
type keyTotal struct {
|
||||
Key string
|
||||
Total time.Duration
|
||||
}
|
||||
|
||||
totalsMapped := make(map[string]*keyTotal, len(l))
|
||||
|
||||
for _, item := range l {
|
||||
if item.Key == nil || item.By == nil || *item.By != by {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(*item.Key)
|
||||
if _, ok := totalsMapped[key]; !ok {
|
||||
totalsMapped[key] = &keyTotal{Key: *item.Key, Total: 0}
|
||||
}
|
||||
totalsMapped[key].Total += item.Total
|
||||
}
|
||||
|
||||
totals := slice.Map[*keyTotal, keyTotal](maputil.Values[string, *keyTotal](totalsMapped), func(i int, item *keyTotal) keyTotal {
|
||||
return *item
|
||||
})
|
||||
if err := slice.SortByField(totals, "Total", "desc"); err != nil {
|
||||
return []string{} // TODO
|
||||
}
|
||||
|
||||
return slice.Map[keyTotal, string](totals, func(i int, item keyTotal) string {
|
||||
return item.Key
|
||||
})
|
||||
}
|
||||
|
||||
func (l Leaderboard) TopKeysByUser(by uint8, userId string) []string {
|
||||
return Leaderboard(slice.Filter[*LeaderboardItem](l, func(i int, item *LeaderboardItem) bool {
|
||||
return item.UserID == userId
|
||||
})).TopKeys(by)
|
||||
}
|
||||
|
||||
func (l Leaderboard) LastUpdate() time.Time {
|
||||
lastUpdate := time.Time{}
|
||||
for _, item := range l {
|
||||
if item.CreatedAt.T().After(lastUpdate) {
|
||||
lastUpdate = item.CreatedAt.T()
|
||||
}
|
||||
}
|
||||
return lastUpdate
|
||||
}
|
@ -4,7 +4,7 @@ import "fmt"
|
||||
|
||||
type CounterMetric struct {
|
||||
Name string
|
||||
Value int
|
||||
Value int64
|
||||
Desc string
|
||||
Labels Labels
|
||||
}
|
||||
|
@ -29,6 +29,11 @@ type Interval struct {
|
||||
End time.Time
|
||||
}
|
||||
|
||||
type KeyedInterval struct {
|
||||
Interval
|
||||
Key *IntervalKey
|
||||
}
|
||||
|
||||
// CustomTime is a wrapper type around time.Time, mainly used for the purpose of transparently unmarshalling Python timestamps in the format <sec>.<nsec> (e.g. 1619335137.3324468)
|
||||
type CustomTime time.Time
|
||||
|
||||
|
@ -8,6 +8,7 @@ import (
|
||||
|
||||
const (
|
||||
NSummaryTypes uint8 = 99
|
||||
SummaryUnknown uint8 = 98
|
||||
SummaryProject uint8 = 0
|
||||
SummaryLanguage uint8 = 1
|
||||
SummaryEditor uint8 = 2
|
||||
@ -21,7 +22,7 @@ const UnknownSummaryKey = "unknown"
|
||||
const DefaultProjectLabel = "default"
|
||||
|
||||
type Summary struct {
|
||||
ID uint `json:"-" gorm:"primary_key"`
|
||||
ID uint `json:"-" gorm:"primary_key; size:32"`
|
||||
User *User `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
UserID string `json:"user_id" gorm:"not null; index:idx_time_summary_user"`
|
||||
FromTime CustomTime `json:"from" gorm:"not null; type:timestamp; default:CURRENT_TIMESTAMP; index:idx_time_summary_user" swaggertype:"string" format:"date" example:"2006-01-02 15:04:05.000"`
|
||||
@ -41,9 +42,9 @@ type SummaryItems []*SummaryItem
|
||||
type SummaryItem struct {
|
||||
ID uint64 `json:"-" gorm:"primary_key"`
|
||||
Summary *Summary `json:"-" gorm:"not null; constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
SummaryID uint `json:"-"`
|
||||
SummaryID uint `json:"-" gorm:"size:32"`
|
||||
Type uint8 `json:"-" gorm:"index:idx_type"`
|
||||
Key string `json:"key"`
|
||||
Key string `json:"key" gorm:"size:255"`
|
||||
Total time.Duration `json:"total" swaggertype:"primitive,integer"`
|
||||
}
|
||||
|
||||
@ -100,10 +101,42 @@ func (s *Summary) MappedItems() map[uint8]*SummaryItems {
|
||||
}
|
||||
|
||||
func (s *Summary) ItemsByType(summaryType uint8) *SummaryItems {
|
||||
return s.MappedItems()[summaryType]
|
||||
switch summaryType {
|
||||
case SummaryProject:
|
||||
return &s.Projects
|
||||
case SummaryLanguage:
|
||||
return &s.Languages
|
||||
case SummaryEditor:
|
||||
return &s.Editors
|
||||
case SummaryOS:
|
||||
return &s.OperatingSystems
|
||||
case SummaryMachine:
|
||||
return &s.Machines
|
||||
case SummaryLabel:
|
||||
return &s.Labels
|
||||
case SummaryBranch:
|
||||
return &s.Branches
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/* Augments the summary in a way that at least one item is present for every type.
|
||||
func (s *Summary) KeepOnly(types map[uint8]bool) *Summary {
|
||||
if len(types) == 0 {
|
||||
return s
|
||||
}
|
||||
|
||||
for _, t := range SummaryTypes() {
|
||||
if keep, ok := types[t]; !keep || !ok {
|
||||
*s.ItemsByType(t) = []*SummaryItem{}
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
/*
|
||||
Augments the summary in a way that at least one item is present for every type.
|
||||
|
||||
If a summary has zero items for a given type, but one or more for any of the other types,
|
||||
the total summary duration can be derived from those and inserted as a dummy-item with key "unknown"
|
||||
for the missing type.
|
||||
|
@ -168,6 +168,66 @@ func TestSummary_WithResolvedAliases(t *testing.T) {
|
||||
assert.Empty(t, sut.Machines)
|
||||
}
|
||||
|
||||
func TestSummary_KeepOnly(t *testing.T) {
|
||||
newSummary := func() *Summary {
|
||||
return &Summary{
|
||||
Projects: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "wakapi",
|
||||
// hack to work around the issue that the total time of a summary item is mistakenly represented in seconds
|
||||
Total: 10 * time.Minute / time.Second,
|
||||
},
|
||||
{
|
||||
Type: SummaryProject,
|
||||
Key: "anchr",
|
||||
Total: 10 * time.Minute / time.Second,
|
||||
},
|
||||
},
|
||||
Languages: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryLanguage,
|
||||
Key: "Go",
|
||||
Total: 10 * time.Minute / time.Second,
|
||||
},
|
||||
},
|
||||
Editors: []*SummaryItem{
|
||||
{
|
||||
Type: SummaryEditor,
|
||||
Key: "VSCode",
|
||||
Total: 10 * time.Minute / time.Second,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var sut *Summary
|
||||
|
||||
sut = newSummary().KeepOnly(map[uint8]bool{}) // keep all
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
|
||||
assert.Equal(t, 20*time.Minute, sut.TotalTime())
|
||||
|
||||
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryEditor))
|
||||
assert.Equal(t, 20*time.Minute, sut.TotalTime())
|
||||
|
||||
sut = newSummary().KeepOnly(map[uint8]bool{SummaryEditor: true, SummaryLanguage: true})
|
||||
assert.Zero(t, sut.TotalTimeBy(SummaryProject))
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
|
||||
assert.Equal(t, 10*time.Minute, sut.TotalTime())
|
||||
|
||||
sut = newSummary().KeepOnly(map[uint8]bool{SummaryProject: true})
|
||||
sut.FillMissing()
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryProject))
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryLanguage))
|
||||
assert.NotZero(t, sut.TotalTimeBy(SummaryEditor))
|
||||
}
|
||||
|
||||
func TestSummaryItems_Sorted(t *testing.T) {
|
||||
testDuration1, testDuration2, testDuration3 := 10*time.Minute, 5*time.Minute, 20*time.Minute
|
||||
|
||||
|
@ -14,7 +14,7 @@ func init() {
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id" gorm:"primary_key"`
|
||||
ApiKey string `json:"api_key" gorm:"unique"`
|
||||
ApiKey string `json:"api_key" gorm:"unique; default:NULL"`
|
||||
Email string `json:"email" gorm:"index:idx_user_email; size:255"`
|
||||
Location string `json:"location"`
|
||||
Password string `json:"-"`
|
||||
@ -33,6 +33,7 @@ type User struct {
|
||||
WakatimeApiUrl string `json:"-"` // for relay middleware and imports
|
||||
ResetToken string `json:"-"`
|
||||
ReportsWeekly bool `json:"-" gorm:"default:false; type:bool"`
|
||||
PublicLeaderboard bool `json:"-" gorm:"default:false; type:bool"`
|
||||
}
|
||||
|
||||
type Login struct {
|
||||
@ -68,6 +69,7 @@ type UserDataUpdate struct {
|
||||
Email string `schema:"email"`
|
||||
Location string `schema:"location"`
|
||||
ReportsWeekly bool `schema:"reports_weekly"`
|
||||
PublicLeaderboard bool `schema:"public_leaderboard"`
|
||||
}
|
||||
|
||||
type TimeByUser struct {
|
||||
|
@ -1,10 +1,16 @@
|
||||
package view
|
||||
|
||||
type Newsbox struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type HomeViewModel struct {
|
||||
Success string
|
||||
Error string
|
||||
TotalHours int
|
||||
TotalUsers int
|
||||
Newsbox *Newsbox
|
||||
}
|
||||
|
||||
func (s *HomeViewModel) WithSuccess(m string) *HomeViewModel {
|
||||
|
75
models/view/leaderboard.go
Normal file
75
models/view/leaderboard.go
Normal file
@ -0,0 +1,75 @@
|
||||
package view
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LeaderboardViewModel struct {
|
||||
User *models.User
|
||||
By string
|
||||
Key string
|
||||
Items []*models.LeaderboardItem
|
||||
TopKeys []string
|
||||
UserLanguages map[string][]string
|
||||
ApiKey string
|
||||
Success string
|
||||
Error string
|
||||
}
|
||||
|
||||
func (s *LeaderboardViewModel) WithSuccess(m string) *LeaderboardViewModel {
|
||||
s.Success = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LeaderboardViewModel) WithError(m string) *LeaderboardViewModel {
|
||||
s.Error = m
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *LeaderboardViewModel) ColorModifier(item *models.LeaderboardItem, principal *models.User) string {
|
||||
if principal != nil && item.UserID == principal.ID {
|
||||
return "self"
|
||||
}
|
||||
if item.Rank == 1 {
|
||||
return "gold"
|
||||
}
|
||||
if item.Rank == 2 {
|
||||
return "silver"
|
||||
}
|
||||
if item.Rank == 3 {
|
||||
return "bronze"
|
||||
}
|
||||
return "default"
|
||||
}
|
||||
|
||||
func (s *LeaderboardViewModel) LangIcon(lang string) string {
|
||||
// https://icon-sets.iconify.design/mdi/
|
||||
langs := map[string]string{
|
||||
"c++": "cpp",
|
||||
"cpp": "cpp",
|
||||
"go": "go",
|
||||
"haskell": "haskell",
|
||||
"html": "html5",
|
||||
"java": "java",
|
||||
"javascript": "javascript",
|
||||
"kotlin": "kotlin",
|
||||
"lua": "lua",
|
||||
"php": "php",
|
||||
"python": "python",
|
||||
"r": "r",
|
||||
"ruby": "ruby",
|
||||
"rust": "rust",
|
||||
"swift": "swift",
|
||||
"typescript": "typescript",
|
||||
}
|
||||
if match, ok := langs[strings.ToLower(lang)]; ok {
|
||||
return "mdi:language-" + match
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (s *LeaderboardViewModel) LastUpdate() time.Time {
|
||||
return models.Leaderboard(s.Items).LastUpdate()
|
||||
}
|
@ -7,7 +7,9 @@ type SummaryViewModel struct {
|
||||
*models.SummaryParams
|
||||
User *models.User
|
||||
AvatarURL string
|
||||
EditorColors map[string]string
|
||||
LanguageColors map[string]string
|
||||
OSColors map[string]string
|
||||
Error string
|
||||
Success string
|
||||
ApiKey string
|
||||
|
@ -12,7 +12,7 @@
|
||||
"@iconify/json": "^1.1.444",
|
||||
"@iconify/json-tools": "^1.0.10",
|
||||
"chokidar-cli": "^3.0.0",
|
||||
"tailwindcss": "2.2.19"
|
||||
"tailwindcss": "^3.1.8"
|
||||
},
|
||||
"dependencies": {}
|
||||
}
|
||||
|
@ -1,7 +1,7 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"errors"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@ -10,10 +10,11 @@ import (
|
||||
|
||||
type HeartbeatRepository struct {
|
||||
db *gorm.DB
|
||||
config *conf.Config
|
||||
}
|
||||
|
||||
func NewHeartbeatRepository(db *gorm.DB) *HeartbeatRepository {
|
||||
return &HeartbeatRepository{db: db}
|
||||
return &HeartbeatRepository{config: conf.Get(), db: db}
|
||||
}
|
||||
|
||||
// Use with caution!!
|
||||
@ -77,6 +78,26 @@ func (r *HeartbeatRepository) GetAllWithin(from, to time.Time, user *models.User
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetAllWithinByFilters(from, to time.Time, user *models.User, filterMap map[string][]string) ([]*models.Heartbeat, error) {
|
||||
// https://stackoverflow.com/a/20765152/3112139
|
||||
var heartbeats []*models.Heartbeat
|
||||
|
||||
q := r.db.
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Where("time >= ?", from.Local()).
|
||||
Where("time < ?", to.Local()).
|
||||
Order("time asc")
|
||||
|
||||
for col, vals := range filterMap {
|
||||
q = q.Where(col+" in ?", vals)
|
||||
}
|
||||
|
||||
if err := q.Find(&heartbeats).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return heartbeats, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) GetFirstByUsers() ([]*models.TimeByUser, error) {
|
||||
var result []*models.TimeByUser
|
||||
r.db.Model(&models.User{}).
|
||||
@ -97,12 +118,19 @@ func (r *HeartbeatRepository) GetLastByUsers() ([]*models.TimeByUser, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) Count() (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.
|
||||
func (r *HeartbeatRepository) Count(approximate bool) (count int64, err error) {
|
||||
if r.config.Db.IsMySQL() && approximate {
|
||||
err = r.db.Table("information_schema.tables").
|
||||
Select("table_rows").
|
||||
Where("table_schema = ?", r.config.Db.Name).
|
||||
Where("table_name = 'heartbeats'").
|
||||
Scan(&count).Error
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
err = r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
Count(&count).Error
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
@ -126,6 +154,10 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
userIds[i] = u.ID
|
||||
}
|
||||
|
||||
if len(userIds) == 0 {
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Select("user_id as user, count(id) as count").
|
||||
@ -134,20 +166,15 @@ func (r *HeartbeatRepository) CountByUsers(users []*models.User) ([]*models.Coun
|
||||
Find(&counts).Error; err != nil {
|
||||
return counts, err
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r HeartbeatRepository) GetEntitySetByUser(entityType uint8, user *models.User) ([]string, error) {
|
||||
columns := []string{"project", "language", "editor", "operating_system", "machine"}
|
||||
if int(entityType) >= len(columns) {
|
||||
// invalid entity type
|
||||
return nil, errors.New("invalid entity type")
|
||||
}
|
||||
|
||||
var results []string
|
||||
if err := r.db.
|
||||
Model(&models.Heartbeat{}).
|
||||
Distinct(columns[entityType]).
|
||||
Distinct(models.GetEntityColumn(entityType)).
|
||||
Where(&models.Heartbeat{UserID: user.ID}).
|
||||
Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
@ -163,3 +190,12 @@ func (r *HeartbeatRepository) DeleteBefore(t time.Time) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *HeartbeatRepository) DeleteByUser(user *models.User) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", user.ID).
|
||||
Delete(models.Heartbeat{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
81
repositories/leaderboard.go
Normal file
81
repositories/leaderboard.go
Normal file
@ -0,0 +1,81 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type LeaderboardRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewLeaderboardRepository(db *gorm.DB) *LeaderboardRepository {
|
||||
return &LeaderboardRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) InsertBatch(items []*models.LeaderboardItem) error {
|
||||
if err := r.db.
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
Create(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) CountAllByUser(userId string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.
|
||||
Table("leaderboard_items").
|
||||
Where("user_id = ?", userId).
|
||||
Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) GetAllAggregatedByInterval(key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
|
||||
// TODO: distinct by (user, key) to filter out potential duplicates ?
|
||||
var items []*models.LeaderboardItem
|
||||
q := r.db.
|
||||
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
||||
Where("\"interval\" in ?", *key)
|
||||
q = utils.WhereNullable(q, "\"by\"", by)
|
||||
|
||||
if err := q.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) GetAggregatedByUserAndInterval(userId string, key *models.IntervalKey, by *uint8) ([]*models.LeaderboardItem, error) {
|
||||
var items []*models.LeaderboardItem
|
||||
q := r.db.
|
||||
Select("*, rank() over (partition by \"key\" order by total desc) as \"rank\"").
|
||||
Where("user_id = ?", userId).
|
||||
Where("\"interval\" in ?", *key)
|
||||
q = utils.WhereNullable(q, "\"by\"", by)
|
||||
|
||||
if err := q.Find(&items).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) DeleteByUser(userId string) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", userId).
|
||||
Delete(models.LeaderboardItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *LeaderboardRepository) DeleteByUserAndInterval(userId string, key *models.IntervalKey) error {
|
||||
if err := r.db.
|
||||
Where("user_id = ?", userId).
|
||||
Where("\"interval\" in ?", *key).
|
||||
Delete(models.LeaderboardItem{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
43
repositories/metrics.go
Normal file
43
repositories/metrics.go
Normal file
@ -0,0 +1,43 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MetricsRepository struct {
|
||||
config *config.Config
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
const sizeTplMysql = `
|
||||
SELECT SUM(data_length + index_length)
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = ?
|
||||
GROUP BY table_schema`
|
||||
|
||||
const sizeTplPostgres = `SELECT pg_database_size('%s');`
|
||||
|
||||
const sizeTplSqlite = `
|
||||
SELECT page_count * page_size as size
|
||||
FROM pragma_page_count(), pragma_page_size();`
|
||||
|
||||
func NewMetricsRepository(db *gorm.DB) *MetricsRepository {
|
||||
return &MetricsRepository{config: config.Get(), db: db}
|
||||
}
|
||||
|
||||
func (srv *MetricsRepository) GetDatabaseSize() (size int64, err error) {
|
||||
cfg := srv.config.Db
|
||||
|
||||
query := srv.db.Raw("SELECT 0")
|
||||
if cfg.IsMySQL() {
|
||||
query = srv.db.Raw(sizeTplMysql, cfg.Name)
|
||||
} else if cfg.IsPostgres() {
|
||||
query = srv.db.Raw(sizeTplPostgres, cfg.Name)
|
||||
} else if cfg.IsSQLite() {
|
||||
query = srv.db.Raw(sizeTplSqlite)
|
||||
}
|
||||
|
||||
err = query.Scan(&size).Error
|
||||
return size, err
|
||||
}
|
@ -20,15 +20,17 @@ type IHeartbeatRepository interface {
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
GetAll() ([]*models.Heartbeat, error)
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetAllWithinByFilters(time.Time, time.Time, *models.User, map[string][]string) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
GetLastByUsers() ([]*models.TimeByUser, error)
|
||||
GetLatestByUser(*models.User) (*models.Heartbeat, error)
|
||||
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
|
||||
Count() (int64, error)
|
||||
Count(bool) (int64, error)
|
||||
CountByUser(*models.User) (int64, error)
|
||||
CountByUsers([]*models.User) ([]*models.CountByUser, error)
|
||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||
DeleteBefore(time.Time) error
|
||||
DeleteByUser(*models.User) error
|
||||
}
|
||||
|
||||
type IDiagnosticsRepository interface {
|
||||
@ -73,7 +75,9 @@ type IUserRepository interface {
|
||||
GetByEmail(string) (*models.User, error)
|
||||
GetByResetToken(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetMany([]string) ([]*models.User, error)
|
||||
GetAllByReports(bool) ([]*models.User, error)
|
||||
GetAllByLeaderboard(bool) ([]*models.User, error)
|
||||
GetByLoggedInAfter(time.Time) ([]*models.User, error)
|
||||
GetByLastActiveAfter(time.Time) ([]*models.User, error)
|
||||
Count() (int64, error)
|
||||
@ -82,3 +86,12 @@ type IUserRepository interface {
|
||||
UpdateField(*models.User, string, interface{}) (*models.User, error)
|
||||
Delete(*models.User) error
|
||||
}
|
||||
|
||||
type ILeaderboardRepository interface {
|
||||
InsertBatch([]*models.LeaderboardItem) error
|
||||
CountAllByUser(string) (int64, error)
|
||||
DeleteByUser(string) error
|
||||
DeleteByUserAndInterval(string, *models.IntervalKey) error
|
||||
GetAllAggregatedByInterval(*models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
|
||||
GetAggregatedByUserAndInterval(string, *models.IntervalKey, *uint8) ([]*models.LeaderboardItem, error)
|
||||
}
|
||||
|
@ -1,8 +1,10 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/muety/wakapi/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -18,15 +20,15 @@ func (r *SummaryRepository) GetAll() ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
// branch summaries are currently not persisted, as only relevant in combination with project filter
|
||||
Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.populateItems(summaries, []clause.Interface{}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
@ -39,20 +41,29 @@ func (r *SummaryRepository) Insert(summary *models.Summary) error {
|
||||
|
||||
func (r *SummaryRepository) GetByUserWithin(user *models.User, from, to time.Time) ([]*models.Summary, error) {
|
||||
var summaries []*models.Summary
|
||||
if err := r.db.
|
||||
Where(&models.Summary{UserID: user.ID}).
|
||||
Where("from_time >= ?", from.Local()).
|
||||
Where("to_time <= ?", to.Local()).
|
||||
Order("from_time asc").
|
||||
Preload("Projects", "type = ?", models.SummaryProject).
|
||||
Preload("Languages", "type = ?", models.SummaryLanguage).
|
||||
Preload("Editors", "type = ?", models.SummaryEditor).
|
||||
Preload("OperatingSystems", "type = ?", models.SummaryOS).
|
||||
Preload("Machines", "type = ?", models.SummaryMachine).
|
||||
|
||||
queryConditions := []clause.Interface{
|
||||
clause.Where{Exprs: r.db.Statement.BuildCondition("user_id = ?", user.ID)},
|
||||
clause.Where{Exprs: r.db.Statement.BuildCondition("from_time >= ?", from.Local())},
|
||||
clause.Where{Exprs: r.db.Statement.BuildCondition("to_time <= ?", to.Local())},
|
||||
}
|
||||
|
||||
q := r.db.Model(&models.Summary{}).
|
||||
Order("from_time asc")
|
||||
|
||||
for _, c := range queryConditions {
|
||||
q.Statement.AddClause(c)
|
||||
}
|
||||
|
||||
// branch summaries are currently not persisted, as only relevant in combination with project filter
|
||||
Find(&summaries).Error; err != nil {
|
||||
if err := q.Find(&summaries).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := r.populateItems(summaries, queryConditions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return summaries, nil
|
||||
}
|
||||
|
||||
@ -74,3 +85,36 @@ func (r *SummaryRepository) DeleteByUser(userId string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// inplace
|
||||
func (r *SummaryRepository) populateItems(summaries []*models.Summary, conditions []clause.Interface) error {
|
||||
var items []*models.SummaryItem
|
||||
|
||||
summaryMap := slice.GroupWith[*models.Summary, uint](summaries, func(s *models.Summary) uint {
|
||||
return s.ID
|
||||
})
|
||||
|
||||
q := r.db.Model(&models.SummaryItem{}).
|
||||
Select("summary_items.*").
|
||||
Joins("cross join summaries").
|
||||
Where("summary_items.summary_id = summaries.id").
|
||||
Where("num_heartbeats > ?", 0)
|
||||
|
||||
for _, c := range conditions {
|
||||
q.Statement.AddClause(c)
|
||||
}
|
||||
|
||||
if err := q.Find(&items).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, item := range items {
|
||||
if _, ok := summaryMap[item.SummaryID]; !ok {
|
||||
continue
|
||||
}
|
||||
l := summaryMap[item.SummaryID][0].ItemsByType(item.Type)
|
||||
*l = append(*l, item)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -77,6 +77,17 @@ func (r *UserRepository) GetAll() ([]*models.User, error) {
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetMany(ids []string) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
Table("users").
|
||||
Where("id in ?", ids).
|
||||
Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.Where(&models.User{ReportsWeekly: reportsEnabled}).Find(&users).Error; err != nil {
|
||||
@ -85,6 +96,14 @@ func (r *UserRepository) GetAllByReports(reportsEnabled bool) ([]*models.User, e
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetAllByLeaderboard(leaderboardEnabled bool) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.Where(&models.User{PublicLeaderboard: leaderboardEnabled}).Find(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
if err := r.db.
|
||||
@ -98,10 +117,9 @@ func (r *UserRepository) GetByLoggedInAfter(t time.Time) ([]*models.User, error)
|
||||
// Returns a list of user ids, whose last heartbeat is not older than t
|
||||
// NOTE: Only ID field will be populated
|
||||
func (r *UserRepository) GetByLastActiveAfter(t time.Time) ([]*models.User, error) {
|
||||
subQuery1 := r.db.Model(&models.User{}).
|
||||
Select("users.id as user, max(time) as time").
|
||||
Joins("left join heartbeats on users.id = heartbeats.user_id").
|
||||
Group("user")
|
||||
subQuery1 := r.db.Model(&models.Heartbeat{}).
|
||||
Select("user_id as user, max(time) as time").
|
||||
Group("user_id")
|
||||
|
||||
var userIds []string
|
||||
if err := r.db.
|
||||
@ -157,6 +175,7 @@ func (r *UserRepository) Update(user *models.User) (*models.User, error) {
|
||||
"reset_token": user.ResetToken,
|
||||
"location": user.Location,
|
||||
"reports_weekly": user.ReportsWeekly,
|
||||
"public_leaderboard": user.PublicLeaderboard,
|
||||
}
|
||||
|
||||
result := r.db.Model(user).Updates(updateMap)
|
||||
|
51
routes/api/avatar.go
Normal file
51
routes/api/avatar.go
Normal file
@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"codeberg.org/Codeberg/avatars"
|
||||
"github.com/gorilla/mux"
|
||||
lru "github.com/hashicorp/golang-lru"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type AvatarHandler struct {
|
||||
config *conf.Config
|
||||
cache *lru.Cache
|
||||
}
|
||||
|
||||
func NewAvatarHandler() *AvatarHandler {
|
||||
cache, err := lru.New(1 * 1000 * 64) // assuming an avatar is 1 kb, allocate up to 64 mb of memory for avatars cache
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return &AvatarHandler{
|
||||
config: conf.Get(),
|
||||
cache: cache,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *AvatarHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/avatar/{hash}.svg").Subrouter()
|
||||
r.Path("").Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
func (h *AvatarHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
hash := mux.Vars(r)["hash"]
|
||||
|
||||
if utils.IsNoCache(r, 1*time.Hour) {
|
||||
h.cache.Remove(hash)
|
||||
}
|
||||
|
||||
if !h.cache.Contains(hash) {
|
||||
h.cache.Add(hash, avatars.MakeMaleAvatar(hash))
|
||||
}
|
||||
data, _ := h.cache.Get(hash)
|
||||
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "max-age=2592000")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(data.(string)))
|
||||
}
|
98
routes/api/badge.go
Normal file
98
routes/api/badge.go
Normal file
@ -0,0 +1,98 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/maputil"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/shields/v1"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/narqo/go-badge"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
config *conf.Config
|
||||
cache *cache.Cache
|
||||
userSrvc services.IUserService
|
||||
summarySrvc services.ISummaryService
|
||||
}
|
||||
|
||||
func NewBadgeHandler(userService services.IUserService, summaryService services.ISummaryService) *BadgeHandler {
|
||||
return &BadgeHandler{
|
||||
config: conf.Get(),
|
||||
cache: cache.New(time.Hour, time.Hour),
|
||||
userSrvc: userService,
|
||||
summarySrvc: summaryService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/badge/{user}").Subrouter()
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.Get)
|
||||
}
|
||||
|
||||
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := h.userSrvc.GetUserById(mux.Vars(r)["user"])
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
interval, filters, err := routeutils.GetBadgeParams(r, user)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
|
||||
noCache := utils.IsNoCache(r, 1*time.Hour)
|
||||
if cacheResult, ok := h.cache.Get(cacheKey); ok && !noCache {
|
||||
respondSvg(w, cacheResult.([]byte))
|
||||
return
|
||||
}
|
||||
|
||||
params := &models.SummaryParams{
|
||||
From: interval.Start,
|
||||
To: interval.End,
|
||||
User: user,
|
||||
Filters: filters,
|
||||
}
|
||||
|
||||
summary, err, status := routeutils.LoadUserSummaryByParams(h.summarySrvc, params)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
badgeData := v1.NewBadgeDataFrom(summary)
|
||||
if customLabel := r.URL.Query().Get("label"); customLabel != "" {
|
||||
badgeData.Label = customLabel
|
||||
}
|
||||
if customColor := r.URL.Query().Get("color"); customColor != "" {
|
||||
badgeData.Color = customColor
|
||||
}
|
||||
|
||||
if badgeData.Color[0:1] != "#" && !slice.Contain(maputil.Keys(badge.ColorScheme), badgeData.Color) {
|
||||
badgeData.Color = "#" + badgeData.Color
|
||||
}
|
||||
|
||||
badgeSvg, err := badge.RenderBytes(badgeData.Label, badgeData.Message, badge.Color(badgeData.Color))
|
||||
h.cache.SetDefault(cacheKey, badgeSvg)
|
||||
respondSvg(w, badgeSvg)
|
||||
}
|
||||
|
||||
func respondSvg(w http.ResponseWriter, data []byte) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "max-age=3600")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write(data)
|
||||
}
|
@ -6,7 +6,6 @@ import (
|
||||
|
||||
"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"
|
||||
|
||||
@ -29,9 +28,6 @@ func NewDiagnosticsApiHandler(userService services.IUserService, diagnosticsServ
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -40,26 +36,17 @@ func (h *DiagnosticsApiHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @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)
|
||||
|
@ -79,6 +79,12 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
machineName := r.Header.Get("X-Machine-Name")
|
||||
|
||||
for _, hb := range heartbeats {
|
||||
if hb == nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid heartbeat object"))
|
||||
return
|
||||
}
|
||||
|
||||
hb.OperatingSystem = opSys
|
||||
hb.Editor = editor
|
||||
hb.Machine = machineName
|
||||
@ -86,7 +92,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
hb.UserID = user.ID
|
||||
hb.UserAgent = userAgent
|
||||
|
||||
if !hb.Valid() {
|
||||
if !hb.Valid() || !hb.Timely(h.config.App.HeartbeatsMaxAge()) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte("invalid heartbeat object"))
|
||||
return
|
||||
@ -98,7 +104,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.heartbeatSrvc.InsertBatch(heartbeats); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
conf.Log().Request(r).Error("failed to batch-insert heartbeats – %v", err)
|
||||
conf.Log().Request(r).Error("failed to batch-insert heartbeats - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -107,7 +113,7 @@ func (h *HeartbeatApiHandler) Post(w http.ResponseWriter, r *http.Request) {
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte(conf.ErrInternalServerError))
|
||||
conf.Log().Request(r).Error("failed to update user – %v", err)
|
||||
conf.Log().Request(r).Error("failed to update user - %v", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ import (
|
||||
"github.com/muety/wakapi/models"
|
||||
v1 "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
mm "github.com/muety/wakapi/models/metrics"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
@ -39,6 +40,7 @@ const (
|
||||
DescMemAllocTotal = "Total number of bytes allocated for heap"
|
||||
DescMemSysTotal = "Total number of bytes obtained from the OS"
|
||||
DescGoroutines = "Total number of running goroutines"
|
||||
DescDatabaseSize = "Total database size in bytes"
|
||||
)
|
||||
|
||||
type MetricsHandler struct {
|
||||
@ -47,14 +49,16 @@ type MetricsHandler struct {
|
||||
summarySrvc services.ISummaryService
|
||||
heartbeatSrvc services.IHeartbeatService
|
||||
keyValueSrvc services.IKeyValueService
|
||||
metricsRepo *repositories.MetricsRepository
|
||||
}
|
||||
|
||||
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService) *MetricsHandler {
|
||||
func NewMetricsHandler(userService services.IUserService, summaryService services.ISummaryService, heartbeatService services.IHeartbeatService, keyValueService services.IKeyValueService, metricsRepo *repositories.MetricsRepository) *MetricsHandler {
|
||||
return &MetricsHandler{
|
||||
userSrvc: userService,
|
||||
summarySrvc: summaryService,
|
||||
heartbeatSrvc: heartbeatService,
|
||||
keyValueSrvc: keyValueService,
|
||||
metricsRepo: metricsRepo,
|
||||
config: conf.Get(),
|
||||
}
|
||||
}
|
||||
@ -141,21 +145,21 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_cumulative_seconds_total",
|
||||
Desc: DescAllTime,
|
||||
Value: int(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
|
||||
Value: int64(v1.NewAllTimeFrom(summaryAllTime).Data.TotalSeconds),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_seconds_total",
|
||||
Desc: DescTotal,
|
||||
Value: int(summaryToday.TotalTime().Seconds()),
|
||||
Value: int64(summaryToday.TotalTime().Seconds()),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_heartbeats_total",
|
||||
Desc: DescHeartbeats,
|
||||
Value: int(heartbeatCount),
|
||||
Value: int64(heartbeatCount),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
@ -163,7 +167,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_project_seconds_total",
|
||||
Desc: DescProjects,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryProject, p.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: p.Key}},
|
||||
})
|
||||
}
|
||||
@ -172,7 +176,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_language_seconds_total",
|
||||
Desc: DescLanguages,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLanguage, l.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: l.Key}},
|
||||
})
|
||||
}
|
||||
@ -181,7 +185,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_editor_seconds_total",
|
||||
Desc: DescEditors,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryEditor, e.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: e.Key}},
|
||||
})
|
||||
}
|
||||
@ -190,7 +194,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_operating_system_seconds_total",
|
||||
Desc: DescOperatingSystems,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryOS, o.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: o.Key}},
|
||||
})
|
||||
}
|
||||
@ -199,7 +203,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_machine_seconds_total",
|
||||
Desc: DescMachines,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryMachine, m.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: m.Key}},
|
||||
})
|
||||
}
|
||||
@ -208,7 +212,7 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_label_seconds_total",
|
||||
Desc: DescLabels,
|
||||
Value: int(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
|
||||
Value: int64(summaryToday.TotalTimeByKey(models.SummaryLabel, m.Key).Seconds()),
|
||||
Labels: []mm.Label{{Key: "name", Value: m.Key}},
|
||||
})
|
||||
}
|
||||
@ -220,21 +224,34 @@ func (h *MetricsHandler) getUserMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_goroutines_total",
|
||||
Desc: DescGoroutines,
|
||||
Value: runtime.NumGoroutine(),
|
||||
Value: int64(runtime.NumGoroutine()),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_mem_alloc_total",
|
||||
Desc: DescMemAllocTotal,
|
||||
Value: int(memStats.Alloc),
|
||||
Value: int64(memStats.Alloc),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_mem_sys_total",
|
||||
Desc: DescMemSysTotal,
|
||||
Value: int(memStats.Sys),
|
||||
Value: int64(memStats.Sys),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
// Database metrics
|
||||
dbSize, err := h.metricsRepo.GetDatabaseSize()
|
||||
if err != nil {
|
||||
logbuch.Warn("failed to get database size (%v)", err)
|
||||
}
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_db_total_bytes",
|
||||
Desc: DescDatabaseSize,
|
||||
Value: dbSize,
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
@ -256,39 +273,39 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
||||
}
|
||||
|
||||
totalUsers, _ := h.userSrvc.Count()
|
||||
totalHeartbeats, _ := h.heartbeatSrvc.Count()
|
||||
totalHeartbeats, _ := h.heartbeatSrvc.Count(true)
|
||||
|
||||
activeUsers, err := h.userSrvc.GetActive(false)
|
||||
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
|
||||
}
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_seconds_total",
|
||||
Desc: DescAdminTotalTime,
|
||||
Value: totalSeconds,
|
||||
Value: int64(totalSeconds),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_heartbeats_total",
|
||||
Desc: DescAdminTotalHeartbeats,
|
||||
Value: int(totalHeartbeats),
|
||||
Value: totalHeartbeats,
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_users_total",
|
||||
Desc: DescAdminTotalUsers,
|
||||
Value: int(totalUsers),
|
||||
Value: totalUsers,
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_users_active_total",
|
||||
Desc: DescAdminActiveUsers,
|
||||
Value: len(activeUsers),
|
||||
Value: int64(len(activeUsers)),
|
||||
Labels: []mm.Label{},
|
||||
})
|
||||
|
||||
@ -304,7 +321,7 @@ func (h *MetricsHandler) getAdminMetrics(user *models.User) (*mm.Metrics, error)
|
||||
metrics = append(metrics, &mm.CounterMetric{
|
||||
Name: MetricsPrefix + "_admin_user_heartbeats_total",
|
||||
Desc: DescAdminUserHeartbeats,
|
||||
Value: int(uc.Count),
|
||||
Value: uc.Count,
|
||||
Labels: []mm.Label{{Key: "user", Value: uc.User}},
|
||||
})
|
||||
}
|
||||
|
@ -37,7 +37,7 @@ func (h *SummaryApiHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @ID get-summary
|
||||
// @Tags summary
|
||||
// @Produce json
|
||||
// @Param interval query string false "Interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||
// @Param interval query string false "Interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 6_months, last_6_months, 12_months, last_12_months, last_year, any, all_time)
|
||||
// @Param from query string false "Start date (e.g. '2021-02-07')"
|
||||
// @Param to query string false "End date (e.g. '2021-02-08')"
|
||||
// @Param recompute query bool false "Whether to recompute the summary from raw heartbeat or use cache"
|
||||
|
@ -2,8 +2,8 @@ package v1
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
routeutils "github.com/muety/wakapi/routes/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
@ -15,11 +15,6 @@ import (
|
||||
"github.com/patrickmn/go-cache"
|
||||
)
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine|label):([_a-zA-Z0-9-\s\.]+)`
|
||||
)
|
||||
|
||||
type BadgeHandler struct {
|
||||
config *conf.Config
|
||||
userSrvc services.IUserService
|
||||
@ -48,82 +43,38 @@ func (h *BadgeHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @Tags badges
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for"
|
||||
// @Param interval path string true "Interval to aggregate data for" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||
// @Param interval path string true "Interval to aggregate data for" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 6_months, last_6_months, 12_months, last_12_months, last_year, any, all_time)
|
||||
// @Param filter path string true "Filter to apply (e.g. 'project:wakapi' or 'language:Go')"
|
||||
// @Success 200 {object} v1.BadgeData
|
||||
// @Router /compat/shields/v1/{user}/{interval}/{filter} [get]
|
||||
func (h *BadgeHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
intervalReg := regexp.MustCompile(intervalPattern)
|
||||
entityFilterReg := regexp.MustCompile(entityFilterPattern)
|
||||
|
||||
var filterEntity, filterKey string
|
||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
||||
filterEntity, filterKey = groups[1], groups[2]
|
||||
}
|
||||
|
||||
var interval = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
if i, err := utils.ParseInterval(groups[1]); err == nil {
|
||||
interval = i
|
||||
}
|
||||
}
|
||||
|
||||
requestedUserId := mux.Vars(r)["user"]
|
||||
user, err := h.userSrvc.GetUserById(requestedUserId)
|
||||
user, err := h.userSrvc.GetUserById(mux.Vars(r)["user"])
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(user.ShareDataMaxDays))
|
||||
// negative value means no limit
|
||||
if rangeFrom.Before(minStart) && user.ShareDataMaxDays >= 0 {
|
||||
interval, filters, err := routeutils.GetBadgeParams(r, user)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("requested time range too broad"))
|
||||
w.Write([]byte(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
var permitEntity bool
|
||||
var filters *models.Filters
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
permitEntity = user.ShareProjects
|
||||
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
||||
case "os":
|
||||
permitEntity = user.ShareOSs
|
||||
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
||||
case "editor":
|
||||
permitEntity = user.ShareEditors
|
||||
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
||||
case "language":
|
||||
permitEntity = user.ShareLanguages
|
||||
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
||||
case "machine":
|
||||
permitEntity = user.ShareMachines
|
||||
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
||||
case "label":
|
||||
permitEntity = user.ShareLabels
|
||||
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
|
||||
// branches are intentionally omitted here, as only relevant in combination with a project filter
|
||||
default:
|
||||
permitEntity = true
|
||||
filters = &models.Filters{}
|
||||
}
|
||||
|
||||
if !permitEntity {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
w.Write([]byte("user did not opt in to share entity-specific data"))
|
||||
return
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s_%v_%s_%s", user.ID, *interval, filterEntity, filterKey)
|
||||
cacheKey := fmt.Sprintf("%s_%v_%s", user.ID, *interval.Key, filters.Hash())
|
||||
if cacheResult, ok := h.cache.Get(cacheKey); ok {
|
||||
utils.RespondJSON(w, r, http.StatusOK, cacheResult.(*v1.BadgeData))
|
||||
return
|
||||
}
|
||||
|
||||
summary, err, status := h.loadUserSummary(user, interval, filters)
|
||||
params := &models.SummaryParams{
|
||||
From: interval.Start,
|
||||
To: interval.End,
|
||||
User: user,
|
||||
Filters: filters,
|
||||
}
|
||||
|
||||
summary, err, status := routeutils.LoadUserSummaryByParams(h.summarySrvc, params)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
w.Write([]byte(err.Error()))
|
||||
|
@ -30,7 +30,7 @@ func TestBadgeHandler_EntityPattern(t *testing.T) {
|
||||
{test: pathPrefix + "project:Anchr-Android_v2.0", key: "project", val: "Anchr-Android_v2.0"}, // all the way
|
||||
}
|
||||
|
||||
sut := regexp.MustCompile(entityFilterPattern)
|
||||
sut := regexp.MustCompile(`(project|os|editor|language|machine|label):([^:?&/]+)`) // see entityFilterPattern in badge_utils.go
|
||||
|
||||
for _, tc := range tests {
|
||||
var key, val string
|
||||
|
@ -1,6 +1,7 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -65,7 +66,7 @@ func (h *HeartbeatHandler) Get(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
timezone := user.TZ()
|
||||
rangeFrom, rangeTo := utils.StartOfDay(date.In(timezone)), utils.EndOfDay(date.In(timezone))
|
||||
rangeFrom, rangeTo := datetime.BeginOfDay(date.In(timezone)), datetime.EndOfDay(date.In(timezone))
|
||||
|
||||
heartbeats, err := h.heartbeatSrvc.GetAllWithin(rangeFrom, rangeTo, user)
|
||||
if err != nil {
|
||||
|
@ -48,7 +48,7 @@ func (h *StatsHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @Tags wakatime
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||
// @Param range path string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||
// @Param range path string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 6_months, last_6_months, 12_months, last_12_months, last_year, any, all_time)
|
||||
// @Param project query string false "Project to filter by"
|
||||
// @Param language query string false "Language to filter by"
|
||||
// @Param editor query string false "Editor to filter by"
|
||||
|
@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@ -49,7 +50,7 @@ func (h *SummariesHandler) RegisterRoutes(router *mux.Router) {
|
||||
// @Tags wakatime
|
||||
// @Produce json
|
||||
// @Param user path string true "User ID to fetch data for (or 'current')"
|
||||
// @Param range query string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 12_months, last_12_months, any)
|
||||
// @Param range query string false "Range interval identifier" Enums(today, yesterday, week, month, year, 7_days, last_7_days, 30_days, last_30_days, 6_months, last_6_months, 12_months, last_12_months, last_year, any, all_time)
|
||||
// @Param start query string false "Start date (e.g. '2021-02-07')"
|
||||
// @Param end query string false "End date (e.g. '2021-02-08')"
|
||||
// @Param project query string false "Project to filter by"
|
||||
@ -120,7 +121,7 @@ func (h *SummariesHandler) loadUserSummaries(r *http.Request) ([]*models.Summary
|
||||
// i.e. for wakatime, an interval 2021-04-29 - 2021-04-29 is actually 2021-04-29 - 2021-04-30,
|
||||
// while for wakapi it would be empty
|
||||
// see https://github.com/muety/wakapi/issues/192
|
||||
end = utils.EndOfDay(end).Add(-1 * time.Second)
|
||||
end = datetime.EndOfDay(end)
|
||||
|
||||
overallParams := &models.SummaryParams{
|
||||
From: start,
|
||||
|
128
routes/compat/wakatime/v1/users_test.go
Normal file
128
routes/compat/wakatime/v1/users_test.go
Normal file
@ -0,0 +1,128 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
adminUser = &models.User{
|
||||
ID: "AdminUser",
|
||||
ApiKey: "admin-user-api-key",
|
||||
Email: "admin@user.com",
|
||||
IsAdmin: true,
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 1, time.UTC)),
|
||||
LastLoggedInAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 2, time.UTC)),
|
||||
}
|
||||
|
||||
basicUser = &models.User{
|
||||
ID: "BasicUser",
|
||||
ApiKey: "basic-user-api-key",
|
||||
Email: "basic@user.com",
|
||||
IsAdmin: false,
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 3, time.UTC)),
|
||||
LastLoggedInAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 4, time.UTC)),
|
||||
}
|
||||
)
|
||||
|
||||
func TestUsersHandler_Get(t *testing.T) {
|
||||
router := mux.NewRouter()
|
||||
apiRouter := router.PathPrefix("/api").Subrouter().StrictSlash(true)
|
||||
apiRouter.Use(middlewares.NewPrincipalMiddleware())
|
||||
|
||||
userServiceMock := new(mocks.UserServiceMock)
|
||||
userServiceMock.On("GetUserById", "AdminUser").Return(adminUser, nil)
|
||||
userServiceMock.On("GetUserByKey", "admin-user-api-key").Return(adminUser, nil)
|
||||
userServiceMock.On("GetUserById", "BasicUser").Return(basicUser, nil)
|
||||
userServiceMock.On("GetUserByKey", "basic-user-api-key").Return(basicUser, nil)
|
||||
|
||||
heartbeatServiceMock := new(mocks.HeartbeatServiceMock)
|
||||
heartbeatServiceMock.On("GetLatestByUser", adminUser).Return(&models.Heartbeat{
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 5, time.UTC)),
|
||||
Time: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 6, time.UTC)),
|
||||
}, nil)
|
||||
heartbeatServiceMock.On("GetLatestByUser", basicUser).Return(&models.Heartbeat{
|
||||
CreatedAt: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 5, time.UTC)),
|
||||
Time: models.CustomTime(time.Date(2022, 2, 2, 22, 22, 22, 6, time.UTC)),
|
||||
}, nil)
|
||||
|
||||
usersHandler := NewUsersHandler(userServiceMock, heartbeatServiceMock)
|
||||
usersHandler.RegisterRoutes(apiRouter)
|
||||
|
||||
t.Run("when requesting own user data", func(t *testing.T) {
|
||||
t.Run("should return own data", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/AdminUser", nil)
|
||||
req.Header.Add(
|
||||
"Authorization",
|
||||
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(adminUser.ApiKey))),
|
||||
)
|
||||
requestRecorder := httptest.NewRecorder()
|
||||
apiRouter.ServeHTTP(requestRecorder, req)
|
||||
res := requestRecorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Errorf("unextected error. Error: %s", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), "\"username\":\"AdminUser\"") {
|
||||
t.Errorf("invalid response received. Expected json Received: %s", string(data))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("when requesting another users data", func(t *testing.T) {
|
||||
t.Run("should respond with '401 unauthorized' if not an admin user", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/AdminUser", nil)
|
||||
req.Header.Add(
|
||||
"Authorization",
|
||||
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(basicUser.ApiKey))),
|
||||
)
|
||||
requestRecorder := httptest.NewRecorder()
|
||||
apiRouter.ServeHTTP(requestRecorder, req)
|
||||
res := requestRecorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Errorf("unextected error. Error: %s", err)
|
||||
}
|
||||
|
||||
if string(data) != "401 unauthorized" {
|
||||
t.Errorf("invalid response received. Expected: '401 unauthorized' Received: %s", string(data))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should receive user data if requesting user is an admin", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/compat/wakatime/v1/users/BasicUser", nil)
|
||||
req.Header.Add(
|
||||
"Authorization",
|
||||
fmt.Sprintf("Bearer %s", base64.StdEncoding.EncodeToString([]byte(adminUser.ApiKey))),
|
||||
)
|
||||
requestRecorder := httptest.NewRecorder()
|
||||
apiRouter.ServeHTTP(requestRecorder, req)
|
||||
res := requestRecorder.Result()
|
||||
defer res.Body.Close()
|
||||
|
||||
data, err := ioutil.ReadAll(res.Body)
|
||||
if err != nil {
|
||||
t.Errorf("unextected error. Error: %s", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(string(data), "\"username\":\"BasicUser\"") {
|
||||
t.Errorf("invalid response received. Expected 'BasicUser' info Received: %s", string(data))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/schema"
|
||||
@ -10,6 +11,7 @@ import (
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@ -49,23 +51,29 @@ func (h *HomeHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
func (h *HomeHandler) buildViewModel(r *http.Request) *view.HomeViewModel {
|
||||
var totalHours int
|
||||
var totalUsers int
|
||||
var newsbox view.Newsbox
|
||||
|
||||
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && t != nil && t.Value != "" {
|
||||
if d, err := time.ParseDuration(t.Value); err == nil {
|
||||
if kv, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalTime); err == nil && kv != nil && kv.Value != "" {
|
||||
if d, err := time.ParseDuration(kv.Value); err == nil {
|
||||
totalHours = int(d.Hours())
|
||||
}
|
||||
}
|
||||
|
||||
if t, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && t != nil && t.Value != "" {
|
||||
if d, err := strconv.Atoi(t.Value); err == nil {
|
||||
if kv, err := h.keyValueSrvc.GetString(conf.KeyLatestTotalUsers); err == nil && kv != nil && kv.Value != "" {
|
||||
if d, err := strconv.Atoi(kv.Value); err == nil {
|
||||
totalUsers = d
|
||||
}
|
||||
}
|
||||
|
||||
if kv, err := h.keyValueSrvc.GetString(conf.KeyNewsbox); err == nil && kv != nil && kv.Value != "" {
|
||||
json.NewDecoder(strings.NewReader(kv.Value)).Decode(&newsbox)
|
||||
}
|
||||
|
||||
return &view.HomeViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
TotalHours: totalHours,
|
||||
TotalUsers: totalUsers,
|
||||
Newsbox: &newsbox,
|
||||
}
|
||||
}
|
||||
|
116
routes/leaderboard.go
Normal file
116
routes/leaderboard.go
Normal file
@ -0,0 +1,116 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
conf "github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/models/view"
|
||||
"github.com/muety/wakapi/services"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type LeaderboardHandler struct {
|
||||
config *conf.Config
|
||||
userService services.IUserService
|
||||
leaderboardService services.ILeaderboardService
|
||||
}
|
||||
|
||||
var allowedAggregations = map[string]uint8{
|
||||
"language": models.SummaryLanguage,
|
||||
}
|
||||
|
||||
func NewLeaderboardHandler(userService services.IUserService, leaderboardService services.ILeaderboardService) *LeaderboardHandler {
|
||||
return &LeaderboardHandler{
|
||||
config: conf.Get(),
|
||||
userService: userService,
|
||||
leaderboardService: leaderboardService,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LeaderboardHandler) RegisterRoutes(router *mux.Router) {
|
||||
r := router.PathPrefix("/leaderboard").Subrouter()
|
||||
r.Use(
|
||||
middlewares.NewAuthenticateMiddleware(h.userService).
|
||||
WithRedirectTarget(defaultErrorRedirectTarget()).
|
||||
WithOptionalFor([]string{"/"}).
|
||||
Handler,
|
||||
)
|
||||
r.Methods(http.MethodGet).HandlerFunc(h.GetIndex)
|
||||
}
|
||||
|
||||
func (h *LeaderboardHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
if err := templates[conf.LeaderboardTemplate].Execute(w, h.buildViewModel(r)); err != nil {
|
||||
logbuch.Error(err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (h *LeaderboardHandler) buildViewModel(r *http.Request) *view.LeaderboardViewModel {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
byParam := strings.ToLower(r.URL.Query().Get("by"))
|
||||
keyParam := strings.ToLower(r.URL.Query().Get("key"))
|
||||
|
||||
var err error
|
||||
var leaderboard models.Leaderboard
|
||||
var userLanguages map[string][]string
|
||||
var topKeys []string
|
||||
|
||||
if byParam == "" {
|
||||
leaderboard, err = h.leaderboardService.GetByInterval(models.IntervalPast7Days, true)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
|
||||
return &view.LeaderboardViewModel{Error: criticalError}
|
||||
}
|
||||
} else {
|
||||
if by, ok := allowedAggregations[byParam]; ok {
|
||||
leaderboard, err = h.leaderboardService.GetAggregatedByInterval(models.IntervalPast7Days, &by, true)
|
||||
if err != nil {
|
||||
conf.Log().Request(r).Error("error while fetching general leaderboard items - %v", err)
|
||||
return &view.LeaderboardViewModel{Error: criticalError}
|
||||
}
|
||||
|
||||
userLeaderboards := slice.GroupWith[*models.LeaderboardItem, string](leaderboard, func(item *models.LeaderboardItem) string {
|
||||
return item.UserID
|
||||
})
|
||||
userLanguages = map[string][]string{}
|
||||
for u, items := range userLeaderboards {
|
||||
userLanguages[u] = models.Leaderboard(items).TopKeysByUser(models.SummaryLanguage, u)
|
||||
}
|
||||
|
||||
topKeys = leaderboard.TopKeys(by)
|
||||
if len(topKeys) > 0 {
|
||||
if keyParam == "" {
|
||||
keyParam = topKeys[0]
|
||||
}
|
||||
keyParam = strings.ToLower(keyParam)
|
||||
leaderboard = leaderboard.TopByKey(by, keyParam)
|
||||
}
|
||||
} else {
|
||||
return &view.LeaderboardViewModel{Error: fmt.Sprintf("unsupported aggregation '%s'", byParam)}
|
||||
}
|
||||
}
|
||||
|
||||
var apiKey string
|
||||
if user != nil {
|
||||
apiKey = user.ApiKey
|
||||
}
|
||||
|
||||
return &view.LeaderboardViewModel{
|
||||
User: user,
|
||||
By: byParam,
|
||||
Key: keyParam,
|
||||
Items: leaderboard,
|
||||
UserLanguages: userLanguages,
|
||||
TopKeys: topKeys,
|
||||
ApiKey: apiKey,
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
}
|
||||
}
|
@ -91,6 +91,7 @@ func (h *LoginHandler) PostLogin(w http.ResponseWriter, r *http.Request) {
|
||||
encoded, err := h.config.Security.SecureCookie.Encode(models.AuthCookieKey, login.Username)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to encode secure cookie - %v", err)
|
||||
templates[conf.LoginTemplate].Execute(w, h.buildViewModel(r).WithError("internal server error"))
|
||||
return
|
||||
}
|
||||
@ -163,6 +164,7 @@ func (h *LoginHandler) PostSignup(w http.ResponseWriter, r *http.Request) {
|
||||
_, created, err := h.userSrvc.CreateOrGet(&signup, numUsers == 0)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to create new user - %v", err)
|
||||
templates[conf.SignupTemplate].Execute(w, h.buildViewModel(r).WithError("failed to create new user"))
|
||||
return
|
||||
}
|
||||
@ -237,6 +239,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
user.ResetToken = ""
|
||||
if hash, err := utils.HashBcrypt(user.Password, h.config.Security.PasswordSalt); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to set new password - %v", err)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to set new password"))
|
||||
return
|
||||
} else {
|
||||
@ -245,6 +248,7 @@ func (h *LoginHandler) PostSetPassword(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to save new password - %v", err)
|
||||
templates[conf.SetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to save new password"))
|
||||
return
|
||||
}
|
||||
@ -278,13 +282,14 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
||||
if user, err := h.userSrvc.GetUserByEmail(resetRequest.Email); user != nil && err == nil {
|
||||
if u, err := h.userSrvc.GenerateResetToken(user); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
conf.Log().Request(r).Error("failed to generate password reset token - %v", err)
|
||||
templates[conf.ResetPasswordTemplate].Execute(w, h.buildViewModel(r).WithError("failed to generate password reset token"))
|
||||
return
|
||||
} else {
|
||||
go func(user *models.User) {
|
||||
link := fmt.Sprintf("%s/set-password?token=%s", h.config.Server.GetPublicUrl(), user.ResetToken)
|
||||
if err := h.mailSrvc.SendPasswordReset(user, link); err != nil {
|
||||
conf.Log().Request(r).Error("failed to send password reset mail to %s – %v", user.ID, err)
|
||||
conf.Log().Request(r).Error("failed to send password reset mail to %s - %v", user.ID, err)
|
||||
} else {
|
||||
logbuch.Info("sent password reset mail to %s", user.ID)
|
||||
}
|
||||
@ -299,12 +304,11 @@ func (h *LoginHandler) PostResetPassword(w http.ResponseWriter, r *http.Request)
|
||||
|
||||
func (h *LoginHandler) buildViewModel(r *http.Request) *view.LoginViewModel {
|
||||
numUsers, _ := h.userSrvc.Count()
|
||||
allowSignup := h.config.Security.AllowSignup
|
||||
|
||||
return &view.LoginViewModel{
|
||||
Success: r.URL.Query().Get("success"),
|
||||
Error: r.URL.Query().Get("error"),
|
||||
TotalUsers: int(numUsers),
|
||||
AllowSignup: allowSignup,
|
||||
AllowSignup: h.config.IsDev() || h.config.Security.AllowSignup,
|
||||
}
|
||||
}
|
||||
|
@ -2,14 +2,15 @@ package routes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/muety/wakapi/views"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/muety/wakapi/views"
|
||||
)
|
||||
|
||||
type action func(w http.ResponseWriter, r *http.Request) (int, string, string)
|
||||
@ -28,15 +29,17 @@ func DefaultTemplateFuncs() template.FuncMap {
|
||||
"simpledate": utils.FormatDate,
|
||||
"simpledatetime": utils.FormatDateTime,
|
||||
"duration": utils.FmtWakatimeDuration,
|
||||
"floordate": utils.FloorDate,
|
||||
"floordate": datetime.BeginOfDay,
|
||||
"ceildate": utils.CeilDate,
|
||||
"title": strings.Title,
|
||||
"join": strings.Join,
|
||||
"add": utils.Add,
|
||||
"capitalize": utils.Capitalize,
|
||||
"lower": strings.ToLower,
|
||||
"toRunes": utils.ToRunes,
|
||||
"localTZOffset": utils.LocalTZOffset,
|
||||
"entityTypes": models.SummaryTypes,
|
||||
"strslice": utils.SubSlice[string],
|
||||
"typeName": typeName,
|
||||
"isDev": func() bool {
|
||||
return config.Get().IsDev()
|
||||
@ -53,6 +56,9 @@ func DefaultTemplateFuncs() template.FuncMap {
|
||||
"htmlSafe": func(html string) template.HTML {
|
||||
return template.HTML(html)
|
||||
},
|
||||
"urlSafe": func(s string) template.URL {
|
||||
return template.URL(s)
|
||||
},
|
||||
"avatarUrlTemplate": func() string {
|
||||
return config.Get().App.AvatarURLTemplate
|
||||
},
|
||||
|
@ -3,6 +3,7 @@ package routes
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/gorilla/schema"
|
||||
@ -145,12 +146,16 @@ func (h *SettingsHandler) dispatchAction(action string) action {
|
||||
return h.actionAddLanguageMapping
|
||||
case "update_sharing":
|
||||
return h.actionUpdateSharing
|
||||
case "update_leaderboard":
|
||||
return h.actionUpdateLeaderboard
|
||||
case "toggle_wakatime":
|
||||
return h.actionSetWakatimeApiKey
|
||||
case "import_wakatime":
|
||||
return h.actionImportWakatime
|
||||
case "regenerate_summaries":
|
||||
return h.actionRegenerateSummaries
|
||||
case "clear_data":
|
||||
return h.actionClearData
|
||||
case "delete_account":
|
||||
return h.actionDeleteUser
|
||||
}
|
||||
@ -179,6 +184,7 @@ func (h *SettingsHandler) actionUpdateUser(w http.ResponseWriter, r *http.Reques
|
||||
user.Email = payload.Email
|
||||
user.Location = payload.Location
|
||||
user.ReportsWeekly = payload.ReportsWeekly
|
||||
user.PublicLeaderboard = payload.PublicLeaderboard
|
||||
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
return http.StatusInternalServerError, "", conf.ErrInternalServerError
|
||||
@ -248,6 +254,26 @@ func (h *SettingsHandler) actionResetApiKey(w http.ResponseWriter, r *http.Reque
|
||||
return http.StatusOK, msg, ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionUpdateLeaderboard(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
var err error
|
||||
user := middlewares.GetPrincipal(r)
|
||||
defer h.userSrvc.FlushCache()
|
||||
|
||||
user.PublicLeaderboard, err = strconv.ParseBool(r.PostFormValue("enable_leaderboard"))
|
||||
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, "", "invalid input"
|
||||
}
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
return http.StatusInternalServerError, "", "internal sever error"
|
||||
}
|
||||
return http.StatusOK, "settings updated", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionUpdateSharing(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
@ -492,7 +518,7 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
||||
|
||||
insert := func(batch []*models.Heartbeat) {
|
||||
if err := h.heartbeatSrvc.InsertBatch(batch); err != nil {
|
||||
logbuch.Warn("failed to insert imported heartbeat, already existing? – %v", err)
|
||||
logbuch.Warn("failed to insert imported heartbeat, already existing? - %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@ -518,13 +544,13 @@ func (h *SettingsHandler) actionImportWakatime(w http.ResponseWriter, r *http.Re
|
||||
if !user.HasData {
|
||||
user.HasData = true
|
||||
if _, err := h.userSrvc.Update(user); err != nil {
|
||||
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s – %v", user.ID, err)
|
||||
conf.Log().Request(r).Error("failed to set 'has_data' flag for user %s - %v", user.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
if user.Email != "" {
|
||||
if err := h.mailSrvc.SendImportNotification(user, time.Now().Sub(start), int(countAfter-countBefore)); err != nil {
|
||||
conf.Log().Request(r).Error("failed to send import notification mail to %s – %v", user.ID, err)
|
||||
conf.Log().Request(r).Error("failed to send import notification mail to %s - %v", user.ID, err)
|
||||
} else {
|
||||
logbuch.Info("sent import notification mail to %s", user.ID)
|
||||
}
|
||||
@ -546,11 +572,34 @@ func (h *SettingsHandler) actionRegenerateSummaries(w http.ResponseWriter, r *ht
|
||||
|
||||
go func(user *models.User) {
|
||||
if err := h.regenerateSummaries(user); err != nil {
|
||||
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' – %v", user.ID, err)
|
||||
conf.Log().Request(r).Error("failed to regenerate summaries for user '%s' - %v", user.ID, err)
|
||||
}
|
||||
}(middlewares.GetPrincipal(r))
|
||||
|
||||
return http.StatusAccepted, "summaries are being regenerated – this may take a up to a couple of minutes, please come back later", ""
|
||||
return http.StatusAccepted, "summaries are being regenerated - this may take a up to a couple of minutes, please come back later", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionClearData(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
if h.config.IsDev() {
|
||||
loadTemplates()
|
||||
}
|
||||
|
||||
user := middlewares.GetPrincipal(r)
|
||||
logbuch.Info("user '%s' requested to delete all data", user.ID)
|
||||
|
||||
go func(user *models.User) {
|
||||
logbuch.Info("deleting summaries for user '%s'", user.ID)
|
||||
if err := h.summarySrvc.DeleteByUser(user.ID); err != nil {
|
||||
logbuch.Error("failed to clear summaries: %v", err)
|
||||
}
|
||||
|
||||
logbuch.Info("deleting heartbeats for user '%s'", user.ID)
|
||||
if err := h.heartbeatSrvc.DeleteByUser(user); err != nil {
|
||||
logbuch.Error("failed to clear heartbeats: %v", err)
|
||||
}
|
||||
}(user)
|
||||
|
||||
return http.StatusAccepted, "deletion in progress, this may take a couple of seconds", ""
|
||||
}
|
||||
|
||||
func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Request) (int, string, string) {
|
||||
@ -563,7 +612,7 @@ func (h *SettingsHandler) actionDeleteUser(w http.ResponseWriter, r *http.Reques
|
||||
logbuch.Info("deleting user '%s' shortly", user.ID)
|
||||
time.Sleep(5 * time.Minute)
|
||||
if err := h.userSrvc.Delete(user); err != nil {
|
||||
conf.Log().Request(r).Error("failed to delete user '%s' – %v", user.ID, err)
|
||||
conf.Log().Request(r).Error("failed to delete user '%s' - %v", user.ID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully deleted user '%s'", user.ID)
|
||||
}
|
||||
@ -612,7 +661,7 @@ func (h *SettingsHandler) regenerateSummaries(user *models.User) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := h.aggregationSrvc.Run(map[string]bool{user.ID: true}); err != nil {
|
||||
if err := h.aggregationSrvc.Run(datastructure.NewSet(user.ID)); err != nil {
|
||||
logbuch.Error("failed to regenerate summaries: %v", err)
|
||||
return err
|
||||
}
|
||||
|
@ -51,6 +51,7 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
summary, err, status := su.LoadUserSummary(h.summarySrvc, r)
|
||||
if err != nil {
|
||||
w.WriteHeader(status)
|
||||
conf.Log().Request(r).Error("failed to load summary - %v", err)
|
||||
templates[conf.SummaryTemplate].Execute(w, h.buildViewModel(r).WithError(err.Error()))
|
||||
return
|
||||
}
|
||||
@ -66,7 +67,9 @@ func (h *SummaryHandler) GetIndex(w http.ResponseWriter, r *http.Request) {
|
||||
Summary: summary,
|
||||
SummaryParams: summaryParams,
|
||||
User: user,
|
||||
EditorColors: utils.FilterColors(h.config.App.GetEditorColors(), summary.Editors),
|
||||
LanguageColors: utils.FilterColors(h.config.App.GetLanguageColors(), summary.Languages),
|
||||
OSColors: utils.FilterColors(h.config.App.GetOSColors(), summary.OperatingSystems),
|
||||
ApiKey: user.ApiKey,
|
||||
RawQuery: rawQuery,
|
||||
}
|
||||
|
85
routes/utils/badge_utils.go
Normal file
85
routes/utils/badge_utils.go
Normal file
@ -0,0 +1,85 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
intervalPattern = `interval:([a-z0-9_]+)`
|
||||
entityFilterPattern = `(project|os|editor|language|machine|label):([^:?&/]+)`
|
||||
)
|
||||
|
||||
var (
|
||||
intervalReg *regexp.Regexp
|
||||
entityFilterReg *regexp.Regexp
|
||||
)
|
||||
|
||||
func init() {
|
||||
intervalReg = regexp.MustCompile(intervalPattern)
|
||||
entityFilterReg = regexp.MustCompile(entityFilterPattern)
|
||||
}
|
||||
|
||||
func GetBadgeParams(r *http.Request, requestedUser *models.User) (*models.KeyedInterval, *models.Filters, error) {
|
||||
var filterEntity, filterKey string
|
||||
if groups := entityFilterReg.FindStringSubmatch(r.URL.Path); len(groups) > 2 {
|
||||
filterEntity, filterKey = groups[1], groups[2]
|
||||
}
|
||||
|
||||
var intervalKey = models.IntervalPast30Days
|
||||
if groups := intervalReg.FindStringSubmatch(r.URL.Path); len(groups) > 1 {
|
||||
if i, err := utils.ParseInterval(groups[1]); err == nil {
|
||||
intervalKey = i
|
||||
}
|
||||
}
|
||||
|
||||
_, rangeFrom, rangeTo := utils.ResolveIntervalTZ(intervalKey, requestedUser.TZ())
|
||||
interval := &models.KeyedInterval{
|
||||
Interval: models.Interval{Start: rangeFrom, End: rangeTo},
|
||||
Key: intervalKey,
|
||||
}
|
||||
|
||||
minStart := rangeTo.Add(-24 * time.Hour * time.Duration(requestedUser.ShareDataMaxDays))
|
||||
// negative value means no limit
|
||||
if rangeFrom.Before(minStart) && requestedUser.ShareDataMaxDays >= 0 {
|
||||
return nil, nil, errors.New("requested time range too broad")
|
||||
}
|
||||
|
||||
var permitEntity bool
|
||||
var filters *models.Filters
|
||||
switch filterEntity {
|
||||
case "project":
|
||||
permitEntity = requestedUser.ShareProjects
|
||||
filters = models.NewFiltersWith(models.SummaryProject, filterKey)
|
||||
case "os":
|
||||
permitEntity = requestedUser.ShareOSs
|
||||
filters = models.NewFiltersWith(models.SummaryOS, filterKey)
|
||||
case "editor":
|
||||
permitEntity = requestedUser.ShareEditors
|
||||
filters = models.NewFiltersWith(models.SummaryEditor, filterKey)
|
||||
case "language":
|
||||
permitEntity = requestedUser.ShareLanguages
|
||||
filters = models.NewFiltersWith(models.SummaryLanguage, filterKey)
|
||||
case "machine":
|
||||
permitEntity = requestedUser.ShareMachines
|
||||
filters = models.NewFiltersWith(models.SummaryMachine, filterKey)
|
||||
case "label":
|
||||
permitEntity = requestedUser.ShareLabels
|
||||
filters = models.NewFiltersWith(models.SummaryLabel, filterKey)
|
||||
// branches are intentionally omitted here, as only relevant in combination with a project filter
|
||||
default:
|
||||
// non-entity-specific request, just a general, in-total query
|
||||
permitEntity = true
|
||||
filters = &models.Filters{}
|
||||
}
|
||||
|
||||
if !permitEntity {
|
||||
return nil, nil, errors.New("user did not opt in to share entity-specific data")
|
||||
}
|
||||
|
||||
return interval, filters, nil
|
||||
}
|
@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"sort"
|
||||
@ -12,42 +13,28 @@ import (
|
||||
// -> Instead, the "virtual" project "AB" shall appear
|
||||
// See https://github.com/muety/wakapi/issues/231
|
||||
func GetEffectiveProjectsList(user *models.User, heartbeatSrvc services.IHeartbeatService, aliasSrvc services.IAliasService) ([]string, error) {
|
||||
projectsMap := make(map[string]bool) // proper sets as part of stdlib would be nice...
|
||||
|
||||
// extract actual projects from heartbeats
|
||||
realProjects, err := heartbeatSrvc.GetEntitySetByUser(models.SummaryProject, user)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
// create a "set" / lookup table
|
||||
for _, p := range realProjects {
|
||||
projectsMap[p] = true
|
||||
}
|
||||
|
||||
// fetch aliases
|
||||
projectAliases, err := aliasSrvc.GetByUserAndType(user.ID, models.SummaryProject)
|
||||
if err != nil {
|
||||
return []string{}, err
|
||||
}
|
||||
|
||||
projects := datastructure.NewSet[string](realProjects...)
|
||||
|
||||
// remove alias values (source of a mapping)
|
||||
// add alias key (target of a mapping) instead
|
||||
for _, a := range projectAliases {
|
||||
if projectsMap[a.Value] {
|
||||
projectsMap[a.Value] = false
|
||||
}
|
||||
projectsMap[a.Key] = true
|
||||
projects.Delete(a.Value)
|
||||
projects.Add(a.Key)
|
||||
}
|
||||
|
||||
projects := make([]string, 0, len(projectsMap))
|
||||
for key, val := range projectsMap {
|
||||
if !val {
|
||||
continue
|
||||
}
|
||||
projects = append(projects, key)
|
||||
}
|
||||
|
||||
sort.Strings(projects)
|
||||
return projects, nil
|
||||
sorted := projects.Values()
|
||||
sort.Strings(sorted)
|
||||
return sorted, nil
|
||||
}
|
||||
|
@ -1,7 +1,6 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"github.com/muety/wakapi/middlewares"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/services"
|
||||
"github.com/muety/wakapi/utils"
|
||||
@ -9,24 +8,33 @@ import (
|
||||
)
|
||||
|
||||
func LoadUserSummary(ss services.ISummaryService, r *http.Request) (*models.Summary, error, int) {
|
||||
user := middlewares.GetPrincipal(r)
|
||||
summaryParams, err := utils.ParseSummaryParams(r)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusBadRequest
|
||||
}
|
||||
return LoadUserSummaryByParams(ss, summaryParams)
|
||||
}
|
||||
|
||||
func LoadUserSummaryByParams(ss services.ISummaryService, params *models.SummaryParams) (*models.Summary, error, int) {
|
||||
var retrieveSummary services.SummaryRetriever = ss.Retrieve
|
||||
if summaryParams.Recompute {
|
||||
if params.Recompute {
|
||||
retrieveSummary = ss.Summarize
|
||||
}
|
||||
|
||||
summary, err := ss.Aliased(summaryParams.From, summaryParams.To, summaryParams.User, retrieveSummary, summaryParams.Filters, summaryParams.Recompute)
|
||||
summary, err := ss.Aliased(
|
||||
params.From,
|
||||
params.To,
|
||||
params.User,
|
||||
retrieveSummary,
|
||||
params.Filters,
|
||||
params.Recompute,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err, http.StatusInternalServerError
|
||||
}
|
||||
|
||||
summary.FromTime = models.CustomTime(summary.FromTime.T().In(user.TZ()))
|
||||
summary.ToTime = models.CustomTime(summary.ToTime.T().In(user.TZ()))
|
||||
summary.FromTime = models.CustomTime(summary.FromTime.T().In(params.User.TZ()))
|
||||
summary.ToTime = models.CustomTime(summary.ToTime.T().In(params.User.TZ()))
|
||||
|
||||
return summary, nil, http.StatusOK
|
||||
}
|
||||
|
@ -35,12 +35,12 @@ func CheckEffectiveUser(w http.ResponseWriter, r *http.Request, userService serv
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID {
|
||||
if authorizedUser == nil || authorizedUser.ID != requestedUser.ID && !authorizedUser.IsAdmin {
|
||||
err := errors.New(conf.ErrUnauthorized)
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
w.Write([]byte(err.Error()))
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authorizedUser, nil
|
||||
return requestedUser, nil
|
||||
}
|
||||
|
9
scripts/aggregate_durations.sql
Normal file
9
scripts/aggregate_durations.sql
Normal file
@ -0,0 +1,9 @@
|
||||
SELECT project, language, editor, operating_system, machine, branch, SUM(GREATEST(1, diff)) as 'sum'
|
||||
FROM (
|
||||
SELECT project, language, editor, operating_system, machine, branch, TIME_TO_SEC(LEAST(TIMEDIFF(time, LAG(time) over w), '00:02:00')) as 'diff'
|
||||
FROM heartbeats
|
||||
WHERE user_id = 'n1try'
|
||||
WINDOW w AS (ORDER BY time)
|
||||
) s2
|
||||
WHERE diff IS NOT NULL
|
||||
GROUP BY project, language, editor, operating_system, machine, branch;
|
@ -53,7 +53,24 @@ let icons = [
|
||||
'ion:rocket',
|
||||
'heroicons-solid:server',
|
||||
'eva:checkmark-circle-2-fill',
|
||||
'fluent:key-24-filled'
|
||||
'fluent:key-24-filled',
|
||||
'mdi:language-c',
|
||||
'mdi:language-cpp',
|
||||
'mdi:language-go',
|
||||
'mdi:language-haskell',
|
||||
'mdi:language-html5',
|
||||
'mdi:language-java',
|
||||
'mdi:language-javascript',
|
||||
'mdi:language-kotlin',
|
||||
'mdi:language-lua',
|
||||
'mdi:language-php',
|
||||
'mdi:language-python',
|
||||
'mdi:language-r',
|
||||
'mdi:language-ruby',
|
||||
'mdi:language-rust',
|
||||
'mdi:language-swift',
|
||||
'mdi:language-typescript',
|
||||
'twemoji:frowning-face',
|
||||
]
|
||||
|
||||
const output = path.normalize(path.join(__dirname, '../static/assets/js/icons.dist.js'))
|
||||
|
12
scripts/clean_duplicates.sql
Normal file
12
scripts/clean_duplicates.sql
Normal file
@ -0,0 +1,12 @@
|
||||
DELETE t1
|
||||
FROM heartbeats t1
|
||||
INNER JOIN heartbeats t2
|
||||
WHERE t1.id < t2.id
|
||||
AND t1.time = t2.time
|
||||
AND t1.entity = t2.entity
|
||||
AND t1.is_write = t2.is_write
|
||||
AND t1.branch = t2.branch
|
||||
AND t1.editor = t2.editor
|
||||
AND t1.machine = t2.machine
|
||||
AND t1.operating_system = t2.operating_system
|
||||
AND t1.user_id = t2.user_id;
|
10
scripts/count_duplicates_by_user.sql
Normal file
10
scripts/count_duplicates_by_user.sql
Normal file
@ -0,0 +1,10 @@
|
||||
SELECT s2.user_id, sum(c) as count, total, (sum(c) / total) as ratio
|
||||
FROM (
|
||||
SELECT time, user_id, entity, is_write, branch, editor, machine, operating_system, COUNT(time) as c
|
||||
FROM heartbeats
|
||||
GROUP BY time, user_id, entity, is_write, branch, editor, machine, operating_system
|
||||
HAVING COUNT(time) > 1
|
||||
) s2
|
||||
LEFT JOIN (SELECT user_id, count(id) AS total FROM heartbeats GROUP BY user_id) s3 ON s2.user_id = s3.user_id
|
||||
GROUP BY user_id
|
||||
ORDER BY count DESC;
|
137
scripts/get.sh
137
scripts/get.sh
@ -15,54 +15,163 @@
|
||||
set -e -u
|
||||
|
||||
githubLatestTag() {
|
||||
finalUrl=$(curl "https://github.com/$1/releases/latest" -s -L -I -o /dev/null -w '%{url_effective}')
|
||||
printf "%s\n" "${finalUrl##*/}"
|
||||
latestJSON="$( eval "$http 'https://api.github.com/repos/$1/releases/latest'" 2>/dev/null )" || true
|
||||
|
||||
versionNumber=''
|
||||
if ! echo "$latestJSON" | grep 'API rate limit exceeded' >/dev/null 2>&1 ; then
|
||||
if ! versionNumber="$( echo "$latestJSON" | grep -oEm1 '[0-9]+[.][0-9]+[.][0-9]+' - 2>/dev/null )" ; then
|
||||
versionNumber=''
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${versionNumber:-x}" = "x" ] ; then
|
||||
# Try to fallback to previous latest version detection method if curl is available
|
||||
if command -v curl >/dev/null 2>&1 ; then
|
||||
if finalUrl="$( curl "https://github.com/$1/releases/latest" -s -L -I -o /dev/null -w '%{url_effective}' 2>/dev/null )" ; then
|
||||
trimmedVers="${finalUrl##*v}"
|
||||
if [ "${trimmedVers:-x}" != "x" ] ; then
|
||||
echo "$trimmedVers"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
cat 1>&2 << 'EOA'
|
||||
/=====================================\\
|
||||
| FAILED TO HTTP DOWNLOAD FILE |
|
||||
\\=====================================/
|
||||
|
||||
Uh oh! We couldn't download needed internet resources for you. Perhaps you are
|
||||
offline, your DNS servers are not set up properly, your internet plan doesn't
|
||||
include GitHub, or the GitHub servers are down?
|
||||
|
||||
EOA
|
||||
exit 1
|
||||
else
|
||||
echo "$versionNumber"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "${GETMICRO_HTTP:-x}" != "x" ]; then
|
||||
http="$GETMICRO_HTTP"
|
||||
elif command -v curl >/dev/null 2>&1 ; then
|
||||
http="curl -L"
|
||||
elif command -v wget >/dev/null 2>&1 ; then
|
||||
http="wget -O-"
|
||||
else
|
||||
cat 1>&2 << 'EOA'
|
||||
/=====================================\\
|
||||
| COULD NOT FIND HTTP PROGRAM |
|
||||
\\=====================================/
|
||||
|
||||
Uh oh! We couldn't find either curl or wget installed on your system.
|
||||
|
||||
To continue with installation, you have two options:
|
||||
|
||||
A. Install either wget or curl on your system. You may need to run `hash -r`.
|
||||
|
||||
B. Define GETMICRO_HTTP to be a command (with arguments deliminated by spaces)
|
||||
that both follows HTTP redirects AND prints the fetched content to stdout.
|
||||
|
||||
For examples of option B, this script uses the below values for wget and curl:
|
||||
|
||||
$ curl https://wakapi.dev/get | GETMICRO_HTTP="curl -L" sh
|
||||
|
||||
$ wget -O- https://wakapi.dev/get | GETMICRO_HTTP="wget -O-" sh
|
||||
|
||||
EOA
|
||||
exit 1
|
||||
fi
|
||||
|
||||
platform=''
|
||||
machine=$(uname -m) # currently, Wakapi builds are only available for AMD64 anyway
|
||||
machine=$(uname -m)
|
||||
|
||||
if [ "${GETWAKAPI_PLATFORM:-x}" != "x" ]; then
|
||||
platform="$GETWAKAPI_PLATFORM"
|
||||
else
|
||||
case "$(uname -s | tr '[:upper:]' '[:lower:]')" in
|
||||
"linux") platform='linux_amd64' ;;
|
||||
"msys"*|"cygwin"*|"mingw"*|*"_nt"*|"win"*) platform='win_amd64' ;;
|
||||
"linux")
|
||||
case "$machine" in
|
||||
"arm64"* | "aarch64"* ) platform='linux_arm64' ;;
|
||||
*"64") platform='linux_amd64' ;;
|
||||
esac
|
||||
;;
|
||||
"darwin")
|
||||
case "$machine" in
|
||||
"arm64"* | "aarch64"* ) platform='darwin_arm64' ;;
|
||||
*"64") platform='darwin_amd64' ;;
|
||||
esac;;
|
||||
"msys"*|"cygwin"*|"mingw"*|*"_nt"*|"win"*)
|
||||
case "$machine" in
|
||||
"arm64"* | "aarch64"* ) platform='win_arm64' ;;
|
||||
*"64") platform='win_amd64' ;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
if [ "x$platform" = "x" ]; then
|
||||
cat << 'EOM'
|
||||
if [ "${platform:-x}" = "x" ]; then
|
||||
cat 1>&2 << 'EOM'
|
||||
/=====================================\\
|
||||
| COULD NOT DETECT PLATFORM |
|
||||
\\=====================================/
|
||||
|
||||
Uh oh! We couldn't automatically detect your operating system. You can file a
|
||||
bug here: https://github.com/muety/wakapi
|
||||
|
||||
To continue with installation, please choose from one of the following values:
|
||||
|
||||
- win_amd64
|
||||
- darwin_amd64
|
||||
- linux_amd64
|
||||
|
||||
Export your selection as the GETWAKAPI_PLATFORM environment variable, and then
|
||||
re-run this script.
|
||||
|
||||
For example:
|
||||
|
||||
$ curl https://getmic.ro | GETWAKAPI_PLATFORM=linux_amd64 sh
|
||||
|
||||
EOM
|
||||
exit 1
|
||||
else
|
||||
printf "Detected platform: %s\n" "$platform"
|
||||
echo "Detected platform: $platform"
|
||||
fi
|
||||
|
||||
TAG=$(githubLatestTag muety/wakapi)
|
||||
|
||||
printf "Tag: %s" "$TAG"
|
||||
if command -v grep >/dev/null 2>&1 ; then
|
||||
if ! echo "v$TAG" | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' >/dev/null 2>&1 ; then
|
||||
cat 1>&2 << 'EOM'
|
||||
/=====================================\\
|
||||
| INVALID TAG RECIEVED |
|
||||
\\=====================================/
|
||||
|
||||
Uh oh! We recieved an invalid tag and cannot be sure that the tag will not break
|
||||
this script.
|
||||
|
||||
Please open an issue on GitHub at https://github.com/muety/wakapi with
|
||||
the invalid tag included:
|
||||
|
||||
EOM
|
||||
echo "> $TAG" 1>&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
extension='zip'
|
||||
|
||||
printf "Latest Version: %s\n" "$TAG"
|
||||
printf "Downloading https://github.com/muety/wakapi/releases/download/%s/wakapi_%s.%s\n" "$TAG" "$platform" "$extension"
|
||||
echo "Latest Version: $TAG"
|
||||
echo "Downloading https://github.com/muety/wakapi/releases/download/$TAG/wakapi_$platform.$extension"
|
||||
|
||||
curl -L "https://github.com/muety/wakapi/releases/download/$TAG/wakapi_$platform.$extension" > "wakapi.$extension"
|
||||
eval "$http 'https://github.com/muety/wakapi/releases/download/$TAG/wakapi_$platform.$extension'" > "wakapi.$extension"
|
||||
|
||||
case "$extension" in
|
||||
"zip") unzip -j "wakapi.$extension" -d "wakapi-$TAG" ;;
|
||||
"tar.gz") tar -xvzf "wakapi.$extension" "wakapi-$TAG/wakapi" ;;
|
||||
esac
|
||||
|
||||
mv "wakapi-$TAG/wakapi" ./wakapi
|
||||
mv "wakapi-$TAG/config.yml" ./config.yml
|
||||
mv wakapi-$TAG/* .
|
||||
|
||||
rm "wakapi.$extension"
|
||||
rm -rf "wakapi-$TAG"
|
||||
|
@ -21,6 +21,7 @@ LANGUAGES = {
|
||||
'PHP': 'php',
|
||||
'Blade': 'blade.php'
|
||||
}
|
||||
BRANCHES = ['master', 'feature-1', 'feature-2']
|
||||
|
||||
|
||||
class Heartbeat:
|
||||
@ -65,6 +66,7 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
|
||||
p: str = random.choice(projects)
|
||||
l: str = random.choice(languages)
|
||||
f: str = randomword(random.randint(2, 8))
|
||||
b: str = random.choice(BRANCHES)
|
||||
delta: timedelta = timedelta(
|
||||
hours=random.randint(0, n_past_hours - 1),
|
||||
minutes=random.randint(0, 59),
|
||||
@ -77,6 +79,7 @@ def generate_data(n: int, n_projects: int = 5, n_past_hours: int = 24) -> List[H
|
||||
entity=f'/home/me/dev/{p}/{f}.{LANGUAGES[l]}',
|
||||
project=p,
|
||||
language=l,
|
||||
branch=b,
|
||||
time=(now - delta).timestamp()
|
||||
))
|
||||
|
||||
|
@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"runtime"
|
||||
@ -23,7 +24,7 @@ type AggregationService struct {
|
||||
userService IUserService
|
||||
summaryService ISummaryService
|
||||
heartbeatService IHeartbeatService
|
||||
inProgress map[string]bool
|
||||
inProgress datastructure.Set[string]
|
||||
}
|
||||
|
||||
func NewAggregationService(userService IUserService, summaryService ISummaryService, heartbeatService IHeartbeatService) *AggregationService {
|
||||
@ -32,7 +33,7 @@ func NewAggregationService(userService IUserService, summaryService ISummaryServ
|
||||
userService: userService,
|
||||
summaryService: summaryService,
|
||||
heartbeatService: heartbeatService,
|
||||
inProgress: map[string]bool{},
|
||||
inProgress: datastructure.NewSet[string](),
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,17 +45,12 @@ type AggregationJob struct {
|
||||
|
||||
// Schedule a job to (re-)generate summaries every day shortly after midnight
|
||||
func (srv *AggregationService) Schedule() {
|
||||
// Run once initially
|
||||
if err := srv.Run(nil); err != nil {
|
||||
logbuch.Fatal("failed to run AggregationJob: %v", err)
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.AggregationTime).Do(srv.Run, map[string]bool{})
|
||||
s.Every(1).Day().At(srv.config.App.AggregationTime).WaitForSchedule().Do(srv.Run, datastructure.NewSet[string]())
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
func (srv *AggregationService) Run(userIds datastructure.Set[string]) error {
|
||||
if err := srv.lockUsers(userIds); err != nil {
|
||||
return err
|
||||
}
|
||||
@ -84,7 +80,7 @@ func (srv *AggregationService) Run(userIds map[string]bool) error {
|
||||
func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summaries chan<- *models.Summary) {
|
||||
for job := range jobs {
|
||||
if summary, err := srv.summaryService.Summarize(job.From, job.To, &models.User{ID: job.UserID}, nil); err != nil {
|
||||
config.Log().Error("failed to generate summary (%v, %v, %s) – %v", job.From, job.To, job.UserID, err)
|
||||
config.Log().Error("failed to generate summary (%v, %v, %s) - %v", job.From, job.To, job.UserID, err)
|
||||
} else {
|
||||
logbuch.Info("successfully generated summary (%v, %v, %s)", job.From, job.To, job.UserID)
|
||||
summaries <- summary
|
||||
@ -95,29 +91,14 @@ func (srv *AggregationService) summaryWorker(jobs <-chan *AggregationJob, summar
|
||||
func (srv *AggregationService) persistWorker(summaries <-chan *models.Summary) {
|
||||
for summary := range summaries {
|
||||
if err := srv.summaryService.Insert(summary); err != nil {
|
||||
config.Log().Error("failed to save summary (%v, %v, %s) – %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
config.Log().Error("failed to save summary (%v, %v, %s) - %v", summary.UserID, summary.FromTime, summary.ToTime, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[string]bool) error {
|
||||
func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds datastructure.Set[string]) error {
|
||||
logbuch.Info("generating summaries")
|
||||
|
||||
var users []*models.User
|
||||
if allUsers, err := srv.userService.GetAll(); err != nil {
|
||||
config.Log().Error(err.Error())
|
||||
return err
|
||||
} else if userIds != nil && len(userIds) > 0 {
|
||||
users = make([]*models.User, 0)
|
||||
for _, u := range allUsers {
|
||||
if yes, ok := userIds[u.ID]; yes && ok {
|
||||
users = append(users, u)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
users = allUsers
|
||||
}
|
||||
|
||||
// Get a map from user ids to the time of their latest summary or nil if none exists yet
|
||||
lastUserSummaryTimes, err := srv.summaryService.GetLatestByUser()
|
||||
if err != nil {
|
||||
@ -140,6 +121,10 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
|
||||
|
||||
// Generate summary aggregation jobs
|
||||
for _, e := range lastUserSummaryTimes {
|
||||
if userIds != nil && !userIds.IsEmpty() && !userIds.Contain(e.User) {
|
||||
continue
|
||||
}
|
||||
|
||||
if e.Time.Valid() {
|
||||
// Case 1: User has aggregated summaries already
|
||||
// -> Spawn jobs to create summaries from their latest aggregation to now
|
||||
@ -156,25 +141,23 @@ func (srv *AggregationService) trigger(jobs chan<- *AggregationJob, userIds map[
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AggregationService) lockUsers(userIds map[string]bool) error {
|
||||
func (srv *AggregationService) lockUsers(userIds datastructure.Set[string]) error {
|
||||
aggregationLock.Lock()
|
||||
defer aggregationLock.Unlock()
|
||||
for uid := range userIds {
|
||||
if _, ok := srv.inProgress[uid]; ok {
|
||||
if srv.inProgress.Contain(uid) {
|
||||
return errors.New("aggregation already in progress for at least of the request users")
|
||||
}
|
||||
}
|
||||
for uid := range userIds {
|
||||
srv.inProgress[uid] = true
|
||||
}
|
||||
srv.inProgress = srv.inProgress.Union(userIds)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *AggregationService) unlockUsers(userIds map[string]bool) {
|
||||
func (srv *AggregationService) unlockUsers(userIds datastructure.Set[string]) {
|
||||
aggregationLock.Lock()
|
||||
defer aggregationLock.Unlock()
|
||||
for uid := range userIds {
|
||||
delete(srv.inProgress, uid)
|
||||
srv.inProgress.Delete(uid)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
@ -117,12 +118,13 @@ func (srv *AliasService) Delete(alias *models.Alias) error {
|
||||
|
||||
func (srv *AliasService) DeleteMulti(aliases []*models.Alias) error {
|
||||
ids := make([]uint, len(aliases))
|
||||
affectedUsers := make(map[string]bool)
|
||||
affectedUsers := datastructure.NewSet[string]()
|
||||
|
||||
for i, a := range aliases {
|
||||
if a.UserID == "" {
|
||||
return errors.New("no user id specified")
|
||||
}
|
||||
affectedUsers[a.UserID] = true
|
||||
affectedUsers.Add(a.UserID)
|
||||
ids[i] = a.ID
|
||||
}
|
||||
|
||||
|
@ -19,5 +19,6 @@ func NewDiagnosticsService(diagnosticsRepo repositories.IDiagnosticsRepository)
|
||||
}
|
||||
|
||||
func (srv *DiagnosticsService) Create(diagnostics *models.Diagnostics) (*models.Diagnostics, error) {
|
||||
diagnostics.ID = 0
|
||||
return srv.repository.Insert(diagnostics)
|
||||
}
|
||||
|
@ -1,6 +1,8 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/duke-git/lancet/v2/mathutil"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
@ -22,12 +24,22 @@ func NewDurationService(heartbeatService IHeartbeatService) *DurationService {
|
||||
}
|
||||
|
||||
func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *models.Filters) (models.Durations, error) {
|
||||
heartbeats, err := srv.heartbeatService.GetAllWithin(from, to, user)
|
||||
get := srv.heartbeatService.GetAllWithin
|
||||
|
||||
if filters != nil && !filters.IsEmpty() {
|
||||
get = func(t1 time.Time, t2 time.Time, user *models.User) ([]*models.Heartbeat, error) {
|
||||
return srv.heartbeatService.GetAllWithinByFilters(t1, t2, user, filters)
|
||||
}
|
||||
}
|
||||
|
||||
heartbeats, err := get(from, to, user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Aggregation
|
||||
// the below logic is approximately equivalent to the SQL query at scripts/aggregate_durations.sql,
|
||||
// but unfortunately we cannot use it, as it features mysql-specific functions (lag(), timediff(), ...)
|
||||
var count int
|
||||
var latest *models.Duration
|
||||
|
||||
@ -49,13 +61,26 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
|
||||
continue
|
||||
}
|
||||
|
||||
dur := d1.Time.T().Sub(latest.Time.T().Add(latest.Duration))
|
||||
if dur > HeartbeatDiffThreshold {
|
||||
dur = HeartbeatDiffThreshold
|
||||
sameDay := datetime.BeginOfDay(d1.Time.T()) == datetime.BeginOfDay(latest.Time.T())
|
||||
dur := time.Duration(mathutil.Min(
|
||||
int64(d1.Time.T().Sub(latest.Time.T().Add(latest.Duration))),
|
||||
int64(HeartbeatDiffThreshold),
|
||||
))
|
||||
|
||||
// skip heartbeats that span across two adjacent summaries (assuming there are no more than 1 summary per day)
|
||||
// this is relevant to prevent the time difference between generating summaries from raw heartbeats and aggregating pre-generated summaries
|
||||
// for the latter case, the very last heartbeat of a day won't be counted, so we don't want to count it here either
|
||||
// another option would be to adapt the Summarize() method to always append up to HeartbeatDiffThreshold seconds to a day's very last duration
|
||||
if !sameDay {
|
||||
dur = 0
|
||||
}
|
||||
latest.Duration += dur
|
||||
|
||||
if dur >= HeartbeatDiffThreshold || latest.GroupHash != d1.GroupHash {
|
||||
// start new "group" if:
|
||||
// (a) heartbeats were too far apart each other,
|
||||
// (b) if they are of a different entity or,
|
||||
// (c) if they span across two days
|
||||
if dur >= HeartbeatDiffThreshold || latest.GroupHash != d1.GroupHash || !sameDay {
|
||||
list := mapping[d1.GroupHash]
|
||||
if d0 := list[len(list)-1]; d0 != d1 {
|
||||
mapping[d1.GroupHash] = append(mapping[d1.GroupHash], d1)
|
||||
@ -72,12 +97,20 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
|
||||
|
||||
for _, list := range mapping {
|
||||
for _, d := range list {
|
||||
// will only happen if two heartbeats with different hashes (e.g. different project) have the same timestamp
|
||||
// that, in turn, will most likely only happen for mysql, where `time` column's precision was set to second for a while
|
||||
// assume that two non-identical heartbeats with identical time are sub-second apart from each other, so round up to expectancy value
|
||||
// also see https://github.com/muety/wakapi/issues/340
|
||||
if d.Duration == 0 {
|
||||
d.Duration = HeartbeatDiffThreshold
|
||||
d.Duration = 500 * time.Millisecond
|
||||
}
|
||||
durations = append(durations, d)
|
||||
}
|
||||
}
|
||||
|
||||
if len(heartbeats) == 1 && len(durations) == 1 {
|
||||
durations[0].Duration = HeartbeatDiffThreshold
|
||||
}
|
||||
|
||||
return durations.Sorted(), nil
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ import (
|
||||
"github.com/muety/wakapi/mocks"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
"github.com/stretchr/testify/suite"
|
||||
"math/rand"
|
||||
"testing"
|
||||
@ -64,6 +65,17 @@ func (suite *DurationServiceTestSuite) SetupSuite() {
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30
|
||||
},
|
||||
// duplicate of previous one
|
||||
{
|
||||
ID: rand.Uint64(),
|
||||
UserID: TestUserId,
|
||||
Project: TestProject1,
|
||||
Language: TestLanguageGo,
|
||||
Editor: TestEditorGoland,
|
||||
OperatingSystem: TestOsLinux,
|
||||
Machine: TestMachine1,
|
||||
Time: models.CustomTime(suite.TestStartTime.Add(30 * time.Second)), // 0:30
|
||||
},
|
||||
{
|
||||
ID: rand.Uint64(),
|
||||
UserID: TestUserId,
|
||||
@ -159,7 +171,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get() {
|
||||
assert.Equal(suite.T(), TestEditorGoland, durations[0].Editor)
|
||||
assert.Equal(suite.T(), TestEditorGoland, durations[1].Editor)
|
||||
assert.Equal(suite.T(), TestEditorVscode, durations[2].Editor)
|
||||
assert.Equal(suite.T(), 2, durations[0].NumHeartbeats)
|
||||
assert.Equal(suite.T(), 3, durations[0].NumHeartbeats)
|
||||
assert.Equal(suite.T(), 1, durations[1].NumHeartbeats)
|
||||
assert.Equal(suite.T(), 3, durations[2].NumHeartbeats)
|
||||
}
|
||||
@ -175,7 +187,7 @@ func (suite *DurationServiceTestSuite) TestDurationService_Get_Filtered() {
|
||||
)
|
||||
|
||||
from, to = suite.TestStartTime.Add(-1*time.Hour), suite.TestStartTime.Add(1*time.Hour)
|
||||
suite.HeartbeatService.On("GetAllWithin", from, to, suite.TestUser).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
|
||||
suite.HeartbeatService.On("GetAllWithinByFilters", from, to, suite.TestUser, mock.Anything).Return(filterHeartbeats(from, to, suite.TestHeartbeats), nil)
|
||||
|
||||
durations, err = sut.Get(from, to, suite.TestUser, models.NewFiltersWith(models.SummaryEditor, TestEditorGoland))
|
||||
assert.Nil(suite.T(), err)
|
||||
|
@ -2,10 +2,10 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"strings"
|
||||
"sync"
|
||||
@ -54,14 +54,18 @@ func (srv *HeartbeatService) Insert(heartbeat *models.Heartbeat) error {
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
hashes := make(map[string]bool)
|
||||
if len(heartbeats) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
hashes := datastructure.NewSet[string]()
|
||||
|
||||
// https://github.com/muety/wakapi/issues/139
|
||||
filteredHeartbeats := make([]*models.Heartbeat, 0, len(heartbeats))
|
||||
for _, hb := range heartbeats {
|
||||
if _, ok := hashes[hb.Hash]; !ok {
|
||||
if !hashes.Contain(hb.Hash) {
|
||||
filteredHeartbeats = append(filteredHeartbeats, hb)
|
||||
hashes[hb.Hash] = true
|
||||
hashes.Add(hb.Hash)
|
||||
}
|
||||
go srv.updateEntityUserCacheByHeartbeat(hb)
|
||||
}
|
||||
@ -73,12 +77,12 @@ func (srv *HeartbeatService) InsertBatch(heartbeats []*models.Heartbeat) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) Count() (int64, error) {
|
||||
func (srv *HeartbeatService) Count(approximate bool) (int64, error) {
|
||||
result, ok := srv.cache.Get(srv.countTotalCacheKey())
|
||||
if ok {
|
||||
return result.(int64), nil
|
||||
}
|
||||
count, err := srv.repository.Count()
|
||||
count, err := srv.repository.Count(approximate)
|
||||
if err == nil {
|
||||
srv.cache.Set(srv.countTotalCacheKey(), count, srv.countCacheTtl())
|
||||
}
|
||||
@ -134,6 +138,14 @@ func (srv *HeartbeatService) GetAllWithin(from, to time.Time, user *models.User)
|
||||
return srv.augmented(heartbeats, user.ID)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) GetAllWithinByFilters(from, to time.Time, user *models.User, filters *models.Filters) ([]*models.Heartbeat, error) {
|
||||
heartbeats, err := srv.repository.GetAllWithinByFilters(from, to, user, srv.filtersToColumnMap(filters))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return srv.augmented(heartbeats, user.ID)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) GetLatestByUser(user *models.User) (*models.Heartbeat, error) {
|
||||
return srv.repository.GetLatestByUser(user)
|
||||
}
|
||||
@ -151,7 +163,7 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
|
||||
if results, found := srv.cache.Get(cacheKey); found {
|
||||
srv.entityCacheLock.RLock()
|
||||
defer srv.entityCacheLock.RUnlock()
|
||||
return utils.SetToStrings(results.(map[string]bool)), nil
|
||||
return results.(datastructure.Set[string]).Values(), nil
|
||||
}
|
||||
|
||||
results, err := srv.repository.GetEntitySetByUser(entityType, user)
|
||||
@ -166,14 +178,20 @@ func (srv *HeartbeatService) GetEntitySetByUser(entityType uint8, user *models.U
|
||||
}
|
||||
}
|
||||
|
||||
srv.cache.Set(cacheKey, utils.StringsToSet(filtered), cache.NoExpiration)
|
||||
srv.cache.Set(cacheKey, datastructure.NewSet(filtered...), cache.NoExpiration)
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteBefore(t time.Time) error {
|
||||
go srv.cache.Flush()
|
||||
return srv.repository.DeleteBefore(t)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) DeleteByUser(user *models.User) error {
|
||||
go srv.cache.Flush()
|
||||
return srv.repository.DeleteByUser(user)
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) augmented(heartbeats []*models.Heartbeat, userId string) ([]*models.Heartbeat, error) {
|
||||
languageMapping, err := srv.languageMappingSrvc.ResolveByUser(userId)
|
||||
if err != nil {
|
||||
@ -194,13 +212,13 @@ func (srv *HeartbeatService) getEntityUserCacheKey(entityType uint8, user *model
|
||||
func (srv *HeartbeatService) updateEntityUserCache(entityType uint8, entityKey string, user *models.User) {
|
||||
cacheKey := srv.getEntityUserCacheKey(entityType, user)
|
||||
if entities, found := srv.cache.Get(cacheKey); found {
|
||||
entitySet := entities.(map[string]bool)
|
||||
entitySet := entities.(datastructure.Set[string])
|
||||
|
||||
srv.entityCacheLock.Lock()
|
||||
defer srv.entityCacheLock.Unlock()
|
||||
|
||||
if _, ok := entitySet[entityKey]; !ok {
|
||||
entitySet[entityKey] = true
|
||||
if !entitySet.Contain(entityKey) {
|
||||
entitySet.Add(entityKey)
|
||||
// new project / language / ..., which is not yet present in cache, arrived as part of a heartbeats
|
||||
// -> update cache instead of just invalidating it, because rebuilding is expensive here
|
||||
srv.cache.Set(cacheKey, entitySet, cache.NoExpiration)
|
||||
@ -237,3 +255,14 @@ func (srv *HeartbeatService) countTotalCacheKey() string {
|
||||
func (srv *HeartbeatService) countCacheTtl() time.Duration {
|
||||
return time.Duration(srv.config.App.CountCacheTTLMin) * time.Minute
|
||||
}
|
||||
|
||||
func (srv *HeartbeatService) filtersToColumnMap(filters *models.Filters) map[string][]string {
|
||||
columnMap := map[string][]string{}
|
||||
for _, t := range models.NativeSummaryTypes() {
|
||||
f := filters.ResolveEntity(t)
|
||||
if len(*f) > 0 {
|
||||
columnMap[models.GetEntityColumn(t)] = *f
|
||||
}
|
||||
}
|
||||
return columnMap
|
||||
}
|
||||
|
@ -6,6 +6,8 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
@ -13,7 +15,6 @@ import (
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
wakatime "github.com/muety/wakapi/models/compat/wakatime/v1"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"go.uber.org/atomic"
|
||||
"golang.org/x/sync/semaphore"
|
||||
)
|
||||
@ -45,7 +46,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
|
||||
startDate, endDate, err := w.fetchRange(baseUrl)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to fetch date range while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -58,13 +59,13 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
|
||||
userAgents, err := w.fetchUserAgents(baseUrl)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to fetch user agents while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
machinesNames, err := w.fetchMachineNames(baseUrl)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to fetch machine names while importing wakatime heartbeats for user '%s' - %v", user.ID, err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -76,7 +77,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
|
||||
for _, d := range days {
|
||||
if err := sem.Acquire(ctx, 1); err != nil {
|
||||
logbuch.Error("failed to acquire semaphore – %v", err)
|
||||
logbuch.Error("failed to acquire semaphore - %v", err)
|
||||
break
|
||||
}
|
||||
|
||||
@ -87,7 +88,7 @@ func (w *WakatimeHeartbeatImporter) Import(user *models.User, minFrom time.Time,
|
||||
d := day.Format(config.SimpleDateFormat)
|
||||
heartbeats, err := w.fetchHeartbeats(d, baseUrl)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' – &v", d, user.ID, err)
|
||||
config.Log().Error("failed to fetch heartbeats for day '%s' and user '%s' - &v", d, user.ID, err)
|
||||
}
|
||||
|
||||
for _, h := range heartbeats {
|
||||
@ -154,8 +155,9 @@ func (w *WakatimeHeartbeatImporter) fetchRange(baseUrl string) (time.Time, time.
|
||||
return notime, notime, err
|
||||
}
|
||||
|
||||
var allTimeData wakatime.AllTimeViewModel
|
||||
if err := json.NewDecoder(res.Body).Decode(&allTimeData); err != nil {
|
||||
// see https://github.com/muety/wakapi/issues/370
|
||||
allTimeData, err := utils.ParseJsonDropKeys[wakatime.AllTimeViewModel](res.Body, "text")
|
||||
if err != nil {
|
||||
return notime, notime, err
|
||||
}
|
||||
|
||||
@ -295,8 +297,8 @@ func mapHeartbeat(
|
||||
func generateDays(from, to time.Time) []time.Time {
|
||||
days := make([]time.Time, 0)
|
||||
|
||||
from = utils.StartOfDay(from)
|
||||
to = utils.StartOfDay(to.AddDate(0, 0, 1))
|
||||
from = datetime.BeginOfDay(from)
|
||||
to = datetime.BeginOfDay(to.AddDate(0, 0, 1))
|
||||
|
||||
for d := from; d.Before(to); d = d.AddDate(0, 0, 1) {
|
||||
days = append(days, d)
|
||||
|
217
services/leaderboard.go
Normal file
217
services/leaderboard.go
Normal file
@ -0,0 +1,217 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/go-co-op/gocron"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
"github.com/muety/wakapi/repositories"
|
||||
"github.com/muety/wakapi/utils"
|
||||
"github.com/patrickmn/go-cache"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LeaderboardService struct {
|
||||
config *config.Config
|
||||
cache *cache.Cache
|
||||
eventBus *hub.Hub
|
||||
repository repositories.ILeaderboardRepository
|
||||
summaryService ISummaryService
|
||||
userService IUserService
|
||||
}
|
||||
|
||||
func NewLeaderboardService(leaderboardRepo repositories.ILeaderboardRepository, summaryService ISummaryService, userService IUserService) *LeaderboardService {
|
||||
srv := &LeaderboardService{
|
||||
config: config.Get(),
|
||||
cache: cache.New(6*time.Hour, 6*time.Hour),
|
||||
eventBus: config.EventBus(),
|
||||
repository: leaderboardRepo,
|
||||
summaryService: summaryService,
|
||||
userService: userService,
|
||||
}
|
||||
|
||||
onUserUpdate := srv.eventBus.Subscribe(0, config.EventUserUpdate)
|
||||
go func(sub *hub.Subscription) {
|
||||
for m := range sub.Receiver {
|
||||
|
||||
// generate leaderboard for updated user, if leaderboard enabled and none present, yet
|
||||
user := m.Fields[config.FieldPayload].(*models.User)
|
||||
|
||||
exists, err := srv.ExistsAnyByUser(user.ID)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to check existing leaderboards upon user update - %v", err)
|
||||
}
|
||||
|
||||
if user.PublicLeaderboard && !exists {
|
||||
logbuch.Info("generating leaderboard for '%s' after settings update", user.ID)
|
||||
srv.Run([]*models.User{user}, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
||||
} else if !user.PublicLeaderboard && exists {
|
||||
logbuch.Info("clearing leaderboard for '%s' after settings update", user.ID)
|
||||
if err := srv.repository.DeleteByUser(user.ID); err != nil {
|
||||
config.Log().Error("failed to clear leaderboard for user '%s' - %v", user.ID, err)
|
||||
}
|
||||
srv.cache.Flush()
|
||||
}
|
||||
}
|
||||
}(&onUserUpdate)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) ScheduleDefault() {
|
||||
runAllUsers := func(interval *models.IntervalKey, by []uint8) {
|
||||
users, err := srv.userService.GetAllByLeaderboard(true)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to get users for leaderboard generation - %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
srv.Run(users, interval, by)
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Day().At(srv.config.App.LeaderboardGenerationTime).Do(runAllUsers, models.IntervalPast7Days, []uint8{models.SummaryLanguage})
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) Run(users []*models.User, interval *models.IntervalKey, by []uint8) error {
|
||||
logbuch.Info("generating leaderboard (%s) for %d users (%d aggregations)", (*interval)[0], len(users), len(by))
|
||||
|
||||
for _, user := range users {
|
||||
if err := srv.repository.DeleteByUserAndInterval(user.ID, interval); err != nil {
|
||||
config.Log().Error("failed to delete leaderboard items for user %s (interval %s) - %v", user.ID, (*interval)[0], err)
|
||||
continue
|
||||
}
|
||||
|
||||
item, err := srv.GenerateByUser(user, interval)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to generate general leaderboard for user %s - %v", user.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := srv.repository.InsertBatch([]*models.LeaderboardItem{item}); err != nil {
|
||||
config.Log().Error("failed to persist general leaderboard for user %s - %v", user.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
for _, by := range by {
|
||||
items, err := srv.GenerateAggregatedByUser(user, interval, by)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to generate aggregated (by %s) leaderboard for user %s - %v", models.GetEntityColumn(by), user.ID, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := srv.repository.InsertBatch(items); err != nil {
|
||||
config.Log().Error("failed to persist aggregated (by %s) leaderboard for user %s - %v", models.GetEntityColumn(by), user.ID, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srv.cache.Flush()
|
||||
logbuch.Info("finished leaderboard generation")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) ExistsAnyByUser(userId string) (bool, error) {
|
||||
count, err := srv.repository.CountAllByUser(userId)
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GetByInterval(interval *models.IntervalKey, resolveUsers bool) (models.Leaderboard, error) {
|
||||
return srv.GetAggregatedByInterval(interval, nil, resolveUsers)
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GetAggregatedByInterval(interval *models.IntervalKey, by *uint8, resolveUsers bool) (models.Leaderboard, error) {
|
||||
// check cache
|
||||
cacheKey := srv.getHash(interval, by)
|
||||
if cacheResult, ok := srv.cache.Get(cacheKey); ok {
|
||||
return cacheResult.([]*models.LeaderboardItem), nil
|
||||
}
|
||||
|
||||
items, err := srv.repository.GetAllAggregatedByInterval(interval, by)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if resolveUsers {
|
||||
a := models.Leaderboard(items).UserIDs()
|
||||
println(a)
|
||||
users, err := srv.userService.GetManyMapped(models.Leaderboard(items).UserIDs())
|
||||
if err != nil {
|
||||
config.Log().Error("failed to resolve users for leaderboard item - %v", err)
|
||||
} else {
|
||||
for _, item := range items {
|
||||
if u, ok := users[item.UserID]; ok {
|
||||
item.User = u
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
srv.cache.SetDefault(cacheKey, items)
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GenerateByUser(user *models.User, interval *models.IntervalKey) (*models.LeaderboardItem, error) {
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary, err := srv.summaryService.Aliased(from, to, user, srv.summaryService.Retrieve, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &models.LeaderboardItem{
|
||||
User: user,
|
||||
UserID: user.ID,
|
||||
Interval: (*interval)[0],
|
||||
Total: summary.TotalTime(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) GenerateAggregatedByUser(user *models.User, interval *models.IntervalKey, by uint8) ([]*models.LeaderboardItem, error) {
|
||||
err, from, to := utils.ResolveIntervalTZ(interval, user.TZ())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summary, err := srv.summaryService.Aliased(from, to, user, srv.summaryService.Retrieve, nil, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
summaryItems := *summary.ItemsByType(by)
|
||||
items := make([]*models.LeaderboardItem, summaryItems.Len())
|
||||
|
||||
for i := 0; i < summaryItems.Len(); i++ {
|
||||
key := summaryItems[i].Key
|
||||
items[i] = &models.LeaderboardItem{
|
||||
User: user,
|
||||
UserID: user.ID,
|
||||
Interval: (*interval)[0],
|
||||
By: &by,
|
||||
Total: summary.TotalTimeByKey(by, key),
|
||||
Key: &key,
|
||||
}
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (srv *LeaderboardService) getHash(interval *models.IntervalKey, by *uint8) string {
|
||||
k := strings.Join(*interval, "__")
|
||||
if by != nil && !reflect.ValueOf(by).IsNil() {
|
||||
k += "__" + models.GetEntityColumn(*by)
|
||||
}
|
||||
return k
|
||||
}
|
@ -49,6 +49,11 @@ func (s *SMTPSendingService) Send(mail *models.Mail) error {
|
||||
if ok, _ := c.Extension("AUTH"); !ok {
|
||||
return errors.New("smtp: server doesn't support AUTH")
|
||||
}
|
||||
|
||||
if len(s.config.Username) == 0 || len(s.config.Password) == 0 {
|
||||
return errors.New("smtp: server requires authentication, but no authentication is provided")
|
||||
}
|
||||
|
||||
if err = c.Auth(s.auth); err != nil {
|
||||
return err
|
||||
}
|
||||
|
@ -38,13 +38,8 @@ type CountTotalTimeResult struct {
|
||||
}
|
||||
|
||||
func (srv *MiscService) ScheduleCountTotalTime() {
|
||||
// Run once initially
|
||||
if err := srv.runCountTotalTime(); err != nil {
|
||||
logbuch.Fatal("failed to run CountTotalTimeJob: %v", err)
|
||||
}
|
||||
|
||||
s := gocron.NewScheduler(time.Local)
|
||||
s.Every(1).Hour().Do(srv.runCountTotalTime)
|
||||
s.Every(1).Hour().WaitForSchedule().Do(srv.runCountTotalTime)
|
||||
s.StartBlocking()
|
||||
}
|
||||
|
||||
|
@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"github.com/duke-git/lancet/v2/slice"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
"github.com/muety/wakapi/models"
|
||||
@ -45,38 +46,26 @@ func (srv *ProjectLabelService) GetByUser(userId string) ([]*models.ProjectLabel
|
||||
|
||||
// GetByUserGrouped returns lists of project labels, grouped by their project key
|
||||
func (srv *ProjectLabelService) GetByUserGrouped(userId string) (map[string][]*models.ProjectLabel, error) {
|
||||
labelsByProject := make(map[string][]*models.ProjectLabel)
|
||||
userLabels, err := srv.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, l := range userLabels {
|
||||
if _, ok := labelsByProject[l.ProjectKey]; !ok {
|
||||
labelsByProject[l.ProjectKey] = []*models.ProjectLabel{l}
|
||||
} else {
|
||||
labelsByProject[l.ProjectKey] = append(labelsByProject[l.ProjectKey], l)
|
||||
}
|
||||
}
|
||||
return labelsByProject, nil
|
||||
mappedLabels := slice.GroupWith[*models.ProjectLabel, string](userLabels, func(l *models.ProjectLabel) string {
|
||||
return l.ProjectKey
|
||||
})
|
||||
return mappedLabels, nil
|
||||
}
|
||||
|
||||
// GetByUserGroupedInverted returns lists of project labels, grouped by their label key
|
||||
func (srv *ProjectLabelService) GetByUserGroupedInverted(userId string) (map[string][]*models.ProjectLabel, error) {
|
||||
projectsByLabel := make(map[string][]*models.ProjectLabel)
|
||||
userLabels, err := srv.GetByUser(userId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, l := range userLabels {
|
||||
if _, ok := projectsByLabel[l.Label]; !ok {
|
||||
projectsByLabel[l.Label] = []*models.ProjectLabel{l}
|
||||
} else {
|
||||
projectsByLabel[l.Label] = append(projectsByLabel[l.Label], l)
|
||||
}
|
||||
}
|
||||
return projectsByLabel, nil
|
||||
mappedLabels := slice.GroupWith[*models.ProjectLabel, string](userLabels, func(l *models.ProjectLabel) string {
|
||||
return l.Label
|
||||
})
|
||||
return mappedLabels, nil
|
||||
}
|
||||
|
||||
func (srv *ProjectLabelService) Create(label *models.ProjectLabel) (*models.ProjectLabel, error) {
|
||||
|
@ -89,7 +89,7 @@ func (srv *ReportService) SyncSchedule(u *models.User) bool {
|
||||
At(t).
|
||||
Tag(u.ID).
|
||||
Do(srv.Run, u, 7*24*time.Hour); err != nil {
|
||||
config.Log().Error("failed to schedule report job for user '%s' – %v", u.ID, err)
|
||||
config.Log().Error("failed to schedule report job for user '%s' - %v", u.ID, err)
|
||||
} else {
|
||||
logbuch.Info("next report for user %s is scheduled for %v", u.ID, job.NextRun())
|
||||
}
|
||||
@ -114,7 +114,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
||||
|
||||
summary, err := srv.summaryService.Aliased(start, end, user, srv.summaryService.Retrieve, nil, false)
|
||||
if err != nil {
|
||||
config.Log().Error("failed to generate report for '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to generate report for '%s' - %v", user.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
@ -126,7 +126,7 @@ func (srv *ReportService) Run(user *models.User, duration time.Duration) error {
|
||||
}
|
||||
|
||||
if err := srv.mailService.SendReport(user, report); err != nil {
|
||||
config.Log().Error("failed to send report for '%s' – %v", user.ID, err)
|
||||
config.Log().Error("failed to send report for '%s' - %v", user.ID, err)
|
||||
return err
|
||||
}
|
||||
|
||||
|
@ -1,13 +1,14 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
datastructure "github.com/duke-git/lancet/v2/datastructure/set"
|
||||
"github.com/muety/wakapi/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
type IAggregationService interface {
|
||||
Schedule()
|
||||
Run(map[string]bool) error
|
||||
Run(set datastructure.Set[string]) error
|
||||
}
|
||||
|
||||
type IMiscService interface {
|
||||
@ -29,15 +30,17 @@ type IAliasService interface {
|
||||
type IHeartbeatService interface {
|
||||
Insert(*models.Heartbeat) error
|
||||
InsertBatch([]*models.Heartbeat) error
|
||||
Count() (int64, error)
|
||||
Count(bool) (int64, error)
|
||||
CountByUser(*models.User) (int64, error)
|
||||
CountByUsers([]*models.User) ([]*models.CountByUser, error)
|
||||
GetAllWithin(time.Time, time.Time, *models.User) ([]*models.Heartbeat, error)
|
||||
GetAllWithinByFilters(time.Time, time.Time, *models.User, *models.Filters) ([]*models.Heartbeat, error)
|
||||
GetFirstByUsers() ([]*models.TimeByUser, error)
|
||||
GetLatestByUser(*models.User) (*models.Heartbeat, error)
|
||||
GetLatestByOriginAndUser(string, *models.User) (*models.Heartbeat, error)
|
||||
GetEntitySetByUser(uint8, *models.User) ([]string, error)
|
||||
DeleteBefore(time.Time) error
|
||||
DeleteByUser(*models.User) error
|
||||
}
|
||||
|
||||
type IDiagnosticsService interface {
|
||||
@ -94,13 +97,26 @@ type IReportService interface {
|
||||
Run(*models.User, time.Duration) error
|
||||
}
|
||||
|
||||
type ILeaderboardService interface {
|
||||
ScheduleDefault()
|
||||
Run([]*models.User, *models.IntervalKey, []uint8) error
|
||||
ExistsAnyByUser(string) (bool, error)
|
||||
GetByInterval(*models.IntervalKey, bool) (models.Leaderboard, error)
|
||||
GetAggregatedByInterval(*models.IntervalKey, *uint8, bool) (models.Leaderboard, error)
|
||||
GenerateByUser(*models.User, *models.IntervalKey) (*models.LeaderboardItem, error)
|
||||
GenerateAggregatedByUser(*models.User, *models.IntervalKey, uint8) ([]*models.LeaderboardItem, error)
|
||||
}
|
||||
|
||||
type IUserService interface {
|
||||
GetUserById(string) (*models.User, error)
|
||||
GetUserByKey(string) (*models.User, error)
|
||||
GetUserByEmail(string) (*models.User, error)
|
||||
GetUserByResetToken(string) (*models.User, error)
|
||||
GetAll() ([]*models.User, error)
|
||||
GetMany([]string) ([]*models.User, error)
|
||||
GetManyMapped([]string) (map[string]*models.User, error)
|
||||
GetAllByReports(bool) ([]*models.User, error)
|
||||
GetAllByLeaderboard(bool) ([]*models.User, error)
|
||||
GetActive(bool) ([]*models.User, error)
|
||||
Count() (int64, error)
|
||||
CreateOrGet(*models.Signup, bool) (*models.User, bool, error)
|
||||
|
@ -2,7 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
@ -40,12 +40,7 @@ func NewSummaryService(summaryRepo repositories.ISummaryRepository, durationServ
|
||||
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)
|
||||
}
|
||||
}
|
||||
srv.invalidateUserCache(m.Fields[config.FieldUserId].(string))
|
||||
}
|
||||
}(&sub1)
|
||||
|
||||
@ -113,9 +108,15 @@ func (srv *SummaryService) Retrieve(from, to time.Time, user *models.User, filte
|
||||
}
|
||||
|
||||
// Generate missing slots (especially before and after existing summaries) from durations (formerly raw heartbeats)
|
||||
missingIntervals := srv.getMissingIntervals(from, to, summaries)
|
||||
missingIntervals := srv.getMissingIntervals(from, to, summaries, false)
|
||||
for _, interval := range missingIntervals {
|
||||
if s, err := srv.Summarize(interval.Start, interval.End, user, filters); err == nil {
|
||||
if len(missingIntervals) > 2 && s.FromTime.T().Equal(s.ToTime.T()) {
|
||||
// little hack here: GetAllWithin will query for >= from_date
|
||||
// however, for "in-between" / intra-day missing intervals, we want strictly > from_date to prevent double-counting
|
||||
// to not have to rewrite many interfaces, we skip these summaries here
|
||||
continue
|
||||
}
|
||||
summaries = append(summaries, s)
|
||||
} else {
|
||||
return nil, err
|
||||
@ -368,7 +369,7 @@ func (srv *SummaryService) mergeSummaryItems(existing []*models.SummaryItem, new
|
||||
return itemList
|
||||
}
|
||||
|
||||
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary) []*models.Interval {
|
||||
func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*models.Summary, precise bool) []*models.Interval {
|
||||
if len(summaries) == 0 {
|
||||
return []*models.Interval{{from, to}}
|
||||
}
|
||||
@ -377,37 +378,43 @@ func (srv *SummaryService) getMissingIntervals(from, to time.Time, summaries []*
|
||||
|
||||
// Pre
|
||||
if from.Before(summaries[0].FromTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{from, summaries[0].FromTime.T()})
|
||||
intervals = append(intervals, &models.Interval{Start: from, End: summaries[0].FromTime.T()})
|
||||
}
|
||||
|
||||
// Between
|
||||
for i := 0; i < len(summaries)-1; i++ {
|
||||
t1, t2 := summaries[i].ToTime.T(), summaries[i+1].FromTime.T()
|
||||
if t1.Equal(t2) {
|
||||
if t1.Equal(t2) || t1.Equal(to) || t1.After(to) {
|
||||
continue
|
||||
}
|
||||
|
||||
td1 := t1
|
||||
td2 := t2
|
||||
|
||||
// round to end of day / start of day, assuming that summaries are always generated on a per-day basis
|
||||
// we assume that, if summary for any time range within a day is present, no further heartbeats exist on that day before 'from' and after 'to' time of that summary
|
||||
// this requires that a summary exists for every single day in a year and none is skipped, which shouldn't ever happen
|
||||
td1 := time.Date(t1.Year(), t1.Month(), t1.Day()+1, 0, 0, 0, 0, t1.Location())
|
||||
td2 := time.Date(t2.Year(), t2.Month(), t2.Day(), 0, 0, 0, 0, t2.Location())
|
||||
// non-precise mode is mainly for speed when fetching summaries over large intervals and trades speed for summary accuracy / comprehensiveness
|
||||
if !precise {
|
||||
td1 = datetime.BeginOfDay(t1).AddDate(0, 0, 1)
|
||||
td2 = datetime.BeginOfDay(t2)
|
||||
|
||||
// we always want to jump to beginning of next day
|
||||
// however, if left summary ends already at midnight, we would instead jump to beginning of second-next day -> go back again
|
||||
if td1.Sub(t1) == 24*time.Hour {
|
||||
if td1.AddDate(0, 0, 1).Equal(t1) {
|
||||
td1 = td1.Add(-1 * time.Hour)
|
||||
}
|
||||
}
|
||||
|
||||
// one or more day missing in between?
|
||||
if td1.Before(td2) {
|
||||
intervals = append(intervals, &models.Interval{summaries[i].ToTime.T(), summaries[i+1].FromTime.T()})
|
||||
intervals = append(intervals, &models.Interval{Start: t1, End: t2})
|
||||
}
|
||||
}
|
||||
|
||||
// Post
|
||||
if to.After(summaries[len(summaries)-1].ToTime.T()) {
|
||||
intervals = append(intervals, &models.Interval{summaries[len(summaries)-1].ToTime.T(), to})
|
||||
intervals = append(intervals, &models.Interval{Start: summaries[len(summaries)-1].ToTime.T(), End: to})
|
||||
}
|
||||
|
||||
return intervals
|
||||
|
@ -318,7 +318,7 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve() {
|
||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject1))
|
||||
assert.Equal(suite.T(), 45*time.Minute, result.TotalTimeByKey(models.SummaryProject, TestProject2))
|
||||
assert.Equal(suite.T(), 200, result.NumHeartbeats)
|
||||
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1+1)
|
||||
suite.DurationService.AssertNumberOfCalls(suite.T(), "Get", 2+1)
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_Retrieve_DuplicateSummaries() {
|
||||
@ -485,6 +485,45 @@ func (suite *SummaryServiceTestSuite) TestSummaryService_Filters() {
|
||||
assert.Contains(suite.T(), effectiveFilters.Label, TestProjectLabel3)
|
||||
}
|
||||
|
||||
func (suite *SummaryServiceTestSuite) TestSummaryService_getMissingIntervals() {
|
||||
sut := NewSummaryService(suite.SummaryRepository, suite.DurationService, suite.AliasService, suite.ProjectLabelService)
|
||||
|
||||
from1, _ := time.Parse(time.RFC822, "25 Mar 22 11:00 UTC")
|
||||
to1, _ := time.Parse(time.RFC822, "25 Mar 22 13:00 UTC")
|
||||
from2, _ := time.Parse(time.RFC822, "25 Mar 22 15:00 UTC")
|
||||
to2, _ := time.Parse(time.RFC822, "26 Mar 22 00:00 UTC")
|
||||
|
||||
summaries := []*models.Summary{
|
||||
{FromTime: models.CustomTime(from1), ToTime: models.CustomTime(to1)},
|
||||
{FromTime: models.CustomTime(from2), ToTime: models.CustomTime(to2)},
|
||||
}
|
||||
|
||||
r1 := sut.getMissingIntervals(from1, to1, summaries, true)
|
||||
assert.Empty(suite.T(), r1)
|
||||
|
||||
r2 := sut.getMissingIntervals(from1, from1, summaries, true)
|
||||
assert.Empty(suite.T(), r2)
|
||||
|
||||
// non-precise mode will not return intra-day intervals
|
||||
// we might want to change this ...
|
||||
r3 := sut.getMissingIntervals(from1, to2, summaries, false)
|
||||
assert.Len(suite.T(), r3, 0)
|
||||
|
||||
r4 := sut.getMissingIntervals(from1, to2, summaries, true)
|
||||
assert.Len(suite.T(), r4, 1)
|
||||
assert.Equal(suite.T(), to1, r4[0].Start)
|
||||
assert.Equal(suite.T(), from2, r4[0].End)
|
||||
|
||||
r5 := sut.getMissingIntervals(from1.Add(-time.Hour), to2.Add(time.Hour), summaries, true)
|
||||
assert.Len(suite.T(), r5, 3)
|
||||
assert.Equal(suite.T(), from1.Add(-time.Hour), r5[0].Start)
|
||||
assert.Equal(suite.T(), from1, r5[0].End)
|
||||
assert.Equal(suite.T(), to1, r5[1].Start)
|
||||
assert.Equal(suite.T(), from2, r5[1].End)
|
||||
assert.Equal(suite.T(), to2, r5[2].Start)
|
||||
assert.Equal(suite.T(), to2.Add(time.Hour), r5[2].End)
|
||||
}
|
||||
|
||||
func filterDurations(from, to time.Time, durations models.Durations) models.Durations {
|
||||
filtered := make([]*models.Duration, 0, len(durations))
|
||||
for _, d := range durations {
|
||||
|
@ -2,6 +2,8 @@ package services
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/duke-git/lancet/v2/convertor"
|
||||
"github.com/duke-git/lancet/v2/datetime"
|
||||
"github.com/emvi/logbuch"
|
||||
"github.com/leandro-lugaresi/hub"
|
||||
"github.com/muety/wakapi/config"
|
||||
@ -95,14 +97,32 @@ func (srv *UserService) GetAll() ([]*models.User, error) {
|
||||
return srv.repository.GetAll()
|
||||
}
|
||||
|
||||
func (srv *UserService) GetMany(ids []string) ([]*models.User, error) {
|
||||
return srv.repository.GetMany(ids)
|
||||
}
|
||||
|
||||
func (srv *UserService) GetManyMapped(ids []string) (map[string]*models.User, error) {
|
||||
users, err := srv.repository.GetMany(ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return convertor.ToMap[*models.User, string, *models.User](users, func(u *models.User) (string, *models.User) {
|
||||
return u.ID, u
|
||||
}), nil
|
||||
}
|
||||
|
||||
func (srv *UserService) GetAllByReports(reportsEnabled bool) ([]*models.User, error) {
|
||||
return srv.repository.GetAllByReports(reportsEnabled)
|
||||
}
|
||||
|
||||
func (srv *UserService) GetAllByLeaderboard(leaderboardEnabled bool) ([]*models.User, error) {
|
||||
return srv.repository.GetAllByLeaderboard(leaderboardEnabled)
|
||||
}
|
||||
|
||||
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().AddDate(0, 0, -1*srv.config.App.InactiveDays)
|
||||
if !exact {
|
||||
minDate = utils.FloorDateHour(minDate)
|
||||
minDate = datetime.BeginOfHour(minDate)
|
||||
}
|
||||
|
||||
cacheKey := fmt.Sprintf("%s--active", minDate.String())
|
||||
|
@ -69,6 +69,10 @@ body {
|
||||
@apply py-2 px-4 font-semibold rounded bg-red-600 hover:bg-red-700 text-white text-sm;
|
||||
}
|
||||
|
||||
.btn-small {
|
||||
@apply py-1 px-2;
|
||||
}
|
||||
|
||||
.input-default {
|
||||
@apply appearance-none bg-gray-850 focus:bg-gray-800 text-gray-300 outline-none rounded w-full py-2 px-4;
|
||||
}
|
||||
@ -93,7 +97,53 @@ body {
|
||||
@apply font-semibold text-gray-400 hover:text-gray-300;
|
||||
}
|
||||
|
||||
.newsbox {
|
||||
@apply px-4 py-2 border-2 border-red-700 bg-gray-850 rounded-md text-white border-green-700;
|
||||
}
|
||||
|
||||
.newsbox-default {
|
||||
@apply border-green-700;
|
||||
}
|
||||
|
||||
.newsbox-warning {
|
||||
@apply border-yellow-600;
|
||||
}
|
||||
|
||||
.newsbox-danger {
|
||||
@apply border-red-700;
|
||||
}
|
||||
|
||||
.leaderboard-default {
|
||||
@apply border-gray-700;
|
||||
}
|
||||
|
||||
.leaderboard-self {
|
||||
margin-left: -10px;
|
||||
margin-right: -10px;
|
||||
padding-left: calc(1rem + 10px);
|
||||
padding-right: calc(1rem + 10px);
|
||||
@apply border-green-700 bg-gray-800;
|
||||
}
|
||||
|
||||
.leaderboard-gold {
|
||||
border-color: #ffd700;
|
||||
}
|
||||
|
||||
.leaderboard-silver {
|
||||
border-color: #c0c0c0;
|
||||
}
|
||||
|
||||
.leaderboard-bronze {
|
||||
border-color: #cd7f32;
|
||||
}
|
||||
|
||||
::-webkit-calendar-picker-indicator {
|
||||
filter: invert(1);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.max-available {
|
||||
max-width: -moz-available;
|
||||
max-width: -webkit-fill-available;
|
||||
max-width: fill-available;
|
||||
}
|
File diff suppressed because one or more lines are too long
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user