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

Compare commits

...

36 Commits

Author SHA1 Message Date
cec2a84e2d Merge pull request #402 from muety/release-arch
Add more architectures to release binaries (resolve #401)
2022-09-06 22:16:35 +02:00
ffb0b84d78 build: re-enable release steps 2022-09-06 20:51:05 +10:00
8a7333b899 build: test release architectures 2022-09-06 20:23:47 +10:00
dd3b9c9b9c Merge pull request #398 from marvinscham/patch-1
Add last_6_months interval identifier
2022-08-21 10:24:03 +02:00
d2b62e21c5 Fix indentation 2022-08-20 07:07:44 +02:00
9505773165 Add last_6_months interval identifier 2022-08-19 17:14:00 +02:00
4bfc8a9e9f Merge pull request #397 from muety/docker-simplify
Simplify Dockerfile
2022-08-19 13:59:33 +02:00
df5fe6e623 fix: initialise data/ dir in Docker 2022-08-19 21:55:58 +10:00
037ad7b9b1 fix: check database open 2022-08-19 21:48:03 +10:00
ec10cc922c perf: strip binaries 2022-08-19 19:53:02 +10:00
acb76e1ab1 ref: simplify Dockerfile stages 2022-08-19 19:37:05 +10:00
252a304ba8 Merge pull request #396 from HellsCrimson/master
add last_year and all_time interval identifier like in wakatime docs
2022-08-17 18:50:07 +10:00
c863cf6dc5 add last_year and all_time interval identifier like in wakatime docs 2022-08-16 23:28:48 +02:00
373d969734 feat: introduce newsbox for front page (resolve #393) 2022-08-13 10:28:36 +02:00
99a3e8f5da Merge pull request #394 from muety/nil-fix
fix: 500 on POST /api/heartbeats
2022-08-12 14:37:33 +02:00
4302cfcbd6 fix: 500 on POST /api/heartbeats 2022-08-12 17:25:43 +10:00
7cca0055fe Merge pull request #388 from muety/automated-version
feat: automatic version.txt updates
2022-07-14 14:31:06 +02:00
20993a1182 feat: automatic version.txt updates
Resolves #387
2022-07-09 13:58:16 +10:00
d5a85639b1 fix: broken summary aggregation (resolve #385) 2022-07-03 20:39:16 +02:00
b6a8185957 Merge branch 'Enthys_master' 2022-07-02 00:30:15 +02:00
c5da5e4622 fix: bug in same day comparison 2022-07-02 00:28:56 +02:00
a0f69a371f fix: api tests time zone bug (resolve #355) 2022-07-01 23:31:51 +02:00
2f0cb112dd test: user api retrieving user information 2022-06-30 10:36:13 +03:00
2173954b84 docs: instructions for go install [ci skip] 2022-06-29 23:39:35 +02:00
991e64b961 fix: returning current user instead of requested one 2022-06-29 10:46:46 +03:00
affff0c386 fix: admin users can't fetch other user data 2022-06-28 13:01:35 +03:00
099cdaddbc chore: add example systemd service unit file [ci skip] 2022-06-22 00:18:50 +02:00
409405117e Merge pull request #377 from NChechulin/master
elaborate on cloud server API URL [ci skip]
2022-05-29 15:06:42 +02:00
af89ecc9c1 update client configuration URLs for the new version 2022-05-29 15:43:59 +03:00
be354fa790 elaborate on cloud server API URL
Personally, for me, it was slightly unclear which URL has to be pasted.
2022-05-28 11:05:12 +03:00
a1c4c5da6b Merge pull request #376 from Daste745/avatar_url_template_env
Add an env configuration option for AvatarURLTemplate
2022-05-22 23:06:25 +02:00
33509beaf7 Enable env configuration for AvatarURLTemplate
Added an `env:"WAKAPI_AVATAR_URL_TEMPLATE"` option for the Avatar URL Template configuration setting.

I wanted to configure this on my instance, but the only way now is through the yaml config file.
2022-05-22 02:04:25 +02:00
ab6ccbdfbe README: Mention the WAKAPI_AVATAR_URL_TEMPLATE configuration variable 2022-05-22 02:03:04 +02:00
77e6cd9faa Merge pull request #375 from daief/master
Github Actions add a release for macOS
2022-05-20 09:40:26 +02:00
34bc38cecf chore: add descriptive names for workflows 2022-05-20 09:34:54 +02:00
69d3e0494b chore: add a new endline 2022-05-20 12:17:35 +08:00
37 changed files with 1083 additions and 767 deletions

View File

@ -7,6 +7,7 @@ on:
jobs:
test:
name: 'Unit- & API tests'
runs-on: ubuntu-latest
steps:
@ -31,7 +32,7 @@ jobs:
./testing/run_api_tests.sh
mapi:
name: 'Mayhem for API'
name: 'Automated pen-tests with Mayhem for API'
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.x
@ -74,6 +75,8 @@ jobs:
sarif_file: mapi.sarif
build:
name: 'Build (Win, Linux, Mac)'
strategy:
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
@ -94,4 +97,4 @@ jobs:
run: go get
- name: Build
run: go build -v .
run: go build -v .

View File

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

View File

@ -7,20 +7,14 @@ on:
jobs:
release:
name: Release
name: 'Build, package and release to GitHub'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
platform: [ubuntu-latest, macos-latest, windows-latest]
include:
- platform: ubuntu-latest
alias: linux
- platform: macos-latest
alias: mac
- platform: windows-latest
alias: win
GOOS: [darwin, windows, linux]
GOARCH: [amd64, arm64]
runs-on: ${{ matrix.platform }}
steps:
- name: Set up Go 1.x
@ -32,24 +26,29 @@ jobs:
- name: Check out code into the Go module directory
uses: actions/checkout@v2
- name: Get dependencies
run: go get
- 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
run: go build -v .
working-directory: ./dist
run: |
GOOS=${{ matrix.GOOS }} GOARCH=${{ matrix.GOARCH }} go build -v ../
- name: Compress working folder on Windows
if: runner.os == 'Windows'
- name: Compress working folder
working-directory: ./dist
run: |
cp .\config.default.yml .\config.yml
Compress-Archive -Path .\wakapi.exe, .\config.yml -DestinationPath wakapi_${{ matrix.alias }}_amd64.zip
- name: Compress working folder on Unix
if: runner.os != 'Windows'
run: |
cp config.default.yml config.yml
zip -9 wakapi_${{ matrix.alias }}_amd64.zip wakapi config.yml
zip -9 wakapi_${{ matrix.GOOS }}_${{ matrix.GOARCH }}.zip *
- name: Upload built executable to Release
uses: softprops/action-gh-release@v1
with:
files: wakapi_${{ matrix.alias }}_amd64.zip
files: ./dist/*.zip

View File

@ -1,33 +1,25 @@
# To build locally: docker buildx build . -t wakapi --load
# Preparation to save some time
FROM --platform=$BUILDPLATFORM golang:1.18-alpine AS prep-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.18-alpine AS build-env
WORKDIR /src
# Required for go-sqlite3
RUN apk add --no-cache gcc musl-dev
WORKDIR /src
COPY --from=prep-env /src .
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
RUN go build -v -o wakapi
ADD ./go.mod ./go.sum ./
RUN go mod download
ADD . .
WORKDIR /app
RUN cp /src/wakapi . && \
cp /src/config.default.yml config.yml && \
sed -i 's/listen_ipv6: ::1/listen_ipv6: /g' config.yml && \
cp /src/wait-for-it.sh . && \
cp /src/entrypoint.sh .
RUN go build -ldflags "-s -w" -v -o wakapi main.go
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,18 +33,18 @@ 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 /
EXPOSE 3000

View File

@ -100,19 +100,22 @@ If you want to run Wakapi on **Kubernetes**, there is [wakapi-helm-chart](https:
#### 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 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
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.
@ -123,8 +126,8 @@ Wakapi relies on the open-source [WakaTime](https://github.com/wakatime/wakatime
```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
@ -145,7 +148,7 @@ You can specify configuration options either via a config file (default: `config
| `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) |

View File

@ -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"
@ -70,7 +72,7 @@ type appConfig struct {
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:"api/avatar/{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:"-"`
}
@ -363,7 +365,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)

File diff suppressed because it is too large Load Diff

53
etc/wakapi.service Normal file
View 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

26
main.go
View File

@ -2,7 +2,6 @@ package main
import (
"embed"
"github.com/muety/wakapi/migrations"
"io/fs"
"log"
"net"
@ -11,28 +10,29 @@ import (
"strconv"
"time"
"github.com/lpar/gzipped/v2"
"github.com/muety/wakapi/routes/relay"
"github.com/emvi/logbuch"
"github.com/gorilla/handlers"
conf "github.com/muety/wakapi/config"
"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/lpar/gzipped/v2"
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"
"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
@ -121,6 +121,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;")
}

View File

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

View File

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

View File

@ -109,7 +109,7 @@ func (r *SummaryRepository) populateItems(summaries []*models.Summary, condition
}
for _, item := range items {
if _, ok := summaryMap[item.SummaryID]; ok {
if _, ok := summaryMap[item.SummaryID]; !ok {
continue
}
l := summaryMap[item.SummaryID][0].ItemsByType(item.Type)

View File

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

View File

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

View File

@ -43,7 +43,7 @@ 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]

View File

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

View File

@ -50,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"

View 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))
}
})
})
}

View File

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

View File

@ -2,15 +2,15 @@ package routes
import (
"fmt"
"github.com/duke-git/lancet/v2/datetime"
"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)

View File

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

View File

@ -1,6 +1,7 @@
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"
@ -60,7 +61,7 @@ func (srv *DurationService) Get(from, to time.Time, user *models.User, filters *
continue
}
sameDay := d1.Time.T().Day() == latest.Time.T().Day()
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),

View File

@ -93,6 +93,22 @@ 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;
}
::-webkit-calendar-picker-indicator {
filter: invert(1);
cursor: pointer;

File diff suppressed because one or more lines are too long

Binary file not shown.

View File

@ -54,9 +54,13 @@ const docTemplate = `{
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Interval to aggregate data for",
@ -354,9 +358,13 @@ const docTemplate = `{
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Range interval identifier",
@ -445,9 +453,13 @@ const docTemplate = `{
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Range interval identifier",
@ -809,9 +821,13 @@ const docTemplate = `{
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Interval identifier",

View File

@ -46,9 +46,13 @@
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Interval to aggregate data for",
@ -346,9 +350,13 @@
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Range interval identifier",
@ -437,9 +445,13 @@
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Range interval identifier",
@ -801,9 +813,13 @@
"last_7_days",
"30_days",
"last_30_days",
"6_months",
"last_6_months",
"12_months",
"last_12_months",
"any"
"last_year",
"any",
"all_time"
],
"type": "string",
"description": "Interval identifier",

View File

@ -430,9 +430,13 @@ paths:
- last_7_days
- 30_days
- last_30_days
- 6_months
- last_6_months
- 12_months
- last_12_months
- last_year
- any
- all_time
in: path
name: interval
required: true
@ -623,9 +627,13 @@ paths:
- last_7_days
- 30_days
- last_30_days
- 6_months
- last_6_months
- 12_months
- last_12_months
- last_year
- any
- all_time
in: path
name: range
type: string
@ -686,9 +694,13 @@ paths:
- last_7_days
- 30_days
- last_30_days
- 6_months
- last_6_months
- 12_months
- last_12_months
- last_year
- any
- all_time
in: query
name: range
type: string
@ -925,9 +937,13 @@ paths:
- last_7_days
- 30_days
- last_30_days
- 6_months
- last_6_months
- 12_months
- last_12_months
- last_year
- any
- all_time
in: query
name: interval
type: string

View File

@ -2,6 +2,11 @@ module.exports = {
purge: {
enabled: true,
mode: 'all',
content: ['./views/*.tpl.html']
}
content: ['./views/*.tpl.html'],
safelist: [
'newsbox-default',
'newsbox-warning',
'newsbox-danger',
]
},
}

View File

@ -12,6 +12,7 @@ server:
app:
aggregation_time: '02:15'
report_time_weekly: 'fri,18:00'
heartbeat_max_age: 87600h # 10 years
inactive_days: 7
custom_languages:
vue: Vue

View File

@ -1,6 +1,6 @@
{
"info": {
"_postman_id": "43639725-0458-40d7-a4d4-9f55a539a7f7",
"_postman_id": "5c0749a5-6ddf-41ea-82f1-140578788bc3",
"name": "Wakapi API Tests",
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
},
@ -973,10 +973,9 @@
"listen": "test",
"script": {
"exec": [
"// 1640995199 Friday, 31 December 2021 11:59:59 PM (Jan 1st in +1, +2)",
"// 1641074399 Saturday, 1 January 2022 9:59:59 PM (Jan 1st in +1, +2)",
"// 1641081599 Saturday, 1 January 2022 11:59:59 PM (Jan 2nd in +1, +2)",
""
"pm.test(\"Status code is 201\", function () {",
" pm.response.to.have.status(201);",
"});"
],
"type": "text/javascript"
}
@ -997,7 +996,7 @@
"header": [],
"body": {
"mode": "raw",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995199\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074399\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081599\n}]",
"raw": "[{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1640995200\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641074400\n},\n{\n \"entity\": \"/home/user1/dev/project1/main.go\",\n \"project\": \"wakapi\",\n \"language\": \"Go\",\n \"is_write\": true,\n \"type\": \"file\",\n \"category\": null,\n \"branch\": null,\n \"time\": 1641081600\n}]",
"options": {
"raw": {
"language": "json"
@ -1331,8 +1330,8 @@
"",
"pm.test(\"Correct dates\", function () {",
" const jsonData = pm.response.json();",
" pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayDate')).unix())",
" pm.expect(moment(jsonData.to).unix()).to.gte(moment(pm.variables.get('tsEndOfDayDate')).unix())",
" pm.expect(moment(jsonData.from).unix()).to.gte(moment(pm.variables.get('tsStartOfDayIso')).unix())",
" pm.expect(moment(jsonData.to).unix()).to.lte(moment(pm.variables.get('tsEndOfDayIso')).unix())",
"});",
""
],
@ -1358,7 +1357,7 @@
"method": "GET",
"header": [],
"url": {
"raw": "{{BASE_URL}}/api/summary?from={{tsStartOfDayDate}}&to={{tsEndOfTomorrowDate}}",
"raw": "{{BASE_URL}}/api/summary?from={{tsStartOfDayIso}}&to={{tsEndOfDayIso}}",
"host": [
"{{BASE_URL}}"
],
@ -1369,11 +1368,11 @@
"query": [
{
"key": "from",
"value": "{{tsStartOfDayDate}}"
"value": "{{tsStartOfDayIso}}"
},
{
"key": "to",
"value": "{{tsEndOfTomorrowDate}}"
"value": "{{tsEndOfDayIso}}"
}
]
}
@ -3371,46 +3370,60 @@
"exec": [
"const moment = require('moment')",
"",
"const now = moment()",
"const startOfDay = moment().startOf('day')",
"const endOfDay = moment().endOf('day')",
"const endOfTomorrow = moment().add(1, 'd').endOf('day')",
"// pretend we're in Berlin, as this is also the time zone configured for the user",
"const userZone = 'Europe/Berlin'",
"",
"console.log(`Current timestamp is: ${now.format('x') / 1000}`)",
"",
"",
"// Auth stuff",
"const readApiKey = pm.variables.get('READUSER_API_KEY')",
"const writeApiKey = pm.variables.get('WRITEUSER_API_KEY')",
"",
"if (!readApiKey || !writeApiKey) {",
" throw new Error('no api key given')",
"// postman doesn't have moment-timezone package included",
"// and we can't just use utcOffset(2), because of summer / winter time",
"// inspired by https://stackoverflow.com/a/56853085/3112139",
"function getUtcOffset(cb) {",
" let offset = pm.globals.get('utcOffset')",
" if (offset) return cb(offset)",
" pm.sendRequest(`https://worldtimeapi.org/api/timezone/${userZone}`, (err, res) => {",
" offset = res.json().utc_offset",
" pm.globals.set('utcOffset', offset)",
" return cb(offset)",
" })",
"}",
"",
"pm.variables.set('READUSER_TOKEN', base64encode(readApiKey))",
"pm.variables.set('WRITEUSER_TOKEN', base64encode(writeApiKey))",
"getUtcOffset((utcOffset) => {",
" const now = moment().utcOffset(utcOffset)",
" const startOfDay = now.clone().startOf('day')",
" const endOfDay = now.clone().endOf('day')",
" const endOfTomorrow = now.clone().add(1, 'd').endOf('day')",
"",
"function base64encode(str) {",
" return Buffer.from(str, 'utf-8').toString('base64')",
"}",
" // Auth stuff",
" const readApiKey = pm.variables.get('READUSER_API_KEY')",
" const writeApiKey = pm.variables.get('WRITEUSER_API_KEY')",
"",
"// Heartbeat stuff",
"pm.variables.set('tsNow', now.format('x') / 1000)",
"pm.variables.set('tsNowMinus1Min', now.add(-1, 'm').format('x') / 1000)",
"pm.variables.set('tsNowMinus2Min', now.add(-2, 'm').format('x') / 1000)",
"pm.variables.set('tsNowMinus3Min', now.add(-3, 'm').format('x') / 1000)",
"pm.variables.set('tsStartOfDay', startOfDay.format('x') / 1000)",
"pm.variables.set('tsEndOfDay', endOfDay.format('x') / 1000)",
"pm.variables.set('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)",
"pm.variables.set('tsStartOfDayIso', startOfDay.toISOString())",
"pm.variables.set('tsEndOfDayIso', endOfDay.toISOString())",
"pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
"pm.variables.set('tsStartOfDayDate', startOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfDayDate', endOfDay.format('YYYY-MM-DD'))",
"pm.variables.set('tsEndOfTomorrowDate', endOfTomorrow.format('YYYY-MM-DD'))",
"pm.variables.set('ts1', now.startOf('hour').format('x') / 1000)",
"pm.variables.set('ts2', now.startOf('hour').add(1, 'm').format('x') / 1000)",
"pm.variables.set('ts3', now.startOf('hour').add(2, 'm').format('x') / 1000)"
" console.log(readApiKey)",
"",
" if (!readApiKey || !writeApiKey) {",
" throw new Error('no api key given')",
" }",
"",
" pm.variables.set('READUSER_TOKEN', base64encode(readApiKey))",
" pm.variables.set('WRITEUSER_TOKEN', base64encode(writeApiKey))",
"",
" function base64encode(str) {",
" return Buffer.from(str, 'utf-8').toString('base64')",
" }",
"",
" // Heartbeat stuff",
" pm.variables.set('tsNow', now.clone().format('x') / 1000)",
" pm.variables.set('tsNowMinus1Min', now.clone().add(-1, 'm').format('x') / 1000)",
" pm.variables.set('tsNowMinus2Min', now.clone().add(-2, 'm').format('x') / 1000)",
" pm.variables.set('tsNowMinus3Min', now.clone().add(-3, 'm').format('x') / 1000)",
" pm.variables.set('tsStartOfDay', startOfDay.format('x') / 1000)",
" pm.variables.set('tsEndOfDay', endOfDay.format('x') / 1000)",
" pm.variables.set('tsEndOfTomorrow', endOfTomorrow.format('x') / 1000)",
" pm.variables.set('tsStartOfDayIso', startOfDay.toISOString())",
" pm.variables.set('tsEndOfDayIso', endOfDay.toISOString())",
" pm.variables.set('tsEndOfTomorrowIso', endOfTomorrow.toISOString())",
" pm.variables.set('ts1', now.clone().startOf('hour').format('x') / 1000)",
" pm.variables.set('ts2', now.clone().startOf('hour').add(1, 'm').format('x') / 1000)",
" pm.variables.set('ts3', now.clone().startOf('hour').add(2, 'm').format('x') / 1000)",
"})"
]
}
},

View File

@ -60,6 +60,8 @@ func ResolveIntervalTZ(interval *models.IntervalKey, tz *time.Location) (err err
from = now.AddDate(0, 0, -14)
case models.IntervalPast30Days:
from = now.AddDate(0, 0, -30)
case models.IntervalPast6Months:
from = now.AddDate(0, -6, 0)
case models.IntervalPast12Months:
from = now.AddDate(0, -12, 0)
case models.IntervalAny:

View File

@ -1 +1 @@
2.3.5
dev

View File

@ -1,6 +1,6 @@
<footer class="flex justify-between w-full text-center text-gray-500 text-xs mt-20">
<div class="text-xs font-mono font-semibold">
v{{ getVersion }} @ {{ getDbType }}
{{ getVersion }} @ {{ getDbType }}
</div>
<div class="font-semibold text-sm hidden sm:inline-block">
Made with &nbsp; <span class="iconify inline" data-icon="bi:heart-fill"></span> &nbsp; by <a href="https://muetsch.io" class="text-gray-400 hover:text-gray-300">Ferdinand Mütsch</a> as <a

View File

@ -9,7 +9,7 @@
{{ template "alerts.tpl.html" . }}
<div class="absolute flex top-0 right-0 mr-8 mt-10 py-2">
<div class="absolute flex top-0 right-0 mr-4 mt-10 py-2">
<div class="mx-1">
<a href="login" class="btn-primary">
<span class="iconify inline" data-icon="fluent:key-24-filled"></span> &nbsp;Login</a>
@ -18,6 +18,12 @@
<main class="mt-10 px-4 md:px-10 lg:px-24 flex-grow flex justify-center w-full">
<div class="flex flex-col text-white">
{{ if and .Newsbox .Newsbox.Text }}
<div class="mb-14 -mt-4 newsbox newsbox-{{ .Newsbox.Type }}">
{{ .Newsbox.Text | htmlSafe }}
</div>
{{ end }}
<h1 class="text-8xl font-semibold antialiased text-center mb-10 leading-snug">Keep Track of<br><span
class="text-green-700">Your</span> Coding Time</h1>
<p class="text-center text-gray-500 text-xl my-2">Wakapi is an open-source tool that helps you keep track of the

View File

@ -14,6 +14,7 @@
<a id="time-option-year" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('year')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">This Year</a>
<a id="time-option-last_7_days" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('last_7_days')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">Past 7 Days</a>
<a id="time-option-last_30_days" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('last_30_days')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">Past 30 Days</a>
<a id="time-option-last_6_months" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('last_6_months')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">Past 6 Months</a>
<a id="time-option-last_12_months" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('last_12_months')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">Past 12 Months</a>
<a id="time-option-any" class="submenu-item hover:bg-gray-800 rounded p-1 text-right w-full text-gray-300 px-2 font-semibold text-sm" :href="intervalLink('any')" @click="state.showDropdownTimepicker = !state.showDropdownTimepicker" data-trigger-for="showDropdownTimepicker">All Time</a>
<hr class="my-2">