diff --git a/.dockerignore b/.dockerignore index 57c13cb11f7d2a8b7491f9e5217fb1de91121916..9a39d69084cfb3f744205ceb857a445a94f77874 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,22 @@ +# git .git -__pycache__ -*.pyc +.gitignore + +# local env and editor files +.env +.env.* +!.env.example .DS_Store + +# runtime data +/data/ +*.db +*.db-* +backups/ + +# frontend deps and build cache +web/node_modules/ +web/dist/ + +# docs / temp +.tmp-test.db diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..8600c6aa32e5d20e95713bc4924e4b9e2ecbf408 --- /dev/null +++ b/.env.example @@ -0,0 +1,75 @@ +# CPA 服务基础地址。必填:是。默认值:无。 +# Base URL of the CPA service. Required: yes. Default: none. +CPA_BASE_URL=http://127.0.0.1:8317 + +# CPA management key,用于拉取管理接口数据。必填:是。默认值:无。 +# CPA management key used to access management endpoints. Required: yes. Default: none. +CPA_MANAGEMENT_KEY=replace-with-your-management-key + +# 是否启用登录保护。必填:否。默认值:false。 +# Whether to enable login protection. Required: no. Default: false. +AUTH_ENABLED=false + +# 登录密码;仅在启用登录保护时必填。默认值:无。 +# Login password; required only when login protection is enabled. Default: none. +LOGIN_PASSWORD=replace-with-your-login-password + +# 登录 session 的有效时长。必填:否。默认值:168h。 +# Lifetime of the login session. Required: no. Default: 168h. +AUTH_SESSION_TTL=168h + +# HTTP 服务监听端口。必填:否。默认值:8080。 +# HTTP listen port for the application. Required: no. Default: 8080. +APP_PORT=8080 + +# 留空表示部署在根路径 /;如需部署到子路径,必须以 / 开头,例如 /cpa。允许写成 /cpa/,程序会自动规范成 /cpa;不建议写成不带前导斜杠的 cpa。必填:否。默认值:空(根路径 /)。 +# Leave empty to serve from /. For subpath deployment, the value must start with /, for example /cpa. A trailing slash like /cpa/ is accepted and normalized to /cpa; a value without the leading slash such as cpa is invalid. Required: no. Default: empty (root path /). +APP_BASE_PATH= + +# 项目业务时区,影响 Today、按天聚合、每日 03:00 清理和日志时间。必填:否。默认值:Asia/Shanghai。 +# Project business timezone. Affects Today, daily aggregation, daily 03:00 cleanup, and log timestamps. Required: no. Default: Asia/Shanghai. +TZ=Asia/Shanghai + +# CPA management data stream 的 Redis/RESP TCP 地址。留空时默认使用 CPA_BASE_URL 的主机名加 8317 端口;如果通过 nginx stream 暴露到其它端口,请显式填写 host:port。 +# Redis/RESP TCP address for the CPA management data stream. When empty, defaults to the CPA_BASE_URL hostname plus port 8317; set host:port explicitly when exposed through nginx stream on another port. +REDIS_QUEUE_ADDR= + +# 每次 Redis LPOP 最多拉取的队列记录数。redis/auto 模式会持续 drain,非空批次会立即继续拉取。必填:否。默认值:1000。 +# Maximum queue records per Redis LPOP. redis/auto modes continuously drain and immediately continue after a non-empty batch. Required: no. Default: 1000. +REDIS_QUEUE_BATCH_SIZE=1000 + +# Redis 队列为空时的 idle 检查间隔。必填:否。默认值:1s。 +# Idle check interval when the Redis queue is empty. Required: no. Default: 1s. +REDIS_QUEUE_IDLE_INTERVAL=1s + +# 请求 CPA 接口时的超时时间。必填:否。默认值:30s。 +# Timeout for requests to the CPA service. Required: no. Default: 30s. +REQUEST_TIMEOUT=30s + +# 应用工作目录,SQLite 数据库、日志和备份默认都写入这里。Docker 中默认工作目录为 /,因此 ./data 对应 /data;二进制通过 --env 或同目录 .env 加载时,./data 对应 env 文件同目录的 data 子目录。必填:否。默认值:./data。 +# Application work directory. SQLite database, logs, and backups are stored here by default. In Docker the runtime working directory is /, so ./data maps to /data; for binaries loaded with --env or package-local .env, ./data maps to a data directory next to the env file. Required: no. Default: ./data. +WORK_DIR=./data + +# 应用日志级别。必填:否。默认值:info。 +# Application log level. Required: no. Default: info. +LOG_LEVEL=info + +# 是否写入持久化日志文件。必填:否。默认值:true。 +# Whether to write persistent log files. Required: no. Default: true. +LOG_FILE_ENABLED=true + +# 日志文件保留天数;0 表示不自动清理。必填:否。默认值:7。 +# Number of days to retain log files; 0 disables cleanup. Required: no. Default: 7. +LOG_RETENTION_DAYS=7 + +# 是否启用 SQLite 数据库备份。必填:否。默认值:true。 +# Whether to enable SQLite database backups. Required: no. Default: true. +BACKUP_ENABLED=true + +# 数据库备份间隔。必填:否。默认值:24h。 +# Database backup interval. Required: no. Default: 24h. +BACKUP_INTERVAL=24h + +# 备份文件保留天数。必填:否。默认值:7。 +# Number of days to retain backup files. Required: no. Default: 7. +BACKUP_RETENTION_DAYS=7 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..5de0b955229571f818c49fbc0579d099b37e882c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +web/src/assets/cli-proxy-api-favicon.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/workflows/binary-dev-release.yml b/.github/workflows/binary-dev-release.yml new file mode 100644 index 0000000000000000000000000000000000000000..f3cf5fa6973e447b2d0f140024444145751c4c2f --- /dev/null +++ b/.github/workflows/binary-dev-release.yml @@ -0,0 +1,304 @@ +name: Publish dev binaries + +on: + workflow_dispatch: + inputs: + specific_name: + description: 'Optional binary package name suffix; defaults to the current branch name, for example pr-123 or feature-login-test' + required: false + type: string + +permissions: + contents: read + +jobs: + resolve-dev-name: + runs-on: ubuntu-24.04 + outputs: + specific_name: ${{ steps.dev_name.outputs.specific_name }} + steps: + - name: Validate branch ref + if: ${{ !startsWith(github.ref, 'refs/heads/') || github.ref_name == 'main' }} + run: | + echo "Manual dev binary publishing must be run from a non-main branch." >&2 + echo "Current ref: $GITHUB_REF" >&2 + exit 1 + + - name: Resolve specific binary package name + id: dev_name + env: + INPUT_NAME: ${{ inputs.specific_name }} + run: | + raw_name="${INPUT_NAME:-$GITHUB_REF_NAME}" + specific_name=$(printf '%s' "$raw_name" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[^a-z0-9]+//; s/[._-]+$//' | cut -c1-96) + + if [[ -z "$INPUT_NAME" ]]; then + case "$specific_name" in + dev|latest|sha-*) + specific_name=$(printf '%s-%s' "$specific_name" "${GITHUB_SHA::7}" | cut -c1-96) + ;; + esac + fi + + if [[ ! "$specific_name" =~ ^[a-z0-9][a-z0-9._-]{0,95}$ ]]; then + echo "Unable to generate a valid binary package name from: $raw_name" >&2 + exit 1 + fi + + case "$specific_name" in + dev|latest|sha-*) + echo "Invalid binary package name: $specific_name is reserved." >&2 + exit 1 + ;; + esac + + echo "specific_name=$specific_name" >> "$GITHUB_OUTPUT" + echo "Using specific binary package name: $specific_name" + + binary-dev-linux: + runs-on: ubuntu-24.04 + needs: resolve-dev-name + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + cc: gcc + asset_arch: amd64 + - goos: linux + goarch: arm64 + cc: aarch64-linux-gnu-gcc + asset_arch: arm64 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + check-latest: true + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y build-essential gcc-aarch64-linux-gnu + + - name: Install frontend dependencies + run: npm --prefix ./web ci + + - name: Build frontend + run: npm --prefix ./web run build + + - name: Build binary package + env: + PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CC: ${{ matrix.cc }} + ASSET_ARCH: ${{ matrix.asset_arch }} + run: | + set -euo pipefail + package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}" + package_dir="dist/${package_name}" + mkdir -p "${package_dir}/data" + + CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \ + go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go + cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/" + tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}" + + - name: Upload binary package artifact + uses: actions/upload-artifact@v6 + with: + name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }} + path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz + if-no-files-found: error + + binary-dev-darwin: + runs-on: ${{ matrix.runs_on }} + needs: resolve-dev-name + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + asset_arch: amd64 + runs_on: macos-15-intel + - goos: darwin + goarch: arm64 + asset_arch: arm64 + runs_on: macos-15 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + check-latest: true + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend dependencies + run: npm --prefix ./web ci + + - name: Build frontend + run: npm --prefix ./web run build + + - name: Build binary package + env: + PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + ASSET_ARCH: ${{ matrix.asset_arch }} + run: | + set -euo pipefail + package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}" + package_dir="dist/${package_name}" + mkdir -p "${package_dir}/data" + + CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" \ + go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go + cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/" + tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}" + + - name: Upload binary package artifact + uses: actions/upload-artifact@v6 + with: + name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }} + path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz + if-no-files-found: error + + binary-dev-windows: + runs-on: ${{ matrix.runs_on }} + needs: resolve-dev-name + strategy: + fail-fast: false + matrix: + include: + - goarch: amd64 + asset_arch: amd64 + runs_on: windows-2025 + msystem: UCRT64 + install: mingw-w64-ucrt-x86_64-gcc + cc: gcc + - goarch: arm64 + asset_arch: arm64 + runs_on: windows-11-arm + msystem: CLANGARM64 + install: mingw-w64-clang-aarch64-gcc + cc: gcc + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + check-latest: true + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Set up MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: ${{ matrix.msystem }} + update: true + path-type: inherit + install: ${{ matrix.install }} + + - name: Install frontend dependencies + shell: bash + run: npm --prefix ./web ci + + - name: Build frontend + shell: bash + run: npm --prefix ./web run build + + - name: Build binary package + shell: msys2 {0} + env: + PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }} + GOOS: windows + GOARCH: ${{ matrix.goarch }} + ASSET_ARCH: ${{ matrix.asset_arch }} + CC: ${{ matrix.cc }} + run: | + set -euo pipefail + package_name="cpa-usage-keeper_dev_${PACKAGE_SUFFIX}_${GITHUB_SHA::7}_${GOOS}_${ASSET_ARCH}" + package_dir="dist/${package_name}" + mkdir -p "${package_dir}/data" + + CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \ + go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper.exe" ./cmd/server/main.go + cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/" + + - name: Archive binary package + shell: pwsh + env: + PACKAGE_SUFFIX: ${{ needs.resolve-dev-name.outputs.specific_name }} + ASSET_ARCH: ${{ matrix.asset_arch }} + run: | + $shortSha = "${env:GITHUB_SHA}".Substring(0, 7) + $packageName = "cpa-usage-keeper_dev_${env:PACKAGE_SUFFIX}_${shortSha}_windows_${env:ASSET_ARCH}" + Compress-Archive -Path "dist/$packageName" -DestinationPath "dist/$packageName.zip" + + - name: Upload binary package artifact + uses: actions/upload-artifact@v6 + with: + name: cpa-usage-keeper-dev-${{ needs.resolve-dev-name.outputs.specific_name }}-windows-${{ matrix.asset_arch }} + path: dist/cpa-usage-keeper_dev_${{ needs.resolve-dev-name.outputs.specific_name }}_*_windows_${{ matrix.asset_arch }}.zip + if-no-files-found: error + + publish-dev-artifacts: + runs-on: ubuntu-24.04 + needs: + - resolve-dev-name + - binary-dev-linux + - binary-dev-darwin + - binary-dev-windows + steps: + - name: Download binary package artifacts + uses: actions/download-artifact@v6 + with: + path: dist + merge-multiple: true + + - name: Generate checksums + run: | + set -euo pipefail + cd dist + sha256sum *.tar.gz *.zip > checksums.txt + + - name: Upload dev binary packages + uses: actions/upload-artifact@v6 + with: + name: cpa-usage-keeper-dev-binaries-${{ github.run_id }} + path: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt + if-no-files-found: error diff --git a/.github/workflows/binary-release.yml b/.github/workflows/binary-release.yml new file mode 100644 index 0000000000000000000000000000000000000000..1269e0c7993e66041f0a34d660a41af0bfcaa3f7 --- /dev/null +++ b/.github/workflows/binary-release.yml @@ -0,0 +1,251 @@ +name: Publish release binaries + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + binary-release-linux: + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + include: + - goos: linux + goarch: amd64 + cc: gcc + asset_arch: amd64 + - goos: linux + goarch: arm64 + cc: aarch64-linux-gnu-gcc + asset_arch: arm64 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + check-latest: true + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install build dependencies + run: sudo apt-get update && sudo apt-get install -y build-essential gcc-aarch64-linux-gnu + + - name: Install frontend dependencies + run: npm --prefix ./web ci + + - name: Build frontend + run: npm --prefix ./web run build + + - name: Build binary package + env: + VERSION: ${{ github.ref_name }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CC: ${{ matrix.cc }} + ASSET_ARCH: ${{ matrix.asset_arch }} + run: | + set -euo pipefail + package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}" + package_dir="dist/${package_name}" + mkdir -p "${package_dir}/data" + + CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \ + go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go + cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/" + tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}" + + - name: Upload binary package artifact + uses: actions/upload-artifact@v6 + with: + name: cpa-usage-keeper-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }} + path: dist/cpa-usage-keeper_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz + if-no-files-found: error + + binary-release-darwin: + runs-on: ${{ matrix.runs_on }} + strategy: + fail-fast: false + matrix: + include: + - goos: darwin + goarch: amd64 + asset_arch: amd64 + runs_on: macos-15-intel + - goos: darwin + goarch: arm64 + asset_arch: arm64 + runs_on: macos-15 + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + check-latest: true + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend dependencies + run: npm --prefix ./web ci + + - name: Build frontend + run: npm --prefix ./web run build + + - name: Build binary package + env: + VERSION: ${{ github.ref_name }} + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + ASSET_ARCH: ${{ matrix.asset_arch }} + run: | + set -euo pipefail + package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}" + package_dir="dist/${package_name}" + mkdir -p "${package_dir}/data" + + CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" \ + go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper" ./cmd/server/main.go + cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/" + tar -czf "dist/${package_name}.tar.gz" -C dist "${package_name}" + + - name: Upload binary package artifact + uses: actions/upload-artifact@v6 + with: + name: cpa-usage-keeper-${{ github.ref_name }}-${{ matrix.goos }}-${{ matrix.asset_arch }} + path: dist/cpa-usage-keeper_${{ github.ref_name }}_${{ matrix.goos }}_${{ matrix.asset_arch }}.tar.gz + if-no-files-found: error + + binary-release-windows: + runs-on: ${{ matrix.runs_on }} + strategy: + fail-fast: false + matrix: + include: + - goarch: amd64 + asset_arch: amd64 + runs_on: windows-2025 + msystem: UCRT64 + install: mingw-w64-ucrt-x86_64-gcc + cc: gcc + - goarch: arm64 + asset_arch: arm64 + runs_on: windows-11-arm + msystem: CLANGARM64 + install: mingw-w64-clang-aarch64-gcc + cc: gcc + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: stable + check-latest: true + cache: true + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '24' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Set up MSYS2 + uses: msys2/setup-msys2@v2 + with: + msystem: ${{ matrix.msystem }} + update: true + path-type: inherit + install: ${{ matrix.install }} + + - name: Install frontend dependencies + shell: bash + run: npm --prefix ./web ci + + - name: Build frontend + shell: bash + run: npm --prefix ./web run build + + - name: Build binary package + shell: msys2 {0} + env: + VERSION: ${{ github.ref_name }} + GOOS: windows + GOARCH: ${{ matrix.goarch }} + ASSET_ARCH: ${{ matrix.asset_arch }} + CC: ${{ matrix.cc }} + run: | + set -euo pipefail + package_name="cpa-usage-keeper_${VERSION}_${GOOS}_${ASSET_ARCH}" + package_dir="dist/${package_name}" + mkdir -p "${package_dir}/data" + + CGO_ENABLED=1 GOOS="${GOOS}" GOARCH="${GOARCH}" CC="${CC}" \ + go build -trimpath -ldflags="-s -w" -o "${package_dir}/cpa-usage-keeper.exe" ./cmd/server/main.go + cp .env.example README.md README.en.md LICENSE CONTRIBUTORS.md "${package_dir}/" + + - name: Archive binary package + shell: pwsh + env: + VERSION: ${{ github.ref_name }} + ASSET_ARCH: ${{ matrix.asset_arch }} + run: | + $packageName = "cpa-usage-keeper_${env:VERSION}_windows_${env:ASSET_ARCH}" + Compress-Archive -Path "dist/$packageName" -DestinationPath "dist/$packageName.zip" + + - name: Upload binary package artifact + uses: actions/upload-artifact@v6 + with: + name: cpa-usage-keeper-${{ github.ref_name }}-windows-${{ matrix.asset_arch }} + path: dist/cpa-usage-keeper_${{ github.ref_name }}_windows_${{ matrix.asset_arch }}.zip + if-no-files-found: error + + publish-release: + runs-on: ubuntu-24.04 + needs: + - binary-release-linux + - binary-release-darwin + - binary-release-windows + steps: + - name: Download binary package artifacts + uses: actions/download-artifact@v6 + with: + path: dist + merge-multiple: true + + - name: Generate checksums + run: | + set -euo pipefail + cd dist + sha256sum *.tar.gz *.zip > checksums.txt + + - name: Upload release assets + uses: softprops/action-gh-release@v3 + with: + files: | + dist/*.tar.gz + dist/*.zip + dist/checksums.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..2904a545e6a1e3148f194e3e4c929d54e9ff7ae0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,76 @@ +name: CI + +on: + pull_request: + push: + branches: + - main + +permissions: + contents: read + +jobs: + backend: + runs-on: ${{ matrix.runs-on }} + strategy: + fail-fast: false + matrix: + runs-on: + - ubuntu-latest + - macos-latest + - windows-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Go + uses: actions/setup-go@v6 + with: + go-version: '1.22' + cache: true + + - name: Set up MSYS2 + if: ${{ runner.os == 'Windows' }} + uses: msys2/setup-msys2@v2 + with: + msystem: UCRT64 + update: true + path-type: inherit + install: mingw-w64-ucrt-x86_64-gcc + + - name: Run backend tests + if: ${{ runner.os != 'Windows' }} + run: go test ./cmd/... ./internal/... + + - name: Run backend tests + if: ${{ runner.os == 'Windows' }} + shell: msys2 {0} + run: go test ./cmd/... ./internal/... + + frontend: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: '22' + cache: npm + cache-dependency-path: web/package-lock.json + + - name: Install frontend dependencies + run: npm --prefix ./web ci + + - name: Run frontend tests + run: npm --prefix ./web run test + + - name: Run frontend lint + run: npm --prefix ./web run lint + + - name: Run frontend typecheck + run: npm --prefix ./web run typecheck + + - name: Build frontend + run: npm --prefix ./web run build diff --git a/.github/workflows/docker-dev-publish.yml b/.github/workflows/docker-dev-publish.yml new file mode 100644 index 0000000000000000000000000000000000000000..76d4b2aca3d87ac1f8521a09397d887780308f01 --- /dev/null +++ b/.github/workflows/docker-dev-publish.yml @@ -0,0 +1,91 @@ +name: Publish dev Docker image + +on: + workflow_dispatch: + inputs: + specific_tag: + description: 'Optional Docker tag to publish; defaults to the current branch name, for example pr-123 or feature-login-test' + required: false + type: string + +permissions: + contents: read + packages: write + +jobs: + docker-dev: + runs-on: ubuntu-latest + steps: + - name: Validate branch ref + if: ${{ !startsWith(github.ref, 'refs/heads/') || github.ref_name == 'main' }} + run: | + echo "Manual dev Docker publishing must be run from a non-main branch." >&2 + echo "Current ref: $GITHUB_REF" >&2 + exit 1 + + - name: Resolve specific Docker tag + id: dev_tag + env: + INPUT_TAG: ${{ inputs.specific_tag }} + run: | + raw_tag="${INPUT_TAG:-$GITHUB_REF_NAME}" + specific_tag=$(printf '%s' "$raw_tag" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9._-]+/-/g; s/^[^a-z0-9]+//; s/[._-]+$//' | cut -c1-128) + + if [[ -z "$INPUT_TAG" ]]; then + case "$specific_tag" in + dev|latest|sha-*) + specific_tag=$(printf '%s-%s' "$specific_tag" "${GITHUB_SHA::7}" | cut -c1-128) + ;; + esac + fi + + if [[ ! "$specific_tag" =~ ^[a-z0-9][a-z0-9._-]{0,127}$ ]]; then + echo "Unable to generate a valid Docker tag from: $raw_tag" >&2 + exit 1 + fi + + case "$specific_tag" in + dev|latest|sha-*) + echo "Invalid Docker tag: $specific_tag is reserved." >&2 + exit 1 + ;; + esac + + echo "specific_tag=$specific_tag" >> "$GITHUB_OUTPUT" + echo "Using specific Docker tag: $specific_tag" + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Convert image name to lowercase + id: repo + run: echo "image=ghcr.io/${GITHUB_REPOSITORY@L}" >> "$GITHUB_OUTPUT" + + - name: Generate Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ steps.repo.outputs.image }} + tags: | + type=raw,value=dev + type=raw,value=${{ steps.dev_tag.outputs.specific_tag }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000000000000000000000000000000000000..8fe60ecf2eccf72f33d0dc2242d5bbdd3434b57c --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,54 @@ +name: Publish release Docker image + +on: + push: + tags: + - 'v*' + +permissions: + contents: read + packages: write + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Convert image name to lowercase + id: repo + run: echo "image=ghcr.io/${GITHUB_REPOSITORY@L}" >> "$GITHUB_OUTPUT" + + - name: Generate Docker metadata + id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ steps.repo.outputs.image }} + tags: | + type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=sha,prefix=sha- + type=ref,event=tag,enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{version}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}}.{{minor}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + type=semver,pattern={{major}},enable=${{ startsWith(github.ref, 'refs/tags/v') }} + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v4 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push Docker image + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..64be0ec0610e574eee0577b75144d6ea7dd2d4fc --- /dev/null +++ b/.gitignore @@ -0,0 +1,46 @@ +# internal docs +.dev-docs/ +.claude/ +.worktrees/ + +# local environment files +.env +.env.* +!.env.example +!deploy/.env.example + +# runtime data +/data/ +/test/ +*.db +*.db-* +.tmp-test.db +backups/ +.runtime-test/ + +# frontend dependencies and build output +web/node_modules/ +web/dist/ +web/dist-ssr/ + +# logs +logs/ +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# local overrides +*.local + +# editor and OS files +.DS_Store +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md new file mode 100644 index 0000000000000000000000000000000000000000..09beb729c0d19177d67b933570c3cfba28eb96f8 --- /dev/null +++ b/CONTRIBUTORS.md @@ -0,0 +1,7 @@ +# Contributors + +This project is made possible by the work and ideas of the people below. + +## Acknowledgements + +- [luispater](https://github.com/luispater) — Author of CPA, the upstream project that this usage keeper integrates with. diff --git a/Dockerfile b/Dockerfile index cc0357456a9a982f80b2ace5e4b61e368ccc84d9..f895df8ad1e2aeb8a589ba672f892c23121730dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,16 +1,36 @@ -FROM ghcr.io/willxup/cpa-usage-keeper:latest +# syntax=docker/dockerfile:1 -ENV APP_PORT=8080 \ - CPA_BASE_URL=https://pjpjq-daili.hf.space \ - REDIS_QUEUE_ADDR=127.0.0.1:9 \ - AUTH_ENABLED=true \ - TZ=Asia/Shanghai \ - WORK_DIR=/data \ - LOG_FILE_ENABLED=true \ - BACKUP_ENABLED=true +FROM node:22-alpine AS web-builder +WORKDIR /app/web +COPY web/package.json web/package-lock.json ./ +RUN npm ci +COPY web/ ./ +RUN npm run build -COPY entrypoint.space.sh /usr/local/bin/entrypoint.space.sh -RUN chmod +x /usr/local/bin/entrypoint.space.sh +FROM golang:1.22-alpine AS go-builder +WORKDIR /app +RUN apk add --no-cache build-base +COPY go.mod go.sum ./ +RUN go mod download +COPY cmd/ ./cmd/ +COPY internal/ ./internal/ +COPY --from=web-builder /app/web/dist ./web/dist +COPY web/static.go ./web/static.go +RUN CGO_ENABLED=1 GOOS=linux go build -o /out/cpa-usage-keeper ./cmd/server/main.go -ENTRYPOINT ["/usr/local/bin/entrypoint.space.sh"] +FROM alpine:3.20 +WORKDIR / +RUN apk add --no-cache ca-certificates tzdata su-exec \ + && addgroup -S app \ + && adduser -S -G app app \ + && mkdir -p /data \ + && chown -R app:app /data +COPY --from=go-builder /out/cpa-usage-keeper /app/cpa-usage-keeper +COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh \ + && chmod +x /usr/local/bin/docker-entrypoint.sh +VOLUME ["/data"] +EXPOSE 8080 +HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 CMD wget -q --spider "http://127.0.0.1:${APP_PORT:-8080}${APP_BASE_PATH:-}/healthz" || exit 1 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/app/cpa-usage-keeper"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..0c825b42175e67061645f4647e1e2fec4f3abec2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Will + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..bbcf501d31fc28584f2009a651f6bc6e4449f548 --- /dev/null +++ b/Makefile @@ -0,0 +1,16 @@ +.PHONY: verify verify-backend verify-frontend verify-docker + +verify: verify-backend verify-frontend + +verify-backend: + go test ./cmd/... ./internal/... + +verify-frontend: + npm --prefix ./web ci + npm --prefix ./web run test + npm --prefix ./web run lint + npm --prefix ./web run typecheck + npm --prefix ./web run build + +verify-docker: + docker build -t cpa-usage-keeper:ci . diff --git a/README.en.md b/README.en.md new file mode 100644 index 0000000000000000000000000000000000000000..767ed3dc4f73237bee0fb5b1caaacfc7b7d06883 --- /dev/null +++ b/README.en.md @@ -0,0 +1,223 @@ +# CPA Usage Keeper + +[中文说明](./README.md) + +CPA Usage Keeper is a standalone CPA usage persistence and dashboard service. + +It relies on [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI) as the backend CPA data source and adds persistent storage and statistical analysis capabilities on top of CPA. The service consumes events from the CPA Redis usage queue into SQLite, periodically pulls CPA metadata, exposes aggregation APIs, and serves a built-in web dashboard for usage, pricing, request health, and model/API statistics. + +![cpa-usage-keeper-screenshot](https://images.bitskyline.com/i/2026/05/1pmg6l.png) + +## Features + +- CPA usage persistence in SQLite +- Aggregated usage and pricing APIs +- Built-in React dashboard +- Optional password login protection +- Local SQLite database backups with retention +- Docker / Docker Compose deployment + +## Project Structure + +```text +cmd/ Application entrypoint +internal/api/ HTTP routes and handlers +internal/app/ App wiring and startup +internal/auth/ In-memory session auth +internal/backup/ SQLite database backup management +internal/config/ Environment config loading +internal/cpa/ CPA client and types +internal/models/ GORM models +internal/poller/ Background sync loop +internal/repository/ SQLite access and aggregations +internal/service/ Sync, usage, and pricing services +web/ React + TypeScript frontend +``` + +## Configuration + +Copy the example config: + +```bash +cp .env.example .env +``` + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `CPA_BASE_URL` | Yes | - | CPA server URL | +| `CPA_MANAGEMENT_KEY` | Yes | - | CPA management key | +| `AUTH_ENABLED` | No | `false` | Enable login protection | +| `LOGIN_PASSWORD` | When auth is enabled | - | Login password | +| `AUTH_SESSION_TTL` | No | `168h` | Session lifetime | +| `APP_PORT` | No | `8080` | HTTP listen port | +| `APP_BASE_PATH` | No | root path | Subpath prefix such as `/cpa`; empty means `/` | +| `TZ` | No | `Asia/Shanghai` | Project business timezone; affects Today, daily aggregation, scheduled tasks, and log timestamps | +| `REDIS_QUEUE_ADDR` | No | `CPA_BASE_URL` hostname + `8317` | CPA Redis/RESP TCP address; set `host:port` for non-default ports | +| `REDIS_QUEUE_BATCH_SIZE` | No | `1000` | Maximum queue records per pull | +| `REDIS_QUEUE_IDLE_INTERVAL` | No | `1s` | Empty queue check interval | +| `REQUEST_TIMEOUT` | No | `30s` | CPA request timeout | +| `WORK_DIR` | No | `./data` | Application work directory; database, logs, and backups default to `app.db`, `logs/`, and `backups/` under it | +| `LOG_LEVEL` | No | `info` | Log level | +| `LOG_FILE_ENABLED` | No | `true` | Write persistent log files | +| `LOG_RETENTION_DAYS` | No | `7` | Log retention days; `0` disables cleanup | +| `BACKUP_ENABLED` | No | `true` | Enable SQLite database backups | +| `BACKUP_INTERVAL` | No | `24h` | Database backup interval | +| `BACKUP_RETENTION_DAYS` | No | `7` | Backup retention days | + +`APP_BASE_PATH` must be empty or start with `/`; for example `/cpa`. `/cpa/` is normalized to `/cpa`. + +Security and data notes: + +- SQLite database backups store original data from the application database, and backup files are not encrypted. +- Browser-facing APIs redact key-like source/lookup fields or map them to stable public identifiers, but raw database values are unchanged. +- For public deployments, enable `AUTH_ENABLED=true` and terminate HTTPS at your reverse proxy. +- Login sessions are stored in process memory and become invalid after restart. +- Redis inbox raw messages are cleaned up automatically: successful rows are kept until the end of the current day, and failed rows are kept for 7 days. + +## Development + +### Prerequisites + +- Go 1.22+ +- Node.js 22+ +- npm +- A running [CLIProxyAPI (CPA)](https://github.com/router-for-me/CLIProxyAPI) instance + +### Run locally + +1. Create your local config: + +```bash +cp .env.example .env +``` + +2. Start the backend: + +```bash +go run ./cmd/server/main.go +``` + +3. In another terminal, install frontend dependencies and start the dev server: + +```bash +npm --prefix ./web ci +npm --prefix ./web run dev -- --host 127.0.0.1 +``` + +4. Build the frontend for production: + +```bash +npm --prefix ./web run build +``` + +### Tests + +Run the full local verification baseline: + +```bash +make verify +``` + +Or run checks individually: + +```bash +go test ./cmd/... ./internal/... +npm --prefix ./web run test +npm --prefix ./web run lint +npm --prefix ./web run typecheck +npm --prefix ./web run build +``` + +## Docker + +If CPA is already running on the host: + +```bash +# TZ sets the container timezone; log timestamps are displayed in this timezone. +docker run -d \ + --name cpa-usage-keeper \ + --add-host=host.docker.internal:host-gateway \ + -p 8080:8080 \ + -v "$(pwd)/keeper/data:/data" \ + -e TZ=Asia/Shanghai \ + -e CPA_BASE_URL=http://host.docker.internal:8317 \ + -e CPA_MANAGEMENT_KEY=replace-with-your-management-key \ + -e REDIS_QUEUE_ADDR=host.docker.internal:8317 \ + -e AUTH_ENABLED=true \ + -e LOGIN_PASSWORD=replace-with-your-login-password \ + ghcr.io/willxup/cpa-usage-keeper:latest +``` + +`/data` stores the SQLite database, backups, and log files. Mount it to persistent storage. + +## Docker Compose + +The repository includes a minimal `docker-compose.yaml` example for running CPA and CPA Usage Keeper together: + +```yaml +services: + cli-proxy-api: + image: eceasy/cli-proxy-api:latest + container_name: cli-proxy-api + restart: unless-stopped + ports: + - "8317:8317" + - "1455:1455" + volumes: + - ./cpa/config.yaml:/CLIProxyAPI/config.yaml + - ./cpa/auths:/root/.cli-proxy-api + - ./cpa/logs:/CLIProxyAPI/logs + networks: + - cpa-network + + cpa-usage-keeper: + image: ghcr.io/willxup/cpa-usage-keeper:latest + container_name: cpa-usage-keeper + restart: unless-stopped + depends_on: + - cli-proxy-api + ports: + - "8080:8080" + environment: + TZ: Asia/Shanghai # Sets the container timezone; log timestamps use this timezone. + CPA_BASE_URL: http://cli-proxy-api:8317 + CPA_MANAGEMENT_KEY: replace-with-your-management-key + REDIS_QUEUE_ADDR: cli-proxy-api:8317 + AUTH_ENABLED: true + LOGIN_PASSWORD: replace-with-your-login-password + volumes: + - ./keeper:/data + networks: + - cpa-network + +networks: + cpa-network: + driver: bridge +``` + +Start: + +```bash +docker compose up -d +``` + +Stop: + +```bash +docker compose down +``` + +CPA files are stored under `./cpa`, and CPA Usage Keeper data is stored under `./keeper`. + +## Subpath reverse proxy + +When serving under `/cpa`, set `APP_BASE_PATH=/cpa` and keep the prefix in your reverse proxy: + +```nginx +location /cpa/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} +``` diff --git a/README.md b/README.md index 90ee7bdf5b5a48eb22cefd62ad736e0a4b10fba8..cc3fb3fac140140feafeb8d2f09dbc87a091b542 100644 --- a/README.md +++ b/README.md @@ -8,25 +8,230 @@ app_port: 8080 pinned: false --- -# Daili Usage Keeper +# CPA Usage Keeper -A standalone [CPA Usage Keeper](https://github.com/Willxup/cpa-usage-keeper) Space for `pjpjq/daili`. +[English README](./README.en.md) -This Space intentionally runs separately from the main CPA Space so the proxy path is not coupled to usage visualization. +`CPA Usage Keeper` 是一个独立的 CPA 用量持久化与可视化服务。 -Required secret before it starts the real dashboard: +它依赖 [CLIProxyAPI(CPA)](https://github.com/router-for-me/CLIProxyAPI) 作为后端 CPA 数据来源,目标是在 CPA 之上补充持久化存储与统计分析能力。服务会从 CPA Redis usage 队列消费事件并写入 SQLite,定时拉取 CPA metadata,暴露聚合 API,并提供内置 Web Dashboard 用于查看 usage、pricing、request health 和 model/API 维度的统计信息。 -- `CPA_MANAGEMENT_KEY`: management key for `https://pjpjq-daili.hf.space` +![cpa-usage-keeper-screenshot](https://images.bitskyline.com/i/2026/05/1pmg6l.png) -Default variables: +## 功能特性 -- `CPA_BASE_URL=https://pjpjq-daili.hf.space` -- `REDIS_QUEUE_ADDR=127.0.0.1:9` to force fast HTTP fallback to CPA `/v0/management/usage-queue`, because Hugging Face Spaces cannot reach the raw CPA TCP/RESP port across Spaces. -- `AUTH_ENABLED=true`; set `LOGIN_PASSWORD` as a Space secret. The Space can be public while the dashboard remains password protected. +- CPA usage 数据持久化到 SQLite +- usage 聚合 API 与 pricing API +- 内置 React Dashboard +- 可选密码登录保护 +- SQLite 数据库本地备份与保留策略 +- Docker / Docker Compose 部署 -Persistent history requires Hugging Face persistent storage or another backup strategy for `/data`. +## 项目结构 -Runtime secrets configured on Hugging Face: +```text +cmd/ 应用入口 +internal/api/ HTTP 路由与处理器 +internal/app/ 应用装配与启动 +internal/auth/ 内存 session 鉴权 +internal/backup/ SQLite 数据库备份管理 +internal/config/ 环境配置加载 +internal/cpa/ CPA 客户端与类型定义 +internal/models/ GORM 模型 +internal/poller/ 后台同步轮询 +internal/repository/ SQLite 访问与聚合逻辑 +internal/service/ 同步、usage 与 pricing 服务 +web/ React + TypeScript 前端 +``` -- `CPA_MANAGEMENT_KEY` -- `LOGIN_PASSWORD` +## 配置 + +复制配置模板: + +```bash +cp .env.example .env +``` + +| 变量 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `CPA_BASE_URL` | 是 | - | CPA 服务地址 | +| `CPA_MANAGEMENT_KEY` | 是 | - | CPA management key | +| `AUTH_ENABLED` | 否 | `false` | 是否启用登录保护 | +| `LOGIN_PASSWORD` | 鉴权启用时必填 | - | 登录密码 | +| `AUTH_SESSION_TTL` | 否 | `168h` | Session 生命周期 | +| `APP_PORT` | 否 | `8080` | HTTP 监听端口 | +| `APP_BASE_PATH` | 否 | 根路径 | 子路径部署前缀,例如 `/cpa`;留空表示 `/` | +| `TZ` | 否 | `Asia/Shanghai` | 项目业务时区,影响 Today、按天聚合、定时任务和日志时间 | +| `REDIS_QUEUE_ADDR` | 否 | `CPA_BASE_URL` 主机名 + `8317` | CPA Redis/RESP TCP 地址;非默认端口时填写 `host:port` | +| `REDIS_QUEUE_BATCH_SIZE` | 否 | `1000` | 每次最多拉取的队列记录数 | +| `REDIS_QUEUE_IDLE_INTERVAL` | 否 | `1s` | 队列为空时的检查间隔 | +| `REQUEST_TIMEOUT` | 否 | `30s` | CPA 请求超时 | +| `WORK_DIR` | 否 | `./data` | 应用工作目录;数据库、日志和备份默认分别写入 `app.db`、`logs/`、`backups/` | +| `LOG_LEVEL` | 否 | `info` | 日志级别 | +| `LOG_FILE_ENABLED` | 否 | `true` | 是否写入持久化日志文件 | +| `LOG_RETENTION_DAYS` | 否 | `7` | 日志保留天数;`0` 表示不自动清理 | +| `BACKUP_ENABLED` | 否 | `true` | 是否启用 SQLite 数据库备份 | +| `BACKUP_INTERVAL` | 否 | `24h` | 数据库备份间隔 | +| `BACKUP_RETENTION_DAYS` | 否 | `7` | 备份保留天数 | + +`APP_BASE_PATH` 必须为空或以 `/` 开头;例如 `/cpa`,`/cpa/` 会规范为 `/cpa`。 + +安全与数据说明: + +- SQLite 数据库备份会保存应用数据库中的原始数据,备份文件不做加密。 +- 面向浏览器的 API 会对 key-like source/lookup 字段做脱敏或稳定公开标识映射,但不会修改数据库原始值。 +- 公开部署建议开启 `AUTH_ENABLED=true`,并在反向代理层配置 HTTPS。 +- 登录 session 存在服务进程内存中,服务重启后已登录 session 会失效。 +- Redis inbox 原始消息会自动清理:成功数据保留到当天结束后清理,失败数据保留 7 天。 + +## 本地开发 + +### 前置依赖 + +- Go 1.22+ +- Node.js 22+ +- npm +- 已运行的 [CLIProxyAPI(CPA)](https://github.com/router-for-me/CLIProxyAPI) + +### 本地启动 + +1. 复制本地配置: + +```bash +cp .env.example .env +``` + +2. 启动后端: + +```bash +go run ./cmd/server/main.go +``` + +3. 在另一个终端安装前端依赖并启动开发服务器: + +```bash +npm --prefix ./web ci +npm --prefix ./web run dev -- --host 127.0.0.1 +``` + +4. 构建前端生产产物: + +```bash +npm --prefix ./web run build +``` + +### 测试 + +运行完整的本地验证基线: + +```bash +make verify +``` + +也可以单独运行各项检查: + +```bash +go test ./cmd/... ./internal/... +npm --prefix ./web run test +npm --prefix ./web run lint +npm --prefix ./web run typecheck +npm --prefix ./web run build +``` + +## Docker + +如果 CPA 已在宿主机运行: + +```bash +# TZ 设置容器时区,日志时间会按该时区显示。 +docker run -d \ + --name cpa-usage-keeper \ + --add-host=host.docker.internal:host-gateway \ + -p 8080:8080 \ + -v "$(pwd)/keeper/data:/data" \ + -e TZ=Asia/Shanghai \ + -e CPA_BASE_URL=http://host.docker.internal:8317 \ + -e CPA_MANAGEMENT_KEY=replace-with-your-management-key \ + -e REDIS_QUEUE_ADDR=host.docker.internal:8317 \ + -e AUTH_ENABLED=true \ + -e LOGIN_PASSWORD=replace-with-your-login-password \ + ghcr.io/willxup/cpa-usage-keeper:latest +``` + +`/data` 用于保存 SQLite 数据库、备份文件和日志文件,请挂载到持久化目录。 + +## Docker Compose + +仓库提供了一个最简 `docker-compose.yaml` 示例,用于同时部署 CPA 和 CPA Usage Keeper: + +```yaml +services: + cli-proxy-api: + image: eceasy/cli-proxy-api:latest + container_name: cli-proxy-api + restart: unless-stopped + ports: + - "8317:8317" + - "1455:1455" + volumes: + - ./cpa/config.yaml:/CLIProxyAPI/config.yaml + - ./cpa/auths:/root/.cli-proxy-api + - ./cpa/logs:/CLIProxyAPI/logs + networks: + - cpa-network + + cpa-usage-keeper: + image: ghcr.io/willxup/cpa-usage-keeper:latest + container_name: cpa-usage-keeper + restart: unless-stopped + depends_on: + - cli-proxy-api + ports: + - "8080:8080" + environment: + TZ: Asia/Shanghai # 设置容器时区,日志时间会按该时区显示。 + CPA_BASE_URL: http://cli-proxy-api:8317 + CPA_MANAGEMENT_KEY: replace-with-your-management-key + REDIS_QUEUE_ADDR: cli-proxy-api:8317 + AUTH_ENABLED: true + LOGIN_PASSWORD: replace-with-your-login-password + volumes: + - ./keeper:/data + networks: + - cpa-network + +networks: + cpa-network: + driver: bridge +``` + +启动: + +```bash +docker compose up -d +``` + +停止: + +```bash +docker compose down +``` + +CPA 文件放在 `./cpa`,CPA Usage Keeper 数据放在 `./keeper`。 + +## 子路径反代 + +部署到 `/cpa` 时设置 `APP_BASE_PATH=/cpa`,并在反向代理中保留该前缀: + +```nginx +location /cpa/ { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +} +``` + +## Daili Space deployment + +This Space builds CPA Usage Keeper from source and connects to `https://pjpjq-daili.hf.space` through CPA's HTTP `/v0/management/usage-queue` fallback. Dashboard auth is enabled with `LOGIN_PASSWORD`. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000000000000000000000000000000000000..9c430c7d1c36a5c21c1e1d36454addad8661c805 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,28 @@ +package main + +import ( + "flag" + "log" + "os" + + "cpa-usage-keeper/internal/app" +) + +func main() { + envFile := flag.String("env", "", "path to env file") + flag.Parse() + + application, err := app.NewWithOptions(app.Options{EnvFile: *envFile}) + if err != nil { + log.Fatalf("initialize app: %v", err) + } + defer application.Close() + + if err := application.Run(); err != nil { + log.Printf("run app: %v", err) + if closeErr := application.Close(); closeErr != nil { + log.Printf("close app: %v", closeErr) + } + os.Exit(1) + } +} diff --git a/docker-compose.example.yml b/docker-compose.example.yml new file mode 100644 index 0000000000000000000000000000000000000000..bfc58bf4bbe380cc9c28dfad880e5b128795f070 --- /dev/null +++ b/docker-compose.example.yml @@ -0,0 +1,28 @@ +services: + cpa-usage-keeper: + image: ghcr.io/willxup/cpa-usage-keeper:latest + ports: + - "8080:8080" + environment: + CPA_BASE_URL: ${CPA_BASE_URL} + CPA_MANAGEMENT_KEY: ${CPA_MANAGEMENT_KEY} + AUTH_ENABLED: ${AUTH_ENABLED:-false} + LOGIN_PASSWORD: ${LOGIN_PASSWORD:-} + AUTH_SESSION_TTL: ${AUTH_SESSION_TTL:-168h} + APP_PORT: ${APP_PORT:-8080} + APP_BASE_PATH: ${APP_BASE_PATH:-} + TZ: ${TZ:-Asia/Shanghai} + REDIS_QUEUE_ADDR: ${REDIS_QUEUE_ADDR:-} + REDIS_QUEUE_BATCH_SIZE: ${REDIS_QUEUE_BATCH_SIZE:-1000} + REDIS_QUEUE_IDLE_INTERVAL: ${REDIS_QUEUE_IDLE_INTERVAL:-1s} + REQUEST_TIMEOUT: ${REQUEST_TIMEOUT:-30s} + WORK_DIR: ${WORK_DIR:-./data} + LOG_LEVEL: ${LOG_LEVEL:-info} + LOG_FILE_ENABLED: ${LOG_FILE_ENABLED:-true} + LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-7} + BACKUP_ENABLED: ${BACKUP_ENABLED:-true} + BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h} + BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-7} + volumes: + - ./data:/data + restart: unless-stopped diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..347a9c6b671f5e0ff25b58b1489e7fcb6c9b0119 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/sh +set -eu + +ensure_writable_dir() { + dir="$1" + if [ -z "$dir" ]; then + return + fi + mkdir -p "$dir" + chown -R app:app "$dir" +} + +work_dir="${WORK_DIR:-./data}" +ensure_writable_dir "$work_dir" + +case "${BACKUP_ENABLED:-true}" in + false|FALSE|False|0) + ;; + *) + ensure_writable_dir "$work_dir/backups" + ;; +esac + +case "${LOG_FILE_ENABLED:-true}" in + false|FALSE|False|0) + ;; + *) + ensure_writable_dir "$work_dir/logs" + ;; +esac + +exec su-exec app "$@" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000000000000000000000000000000000000..0255321a53b2985c6a6105af1c572960b9d13ed3 --- /dev/null +++ b/go.mod @@ -0,0 +1,43 @@ +module cpa-usage-keeper + +go 1.22 + +require ( + github.com/gin-gonic/gin v1.10.0 + github.com/joho/godotenv v1.5.1 + github.com/sirupsen/logrus v1.9.3 + gorm.io/driver/sqlite v1.5.7 + gorm.io/gorm v1.25.12 +) + +require ( + github.com/bytedance/sonic v1.11.6 // indirect + github.com/bytedance/sonic/loader v0.1.1 // indirect + github.com/cloudwego/base64x v0.1.4 // indirect + github.com/cloudwego/iasm v0.2.0 // indirect + github.com/gabriel-vasile/mimetype v1.4.3 // indirect + github.com/gin-contrib/sse v0.1.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.20.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/jinzhu/inflection v1.0.0 // indirect + github.com/jinzhu/now v1.1.5 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.2.7 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-sqlite3 v1.14.22 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/pelletier/go-toml/v2 v2.2.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.2.12 // indirect + golang.org/x/arch v0.8.0 // indirect + golang.org/x/crypto v0.23.0 // indirect + golang.org/x/net v0.25.0 // indirect + golang.org/x/sys v0.20.0 // indirect + golang.org/x/text v0.15.0 // indirect + google.golang.org/protobuf v1.34.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000000000000000000000000000000000000..a0647a0f253368298606e78beeb6b12714d22267 --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= +github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= +github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= +github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= +github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= +github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg= +github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= +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/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= +github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= +github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= +github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= +github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= +github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= +github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +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.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= +github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= +github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= +github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= +github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= +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/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= +github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= +golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= +golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= +golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= +google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +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/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I= +gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4= +gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= +gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= +nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= +rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/api/auth.go b/internal/api/auth.go new file mode 100644 index 0000000000000000000000000000000000000000..1c325b267caba1fa7462bc084028629f47d65d32 --- /dev/null +++ b/internal/api/auth.go @@ -0,0 +1,199 @@ +package api + +import ( + "crypto/subtle" + "net" + "net/http" + "sync" + "time" + + "cpa-usage-keeper/internal/auth" + "github.com/gin-gonic/gin" +) + +const sessionCookieName = "cpa_usage_keeper_session" + +const maxFailedLoginAttempts = 5 + +type AuthConfig struct { + Enabled bool + LoginPassword string + SessionTTL time.Duration + BasePath string +} + +type authHandler struct { + config AuthConfig + sessions *auth.SessionManager + + mu sync.Mutex + failedAttempts map[string]int +} + +type loginRequest struct { + Password string `json:"password"` +} + +type sessionResponse struct { + Authenticated bool `json:"authenticated"` +} + +func NewAuthHandler(config AuthConfig, sessions *auth.SessionManager) *authHandler { + return &authHandler{config: config, sessions: sessions, failedAttempts: make(map[string]int)} +} + +func (h *authHandler) registerRoutes(router gin.IRoutes) { + router.GET("/session", h.getSession) + router.POST("/login", h.login) + router.POST("/logout", h.logout) +} + +func (h *authHandler) middleware() gin.HandlerFunc { + return func(c *gin.Context) { + if h == nil || !h.config.Enabled { + c.Next() + return + } + if h.sessions == nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + token, err := c.Cookie(sessionCookieName) + if err != nil || !h.sessions.Validate(token) { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "authentication required"}) + return + } + + c.Next() + } +} + +func (h *authHandler) getSession(c *gin.Context) { + if h == nil || !h.config.Enabled { + c.JSON(http.StatusOK, sessionResponse{Authenticated: true}) + return + } + if h.sessions == nil { + c.JSON(http.StatusOK, sessionResponse{Authenticated: false}) + return + } + + token, err := c.Cookie(sessionCookieName) + if err != nil { + c.JSON(http.StatusOK, sessionResponse{Authenticated: false}) + return + } + + c.JSON(http.StatusOK, sessionResponse{Authenticated: h.sessions.Validate(token)}) +} + +func (h *authHandler) login(c *gin.Context) { + if h == nil || !h.config.Enabled { + c.Status(http.StatusNoContent) + return + } + if h.sessions == nil { + writeInternalError(c, "session manager is not configured", nil) + return + } + + var request loginRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + clientKey := loginClientKey(c) + passwordMatches := subtle.ConstantTimeCompare([]byte(request.Password), []byte(h.config.LoginPassword)) == 1 + if h.tooManyFailedAttempts(clientKey) && !passwordMatches { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "too many failed login attempts"}) + return + } + + if !passwordMatches { + h.recordFailedAttempt(clientKey) + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid password"}) + return + } + h.clearFailedAttempts(clientKey) + + token, expiresAt, err := h.sessions.Create() + if err != nil { + writeInternalError(c, "create auth session failed", err) + return + } + + secure := c.Request.TLS != nil || c.GetHeader("X-Forwarded-Proto") == "https" + cookiePath := h.config.BasePath + if cookiePath == "" { + cookiePath = "/" + } + http.SetCookie(c.Writer, &http.Cookie{ + Name: sessionCookieName, + Value: token, + Path: cookiePath, + HttpOnly: true, + Secure: secure, + SameSite: http.SameSiteLaxMode, + Expires: expiresAt, + MaxAge: int(time.Until(expiresAt).Seconds()), + }) + c.Status(http.StatusNoContent) +} + +func (h *authHandler) logout(c *gin.Context) { + if h == nil || !h.config.Enabled { + c.Status(http.StatusNoContent) + return + } + if h.sessions != nil { + if token, err := c.Cookie(sessionCookieName); err == nil { + h.sessions.Delete(token) + } + } + clearSessionCookie(c, h.config.BasePath) + c.Status(http.StatusNoContent) +} + +func (h *authHandler) tooManyFailedAttempts(key string) bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.failedAttempts[key] >= maxFailedLoginAttempts +} + +func (h *authHandler) recordFailedAttempt(key string) { + h.mu.Lock() + defer h.mu.Unlock() + h.failedAttempts[key]++ +} + +func (h *authHandler) clearFailedAttempts(key string) { + h.mu.Lock() + defer h.mu.Unlock() + delete(h.failedAttempts, key) +} + +func loginClientKey(c *gin.Context) string { + host, _, err := net.SplitHostPort(c.Request.RemoteAddr) + if err == nil && host != "" { + return host + } + return c.ClientIP() +} + +func clearSessionCookie(c *gin.Context, basePath string) { + cookiePath := basePath + if cookiePath == "" { + cookiePath = "/" + } + http.SetCookie(c.Writer, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: cookiePath, + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + Expires: time.Unix(0, 0), + MaxAge: -1, + }) +} diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go new file mode 100644 index 0000000000000000000000000000000000000000..650d724f21abc2a39fa2092e43955a9d0dad462b --- /dev/null +++ b/internal/api/auth_test.go @@ -0,0 +1,225 @@ +package api + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/auth" +) + +func TestAuthSessionReportsAuthenticatedWhenDisabled(t *testing.T) { + router := NewRouter(nil, nil, nil, nil, AuthConfig{Enabled: false}, nil, "") + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/session", nil) + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"authenticated":true`) { + t.Fatalf("unexpected response: %d %s", resp.Code, resp.Body.String()) + } +} + +func TestAuthProtectedRouteRequiresSessionWhenEnabled(t *testing.T) { + sessions := auth.NewSessionManager(time.Hour) + config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour} + router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "") + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil) + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", resp.Code) + } +} + +func TestAuthLoginSetsCookieAndUnlocksProtectedRoute(t *testing.T) { + sessions := auth.NewSessionManager(time.Hour) + config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour} + handler := NewAuthHandler(config, sessions) + router := NewRouter(nil, nil, nil, nil, config, handler, "") + + loginResp := httptest.NewRecorder() + loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`)) + loginReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(loginResp, loginReq) + + if loginResp.Code != http.StatusNoContent { + t.Fatalf("expected login status 204, got %d", loginResp.Code) + } + cookie := loginResp.Result().Cookies() + if len(cookie) == 0 { + t.Fatal("expected auth cookie to be set") + } + if cookie[0].Name != sessionCookieName { + t.Fatalf("expected cookie %q, got %q", sessionCookieName, cookie[0].Name) + } + if cookie[0].Path != "/" { + t.Fatalf("expected root cookie path '/', got %q", cookie[0].Path) + } + + usageResp := httptest.NewRecorder() + usageReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil) + usageReq.AddCookie(cookie[0]) + router.ServeHTTP(usageResp, usageReq) + + if usageResp.Code != http.StatusOK { + t.Fatalf("expected protected route to succeed, got %d %s", usageResp.Code, usageResp.Body.String()) + } +} + +func TestAuthLoginRejectsWrongPassword(t *testing.T) { + sessions := auth.NewSessionManager(time.Hour) + config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour} + router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "") + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", resp.Code) + } +} + +func TestAuthLoginRateLimitsRepeatedFailures(t *testing.T) { + sessions := auth.NewSessionManager(time.Hour) + config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour} + router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "") + + for i := 0; i < 5; i++ { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "198.51.100.1:1234" + router.ServeHTTP(resp, req) + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected failed attempt %d to return 401, got %d", i+1, resp.Code) + } + } + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "198.51.100.1:1234" + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusTooManyRequests { + t.Fatalf("expected repeated failed attempts to return 429, got %d", resp.Code) + } +} + +func TestAuthLoginAllowsCorrectPasswordAfterRateLimitThreshold(t *testing.T) { + sessions := auth.NewSessionManager(time.Hour) + config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour} + router := NewRouter(nil, nil, nil, nil, config, NewAuthHandler(config, sessions), "") + + for i := 0; i < 5; i++ { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"wrong"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "198.51.100.2:1234" + router.ServeHTTP(resp, req) + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected failed attempt %d to return 401, got %d", i+1, resp.Code) + } + } + + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`)) + req.Header.Set("Content-Type", "application/json") + req.RemoteAddr = "198.51.100.2:1234" + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNoContent { + t.Fatalf("expected correct password to clear failed attempts and login, got %d", resp.Code) + } +} + +func TestAuthLogoutDeletesSessionCookie(t *testing.T) { + sessions := auth.NewSessionManager(time.Hour) + config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour} + handler := NewAuthHandler(config, sessions) + router := NewRouter(nil, nil, nil, nil, config, handler, "") + + loginResp := httptest.NewRecorder() + loginReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`)) + loginReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(loginResp, loginReq) + if loginResp.Code != http.StatusNoContent { + t.Fatalf("expected login status 204, got %d", loginResp.Code) + } + cookies := loginResp.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("expected auth cookie to be set") + } + + logoutResp := httptest.NewRecorder() + logoutReq := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil) + logoutReq.AddCookie(cookies[0]) + router.ServeHTTP(logoutResp, logoutReq) + if logoutResp.Code != http.StatusNoContent { + t.Fatalf("expected logout status 204, got %d", logoutResp.Code) + } + clearCookies := logoutResp.Result().Cookies() + if len(clearCookies) == 0 || clearCookies[0].Name != sessionCookieName || clearCookies[0].MaxAge >= 0 { + t.Fatalf("expected logout to clear session cookie, got %+v", clearCookies) + } + + usageResp := httptest.NewRecorder() + usageReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil) + usageReq.AddCookie(cookies[0]) + router.ServeHTTP(usageResp, usageReq) + if usageResp.Code != http.StatusUnauthorized { + t.Fatalf("expected logged out session to be rejected, got %d", usageResp.Code) + } +} + +func TestSubpathAuthUsesPrefixedRoutesAndCookiePath(t *testing.T) { + sessions := auth.NewSessionManager(time.Hour) + config := AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour, BasePath: "/cpa"} + handler := NewAuthHandler(config, sessions) + router := NewRouter(nil, nil, nil, nil, config, handler, "/cpa") + + sessionResp := httptest.NewRecorder() + sessionReq := httptest.NewRequest(http.MethodGet, "/cpa/api/v1/auth/session", nil) + router.ServeHTTP(sessionResp, sessionReq) + if sessionResp.Code != http.StatusOK || !contains(sessionResp.Body.String(), `"authenticated":false`) { + t.Fatalf("unexpected session response: %d %s", sessionResp.Code, sessionResp.Body.String()) + } + + loginResp := httptest.NewRecorder() + loginReq := httptest.NewRequest(http.MethodPost, "/cpa/api/v1/auth/login", strings.NewReader(`{"password":"secret"}`)) + loginReq.Header.Set("Content-Type", "application/json") + router.ServeHTTP(loginResp, loginReq) + if loginResp.Code != http.StatusNoContent { + t.Fatalf("expected login status 204, got %d", loginResp.Code) + } + cookies := loginResp.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("expected auth cookie to be set") + } + if cookies[0].Path != "/cpa" { + t.Fatalf("expected subpath cookie path '/cpa', got %q", cookies[0].Path) + } + + usageResp := httptest.NewRecorder() + usageReq := httptest.NewRequest(http.MethodGet, "/cpa/api/v1/usage/overview", nil) + usageReq.AddCookie(cookies[0]) + router.ServeHTTP(usageResp, usageReq) + if usageResp.Code != http.StatusOK { + t.Fatalf("expected protected route under subpath to succeed, got %d %s", usageResp.Code, usageResp.Body.String()) + } + + unprefixedResp := httptest.NewRecorder() + unprefixedReq := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview", nil) + unprefixedReq.AddCookie(cookies[0]) + router.ServeHTTP(unprefixedResp, unprefixedReq) + if unprefixedResp.Code != http.StatusNotFound { + t.Fatalf("expected unprefixed route to 404, got %d", unprefixedResp.Code) + } +} diff --git a/internal/api/errors.go b/internal/api/errors.go new file mode 100644 index 0000000000000000000000000000000000000000..50821f1b5839d88defec5089a64b0990a09acd73 --- /dev/null +++ b/internal/api/errors.go @@ -0,0 +1,15 @@ +package api + +import ( + "log/slog" + "net/http" + + "github.com/gin-gonic/gin" +) + +func writeInternalError(c *gin.Context, message string, err error) { + if err != nil { + slog.Error(message, "error", err) + } + c.JSON(http.StatusInternalServerError, gin.H{"error": "internal server error"}) +} diff --git a/internal/api/health.go b/internal/api/health.go new file mode 100644 index 0000000000000000000000000000000000000000..0cc73130f06b27c7634fd7985333b7445c68d2ec --- /dev/null +++ b/internal/api/health.go @@ -0,0 +1,13 @@ +package api + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +func registerHealthRoutes(router gin.IRoutes) { + router.GET("/healthz", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"status": "ok"}) + }) +} diff --git a/internal/api/pricing.go b/internal/api/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..df6f5ee639ea7f9b1432107825e9f12fa9adf763 --- /dev/null +++ b/internal/api/pricing.go @@ -0,0 +1,145 @@ +package api + +import ( + "net/http" + "strings" + + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +type usedModelsResponse struct { + Models []string `json:"models"` +} + +type pricingEntryResponse struct { + Model string `json:"model"` + PromptPricePer1M float64 `json:"prompt_price_per_1m"` + CompletionPricePer1M float64 `json:"completion_price_per_1m"` + CachePricePer1M float64 `json:"cache_price_per_1m"` +} + +type pricingListResponse struct { + Pricing []pricingEntryResponse `json:"pricing"` +} + +type updatePricingRequest struct { + Model string `json:"model"` + PromptPricePer1M float64 `json:"prompt_price_per_1m"` + CompletionPricePer1M float64 `json:"completion_price_per_1m"` + CachePricePer1M float64 `json:"cache_price_per_1m"` +} + +func registerPricingRoutes(router gin.IRoutes, pricingProvider service.PricingProvider) { + router.GET("/models/used", func(c *gin.Context) { + if pricingProvider == nil { + c.JSON(http.StatusOK, usedModelsResponse{Models: []string{}}) + return + } + + models, err := pricingProvider.ListUsedModels(c.Request.Context()) + if err != nil { + writeInternalError(c, "list used models failed", err) + return + } + + c.JSON(http.StatusOK, usedModelsResponse{Models: models}) + }) + + router.GET("/pricing", func(c *gin.Context) { + if pricingProvider == nil { + c.JSON(http.StatusOK, pricingListResponse{Pricing: []pricingEntryResponse{}}) + return + } + + settings, err := pricingProvider.ListPricing(c.Request.Context()) + if err != nil { + writeInternalError(c, "list pricing failed", err) + return + } + + response := make([]pricingEntryResponse, 0, len(settings)) + for _, setting := range settings { + response = append(response, pricingEntryResponse{ + Model: setting.Model, + PromptPricePer1M: setting.PromptPricePer1M, + CompletionPricePer1M: setting.CompletionPricePer1M, + CachePricePer1M: setting.CachePricePer1M, + }) + } + c.JSON(http.StatusOK, pricingListResponse{Pricing: response}) + }) + + router.PUT("/pricing", func(c *gin.Context) { + updatePricing(c, pricingProvider, "") + }) + + router.PUT("/pricing/:model", func(c *gin.Context) { + updatePricing(c, pricingProvider, c.Param("model")) + }) + + router.DELETE("/pricing", func(c *gin.Context) { + if pricingProvider == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "pricing provider is not configured"}) + return + } + model := strings.TrimSpace(c.Query("model")) + if model == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"}) + return + } + if err := pricingProvider.DeletePricing(c.Request.Context(), model); err != nil { + if strings.Contains(err.Error(), "required") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + writeInternalError(c, "delete pricing failed", err) + return + } + c.Status(http.StatusNoContent) + }) +} + +func updatePricing(c *gin.Context, pricingProvider service.PricingProvider, pathModel string) { + if pricingProvider == nil { + c.JSON(http.StatusNotImplemented, gin.H{"error": "pricing provider is not configured"}) + return + } + + var request updatePricingRequest + if err := c.ShouldBindJSON(&request); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + model := strings.TrimSpace(pathModel) + if model == "" { + model = strings.TrimSpace(request.Model) + } + if model == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "model is required"}) + return + } + + setting, err := pricingProvider.UpdatePricing(c.Request.Context(), service.UpdatePricingInput{ + Model: model, + PromptPricePer1M: request.PromptPricePer1M, + CompletionPricePer1M: request.CompletionPricePer1M, + CachePricePer1M: request.CachePricePer1M, + }) + if err != nil { + if strings.Contains(err.Error(), "has not been used") || strings.Contains(err.Error(), "required") || strings.Contains(err.Error(), "non-negative") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + writeInternalError(c, "update pricing failed", err) + return + } + + c.JSON(http.StatusOK, pricingEntryResponse{ + Model: setting.Model, + PromptPricePer1M: setting.PromptPricePer1M, + CompletionPricePer1M: setting.CompletionPricePer1M, + CachePricePer1M: setting.CachePricePer1M, + }) +} diff --git a/internal/api/pricing_test.go b/internal/api/pricing_test.go new file mode 100644 index 0000000000000000000000000000000000000000..580a8b8a7662de519e747c97b6f91164ae21bdf0 --- /dev/null +++ b/internal/api/pricing_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/service" +) + +type pricingStub struct { + usedModels []string + pricing []models.ModelPriceSetting + updated *models.ModelPriceSetting + lastUpdate *service.UpdatePricingInput + deleted string + err error +} + +func (s pricingStub) ListUsedModels(context.Context) ([]string, error) { + return s.usedModels, s.err +} + +func (s pricingStub) ListPricing(context.Context) ([]models.ModelPriceSetting, error) { + return s.pricing, s.err +} + +func (s *pricingStub) UpdatePricing(_ context.Context, input service.UpdatePricingInput) (*models.ModelPriceSetting, error) { + s.lastUpdate = &input + return s.updated, s.err +} + +func (s *pricingStub) DeletePricing(_ context.Context, model string) error { + s.deleted = model + return s.err +} + +func TestPricingRoutesReturnEmptyResponsesWithoutProvider(t *testing.T) { + router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "") + + usedReq := httptest.NewRequest(http.MethodGet, "/api/v1/models/used", nil) + usedResp := httptest.NewRecorder() + router.ServeHTTP(usedResp, usedReq) + if usedResp.Code != http.StatusOK || !contains(usedResp.Body.String(), `"models":[]`) { + t.Fatalf("unexpected used models response: %d %s", usedResp.Code, usedResp.Body.String()) + } + + pricingReq := httptest.NewRequest(http.MethodGet, "/api/v1/pricing", nil) + pricingResp := httptest.NewRecorder() + router.ServeHTTP(pricingResp, pricingReq) + if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"pricing":[]`) { + t.Fatalf("unexpected pricing response: %d %s", pricingResp.Code, pricingResp.Body.String()) + } +} + +func TestPricingRoutesReturnConfiguredData(t *testing.T) { + router := NewRouter(nil, nil, nil, &pricingStub{ + usedModels: []string{"claude-sonnet"}, + pricing: []models.ModelPriceSetting{{ + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }}, + }, AuthConfig{}, nil, "") + + usedReq := httptest.NewRequest(http.MethodGet, "/api/v1/models/used", nil) + usedResp := httptest.NewRecorder() + router.ServeHTTP(usedResp, usedReq) + if usedResp.Code != http.StatusOK || !contains(usedResp.Body.String(), `claude-sonnet`) { + t.Fatalf("unexpected used models response: %d %s", usedResp.Code, usedResp.Body.String()) + } + + pricingReq := httptest.NewRequest(http.MethodGet, "/api/v1/pricing", nil) + pricingResp := httptest.NewRecorder() + router.ServeHTTP(pricingResp, pricingReq) + if pricingResp.Code != http.StatusOK || !contains(pricingResp.Body.String(), `"prompt_price_per_1m":3`) { + t.Fatalf("unexpected pricing response: %d %s", pricingResp.Code, pricingResp.Body.String()) + } +} + +func TestUpdatePricingRoute(t *testing.T) { + provider := &pricingStub{ + updated: &models.ModelPriceSetting{ + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }, + } + router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "") + + req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing/claude-sonnet", strings.NewReader(`{"prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"claude-sonnet"`) { + t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String()) + } +} + +func TestUpdatePricingRouteAcceptsModelInBody(t *testing.T) { + provider := &pricingStub{ + updated: &models.ModelPriceSetting{ + Model: "openai/gpt-4.1", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }, + } + router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "") + + req := httptest.NewRequest(http.MethodPut, "/api/v1/pricing", strings.NewReader(`{"model":"openai/gpt-4.1","prompt_price_per_1m":3,"completion_price_per_1m":15,"cache_price_per_1m":0.3}`)) + req.Header.Set("Content-Type", "application/json") + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK || !contains(resp.Body.String(), `"model":"openai/gpt-4.1"`) { + t.Fatalf("unexpected update response: %d %s", resp.Code, resp.Body.String()) + } + if provider.lastUpdate == nil || provider.lastUpdate.Model != "openai/gpt-4.1" { + t.Fatalf("expected model from body to be passed through, got %+v", provider.lastUpdate) + } +} + +func TestDeletePricingRoute(t *testing.T) { + provider := &pricingStub{} + router := NewRouter(nil, nil, nil, provider, AuthConfig{}, nil, "") + + req := httptest.NewRequest(http.MethodDelete, "/api/v1/pricing?model=openai%2Fgpt-4.1", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNoContent { + t.Fatalf("expected status 204, got %d %s", resp.Code, resp.Body.String()) + } + if provider.deleted != "openai/gpt-4.1" { + t.Fatalf("expected model to be deleted, got %q", provider.deleted) + } +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000000000000000000000000000000000000..3477de11e9a55d86d1ef1715089cab90866276f9 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,272 @@ +package api + +import ( + "bytes" + "context" + "errors" + "io" + "io/fs" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" + "time" + + "cpa-usage-keeper/internal/poller" + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +const appBasePathPlaceholder = "__APP_BASE_PATH__" +const manualSyncRateLimitWindow = time.Second + +type syncLimiter struct { + mu sync.Mutex + window time.Duration + lastSync time.Time +} + +func (l *syncLimiter) allow(now time.Time) bool { + l.mu.Lock() + defer l.mu.Unlock() + if !l.lastSync.IsZero() && now.Sub(l.lastSync) < l.window { + return false + } + l.lastSync = now + return true +} + +type StatusProvider interface { + Status() poller.Status +} + +type SyncRunner interface { + SyncNow(ctx context.Context) error +} + +type syncUserMessageError interface { + UserMessage() string +} + +func NewRouter( + staticFS fs.FS, + statusProvider StatusProvider, + usageProvider service.UsageProvider, + pricingProvider service.PricingProvider, + authConfig AuthConfig, + authHandler *authHandler, + basePath string, + usageIdentityProviders ...service.UsageIdentityProvider, +) *gin.Engine { + router := gin.New() + router.Use(gin.Recovery()) + + appGroup := router.Group(basePath) + registerHealthRoutes(appGroup) + + apiV1 := appGroup.Group("/api/v1") + apiV1.GET("/ping", func(c *gin.Context) { + c.JSON(http.StatusOK, gin.H{"message": "ok"}) + }) + + authGroup := apiV1.Group("/auth") + if authHandler == nil { + authHandler = NewAuthHandler(authConfig, nil) + } + authHandler.registerRoutes(authGroup) + + var usageIdentityProvider service.UsageIdentityProvider + if len(usageIdentityProviders) > 0 { + usageIdentityProvider = usageIdentityProviders[0] + } + + protected := apiV1.Group("") + protected.Use(authHandler.middleware()) + registerStatusRoutes(protected, statusProvider) + registerSyncRoutes(protected, statusProvider, &syncLimiter{window: manualSyncRateLimitWindow}) + registerUsageOverviewRoute(protected, usageProvider) + registerUsageAnalysisRoute(protected, usageProvider) + registerUsageEventsRoute(protected, usageProvider, usageIdentityProvider) + registerUsageCredentialsRoute(protected, usageProvider, usageIdentityProvider) + registerUsageIdentityRoutes(protected, usageIdentityProvider) + registerPricingRoutes(protected, pricingProvider) + + if staticFS != nil { + if indexFile, err := staticFS.Open("index.html"); err == nil { + _ = indexFile.Close() + httpFS := http.FS(staticFS) + serveIndex := func(c *gin.Context) { + indexHTML, err := renderIndexHTML(staticFS, basePath) + if err != nil { + c.Status(http.StatusNotFound) + return + } + c.Data(http.StatusOK, "text/html; charset=utf-8", indexHTML) + } + + appGroup.GET("/", serveIndex) + assetsFS, _ := fs.Sub(staticFS, "assets") + appGroup.StaticFS("/assets", http.FS(assetsFS)) + router.NoRoute(func(c *gin.Context) { + requestPath, ok := stripBasePath(basePath, c.Request.URL.Path) + if !ok { + c.Status(http.StatusNotFound) + return + } + if strings.HasPrefix(requestPath, "/api/") { + c.Status(http.StatusNotFound) + return + } + + if assetPath, ok := staticAssetPath(requestPath); ok { + if assetFile, err := staticFS.Open(assetPath); err == nil { + _ = assetFile.Close() + c.FileFromFS(assetPath, httpFS) + return + } + } + + serveIndex(c) + }) + } + } + + return router +} + +func renderIndexHTML(staticFS fs.FS, basePath string) ([]byte, error) { + indexFile, err := staticFS.Open("index.html") + if err != nil { + return nil, err + } + defer indexFile.Close() + indexHTML, err := io.ReadAll(indexFile) + if err != nil { + return nil, err + } + + return bytes.ReplaceAll( + indexHTML, + []byte(strconv.Quote(appBasePathPlaceholder)), + []byte(strconv.Quote(basePath)), + ), nil +} + +func cleanURLPath(requestPath string) string { + cleaned := path.Clean(requestPath) + if cleaned == "." { + return "/" + } + if !strings.HasPrefix(cleaned, "/") { + return "/" + cleaned + } + return cleaned +} + +func staticAssetPath(requestPath string) (string, bool) { + cleaned := cleanURLPath(requestPath) + if strings.Contains(cleaned, "\\") { + return "", false + } + relPath := strings.TrimPrefix(cleaned, "/") + if relPath == "" { + return "", false + } + return relPath, true +} + +func stripBasePath(basePath, requestPath string) (string, bool) { + cleaned := cleanURLPath(requestPath) + if basePath == "" { + return cleaned, true + } + if cleaned == basePath { + return "/", true + } + if !strings.HasPrefix(cleaned, basePath+"/") { + return "", false + } + trimmed := strings.TrimPrefix(cleaned, basePath) + if trimmed == "" { + return "/", true + } + return trimmed, true +} + +type statusResponse struct { + Running bool `json:"running"` + SyncRunning bool `json:"sync_running"` + Timezone string `json:"timezone"` + LastRunAt *time.Time `json:"last_run_at,omitempty"` + LastError string `json:"last_error,omitempty"` + LastWarning string `json:"last_warning,omitempty"` + LastStatus string `json:"last_status,omitempty"` +} + +func registerStatusRoutes(router gin.IRoutes, statusProvider StatusProvider) { + router.GET("/status", func(c *gin.Context) { + if statusProvider == nil { + c.JSON(http.StatusOK, statusResponse{Timezone: time.Local.String()}) + return + } + + c.JSON(http.StatusOK, buildStatusResponse(statusProvider.Status())) + }) +} + +func manualSyncErrorMessage(err error) string { + var userMessage syncUserMessageError + if errors.As(err, &userMessage) && userMessage.UserMessage() != "" { + return userMessage.UserMessage() + } + return "manual sync failed" +} + +func registerSyncRoutes(router gin.IRoutes, statusProvider StatusProvider, limiter *syncLimiter) { + router.POST("/sync", func(c *gin.Context) { + if limiter != nil && !limiter.allow(time.Now()) { + c.JSON(http.StatusTooManyRequests, gin.H{"error": "sync rate limit exceeded"}) + return + } + + syncRunner, ok := statusProvider.(SyncRunner) + if !ok || syncRunner == nil { + writeInternalError(c, "sync runner is not configured", nil) + return + } + + if err := syncRunner.SyncNow(c.Request.Context()); err != nil { + if errors.Is(err, poller.ErrSyncAlreadyRunning) { + c.JSON(http.StatusConflict, gin.H{"error": "sync already running"}) + return + } + slog.Error("manual sync failed", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{"error": manualSyncErrorMessage(err)}) + return + } + + if statusProvider, ok := syncRunner.(StatusProvider); ok { + c.JSON(http.StatusOK, buildStatusResponse(statusProvider.Status())) + return + } + c.JSON(http.StatusOK, gin.H{"sync_running": false}) + }) +} + +func buildStatusResponse(status poller.Status) statusResponse { + response := statusResponse{ + Running: status.Running, + SyncRunning: status.SyncRunning, + Timezone: time.Local.String(), + LastError: status.LastError, + LastWarning: status.LastWarning, + LastStatus: status.LastStatus, + } + if !status.LastRunAt.IsZero() { + lastRunAt := status.LastRunAt.UTC() + response.LastRunAt = &lastRunAt + } + return response +} diff --git a/internal/api/router_test.go b/internal/api/router_test.go new file mode 100644 index 0000000000000000000000000000000000000000..c28d2a64cd159baede9c4ed978792c9c2796cfb1 --- /dev/null +++ b/internal/api/router_test.go @@ -0,0 +1,321 @@ +package api + +import ( + "context" + "io/fs" + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" + "time" + + "cpa-usage-keeper/internal/poller" +) + +func testStaticFS(t *testing.T, files map[string]string) fs.FS { + t.Helper() + staticFS := fstest.MapFS{} + for name, content := range files { + staticFS[name] = &fstest.MapFile{Data: []byte(content), Mode: 0o644} + } + return staticFS +} + +type statusStub struct { + status poller.Status +} + +type syncStatusStub struct { + status poller.Status + calls int + err error +} + +type userFacingSyncError struct { + message string +} + +func (e userFacingSyncError) Error() string { + return e.message +} + +func (e userFacingSyncError) UserMessage() string { + return e.message +} + +func (s statusStub) Status() poller.Status { + return s.status +} + +func (s *syncStatusStub) Status() poller.Status { + return s.status +} + +func (s *syncStatusStub) SyncNow(context.Context) error { + s.calls++ + return s.err +} + +func TestHealthzReturnsOK(t *testing.T) { + router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/healthz", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } +} + +func TestStatusReturnsPollerState(t *testing.T) { + lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + router := NewRouter(nil, statusStub{status: poller.Status{ + Running: true, + SyncRunning: false, + LastRunAt: lastRunAt, + LastError: "boom", + LastWarning: "metadata unavailable", + LastStatus: "completed_with_warnings", + }}, nil, nil, AuthConfig{}, nil, "") + + req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + body := resp.Body.String() + if !(contains(body, `"running":true`) && contains(body, `"sync_running":false`) && contains(body, `"last_error":"boom"`) && contains(body, `"last_warning":"metadata unavailable"`) && contains(body, `"last_status":"completed_with_warnings"`) && contains(body, `"last_run_at":"2026-04-16T12:00:00Z"`)) { + t.Fatalf("unexpected response body: %s", body) + } +} + +func TestStatusReturnsProjectTimezone(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + t.Cleanup(func() { time.Local = previousLocal }) + time.Local = location + + router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + if body := resp.Body.String(); !contains(body, `"timezone":"Asia/Shanghai"`) { + t.Fatalf("expected status response to include project timezone, got %s", body) + } +} + +func TestStatusReturnsEmptyStateWithoutProvider(t *testing.T) { + router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/status", nil) + resp := httptest.NewRecorder() + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + if body := resp.Body.String(); !contains(body, `"running":false`) || !contains(body, `"sync_running":false`) || !contains(body, `"timezone":`) { + t.Fatalf("unexpected response body: %s", body) + } +} + +func TestManualSyncTriggersSyncRunner(t *testing.T) { + lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + syncer := &syncStatusStub{status: poller.Status{Running: true, LastRunAt: lastRunAt, LastStatus: "completed"}} + router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + if syncer.calls != 1 { + t.Fatalf("expected sync runner to be called once, got %d", syncer.calls) + } + body := resp.Body.String() + if !(contains(body, `"last_status":"completed"`) && contains(body, `"last_run_at":"2026-04-16T12:00:00Z"`)) { + t.Fatalf("unexpected response body: %s", body) + } +} + +func TestManualSyncReturnsConflictWhenAlreadyRunning(t *testing.T) { + syncer := &syncStatusStub{err: poller.ErrSyncAlreadyRunning} + router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusConflict { + t.Fatalf("expected status 409, got %d", resp.Code) + } +} + +func TestManualSyncReturnsWarningsAsError(t *testing.T) { + syncer := &syncStatusStub{ + status: poller.Status{LastStatus: "completed_with_warnings", LastWarning: "metadata unavailable"}, + err: poller.ErrSyncCompletedWithWarnings, + } + router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", resp.Code) + } + if body := resp.Body.String(); !contains(body, `"error":"manual sync failed"`) { + t.Fatalf("unexpected response body: %s", body) + } +} + +func TestManualSyncReturnsUserFacingStageError(t *testing.T) { + syncer := &syncStatusStub{err: userFacingSyncError{message: "metadata sync failed"}} + router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusInternalServerError { + t.Fatalf("expected status 500, got %d", resp.Code) + } + if body := resp.Body.String(); !contains(body, `"error":"metadata sync failed"`) { + t.Fatalf("unexpected response body: %s", body) + } +} + +func TestManualSyncRateLimitsRepeatedRequests(t *testing.T) { + syncer := &syncStatusStub{status: poller.Status{LastStatus: "completed"}} + router := NewRouter(nil, syncer, nil, nil, AuthConfig{}, nil, "") + + firstResp := httptest.NewRecorder() + firstReq := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil) + router.ServeHTTP(firstResp, firstReq) + if firstResp.Code != http.StatusOK { + t.Fatalf("expected first sync to return 200, got %d", firstResp.Code) + } + + secondResp := httptest.NewRecorder() + secondReq := httptest.NewRequest(http.MethodPost, "/api/v1/sync", nil) + router.ServeHTTP(secondResp, secondReq) + if secondResp.Code != http.StatusTooManyRequests { + t.Fatalf("expected second sync within one second to return 429, got %d", secondResp.Code) + } + if syncer.calls != 1 { + t.Fatalf("expected rate-limited sync not to call runner again, got %d calls", syncer.calls) + } +} + +func TestSubpathRoutesOnlyServePrefixedEndpoints(t *testing.T) { + lastRunAt := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + router := NewRouter(nil, statusStub{status: poller.Status{ + Running: true, + LastRunAt: lastRunAt, + }}, nil, nil, AuthConfig{BasePath: "/cpa"}, nil, "/cpa") + + for _, testCase := range []struct { + path string + statusCode int + }{ + {path: "/cpa/healthz", statusCode: http.StatusOK}, + {path: "/cpa/api/v1/status", statusCode: http.StatusOK}, + {path: "/healthz", statusCode: http.StatusNotFound}, + {path: "/api/v1/status", statusCode: http.StatusNotFound}, + } { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, testCase.path, nil) + router.ServeHTTP(resp, req) + if resp.Code != testCase.statusCode { + t.Fatalf("expected %s to return %d, got %d", testCase.path, testCase.statusCode, resp.Code) + } + } +} + +func TestSubpathStaticRoutesServeOnlyUnderPrefix(t *testing.T) { + staticFS := testStaticFS(t, map[string]string{ + "index.html": `app`, + "assets/app.js": "console.log('ok')", + }) + + router := NewRouter(staticFS, nil, nil, nil, AuthConfig{BasePath: "/cpa"}, nil, "/cpa") + + for _, testCase := range []struct { + path string + statusCode int + contains string + }{ + {path: "/cpa/", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`}, + {path: "/cpa/dashboard", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`}, + {path: "/cpa/assets/app.js", statusCode: http.StatusOK, contains: "console.log('ok')"}, + {path: "/cpa/missing.html", statusCode: http.StatusOK, contains: `window.__APP_BASE_PATH__ = "/cpa";`}, + {path: "/foo", statusCode: http.StatusNotFound}, + {path: "/assets/app.js", statusCode: http.StatusNotFound}, + {path: "/cpa/api/unknown", statusCode: http.StatusNotFound}, + } { + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, testCase.path, nil) + router.ServeHTTP(resp, req) + if resp.Code != testCase.statusCode { + t.Fatalf("expected %s to return %d, got %d", testCase.path, testCase.statusCode, resp.Code) + } + if testCase.contains != "" && !contains(resp.Body.String(), testCase.contains) { + t.Fatalf("expected %s response to contain %q, got %s", testCase.path, testCase.contains, resp.Body.String()) + } + } +} + +func TestCleanURLPathUsesSlashSemantics(t *testing.T) { + if cleaned := cleanURLPath("/cpa//dashboard/../assets/app.js"); cleaned != "/cpa/assets/app.js" { + t.Fatalf("expected slash-normalized URL path, got %q", cleaned) + } +} + +func TestStaticAssetPathRejectsBackslashTraversal(t *testing.T) { + if _, ok := staticAssetPath(`/..\.env`); ok { + t.Fatal("expected backslash traversal path to be rejected") + } +} + +func TestRootStaticRouteInjectsEmptyBasePath(t *testing.T) { + staticFS := testStaticFS(t, map[string]string{ + "index.html": `app`, + }) + + router := NewRouter(staticFS, nil, nil, nil, AuthConfig{}, nil, "") + resp := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + if !contains(resp.Body.String(), `window.__APP_BASE_PATH__ = "";`) { + t.Fatalf("expected injected empty base path, got %s", resp.Body.String()) + } +} + +func contains(s, sub string) bool { + return len(sub) == 0 || (len(s) >= len(sub) && (func() bool { return stringContains(s, sub) })()) +} + +func stringContains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/internal/api/usage_analysis.go b/internal/api/usage_analysis.go new file mode 100644 index 0000000000000000000000000000000000000000..f96a1b40b75ae7bc4992f2429c6ab1271740e6d3 --- /dev/null +++ b/internal/api/usage_analysis.go @@ -0,0 +1,126 @@ +package api + +import ( + "net/http" + "time" + + "cpa-usage-keeper/internal/redact" + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +type usageAnalysisResponse struct { + APIs []usageAnalysisAPIPayload `json:"apis"` + Models []usageAnalysisModelPayload `json:"models"` +} + +type usageAnalysisAPIPayload struct { + APIKey string `json:"api_key"` + DisplayName string `json:"display_name"` + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CachedTokens int64 `json:"cached_tokens"` + TotalTokens int64 `json:"total_tokens"` + Models []usageAnalysisModelPayload `json:"models"` +} + +type usageAnalysisModelPayload struct { + Model string `json:"model"` + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CachedTokens int64 `json:"cached_tokens"` + TotalTokens int64 `json:"total_tokens"` + TotalLatencyMS int64 `json:"total_latency_ms"` + LatencySampleCount int64 `json:"latency_sample_count"` +} + +func registerUsageAnalysisRoute(router gin.IRoutes, usageProvider service.UsageProvider) { + router.GET("/usage/analysis", func(c *gin.Context) { + if usageProvider == nil { + c.JSON(http.StatusOK, usageAnalysisResponse{APIs: []usageAnalysisAPIPayload{}, Models: []usageAnalysisModelPayload{}}) + return + } + + filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC()) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + analysis, err := usageProvider.GetUsageAnalysis(c.Request.Context(), filter) + if err != nil { + writeInternalError(c, "get usage analysis failed", err) + return + } + + c.JSON(http.StatusOK, buildUsageAnalysisPayload(analysis)) + }) +} + +func buildUsageAnalysisPayload(snapshot *service.UsageAnalysisSnapshot) usageAnalysisResponse { + if snapshot == nil { + return usageAnalysisResponse{APIs: []usageAnalysisAPIPayload{}, Models: []usageAnalysisModelPayload{}} + } + + apis := make([]usageAnalysisAPIPayload, 0, len(snapshot.APIs)) + for _, api := range snapshot.APIs { + models := make([]usageAnalysisModelPayload, 0, len(api.Models)) + for _, model := range api.Models { + models = append(models, usageAnalysisModelPayload{ + Model: model.Model, + TotalRequests: model.TotalRequests, + SuccessCount: model.SuccessCount, + FailureCount: model.FailureCount, + InputTokens: model.InputTokens, + OutputTokens: model.OutputTokens, + ReasoningTokens: model.ReasoningTokens, + CachedTokens: model.CachedTokens, + TotalTokens: model.TotalTokens, + TotalLatencyMS: model.TotalLatencyMS, + LatencySampleCount: model.LatencySampleCount, + }) + } + apiKey := redact.APIAlias(api.APIKey) + displayName := redact.APIKeyDisplayName(api.APIKey) + apis = append(apis, usageAnalysisAPIPayload{ + APIKey: apiKey, + DisplayName: displayName, + TotalRequests: api.TotalRequests, + SuccessCount: api.SuccessCount, + FailureCount: api.FailureCount, + InputTokens: api.InputTokens, + OutputTokens: api.OutputTokens, + ReasoningTokens: api.ReasoningTokens, + CachedTokens: api.CachedTokens, + TotalTokens: api.TotalTokens, + Models: models, + }) + } + + models := make([]usageAnalysisModelPayload, 0, len(snapshot.Models)) + for _, model := range snapshot.Models { + models = append(models, usageAnalysisModelPayload{ + Model: model.Model, + TotalRequests: model.TotalRequests, + SuccessCount: model.SuccessCount, + FailureCount: model.FailureCount, + InputTokens: model.InputTokens, + OutputTokens: model.OutputTokens, + ReasoningTokens: model.ReasoningTokens, + CachedTokens: model.CachedTokens, + TotalTokens: model.TotalTokens, + TotalLatencyMS: model.TotalLatencyMS, + LatencySampleCount: model.LatencySampleCount, + }) + } + + return usageAnalysisResponse{APIs: apis, Models: models} +} diff --git a/internal/api/usage_analysis_test.go b/internal/api/usage_analysis_test.go new file mode 100644 index 0000000000000000000000000000000000000000..3a2c8ae9465e3d7f270dc86eab3a34327973f59f --- /dev/null +++ b/internal/api/usage_analysis_test.go @@ -0,0 +1,128 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/redact" + "cpa-usage-keeper/internal/service" +) + +type usageAnalysisStub struct { + analysis *service.UsageAnalysisSnapshot + err error + lastFilter service.UsageFilter + analysisCalls int +} + +func (s *usageAnalysisStub) GetUsageWithFilter(context.Context, service.UsageFilter) (*cpa.StatisticsSnapshot, error) { + return nil, nil +} + +func (s *usageAnalysisStub) GetUsageOverview(context.Context, service.UsageFilter) (*service.UsageOverviewSnapshot, error) { + return nil, nil +} + +func (s *usageAnalysisStub) ListUsageEvents(context.Context, service.UsageFilter) (*service.UsageEventsPage, error) { + return nil, nil +} + +func (s *usageAnalysisStub) ListUsageEventFilterOptions(context.Context, service.UsageFilter) (*service.UsageEventFilterOptions, error) { + return nil, nil +} + +func (s *usageAnalysisStub) ListUsageCredentialStats(context.Context, service.UsageFilter) ([]service.UsageCredentialStat, error) { + return nil, nil +} + +func (s *usageAnalysisStub) GetUsageAnalysis(_ context.Context, filter service.UsageFilter) (*service.UsageAnalysisSnapshot, error) { + s.lastFilter = filter + s.analysisCalls++ + return s.analysis, s.err +} + +func TestUsageAnalysisReturnsAggregatedRows(t *testing.T) { + provider := &usageAnalysisStub{analysis: &service.UsageAnalysisSnapshot{ + APIs: []service.UsageAnalysisAPIStat{{ + APIKey: "provider-a", + DisplayName: "provider-a", + TotalRequests: 2, + SuccessCount: 1, + FailureCount: 1, + TotalTokens: 42, + Models: []service.UsageAnalysisModelStat{{ + Model: "claude-sonnet", + TotalRequests: 2, + SuccessCount: 1, + FailureCount: 1, + InputTokens: 30, + OutputTokens: 9, + ReasoningTokens: 2, + CachedTokens: 1, + TotalTokens: 42, + TotalLatencyMS: 350, + LatencySampleCount: 2, + }}, + }}, + Models: []service.UsageAnalysisModelStat{{ + Model: "claude-sonnet", + TotalRequests: 2, + SuccessCount: 1, + FailureCount: 1, + InputTokens: 30, + OutputTokens: 9, + ReasoningTokens: 2, + CachedTokens: 1, + TotalTokens: 42, + TotalLatencyMS: 350, + LatencySampleCount: 2, + }}, + }} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/analysis?range=24h", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + body := resp.Body.String() + if !contains(body, `"apis":[`) || !contains(body, `"models":[`) { + t.Fatalf("unexpected response body: %s", body) + } + if !contains(body, `"display_name":"prov**er-a"`) { + t.Fatalf("expected display name in response body: %s", body) + } + if !contains(body, `"api_key":"`+redact.APIAlias("provider-a")+`"`) { + t.Fatalf("expected redacted api key alias in response body: %s", body) + } + if !contains(body, `"model":"claude-sonnet"`) || !contains(body, `"latency_sample_count":2`) || !contains(body, `"total_latency_ms":350`) { + t.Fatalf("expected model latency aggregates in response body: %s", body) + } + if provider.analysisCalls != 1 { + t.Fatalf("expected GetUsageAnalysis to be called once, got %d", provider.analysisCalls) + } + if provider.lastFilter.Range != "24h" { + t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter) + } + if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil { + t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter) + } +} + +func TestUsageAnalysisRequiresAuthWhenEnabled(t *testing.T) { + router := NewRouter(nil, nil, &usageAnalysisStub{}, nil, AuthConfig{Enabled: true, LoginPassword: "secret", SessionTTL: time.Hour}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/analysis", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusUnauthorized { + t.Fatalf("expected status 401, got %d", resp.Code) + } +} diff --git a/internal/api/usage_credentials.go b/internal/api/usage_credentials.go new file mode 100644 index 0000000000000000000000000000000000000000..b554f947842047ed129a398767df2fca7ee3da28 --- /dev/null +++ b/internal/api/usage_credentials.go @@ -0,0 +1,93 @@ +package api + +import ( + "net/http" + "time" + + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +type usageCredentialsResponse struct { + Credentials []usageCredentialPayload `json:"credentials"` +} + +type usageCredentialPayload struct { + Source string `json:"source"` + SourceType string `json:"source_type,omitempty"` + SourceKey string `json:"source_key,omitempty"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalCount int64 `json:"total_count"` +} + +func registerUsageCredentialsRoute( + router gin.IRoutes, + usageProvider service.UsageProvider, + usageIdentityProvider service.UsageIdentityProvider, +) { + router.GET("/usage/credentials", func(c *gin.Context) { + if usageProvider == nil { + c.JSON(http.StatusOK, usageCredentialsResponse{Credentials: []usageCredentialPayload{}}) + return + } + + filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC()) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rows, err := usageProvider.ListUsageCredentialStats(c.Request.Context(), filter) + if err != nil { + writeInternalError(c, "list usage credential stats failed", err) + return + } + + identities, err := loadUsageResolutionData(c, usageIdentityProvider) + if err != nil { + writeInternalError(c, "load usage resolution data failed", err) + return + } + resolver := newUsageSourceResolver(identities) + c.JSON(http.StatusOK, usageCredentialsResponse{Credentials: buildUsageCredentialsPayload(rows, resolver)}) + }) +} + +func buildUsageCredentialsPayload(rows []service.UsageCredentialStat, resolver usageSourceResolver) []usageCredentialPayload { + if len(rows) == 0 { + return []usageCredentialPayload{} + } + + buckets := make(map[string]*usageCredentialPayload, len(rows)) + orderedKeys := make([]string, 0, len(rows)) + for _, row := range rows { + resolved := resolver.resolve(row.Source, row.AuthIndex) + bucketKey := resolved.SourceKey + if bucketKey == "" { + bucketKey = resolved.DisplayName + } + payload, ok := buckets[bucketKey] + if !ok { + payload = &usageCredentialPayload{ + Source: resolved.DisplayName, + SourceType: resolved.SourceType, + SourceKey: resolved.SourceKey, + } + buckets[bucketKey] = payload + orderedKeys = append(orderedKeys, bucketKey) + } + if row.Failed { + payload.FailureCount += row.RequestCount + } else { + payload.SuccessCount += row.RequestCount + } + payload.TotalCount = payload.SuccessCount + payload.FailureCount + } + + result := make([]usageCredentialPayload, 0, len(orderedKeys)) + for _, key := range orderedKeys { + result = append(result, *buckets[key]) + } + return result +} diff --git a/internal/api/usage_events.go b/internal/api/usage_events.go new file mode 100644 index 0000000000000000000000000000000000000000..057bb8e5b003d59247f3a30ad016d3a09cd0d8eb --- /dev/null +++ b/internal/api/usage_events.go @@ -0,0 +1,245 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + "time" + + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +type usageEventsResponse struct { + Events []usageEventPayload `json:"events"` + Models []string `json:"models"` + Sources []usageSourceFilterOption `json:"sources"` + TotalCount int64 `json:"total_count"` + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalPages int `json:"total_pages"` +} + +type usageSourceFilterOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type usageEventFilterOptionsResponse struct { + Models []string `json:"models"` + Sources []usageSourceFilterOption `json:"sources"` +} + +type usageEventPayload struct { + ID uint `json:"id,omitempty"` + Timestamp string `json:"timestamp"` + Model string `json:"model"` + Source string `json:"source"` + SourceRaw string `json:"source_raw,omitempty"` + SourceType string `json:"source_type,omitempty"` + SourceKey string `json:"source_key,omitempty"` + AuthIndex string `json:"auth_index,omitempty"` + Failed bool `json:"failed"` + LatencyMS int64 `json:"latency_ms"` + Tokens usageEventTokenPayload `json:"tokens"` +} + +type usageEventTokenPayload struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CachedTokens int64 `json:"cached_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +func registerUsageEventsRoute( + router gin.IRoutes, + usageProvider service.UsageProvider, + usageIdentityProvider service.UsageIdentityProvider, +) { + router.GET("/usage/events/filters", func(c *gin.Context) { + if usageProvider == nil { + c.JSON(http.StatusOK, usageEventFilterOptionsResponse{Models: []string{}, Sources: []usageSourceFilterOption{}}) + return + } + + options, err := usageProvider.ListUsageEventFilterOptions(c.Request.Context(), service.UsageFilter{}) + if err != nil { + writeInternalError(c, "list usage event filter options failed", err) + return + } + + identities, err := loadUsageResolutionData(c, usageIdentityProvider) + if err != nil { + writeInternalError(c, "load usage resolution data failed", err) + return + } + c.JSON(http.StatusOK, usageEventFilterOptionsResponse{ + Models: options.Models, + Sources: buildUsageSourceFilterOptions(options.Sources, identities), + }) + }) + + router.GET("/usage/events", func(c *gin.Context) { + if usageProvider == nil { + c.JSON(http.StatusOK, usageEventsResponse{Events: []usageEventPayload{}, Models: []string{}, Sources: []usageSourceFilterOption{}, Page: 1, PageSize: service.DefaultUsageEventsLimit}) + return + } + + filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC()) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if err := applyUsageEventsSourceFilter(&filter); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + rows, err := usageProvider.ListUsageEvents(c.Request.Context(), filter) + if err != nil { + writeInternalError(c, "list usage events failed", err) + return + } + + identities, err := loadUsageResolutionData(c, usageIdentityProvider) + if err != nil { + writeInternalError(c, "load usage resolution data failed", err) + return + } + c.JSON(http.StatusOK, usageEventsResponse{ + Events: buildUsageEventsPayload(rows.Events), + Models: rows.Models, + Sources: buildUsageSourceFilterOptions(rows.Sources, identities), + TotalCount: rows.TotalCount, + Page: rows.Page, + PageSize: rows.PageSize, + TotalPages: rows.TotalPages, + }) + }) +} + +func applyUsageEventsSourceFilter(filter *service.UsageFilter) error { + if filter == nil { + return nil + } + source := strings.TrimSpace(filter.Source) + if source == "" { + return nil + } + if value, ok := strings.CutPrefix(source, "auth:"); ok { + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("source auth filter value is required") + } + filter.AuthType = "oauth" + filter.AuthIndex = value + filter.Source = value + filter.Provider = "" + return nil + } + if value, ok := strings.CutPrefix(source, "provider:"); ok { + value = strings.TrimSpace(value) + if value == "" { + return fmt.Errorf("source provider filter value is required") + } + filter.AuthType = "apikey" + filter.Provider = value + filter.Source = "" + filter.AuthIndex = "" + } + return nil +} + +func buildUsageEventsPayload(rows []service.UsageEventRecord) []usageEventPayload { + if len(rows) == 0 { + return []usageEventPayload{} + } + payload := make([]usageEventPayload, 0, len(rows)) + for _, row := range rows { + source, sourceKey := usageEventPublicSource(row) + payload = append(payload, usageEventPayload{ + ID: row.ID, + Timestamp: row.Timestamp.UTC().Format(time.RFC3339), + Model: row.Model, + Source: source, + SourceKey: sourceKey, + AuthIndex: row.AuthIndex, + Failed: row.Failed, + LatencyMS: row.LatencyMS, + Tokens: usageEventTokenPayload{ + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + ReasoningTokens: row.ReasoningTokens, + CachedTokens: row.CachedTokens, + TotalTokens: row.TotalTokens, + }, + }) + } + return payload +} + +func usageEventPublicSource(row service.UsageEventRecord) (string, string) { + switch strings.TrimSpace(row.AuthType) { + case "apikey": + provider := strings.TrimSpace(row.Provider) + if provider == "" { + provider = "AI Provider" + } + return provider, "provider:" + provider + case "oauth": + source := firstNonEmptyString(row.Source, row.AuthIndex, "unknown") + return source, "auth:" + source + default: + if provider := strings.TrimSpace(row.Provider); provider != "" { + return provider, "provider:" + provider + } + source := firstNonEmptyString(row.Source, row.AuthIndex, "unknown") + return source, "auth:" + source + } +} + +func buildUsageSourceFilterOptions(sources []string, identities []models.UsageIdentity) []usageSourceFilterOption { + if len(identities) == 0 { + return []usageSourceFilterOption{} + } + options := make([]usageSourceFilterOption, 0, len(identities)) + seen := make(map[string]struct{}, len(identities)) + for _, identity := range identities { + if identity.TotalRequests == 0 { + continue + } + option, ok := usageSourceFilterOptionFromIdentity(identity) + if !ok { + continue + } + if _, exists := seen[option.Value]; exists { + continue + } + seen[option.Value] = struct{}{} + options = append(options, option) + } + return options +} + +func usageSourceFilterOptionFromIdentity(identity models.UsageIdentity) (usageSourceFilterOption, bool) { + switch identity.AuthType { + case models.UsageIdentityAuthTypeAuthFile: + value := strings.TrimSpace(identity.Identity) + if value == "" { + return usageSourceFilterOption{}, false + } + label := firstNonEmptyString(identity.Name, value) + return usageSourceFilterOption{Value: "auth:" + value, Label: label}, true + case models.UsageIdentityAuthTypeAIProvider: + provider := safeAIProviderDisplayValue(identity.Provider, identity.Identity, "") + label := firstNonEmptyString(provider, safeAIProviderDisplayValue(identity.Name, identity.Identity, ""), safeAIProviderDisplayValue(identity.Type, identity.Identity, "")) + if label == "" { + return usageSourceFilterOption{}, false + } + return usageSourceFilterOption{Value: "provider:" + label, Label: label}, true + default: + return usageSourceFilterOption{}, false + } +} diff --git a/internal/api/usage_events_test.go b/internal/api/usage_events_test.go new file mode 100644 index 0000000000000000000000000000000000000000..7c881fdcfa2f8e88695fb25225e2f8096799a11a --- /dev/null +++ b/internal/api/usage_events_test.go @@ -0,0 +1,295 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/service" +) + +type usageEventsStub struct { + events []service.UsageEventRecord + eventsPage *service.UsageEventsPage + eventFilterOptions *service.UsageEventFilterOptions + credentialStats []service.UsageCredentialStat + err error + lastFilter service.UsageFilter + filterCalls int + filterOptionCalls int + credentialsCalls int +} + +func (s *usageEventsStub) GetUsageWithFilter(context.Context, service.UsageFilter) (*cpa.StatisticsSnapshot, error) { + return nil, nil +} + +func (s *usageEventsStub) GetUsageOverview(context.Context, service.UsageFilter) (*service.UsageOverviewSnapshot, error) { + return nil, nil +} + +func (s *usageEventsStub) ListUsageEvents(_ context.Context, filter service.UsageFilter) (*service.UsageEventsPage, error) { + s.lastFilter = filter + s.filterCalls++ + if s.eventsPage != nil { + return s.eventsPage, s.err + } + return &service.UsageEventsPage{Events: s.events, TotalCount: int64(len(s.events)), Page: 1, PageSize: service.DefaultUsageEventsLimit, TotalPages: 1}, s.err +} + +func (s *usageEventsStub) ListUsageEventFilterOptions(_ context.Context, filter service.UsageFilter) (*service.UsageEventFilterOptions, error) { + s.lastFilter = filter + s.filterOptionCalls++ + if s.eventFilterOptions != nil { + return s.eventFilterOptions, s.err + } + return &service.UsageEventFilterOptions{}, s.err +} + +func (s *usageEventsStub) ListUsageCredentialStats(_ context.Context, filter service.UsageFilter) ([]service.UsageCredentialStat, error) { + s.lastFilter = filter + s.credentialsCalls++ + return s.credentialStats, s.err +} + +func (s *usageEventsStub) GetUsageAnalysis(context.Context, service.UsageFilter) (*service.UsageAnalysisSnapshot, error) { + return nil, s.err +} + +func TestUsageEventsReturnsFilteredRows(t *testing.T) { + provider := &usageEventsStub{events: []service.UsageEventRecord{{ + ID: 42, + Timestamp: time.Date(2026, 4, 22, 11, 0, 0, 0, time.UTC), + Model: "claude-sonnet", + AuthType: "apikey", + Provider: "OpenAI Mirror", + Source: "sk-provider-key", + AuthIndex: "2", + Failed: false, + LatencyMS: 321, + InputTokens: 10, + OutputTokens: 5, + ReasoningTokens: 2, + CachedTokens: 1, + TotalTokens: 18, + }}} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?range=24h", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + body := resp.Body.String() + if !contains(body, `"events":[`) || !contains(body, `"model":"claude-sonnet"`) { + t.Fatalf("unexpected response body: %s", body) + } + if !contains(body, `"id":42`) || !contains(body, `"total_count":1`) || !contains(body, `"page":1`) || !contains(body, `"page_size":100`) || !contains(body, `"total_pages":1`) { + t.Fatalf("expected pagination metadata and event id in response body: %s", body) + } + if !contains(body, `"source":"OpenAI Mirror"`) { + t.Fatalf("expected resolved source display in response body: %s", body) + } + if contains(body, `sk-provider-key`) || contains(body, `sk-provider-prefix`) { + t.Fatalf("expected raw source values to be redacted from response body: %s", body) + } + if contains(body, `"source_type"`) || !contains(body, `"source_key":"provider:OpenAI Mirror"`) { + t.Fatalf("expected provider source key from usage event provider only, got %s", body) + } + if !contains(body, `"auth_index":"2"`) { + t.Fatalf("expected auth index in response body: %s", body) + } + if provider.filterCalls != 1 { + t.Fatalf("expected ListUsageEvents to be called once, got %d", provider.filterCalls) + } + if provider.lastFilter.Range != "24h" { + t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter) + } + if provider.lastFilter.Page != 1 || provider.lastFilter.PageSize != 100 || provider.lastFilter.Offset != 0 { + t.Fatalf("expected default pagination to be passed through, got %+v", provider.lastFilter) + } + if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil { + t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter) + } +} + +func TestUsageEventsPassesPaginationAndProviderSourceFilter(t *testing.T) { + provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 3, PageSize: 100, TotalPages: 0}} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?page=3&page_size=100&model=claude-sonnet&source=provider:OpenAI%20Mirror&result=failed", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + if provider.lastFilter.Page != 3 || provider.lastFilter.PageSize != 100 || provider.lastFilter.Offset != 200 { + t.Fatalf("expected pagination filter, got %+v", provider.lastFilter) + } + if provider.lastFilter.Model != "claude-sonnet" || provider.lastFilter.AuthType != "apikey" || provider.lastFilter.Provider != "OpenAI Mirror" || provider.lastFilter.Source != "" || provider.lastFilter.Result != "failed" { + t.Fatalf("expected provider source filter to be translated, got %+v", provider.lastFilter) + } + body := resp.Body.String() + if !contains(body, `"page":3`) || !contains(body, `"page_size":100`) || !contains(body, `"total_count":0`) || !contains(body, `"total_pages":0`) { + t.Fatalf("expected response pagination metadata, got %s", body) + } +} + +func TestUsageEventsPassesAuthSourceFilter(t *testing.T) { + provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 1, PageSize: 100, TotalPages: 0}} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events?source=auth:2", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + if provider.lastFilter.AuthType != "oauth" || provider.lastFilter.AuthIndex != "2" || provider.lastFilter.Source != "2" { + t.Fatalf("expected auth source filter to be translated, got %+v", provider.lastFilter) + } +} + +func TestUsageEventsRejectsEmptyPrefixedSourceFilter(t *testing.T) { + for _, path := range []string{"/api/v1/usage/events?source=auth:", "/api/v1/usage/events?source=provider:"} { + t.Run(path, func(t *testing.T) { + provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{Events: []service.UsageEventRecord{}, TotalCount: 0, Page: 1, PageSize: 100, TotalPages: 0}} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, path, nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusBadRequest { + t.Fatalf("expected status 400, got %d with body %s", resp.Code, resp.Body.String()) + } + if provider.filterCalls != 0 { + t.Fatalf("expected invalid source filter not to query events, got %d calls", provider.filterCalls) + } + }) + } +} + +func TestUsageEventsReturnsFilterOptions(t *testing.T) { + provider := &usageEventsStub{eventsPage: &service.UsageEventsPage{ + Events: []service.UsageEventRecord{{ + ID: 7, Timestamp: time.Date(2026, 4, 22, 11, 0, 0, 0, time.UTC), Model: "gpt-5", AuthType: "apikey", Provider: "Provider A", Source: "source-a", Failed: true, + }}, + Models: []string{"claude-sonnet", "gpt-5"}, + Sources: []string{"source-a", "source-b"}, + TotalCount: 2, Page: 1, PageSize: 20, TotalPages: 1, + }} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-source-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-a", Type: "openai", Provider: "Provider A", TotalRequests: 1}, {ID: 2, Name: "Provider A", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-b", Type: "openai", Provider: "Provider A", TotalRequests: 1}, {ID: 3, Name: "Auth User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-1", Type: "claude", Provider: "Claude", TotalRequests: 1}}}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + body := resp.Body.String() + if !contains(body, `"models":["claude-sonnet","gpt-5"]`) { + t.Fatalf("expected model filter options, got %s", body) + } + if !contains(body, `"sources":[`) || !contains(body, `"value":"auth:auth-1"`) || !contains(body, `"label":"Auth User"`) || !contains(body, `"value":"provider:Provider A"`) || !contains(body, `"label":"Provider A"`) { + t.Fatalf("expected prefixed source filter options, got %s", body) + } + if contains(body, `"value":"source-a"`) || contains(body, `"value":"source-b"`) || contains(body, `"provider:1"`) || contains(body, `"provider:2"`) || contains(body, `sk-source-prefix`) { + t.Fatalf("expected raw source filter values to be redacted, got %s", body) + } +} + +func TestUsageEventFilterOptionsReturnsStableModelsAndSources(t *testing.T) { + provider := &usageEventsStub{eventFilterOptions: &service.UsageEventFilterOptions{ + Models: []string{"claude-sonnet", "gpt-5"}, + Sources: []string{"source-a", "source-b"}, + }} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-source-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-a", Type: "openai", Provider: "Provider A", TotalRequests: 3}, {ID: 2, Name: "Provider A", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-b", Type: "openai", Provider: "Provider A"}, {ID: 3, Name: "Auth User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-1", Type: "claude", Provider: "Claude", TotalRequests: 2}, {ID: 4, Name: "Zero Request User", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-zero", Type: "claude", Provider: "Claude"}, {ID: 5, Name: "Zero Provider", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "source-zero", Type: "openai", Provider: "Zero Provider"}}}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/events/filters?range=24h&model=ignored&source=ignored&result=failed&page=3&page_size=20", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + if provider.filterOptionCalls != 1 || provider.filterCalls != 0 { + t.Fatalf("expected filter options endpoint only, events=%d filterOptions=%d", provider.filterCalls, provider.filterOptionCalls) + } + if provider.lastFilter.Range != "" || provider.lastFilter.StartTime != nil || provider.lastFilter.EndTime != nil || provider.lastFilter.Model != "" || provider.lastFilter.Source != "" || provider.lastFilter.Result != "" || provider.lastFilter.Page != 0 || provider.lastFilter.PageSize != 0 { + t.Fatalf("expected filters endpoint to ignore query filters, got %+v", provider.lastFilter) + } + body := resp.Body.String() + if !contains(body, `"models":["claude-sonnet","gpt-5"]`) { + t.Fatalf("expected stable model filter options, got %s", body) + } + if !contains(body, `"sources":[`) || !contains(body, `"value":"auth:auth-1"`) || !contains(body, `"label":"Auth User"`) || !contains(body, `"value":"provider:Provider A"`) || !contains(body, `"label":"Provider A"`) { + t.Fatalf("expected stable prefixed source filter options, got %s", body) + } + if contains(body, `"value":"source-a"`) || contains(body, `"value":"source-b"`) || contains(body, `"provider:1"`) || contains(body, `"provider:2"`) || contains(body, `sk-source-prefix`) { + t.Fatalf("expected raw source filter values to be redacted, got %s", body) + } + if contains(body, `Zero Request User`) || contains(body, `Zero Provider`) || contains(body, `auth-zero`) || contains(body, `source-zero`) { + t.Fatalf("expected zero-request source filter options to be omitted, got %s", body) + } +} + +func TestUsageCredentialsReturnsAggregatedRows(t *testing.T) { + provider := &usageEventsStub{credentialStats: []service.UsageCredentialStat{{ + Source: "sk-provider-key", + AuthIndex: "2", + Failed: false, + RequestCount: 3, + }, { + Source: "sk-provider-key", + AuthIndex: "2", + Failed: true, + RequestCount: 1, + }}} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{{ID: 1, Name: "sk-provider-prefix", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "sk-provider-key", Type: "openai", Provider: "OpenAI Mirror"}}}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/credentials?range=24h", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + body := resp.Body.String() + if !contains(body, `"credentials":[`) { + t.Fatalf("unexpected response body: %s", body) + } + if !contains(body, `"source":"OpenAI Mirror"`) { + t.Fatalf("expected resolved source display in response body: %s", body) + } + if !contains(body, `"source_type":"openai"`) { + t.Fatalf("expected source type in response body: %s", body) + } + if !contains(body, `"source_key":"provider:1"`) { + t.Fatalf("expected source key in response body: %s", body) + } + if contains(body, `sk-provider-key`) || contains(body, `sk-provider-prefix`) { + t.Fatalf("expected raw source values to be redacted from response body: %s", body) + } + if !contains(body, `"success_count":3`) || !contains(body, `"failure_count":1`) || !contains(body, `"total_count":4`) { + t.Fatalf("expected aggregated counts in response body: %s", body) + } + if provider.credentialsCalls != 1 { + t.Fatalf("expected ListUsageCredentialStats to be called once, got %d", provider.credentialsCalls) + } + if provider.lastFilter.Range != "24h" { + t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter) + } + if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil { + t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter) + } +} diff --git a/internal/api/usage_filter.go b/internal/api/usage_filter.go new file mode 100644 index 0000000000000000000000000000000000000000..5231bb1072e52f19b5b51e4b572a4bb28341f13d --- /dev/null +++ b/internal/api/usage_filter.go @@ -0,0 +1,141 @@ +package api + +import ( + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "cpa-usage-keeper/internal/service" +) + +var presetUsageRangeDurations = map[string]time.Duration{ + "4h": 4 * time.Hour, + "8h": 8 * time.Hour, + "12h": 12 * time.Hour, + "24h": 24 * time.Hour, + "7d": 7 * 24 * time.Hour, +} + +var allowedUsageEventsPageSizes = map[int]struct{}{ + 20: {}, + 50: {}, + 100: {}, + 500: {}, + 1000: {}, +} + +func parseUsageTimeFilterQuery(req *http.Request, anchor time.Time) (service.UsageFilter, error) { + filter, err := parseUsageFilterQuery(req, anchor) + if err != nil { + return service.UsageFilter{}, err + } + filter.Limit = 0 + filter.Page = 0 + filter.PageSize = 0 + filter.Offset = 0 + filter.Model = "" + filter.Source = "" + filter.AuthIndex = "" + filter.Result = "" + return filter, nil +} + +func parseCustomUsageRangeBoundary(value string, endOfDay bool) (time.Time, error) { + if date, err := time.ParseInLocation(time.DateOnly, value, time.Local); err == nil { + if endOfDay { + return date.AddDate(0, 0, 1).Add(-time.Nanosecond), nil + } + return date, nil + } + return time.Parse(time.RFC3339, value) +} + +func parseUsageFilterQuery(req *http.Request, anchor time.Time) (service.UsageFilter, error) { + if req == nil { + return service.UsageFilter{}, nil + } + + rangeValue := strings.TrimSpace(req.URL.Query().Get("range")) + if rangeValue == "" { + rangeValue = "all" + } + + filter := service.UsageFilter{Range: rangeValue, Limit: service.DefaultUsageEventsLimit, Page: 1, PageSize: service.DefaultUsageEventsLimit} + query := req.URL.Query() + if pageValue := strings.TrimSpace(query.Get("page")); pageValue != "" { + page, err := strconv.Atoi(pageValue) + if err != nil || page < 1 { + return service.UsageFilter{}, fmt.Errorf("invalid page %q", pageValue) + } + filter.Page = page + } + pageSizeValue := strings.TrimSpace(query.Get("page_size")) + if pageSizeValue == "" { + pageSizeValue = strings.TrimSpace(query.Get("limit")) + } + if pageSizeValue != "" { + pageSize, err := strconv.Atoi(pageSizeValue) + if err != nil { + return service.UsageFilter{}, fmt.Errorf("invalid page_size %q", pageSizeValue) + } + if _, ok := allowedUsageEventsPageSizes[pageSize]; !ok { + return service.UsageFilter{}, fmt.Errorf("invalid page_size %q", pageSizeValue) + } + filter.PageSize = pageSize + filter.Limit = pageSize + } + filter.Offset = (filter.Page - 1) * filter.PageSize + filter.Model = strings.TrimSpace(query.Get("model")) + filter.Source = strings.TrimSpace(query.Get("source")) + filter.AuthIndex = strings.TrimSpace(query.Get("auth_index")) + filter.Result = strings.TrimSpace(query.Get("result")) + if filter.Result != "" && filter.Result != "success" && filter.Result != "failed" { + return service.UsageFilter{}, fmt.Errorf("invalid result %q", filter.Result) + } + switch rangeValue { + case "all": + return filter, nil + case "today": + localAnchor := anchor.In(time.Local) + localStart := time.Date(localAnchor.Year(), localAnchor.Month(), localAnchor.Day(), 0, 0, 0, 0, time.Local) + startTime := localStart.UTC() + endTime := localStart.AddDate(0, 0, 1).Add(-time.Nanosecond).UTC() + filter.StartTime = &startTime + filter.EndTime = &endTime + return filter, nil + case "custom": + startValue := strings.TrimSpace(req.URL.Query().Get("start")) + endValue := strings.TrimSpace(req.URL.Query().Get("end")) + if startValue == "" || endValue == "" { + return service.UsageFilter{}, fmt.Errorf("custom range requires start and end") + } + startTime, err := parseCustomUsageRangeBoundary(startValue, false) + if err != nil { + return service.UsageFilter{}, fmt.Errorf("invalid start: %w", err) + } + endTime, err := parseCustomUsageRangeBoundary(endValue, true) + if err != nil { + return service.UsageFilter{}, fmt.Errorf("invalid end: %w", err) + } + startTime = startTime.UTC() + endTime = endTime.UTC() + if startTime.After(endTime) { + return service.UsageFilter{}, fmt.Errorf("custom range start must be before end") + } + filter.StartTime = &startTime + filter.EndTime = &endTime + return filter, nil + default: + duration, ok := presetUsageRangeDurations[rangeValue] + if !ok { + return service.UsageFilter{}, fmt.Errorf("unsupported usage range %q", rangeValue) + } + endTime := anchor.UTC() + startTime := endTime.Add(-duration) + filter.StartTime = &startTime + filter.EndTime = &endTime + return filter, nil + } +} diff --git a/internal/api/usage_filter_test.go b/internal/api/usage_filter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6f7398f1db8da687172971179ec1e31a58d648d2 --- /dev/null +++ b/internal/api/usage_filter_test.go @@ -0,0 +1,209 @@ +package api + +import ( + "net/http/httptest" + "testing" + "time" +) + +func TestParseUsageFilterQueryPresetRange(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=24h", nil) + anchor := time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC) + + filter, err := parseUsageFilterQuery(req, anchor) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.Range != "24h" { + t.Fatalf("expected range to be preserved, got %+v", filter) + } + if filter.StartTime == nil || filter.EndTime == nil { + t.Fatalf("expected preset range to resolve concrete times, got %+v", filter) + } + if !filter.EndTime.Equal(anchor) { + t.Fatalf("expected preset range end to use anchor time, got %+v", filter) + } + if !filter.StartTime.Equal(anchor.Add(-24 * time.Hour)) { + t.Fatalf("expected preset range start to subtract 24h, got %+v", filter) + } +} + +func TestParseUsageFilterQueryTodayRangeUsesLocalDayBoundary(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + t.Cleanup(func() { time.Local = previousLocal }) + time.Local = location + + req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=today", nil) + anchor := time.Date(2026, 4, 22, 12, 34, 56, 0, time.UTC) + + filter, err := parseUsageFilterQuery(req, anchor) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.Range != "today" { + t.Fatalf("expected today range to be preserved, got %+v", filter) + } + if filter.StartTime == nil || filter.EndTime == nil { + t.Fatalf("expected today range to resolve concrete times, got %+v", filter) + } + expectedStart := time.Date(2026, 4, 22, 0, 0, 0, 0, location).UTC() + expectedEnd := time.Date(2026, 4, 23, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC() + if !filter.StartTime.Equal(expectedStart) { + t.Fatalf("expected today start %s, got %s", expectedStart, *filter.StartTime) + } + if !filter.EndTime.Equal(expectedEnd) { + t.Fatalf("expected today end %s, got %s", expectedEnd, *filter.EndTime) + } +} + +func TestParseUsageFilterQueryTodayRangeUsesLocalDSTBoundary(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("America/New_York") + if err != nil { + t.Fatalf("load location: %v", err) + } + t.Cleanup(func() { time.Local = previousLocal }) + time.Local = location + + req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=today", nil) + anchor := time.Date(2026, 3, 8, 12, 0, 0, 0, location) + + filter, err := parseUsageFilterQuery(req, anchor) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.StartTime == nil || filter.EndTime == nil { + t.Fatalf("expected today range to resolve concrete times, got %+v", filter) + } + expectedStart := time.Date(2026, 3, 8, 0, 0, 0, 0, location).UTC() + expectedEnd := time.Date(2026, 3, 9, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC() + if !filter.StartTime.Equal(expectedStart) { + t.Fatalf("expected DST today start %s, got %s", expectedStart, *filter.StartTime) + } + if !filter.EndTime.Equal(expectedEnd) { + t.Fatalf("expected DST today end %s, got %s", expectedEnd, *filter.EndTime) + } +} + +func TestParseUsageFilterQueryCustomRange(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-20T00:00:00Z&end=2026-04-21T23:59:59Z", nil) + + filter, err := parseUsageFilterQuery(req, time.Time{}) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.StartTime == nil || filter.EndTime == nil { + t.Fatalf("expected custom range bounds, got %+v", filter) + } + if !filter.StartTime.Equal(time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC)) { + t.Fatalf("unexpected custom start: %+v", filter) + } + if !filter.EndTime.Equal(time.Date(2026, 4, 21, 23, 59, 59, 0, time.UTC)) { + t.Fatalf("unexpected custom end: %+v", filter) + } +} + +func TestParseUsageFilterQueryCustomDateRangeUsesLocalDayBoundary(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + t.Cleanup(func() { time.Local = previousLocal }) + time.Local = location + + req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-20&end=2026-04-21", nil) + + filter, err := parseUsageFilterQuery(req, time.Time{}) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.StartTime == nil || filter.EndTime == nil { + t.Fatalf("expected custom date range bounds, got %+v", filter) + } + expectedStart := time.Date(2026, 4, 20, 0, 0, 0, 0, location).UTC() + expectedEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC() + if !filter.StartTime.Equal(expectedStart) { + t.Fatalf("expected custom date start %s, got %s", expectedStart, *filter.StartTime) + } + if !filter.EndTime.Equal(expectedEnd) { + t.Fatalf("expected custom date end %s, got %s", expectedEnd, *filter.EndTime) + } +} + +func TestParseUsageFilterQueryRejectsInvalidCustomRange(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/usage/overview?range=custom&start=2026-04-21T00:00:00Z&end=2026-04-20T23:59:59Z", nil) + + _, err := parseUsageFilterQuery(req, time.Time{}) + if err == nil { + t.Fatal("expected invalid custom range error") + } +} + +func TestParseUsageFilterQueryDefaultsEventsPagination(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/usage/events?range=all", nil) + + filter, err := parseUsageFilterQuery(req, time.Time{}) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.Page != 1 || filter.PageSize != 100 || filter.Offset != 0 { + t.Fatalf("expected default pagination, got %+v", filter) + } +} + +func TestParseUsageFilterQueryAcceptsEventsPaginationAndFilters(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/usage/events?page=3&page_size=100&model=%20claude-sonnet%20&source=%20source-a%20&auth_index=%202%20", nil) + + filter, err := parseUsageFilterQuery(req, time.Time{}) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.Page != 3 || filter.PageSize != 100 || filter.Offset != 200 { + t.Fatalf("expected page 3/page size 100 offset 200, got %+v", filter) + } + if filter.Model != "claude-sonnet" || filter.Source != "source-a" || filter.AuthIndex != "2" { + t.Fatalf("expected trimmed server-side filters, got %+v", filter) + } +} + +func TestParseUsageFilterQueryUsesLimitAsPageSizeAlias(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/usage/events?limit=20", nil) + + filter, err := parseUsageFilterQuery(req, time.Time{}) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.Page != 1 || filter.PageSize != 20 || filter.Offset != 0 { + t.Fatalf("expected limit alias to set page size, got %+v", filter) + } +} + +func TestParseUsageFilterQueryPrefersPageSizeOverLimit(t *testing.T) { + req := httptest.NewRequest("GET", "/api/v1/usage/events?page_size=50&limit=20", nil) + + filter, err := parseUsageFilterQuery(req, time.Time{}) + if err != nil { + t.Fatalf("parseUsageFilterQuery returned error: %v", err) + } + if filter.PageSize != 50 { + t.Fatalf("expected page_size to win over limit, got %+v", filter) + } +} + +func TestParseUsageFilterQueryRejectsInvalidEventsPagination(t *testing.T) { + tests := []string{ + "/api/v1/usage/events?page=0", + "/api/v1/usage/events?page_size=25", + } + for _, path := range tests { + req := httptest.NewRequest("GET", path, nil) + if _, err := parseUsageFilterQuery(req, time.Time{}); err == nil { + t.Fatalf("expected pagination error for %s", path) + } + } +} diff --git a/internal/api/usage_identities.go b/internal/api/usage_identities.go new file mode 100644 index 0000000000000000000000000000000000000000..380c8f33a77bebc2e19245aeabd41a7c4c76fb12 --- /dev/null +++ b/internal/api/usage_identities.go @@ -0,0 +1,125 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/redact" + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +type usageIdentitiesResponse struct { + Identities []usageIdentityResponse `json:"identities"` +} + +type usageIdentityResponse struct { + ID uint `json:"id"` + Name string `json:"name"` + AuthType models.UsageIdentityAuthType `json:"auth_type"` + AuthTypeName string `json:"auth_type_name"` + Identity string `json:"identity"` + Type string `json:"type"` + Provider string `json:"provider"` + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CachedTokens int64 `json:"cached_tokens"` + TotalTokens int64 `json:"total_tokens"` + LastAggregatedUsageEventID uint `json:"last_aggregated_usage_event_id"` + FirstUsedAt *time.Time `json:"first_used_at,omitempty"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + StatsUpdatedAt *time.Time `json:"stats_updated_at,omitempty"` + IsDeleted bool `json:"is_deleted"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt *time.Time `json:"deleted_at,omitempty"` +} + +func registerUsageIdentityRoutes(router gin.IRoutes, usageIdentityProvider service.UsageIdentityProvider) { + router.GET("/usage/identities", func(c *gin.Context) { + if usageIdentityProvider == nil { + c.JSON(http.StatusOK, usageIdentitiesResponse{Identities: []usageIdentityResponse{}}) + return + } + + items, err := usageIdentityProvider.ListUsageIdentities(c.Request.Context()) + if err != nil { + writeInternalError(c, "list usage identities failed", err) + return + } + + response := make([]usageIdentityResponse, 0, len(items)) + for _, item := range items { + response = append(response, mapUsageIdentityResponse(item)) + } + c.JSON(http.StatusOK, usageIdentitiesResponse{Identities: response}) + }) +} + +func mapUsageIdentityResponse(item models.UsageIdentity) usageIdentityResponse { + identity := item.Identity + name := item.Name + identityType := item.Type + provider := item.Provider + if item.AuthType == models.UsageIdentityAuthTypeAIProvider { + identity = redact.APIKeyDisplayName(item.Identity) + identityType = safeAIProviderDisplayValue(item.Type, item.Identity, item.AuthTypeName) + provider = safeAIProviderDisplayValue(item.Provider, item.Identity, firstNonEmptyString(identityType, identity)) + name = safeAIProviderDisplayValue(item.Name, item.Identity, firstNonEmptyString(provider, identityType, identity)) + } + + return usageIdentityResponse{ + ID: item.ID, + Name: name, + AuthType: item.AuthType, + AuthTypeName: item.AuthTypeName, + Identity: identity, + Type: identityType, + Provider: provider, + TotalRequests: item.TotalRequests, + SuccessCount: item.SuccessCount, + FailureCount: item.FailureCount, + InputTokens: item.InputTokens, + OutputTokens: item.OutputTokens, + ReasoningTokens: item.ReasoningTokens, + CachedTokens: item.CachedTokens, + TotalTokens: item.TotalTokens, + LastAggregatedUsageEventID: item.LastAggregatedUsageEventID, + FirstUsedAt: item.FirstUsedAt, + LastUsedAt: item.LastUsedAt, + StatsUpdatedAt: item.StatsUpdatedAt, + IsDeleted: item.IsDeleted, + CreatedAt: item.CreatedAt, + UpdatedAt: item.UpdatedAt, + DeletedAt: item.DeletedAt, + } +} + +func safeAIProviderDisplayValue(value, rawIdentity, fallback string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fallback + } + if isSensitiveUsageIdentityValue(trimmed, rawIdentity) { + return fallback + } + return trimmed +} + +func isSensitiveUsageIdentityValue(value, rawIdentity string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return false + } + if raw := strings.TrimSpace(rawIdentity); raw != "" && strings.Contains(trimmed, raw) { + return true + } + lower := strings.ToLower(trimmed) + return strings.Contains(lower, "sk-") || strings.Contains(lower, "aiza") || strings.Contains(lower, "cr_") || strings.Contains(lower, "cr-") +} diff --git a/internal/api/usage_identities_test.go b/internal/api/usage_identities_test.go new file mode 100644 index 0000000000000000000000000000000000000000..001e946213f1ef33bd774e12dafdb7ddf71fed52 --- /dev/null +++ b/internal/api/usage_identities_test.go @@ -0,0 +1,144 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/redact" +) + +type usageIdentitiesStub struct { + items []models.UsageIdentity + err error +} + +func (s usageIdentitiesStub) ListUsageIdentities(context.Context) ([]models.UsageIdentity, error) { + return s.items, s.err +} + +func TestUsageIdentitiesRouteReturnsMetadataStatsAndDeletedRows(t *testing.T) { + firstUsedAt := time.Date(2026, 5, 4, 8, 0, 0, 0, time.UTC) + lastUsedAt := time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC) + statsUpdatedAt := time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC) + createdAt := time.Date(2026, 5, 3, 8, 0, 0, 0, time.UTC) + updatedAt := time.Date(2026, 5, 4, 10, 30, 0, 0, time.UTC) + deletedAt := time.Date(2026, 5, 4, 11, 0, 0, 0, time.UTC) + + router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{ + { + ID: 1, + Name: "Claude Desktop", + AuthType: models.UsageIdentityAuthTypeAuthFile, + AuthTypeName: "oauth", + Identity: "2", + Type: "auth-file", + Provider: "anthropic", + TotalRequests: 10, + SuccessCount: 8, + FailureCount: 2, + InputTokens: 100, + OutputTokens: 200, + ReasoningTokens: 30, + CachedTokens: 40, + TotalTokens: 370, + LastAggregatedUsageEventID: 99, + FirstUsedAt: &firstUsedAt, + LastUsedAt: &lastUsedAt, + StatsUpdatedAt: &statsUpdatedAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + { + ID: 2, + Name: "Deleted Provider", + AuthType: models.UsageIdentityAuthTypeAIProvider, + AuthTypeName: "apikey", + Identity: "sk-deleted-provider-secret", + Type: "openai", + Provider: "OpenAI", + IsDeleted: true, + DeletedAt: &deletedAt, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + }, + }}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/identities", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + body := resp.Body.String() + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", resp.Code, body) + } + if !contains(body, `"identities":[`) || !contains(body, `"id":1`) || !contains(body, `"identity":"2"`) { + t.Fatalf("expected auth file identity row in response, got %s", body) + } + for _, expected := range []string{ + `"name":"Claude Desktop"`, + `"auth_type":1`, + `"auth_type_name":"oauth"`, + `"type":"auth-file"`, + `"provider":"anthropic"`, + `"total_requests":10`, + `"success_count":8`, + `"failure_count":2`, + `"input_tokens":100`, + `"output_tokens":200`, + `"reasoning_tokens":30`, + `"cached_tokens":40`, + `"total_tokens":370`, + `"last_aggregated_usage_event_id":99`, + `"first_used_at":"2026-05-04T08:00:00Z"`, + `"last_used_at":"2026-05-04T09:00:00Z"`, + `"stats_updated_at":"2026-05-04T10:00:00Z"`, + `"is_deleted":true`, + `"deleted_at":"2026-05-04T11:00:00Z"`, + } { + if !contains(body, expected) { + t.Fatalf("expected %s in response body: %s", expected, body) + } + } +} + +func TestUsageIdentitiesRouteMasksAIProviderIdentity(t *testing.T) { + rawLookupKey := "sk-live-secret-value" + rawPrefix := "sk-live-prefix" + maskedLookupKey := redact.APIKeyDisplayName(rawLookupKey) + router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{items: []models.UsageIdentity{ + {ID: 1, Name: rawPrefix, AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: rawLookupKey, Type: "openai " + rawLookupKey, Provider: "OpenAI " + rawPrefix}, + }}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/identities", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + body := resp.Body.String() + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d: %s", resp.Code, body) + } + if contains(body, rawLookupKey) || contains(body, rawPrefix) { + t.Fatalf("expected raw AI provider lookup values to be hidden, got %s", body) + } + if !contains(body, `"identity":"`+maskedLookupKey+`"`) { + t.Fatalf("expected masked AI provider identity %q in response body: %s", maskedLookupKey, body) + } +} + +func TestUsageIdentityReplacesLegacyMetadataRoutes(t *testing.T) { + router := NewRouter(nil, nil, nil, nil, AuthConfig{}, nil, "", usageIdentitiesStub{}) + for _, path := range []string{"/api/v1/auth-files", "/api/v1/provider-metadata"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusNotFound { + t.Fatalf("expected %s to return 404, got %d: %s", path, resp.Code, resp.Body.String()) + } + } +} diff --git a/internal/api/usage_overview.go b/internal/api/usage_overview.go new file mode 100644 index 0000000000000000000000000000000000000000..8a308cc7bad2c0113deab6e34e8d81ad0315f6b9 --- /dev/null +++ b/internal/api/usage_overview.go @@ -0,0 +1,335 @@ +package api + +import ( + "net/http" + "time" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/redact" + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +type usageOverviewResponse struct { + Usage usageOverviewPayload `json:"usage"` + Summary usageOverviewSummary `json:"summary"` + Series usageOverviewSeries `json:"series"` + HourlySeries usageOverviewSeries `json:"hourly_series"` + DailySeries usageOverviewSeries `json:"daily_series"` + ServiceHealth usageOverviewServiceHealth `json:"service_health"` + Timezone string `json:"timezone"` + RangeStart *time.Time `json:"range_start,omitempty"` + RangeEnd *time.Time `json:"range_end,omitempty"` +} + +type usageOverviewPayload struct { + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` + APIs map[string]usageOverviewAPISnapshot `json:"apis"` + RequestsByDay map[string]int64 `json:"requests_by_day"` + RequestsByHour map[string]int64 `json:"requests_by_hour"` + TokensByDay map[string]int64 `json:"tokens_by_day"` + TokensByHour map[string]int64 `json:"tokens_by_hour"` +} + +type usageOverviewSummary struct { + RequestCount int64 `json:"request_count"` + TokenCount int64 `json:"token_count"` + WindowMinutes int64 `json:"window_minutes"` + RPM float64 `json:"rpm"` + TPM float64 `json:"tpm"` + TotalCost float64 `json:"total_cost"` + CostAvailable bool `json:"cost_available"` + CachedTokens int64 `json:"cached_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` +} + +type usageOverviewSeries struct { + Requests map[string]int64 `json:"requests"` + Tokens map[string]int64 `json:"tokens"` + RPM map[string]float64 `json:"rpm"` + TPM map[string]float64 `json:"tpm"` + Cost map[string]float64 `json:"cost"` + InputTokens map[string]int64 `json:"input_tokens"` + OutputTokens map[string]int64 `json:"output_tokens"` + CachedTokens map[string]int64 `json:"cached_tokens"` + ReasoningTokens map[string]int64 `json:"reasoning_tokens"` + Models map[string]usageOverviewSeriesLine `json:"models"` +} + +type usageOverviewSeriesLine struct { + Requests map[string]int64 `json:"requests"` + Tokens map[string]int64 `json:"tokens"` + RPM map[string]float64 `json:"rpm"` + TPM map[string]float64 `json:"tpm"` + Cost map[string]float64 `json:"cost"` + InputTokens map[string]int64 `json:"input_tokens"` + OutputTokens map[string]int64 `json:"output_tokens"` + CachedTokens map[string]int64 `json:"cached_tokens"` + ReasoningTokens map[string]int64 `json:"reasoning_tokens"` +} + +type usageOverviewServiceHealth struct { + TotalSuccess int64 `json:"total_success"` + TotalFailure int64 `json:"total_failure"` + SuccessRate float64 `json:"success_rate"` + Rows int `json:"rows"` + Columns int `json:"columns"` + BucketSeconds int64 `json:"bucket_seconds"` + WindowStart time.Time `json:"window_start"` + WindowEnd time.Time `json:"window_end"` + BlockDetails []usageOverviewServiceHealthBlock `json:"block_details"` +} + +type usageOverviewServiceHealthBlock struct { + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + Success int64 `json:"success"` + Failure int64 `json:"failure"` + Rate float64 `json:"rate"` +} + +type usageOverviewAPISnapshot struct { + DisplayName string `json:"display_name,omitempty"` + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` + Models map[string]usageOverviewModelSnapshot `json:"models"` +} + +type usageOverviewModelSnapshot struct { + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` +} + +func registerUsageOverviewRoute(router gin.IRoutes, usageProvider service.UsageProvider) { + router.GET("/usage/overview", func(c *gin.Context) { + if usageProvider == nil { + c.JSON(http.StatusOK, usageOverviewResponse{ + Usage: buildUsageOverviewPayload(nil), + Summary: usageOverviewSummary{}, + Series: emptyUsageOverviewSeries(), + HourlySeries: emptyUsageOverviewSeries(), + DailySeries: emptyUsageOverviewSeries(), + ServiceHealth: usageOverviewServiceHealth{BlockDetails: []usageOverviewServiceHealthBlock{}}, + Timezone: time.Local.String(), + }) + return + } + + filter, err := parseUsageFilterQuery(c.Request, time.Now().UTC()) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + overview, err := usageProvider.GetUsageOverview(c.Request.Context(), filter) + if err != nil { + writeInternalError(c, "get usage overview failed", err) + return + } + + var usage *cpa.StatisticsSnapshot + if overview != nil { + usage = overview.Usage + } + redactedUsage := redact.UsageSnapshot(usage) + c.JSON(http.StatusOK, usageOverviewResponse{ + Usage: buildUsageOverviewPayload(redactedUsage), + Summary: buildUsageOverviewSummary(overview), + Series: buildUsageOverviewSeries(overview), + HourlySeries: buildUsageOverviewHourlySeries(overview), + DailySeries: buildUsageOverviewDailySeries(overview), + ServiceHealth: buildUsageOverviewServiceHealth(overview), + Timezone: time.Local.String(), + RangeStart: filter.StartTime, + RangeEnd: filter.EndTime, + }) + }) +} + +func buildUsageOverviewPayload(snapshot *cpa.StatisticsSnapshot) usageOverviewPayload { + if snapshot == nil { + return usageOverviewPayload{ + APIs: map[string]usageOverviewAPISnapshot{}, + RequestsByDay: map[string]int64{}, + RequestsByHour: map[string]int64{}, + TokensByDay: map[string]int64{}, + TokensByHour: map[string]int64{}, + } + } + + payload := usageOverviewPayload{ + TotalRequests: snapshot.TotalRequests, + SuccessCount: snapshot.SuccessCount, + FailureCount: snapshot.FailureCount, + TotalTokens: snapshot.TotalTokens, + RequestsByDay: cloneInt64Map(snapshot.RequestsByDay), + RequestsByHour: cloneInt64Map(snapshot.RequestsByHour), + TokensByDay: cloneInt64Map(snapshot.TokensByDay), + TokensByHour: cloneInt64Map(snapshot.TokensByHour), + APIs: map[string]usageOverviewAPISnapshot{}, + } + + for apiName, apiSnapshot := range snapshot.APIs { + payloadAPI := usageOverviewAPISnapshot{ + DisplayName: apiSnapshot.DisplayName, + TotalRequests: apiSnapshot.TotalRequests, + SuccessCount: apiSnapshot.SuccessCount, + FailureCount: apiSnapshot.FailureCount, + TotalTokens: apiSnapshot.TotalTokens, + Models: map[string]usageOverviewModelSnapshot{}, + } + for modelName, modelSnapshot := range apiSnapshot.Models { + payloadAPI.Models[modelName] = usageOverviewModelSnapshot{ + TotalRequests: modelSnapshot.TotalRequests, + SuccessCount: modelSnapshot.SuccessCount, + FailureCount: modelSnapshot.FailureCount, + TotalTokens: modelSnapshot.TotalTokens, + } + } + payload.APIs[apiName] = payloadAPI + } + + return payload +} + +func buildUsageOverviewSummary(overview *service.UsageOverviewSnapshot) usageOverviewSummary { + if overview == nil { + return usageOverviewSummary{} + } + return usageOverviewSummary{ + RequestCount: overview.Summary.RequestCount, + TokenCount: overview.Summary.TokenCount, + WindowMinutes: overview.Summary.WindowMinutes, + RPM: overview.Summary.RPM, + TPM: overview.Summary.TPM, + TotalCost: overview.Summary.TotalCost, + CostAvailable: overview.Summary.CostAvailable, + CachedTokens: overview.Summary.CachedTokens, + ReasoningTokens: overview.Summary.ReasoningTokens, + } +} + +func emptyUsageOverviewSeries() usageOverviewSeries { + return usageOverviewSeries{ + Requests: map[string]int64{}, + Tokens: map[string]int64{}, + RPM: map[string]float64{}, + TPM: map[string]float64{}, + Cost: map[string]float64{}, + InputTokens: map[string]int64{}, + OutputTokens: map[string]int64{}, + CachedTokens: map[string]int64{}, + ReasoningTokens: map[string]int64{}, + Models: map[string]usageOverviewSeriesLine{}, + } +} + +func mapUsageOverviewSeriesLine(series service.UsageOverviewSeries) usageOverviewSeriesLine { + return usageOverviewSeriesLine{ + Requests: cloneInt64Map(series.Requests), + Tokens: cloneInt64Map(series.Tokens), + RPM: cloneFloat64Map(series.RPM), + TPM: cloneFloat64Map(series.TPM), + Cost: cloneFloat64Map(series.Cost), + InputTokens: cloneInt64Map(series.InputTokens), + OutputTokens: cloneInt64Map(series.OutputTokens), + CachedTokens: cloneInt64Map(series.CachedTokens), + ReasoningTokens: cloneInt64Map(series.ReasoningTokens), + } +} + +func mapUsageOverviewSeries(series service.UsageOverviewSeries) usageOverviewSeries { + models := make(map[string]usageOverviewSeriesLine, len(series.Models)) + for model, modelSeries := range series.Models { + models[model] = mapUsageOverviewSeriesLine(modelSeries) + } + return usageOverviewSeries{ + Requests: cloneInt64Map(series.Requests), + Tokens: cloneInt64Map(series.Tokens), + RPM: cloneFloat64Map(series.RPM), + TPM: cloneFloat64Map(series.TPM), + Cost: cloneFloat64Map(series.Cost), + InputTokens: cloneInt64Map(series.InputTokens), + OutputTokens: cloneInt64Map(series.OutputTokens), + CachedTokens: cloneInt64Map(series.CachedTokens), + ReasoningTokens: cloneInt64Map(series.ReasoningTokens), + Models: models, + } +} + +func buildUsageOverviewSeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries { + if overview == nil { + return emptyUsageOverviewSeries() + } + return mapUsageOverviewSeries(overview.Series) +} + +func buildUsageOverviewHourlySeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries { + if overview == nil { + return emptyUsageOverviewSeries() + } + return mapUsageOverviewSeries(overview.HourlySeries) +} + +func buildUsageOverviewDailySeries(overview *service.UsageOverviewSnapshot) usageOverviewSeries { + if overview == nil { + return emptyUsageOverviewSeries() + } + return mapUsageOverviewSeries(overview.DailySeries) +} + +func buildUsageOverviewServiceHealth(overview *service.UsageOverviewSnapshot) usageOverviewServiceHealth { + if overview == nil { + return usageOverviewServiceHealth{BlockDetails: []usageOverviewServiceHealthBlock{}} + } + blocks := make([]usageOverviewServiceHealthBlock, 0, len(overview.Health.BlockDetails)) + for _, block := range overview.Health.BlockDetails { + blocks = append(blocks, usageOverviewServiceHealthBlock{ + StartTime: block.StartTime, + EndTime: block.EndTime, + Success: block.Success, + Failure: block.Failure, + Rate: block.Rate, + }) + } + return usageOverviewServiceHealth{ + TotalSuccess: overview.Health.TotalSuccess, + TotalFailure: overview.Health.TotalFailure, + SuccessRate: overview.Health.SuccessRate, + Rows: overview.Health.Rows, + Columns: overview.Health.Columns, + BucketSeconds: overview.Health.BucketSeconds, + WindowStart: overview.Health.WindowStart, + WindowEnd: overview.Health.WindowEnd, + BlockDetails: blocks, + } +} + +func cloneInt64Map(source map[string]int64) map[string]int64 { + if len(source) == 0 { + return map[string]int64{} + } + cloned := make(map[string]int64, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} + +func cloneFloat64Map(source map[string]float64) map[string]float64 { + if len(source) == 0 { + return map[string]float64{} + } + cloned := make(map[string]float64, len(source)) + for key, value := range source { + cloned[key] = value + } + return cloned +} diff --git a/internal/api/usage_overview_test.go b/internal/api/usage_overview_test.go new file mode 100644 index 0000000000000000000000000000000000000000..165a0488aec0cf2fe339fc23e9e4c844e4b0a9e3 --- /dev/null +++ b/internal/api/usage_overview_test.go @@ -0,0 +1,196 @@ +package api + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/service" +) + +type usageFilterStub struct { + usage *cpa.StatisticsSnapshot + overview *service.UsageOverviewSnapshot + err error + lastFilter service.UsageFilter + filterCalls int + overviewCalls int +} + +func (s *usageFilterStub) GetUsageWithFilter(_ context.Context, filter service.UsageFilter) (*cpa.StatisticsSnapshot, error) { + s.lastFilter = filter + s.filterCalls++ + return s.usage, s.err +} + +func (s *usageFilterStub) GetUsageOverview(_ context.Context, filter service.UsageFilter) (*service.UsageOverviewSnapshot, error) { + s.lastFilter = filter + s.overviewCalls++ + return s.overview, s.err +} + +func (s *usageFilterStub) ListUsageEvents(context.Context, service.UsageFilter) (*service.UsageEventsPage, error) { + return nil, s.err +} + +func (s *usageFilterStub) ListUsageEventFilterOptions(context.Context, service.UsageFilter) (*service.UsageEventFilterOptions, error) { + return nil, s.err +} + +func (s *usageFilterStub) ListUsageCredentialStats(context.Context, service.UsageFilter) ([]service.UsageCredentialStat, error) { + return nil, s.err +} + +func (s *usageFilterStub) GetUsageAnalysis(context.Context, service.UsageFilter) (*service.UsageAnalysisSnapshot, error) { + return nil, s.err +} + +func mustParseTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("time.Parse returned error: %v", err) + } + return parsed +} + +func TestUsageOverviewResponseIncludesResolvedRangeAndTimezone(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + t.Cleanup(func() { time.Local = previousLocal }) + time.Local = location + + provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{}} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=custom&start=2026-04-20&end=2026-04-21", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + expectedStart := time.Date(2026, 4, 20, 0, 0, 0, 0, location).UTC().Format(time.RFC3339Nano) + expectedEnd := time.Date(2026, 4, 22, 0, 0, 0, 0, location).Add(-time.Nanosecond).UTC().Format(time.RFC3339Nano) + body := resp.Body.String() + if !contains(body, `"timezone":"Asia/Shanghai"`) || !contains(body, `"range_start":"`+expectedStart+`"`) || !contains(body, `"range_end":"`+expectedEnd+`"`) { + t.Fatalf("expected overview response to include resolved range and timezone, got %s", body) + } +} + +func TestUsageOverviewReturnsFilteredSnapshot(t *testing.T) { + provider := &usageFilterStub{overview: &service.UsageOverviewSnapshot{ + Usage: &cpa.StatisticsSnapshot{ + TotalRequests: 1, + SuccessCount: 1, + TotalTokens: 20, + RequestsByHour: map[string]int64{ + "2026-04-22T11:00:00Z": 1, + }, + TokensByHour: map[string]int64{ + "2026-04-22T11:00:00Z": 20, + }, + APIs: map[string]cpa.APISnapshot{ + "provider-a": { + TotalRequests: 1, + SuccessCount: 1, + TotalTokens: 20, + Models: map[string]cpa.ModelSnapshot{ + "claude-sonnet": { + TotalRequests: 1, + SuccessCount: 1, + TotalTokens: 20, + }, + }, + }, + }, + }, + Summary: service.UsageOverviewSummary{ + RequestCount: 1, + TokenCount: 20, + WindowMinutes: 1440, + RPM: 1.0 / 1440.0, + TPM: 20.0 / 1440.0, + TotalCost: 0.123, + CostAvailable: true, + CachedTokens: 2, + ReasoningTokens: 3, + }, + Series: service.UsageOverviewSeries{ + Requests: map[string]int64{"2026-04-22T11:00:00Z": 1}, + Tokens: map[string]int64{"2026-04-22T11:00:00Z": 20}, + RPM: map[string]float64{"2026-04-22T11:00:00Z": 1.0 / 60.0}, + TPM: map[string]float64{"2026-04-22T11:00:00Z": 20.0 / 60.0}, + Cost: map[string]float64{"2026-04-22T11:00:00Z": 0.123}, + InputTokens: map[string]int64{"2026-04-22T11:00:00Z": 11}, + OutputTokens: map[string]int64{"2026-04-22T11:00:00Z": 7}, + CachedTokens: map[string]int64{"2026-04-22T11:00:00Z": 2}, + ReasoningTokens: map[string]int64{"2026-04-22T11:00:00Z": 3}, + }, + Health: service.UsageOverviewHealth{ + TotalSuccess: 1, + TotalFailure: 0, + SuccessRate: 100, + BlockDetails: []service.UsageOverviewHealthBlock{{ + StartTime: mustParseTime(t, "2026-04-22T11:00:00Z"), + EndTime: mustParseTime(t, "2026-04-22T11:15:00Z"), + Success: 1, + Failure: 0, + Rate: 1, + }}, + }, + }} + router := NewRouter(nil, nil, provider, nil, AuthConfig{}, nil, "") + req := httptest.NewRequest(http.MethodGet, "/api/v1/usage/overview?range=24h", nil) + resp := httptest.NewRecorder() + + router.ServeHTTP(resp, req) + + if resp.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", resp.Code) + } + body := resp.Body.String() + if !contains(body, `"usage":`) || !contains(body, `"total_requests":1`) { + t.Fatalf("unexpected response body: %s", body) + } + if !contains(body, `"summary":{"request_count":1,"token_count":20`) { + t.Fatalf("expected backend summary in response body: %s", body) + } + if !contains(body, `"cost_available":true`) { + t.Fatalf("expected backend cost availability in response body: %s", body) + } + if !contains(body, `"series":{"requests":{"2026-04-22T11:00:00Z":1}`) { + t.Fatalf("expected backend series in response body: %s", body) + } + if !contains(body, `"input_tokens":{"2026-04-22T11:00:00Z":11}`) || + !contains(body, `"output_tokens":{"2026-04-22T11:00:00Z":7}`) || + !contains(body, `"cached_tokens":{"2026-04-22T11:00:00Z":2}`) || + !contains(body, `"reasoning_tokens":{"2026-04-22T11:00:00Z":3}`) { + t.Fatalf("expected token breakdown series in response body: %s", body) + } + if !contains(body, `"service_health":{"total_success":1,"total_failure":0,"success_rate":100`) || + !contains(body, `"block_details":[{"start_time":"2026-04-22T11:00:00Z","end_time":"2026-04-22T11:15:00Z","success":1,"failure":0,"rate":1}]`) { + t.Fatalf("expected service health in response body: %s", body) + } + if contains(body, `"details":`) { + t.Fatalf("expected overview response to omit request details: %s", body) + } + if provider.filterCalls != 0 { + t.Fatalf("expected GetUsageWithFilter not to be called, got %d", provider.filterCalls) + } + if provider.overviewCalls != 1 { + t.Fatalf("expected GetUsageOverview to be called once, got %d", provider.overviewCalls) + } + if provider.lastFilter.Range != "24h" { + t.Fatalf("expected range to be passed through, got %+v", provider.lastFilter) + } + if provider.lastFilter.StartTime == nil || provider.lastFilter.EndTime == nil { + t.Fatalf("expected resolved time bounds in filter, got %+v", provider.lastFilter) + } +} diff --git a/internal/api/usage_resolution.go b/internal/api/usage_resolution.go new file mode 100644 index 0000000000000000000000000000000000000000..52ff2e2d8e4ff39e73e1eafe6896c10dcd2915d7 --- /dev/null +++ b/internal/api/usage_resolution.go @@ -0,0 +1,17 @@ +package api + +import ( + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/service" + "github.com/gin-gonic/gin" +) + +func loadUsageResolutionData( + c *gin.Context, + usageIdentityProvider service.UsageIdentityProvider, +) ([]models.UsageIdentity, error) { + if usageIdentityProvider == nil { + return []models.UsageIdentity{}, nil + } + return usageIdentityProvider.ListUsageIdentities(c.Request.Context()) +} diff --git a/internal/api/usage_source_resolution.go b/internal/api/usage_source_resolution.go new file mode 100644 index 0000000000000000000000000000000000000000..b1c939c9dacbf0adc5e8e7b004ccda54ded7c5dd --- /dev/null +++ b/internal/api/usage_source_resolution.go @@ -0,0 +1,167 @@ +package api + +import ( + "strconv" + "strings" + + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/redact" +) + +type usageSourceResolver struct { + authIdentities map[string]models.UsageIdentity + providerIdentities map[string]models.UsageIdentity + providerRawByKey map[string]string +} + +func newUsageSourceResolver(identities []models.UsageIdentity) usageSourceResolver { + authIdentities := make(map[string]models.UsageIdentity, len(identities)) + providerIdentities := make(map[string]models.UsageIdentity, len(identities)) + providerRawByKey := make(map[string]string, len(identities)) + for _, identity := range identities { + key := strings.TrimSpace(identity.Identity) + if key == "" { + continue + } + switch identity.AuthType { + case models.UsageIdentityAuthTypeAuthFile: + authIdentities[key] = identity + case models.UsageIdentityAuthTypeAIProvider: + providerIdentities[key] = identity + resolved := usageSourceResolutionFromIdentity(identity, key) + if resolved.SourceKey != "" { + providerRawByKey[resolved.SourceKey] = key + } + } + } + + return usageSourceResolver{ + authIdentities: authIdentities, + providerIdentities: providerIdentities, + providerRawByKey: providerRawByKey, + } +} + +type usageSourceResolution struct { + DisplayName string + SourceType string + SourceKey string +} + +func usageSourceResolutionFromIdentity(item models.UsageIdentity, fallbackIdentity string) usageSourceResolution { + identityType := safeAIProviderDisplayValue(item.Type, fallbackIdentity, "") + displayName := firstNonEmptyString( + safeAIProviderDisplayValue(item.Name, fallbackIdentity, ""), + safeAIProviderDisplayValue(item.Provider, fallbackIdentity, ""), + identityType, + redact.APIKeyDisplayName(fallbackIdentity), + ) + sourceKey := "provider:" + uintToString(item.ID) + if item.ID == 0 { + sourceKey = "provider:" + redact.APIKeyDisplayName(fallbackIdentity) + } + return usageSourceResolution{ + DisplayName: displayName, + SourceType: identityType, + SourceKey: sourceKey, + } +} + +func (r usageSourceResolver) rawSourceForPublicValue(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "" + } + if raw, ok := r.providerRawByKey[trimmed]; ok { + return raw + } + return trimmed +} + +func (r usageSourceResolver) resolve(rawSource string, authIndex string) usageSourceResolution { + normalizedSource := strings.TrimSpace(rawSource) + if normalizedSource != "" { + if item, ok := r.providerIdentities[normalizedSource]; ok { + return usageSourceResolutionFromIdentity(item, normalizedSource) + } + } + + normalizedAuthIndex := strings.TrimSpace(authIndex) + if normalizedAuthIndex != "" { + if identity, ok := r.authIdentities[normalizedAuthIndex]; ok { + displayName := firstNonEmptyString(identity.Name, normalizedAuthIndex) + return usageSourceResolution{ + DisplayName: displayName, + SourceType: firstNonEmptyString(identity.Type, identity.Provider), + SourceKey: "auth:" + normalizedAuthIndex, + } + } + } + + if normalizedSource == "" { + return usageSourceResolution{DisplayName: "-", SourceKey: "raw:-"} + } + if looksLikeEmail(normalizedSource) { + return usageSourceResolution{ + DisplayName: normalizedSource, + SourceKey: "email:" + normalizedSource, + } + } + if inferredProvider := inferUsageProviderType(normalizedSource); inferredProvider != "" { + return usageSourceResolution{ + DisplayName: inferredProvider, + SourceType: inferredProvider, + SourceKey: "provider:fallback:" + inferredProvider, + } + } + masked := redact.APIKeyDisplayName(normalizedSource) + return usageSourceResolution{ + DisplayName: masked, + SourceKey: "raw:" + masked, + } +} + +func uintToString(value uint) string { + return strconv.FormatUint(uint64(value), 10) +} + +func firstNonEmptyString(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func looksLikeEmail(value string) bool { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return false + } + atIndex := strings.Index(trimmed, "@") + return atIndex > 0 && atIndex < len(trimmed)-1 && strings.Contains(trimmed[atIndex+1:], ".") +} + +func inferUsageProviderType(source string) string { + value := strings.ToLower(strings.TrimSpace(source)) + switch { + case value == "": + return "" + case strings.Contains(value, "ampcode"): + return "ampcode" + case strings.HasPrefix(value, "sk-ant-") || strings.Contains(value, "anthropic") || strings.Contains(value, "claude"): + return "claude" + case strings.HasPrefix(value, "sk-proj-") || strings.HasPrefix(value, "sk-") || strings.Contains(value, "openai") || strings.Contains(value, "gpt"): + return "openai" + case strings.HasPrefix(value, "aiza") || strings.Contains(value, "gemini"): + return "gemini" + case strings.Contains(value, "vertex"): + return "vertex" + case strings.Contains(value, "codex"): + return "codex" + default: + return "" + } +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000000000000000000000000000000000000..51ce2a3d5777cf7263653c5724db9210d7db3089 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,217 @@ +package app + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + + "cpa-usage-keeper/internal/api" + "cpa-usage-keeper/internal/auth" + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/logging" + "cpa-usage-keeper/internal/poller" + "cpa-usage-keeper/internal/repository" + "cpa-usage-keeper/internal/service" + webui "cpa-usage-keeper/web" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type Runner interface { + Run(ctx context.Context) error + Status() poller.Status + SyncNow(ctx context.Context) error +} + +type Options struct { + EnvFile string +} + +type App struct { + Config *config.Config + DB *gorm.DB + Router *gin.Engine + Poller Runner + Maintenance *StorageCleanupRunner + MetadataSync *MetadataSyncRunner + BackupMaintenance *DatabaseBackupRunner + LogCloser io.Closer + + backgroundCancel context.CancelFunc + backgroundWG sync.WaitGroup +} + +func New() (*App, error) { + return NewWithOptions(Options{}) +} + +func NewWithOptions(options Options) (*App, error) { + cfg, err := config.Load(config.LoadOptions{EnvFile: options.EnvFile}) + if err != nil { + return nil, err + } + + return NewWithConfig(*cfg) +} + +func NewWithConfig(cfg config.Config) (*App, error) { + logCloser, err := logging.Configure(cfg) + if err != nil { + return nil, err + } + + db, err := repository.OpenDatabase(cfg) + if err != nil { + _ = logCloser.Close() + return nil, err + } + + syncService := service.NewSyncService(db, cfg) + backgroundPoller := poller.NewRedisDrain(syncService, poller.RedisDrainConfig{ + IdleInterval: cfg.RedisQueueIdleInterval, + ErrorBackoff: cfg.RedisQueueErrorBackoff, + }) + var backupMaintenance *DatabaseBackupRunner + if cfg.BackupEnabled { + sqlDB, err := db.DB() + if err != nil { + _ = closeGormDB(db) + _ = logCloser.Close() + return nil, err + } + backupStore := newDatabaseBackupStore(sqlDB, cfg.BackupDir) + backupMaintenance = NewDatabaseBackupRunner(backupStore, backupStore, cfg.BackupInterval, cfg.BackupRetentionDays) + } + + usageService := service.NewUsageService(db) + usageIdentityService := service.NewUsageIdentityService(db) + pricingModelsClient := cpa.NewClient(cfg.CPABaseURL, cfg.CPAManagementKey, cfg.RequestTimeout) + pricingService := service.NewPricingService(db, pricingModelsClient) + sessionManager := auth.NewSessionManager(cfg.AuthSessionTTL) + authHandler := api.NewAuthHandler(api.AuthConfig{ + Enabled: cfg.AuthEnabled, + LoginPassword: cfg.LoginPassword, + SessionTTL: cfg.AuthSessionTTL, + BasePath: cfg.AppBasePath, + }, sessionManager) + + return &App{ + Config: &cfg, + DB: db, + Poller: backgroundPoller, + Maintenance: NewStorageCleanupRunner(syncService), + MetadataSync: NewMetadataSyncRunner(syncService, cfg.MetadataSyncInterval), + BackupMaintenance: backupMaintenance, + LogCloser: logCloser, + Router: api.NewRouter( + webui.Static, + newManualSyncRunner(backgroundPoller, syncService), + usageService, + pricingService, + api.AuthConfig{ + Enabled: cfg.AuthEnabled, + LoginPassword: cfg.LoginPassword, + SessionTTL: cfg.AuthSessionTTL, + BasePath: cfg.AppBasePath, + }, + authHandler, + cfg.AppBasePath, + usageIdentityService, + ), + }, nil +} + +func closeGormDB(db *gorm.DB) error { + if db == nil { + return nil + } + sqlDB, err := db.DB() + if err != nil { + return err + } + return sqlDB.Close() +} + +func (a *App) Close() error { + if a == nil { + return nil + } + + a.stopBackgroundTasks() + + var closeErr error + if a.DB != nil { + closeErr = errors.Join(closeErr, closeGormDB(a.DB)) + a.DB = nil + } + if a.LogCloser != nil { + closeErr = errors.Join(closeErr, a.LogCloser.Close()) + a.LogCloser = nil + } + return closeErr +} + +func (a *App) Run() error { + if a == nil || a.Router == nil || a.Config == nil { + return fmt.Errorf("application is not initialized") + } + + ctx := a.startBackgroundContext() + defer a.stopBackgroundTasks() + if a.Poller != nil { + a.startBackgroundTask(func() { + if err := a.Poller.Run(ctx); err != nil { + logrus.Errorf("poller stopped: %v", err) + } + }) + } + if a.Maintenance != nil { + a.startBackgroundTask(func() { + if err := a.Maintenance.Run(ctx); err != nil { + logrus.Errorf("maintenance cleanup stopped: %v", err) + } + }) + } + if a.MetadataSync != nil { + a.startBackgroundTask(func() { + if err := a.MetadataSync.Run(ctx); err != nil { + logrus.Errorf("metadata sync stopped: %v", err) + } + }) + } + if a.BackupMaintenance != nil { + a.startBackgroundTask(func() { + if err := a.BackupMaintenance.Run(ctx); err != nil { + logrus.Errorf("database backup stopped: %v", err) + } + }) + } + + return a.Router.Run(":" + a.Config.AppPort) +} + +func (a *App) startBackgroundContext() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + a.backgroundCancel = cancel + return ctx +} + +func (a *App) startBackgroundTask(run func()) { + a.backgroundWG.Add(1) + go func() { + defer a.backgroundWG.Done() + run() + }() +} + +func (a *App) stopBackgroundTasks() { + if a.backgroundCancel != nil { + a.backgroundCancel() + a.backgroundCancel = nil + } + a.backgroundWG.Wait() +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000000000000000000000000000000000000..aa70bb27fa1be1d4a7f4a55f42e432af8f36b958 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,239 @@ +package app + +import ( + "bytes" + "context" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/poller" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func TestAppCloseClosesDatabase(t *testing.T) { + app, err := NewWithConfig(testAppConfig(t)) + if err != nil { + t.Fatalf("NewWithConfig returned error: %v", err) + } + sqlDB, err := app.DB.DB() + if err != nil { + t.Fatalf("load sql db: %v", err) + } + + if err := app.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + if err := sqlDB.Ping(); err == nil { + t.Fatal("expected database ping to fail after app close") + } +} + +func TestNewWithConfigBuildsRedisDrainAndRouter(t *testing.T) { + app, err := NewWithConfig(testAppConfig(t)) + if err != nil { + t.Fatalf("NewWithConfig returned error: %v", err) + } + defer app.Close() + if app.Poller == nil { + t.Fatal("expected poller to be initialized") + } + if app.Router == nil { + t.Fatal("expected router to be initialized") + } + if app.LogCloser == nil { + t.Fatal("expected log closer to be initialized") + } + if app.BackupMaintenance == nil { + t.Fatal("expected database backup runner to be initialized") + } + if app.MetadataSync == nil { + t.Fatal("expected metadata sync runner to be initialized") + } +} + +func TestNewWithConfigSkipsBackupRunnerWhenDisabled(t *testing.T) { + cfg := testAppConfig(t) + cfg.BackupEnabled = false + app, err := NewWithConfig(cfg) + if err != nil { + t.Fatalf("NewWithConfig returned error: %v", err) + } + defer app.Close() + if app.BackupMaintenance != nil { + t.Fatal("expected database backup runner to be skipped when backups are disabled") + } +} + +func TestNewWithConfigSelectsRedisDrain(t *testing.T) { + app, err := NewWithConfig(testAppConfig(t)) + if err != nil { + t.Fatalf("NewWithConfig returned error: %v", err) + } + defer app.Close() + if _, ok := app.Poller.(*poller.RedisDrain); !ok { + t.Fatalf("expected redis to use redis drain, got %T", app.Poller) + } + if app.Maintenance == nil { + t.Fatal("expected maintenance cleanup runner to be initialized") + } +} + +func TestNewWithConfigCreatesIndependentMaintenanceRunner(t *testing.T) { + app, err := NewWithConfig(testAppConfig(t)) + if err != nil { + t.Fatalf("NewWithConfig returned error: %v", err) + } + defer app.Close() + if app.Poller == nil { + t.Fatal("expected sync poller to be initialized") + } + if app.Maintenance == nil { + t.Fatal("expected independent maintenance runner to be initialized") + } +} + +func TestRunStartsPollerAndMaintenanceIndependently(t *testing.T) { + cfg := testAppConfig(t) + cfg.AppPort = "invalid-port" + pollerStarted := make(chan struct{}) + maintenanceStarted := make(chan struct{}) + metadataStarted := make(chan struct{}) + backupStarted := make(chan struct{}) + maintenance := NewStorageCleanupRunner(&maintenanceSyncStub{}) + maintenance.sleep = func(context.Context, time.Duration) bool { + close(maintenanceStarted) + return false + } + metadataRunner := NewMetadataSyncRunner(&metadataSyncStub{}, time.Second) + metadataRunner.sleep = func(context.Context, time.Duration) bool { + close(metadataStarted) + return false + } + backupRunner := NewDatabaseBackupRunner(&databaseBackupWriterStub{}, nil, time.Second, 0) + backupRunner.sleep = func(context.Context, time.Duration) bool { + close(backupStarted) + return false + } + app := &App{ + Config: &cfg, + Router: gin.New(), + Poller: &appRunStub{started: pollerStarted}, + Maintenance: maintenance, + MetadataSync: metadataRunner, + BackupMaintenance: backupRunner, + } + + if err := app.Run(); err == nil { + t.Fatal("expected Run to return an error for invalid port") + } + select { + case <-pollerStarted: + case <-time.After(time.Second): + t.Fatal("expected poller runner to start") + } + select { + case <-maintenanceStarted: + case <-time.After(time.Second): + t.Fatal("expected maintenance runner to start") + } + select { + case <-metadataStarted: + case <-time.After(time.Second): + t.Fatal("expected metadata sync runner to start") + } + select { + case <-backupStarted: + case <-time.After(time.Second): + t.Fatal("expected database backup runner to start") + } +} + +func TestRunCancelsBackgroundTasksWhenRouterStops(t *testing.T) { + cfg := testAppConfig(t) + cfg.AppPort = "invalid-port" + backupStarted := make(chan struct{}) + backupCanceled := make(chan struct{}) + backupRunner := NewDatabaseBackupRunner(&databaseBackupWriterStub{}, nil, time.Second, 0) + backupRunner.sleep = func(ctx context.Context, _ time.Duration) bool { + close(backupStarted) + <-ctx.Done() + close(backupCanceled) + return false + } + app := &App{ + Config: &cfg, + Router: gin.New(), + BackupMaintenance: backupRunner, + } + + if err := app.Run(); err == nil { + t.Fatal("expected Run to return an error for invalid port") + } + select { + case <-backupStarted: + case <-time.After(time.Second): + t.Fatal("expected database backup runner to start") + } + select { + case <-backupCanceled: + case <-time.After(time.Second): + t.Fatal("expected database backup runner context to be canceled") + } +} + +type appRunStub struct { + started chan struct{} +} + +func (s *appRunStub) Run(context.Context) error { + close(s.started) + return nil +} + +func (s *appRunStub) Status() poller.Status { + return poller.Status{} +} + +func (s *appRunStub) SyncNow(context.Context) error { + return nil +} + +func captureAppInfoLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var logs bytes.Buffer + previousOutput := logrus.StandardLogger().Out + previousFormatter := logrus.StandardLogger().Formatter + previousLevel := logrus.GetLevel() + logrus.SetOutput(&logs) + logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true}) + logrus.SetLevel(logrus.InfoLevel) + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + logrus.SetFormatter(previousFormatter) + logrus.SetLevel(previousLevel) + }) + return &logs +} + +func testAppConfig(t *testing.T) config.Config { + t.Helper() + return config.Config{ + AppPort: "8080", + CPABaseURL: "https://cpa.example.com", + CPAManagementKey: "secret", + RedisQueueIdleInterval: time.Second, + RedisQueueErrorBackoff: 10 * time.Second, + MetadataSyncInterval: 30 * time.Second, + SQLitePath: t.TempDir() + "/app.db", + BackupEnabled: true, + BackupDir: t.TempDir() + "/backups", + BackupRetentionDays: 7, + RequestTimeout: 5 * time.Second, + LogLevel: "info", + LogFileEnabled: false, + LogRetentionDays: 7, + } +} diff --git a/internal/app/backup_runner.go b/internal/app/backup_runner.go new file mode 100644 index 0000000000000000000000000000000000000000..68af66ffb908b71e98304e35532004ad6a4e97e3 --- /dev/null +++ b/internal/app/backup_runner.go @@ -0,0 +1,218 @@ +package app + +import ( + "context" + "database/sql" + "fmt" + "os" + "sync" + "time" + + "cpa-usage-keeper/internal/backup" + "github.com/sirupsen/logrus" +) + +type DatabaseBackupWriter interface { + WriteDatabase(context.Context, time.Time) (string, error) +} + +type DatabaseBackupCleaner interface { + Cleanup(retentionDays int, now time.Time) (int, error) +} + +type DatabaseBackupHistory interface { + LastBackupAt() (time.Time, bool, error) +} + +type databaseBackupStore struct { + db *sql.DB + dir string + writer *backup.Writer +} + +func newDatabaseBackupStore(db *sql.DB, dir string) *databaseBackupStore { + return &databaseBackupStore{db: db, dir: dir, writer: backup.NewWriter(dir)} +} + +func (s *databaseBackupStore) WriteDatabase(ctx context.Context, backupAt time.Time) (string, error) { + return s.writer.WriteDatabase(ctx, s.db, backupAt) +} + +func (s *databaseBackupStore) Cleanup(retentionDays int, now time.Time) (int, error) { + return s.writer.Cleanup(retentionDays, now) +} + +func (s *databaseBackupStore) LastBackupAt() (time.Time, bool, error) { + files, err := backup.ListFiles(s.dir) + if err != nil { + return time.Time{}, false, err + } + var latest time.Time + for _, file := range files { + info, err := os.Stat(file) + if err != nil { + return time.Time{}, false, err + } + if info.ModTime().After(latest) { + latest = info.ModTime() + } + } + return latest, !latest.IsZero(), nil +} + +type DatabaseBackupRunner struct { + writer DatabaseBackupWriter + cleaner DatabaseBackupCleaner + history DatabaseBackupHistory + interval time.Duration + retentionDays int + lastBackupAt time.Time + retryDelay time.Duration + retryAttempts int + pendingRetry bool + now func() time.Time + sleep func(context.Context, time.Duration) bool + + mu sync.Mutex + running bool +} + +func NewDatabaseBackupRunner(writer DatabaseBackupWriter, cleaner DatabaseBackupCleaner, interval time.Duration, retentionDays int) *DatabaseBackupRunner { + history, _ := writer.(DatabaseBackupHistory) + return &DatabaseBackupRunner{ + writer: writer, + cleaner: cleaner, + history: history, + interval: interval, + retentionDays: retentionDays, + retryDelay: 15 * time.Minute, + now: time.Now, + sleep: maintenanceSleepContext, + } +} + +func (r *DatabaseBackupRunner) Run(ctx context.Context) error { + if err := r.validate(); err != nil { + return err + } + logrus.Info("database backup task started") + r.setRunning(true) + defer r.setRunning(false) + + for { + now := r.now() + delay := r.nextDelay(now) + if delay < 0 { + delay = 0 + } + if !r.sleep(ctx, delay) { + return nil + } + backupAt := r.now() + if _, err := r.writer.WriteDatabase(ctx, backupAt); err != nil { + logrus.WithError(err).Error("database backup failed") + r.retryAttempts++ + r.pendingRetry = r.retryAttempts <= 3 + } else { + r.lastBackupAt = backupAt + r.retryAttempts = 0 + r.pendingRetry = false + } + r.cleanup(backupAt) + } +} + +func (r *DatabaseBackupRunner) nextDelay(now time.Time) time.Duration { + if r.pendingRetry { + return r.retryDelay + } + if r.usesDailySchedule() { + return r.nextDailyDelay(now) + } + lastBackupAt, ok := r.lastBackupAtFromHistory() + if !ok { + return 0 + } + return lastBackupAt.Add(r.interval).Sub(now) +} + +func (r *DatabaseBackupRunner) cleanup(now time.Time) { + if r.cleaner == nil || r.retentionDays <= 0 { + return + } + if _, err := r.cleaner.Cleanup(r.retentionDays, now); err != nil { + logrus.WithError(err).Error("database backup cleanup failed") + } +} + +func (r *DatabaseBackupRunner) nextDailyDelay(now time.Time) time.Duration { + localNow := now.In(time.Local) + nextBackupAt := nextDailyBackupAt(localNow) + if lastBackupAt, ok := r.lastBackupAtFromHistory(); ok { + lastLocalBackup := lastBackupAt.In(time.Local) + lastBackupDay := time.Date(lastLocalBackup.Year(), lastLocalBackup.Month(), lastLocalBackup.Day(), 4, 0, 0, 0, time.Local) + candidate := lastBackupDay.AddDate(0, 0, r.dailyScheduleDays()) + if localNow.Before(candidate) { + nextBackupAt = candidate + } + } + return nextBackupAt.Sub(localNow) +} + +func (r *DatabaseBackupRunner) usesDailySchedule() bool { + return r.interval >= 24*time.Hour && r.interval%(24*time.Hour) == 0 +} + +func (r *DatabaseBackupRunner) dailyScheduleDays() int { + return int(r.interval / (24 * time.Hour)) +} + +func (r *DatabaseBackupRunner) lastBackupAtFromHistory() (time.Time, bool) { + lastBackupAt := r.lastBackupAt + if r.history != nil { + storedBackupAt, ok, err := r.history.LastBackupAt() + if err != nil { + logrus.WithError(err).Error("load last database backup time failed") + } else if ok && storedBackupAt.After(lastBackupAt) { + lastBackupAt = storedBackupAt + } + } + return lastBackupAt, !lastBackupAt.IsZero() +} + +func nextDailyBackupAt(now time.Time) time.Time { + localNow := now.In(time.Local) + backupAt := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 4, 0, 0, 0, time.Local) + if !localNow.Before(backupAt) { + backupAt = backupAt.AddDate(0, 0, 1) + } + return backupAt +} + +func (r *DatabaseBackupRunner) validate() error { + if r == nil { + return fmt.Errorf("database backup runner is nil") + } + if r.writer == nil { + return fmt.Errorf("database backup writer is nil") + } + if r.interval <= 0 { + return fmt.Errorf("database backup interval must be positive") + } + if r.retryDelay <= 0 { + r.retryDelay = 15 * time.Minute + } + if r.now == nil { + r.now = time.Now + } + if r.sleep == nil { + r.sleep = maintenanceSleepContext + } + return nil +} + +func (r *DatabaseBackupRunner) setRunning(running bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.running = running +} diff --git a/internal/app/backup_runner_test.go b/internal/app/backup_runner_test.go new file mode 100644 index 0000000000000000000000000000000000000000..526c7ce3a1a282235a625464e9c574b56df9b402 --- /dev/null +++ b/internal/app/backup_runner_test.go @@ -0,0 +1,264 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +func TestNextDailyBackupAtUsesLocal0400(t *testing.T) { + previousLocal := time.Local + time.Local = time.FixedZone("Test/Local", 8*60*60) + t.Cleanup(func() { time.Local = previousLocal }) + + before := time.Date(2026, 4, 16, 3, 30, 0, 0, time.Local) + if got := nextDailyBackupAt(before); !got.Equal(time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local)) { + t.Fatalf("expected same-day 04:00 backup, got %s", got) + } + after := time.Date(2026, 4, 16, 4, 1, 0, 0, time.Local) + if got := nextDailyBackupAt(after); !got.Equal(time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local)) { + t.Fatalf("expected next-day 04:00 backup, got %s", got) + } +} + +func TestDatabaseBackupRunnerRunsAtScheduledTime(t *testing.T) { + writer := &databaseBackupWriterStub{} + cleaner := &databaseBackupCleanerStub{} + runner := NewDatabaseBackupRunner(writer, cleaner, 24*time.Hour, 30) + now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local) + runner.now = func() time.Time { return now } + sleepCalls := 0 + runner.sleep = func(context.Context, time.Duration) bool { + sleepCalls++ + if sleepCalls == 1 { + return true + } + return false + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if writer.calls != 1 { + t.Fatalf("expected one database backup, got %d", writer.calls) + } + if cleaner.calls != 1 { + t.Fatalf("expected one backup cleanup, got %d", cleaner.calls) + } + if cleaner.retentionDays != 30 { + t.Fatalf("expected cleanup retention 30, got %d", cleaner.retentionDays) + } +} + +func TestDatabaseBackupRunnerWaitsUntilNext0400AfterDailyScheduleWithoutTodaysBackup(t *testing.T) { + writer := &databaseBackupWriterStub{} + runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0) + now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local) + writer.lastBackupAt = now.AddDate(0, 0, -1) + runner.now = func() time.Time { return now } + var delay time.Duration + runner.sleep = func(_ context.Context, d time.Duration) bool { + delay = d + return false + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now) + if delay != expected { + t.Fatalf("expected next 04:00 backup delay %s, got %s", expected, delay) + } +} + +func TestDatabaseBackupRunnerWaitsUntilTomorrowAfterTodaysDailyBackup(t *testing.T) { + writer := &databaseBackupWriterStub{} + runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0) + now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local) + writer.lastBackupAt = time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local) + runner.now = func() time.Time { return now } + var delay time.Duration + runner.sleep = func(_ context.Context, d time.Duration) bool { + delay = d + return false + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now) + if delay != expected { + t.Fatalf("expected next daily backup delay %s, got %s", expected, delay) + } +} + +func TestDatabaseBackupRunnerUsesDailyScheduleForMultipleOf24Hours(t *testing.T) { + writer := &databaseBackupWriterStub{} + runner := NewDatabaseBackupRunner(writer, nil, 48*time.Hour, 0) + now := time.Date(2026, 4, 16, 4, 5, 0, 0, time.Local) + writer.lastBackupAt = time.Date(2026, 4, 15, 10, 0, 0, 0, time.Local) + runner.now = func() time.Time { return now } + var delay time.Duration + runner.sleep = func(_ context.Context, d time.Duration) bool { + delay = d + return false + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + expected := time.Date(2026, 4, 17, 4, 0, 0, 0, time.Local).Sub(now) + if delay != expected { + t.Fatalf("expected next 48h daily schedule delay %s, got %s", expected, delay) + } +} + +func TestDatabaseBackupRunnerUsesExistingBackupForIntervalSchedule(t *testing.T) { + writer := &databaseBackupWriterStub{} + runner := NewDatabaseBackupRunner(writer, nil, 10*time.Second, 0) + now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local) + writer.lastBackupAt = now.Add(-4 * time.Second) + runner.now = func() time.Time { return now } + var delays []time.Duration + sleepCalls := 0 + runner.sleep = func(_ context.Context, d time.Duration) bool { + sleepCalls++ + delays = append(delays, d) + return sleepCalls == 1 + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if len(delays) == 0 || delays[0] != 6*time.Second { + t.Fatalf("expected first interval delay 6s, got %+v", delays) + } + if writer.calls != 1 { + t.Fatalf("expected one database backup, got %d", writer.calls) + } +} + +func TestDatabaseBackupRunnerRunsImmediatelyWhenIntervalBackupIsExpired(t *testing.T) { + writer := &databaseBackupWriterStub{} + runner := NewDatabaseBackupRunner(writer, nil, 10*time.Second, 0) + now := time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local) + writer.lastBackupAt = now.Add(-11 * time.Second) + runner.now = func() time.Time { return now } + var delays []time.Duration + sleepCalls := 0 + runner.sleep = func(_ context.Context, d time.Duration) bool { + sleepCalls++ + delays = append(delays, d) + return sleepCalls == 1 + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if len(delays) == 0 || delays[0] != 0 { + t.Fatalf("expected immediate backup for expired interval, got delays %+v", delays) + } +} + +func TestDatabaseBackupRunnerCleansAfterBackupFailure(t *testing.T) { + logs := captureAppInfoLogs(t) + writer := &databaseBackupWriterStub{err: errors.New("disk full")} + cleaner := &databaseBackupCleanerStub{} + runner := NewDatabaseBackupRunner(writer, cleaner, time.Second, 30) + runner.now = func() time.Time { return time.Date(2026, 4, 16, 3, 45, 0, 0, time.Local) } + sleepCalls := 0 + runner.sleep = func(context.Context, time.Duration) bool { + sleepCalls++ + return sleepCalls == 1 + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if writer.calls != 1 { + t.Fatalf("expected one database backup attempt, got %d", writer.calls) + } + if cleaner.calls != 1 { + t.Fatalf("expected cleanup after failed backup, got %d calls", cleaner.calls) + } + content := logs.String() + if !strings.Contains(content, "level=error") || !strings.Contains(content, "msg=\"database backup failed\"") { + t.Fatalf("expected database backup failure error log, got %q", content) + } +} + +func TestDatabaseBackupRunnerRetriesDailyBackupAfterFailure(t *testing.T) { + writer := &databaseBackupWriterStub{err: errors.New("temporary failure")} + runner := NewDatabaseBackupRunner(writer, nil, 24*time.Hour, 0) + now := time.Date(2026, 4, 16, 3, 59, 0, 0, time.Local) + runner.now = func() time.Time { return now } + var delays []time.Duration + sleepCalls := 0 + runner.sleep = func(_ context.Context, d time.Duration) bool { + delays = append(delays, d) + sleepCalls++ + switch sleepCalls { + case 1: + now = time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local) + case 2: + now = time.Date(2026, 4, 16, 4, 15, 0, 0, time.Local) + case 3: + now = time.Date(2026, 4, 16, 4, 30, 0, 0, time.Local) + case 4: + now = time.Date(2026, 4, 16, 4, 45, 0, 0, time.Local) + default: + return false + } + return true + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + expectedDelays := []time.Duration{time.Minute, 15 * time.Minute, 15 * time.Minute, 15 * time.Minute, 23*time.Hour + 15*time.Minute} + if len(delays) != len(expectedDelays) { + t.Fatalf("expected delays %+v, got %+v", expectedDelays, delays) + } + for i, expected := range expectedDelays { + if delays[i] != expected { + t.Fatalf("expected delay %d to be %s, got %s", i, expected, delays[i]) + } + } + if writer.calls != 4 { + t.Fatalf("expected initial attempt plus 3 retries, got %d attempts", writer.calls) + } +} + +type databaseBackupWriterStub struct { + calls int + err error + lastBackupAt time.Time +} + +func (s *databaseBackupWriterStub) WriteDatabase(_ context.Context, backupAt time.Time) (string, error) { + s.calls++ + if s.err == nil { + s.lastBackupAt = backupAt + } + return "/tmp/database.db", s.err +} + +func (s *databaseBackupWriterStub) LastBackupAt() (time.Time, bool, error) { + return s.lastBackupAt, !s.lastBackupAt.IsZero(), nil +} + +type databaseBackupCleanerStub struct { + calls int + retentionDays int + now time.Time + err error +} + +func (s *databaseBackupCleanerStub) Cleanup(retentionDays int, now time.Time) (int, error) { + s.calls++ + s.retentionDays = retentionDays + s.now = now + return 0, s.err +} diff --git a/internal/app/maintenance.go b/internal/app/maintenance.go new file mode 100644 index 0000000000000000000000000000000000000000..f116374585df6ae9e4dc7532355372859e51101d --- /dev/null +++ b/internal/app/maintenance.go @@ -0,0 +1,98 @@ +package app + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +type StorageCleanupSyncer interface { + CleanupStorage(ctx context.Context) error +} + +type StorageCleanupRunner struct { + syncer StorageCleanupSyncer + now func() time.Time + sleep func(context.Context, time.Duration) bool + + mu sync.Mutex + running bool +} + +func NewStorageCleanupRunner(syncer StorageCleanupSyncer) *StorageCleanupRunner { + return &StorageCleanupRunner{ + syncer: syncer, + now: time.Now, + sleep: maintenanceSleepContext, + } +} + +// Run 每天按项目本地时区 03:00 执行一次统一存储清理,失败只记录日志,不终止后台任务。 +func (r *StorageCleanupRunner) Run(ctx context.Context) error { + if err := r.validate(); err != nil { + return err + } + logrus.Info("storage cleanup task started") + r.setRunning(true) + defer r.setRunning(false) + + for { + now := r.now() + delay := nextDailyCleanupAt(now).Sub(now) + if delay < 0 { + delay = 0 + } + if !r.sleep(ctx, delay) { + return nil + } + if err := r.syncer.CleanupStorage(ctx); err != nil { + logrus.WithError(err).Error("storage cleanup failed") + } + } +} + +// nextDailyCleanupAt 用 time.Local 计算下一次 03:00,因此 TZ 同时控制业务日期边界和清理触发时间。 +func nextDailyCleanupAt(now time.Time) time.Time { + localNow := now.In(time.Local) + cleanupAt := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 3, 0, 0, 0, time.Local) + if !localNow.Before(cleanupAt) { + cleanupAt = cleanupAt.AddDate(0, 0, 1) + } + return cleanupAt +} + +func (r *StorageCleanupRunner) validate() error { + if r == nil { + return fmt.Errorf("storage cleanup runner is nil") + } + if r.syncer == nil { + return fmt.Errorf("storage cleanup syncer is nil") + } + if r.now == nil { + r.now = time.Now + } + if r.sleep == nil { + r.sleep = maintenanceSleepContext + } + return nil +} + +func maintenanceSleepContext(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func (r *StorageCleanupRunner) setRunning(running bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.running = running +} diff --git a/internal/app/maintenance_test.go b/internal/app/maintenance_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e882c2339967491dd9dd7ad92edbc5eadde4983b --- /dev/null +++ b/internal/app/maintenance_test.go @@ -0,0 +1,107 @@ +package app + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/sirupsen/logrus" +) + +type maintenanceSyncStub struct { + cleanupCalls int +} + +func (s *maintenanceSyncStub) CleanupStorage(context.Context) error { + s.cleanupCalls++ + return nil +} + +func TestNextDailyCleanupAtUsesLocalThreeAM(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + time.Local = location + t.Cleanup(func() { time.Local = previousLocal }) + + before := nextDailyCleanupAt(time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC)) + if !before.Equal(time.Date(2026, 4, 26, 19, 0, 0, 0, time.UTC)) { + t.Fatalf("expected same local day 03:00 cleanup, got %s", before) + } + after := nextDailyCleanupAt(time.Date(2026, 4, 26, 20, 30, 0, 0, time.UTC)) + if !after.Equal(time.Date(2026, 4, 27, 19, 0, 0, 0, time.UTC)) { + t.Fatalf("expected next local day 03:00 cleanup, got %s", after) + } +} + +func TestStorageCleanupRunnerLogsTaskStart(t *testing.T) { + logs := captureMaintenanceInfoLogs(t) + syncer := &maintenanceSyncStub{} + runner := NewStorageCleanupRunner(syncer) + runner.now = func() time.Time { return time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC) } + runner.sleep = func(context.Context, time.Duration) bool { return false } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("cleanup runner returned error: %v", err) + } + + content := logs.String() + if !strings.Contains(content, "level=info") || !strings.Contains(content, "msg=\"storage cleanup task started\"") { + t.Fatalf("expected storage cleanup start info log, got %q", content) + } +} + +func TestStorageCleanupRunnerRunsAtScheduledTime(t *testing.T) { + syncer := &maintenanceSyncStub{} + runner := NewStorageCleanupRunner(syncer) + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + time.Local = location + t.Cleanup(func() { time.Local = previousLocal }) + runner.now = func() time.Time { return time.Date(2026, 4, 26, 18, 30, 0, 0, time.UTC) } + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + runner.sleep = func(_ context.Context, d time.Duration) bool { + calls++ + if calls == 1 { + if d != 30*time.Minute { + t.Fatalf("expected cleanup sleep until local 03:00, got %s", d) + } + return true + } + cancel() + return false + } + + if err := runner.Run(ctx); err != nil { + t.Fatalf("cleanup runner returned error: %v", err) + } + + if syncer.cleanupCalls != 1 { + t.Fatalf("expected cleanup loop to run once, got %d", syncer.cleanupCalls) + } +} + +func captureMaintenanceInfoLogs(t *testing.T) *bytes.Buffer { + t.Helper() + var logs bytes.Buffer + previousOutput := logrus.StandardLogger().Out + previousFormatter := logrus.StandardLogger().Formatter + previousLevel := logrus.GetLevel() + logrus.SetOutput(&logs) + logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true}) + logrus.SetLevel(logrus.InfoLevel) + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + logrus.SetFormatter(previousFormatter) + logrus.SetLevel(previousLevel) + }) + return &logs +} diff --git a/internal/app/manual_sync.go b/internal/app/manual_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..a47f0e4f9bd262ef9c0a7263f71beaea8da39c70 --- /dev/null +++ b/internal/app/manual_sync.go @@ -0,0 +1,60 @@ +package app + +import ( + "context" + "fmt" + + "cpa-usage-keeper/internal/poller" +) + +type manualSyncRunner struct { + redis Runner + metadata MetadataSyncer +} + +type manualSyncStageError struct { + message string + err error +} + +func (e manualSyncStageError) Error() string { + return fmt.Sprintf("%s: %v", e.message, e.err) +} + +func (e manualSyncStageError) Unwrap() error { + return e.err +} + +func (e manualSyncStageError) UserMessage() string { + return e.message +} + +func newManualSyncRunner(redis Runner, metadata MetadataSyncer) *manualSyncRunner { + return &manualSyncRunner{redis: redis, metadata: metadata} +} + +func (r *manualSyncRunner) Status() poller.Status { + if r == nil || r.redis == nil { + return poller.Status{} + } + return r.redis.Status() +} + +func (r *manualSyncRunner) SyncNow(ctx context.Context) error { + if r == nil { + return fmt.Errorf("manual sync runner is nil") + } + if r.redis == nil { + return fmt.Errorf("manual redis syncer is nil") + } + if err := r.redis.SyncNow(ctx); err != nil { + return manualSyncStageError{message: "redis sync failed", err: err} + } + if r.metadata == nil { + return fmt.Errorf("manual metadata syncer is nil") + } + if err := r.metadata.SyncMetadata(ctx); err != nil { + return manualSyncStageError{message: "metadata sync failed", err: err} + } + return nil +} diff --git a/internal/app/manual_sync_test.go b/internal/app/manual_sync_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e95f9441c55955decee241f767a640e8d8a83cfd --- /dev/null +++ b/internal/app/manual_sync_test.go @@ -0,0 +1,100 @@ +package app + +import ( + "context" + "errors" + "reflect" + "testing" + + "cpa-usage-keeper/internal/poller" +) + +type manualRedisSyncStub struct { + status poller.Status + err error + calls int + order *[]string +} + +func (s *manualRedisSyncStub) Run(context.Context) error { + return nil +} + +func (s *manualRedisSyncStub) Status() poller.Status { + return s.status +} + +func (s *manualRedisSyncStub) SyncNow(context.Context) error { + s.calls++ + if s.order != nil { + *s.order = append(*s.order, "redis") + } + return s.err +} + +type manualMetadataSyncStub struct { + err error + calls int + order *[]string +} + +func (s *manualMetadataSyncStub) SyncMetadata(context.Context) error { + s.calls++ + if s.order != nil { + *s.order = append(*s.order, "metadata") + } + return s.err +} + +func TestManualSyncRunnerRunsRedisThenMetadata(t *testing.T) { + var order []string + redis := &manualRedisSyncStub{status: poller.Status{LastStatus: "completed"}, order: &order} + metadata := &manualMetadataSyncStub{order: &order} + runner := newManualSyncRunner(redis, metadata) + + if err := runner.SyncNow(context.Background()); err != nil { + t.Fatalf("SyncNow returned error: %v", err) + } + if !reflect.DeepEqual(order, []string{"redis", "metadata"}) { + t.Fatalf("expected redis then metadata sync, got %v", order) + } + if runner.Status().LastStatus != "completed" { + t.Fatalf("expected status to delegate to redis runner, got %+v", runner.Status()) + } +} + +func TestManualSyncRunnerReturnsRedisErrorWithoutMetadata(t *testing.T) { + redisErr := errors.New("redis failed") + redis := &manualRedisSyncStub{err: redisErr} + metadata := &manualMetadataSyncStub{} + runner := newManualSyncRunner(redis, metadata) + + err := runner.SyncNow(context.Background()) + if !errors.Is(err, redisErr) { + t.Fatalf("expected redis error, got %v", err) + } + if err == nil || err.Error() != "redis sync failed: redis failed" { + t.Fatalf("expected redis-specific sync error, got %v", err) + } + if metadata.calls != 0 { + t.Fatalf("expected metadata sync not to run after redis failure, got %d calls", metadata.calls) + } +} + +func TestManualSyncRunnerReturnsMetadataError(t *testing.T) { + metadataErr := errors.New("metadata failed") + redis := &manualRedisSyncStub{} + metadata := &manualMetadataSyncStub{err: metadataErr} + runner := newManualSyncRunner(redis, metadata) + + err := runner.SyncNow(context.Background()) + if !errors.Is(err, metadataErr) { + t.Fatalf("expected metadata error, got %v", err) + } + if err == nil || err.Error() != "metadata sync failed: metadata failed" { + t.Fatalf("expected metadata-specific sync error, got %v", err) + } + if redis.calls != 1 || metadata.calls != 1 { + t.Fatalf("expected redis and metadata to run once, got redis=%d metadata=%d", redis.calls, metadata.calls) + } +} diff --git a/internal/app/metadata_sync.go b/internal/app/metadata_sync.go new file mode 100644 index 0000000000000000000000000000000000000000..a8cdd8148bae26ea754bc703d347f55b7a57244d --- /dev/null +++ b/internal/app/metadata_sync.go @@ -0,0 +1,74 @@ +package app + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/sirupsen/logrus" +) + +type MetadataSyncer interface { + SyncMetadata(ctx context.Context) error +} + +type MetadataSyncRunner struct { + syncer MetadataSyncer + interval time.Duration + sleep func(context.Context, time.Duration) bool + + mu sync.Mutex + running bool +} + +func NewMetadataSyncRunner(syncer MetadataSyncer, interval time.Duration) *MetadataSyncRunner { + return &MetadataSyncRunner{ + syncer: syncer, + interval: interval, + sleep: maintenanceSleepContext, + } +} + +// Run 启动独立 metadata 同步任务:启动后立即执行一次,之后按固定间隔刷新 auth files 和 provider metadata。 +func (r *MetadataSyncRunner) Run(ctx context.Context) error { + if err := r.validate(); err != nil { + return err + } + logrus.Info("metadata sync task started") + r.setRunning(true) + defer r.setRunning(false) + + delay := time.Duration(0) + for { + if !r.sleep(ctx, delay) { + return nil + } + if err := r.syncer.SyncMetadata(ctx); err != nil { + logrus.WithError(err).Error("metadata sync failed") + } + delay = r.interval + } +} + +func (r *MetadataSyncRunner) validate() error { + if r == nil { + return fmt.Errorf("metadata sync runner is nil") + } + if r.syncer == nil { + return fmt.Errorf("metadata syncer is nil") + } + if r.interval <= 0 { + return fmt.Errorf("metadata sync interval must be positive") + } + if r.sleep == nil { + r.sleep = maintenanceSleepContext + } + return nil +} + +func (r *MetadataSyncRunner) setRunning(running bool) { + r.mu.Lock() + defer r.mu.Unlock() + r.running = running +} diff --git a/internal/app/metadata_sync_test.go b/internal/app/metadata_sync_test.go new file mode 100644 index 0000000000000000000000000000000000000000..97549ec27d3059a3a550831c8a5c16311d288296 --- /dev/null +++ b/internal/app/metadata_sync_test.go @@ -0,0 +1,83 @@ +package app + +import ( + "context" + "errors" + "strings" + "testing" + "time" +) + +type metadataSyncStub struct { + calls int + errs []error +} + +func (s *metadataSyncStub) SyncMetadata(context.Context) error { + s.calls++ + call := s.calls + if len(s.errs) >= call { + return s.errs[call-1] + } + if len(s.errs) > 0 { + return s.errs[len(s.errs)-1] + } + return nil +} + +func TestMetadataSyncRunnerRunsImmediatelyThenAtInterval(t *testing.T) { + syncer := &metadataSyncStub{} + runner := NewMetadataSyncRunner(syncer, 15*time.Minute) + var delays []time.Duration + runner.sleep = func(_ context.Context, d time.Duration) bool { + delays = append(delays, d) + return len(delays) < 3 + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if syncer.calls != 2 { + t.Fatalf("expected two metadata sync calls, got %d", syncer.calls) + } + expected := []time.Duration{0, 15 * time.Minute, 15 * time.Minute} + if len(delays) != len(expected) { + t.Fatalf("expected delays %+v, got %+v", expected, delays) + } + for i, want := range expected { + if delays[i] != want { + t.Fatalf("expected delay %d to be %s, got %s", i, want, delays[i]) + } + } +} + +func TestMetadataSyncRunnerLogsFailureAndContinues(t *testing.T) { + logs := captureAppInfoLogs(t) + syncer := &metadataSyncStub{errs: []error{errors.New("metadata endpoint failed"), nil}} + runner := NewMetadataSyncRunner(syncer, time.Minute) + sleepCalls := 0 + runner.sleep = func(context.Context, time.Duration) bool { + sleepCalls++ + return sleepCalls < 3 + } + + if err := runner.Run(context.Background()); err != nil { + t.Fatalf("Run returned error: %v", err) + } + if syncer.calls != 2 { + t.Fatalf("expected runner to continue after metadata error, got %d calls", syncer.calls) + } + content := logs.String() + if !strings.Contains(content, "level=error") || !strings.Contains(content, "msg=\"metadata sync failed\"") { + t.Fatalf("expected metadata sync failure error log, got %q", content) + } +} + +func TestMetadataSyncRunnerValidatesConfig(t *testing.T) { + if err := NewMetadataSyncRunner(nil, time.Minute).Run(context.Background()); err == nil { + t.Fatal("expected nil syncer validation error") + } + if err := NewMetadataSyncRunner(&metadataSyncStub{}, 0).Run(context.Background()); err == nil { + t.Fatal("expected non-positive interval validation error") + } +} diff --git a/internal/auth/session.go b/internal/auth/session.go new file mode 100644 index 0000000000000000000000000000000000000000..a694e9660b0eb15c2f239dd93bd1bef4cfbcd43e --- /dev/null +++ b/internal/auth/session.go @@ -0,0 +1,92 @@ +package auth + +import ( + "crypto/rand" + "encoding/hex" + "sync" + "time" +) + +type SessionManager struct { + ttl time.Duration + now func() time.Time + generate func() (string, error) + + mu sync.RWMutex + sessions map[string]time.Time +} + +func NewSessionManager(ttl time.Duration) *SessionManager { + return &SessionManager{ + ttl: ttl, + now: time.Now, + generate: generateToken, + sessions: make(map[string]time.Time), + } +} + +func (m *SessionManager) Create() (string, time.Time, error) { + token, err := m.generate() + if err != nil { + return "", time.Time{}, err + } + + m.mu.Lock() + defer m.mu.Unlock() + + m.cleanupExpiredLocked() + expiresAt := m.now().Add(m.ttl) + m.sessions[token] = expiresAt + + return token, expiresAt, nil +} + +func (m *SessionManager) Validate(token string) bool { + if token == "" { + return false + } + + m.mu.RLock() + expiresAt, ok := m.sessions[token] + m.mu.RUnlock() + if !ok { + return false + } + if !expiresAt.After(m.now()) { + m.Delete(token) + return false + } + return true +} + +func (m *SessionManager) Delete(token string) { + if token == "" { + return + } + m.mu.Lock() + defer m.mu.Unlock() + delete(m.sessions, token) +} + +func (m *SessionManager) CleanupExpired() { + m.mu.Lock() + defer m.mu.Unlock() + m.cleanupExpiredLocked() +} + +func (m *SessionManager) cleanupExpiredLocked() { + now := m.now() + for token, expiresAt := range m.sessions { + if !expiresAt.After(now) { + delete(m.sessions, token) + } + } +} + +func generateToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} diff --git a/internal/auth/session_test.go b/internal/auth/session_test.go new file mode 100644 index 0000000000000000000000000000000000000000..585e051e71df4ef250ef4090fb459f2a6e71d5cc --- /dev/null +++ b/internal/auth/session_test.go @@ -0,0 +1,77 @@ +package auth + +import ( + "testing" + "time" +) + +func TestSessionManagerCreateValidateDelete(t *testing.T) { + manager := NewSessionManager(2 * time.Hour) + manager.now = func() time.Time { return time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC) } + manager.generate = func() (string, error) { return "token-1", nil } + + token, expiresAt, err := manager.Create() + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + if token != "token-1" { + t.Fatalf("expected token token-1, got %q", token) + } + if !expiresAt.Equal(time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC)) { + t.Fatalf("unexpected expiry: %s", expiresAt) + } + if !manager.Validate(token) { + t.Fatal("expected token to validate") + } + + manager.Delete(token) + if manager.Validate(token) { + t.Fatal("expected deleted token to fail validation") + } +} + +func TestSessionManagerRejectsExpiredSessions(t *testing.T) { + baseTime := time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC) + manager := NewSessionManager(30 * time.Minute) + manager.now = func() time.Time { return baseTime } + manager.generate = func() (string, error) { return "token-2", nil } + + token, _, err := manager.Create() + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + manager.now = func() time.Time { return baseTime.Add(31 * time.Minute) } + if manager.Validate(token) { + t.Fatal("expected expired token to fail validation") + } +} + +func TestSessionManagerCleanupExpired(t *testing.T) { + baseTime := time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC) + manager := NewSessionManager(time.Hour) + manager.now = func() time.Time { return baseTime } + manager.generate = func() (string, error) { return "token-3", nil } + + if _, _, err := manager.Create(); err != nil { + t.Fatalf("Create returned error: %v", err) + } + + manager.mu.Lock() + manager.sessions["expired"] = baseTime.Add(-time.Minute) + manager.mu.Unlock() + + manager.CleanupExpired() + + manager.mu.RLock() + _, expiredExists := manager.sessions["expired"] + _, activeExists := manager.sessions["token-3"] + manager.mu.RUnlock() + + if expiredExists { + t.Fatal("expected expired token to be removed") + } + if !activeExists { + t.Fatal("expected active token to remain") + } +} diff --git a/internal/backup/backup.go b/internal/backup/backup.go new file mode 100644 index 0000000000000000000000000000000000000000..540d9bf681fb97c63bdac38867f08582f6c44b5c --- /dev/null +++ b/internal/backup/backup.go @@ -0,0 +1,188 @@ +package backup + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/mattn/go-sqlite3" +) + +type Writer struct { + dir string +} + +func NewWriter(dir string) *Writer { + return &Writer{dir: strings.TrimSpace(dir)} +} + +func (w *Writer) WriteDatabase(ctx context.Context, db *sql.DB, backupAt time.Time) (string, error) { + if w == nil { + return "", fmt.Errorf("backup writer is nil") + } + if w.dir == "" { + return "", fmt.Errorf("backup directory is required") + } + if db == nil { + return "", fmt.Errorf("database is required") + } + + stamp := backupAt.In(time.Local) + if stamp.IsZero() { + stamp = time.Now().In(time.Local) + } + dayDir := filepath.Join(w.dir, stamp.Format("2006-01-02")) + if err := os.MkdirAll(dayDir, 0o700); err != nil { + return "", fmt.Errorf("create backup directory: %w", err) + } + if err := os.Chmod(dayDir, 0o700); err != nil { + return "", fmt.Errorf("restrict backup directory permissions: %w", err) + } + + fileName := fmt.Sprintf("database_%s.db", stamp.Format("20060102T150405.000000000")) + fullPath := filepath.Join(dayDir, fileName) + tempPath := fullPath + ".tmp" + _ = os.Remove(tempPath) + if err := copySQLiteDatabase(ctx, db, tempPath); err != nil { + _ = os.Remove(tempPath) + return "", err + } + if err := os.Chmod(tempPath, 0o600); err != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("restrict backup file permissions: %w", err) + } + if err := os.Rename(tempPath, fullPath); err != nil { + _ = os.Remove(tempPath) + return "", fmt.Errorf("finalize backup file: %w", err) + } + return fullPath, nil +} + +func copySQLiteDatabase(ctx context.Context, sourceDB *sql.DB, destPath string) error { + sourceConn, err := sourceDB.Conn(ctx) + if err != nil { + return fmt.Errorf("open source database connection: %w", err) + } + defer sourceConn.Close() + + destDB, err := sql.Open("sqlite3", destPath) + if err != nil { + return fmt.Errorf("open backup database: %w", err) + } + defer destDB.Close() + destConn, err := destDB.Conn(ctx) + if err != nil { + return fmt.Errorf("open backup database connection: %w", err) + } + defer destConn.Close() + + return sourceConn.Raw(func(sourceDriverConn any) error { + sourceSQLite, ok := sourceDriverConn.(*sqlite3.SQLiteConn) + if !ok { + return fmt.Errorf("source database connection is not sqlite3") + } + return destConn.Raw(func(destDriverConn any) error { + destSQLite, ok := destDriverConn.(*sqlite3.SQLiteConn) + if !ok { + return fmt.Errorf("backup database connection is not sqlite3") + } + backup, err := destSQLite.Backup("main", sourceSQLite, "main") + if err != nil { + return fmt.Errorf("start sqlite backup: %w", err) + } + var backupErr error + for { + if err := ctx.Err(); err != nil { + backupErr = err + break + } + done, err := backup.Step(100) + if err != nil { + backupErr = fmt.Errorf("copy sqlite backup: %w", err) + break + } + if done { + break + } + } + if err := backup.Close(); err != nil { + backupErr = errors.Join(backupErr, fmt.Errorf("close sqlite backup: %w", err)) + } + return backupErr + }) + }) +} + +func (w *Writer) Cleanup(retentionDays int, now time.Time) (int, error) { + if w == nil { + return 0, fmt.Errorf("backup writer is nil") + } + return Cleanup(w.dir, retentionDays, now) +} + +func Cleanup(dir string, retentionDays int, now time.Time) (int, error) { + dir = strings.TrimSpace(dir) + if dir == "" || retentionDays <= 0 { + return 0, nil + } + + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + return 0, nil + } + return 0, fmt.Errorf("read backup directory: %w", err) + } + + localNow := now.In(time.Local) + cutoff := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, time.Local).AddDate(0, 0, -retentionDays) + removed := 0 + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + backupDay, err := time.ParseInLocation("2006-01-02", entry.Name(), time.Local) + if err != nil { + continue + } + if backupDay.Before(cutoff.Truncate(24 * time.Hour)) { + if err := os.RemoveAll(filepath.Join(dir, entry.Name())); err != nil { + return removed, fmt.Errorf("remove expired backup directory %s: %w", entry.Name(), err) + } + removed++ + } + } + + return removed, nil +} + +func ListFiles(dir string) ([]string, error) { + var files []string + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if strings.EqualFold(filepath.Ext(d.Name()), ".db") { + files = append(files, path) + } + return nil + }) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + sort.Strings(files) + return files, nil +} diff --git a/internal/backup/backup_test.go b/internal/backup/backup_test.go new file mode 100644 index 0000000000000000000000000000000000000000..34e22156ccd50c04e46e9a4905bcfb6b41cd0c84 --- /dev/null +++ b/internal/backup/backup_test.go @@ -0,0 +1,236 @@ +package backup + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func TestWriterWriteDatabaseBacksUpSQLiteDatabase(t *testing.T) { + root := t.TempDir() + source := openTestSQLiteDB(t, filepath.Join(t.TempDir(), "source.db")) + defer source.Close() + if _, err := source.Exec(`CREATE TABLE records (id INTEGER PRIMARY KEY, name TEXT NOT NULL)`); err != nil { + t.Fatalf("create table: %v", err) + } + if _, err := source.Exec(`INSERT INTO records (name) VALUES (?)`, "saved"); err != nil { + t.Fatalf("insert row: %v", err) + } + writer := NewWriter(root) + backupAt := time.Date(2026, 4, 16, 12, 34, 56, 123456789, time.UTC) + + path, err := writer.WriteDatabase(context.Background(), source, backupAt) + if err != nil { + t.Fatalf("WriteDatabase returned error: %v", err) + } + + if filepath.Dir(path) != filepath.Join(root, "2026-04-16") { + t.Fatalf("unexpected backup directory: %s", path) + } + if filepath.Ext(path) != ".db" || !strings.HasPrefix(filepath.Base(path), "database_") { + t.Fatalf("unexpected backup file name: %s", filepath.Base(path)) + } + backupDB := openTestSQLiteDB(t, path) + defer backupDB.Close() + var name string + if err := backupDB.QueryRow(`SELECT name FROM records WHERE id = 1`).Scan(&name); err != nil { + t.Fatalf("query backup row: %v", err) + } + if name != "saved" { + t.Fatalf("expected backed up row name saved, got %q", name) + } +} + +func TestWriterWriteDatabaseUsesLocalDateDirectory(t *testing.T) { + previousLocal := time.Local + time.Local = time.FixedZone("Test/Local", 8*60*60) + t.Cleanup(func() { time.Local = previousLocal }) + root := t.TempDir() + source := openTestSQLiteDB(t, filepath.Join(t.TempDir(), "source.db")) + defer source.Close() + if _, err := source.Exec(`CREATE TABLE records (id INTEGER PRIMARY KEY)`); err != nil { + t.Fatalf("create table: %v", err) + } + writer := NewWriter(root) + backupAt := time.Date(2026, 4, 16, 4, 0, 0, 0, time.Local) + + path, err := writer.WriteDatabase(context.Background(), source, backupAt) + if err != nil { + t.Fatalf("WriteDatabase returned error: %v", err) + } + if filepath.Dir(path) != filepath.Join(root, "2026-04-16") { + t.Fatalf("expected local backup date directory 2026-04-16, got %s", path) + } +} + +func TestWriterWriteDatabaseRestrictsBackupPermissions(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Unix permission bits are not meaningful on Windows") + } + root := t.TempDir() + source := openTestSQLiteDB(t, filepath.Join(t.TempDir(), "source.db")) + defer source.Close() + if _, err := source.Exec(`CREATE TABLE records (id INTEGER PRIMARY KEY)`); err != nil { + t.Fatalf("create table: %v", err) + } + writer := NewWriter(root) + backupAt := time.Date(2026, 4, 16, 12, 34, 56, 0, time.UTC) + + path, err := writer.WriteDatabase(context.Background(), source, backupAt) + if err != nil { + t.Fatalf("WriteDatabase returned error: %v", err) + } + + dayDirInfo, err := os.Stat(filepath.Dir(path)) + if err != nil { + t.Fatalf("stat backup day directory: %v", err) + } + if mode := dayDirInfo.Mode().Perm(); mode != 0o700 { + t.Fatalf("expected backup day directory mode 0700, got %o", mode) + } + + fileInfo, err := os.Stat(path) + if err != nil { + t.Fatalf("stat backup file: %v", err) + } + if mode := fileInfo.Mode().Perm(); mode != 0o600 { + t.Fatalf("expected backup file mode 0600, got %o", mode) + } +} + +func TestWriterWriteDatabaseHonorsCanceledContext(t *testing.T) { + root := t.TempDir() + source := openTestSQLiteDB(t, filepath.Join(t.TempDir(), "source.db")) + defer source.Close() + if _, err := source.Exec(`CREATE TABLE records (id INTEGER PRIMARY KEY)`); err != nil { + t.Fatalf("create table: %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := NewWriter(root).WriteDatabase(ctx, source, time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)) + if err == nil { + t.Fatal("expected canceled context error") + } + files, listErr := ListFiles(root) + if listErr != nil { + t.Fatalf("ListFiles returned error: %v", listErr) + } + if len(files) != 0 { + t.Fatalf("expected no finalized backup files after cancellation, got %+v", files) + } +} + +func TestWriterWriteDatabaseValidatesInputs(t *testing.T) { + if _, err := NewWriter("").WriteDatabase(context.Background(), openTestSQLiteDB(t, filepath.Join(t.TempDir(), "source.db")), time.Now()); err == nil || !strings.Contains(err.Error(), "backup directory is required") { + t.Fatalf("expected backup directory error, got %v", err) + } + if _, err := NewWriter(t.TempDir()).WriteDatabase(context.Background(), nil, time.Now()); err == nil || !strings.Contains(err.Error(), "database is required") { + t.Fatalf("expected database required error, got %v", err) + } +} + +func TestCleanupRemovesExpiredBackupDirectories(t *testing.T) { + root := t.TempDir() + oldDir := filepath.Join(root, "2026-04-10") + keepDir := filepath.Join(root, "2026-04-15") + if err := os.MkdirAll(oldDir, 0o700); err != nil { + t.Fatalf("create old dir: %v", err) + } + if err := os.MkdirAll(keepDir, 0o700); err != nil { + t.Fatalf("create keep dir: %v", err) + } + if err := os.WriteFile(filepath.Join(oldDir, "database_old.db"), []byte("old"), 0o600); err != nil { + t.Fatalf("write old backup: %v", err) + } + if err := os.WriteFile(filepath.Join(keepDir, "database_keep.db"), []byte("keep"), 0o600); err != nil { + t.Fatalf("write keep backup: %v", err) + } + + removed, err := Cleanup(root, 3, time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("Cleanup returned error: %v", err) + } + if removed != 1 { + t.Fatalf("expected 1 removed directory, got %d", removed) + } + + files, err := ListFiles(root) + if err != nil { + t.Fatalf("ListFiles returned error: %v", err) + } + if len(files) != 1 || filepath.Base(filepath.Dir(files[0])) != "2026-04-15" { + t.Fatalf("unexpected remaining files: %+v", files) + } +} + +func TestCleanupUsesLocalDateBoundaries(t *testing.T) { + previousLocal := time.Local + time.Local = time.FixedZone("Test/Local", 8*60*60) + t.Cleanup(func() { time.Local = previousLocal }) + root := t.TempDir() + keepDir := filepath.Join(root, "2026-04-15") + if err := os.MkdirAll(keepDir, 0o700); err != nil { + t.Fatalf("create keep dir: %v", err) + } + if err := os.WriteFile(filepath.Join(keepDir, "database_keep.db"), []byte("keep"), 0o600); err != nil { + t.Fatalf("write keep backup: %v", err) + } + + removed, err := Cleanup(root, 1, time.Date(2026, 4, 16, 0, 30, 0, 0, time.Local)) + if err != nil { + t.Fatalf("Cleanup returned error: %v", err) + } + if removed != 0 { + t.Fatalf("expected local-date retention to keep previous local day, removed %d", removed) + } +} + +func TestListFilesReturnsDatabaseBackups(t *testing.T) { + root := t.TempDir() + dayDir := filepath.Join(root, "2026-04-16") + if err := os.MkdirAll(dayDir, 0o700); err != nil { + t.Fatalf("create day dir: %v", err) + } + databasePath := filepath.Join(dayDir, "database.db") + if err := os.WriteFile(databasePath, []byte("db"), 0o600); err != nil { + t.Fatalf("write db backup: %v", err) + } + if err := os.WriteFile(filepath.Join(dayDir, "snapshot.json"), []byte("json"), 0o600); err != nil { + t.Fatalf("write json backup: %v", err) + } + + files, err := ListFiles(root) + if err != nil { + t.Fatalf("ListFiles returned error: %v", err) + } + if len(files) != 1 || files[0] != databasePath { + t.Fatalf("expected only database backup, got %+v", files) + } +} + +func TestCleanupIgnoresMissingDirectory(t *testing.T) { + removed, err := Cleanup(filepath.Join(t.TempDir(), "missing"), 30, time.Now()) + if err != nil { + t.Fatalf("Cleanup returned error: %v", err) + } + if removed != 0 { + t.Fatalf("expected 0 removed directories, got %d", removed) + } +} + +func openTestSQLiteDB(t *testing.T, path string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite3", path) + if err != nil { + t.Fatalf("open sqlite db: %v", err) + } + return db +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000000000000000000000000000000000000..9b9b151bf93f59883e17683b83e43a5a1ff18426 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,374 @@ +package config + +import ( + "errors" + "fmt" + "os" + "path" + "path/filepath" + "strconv" + "strings" + "time" + + "cpa-usage-keeper/internal/cpa" + "github.com/joho/godotenv" +) + +const ( + DefaultTimeZone = "Asia/Shanghai" + RedisQueueKeyDefault = cpa.ManagementUsageQueueKey + RedisQueueErrorBackoffDefault = 10 * time.Second + MetadataSyncIntervalDefault = 30 * time.Second +) + +var ( + DefaultWorkDir = filepath.Join(".", "data") + DefaultSQLitePath = filepath.Join(DefaultWorkDir, "app.db") + DefaultLogDir = filepath.Join(DefaultWorkDir, "logs") + DefaultBackupDir = filepath.Join(DefaultWorkDir, "backups") + workDirDatabaseName = filepath.Base(DefaultSQLitePath) + workDirLogsName = filepath.Base(DefaultLogDir) + workDirBackupsName = filepath.Base(DefaultBackupDir) +) + +type Config struct { + // AppPort 是 Web 服务监听端口。 + AppPort string + // AppBasePath 是 Web 服务部署子路径,空值表示根路径。 + AppBasePath string + // CPABaseURL 是 CPA 服务基础地址。 + CPABaseURL string + // CPAManagementKey 是访问 CPA 管理数据的密钥。 + CPAManagementKey string + // RedisQueueAddr 是 CPA management data stream 的 TCP 地址,空值时按 CPA_BASE_URL 推导。 + RedisQueueAddr string + // RedisQueueKey 是 CPA usage 队列名。 + RedisQueueKey string + // RedisQueueBatchSize 是单次 Redis LPOP 最多拉取的消息数。 + RedisQueueBatchSize int + // RedisQueueIdleInterval 是 Redis 队列为空时的下一次检查间隔。 + RedisQueueIdleInterval time.Duration + // RedisQueueErrorBackoff 是 Redis 临时错误后的固定退避间隔。 + RedisQueueErrorBackoff time.Duration + // MetadataSyncInterval 是 auth files 和 provider metadata 的固定刷新间隔。 + MetadataSyncInterval time.Duration + // WorkDir 是应用工作目录,数据库、日志和备份默认从这里派生。 + WorkDir string + // SQLitePath 是 SQLite 数据库文件路径。 + SQLitePath string + // BackupEnabled 控制是否保存 SQLite 数据库备份文件。 + BackupEnabled bool + // BackupDir 是 SQLite 数据库备份目录。 + BackupDir string + // BackupInterval 是两次备份写入之间的最小间隔。 + BackupInterval time.Duration + // BackupRetentionDays 是备份文件保留天数。 + BackupRetentionDays int + // RequestTimeout 是访问 CPA HTTP 和 Redis TCP 的超时时间。 + RequestTimeout time.Duration + // LogLevel 是应用日志级别。 + LogLevel string + // LogFileEnabled 控制是否写入持久化日志文件。 + LogFileEnabled bool + // LogDir 是应用日志文件目录。 + LogDir string + // LogRetentionDays 是日志保留天数,0 表示不自动清理。 + LogRetentionDays int + // AuthEnabled 控制是否启用登录保护。 + AuthEnabled bool + // LoginPassword 是启用登录保护时使用的登录密码。 + LoginPassword string + // AuthSessionTTL 是登录 session 有效时长。 + AuthSessionTTL time.Duration +} + +type LoadOptions struct { + EnvFile string +} + +var executableDir = func() (string, error) { + executablePath, err := os.Executable() + if err != nil { + return "", err + } + return filepath.Dir(executablePath), nil +} + +func LoadFromEnv() (*Config, error) { + return Load(LoadOptions{}) +} + +func Load(options LoadOptions) (*Config, error) { + envBaseDir, err := loadDotEnv(options) + if err != nil { + return nil, err + } + if err := applyProjectTimeZone(); err != nil { + return nil, err + } + + redisQueueBatchSize, err := getInt("REDIS_QUEUE_BATCH_SIZE", 1000) + if err != nil { + return nil, err + } + if redisQueueBatchSize <= 0 { + return nil, fmt.Errorf("REDIS_QUEUE_BATCH_SIZE must be positive") + } + + redisQueueIdleInterval, err := getDuration("REDIS_QUEUE_IDLE_INTERVAL", time.Second) + if err != nil { + return nil, err + } + if redisQueueIdleInterval <= 0 { + return nil, fmt.Errorf("REDIS_QUEUE_IDLE_INTERVAL must be positive") + } + + requestTimeout, err := getDuration("REQUEST_TIMEOUT", 30*time.Second) + if err != nil { + return nil, err + } + + backupEnabled, err := getBool("BACKUP_ENABLED", true) + if err != nil { + return nil, err + } + + backupInterval, err := getDuration("BACKUP_INTERVAL", 24*time.Hour) + if err != nil { + return nil, err + } + if backupInterval <= 0 { + return nil, fmt.Errorf("BACKUP_INTERVAL must be positive") + } + + backupRetentionDays, err := getInt("BACKUP_RETENTION_DAYS", 7) + if err != nil { + return nil, err + } + if backupRetentionDays < 0 { + return nil, fmt.Errorf("BACKUP_RETENTION_DAYS must be non-negative") + } + + logFileEnabled, err := getBool("LOG_FILE_ENABLED", true) + if err != nil { + return nil, err + } + logRetentionDays, err := getInt("LOG_RETENTION_DAYS", 7) + if err != nil { + return nil, err + } + if logRetentionDays < 0 { + return nil, fmt.Errorf("LOG_RETENTION_DAYS must be non-negative") + } + + authSessionTTL, err := getDuration("AUTH_SESSION_TTL", 7*24*time.Hour) + if err != nil { + return nil, err + } + if authSessionTTL <= 0 { + return nil, fmt.Errorf("AUTH_SESSION_TTL must be positive") + } + + authEnabled, err := getBool("AUTH_ENABLED", false) + if err != nil { + return nil, err + } + + appBasePath, err := normalizeBasePath(strings.TrimSpace(os.Getenv("APP_BASE_PATH"))) + if err != nil { + return nil, fmt.Errorf("APP_BASE_PATH is invalid: %w", err) + } + + workDir := getString("WORK_DIR", DefaultWorkDir) + + cfg := &Config{ + AppPort: getString("APP_PORT", "8080"), + AppBasePath: appBasePath, + CPABaseURL: strings.TrimSpace(os.Getenv("CPA_BASE_URL")), + CPAManagementKey: strings.TrimSpace(os.Getenv("CPA_MANAGEMENT_KEY")), + RedisQueueAddr: strings.TrimSpace(os.Getenv("REDIS_QUEUE_ADDR")), + RedisQueueKey: RedisQueueKeyDefault, + RedisQueueBatchSize: redisQueueBatchSize, + RedisQueueIdleInterval: redisQueueIdleInterval, + RedisQueueErrorBackoff: RedisQueueErrorBackoffDefault, + MetadataSyncInterval: MetadataSyncIntervalDefault, + WorkDir: workDir, + SQLitePath: filepath.Join(workDir, workDirDatabaseName), + BackupEnabled: backupEnabled, + BackupDir: filepath.Join(workDir, workDirBackupsName), + BackupInterval: backupInterval, + BackupRetentionDays: backupRetentionDays, + RequestTimeout: requestTimeout, + LogLevel: getString("LOG_LEVEL", "info"), + LogFileEnabled: logFileEnabled, + LogDir: filepath.Join(workDir, workDirLogsName), + LogRetentionDays: logRetentionDays, + AuthEnabled: authEnabled, + LoginPassword: strings.TrimSpace(os.Getenv("LOGIN_PASSWORD")), + AuthSessionTTL: authSessionTTL, + } + if cfg.CPABaseURL == "" { + return nil, fmt.Errorf("CPA_BASE_URL is required") + } + if cfg.CPAManagementKey == "" { + return nil, fmt.Errorf("CPA_MANAGEMENT_KEY is required") + } + if cfg.AuthEnabled && cfg.LoginPassword == "" { + return nil, fmt.Errorf("LOGIN_PASSWORD is required when AUTH_ENABLED is true") + } + cfg.resolveRelativePaths(envBaseDir) + + return cfg, nil +} + +func applyProjectTimeZone() error { + zoneName := strings.TrimSpace(os.Getenv("TZ")) + if zoneName == "" { + zoneName = DefaultTimeZone + if err := os.Setenv("TZ", zoneName); err != nil { + return fmt.Errorf("set default TZ: %w", err) + } + } + location, err := time.LoadLocation(zoneName) + if err != nil { + return fmt.Errorf("TZ is invalid: %w", err) + } + time.Local = location + return nil +} + +func loadDotEnv(options LoadOptions) (string, error) { + if strings.TrimSpace(options.EnvFile) != "" { + return loadDotEnvFile(options.EnvFile, true) + } + + cwd, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + if loaded, err := loadOptionalDotEnv(filepath.Join(cwd, ".env")); err != nil || loaded { + if loaded { + return cwd, err + } + return "", err + } + + exeDir, err := executableDir() + if err != nil { + return "", fmt.Errorf("get executable directory: %w", err) + } + loaded, err := loadOptionalDotEnv(filepath.Join(exeDir, ".env")) + if loaded { + return exeDir, err + } + return "", err +} + +func loadOptionalDotEnv(path string) (bool, error) { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + return false, fmt.Errorf("stat .env: %w", err) + } + if err := godotenv.Overload(path); err != nil { + return false, fmt.Errorf("load .env: %w", err) + } + return true, nil +} + +func loadDotEnvFile(path string, required bool) (string, error) { + if _, err := os.Stat(path); err != nil { + if errors.Is(err, os.ErrNotExist) && !required { + return "", nil + } + return "", fmt.Errorf("stat env file: %w", err) + } + if err := godotenv.Overload(path); err != nil { + return "", fmt.Errorf("load env file: %w", err) + } + absolutePath, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve env file path: %w", err) + } + return filepath.Dir(absolutePath), nil +} + +func (cfg *Config) resolveRelativePaths(baseDir string) { + if baseDir == "" { + return + } + cfg.WorkDir = resolveRelativePath(baseDir, cfg.WorkDir) + cfg.SQLitePath = resolveRelativePath(baseDir, cfg.SQLitePath) + cfg.LogDir = resolveRelativePath(baseDir, cfg.LogDir) + cfg.BackupDir = resolveRelativePath(baseDir, cfg.BackupDir) +} + +func resolveRelativePath(baseDir, value string) string { + if value == "" || filepath.IsAbs(value) { + return value + } + return filepath.Join(baseDir, value) +} + +func normalizeBasePath(value string) (string, error) { + if value == "" || value == "/" { + return "", nil + } + if !strings.HasPrefix(value, "/") { + return "", fmt.Errorf("must start with '/'") + } + + normalized := path.Clean(value) + if normalized == "." || normalized == "/" { + return "", nil + } + if !strings.HasPrefix(normalized, "/") { + normalized = "/" + normalized + } + return normalized, nil +} + +func getString(key, fallback string) string { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback + } + return value +} + +func getDuration(key string, fallback time.Duration) (time.Duration, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + duration, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("%s must be a valid duration: %w", key, err) + } + return duration, nil +} + +func getBool(key string, fallback bool) (bool, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s must be a valid bool: %w", key, err) + } + return parsed, nil +} + +func getInt(key string, fallback int) (int, error) { + value := strings.TrimSpace(os.Getenv(key)) + if value == "" { + return fallback, nil + } + parsed, err := strconv.Atoi(value) + if err != nil { + return 0, fmt.Errorf("%s must be a valid integer: %w", key, err) + } + return parsed, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4af74430809ea373ca6240d3c4639efc5c9d514f --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,532 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/cpa" +) + +var configEnvKeys = []string{ + "APP_PORT", "APP_BASE_PATH", "WORK_DIR", "CPA_BASE_URL", "CPA_MANAGEMENT_KEY", "POLL_INTERVAL", + "USAGE_SYNC_MODE", "REDIS_QUEUE_ADDR", "REDIS_QUEUE_BATCH_SIZE", "REDIS_QUEUE_IDLE_INTERVAL", + "SQLITE_PATH", "BACKUP_ENABLED", "BACKUP_DIR", "BACKUP_INTERVAL", "BACKUP_RETENTION_DAYS", + "REQUEST_TIMEOUT", "LOG_LEVEL", "LOG_FILE_ENABLED", "LOG_DIR", "LOG_RETENTION_DAYS", + "AUTH_ENABLED", "LOGIN_PASSWORD", "AUTH_SESSION_TTL", "TZ", +} + +func TestMain(m *testing.M) { + previousEnv := make(map[string]string, len(configEnvKeys)) + previousPresent := make(map[string]bool, len(configEnvKeys)) + for _, key := range configEnvKeys { + previousEnv[key], previousPresent[key] = os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + panic(err) + } + } + code := m.Run() + for _, key := range configEnvKeys { + if previousPresent[key] { + if err := os.Setenv(key, previousEnv[key]); err != nil { + panic(err) + } + continue + } + if err := os.Unsetenv(key); err != nil { + panic(err) + } + } + os.Exit(code) +} + +func withIsolatedEnvFiles(t *testing.T) { + t.Helper() + previousEnv := make(map[string]string, len(configEnvKeys)) + previousPresent := make(map[string]bool, len(configEnvKeys)) + for _, key := range configEnvKeys { + previousEnv[key], previousPresent[key] = os.LookupEnv(key) + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } + t.Cleanup(func() { + for _, key := range configEnvKeys { + if previousPresent[key] { + if err := os.Setenv(key, previousEnv[key]); err != nil { + t.Fatalf("restore %s: %v", key, err) + } + continue + } + if err := os.Unsetenv(key); err != nil { + t.Fatalf("unset %s: %v", key, err) + } + } + }) + cwd := t.TempDir() + exeDir := t.TempDir() + previousExecutableDir := executableDir + previousWorkingDir, err := os.Getwd() + if err != nil { + t.Fatalf("get cwd: %v", err) + } + t.Cleanup(func() { + executableDir = previousExecutableDir + if err := os.Chdir(previousWorkingDir); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + if err := os.Chdir(cwd); err != nil { + t.Fatalf("chdir: %v", err) + } + executableDir = func() (string, error) { return exeDir, nil } +} + +func TestLoadFromEnvAppliesDefaults(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + + if cfg.AppPort != "8080" { + t.Fatalf("expected default app port 8080, got %s", cfg.AppPort) + } + if cfg.AppBasePath != "" { + t.Fatalf("expected default app base path to be empty, got %q", cfg.AppBasePath) + } + if !cfg.BackupEnabled { + t.Fatal("expected backup to be enabled by default") + } + if cfg.WorkDir != filepath.Join(".", "data") { + t.Fatalf("expected default work dir ./data, got %s", cfg.WorkDir) + } + if cfg.BackupDir != filepath.Join("data", "backups") { + t.Fatalf("expected default backup dir data/backups, got %s", cfg.BackupDir) + } + if cfg.BackupInterval != 24*time.Hour { + t.Fatalf("expected default backup interval 24h, got %s", cfg.BackupInterval) + } + if cfg.BackupRetentionDays != 7 { + t.Fatalf("expected default backup retention 7 days, got %d", cfg.BackupRetentionDays) + } + if cfg.RequestTimeout != 30*time.Second { + t.Fatalf("expected default request timeout 30s, got %s", cfg.RequestTimeout) + } + if cfg.SQLitePath != filepath.Join("data", "app.db") { + t.Fatalf("expected default sqlite path data/app.db, got %s", cfg.SQLitePath) + } + if cfg.AuthEnabled { + t.Fatal("expected auth to be disabled by default") + } + if cfg.AuthSessionTTL != 7*24*time.Hour { + t.Fatalf("expected default auth session ttl 168h, got %s", cfg.AuthSessionTTL) + } + if cfg.RedisQueueAddr != "" { + t.Fatalf("expected default redis queue addr to be empty, got %q", cfg.RedisQueueAddr) + } + if cfg.RedisQueueKey != RedisQueueKeyDefault { + t.Fatalf("expected default redis queue key queue, got %s", cfg.RedisQueueKey) + } + if cfg.RedisQueueBatchSize != 1000 { + t.Fatalf("expected default redis queue batch size 1000, got %d", cfg.RedisQueueBatchSize) + } + if cfg.RedisQueueIdleInterval != time.Second { + t.Fatalf("expected default redis queue idle interval 1s, got %s", cfg.RedisQueueIdleInterval) + } + if cfg.RedisQueueErrorBackoff != RedisQueueErrorBackoffDefault { + t.Fatalf("expected default redis queue error backoff 10s, got %s", cfg.RedisQueueErrorBackoff) + } + if cfg.MetadataSyncInterval != MetadataSyncIntervalDefault { + t.Fatalf("expected default metadata sync interval 30s, got %s", cfg.MetadataSyncInterval) + } + if !cfg.LogFileEnabled { + t.Fatal("expected log file output to be enabled by default") + } + if cfg.LogDir != filepath.Join("data", "logs") { + t.Fatalf("expected default log dir data/logs, got %s", cfg.LogDir) + } + if cfg.LogRetentionDays != 7 { + t.Fatalf("expected default log retention 7 days, got %d", cfg.LogRetentionDays) + } +} + +func TestLoadReadsSpecifiedEnvFile(t *testing.T) { + withIsolatedEnvFiles(t) + envDir := t.TempDir() + envPath := filepath.Join(envDir, "custom.env") + if err := os.WriteFile(envPath, []byte("CPA_BASE_URL=https://from-file.example.com\nCPA_MANAGEMENT_KEY=from-file\nAPP_PORT=9091\nWORK_DIR=./custom-data\n"), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + cfg, err := Load(LoadOptions{EnvFile: envPath}) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if cfg.CPABaseURL != "https://from-file.example.com" || cfg.CPAManagementKey != "from-file" || cfg.AppPort != "9091" || cfg.WorkDir != filepath.Join(envDir, "custom-data") || cfg.SQLitePath != filepath.Join(envDir, "custom-data", "app.db") || cfg.LogDir != filepath.Join(envDir, "custom-data", "logs") || cfg.BackupDir != filepath.Join(envDir, "custom-data", "backups") { + t.Fatalf("expected config values from specified env file, got %+v", cfg) + } +} + +func TestLoadResolvesRelativeEnvFilePathBase(t *testing.T) { + withIsolatedEnvFiles(t) + cwd := t.TempDir() + previousWorkingDir, err := os.Getwd() + if err != nil { + t.Fatalf("get cwd: %v", err) + } + t.Cleanup(func() { + if err := os.Chdir(previousWorkingDir); err != nil { + t.Fatalf("restore cwd: %v", err) + } + }) + if err := os.Chdir(cwd); err != nil { + t.Fatalf("chdir: %v", err) + } + if err := os.Mkdir("config", 0o755); err != nil { + t.Fatalf("mkdir config: %v", err) + } + if err := os.WriteFile(filepath.Join("config", "app.env"), []byte("CPA_BASE_URL=https://relative-env.example.com\nCPA_MANAGEMENT_KEY=relative\nWORK_DIR=./data\n"), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + cfg, err := Load(LoadOptions{EnvFile: filepath.Join("config", "app.env")}) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + envFileAbsolutePath, err := filepath.Abs(filepath.Join("config", "app.env")) + if err != nil { + t.Fatalf("resolve env file path: %v", err) + } + expectedWorkDir := filepath.Join(filepath.Dir(envFileAbsolutePath), "data") + if cfg.WorkDir != expectedWorkDir || cfg.SQLitePath != filepath.Join(expectedWorkDir, "app.db") || cfg.LogDir != filepath.Join(expectedWorkDir, "logs") || cfg.BackupDir != filepath.Join(expectedWorkDir, "backups") { + t.Fatalf("expected paths under %q, got %+v", expectedWorkDir, cfg) + } +} + +func TestLoadIgnoresLegacyPathOverrides(t *testing.T) { + withIsolatedEnvFiles(t) + envDir := t.TempDir() + envPath := filepath.Join(envDir, "legacy.env") + content := "CPA_BASE_URL=https://legacy.example.com\nCPA_MANAGEMENT_KEY=legacy\nWORK_DIR=./work\nSQLITE_PATH=./legacy/app.db\nLOG_DIR=./legacy/logs\nBACKUP_DIR=./legacy/backups\n" + if err := os.WriteFile(envPath, []byte(content), 0o600); err != nil { + t.Fatalf("write env file: %v", err) + } + + cfg, err := Load(LoadOptions{EnvFile: envPath}) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + expectedWorkDir := filepath.Join(envDir, "work") + if cfg.WorkDir != expectedWorkDir || cfg.SQLitePath != filepath.Join(expectedWorkDir, "app.db") || cfg.LogDir != filepath.Join(expectedWorkDir, "logs") || cfg.BackupDir != filepath.Join(expectedWorkDir, "backups") { + t.Fatalf("expected legacy path overrides to be ignored, got %+v", cfg) + } +} + +func TestLoadRejectsMissingSpecifiedEnvFile(t *testing.T) { + missingPath := filepath.Join(t.TempDir(), "missing.env") + + _, err := Load(LoadOptions{EnvFile: missingPath}) + if err == nil || !strings.Contains(err.Error(), "stat env file") { + t.Fatalf("expected missing specified env file error, got %v", err) + } +} + +func TestLoadFallsBackToExecutableDirEnv(t *testing.T) { + withIsolatedEnvFiles(t) + exeDir, err := executableDir() + if err != nil { + t.Fatalf("get executable dir: %v", err) + } + if err := os.WriteFile(filepath.Join(exeDir, ".env"), []byte("CPA_BASE_URL=https://from-exe.example.com\nCPA_MANAGEMENT_KEY=from-exe\nWORK_DIR=./data\n"), 0o600); err != nil { + t.Fatalf("write executable env file: %v", err) + } + + cfg, err := Load(LoadOptions{}) + if err != nil { + t.Fatalf("Load returned error: %v", err) + } + + if cfg.CPABaseURL != "https://from-exe.example.com" || cfg.CPAManagementKey != "from-exe" || cfg.WorkDir != filepath.Join(exeDir, "data") || cfg.SQLitePath != filepath.Join(exeDir, "data", "app.db") || cfg.LogDir != filepath.Join(exeDir, "data", "logs") || cfg.BackupDir != filepath.Join(exeDir, "data", "backups") { + t.Fatalf("expected config values from executable dir env, got %+v", cfg) + } +} + +func TestDefaultTimeZoneIsLoadable(t *testing.T) { + location, err := time.LoadLocation(DefaultTimeZone) + if err != nil { + t.Fatalf("expected default timezone %s to be loadable: %v", DefaultTimeZone, err) + } + if location.String() != DefaultTimeZone { + t.Fatalf("expected location %s, got %s", DefaultTimeZone, location) + } +} + +func TestLoadFromEnvAppliesDefaultTimeZone(t *testing.T) { + previousLocal := time.Local + t.Cleanup(func() { time.Local = previousLocal }) + t.Setenv("TZ", "") + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + + _, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + + if time.Local.String() != "Asia/Shanghai" { + t.Fatalf("expected default local timezone Asia/Shanghai, got %s", time.Local) + } +} + +func TestLoadFromEnvHonorsExplicitTimeZone(t *testing.T) { + previousLocal := time.Local + t.Cleanup(func() { time.Local = previousLocal }) + t.Setenv("TZ", "UTC") + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + + _, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + + if time.Local.String() != "UTC" { + t.Fatalf("expected explicit local timezone UTC, got %s", time.Local) + } +} + +func TestLoadFromEnvHonorsExplicitIANATimeZone(t *testing.T) { + previousLocal := time.Local + t.Cleanup(func() { time.Local = previousLocal }) + t.Setenv("TZ", "America/New_York") + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + + _, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + + if time.Local.String() != "America/New_York" { + t.Fatalf("expected explicit local timezone America/New_York, got %s", time.Local) + } +} + +func TestLoadFromEnvRejectsInvalidTimeZone(t *testing.T) { + previousLocal := time.Local + t.Cleanup(func() { time.Local = previousLocal }) + t.Setenv("TZ", "Not/AZone") + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + + _, err := LoadFromEnv() + if err == nil || !strings.Contains(err.Error(), "TZ is invalid") { + t.Fatalf("expected invalid TZ error, got %v", err) + } +} + +func TestLoadFromEnvRequiresCriticalValues(t *testing.T) { + withIsolatedEnvFiles(t) + + t.Run("missing base url", func(t *testing.T) { + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "CPA_BASE_URL is required" { + t.Fatalf("expected CPA_BASE_URL required error, got %v", err) + } + }) + + t.Run("missing management key", func(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + + _, err := LoadFromEnv() + if err == nil || err.Error() != "CPA_MANAGEMENT_KEY is required" { + t.Fatalf("expected CPA_MANAGEMENT_KEY required error, got %v", err) + } + }) + + t.Run("missing login password when auth enabled", func(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("AUTH_ENABLED", "true") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "LOGIN_PASSWORD is required when AUTH_ENABLED is true" { + t.Fatalf("expected LOGIN_PASSWORD required error, got %v", err) + } + }) +} + +func TestLoadFromEnvIgnoresRemovedLegacySyncEnvVars(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("USAGE_SYNC_MODE", "invalid") + t.Setenv("POLL_INTERVAL", "not-a-duration") + + _, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv should ignore removed legacy sync env vars, got error: %v", err) + } +} + +func TestLoadFromEnvUsesRedisQueueAddrOverride(t *testing.T) { + t.Setenv("CPA_BASE_URL", "https://cpa.example.com") + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("REDIS_QUEUE_ADDR", "redis-stream.example.com:6380") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + + if cfg.RedisQueueAddr != "redis-stream.example.com:6380" { + t.Fatalf("expected redis queue addr override, got %q", cfg.RedisQueueAddr) + } +} + +func TestLoadFromEnvIgnoresRemovedRedisQueueKeyOverride(t *testing.T) { + t.Setenv("CPA_BASE_URL", "https://cpa.example.com") + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("REDIS_QUEUE_KEY", "custom-queue") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + if cfg.RedisQueueKey != RedisQueueKeyDefault { + t.Fatalf("expected removed redis queue key override to be ignored, got %q", cfg.RedisQueueKey) + } +} + +func TestLoadFromEnvRejectsNonPositiveRedisQueueBatchSize(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("REDIS_QUEUE_BATCH_SIZE", "0") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "REDIS_QUEUE_BATCH_SIZE must be positive" { + t.Fatalf("expected REDIS_QUEUE_BATCH_SIZE validation error, got %v", err) + } +} + +func TestLoadFromEnvParsesOverrides(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("WORK_DIR", "/tmp/work") + t.Setenv("APP_PORT", "9090") + t.Setenv("APP_BASE_PATH", "/cpa/") + t.Setenv("BACKUP_ENABLED", "false") + t.Setenv("BACKUP_INTERVAL", "2h") + t.Setenv("BACKUP_RETENTION_DAYS", "7") + t.Setenv("REQUEST_TIMEOUT", "15s") + t.Setenv("LOG_LEVEL", "debug") + t.Setenv("LOG_FILE_ENABLED", "false") + t.Setenv("LOG_RETENTION_DAYS", "14") + t.Setenv("AUTH_ENABLED", "true") + t.Setenv("LOGIN_PASSWORD", "top-secret") + t.Setenv("AUTH_SESSION_TTL", "12h") + t.Setenv("REDIS_QUEUE_IDLE_INTERVAL", "2s") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + + if cfg.AppPort != "9090" || cfg.AppBasePath != "/cpa" || cfg.WorkDir != "/tmp/work" || cfg.SQLitePath != filepath.Join("/tmp/work", "app.db") || cfg.BackupEnabled || cfg.BackupDir != filepath.Join("/tmp/work", "backups") || cfg.BackupInterval != 2*time.Hour || cfg.BackupRetentionDays != 7 || cfg.RequestTimeout != 15*time.Second || cfg.LogLevel != "debug" || cfg.LogFileEnabled || cfg.LogDir != filepath.Join("/tmp/work", "logs") || cfg.LogRetentionDays != 14 || !cfg.AuthEnabled || cfg.LoginPassword != "top-secret" || cfg.AuthSessionTTL != 12*time.Hour || cfg.RedisQueueIdleInterval != 2*time.Second { + t.Fatalf("unexpected config override result: %+v", cfg) + } +} + +func TestLoadFromEnvRejectsNonPositiveBackupInterval(t *testing.T) { + for _, value := range []string{"0s", "-1h"} { + t.Run(value, func(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("BACKUP_INTERVAL", value) + + _, err := LoadFromEnv() + if err == nil || err.Error() != "BACKUP_INTERVAL must be positive" { + t.Fatalf("expected BACKUP_INTERVAL validation error, got %v", err) + } + }) + } +} + +func TestLoadFromEnvRejectsNegativeBackupRetentionDays(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("BACKUP_RETENTION_DAYS", "-1") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "BACKUP_RETENTION_DAYS must be non-negative" { + t.Fatalf("expected BACKUP_RETENTION_DAYS validation error, got %v", err) + } +} + +func TestLoadFromEnvRejectsNegativeLogRetentionDays(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("LOG_RETENTION_DAYS", "-1") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "LOG_RETENTION_DAYS must be non-negative" { + t.Fatalf("expected LOG_RETENTION_DAYS validation error, got %v", err) + } +} + +func TestLoadFromEnvRejectsNonPositiveRedisQueueIdleInterval(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("REDIS_QUEUE_IDLE_INTERVAL", "0s") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "REDIS_QUEUE_IDLE_INTERVAL must be positive" { + t.Fatalf("expected REDIS_QUEUE_IDLE_INTERVAL validation error, got %v", err) + } +} + +func TestLoadFromEnvIgnoresRemovedRedisDrainEnvOverrides(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("REDIS_QUEUE_ERROR_BACKOFF", "20s") + t.Setenv("REDIS_METADATA_SYNC_INTERVAL", "45s") + + cfg, err := LoadFromEnv() + if err != nil { + t.Fatalf("LoadFromEnv returned error: %v", err) + } + if cfg.RedisQueueErrorBackoff != RedisQueueErrorBackoffDefault || cfg.MetadataSyncInterval != MetadataSyncIntervalDefault { + t.Fatalf("expected removed env overrides to be ignored, got error_backoff=%s metadata_interval=%s", cfg.RedisQueueErrorBackoff, cfg.MetadataSyncInterval) + } +} + +func TestLoadFromEnvRejectsInvalidBasePath(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("APP_BASE_PATH", "cpa") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "APP_BASE_PATH is invalid: must start with '/'" { + t.Fatalf("expected APP_BASE_PATH validation error, got %v", err) + } +} + +func TestLoadFromEnvRejectsNonPositiveAuthSessionTTL(t *testing.T) { + t.Setenv("CPA_BASE_URL", "http://127.0.0.1:"+cpa.ManagementRedisDefaultPort) + t.Setenv("CPA_MANAGEMENT_KEY", "secret") + t.Setenv("AUTH_SESSION_TTL", "0s") + + _, err := LoadFromEnv() + if err == nil || err.Error() != "AUTH_SESSION_TTL must be positive" { + t.Fatalf("expected AUTH_SESSION_TTL validation error, got %v", err) + } +} diff --git a/internal/config/tzdata.go b/internal/config/tzdata.go new file mode 100644 index 0000000000000000000000000000000000000000..4d51db51d157c174f283b54a0badc5d9bfc86ac2 --- /dev/null +++ b/internal/config/tzdata.go @@ -0,0 +1,3 @@ +package config + +import _ "time/tzdata" diff --git a/internal/cpa/client.go b/internal/cpa/client.go new file mode 100644 index 0000000000000000000000000000000000000000..8101ad034801a9c0b91d23ee192c434ab559afcf --- /dev/null +++ b/internal/cpa/client.go @@ -0,0 +1,234 @@ +package cpa + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +type Client struct { + baseURL string + managementKey string + httpClient *http.Client +} + +func (c *Client) doJSONRequest(ctx context.Context, path string, target any, kind string, configure func(*http.Request)) (int, []byte, error) { + if c == nil { + return 0, nil, fmt.Errorf("cpa client is nil") + } + if c.baseURL == "" { + return 0, nil, fmt.Errorf("cpa base url is required") + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) + if err != nil { + return 0, nil, fmt.Errorf("build %s request: %w", kind, err) + } + if configure != nil { + configure(req) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, nil, fmt.Errorf("request %s: %w", kind, err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return resp.StatusCode, nil, fmt.Errorf("read %s response: %w", kind, err) + } + + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return resp.StatusCode, body, fmt.Errorf("%s request returned status %d", kind, resp.StatusCode) + } + if err := json.Unmarshal(body, target); err != nil { + return resp.StatusCode, body, fmt.Errorf("decode %s json: %w", kind, err) + } + return resp.StatusCode, body, nil +} + +func (c *Client) doManagementJSONRequest(ctx context.Context, path string, target any, kind string) (int, []byte, error) { + if c == nil { + return 0, nil, fmt.Errorf("cpa client is nil") + } + if c.managementKey == "" { + return 0, nil, fmt.Errorf("cpa management key is required") + } + return c.doJSONRequest(ctx, path, target, "management "+kind, func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+c.managementKey) + }) +} + +func NewClient(baseURL, managementKey string, timeout time.Duration) *Client { + return &Client{ + baseURL: strings.TrimRight(strings.TrimSpace(baseURL), "/"), + managementKey: strings.TrimSpace(managementKey), + httpClient: &http.Client{ + Timeout: timeout, + }, + } +} + +func (c *Client) FetchExternalAPIKeys(ctx context.Context) (*ExternalAPIKeysResult, error) { + result := &ExternalAPIKeysResult{} + statusCode, body, err := c.doManagementJSONRequest(ctx, cpaManagementExternalAPIKeysEndpoint, &result.Payload, "external api keys") + result.StatusCode = statusCode + result.Body = body + if err != nil { + return result, err + } + return result, nil +} + +func (c *Client) FetchUsageQueue(ctx context.Context, count int) (*UsageQueueResult, error) { + result := &UsageQueueResult{} + if count <= 0 { + return result, fmt.Errorf("usage queue count must be positive") + } + queryPath := cpaManagementUsageQueueEndpoint + "?count=" + url.QueryEscape(strconv.Itoa(count)) + statusCode, body, err := c.doManagementJSONRequest(ctx, queryPath, &result.Payload, "usage queue") + result.StatusCode = statusCode + result.Body = body + if err != nil { + return result, err + } + return result, nil +} + +func (c *Client) FetchModels(ctx context.Context) (*ModelsResult, error) { + externalAPIKeys, err := c.FetchExternalAPIKeys(ctx) + if err != nil { + return &ModelsResult{}, err + } + externalAPIKey := firstNonEmptyString(externalAPIKeys.Payload.ExternalAPIKeys) + if externalAPIKey == "" { + return &ModelsResult{}, fmt.Errorf("cpa external api keys are required") + } + + result := &ModelsResult{} + statusCode, body, err := c.doJSONRequest(ctx, cpaModelsEndpoint, &result.Payload, "models", func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+externalAPIKey) + }) + result.StatusCode = statusCode + result.Body = body + if err != nil { + return result, err + } + return result, nil +} + +func (c *Client) FetchAuthFiles(ctx context.Context) (*AuthFilesResult, error) { + result := &AuthFilesResult{} + statusCode, body, err := c.doManagementJSONRequest(ctx, cpaManagementAuthFilesEndpoint, &result.Payload, "auth files") + result.StatusCode = statusCode + result.Body = body + if err != nil { + return result, err + } + return result, nil +} + +func (c *Client) FetchGeminiAPIKeys(ctx context.Context) (*ProviderKeyConfigResult, error) { + return c.fetchProviderKeyConfig(ctx, cpaManagementGeminiAPIKeyEndpoint, "gemini-api-key", "gemini api keys") +} + +func (c *Client) FetchClaudeAPIKeys(ctx context.Context) (*ProviderKeyConfigResult, error) { + return c.fetchProviderKeyConfig(ctx, cpaManagementClaudeAPIKeyEndpoint, "claude-api-key", "claude api keys") +} + +func (c *Client) FetchCodexAPIKeys(ctx context.Context) (*ProviderKeyConfigResult, error) { + return c.fetchProviderKeyConfig(ctx, cpaManagementCodexAPIKeyEndpoint, "codex-api-key", "codex api keys") +} + +func (c *Client) FetchVertexAPIKeys(ctx context.Context) (*ProviderKeyConfigResult, error) { + return c.fetchProviderKeyConfig(ctx, cpaManagementVertexAPIKeyEndpoint, "vertex-api-key", "vertex api keys") +} + +func (c *Client) fetchProviderKeyConfig(ctx context.Context, path string, payloadKey string, kind string) (*ProviderKeyConfigResult, error) { + result := &ProviderKeyConfigResult{} + var raw json.RawMessage + statusCode, body, err := c.doManagementJSONRequest(ctx, path, &raw, kind) + result.StatusCode = statusCode + result.Body = body + if err != nil { + return result, err + } + payload, err := decodeProviderKeyConfigPayload(raw, payloadKey) + if err != nil { + return result, fmt.Errorf("decode management %s json: %w", kind, err) + } + result.Payload = payload + return result, nil +} + +func (c *Client) FetchOpenAICompatibility(ctx context.Context) (*OpenAICompatibilityResult, error) { + result := &OpenAICompatibilityResult{} + var raw json.RawMessage + statusCode, body, err := c.doManagementJSONRequest(ctx, cpaManagementOpenAICompatibilityEndpoint, &raw, "openai compatibility") + result.StatusCode = statusCode + result.Body = body + if err != nil { + return result, err + } + payload, err := decodeOpenAICompatibilityPayload(raw, "openai-compatibility") + if err != nil { + return result, fmt.Errorf("decode management openai compatibility json: %w", err) + } + result.Payload = payload + return result, nil +} + +func decodeProviderKeyConfigPayload(raw json.RawMessage, payloadKey string) ([]ProviderKeyConfig, error) { + var direct []ProviderKeyConfig + if err := json.Unmarshal(raw, &direct); err == nil { + return direct, nil + } + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(raw, &wrapped); err != nil { + return nil, err + } + payloadRaw, ok := wrapped[payloadKey] + if !ok { + return nil, fmt.Errorf("missing %s payload", payloadKey) + } + if err := json.Unmarshal(payloadRaw, &direct); err != nil { + return nil, err + } + return direct, nil +} + +func decodeOpenAICompatibilityPayload(raw json.RawMessage, payloadKey string) ([]OpenAICompatibilityConfig, error) { + var direct []OpenAICompatibilityConfig + if err := json.Unmarshal(raw, &direct); err == nil { + return direct, nil + } + var wrapped map[string]json.RawMessage + if err := json.Unmarshal(raw, &wrapped); err != nil { + return nil, err + } + payloadRaw, ok := wrapped[payloadKey] + if !ok { + return nil, fmt.Errorf("missing %s payload", payloadKey) + } + if err := json.Unmarshal(payloadRaw, &direct); err != nil { + return nil, err + } + return direct, nil +} + +func firstNonEmptyString(values []string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} diff --git a/internal/cpa/client_test.go b/internal/cpa/client_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f13439a8268aa97884ea6255ae1c0aae41fd1d41 --- /dev/null +++ b/internal/cpa/client_test.go @@ -0,0 +1,380 @@ +package cpa + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestFetchExternalAPIKeysSendsBearerTokenAndParsesExternalKeys(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != cpaManagementExternalAPIKeysEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer management-secret" { + t.Fatalf("expected management Authorization header, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"api-keys":["", " ", "normal-api-key"]}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + result, err := client.FetchExternalAPIKeys(context.Background()) + if err != nil { + t.Fatalf("FetchExternalAPIKeys returned error: %v", err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", result.StatusCode) + } + if string(result.Body) != `{"api-keys":["", " ", "normal-api-key"]}` { + t.Fatalf("unexpected body: %s", string(result.Body)) + } + if len(result.Payload.ExternalAPIKeys) != 3 || result.Payload.ExternalAPIKeys[2] != "normal-api-key" { + t.Fatalf("unexpected external API keys payload: %#v", result.Payload) + } +} + +func TestFetchUsageQueueUsesManagementEndpointAndParsesMessages(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != cpaManagementUsageQueueEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.URL.Query().Get("count"); got != "2" { + t.Fatalf("expected count=2, got %q", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer management-secret" { + t.Fatalf("expected management Authorization header, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"request_id":"req-1"},{"request_id":"req-2"}]`)) + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + result, err := client.FetchUsageQueue(context.Background(), 2) + if err != nil { + t.Fatalf("FetchUsageQueue returned error: %v", err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", result.StatusCode) + } + if len(result.Payload) != 2 || string(result.Payload[0]) != `{"request_id":"req-1"}` || string(result.Payload[1]) != `{"request_id":"req-2"}` { + t.Fatalf("unexpected usage queue payload: %#v", result.Payload) + } +} + +func TestFetchUsageQueueRejectsNonPositiveCount(t *testing.T) { + client := NewClient("https://cpa.example.com", "management-secret", 2*time.Second) + if _, err := client.FetchUsageQueue(context.Background(), 0); err == nil { + t.Fatal("expected invalid count error") + } +} + +func TestFetchModelsUsesExternalAPIKeyAndParsesOpenAICompatibleResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case cpaManagementExternalAPIKeysEndpoint: + if got := r.Header.Get("Authorization"); got != "Bearer management-secret" { + t.Fatalf("expected management Authorization header, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"api-keys":["", " ", "normal-api-key"]}`)) + case cpaModelsEndpoint: + if got := r.Header.Get("Authorization"); got != "Bearer normal-api-key" { + t.Fatalf("expected normal API Authorization header, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"object":"list","data":[{"id":"claude-sonnet","object":"model","created":123,"owned_by":"anthropic"}]}`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + result, err := client.FetchModels(context.Background()) + if err != nil { + t.Fatalf("FetchModels returned error: %v", err) + } + if result.StatusCode != http.StatusOK { + t.Fatalf("expected status 200, got %d", result.StatusCode) + } + if len(result.Payload.Data) != 1 || result.Payload.Data[0].ID != "claude-sonnet" { + t.Fatalf("unexpected models payload: %#v", result.Payload) + } +} + +func TestFetchModelsRejectsMissingExternalAPIKeys(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != cpaManagementExternalAPIKeysEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"api-keys":[]}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + if _, err := client.FetchModels(context.Background()); err == nil { + t.Fatal("expected missing external API keys error") + } +} + +func TestFetchModelsDoesNotUseProviderEndpointsWhenCPAExternalAPIKeysAreMissing(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case cpaManagementExternalAPIKeysEndpoint: + _, _ = w.Write([]byte(`{"api-keys":[]}`)) + case cpaManagementClaudeAPIKeyEndpoint, cpaManagementCodexAPIKeyEndpoint, cpaManagementOpenAICompatibilityEndpoint, cpaModelsEndpoint: + t.Fatalf("FetchModels should not request %s when CPA external API keys are missing", r.URL.Path) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + if _, err := client.FetchModels(context.Background()); err == nil { + t.Fatal("expected missing CPA external API keys error") + } +} + +func TestFetchModelsHandlesModelNonSuccessStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case cpaManagementExternalAPIKeysEndpoint: + _, _ = w.Write([]byte(`{"api-keys":["normal-api-key"]}`)) + case cpaModelsEndpoint: + http.Error(w, `{"error":"unavailable"}`, http.StatusBadGateway) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + _, err := client.FetchModels(context.Background()) + if err == nil { + t.Fatal("expected non-success status error") + } +} + +func TestFetchModelsRejectsRedirectStatus(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case cpaManagementExternalAPIKeysEndpoint: + _, _ = w.Write([]byte(`{"api-keys":["normal-api-key"]}`)) + case cpaModelsEndpoint: + w.WriteHeader(http.StatusFound) + _, _ = w.Write([]byte(`{"object":"list","data":[]}`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + _, err := client.FetchModels(context.Background()) + if err == nil { + t.Fatal("expected redirect status error") + } +} + +func TestFetchModelsRejectsInvalidModelsJSON(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case cpaManagementExternalAPIKeysEndpoint: + _, _ = w.Write([]byte(`{"api-keys":["normal-api-key"]}`)) + case cpaModelsEndpoint: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`not-json`)) + default: + t.Fatalf("unexpected path %q", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + _, err := client.FetchModels(context.Background()) + if err == nil { + t.Fatal("expected invalid json error") + } +} + +func TestProviderMetadataFetchersUseDedicatedEndpoints(t *testing.T) { + tests := []struct { + name string + path string + fetch func(context.Context, *Client) (*ProviderKeyConfigResult, error) + response string + }{ + { + name: "gemini", + path: cpaManagementGeminiAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchGeminiAPIKeys(ctx) + }, + response: `[{"apiKey":"gemini-key","prefix":"gemini-prefix","name":"Gemini"}]`, + }, + { + name: "claude", + path: cpaManagementClaudeAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchClaudeAPIKeys(ctx) + }, + response: `[{"api-key":"claude-key","prefix":"claude-prefix","name":"Claude"}]`, + }, + { + name: "codex", + path: cpaManagementCodexAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchCodexAPIKeys(ctx) + }, + response: `[{"key":"codex-key","prefix":"codex-prefix","name":"Codex"}]`, + }, + { + name: "vertex", + path: cpaManagementVertexAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchVertexAPIKeys(ctx) + }, + response: `[{"apiKey":"vertex-key","prefix":"vertex-prefix","name":"Vertex"}]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tt.path { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer management-secret" { + t.Fatalf("expected management Authorization header, got %q", got) + } + _, _ = w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + result, err := tt.fetch(context.Background(), client) + if err != nil { + t.Fatalf("fetch returned error: %v", err) + } + if result.StatusCode != http.StatusOK || len(result.Body) == 0 { + t.Fatalf("unexpected result metadata: %+v", result) + } + if len(result.Payload) != 1 || result.Payload[0].APIKey == "" || result.Payload[0].Prefix == "" || result.Payload[0].Name == "" { + t.Fatalf("unexpected provider payload: %#v", result.Payload) + } + }) + } +} + +func TestProviderMetadataFetchersParseWrappedEndpointResponses(t *testing.T) { + tests := []struct { + name string + path string + fetch func(context.Context, *Client) (*ProviderKeyConfigResult, error) + response string + }{ + { + name: "gemini", + path: cpaManagementGeminiAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchGeminiAPIKeys(ctx) + }, + response: `{"gemini-api-key":[{"apiKey":"gemini-key","prefix":"gemini-prefix","name":"Gemini"}]}`, + }, + { + name: "claude", + path: cpaManagementClaudeAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchClaudeAPIKeys(ctx) + }, + response: `{"claude-api-key":[{"api-key":"claude-key","prefix":"claude-prefix","name":"Claude"}]}`, + }, + { + name: "codex", + path: cpaManagementCodexAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchCodexAPIKeys(ctx) + }, + response: `{"codex-api-key":[{"key":"codex-key","prefix":"codex-prefix","name":"Codex"}]}`, + }, + { + name: "vertex", + path: cpaManagementVertexAPIKeyEndpoint, + fetch: func(ctx context.Context, client *Client) (*ProviderKeyConfigResult, error) { + return client.FetchVertexAPIKeys(ctx) + }, + response: `{"vertex-api-key":[{"apiKey":"vertex-key","prefix":"vertex-prefix","name":"Vertex"}]}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != tt.path { + t.Fatalf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(tt.response)) + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + result, err := tt.fetch(context.Background(), client) + if err != nil { + t.Fatalf("fetch returned error: %v", err) + } + if len(result.Payload) != 1 || result.Payload[0].APIKey == "" || result.Payload[0].Prefix == "" || result.Payload[0].Name == "" { + t.Fatalf("unexpected wrapped provider payload: %#v", result.Payload) + } + }) + } +} + +func TestFetchOpenAICompatibilityParsesWrappedEndpointResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != cpaManagementOpenAICompatibilityEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) + } + _, _ = w.Write([]byte(`{"openai-compatibility":[{"id":"custom-openai","prefix":"custom","api-keys":["custom-key"]}]}`)) + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + result, err := client.FetchOpenAICompatibility(context.Background()) + if err != nil { + t.Fatalf("FetchOpenAICompatibility returned error: %v", err) + } + if len(result.Payload) != 1 || result.Payload[0].Name != "custom-openai" || result.Payload[0].Prefix != "custom" || len(result.Payload[0].APIKeyEntries) != 1 || result.Payload[0].APIKeyEntries[0].APIKey != "custom-key" { + t.Fatalf("unexpected wrapped openai compatibility payload: %#v", result.Payload) + } +} + +func TestFetchOpenAICompatibilityUsesDedicatedEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != cpaManagementOpenAICompatibilityEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.Header.Get("Authorization"); got != "Bearer management-secret" { + t.Fatalf("expected management Authorization header, got %q", got) + } + _, _ = w.Write([]byte(`[{"id":"custom-openai","prefix":"custom","api-keys":["custom-key"]}]`)) + })) + defer server.Close() + + client := NewClient(server.URL, "management-secret", 2*time.Second) + result, err := client.FetchOpenAICompatibility(context.Background()) + if err != nil { + t.Fatalf("FetchOpenAICompatibility returned error: %v", err) + } + if result.StatusCode != http.StatusOK || len(result.Body) == 0 { + t.Fatalf("unexpected result metadata: %+v", result) + } + if len(result.Payload) != 1 || result.Payload[0].Name != "custom-openai" || result.Payload[0].Prefix != "custom" || len(result.Payload[0].APIKeyEntries) != 1 || result.Payload[0].APIKeyEntries[0].APIKey != "custom-key" { + t.Fatalf("unexpected openai compatibility payload: %#v", result.Payload) + } +} diff --git a/internal/cpa/endpoints.go b/internal/cpa/endpoints.go new file mode 100644 index 0000000000000000000000000000000000000000..064f025ae57366d77202f758fd433898fe9a43c5 --- /dev/null +++ b/internal/cpa/endpoints.go @@ -0,0 +1,20 @@ +package cpa + +const ( + cpaManagementAuthFilesEndpoint = "/v0/management/auth-files" + cpaManagementExternalAPIKeysEndpoint = "/v0/management/api-keys" + cpaManagementVertexAPIKeyEndpoint = "/v0/management/vertex-api-key" + cpaManagementGeminiAPIKeyEndpoint = "/v0/management/gemini-api-key" + cpaManagementCodexAPIKeyEndpoint = "/v0/management/codex-api-key" + cpaManagementClaudeAPIKeyEndpoint = "/v0/management/claude-api-key" + cpaManagementAmpcodeEndpoint = "/v0/management/ampcode" + cpaManagementOpenAICompatibilityEndpoint = "/v0/management/openai-compatibility" + cpaManagementUsageQueueEndpoint = "/v0/management/usage-queue" + cpaModelsEndpoint = "/v1/models" + + cpaManagementRedisNetwork = "tcp" + ManagementRedisDefaultPort = "8317" + cpaManagementRedisAuthCommand = "AUTH" + cpaManagementRedisPopCommand = "LPOP" + ManagementUsageQueueKey = "queue" +) diff --git a/internal/cpa/redis_queue_client.go b/internal/cpa/redis_queue_client.go new file mode 100644 index 0000000000000000000000000000000000000000..cc67abe868473b64498fbd55dd4ce761a6c85148 --- /dev/null +++ b/internal/cpa/redis_queue_client.go @@ -0,0 +1,281 @@ +package cpa + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "net" + "net/url" + "strconv" + "strings" + "time" +) + +var ErrRedisQueueAuth = errors.New("redis queue auth failed") + +type RedisQueueClient struct { + address string + managementKey string + timeout time.Duration + queueKey string + batchSize int + httpClient *Client +} + +func NewRedisQueueClient(baseURL, redisQueueAddr, managementKey string, timeout time.Duration, queueKey string, batchSize int) *RedisQueueClient { + trimmedBaseURL := strings.TrimSpace(baseURL) + trimmedQueueAddr := strings.TrimSpace(redisQueueAddr) + return &RedisQueueClient{ + address: redisQueueAddress(trimmedBaseURL, trimmedQueueAddr), + managementKey: strings.TrimSpace(managementKey), + timeout: timeout, + queueKey: strings.TrimSpace(queueKey), + batchSize: batchSize, + httpClient: NewClient(trimmedBaseURL, managementKey, timeout), + } +} + +func (c *RedisQueueClient) PopUsage(ctx context.Context) ([]string, error) { + if c == nil { + return nil, fmt.Errorf("redis queue client is nil") + } + if c.queueKey == "" { + return nil, fmt.Errorf("redis queue key is required") + } + if c.batchSize <= 0 { + return nil, fmt.Errorf("redis queue batch size must be positive") + } + + messages, err := c.popUsageOverRedis(ctx) + if err == nil { + return messages, nil + } + if !c.canFallbackToHTTP() { + return nil, err + } + + messages, fallbackErr := c.popUsageOverHTTP(ctx) + if fallbackErr != nil { + return nil, fmt.Errorf("redis queue pop failed: %w; http usage queue fallback failed: %w", err, fallbackErr) + } + return messages, nil +} + +func (c *RedisQueueClient) popUsageOverRedis(ctx context.Context) ([]string, error) { + conn, reader, err := c.openAuthenticatedConnection(ctx) + if err != nil { + return nil, err + } + defer conn.Close() + + if err := writeRESPCommand(conn, cpaManagementRedisPopCommand, c.queueKey, strconv.Itoa(c.batchSize)); err != nil { + return nil, fmt.Errorf("write redis queue pop command: %w", err) + } + popResponse, err := readRESPValue(reader) + if err != nil { + return nil, fmt.Errorf("read redis queue pop response: %w", err) + } + if popResponse.err != "" { + return nil, fmt.Errorf("redis queue pop failed: %s", popResponse.err) + } + return popResponse.strings(), nil +} + +func (c *RedisQueueClient) canFallbackToHTTP() bool { + return c != nil && c.httpClient != nil && strings.TrimSpace(c.httpClient.baseURL) != "" +} + +func (c *RedisQueueClient) popUsageOverHTTP(ctx context.Context) ([]string, error) { + if c == nil || c.httpClient == nil { + return nil, fmt.Errorf("redis queue http client is nil") + } + result, err := c.httpClient.FetchUsageQueue(ctx, c.batchSize) + if err != nil { + return nil, fmt.Errorf("fetch usage queue over http: %w", err) + } + messages := make([]string, 0, len(result.Payload)) + for _, item := range result.Payload { + trimmed := strings.TrimSpace(string(item)) + if trimmed == "" || trimmed == "null" { + continue + } + messages = append(messages, trimmed) + } + return messages, nil +} + +func (c *RedisQueueClient) openAuthenticatedConnection(ctx context.Context) (net.Conn, *bufio.Reader, error) { + if c == nil { + return nil, nil, fmt.Errorf("redis queue client is nil") + } + if c.address == "" { + return nil, nil, fmt.Errorf("redis queue address is required") + } + if c.managementKey == "" { + return nil, nil, fmt.Errorf("redis queue management key is required") + } + + dialer := net.Dialer{Timeout: c.timeout} + conn, err := dialer.DialContext(ctx, cpaManagementRedisNetwork, c.address) + if err != nil { + return nil, nil, fmt.Errorf("connect redis queue: %w", err) + } + if c.timeout > 0 { + _ = conn.SetDeadline(time.Now().Add(c.timeout)) + } + + reader := bufio.NewReader(conn) + if err := writeRESPCommand(conn, cpaManagementRedisAuthCommand, c.managementKey); err != nil { + conn.Close() + return nil, nil, fmt.Errorf("write redis queue auth command: %w", err) + } + authResponse, err := readRESPValue(reader) + if err != nil { + conn.Close() + return nil, nil, fmt.Errorf("read redis queue auth response: %w", err) + } + if authResponse.err != "" { + conn.Close() + return nil, nil, fmt.Errorf("%w: %s", ErrRedisQueueAuth, authResponse.err) + } + return conn, reader, nil +} + +func redisQueueAddress(baseURL, redisQueueAddr string) string { + override := strings.TrimSpace(redisQueueAddr) + if override != "" { + if parsed, err := url.Parse(override); err == nil && parsed.Host != "" { + return parsed.Host + } + return override + } + trimmed := strings.TrimSpace(baseURL) + if trimmed == "" { + return "" + } + parsed, err := url.Parse(trimmed) + if err == nil && parsed.Host != "" { + if parsed.Port() != "" { + return parsed.Host + } + return net.JoinHostPort(parsed.Hostname(), ManagementRedisDefaultPort) + } + trimmed = strings.TrimPrefix(strings.TrimPrefix(trimmed, "http://"), "https://") + if _, _, err := net.SplitHostPort(trimmed); err == nil { + return trimmed + } + return net.JoinHostPort(trimmed, ManagementRedisDefaultPort) +} + +func writeRESPCommand(writer io.Writer, parts ...string) error { + if _, err := fmt.Fprintf(writer, "*%d\r\n", len(parts)); err != nil { + return err + } + for _, part := range parts { + if _, err := fmt.Fprintf(writer, "$%d\r\n%s\r\n", len(part), part); err != nil { + return err + } + } + return nil +} + +type respValue struct { + simple string + bulk *string + array []respValue + err string + nil bool +} + +func (v respValue) strings() []string { + if v.nil { + return nil + } + if v.bulk != nil { + return []string{*v.bulk} + } + if len(v.array) == 0 { + return nil + } + items := make([]string, 0, len(v.array)) + for _, item := range v.array { + if item.bulk != nil { + items = append(items, *item.bulk) + } + } + return items +} + +func readRESPValue(reader *bufio.Reader) (respValue, error) { + prefix, err := reader.ReadByte() + if err != nil { + return respValue{}, err + } + switch prefix { + case '+': + line, err := readRESPLine(reader) + return respValue{simple: line}, err + case '-': + line, err := readRESPLine(reader) + return respValue{err: line}, err + case '$': + return readRESPBulk(reader) + case '*': + return readRESPArray(reader) + default: + return respValue{}, fmt.Errorf("unexpected RESP prefix %q", prefix) + } +} + +func readRESPBulk(reader *bufio.Reader) (respValue, error) { + line, err := readRESPLine(reader) + if err != nil { + return respValue{}, err + } + size, err := strconv.Atoi(line) + if err != nil { + return respValue{}, fmt.Errorf("parse bulk size: %w", err) + } + if size < 0 { + return respValue{nil: true}, nil + } + buf := make([]byte, size+2) + if _, err := io.ReadFull(reader, buf); err != nil { + return respValue{}, err + } + value := string(buf[:size]) + return respValue{bulk: &value}, nil +} + +func readRESPArray(reader *bufio.Reader) (respValue, error) { + line, err := readRESPLine(reader) + if err != nil { + return respValue{}, err + } + count, err := strconv.Atoi(line) + if err != nil { + return respValue{}, fmt.Errorf("parse array size: %w", err) + } + if count < 0 { + return respValue{nil: true}, nil + } + items := make([]respValue, 0, count) + for range count { + item, err := readRESPValue(reader) + if err != nil { + return respValue{}, err + } + items = append(items, item) + } + return respValue{array: items}, nil +} + +func readRESPLine(reader *bufio.Reader) (string, error) { + line, err := reader.ReadString('\n') + if err != nil { + return "", err + } + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} diff --git a/internal/cpa/redis_queue_client_test.go b/internal/cpa/redis_queue_client_test.go new file mode 100644 index 0000000000000000000000000000000000000000..23c23e309b03cca901406764c7f8115d64130656 --- /dev/null +++ b/internal/cpa/redis_queue_client_test.go @@ -0,0 +1,232 @@ +package cpa + +import ( + "bufio" + "context" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestRedisQueueClientPopsBatch(t *testing.T) { + server := newRedisQueueTestServer(t, func(t *testing.T, conn net.Conn) { + reader := bufio.NewReader(conn) + if got := readRESPCommand(t, reader); strings.Join(got, " ") != cpaManagementRedisAuthCommand+" secret" { + t.Fatalf("unexpected auth command: %v", got) + } + fmt.Fprint(conn, "+OK\r\n") + if got := readRESPCommand(t, reader); strings.Join(got, " ") != cpaManagementRedisPopCommand+" "+ManagementUsageQueueKey+" 2" { + t.Fatalf("unexpected pop command: %v", got) + } + fmt.Fprint(conn, "*2\r\n$7\r\n{\"a\":1}\r\n$7\r\n{\"b\":2}\r\n") + }) + + client := NewRedisQueueClient(server.URL, "", "secret", time.Second, ManagementUsageQueueKey, 2) + messages, err := client.PopUsage(ctxWithTimeout(t)) + if err != nil { + t.Fatalf("PopUsage returned error: %v", err) + } + + if len(messages) != 2 || messages[0] != `{"a":1}` || messages[1] != `{"b":2}` { + t.Fatalf("unexpected messages: %#v", messages) + } +} + +func TestRedisQueueClientFallsBackToHTTPUsageQueueWhenRedisFails(t *testing.T) { + server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != cpaManagementUsageQueueEndpoint { + t.Fatalf("unexpected path %q", r.URL.Path) + } + if got := r.URL.Query().Get("count"); got != "2" { + t.Fatalf("expected count=2, got %q", got) + } + if got := r.Header.Get("Authorization"); got != "Bearer secret" { + t.Fatalf("expected management Authorization header, got %q", got) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[{"a":1},{"b":2}]`)) + })) + defer server.Close() + + client := NewRedisQueueClient(server.URL, "127.0.0.1:1", "secret", 10*time.Millisecond, ManagementUsageQueueKey, 2) + client.httpClient.httpClient = server.Client() + messages, err := client.PopUsage(ctxWithTimeout(t)) + if err != nil { + t.Fatalf("PopUsage returned error: %v", err) + } + if len(messages) != 2 || messages[0] != `{"a":1}` || messages[1] != `{"b":2}` { + t.Fatalf("unexpected messages: %#v", messages) + } +} + +func TestRedisQueueClientPrefersRedisBeforeHTTPFallback(t *testing.T) { + redisServer := newRedisQueueTestServer(t, func(t *testing.T, conn net.Conn) { + reader := bufio.NewReader(conn) + readRESPCommand(t, reader) + fmt.Fprint(conn, "+OK\r\n") + readRESPCommand(t, reader) + fmt.Fprint(conn, "*1\r\n$7\r\n{\"r\":1}\r\n") + }) + httpCalled := false + httpServer := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + httpCalled = true + _, _ = w.Write([]byte(`[{"h":1}]`)) + })) + defer httpServer.Close() + + client := NewRedisQueueClient(httpServer.URL, redisServer.URL, "secret", time.Second, ManagementUsageQueueKey, 2) + client.httpClient.httpClient = httpServer.Client() + messages, err := client.PopUsage(ctxWithTimeout(t)) + if err != nil { + t.Fatalf("PopUsage returned error: %v", err) + } + if httpCalled { + t.Fatal("expected redis success to skip http fallback") + } + if len(messages) != 1 || messages[0] != `{"r":1}` { + t.Fatalf("unexpected messages: %#v", messages) + } +} + +func TestRedisQueueClientTreatsEmptyPopAsSuccess(t *testing.T) { + server := newRedisQueueTestServer(t, func(t *testing.T, conn net.Conn) { + reader := bufio.NewReader(conn) + readRESPCommand(t, reader) + fmt.Fprint(conn, "+OK\r\n") + readRESPCommand(t, reader) + fmt.Fprint(conn, "*0\r\n") + }) + + client := NewRedisQueueClient(server.URL, "", "secret", time.Second, ManagementUsageQueueKey, 1000) + messages, err := client.PopUsage(ctxWithTimeout(t)) + if err != nil { + t.Fatalf("PopUsage returned error: %v", err) + } + if len(messages) != 0 { + t.Fatalf("expected empty messages, got %#v", messages) + } +} + +func TestRedisQueueClientClassifiesAuthErrors(t *testing.T) { + server := newRedisQueueTestServer(t, func(t *testing.T, conn net.Conn) { + readRESPCommand(t, bufio.NewReader(conn)) + fmt.Fprint(conn, "-ERR invalid password\r\n") + }) + + client := NewRedisQueueClient(server.URL, "", "wrong", time.Second, ManagementUsageQueueKey, 1000) + _, err := client.PopUsage(ctxWithTimeout(t)) + if err == nil { + t.Fatal("expected auth error") + } + if !errors.Is(err, ErrRedisQueueAuth) { + t.Fatalf("expected ErrRedisQueueAuth, got %v", err) + } +} + +func TestRedisQueueClientPrefersExplicitQueueAddr(t *testing.T) { + if got := redisQueueAddress("https://cpa.example.com", "redis-stream.example.com:6380"); got != "redis-stream.example.com:6380" { + t.Fatalf("expected explicit redis queue addr, got %q", got) + } + if got := redisQueueAddress("https://cpa.example.com", "redis://redis-stream.example.com:6380"); got != "redis-stream.example.com:6380" { + t.Fatalf("expected redis scheme to be stripped, got %q", got) + } + if got := redisQueueAddress("https://cpa.example.com", "http://redis-stream.example.com:6380"); got != "redis-stream.example.com:6380" { + t.Fatalf("expected http scheme to be stripped, got %q", got) + } +} + +func TestRedisQueueClientDefaultsToManagementPortFromBaseURLHost(t *testing.T) { + if got := redisQueueAddress("https://cpa.example.com", ""); got != "cpa.example.com:"+ManagementRedisDefaultPort { + t.Fatalf("expected default management port from https host, got %q", got) + } + if got := redisQueueAddress("http://cpa.example.com", ""); got != "cpa.example.com:"+ManagementRedisDefaultPort { + t.Fatalf("expected default management port from http host, got %q", got) + } + if got := redisQueueAddress("http://127.0.0.1:"+ManagementRedisDefaultPort, ""); got != "127.0.0.1:"+ManagementRedisDefaultPort { + t.Fatalf("expected explicit port to be preserved, got %q", got) + } +} + +func TestRedisQueueClientReportsMalformedRESP(t *testing.T) { + server := newRedisQueueTestServer(t, func(t *testing.T, conn net.Conn) { + reader := bufio.NewReader(conn) + readRESPCommand(t, reader) + fmt.Fprint(conn, "+OK\r\n") + readRESPCommand(t, reader) + fmt.Fprint(conn, "!not-resp\r\n") + }) + + client := NewRedisQueueClient(server.URL, "", "secret", time.Second, ManagementUsageQueueKey, 1000) + _, err := client.PopUsage(ctxWithTimeout(t)) + if err == nil || !strings.Contains(err.Error(), "read redis queue pop response") { + t.Fatalf("expected malformed response error, got %v", err) + } +} + +type redisQueueTestServer struct { + URL string +} + +func newRedisQueueTestServer(t *testing.T, handler func(*testing.T, net.Conn)) redisQueueTestServer { + t.Helper() + listener, err := net.Listen(cpaManagementRedisNetwork, "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { listener.Close() }) + + done := make(chan struct{}) + go func() { + defer close(done) + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + handler(t, conn) + }() + t.Cleanup(func() { <-done }) + + return redisQueueTestServer{URL: "http://" + listener.Addr().String()} +} + +func readRESPCommand(t *testing.T, reader *bufio.Reader) []string { + t.Helper() + line, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read command header: %v", err) + } + var count int + if _, err := fmt.Sscanf(line, "*%d\r\n", &count); err != nil { + t.Fatalf("parse command header %q: %v", line, err) + } + parts := make([]string, 0, count) + for range count { + bulkHeader, err := reader.ReadString('\n') + if err != nil { + t.Fatalf("read bulk header: %v", err) + } + var size int + if _, err := fmt.Sscanf(bulkHeader, "$%d\r\n", &size); err != nil { + t.Fatalf("parse bulk header %q: %v", bulkHeader, err) + } + buf := make([]byte, size+2) + if _, err := reader.Read(buf); err != nil { + t.Fatalf("read bulk body: %v", err) + } + parts = append(parts, string(buf[:size])) + } + return parts +} + +func ctxWithTimeout(t *testing.T) context.Context { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + t.Cleanup(cancel) + return ctx +} diff --git a/internal/cpa/types.go b/internal/cpa/types.go new file mode 100644 index 0000000000000000000000000000000000000000..b657353ad66c8c26d7e7f2d3bb65115158929fdf --- /dev/null +++ b/internal/cpa/types.go @@ -0,0 +1,243 @@ +package cpa + +import ( + "encoding/json" + "fmt" + "time" +) + +type StatisticsSnapshot struct { + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` + APIs map[string]APISnapshot `json:"apis"` + RequestsByDay map[string]int64 `json:"requests_by_day"` + RequestsByHour map[string]int64 `json:"requests_by_hour"` + TokensByDay map[string]int64 `json:"tokens_by_day"` + TokensByHour map[string]int64 `json:"tokens_by_hour"` +} + +type APISnapshot struct { + DisplayName string `json:"display_name,omitempty"` + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` + Models map[string]ModelSnapshot `json:"models"` +} + +type ModelSnapshot struct { + TotalRequests int64 `json:"total_requests"` + SuccessCount int64 `json:"success_count"` + FailureCount int64 `json:"failure_count"` + TotalTokens int64 `json:"total_tokens"` + Details []RequestDetail `json:"details"` +} + +type RequestDetail struct { + Timestamp time.Time `json:"timestamp"` + LatencyMS int64 `json:"latency_ms"` + Source string `json:"source"` + SourceRaw string `json:"source_raw,omitempty"` + SourceDisplay string `json:"source_display,omitempty"` + SourceType string `json:"source_type,omitempty"` + SourceKey string `json:"source_key,omitempty"` + AuthIndex string `json:"auth_index"` + Failed bool `json:"failed"` + Tokens TokenStats `json:"tokens"` +} + +type TokenStats struct { + InputTokens int64 `json:"input_tokens"` + OutputTokens int64 `json:"output_tokens"` + ReasoningTokens int64 `json:"reasoning_tokens"` + CachedTokens int64 `json:"cached_tokens"` + TotalTokens int64 `json:"total_tokens"` +} + +type ExternalAPIKeysResult struct { + StatusCode int + Body []byte + Payload ExternalAPIKeysResponse +} + +type ExternalAPIKeysResponse struct { + ExternalAPIKeys []string `json:"api-keys"` +} + +type ModelsResult struct { + StatusCode int + Body []byte + Payload ModelsResponse +} + +type ModelsResponse struct { + Object string `json:"object"` + Data []ModelInfo `json:"data"` +} + +type ModelInfo struct { + ID string `json:"id"` + Object string `json:"object,omitempty"` + Created int64 `json:"created,omitempty"` + OwnedBy string `json:"owned_by,omitempty"` +} + +type AuthFilesResult struct { + StatusCode int + Body []byte + Payload AuthFilesResponse +} + +type AuthFilesResponse struct { + Files []AuthFile `json:"files"` +} + +type AuthFile struct { + AuthIndex string `json:"auth_index"` + Name string `json:"name"` + Email string `json:"email"` + Type string `json:"type"` + Provider string `json:"provider"` + Label string `json:"label"` + Status string `json:"status"` + Source string `json:"source"` + Disabled bool `json:"disabled"` + Unavailable bool `json:"unavailable"` + RuntimeOnly bool `json:"runtime_only"` +} + +type UsageQueueResult struct { + StatusCode int + Body []byte + Payload []json.RawMessage +} + +type ProviderKeyConfigResult struct { + StatusCode int + Body []byte + Payload []ProviderKeyConfig +} + +type OpenAICompatibilityResult struct { + StatusCode int + Body []byte + Payload []OpenAICompatibilityConfig +} + +type ProviderMetadataConfig struct { + GeminiAPIKeys []ProviderKeyConfig `json:"gemini-api-key"` + ClaudeAPIKeys []ProviderKeyConfig `json:"claude-api-key"` + CodexAPIKeys []ProviderKeyConfig `json:"codex-api-key"` + VertexAPIKeys []ProviderKeyConfig `json:"vertex-api-key"` + OpenAICompatibility []OpenAICompatibilityConfig `json:"openai-compatibility"` +} + +type ProviderKeyConfig struct { + APIKey string + Prefix string + Name string +} + +func (p *ProviderKeyConfig) UnmarshalJSON(data []byte) error { + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("decode provider key config: %w", err) + } + p.APIKey = firstString(raw, "apiKey", "api-key", "key") + p.Prefix = firstString(raw, "prefix") + p.Name = firstString(raw, "name") + return nil +} + +type OpenAICompatibilityConfig struct { + Name string + Prefix string + APIKeyEntries []OpenAIApiKeyEntry +} + +func (c *OpenAICompatibilityConfig) UnmarshalJSON(data []byte) error { + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("decode openai compatibility config: %w", err) + } + c.Name = firstString(raw, "name", "id") + c.Prefix = firstString(raw, "prefix") + c.APIKeyEntries = nil + for _, key := range []string{"apiKeyEntries", "api-key-entries", "api-keys"} { + value, ok := raw[key] + if !ok { + continue + } + entries, err := decodeOpenAIApiKeyEntries(value) + if err != nil { + return err + } + c.APIKeyEntries = entries + break + } + return nil +} + +type OpenAIApiKeyEntry struct { + APIKey string +} + +func (e *OpenAIApiKeyEntry) UnmarshalJSON(data []byte) error { + var raw any + if err := json.Unmarshal(data, &raw); err != nil { + return fmt.Errorf("decode openai api key entry: %w", err) + } + entry, err := decodeOpenAIApiKeyEntry(raw) + if err != nil { + return err + } + *e = entry + return nil +} + +func decodeOpenAIApiKeyEntries(value any) ([]OpenAIApiKeyEntry, error) { + rawEntries, ok := value.([]any) + if !ok { + return nil, nil + } + entries := make([]OpenAIApiKeyEntry, 0, len(rawEntries)) + for _, rawEntry := range rawEntries { + entry, err := decodeOpenAIApiKeyEntry(rawEntry) + if err != nil { + return nil, err + } + if entry.APIKey == "" { + continue + } + entries = append(entries, entry) + } + return entries, nil +} + +func decodeOpenAIApiKeyEntry(raw any) (OpenAIApiKeyEntry, error) { + switch value := raw.(type) { + case string: + return OpenAIApiKeyEntry{APIKey: value}, nil + case map[string]any: + return OpenAIApiKeyEntry{APIKey: firstString(value, "apiKey", "api-key", "key")}, nil + case nil: + return OpenAIApiKeyEntry{}, nil + default: + return OpenAIApiKeyEntry{}, fmt.Errorf("unsupported openai api key entry type %T", raw) + } +} + +func firstString(raw map[string]any, keys ...string) string { + for _, key := range keys { + value, ok := raw[key] + if !ok { + continue + } + if text, ok := value.(string); ok { + return text + } + } + return "" +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000000000000000000000000000000000000..69ebe7872875d30057aa3190a9137ec4ea0599a4 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,249 @@ +package logging + +import ( + "fmt" + "io" + stdlog "log" + "log/slog" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "cpa-usage-keeper/internal/config" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +const logFilePrefix = "cpa-usage-keeper-" + +type noopCloser struct{} + +func (noopCloser) Close() error { return nil } + +type restoreCloser struct { + closer io.Closer + previousLogrusOutput io.Writer + previousLogrusLevel logrus.Level + previousLogrusFormatter logrus.Formatter + previousStdlogOutput io.Writer + previousSlog *slog.Logger + previousGinDefaultWriter io.Writer + previousGinErrorWriter io.Writer + previousGinDebugPrint func(string, ...interface{}) + previousGinDebugPrintRoute func(string, string, string, int) +} + +func (c *restoreCloser) Close() error { + logrus.SetOutput(c.previousLogrusOutput) + logrus.SetLevel(c.previousLogrusLevel) + logrus.SetFormatter(c.previousLogrusFormatter) + stdlog.SetOutput(c.previousStdlogOutput) + slog.SetDefault(c.previousSlog) + gin.DefaultWriter = c.previousGinDefaultWriter + gin.DefaultErrorWriter = c.previousGinErrorWriter + gin.DebugPrintFunc = c.previousGinDebugPrint + gin.DebugPrintRouteFunc = c.previousGinDebugPrintRoute + return c.closer.Close() +} + +func resolveLogDir(cfg config.Config) string { + logDir := strings.TrimSpace(cfg.LogDir) + if logDir != "" { + return logDir + } + workDir := strings.TrimSpace(cfg.WorkDir) + if workDir == "" { + workDir = config.DefaultWorkDir + } + return filepath.Join(workDir, filepath.Base(config.DefaultLogDir)) +} + +func Configure(cfg config.Config) (io.Closer, error) { + previousLogrusOutput := logrus.StandardLogger().Out + previousLogrusLevel := logrus.GetLevel() + previousLogrusFormatter := logrus.StandardLogger().Formatter + previousStdlogOutput := stdlog.Writer() + previousSlog := slog.Default() + previousGinDefaultWriter := gin.DefaultWriter + previousGinErrorWriter := gin.DefaultErrorWriter + previousGinDebugPrint := gin.DebugPrintFunc + previousGinDebugPrintRoute := gin.DebugPrintRouteFunc + + level, err := logrus.ParseLevel(cfg.LogLevel) + if err != nil { + level = logrus.InfoLevel + } + + writer := io.Writer(os.Stderr) + var closer io.Closer = noopCloser{} + if cfg.LogFileEnabled { + logDir := resolveLogDir(cfg) + dailyWriter, err := newDailyFileWriter(logDir, cfg.LogRetentionDays, time.Now) + if err != nil { + return nil, err + } + writer = io.MultiWriter(os.Stderr, dailyWriter) + closer = dailyWriter + } + + logrus.SetLevel(level) + logrus.SetFormatter(&logrus.TextFormatter{ + FullTimestamp: true, + TimestampFormat: time.RFC3339, + }) + logrus.SetOutput(writer) + stdlog.SetOutput(writer) + slog.SetDefault(slog.New(slog.NewTextHandler(writer, nil))) + configureGinLogging() + return &restoreCloser{ + closer: closer, + previousLogrusOutput: previousLogrusOutput, + previousLogrusLevel: previousLogrusLevel, + previousLogrusFormatter: previousLogrusFormatter, + previousStdlogOutput: previousStdlogOutput, + previousSlog: previousSlog, + previousGinDefaultWriter: previousGinDefaultWriter, + previousGinErrorWriter: previousGinErrorWriter, + previousGinDebugPrint: previousGinDebugPrint, + previousGinDebugPrintRoute: previousGinDebugPrintRoute, + }, nil +} + +type logrusWriter struct { + level logrus.Level +} + +func (w logrusWriter) Write(p []byte) (int, error) { + message := strings.TrimRight(string(p), "\r\n") + if message != "" { + logrus.StandardLogger().Log(w.level, message) + } + return len(p), nil +} + +func configureGinLogging() { + gin.DefaultWriter = logrusWriter{level: logrus.InfoLevel} + gin.DefaultErrorWriter = logrusWriter{level: logrus.ErrorLevel} + gin.DebugPrintFunc = func(format string, values ...interface{}) { + logrus.Infof("[GIN-debug] "+strings.TrimRight(format, "\r\n"), values...) + } + gin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) { + logrus.Infof("[GIN-debug] %-6s %s --> %s (%d handlers)", httpMethod, absolutePath, handlerName, nuHandlers) + } +} + +type dailyFileWriter struct { + mu sync.Mutex + dir string + retentionDays int + now func() time.Time + currentDate string + file *os.File +} + +func newDailyFileWriter(dir string, retentionDays int, now func() time.Time) (*dailyFileWriter, error) { + if now == nil { + now = time.Now + } + writer := &dailyFileWriter{ + dir: dir, + retentionDays: retentionDays, + now: now, + } + if err := os.MkdirAll(dir, 0755); err != nil { + return nil, fmt.Errorf("create log dir: %w", err) + } + if err := writer.rotateLocked(); err != nil { + return nil, err + } + if err := writer.cleanupLocked(); err != nil { + _ = writer.Close() + return nil, err + } + return writer, nil +} + +func (w *dailyFileWriter) Write(p []byte) (int, error) { + w.mu.Lock() + defer w.mu.Unlock() + + date := w.now().Format("2006-01-02") + if w.file == nil || w.currentDate != date { + if err := w.rotateLocked(); err != nil { + return 0, err + } + if err := w.cleanupLocked(); err != nil { + return 0, err + } + } + return w.file.Write(p) +} + +func (w *dailyFileWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + if w.file == nil { + return nil + } + err := w.file.Close() + w.file = nil + return err +} + +func (w *dailyFileWriter) rotateLocked() error { + date := w.now().Format("2006-01-02") + if w.file != nil && w.currentDate == date { + return nil + } + if w.file != nil { + if err := w.file.Close(); err != nil { + return fmt.Errorf("close log file: %w", err) + } + } + filePath := filepath.Join(w.dir, logFilePrefix+date+".log") + file, err := os.OpenFile(filePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644) + if err != nil { + return fmt.Errorf("open log file: %w", err) + } + w.file = file + w.currentDate = date + return nil +} + +func (w *dailyFileWriter) cleanupLocked() error { + if w.retentionDays <= 0 { + return nil + } + cutoff := w.now().AddDate(0, 0, -w.retentionDays) + entries, err := os.ReadDir(w.dir) + if err != nil { + return fmt.Errorf("read log dir: %w", err) + } + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if !strings.HasPrefix(name, logFilePrefix) || !strings.HasSuffix(name, ".log") { + continue + } + datePart := strings.TrimSuffix(strings.TrimPrefix(name, logFilePrefix), ".log") + logDate, err := time.ParseInLocation("2006-01-02", datePart, time.Local) + if err != nil { + continue + } + if logDate.Before(dateOnly(cutoff)) { + if err := os.Remove(filepath.Join(w.dir, name)); err != nil { + return fmt.Errorf("remove old log file: %w", err) + } + } + } + return nil +} + +func dateOnly(value time.Time) time.Time { + year, month, day := value.Date() + return time.Date(year, month, day, 0, 0, 0, 0, value.Location()) +} diff --git a/internal/logging/logging_test.go b/internal/logging/logging_test.go new file mode 100644 index 0000000000000000000000000000000000000000..4c4d6519c7aecf202939c31da2d7f816a2c3523d --- /dev/null +++ b/internal/logging/logging_test.go @@ -0,0 +1,360 @@ +package logging + +import ( + "bytes" + stdlog "log" + "log/slog" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "github.com/gin-gonic/gin" + "github.com/sirupsen/logrus" +) + +func TestResolveLogDirUsesWorkDirFallback(t *testing.T) { + workDir := filepath.Join(t.TempDir(), "work") + + logDir := resolveLogDir(config.Config{WorkDir: workDir}) + + if logDir != filepath.Join(workDir, filepath.Base(config.DefaultLogDir)) { + t.Fatalf("expected log dir under work dir, got %q", logDir) + } +} + +func TestConfigureWritesLogrusToDailyFile(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + logDir := t.TempDir() + closer, err := Configure(config.Config{ + LogLevel: "info", + LogFileEnabled: true, + LogDir: logDir, + LogRetentionDays: 7, + }) + if err != nil { + t.Fatalf("Configure returned error: %v", err) + } + defer closer.Close() + + logrus.Info("file logging works") + + content := readTodayLogFile(t, logDir) + if !strings.Contains(content, "file logging works") { + t.Fatalf("expected log file to contain logrus message, got %q", content) + } + if !logLineHasTimestamp(content) { + t.Fatalf("expected log file to include timestamp, got %q", content) + } +} + +func TestConfigureUsesFullTimestampFormatter(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + closer, err := Configure(config.Config{ + LogLevel: "info", + LogFileEnabled: false, + LogRetentionDays: 7, + }) + if err != nil { + t.Fatalf("Configure returned error: %v", err) + } + defer closer.Close() + + formatter, ok := logrus.StandardLogger().Formatter.(*logrus.TextFormatter) + if !ok { + t.Fatalf("expected text formatter, got %T", logrus.StandardLogger().Formatter) + } + if !formatter.FullTimestamp || formatter.TimestampFormat == "" { + t.Fatalf("expected full timestamp formatter, got FullTimestamp=%v TimestampFormat=%q", formatter.FullTimestamp, formatter.TimestampFormat) + } +} + +func TestConfigureWritesLogrusConsoleWithTimestamp(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + previousStderr := os.Stderr + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + os.Stderr = writer + defer func() { + os.Stderr = previousStderr + _ = reader.Close() + }() + + closer, err := Configure(config.Config{ + LogLevel: "info", + LogFileEnabled: false, + LogRetentionDays: 7, + }) + if err != nil { + t.Fatalf("Configure returned error: %v", err) + } + + logrus.Info("console timestamp works") + if err := closer.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close stderr writer: %v", err) + } + + var output bytes.Buffer + if _, err := output.ReadFrom(reader); err != nil { + t.Fatalf("read stderr output: %v", err) + } + content := output.String() + if !strings.Contains(content, "console timestamp works") { + t.Fatalf("expected console output to contain logrus message, got %q", content) + } + if !logLineHasTimestamp(content) { + t.Fatalf("expected console output to include timestamp, got %q", content) + } +} + +func TestConfigureDisablesFileLogging(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + logDir := t.TempDir() + closer, err := Configure(config.Config{ + LogLevel: "info", + LogFileEnabled: false, + LogDir: logDir, + LogRetentionDays: 7, + }) + if err != nil { + t.Fatalf("Configure returned error: %v", err) + } + defer closer.Close() + + logrus.Info("stderr only") + + entries, err := os.ReadDir(logDir) + if err != nil { + t.Fatalf("read log dir: %v", err) + } + if len(entries) != 0 { + t.Fatalf("expected no log files when file logging disabled, got %d", len(entries)) + } +} + +func TestConfigureRoutesStdlibLogAndSlogToFile(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + logDir := t.TempDir() + closer, err := Configure(config.Config{ + LogLevel: "info", + LogFileEnabled: true, + LogDir: logDir, + LogRetentionDays: 7, + }) + if err != nil { + t.Fatalf("Configure returned error: %v", err) + } + defer closer.Close() + + stdlog.Print("stdlib message") + slog.Error("slog message") + + content := readTodayLogFile(t, logDir) + if !strings.Contains(content, "stdlib message") || !strings.Contains(content, "slog message") { + t.Fatalf("expected stdlib and slog messages in file, got %q", content) + } +} + +func TestConfigureRoutesGinDebugToTimestampedLogrusOutput(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + previousStderr := os.Stderr + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("create stderr pipe: %v", err) + } + os.Stderr = writer + defer func() { + os.Stderr = previousStderr + _ = reader.Close() + }() + + closer, err := Configure(config.Config{ + LogLevel: "info", + LogFileEnabled: false, + LogRetentionDays: 7, + }) + if err != nil { + t.Fatalf("Configure returned error: %v", err) + } + + if gin.DebugPrintFunc == nil { + t.Fatal("expected Configure to install Gin debug print function") + } + gin.DebugPrintFunc("GET %s", "/api/v1/status") + if err := closer.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if err := writer.Close(); err != nil { + t.Fatalf("close stderr writer: %v", err) + } + + var output bytes.Buffer + if _, err := output.ReadFrom(reader); err != nil { + t.Fatalf("read stderr output: %v", err) + } + content := output.String() + if !strings.Contains(content, "[GIN-debug] GET /api/v1/status") { + t.Fatalf("expected Gin debug output to be routed through logrus, got %q", content) + } + if !logLineHasTimestamp(content) { + t.Fatalf("expected Gin debug output to include timestamp, got %q", content) + } +} + +func TestConfigureCloseRestoresGlobalLoggers(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + var restoredOutput bytes.Buffer + logrus.SetOutput(&restoredOutput) + stdlog.SetOutput(&restoredOutput) + slog.SetDefault(slog.New(slog.NewTextHandler(&restoredOutput, nil))) + gin.DefaultWriter = &restoredOutput + gin.DefaultErrorWriter = &restoredOutput + gin.DebugPrintFunc = func(format string, values ...interface{}) { + restoredOutput.WriteString("after close gin debug") + } + gin.DebugPrintRouteFunc = func(httpMethod, absolutePath, handlerName string, nuHandlers int) { + restoredOutput.WriteString(" after close gin route") + } + + closer, err := Configure(config.Config{ + LogLevel: "info", + LogFileEnabled: true, + LogDir: t.TempDir(), + LogRetentionDays: 7, + }) + if err != nil { + t.Fatalf("Configure returned error: %v", err) + } + if err := closer.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + + logrus.Info("after close logrus") + stdlog.Print("after close stdlib") + slog.Error("after close slog") + gin.DebugPrintFunc("ignored") + gin.DebugPrintRouteFunc("GET", "/", "handler", 1) + + content := restoredOutput.String() + for _, want := range []string{"after close logrus", "after close stdlib", "after close slog", "after close gin debug", "after close gin route"} { + if !strings.Contains(content, want) { + t.Fatalf("expected global loggers to be restored after close with %q, got %q", want, content) + } + } +} + +func TestConfigureErrorLeavesGlobalLoggerStateUnchanged(t *testing.T) { + reset := captureGlobalLogState(t) + defer reset() + + logrus.SetLevel(logrus.DebugLevel) + invalidLogDir := filepath.Join(t.TempDir(), "not-a-directory") + if err := os.WriteFile(invalidLogDir, []byte("file"), 0644); err != nil { + t.Fatalf("write invalid log dir fixture: %v", err) + } + + _, err := Configure(config.Config{ + LogLevel: "error", + LogFileEnabled: true, + LogDir: invalidLogDir, + LogRetentionDays: 7, + }) + if err == nil { + t.Fatal("expected Configure to return an error") + } + if level := logrus.GetLevel(); level != logrus.DebugLevel { + t.Fatalf("expected logrus level to remain debug after configure error, got %s", level) + } +} + +func TestRetentionDeletesOnlyOldAppLogs(t *testing.T) { + logDir := t.TempDir() + oldAppLog := filepath.Join(logDir, "cpa-usage-keeper-2020-01-01.log") + freshAppLog := filepath.Join(logDir, "cpa-usage-keeper-2099-01-01.log") + otherLog := filepath.Join(logDir, "other.log") + for _, path := range []string{oldAppLog, freshAppLog, otherLog} { + if err := os.WriteFile(path, []byte("log"), 0644); err != nil { + t.Fatalf("write fixture %s: %v", path, err) + } + } + + writer, err := newDailyFileWriter(logDir, 7, func() time.Time { + return time.Date(2026, 4, 28, 12, 0, 0, 0, time.Local) + }) + if err != nil { + t.Fatalf("newDailyFileWriter returned error: %v", err) + } + defer writer.Close() + + if _, err := os.Stat(oldAppLog); !os.IsNotExist(err) { + t.Fatalf("expected old app log to be removed, stat err=%v", err) + } + for _, path := range []string{freshAppLog, otherLog} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("expected %s to remain: %v", path, err) + } + } +} + +func readTodayLogFile(t *testing.T, logDir string) string { + t.Helper() + path := filepath.Join(logDir, "cpa-usage-keeper-"+time.Now().Format("2006-01-02")+".log") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read today log file: %v", err) + } + return string(content) +} + +func logLineHasTimestamp(content string) bool { + return regexp.MustCompile(`time="?\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}`).MatchString(content) +} + +func captureGlobalLogState(t *testing.T) func() { + t.Helper() + previousLogrusOutput := logrus.StandardLogger().Out + previousLogrusLevel := logrus.GetLevel() + previousLogrusFormatter := logrus.StandardLogger().Formatter + previousStdlogOutput := stdlog.Writer() + previousSlog := slog.Default() + previousGinDefaultWriter := gin.DefaultWriter + previousGinErrorWriter := gin.DefaultErrorWriter + previousGinDebugPrint := gin.DebugPrintFunc + previousGinDebugPrintRoute := gin.DebugPrintRouteFunc + var stderr bytes.Buffer + logrus.SetOutput(&stderr) + stdlog.SetOutput(&stderr) + return func() { + logrus.SetOutput(previousLogrusOutput) + logrus.SetLevel(previousLogrusLevel) + logrus.SetFormatter(previousLogrusFormatter) + stdlog.SetOutput(previousStdlogOutput) + slog.SetDefault(previousSlog) + gin.DefaultWriter = previousGinDefaultWriter + gin.DefaultErrorWriter = previousGinErrorWriter + gin.DebugPrintFunc = previousGinDebugPrint + gin.DebugPrintRouteFunc = previousGinDebugPrintRoute + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000000000000000000000000000000000000..eecfc7e1a303bdbe82daa144b7b64d5e4ee68d16 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,95 @@ +package models + +import "time" + +type UsageEvent struct { + ID uint `gorm:"primaryKey"` + EventKey string `gorm:"uniqueIndex:uniq_usage_events_event_key"` + APIGroupKey string `gorm:"index:idx_usage_events_api_group_key"` + Provider string `gorm:"column:provider"` + Endpoint string `gorm:"column:endpoint"` + AuthType string `gorm:"column:auth_type"` + RequestID string `gorm:"column:request_id"` + Model string `gorm:"index:idx_usage_events_model"` + Timestamp time.Time `gorm:"index:idx_usage_events_timestamp"` + Source string `gorm:"index:idx_usage_events_source"` + AuthIndex string `gorm:"index:idx_usage_events_auth_index"` + Failed bool `gorm:"index:idx_usage_events_failed"` + LatencyMS int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 + CreatedAt time.Time +} + +type RedisUsageInbox struct { + ID uint `gorm:"primaryKey"` + QueueKey string `gorm:"not null;index"` + MessageHash string `gorm:"not null;index"` + RawMessage string `gorm:"not null"` + Status string `gorm:"not null;index"` + AttemptCount int `gorm:"not null;default:0"` + LastError string + UsageEventKey string `gorm:"index"` + PoppedAt time.Time `gorm:"not null;index"` + ProcessedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +type ModelPriceSetting struct { + ID uint `gorm:"primaryKey"` + Model string `gorm:"uniqueIndex:uniq_model_price_settings_model"` + PromptPricePer1M float64 + CompletionPricePer1M float64 + CachePricePer1M float64 + CreatedAt time.Time + UpdatedAt time.Time +} + +type UsageIdentityAuthType int + +const ( + UsageIdentityAuthTypeAuthFile UsageIdentityAuthType = 1 + UsageIdentityAuthTypeAIProvider UsageIdentityAuthType = 2 +) + +type UsageIdentity struct { + ID uint `gorm:"primaryKey"` + Name string + AuthType UsageIdentityAuthType `gorm:"uniqueIndex:uniq_usage_identities_type_identity;index:idx_usage_identities_auth_type"` + AuthTypeName string `gorm:"index:idx_usage_identities_auth_type_name"` + Identity string `gorm:"uniqueIndex:uniq_usage_identities_type_identity;index:idx_usage_identities_identity"` + Type string `gorm:"column:type"` + Provider string + + TotalRequests int64 + SuccessCount int64 + FailureCount int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 + + LastAggregatedUsageEventID uint `gorm:"index:idx_usage_identities_last_aggregated_usage_event_id"` + FirstUsedAt *time.Time + LastUsedAt *time.Time + StatsUpdatedAt *time.Time + + IsDeleted bool `gorm:"index:idx_usage_identities_is_deleted"` + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt *time.Time `gorm:"index:idx_usage_identities_deleted_at"` +} + +func All() []any { + return []any{ + &UsageEvent{}, + &RedisUsageInbox{}, + &ModelPriceSetting{}, + &UsageIdentity{}, + } +} diff --git a/internal/models/models_test.go b/internal/models/models_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e411cf9b44a1262cf974d537424f48dadffe510a --- /dev/null +++ b/internal/models/models_test.go @@ -0,0 +1,16 @@ +package models + +import "testing" + +func TestAllIncludesCoreModels(t *testing.T) { + items := All() + if len(items) != 4 { + t.Fatalf("expected 4 models after removing legacy metadata tables, got %d", len(items)) + } + if _, ok := items[0].(*UsageEvent); !ok { + t.Fatalf("expected UsageEvent to be first registered model, got %T", items[0]) + } + if _, ok := items[1].(*RedisUsageInbox); !ok { + t.Fatalf("expected RedisUsageInbox to be registered, got %T", items[1]) + } +} diff --git a/internal/poller/poller.go b/internal/poller/poller.go new file mode 100644 index 0000000000000000000000000000000000000000..2c119b67a14d2ab451c2e0d0c8eee9c58be6ef48 --- /dev/null +++ b/internal/poller/poller.go @@ -0,0 +1,23 @@ +package poller + +import ( + "context" + "errors" + "time" +) + +var ErrSyncAlreadyRunning = errors.New("sync already running") +var ErrSyncCompletedWithWarnings = errors.New("sync completed with warnings") + +type Status struct { + Running bool + LastRunAt time.Time + LastError string + LastWarning string + LastStatus string + SyncRunning bool +} + +func shouldLogSyncError(err error) bool { + return err != nil && !errors.Is(err, ErrSyncCompletedWithWarnings) && !errors.Is(err, ErrSyncAlreadyRunning) && !errors.Is(err, context.Canceled) +} diff --git a/internal/poller/redis_drain.go b/internal/poller/redis_drain.go new file mode 100644 index 0000000000000000000000000000000000000000..a8243d7211e55c8c4c505d5c7939bd67cbf61c6f --- /dev/null +++ b/internal/poller/redis_drain.go @@ -0,0 +1,285 @@ +package poller + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "cpa-usage-keeper/internal/service" + "github.com/sirupsen/logrus" +) + +// Redis inbox 处理频率固定为 5 秒:拉取任务只负责把 Redis 原始消息落库,处理任务按这个间隔独立消费本地 inbox。 +const redisInboxProcessInterval = 5 * time.Second + +type RedisBatchSyncer interface { + PullRedisUsageInbox(ctx context.Context) (*service.RedisInboxPullResult, error) + ProcessRedisUsageInbox(ctx context.Context) (*service.RedisBatchSyncResult, error) +} + +type RedisDrainConfig struct { + IdleInterval time.Duration + ErrorBackoff time.Duration +} + +type RedisDrain struct { + syncer RedisBatchSyncer + config RedisDrainConfig + now func() time.Time + sleep func(context.Context, time.Duration) bool + + mu sync.Mutex + running bool + lastRunAt time.Time + lastError string + lastWarning string + lastStatus string + pullRunning bool + processRunning bool +} + +func NewRedisDrain(syncer RedisBatchSyncer, cfg RedisDrainConfig) *RedisDrain { + return &RedisDrain{ + syncer: syncer, + config: cfg, + now: time.Now, + sleep: sleepContext, + } +} + +// Run 启动 Redis 连续同步:一个 goroutine 只执行 Pull,另一个 goroutine 只执行 Process,二者互不串行等待。 +func (d *RedisDrain) Run(ctx context.Context) error { + if err := d.validate(); err != nil { + return err + } + d.setRunning(true) + defer d.setRunning(false) + + var wg sync.WaitGroup + wg.Add(2) + go func() { + defer wg.Done() + d.runPullLoop(ctx) + }() + go func() { + defer wg.Done() + d.runProcessLoop(ctx) + }() + <-ctx.Done() + wg.Wait() + return nil +} + +// runPullLoop 只从 CPA Redis 队列 LPOP 数据并写入 redis_usage_inboxes,不解码、不写 usage_events。 +func (d *RedisDrain) runPullLoop(ctx context.Context) { + logrus.WithField("idle_interval", d.config.IdleInterval.String()).Info("redis inbox pull task started") + for { + select { + case <-ctx.Done(): + return + default: + } + result, err := d.runRedisPull(ctx) + if err != nil { + if shouldLogSyncError(err) { + logrus.WithError(err).Error("redis drain pull failed") + } + if !d.sleep(ctx, d.config.ErrorBackoff) { + return + } + continue + } + if result != nil && result.Empty { + if !d.sleep(ctx, d.config.IdleInterval) { + return + } + } + } +} + +// runProcessLoop 固定每 5 秒处理已落库的 inbox 行,失败行保留为可重试状态,坏消息单独标记不阻塞后续行。 +func (d *RedisDrain) runProcessLoop(ctx context.Context) { + logrus.WithField("interval", redisInboxProcessInterval.String()).Info("redis inbox process task started") + for { + if !d.sleep(ctx, redisInboxProcessInterval) { + return + } + result, err := d.runRedisProcess(ctx) + if err != nil && !errors.Is(err, ErrSyncCompletedWithWarnings) { + if shouldLogSyncError(err) { + d.logBatchFailure(result, err) + } + continue + } + } +} + +func (d *RedisDrain) logBatchFailure(result *service.RedisBatchSyncResult, err error) { + fields := logrus.Fields{ + "status": "", + "empty": false, + "inserted_events": 0, + "deduped_events": 0, + } + if result != nil { + fields["status"] = result.Status + fields["empty"] = result.Empty + fields["inserted_events"] = result.InsertedEvents + fields["deduped_events"] = result.DedupedEvents + } + logrus.WithError(err).WithFields(fields).Error("redis drain batch failed") +} + +func (d *RedisDrain) Status() Status { + d.mu.Lock() + defer d.mu.Unlock() + return Status{ + Running: d.running, + LastRunAt: d.lastRunAt, + LastError: d.lastError, + LastWarning: d.lastWarning, + LastStatus: d.lastStatus, + SyncRunning: d.pullRunning || d.processRunning, + } +} + +// SyncNow 是手动同步入口:Redis 模式下先 Pull 一次再 Process 一次,保持用户手动触发时立即看到新数据的直觉。 +func (d *RedisDrain) SyncNow(ctx context.Context) error { + if err := d.validate(); err != nil { + return err + } + if _, err := d.runRedisPull(ctx); err != nil { + return err + } + _, err := d.runRedisProcess(ctx) + return err +} + +// runRedisPull 只防止 Pull 自身重入,不阻塞 Process;这样 Redis 长轮询或退避不会跳过本地 inbox 处理周期。 +func (d *RedisDrain) runRedisPull(ctx context.Context) (*service.RedisInboxPullResult, error) { + d.mu.Lock() + if d.pullRunning { + d.mu.Unlock() + return nil, ErrSyncAlreadyRunning + } + d.pullRunning = true + d.mu.Unlock() + + defer func() { + d.mu.Lock() + d.pullRunning = false + d.mu.Unlock() + }() + + result, err := d.syncer.PullRedisUsageInbox(ctx) + d.recordPullResult(result, err) + return result, err +} + +// runRedisProcess 只防止 Process 自身重入,不阻塞 Pull;Process 的输入必须来自已持久化的 redis_usage_inboxes。 +func (d *RedisDrain) runRedisProcess(ctx context.Context) (*service.RedisBatchSyncResult, error) { + d.mu.Lock() + if d.processRunning { + d.mu.Unlock() + return nil, ErrSyncAlreadyRunning + } + d.processRunning = true + d.mu.Unlock() + + defer func() { + d.mu.Lock() + d.processRunning = false + d.mu.Unlock() + }() + + result, err := d.syncer.ProcessRedisUsageInbox(ctx) + returnErr := err + if err != nil && result != nil && result.Status != "" && result.Status != "failed" { + returnErr = fmt.Errorf("%w: %v", ErrSyncCompletedWithWarnings, err) + } + d.recordResult(result, err) + return result, returnErr +} + +func (d *RedisDrain) recordPullResult(result *service.RedisInboxPullResult, err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.lastRunAt = d.now().UTC() + status := "" + if result != nil { + status = result.Status + } + if status == "" && err == nil { + status = "completed" + } + d.lastStatus = status + d.lastError = "" + d.lastWarning = "" + if err != nil { + d.lastError = err.Error() + } +} + +func (d *RedisDrain) recordResult(result *service.RedisBatchSyncResult, err error) { + d.mu.Lock() + defer d.mu.Unlock() + d.lastRunAt = d.now().UTC() + status := "" + if result != nil { + status = result.Status + } + if status == "" && err == nil { + status = "completed" + } + d.lastStatus = status + d.lastError = "" + d.lastWarning = "" + if err != nil { + if result != nil && result.Status != "" && result.Status != "failed" { + d.lastWarning = err.Error() + } else { + d.lastError = err.Error() + } + } +} + +func (d *RedisDrain) setRunning(running bool) { + d.mu.Lock() + defer d.mu.Unlock() + d.running = running +} + +func (d *RedisDrain) validate() error { + if d == nil { + return fmt.Errorf("redis drain is nil") + } + if d.syncer == nil { + return fmt.Errorf("redis drain syncer is nil") + } + if d.config.IdleInterval <= 0 { + return fmt.Errorf("redis drain idle interval must be greater than zero") + } + if d.config.ErrorBackoff <= 0 { + return fmt.Errorf("redis drain error backoff must be greater than zero") + } + if d.now == nil { + d.now = time.Now + } + if d.sleep == nil { + d.sleep = sleepContext + } + return nil +} + +func sleepContext(ctx context.Context, d time.Duration) bool { + timer := time.NewTimer(d) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} diff --git a/internal/poller/redis_drain_test.go b/internal/poller/redis_drain_test.go new file mode 100644 index 0000000000000000000000000000000000000000..1f63f6ec8a72de17c905c15900d528569e9b7430 --- /dev/null +++ b/internal/poller/redis_drain_test.go @@ -0,0 +1,236 @@ +package poller + +import ( + "bytes" + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "cpa-usage-keeper/internal/service" + "github.com/sirupsen/logrus" +) + +type redisDrainSyncStub struct { + mu sync.Mutex + pullResults []*service.RedisInboxPullResult + pullErrs []error + processResults []*service.RedisBatchSyncResult + processErrs []error + pullStarted chan struct{} + releasePull chan struct{} + pullCalls int + processCalls int +} + +func (s *redisDrainSyncStub) PullRedisUsageInbox(context.Context) (*service.RedisInboxPullResult, error) { + s.mu.Lock() + s.pullCalls++ + call := s.pullCalls + result := &service.RedisInboxPullResult{Status: "completed", InsertedRows: 1} + if len(s.pullResults) >= call { + result = s.pullResults[call-1] + } else if len(s.pullResults) > 0 { + result = s.pullResults[len(s.pullResults)-1] + } + var err error + if len(s.pullErrs) >= call { + err = s.pullErrs[call-1] + } else if len(s.pullErrs) > 0 { + err = s.pullErrs[len(s.pullErrs)-1] + } + pullStarted := s.pullStarted + releasePull := s.releasePull + s.mu.Unlock() + if pullStarted != nil { + close(pullStarted) + } + if releasePull != nil { + <-releasePull + } + return result, err +} + +func (s *redisDrainSyncStub) ProcessRedisUsageInbox(ctx context.Context) (*service.RedisBatchSyncResult, error) { + s.mu.Lock() + s.processCalls++ + call := s.processCalls + result := &service.RedisBatchSyncResult{Status: "completed", InsertedEvents: 1} + if len(s.processResults) >= call { + result = s.processResults[call-1] + } else if len(s.processResults) > 0 { + result = s.processResults[len(s.processResults)-1] + } + var err error + if len(s.processErrs) >= call { + err = s.processErrs[call-1] + } else if len(s.processErrs) > 0 { + err = s.processErrs[len(s.processErrs)-1] + } + s.mu.Unlock() + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + return result, err +} + +func (s *redisDrainSyncStub) counts() (int, int) { + s.mu.Lock() + defer s.mu.Unlock() + return s.pullCalls, s.processCalls +} + +func captureRedisDrainLogrusOutput(t *testing.T) *bytes.Buffer { + t.Helper() + previousOutput := logrus.StandardLogger().Out + previousFormatter := logrus.StandardLogger().Formatter + previousLevel := logrus.GetLevel() + var logs bytes.Buffer + logrus.SetOutput(&logs) + logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true}) + logrus.SetLevel(logrus.InfoLevel) + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + logrus.SetFormatter(previousFormatter) + logrus.SetLevel(previousLevel) + }) + return &logs +} + +func TestRedisDrainLoopsLogTaskStarts(t *testing.T) { + logs := captureRedisDrainLogrusOutput(t) + syncer := &redisDrainSyncStub{pullResults: []*service.RedisInboxPullResult{{Empty: true, Status: "empty"}}} + drain := NewRedisDrain(syncer, RedisDrainConfig{IdleInterval: time.Hour, ErrorBackoff: time.Hour}) + + pullCtx, cancelPull := context.WithCancel(context.Background()) + drain.sleep = func(context.Context, time.Duration) bool { + cancelPull() + return false + } + drain.runPullLoop(pullCtx) + + processCtx, cancelProcess := context.WithCancel(context.Background()) + drain.sleep = func(context.Context, time.Duration) bool { + cancelProcess() + return false + } + drain.runProcessLoop(processCtx) + + content := logs.String() + for _, expected := range []string{"msg=\"redis inbox pull task started\"", "msg=\"redis inbox process task started\""} { + if !strings.Contains(content, expected) { + t.Fatalf("expected redis drain start log %q, got %q", expected, content) + } + } +} + +func TestRedisDrainPullLoopDoesNotProcessInbox(t *testing.T) { + syncer := &redisDrainSyncStub{pullResults: []*service.RedisInboxPullResult{{Empty: true, Status: "empty"}}} + drain := NewRedisDrain(syncer, RedisDrainConfig{IdleInterval: time.Hour, ErrorBackoff: time.Hour}) + ctx, cancel := context.WithCancel(context.Background()) + drain.sleep = func(context.Context, time.Duration) bool { + cancel() + return false + } + + drain.runPullLoop(ctx) + + pulls, processes := syncer.counts() + if pulls != 1 || processes != 0 { + t.Fatalf("expected pull loop to pull once and not process inbox, got pulls=%d processes=%d", pulls, processes) + } +} + +func TestRedisDrainProcessLoopUsesFixedInterval(t *testing.T) { + syncer := &redisDrainSyncStub{} + drain := NewRedisDrain(syncer, RedisDrainConfig{IdleInterval: time.Hour, ErrorBackoff: time.Hour}) + ctx, cancel := context.WithCancel(context.Background()) + calls := 0 + drain.sleep = func(_ context.Context, d time.Duration) bool { + calls++ + if calls == 1 { + if d != redisInboxProcessInterval { + t.Fatalf("expected process interval %s, got %s", redisInboxProcessInterval, d) + } + return true + } + cancel() + return false + } + + drain.runProcessLoop(ctx) + + _, processes := syncer.counts() + if processes != 1 { + t.Fatalf("expected process loop to process once, got %d", processes) + } + if calls == 0 { + t.Fatal("expected process loop to sleep before processing") + } +} + +func TestRedisDrainSyncNowPullsThenProcesses(t *testing.T) { + syncer := &redisDrainSyncStub{} + drain := NewRedisDrain(syncer, RedisDrainConfig{IdleInterval: time.Hour, ErrorBackoff: time.Hour}) + + if err := drain.SyncNow(context.Background()); err != nil { + t.Fatalf("SyncNow returned error: %v", err) + } + + pulls, processes := syncer.counts() + if pulls != 1 || processes != 1 { + t.Fatalf("expected SyncNow to pull and process once, got pulls=%d processes=%d", pulls, processes) + } +} + +func TestRedisDrainPullAndProcessCanRunIndependently(t *testing.T) { + syncer := &redisDrainSyncStub{pullStarted: make(chan struct{}), releasePull: make(chan struct{})} + drain := NewRedisDrain(syncer, RedisDrainConfig{IdleInterval: time.Hour, ErrorBackoff: time.Hour}) + ctx := context.Background() + pullDone := make(chan error, 1) + go func() { + _, err := drain.runRedisPull(ctx) + pullDone <- err + }() + <-syncer.pullStarted + + if _, err := drain.runRedisProcess(ctx); err != nil { + close(syncer.releasePull) + t.Fatalf("expected process to run while pull is active, got %v", err) + } + close(syncer.releasePull) + if err := <-pullDone; err != nil { + t.Fatalf("pull returned error: %v", err) + } + + pulls, processes := syncer.counts() + if pulls != 1 || processes != 1 { + t.Fatalf("expected pull and process to each run once, got pulls=%d processes=%d", pulls, processes) + } +} + +func TestRedisDrainBacksOffAfterPullError(t *testing.T) { + syncer := &redisDrainSyncStub{pullErrs: []error{errors.New("dial failed")}} + drain := NewRedisDrain(syncer, RedisDrainConfig{IdleInterval: time.Hour, ErrorBackoff: 25 * time.Millisecond}) + ctx, cancel := context.WithCancel(context.Background()) + var slept time.Duration + drain.sleep = func(_ context.Context, d time.Duration) bool { + slept = d + cancel() + return false + } + + drain.runPullLoop(ctx) + + if slept != 25*time.Millisecond { + t.Fatalf("expected error backoff sleep, got %s", slept) + } + status := drain.Status() + if status.LastError != "dial failed" { + t.Fatalf("expected recorded pull error, got %+v", status) + } +} diff --git a/internal/redact/redact.go b/internal/redact/redact.go new file mode 100644 index 0000000000000000000000000000000000000000..11d4740bdddad9ba74a8aecf9aef336471c482b2 --- /dev/null +++ b/internal/redact/redact.go @@ -0,0 +1,115 @@ +package redact + +import ( + "crypto/sha256" + "encoding/hex" + "strings" + "unicode/utf8" + + "cpa-usage-keeper/internal/cpa" +) + +const apiAliasPrefix = "redacted_api_" + +func UsageSnapshot(snapshot *cpa.StatisticsSnapshot) *cpa.StatisticsSnapshot { + if snapshot == nil { + return nil + } + + redacted := &cpa.StatisticsSnapshot{ + TotalRequests: snapshot.TotalRequests, + SuccessCount: snapshot.SuccessCount, + FailureCount: snapshot.FailureCount, + TotalTokens: snapshot.TotalTokens, + APIs: make(map[string]cpa.APISnapshot, len(snapshot.APIs)), + RequestsByDay: cloneIntMap(snapshot.RequestsByDay), + RequestsByHour: cloneIntMap(snapshot.RequestsByHour), + TokensByDay: cloneIntMap(snapshot.TokensByDay), + TokensByHour: cloneIntMap(snapshot.TokensByHour), + } + + for apiKey, apiSnapshot := range snapshot.APIs { + alias := APIAlias(apiKey) + cloned := cloneAPISnapshot(apiSnapshot) + cloned.DisplayName = APIKeyDisplayName(apiKey) + redacted.APIs[alias] = cloned + } + + return redacted +} + +func APIAlias(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "unknown" + } + if trimmed == "unknown" || strings.HasPrefix(trimmed, apiAliasPrefix) { + return trimmed + } + + sum := sha256.Sum256([]byte(trimmed)) + return apiAliasPrefix + hex.EncodeToString(sum[:])[:12] +} + +func APIKeyDisplayName(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" || trimmed == "unknown" { + return "unknown" + } + + runeCount := utf8.RuneCountInString(trimmed) + if runeCount <= 4 { + return strings.Repeat("*", runeCount) + } + if runeCount <= 8 { + prefix := []rune(trimmed)[:1] + suffix := []rune(trimmed)[runeCount-1:] + return string(prefix) + strings.Repeat("*", runeCount-2) + string(suffix) + } + + runes := []rune(trimmed) + prefix := string(runes[:4]) + suffix := string(runes[runeCount-4:]) + return prefix + strings.Repeat("*", runeCount-8) + suffix +} + +func cloneIntMap(src map[string]int64) map[string]int64 { + if len(src) == 0 { + return map[string]int64{} + } + + cloned := make(map[string]int64, len(src)) + for key, value := range src { + cloned[key] = value + } + return cloned +} + +func cloneAPISnapshot(src cpa.APISnapshot) cpa.APISnapshot { + cloned := cpa.APISnapshot{ + DisplayName: src.DisplayName, + TotalRequests: src.TotalRequests, + SuccessCount: src.SuccessCount, + FailureCount: src.FailureCount, + TotalTokens: src.TotalTokens, + Models: make(map[string]cpa.ModelSnapshot, len(src.Models)), + } + + for modelName, modelSnapshot := range src.Models { + cloned.Models[modelName] = cloneModelSnapshot(modelSnapshot) + } + + return cloned +} + +func cloneModelSnapshot(src cpa.ModelSnapshot) cpa.ModelSnapshot { + cloned := cpa.ModelSnapshot{ + TotalRequests: src.TotalRequests, + SuccessCount: src.SuccessCount, + FailureCount: src.FailureCount, + TotalTokens: src.TotalTokens, + Details: make([]cpa.RequestDetail, len(src.Details)), + } + copy(cloned.Details, src.Details) + return cloned +} diff --git a/internal/redact/redact_test.go b/internal/redact/redact_test.go new file mode 100644 index 0000000000000000000000000000000000000000..bc1b49907fe8a09c12d85e13ef25759f8814b6c0 --- /dev/null +++ b/internal/redact/redact_test.go @@ -0,0 +1,89 @@ +package redact + +import ( + "testing" + "time" + + "cpa-usage-keeper/internal/cpa" +) + +func TestAPIAliasIsStableAndIdempotent(t *testing.T) { + raw := "sk-live-secret-value" + first := APIAlias(raw) + second := APIAlias(raw) + + if first != second { + t.Fatalf("expected stable alias, got %q and %q", first, second) + } + if first == raw { + t.Fatalf("expected raw key to be redacted") + } + if again := APIAlias(first); again != first { + t.Fatalf("expected redacted alias to remain unchanged, got %q", again) + } +} + +func TestAPIKeyDisplayNameMasksMiddle(t *testing.T) { + if got := APIKeyDisplayName("provider-a"); got != "prov**er-a" { + t.Fatalf("expected masked display name, got %q", got) + } + if got := APIKeyDisplayName("abcd"); got != "****" { + t.Fatalf("expected short key to be fully masked, got %q", got) + } +} + +func TestUsageSnapshotRedactsOnlyAPIKeys(t *testing.T) { + snapshot := &cpa.StatisticsSnapshot{ + TotalRequests: 1, + SuccessCount: 1, + TotalTokens: 42, + APIs: map[string]cpa.APISnapshot{ + "sk-live-secret-value": { + TotalRequests: 1, + SuccessCount: 1, + TotalTokens: 42, + Models: map[string]cpa.ModelSnapshot{ + "claude-sonnet": { + TotalRequests: 1, + SuccessCount: 1, + TotalTokens: 42, + Details: []cpa.RequestDetail{{ + Timestamp: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), + LatencyMS: 150, + Source: "source-a", + AuthIndex: "3", + Failed: false, + Tokens: cpa.TokenStats{TotalTokens: 42}, + }}, + }, + }, + }, + }, + RequestsByDay: map[string]int64{"2026-04-20": 1}, + RequestsByHour: map[string]int64{"2026-04-20T12:00:00Z": 1}, + TokensByDay: map[string]int64{"2026-04-20": 42}, + TokensByHour: map[string]int64{"2026-04-20T12:00:00Z": 42}, + } + + redacted := UsageSnapshot(snapshot) + alias := APIAlias("sk-live-secret-value") + apiSnapshot, ok := redacted.APIs[alias] + if !ok { + t.Fatalf("expected redacted API alias %q in snapshot", alias) + } + if _, ok := redacted.APIs["sk-live-secret-value"]; ok { + t.Fatalf("expected raw API key to be absent from response snapshot") + } + if apiSnapshot.DisplayName != "sk-l************alue" { + t.Fatalf("expected masked display name, got %q", apiSnapshot.DisplayName) + } + if got := apiSnapshot.Models["claude-sonnet"].Details[0].Source; got != "source-a" { + t.Fatalf("expected source to remain unchanged, got %q", got) + } + if got := apiSnapshot.Models["claude-sonnet"].Details[0].AuthIndex; got != "3" { + t.Fatalf("expected auth index to remain unchanged, got %q", got) + } + if _, ok := snapshot.APIs["sk-live-secret-value"]; !ok { + t.Fatalf("expected source snapshot to remain unchanged") + } +} diff --git a/internal/repository/db.go b/internal/repository/db.go new file mode 100644 index 0000000000000000000000000000000000000000..fe65466c5b5d4a707ac182025ba0525a94cef3fe --- /dev/null +++ b/internal/repository/db.go @@ -0,0 +1,108 @@ +package repository + +import ( + "fmt" + "path/filepath" + "strings" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +type StorageCleanupResult struct { + RedisInbox RedisUsageInboxCleanupResult +} + +func OpenDatabase(cfg config.Config) (*gorm.DB, error) { + dsn := sqliteDSN(cfg.SQLitePath) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + if err != nil { + return nil, fmt.Errorf("open sqlite database %s: %w", filepath.Clean(cfg.SQLitePath), err) + } + + sqlDB, err := db.DB() + if err != nil { + return nil, fmt.Errorf("configure sqlite database: %w", err) + } + sqlDB.SetMaxOpenConns(1) + sqlDB.SetMaxIdleConns(1) + + if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil { + return nil, fmt.Errorf("enable sqlite WAL: %w", err) + } + if err := db.Exec("PRAGMA busy_timeout=5000").Error; err != nil { + return nil, fmt.Errorf("set sqlite busy timeout: %w", err) + } + if err := db.Exec("PRAGMA foreign_keys=ON").Error; err != nil { + return nil, fmt.Errorf("enable sqlite foreign keys: %w", err) + } + + if err := runSchemaMigrations(db); err != nil { + return nil, fmt.Errorf("run schema migrations: %w", err) + } + if err := db.AutoMigrate(models.All()...); err != nil { + return nil, fmt.Errorf("auto migrate database: %w", err) + } + + return db, nil +} + +func sqliteDSN(path string) string { + trimmed := strings.TrimSpace(path) + if strings.Contains(trimmed, "?") { + return trimmed + } + return trimmed + "?_busy_timeout=5000&_foreign_keys=on" +} + +func InsertUsageEvents(db *gorm.DB, events []models.UsageEvent) (int, int, error) { + if db == nil { + return 0, 0, fmt.Errorf("database is nil") + } + if len(events) == 0 { + return 0, 0, nil + } + + const batchSize = 100 + inserted := 0 + + for start := 0; start < len(events); start += batchSize { + end := min(start+batchSize, len(events)) + batch := events[start:end] + result := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "event_key"}}, + DoNothing: true, + }).Create(&batch) + if result.Error != nil { + return 0, 0, fmt.Errorf("insert usage events: %w", result.Error) + } + inserted += int(result.RowsAffected) + } + + deduped := len(events) - inserted + return inserted, deduped, nil +} + +// CleanupStorage 是每日维护任务的统一仓储清理入口:先清 Redis inbox,最后执行 VACUUM。 +// VACUUM 必须在删除完成后单独执行,任何一步失败都会停止后续步骤并把已完成部分的结果返回给上层日志。 +func CleanupStorage(db *gorm.DB, now time.Time) (StorageCleanupResult, error) { + redisResult, err := CleanupRedisUsageInbox(db, now) + if err != nil { + return StorageCleanupResult{RedisInbox: redisResult}, err + } + if err := db.Exec("VACUUM").Error; err != nil { + return StorageCleanupResult{RedisInbox: redisResult}, err + } + return StorageCleanupResult{RedisInbox: redisResult}, nil +} + +func Vacuum(db *gorm.DB) error { + if db == nil { + return fmt.Errorf("database is nil") + } + return db.Exec("VACUUM").Error +} diff --git a/internal/repository/db_test.go b/internal/repository/db_test.go new file mode 100644 index 0000000000000000000000000000000000000000..28d5550cfbd08ce5f1019fbeeec2b5e5a6168d28 --- /dev/null +++ b/internal/repository/db_test.go @@ -0,0 +1,194 @@ +package repository + +import ( + "fmt" + "path/filepath" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" + "gorm.io/gorm" +) + +func TestOpenDatabaseAutoMigratesCoreTables(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "app.db") + cfg := config.Config{ + SQLitePath: dbPath, + } + + db, err := OpenDatabase(cfg) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + if db.Migrator().HasTable("snapshot_runs") { + t.Fatal("expected legacy snapshot_runs table not to exist") + } + if !db.Migrator().HasTable("usage_events") { + t.Fatal("expected usage_events table to exist") + } + if !db.Migrator().HasTable("redis_usage_inboxes") { + t.Fatal("expected redis_usage_inboxes table to exist") + } +} + +func TestOpenDatabaseConfiguresSQLiteRuntime(t *testing.T) { + db := openTestDatabase(t) + + var journalMode string + if err := db.Raw("PRAGMA journal_mode").Scan(&journalMode).Error; err != nil { + t.Fatalf("read journal mode: %v", err) + } + if journalMode != "wal" { + t.Fatalf("expected WAL journal mode, got %q", journalMode) + } + + var busyTimeout int + if err := db.Raw("PRAGMA busy_timeout").Scan(&busyTimeout).Error; err != nil { + t.Fatalf("read busy timeout: %v", err) + } + if busyTimeout < 5000 { + t.Fatalf("expected busy timeout at least 5000ms, got %d", busyTimeout) + } + + var foreignKeys int + if err := db.Raw("PRAGMA foreign_keys").Scan(&foreignKeys).Error; err != nil { + t.Fatalf("read foreign keys pragma: %v", err) + } + if foreignKeys != 1 { + t.Fatalf("expected foreign keys to be enabled, got %d", foreignKeys) + } + + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("load sql db: %v", err) + } + if stats := sqlDB.Stats(); stats.MaxOpenConnections != 1 { + t.Fatalf("expected sqlite max open connections to be 1, got %+v", stats) + } +} + +func TestInsertUsageEventsDeduplicatesByEventKey(t *testing.T) { + db := openTestDatabase(t) + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), TotalTokens: 10}, + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-opus", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), TotalTokens: 20}, + } + + inserted, deduped, err := InsertUsageEvents(db, events) + if err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + if inserted != 2 || deduped != 1 { + t.Fatalf("expected inserted=2 deduped=1, got inserted=%d deduped=%d", inserted, deduped) + } + + var count int64 + if err := db.Model(&models.UsageEvent{}).Count(&count).Error; err != nil { + t.Fatalf("count usage events: %v", err) + } + if count != 2 { + t.Fatalf("expected 2 persisted usage events, got %d", count) + } +} + +func TestInsertUsageEventsBatchesLargeInsertSet(t *testing.T) { + db := openTestDatabase(t) + events := make([]models.UsageEvent, 0, 300) + baseTime := time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC) + for i := 0; i < 300; i++ { + events = append(events, models.UsageEvent{ + EventKey: fmt.Sprintf("event-%03d", i), + APIGroupKey: "provider-a", + Model: "claude-sonnet", + Timestamp: baseTime.Add(time.Duration(i) * time.Minute), + Source: "source-a", + AuthIndex: "auth-1", + TotalTokens: int64(i + 1), + }) + } + + inserted, deduped, err := InsertUsageEvents(db, events) + if err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + if inserted != len(events) || deduped != 0 { + t.Fatalf("expected inserted=%d deduped=0, got inserted=%d deduped=%d", len(events), inserted, deduped) + } + + var count int64 + if err := db.Model(&models.UsageEvent{}).Count(&count).Error; err != nil { + t.Fatalf("count usage events: %v", err) + } + if count != int64(len(events)) { + t.Fatalf("expected %d persisted usage events, got %d", len(events), count) + } +} + +func TestCleanupStorageCleansRedisInboxAndVacuums(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + time.Local = location + t.Cleanup(func() { time.Local = previousLocal }) + db := openTestDatabase(t) + now := time.Date(2026, 4, 27, 2, 30, 0, 0, time.UTC) + + inboxRows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{ + {QueueKey: "queue", RawMessage: `{"request_id":"processed-old"}`, PoppedAt: now.AddDate(0, 0, -2)}, + {QueueKey: "queue", RawMessage: `{"request_id":"pending"}`, PoppedAt: now.AddDate(0, 0, -2)}, + }) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + if err := db.Model(&models.RedisUsageInbox{}).Where("id = ?", inboxRows[0].ID).Updates(map[string]any{"status": RedisUsageInboxStatusProcessed, "processed_at": time.Date(2026, 4, 26, 15, 59, 59, 0, time.UTC)}).Error; err != nil { + t.Fatalf("seed processed inbox row: %v", err) + } + + result, err := CleanupStorage(db, now) + if err != nil { + t.Fatalf("CleanupStorage returned error: %v", err) + } + if result.RedisInbox.ProcessedDeleted != 1 { + t.Fatalf("unexpected cleanup result: %+v", result) + } + + var inboxRemaining []models.RedisUsageInbox + if err := db.Order("id asc").Find(&inboxRemaining).Error; err != nil { + t.Fatalf("load remaining inbox rows: %v", err) + } + if len(inboxRemaining) != 1 || inboxRemaining[0].ID != inboxRows[1].ID { + t.Fatalf("expected only pending inbox row to remain, got %+v", inboxRemaining) + } +} + +func openTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + + dbPath := filepath.Join(t.TempDir(), "app.db") + db, err := OpenDatabase(config.Config{SQLitePath: dbPath}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + return db +} + +func closeTestDatabase(t *testing.T, db *gorm.DB) { + t.Helper() + + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sql database: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Fatalf("close database: %v", err) + } + }) +} diff --git a/internal/repository/migrations.go b/internal/repository/migrations.go new file mode 100644 index 0000000000000000000000000000000000000000..57831b285347cea226ed0597251e8e70e7178d53 --- /dev/null +++ b/internal/repository/migrations.go @@ -0,0 +1,502 @@ +package repository + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "cpa-usage-keeper/internal/models" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +const ( + migrationAddUsageEventRedisFields = "20260503_add_usage_event_redis_fields" + migrationBackfillUsageEventRedisFields = "20260503_backfill_usage_event_redis_fields" + migrationDropSnapshotRuns = "20260503_drop_snapshot_runs" + migrationDropLegacySnapshotRunColumns = "20260504_drop_legacy_snapshot_run_columns" + migrationCreateUsageIdentities = "20260504_create_usage_identities" + migrationMigrateUsageIdentitiesMetadata = "20260504_migrate_usage_identities_metadata" + migrationBackfillUsageEventIdentityFields = "20260504_backfill_usage_event_identity_fields" + migrationBackfillUsageIdentityStats = "20260504_backfill_usage_identity_stats" + migrationDropLegacyMetadataTables = "20260504_drop_legacy_metadata_tables" + migrationRemovePrefixUsageIdentities = "20260504_remove_prefix_usage_identities" +) + +type schemaMigration struct { + Version string `gorm:"primaryKey;column:version"` + AppliedAt time.Time `gorm:"not null;column:applied_at"` +} + +func (schemaMigration) TableName() string { + return "schema_migrations" +} + +type databaseMigration struct { + version string + run func(*gorm.DB) error +} + +func runSchemaMigrations(db *gorm.DB) error { + if err := db.Exec("CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)").Error; err != nil { + return fmt.Errorf("create schema_migrations table: %w", err) + } + + migrations := []databaseMigration{ + {version: migrationAddUsageEventRedisFields, run: addUsageEventRedisFieldsMigration}, + {version: migrationBackfillUsageEventRedisFields, run: backfillUsageEventRedisFieldsMigration}, + {version: migrationDropSnapshotRuns, run: dropSnapshotRunsMigration}, + {version: migrationDropLegacySnapshotRunColumns, run: dropLegacySnapshotRunColumnsMigration}, + {version: migrationCreateUsageIdentities, run: createUsageIdentitiesMigration}, + {version: migrationMigrateUsageIdentitiesMetadata, run: migrateUsageIdentitiesMetadataMigration}, + {version: migrationBackfillUsageEventIdentityFields, run: backfillUsageEventIdentityFieldsMigration}, + {version: migrationBackfillUsageIdentityStats, run: backfillUsageIdentityStatsMigration}, + {version: migrationDropLegacyMetadataTables, run: dropLegacyMetadataTablesMigration}, + {version: migrationRemovePrefixUsageIdentities, run: removePrefixUsageIdentitiesMigration}, + } + for _, migration := range migrations { + if err := runSchemaMigration(db, migration); err != nil { + return err + } + } + return nil +} + +func runSchemaMigration(db *gorm.DB, migration databaseMigration) error { + return db.Transaction(func(tx *gorm.DB) error { + logger := logrus.WithField("version", migration.version) + var count int64 + if err := tx.Table("schema_migrations").Where("version = ?", migration.version).Count(&count).Error; err != nil { + logger.WithError(err).Error("schema migration failed") + return fmt.Errorf("check schema migration %s: %w", migration.version, err) + } + if count > 0 { + logger.Info("schema migration skipped") + return nil + } + logger.Info("schema migration started") + if err := migration.run(tx); err != nil { + logger.WithError(err).Error("schema migration failed") + return fmt.Errorf("run schema migration %s: %w", migration.version, err) + } + if err := tx.Create(&schemaMigration{Version: migration.version, AppliedAt: time.Now().UTC()}).Error; err != nil { + logger.WithError(err).Error("schema migration failed") + return fmt.Errorf("record schema migration %s: %w", migration.version, err) + } + logger.Info("schema migration applied") + return nil + }) +} + +func addUsageEventRedisFieldsMigration(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&models.UsageEvent{}) { + return nil + } + columns := []struct { + name string + sql string + }{ + {name: "provider", sql: "ALTER TABLE usage_events ADD COLUMN provider TEXT"}, + {name: "endpoint", sql: "ALTER TABLE usage_events ADD COLUMN endpoint TEXT"}, + {name: "auth_type", sql: "ALTER TABLE usage_events ADD COLUMN auth_type TEXT"}, + {name: "request_id", sql: "ALTER TABLE usage_events ADD COLUMN request_id TEXT"}, + } + for _, column := range columns { + if tx.Migrator().HasColumn(&models.UsageEvent{}, column.name) { + continue + } + if err := tx.Exec(column.sql).Error; err != nil { + return fmt.Errorf("add usage_events.%s column: %w", column.name, err) + } + } + return nil +} + +type redisUsageBackfillPayload struct { + Provider string `json:"provider"` + Endpoint string `json:"endpoint"` + AuthType string `json:"auth_type"` + RequestID string `json:"request_id"` +} + +func backfillUsageEventRedisFieldsMigration(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&models.UsageEvent{}) || !tx.Migrator().HasTable(&models.RedisUsageInbox{}) { + return nil + } + for _, column := range []string{"provider", "endpoint", "auth_type", "request_id"} { + if !tx.Migrator().HasColumn(&models.UsageEvent{}, column) { + return nil + } + } + + var inboxRows []models.RedisUsageInbox + return tx.Where("status = ?", RedisUsageInboxStatusProcessed). + Order("id asc"). + FindInBatches(&inboxRows, 500, func(_ *gorm.DB, _ int) error { + for _, inbox := range inboxRows { + var payload redisUsageBackfillPayload + if err := json.Unmarshal([]byte(inbox.RawMessage), &payload); err != nil { + continue + } + payload.Provider = strings.TrimSpace(payload.Provider) + payload.Endpoint = strings.TrimSpace(payload.Endpoint) + payload.AuthType = normalizeUsageEventRedisAuthType(payload.AuthType) + payload.RequestID = strings.TrimSpace(payload.RequestID) + if payload.Provider == "" && payload.Endpoint == "" && payload.AuthType == "" && payload.RequestID == "" { + continue + } + if err := backfillUsageEventRedisFields(tx, strings.TrimSpace(inbox.UsageEventKey), payload, true); err != nil { + return err + } + } + return nil + }).Error +} + +func backfillUsageEventRedisFields(tx *gorm.DB, usageEventKey string, payload redisUsageBackfillPayload, allowRequestIDFallback bool) error { + if usageEventKey == "" { + if allowRequestIDFallback && payload.RequestID != "" { + return backfillUsageEventRedisFields(tx, payload.RequestID, payload, false) + } + return nil + } + + var event models.UsageEvent + result := tx.Where("event_key = ?", usageEventKey).Limit(1).Find(&event) + if result.Error != nil { + return fmt.Errorf("load usage event %q for redis backfill: %w", usageEventKey, result.Error) + } + if result.RowsAffected == 0 { + if allowRequestIDFallback && payload.RequestID != "" && payload.RequestID != usageEventKey { + return backfillUsageEventRedisFields(tx, payload.RequestID, payload, false) + } + return nil + } + + updates := map[string]any{} + if strings.TrimSpace(event.Provider) == "" && payload.Provider != "" { + updates["provider"] = payload.Provider + } + if strings.TrimSpace(event.Endpoint) == "" && payload.Endpoint != "" { + updates["endpoint"] = payload.Endpoint + } + if strings.TrimSpace(event.AuthType) == "" && payload.AuthType != "" { + updates["auth_type"] = payload.AuthType + } + if strings.TrimSpace(event.RequestID) == "" && payload.RequestID != "" { + updates["request_id"] = payload.RequestID + } + if len(updates) == 0 { + return nil + } + if err := tx.Model(&models.UsageEvent{}).Where("id = ?", event.ID).Updates(updates).Error; err != nil { + return fmt.Errorf("backfill usage event %q redis fields: %w", event.EventKey, err) + } + return nil +} + +func normalizeUsageEventRedisAuthType(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "api_key" { + return "apikey" + } + return trimmed +} + +func dropSnapshotRunsMigration(tx *gorm.DB) error { + if !tx.Migrator().HasTable("snapshot_runs") { + return nil + } + if err := tx.Exec("DROP TABLE IF EXISTS snapshot_runs").Error; err != nil { + return fmt.Errorf("drop snapshot_runs table: %w", err) + } + return nil +} + +func dropLegacySnapshotRunColumnsMigration(tx *gorm.DB) error { + for _, indexName := range []string{"idx_usage_events_snapshot_run_id", "idx_redis_usage_inboxes_snapshot_run_id"} { + if err := tx.Exec("DROP INDEX IF EXISTS " + indexName).Error; err != nil { + return fmt.Errorf("drop legacy snapshot_run_id index %s: %w", indexName, err) + } + } + if err := dropColumnIfExists(tx, &models.UsageEvent{}, "snapshot_run_id", "usage_events"); err != nil { + return err + } + if err := dropColumnIfExists(tx, &models.RedisUsageInbox{}, "snapshot_run_id", "redis_usage_inboxes"); err != nil { + return err + } + return nil +} + +func dropColumnIfExists(tx *gorm.DB, model any, columnName string, tableName string) error { + if !tx.Migrator().HasTable(model) || !tx.Migrator().HasColumn(model, columnName) { + return nil + } + if err := tx.Exec("ALTER TABLE " + tableName + " DROP COLUMN " + columnName).Error; err != nil { + return fmt.Errorf("drop %s.%s column: %w", tableName, columnName, err) + } + return nil +} + +func createUsageIdentitiesMigration(tx *gorm.DB) error { + statements := []string{ + `CREATE TABLE IF NOT EXISTS usage_identities ( + id integer PRIMARY KEY AUTOINCREMENT, + name text, + auth_type integer, + auth_type_name text, + identity text, + type text, + provider text, + total_requests integer DEFAULT 0, + success_count integer DEFAULT 0, + failure_count integer DEFAULT 0, + input_tokens integer DEFAULT 0, + output_tokens integer DEFAULT 0, + reasoning_tokens integer DEFAULT 0, + cached_tokens integer DEFAULT 0, + total_tokens integer DEFAULT 0, + last_aggregated_usage_event_id integer DEFAULT 0, + first_used_at datetime, + last_used_at datetime, + stats_updated_at datetime, + is_deleted numeric DEFAULT false, + created_at datetime, + updated_at datetime, + deleted_at datetime + )`, + `CREATE UNIQUE INDEX IF NOT EXISTS uniq_usage_identities_type_identity ON usage_identities(auth_type, identity)`, + `CREATE INDEX IF NOT EXISTS idx_usage_identities_auth_type ON usage_identities(auth_type)`, + `CREATE INDEX IF NOT EXISTS idx_usage_identities_auth_type_name ON usage_identities(auth_type_name)`, + `CREATE INDEX IF NOT EXISTS idx_usage_identities_identity ON usage_identities(identity)`, + `CREATE INDEX IF NOT EXISTS idx_usage_identities_is_deleted ON usage_identities(is_deleted)`, + `CREATE INDEX IF NOT EXISTS idx_usage_identities_last_aggregated_usage_event_id ON usage_identities(last_aggregated_usage_event_id)`, + `CREATE INDEX IF NOT EXISTS idx_usage_identities_deleted_at ON usage_identities(deleted_at)`, + } + for _, statement := range statements { + if err := tx.Exec(statement).Error; err != nil { + return fmt.Errorf("create usage_identities schema: %w", err) + } + } + return nil +} + +func migrateUsageIdentitiesMetadataMigration(tx *gorm.DB) error { + now := time.Now().UTC() + if tx.Migrator().HasTable("auth_files") { + isDeletedSelect, deletedAtSelect := legacyDeletedStateSelect(tx, "auth_files") + if err := tx.Exec(` + INSERT INTO usage_identities (name, auth_type, auth_type_name, identity, type, provider, is_deleted, created_at, updated_at, deleted_at) + SELECT COALESCE(NULLIF(TRIM(email), ''), NULLIF(TRIM(label), ''), NULLIF(TRIM(name), ''), auth_index), + ?, ?, auth_index, type, provider, `+isDeletedSelect+`, COALESCE(created_at, ?), ?, `+deletedAtSelect+` + FROM auth_files + WHERE auth_index IS NOT NULL AND TRIM(auth_index) != '' + ON CONFLICT(auth_type, identity) DO UPDATE SET + name = excluded.name, + auth_type_name = excluded.auth_type_name, + type = excluded.type, + provider = excluded.provider, + is_deleted = excluded.is_deleted, + deleted_at = excluded.deleted_at, + updated_at = excluded.updated_at`, models.UsageIdentityAuthTypeAuthFile, "oauth", now, now).Error; err != nil { + return fmt.Errorf("migrate auth_files to usage_identities: %w", err) + } + } + if tx.Migrator().HasTable("provider_metadata") { + isDeletedSelect, deletedAtSelect := legacyDeletedStateSelect(tx, "provider_metadata") + if err := tx.Exec(` + INSERT INTO usage_identities (name, auth_type, auth_type_name, identity, type, provider, is_deleted, created_at, updated_at, deleted_at) + SELECT display_name, ?, ?, lookup_key, provider_type, display_name, `+isDeletedSelect+`, COALESCE(created_at, ?), ?, `+deletedAtSelect+` + FROM provider_metadata + WHERE lookup_key IS NOT NULL AND TRIM(lookup_key) != '' + ON CONFLICT(auth_type, identity) DO UPDATE SET + name = excluded.name, + auth_type_name = excluded.auth_type_name, + type = excluded.type, + provider = excluded.provider, + is_deleted = excluded.is_deleted, + deleted_at = excluded.deleted_at, + updated_at = excluded.updated_at`, models.UsageIdentityAuthTypeAIProvider, "apikey", now, now).Error; err != nil { + return fmt.Errorf("migrate provider_metadata to usage_identities: %w", err) + } + } + return nil +} + +func legacyDeletedStateSelect(tx *gorm.DB, table string) (string, string) { + if tx.Migrator().HasColumn(table, "deleted_at") { + return "deleted_at IS NOT NULL", "deleted_at" + } + return "false", "NULL" +} + +func backfillUsageEventIdentityFieldsMigration(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&models.UsageIdentity{}) || !tx.Migrator().HasTable(&models.UsageEvent{}) { + return nil + } + for _, column := range []string{"auth_type", "provider", "source", "auth_index"} { + if !tx.Migrator().HasColumn(&models.UsageEvent{}, column) { + return nil + } + } + + if err := tx.Exec(` + UPDATE usage_events + SET auth_type = CASE + WHEN TRIM(COALESCE(auth_type, '')) = '' THEN ? + ELSE auth_type + END, + provider = CASE + WHEN TRIM(COALESCE(provider, '')) = '' THEN COALESCE(( + SELECT NULLIF(TRIM(usage_identities.provider), '') + FROM usage_identities + WHERE usage_identities.auth_type = ? + AND usage_identities.identity = usage_events.source + LIMIT 1 + ), provider) + ELSE provider + END + WHERE EXISTS ( + SELECT 1 + FROM usage_identities + WHERE usage_identities.auth_type = ? + AND usage_identities.identity = usage_events.source + ) + AND (TRIM(COALESCE(auth_type, '')) = '' OR TRIM(COALESCE(provider, '')) = '')`, "apikey", models.UsageIdentityAuthTypeAIProvider, models.UsageIdentityAuthTypeAIProvider).Error; err != nil { + return fmt.Errorf("backfill AI provider usage event identity fields: %w", err) + } + + if err := tx.Exec(` + UPDATE usage_events + SET auth_type = ? + WHERE TRIM(COALESCE(auth_type, '')) = '' + AND EXISTS ( + SELECT 1 + FROM usage_identities + WHERE usage_identities.auth_type = ? + AND usage_identities.identity = usage_events.auth_index + )`, "oauth", models.UsageIdentityAuthTypeAuthFile).Error; err != nil { + return fmt.Errorf("backfill auth file usage event identity fields: %w", err) + } + return nil +} + +func backfillUsageIdentityStatsMigration(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&models.UsageIdentity{}) || !tx.Migrator().HasTable(&models.UsageEvent{}) { + return nil + } + for _, column := range []string{"auth_type", "source", "auth_index"} { + if !tx.Migrator().HasColumn(&models.UsageEvent{}, column) { + return nil + } + } + + var identities []models.UsageIdentity + if err := tx.Find(&identities).Error; err != nil { + return fmt.Errorf("list usage identities for stats backfill: %w", err) + } + for _, identity := range identities { + stats, err := aggregateUsageIdentityFullStats(tx, identity) + if err != nil { + return err + } + updates := map[string]any{ + "total_requests": stats.TotalRequests, + "success_count": stats.SuccessCount, + "failure_count": stats.FailureCount, + "input_tokens": stats.InputTokens, + "output_tokens": stats.OutputTokens, + "reasoning_tokens": stats.ReasoningTokens, + "cached_tokens": stats.CachedTokens, + "total_tokens": stats.TotalTokens, + "first_used_at": stats.FirstUsedAt, + "last_used_at": stats.LastUsedAt, + "stats_updated_at": nil, + "last_aggregated_usage_event_id": stats.MaxUsageEventID, + } + if stats.TotalRequests > 0 { + now := time.Now().UTC() + updates["stats_updated_at"] = now + } + if err := tx.Model(&models.UsageIdentity{}).Where("id = ?", identity.ID).Updates(updates).Error; err != nil { + return fmt.Errorf("backfill usage identity stats for %q: %w", identity.Identity, err) + } + } + return nil +} + +func aggregateUsageIdentityFullStats(tx *gorm.DB, identity models.UsageIdentity) (usageIdentityStatsDelta, error) { + var stats usageIdentityStatsDelta + query, ok := usageIdentityBackfillEventsQuery(tx.Model(&models.UsageEvent{}), identity) + if !ok { + return stats, nil + } + if err := query.Select(` + COUNT(*) AS total_requests, + COALESCE(SUM(CASE WHEN failed THEN 0 ELSE 1 END), 0) AS success_count, + COALESCE(SUM(CASE WHEN failed THEN 1 ELSE 0 END), 0) AS failure_count, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, + COALESCE(SUM(cached_tokens), 0) AS cached_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(MAX(id), 0) AS max_usage_event_id`). + Scan(&stats).Error; err != nil { + return stats, fmt.Errorf("aggregate full usage identity stats for %q: %w", identity.Identity, err) + } + if stats.TotalRequests == 0 { + return stats, nil + } + + var firstEvent models.UsageEvent + firstQuery, _ := usageIdentityBackfillEventsQuery(tx.Model(&models.UsageEvent{}), identity) + if err := firstQuery.Order("timestamp asc, id asc").First(&firstEvent).Error; err != nil { + return stats, fmt.Errorf("find first usage identity event for %q: %w", identity.Identity, err) + } + firstUsedAt := firstEvent.Timestamp + stats.FirstUsedAt = &firstUsedAt + + var lastEvent models.UsageEvent + lastQuery, _ := usageIdentityBackfillEventsQuery(tx.Model(&models.UsageEvent{}), identity) + if err := lastQuery.Order("timestamp desc, id desc").First(&lastEvent).Error; err != nil { + return stats, fmt.Errorf("find last usage identity event for %q: %w", identity.Identity, err) + } + lastUsedAt := lastEvent.Timestamp + stats.LastUsedAt = &lastUsedAt + return stats, nil +} + +func usageIdentityBackfillEventsQuery(query *gorm.DB, identity models.UsageIdentity) (*gorm.DB, bool) { + switch identity.AuthType { + case models.UsageIdentityAuthTypeAuthFile: + return query.Where("auth_index = ? AND (auth_type = ? OR TRIM(COALESCE(auth_type, '')) = '')", identity.Identity, "oauth"), true + case models.UsageIdentityAuthTypeAIProvider: + return query.Where("source = ? AND (auth_type = ? OR TRIM(COALESCE(auth_type, '')) = '')", identity.Identity, "apikey"), true + default: + return query, false + } +} + +func dropLegacyMetadataTablesMigration(tx *gorm.DB) error { + if err := tx.Exec("DROP TABLE IF EXISTS auth_files").Error; err != nil { + return fmt.Errorf("drop auth_files table: %w", err) + } + if err := tx.Exec("DROP TABLE IF EXISTS provider_metadata").Error; err != nil { + return fmt.Errorf("drop provider_metadata table: %w", err) + } + return nil +} + +func removePrefixUsageIdentitiesMigration(tx *gorm.DB) error { + if !tx.Migrator().HasTable(&models.UsageIdentity{}) { + return nil + } + if err := tx.Exec(` + DELETE FROM usage_identities + WHERE auth_type = ? + AND LOWER(TRIM(identity)) IN ('gemini', 'claude', 'codex', 'vertex', 'openai')`, models.UsageIdentityAuthTypeAIProvider).Error; err != nil { + return fmt.Errorf("remove prefix-generated usage identities: %w", err) + } + return nil +} diff --git a/internal/repository/migrations_test.go b/internal/repository/migrations_test.go new file mode 100644 index 0000000000000000000000000000000000000000..cadf62758e494d39e8d50348fe9eeea598670774 --- /dev/null +++ b/internal/repository/migrations_test.go @@ -0,0 +1,875 @@ +package repository + +import ( + "bytes" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" + "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestOpenDatabaseRunsSchemaMigrationsAndAddsUsageEventRedisFields(t *testing.T) { + db := openTestDatabase(t) + + if !db.Migrator().HasTable("schema_migrations") { + t.Fatal("expected schema_migrations table to exist") + } + for _, column := range []string{"provider", "endpoint", "auth_type", "request_id"} { + if !db.Migrator().HasColumn(&models.UsageEvent{}, column) { + t.Fatalf("expected usage_events.%s column to exist", column) + } + } + + var versions []string + if err := db.Table("schema_migrations").Order("version asc").Pluck("version", &versions).Error; err != nil { + t.Fatalf("load schema migrations: %v", err) + } + expected := []string{ + "20260503_add_usage_event_redis_fields", + "20260503_backfill_usage_event_redis_fields", + "20260503_drop_snapshot_runs", + "20260504_backfill_usage_event_identity_fields", + "20260504_backfill_usage_identity_stats", + "20260504_create_usage_identities", + "20260504_drop_legacy_metadata_tables", + "20260504_drop_legacy_snapshot_run_columns", + "20260504_migrate_usage_identities_metadata", + "20260504_remove_prefix_usage_identities", + } + if len(versions) != len(expected) { + t.Fatalf("expected migration versions %v, got %v", expected, versions) + } + for i := range expected { + if versions[i] != expected[i] { + t.Fatalf("expected migration versions %v, got %v", expected, versions) + } + } +} + +func TestOpenDatabaseMigrationsAreIdempotent(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "app.db") + cfg := config.Config{SQLitePath: dbPath} + + db, err := OpenDatabase(cfg) + if err != nil { + t.Fatalf("first OpenDatabase returned error: %v", err) + } + closeOpenedDatabase(t, db) + + db, err = OpenDatabase(cfg) + if err != nil { + t.Fatalf("second OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + var count int64 + if err := db.Table("schema_migrations").Count(&count).Error; err != nil { + t.Fatalf("count schema migrations: %v", err) + } + if count != 10 { + t.Fatalf("expected 10 applied migrations after reopening database, got %d", count) + } +} + +func TestOpenDatabaseLogsSchemaMigrations(t *testing.T) { + logs := captureRepositoryLogs(t, logrus.InfoLevel) + dbPath := filepath.Join(t.TempDir(), "app.db") + cfg := config.Config{SQLitePath: dbPath} + + db, err := OpenDatabase(cfg) + if err != nil { + t.Fatalf("first OpenDatabase returned error: %v", err) + } + closeOpenedDatabase(t, db) + + db, err = OpenDatabase(cfg) + if err != nil { + t.Fatalf("second OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + content := logs.String() + for _, want := range []string{ + "level=info", + "msg=\"schema migration started\"", + "msg=\"schema migration applied\"", + "msg=\"schema migration skipped\"", + "version=20260503_add_usage_event_redis_fields", + "version=20260504_migrate_usage_identities_metadata", + "version=20260504_drop_legacy_metadata_tables", + } { + if !strings.Contains(content, want) { + t.Fatalf("expected migration logs to contain %q, got:\n%s", want, content) + } + } +} + +func TestRunSchemaMigrationLogsErrors(t *testing.T) { + logs := captureRepositoryLogs(t, logrus.InfoLevel) + db, err := gorm.Open(sqlite.Open(sqliteDSN(filepath.Join(t.TempDir(), "app.db"))), &gorm.Config{}) + if err != nil { + t.Fatalf("open database: %v", err) + } + defer closeOpenedDatabase(t, db) + if err := db.Exec("CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at DATETIME NOT NULL)").Error; err != nil { + t.Fatalf("create schema_migrations: %v", err) + } + + err = runSchemaMigration(db, databaseMigration{ + version: "test_failure", + run: func(*gorm.DB) error { + return fmt.Errorf("boom") + }, + }) + if err == nil { + t.Fatal("expected migration error") + } + + content := logs.String() + for _, want := range []string{ + "level=info", + "msg=\"schema migration started\"", + "version=test_failure", + "level=error", + "msg=\"schema migration failed\"", + "error=boom", + } { + if !strings.Contains(content, want) { + t.Fatalf("expected migration error logs to contain %q, got:\n%s", want, content) + } + } +} + +func TestOpenDatabaseDropsLegacySnapshotRunsTable(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + db, err := gorm.Open(sqlite.Open(sqliteDSN(dbPath)), &gorm.Config{}) + if err != nil { + t.Fatalf("open legacy database: %v", err) + } + if err := db.Exec(`CREATE TABLE snapshot_runs (id integer PRIMARY KEY AUTOINCREMENT, fetched_at datetime, status text)`).Error; err != nil { + t.Fatalf("create legacy snapshot_runs table: %v", err) + } + if err := db.Exec(`INSERT INTO snapshot_runs (fetched_at, status) VALUES (?, ?)`, time.Date(2026, 5, 3, 8, 0, 0, 0, time.UTC), "completed").Error; err != nil { + t.Fatalf("seed legacy snapshot_runs table: %v", err) + } + closeOpenedDatabase(t, db) + + db = openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + if db.Migrator().HasTable("snapshot_runs") { + t.Fatal("expected legacy snapshot_runs table to be dropped") + } + var count int64 + if err := db.Table("schema_migrations").Where("version = ?", "20260503_drop_snapshot_runs").Count(&count).Error; err != nil { + t.Fatalf("count drop snapshot migration: %v", err) + } + if count != 1 { + t.Fatalf("expected drop snapshot migration to be recorded once, got %d", count) + } +} + +func TestOpenDatabaseDropsLegacySnapshotRunIDColumns(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + seedLegacyRedisUsageTables(t, dbPath) + + db := openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + if db.Migrator().HasColumn(&models.UsageEvent{}, "snapshot_run_id") { + t.Fatal("expected usage_events.snapshot_run_id to be dropped") + } + if db.Migrator().HasColumn(&models.RedisUsageInbox{}, "snapshot_run_id") { + t.Fatal("expected redis_usage_inboxes.snapshot_run_id to be dropped") + } + var oldIndexCount int64 + if err := db.Raw("SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' AND name IN (?, ?)", "idx_usage_events_snapshot_run_id", "idx_redis_usage_inboxes_snapshot_run_id").Scan(&oldIndexCount).Error; err != nil { + t.Fatalf("count legacy snapshot_run_id indexes: %v", err) + } + if oldIndexCount != 0 { + t.Fatalf("expected legacy snapshot_run_id indexes to be dropped, got %d", oldIndexCount) + } + var migrationCount int64 + if err := db.Table("schema_migrations").Where("version = ?", "20260504_drop_legacy_snapshot_run_columns").Count(&migrationCount).Error; err != nil { + t.Fatalf("count drop snapshot_run_id columns migration: %v", err) + } + if migrationCount != 1 { + t.Fatalf("expected drop snapshot_run_id columns migration to be recorded once, got %d", migrationCount) + } +} + +func TestOpenDatabaseBackfillsUsageEventRedisFieldsByUsageEventKey(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + seedLegacyRedisUsageTables(t, dbPath) + + db := openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + var event models.UsageEvent + if err := db.Where("event_key = ?", "legacy-canonical-key").First(&event).Error; err != nil { + t.Fatalf("load usage event: %v", err) + } + if event.Provider != "claude" || event.Endpoint != "/v1/messages" || event.AuthType != "apikey" || event.RequestID != "req-from-raw" { + t.Fatalf("expected backfill by usage_event_key, got %+v", event) + } +} + +func TestOpenDatabaseBackfillsUsageEventRedisFieldsByRawRequestIDFallback(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + seedLegacyRedisUsageTables(t, dbPath) + + db := openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + var event models.UsageEvent + if err := db.Where("event_key = ?", "req-fallback").First(&event).Error; err != nil { + t.Fatalf("load fallback usage event: %v", err) + } + if event.Provider != "fallback-provider" || event.Endpoint != "/fallback" || event.AuthType != "oauth" || event.RequestID != "req-fallback" { + t.Fatalf("expected fallback backfill by raw request_id, got %+v", event) + } +} + +func TestOpenDatabaseBackfillsUsageEventRedisFieldsByRawRequestIDWhenUsageEventKeyIsBlank(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + seedLegacyRedisUsageTables(t, dbPath) + + db := openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + var event models.UsageEvent + if err := db.Where("event_key = ?", "req-blank-fallback").First(&event).Error; err != nil { + t.Fatalf("load blank fallback usage event: %v", err) + } + if event.Provider != "blank-provider" || event.Endpoint != "/blank" || event.AuthType != "oauth" || event.RequestID != "req-blank-fallback" { + t.Fatalf("expected blank usage_event_key to fall back by raw request_id, got %+v", event) + } + + var emptyEvent models.UsageEvent + if err := db.Where("event_key = ?", "").First(&emptyEvent).Error; err != nil { + t.Fatalf("load empty-key usage event: %v", err) + } + if emptyEvent.Provider != "" || emptyEvent.Endpoint != "" || emptyEvent.AuthType != "" || emptyEvent.RequestID != "" { + t.Fatalf("expected empty-key usage event to remain unchanged, got %+v", emptyEvent) + } +} + +func TestOpenDatabaseBackfillDoesNotOverwriteExistingUsageEventFields(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy.db") + seedLegacyRedisUsageTables(t, dbPath) + + // 模拟目标列已经有值的部分迁移数据库。 + db, err := gorm.Open(sqlite.Open(sqliteDSN(dbPath)), &gorm.Config{}) + if err != nil { + t.Fatalf("open partially migrated database: %v", err) + } + for _, statement := range []string{ + "ALTER TABLE usage_events ADD COLUMN provider TEXT", + "ALTER TABLE usage_events ADD COLUMN endpoint TEXT", + "ALTER TABLE usage_events ADD COLUMN auth_type TEXT", + "ALTER TABLE usage_events ADD COLUMN request_id TEXT", + "UPDATE usage_events SET provider = 'existing-provider', endpoint = 'existing-endpoint', auth_type = 'existing-auth', request_id = 'existing-request' WHERE event_key = 'existing-key'", + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("prepare partially migrated database with %q: %v", statement, err) + } + } + closeOpenedDatabase(t, db) + + db = openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + var event models.UsageEvent + if err := db.Where("event_key = ?", "existing-key").First(&event).Error; err != nil { + t.Fatalf("load existing usage event: %v", err) + } + if event.Provider != "existing-provider" || event.Endpoint != "existing-endpoint" || event.AuthType != "existing-auth" || event.RequestID != "existing-request" { + t.Fatalf("expected existing fields to remain unchanged, got %+v", event) + } +} + +func TestOpenDatabaseUsageIdentityMigratesLegacyMetadataAndDropsOldTables(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy-identities.db") + seedLegacyUsageIdentityTables(t, dbPath) + + db := openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + if !db.Migrator().HasTable(&models.UsageIdentity{}) { + t.Fatal("expected usage_identities table to exist") + } + if db.Migrator().HasTable("auth_files") { + t.Fatal("expected auth_files table to be dropped") + } + if db.Migrator().HasTable("provider_metadata") { + t.Fatal("expected provider_metadata table to be dropped") + } + + var identities []models.UsageIdentity + if err := db.Order("auth_type asc, identity asc").Find(&identities).Error; err != nil { + t.Fatalf("load usage identities: %v", err) + } + if len(identities) != 4 { + t.Fatalf("expected all 4 legacy usage identities, got %d: %+v", len(identities), identities) + } + + oauth := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAuthFile, "auth-1") + if oauth.Name != "person@example.com" || oauth.AuthTypeName != "oauth" || oauth.Type != "claude" || oauth.Provider != "claude" { + t.Fatalf("unexpected oauth identity mapping: %+v", oauth) + } + if oauth.TotalRequests != 3 || oauth.SuccessCount != 2 || oauth.FailureCount != 1 || oauth.InputTokens != 31 || oauth.OutputTokens != 41 || oauth.ReasoningTokens != 11 || oauth.CachedTokens != 7 || oauth.TotalTokens != 90 { + t.Fatalf("unexpected oauth identity stats: %+v", oauth) + } + if oauth.FirstUsedAt == nil || !oauth.FirstUsedAt.Equal(time.Date(2026, 5, 4, 8, 0, 0, 0, time.UTC)) { + t.Fatalf("unexpected oauth first used timestamp: %+v", oauth.FirstUsedAt) + } + if oauth.LastUsedAt == nil || !oauth.LastUsedAt.Equal(time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC)) { + t.Fatalf("unexpected oauth last used timestamp: %+v", oauth.LastUsedAt) + } + if oauth.StatsUpdatedAt == nil { + t.Fatal("expected oauth stats_updated_at to be set") + } + if oauth.LastAggregatedUsageEventID != 3 { + t.Fatalf("expected oauth last aggregated usage event id 3, got %d", oauth.LastAggregatedUsageEventID) + } + + provider := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAIProvider, "api-source-1") + if provider.Name != "Claude API" || provider.AuthTypeName != "apikey" || provider.Type != "claude" || provider.Provider != "Claude API" { + t.Fatalf("unexpected provider identity mapping: %+v", provider) + } + if provider.TotalRequests != 2 || provider.SuccessCount != 2 || provider.FailureCount != 0 || provider.InputTokens != 9 || provider.OutputTokens != 9 || provider.ReasoningTokens != 10 || provider.CachedTokens != 11 || provider.TotalTokens != 39 { + t.Fatalf("unexpected provider identity stats: %+v", provider) + } + if provider.FirstUsedAt == nil || !provider.FirstUsedAt.Equal(time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC)) || provider.LastUsedAt == nil || !provider.LastUsedAt.Equal(time.Date(2026, 5, 4, 10, 30, 0, 0, time.UTC)) { + t.Fatalf("unexpected provider usage timestamps: first=%+v last=%+v", provider.FirstUsedAt, provider.LastUsedAt) + } + if provider.StatsUpdatedAt == nil { + t.Fatal("expected provider stats_updated_at to be set") + } + if provider.LastAggregatedUsageEventID != 5 { + t.Fatalf("expected provider last aggregated usage event id 5, got %d", provider.LastAggregatedUsageEventID) + } + + deletedOAuth := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAuthFile, "auth-deleted") + if !deletedOAuth.IsDeleted || deletedOAuth.DeletedAt == nil || !deletedOAuth.DeletedAt.Equal(time.Date(2026, 5, 4, 7, 30, 0, 0, time.UTC)) { + t.Fatalf("expected deleted auth file state to be preserved, got %+v", deletedOAuth) + } + if deletedOAuth.TotalRequests != 1 || deletedOAuth.TotalTokens != 100 || deletedOAuth.LastAggregatedUsageEventID != 6 { + t.Fatalf("expected deleted auth file usage stats to be backfilled, got %+v", deletedOAuth) + } + + deletedProvider := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAIProvider, "api-deleted") + if !deletedProvider.IsDeleted || deletedProvider.DeletedAt == nil || !deletedProvider.DeletedAt.Equal(time.Date(2026, 5, 4, 7, 30, 0, 0, time.UTC)) { + t.Fatalf("expected deleted provider state to be preserved, got %+v", deletedProvider) + } + if deletedProvider.TotalRequests != 1 || deletedProvider.TotalTokens != 100 || deletedProvider.LastAggregatedUsageEventID != 7 { + t.Fatalf("expected deleted provider usage stats to be backfilled, got %+v", deletedProvider) + } +} + +func TestOpenDatabaseBackfillsUsageEventIdentityFieldsFromUsageIdentities(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy-identities.db") + seedLegacyUsageIdentityTables(t, dbPath) + + db, err := gorm.Open(sqlite.Open(sqliteDSN(dbPath)), &gorm.Config{}) + if err != nil { + t.Fatalf("open seeded legacy database: %v", err) + } + if err := db.Exec("UPDATE usage_events SET provider = '' WHERE event_key IN (?, ?)", "legacy-apikey", "legacy-oauth").Error; err != nil { + t.Fatalf("blank legacy usage event providers: %v", err) + } + if err := db.Exec("UPDATE usage_events SET provider = ? WHERE event_key = ?", "existing-provider", "apikey-success").Error; err != nil { + t.Fatalf("set existing provider: %v", err) + } + closeOpenedDatabase(t, db) + + db = openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + var legacyProvider models.UsageEvent + if err := db.Where("event_key = ?", "legacy-apikey").First(&legacyProvider).Error; err != nil { + t.Fatalf("load legacy provider event: %v", err) + } + if legacyProvider.AuthType != "apikey" || legacyProvider.Provider != "Claude API" { + t.Fatalf("expected legacy provider event identity fields to be backfilled, got %+v", legacyProvider) + } + + var legacyOAuth models.UsageEvent + if err := db.Where("event_key = ?", "legacy-oauth").First(&legacyOAuth).Error; err != nil { + t.Fatalf("load legacy oauth event: %v", err) + } + if legacyOAuth.AuthType != "oauth" { + t.Fatalf("expected legacy oauth event auth_type to be backfilled, got %+v", legacyOAuth) + } + + var existingProvider models.UsageEvent + if err := db.Where("event_key = ?", "apikey-success").First(&existingProvider).Error; err != nil { + t.Fatalf("load existing provider event: %v", err) + } + if existingProvider.Provider != "existing-provider" { + t.Fatalf("expected existing provider field to remain unchanged, got %+v", existingProvider) + } + + var providerFilterCount int64 + if err := db.Model(&models.UsageEvent{}).Where("auth_type = ? AND provider = ?", "apikey", "Claude API").Count(&providerFilterCount).Error; err != nil { + t.Fatalf("count provider-filtered usage events: %v", err) + } + if providerFilterCount != 1 { + t.Fatalf("expected provider filter to match migrated legacy event, got %d", providerFilterCount) + } +} + +func TestOpenDatabaseUsageIdentityMigrationsAreIdempotent(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "legacy-identities.db") + seedLegacyUsageIdentityTables(t, dbPath) + + db := openMigratedDatabase(t, dbPath) + closeOpenedDatabase(t, db) + db = openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + var identities []models.UsageIdentity + if err := db.Order("auth_type asc, identity asc").Find(&identities).Error; err != nil { + t.Fatalf("load usage identities after reopen: %v", err) + } + if len(identities) != 4 { + t.Fatalf("expected all 4 usage identities after reopen, got %d: %+v", len(identities), identities) + } + oauth := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAuthFile, "auth-1") + if oauth.TotalRequests != 3 || oauth.TotalTokens != 90 || oauth.LastAggregatedUsageEventID != 3 { + t.Fatalf("expected oauth stats not to double-add after reopen, got %+v", oauth) + } + provider := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAIProvider, "api-source-1") + if provider.TotalRequests != 2 || provider.TotalTokens != 39 || provider.LastAggregatedUsageEventID != 5 { + t.Fatalf("expected provider stats not to double-add after reopen, got %+v", provider) + } + deletedOAuth := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAuthFile, "auth-deleted") + if !deletedOAuth.IsDeleted || deletedOAuth.TotalRequests != 1 || deletedOAuth.TotalTokens != 100 || deletedOAuth.LastAggregatedUsageEventID != 6 { + t.Fatalf("expected deleted oauth stats not to double-add after reopen, got %+v", deletedOAuth) + } + deletedProvider := findUsageIdentity(t, identities, models.UsageIdentityAuthTypeAIProvider, "api-deleted") + if !deletedProvider.IsDeleted || deletedProvider.TotalRequests != 1 || deletedProvider.TotalTokens != 100 || deletedProvider.LastAggregatedUsageEventID != 7 { + t.Fatalf("expected deleted provider stats not to double-add after reopen, got %+v", deletedProvider) + } + + var duplicateVersions int64 + if err := db.Table("schema_migrations").Select("COUNT(*) - COUNT(DISTINCT version)").Scan(&duplicateVersions).Error; err != nil { + t.Fatalf("count duplicate schema migration versions: %v", err) + } + if duplicateVersions != 0 { + t.Fatalf("expected no duplicate schema migration versions, got %d", duplicateVersions) + } + for _, version := range []string{"20260504_create_usage_identities", "20260504_migrate_usage_identities_metadata", "20260504_backfill_usage_event_identity_fields", "20260504_backfill_usage_identity_stats", "20260504_drop_legacy_metadata_tables", "20260504_drop_legacy_snapshot_run_columns", "20260504_remove_prefix_usage_identities"} { + var count int64 + if err := db.Table("schema_migrations").Where("version = ?", version).Count(&count).Error; err != nil { + t.Fatalf("count schema migration %s: %v", version, err) + } + if count != 1 { + t.Fatalf("expected schema migration %s to be recorded once, got %d", version, count) + } + } + if db.Migrator().HasTable("auth_files") || db.Migrator().HasTable("provider_metadata") { + t.Fatal("expected old metadata tables to stay dropped after reopen") + } +} + +func TestOpenDatabaseSkipsUsageIdentityMetadataMigrationWhenLegacyTablesAreMissing(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "no-legacy-identities.db") + + db := openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + if !db.Migrator().HasTable(&models.UsageIdentity{}) { + t.Fatal("expected usage_identities table to exist") + } + var count int64 + if err := db.Model(&models.UsageIdentity{}).Count(&count).Error; err != nil { + t.Fatalf("count usage identities: %v", err) + } + if count != 0 { + t.Fatalf("expected no usage identities without legacy metadata, got %d", count) + } + if db.Migrator().HasTable("auth_files") || db.Migrator().HasTable("provider_metadata") { + t.Fatal("expected legacy metadata tables not to be recreated") + } +} + +func TestOpenDatabaseRemovesPrefixGeneratedUsageIdentities(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "prefix-identities.db") + seedPrefixGeneratedUsageIdentities(t, dbPath) + + db := openMigratedDatabase(t, dbPath) + defer closeOpenedDatabase(t, db) + + for _, prefix := range []string{"gemini", "claude", "codex", "vertex", "openai"} { + var prefixCount int64 + if err := db.Model(&models.UsageIdentity{}).Where("auth_type = ? AND identity = ?", models.UsageIdentityAuthTypeAIProvider, prefix).Count(&prefixCount).Error; err != nil { + t.Fatalf("count prefix usage identity %q: %v", prefix, err) + } + if prefixCount != 0 { + t.Fatalf("expected fixed prefix usage identity %q to be removed, got %d", prefix, prefixCount) + } + } + + var apiKey models.UsageIdentity + if err := db.Where("auth_type = ? AND identity = ?", models.UsageIdentityAuthTypeAIProvider, "claude-key").First(&apiKey).Error; err != nil { + t.Fatalf("load real api key identity: %v", err) + } + if apiKey.TotalRequests != 1 || apiKey.LastAggregatedUsageEventID != 1 { + t.Fatalf("expected real api key identity stats to remain, got %+v", apiKey) + } + + var unusedSiblingKey models.UsageIdentity + if err := db.Where("auth_type = ? AND identity = ?", models.UsageIdentityAuthTypeAIProvider, "claude-unused-key").First(&unusedSiblingKey).Error; err != nil { + t.Fatalf("load unused sibling api key identity: %v", err) + } + if unusedSiblingKey.Type != "claude" || unusedSiblingKey.Provider != "Claude Team" { + t.Fatalf("expected unused sibling api key identity to remain unchanged, got %+v", unusedSiblingKey) + } + + var unusedKey models.UsageIdentity + if err := db.Where("auth_type = ? AND identity = ?", models.UsageIdentityAuthTypeAIProvider, "gemini-unused-key").First(&unusedKey).Error; err != nil { + t.Fatalf("load unused real api key identity: %v", err) + } + if unusedKey.Type != "gemini" || unusedKey.Provider != "Gemini Team" { + t.Fatalf("expected unused real api key identity to remain unchanged, got %+v", unusedKey) + } + + var customPrefix models.UsageIdentity + if err := db.Where("auth_type = ? AND identity = ?", models.UsageIdentityAuthTypeAIProvider, "https://proxy.internal/v1").First(&customPrefix).Error; err != nil { + t.Fatalf("load custom prefix-like identity: %v", err) + } + if customPrefix.Type != "openai" || customPrefix.Provider != "Custom OpenAI" { + t.Fatalf("expected non-fixed custom prefix-like identity to remain unchanged, got %+v", customPrefix) + } +} + +func findUsageIdentity(t *testing.T, identities []models.UsageIdentity, authType models.UsageIdentityAuthType, identity string) models.UsageIdentity { + t.Helper() + for _, usageIdentity := range identities { + if usageIdentity.AuthType == authType && usageIdentity.Identity == identity { + return usageIdentity + } + } + t.Fatalf("usage identity auth_type=%d identity=%q not found in %+v", authType, identity, identities) + return models.UsageIdentity{} +} + +func seedPrefixGeneratedUsageIdentities(t *testing.T, dbPath string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(sqliteDSN(dbPath)), &gorm.Config{}) + if err != nil { + t.Fatalf("open prefix identity database: %v", err) + } + defer closeOpenedDatabase(t, db) + + if err := db.Exec(`CREATE TABLE usage_identities ( + id integer PRIMARY KEY AUTOINCREMENT, + name text, + auth_type integer, + auth_type_name text, + identity text, + type text, + provider text, + total_requests integer DEFAULT 0, + success_count integer DEFAULT 0, + failure_count integer DEFAULT 0, + input_tokens integer DEFAULT 0, + output_tokens integer DEFAULT 0, + reasoning_tokens integer DEFAULT 0, + cached_tokens integer DEFAULT 0, + total_tokens integer DEFAULT 0, + last_aggregated_usage_event_id integer DEFAULT 0, + first_used_at datetime, + last_used_at datetime, + stats_updated_at datetime, + is_deleted numeric DEFAULT false, + created_at datetime, + updated_at datetime, + deleted_at datetime + )`).Error; err != nil { + t.Fatalf("create usage_identities table: %v", err) + } + if err := db.Exec(`CREATE UNIQUE INDEX uniq_usage_identities_type_identity ON usage_identities(auth_type, identity)`).Error; err != nil { + t.Fatalf("create usage identity unique index: %v", err) + } + if err := db.Exec(`CREATE TABLE usage_events ( + id integer PRIMARY KEY AUTOINCREMENT, + event_key text, + api_group_key text, + provider text, + endpoint text, + auth_type text, + request_id text, + model text, + timestamp datetime, + source text, + auth_index text, + failed numeric, + latency_ms integer, + input_tokens integer, + output_tokens integer, + reasoning_tokens integer, + cached_tokens integer, + total_tokens integer, + created_at datetime + )`).Error; err != nil { + t.Fatalf("create usage_events table: %v", err) + } + + now := time.Date(2026, 5, 4, 8, 0, 0, 0, time.UTC) + rows := []models.UsageIdentity{ + {Name: "Claude Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "claude-key", Type: "claude", Provider: "Claude Team", TotalRequests: 1, SuccessCount: 1, TotalTokens: 30, LastAggregatedUsageEventID: 1, CreatedAt: now, UpdatedAt: now}, + {Name: "Claude Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "claude-unused-key", Type: "claude", Provider: "Claude Team", CreatedAt: now, UpdatedAt: now}, + {Name: "Gemini Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "gemini", Type: "gemini", Provider: "Gemini Team", TotalRequests: 2, SuccessCount: 2, TotalTokens: 40, LastAggregatedUsageEventID: 2, CreatedAt: now, UpdatedAt: now}, + {Name: "Claude Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "claude", Type: "claude", Provider: "Claude Team", CreatedAt: now, UpdatedAt: now}, + {Name: "Codex Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "codex", Type: "codex", Provider: "Codex Team", CreatedAt: now, UpdatedAt: now}, + {Name: "Vertex Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "vertex", Type: "vertex", Provider: "Vertex Team", CreatedAt: now, UpdatedAt: now}, + {Name: "OpenAI Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "openai", Type: "openai", Provider: "OpenAI Team", CreatedAt: now, UpdatedAt: now}, + {Name: "Gemini Team", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "gemini-unused-key", Type: "gemini", Provider: "Gemini Team", CreatedAt: now, UpdatedAt: now}, + {Name: "Custom OpenAI", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "https://proxy.internal/v1", Type: "openai", Provider: "Custom OpenAI", CreatedAt: now, UpdatedAt: now}, + } + if err := db.Create(&rows).Error; err != nil { + t.Fatalf("seed usage identities: %v", err) + } + if err := db.Exec(`INSERT INTO usage_events (event_key, api_group_key, provider, endpoint, auth_type, request_id, model, timestamp, source, failed, latency_ms, input_tokens, output_tokens, total_tokens, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "claude-event", "group", "Claude Team", "/v1/messages", "apikey", "req", "claude-sonnet", now, "claude-key", false, 100, 10, 20, 30, now).Error; err != nil { + t.Fatalf("seed usage event: %v", err) + } +} + +func seedLegacyUsageIdentityTables(t *testing.T, dbPath string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(sqliteDSN(dbPath)), &gorm.Config{}) + if err != nil { + t.Fatalf("open legacy identity database: %v", err) + } + defer closeOpenedDatabase(t, db) + + statements := []string{ + `CREATE TABLE auth_files ( + id integer PRIMARY KEY AUTOINCREMENT, + auth_index text, + name text, + email text, + type text, + provider text, + label text, + created_at datetime, + updated_at datetime, + deleted_at datetime + )`, + `CREATE TABLE provider_metadata ( + id integer PRIMARY KEY AUTOINCREMENT, + lookup_key text, + provider_type text, + display_name text, + created_at datetime, + updated_at datetime, + deleted_at datetime + )`, + `CREATE TABLE usage_events ( + id integer PRIMARY KEY AUTOINCREMENT, + event_key text, + api_group_key text, + provider text, + endpoint text, + auth_type text, + request_id text, + model text, + timestamp datetime, + source text, + auth_index text, + failed numeric, + latency_ms integer, + input_tokens integer, + output_tokens integer, + reasoning_tokens integer, + cached_tokens integer, + total_tokens integer, + created_at datetime + )`, + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("seed legacy identity schema with %q: %v", statement, err) + } + } + + now := time.Date(2026, 5, 4, 7, 0, 0, 0, time.UTC) + deletedAt := time.Date(2026, 5, 4, 7, 30, 0, 0, time.UTC) + if err := db.Exec("INSERT INTO auth_files (auth_index, name, email, type, provider, label, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "auth-1", "OAuth Name", "person@example.com", "claude", "claude", "OAuth Label", now, now, nil).Error; err != nil { + t.Fatalf("seed active auth file: %v", err) + } + if err := db.Exec("INSERT INTO auth_files (auth_index, name, email, type, provider, label, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", "auth-deleted", "Deleted OAuth", "deleted@example.com", "claude", "claude", "Deleted", now, now, deletedAt).Error; err != nil { + t.Fatalf("seed deleted auth file: %v", err) + } + if err := db.Exec("INSERT INTO provider_metadata (lookup_key, provider_type, display_name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?)", "api-source-1", "claude", "Claude API", now, now, nil).Error; err != nil { + t.Fatalf("seed active provider metadata: %v", err) + } + if err := db.Exec("INSERT INTO provider_metadata (lookup_key, provider_type, display_name, created_at, updated_at, deleted_at) VALUES (?, ?, ?, ?, ?, ?)", "api-deleted", "claude", "Deleted API", now, now, deletedAt).Error; err != nil { + t.Fatalf("seed deleted provider metadata: %v", err) + } + + events := []struct { + eventKey string + authType string + authIndex string + source string + failed bool + inputTokens int64 + outputTokens int64 + reasoningTokens int64 + cachedTokens int64 + totalTokens int64 + timestamp time.Time + }{ + {eventKey: "oauth-success", authType: "oauth", authIndex: "auth-1", failed: false, inputTokens: 10, outputTokens: 20, reasoningTokens: 3, cachedTokens: 4, totalTokens: 37, timestamp: time.Date(2026, 5, 4, 8, 0, 0, 0, time.UTC)}, + {eventKey: "legacy-oauth", authIndex: "auth-1", failed: false, inputTokens: 1, outputTokens: 1, reasoningTokens: 1, cachedTokens: 1, totalTokens: 4, timestamp: time.Date(2026, 5, 4, 8, 30, 0, 0, time.UTC)}, + {eventKey: "oauth-failure", authType: "oauth", authIndex: "auth-1", failed: true, inputTokens: 20, outputTokens: 20, reasoningTokens: 7, cachedTokens: 2, totalTokens: 49, timestamp: time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC)}, + {eventKey: "apikey-success", authType: "apikey", source: "api-source-1", failed: false, inputTokens: 7, outputTokens: 8, reasoningTokens: 9, cachedTokens: 10, totalTokens: 34, timestamp: time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC)}, + {eventKey: "legacy-apikey", source: "api-source-1", failed: false, inputTokens: 2, outputTokens: 1, reasoningTokens: 1, cachedTokens: 1, totalTokens: 5, timestamp: time.Date(2026, 5, 4, 10, 30, 0, 0, time.UTC)}, + {eventKey: "deleted-oauth", authType: "oauth", authIndex: "auth-deleted", failed: false, totalTokens: 100, timestamp: time.Date(2026, 5, 4, 11, 0, 0, 0, time.UTC)}, + {eventKey: "deleted-api", authType: "apikey", source: "api-deleted", failed: false, totalTokens: 100, timestamp: time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC)}, + } + for _, event := range events { + if err := db.Exec( + `INSERT INTO usage_events (event_key, api_group_key, provider, endpoint, auth_type, request_id, model, timestamp, source, auth_index, failed, latency_ms, input_tokens, output_tokens, reasoning_tokens, cached_tokens, total_tokens, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + event.eventKey, "group", "claude", "/v1/messages", event.authType, event.eventKey, "claude-sonnet", event.timestamp, event.source, event.authIndex, event.failed, 100, event.inputTokens, event.outputTokens, event.reasoningTokens, event.cachedTokens, event.totalTokens, event.timestamp, + ).Error; err != nil { + t.Fatalf("seed usage event %s: %v", event.eventKey, err) + } + } +} + +func seedLegacyRedisUsageTables(t *testing.T, dbPath string) { + t.Helper() + db, err := gorm.Open(sqlite.Open(sqliteDSN(dbPath)), &gorm.Config{}) + if err != nil { + t.Fatalf("open legacy database: %v", err) + } + defer closeOpenedDatabase(t, db) + + statements := []string{ + `CREATE TABLE usage_events ( + id integer PRIMARY KEY AUTOINCREMENT, + event_key text, + snapshot_run_id integer, + api_group_key text, + model text, + timestamp datetime, + source text, + auth_index text, + failed numeric, + latency_ms integer, + input_tokens integer, + output_tokens integer, + reasoning_tokens integer, + cached_tokens integer, + total_tokens integer, + created_at datetime + )`, + `CREATE UNIQUE INDEX uniq_usage_events_event_key ON usage_events(event_key)`, + `CREATE TABLE redis_usage_inboxes ( + id integer PRIMARY KEY AUTOINCREMENT, + queue_key text NOT NULL DEFAULT '', + message_hash text NOT NULL DEFAULT '', + raw_message text NOT NULL DEFAULT '', + status text NOT NULL DEFAULT '', + attempt_count integer NOT NULL DEFAULT 0, + last_error text, + snapshot_run_id integer, + usage_event_key text, + popped_at datetime NOT NULL DEFAULT '1970-01-01 00:00:00', + processed_at datetime, + created_at datetime, + updated_at datetime + )`, + } + for _, statement := range statements { + if err := db.Exec(statement).Error; err != nil { + t.Fatalf("seed legacy schema with %q: %v", statement, err) + } + } + + now := time.Date(2026, 5, 3, 8, 0, 0, 0, time.UTC) + legacyEvents := []map[string]any{ + {"event_key": "legacy-canonical-key", "api_group_key": "raw-key", "model": "claude-sonnet", "timestamp": now, "created_at": now}, + {"event_key": "req-fallback", "api_group_key": "fallback", "model": "claude-opus", "timestamp": now, "created_at": now}, + {"event_key": "req-blank-fallback", "api_group_key": "blank", "model": "claude-opus", "timestamp": now, "created_at": now}, + {"event_key": "", "api_group_key": "empty", "model": "claude-empty", "timestamp": now, "created_at": now}, + {"event_key": "existing-key", "api_group_key": "existing", "model": "claude-haiku", "timestamp": now, "created_at": now}, + } + for _, values := range legacyEvents { + if err := db.Table("usage_events").Create(values).Error; err != nil { + t.Fatalf("seed legacy usage event: %v", err) + } + } + + inboxes := []struct { + hash string + rawMessage string + status string + usageEventKey string + processedAt *time.Time + }{ + {hash: "hash-1", rawMessage: `{"provider":" claude ","endpoint":" /v1/messages ","auth_type":" API_KEY ","request_id":" req-from-raw "}`, status: RedisUsageInboxStatusProcessed, usageEventKey: "legacy-canonical-key", processedAt: &now}, + {hash: "hash-2", rawMessage: `{"provider":" fallback-provider ","endpoint":" /fallback ","auth_type":" OAuth ","request_id":" req-fallback "}`, status: RedisUsageInboxStatusProcessed, usageEventKey: "missing-key", processedAt: &now}, + {hash: "hash-3", rawMessage: `{"provider":" overwrite-provider ","endpoint":" /overwrite ","auth_type":" api_key ","request_id":" overwrite-request "}`, status: RedisUsageInboxStatusProcessed, usageEventKey: "existing-key", processedAt: &now}, + {hash: "hash-4", rawMessage: `{"provider":" blank-provider ","endpoint":" /blank ","auth_type":" OAuth ","request_id":" req-blank-fallback "}`, status: RedisUsageInboxStatusProcessed, usageEventKey: "", processedAt: &now}, + {hash: "hash-5", rawMessage: `{"provider":"pending-provider","request_id":"pending-key"}`, status: RedisUsageInboxStatusPending, usageEventKey: "pending-key"}, + } + for _, inbox := range inboxes { + if err := db.Exec( + "INSERT INTO redis_usage_inboxes (queue_key, message_hash, raw_message, status, attempt_count, usage_event_key, popped_at, processed_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", + "queue", inbox.hash, inbox.rawMessage, inbox.status, 0, inbox.usageEventKey, now, inbox.processedAt, now, now, + ).Error; err != nil { + t.Fatalf("seed legacy redis inbox: %v", err) + } + } +} + +func openMigratedDatabase(t *testing.T, dbPath string) *gorm.DB { + t.Helper() + db, err := OpenDatabase(config.Config{SQLitePath: dbPath}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + return db +} + +func captureRepositoryLogs(t *testing.T, level logrus.Level) *bytes.Buffer { + t.Helper() + var logs bytes.Buffer + previousOutput := logrus.StandardLogger().Out + previousFormatter := logrus.StandardLogger().Formatter + previousLevel := logrus.GetLevel() + logrus.SetOutput(&logs) + logrus.SetFormatter(&logrus.TextFormatter{DisableTimestamp: true}) + logrus.SetLevel(level) + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + logrus.SetFormatter(previousFormatter) + logrus.SetLevel(previousLevel) + }) + return &logs +} + +func closeOpenedDatabase(t *testing.T, db *gorm.DB) { + t.Helper() + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sql database: %v", err) + } + if err := sqlDB.Close(); err != nil { + t.Fatalf("close database: %v", err) + } +} diff --git a/internal/repository/pricing.go b/internal/repository/pricing.go new file mode 100644 index 0000000000000000000000000000000000000000..656d008c46122cceac1e949eaa248297b6beafcb --- /dev/null +++ b/internal/repository/pricing.go @@ -0,0 +1,105 @@ +package repository + +import ( + "fmt" + "sort" + "strings" + + "cpa-usage-keeper/internal/models" + "gorm.io/gorm" +) + +type ModelPriceSettingInput struct { + Model string + PromptPricePer1M float64 + CompletionPricePer1M float64 + CachePricePer1M float64 +} + +func ListUsedModels(db *gorm.DB) ([]string, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + var modelsList []string + if err := db.Model(&models.UsageEvent{}). + Distinct(). + Where("trim(model) <> ''"). + Order("model asc"). + Pluck("model", &modelsList).Error; err != nil { + return nil, fmt.Errorf("list used models: %w", err) + } + + cleaned := make([]string, 0, len(modelsList)) + seen := make(map[string]struct{}, len(modelsList)) + for _, model := range modelsList { + trimmed := strings.TrimSpace(model) + if trimmed == "" { + continue + } + if _, ok := seen[trimmed]; ok { + continue + } + seen[trimmed] = struct{}{} + cleaned = append(cleaned, trimmed) + } + sort.Strings(cleaned) + return cleaned, nil +} + +func ListModelPriceSettings(db *gorm.DB) ([]models.ModelPriceSetting, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + var settings []models.ModelPriceSetting + if err := db.Order("model asc").Find(&settings).Error; err != nil { + return nil, fmt.Errorf("list pricing settings: %w", err) + } + return settings, nil +} + +func UpsertModelPriceSetting(db *gorm.DB, input ModelPriceSettingInput) (*models.ModelPriceSetting, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + modelName := strings.TrimSpace(input.Model) + if modelName == "" { + return nil, fmt.Errorf("model is required") + } + + setting := &models.ModelPriceSetting{} + if err := db.Where("model = ?", modelName).First(setting).Error; err != nil { + if err == gorm.ErrRecordNotFound { + setting = &models.ModelPriceSetting{Model: modelName} + } else { + return nil, fmt.Errorf("load pricing setting: %w", err) + } + } + + setting.Model = modelName + setting.PromptPricePer1M = input.PromptPricePer1M + setting.CompletionPricePer1M = input.CompletionPricePer1M + setting.CachePricePer1M = input.CachePricePer1M + + if err := db.Save(setting).Error; err != nil { + return nil, fmt.Errorf("save pricing setting: %w", err) + } + + return setting, nil +} + +func DeleteModelPriceSetting(db *gorm.DB, model string) error { + if db == nil { + return fmt.Errorf("database is nil") + } + modelName := strings.TrimSpace(model) + if modelName == "" { + return fmt.Errorf("model is required") + } + if err := db.Where("model = ?", modelName).Delete(&models.ModelPriceSetting{}).Error; err != nil { + return fmt.Errorf("delete pricing setting: %w", err) + } + return nil +} diff --git a/internal/repository/pricing_test.go b/internal/repository/pricing_test.go new file mode 100644 index 0000000000000000000000000000000000000000..606c17ba7770aefac5045bee572fb97e2b8f03ff --- /dev/null +++ b/internal/repository/pricing_test.go @@ -0,0 +1,80 @@ +package repository + +import ( + "path/filepath" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" + "gorm.io/gorm" +) + +func TestListUsedModelsReturnsDistinctSortedModels(t *testing.T) { + db := openPricingTestDatabase(t) + + events := []models.UsageEvent{ + {EventKey: "1", Model: "claude-sonnet", Timestamp: time.Unix(1, 0)}, + {EventKey: "2", Model: "claude-haiku", Timestamp: time.Unix(2, 0)}, + {EventKey: "3", Model: "claude-sonnet", Timestamp: time.Unix(3, 0)}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("insert usage events: %v", err) + } + + modelsList, err := ListUsedModels(db) + if err != nil { + t.Fatalf("list used models: %v", err) + } + if len(modelsList) != 2 || modelsList[0] != "claude-haiku" || modelsList[1] != "claude-sonnet" { + t.Fatalf("unexpected models: %#v", modelsList) + } +} + +func TestUpsertModelPriceSettingCreatesAndUpdatesRow(t *testing.T) { + db := openPricingTestDatabase(t) + + created, err := UpsertModelPriceSetting(db, ModelPriceSettingInput{ + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }) + if err != nil { + t.Fatalf("create pricing setting: %v", err) + } + if created.Model != "claude-sonnet" || created.PromptPricePer1M != 3 { + t.Fatalf("unexpected created setting: %#v", created) + } + + updated, err := UpsertModelPriceSetting(db, ModelPriceSettingInput{ + Model: "claude-sonnet", + PromptPricePer1M: 4, + CompletionPricePer1M: 16, + CachePricePer1M: 0.4, + }) + if err != nil { + t.Fatalf("update pricing setting: %v", err) + } + if updated.ID != created.ID || updated.PromptPricePer1M != 4 || updated.CachePricePer1M != 0.4 { + t.Fatalf("unexpected updated setting: %#v", updated) + } + + settings, err := ListModelPriceSettings(db) + if err != nil { + t.Fatalf("list pricing settings: %v", err) + } + if len(settings) != 1 || settings[0].CompletionPricePer1M != 16 { + t.Fatalf("unexpected settings: %#v", settings) + } +} + +func openPricingTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "pricing.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + return db +} diff --git a/internal/repository/redis_usage_inbox.go b/internal/repository/redis_usage_inbox.go new file mode 100644 index 0000000000000000000000000000000000000000..f71c1034178b1dd24bf423a1e73a96a5caf48b2f --- /dev/null +++ b/internal/repository/redis_usage_inbox.go @@ -0,0 +1,159 @@ +package repository + +import ( + "crypto/sha256" + "fmt" + "strings" + "time" + "unicode/utf8" + + "cpa-usage-keeper/internal/models" + "gorm.io/gorm" +) + +const ( + RedisUsageInboxStatusPending = "pending" + RedisUsageInboxStatusProcessed = "processed" + RedisUsageInboxStatusDecodeFailed = "decode_failed" + RedisUsageInboxStatusProcessFailed = "process_failed" + RedisUsageInboxStatusDiscarded = "discarded" + + redisUsageInboxMaxErrorLength = 1024 + redisUsageInboxMaxProcessAttempts = 5 +) + +type RedisInboxInsert struct { + QueueKey string + RawMessage string + PoppedAt time.Time +} + +type RedisUsageInboxCleanupResult struct { + ProcessedDeleted int64 + FailedDeleted int64 +} + +func InsertRedisUsageInboxMessages(db *gorm.DB, inputs []RedisInboxInsert) ([]models.RedisUsageInbox, error) { + if len(inputs) == 0 { + return nil, nil + } + + rows := make([]models.RedisUsageInbox, 0, len(inputs)) + for _, input := range inputs { + hash := sha256.Sum256([]byte(input.RawMessage)) + rows = append(rows, models.RedisUsageInbox{ + QueueKey: strings.TrimSpace(input.QueueKey), + MessageHash: fmt.Sprintf("%x", hash), + RawMessage: input.RawMessage, + Status: RedisUsageInboxStatusPending, + AttemptCount: 0, + PoppedAt: input.PoppedAt.UTC(), + }) + } + + if err := db.Transaction(func(tx *gorm.DB) error { + return tx.Create(&rows).Error + }); err != nil { + return nil, err + } + return rows, nil +} + +func MarkRedisUsageInboxProcessed(db *gorm.DB, id uint, eventKey string, processedAt time.Time) error { + return db.Model(&models.RedisUsageInbox{}).Where("id = ?", id).Updates(map[string]any{ + "status": RedisUsageInboxStatusProcessed, + "usage_event_key": eventKey, + "processed_at": processedAt.UTC(), + "last_error": "", + }).Error +} + +func MarkRedisUsageInboxDecodeFailed(db *gorm.DB, id uint, decodeErr error) error { + return markRedisUsageInboxFailed(db, id, RedisUsageInboxStatusDecodeFailed, decodeErr) +} + +func MarkRedisUsageInboxProcessFailed(db *gorm.DB, id uint, processErr error) error { + return db.Model(&models.RedisUsageInbox{}).Where("id = ?", id).Updates(map[string]any{ + "status": gorm.Expr( + "CASE WHEN attempt_count + ? >= ? THEN ? ELSE ? END", + 1, + redisUsageInboxMaxProcessAttempts, + RedisUsageInboxStatusDiscarded, + RedisUsageInboxStatusProcessFailed, + ), + "attempt_count": gorm.Expr("attempt_count + ?", 1), + "last_error": boundedRedisUsageInboxError(processErr), + }).Error +} + +// ListProcessableRedisUsageInbox 返回待处理和可重试的数据,不返回已解码失败或已丢弃的数据。 +func ListProcessableRedisUsageInbox(db *gorm.DB, limit int) ([]models.RedisUsageInbox, error) { + query := db.Where("status = ? OR status = ?", RedisUsageInboxStatusPending, RedisUsageInboxStatusProcessFailed).Order("id asc") + if limit > 0 { + query = query.Limit(limit) + } + var rows []models.RedisUsageInbox + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +func ListPendingRedisUsageInbox(db *gorm.DB, limit int) ([]models.RedisUsageInbox, error) { + query := db.Where("status = ?", RedisUsageInboxStatusPending).Order("id asc") + if limit > 0 { + query = query.Limit(limit) + } + var rows []models.RedisUsageInbox + if err := query.Find(&rows).Error; err != nil { + return nil, err + } + return rows, nil +} + +// CleanupRedisUsageInbox 清理已完成和失败的 Redis inbox 原始消息,pending 数据永远不在这里删除。 +// processed 保留到下一个本地日开始后才清理;decode_failed/process_failed/discarded 保留 7 天便于排查。 +func CleanupRedisUsageInbox(db *gorm.DB, now time.Time) (RedisUsageInboxCleanupResult, error) { + localNow := now.In(time.Local) + localDayStart := time.Date(localNow.Year(), localNow.Month(), localNow.Day(), 0, 0, 0, 0, time.Local) + processedCutoff := localDayStart.UTC() + failedCutoff := now.UTC().AddDate(0, 0, -7) + result := RedisUsageInboxCleanupResult{} + + processedDelete := db.Where("status = ? AND processed_at IS NOT NULL AND processed_at < ?", RedisUsageInboxStatusProcessed, processedCutoff).Delete(&models.RedisUsageInbox{}) + if processedDelete.Error != nil { + return result, processedDelete.Error + } + result.ProcessedDeleted = processedDelete.RowsAffected + + failedDelete := db.Where("status IN ? AND updated_at < ?", []string{RedisUsageInboxStatusDecodeFailed, RedisUsageInboxStatusProcessFailed, RedisUsageInboxStatusDiscarded}, failedCutoff).Delete(&models.RedisUsageInbox{}) + if failedDelete.Error != nil { + return result, failedDelete.Error + } + result.FailedDeleted = failedDelete.RowsAffected + + return result, nil +} + +func markRedisUsageInboxFailed(db *gorm.DB, id uint, status string, err error) error { + return db.Model(&models.RedisUsageInbox{}).Where("id = ?", id).Updates(map[string]any{ + "status": status, + "attempt_count": gorm.Expr("attempt_count + ?", 1), + "last_error": boundedRedisUsageInboxError(err), + }).Error +} + +func boundedRedisUsageInboxError(err error) string { + if err == nil { + return "" + } + message := err.Error() + if len(message) <= redisUsageInboxMaxErrorLength { + return message + } + message = message[:redisUsageInboxMaxErrorLength] + for !utf8.ValidString(message) { + message = message[:len(message)-1] + } + return message +} diff --git a/internal/repository/redis_usage_inbox_test.go b/internal/repository/redis_usage_inbox_test.go new file mode 100644 index 0000000000000000000000000000000000000000..07e5ee455d210786a10ff368457fb6748b102935 --- /dev/null +++ b/internal/repository/redis_usage_inbox_test.go @@ -0,0 +1,294 @@ +package repository + +import ( + "crypto/sha256" + "fmt" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/models" +) + +func TestInsertRedisUsageInboxMessagesPersistsPendingRows(t *testing.T) { + db := openTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 10, 0, 0, 0, time.UTC) + + rows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{ + {QueueKey: "queue", RawMessage: `{"request_id":"one"}`, PoppedAt: poppedAt}, + {QueueKey: "queue", RawMessage: `{"request_id":"two"}`, PoppedAt: poppedAt.Add(time.Second)}, + }) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + if len(rows) != 2 { + t.Fatalf("expected 2 rows, got %d", len(rows)) + } + + var stored []models.RedisUsageInbox + if err := db.Order("id asc").Find(&stored).Error; err != nil { + t.Fatalf("load inbox rows: %v", err) + } + if len(stored) != 2 { + t.Fatalf("expected 2 stored rows, got %d", len(stored)) + } + if stored[0].Status != RedisUsageInboxStatusPending { + t.Fatalf("expected pending status, got %q", stored[0].Status) + } + if stored[0].AttemptCount != 0 { + t.Fatalf("expected initial attempt count 0, got %d", stored[0].AttemptCount) + } + if stored[0].RawMessage != `{"request_id":"one"}` { + t.Fatalf("unexpected raw message: %q", stored[0].RawMessage) + } + if stored[0].MessageHash != fmt.Sprintf("%x", sha256.Sum256([]byte(stored[0].RawMessage))) { + t.Fatalf("unexpected message hash: %q", stored[0].MessageHash) + } + if !stored[1].PoppedAt.Equal(poppedAt.Add(time.Second)) { + t.Fatalf("expected popped_at to be stored, got %s", stored[1].PoppedAt) + } +} + +func TestInsertRedisUsageInboxMessagesAllowsEmptyInput(t *testing.T) { + db := openTestDatabase(t) + + rows, err := InsertRedisUsageInboxMessages(db, nil) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + if len(rows) != 0 { + t.Fatalf("expected no rows, got %d", len(rows)) + } +} + +func TestRedisUsageInboxStatusTransitions(t *testing.T) { + db := openTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 10, 0, 0, 0, time.UTC) + processedAt := poppedAt.Add(time.Minute) + + rows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{{QueueKey: "queue", RawMessage: `{"request_id":"one"}`, PoppedAt: poppedAt}}) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + + if err := MarkRedisUsageInboxProcessed(db, rows[0].ID, "event-1", processedAt); err != nil { + t.Fatalf("MarkRedisUsageInboxProcessed returned error: %v", err) + } + + var stored models.RedisUsageInbox + if err := db.First(&stored, rows[0].ID).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if stored.Status != RedisUsageInboxStatusProcessed { + t.Fatalf("expected processed status, got %q", stored.Status) + } + if stored.UsageEventKey != "event-1" { + t.Fatalf("expected event key to be stored, got %q", stored.UsageEventKey) + } + if stored.ProcessedAt == nil || !stored.ProcessedAt.Equal(processedAt) { + t.Fatalf("expected processed_at %s, got %+v", processedAt, stored.ProcessedAt) + } +} + +func TestRedisUsageInboxFailureTransitionsBoundErrors(t *testing.T) { + db := openTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 10, 0, 0, 0, time.UTC) + + rows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{ + {QueueKey: "queue", RawMessage: `{bad`, PoppedAt: poppedAt}, + {QueueKey: "queue", RawMessage: `{"request_id":"two"}`, PoppedAt: poppedAt}, + }) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + + longErr := fmt.Errorf("%s", strings.Repeat("界", 5000)) + if err := MarkRedisUsageInboxDecodeFailed(db, rows[0].ID, longErr); err != nil { + t.Fatalf("MarkRedisUsageInboxDecodeFailed returned error: %v", err) + } + if err := MarkRedisUsageInboxProcessFailed(db, rows[1].ID, fmt.Errorf("insert failed")); err != nil { + t.Fatalf("MarkRedisUsageInboxProcessFailed returned error: %v", err) + } + + var stored []models.RedisUsageInbox + if err := db.Order("id asc").Find(&stored).Error; err != nil { + t.Fatalf("load inbox rows: %v", err) + } + if stored[0].Status != RedisUsageInboxStatusDecodeFailed { + t.Fatalf("expected decode_failed, got %q", stored[0].Status) + } + if stored[0].AttemptCount != 1 { + t.Fatalf("expected decode attempt count 1, got %d", stored[0].AttemptCount) + } + if len(stored[0].LastError) > redisUsageInboxMaxErrorLength { + t.Fatalf("expected bounded decode error, got length %d", len(stored[0].LastError)) + } + if !strings.HasSuffix(stored[0].LastError, "界") { + t.Fatalf("expected bounded decode error to preserve valid utf-8, got %q", stored[0].LastError) + } + if stored[1].Status != RedisUsageInboxStatusProcessFailed { + t.Fatalf("expected process_failed, got %q", stored[1].Status) + } + if stored[1].AttemptCount != 1 || stored[1].LastError != "insert failed" { + t.Fatalf("unexpected process failure fields: %+v", stored[1]) + } +} + +func TestMarkRedisUsageInboxProcessFailedDiscardsRowsAfterMaxAttempts(t *testing.T) { + db := openTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 10, 0, 0, 0, time.UTC) + + rows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{{QueueKey: "queue", RawMessage: `{"request_id":"retry"}`, PoppedAt: poppedAt}}) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + const maxProcessAttempts = 5 + for i := 0; i < maxProcessAttempts; i++ { + if err := MarkRedisUsageInboxProcessFailed(db, rows[0].ID, fmt.Errorf("insert failed %d", i+1)); err != nil { + t.Fatalf("MarkRedisUsageInboxProcessFailed attempt %d returned error: %v", i+1, err) + } + } + + var stored models.RedisUsageInbox + if err := db.First(&stored, rows[0].ID).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if stored.Status != RedisUsageInboxStatusDiscarded { + t.Fatalf("expected discarded after repeated process failures, got %q", stored.Status) + } + if stored.AttemptCount != maxProcessAttempts { + t.Fatalf("expected %d attempts, got %d", maxProcessAttempts, stored.AttemptCount) + } + if stored.LastError != "insert failed 5" { + t.Fatalf("expected last error from final attempt, got %q", stored.LastError) + } + + processable, err := ListProcessableRedisUsageInbox(db, 10) + if err != nil { + t.Fatalf("ListProcessableRedisUsageInbox returned error: %v", err) + } + if len(processable) != 0 { + t.Fatalf("expected discarded row to be excluded from processing, got %+v", processable) + } +} + +func TestListProcessableRedisUsageInboxIncludesProcessFailedRows(t *testing.T) { + db := openTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 10, 0, 0, 0, time.UTC) + + rows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{ + {QueueKey: "queue", RawMessage: `{"request_id":"pending"}`, PoppedAt: poppedAt}, + {QueueKey: "queue", RawMessage: `{"request_id":"retry"}`, PoppedAt: poppedAt}, + {QueueKey: "queue", RawMessage: `{bad`, PoppedAt: poppedAt}, + }) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + if err := MarkRedisUsageInboxProcessFailed(db, rows[1].ID, fmt.Errorf("temporary insert failure")); err != nil { + t.Fatalf("MarkRedisUsageInboxProcessFailed returned error: %v", err) + } + if err := MarkRedisUsageInboxDecodeFailed(db, rows[2].ID, fmt.Errorf("bad json")); err != nil { + t.Fatalf("MarkRedisUsageInboxDecodeFailed returned error: %v", err) + } + + processable, err := ListProcessableRedisUsageInbox(db, 10) + if err != nil { + t.Fatalf("ListProcessableRedisUsageInbox returned error: %v", err) + } + if len(processable) != 2 { + t.Fatalf("expected 2 processable rows, got %d", len(processable)) + } + if processable[0].ID != rows[0].ID || processable[1].ID != rows[1].ID { + t.Fatalf("expected pending and process_failed rows in id order, got %+v", processable) + } +} + +func TestCleanupRedisUsageInboxRemovesOldProcessedAndFailedRows(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + time.Local = location + t.Cleanup(func() { time.Local = previousLocal }) + db := openTestDatabase(t) + now := time.Date(2026, 4, 27, 2, 30, 0, 0, time.UTC) + + rows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{ + {QueueKey: "queue", RawMessage: `{"request_id":"processed-old"}`, PoppedAt: now.Add(-48 * time.Hour)}, + {QueueKey: "queue", RawMessage: `{"request_id":"processed-today"}`, PoppedAt: now.Add(-time.Hour)}, + {QueueKey: "queue", RawMessage: `{"request_id":"failed-old"}`, PoppedAt: now.AddDate(0, 0, -8)}, + {QueueKey: "queue", RawMessage: `{"request_id":"discarded-old"}`, PoppedAt: now.AddDate(0, 0, -8)}, + {QueueKey: "queue", RawMessage: `{"request_id":"failed-recent"}`, PoppedAt: now.AddDate(0, 0, -6)}, + {QueueKey: "queue", RawMessage: `{"request_id":"pending-old"}`, PoppedAt: now.AddDate(0, 0, -10)}, + }) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + oldProcessedAt := time.Date(2026, 4, 26, 15, 59, 59, 0, time.UTC) + todayProcessedAt := time.Date(2026, 4, 26, 16, 0, 0, 0, time.UTC) + if err := db.Model(&models.RedisUsageInbox{}).Where("id = ?", rows[0].ID).Updates(map[string]any{"status": RedisUsageInboxStatusProcessed, "processed_at": oldProcessedAt}).Error; err != nil { + t.Fatalf("seed old processed row: %v", err) + } + if err := db.Model(&models.RedisUsageInbox{}).Where("id = ?", rows[1].ID).Updates(map[string]any{"status": RedisUsageInboxStatusProcessed, "processed_at": todayProcessedAt}).Error; err != nil { + t.Fatalf("seed today processed row: %v", err) + } + if err := db.Model(&models.RedisUsageInbox{}).Where("id = ?", rows[2].ID).Updates(map[string]any{"status": RedisUsageInboxStatusProcessFailed, "updated_at": now.AddDate(0, 0, -8)}).Error; err != nil { + t.Fatalf("seed old failed row: %v", err) + } + if err := db.Model(&models.RedisUsageInbox{}).Where("id = ?", rows[3].ID).Updates(map[string]any{"status": RedisUsageInboxStatusDiscarded, "updated_at": now.AddDate(0, 0, -8)}).Error; err != nil { + t.Fatalf("seed old discarded row: %v", err) + } + if err := db.Model(&models.RedisUsageInbox{}).Where("id = ?", rows[4].ID).Updates(map[string]any{"status": RedisUsageInboxStatusDecodeFailed, "updated_at": now.AddDate(0, 0, -6)}).Error; err != nil { + t.Fatalf("seed recent failed row: %v", err) + } + + result, err := CleanupRedisUsageInbox(db, now) + if err != nil { + t.Fatalf("CleanupRedisUsageInbox returned error: %v", err) + } + if result.ProcessedDeleted != 1 || result.FailedDeleted != 2 { + t.Fatalf("unexpected cleanup result: %+v", result) + } + + var remaining []models.RedisUsageInbox + if err := db.Order("id asc").Find(&remaining).Error; err != nil { + t.Fatalf("load remaining inbox rows: %v", err) + } + remainingIDs := make([]uint, 0, len(remaining)) + for _, row := range remaining { + remainingIDs = append(remainingIDs, row.ID) + } + expectedIDs := []uint{rows[1].ID, rows[4].ID, rows[5].ID} + if fmt.Sprint(remainingIDs) != fmt.Sprint(expectedIDs) { + t.Fatalf("expected remaining ids %v, got %v", expectedIDs, remainingIDs) + } +} + +func TestListPendingRedisUsageInboxReturnsPendingRowsInIDOrder(t *testing.T) { + db := openTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 10, 0, 0, 0, time.UTC) + + rows, err := InsertRedisUsageInboxMessages(db, []RedisInboxInsert{ + {QueueKey: "queue", RawMessage: `{"request_id":"one"}`, PoppedAt: poppedAt}, + {QueueKey: "queue", RawMessage: `{"request_id":"two"}`, PoppedAt: poppedAt}, + {QueueKey: "queue", RawMessage: `{"request_id":"three"}`, PoppedAt: poppedAt}, + }) + if err != nil { + t.Fatalf("InsertRedisUsageInboxMessages returned error: %v", err) + } + if err := MarkRedisUsageInboxProcessed(db, rows[1].ID, "event-2", poppedAt.Add(time.Minute)); err != nil { + t.Fatalf("MarkRedisUsageInboxProcessed returned error: %v", err) + } + + pending, err := ListPendingRedisUsageInbox(db, 10) + if err != nil { + t.Fatalf("ListPendingRedisUsageInbox returned error: %v", err) + } + if len(pending) != 2 { + t.Fatalf("expected 2 pending rows, got %d", len(pending)) + } + if pending[0].ID != rows[0].ID || pending[1].ID != rows[2].ID { + t.Fatalf("expected pending rows in id order, got %+v", pending) + } +} diff --git a/internal/repository/usage.go b/internal/repository/usage.go new file mode 100644 index 0000000000000000000000000000000000000000..5e8398c9a9548b771976deae586f9a60cccf32a3 --- /dev/null +++ b/internal/repository/usage.go @@ -0,0 +1,717 @@ +package repository + +import ( + "fmt" + "sort" + "strings" + "time" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/models" + "gorm.io/gorm" +) + +func BuildUsageSnapshot(db *gorm.DB) (*cpa.StatisticsSnapshot, error) { + return BuildUsageSnapshotWithFilter(db, UsageQueryFilter{}) +} + +func ListUsageEventsWithFilter(db *gorm.DB, filter UsageQueryFilter) (*UsageEventsPageRecord, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + baseQuery := db.Model(&models.UsageEvent{}) + baseQuery = applyUsageEventsListFilter(baseQuery, filter) + + var totalCount int64 + if err := baseQuery.Count(&totalCount).Error; err != nil { + return nil, fmt.Errorf("count usage events: %w", err) + } + modelFacets, err := listUsageEventFacetValues(db, filter, "model") + if err != nil { + return nil, err + } + sources, err := listUsageEventFacetValues(db, filter, "source") + if err != nil { + return nil, err + } + + page := filter.Page + if page <= 0 { + page = 1 + } + pageSize := filter.PageSize + if pageSize <= 0 { + pageSize = filter.Limit + } + if pageSize <= 0 { + pageSize = DefaultUsageEventsLimit + } + offset := filter.Offset + if offset <= 0 { + offset = (page - 1) * pageSize + } + if offset < 0 { + offset = 0 + } + + query := applyUsageEventsListFilter(db.Model(&models.UsageEvent{}), filter) + query = query.Order("timestamp DESC, id DESC").Limit(pageSize).Offset(offset) + + var events []models.UsageEvent + if err := query.Find(&events).Error; err != nil { + return nil, fmt.Errorf("load usage events: %w", err) + } + + rows := make([]UsageEventRecord, 0, len(events)) + for _, event := range events { + rows = append(rows, UsageEventRecord{ + ID: event.ID, + Timestamp: event.Timestamp.UTC(), + APIGroupKey: strings.TrimSpace(event.APIGroupKey), + Model: strings.TrimSpace(event.Model), + AuthType: strings.TrimSpace(event.AuthType), + Provider: strings.TrimSpace(event.Provider), + Source: strings.TrimSpace(event.Source), + AuthIndex: strings.TrimSpace(event.AuthIndex), + Failed: event.Failed, + LatencyMS: event.LatencyMS, + InputTokens: event.InputTokens, + OutputTokens: event.OutputTokens, + ReasoningTokens: event.ReasoningTokens, + CachedTokens: event.CachedTokens, + TotalTokens: event.TotalTokens, + }) + } + totalPages := 0 + if totalCount > 0 { + totalPages = int((totalCount + int64(pageSize) - 1) / int64(pageSize)) + } + return &UsageEventsPageRecord{Events: rows, Models: modelFacets, Sources: sources, TotalCount: totalCount, Page: page, PageSize: pageSize, TotalPages: totalPages}, nil +} + +func ListUsageEventFilterOptionsWithFilter(db *gorm.DB, filter UsageQueryFilter) (*UsageEventFilterOptionsRecord, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + models, err := listUsageEventFacetValues(db, filter, "model") + if err != nil { + return nil, err + } + sources, err := listUsageEventFacetValues(db, filter, "source") + if err != nil { + return nil, err + } + return &UsageEventFilterOptionsRecord{Models: models, Sources: sources}, nil +} + +func listUsageEventFacetValues(db *gorm.DB, filter UsageQueryFilter, column string) ([]string, error) { + query := applyUsageEventTimeFilter(db.Model(&models.UsageEvent{}), filter) + var values []string + if err := query.Select("DISTINCT TRIM("+column+")").Where("TRIM("+column+") <> ''").Order("TRIM("+column+") ASC").Pluck(column, &values).Error; err != nil { + return nil, fmt.Errorf("load usage event %s facets: %w", column, err) + } + return values, nil +} + +func applyUsageEventTimeFilter(query *gorm.DB, filter UsageQueryFilter) *gorm.DB { + if filter.StartTime != nil { + query = query.Where("timestamp >= ?", filter.StartTime.UTC()) + } + if filter.EndTime != nil { + query = query.Where("timestamp <= ?", filter.EndTime.UTC()) + } + return query +} + +func applyUsageEventsListFilter(query *gorm.DB, filter UsageQueryFilter) *gorm.DB { + query = applyUsageEventTimeFilter(query, filter) + if model := strings.TrimSpace(filter.Model); model != "" { + query = query.Where("TRIM(model) = ?", model) + } + if authType := strings.TrimSpace(filter.AuthType); authType != "" { + query = query.Where("TRIM(auth_type) = ?", authType) + } + if provider := strings.TrimSpace(filter.Provider); provider != "" { + query = query.Where("TRIM(provider) = ?", provider) + } + if source := strings.TrimSpace(filter.Source); source != "" { + if authIndex := strings.TrimSpace(filter.AuthIndex); authIndex != "" && strings.TrimSpace(filter.AuthType) == "oauth" { + query = query.Where("(TRIM(auth_index) = ? OR TRIM(source) = ?)", authIndex, source) + } else { + query = query.Where("TRIM(source) = ?", source) + } + } else if authIndex := strings.TrimSpace(filter.AuthIndex); authIndex != "" { + query = query.Where("TRIM(auth_index) = ?", authIndex) + } + switch strings.TrimSpace(filter.Result) { + case "success": + query = query.Where("failed = ?", false) + case "failed": + query = query.Where("failed = ?", true) + } + return query +} + +func ListUsageCredentialStatsWithFilter(db *gorm.DB, filter UsageQueryFilter) ([]UsageCredentialStatRecord, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + query := applyUsageEventsListFilter(db.Model(&models.UsageEvent{}), filter) + query = query.Select("TRIM(source) AS source, TRIM(auth_index) AS auth_index, failed, COUNT(*) AS request_count") + query = query.Group("TRIM(source), TRIM(auth_index), failed") + query = query.Order("request_count DESC, source ASC, auth_index ASC, failed ASC") + + var rows []UsageCredentialStatRecord + if err := query.Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("load usage credential stats: %w", err) + } + return rows, nil +} + +func ListUsageAnalysisWithFilter(db *gorm.DB, filter UsageQueryFilter) ([]UsageAnalysisAPIStatRecord, []UsageAnalysisModelStatRecord, error) { + if db == nil { + return nil, nil, fmt.Errorf("database is nil") + } + + baseQuery := applyUsageEventsListFilter(db.Model(&models.UsageEvent{}), filter) + + apiQuery := baseQuery.Session(&gorm.Session{}) + apiQuery = apiQuery.Select(strings.Join([]string{ + "TRIM(api_group_key) AS api_group_key", + "COUNT(*) AS total_requests", + "SUM(CASE WHEN failed THEN 0 ELSE 1 END) AS success_count", + "SUM(CASE WHEN failed THEN 1 ELSE 0 END) AS failure_count", + "SUM(input_tokens) AS input_tokens", + "SUM(output_tokens) AS output_tokens", + "SUM(reasoning_tokens) AS reasoning_tokens", + "SUM(cached_tokens) AS cached_tokens", + "SUM(total_tokens) AS total_tokens", + }, ", ")) + apiQuery = apiQuery.Group("TRIM(api_group_key)") + apiQuery = apiQuery.Order("total_requests DESC, api_group_key ASC") + + var apiRows []UsageAnalysisAPIStatRecord + if err := apiQuery.Scan(&apiRows).Error; err != nil { + return nil, nil, fmt.Errorf("load usage analysis api stats: %w", err) + } + + modelQuery := baseQuery.Session(&gorm.Session{}) + modelQuery = modelQuery.Select(strings.Join([]string{ + "TRIM(model) AS model", + "COUNT(*) AS total_requests", + "SUM(CASE WHEN failed THEN 0 ELSE 1 END) AS success_count", + "SUM(CASE WHEN failed THEN 1 ELSE 0 END) AS failure_count", + "SUM(input_tokens) AS input_tokens", + "SUM(output_tokens) AS output_tokens", + "SUM(reasoning_tokens) AS reasoning_tokens", + "SUM(cached_tokens) AS cached_tokens", + "SUM(total_tokens) AS total_tokens", + "SUM(latency_ms) AS total_latency_ms", + "SUM(CASE WHEN latency_ms > 0 THEN 1 ELSE 0 END) AS latency_sample_count", + }, ", ")) + modelQuery = modelQuery.Group("TRIM(model)") + modelQuery = modelQuery.Order("total_requests DESC, model ASC") + + var modelRows []UsageAnalysisModelStatRecord + if err := modelQuery.Scan(&modelRows).Error; err != nil { + return nil, nil, fmt.Errorf("load usage analysis model stats: %w", err) + } + + apiModelQuery := baseQuery.Session(&gorm.Session{}) + apiModelQuery = apiModelQuery.Select(strings.Join([]string{ + "TRIM(api_group_key) AS api_group_key", + "TRIM(model) AS model", + "COUNT(*) AS total_requests", + "SUM(CASE WHEN failed THEN 0 ELSE 1 END) AS success_count", + "SUM(CASE WHEN failed THEN 1 ELSE 0 END) AS failure_count", + "SUM(input_tokens) AS input_tokens", + "SUM(output_tokens) AS output_tokens", + "SUM(reasoning_tokens) AS reasoning_tokens", + "SUM(cached_tokens) AS cached_tokens", + "SUM(total_tokens) AS total_tokens", + "SUM(latency_ms) AS total_latency_ms", + "SUM(CASE WHEN latency_ms > 0 THEN 1 ELSE 0 END) AS latency_sample_count", + }, ", ")) + apiModelQuery = apiModelQuery.Group("TRIM(api_group_key), TRIM(model)") + apiModelQuery = apiModelQuery.Order("api_group_key ASC, total_requests DESC, model ASC") + + var apiModelRows []struct { + APIGroupKey string + Model string + TotalRequests int64 + SuccessCount int64 + FailureCount int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 + TotalLatencyMS int64 + LatencySampleCount int64 + } + if err := apiModelQuery.Scan(&apiModelRows).Error; err != nil { + return nil, nil, fmt.Errorf("load usage analysis api model stats: %w", err) + } + + modelsByAPI := make(map[string][]UsageAnalysisModelStatRecord, len(apiRows)) + for _, row := range apiModelRows { + modelsByAPI[row.APIGroupKey] = append(modelsByAPI[row.APIGroupKey], UsageAnalysisModelStatRecord{ + Model: row.Model, + TotalRequests: row.TotalRequests, + SuccessCount: row.SuccessCount, + FailureCount: row.FailureCount, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + ReasoningTokens: row.ReasoningTokens, + CachedTokens: row.CachedTokens, + TotalTokens: row.TotalTokens, + TotalLatencyMS: row.TotalLatencyMS, + LatencySampleCount: row.LatencySampleCount, + }) + } + normalize := func(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "unknown" + } + return trimmed + } + + resultAPIs := make([]UsageAnalysisAPIStatRecord, 0, len(apiRows)) + for _, row := range apiRows { + row.APIGroupKey = normalize(row.APIGroupKey) + row.DisplayName = row.APIGroupKey + models := modelsByAPI[strings.TrimSpace(row.APIGroupKey)] + if len(models) == 0 { + models = modelsByAPI[row.APIGroupKey] + } + for index := range models { + models[index].Model = normalize(models[index].Model) + } + row.Models = models + resultAPIs = append(resultAPIs, row) + } + for index := range modelRows { + modelRows[index].Model = normalize(modelRows[index].Model) + } + + return resultAPIs, modelRows, nil +} + +func BuildUsageSnapshotWithFilter(db *gorm.DB, filter UsageQueryFilter) (*cpa.StatisticsSnapshot, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + events, err := loadUsageEventsWithFilter(db, filter) + if err != nil { + return nil, err + } + + return buildUsageSnapshotFromEvents(events), nil +} + +func BuildUsageOverviewWithFilter(db *gorm.DB, filter UsageQueryFilter) (*UsageOverviewRecord, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + events, err := loadUsageEventsWithFilter(db, filter) + if err != nil { + return nil, err + } + pricingByModel, err := loadPriceSettingsByModel(db) + if err != nil { + return nil, err + } + + return buildUsageOverviewFromEvents(events, filter, pricingByModel), nil +} + +func buildUsageOverviewFromEvents(events []models.UsageEvent, filter UsageQueryFilter, pricingByModel map[string]models.ModelPriceSetting) *UsageOverviewRecord { + windowMinutes := computeWindowMinutes(filter) + bucketByDay := shouldBucketUsageOverviewByDay(filter, windowMinutes) + latestHourlyStart := latestHourlySeriesStart(filter) + overview := &UsageOverviewRecord{ + Usage: &cpa.StatisticsSnapshot{ + APIs: map[string]cpa.APISnapshot{}, + RequestsByDay: map[string]int64{}, + RequestsByHour: map[string]int64{}, + TokensByDay: map[string]int64{}, + TokensByHour: map[string]int64{}, + }, + Summary: UsageOverviewSummaryRecord{ + WindowMinutes: windowMinutes, + CostAvailable: true, + }, + Series: newUsageOverviewSeriesRecord(), + HourlySeries: newUsageOverviewSeriesRecord(), + DailySeries: newUsageOverviewSeriesRecord(), + Health: buildUsageOverviewHealth(filter), + } + if len(events) == 0 { + return overview + } + + for _, event := range events { + applyUsageEventToSnapshot(overview.Usage, event, false) + applyUsageEventToOverview(overview, event, bucketByDay, latestHourlyStart, pricingByModel) + } + finalizeUsageOverview(overview, false) + return overview +} + +func loadUsageEventsWithFilter(db *gorm.DB, filter UsageQueryFilter) ([]models.UsageEvent, error) { + query := applyUsageEventsListFilter(db.Model(&models.UsageEvent{}), filter).Order("timestamp asc") + + var events []models.UsageEvent + if err := query.Find(&events).Error; err != nil { + return nil, fmt.Errorf("load usage events: %w", err) + } + return events, nil +} + +func buildUsageSnapshotFromEvents(events []models.UsageEvent) *cpa.StatisticsSnapshot { + snapshot := &cpa.StatisticsSnapshot{ + APIs: map[string]cpa.APISnapshot{}, + RequestsByDay: map[string]int64{}, + RequestsByHour: map[string]int64{}, + TokensByDay: map[string]int64{}, + TokensByHour: map[string]int64{}, + } + if len(events) == 0 { + return snapshot + } + + for _, event := range events { + applyUsageEventToSnapshot(snapshot, event, true) + } + finalizeUsageSnapshot(snapshot, true) + return snapshot +} + +func applyUsageEventToSnapshot(snapshot *cpa.StatisticsSnapshot, event models.UsageEvent, includeDetails bool) { + apiKey := normalizeUsageOverviewDimension(event.APIGroupKey) + modelName := normalizeUsageOverviewDimension(event.Model) + + apiSnapshot := snapshot.APIs[apiKey] + if apiSnapshot.Models == nil { + apiSnapshot.Models = map[string]cpa.ModelSnapshot{} + } + + modelSnapshot := apiSnapshot.Models[modelName] + if includeDetails { + detail := cpa.RequestDetail{ + Timestamp: event.Timestamp.UTC(), + LatencyMS: event.LatencyMS, + Source: strings.TrimSpace(event.Source), + AuthIndex: strings.TrimSpace(event.AuthIndex), + Failed: event.Failed, + Tokens: cpa.TokenStats{ + InputTokens: event.InputTokens, + OutputTokens: event.OutputTokens, + ReasoningTokens: event.ReasoningTokens, + CachedTokens: event.CachedTokens, + TotalTokens: event.TotalTokens, + }, + } + modelSnapshot.Details = append(modelSnapshot.Details, detail) + } + modelSnapshot.TotalRequests++ + modelSnapshot.TotalTokens += event.TotalTokens + apiSnapshot.TotalRequests++ + apiSnapshot.TotalTokens += event.TotalTokens + snapshot.TotalRequests++ + snapshot.TotalTokens += event.TotalTokens + if event.Failed { + modelSnapshot.FailureCount++ + apiSnapshot.FailureCount++ + snapshot.FailureCount++ + } else { + modelSnapshot.SuccessCount++ + apiSnapshot.SuccessCount++ + snapshot.SuccessCount++ + } + + dayKey := event.Timestamp.In(time.Local).Format("2006-01-02") + hourKey := event.Timestamp.UTC().Format("2006-01-02T15:00:00Z") + snapshot.RequestsByDay[dayKey]++ + snapshot.RequestsByHour[hourKey]++ + snapshot.TokensByDay[dayKey] += event.TotalTokens + snapshot.TokensByHour[hourKey] += event.TotalTokens + + apiSnapshot.Models[modelName] = modelSnapshot + snapshot.APIs[apiKey] = apiSnapshot +} + +func finalizeUsageSnapshot(snapshot *cpa.StatisticsSnapshot, includeDetails bool) { + if !includeDetails { + return + } + for apiKey, apiSnapshot := range snapshot.APIs { + for modelName, modelSnapshot := range apiSnapshot.Models { + sort.Slice(modelSnapshot.Details, func(i, j int) bool { + return modelSnapshot.Details[i].Timestamp.Before(modelSnapshot.Details[j].Timestamp) + }) + apiSnapshot.Models[modelName] = modelSnapshot + } + snapshot.APIs[apiKey] = apiSnapshot + } +} + +func newUsageOverviewSeriesRecord() UsageOverviewSeriesRecord { + return UsageOverviewSeriesRecord{ + Requests: map[string]int64{}, + Tokens: map[string]int64{}, + RPM: map[string]float64{}, + TPM: map[string]float64{}, + Cost: map[string]float64{}, + InputTokens: map[string]int64{}, + OutputTokens: map[string]int64{}, + CachedTokens: map[string]int64{}, + ReasoningTokens: map[string]int64{}, + Models: map[string]UsageOverviewSeriesRecord{}, + } +} + +func applyUsageEventToOverviewSeries(series *UsageOverviewSeriesRecord, event models.UsageEvent, cost float64, bucketKey string, bucketMinutes int64) { + series.Requests[bucketKey]++ + series.Tokens[bucketKey] += event.TotalTokens + series.Cost[bucketKey] += cost + series.InputTokens[bucketKey] += event.InputTokens + series.OutputTokens[bucketKey] += event.OutputTokens + series.CachedTokens[bucketKey] += event.CachedTokens + series.ReasoningTokens[bucketKey] += event.ReasoningTokens + series.RPM[bucketKey] = float64(series.Requests[bucketKey]) / float64(bucketMinutes) + series.TPM[bucketKey] = float64(series.Tokens[bucketKey]) / float64(bucketMinutes) + + modelName := normalizeUsageOverviewDimension(event.Model) + modelSeries := series.Models[modelName] + if modelSeries.Requests == nil { + modelSeries = newUsageOverviewSeriesRecord() + } + modelSeries.Requests[bucketKey]++ + modelSeries.Tokens[bucketKey] += event.TotalTokens + modelSeries.Cost[bucketKey] += cost + modelSeries.InputTokens[bucketKey] += event.InputTokens + modelSeries.OutputTokens[bucketKey] += event.OutputTokens + modelSeries.CachedTokens[bucketKey] += event.CachedTokens + modelSeries.ReasoningTokens[bucketKey] += event.ReasoningTokens + modelSeries.RPM[bucketKey] = float64(modelSeries.Requests[bucketKey]) / float64(bucketMinutes) + modelSeries.TPM[bucketKey] = float64(modelSeries.Tokens[bucketKey]) / float64(bucketMinutes) + series.Models[modelName] = modelSeries +} + +func usageEventRequiresPricing(event models.UsageEvent) bool { + return event.InputTokens > 0 || event.OutputTokens > 0 || event.CachedTokens > 0 +} + +func applyUsageEventToOverview(overview *UsageOverviewRecord, event models.UsageEvent, bucketByDay bool, latestHourlyStart *time.Time, pricingByModel map[string]models.ModelPriceSetting) { + overview.Summary.CachedTokens += event.CachedTokens + overview.Summary.ReasoningTokens += event.ReasoningTokens + if event.Failed { + overview.Health.TotalFailure++ + } else { + overview.Health.TotalSuccess++ + } + pricing, ok := pricingByModel[strings.TrimSpace(event.Model)] + if !ok && usageEventRequiresPricing(event) { + overview.Summary.CostAvailable = false + } + cost := calculateUsageEventCost(event, pricing) + overview.Summary.TotalCost += cost + + bucketKey, bucketMinutes := usageOverviewBucket(event.Timestamp.UTC(), bucketByDay) + applyUsageEventToOverviewSeries(&overview.Series, event, cost, bucketKey, bucketMinutes) + + hourKey, hourMinutes := usageOverviewBucket(event.Timestamp.UTC(), false) + if latestHourlyStart == nil || !event.Timestamp.UTC().Before(*latestHourlyStart) { + applyUsageEventToOverviewSeries(&overview.HourlySeries, event, cost, hourKey, hourMinutes) + } + + dayKey, dayMinutes := usageOverviewBucket(event.Timestamp.UTC(), true) + applyUsageEventToOverviewSeries(&overview.DailySeries, event, cost, dayKey, dayMinutes) + updateUsageOverviewHealthBlock(overview.Health.BlockDetails, event) +} + +func finalizeUsageOverview(overview *UsageOverviewRecord, includeDetails bool) { + finalizeUsageSnapshot(overview.Usage, includeDetails) + overview.Summary.RequestCount = overview.Usage.TotalRequests + overview.Summary.TokenCount = overview.Usage.TotalTokens + if overview.Summary.WindowMinutes > 0 { + overview.Summary.RPM = float64(overview.Summary.RequestCount) / float64(overview.Summary.WindowMinutes) + overview.Summary.TPM = float64(overview.Summary.TokenCount) / float64(overview.Summary.WindowMinutes) + } + if total := overview.Health.TotalSuccess + overview.Health.TotalFailure; total > 0 { + overview.Health.SuccessRate = (float64(overview.Health.TotalSuccess) / float64(total)) * 100 + } +} + +func normalizeUsageOverviewDimension(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return "unknown" + } + return trimmed +} + +func loadPriceSettingsByModel(db *gorm.DB) (map[string]models.ModelPriceSetting, error) { + settings, err := ListModelPriceSettings(db) + if err != nil { + return nil, err + } + result := make(map[string]models.ModelPriceSetting, len(settings)) + for _, setting := range settings { + result[strings.TrimSpace(setting.Model)] = setting + } + return result, nil +} + +func calculateUsageEventCost(event models.UsageEvent, pricing models.ModelPriceSetting) float64 { + inputTokens := event.InputTokens + if inputTokens < 0 { + inputTokens = 0 + } + completionTokens := event.OutputTokens + if completionTokens < 0 { + completionTokens = 0 + } + cachedTokens := event.CachedTokens + if cachedTokens < 0 { + cachedTokens = 0 + } + promptTokens := inputTokens - cachedTokens + if promptTokens < 0 { + promptTokens = 0 + } + return (float64(promptTokens)/1_000_000.0)*pricing.PromptPricePer1M + + (float64(completionTokens)/1_000_000.0)*pricing.CompletionPricePer1M + + (float64(cachedTokens)/1_000_000.0)*pricing.CachePricePer1M +} + +const usageOverviewDailyBucketThresholdMinutes int64 = 7 * 24 * 60 + +func computeWindowMinutes(filter UsageQueryFilter) int64 { + if filter.StartTime == nil || filter.EndTime == nil { + return 0 + } + start := filter.StartTime.UTC() + end := filter.EndTime.UTC() + if end.Before(start) { + return 0 + } + minutes := int64(end.Sub(start) / time.Minute) + if end.Sub(start)%time.Minute != 0 { + minutes++ + } + if minutes < 1 { + return 1 + } + return minutes +} + +func shouldBucketUsageOverviewByDay(filter UsageQueryFilter, windowMinutes int64) bool { + if filter.Range == "all" || filter.Range == "7d" { + return true + } + return windowMinutes >= usageOverviewDailyBucketThresholdMinutes +} + +func usageOverviewBucket(timestamp time.Time, byDay bool) (string, int64) { + if byDay { + return timestamp.In(time.Local).Format("2006-01-02"), 24 * 60 + } + return timestamp.UTC().Format("2006-01-02T15:00:00Z"), 60 +} + +func latestHourlySeriesStart(filter UsageQueryFilter) *time.Time { + if filter.EndTime == nil { + return nil + } + currentHour := filter.EndTime.UTC().Truncate(time.Hour) + start := currentHour.Add(-23 * time.Hour) + return &start +} + +const ( + usageOverviewHealthRows = 7 + usageOverviewHealthDefaultColumns = 96 + usageOverviewHealthDefaultSpan = 15 * time.Minute + usageOverviewHealthPresetWindow = 24 * time.Hour + usageOverviewHealthPresetSpan = (usageOverviewHealthPresetWindow + time.Duration(usageOverviewHealthRows*usageOverviewHealthDefaultColumns) - 1) / time.Duration(usageOverviewHealthRows*usageOverviewHealthDefaultColumns) +) + +func buildUsageOverviewHealth(filter UsageQueryFilter) UsageOverviewHealthRecord { + rows := usageOverviewHealthRows + columns, span := usageOverviewHealthGrid(filter) + totalBlocks := rows * columns + windowStart, windowEnd := usageOverviewHealthWindow(filter, totalBlocks, span) + blocks := make([]UsageOverviewHealthBlockRecord, totalBlocks) + for index := range blocks { + startTime := windowStart.Add(time.Duration(index) * span) + blocks[index] = UsageOverviewHealthBlockRecord{ + StartTime: startTime, + EndTime: startTime.Add(span), + Rate: -1, + } + } + return UsageOverviewHealthRecord{ + Rows: rows, + Columns: columns, + BucketSeconds: int64((span + time.Second - 1) / time.Second), + WindowStart: windowStart, + WindowEnd: windowEnd, + BlockDetails: blocks, + } +} + +func usageOverviewHealthGrid(filter UsageQueryFilter) (int, time.Duration) { + if isUsageOverviewShortHealthRange(filter.Range) { + return usageOverviewHealthDefaultColumns, usageOverviewHealthPresetSpan + } + return usageOverviewHealthDefaultColumns, usageOverviewHealthDefaultSpan +} + +func isUsageOverviewShortHealthRange(value string) bool { + switch value { + case "4h", "8h", "12h", "24h", "today": + return true + default: + return false + } +} + +func usageOverviewHealthWindow(filter UsageQueryFilter, totalBlocks int, span time.Duration) (time.Time, time.Time) { + end := time.Now().UTC() + if filter.EndTime != nil { + end = filter.EndTime.UTC() + } + if isUsageOverviewShortHealthRange(filter.Range) { + return end.Add(-usageOverviewHealthPresetWindow), end + } + currentBucketStart := end.Truncate(span) + windowEnd := currentBucketStart.Add(span) + return windowEnd.Add(-time.Duration(totalBlocks) * span), windowEnd +} + +func updateUsageOverviewHealthBlock(blocks []UsageOverviewHealthBlockRecord, event models.UsageEvent) { + timestamp := event.Timestamp.UTC() + for index := range blocks { + block := &blocks[index] + if timestamp.Before(block.StartTime) || !timestamp.Before(block.EndTime) { + continue + } + if event.Failed { + block.Failure++ + } else { + block.Success++ + } + total := block.Success + block.Failure + if total > 0 { + block.Rate = float64(block.Success) / float64(total) + } + return + } +} diff --git a/internal/repository/usage_events_test.go b/internal/repository/usage_events_test.go new file mode 100644 index 0000000000000000000000000000000000000000..202f61192170188b092bc96f1760ba9e9eaf7467 --- /dev/null +++ b/internal/repository/usage_events_test.go @@ -0,0 +1,248 @@ +package repository + +import ( + "path/filepath" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" +) + +func TestListUsageEventsWithFilterAppliesTimeBoundsAndPagination(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-events.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), Source: "source-a", AuthIndex: "1", TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), Source: "source-b", AuthIndex: "2", TotalTokens: 20}, + {EventKey: "event-3", APIGroupKey: "provider-b", Model: "claude-opus", Timestamp: time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC), Source: "source-c", AuthIndex: "3", TotalTokens: 30}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 9, 30, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC) + page, err := ListUsageEventsWithFilter(db, UsageQueryFilter{StartTime: &start, EndTime: &end, Page: 1, PageSize: 1}) + if err != nil { + t.Fatalf("ListUsageEventsWithFilter returned error: %v", err) + } + if page.TotalCount != 2 || page.TotalPages != 2 || page.Page != 1 || page.PageSize != 1 { + t.Fatalf("unexpected pagination metadata: %+v", page) + } + if len(page.Events) != 1 { + t.Fatalf("expected one row after page size, got %d", len(page.Events)) + } + if page.Events[0].Source != "source-c" { + t.Fatalf("expected newest in-range row first, got %+v", page.Events[0]) + } +} + +func TestListUsageEventsWithFilterPagesByTimestampAndID(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-events-pages.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + timestamp := time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC) + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: timestamp, Source: "source-a", AuthIndex: "1", TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: timestamp, Source: "source-b", AuthIndex: "2", TotalTokens: 20}, + {EventKey: "event-3", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: timestamp.Add(-time.Hour), Source: "source-c", AuthIndex: "3", TotalTokens: 30}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + firstPage, err := ListUsageEventsWithFilter(db, UsageQueryFilter{Page: 1, PageSize: 1}) + if err != nil { + t.Fatalf("ListUsageEventsWithFilter returned error: %v", err) + } + secondPage, err := ListUsageEventsWithFilter(db, UsageQueryFilter{Page: 2, PageSize: 1}) + if err != nil { + t.Fatalf("ListUsageEventsWithFilter returned error: %v", err) + } + if firstPage.TotalCount != 3 || firstPage.TotalPages != 3 || secondPage.TotalCount != 3 || secondPage.TotalPages != 3 { + t.Fatalf("unexpected page metadata: first=%+v second=%+v", firstPage, secondPage) + } + if len(firstPage.Events) != 1 || len(secondPage.Events) != 1 { + t.Fatalf("expected one event on each page: first=%+v second=%+v", firstPage, secondPage) + } + if firstPage.Events[0].ID <= secondPage.Events[0].ID { + t.Fatalf("expected id desc tie-breaker, first=%+v second=%+v", firstPage.Events[0], secondPage.Events[0]) + } +} + +func TestListUsageEventsWithFilterAppliesModelSourceAndResultFilters(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-events-filtered.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), Source: "source-a", Failed: false, TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), Source: "source-a", Failed: true, TotalTokens: 20}, + {EventKey: "event-3", APIGroupKey: "provider-b", Model: "claude-opus", Timestamp: time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC), Source: "source-a", Failed: false, TotalTokens: 30}, + {EventKey: "event-4", APIGroupKey: "provider-c", Model: "gpt-5", Timestamp: time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC), Source: "source-b", Failed: false, TotalTokens: 40}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + page, err := ListUsageEventsWithFilter(db, UsageQueryFilter{Page: 1, PageSize: 20, Model: "claude-sonnet", Source: "source-a", Result: "success"}) + if err != nil { + t.Fatalf("ListUsageEventsWithFilter returned error: %v", err) + } + if page.TotalCount != 1 || len(page.Events) != 1 { + t.Fatalf("expected one matching event, got %+v", page) + } + if page.Events[0].Model != "claude-sonnet" || page.Events[0].Source != "source-a" || page.Events[0].Failed { + t.Fatalf("unexpected filtered event: %+v", page.Events[0]) + } +} + +func TestListUsageEventsWithFilterAppliesProviderAuthTypeFilter(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-events-provider-filter.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + events := []models.UsageEvent{ + {EventKey: "event-1", Model: "gpt-5", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), AuthType: "apikey", Provider: "OpenAI Mirror", Source: "sk-key-a", TotalTokens: 10}, + {EventKey: "event-2", Model: "gpt-5", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), AuthType: "apikey", Provider: "OpenAI Mirror", Source: "sk-key-b", TotalTokens: 20}, + {EventKey: "event-3", Model: "gpt-5", Timestamp: time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC), AuthType: "apikey", Provider: "Other Provider", Source: "sk-key-c", TotalTokens: 30}, + {EventKey: "event-4", Model: "gpt-5", Timestamp: time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC), AuthType: "oauth", Provider: "OpenAI Mirror", Source: "oauth-source", AuthIndex: "auth-1", TotalTokens: 40}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + page, err := ListUsageEventsWithFilter(db, UsageQueryFilter{AuthType: "apikey", Provider: "OpenAI Mirror", Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListUsageEventsWithFilter returned error: %v", err) + } + if page.TotalCount != 2 || len(page.Events) != 2 { + t.Fatalf("expected two matching provider events, got %+v", page) + } + for _, event := range page.Events { + if event.AuthType != "apikey" || event.Provider != "OpenAI Mirror" { + t.Fatalf("unexpected provider filtered event: %+v", event) + } + } +} + +func TestListUsageEventsWithFilterAppliesAuthSourceOrAuthIndexFilter(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-events-auth-filter.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + events := []models.UsageEvent{ + {EventKey: "event-1", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), AuthType: "oauth", Source: "auth-1", AuthIndex: "1", TotalTokens: 10}, + {EventKey: "event-2", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), AuthType: "oauth", Source: "source-alias", AuthIndex: "auth-1", TotalTokens: 20}, + {EventKey: "event-3", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC), AuthType: "oauth", Source: "other", AuthIndex: "other", TotalTokens: 30}, + {EventKey: "event-4", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC), AuthType: "apikey", Source: "auth-1", AuthIndex: "auth-1", Provider: "Provider A", TotalTokens: 40}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + page, err := ListUsageEventsWithFilter(db, UsageQueryFilter{AuthType: "oauth", Source: "auth-1", AuthIndex: "auth-1", Page: 1, PageSize: 20}) + if err != nil { + t.Fatalf("ListUsageEventsWithFilter returned error: %v", err) + } + if page.TotalCount != 2 || len(page.Events) != 2 { + t.Fatalf("expected two matching auth events, got %+v", page) + } + for _, event := range page.Events { + if event.AuthType != "oauth" || (event.Source != "auth-1" && event.AuthIndex != "auth-1") { + t.Fatalf("unexpected auth filtered event: %+v", event) + } + } +} + +func TestListUsageEventFilterOptionsWithFilterReturnsStableOptions(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-events-facets.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), Source: "source-a", Failed: false, TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), Source: "source-b", Failed: true, TotalTokens: 20}, + {EventKey: "event-3", APIGroupKey: "provider-b", Model: "gpt-5", Timestamp: time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC), Source: "source-a", Failed: false, TotalTokens: 30}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + options, err := ListUsageEventFilterOptionsWithFilter(db, UsageQueryFilter{Result: "success"}) + if err != nil { + t.Fatalf("ListUsageEventFilterOptionsWithFilter returned error: %v", err) + } + if len(options.Models) != 2 || options.Models[0] != "claude-sonnet" || options.Models[1] != "gpt-5" { + t.Fatalf("expected stable model options, got %+v", options.Models) + } + if len(options.Sources) != 2 || options.Sources[0] != "source-a" || options.Sources[1] != "source-b" { + t.Fatalf("expected stable source options, got %+v", options.Sources) + } +} + +func TestListUsageAnalysisWithFilterAggregatesApisAndModels(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-analysis.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + events := []models.UsageEvent{ + { + EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), Failed: false, LatencyMS: 100, + InputTokens: 10, OutputTokens: 4, ReasoningTokens: 2, CachedTokens: 1, TotalTokens: 17, + }, + { + EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), Failed: true, LatencyMS: 250, + InputTokens: 20, OutputTokens: 5, ReasoningTokens: 0, CachedTokens: 0, TotalTokens: 25, + }, + { + EventKey: "event-3", APIGroupKey: "provider-b", Model: "gpt-5", + Timestamp: time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC), Failed: false, LatencyMS: 400, + InputTokens: 30, OutputTokens: 7, ReasoningTokens: 3, CachedTokens: 2, TotalTokens: 42, + }, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 9, 30, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 11, 30, 0, 0, time.UTC) + apiRows, modelRows, err := ListUsageAnalysisWithFilter(db, UsageQueryFilter{StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("ListUsageAnalysisWithFilter returned error: %v", err) + } + if len(apiRows) != 2 { + t.Fatalf("expected two api rows, got %d", len(apiRows)) + } + if len(modelRows) != 2 { + t.Fatalf("expected two model rows, got %d", len(modelRows)) + } + if apiRows[0].APIGroupKey != "provider-a" || apiRows[0].TotalRequests != 1 || apiRows[0].FailureCount != 1 || apiRows[0].TotalTokens != 25 { + t.Fatalf("unexpected first api row: %+v", apiRows[0]) + } + modelByName := map[string]UsageAnalysisModelStatRecord{} + for _, row := range modelRows { + modelByName[row.Model] = row + } + if row := modelByName["gpt-5"]; row.Model != "gpt-5" || row.TotalRequests != 1 || row.TotalLatencyMS != 400 || row.LatencySampleCount != 1 { + t.Fatalf("unexpected gpt-5 model row: %+v", row) + } + if row := modelByName["claude-sonnet"]; row.Model != "claude-sonnet" || row.FailureCount != 1 || row.InputTokens != 20 || row.CachedTokens != 0 { + t.Fatalf("unexpected claude-sonnet model row: %+v", row) + } +} diff --git a/internal/repository/usage_filter.go b/internal/repository/usage_filter.go new file mode 100644 index 0000000000000000000000000000000000000000..e27796a88033a07bb80438a09aab1570451fe970 --- /dev/null +++ b/internal/repository/usage_filter.go @@ -0,0 +1,147 @@ +package repository + +import ( + "time" + + "cpa-usage-keeper/internal/cpa" +) + +type UsageQueryFilter struct { + Range string + StartTime *time.Time + EndTime *time.Time + Limit int + Page int + PageSize int + Offset int + Model string + Source string + AuthIndex string + AuthType string + Provider string + Result string +} + +const DefaultUsageEventsLimit = 100 + +type UsageEventsPageRecord struct { + Events []UsageEventRecord + Models []string + Sources []string + TotalCount int64 + Page int + PageSize int + TotalPages int +} + +type UsageEventFilterOptionsRecord struct { + Models []string + Sources []string +} + +type UsageEventRecord struct { + ID uint + Timestamp time.Time + APIGroupKey string + Model string + AuthType string + Provider string + Source string + AuthIndex string + Failed bool + LatencyMS int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 +} + +type UsageCredentialStatRecord struct { + Source string + AuthIndex string + Failed bool + RequestCount int64 +} + +type UsageAnalysisModelStatRecord struct { + Model string + TotalRequests int64 + SuccessCount int64 + FailureCount int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 + TotalLatencyMS int64 + LatencySampleCount int64 +} + +type UsageAnalysisAPIStatRecord struct { + APIGroupKey string + DisplayName string + TotalRequests int64 + SuccessCount int64 + FailureCount int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 + Models []UsageAnalysisModelStatRecord `gorm:"-"` +} + +type UsageOverviewSummaryRecord struct { + RequestCount int64 + TokenCount int64 + WindowMinutes int64 + RPM float64 + TPM float64 + TotalCost float64 + CostAvailable bool + CachedTokens int64 + ReasoningTokens int64 +} + +type UsageOverviewSeriesRecord struct { + Requests map[string]int64 + Tokens map[string]int64 + RPM map[string]float64 + TPM map[string]float64 + Cost map[string]float64 + InputTokens map[string]int64 + OutputTokens map[string]int64 + CachedTokens map[string]int64 + ReasoningTokens map[string]int64 + Models map[string]UsageOverviewSeriesRecord +} + +type UsageOverviewHealthBlockRecord struct { + StartTime time.Time + EndTime time.Time + Success int64 + Failure int64 + Rate float64 +} + +type UsageOverviewHealthRecord struct { + TotalSuccess int64 + TotalFailure int64 + SuccessRate float64 + Rows int + Columns int + BucketSeconds int64 + WindowStart time.Time + WindowEnd time.Time + BlockDetails []UsageOverviewHealthBlockRecord +} + +type UsageOverviewRecord struct { + Usage *cpa.StatisticsSnapshot + Summary UsageOverviewSummaryRecord + Series UsageOverviewSeriesRecord + HourlySeries UsageOverviewSeriesRecord + DailySeries UsageOverviewSeriesRecord + Health UsageOverviewHealthRecord +} diff --git a/internal/repository/usage_filter_test.go b/internal/repository/usage_filter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..f8cca5a67d085b72d8ee2f9fe4abd6d1e95df4ca --- /dev/null +++ b/internal/repository/usage_filter_test.go @@ -0,0 +1,629 @@ +package repository + +import ( + "math" + "path/filepath" + "reflect" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" +) + +func withRepositoryTestLocation(t *testing.T, name string) { + t.Helper() + previousLocal := time.Local + location, err := time.LoadLocation(name) + if err != nil { + t.Fatalf("load location %s: %v", name, err) + } + t.Cleanup(func() { time.Local = previousLocal }) + time.Local = location +} + +func TestBuildUsageSnapshotWithFilterAppliesTimeBounds(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-filter.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), Source: "source-a", AuthIndex: "1", TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), Source: "source-b", AuthIndex: "2", TotalTokens: 20}, + {EventKey: "event-3", APIGroupKey: "provider-b", Model: "claude-opus", Timestamp: time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC), Source: "source-c", AuthIndex: "3", TotalTokens: 30}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 9, 30, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 23, 59, 59, 0, time.UTC) + snapshot, err := BuildUsageSnapshotWithFilter(db, UsageQueryFilter{StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageSnapshotWithFilter returned error: %v", err) + } + + if snapshot.TotalRequests != 1 { + t.Fatalf("expected one in-range request, got %+v", snapshot) + } + if snapshot.TotalTokens != 20 { + t.Fatalf("expected in-range tokens only, got %+v", snapshot) + } + if len(snapshot.APIs) != 1 { + t.Fatalf("expected one API in filtered snapshot, got %+v", snapshot.APIs) + } + if snapshot.RequestsByHour["2026-04-16T10:00:00Z"] != 1 { + t.Fatalf("expected only 10:00 bucket to remain, got %+v", snapshot.RequestsByHour) + } + if _, ok := snapshot.RequestsByHour["2026-04-16T09:00:00Z"]; ok { + t.Fatalf("expected 09:00 bucket to be filtered out, got %+v", snapshot.RequestsByHour) + } +} + +func TestBuildUsageOverviewWithFilterComputesSummaryAndSeries(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + if _, err := UpsertModelPriceSetting(db, ModelPriceSettingInput{ + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }); err != nil { + t.Fatalf("UpsertModelPriceSetting returned error: %v", err) + } + + events := []models.UsageEvent{ + { + EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 16, 9, 15, 0, 0, time.UTC), Failed: false, + InputTokens: 1000, OutputTokens: 500, ReasoningTokens: 100, CachedTokens: 200, TotalTokens: 1800, + }, + { + EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 16, 10, 45, 0, 0, time.UTC), Failed: true, + InputTokens: 2000, OutputTokens: 1000, ReasoningTokens: 50, CachedTokens: 100, TotalTokens: 3150, + }, + { + EventKey: "event-3", APIGroupKey: "provider-a", Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 17, 11, 5, 0, 0, time.UTC), Failed: false, + InputTokens: 500, OutputTokens: 250, ReasoningTokens: 25, CachedTokens: 50, TotalTokens: 825, + }, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 17, 23, 59, 59, 999000000, time.UTC) + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: "7d", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if overview.Summary.RequestCount != 3 || overview.Summary.TokenCount != 5775 { + t.Fatalf("unexpected summary counts: %+v", overview.Summary) + } + if overview.Summary.CachedTokens != 350 || overview.Summary.ReasoningTokens != 175 { + t.Fatalf("unexpected summary token breakdown: %+v", overview.Summary) + } + if overview.Summary.WindowMinutes != 2880 { + t.Fatalf("expected 2880 minute window, got %+v", overview.Summary) + } + if overview.Summary.RPM != 3.0/2880.0 || overview.Summary.TPM != 5775.0/2880.0 { + t.Fatalf("unexpected summary rates: %+v", overview.Summary) + } + if math.Abs(overview.Summary.TotalCost-0.035805) > 0.000000001 { + t.Fatalf("unexpected summary cost: %+v", overview.Summary) + } + if !overview.Summary.CostAvailable { + t.Fatalf("expected summary cost to be available, got %+v", overview.Summary) + } + + if len(overview.Series.Requests) != 2 || overview.Series.Requests["2026-04-16"] != 2 || overview.Series.Requests["2026-04-17"] != 1 { + t.Fatalf("unexpected request series: %+v", overview.Series.Requests) + } + if overview.Series.Tokens["2026-04-16"] != 4950 || overview.Series.Tokens["2026-04-17"] != 825 { + t.Fatalf("unexpected token series: %+v", overview.Series.Tokens) + } + if overview.Series.RPM["2026-04-16"] != 2.0/1440.0 || overview.Series.RPM["2026-04-17"] != 1.0/1440.0 { + t.Fatalf("unexpected rpm series: %+v", overview.Series.RPM) + } + if overview.Series.TPM["2026-04-16"] != 4950.0/1440.0 || overview.Series.TPM["2026-04-17"] != 825.0/1440.0 { + t.Fatalf("unexpected tpm series: %+v", overview.Series.TPM) + } + if math.Abs(overview.Series.Cost["2026-04-16"]-0.03069) > 0.000000001 || math.Abs(overview.Series.Cost["2026-04-17"]-0.005115) > 0.000000001 { + t.Fatalf("unexpected cost series: %+v", overview.Series.Cost) + } + if !reflect.DeepEqual(overview.Series.InputTokens, map[string]int64{"2026-04-16": 3000, "2026-04-17": 500}) { + t.Fatalf("unexpected input token series: %+v", overview.Series.InputTokens) + } + if !reflect.DeepEqual(overview.Series.OutputTokens, map[string]int64{"2026-04-16": 1500, "2026-04-17": 250}) { + t.Fatalf("unexpected output token series: %+v", overview.Series.OutputTokens) + } + if !reflect.DeepEqual(overview.Series.CachedTokens, map[string]int64{"2026-04-16": 300, "2026-04-17": 50}) { + t.Fatalf("unexpected cached token series: %+v", overview.Series.CachedTokens) + } + if !reflect.DeepEqual(overview.Series.ReasoningTokens, map[string]int64{"2026-04-16": 150, "2026-04-17": 25}) { + t.Fatalf("unexpected reasoning token series: %+v", overview.Series.ReasoningTokens) + } + if overview.Health.TotalSuccess != 2 || overview.Health.TotalFailure != 1 { + t.Fatalf("unexpected overview health totals: %+v", overview.Health) + } + expectedSuccessRate := (2.0 / 3.0) * 100.0 + if diff := overview.Health.SuccessRate - expectedSuccessRate; diff < -1e-9 || diff > 1e-9 { + t.Fatalf("unexpected overview health success rate: %+v", overview.Health) + } + if overview.Health.Rows != 7 || overview.Health.Columns != 96 || overview.Health.BucketSeconds != 15*60 { + t.Fatalf("unexpected service health grid metadata: %+v", overview.Health) + } + if overview.Health.WindowStart != time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC) || + overview.Health.WindowEnd != time.Date(2026, 4, 18, 0, 0, 0, 0, time.UTC) { + t.Fatalf("unexpected service health window: %+v", overview.Health) + } + if len(overview.Health.BlockDetails) != overview.Health.Rows*overview.Health.Columns { + t.Fatalf("expected full service health grid, got %d blocks", len(overview.Health.BlockDetails)) + } + firstBlock := overview.Health.BlockDetails[0] + if firstBlock.StartTime != time.Date(2026, 4, 11, 0, 0, 0, 0, time.UTC) || + firstBlock.EndTime != time.Date(2026, 4, 11, 0, 15, 0, 0, time.UTC) || + firstBlock.Success != 0 || firstBlock.Failure != 0 || firstBlock.Rate != -1 { + t.Fatalf("unexpected first health block: %+v", firstBlock) + } + populatedBlock := overview.Health.BlockDetails[517] + if populatedBlock.StartTime != time.Date(2026, 4, 16, 9, 15, 0, 0, time.UTC) || + populatedBlock.EndTime != time.Date(2026, 4, 16, 9, 30, 0, 0, time.UTC) || + populatedBlock.Success != 1 || populatedBlock.Failure != 0 || populatedBlock.Rate != 1 { + t.Fatalf("unexpected populated health block: %+v", populatedBlock) + } + failedBlock := overview.Health.BlockDetails[523] + if failedBlock.StartTime != time.Date(2026, 4, 16, 10, 45, 0, 0, time.UTC) || + failedBlock.EndTime != time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC) || + failedBlock.Success != 0 || failedBlock.Failure != 1 || failedBlock.Rate != 0 { + t.Fatalf("unexpected failed health block: %+v", failedBlock) + } + latestPopulatedBlock := overview.Health.BlockDetails[620] + if latestPopulatedBlock.StartTime != time.Date(2026, 4, 17, 11, 0, 0, 0, time.UTC) || + latestPopulatedBlock.EndTime != time.Date(2026, 4, 17, 11, 15, 0, 0, time.UTC) || + latestPopulatedBlock.Success != 1 || latestPopulatedBlock.Failure != 0 || latestPopulatedBlock.Rate != 1 { + t.Fatalf("unexpected latest populated health block: %+v", latestPopulatedBlock) + } +} + +func TestBuildUsageOverviewFromEventsBuildsSnapshotAndOverviewInOnePass(t *testing.T) { + events := []models.UsageEvent{ + { + EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 16, 9, 15, 0, 0, time.UTC), Failed: false, + InputTokens: 1000, OutputTokens: 500, ReasoningTokens: 100, CachedTokens: 200, TotalTokens: 1800, + Source: "source-a", AuthIndex: "1", LatencyMS: 120, + }, + { + EventKey: "event-2", APIGroupKey: "", Model: "", + Timestamp: time.Date(2026, 4, 16, 10, 45, 0, 0, time.UTC), Failed: true, + InputTokens: 2000, OutputTokens: 1000, ReasoningTokens: 50, CachedTokens: 100, TotalTokens: 3150, + Source: " source-b ", AuthIndex: " 2 ", LatencyMS: 250, + }, + } + filterStart := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) + filterEnd := time.Date(2026, 4, 16, 23, 59, 59, 999000000, time.UTC) + filter := UsageQueryFilter{Range: "24h", StartTime: &filterStart, EndTime: &filterEnd} + pricingByModel := map[string]models.ModelPriceSetting{ + "claude-sonnet": { + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }, + } + + overview := buildUsageOverviewFromEvents(events, filter, pricingByModel) + + if overview.Usage == nil { + t.Fatal("expected usage snapshot to be populated") + } + if overview.Usage.TotalRequests != 2 || overview.Usage.TotalTokens != 4950 { + t.Fatalf("unexpected usage snapshot totals: %+v", overview.Usage) + } + if overview.Usage.SuccessCount != 1 || overview.Usage.FailureCount != 1 { + t.Fatalf("unexpected usage snapshot success/failure counts: %+v", overview.Usage) + } + if overview.Usage.APIs["provider-a"].Models["claude-sonnet"].TotalRequests != 1 { + t.Fatalf("expected provider/model snapshot to be populated, got %+v", overview.Usage.APIs) + } + if overview.Usage.APIs["unknown"].Models["unknown"].TotalRequests != 1 { + t.Fatalf("expected unknown provider/model snapshot to be populated, got %+v", overview.Usage.APIs) + } + if details := overview.Usage.APIs["provider-a"].Models["claude-sonnet"].Details; len(details) != 0 { + t.Fatalf("expected overview snapshot to skip unreturned details, got %+v", details) + } + if details := overview.Usage.APIs["unknown"].Models["unknown"].Details; len(details) != 0 { + t.Fatalf("expected overview snapshot to skip unreturned unknown-model details, got %+v", details) + } + if overview.Summary.RequestCount != 2 || overview.Summary.TokenCount != 4950 { + t.Fatalf("unexpected summary totals: %+v", overview.Summary) + } + if overview.Summary.CachedTokens != 300 || overview.Summary.ReasoningTokens != 150 { + t.Fatalf("unexpected summary token breakdown: %+v", overview.Summary) + } + if overview.Summary.CostAvailable { + t.Fatalf("expected cost to be unavailable when any event model with billable tokens is unpriced, got %+v", overview.Summary) + } + if overview.Series.Requests["2026-04-16T09:00:00Z"] != 1 || overview.Series.Requests["2026-04-16T10:00:00Z"] != 1 { + t.Fatalf("unexpected hourly request series: %+v", overview.Series.Requests) + } + if overview.HourlySeries.Models["claude-sonnet"].Requests["2026-04-16T09:00:00Z"] != 1 { + t.Fatalf("expected claude-sonnet hourly model series, got %+v", overview.HourlySeries.Models) + } + if overview.HourlySeries.Models["unknown"].Tokens["2026-04-16T10:00:00Z"] != 3150 { + t.Fatalf("expected unknown hourly model token series, got %+v", overview.HourlySeries.Models) + } + if overview.DailySeries.Models["claude-sonnet"].Requests["2026-04-16"] != 1 { + t.Fatalf("expected claude-sonnet daily model series, got %+v", overview.DailySeries.Models) + } + if overview.Health.TotalSuccess != 1 || overview.Health.TotalFailure != 1 { + t.Fatalf("unexpected health totals: %+v", overview.Health) + } + if overview.Health.SuccessRate != 50 { + t.Fatalf("expected 50%% success rate, got %+v", overview.Health) + } +} + +func TestBuildUsageOverviewWithFilterBuilds24hHealthGridFor24hRange(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview-health-24h.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + events := []models.UsageEvent{ + {EventKey: "event-success", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 17, 9, 31, 0, 0, time.UTC), Failed: false, TotalTokens: 10}, + {EventKey: "event-failed", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 17, 23, 59, 0, 0, time.UTC), Failed: true, TotalTokens: 20}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 17, 23, 59, 59, 999000000, time.UTC) + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: "24h", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if overview.Health.Rows != 7 || overview.Health.Columns != 96 || overview.Health.BucketSeconds != 129 { + t.Fatalf("unexpected service health grid metadata: rows=%d columns=%d bucket_seconds=%d", overview.Health.Rows, overview.Health.Columns, overview.Health.BucketSeconds) + } + if overview.Health.WindowStart.Before(end.Add(-24*time.Hour)) || overview.Health.WindowStart.After(end.Add(-24*time.Hour).Add(time.Second)) || + overview.Health.WindowEnd.Before(end) || overview.Health.WindowEnd.After(end.Add(time.Second)) { + t.Fatalf("unexpected service health window: %+v", overview.Health) + } + if len(overview.Health.BlockDetails) != 7*96 { + t.Fatalf("expected 24h service health grid, got %d blocks", len(overview.Health.BlockDetails)) + } + + var successBlock *UsageOverviewHealthBlockRecord + var failedBlock *UsageOverviewHealthBlockRecord + for index := range overview.Health.BlockDetails { + block := &overview.Health.BlockDetails[index] + if block.Success == 1 { + successBlock = block + } + if block.Failure == 1 { + failedBlock = block + } + } + if successBlock == nil || successBlock.StartTime.After(events[0].Timestamp) || !successBlock.EndTime.After(events[0].Timestamp) || successBlock.Rate != 1 { + t.Fatalf("unexpected success health block: %+v", successBlock) + } + if failedBlock == nil || failedBlock.StartTime.After(events[1].Timestamp) || !failedBlock.EndTime.After(events[1].Timestamp) || failedBlock.Rate != 0 { + t.Fatalf("unexpected failed health block: %+v", failedBlock) + } +} + +func TestCalculateUsageEventCostDoesNotDoubleChargeReasoningTokens(t *testing.T) { + event := models.UsageEvent{ + InputTokens: 1_000_000, + OutputTokens: 2_000_000, + ReasoningTokens: 3_000_000, + CachedTokens: 400_000, + TotalTokens: 6_400_000, + } + pricing := models.ModelPriceSetting{ + PromptPricePer1M: 10, + CompletionPricePer1M: 20, + CachePricePer1M: 1, + } + + cost := calculateUsageEventCost(event, pricing) + + if cost != 46.4 { + t.Fatalf("expected reasoning tokens not to be added to completion cost, got %f", cost) + } +} + +func TestBuildUsageOverviewWithFilterReturnsUnavailableCostForPartialPricing(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview-partial-pricing.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + if _, err := UpsertModelPriceSetting(db, ModelPriceSettingInput{ + Model: "priced-model", + PromptPricePer1M: 1, + CompletionPricePer1M: 0, + CachePricePer1M: 0, + }); err != nil { + t.Fatalf("UpsertModelPriceSetting returned error: %v", err) + } + + events := []models.UsageEvent{ + { + EventKey: "event-priced", APIGroupKey: "provider-a", Model: "priced-model", + Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), TotalTokens: 1_000_000, + InputTokens: 1_000_000, + }, + { + EventKey: "event-unpriced", APIGroupKey: "provider-a", Model: "unpriced-model", + Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), TotalTokens: 1_000_000, + InputTokens: 1_000_000, + }, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 23, 59, 59, 999000000, time.UTC) + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: "24h", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if overview.Summary.CostAvailable { + t.Fatalf("expected cost to be unavailable when any in-range event model with billable tokens is unpriced, got %+v", overview.Summary) + } + if overview.Summary.TotalCost != 1 { + t.Fatalf("expected priced portion to remain in total cost, got %+v", overview.Summary) + } +} + +func TestBuildUsageOverviewWithFilterReturnsAvailableCostWhenUnpricedEventsHaveNoBillableTokens(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview-zero-token-unpriced.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + if _, err := UpsertModelPriceSetting(db, ModelPriceSettingInput{ + Model: "priced-model", + PromptPricePer1M: 1, + CompletionPricePer1M: 0, + CachePricePer1M: 0, + }); err != nil { + t.Fatalf("UpsertModelPriceSetting returned error: %v", err) + } + + events := []models.UsageEvent{ + { + EventKey: "event-priced", APIGroupKey: "provider-a", Model: "priced-model", + Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), TotalTokens: 1_000_000, + InputTokens: 1_000_000, + }, + { + EventKey: "event-zero-token", APIGroupKey: "provider-a", Model: "unpriced-image-model", + Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), + }, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 23, 59, 59, 999000000, time.UTC) + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: "24h", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if !overview.Summary.CostAvailable { + t.Fatalf("expected zero-token unpriced model not to make cost unavailable, got %+v", overview.Summary) + } + if overview.Summary.TotalCost != 1 { + t.Fatalf("expected priced event cost to remain available, got %+v", overview.Summary) + } +} + +func TestBuildUsageOverviewWithFilterReturnsUnavailableCostWithoutPricing(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview-no-pricing.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + events := []models.UsageEvent{{ + EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 16, 9, 15, 0, 0, time.UTC), TotalTokens: 1800, + InputTokens: 1000, OutputTokens: 500, CachedTokens: 200, + }} + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 23, 59, 59, 999000000, time.UTC) + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: "24h", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if overview.Summary.CostAvailable { + t.Fatalf("expected summary cost to be unavailable, got %+v", overview.Summary) + } + if overview.Summary.TotalCost != 0 { + t.Fatalf("expected zero summary cost without pricing, got %+v", overview.Summary) + } +} + +func TestBuildUsageOverviewWithFilterUsesExactPresetWindowMinutes(t *testing.T) { + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview-preset-window.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + cases := []struct { + name string + rangeName string + start time.Time + end time.Time + expectMinutes int64 + expectBucketKey string + }{ + { + name: "24h stays hourly with 1440 minute window", + rangeName: "24h", + start: time.Date(2026, 4, 16, 12, 0, 0, 0, time.UTC), + end: time.Date(2026, 4, 17, 12, 0, 0, 0, time.UTC), + expectMinutes: 1440, + expectBucketKey: "2026-04-17T12:00:00Z", + }, + { + name: "7d uses daily buckets with 10080 minute window", + rangeName: "7d", + start: time.Date(2026, 4, 10, 0, 0, 0, 0, time.UTC), + end: time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC), + expectMinutes: 10080, + expectBucketKey: "2026-04-17", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + event := models.UsageEvent{ + EventKey: "event-" + tc.rangeName, + APIGroupKey: "provider-a", + Model: "claude-sonnet", + Timestamp: tc.end, + TotalTokens: 25, + InputTokens: 10, + OutputTokens: 15, + ReasoningTokens: 0, + } + if _, _, err := InsertUsageEvents(db, []models.UsageEvent{event}); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: tc.rangeName, StartTime: &tc.start, EndTime: &tc.end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if overview.Summary.WindowMinutes != tc.expectMinutes { + t.Fatalf("expected %d minute window, got %+v", tc.expectMinutes, overview.Summary) + } + if len(overview.Series.Requests) != 1 || overview.Series.Requests[tc.expectBucketKey] != 1 { + t.Fatalf("unexpected request series for %s: %+v", tc.rangeName, overview.Series.Requests) + } + }) + if err := db.Exec("DELETE FROM usage_events").Error; err != nil { + t.Fatalf("DELETE usage_events returned error: %v", err) + } + } +} + +func TestBuildUsageOverviewWithFilterBuildsLatestHourlySeriesForLongRanges(t *testing.T) { + withRepositoryTestLocation(t, "Asia/Shanghai") + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview-hourly-series.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + if _, err := UpsertModelPriceSetting(db, ModelPriceSettingInput{ + Model: "claude-sonnet", + PromptPricePer1M: 1, + CompletionPricePer1M: 0, + CachePricePer1M: 0, + }); err != nil { + t.Fatalf("UpsertModelPriceSetting returned error: %v", err) + } + + events := []models.UsageEvent{ + {EventKey: "event-old", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 17, 8, 0, 0, 0, time.UTC), TotalTokens: 1_000_000, InputTokens: 1_000_000}, + {EventKey: "event-latest-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 23, 22, 15, 0, 0, time.UTC), TotalTokens: 2_000_000, InputTokens: 2_000_000, OutputTokens: 5, CachedTokens: 7, ReasoningTokens: 11}, + {EventKey: "event-latest-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 23, 23, 45, 0, 0, time.UTC), TotalTokens: 3_000_000, InputTokens: 3_000_000, OutputTokens: 13, CachedTokens: 17, ReasoningTokens: 19}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 17, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 23, 23, 59, 59, 999000000, time.UTC) + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: "7d", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if len(overview.Series.Requests) != 2 || overview.Series.Requests["2026-04-17"] != 1 || overview.Series.Requests["2026-04-24"] != 2 { + t.Fatalf("expected main overview series to remain daily for 7d, got %+v", overview.Series.Requests) + } + if _, ok := overview.HourlySeries.Requests["2026-04-17T08:00:00Z"]; ok { + t.Fatalf("expected latest hourly series to exclude buckets before the latest 24 hours, got %+v", overview.HourlySeries.Requests) + } + if overview.HourlySeries.Requests["2026-04-23T22:00:00Z"] != 1 || overview.HourlySeries.Requests["2026-04-23T23:00:00Z"] != 1 { + t.Fatalf("unexpected latest hourly request series: %+v", overview.HourlySeries.Requests) + } + if overview.HourlySeries.Cost["2026-04-23T22:00:00Z"] != 1.999993 || overview.HourlySeries.Cost["2026-04-23T23:00:00Z"] != 2.999983 { + t.Fatalf("unexpected latest hourly cost series: %+v", overview.HourlySeries.Cost) + } + if overview.HourlySeries.InputTokens["2026-04-23T22:00:00Z"] != 2_000_000 || overview.HourlySeries.OutputTokens["2026-04-23T23:00:00Z"] != 13 { + t.Fatalf("unexpected latest hourly token category series: %+v", overview.HourlySeries) + } + if overview.DailySeries.Requests["2026-04-17"] != 1 || overview.DailySeries.Requests["2026-04-24"] != 2 { + t.Fatalf("unexpected daily request series: %+v", overview.DailySeries.Requests) + } +} + +func TestBuildUsageOverviewWithFilterUsesDailyBucketsForLongCustomRanges(t *testing.T) { + withRepositoryTestLocation(t, "Asia/Shanghai") + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-overview-custom-buckets.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC), TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 26, 18, 0, 0, 0, time.UTC), TotalTokens: 20}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 20, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 26, 23, 59, 59, 999000000, time.UTC) + overview, err := BuildUsageOverviewWithFilter(db, UsageQueryFilter{Range: "custom", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + + if overview.Summary.WindowMinutes != 10080 { + t.Fatalf("expected 10080 minute window, got %+v", overview.Summary) + } + if len(overview.Series.Requests) != 2 { + t.Fatalf("expected daily buckets for long custom range, got %+v", overview.Series.Requests) + } + if overview.Series.Requests["2026-04-20"] != 1 || overview.Series.Requests["2026-04-27"] != 1 { + t.Fatalf("expected daily request buckets, got %+v", overview.Series.Requests) + } + if _, ok := overview.Series.Requests["2026-04-20T08:00:00Z"]; ok { + t.Fatalf("expected long custom range not to keep hourly buckets, got %+v", overview.Series.Requests) + } +} diff --git a/internal/repository/usage_identities.go b/internal/repository/usage_identities.go new file mode 100644 index 0000000000000000000000000000000000000000..6b06de5f87d6980e4c8f78d6546a3cfa2952981f --- /dev/null +++ b/internal/repository/usage_identities.go @@ -0,0 +1,278 @@ +package repository + +import ( + "context" + "fmt" + "strings" + "time" + + "cpa-usage-keeper/internal/models" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func ReplaceUsageIdentitiesForAuthType(ctx context.Context, db *gorm.DB, identities []models.UsageIdentity, authType models.UsageIdentityAuthType, now time.Time) error { + if db == nil { + return fmt.Errorf("database is nil") + } + + normalized, incomingIdentities := normalizeUsageIdentities(identities, authType) + + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := upsertUsageIdentities(tx, normalized); err != nil { + return err + } + + query := tx.Model(&models.UsageIdentity{}).Where("auth_type = ?", authType) + if len(incomingIdentities) > 0 { + query = query.Where("identity NOT IN ?", incomingIdentities) + } + if err := query.Updates(map[string]any{ + "is_deleted": true, + "deleted_at": now, + }).Error; err != nil { + return fmt.Errorf("mark stale usage identities deleted: %w", err) + } + + return nil + }) +} + +func ReplaceUsageIdentitiesForProviderTypes(ctx context.Context, db *gorm.DB, identities []models.UsageIdentity, providerTypes []string, now time.Time) error { + if db == nil { + return fmt.Errorf("database is nil") + } + + normalized, incomingIdentities := normalizeUsageIdentities(identities, models.UsageIdentityAuthTypeAIProvider) + types := normalizeProviderTypes(providerTypes) + + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := upsertUsageIdentities(tx, normalized); err != nil { + return err + } + if len(types) == 0 { + return nil + } + + query := tx.Model(&models.UsageIdentity{}). + Where("auth_type = ?", models.UsageIdentityAuthTypeAIProvider). + Where("type IN ?", types) + if len(incomingIdentities) > 0 { + query = query.Where("identity NOT IN ?", incomingIdentities) + } + if err := query.Updates(map[string]any{ + "is_deleted": true, + "deleted_at": now, + }).Error; err != nil { + return fmt.Errorf("mark stale provider usage identities deleted: %w", err) + } + + return nil + }) +} + +func ListUsageIdentities(ctx context.Context, db *gorm.DB) ([]models.UsageIdentity, error) { + if db == nil { + return nil, fmt.Errorf("database is nil") + } + + var identities []models.UsageIdentity + if err := db.WithContext(ctx).Order("auth_type asc, name asc, id asc").Find(&identities).Error; err != nil { + return nil, fmt.Errorf("list usage identities: %w", err) + } + return identities, nil +} + +func AggregateUsageIdentityStats(ctx context.Context, db *gorm.DB, now time.Time) error { + if db == nil { + return fmt.Errorf("database is nil") + } + + var identities []models.UsageIdentity + if err := db.WithContext(ctx).Find(&identities).Error; err != nil { + return fmt.Errorf("list usage identities for aggregation: %w", err) + } + + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + for _, identity := range identities { + delta, err := aggregateUsageIdentityDelta(tx, identity) + if err != nil { + return err + } + if delta.TotalRequests == 0 { + continue + } + + firstUsedAt := identity.FirstUsedAt + if delta.FirstUsedAt != nil && (firstUsedAt == nil || delta.FirstUsedAt.Before(*firstUsedAt)) { + first := *delta.FirstUsedAt + firstUsedAt = &first + } + + lastUsedAt := identity.LastUsedAt + if delta.LastUsedAt != nil && (lastUsedAt == nil || delta.LastUsedAt.After(*lastUsedAt)) { + last := *delta.LastUsedAt + lastUsedAt = &last + } + + updates := map[string]any{ + "total_requests": identity.TotalRequests + delta.TotalRequests, + "success_count": identity.SuccessCount + delta.SuccessCount, + "failure_count": identity.FailureCount + delta.FailureCount, + "input_tokens": identity.InputTokens + delta.InputTokens, + "output_tokens": identity.OutputTokens + delta.OutputTokens, + "reasoning_tokens": identity.ReasoningTokens + delta.ReasoningTokens, + "cached_tokens": identity.CachedTokens + delta.CachedTokens, + "total_tokens": identity.TotalTokens + delta.TotalTokens, + "first_used_at": firstUsedAt, + "last_used_at": lastUsedAt, + "stats_updated_at": now, + "last_aggregated_usage_event_id": delta.MaxUsageEventID, + } + if err := tx.Model(&models.UsageIdentity{}).Where("id = ?", identity.ID).Updates(updates).Error; err != nil { + return fmt.Errorf("update usage identity stats for %q: %w", identity.Identity, err) + } + } + return nil + }) +} + +type usageIdentityStatsDelta struct { + TotalRequests int64 + SuccessCount int64 + FailureCount int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 + FirstUsedAt *time.Time + LastUsedAt *time.Time + MaxUsageEventID uint +} + +func aggregateUsageIdentityDelta(tx *gorm.DB, identity models.UsageIdentity) (usageIdentityStatsDelta, error) { + var delta usageIdentityStatsDelta + query, ok := usageIdentityEventsQuery(tx.Model(&models.UsageEvent{}), identity) + if !ok { + return delta, nil + } + + if err := query. + Select(` + COUNT(*) AS total_requests, + COALESCE(SUM(CASE WHEN failed THEN 0 ELSE 1 END), 0) AS success_count, + COALESCE(SUM(CASE WHEN failed THEN 1 ELSE 0 END), 0) AS failure_count, + COALESCE(SUM(input_tokens), 0) AS input_tokens, + COALESCE(SUM(output_tokens), 0) AS output_tokens, + COALESCE(SUM(reasoning_tokens), 0) AS reasoning_tokens, + COALESCE(SUM(cached_tokens), 0) AS cached_tokens, + COALESCE(SUM(total_tokens), 0) AS total_tokens, + COALESCE(MAX(id), 0) AS max_usage_event_id`). + Where("id > ?", identity.LastAggregatedUsageEventID). + Scan(&delta).Error; err != nil { + return delta, fmt.Errorf("aggregate usage identity stats for %q: %w", identity.Identity, err) + } + if delta.TotalRequests == 0 { + return delta, nil + } + + var firstEvent models.UsageEvent + firstQuery, _ := usageIdentityEventsQuery(tx.Model(&models.UsageEvent{}), identity) + if err := firstQuery.Where("id > ?", identity.LastAggregatedUsageEventID).Order("timestamp asc, id asc").First(&firstEvent).Error; err != nil { + return delta, fmt.Errorf("find first usage identity event for %q: %w", identity.Identity, err) + } + firstUsedAt := firstEvent.Timestamp + delta.FirstUsedAt = &firstUsedAt + + var lastEvent models.UsageEvent + lastQuery, _ := usageIdentityEventsQuery(tx.Model(&models.UsageEvent{}), identity) + if err := lastQuery.Where("id > ?", identity.LastAggregatedUsageEventID).Order("timestamp desc, id desc").First(&lastEvent).Error; err != nil { + return delta, fmt.Errorf("find last usage identity event for %q: %w", identity.Identity, err) + } + lastUsedAt := lastEvent.Timestamp + delta.LastUsedAt = &lastUsedAt + + return delta, nil +} + +func usageIdentityEventsQuery(query *gorm.DB, identity models.UsageIdentity) (*gorm.DB, bool) { + switch identity.AuthType { + case models.UsageIdentityAuthTypeAuthFile: + return query.Where("auth_type = ? AND auth_index = ?", "oauth", identity.Identity), true + case models.UsageIdentityAuthTypeAIProvider: + return query.Where("auth_type = ? AND source = ?", "apikey", identity.Identity), true + default: + return query, false + } +} + +func normalizeUsageIdentities(identities []models.UsageIdentity, authType models.UsageIdentityAuthType) ([]models.UsageIdentity, []string) { + normalized := make([]models.UsageIdentity, 0, len(identities)) + incomingIdentities := make([]string, 0, len(identities)) + seen := make(map[string]struct{}, len(identities)) + + for _, identity := range identities { + key := strings.TrimSpace(identity.Identity) + if key == "" { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + incomingIdentities = append(incomingIdentities, key) + + identity.ID = 0 + identity.AuthType = authType + identity.Identity = key + identity.Name = strings.TrimSpace(identity.Name) + identity.AuthTypeName = strings.TrimSpace(identity.AuthTypeName) + identity.Type = strings.TrimSpace(identity.Type) + identity.Provider = strings.TrimSpace(identity.Provider) + identity.IsDeleted = false + identity.DeletedAt = nil + normalized = append(normalized, identity) + } + + return normalized, incomingIdentities +} + +func normalizeProviderTypes(providerTypes []string) []string { + seen := make(map[string]struct{}, len(providerTypes)) + types := make([]string, 0, len(providerTypes)) + for _, providerType := range providerTypes { + providerType = strings.TrimSpace(providerType) + if providerType == "" { + continue + } + if _, ok := seen[providerType]; ok { + continue + } + seen[providerType] = struct{}{} + types = append(types, providerType) + } + return types +} + +func upsertUsageIdentities(tx *gorm.DB, identities []models.UsageIdentity) error { + if len(identities) == 0 { + return nil + } + + if err := tx.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "auth_type"}, {Name: "identity"}}, + DoUpdates: clause.Assignments(map[string]any{ + "name": gorm.Expr("excluded.name"), + "auth_type_name": gorm.Expr("excluded.auth_type_name"), + "type": gorm.Expr("excluded.type"), + "provider": gorm.Expr("excluded.provider"), + "is_deleted": false, + "deleted_at": nil, + "updated_at": gorm.Expr("excluded.updated_at"), + }), + }).Create(&identities).Error; err != nil { + return fmt.Errorf("upsert usage identities: %w", err) + } + return nil +} diff --git a/internal/repository/usage_identities_test.go b/internal/repository/usage_identities_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a99f49e681327817dd382974deee8dd98a6af75c --- /dev/null +++ b/internal/repository/usage_identities_test.go @@ -0,0 +1,532 @@ +package repository + +import ( + "context" + "testing" + "time" + + "cpa-usage-keeper/internal/models" +) + +func TestUsageIdentityReplaceForAuthTypeMarksStaleRowsDeletedAndPreservesStats(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC) + firstUsedAt := now.Add(-2 * time.Hour) + lastUsedAt := now.Add(-time.Hour) + statsUpdatedAt := now.Add(-30 * time.Minute) + + existingActive := models.UsageIdentity{ + Name: "Old Name", + AuthType: models.UsageIdentityAuthTypeAuthFile, + Identity: "auth-1", + Type: "account", + Provider: "claude", + TotalRequests: 10, + SuccessCount: 8, + FailureCount: 2, + InputTokens: 100, + OutputTokens: 50, + TotalTokens: 150, + LastAggregatedUsageEventID: 42, + FirstUsedAt: &firstUsedAt, + LastUsedAt: &lastUsedAt, + StatsUpdatedAt: &statsUpdatedAt, + } + existingStale := models.UsageIdentity{ + Name: "Stale", + AuthType: models.UsageIdentityAuthTypeAuthFile, + Identity: "auth-stale", + Type: "account", + Provider: "claude", + } + unrelatedProvider := models.UsageIdentity{ + Name: "Provider", + AuthType: models.UsageIdentityAuthTypeAIProvider, + Identity: "provider-1", + Type: "openai", + Provider: "OpenAI", + } + if err := db.Create(&[]models.UsageIdentity{existingActive, existingStale, unrelatedProvider}).Error; err != nil { + t.Fatalf("seed usage identities: %v", err) + } + + err := ReplaceUsageIdentitiesForAuthType(ctx, db, []models.UsageIdentity{ + { + Name: "New Name", + AuthTypeName: "oauth", + Identity: "auth-1", + Type: "account", + Provider: "claude-code", + }, + { + Name: "New Auth", + AuthTypeName: "oauth", + Identity: "auth-2", + Type: "account", + Provider: "claude-code", + }, + }, models.UsageIdentityAuthTypeAuthFile, now) + if err != nil { + t.Fatalf("ReplaceUsageIdentitiesForAuthType returned error: %v", err) + } + + rows, err := ListUsageIdentities(ctx, db) + if err != nil { + t.Fatalf("ListUsageIdentities returned error: %v", err) + } + byIdentity := usageIdentitiesByIdentity(rows) + + updated := byIdentity["auth-1"] + if updated.Name != "New Name" || updated.Provider != "claude-code" || updated.AuthType != models.UsageIdentityAuthTypeAuthFile || updated.IsDeleted { + t.Fatalf("expected active metadata update for auth-1, got %+v", updated) + } + if updated.TotalRequests != 10 || updated.SuccessCount != 8 || updated.FailureCount != 2 || updated.InputTokens != 100 || updated.OutputTokens != 50 || updated.TotalTokens != 150 || updated.LastAggregatedUsageEventID != 42 { + t.Fatalf("expected stats to be preserved, got %+v", updated) + } + if updated.FirstUsedAt == nil || !updated.FirstUsedAt.Equal(firstUsedAt) || updated.LastUsedAt == nil || !updated.LastUsedAt.Equal(lastUsedAt) || updated.StatsUpdatedAt == nil || !updated.StatsUpdatedAt.Equal(statsUpdatedAt) { + t.Fatalf("expected usage timestamps to be preserved, got %+v", updated) + } + + inserted := byIdentity["auth-2"] + if inserted.ID == 0 || inserted.IsDeleted || inserted.AuthType != models.UsageIdentityAuthTypeAuthFile || inserted.Name != "New Auth" { + t.Fatalf("expected active inserted auth-2, got %+v", inserted) + } + + stale := byIdentity["auth-stale"] + if !stale.IsDeleted || stale.DeletedAt == nil || !stale.DeletedAt.Equal(now) { + t.Fatalf("expected stale auth identity to be deleted at %s, got %+v", now, stale) + } + + provider := byIdentity["provider-1"] + if provider.IsDeleted || provider.DeletedAt != nil { + t.Fatalf("expected unrelated provider identity untouched, got %+v", provider) + } +} + +func TestUsageIdentityReplaceForAuthTypeRestoresDeletedIdentity(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + deletedAt := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + now := deletedAt.Add(24 * time.Hour) + + deleted := models.UsageIdentity{ + Name: "Deleted", + AuthType: models.UsageIdentityAuthTypeAuthFile, + AuthTypeName: "oauth", + Identity: "auth-1", + Type: "account", + Provider: "claude", + TotalRequests: 7, + IsDeleted: true, + DeletedAt: &deletedAt, + } + if err := db.Create(&deleted).Error; err != nil { + t.Fatalf("seed deleted identity: %v", err) + } + + err := ReplaceUsageIdentitiesForAuthType(ctx, db, []models.UsageIdentity{ + { + Name: "Restored", + AuthTypeName: "oauth", + Identity: "auth-1", + Type: "account", + Provider: "claude-code", + }, + }, models.UsageIdentityAuthTypeAuthFile, now) + if err != nil { + t.Fatalf("ReplaceUsageIdentitiesForAuthType returned error: %v", err) + } + + rows, err := ListUsageIdentities(ctx, db) + if err != nil { + t.Fatalf("ListUsageIdentities returned error: %v", err) + } + restored := usageIdentitiesByIdentity(rows)["auth-1"] + if restored.IsDeleted || restored.DeletedAt != nil { + t.Fatalf("expected deleted identity to be restored, got %+v", restored) + } + if restored.Name != "Restored" || restored.Provider != "claude-code" || restored.TotalRequests != 7 { + t.Fatalf("expected metadata update with stats preserved, got %+v", restored) + } +} + +func TestUsageIdentityReplaceForProviderTypesMarksOnlyScopedProviderTypesDeleted(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC) + + seed := []models.UsageIdentity{ + {Name: "OpenAI Keep", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "openai-keep", Type: "openai", Provider: "OpenAI", TotalRequests: 3}, + {Name: "OpenAI Stale", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "openai-stale", Type: "openai", Provider: "OpenAI"}, + {Name: "Gemini Untouched", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "gemini-untouched", Type: "gemini", Provider: "Gemini"}, + {Name: "Auth Untouched", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-untouched", Type: "account", Provider: "claude"}, + } + if err := db.Create(&seed).Error; err != nil { + t.Fatalf("seed usage identities: %v", err) + } + + err := ReplaceUsageIdentitiesForProviderTypes(ctx, db, []models.UsageIdentity{ + {Name: "OpenAI Updated", AuthTypeName: "apikey", Identity: "openai-keep", Type: "openai", Provider: "OpenAI"}, + {Name: "Anthropic New", AuthTypeName: "apikey", Identity: "anthropic-new", Type: "anthropic", Provider: "Anthropic"}, + }, []string{"openai", "anthropic"}, now) + if err != nil { + t.Fatalf("ReplaceUsageIdentitiesForProviderTypes returned error: %v", err) + } + + rows, err := ListUsageIdentities(ctx, db) + if err != nil { + t.Fatalf("ListUsageIdentities returned error: %v", err) + } + byIdentity := usageIdentitiesByIdentity(rows) + + openAIKeep := byIdentity["openai-keep"] + if openAIKeep.IsDeleted || openAIKeep.Name != "OpenAI Updated" || openAIKeep.TotalRequests != 3 { + t.Fatalf("expected scoped provider identity updated with stats preserved, got %+v", openAIKeep) + } + + openAIStale := byIdentity["openai-stale"] + if !openAIStale.IsDeleted || openAIStale.DeletedAt == nil || !openAIStale.DeletedAt.Equal(now) { + t.Fatalf("expected missing scoped provider identity to be deleted, got %+v", openAIStale) + } + + gemini := byIdentity["gemini-untouched"] + if gemini.IsDeleted || gemini.DeletedAt != nil { + t.Fatalf("expected unmentioned provider type untouched, got %+v", gemini) + } + + auth := byIdentity["auth-untouched"] + if auth.IsDeleted || auth.DeletedAt != nil { + t.Fatalf("expected auth identity untouched by provider replacement, got %+v", auth) + } + + anthropic := byIdentity["anthropic-new"] + if anthropic.ID == 0 || anthropic.IsDeleted || anthropic.AuthType != models.UsageIdentityAuthTypeAIProvider { + t.Fatalf("expected new provider identity active, got %+v", anthropic) + } +} + +func TestUsageIdentityReplaceForProviderTypesWithEmptyProviderTypesDoesNotDeleteExistingRows(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + deletedAt := time.Date(2026, 5, 3, 10, 0, 0, 0, time.UTC) + now := deletedAt.Add(24 * time.Hour) + + seed := []models.UsageIdentity{ + {Name: "OpenAI Active", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "openai-active", Type: "openai", Provider: "OpenAI"}, + {Name: "Gemini Active", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "gemini-active", Type: "gemini", Provider: "Gemini"}, + {Name: "Deleted Provider", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "provider-restore", Type: "anthropic", Provider: "Anthropic", TotalRequests: 9, IsDeleted: true, DeletedAt: &deletedAt}, + } + if err := db.Create(&seed).Error; err != nil { + t.Fatalf("seed usage identities: %v", err) + } + + err := ReplaceUsageIdentitiesForProviderTypes(ctx, db, []models.UsageIdentity{ + {Name: "Restored Provider", AuthTypeName: "apikey", Identity: "provider-restore", Type: "anthropic", Provider: "Anthropic Updated"}, + }, []string{"", " ", "\t"}, now) + if err != nil { + t.Fatalf("ReplaceUsageIdentitiesForProviderTypes returned error: %v", err) + } + + rows, err := ListUsageIdentities(ctx, db) + if err != nil { + t.Fatalf("ListUsageIdentities returned error: %v", err) + } + byIdentity := usageIdentitiesByIdentity(rows) + + for _, identity := range []string{"openai-active", "gemini-active"} { + row := byIdentity[identity] + if row.IsDeleted || row.DeletedAt != nil { + t.Fatalf("expected existing provider identity %s untouched, got %+v", identity, row) + } + } + + restored := byIdentity["provider-restore"] + if restored.IsDeleted || restored.DeletedAt != nil { + t.Fatalf("expected incoming provider identity restored, got %+v", restored) + } + if restored.Name != "Restored Provider" || restored.Provider != "Anthropic Updated" || restored.AuthTypeName != "apikey" || restored.TotalRequests != 9 { + t.Fatalf("expected incoming provider identity updated with stats preserved, got %+v", restored) + } +} + +func TestUsageIdentityListOrdersByAuthTypeNameIDAndIncludesDeletedRows(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + deletedAt := time.Date(2026, 5, 4, 10, 0, 0, 0, time.UTC) + + seed := []models.UsageIdentity{ + {Name: "Zulu", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "provider-zulu", Type: "openai", Provider: "OpenAI"}, + {Name: "Beta", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-beta-1", Type: "account", Provider: "claude"}, + {Name: "Alpha", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-alpha", Type: "account", Provider: "claude", IsDeleted: true, DeletedAt: &deletedAt}, + {Name: "Beta", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-beta-2", Type: "account", Provider: "claude"}, + {Name: "Alpha", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "provider-alpha", Type: "gemini", Provider: "Gemini", IsDeleted: true, DeletedAt: &deletedAt}, + } + if err := db.Create(&seed).Error; err != nil { + t.Fatalf("seed usage identities: %v", err) + } + + rows, err := ListUsageIdentities(ctx, db) + if err != nil { + t.Fatalf("ListUsageIdentities returned error: %v", err) + } + + got := make([]string, 0, len(rows)) + for _, row := range rows { + deleted := "active" + if row.IsDeleted { + deleted = "deleted" + } + got = append(got, row.Identity+":"+deleted) + } + + want := []string{ + "auth-alpha:deleted", + "auth-beta-1:active", + "auth-beta-2:active", + "provider-alpha:deleted", + "provider-zulu:active", + } + if len(got) != len(want) { + t.Fatalf("expected %d identities, got %d: %v", len(want), len(got), got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected identities ordered by auth_type asc, name asc, id asc including deleted rows\nwant: %v\n got: %v", want, got) + } + } +} + +func TestUsageIdentityAggregateStatsForAuthFileUsesOAuthAuthIndex(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 12, 0, 0, 0, time.UTC) + first := now.Add(-3 * time.Hour) + last := now.Add(-time.Hour) + + identity := models.UsageIdentity{ + Name: "Auth Account", + AuthType: models.UsageIdentityAuthTypeAuthFile, + AuthTypeName: "oauth", + Identity: "auth-1", + Type: "account", + Provider: "claude", + } + if err := db.Create(&identity).Error; err != nil { + t.Fatalf("seed usage identity: %v", err) + } + + events := []models.UsageEvent{ + {EventKey: "auth-1", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-1", Source: "wrong-source", RequestID: "r1", Timestamp: last, Failed: false, InputTokens: 10, OutputTokens: 20, ReasoningTokens: 3, CachedTokens: 4, TotalTokens: 37}, + {EventKey: "auth-2", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-1", Source: "wrong-source", RequestID: "r2", Timestamp: first, Failed: true, InputTokens: 5, OutputTokens: 6, ReasoningTokens: 7, CachedTokens: 8, TotalTokens: 26}, + {EventKey: "auth-ignore-auth-type", APIGroupKey: "g1", AuthType: "apikey", AuthIndex: "auth-1", Source: "auth-1", RequestID: "r3", Timestamp: now, Failed: false, InputTokens: 100, TotalTokens: 100}, + {EventKey: "auth-ignore-index", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "other-auth", Source: "auth-1", RequestID: "r4", Timestamp: now, Failed: false, InputTokens: 100, TotalTokens: 100}, + } + if err := db.Create(&events).Error; err != nil { + t.Fatalf("seed usage events: %v", err) + } + + if err := AggregateUsageIdentityStats(ctx, db, now); err != nil { + t.Fatalf("AggregateUsageIdentityStats returned error: %v", err) + } + + var got models.UsageIdentity + if err := db.First(&got, identity.ID).Error; err != nil { + t.Fatalf("load usage identity: %v", err) + } + if got.TotalRequests != 2 || got.SuccessCount != 1 || got.FailureCount != 1 || got.InputTokens != 15 || got.OutputTokens != 26 || got.ReasoningTokens != 10 || got.CachedTokens != 12 || got.TotalTokens != 63 { + t.Fatalf("expected aggregated auth stats, got %+v", got) + } + if got.FirstUsedAt == nil || !got.FirstUsedAt.Equal(first) || got.LastUsedAt == nil || !got.LastUsedAt.Equal(last) || got.StatsUpdatedAt == nil || !got.StatsUpdatedAt.Equal(now) { + t.Fatalf("expected usage timestamps first=%s last=%s updated=%s, got %+v", first, last, now, got) + } + if got.LastAggregatedUsageEventID != events[1].ID { + t.Fatalf("expected cursor %d, got %d", events[1].ID, got.LastAggregatedUsageEventID) + } +} + +func TestUsageIdentityAggregateStatsForAIProviderUsesAPIKeySourceNotProvider(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 13, 0, 0, 0, time.UTC) + + identity := models.UsageIdentity{Name: "Provider", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "provider-source", Type: "openai", Provider: "Display Provider"} + if err := db.Create(&identity).Error; err != nil { + t.Fatalf("seed usage identity: %v", err) + } + + events := []models.UsageEvent{ + {EventKey: "provider-source-1", APIGroupKey: "g1", Provider: "wrong-provider", AuthType: "apikey", Source: "provider-source", RequestID: "r1", Timestamp: now.Add(-2 * time.Hour), Failed: false, InputTokens: 11, OutputTokens: 12, ReasoningTokens: 13, CachedTokens: 14, TotalTokens: 50}, + {EventKey: "provider-source-2", APIGroupKey: "g1", Provider: "Display Provider", AuthType: "apikey", Source: "provider-source", RequestID: "r2", Timestamp: now.Add(-time.Hour), Failed: true, InputTokens: 1, OutputTokens: 2, ReasoningTokens: 3, CachedTokens: 4, TotalTokens: 10}, + {EventKey: "provider-ignore-provider", APIGroupKey: "g1", Provider: "provider-source", AuthType: "apikey", Source: "other-source", RequestID: "r3", Timestamp: now, Failed: false, InputTokens: 100, TotalTokens: 100}, + {EventKey: "provider-ignore-auth-type", APIGroupKey: "g1", Provider: "wrong-provider", AuthType: "oauth", Source: "provider-source", RequestID: "r4", Timestamp: now, Failed: false, InputTokens: 100, TotalTokens: 100}, + } + if err := db.Create(&events).Error; err != nil { + t.Fatalf("seed usage events: %v", err) + } + + if err := AggregateUsageIdentityStats(ctx, db, now); err != nil { + t.Fatalf("AggregateUsageIdentityStats returned error: %v", err) + } + + var got models.UsageIdentity + if err := db.First(&got, identity.ID).Error; err != nil { + t.Fatalf("load usage identity: %v", err) + } + if got.TotalRequests != 2 || got.SuccessCount != 1 || got.FailureCount != 1 || got.InputTokens != 12 || got.OutputTokens != 14 || got.ReasoningTokens != 16 || got.CachedTokens != 18 || got.TotalTokens != 60 { + t.Fatalf("expected provider stats matched by source, got %+v", got) + } + if got.LastAggregatedUsageEventID != events[1].ID { + t.Fatalf("expected cursor %d, got %d", events[1].ID, got.LastAggregatedUsageEventID) + } +} + +func TestUsageIdentityAggregateStatsSecondRunOnlyIncludesEventsAfterCursor(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 14, 0, 0, 0, time.UTC) + first := now.Add(-2 * time.Hour) + last := now.Add(-time.Hour) + + identity := models.UsageIdentity{Name: "Auth Account", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-cursor", Type: "account", Provider: "claude"} + if err := db.Create(&identity).Error; err != nil { + t.Fatalf("seed usage identity: %v", err) + } + initialEvents := []models.UsageEvent{ + {EventKey: "cursor-1", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-cursor", RequestID: "r1", Timestamp: first, Failed: false, InputTokens: 10, TotalTokens: 10}, + {EventKey: "cursor-2", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-cursor", RequestID: "r2", Timestamp: last, Failed: true, InputTokens: 20, TotalTokens: 20}, + } + if err := db.Create(&initialEvents).Error; err != nil { + t.Fatalf("seed initial usage events: %v", err) + } + if err := AggregateUsageIdentityStats(ctx, db, now); err != nil { + t.Fatalf("first AggregateUsageIdentityStats returned error: %v", err) + } + + newEvent := models.UsageEvent{EventKey: "cursor-3", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-cursor", RequestID: "r3", Timestamp: now, Failed: false, InputTokens: 30, OutputTokens: 5, TotalTokens: 35} + if err := db.Create(&newEvent).Error; err != nil { + t.Fatalf("seed new usage event: %v", err) + } + secondNow := now.Add(time.Hour) + if err := AggregateUsageIdentityStats(ctx, db, secondNow); err != nil { + t.Fatalf("second AggregateUsageIdentityStats returned error: %v", err) + } + + var got models.UsageIdentity + if err := db.First(&got, identity.ID).Error; err != nil { + t.Fatalf("load usage identity: %v", err) + } + if got.TotalRequests != 3 || got.SuccessCount != 2 || got.FailureCount != 1 || got.InputTokens != 60 || got.OutputTokens != 5 || got.TotalTokens != 65 { + t.Fatalf("expected second aggregation to include only new event once, got %+v", got) + } + if got.LastAggregatedUsageEventID != newEvent.ID || got.StatsUpdatedAt == nil || !got.StatsUpdatedAt.Equal(secondNow) { + t.Fatalf("expected cursor %d and updated timestamp %s, got %+v", newEvent.ID, secondNow, got) + } +} + +func TestUsageIdentityAggregateStatsLateTimestampWithLargerIDStillAggregates(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 15, 0, 0, 0, time.UTC) + initialTime := now.Add(-time.Hour) + earlierLateTime := now.Add(-24 * time.Hour) + + identity := models.UsageIdentity{Name: "Auth Late", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-late", Type: "account", Provider: "claude"} + if err := db.Create(&identity).Error; err != nil { + t.Fatalf("seed usage identity: %v", err) + } + initialEvent := models.UsageEvent{EventKey: "late-1", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-late", RequestID: "r1", Timestamp: initialTime, Failed: false, InputTokens: 10, TotalTokens: 10} + if err := db.Create(&initialEvent).Error; err != nil { + t.Fatalf("seed initial event: %v", err) + } + if err := AggregateUsageIdentityStats(ctx, db, now); err != nil { + t.Fatalf("first AggregateUsageIdentityStats returned error: %v", err) + } + + lateEvent := models.UsageEvent{EventKey: "late-2", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-late", RequestID: "r2", Timestamp: earlierLateTime, Failed: true, InputTokens: 20, TotalTokens: 20} + if err := db.Create(&lateEvent).Error; err != nil { + t.Fatalf("seed late event: %v", err) + } + if err := AggregateUsageIdentityStats(ctx, db, now.Add(time.Hour)); err != nil { + t.Fatalf("second AggregateUsageIdentityStats returned error: %v", err) + } + + var got models.UsageIdentity + if err := db.First(&got, identity.ID).Error; err != nil { + t.Fatalf("load usage identity: %v", err) + } + if got.TotalRequests != 2 || got.SuccessCount != 1 || got.FailureCount != 1 || got.InputTokens != 30 || got.TotalTokens != 30 { + t.Fatalf("expected late timestamp event with larger DB id aggregated, got %+v", got) + } + if got.FirstUsedAt == nil || !got.FirstUsedAt.Equal(earlierLateTime) || got.LastUsedAt == nil || !got.LastUsedAt.Equal(initialTime) || got.LastAggregatedUsageEventID != lateEvent.ID { + t.Fatalf("expected first_used_at to move earlier and cursor to late event id %d, got %+v", lateEvent.ID, got) + } +} + +func TestUsageIdentityAggregateStatsUsesDatabaseIDNotRequestIDOrdering(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 16, 0, 0, 0, time.UTC) + + identity := models.UsageIdentity{Name: "Auth Request", AuthType: models.UsageIdentityAuthTypeAuthFile, AuthTypeName: "oauth", Identity: "auth-request", Type: "account", Provider: "claude"} + if err := db.Create(&identity).Error; err != nil { + t.Fatalf("seed usage identity: %v", err) + } + events := []models.UsageEvent{ + {EventKey: "request-1", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-request", RequestID: "z-last-lexically", Timestamp: now.Add(-2 * time.Hour), Failed: false, InputTokens: 10, TotalTokens: 10}, + {EventKey: "request-2", APIGroupKey: "g1", AuthType: "oauth", AuthIndex: "auth-request", RequestID: "a-first-lexically", Timestamp: now.Add(-time.Hour), Failed: false, InputTokens: 20, TotalTokens: 20}, + } + if err := db.Create(&events).Error; err != nil { + t.Fatalf("seed usage events: %v", err) + } + if err := AggregateUsageIdentityStats(ctx, db, now); err != nil { + t.Fatalf("AggregateUsageIdentityStats returned error: %v", err) + } + + var got models.UsageIdentity + if err := db.First(&got, identity.ID).Error; err != nil { + t.Fatalf("load usage identity: %v", err) + } + if got.TotalRequests != 2 || got.InputTokens != 30 || got.TotalTokens != 30 || got.LastAggregatedUsageEventID != events[1].ID { + t.Fatalf("expected unordered request_id values aggregated by DB id, got %+v", got) + } +} + +func TestUsageIdentityAggregateStatsDeletedIdentityStillAggregates(t *testing.T) { + db := openTestDatabase(t) + ctx := context.Background() + now := time.Date(2026, 5, 4, 17, 0, 0, 0, time.UTC) + deletedAt := now.Add(-time.Hour) + + identity := models.UsageIdentity{Name: "Deleted Provider", AuthType: models.UsageIdentityAuthTypeAIProvider, AuthTypeName: "apikey", Identity: "deleted-source", Type: "openai", Provider: "OpenAI", IsDeleted: true, DeletedAt: &deletedAt} + if err := db.Create(&identity).Error; err != nil { + t.Fatalf("seed deleted usage identity: %v", err) + } + event := models.UsageEvent{EventKey: "deleted-1", APIGroupKey: "g1", AuthType: "apikey", Source: "deleted-source", RequestID: "r1", Timestamp: now, Failed: false, InputTokens: 10, OutputTokens: 5, TotalTokens: 15} + if err := db.Create(&event).Error; err != nil { + t.Fatalf("seed usage event: %v", err) + } + + if err := AggregateUsageIdentityStats(ctx, db, now); err != nil { + t.Fatalf("AggregateUsageIdentityStats returned error: %v", err) + } + + var got models.UsageIdentity + if err := db.First(&got, identity.ID).Error; err != nil { + t.Fatalf("load usage identity: %v", err) + } + if !got.IsDeleted || got.DeletedAt == nil || !got.DeletedAt.Equal(deletedAt) { + t.Fatalf("expected deleted state preserved, got %+v", got) + } + if got.TotalRequests != 1 || got.SuccessCount != 1 || got.FailureCount != 0 || got.InputTokens != 10 || got.OutputTokens != 5 || got.TotalTokens != 15 || got.LastAggregatedUsageEventID != event.ID { + t.Fatalf("expected deleted identity to aggregate matching event, got %+v", got) + } +} + +func usageIdentitiesByIdentity(rows []models.UsageIdentity) map[string]models.UsageIdentity { + result := make(map[string]models.UsageIdentity, len(rows)) + for _, row := range rows { + result[row.Identity] = row + } + return result +} diff --git a/internal/repository/usage_test.go b/internal/repository/usage_test.go new file mode 100644 index 0000000000000000000000000000000000000000..785fd25ba44242363be9d683ad179d288b9cd941 --- /dev/null +++ b/internal/repository/usage_test.go @@ -0,0 +1,190 @@ +package repository + +import ( + "path/filepath" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" + "gorm.io/gorm" +) + +func TestBuildUsageSnapshotReturnsEmptyStructureWithoutEvents(t *testing.T) { + db := openUsageTestDatabase(t) + + snapshot, err := BuildUsageSnapshot(db) + if err != nil { + t.Fatalf("BuildUsageSnapshot returned error: %v", err) + } + if snapshot.TotalRequests != 0 || snapshot.TotalTokens != 0 || snapshot.SuccessCount != 0 || snapshot.FailureCount != 0 { + t.Fatalf("expected empty totals, got %+v", snapshot) + } + if len(snapshot.APIs) != 0 || len(snapshot.RequestsByDay) != 0 || len(snapshot.RequestsByHour) != 0 { + t.Fatalf("expected empty aggregates, got %+v", snapshot) + } +} + +func TestBuildUsageSnapshotAggregatesEvents(t *testing.T) { + db := openUsageTestDatabase(t) + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), Source: "codex-a", AuthIndex: "1", Failed: false, LatencyMS: 100, InputTokens: 10, OutputTokens: 20, ReasoningTokens: 5, CachedTokens: 0, TotalTokens: 35}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), Source: "codex-b", AuthIndex: "2", Failed: true, LatencyMS: 200, InputTokens: 2, OutputTokens: 3, ReasoningTokens: 0, CachedTokens: 0, TotalTokens: 5}, + {EventKey: "event-3", APIGroupKey: "provider-b", Model: "claude-opus", Timestamp: time.Date(2026, 4, 17, 10, 0, 0, 0, time.UTC), Source: "codex-c", AuthIndex: "3", Failed: false, LatencyMS: 300, InputTokens: 100, OutputTokens: 50, ReasoningTokens: 25, CachedTokens: 10, TotalTokens: 185}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + snapshot, err := BuildUsageSnapshot(db) + if err != nil { + t.Fatalf("BuildUsageSnapshot returned error: %v", err) + } + if snapshot.TotalRequests != 3 || snapshot.SuccessCount != 2 || snapshot.FailureCount != 1 || snapshot.TotalTokens != 225 { + t.Fatalf("unexpected totals: %+v", snapshot) + } + if snapshot.RequestsByDay["2026-04-16"] != 2 || snapshot.RequestsByDay["2026-04-17"] != 1 { + t.Fatalf("unexpected requests by day: %+v", snapshot.RequestsByDay) + } + if snapshot.TokensByHour["2026-04-16T09:00:00Z"] != 35 || snapshot.TokensByHour["2026-04-17T10:00:00Z"] != 185 { + t.Fatalf("unexpected tokens by hour: %+v", snapshot.TokensByHour) + } + providerA := snapshot.APIs["provider-a"] + if providerA.TotalRequests != 2 || providerA.TotalTokens != 40 { + t.Fatalf("unexpected provider-a stats: %+v", providerA) + } + model := providerA.Models["claude-sonnet"] + if model.TotalRequests != 2 || model.TotalTokens != 40 || len(model.Details) != 2 { + t.Fatalf("unexpected model stats: %+v", model) + } + if !model.Details[0].Timestamp.Before(model.Details[1].Timestamp) { + t.Fatalf("expected details to be sorted ascending, got %+v", model.Details) + } +} + +func TestBuildUsageSnapshotBucketsDaysByLocalTime(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + time.Local = location + t.Cleanup(func() { time.Local = previousLocal }) + db := openUsageTestDatabase(t) + events := []models.UsageEvent{{ + EventKey: "event-local-day", + APIGroupKey: "provider-a", + Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 16, 23, 30, 0, 0, time.UTC), + TotalTokens: 20, + }} + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + snapshot, err := BuildUsageSnapshot(db) + if err != nil { + t.Fatalf("BuildUsageSnapshot returned error: %v", err) + } + if snapshot.RequestsByDay["2026-04-17"] != 1 { + t.Fatalf("expected event to be bucketed by local day, got %+v", snapshot.RequestsByDay) + } +} + +func TestUsageOverviewDailyBucketUsesLocalTime(t *testing.T) { + previousLocal := time.Local + location, err := time.LoadLocation("Asia/Shanghai") + if err != nil { + t.Fatalf("load location: %v", err) + } + time.Local = location + t.Cleanup(func() { time.Local = previousLocal }) + + bucketKey, bucketMinutes := usageOverviewBucket(time.Date(2026, 4, 16, 23, 30, 0, 0, time.UTC), true) + + if bucketKey != "2026-04-17" || bucketMinutes != 24*60 { + t.Fatalf("expected local day bucket 2026-04-17/1440, got %s/%d", bucketKey, bucketMinutes) + } +} + +func TestBuildUsageSnapshotPreservesStoredAPIKey(t *testing.T) { + db := openUsageTestDatabase(t) + events := []models.UsageEvent{{ + EventKey: "event-1", + APIGroupKey: "sk-live-secret-value", + Model: "claude-sonnet", + Timestamp: time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC), + Source: "source-a", + AuthIndex: "1", + TotalTokens: 20, + }} + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + snapshot, err := BuildUsageSnapshot(db) + if err != nil { + t.Fatalf("BuildUsageSnapshot returned error: %v", err) + } + if _, ok := snapshot.APIs["sk-live-secret-value"]; !ok { + t.Fatalf("expected repository snapshot to preserve stored API key") + } +} + +func TestUsageAggregatesApplyModelSourceAuthAndResultFilters(t *testing.T) { + db := openUsageTestDatabase(t) + events := []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), Source: "source-a", AuthIndex: "1", Failed: false, TotalTokens: 35}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), Source: "source-a", AuthIndex: "1", Failed: true, TotalTokens: 5}, + {EventKey: "event-3", APIGroupKey: "provider-b", Model: "claude-opus", Timestamp: time.Date(2026, 4, 16, 11, 0, 0, 0, time.UTC), Source: "source-b", AuthIndex: "2", Failed: false, TotalTokens: 185}, + } + if _, _, err := InsertUsageEvents(db, events); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + filter := UsageQueryFilter{Model: "claude-sonnet", Source: "source-a", AuthIndex: "1", Result: "success"} + + snapshot, err := BuildUsageSnapshotWithFilter(db, filter) + if err != nil { + t.Fatalf("BuildUsageSnapshotWithFilter returned error: %v", err) + } + if snapshot.TotalRequests != 1 || snapshot.SuccessCount != 1 || snapshot.FailureCount != 0 || snapshot.TotalTokens != 35 { + t.Fatalf("expected snapshot to include only matching successful event, got %+v", snapshot) + } + + overview, err := BuildUsageOverviewWithFilter(db, filter) + if err != nil { + t.Fatalf("BuildUsageOverviewWithFilter returned error: %v", err) + } + if overview.Summary.RequestCount != 1 || overview.Summary.TokenCount != 35 { + t.Fatalf("expected overview to include only matching successful event, got %+v", overview.Summary) + } + + credentials, err := ListUsageCredentialStatsWithFilter(db, filter) + if err != nil { + t.Fatalf("ListUsageCredentialStatsWithFilter returned error: %v", err) + } + if len(credentials) != 1 || credentials[0].Source != "source-a" || credentials[0].AuthIndex != "1" || credentials[0].Failed || credentials[0].RequestCount != 1 { + t.Fatalf("expected credential stats to include only matching successful event, got %+v", credentials) + } + + apis, models, err := ListUsageAnalysisWithFilter(db, filter) + if err != nil { + t.Fatalf("ListUsageAnalysisWithFilter returned error: %v", err) + } + if len(apis) != 1 || apis[0].APIGroupKey != "provider-a" || apis[0].TotalRequests != 1 || apis[0].FailureCount != 0 { + t.Fatalf("expected analysis API stats to include only matching successful event, got %+v", apis) + } + if len(models) != 1 || models[0].Model != "claude-sonnet" || models[0].TotalRequests != 1 || models[0].FailureCount != 0 { + t.Fatalf("expected analysis model stats to include only matching successful event, got %+v", models) + } +} + +func openUsageTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + db, err := OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + return db +} diff --git a/internal/service/flatten.go b/internal/service/flatten.go new file mode 100644 index 0000000000000000000000000000000000000000..0e470f0d01d8053ea596181afe26d5e1ad21dedc --- /dev/null +++ b/internal/service/flatten.go @@ -0,0 +1,48 @@ +package service + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "strings" + "time" + + "cpa-usage-keeper/internal/cpa" +) + +func BuildEventKey(apiGroupKey, model string, timestamp time.Time, source, authIndex string, failed bool, tokens cpa.TokenStats) string { + normalized := normalizeTokens(tokens) + payload := fmt.Sprintf( + "%s|%s|%s|%s|%s|%t|%d|%d|%d|%d|%d", + strings.TrimSpace(apiGroupKey), + strings.TrimSpace(model), + timestamp.UTC().Format(time.RFC3339Nano), + strings.TrimSpace(source), + strings.TrimSpace(authIndex), + failed, + normalized.InputTokens, + normalized.OutputTokens, + normalized.ReasoningTokens, + normalized.CachedTokens, + normalized.TotalTokens, + ) + sum := sha256.Sum256([]byte(payload)) + return hex.EncodeToString(sum[:]) +} + +func normalizeTokens(tokens cpa.TokenStats) cpa.TokenStats { + if tokens.TotalTokens == 0 { + tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + } + if tokens.TotalTokens == 0 { + tokens.TotalTokens = tokens.InputTokens + tokens.OutputTokens + tokens.ReasoningTokens + tokens.CachedTokens + } + return tokens +} + +func max(value, floor int64) int64 { + if value < floor { + return floor + } + return value +} diff --git a/internal/service/flatten_test.go b/internal/service/flatten_test.go new file mode 100644 index 0000000000000000000000000000000000000000..5ae85928faa7d4d7c46fe5620672a0f836b480cf --- /dev/null +++ b/internal/service/flatten_test.go @@ -0,0 +1,20 @@ +package service + +import ( + "testing" + "time" + + "cpa-usage-keeper/internal/cpa" +) + +func TestBuildEventKeyIsStable(t *testing.T) { + timestamp := time.Date(2026, 4, 16, 12, 0, 0, 123, time.UTC) + tokens := cpa.TokenStats{InputTokens: 1, OutputTokens: 2, ReasoningTokens: 3, CachedTokens: 4, TotalTokens: 10} + + key1 := BuildEventKey("provider-a", "claude-sonnet", timestamp, "source-a", "0", false, tokens) + key2 := BuildEventKey("provider-a", "claude-sonnet", timestamp, "source-a", "0", false, tokens) + + if key1 != key2 { + t.Fatalf("expected stable event key, got %s and %s", key1, key2) + } +} diff --git a/internal/service/pricing_service.go b/internal/service/pricing_service.go new file mode 100644 index 0000000000000000000000000000000000000000..b5a811b09f24bebe62e1af07a5b28b06276f17aa --- /dev/null +++ b/internal/service/pricing_service.go @@ -0,0 +1,123 @@ +package service + +import ( + "context" + "fmt" + "sort" + "strings" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/repository" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type PricingProvider interface { + ListUsedModels(context.Context) ([]string, error) + ListPricing(context.Context) ([]models.ModelPriceSetting, error) + UpdatePricing(context.Context, UpdatePricingInput) (*models.ModelPriceSetting, error) + DeletePricing(context.Context, string) error +} + +type ModelsFetcher interface { + FetchModels(context.Context) (*cpa.ModelsResult, error) +} + +type UpdatePricingInput struct { + Model string + PromptPricePer1M float64 + CompletionPricePer1M float64 + CachePricePer1M float64 +} + +type pricingService struct { + db *gorm.DB + modelsFetcher ModelsFetcher +} + +func NewPricingService(db *gorm.DB, modelsFetcher ...ModelsFetcher) PricingProvider { + service := &pricingService{db: db} + if len(modelsFetcher) > 0 { + service.modelsFetcher = modelsFetcher[0] + } + return service +} + +func (s *pricingService) ListUsedModels(ctx context.Context) ([]string, error) { + return s.effectiveModels(ctx) +} + +func (s *pricingService) ListPricing(context.Context) ([]models.ModelPriceSetting, error) { + return repository.ListModelPriceSettings(s.db) +} + +func (s *pricingService) UpdatePricing(ctx context.Context, input UpdatePricingInput) (*models.ModelPriceSetting, error) { + modelName := strings.TrimSpace(input.Model) + if modelName == "" { + return nil, fmt.Errorf("model is required") + } + if input.PromptPricePer1M < 0 || input.CompletionPricePer1M < 0 || input.CachePricePer1M < 0 { + return nil, fmt.Errorf("prices must be non-negative") + } + + usedModels, err := s.effectiveModels(ctx) + if err != nil { + return nil, err + } + index := make(map[string]struct{}, len(usedModels)) + for _, model := range usedModels { + index[model] = struct{}{} + } + if _, ok := index[modelName]; !ok { + sort.Strings(usedModels) + return nil, fmt.Errorf("model %q has not been used", modelName) + } + + return repository.UpsertModelPriceSetting(s.db, repository.ModelPriceSettingInput{ + Model: modelName, + PromptPricePer1M: input.PromptPricePer1M, + CompletionPricePer1M: input.CompletionPricePer1M, + CachePricePer1M: input.CachePricePer1M, + }) +} + +func (s *pricingService) DeletePricing(_ context.Context, model string) error { + return repository.DeleteModelPriceSetting(s.db, model) +} + +func (s *pricingService) effectiveModels(ctx context.Context) ([]string, error) { + if s.modelsFetcher == nil { + return repository.ListUsedModels(s.db) + } + + result, err := s.modelsFetcher.FetchModels(ctx) + if err != nil { + logrus.WithError(err).Error("pricing model listing falling back to local usage aggregation") + return repository.ListUsedModels(s.db) + } + + logrus.Debug("pricing model listing using CPA models endpoint") + return normalizeCPAModels(result), nil +} + +func normalizeCPAModels(result *cpa.ModelsResult) []string { + if result == nil { + return []string{} + } + seen := make(map[string]struct{}, len(result.Payload.Data)) + models := make([]string, 0, len(result.Payload.Data)) + for _, model := range result.Payload.Data { + id := strings.TrimSpace(model.ID) + if id == "" { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + models = append(models, id) + } + sort.Strings(models) + return models +} diff --git a/internal/service/pricing_service_test.go b/internal/service/pricing_service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..6bb941f0758a1f8c4f7e8ce424659785e257efe0 --- /dev/null +++ b/internal/service/pricing_service_test.go @@ -0,0 +1,252 @@ +package service + +import ( + "bytes" + "context" + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/repository" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +func TestPricingServiceRejectsUnusedModel(t *testing.T) { + db := openPricingServiceTestDatabase(t) + service := NewPricingService(db) + + _, err := service.UpdatePricing(context.Background(), UpdatePricingInput{ + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }) + if err == nil || !strings.Contains(err.Error(), "has not been used") { + t.Fatalf("expected unused model error, got %v", err) + } +} + +func TestPricingServiceStoresPricingForUsedModel(t *testing.T) { + db := openPricingServiceTestDatabase(t) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "evt-1", + Model: "claude-sonnet", + Timestamp: time.Unix(1, 0), + APIGroupKey: "provider-a", + }}); err != nil { + t.Fatalf("insert usage event: %v", err) + } + + service := NewPricingService(db) + setting, err := service.UpdatePricing(context.Background(), UpdatePricingInput{ + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }) + if err != nil { + t.Fatalf("update pricing: %v", err) + } + if setting.Model != "claude-sonnet" || setting.CompletionPricePer1M != 15 { + t.Fatalf("unexpected setting: %#v", setting) + } + + usedModels, err := service.ListUsedModels(context.Background()) + if err != nil { + t.Fatalf("list used models: %v", err) + } + if len(usedModels) != 1 || usedModels[0] != "claude-sonnet" { + t.Fatalf("unexpected used models: %#v", usedModels) + } +} + +func TestPricingServiceListsModelsFromCPAWhenAvailable(t *testing.T) { + db := openPricingServiceTestDatabase(t) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "evt-local", + Model: "local-model", + Timestamp: time.Unix(1, 0), + APIGroupKey: "provider-a", + }}); err != nil { + t.Fatalf("insert usage event: %v", err) + } + logs := captureDebugLogs(t) + + service := NewPricingService(db, stubModelsFetcher{result: &cpa.ModelsResult{Payload: cpa.ModelsResponse{Data: []cpa.ModelInfo{ + {ID: " zeta-model "}, + {ID: "alpha-model"}, + {ID: "zeta-model"}, + {ID: ""}, + }}}}) + modelsList, err := service.ListUsedModels(context.Background()) + if err != nil { + t.Fatalf("list models: %v", err) + } + + expected := []string{"alpha-model", "zeta-model"} + if strings.Join(modelsList, ",") != strings.Join(expected, ",") { + t.Fatalf("expected CPA models %#v, got %#v", expected, modelsList) + } + if !strings.Contains(logs.String(), "using CPA models endpoint") { + t.Fatalf("expected CPA source debug log, got %q", logs.String()) + } +} + +func TestPricingServiceFallsBackToLocalModelsWhenCPAFetchFails(t *testing.T) { + db := openPricingServiceTestDatabase(t) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "evt-local", + Model: "local-model", + Timestamp: time.Unix(1, 0), + APIGroupKey: "provider-a", + }}); err != nil { + t.Fatalf("insert usage event: %v", err) + } + logs := captureDebugLogs(t) + + service := NewPricingService(db, stubModelsFetcher{err: errors.New("cpa unavailable")}) + modelsList, err := service.ListUsedModels(context.Background()) + if err != nil { + t.Fatalf("list models: %v", err) + } + + if len(modelsList) != 1 || modelsList[0] != "local-model" { + t.Fatalf("expected local fallback model, got %#v", modelsList) + } + if !strings.Contains(logs.String(), "level=error") { + t.Fatalf("expected fallback error log, got %q", logs.String()) + } + if !strings.Contains(logs.String(), "falling back to local usage aggregation") { + t.Fatalf("expected fallback error log, got %q", logs.String()) + } + if !strings.Contains(logs.String(), "error=\"cpa unavailable\"") && !strings.Contains(logs.String(), "error=cpa unavailable") { + t.Fatalf("expected fallback log to include original error, got %q", logs.String()) + } +} + +func TestPricingServiceReturnsEmptyCPAListWithoutFallback(t *testing.T) { + db := openPricingServiceTestDatabase(t) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "evt-local", + Model: "local-model", + Timestamp: time.Unix(1, 0), + APIGroupKey: "provider-a", + }}); err != nil { + t.Fatalf("insert usage event: %v", err) + } + + service := NewPricingService(db, stubModelsFetcher{result: &cpa.ModelsResult{Payload: cpa.ModelsResponse{Data: []cpa.ModelInfo{{ID: " "}}}}}) + modelsList, err := service.ListUsedModels(context.Background()) + if err != nil { + t.Fatalf("list models: %v", err) + } + if len(modelsList) != 0 { + t.Fatalf("expected empty CPA model list, got %#v", modelsList) + } +} + +func TestPricingServiceAllowsPricingForCPAModelWithoutUsage(t *testing.T) { + db := openPricingServiceTestDatabase(t) + service := NewPricingService(db, stubModelsFetcher{result: &cpa.ModelsResult{Payload: cpa.ModelsResponse{Data: []cpa.ModelInfo{{ID: "claude-opus"}}}}}) + + setting, err := service.UpdatePricing(context.Background(), UpdatePricingInput{ + Model: "claude-opus", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }) + if err != nil { + t.Fatalf("update pricing: %v", err) + } + if setting.Model != "claude-opus" { + t.Fatalf("unexpected setting: %#v", setting) + } +} + +func TestPricingServiceRejectsLocalOnlyModelWhenCPAFetchSucceeds(t *testing.T) { + db := openPricingServiceTestDatabase(t) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "evt-local", + Model: "local-model", + Timestamp: time.Unix(1, 0), + APIGroupKey: "provider-a", + }}); err != nil { + t.Fatalf("insert usage event: %v", err) + } + service := NewPricingService(db, stubModelsFetcher{result: &cpa.ModelsResult{Payload: cpa.ModelsResponse{Data: []cpa.ModelInfo{{ID: "cpa-model"}}}}}) + + _, err := service.UpdatePricing(context.Background(), UpdatePricingInput{ + Model: "local-model", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }) + if err == nil || !strings.Contains(err.Error(), "has not been used") { + t.Fatalf("expected local-only model rejection, got %v", err) + } +} + +func TestPricingServiceValidatesWithLocalModelsWhenCPAFetchFails(t *testing.T) { + db := openPricingServiceTestDatabase(t) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "evt-local", + Model: "local-model", + Timestamp: time.Unix(1, 0), + APIGroupKey: "provider-a", + }}); err != nil { + t.Fatalf("insert usage event: %v", err) + } + service := NewPricingService(db, stubModelsFetcher{err: errors.New("cpa unavailable")}) + + setting, err := service.UpdatePricing(context.Background(), UpdatePricingInput{ + Model: "local-model", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }) + if err != nil { + t.Fatalf("update pricing: %v", err) + } + if setting.Model != "local-model" { + t.Fatalf("unexpected setting: %#v", setting) + } +} + +type stubModelsFetcher struct { + result *cpa.ModelsResult + err error +} + +func (s stubModelsFetcher) FetchModels(context.Context) (*cpa.ModelsResult, error) { + return s.result, s.err +} + +func captureDebugLogs(t *testing.T) *bytes.Buffer { + t.Helper() + previousOutput := logrus.StandardLogger().Out + previousLevel := logrus.GetLevel() + var logs bytes.Buffer + logrus.SetOutput(&logs) + logrus.SetLevel(logrus.DebugLevel) + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + logrus.SetLevel(previousLevel) + }) + return &logs +} + +func openPricingServiceTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + db, err := repository.OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "pricing-service.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + return db +} diff --git a/internal/service/redis_usage.go b/internal/service/redis_usage.go new file mode 100644 index 0000000000000000000000000000000000000000..1214c671f7a7a7389a4ab0c4a731860cbd8d4510 --- /dev/null +++ b/internal/service/redis_usage.go @@ -0,0 +1,83 @@ +package service + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/models" +) + +type RedisQueue interface { + PopUsage(ctx context.Context) ([]string, error) +} + +func DecodeRedisUsageMessage(message string, fetchedAt time.Time) (models.UsageEvent, json.RawMessage, error) { + raw := json.RawMessage(message) + var payload queuedUsageDetail + if err := json.Unmarshal(raw, &payload); err != nil { + return models.UsageEvent{}, nil, fmt.Errorf("decode redis usage message: %w", err) + } + return payload.toUsageEvent(fetchedAt), raw, nil +} + +type queuedUsageDetail struct { + Timestamp time.Time `json:"timestamp"` + LatencyMS int64 `json:"latency_ms"` + Source string `json:"source"` + AuthIndex string `json:"auth_index"` + Tokens cpa.TokenStats `json:"tokens"` + Failed bool `json:"failed"` + Provider string `json:"provider"` + Model string `json:"model"` + Endpoint string `json:"endpoint"` + AuthType string `json:"auth_type"` + APIKey string `json:"api_key"` + RequestID string `json:"request_id"` +} + +func normalizeRedisAuthType(value string) string { + trimmed := strings.ToLower(strings.TrimSpace(value)) + if trimmed == "api_key" { + return "apikey" + } + return trimmed +} + +func (d queuedUsageDetail) toUsageEvent(fetchedAt time.Time) models.UsageEvent { + tokens := normalizeTokens(d.Tokens) + apiGroupKey := firstNonEmpty(d.APIKey, d.Provider, d.Endpoint, "unknown") + model := firstNonEmpty(d.Model, "unknown") + timestamp := d.Timestamp.UTC() + if timestamp.IsZero() { + timestamp = fetchedAt.UTC() + } + source := strings.TrimSpace(d.Source) + authIndex := strings.TrimSpace(d.AuthIndex) + eventKey := strings.TrimSpace(d.RequestID) + if eventKey == "" { + eventKey = BuildEventKey(apiGroupKey, model, timestamp, source, authIndex, d.Failed, tokens) + } + return models.UsageEvent{ + EventKey: eventKey, + APIGroupKey: apiGroupKey, + Provider: strings.TrimSpace(d.Provider), + Endpoint: strings.TrimSpace(d.Endpoint), + AuthType: normalizeRedisAuthType(d.AuthType), + RequestID: strings.TrimSpace(d.RequestID), + Model: model, + Timestamp: timestamp, + Source: source, + AuthIndex: authIndex, + Failed: d.Failed, + LatencyMS: max(d.LatencyMS, 0), + InputTokens: tokens.InputTokens, + OutputTokens: tokens.OutputTokens, + ReasoningTokens: tokens.ReasoningTokens, + CachedTokens: tokens.CachedTokens, + TotalTokens: tokens.TotalTokens, + } +} diff --git a/internal/service/redis_usage_test.go b/internal/service/redis_usage_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9fd6170c0eb11f2989fa8276db9122e691fb2143 --- /dev/null +++ b/internal/service/redis_usage_test.go @@ -0,0 +1,96 @@ +package service + +import ( + "context" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/cpa" +) + +func TestDecodeRedisUsageMessageMapsPayloadToUsageEvent(t *testing.T) { + fetchedAt := time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC) + + event, raw, err := DecodeRedisUsageMessage(`{ + "timestamp":"2026-04-27T07:59:00Z", + "latency_ms":1234, + "source":"sk-test", + "auth_index":"auth-1", + "tokens":{"input_tokens":10,"output_tokens":20,"reasoning_tokens":3,"cached_tokens":4,"total_tokens":0}, + "failed":true, + "provider":"claude", + "model":"claude-sonnet-4-6", + "endpoint":"/v1/messages", + "auth_type":"api_key", + "api_key":"raw-key", + "request_id":"req-123", + "unknown":"ignored" + }`, fetchedAt) + if err != nil { + t.Fatalf("DecodeRedisUsageMessage returned error: %v", err) + } + if event.EventKey != "req-123" || event.APIGroupKey != "raw-key" || event.Model != "claude-sonnet-4-6" || event.Source != "sk-test" || event.AuthIndex != "auth-1" || !event.Failed || event.LatencyMS != 1234 { + t.Fatalf("unexpected event: %+v", event) + } + if event.Provider != "claude" || event.Endpoint != "/v1/messages" || event.AuthType != "apikey" || event.RequestID != "req-123" { + t.Fatalf("unexpected redis identity fields: %+v", event) + } + if event.InputTokens != 10 || event.OutputTokens != 20 || event.ReasoningTokens != 3 || event.CachedTokens != 4 || event.TotalTokens != 33 { + t.Fatalf("unexpected tokens: %+v", event) + } + if !event.Timestamp.Equal(time.Date(2026, 4, 27, 7, 59, 0, 0, time.UTC)) { + t.Fatalf("unexpected timestamp: %s", event.Timestamp) + } + if !strings.Contains(string(raw), `"unknown":"ignored"`) { + t.Fatalf("expected raw message to be preserved, got %s", string(raw)) + } +} + +func TestDecodeRedisUsageMessageFallsBackFieldsAndEventKey(t *testing.T) { + fetchedAt := time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC) + + event, _, err := DecodeRedisUsageMessage(`{"latency_ms":-5,"tokens":{"input_tokens":1,"output_tokens":2},"endpoint":"/fallback"}`, fetchedAt) + if err != nil { + t.Fatalf("DecodeRedisUsageMessage returned error: %v", err) + } + if event.APIGroupKey != "/fallback" || event.Model != "unknown" || event.LatencyMS != 0 { + t.Fatalf("unexpected fallback event: %+v", event) + } + if event.Provider != "" || event.Endpoint != "/fallback" || event.AuthType != "" || event.RequestID != "" { + t.Fatalf("unexpected fallback redis identity fields: %+v", event) + } + if !event.Timestamp.Equal(fetchedAt) { + t.Fatalf("expected fetchedAt timestamp, got %s", event.Timestamp) + } + expectedKey := BuildEventKey("/fallback", "unknown", fetchedAt, "", "", false, cpa.TokenStats{InputTokens: 1, OutputTokens: 2}) + if event.EventKey != expectedKey { + t.Fatalf("expected fallback event key %s, got %s", expectedKey, event.EventKey) + } +} + +func TestDecodeRedisUsageMessageFallsBackToProviderWhenAPIKeyIsBlank(t *testing.T) { + event, _, err := DecodeRedisUsageMessage(`{"api_key":" ","provider":"claude","endpoint":"/v1/messages","request_id":"req-blank-key"}`, time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("DecodeRedisUsageMessage returned error: %v", err) + } + if event.EventKey != "req-blank-key" || event.APIGroupKey != "claude" { + t.Fatalf("unexpected fallback event: %+v", event) + } +} + +func TestDecodeRedisUsageMessageReportsOnlyMessageError(t *testing.T) { + _, _, err := DecodeRedisUsageMessage(`{bad-json}`, time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC)) + if err == nil || !strings.Contains(err.Error(), "decode redis usage message") { + t.Fatalf("expected decode error, got %v", err) + } +} + +type staticRedisQueue struct { + messages []string + err error +} + +func (q staticRedisQueue) PopUsage(context.Context) ([]string, error) { + return q.messages, q.err +} diff --git a/internal/service/sync.go b/internal/service/sync.go new file mode 100644 index 0000000000000000000000000000000000000000..f95c3e8a4ce017ed4e88637f2652449583549c30 --- /dev/null +++ b/internal/service/sync.go @@ -0,0 +1,692 @@ +package service + +import ( + "context" + "fmt" + "strings" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/repository" + "github.com/sirupsen/logrus" + "gorm.io/gorm" +) + +type MetadataFetcher interface { + FetchAuthFiles(ctx context.Context) (*cpa.AuthFilesResult, error) + FetchGeminiAPIKeys(ctx context.Context) (*cpa.ProviderKeyConfigResult, error) + FetchClaudeAPIKeys(ctx context.Context) (*cpa.ProviderKeyConfigResult, error) + FetchCodexAPIKeys(ctx context.Context) (*cpa.ProviderKeyConfigResult, error) + FetchVertexAPIKeys(ctx context.Context) (*cpa.ProviderKeyConfigResult, error) + FetchOpenAICompatibility(ctx context.Context) (*cpa.OpenAICompatibilityResult, error) +} + +type CPAClientFetcher interface { + MetadataFetcher +} + +const redisInboxProcessLimit = 1000 + +const ( + syncMetadataOptional = false + syncMetadataRequired = true +) + +type SyncService struct { + db *gorm.DB + client CPAClientFetcher + redisQueue RedisQueue + redisQueueKey string + metadataFetcher MetadataFetcher + baseURL string + now func() time.Time +} + +type SyncResult struct { + Status string + InsertedEvents int + DedupedEvents int +} + +type RedisBatchSyncResult struct { + Empty bool + Status string + InsertedEvents int + DedupedEvents int +} + +type RedisInboxPullResult struct { + Empty bool + Status string + InsertedRows int +} + +func NewSyncService(db *gorm.DB, cfg config.Config) *SyncService { + return NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: cfg.CPABaseURL, + Client: cpa.NewClient(cfg.CPABaseURL, cfg.CPAManagementKey, cfg.RequestTimeout), + RedisQueue: cpa.NewRedisQueueClient(cfg.CPABaseURL, cfg.RedisQueueAddr, cfg.CPAManagementKey, cfg.RequestTimeout, cfg.RedisQueueKey, cfg.RedisQueueBatchSize), + RedisQueueKey: cfg.RedisQueueKey, + }) +} + +type SyncServiceOptions struct { + BaseURL string + Client CPAClientFetcher + MetadataFetcher MetadataFetcher + RedisQueue RedisQueue + RedisQueueKey string + Now func() time.Time +} + +func NewSyncServiceWithOptions(db *gorm.DB, opts SyncServiceOptions) *SyncService { + now := opts.Now + if now == nil { + now = time.Now + } + metadataFetcher := opts.MetadataFetcher + if metadataFetcher == nil { + metadataFetcher = opts.Client + } + return &SyncService{ + db: db, + client: opts.Client, + redisQueue: opts.RedisQueue, + redisQueueKey: redisQueueKey(opts.RedisQueueKey), + metadataFetcher: metadataFetcher, + baseURL: strings.TrimSpace(opts.BaseURL), + now: now, + } +} + +func NewSyncServiceWithClient(db *gorm.DB, baseURL string, client CPAClientFetcher) *SyncService { + return NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: baseURL, + Client: client, + }) +} + +func (s *SyncService) SyncOnce(ctx context.Context) error { + _, err := s.SyncNow(ctx) + return err +} + +func (s *SyncService) SyncNow(ctx context.Context) (*SyncResult, error) { + if _, err := s.PullRedisUsageInbox(ctx); err != nil { + return nil, err + } + result, err := s.ProcessRedisUsageInbox(ctx) + return syncResultFromRedisBatch(result), err +} + +func syncResultFromRedisBatch(result *RedisBatchSyncResult) *SyncResult { + if result == nil { + return nil + } + return &SyncResult{ + Status: result.Status, + InsertedEvents: result.InsertedEvents, + DedupedEvents: result.DedupedEvents, + } +} + +func (s *SyncService) SyncStatus(ctx context.Context) (string, error) { + result, err := s.SyncNow(ctx) + if result == nil { + return "", err + } + return result.Status, err +} + +func (s *SyncService) SyncMetadata(ctx context.Context) error { + if err := s.validate(syncMetadataRequired); err != nil { + return err + } + logrus.Debug("metadata sync started") + fetchedAt := s.now().UTC() + authFilesResult, authFilesErr := s.metadataFetcher.FetchAuthFiles(ctx) + providerConfig, fetchedProviderTypes, providerMetadataErr := fetchProviderMetadata(ctx, s.metadataFetcher) + authSyncErr := syncAuthFiles(ctx, s.db, authFilesResult, authFilesErr, fetchedAt) + providerSyncErr, providerWarningErr := syncProviderMetadata(ctx, s.db, providerConfig, fetchedProviderTypes, providerMetadataErr, fetchedAt) + upsertErr := joinErrors(authSyncErr, providerSyncErr) + var aggregateErr error + if upsertErr == nil { + aggregateErr = repository.AggregateUsageIdentityStats(ctx, s.db, fetchedAt) + if aggregateErr != nil { + aggregateErr = fmt.Errorf("aggregate usage identity stats: %w", aggregateErr) + } + } + err := joinErrors(upsertErr, aggregateErr, providerWarningErr) + fields := logrus.Fields{ + "status": "completed", + } + if err != nil { + fields["status"] = "completed_with_warnings" + fields["error"] = err.Error() + } + logrus.WithFields(fields).Debug("metadata sync finished") + return err +} + +// PullRedisUsageInbox 是 Redis 同步的拉取阶段:只 LPOP 队列消息并原样写入 redis_usage_inboxes。 +// 这个阶段不解码消息、不写 usage_events,保证 Redis 消费和本地处理职责分离。 +func (s *SyncService) PullRedisUsageInbox(ctx context.Context) (*RedisInboxPullResult, error) { + if err := s.validate(syncMetadataOptional); err != nil { + return nil, err + } + if s.redisQueue == nil { + return nil, fmt.Errorf("sync service redis queue is nil") + } + + fetchedAt := s.now().UTC() + messages, err := s.redisQueue.PopUsage(ctx) + if err != nil { + return &RedisInboxPullResult{Status: "failed"}, fmt.Errorf("fetch redis usage: %w", err) + } + logrus.WithFields(logrus.Fields{ + "queue_key": s.redisQueueKey, + "message_count": len(messages), + }).Debug("redis usage batch popped") + if len(messages) == 0 { + return &RedisInboxPullResult{Empty: true, Status: "empty"}, nil + } + + inboxRows, err := insertRedisInboxMessages(s.db, s.redisQueueKey, messages, fetchedAt) + if err != nil { + return &RedisInboxPullResult{Status: "failed"}, fmt.Errorf("insert redis usage inbox: %w", err) + } + logrus.WithFields(logrus.Fields{ + "queue_key": s.redisQueueKey, + "row_count": len(inboxRows), + }).Debug("redis usage inbox rows inserted") + return &RedisInboxPullResult{Status: "completed", InsertedRows: len(inboxRows)}, nil +} + +// ProcessRedisUsageInbox 是 Redis 同步的本地处理阶段:只读取 pending/process_failed inbox 行并写入 usage_events。 +// 成功处理后仅用 usage_event_key 记录 inbox 与最终事件的关联。 +func (s *SyncService) ProcessRedisUsageInbox(ctx context.Context) (*RedisBatchSyncResult, error) { + if err := s.validate(syncMetadataOptional); err != nil { + return nil, err + } + fetchedAt := s.now().UTC() + processableRows, err := repository.ListProcessableRedisUsageInbox(s.db, redisInboxProcessLimit) + if err != nil { + return &RedisBatchSyncResult{Status: "failed"}, fmt.Errorf("list processable redis usage inbox: %w", err) + } + if len(processableRows) == 0 { + return &RedisBatchSyncResult{Empty: true, Status: "empty"}, nil + } + logrus.WithField("row_count", len(processableRows)).Debug("redis usage inbox rows found for processing") + return s.processRedisInboxRows(processableRows, fetchedAt) +} + +// CleanupRedisUsageInbox 只清理 Redis inbox 表,供测试和单独维护入口使用;每日任务使用 CleanupStorage 统一执行。 +func (s *SyncService) CleanupRedisUsageInbox(ctx context.Context) error { + if err := s.validate(syncMetadataOptional); err != nil { + return err + } + _, err := repository.CleanupRedisUsageInbox(s.db, s.now()) + return err +} + +// CleanupStorage 是每日 03:00 维护任务调用的统一入口:先清 Redis inbox,最后 VACUUM 收缩 SQLite。 +func (s *SyncService) CleanupStorage(ctx context.Context) error { + if err := s.validate(syncMetadataOptional); err != nil { + return err + } + _, err := repository.CleanupStorage(s.db, s.now()) + return err +} + +// SyncRedisBatch 保留为兼容入口:先处理本地存量 inbox,空了再拉一次 Redis 并立即处理。 +// 后台任务不要调用它,后台必须使用拆分后的 PullRedisUsageInbox、ProcessRedisUsageInbox 和 CleanupStorage。 +func (s *SyncService) SyncRedisBatch(ctx context.Context) (*RedisBatchSyncResult, error) { + if result, err := s.ProcessRedisUsageInbox(ctx); err != nil || result == nil || !result.Empty { + return result, err + } + if _, err := s.PullRedisUsageInbox(ctx); err != nil { + return &RedisBatchSyncResult{Status: "failed"}, err + } + return s.ProcessRedisUsageInbox(ctx) +} + +// processRedisInboxRows 只从已落库的原始消息解码和写入事件,坏消息会标记为 decode_failed,不阻塞同批其它数据。 +// 可解码但入库失败的消息标记为 process_failed,后续 ProcessRedisUsageInbox 会按 id 顺序重试。 +func (s *SyncService) processRedisInboxRows(inboxRows []models.RedisUsageInbox, fetchedAt time.Time) (*RedisBatchSyncResult, error) { + logrus.WithField("row_count", len(inboxRows)).Debug("redis usage inbox processing started") + validRows := make([]models.RedisUsageInbox, 0, len(inboxRows)) + events := make([]models.UsageEvent, 0, len(inboxRows)) + decodeErrs := make([]error, 0) + for _, row := range inboxRows { + event, _, decodeErr := DecodeRedisUsageMessage(row.RawMessage, fetchedAt) + if decodeErr != nil { + if markErr := repository.MarkRedisUsageInboxDecodeFailed(s.db, row.ID, decodeErr); markErr != nil { + return &RedisBatchSyncResult{Status: "failed"}, fmt.Errorf("mark redis usage inbox decode failed: %w", markErr) + } + decodeErrs = append(decodeErrs, decodeErr) + continue + } + validRows = append(validRows, row) + events = append(events, event) + } + decodeErr := joinErrors(decodeErrs...) + logrus.WithFields(logrus.Fields{ + "row_count": len(inboxRows), + "valid_event_count": len(events), + "decode_failed_count": len(decodeErrs), + }).Debug("redis usage inbox rows decoded") + if len(events) == 0 { + if decodeErr != nil { + return &RedisBatchSyncResult{Status: "completed_with_warnings"}, decodeErr + } + return &RedisBatchSyncResult{Empty: true, Status: "empty"}, nil + } + + logrus.WithField("event_count", len(events)).Debug("redis usage events persistence started") + result, err := s.persistRedisUsageEvents(events) + if result == nil { + markRedisInboxRowsProcessFailed(s.db, validRows, err) + return nil, err + } + if err != nil && result.Status == "failed" { + markRedisInboxRowsProcessFailed(s.db, validRows, err) + return &RedisBatchSyncResult{Status: result.Status}, err + } + for i, row := range validRows { + if markErr := repository.MarkRedisUsageInboxProcessed(s.db, row.ID, events[i].EventKey, fetchedAt); markErr != nil { + return &RedisBatchSyncResult{Status: "failed"}, fmt.Errorf("mark redis usage inbox processed: %w", markErr) + } + } + logrus.WithFields(logrus.Fields{ + "processed_rows": len(validRows), + "inserted_events": result.InsertedEvents, + "deduped_events": result.DedupedEvents, + "status": result.Status, + }).Debug("redis usage inbox rows processed") + + status := result.Status + returnErr := err + if decodeErr != nil { + status = "completed_with_warnings" + if returnErr != nil { + returnErr = joinErrors(returnErr, decodeErr) + } else { + returnErr = decodeErr + } + } + return &RedisBatchSyncResult{ + Status: status, + InsertedEvents: result.InsertedEvents, + DedupedEvents: result.DedupedEvents, + }, returnErr +} + +// persistRedisUsageEvents 写入 Redis inbox 解码出的 usage_events。 +func (s *SyncService) persistRedisUsageEvents(events []models.UsageEvent) (*SyncResult, error) { + var err error + events, err = alignUsageEventKeysWithExistingCanonicalEvents(s.db, events) + if err != nil { + return &SyncResult{Status: "failed"}, fmt.Errorf("align usage events: %w", err) + } + logrus.WithField("event_count", len(events)).Debug("usage events insert started") + inserted, deduped, err := repository.InsertUsageEvents(s.db, events) + if err != nil { + return &SyncResult{Status: "failed"}, fmt.Errorf("insert usage events: %w", err) + } + logrus.WithFields(logrus.Fields{ + "inserted_events": inserted, + "deduped_events": deduped, + }).Debug("usage events insert finished") + return &SyncResult{Status: "completed", InsertedEvents: inserted, DedupedEvents: deduped}, nil +} + +func alignUsageEventKeysWithExistingCanonicalEvents(db *gorm.DB, events []models.UsageEvent) ([]models.UsageEvent, error) { + if len(events) == 0 { + return events, nil + } + canonicalEventKeys := make(map[string]string, len(events)) + consumedCanonicalKeys := make(map[string]struct{}, len(events)) + for i := range events { + events[i].Timestamp = events[i].Timestamp.UTC() + canonicalKey := canonicalUsageEventKey(events[i]) + incomingKey := strings.TrimSpace(events[i].EventKey) + if strings.TrimSpace(events[i].RequestID) != "" { + canonicalEventKeys[canonicalKey] = incomingKey + continue + } + if existingKey := canonicalEventKeys[canonicalKey]; existingKey != "" { + if incomingKey == canonicalKey { + events[i].EventKey = existingKey + } else if existingKey == canonicalKey { + if _, consumed := consumedCanonicalKeys[canonicalKey]; !consumed { + events[i].EventKey = existingKey + consumedCanonicalKeys[canonicalKey] = struct{}{} + } + } + continue + } + + var existing models.UsageEvent + result := db.Select("event_key").Where( + "TRIM(api_group_key) = ? AND TRIM(model) = ? AND timestamp = ? AND TRIM(source) = ? AND TRIM(auth_index) = ? AND failed = ? AND input_tokens = ? AND output_tokens = ? AND reasoning_tokens = ? AND cached_tokens = ? AND total_tokens = ?", + strings.TrimSpace(events[i].APIGroupKey), + strings.TrimSpace(events[i].Model), + events[i].Timestamp, + strings.TrimSpace(events[i].Source), + strings.TrimSpace(events[i].AuthIndex), + events[i].Failed, + events[i].InputTokens, + events[i].OutputTokens, + events[i].ReasoningTokens, + events[i].CachedTokens, + events[i].TotalTokens, + ).Order("id ASC").Limit(1).Find(&existing) + if result.Error != nil { + return nil, fmt.Errorf("find equivalent usage event: %w", result.Error) + } + if result.RowsAffected == 0 { + canonicalEventKeys[canonicalKey] = incomingKey + continue + } + existingKey := strings.TrimSpace(existing.EventKey) + if existingKey != "" { + if incomingKey == canonicalKey { + events[i].EventKey = existingKey + } else if existingKey == canonicalKey { + alreadyConsumed, err := redisInboxAlreadyReferencesEventKey(db, canonicalKey) + if err != nil { + return nil, err + } + if !alreadyConsumed { + events[i].EventKey = existingKey + consumedCanonicalKeys[canonicalKey] = struct{}{} + } + } + canonicalEventKeys[canonicalKey] = existingKey + } else { + canonicalEventKeys[canonicalKey] = incomingKey + } + } + return events, nil +} + +func redisInboxAlreadyReferencesEventKey(db *gorm.DB, eventKey string) (bool, error) { + var count int64 + if err := db.Model(&models.RedisUsageInbox{}).Where("status = ? AND usage_event_key = ?", repository.RedisUsageInboxStatusProcessed, eventKey).Count(&count).Error; err != nil { + return false, fmt.Errorf("count redis inbox references: %w", err) + } + return count > 0, nil +} + +func canonicalUsageEventKey(event models.UsageEvent) string { + return BuildEventKey( + event.APIGroupKey, + event.Model, + event.Timestamp, + event.Source, + event.AuthIndex, + event.Failed, + cpa.TokenStats{ + InputTokens: event.InputTokens, + OutputTokens: event.OutputTokens, + ReasoningTokens: event.ReasoningTokens, + CachedTokens: event.CachedTokens, + TotalTokens: event.TotalTokens, + }, + ) +} + +func (s *SyncService) validate(syncMetadata bool) error { + if s == nil { + return fmt.Errorf("sync service is nil") + } + if s.db == nil { + return fmt.Errorf("sync service database is nil") + } + if syncMetadata { + if s.metadataFetcher == nil && s.client != nil { + s.metadataFetcher = s.client + } + if s.metadataFetcher == nil { + return fmt.Errorf("sync service metadata fetcher is nil") + } + } + return nil +} + +// insertRedisInboxMessages 在解码前先把 Redis 原始消息落库,降低 LPOP 后本地处理失败导致的数据丢失风险。 +func insertRedisInboxMessages(db *gorm.DB, queueKey string, messages []string, poppedAt time.Time) ([]models.RedisUsageInbox, error) { + inputs := make([]repository.RedisInboxInsert, 0, len(messages)) + for _, message := range messages { + inputs = append(inputs, repository.RedisInboxInsert{ + QueueKey: queueKey, + RawMessage: message, + PoppedAt: poppedAt, + }) + } + return repository.InsertRedisUsageInboxMessages(db, inputs) +} + +func markRedisInboxRowsProcessFailed(db *gorm.DB, rows []models.RedisUsageInbox, err error) { + if err == nil { + return + } + for _, row := range rows { + if markErr := repository.MarkRedisUsageInboxProcessFailed(db, row.ID, err); markErr != nil { + logrus.WithError(markErr).WithField("inbox_id", row.ID).Warn("failed to mark redis usage inbox process failure") + continue + } + var stored models.RedisUsageInbox + if loadErr := db.First(&stored, row.ID).Error; loadErr != nil { + logrus.WithError(loadErr).WithField("inbox_id", row.ID).Warn("failed to load redis usage inbox after process failure") + continue + } + if stored.Status == repository.RedisUsageInboxStatusDiscarded { + logrus.WithFields(logrus.Fields{ + "inbox_id": stored.ID, + "queue_key": stored.QueueKey, + "message_hash": stored.MessageHash, + "attempt_count": stored.AttemptCount, + "last_error": stored.LastError, + "popped_at": stored.PoppedAt, + }).Warn("discarded redis usage inbox row after repeated process failures") + } + } +} + +func redisQueueKey(value string) string { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return cpa.ManagementUsageQueueKey + } + return trimmed +} + +func errorMessage(err error) string { + if err == nil { + return "" + } + return strings.TrimSpace(err.Error()) +} + +func syncAuthFiles(ctx context.Context, db *gorm.DB, result *cpa.AuthFilesResult, fetchErr error, now time.Time) error { + if fetchErr != nil { + return fmt.Errorf("fetch auth files: %w", fetchErr) + } + if db == nil { + return fmt.Errorf("database is nil") + } + if result == nil { + return fmt.Errorf("fetch auth files: empty response") + } + + identities := make([]models.UsageIdentity, 0, len(result.Payload.Files)) + for _, file := range result.Payload.Files { + identities = append(identities, models.UsageIdentity{ + Name: firstNonEmpty(file.Email, file.Label, file.Name, file.AuthIndex), + AuthType: models.UsageIdentityAuthTypeAuthFile, + AuthTypeName: "oauth", + Identity: file.AuthIndex, + Type: file.Type, + Provider: file.Provider, + }) + } + if err := repository.ReplaceUsageIdentitiesForAuthType(ctx, db, identities, models.UsageIdentityAuthTypeAuthFile, now); err != nil { + return fmt.Errorf("sync auth file usage identities: %w", err) + } + return nil +} + +func fetchProviderMetadata(ctx context.Context, fetcher MetadataFetcher) (cpa.ProviderMetadataConfig, []string, error) { + var cfg cpa.ProviderMetadataConfig + var fetchedProviderTypes []string + var errs []error + + if result, err := fetcher.FetchGeminiAPIKeys(ctx); err != nil { + errs = append(errs, fmt.Errorf("fetch gemini api keys: %w", err)) + } else if result == nil { + errs = append(errs, fmt.Errorf("gemini api keys response is nil")) + } else { + fetchedProviderTypes = append(fetchedProviderTypes, "gemini") + cfg.GeminiAPIKeys = result.Payload + } + if result, err := fetcher.FetchClaudeAPIKeys(ctx); err != nil { + errs = append(errs, fmt.Errorf("fetch claude api keys: %w", err)) + } else if result == nil { + errs = append(errs, fmt.Errorf("claude api keys response is nil")) + } else { + fetchedProviderTypes = append(fetchedProviderTypes, "claude") + cfg.ClaudeAPIKeys = result.Payload + } + if result, err := fetcher.FetchCodexAPIKeys(ctx); err != nil { + errs = append(errs, fmt.Errorf("fetch codex api keys: %w", err)) + } else if result == nil { + errs = append(errs, fmt.Errorf("codex api keys response is nil")) + } else { + fetchedProviderTypes = append(fetchedProviderTypes, "codex") + cfg.CodexAPIKeys = result.Payload + } + if result, err := fetcher.FetchVertexAPIKeys(ctx); err != nil { + errs = append(errs, fmt.Errorf("fetch vertex api keys: %w", err)) + } else if result == nil { + errs = append(errs, fmt.Errorf("vertex api keys response is nil")) + } else { + fetchedProviderTypes = append(fetchedProviderTypes, "vertex") + cfg.VertexAPIKeys = result.Payload + } + if result, err := fetcher.FetchOpenAICompatibility(ctx); err != nil { + errs = append(errs, fmt.Errorf("fetch openai compatibility: %w", err)) + } else if result == nil { + errs = append(errs, fmt.Errorf("openai compatibility response is nil")) + } else { + fetchedProviderTypes = append(fetchedProviderTypes, "openai") + cfg.OpenAICompatibility = result.Payload + } + + return cfg, fetchedProviderTypes, joinErrors(errs...) +} + +func syncProviderMetadata(ctx context.Context, db *gorm.DB, cfg cpa.ProviderMetadataConfig, fetchedProviderTypes []string, fetchErr error, now time.Time) (error, error) { + if db == nil { + return fmt.Errorf("database is nil"), nil + } + + inputs := flattenProviderMetadata(cfg) + identities := providerMetadataUsageIdentities(inputs) + if err := repository.ReplaceUsageIdentitiesForProviderTypes(ctx, db, identities, fetchedProviderTypes, now); err != nil { + return fmt.Errorf("sync provider usage identities: %w", err), nil + } + if fetchErr != nil { + return nil, fmt.Errorf("fetch provider metadata: %w", fetchErr) + } + return nil, nil +} + +type providerMetadataInput struct { + LookupKey string + ProviderType string + DisplayName string +} + +func providerMetadataUsageIdentities(inputs []providerMetadataInput) []models.UsageIdentity { + identities := make([]models.UsageIdentity, 0, len(inputs)) + for _, input := range inputs { + identities = append(identities, models.UsageIdentity{ + Name: input.DisplayName, + AuthType: models.UsageIdentityAuthTypeAIProvider, + AuthTypeName: "apikey", + Identity: input.LookupKey, + Type: input.ProviderType, + Provider: input.DisplayName, + }) + } + return identities +} + +func flattenProviderMetadata(cfg cpa.ProviderMetadataConfig) []providerMetadataInput { + items := make([]providerMetadataInput, 0) + seen := make(map[string]struct{}) + appendItem := func(lookupKey, providerType, displayName string) { + lookupKey = strings.TrimSpace(lookupKey) + providerType = strings.TrimSpace(providerType) + displayName = strings.TrimSpace(displayName) + if lookupKey == "" || providerType == "" || displayName == "" { + return + } + if _, ok := seen[lookupKey]; ok { + return + } + seen[lookupKey] = struct{}{} + items = append(items, providerMetadataInput{ + LookupKey: lookupKey, + ProviderType: providerType, + DisplayName: displayName, + }) + } + appendProviderEntries := func(providerType string, configs []cpa.ProviderKeyConfig) { + for _, cfg := range configs { + displayName := firstNonEmpty(cfg.Name, providerType) + appendItem(cfg.APIKey, providerType, displayName) + } + } + + appendProviderEntries("gemini", cfg.GeminiAPIKeys) + appendProviderEntries("claude", cfg.ClaudeAPIKeys) + appendProviderEntries("codex", cfg.CodexAPIKeys) + appendProviderEntries("vertex", cfg.VertexAPIKeys) + + for _, provider := range cfg.OpenAICompatibility { + displayName := firstNonEmpty(provider.Name, "openai") + for _, entry := range provider.APIKeyEntries { + appendItem(entry.APIKey, "openai", displayName) + } + } + + return items +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed != "" { + return trimmed + } + } + return "" +} + +func joinErrors(errs ...error) error { + messages := make([]string, 0, len(errs)) + for _, err := range errs { + if err == nil { + continue + } + messages = append(messages, strings.TrimSpace(err.Error())) + } + if len(messages) == 0 { + return nil + } + return fmt.Errorf("%s", strings.Join(messages, "; ")) +} diff --git a/internal/service/sync_test.go b/internal/service/sync_test.go new file mode 100644 index 0000000000000000000000000000000000000000..dc572005a8c5397fa62f61339c7c09e8a3c7754c --- /dev/null +++ b/internal/service/sync_test.go @@ -0,0 +1,1151 @@ +package service + +import ( + "bytes" + "context" + "errors" + "log" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/repository" + "github.com/sirupsen/logrus" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" +) + +type stubMetadataFetcher struct { + authFilesResult *cpa.AuthFilesResult + authFilesErr error + providerConfig cpa.ProviderMetadataConfig + geminiErr error + claudeErr error + codexErr error + vertexErr error + openAIErr error + geminiNilResult bool +} + +type trackingMetadataFetcher struct { + authCalls int + geminiCalls int + claudeCalls int + codexCalls int + vertexCalls int + openAICalls int + authErr error + providerErr error +} + +type observingMetadataFetcher struct { + db *gorm.DB + usageEventsBeforeMetadataSync int64 +} + +func (s stubMetadataFetcher) FetchAuthFiles(context.Context) (*cpa.AuthFilesResult, error) { + if s.authFilesResult != nil || s.authFilesErr != nil { + return s.authFilesResult, s.authFilesErr + } + return &cpa.AuthFilesResult{StatusCode: 200, Payload: cpa.AuthFilesResponse{}}, nil +} + +func (s stubMetadataFetcher) FetchGeminiAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + if s.geminiNilResult { + return nil, nil + } + return providerKeyConfigResult(s.providerConfig.GeminiAPIKeys, s.geminiErr) +} + +func (s stubMetadataFetcher) FetchClaudeAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + return providerKeyConfigResult(s.providerConfig.ClaudeAPIKeys, s.claudeErr) +} + +func (s stubMetadataFetcher) FetchCodexAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + return providerKeyConfigResult(s.providerConfig.CodexAPIKeys, s.codexErr) +} + +func (s stubMetadataFetcher) FetchVertexAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + return providerKeyConfigResult(s.providerConfig.VertexAPIKeys, s.vertexErr) +} + +func (s stubMetadataFetcher) FetchOpenAICompatibility(context.Context) (*cpa.OpenAICompatibilityResult, error) { + return openAICompatibilityResult(s.providerConfig.OpenAICompatibility, s.openAIErr) +} + +func providerKeyConfigResult(payload []cpa.ProviderKeyConfig, err error) (*cpa.ProviderKeyConfigResult, error) { + if err != nil { + return nil, err + } + return &cpa.ProviderKeyConfigResult{StatusCode: 200, Payload: payload}, nil +} + +func openAICompatibilityResult(payload []cpa.OpenAICompatibilityConfig, err error) (*cpa.OpenAICompatibilityResult, error) { + if err != nil { + return nil, err + } + return &cpa.OpenAICompatibilityResult{StatusCode: 200, Payload: payload}, nil +} + +func (s *trackingMetadataFetcher) FetchAuthFiles(context.Context) (*cpa.AuthFilesResult, error) { + s.authCalls++ + if s.authErr != nil { + return nil, s.authErr + } + return &cpa.AuthFilesResult{StatusCode: 200, Payload: cpa.AuthFilesResponse{}}, nil +} + +func (s *trackingMetadataFetcher) FetchGeminiAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + s.geminiCalls++ + return providerKeyConfigResult(nil, s.providerErr) +} + +func (s *trackingMetadataFetcher) FetchClaudeAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + s.claudeCalls++ + return providerKeyConfigResult(nil, s.providerErr) +} + +func (s *trackingMetadataFetcher) FetchCodexAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + s.codexCalls++ + return providerKeyConfigResult(nil, s.providerErr) +} + +func (s *trackingMetadataFetcher) FetchVertexAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + s.vertexCalls++ + return providerKeyConfigResult(nil, s.providerErr) +} + +func (s *trackingMetadataFetcher) FetchOpenAICompatibility(context.Context) (*cpa.OpenAICompatibilityResult, error) { + s.openAICalls++ + return openAICompatibilityResult(nil, s.providerErr) +} + +func (s *observingMetadataFetcher) FetchAuthFiles(context.Context) (*cpa.AuthFilesResult, error) { + if err := s.db.Model(&models.UsageEvent{}).Count(&s.usageEventsBeforeMetadataSync).Error; err != nil { + return nil, err + } + return &cpa.AuthFilesResult{StatusCode: 200, Payload: cpa.AuthFilesResponse{}}, nil +} + +func (s *observingMetadataFetcher) FetchGeminiAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + return providerKeyConfigResult(nil, nil) +} + +func (s *observingMetadataFetcher) FetchClaudeAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + return providerKeyConfigResult(nil, nil) +} + +func (s *observingMetadataFetcher) FetchCodexAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + return providerKeyConfigResult(nil, nil) +} + +func (s *observingMetadataFetcher) FetchVertexAPIKeys(context.Context) (*cpa.ProviderKeyConfigResult, error) { + return providerKeyConfigResult(nil, nil) +} + +func (s *observingMetadataFetcher) FetchOpenAICompatibility(context.Context) (*cpa.OpenAICompatibilityResult, error) { + return openAICompatibilityResult(nil, nil) +} + +func (s *trackingMetadataFetcher) providerCalls() int { + return s.geminiCalls + s.claudeCalls + s.codexCalls + s.vertexCalls + s.openAICalls +} + +func TestPullRedisUsageInboxOnlyStoresPendingRows(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{ + `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"pull-only","tokens":{"input_tokens":1,"output_tokens":2}}`, + }}, + }) + + result, err := service.PullRedisUsageInbox(context.Background()) + if err != nil { + t.Fatalf("PullRedisUsageInbox returned error: %v", err) + } + if result == nil || result.Empty || result.Status != "completed" || result.InsertedRows != 1 { + t.Fatalf("unexpected pull result: %+v", result) + } + + var inbox models.RedisUsageInbox + if err := db.First(&inbox).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusPending || inbox.UsageEventKey != "" { + t.Fatalf("expected pending inbox row without processing links, got %+v", inbox) + } + var eventCount int64 + if err := db.Model(&models.UsageEvent{}).Count(&eventCount).Error; err != nil { + t.Fatalf("count usage events: %v", err) + } + if eventCount != 0 { + t.Fatalf("expected pull not to write usage events, got %d", eventCount) + } +} + +func TestProcessRedisUsageInboxPersistsEventsWithoutSnapshot(t *testing.T) { + db := openSyncTestDatabase(t) + rows, err := repository.InsertRedisUsageInboxMessages(db, []repository.RedisInboxInsert{{ + QueueKey: cpa.ManagementUsageQueueKey, + RawMessage: `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","endpoint":"/v1/messages","auth_type":"api_key","model":"sonnet","request_id":"process-only","tokens":{"input_tokens":1,"output_tokens":2}}`, + PoppedAt: time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC), + }}) + if err != nil { + t.Fatalf("seed inbox row: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{err: errors.New("redis should not be popped while processing inbox")}, + }) + + result, err := service.ProcessRedisUsageInbox(context.Background()) + if err != nil { + t.Fatalf("ProcessRedisUsageInbox returned error: %v", err) + } + if result == nil || result.Status != "completed" || result.InsertedEvents != 1 { + t.Fatalf("unexpected process result: %+v", result) + } + var event models.UsageEvent + if err := db.First(&event).Error; err != nil { + t.Fatalf("load usage event: %v", err) + } + if event.EventKey != "process-only" { + t.Fatalf("expected Redis event without snapshot run id, got %+v", event) + } + if event.Provider != "claude" || event.Endpoint != "/v1/messages" || event.AuthType != "apikey" || event.RequestID != "process-only" { + t.Fatalf("expected Redis identity fields to persist, got %+v", event) + } + var inbox models.RedisUsageInbox + if err := db.First(&inbox, rows[0].ID).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusProcessed || inbox.UsageEventKey != "process-only" { + t.Fatalf("expected processed inbox row without snapshot link, got %+v", inbox) + } +} + +func TestProcessRedisUsageInboxDoesNotFetchMetadata(t *testing.T) { + db := openSyncTestDatabase(t) + metadata := &trackingMetadataFetcher{} + rows, err := repository.InsertRedisUsageInboxMessages(db, []repository.RedisInboxInsert{{ + QueueKey: cpa.ManagementUsageQueueKey, + RawMessage: `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"redis-no-metadata","tokens":{"input_tokens":1,"output_tokens":2}}`, + PoppedAt: time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC), + }}) + if err != nil { + t.Fatalf("seed inbox row: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: metadata, + }) + + result, err := service.ProcessRedisUsageInbox(context.Background()) + if err != nil { + t.Fatalf("ProcessRedisUsageInbox returned error: %v", err) + } + if result == nil || result.Status != "completed" || result.InsertedEvents != 1 { + t.Fatalf("unexpected process result: %+v", result) + } + if metadata.authCalls != 0 || metadata.providerCalls() != 0 { + t.Fatalf("expected redis processing not to fetch metadata, got auth=%d provider=%d", metadata.authCalls, metadata.providerCalls()) + } + var inbox models.RedisUsageInbox + if err := db.First(&inbox, rows[0].ID).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusProcessed || inbox.UsageEventKey != "redis-no-metadata" { + t.Fatalf("expected inbox row processed, got %+v", inbox) + } +} + +func TestSyncRedisBatchSkipsEmptyBatchWithoutSnapshotOrMetadata(t *testing.T) { + db := openSyncTestDatabase(t) + metadata := &trackingMetadataFetcher{} + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{}, + MetadataFetcher: metadata, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + if result == nil || !result.Empty || result.Status != "empty" { + t.Fatalf("expected empty redis batch result, got %+v", result) + } + if metadata.authCalls != 0 || metadata.providerCalls() != 0 { + t.Fatalf("expected metadata fetch to be skipped for empty batch, got auth=%d provider=%d", metadata.authCalls, metadata.providerCalls()) + } + +} + +func TestSyncRedisBatchPersistsNonEmptyBatchWithoutMetadata(t *testing.T) { + db := openSyncTestDatabase(t) + metadata := &trackingMetadataFetcher{} + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{`{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"redis-1","tokens":{"input_tokens":1,"output_tokens":2}}`}}, + MetadataFetcher: metadata, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + if result == nil || result.Empty || result.Status != "completed" || result.InsertedEvents != 1 || result.DedupedEvents != 0 { + t.Fatalf("unexpected redis batch result: %+v", result) + } + if metadata.authCalls != 0 || metadata.providerCalls() != 0 { + t.Fatalf("expected metadata fetch to be skipped, got auth=%d provider=%d", metadata.authCalls, metadata.providerCalls()) + } + + var event models.UsageEvent + if err := db.First(&event).Error; err != nil { + t.Fatalf("load usage event: %v", err) + } + if event.EventKey != "redis-1" { + t.Fatalf("unexpected usage event: %+v", event) + } + var inbox models.RedisUsageInbox + if err := db.First(&inbox).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusProcessed || inbox.UsageEventKey != "redis-1" { + t.Fatalf("expected processed inbox row without snapshot link, got %+v", inbox) + } +} + +func TestSyncRedisBatchPersistsValidRowsWhenBatchContainsMalformedMessage(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{ + `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"redis-valid","tokens":{"input_tokens":1,"output_tokens":2}}`, + `{bad-json}`, + }}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err == nil || !strings.Contains(err.Error(), "decode redis usage message") { + t.Fatalf("expected decode warning, got %v", err) + } + if result == nil || result.Status != "completed_with_warnings" || result.InsertedEvents != 1 { + t.Fatalf("expected warning result with valid event persisted, got %+v", result) + } + + var event models.UsageEvent + if err := db.First(&event).Error; err != nil { + t.Fatalf("load usage event: %v", err) + } + if event.EventKey != "redis-valid" { + t.Fatalf("unexpected usage event: %+v", event) + } + + var inboxRows []models.RedisUsageInbox + if err := db.Order("id asc").Find(&inboxRows).Error; err != nil { + t.Fatalf("load inbox rows: %v", err) + } + if len(inboxRows) != 2 { + t.Fatalf("expected 2 inbox rows, got %d", len(inboxRows)) + } + if inboxRows[0].Status != repository.RedisUsageInboxStatusProcessed || inboxRows[0].UsageEventKey != "redis-valid" { + t.Fatalf("expected first row processed, got %+v", inboxRows[0]) + } + if inboxRows[1].Status != repository.RedisUsageInboxStatusDecodeFailed || inboxRows[1].LastError == "" { + t.Fatalf("expected second row decode_failed, got %+v", inboxRows[1]) + } +} + +func TestSyncRedisBatchMarksMalformedOnlyBatchWithoutSnapshot(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{`{bad-json}`}}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err == nil || !strings.Contains(err.Error(), "decode redis usage message") { + t.Fatalf("expected decode warning, got %v", err) + } + if result == nil || result.Status != "completed_with_warnings" { + t.Fatalf("expected warning result, got %+v", result) + } + + var inbox models.RedisUsageInbox + if err := db.First(&inbox).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusDecodeFailed || inbox.RawMessage != `{bad-json}` { + t.Fatalf("expected decode_failed raw inbox row, got %+v", inbox) + } +} + +func TestSyncRedisBatchProcessesPendingInboxBeforePoppingRedis(t *testing.T) { + db := openSyncTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC) + rows, err := repository.InsertRedisUsageInboxMessages(db, []repository.RedisInboxInsert{{ + QueueKey: cpa.ManagementUsageQueueKey, + RawMessage: `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"pending-1","tokens":{"input_tokens":1,"output_tokens":2}}`, + PoppedAt: poppedAt, + }}) + if err != nil { + t.Fatalf("seed inbox row: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{err: errors.New("redis should not be popped while inbox is pending")}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + if result == nil || result.Status != "completed" || result.InsertedEvents != 1 { + t.Fatalf("expected pending inbox row to be processed, got %+v", result) + } + + var event models.UsageEvent + if err := db.First(&event).Error; err != nil { + t.Fatalf("load usage event: %v", err) + } + if event.EventKey != "pending-1" { + t.Fatalf("unexpected usage event: %+v", event) + } + var inbox models.RedisUsageInbox + if err := db.First(&inbox, rows[0].ID).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusProcessed { + t.Fatalf("expected pending row processed, got %+v", inbox) + } +} + +func TestSyncRedisBatchDoesNotWatermarkFilterRedisInboxEvents(t *testing.T) { + db := openSyncTestDatabase(t) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "future-watermark", + APIGroupKey: "claude", + Model: "sonnet", + Timestamp: time.Date(2026, 4, 28, 8, 0, 0, 0, time.UTC), + }}); err != nil { + t.Fatalf("seed future event: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{ + `{"timestamp":"2026-04-26T07:00:00Z","provider":"claude","model":"sonnet","request_id":"old-but-unique","tokens":{"input_tokens":1,"output_tokens":2}}`, + }}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + if result == nil || result.InsertedEvents != 1 { + t.Fatalf("expected old unique Redis event to insert despite watermark, got %+v", result) + } + + var event models.UsageEvent + if err := db.Where("event_key = ?", "old-but-unique").First(&event).Error; err != nil { + t.Fatalf("load old unique Redis event: %v", err) + } +} + +func TestSyncRedisBatchRetriesProcessFailedInboxBeforePoppingRedis(t *testing.T) { + db := openSyncTestDatabase(t) + poppedAt := time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC) + rows, err := repository.InsertRedisUsageInboxMessages(db, []repository.RedisInboxInsert{{ + QueueKey: cpa.ManagementUsageQueueKey, + RawMessage: `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"retry-process-failed","tokens":{"input_tokens":1,"output_tokens":2}}`, + PoppedAt: poppedAt, + }}) + if err != nil { + t.Fatalf("seed inbox row: %v", err) + } + if err := repository.MarkRedisUsageInboxProcessFailed(db, rows[0].ID, errors.New("temporary insert failure")); err != nil { + t.Fatalf("mark process failed: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{err: errors.New("redis should not be popped while process_failed inbox is retryable")}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + if result == nil || result.InsertedEvents != 1 { + t.Fatalf("expected process_failed row retry to insert, got %+v", result) + } + var inbox models.RedisUsageInbox + if err := db.First(&inbox, rows[0].ID).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusProcessed || inbox.LastError != "" { + t.Fatalf("expected retried row processed and error cleared, got %+v", inbox) + } +} + +func TestSyncNowInRedisModeUsesDurableInbox(t *testing.T) { + db := openSyncTestDatabase(t) + metadata := &trackingMetadataFetcher{} + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{ + `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"sync-now-redis","tokens":{"input_tokens":1,"output_tokens":2}}`, + }}, + MetadataFetcher: metadata, + }) + + result, err := service.SyncNow(context.Background()) + if err != nil { + t.Fatalf("SyncNow returned error: %v", err) + } + if result == nil || result.InsertedEvents != 1 { + t.Fatalf("unexpected SyncNow result: %+v", result) + } + if metadata.authCalls != 0 || metadata.providerCalls() != 0 { + t.Fatalf("expected SyncNow not to fetch metadata, got auth=%d provider=%d", metadata.authCalls, metadata.providerCalls()) + } + var inbox models.RedisUsageInbox + if err := db.First(&inbox).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.Status != repository.RedisUsageInboxStatusProcessed || inbox.UsageEventKey != "sync-now-redis" { + t.Fatalf("expected SyncNow redis path to use inbox, got %+v", inbox) + } +} + +func TestSyncRedisBatchKeepsRedisRequestIDWhenEquivalentCanonicalEventExists(t *testing.T) { + db := openSyncTestDatabase(t) + timestamp := time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC) + tokens := cpa.TokenStats{InputTokens: 10, OutputTokens: 20, ReasoningTokens: 5, CachedTokens: 4, TotalTokens: 39} + canonicalKey := BuildEventKey("external-api-key", "claude-sonnet", timestamp, "codex-a", "1", false, tokens) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: canonicalKey, + APIGroupKey: "external-api-key", + Model: "claude-sonnet", + Timestamp: timestamp, + Source: "codex-a", + AuthIndex: "1", + Failed: false, + LatencyMS: 123, + InputTokens: tokens.InputTokens, + OutputTokens: tokens.OutputTokens, + ReasoningTokens: tokens.ReasoningTokens, + CachedTokens: tokens.CachedTokens, + TotalTokens: tokens.TotalTokens, + }}); err != nil { + t.Fatalf("seed canonical usage event: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{ + equivalentRedisMessage("external-api-key", "claude-sonnet", timestamp, "codex-a", "1", false, 123, tokens, "redis-request-canonical"), + }}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + if result.InsertedEvents != 1 || result.DedupedEvents != 0 { + t.Fatalf("expected Redis request_id event to insert separately from canonical event, got %+v", result) + } + assertUsageEventCount(t, db, 2) + var inbox models.RedisUsageInbox + if err := db.First(&inbox).Error; err != nil { + t.Fatalf("load inbox row: %v", err) + } + if inbox.UsageEventKey != "redis-request-canonical" { + t.Fatalf("expected inbox to keep Redis request_id event key, got %+v", inbox) + } +} + +func TestSyncRedisBatchKeepsDistinctRedisRequestIDsWithSameCanonicalFields(t *testing.T) { + db := openSyncTestDatabase(t) + timestamp := time.Date(2026, 4, 27, 8, 0, 0, 0, time.UTC) + tokens := cpa.TokenStats{InputTokens: 10, OutputTokens: 20, ReasoningTokens: 5, CachedTokens: 4, TotalTokens: 39} + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{ + equivalentRedisMessage("external-api-key", "claude-sonnet", timestamp, "codex-a", "1", false, 123, tokens, "redis-request-1"), + equivalentRedisMessage("external-api-key", "claude-sonnet", timestamp, "codex-a", "1", false, 123, tokens, "redis-request-2"), + }}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + if result.InsertedEvents != 2 || result.DedupedEvents != 0 { + t.Fatalf("expected distinct Redis request IDs to insert separately, got %+v", result) + } + assertUsageEventCount(t, db, 2) +} + +func TestSyncRedisBatchWritesDebugLogsWithoutRawPayload(t *testing.T) { + db := openSyncTestDatabase(t) + logs := captureSyncDebugLogs(t) + + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{messages: []string{ + `{"timestamp":"2026-04-27T08:00:00Z","provider":"claude","model":"sonnet","request_id":"redis-log","api_key":"raw-secret-key","tokens":{"input_tokens":1,"output_tokens":2}}`, + }}, + }) + + _, err := service.SyncRedisBatch(context.Background()) + if err != nil { + t.Fatalf("SyncRedisBatch returned error: %v", err) + } + output := logs.String() + for _, expected := range []string{ + "redis usage batch popped", + "redis usage inbox rows inserted", + "redis usage inbox rows processed", + } { + if !strings.Contains(output, expected) { + t.Fatalf("expected debug log %q in output:\n%s", expected, output) + } + } + if strings.Contains(output, "raw-secret-key") || strings.Contains(output, "redis-log") { + t.Fatalf("debug logs should not include raw payload fields, got:\n%s", output) + } +} + +func TestSyncMetadataRefreshesMetadataWithoutSnapshot(t *testing.T) { + db := openSyncTestDatabase(t) + metadata := &trackingMetadataFetcher{} + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: metadata, + }) + + if err := service.SyncMetadata(context.Background()); err != nil { + t.Fatalf("SyncMetadata returned error: %v", err) + } + if metadata.authCalls != 1 || metadata.providerCalls() != 5 { + t.Fatalf("expected metadata fetch once, got auth=%d provider=%d", metadata.authCalls, metadata.providerCalls()) + } +} + +func TestSyncMetadataWritesAuthFilesToUsageIdentities(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: stubMetadataFetcher{authFilesResult: &cpa.AuthFilesResult{StatusCode: 200, Payload: cpa.AuthFilesResponse{Files: []cpa.AuthFile{{ + AuthIndex: "auth-1", + Name: "Fallback Name", + Email: "user@example.com", + Type: "claude", + Provider: "Claude", + Label: "Label Name", + }, { + AuthIndex: "auth-2", + Name: "Name Fallback", + Type: "gemini", + Provider: "Gemini", + Label: "Label Fallback", + }, { + AuthIndex: "auth-3", + Name: "Name Fallback", + Type: "codex", + Provider: "Codex", + }, { + AuthIndex: "auth-4", + Type: "vertex", + Provider: "Vertex", + }}}}}, + }) + + if err := service.SyncMetadata(context.Background()); err != nil { + t.Fatalf("SyncMetadata returned error: %v", err) + } + items, err := repository.ListUsageIdentities(context.Background(), db) + if err != nil { + t.Fatalf("list usage identities: %v", err) + } + byIdentity := usageIdentitiesByIdentity(items) + first := byIdentity["auth-1"] + if first.Name != "user@example.com" || first.AuthType != models.UsageIdentityAuthTypeAuthFile || first.AuthTypeName != "oauth" || first.Identity != "auth-1" || first.Type != "claude" || first.Provider != "Claude" || first.IsDeleted { + t.Fatalf("unexpected auth usage identity for auth-1: %+v", first) + } + second := byIdentity["auth-2"] + if second.Name != "Label Fallback" || second.AuthTypeName != "oauth" || second.Identity != "auth-2" || second.Type != "gemini" || second.Provider != "Gemini" || second.IsDeleted { + t.Fatalf("unexpected auth usage identity for auth-2: %+v", second) + } + third := byIdentity["auth-3"] + if third.Name != "Name Fallback" || third.AuthTypeName != "oauth" || third.Identity != "auth-3" || third.Type != "codex" || third.Provider != "Codex" || third.IsDeleted { + t.Fatalf("unexpected auth usage identity for auth-3: %+v", third) + } + fourth := byIdentity["auth-4"] + if fourth.Name != "auth-4" || fourth.AuthTypeName != "oauth" || fourth.Identity != "auth-4" || fourth.Type != "vertex" || fourth.Provider != "Vertex" || fourth.IsDeleted { + t.Fatalf("unexpected auth usage identity for auth-4: %+v", fourth) + } + assertTableNotExists(t, db, "auth_files") +} + +func TestSyncMetadataWritesProviderMetadataToUsageIdentities(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: stubMetadataFetcher{providerConfig: cpa.ProviderMetadataConfig{ + ClaudeAPIKeys: []cpa.ProviderKeyConfig{{APIKey: "claude-key", Prefix: "claude-prefix", Name: "Claude Team"}}, + }}, + }) + + if err := service.SyncMetadata(context.Background()); err != nil { + t.Fatalf("SyncMetadata returned error: %v", err) + } + items, err := repository.ListUsageIdentities(context.Background(), db) + if err != nil { + t.Fatalf("list usage identities: %v", err) + } + byIdentity := usageIdentitiesByIdentity(items) + apiKey := byIdentity["claude-key"] + if apiKey.Name != "Claude Team" || apiKey.AuthType != models.UsageIdentityAuthTypeAIProvider || apiKey.AuthTypeName != "apikey" || apiKey.Identity != "claude-key" || apiKey.Type != "claude" || apiKey.Provider != "Claude Team" || apiKey.IsDeleted { + t.Fatalf("unexpected provider usage identity for api key: %+v", apiKey) + } + if _, ok := byIdentity["claude-prefix"]; ok { + t.Fatalf("expected provider prefix not to be stored as usage identity, got %+v", byIdentity["claude-prefix"]) + } + assertTableNotExists(t, db, "provider_metadata") +} + +func TestSyncMetadataKeepsProviderIdentityWhenPrefixEqualsAPIKey(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: stubMetadataFetcher{providerConfig: cpa.ProviderMetadataConfig{ + ClaudeAPIKeys: []cpa.ProviderKeyConfig{{APIKey: "same-value", Prefix: "same-value", Name: "Claude Same"}}, + }}, + }) + + if err := service.SyncMetadata(context.Background()); err != nil { + t.Fatalf("SyncMetadata returned error: %v", err) + } + var identity models.UsageIdentity + if err := db.Where("auth_type = ? AND identity = ?", models.UsageIdentityAuthTypeAIProvider, "same-value").First(&identity).Error; err != nil { + t.Fatalf("load protected api key usage identity: %v", err) + } + if identity.IsDeleted || identity.Type != "claude" || identity.Provider != "Claude Same" { + t.Fatalf("expected api key matching prefix to remain active, got %+v", identity) + } +} + +func TestSyncMetadataDoesNotUseOpenAICompatibilityPrefixAsDisplayName(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: stubMetadataFetcher{providerConfig: cpa.ProviderMetadataConfig{ + OpenAICompatibility: []cpa.OpenAICompatibilityConfig{{ + Prefix: "https://proxy.internal/v1", + APIKeyEntries: []cpa.OpenAIApiKeyEntry{{APIKey: "openai-compatible-key"}}, + }}, + }}, + }) + + if err := service.SyncMetadata(context.Background()); err != nil { + t.Fatalf("SyncMetadata returned error: %v", err) + } + items, err := repository.ListUsageIdentities(context.Background(), db) + if err != nil { + t.Fatalf("list usage identities: %v", err) + } + byIdentity := usageIdentitiesByIdentity(items) + identity := byIdentity["openai-compatible-key"] + if identity.Identity != "openai-compatible-key" { + t.Fatalf("expected OpenAI compatibility api key usage identity, got %+v", identity) + } + if identity.Name != "openai" || identity.Provider != "openai" { + t.Fatalf("expected raw OpenAI compatibility prefix not to be used as display value, got %+v", identity) + } + if _, ok := byIdentity["https://proxy.internal/v1"]; ok { + t.Fatalf("expected OpenAI compatibility prefix not to create usage identity, got %+v", items) + } +} + +func TestSyncMetadataUsageIdentityPartialFailureKeepsFailedProviderType(t *testing.T) { + db := openSyncTestDatabase(t) + now := time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC) + oldDeletedAt := time.Date(2026, 5, 1, 9, 0, 0, 0, time.UTC) + if err := db.Create(&[]models.UsageIdentity{{ + Name: "Old Gemini", + AuthType: models.UsageIdentityAuthTypeAIProvider, + AuthTypeName: "apikey", + Identity: "old-gemini-key", + Type: "gemini", + Provider: "Old Gemini", + }, { + Name: "Old Claude", + AuthType: models.UsageIdentityAuthTypeAIProvider, + AuthTypeName: "apikey", + Identity: "old-claude-key", + Type: "claude", + Provider: "Old Claude", + DeletedAt: &oldDeletedAt, + }}).Error; err != nil { + t.Fatalf("seed usage identities: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + Now: func() time.Time { return now }, + MetadataFetcher: stubMetadataFetcher{ + providerConfig: cpa.ProviderMetadataConfig{ClaudeAPIKeys: []cpa.ProviderKeyConfig{{APIKey: "new-claude-key", Prefix: "new-claude-prefix", Name: "New Claude"}}}, + geminiErr: errors.New("gemini unavailable"), + }, + }) + + err := service.SyncMetadata(context.Background()) + if err == nil || !strings.Contains(err.Error(), "gemini unavailable") { + t.Fatalf("expected provider metadata warning, got %v", err) + } + items, listErr := repository.ListUsageIdentities(context.Background(), db) + if listErr != nil { + t.Fatalf("list usage identities: %v", listErr) + } + byIdentity := usageIdentitiesByIdentity(items) + if oldGemini := byIdentity["old-gemini-key"]; oldGemini.Identity == "" || oldGemini.IsDeleted || oldGemini.DeletedAt != nil { + t.Fatalf("expected failed gemini identity to remain untouched, got %+v", oldGemini) + } + if oldClaude := byIdentity["old-claude-key"]; oldClaude.Identity == "" || !oldClaude.IsDeleted || oldClaude.DeletedAt == nil || !oldClaude.DeletedAt.Equal(now) { + t.Fatalf("expected stale successful claude identity to be deleted at sync time, got %+v", oldClaude) + } + if newClaude := byIdentity["new-claude-key"]; newClaude.Identity == "" || newClaude.IsDeleted { + t.Fatalf("expected new claude identity to be active, got %+v", newClaude) + } +} + +func TestSyncMetadataAggregatesUsageIdentityStatsAfterUpsert(t *testing.T) { + db := openSyncTestDatabase(t) + eventTime := time.Date(2026, 5, 4, 8, 0, 0, 0, time.UTC) + now := time.Date(2026, 5, 4, 9, 0, 0, 0, time.UTC) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{{ + EventKey: "auth-stat-event", + AuthType: "oauth", + AuthIndex: "auth-stat", + Model: "sonnet", + Timestamp: eventTime, + InputTokens: 11, + OutputTokens: 13, + TotalTokens: 24, + }}); err != nil { + t.Fatalf("seed usage event: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + Now: func() time.Time { return now }, + MetadataFetcher: stubMetadataFetcher{authFilesResult: &cpa.AuthFilesResult{StatusCode: 200, Payload: cpa.AuthFilesResponse{Files: []cpa.AuthFile{{ + AuthIndex: "auth-stat", + Email: "stats@example.com", + Type: "claude", + Provider: "Claude", + }}}}}, + }) + + if err := service.SyncMetadata(context.Background()); err != nil { + t.Fatalf("SyncMetadata returned error: %v", err) + } + var identity models.UsageIdentity + if err := db.Where("identity = ?", "auth-stat").First(&identity).Error; err != nil { + t.Fatalf("load usage identity: %v", err) + } + if identity.TotalRequests != 1 || identity.SuccessCount != 1 || identity.InputTokens != 11 || identity.OutputTokens != 13 || identity.TotalTokens != 24 || identity.LastAggregatedUsageEventID == 0 || identity.StatsUpdatedAt == nil || !identity.StatsUpdatedAt.Equal(now) { + t.Fatalf("expected usage identity stats aggregated after metadata upsert, got %+v", identity) + } + if identity.FirstUsedAt == nil || !identity.FirstUsedAt.Equal(eventTime) || identity.LastUsedAt == nil || !identity.LastUsedAt.Equal(eventTime) { + t.Fatalf("expected usage identity first/last usage times from seeded event, got %+v", identity) + } +} + +func TestSyncMetadataPersistsProviderUsageIdentitiesFromDedicatedEndpoints(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: stubMetadataFetcher{providerConfig: cpa.ProviderMetadataConfig{ + GeminiAPIKeys: []cpa.ProviderKeyConfig{{APIKey: "gemini-key", Prefix: "gemini-prefix", Name: "Gemini"}}, + ClaudeAPIKeys: []cpa.ProviderKeyConfig{{APIKey: "claude-key", Prefix: "claude-prefix", Name: "Claude"}}, + OpenAICompatibility: []cpa.OpenAICompatibilityConfig{{ + Name: "Custom OpenAI", + Prefix: "custom-openai", + APIKeyEntries: []cpa.OpenAIApiKeyEntry{{APIKey: "custom-key"}}, + }}, + }}, + }) + + if err := service.SyncMetadata(context.Background()); err != nil { + t.Fatalf("SyncMetadata returned error: %v", err) + } + items, err := repository.ListUsageIdentities(context.Background(), db) + if err != nil { + t.Fatalf("list usage identities: %v", err) + } + providerItems := usageIdentitiesByIdentity(items) + for _, expected := range []string{"gemini-key", "claude-key", "custom-key"} { + identity := providerItems[expected] + if identity.Identity != expected || identity.AuthType != models.UsageIdentityAuthTypeAIProvider || identity.AuthTypeName != "apikey" || identity.IsDeleted { + t.Fatalf("expected active provider usage identity %q, got %+v", expected, identity) + } + } + for _, prefix := range []string{"gemini-prefix", "claude-prefix", "custom-openai"} { + if _, ok := providerItems[prefix]; ok { + t.Fatalf("expected provider prefix %q not to create usage identity, got %+v", prefix, items) + } + } + assertTableNotExists(t, db, "provider_metadata") +} + +func TestSyncMetadataPersistsSuccessfulProviderUsageIdentitiesWhenOneEndpointFails(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: stubMetadataFetcher{ + providerConfig: cpa.ProviderMetadataConfig{ClaudeAPIKeys: []cpa.ProviderKeyConfig{{APIKey: "claude-key", Prefix: "claude-prefix", Name: "Claude"}}}, + geminiErr: errors.New("gemini unavailable"), + }, + }) + + err := service.SyncMetadata(context.Background()) + if err == nil || !strings.Contains(err.Error(), "gemini unavailable") { + t.Fatalf("expected provider metadata warning, got %v", err) + } + items, listErr := repository.ListUsageIdentities(context.Background(), db) + if listErr != nil { + t.Fatalf("list usage identities: %v", listErr) + } + byIdentity := usageIdentitiesByIdentity(items) + identity := byIdentity["claude-key"] + if identity.Identity != "claude-key" || identity.Type != "claude" || identity.AuthType != models.UsageIdentityAuthTypeAIProvider || identity.IsDeleted { + t.Fatalf("expected successful provider usage identity to persist, got %+v", identity) + } + if _, ok := byIdentity["claude-prefix"]; ok { + t.Fatalf("expected successful provider prefix not to create usage identity, got %+v", items) + } + if _, ok := byIdentity["gemini-key"]; ok { + t.Fatalf("expected failed gemini endpoint not to create usage identity, got %+v", items) + } +} + +func TestSyncMetadataKeepsFailedProviderUsageIdentitiesDuringPartialFailure(t *testing.T) { + db := openSyncTestDatabase(t) + now := time.Date(2026, 5, 4, 9, 30, 0, 0, time.UTC) + if err := db.Create(&[]models.UsageIdentity{{ + Name: "Old Gemini", + AuthType: models.UsageIdentityAuthTypeAIProvider, + AuthTypeName: "apikey", + Identity: "old-gemini-key", + Type: "gemini", + Provider: "Old Gemini", + }, { + Name: "Old Claude", + AuthType: models.UsageIdentityAuthTypeAIProvider, + AuthTypeName: "apikey", + Identity: "old-claude-key", + Type: "claude", + Provider: "Old Claude", + }}).Error; err != nil { + t.Fatalf("seed usage identities: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + Now: func() time.Time { return now }, + MetadataFetcher: stubMetadataFetcher{ + providerConfig: cpa.ProviderMetadataConfig{ClaudeAPIKeys: []cpa.ProviderKeyConfig{{APIKey: "new-claude-key", Prefix: "new-claude-prefix", Name: "New Claude"}}}, + geminiErr: errors.New("gemini unavailable"), + }, + }) + + err := service.SyncMetadata(context.Background()) + if err == nil || !strings.Contains(err.Error(), "gemini unavailable") { + t.Fatalf("expected provider metadata warning, got %v", err) + } + items, listErr := repository.ListUsageIdentities(context.Background(), db) + if listErr != nil { + t.Fatalf("list usage identities: %v", listErr) + } + byIdentity := usageIdentitiesByIdentity(items) + if oldGemini := byIdentity["old-gemini-key"]; oldGemini.Identity == "" || oldGemini.IsDeleted || oldGemini.DeletedAt != nil { + t.Fatalf("expected failed gemini usage identity to remain untouched, got %+v", oldGemini) + } + if oldClaude := byIdentity["old-claude-key"]; oldClaude.Identity == "" || !oldClaude.IsDeleted || oldClaude.DeletedAt == nil || !oldClaude.DeletedAt.Equal(now) { + t.Fatalf("expected stale successful claude usage identity to be deleted, got %+v", oldClaude) + } + newClaude := byIdentity["new-claude-key"] + if newClaude.Identity != "new-claude-key" || newClaude.IsDeleted { + t.Fatalf("expected active replacement usage identity, got %+v", newClaude) + } + if _, ok := byIdentity["new-claude-prefix"]; ok { + t.Fatalf("expected replacement prefix not to create usage identity, got %+v", items) + } +} + +func TestSyncMetadataKeepsProviderUsageIdentitiesWhenEndpointReturnsNilResult(t *testing.T) { + db := openSyncTestDatabase(t) + if err := db.Create(&models.UsageIdentity{ + Name: "Old Gemini", + AuthType: models.UsageIdentityAuthTypeAIProvider, + AuthTypeName: "apikey", + Identity: "old-gemini-key", + Type: "gemini", + Provider: "Old Gemini", + }).Error; err != nil { + t.Fatalf("seed usage identity: %v", err) + } + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + MetadataFetcher: stubMetadataFetcher{geminiNilResult: true}, + }) + + err := service.SyncMetadata(context.Background()) + if err == nil || !strings.Contains(err.Error(), "gemini api keys response is nil") { + t.Fatalf("expected nil gemini response warning, got %v", err) + } + items, listErr := repository.ListUsageIdentities(context.Background(), db) + if listErr != nil { + t.Fatalf("list usage identities: %v", listErr) + } + byIdentity := usageIdentitiesByIdentity(items) + oldGemini := byIdentity["old-gemini-key"] + if oldGemini.Identity == "" || oldGemini.IsDeleted || oldGemini.DeletedAt != nil { + t.Fatalf("expected old gemini usage identity to remain, got %+v", oldGemini) + } +} + +func TestSyncRedisBatchErrorDoesNotCreateSnapshot(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncServiceWithOptions(db, SyncServiceOptions{ + BaseURL: "https://cpa.example.com", + RedisQueue: staticRedisQueue{err: errors.New("dial failed")}, + }) + + result, err := service.SyncRedisBatch(context.Background()) + if err == nil || result == nil || result.Status != "failed" { + t.Fatalf("expected failed redis batch result, got result=%+v err=%v", result, err) + } +} + +func TestNewSyncServiceBuildsClientFromConfig(t *testing.T) { + db := openSyncTestDatabase(t) + service := NewSyncService(db, config.Config{ + CPABaseURL: " https://cpa.example.com ", + CPAManagementKey: "secret", + RequestTimeout: 5 * time.Second, + }) + if service == nil || service.client == nil { + t.Fatal("expected sync service client to be initialized") + } + if service.baseURL != "https://cpa.example.com" { + t.Fatalf("expected trimmed base url, got %q", service.baseURL) + } +} + +func equivalentRedisMessage(apiGroupKey, model string, timestamp time.Time, source, authIndex string, failed bool, latencyMS int64, tokens cpa.TokenStats, requestID string) string { + failedValue := "false" + if failed { + failedValue = "true" + } + return `{"timestamp":"` + timestamp.UTC().Format(time.RFC3339) + `","latency_ms":` + int64String(latencyMS) + `,"source":"` + source + `","auth_index":"` + authIndex + `","failed":` + failedValue + `,"api_key":"` + apiGroupKey + `","model":"` + model + `","request_id":"` + requestID + `","tokens":{"input_tokens":` + int64String(tokens.InputTokens) + `,"output_tokens":` + int64String(tokens.OutputTokens) + `,"reasoning_tokens":` + int64String(tokens.ReasoningTokens) + `,"cached_tokens":` + int64String(tokens.CachedTokens) + `,"total_tokens":` + int64String(tokens.TotalTokens) + `}}` +} + +func int64String(value int64) string { + return strconv.FormatInt(value, 10) +} + +func usageIdentitiesByIdentity(items []models.UsageIdentity) map[string]models.UsageIdentity { + byIdentity := make(map[string]models.UsageIdentity, len(items)) + for _, item := range items { + byIdentity[item.Identity] = item + } + return byIdentity +} + +func assertUsageEventCount(t *testing.T, db *gorm.DB, expected int64) { + t.Helper() + var count int64 + if err := db.Model(&models.UsageEvent{}).Count(&count).Error; err != nil { + t.Fatalf("count usage events: %v", err) + } + if count != expected { + t.Fatalf("expected %d usage events, got %d", expected, count) + } +} + +func assertTableNotExists(t *testing.T, db *gorm.DB, table string) { + t.Helper() + if db.Migrator().HasTable(table) { + t.Fatalf("expected %s table not to exist", table) + } +} + +func openSyncTestDatabase(t *testing.T) *gorm.DB { + t.Helper() + + db, err := repository.OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "sync.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + return db +} + +func closeTestDatabase(t *testing.T, db *gorm.DB) { + t.Helper() + + sqlDB, err := db.DB() + if err != nil { + t.Fatalf("get sql database: %v", err) + } + t.Cleanup(func() { + if err := sqlDB.Close(); err != nil { + t.Fatalf("close database: %v", err) + } + }) +} + +func captureSyncDebugLogs(t *testing.T) *bytes.Buffer { + t.Helper() + + logs := &bytes.Buffer{} + previousOutput := logrus.StandardLogger().Out + previousLevel := logrus.GetLevel() + logrus.SetOutput(logs) + logrus.SetLevel(logrus.DebugLevel) + t.Cleanup(func() { + logrus.SetOutput(previousOutput) + logrus.SetLevel(previousLevel) + }) + return logs +} + +func openSyncTestDatabaseWithLogs(t *testing.T) (*gorm.DB, *bytes.Buffer) { + t.Helper() + + logs := &bytes.Buffer{} + gormLogger := gormlogger.New( + log.New(logs, "", 0), + gormlogger.Config{ + LogLevel: gormlogger.Info, + IgnoreRecordNotFoundError: false, + Colorful: false, + }, + ) + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "sync.db")), &gorm.Config{Logger: gormLogger}) + if err != nil { + t.Fatalf("gorm.Open returned error: %v", err) + } + closeTestDatabase(t, db) + if err := db.AutoMigrate(models.All()...); err != nil { + t.Fatalf("AutoMigrate returned error: %v", err) + } + return db, logs +} diff --git a/internal/service/usage.go b/internal/service/usage.go new file mode 100644 index 0000000000000000000000000000000000000000..f055d6125d2cd07698df57171f59aec87cb3a6e4 --- /dev/null +++ b/internal/service/usage.go @@ -0,0 +1,229 @@ +package service + +import ( + "context" + + "cpa-usage-keeper/internal/cpa" + "cpa-usage-keeper/internal/repository" + "gorm.io/gorm" +) + +type usageService struct { + db *gorm.DB +} + +func NewUsageService(db *gorm.DB) UsageProvider { + return &usageService{db: db} +} + +func (s *usageService) GetUsageWithFilter(_ context.Context, filter UsageFilter) (*cpa.StatisticsSnapshot, error) { + return repository.BuildUsageSnapshotWithFilter(s.db, repository.UsageQueryFilter{ + Range: filter.Range, + StartTime: filter.StartTime, + EndTime: filter.EndTime, + }) +} + +func (s *usageService) GetUsageOverview(_ context.Context, filter UsageFilter) (*UsageOverviewSnapshot, error) { + overview, err := repository.BuildUsageOverviewWithFilter(s.db, repository.UsageQueryFilter{ + Range: filter.Range, + StartTime: filter.StartTime, + EndTime: filter.EndTime, + }) + if err != nil { + return nil, err + } + return &UsageOverviewSnapshot{ + Usage: overview.Usage, + Summary: UsageOverviewSummary{ + RequestCount: overview.Summary.RequestCount, + TokenCount: overview.Summary.TokenCount, + WindowMinutes: overview.Summary.WindowMinutes, + RPM: overview.Summary.RPM, + TPM: overview.Summary.TPM, + TotalCost: overview.Summary.TotalCost, + CostAvailable: overview.Summary.CostAvailable, + CachedTokens: overview.Summary.CachedTokens, + ReasoningTokens: overview.Summary.ReasoningTokens, + }, + Series: mapUsageOverviewSeries(overview.Series), + HourlySeries: mapUsageOverviewSeries(overview.HourlySeries), + DailySeries: mapUsageOverviewSeries(overview.DailySeries), + Health: UsageOverviewHealth{ + TotalSuccess: overview.Health.TotalSuccess, + TotalFailure: overview.Health.TotalFailure, + SuccessRate: overview.Health.SuccessRate, + Rows: overview.Health.Rows, + Columns: overview.Health.Columns, + BucketSeconds: overview.Health.BucketSeconds, + WindowStart: overview.Health.WindowStart, + WindowEnd: overview.Health.WindowEnd, + BlockDetails: func() []UsageOverviewHealthBlock { + blocks := make([]UsageOverviewHealthBlock, 0, len(overview.Health.BlockDetails)) + for _, block := range overview.Health.BlockDetails { + blocks = append(blocks, UsageOverviewHealthBlock{ + StartTime: block.StartTime, + EndTime: block.EndTime, + Success: block.Success, + Failure: block.Failure, + Rate: block.Rate, + }) + } + return blocks + }(), + }, + }, nil +} + +func mapUsageOverviewSeries(series repository.UsageOverviewSeriesRecord) UsageOverviewSeries { + models := make(map[string]UsageOverviewSeries, len(series.Models)) + for model, modelSeries := range series.Models { + models[model] = mapUsageOverviewSeries(modelSeries) + } + return UsageOverviewSeries{ + Requests: series.Requests, + Tokens: series.Tokens, + RPM: series.RPM, + TPM: series.TPM, + Cost: series.Cost, + InputTokens: series.InputTokens, + OutputTokens: series.OutputTokens, + CachedTokens: series.CachedTokens, + ReasoningTokens: series.ReasoningTokens, + Models: models, + } +} + +func (s *usageService) ListUsageEvents(_ context.Context, filter UsageFilter) (*UsageEventsPage, error) { + page, err := repository.ListUsageEventsWithFilter(s.db, repository.UsageQueryFilter{ + StartTime: filter.StartTime, + EndTime: filter.EndTime, + Limit: filter.Limit, + Page: filter.Page, + PageSize: filter.PageSize, + Offset: filter.Offset, + Model: filter.Model, + Source: filter.Source, + AuthIndex: filter.AuthIndex, + AuthType: filter.AuthType, + Provider: filter.Provider, + Result: filter.Result, + }) + if err != nil { + return nil, err + } + result := make([]UsageEventRecord, 0, len(page.Events)) + for _, row := range page.Events { + result = append(result, UsageEventRecord{ + ID: row.ID, + Timestamp: row.Timestamp, + APIGroupKey: row.APIGroupKey, + Model: row.Model, + AuthType: row.AuthType, + Provider: row.Provider, + Source: row.Source, + AuthIndex: row.AuthIndex, + Failed: row.Failed, + LatencyMS: row.LatencyMS, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + ReasoningTokens: row.ReasoningTokens, + CachedTokens: row.CachedTokens, + TotalTokens: row.TotalTokens, + }) + } + return &UsageEventsPage{Events: result, Models: page.Models, Sources: page.Sources, TotalCount: page.TotalCount, Page: page.Page, PageSize: page.PageSize, TotalPages: page.TotalPages}, nil +} + +func (s *usageService) ListUsageEventFilterOptions(_ context.Context, filter UsageFilter) (*UsageEventFilterOptions, error) { + options, err := repository.ListUsageEventFilterOptionsWithFilter(s.db, repository.UsageQueryFilter{ + StartTime: filter.StartTime, + EndTime: filter.EndTime, + }) + if err != nil { + return nil, err + } + return &UsageEventFilterOptions{Models: options.Models, Sources: options.Sources}, nil +} + +func (s *usageService) ListUsageCredentialStats(_ context.Context, filter UsageFilter) ([]UsageCredentialStat, error) { + rows, err := repository.ListUsageCredentialStatsWithFilter(s.db, repository.UsageQueryFilter{ + StartTime: filter.StartTime, + EndTime: filter.EndTime, + }) + if err != nil { + return nil, err + } + result := make([]UsageCredentialStat, 0, len(rows)) + for _, row := range rows { + result = append(result, UsageCredentialStat{ + Source: row.Source, + AuthIndex: row.AuthIndex, + Failed: row.Failed, + RequestCount: row.RequestCount, + }) + } + return result, nil +} + +func (s *usageService) GetUsageAnalysis(_ context.Context, filter UsageFilter) (*UsageAnalysisSnapshot, error) { + apiRows, modelRows, err := repository.ListUsageAnalysisWithFilter(s.db, repository.UsageQueryFilter{ + StartTime: filter.StartTime, + EndTime: filter.EndTime, + }) + if err != nil { + return nil, err + } + + apis := make([]UsageAnalysisAPIStat, 0, len(apiRows)) + for _, row := range apiRows { + models := make([]UsageAnalysisModelStat, 0, len(row.Models)) + for _, model := range row.Models { + models = append(models, UsageAnalysisModelStat{ + Model: model.Model, + TotalRequests: model.TotalRequests, + SuccessCount: model.SuccessCount, + FailureCount: model.FailureCount, + TotalTokens: model.TotalTokens, + InputTokens: model.InputTokens, + OutputTokens: model.OutputTokens, + ReasoningTokens: model.ReasoningTokens, + CachedTokens: model.CachedTokens, + TotalLatencyMS: model.TotalLatencyMS, + LatencySampleCount: model.LatencySampleCount, + }) + } + apis = append(apis, UsageAnalysisAPIStat{ + APIKey: row.APIGroupKey, + DisplayName: row.DisplayName, + TotalRequests: row.TotalRequests, + SuccessCount: row.SuccessCount, + FailureCount: row.FailureCount, + TotalTokens: row.TotalTokens, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + ReasoningTokens: row.ReasoningTokens, + CachedTokens: row.CachedTokens, + Models: models, + }) + } + + models := make([]UsageAnalysisModelStat, 0, len(modelRows)) + for _, row := range modelRows { + models = append(models, UsageAnalysisModelStat{ + Model: row.Model, + TotalRequests: row.TotalRequests, + SuccessCount: row.SuccessCount, + FailureCount: row.FailureCount, + TotalTokens: row.TotalTokens, + InputTokens: row.InputTokens, + OutputTokens: row.OutputTokens, + ReasoningTokens: row.ReasoningTokens, + CachedTokens: row.CachedTokens, + TotalLatencyMS: row.TotalLatencyMS, + LatencySampleCount: row.LatencySampleCount, + }) + } + + return &UsageAnalysisSnapshot{APIs: apis, Models: models}, nil +} diff --git a/internal/service/usage_filter.go b/internal/service/usage_filter.go new file mode 100644 index 0000000000000000000000000000000000000000..eb0df6512114d33c59510097ce7b4eb4d1e822ff --- /dev/null +++ b/internal/service/usage_filter.go @@ -0,0 +1,152 @@ +package service + +import ( + "time" + + "cpa-usage-keeper/internal/cpa" +) + +type UsageFilter struct { + Range string + StartTime *time.Time + EndTime *time.Time + Limit int + Page int + PageSize int + Offset int + Model string + Source string + AuthIndex string + AuthType string + Provider string + Result string +} + +const DefaultUsageEventsLimit = 100 + +type UsageEventsPage struct { + Events []UsageEventRecord + Models []string + Sources []string + TotalCount int64 + Page int + PageSize int + TotalPages int +} + +type UsageEventFilterOptions struct { + Models []string + Sources []string +} + +type UsageEventRecord struct { + ID uint + Timestamp time.Time + APIGroupKey string + Model string + AuthType string + Provider string + Source string + AuthIndex string + Failed bool + LatencyMS int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalTokens int64 +} + +type UsageCredentialStat struct { + Source string + AuthIndex string + Failed bool + RequestCount int64 +} + +type UsageAnalysisModelStat struct { + Model string + TotalRequests int64 + SuccessCount int64 + FailureCount int64 + TotalTokens int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + TotalLatencyMS int64 + LatencySampleCount int64 +} + +type UsageAnalysisAPIStat struct { + APIKey string + DisplayName string + TotalRequests int64 + SuccessCount int64 + FailureCount int64 + TotalTokens int64 + InputTokens int64 + OutputTokens int64 + ReasoningTokens int64 + CachedTokens int64 + Models []UsageAnalysisModelStat +} + +type UsageAnalysisSnapshot struct { + APIs []UsageAnalysisAPIStat + Models []UsageAnalysisModelStat +} + +type UsageOverviewSummary struct { + RequestCount int64 + TokenCount int64 + WindowMinutes int64 + RPM float64 + TPM float64 + TotalCost float64 + CostAvailable bool + CachedTokens int64 + ReasoningTokens int64 +} + +type UsageOverviewSeries struct { + Requests map[string]int64 + Tokens map[string]int64 + RPM map[string]float64 + TPM map[string]float64 + Cost map[string]float64 + InputTokens map[string]int64 + OutputTokens map[string]int64 + CachedTokens map[string]int64 + ReasoningTokens map[string]int64 + Models map[string]UsageOverviewSeries +} + +type UsageOverviewHealthBlock struct { + StartTime time.Time + EndTime time.Time + Success int64 + Failure int64 + Rate float64 +} + +type UsageOverviewHealth struct { + TotalSuccess int64 + TotalFailure int64 + SuccessRate float64 + Rows int + Columns int + BucketSeconds int64 + WindowStart time.Time + WindowEnd time.Time + BlockDetails []UsageOverviewHealthBlock +} + +type UsageOverviewSnapshot struct { + Usage *cpa.StatisticsSnapshot + Summary UsageOverviewSummary + Series UsageOverviewSeries + HourlySeries UsageOverviewSeries + DailySeries UsageOverviewSeries + Health UsageOverviewHealth +} diff --git a/internal/service/usage_filter_test.go b/internal/service/usage_filter_test.go new file mode 100644 index 0000000000000000000000000000000000000000..76c563629efc457f7ec572b3a1516de17d135599 --- /dev/null +++ b/internal/service/usage_filter_test.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "math" + "path/filepath" + "testing" + "time" + + "cpa-usage-keeper/internal/config" + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/repository" +) + +func TestUsageServiceGetUsageWithFilterDelegatesToFilteredSnapshot(t *testing.T) { + db, err := repository.OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-service-filter.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), TotalTokens: 10}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), TotalTokens: 20}, + }); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 9, 30, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 10, 30, 0, 0, time.UTC) + provider := NewUsageService(db) + snapshot, err := provider.GetUsageWithFilter(context.Background(), UsageFilter{StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("GetUsageWithFilter returned error: %v", err) + } + if snapshot.TotalRequests != 1 || snapshot.TotalTokens != 20 { + t.Fatalf("expected service filter to keep only in-range event, got %+v", snapshot) + } +} + +func TestUsageServiceGetUsageOverviewDelegatesToFilteredOverview(t *testing.T) { + db, err := repository.OpenDatabase(config.Config{SQLitePath: filepath.Join(t.TempDir(), "usage-service-overview.db")}) + if err != nil { + t.Fatalf("OpenDatabase returned error: %v", err) + } + closeTestDatabase(t, db) + if _, err := repository.UpsertModelPriceSetting(db, repository.ModelPriceSettingInput{ + Model: "claude-sonnet", + PromptPricePer1M: 3, + CompletionPricePer1M: 15, + CachePricePer1M: 0.3, + }); err != nil { + t.Fatalf("UpsertModelPriceSetting returned error: %v", err) + } + if _, _, err := repository.InsertUsageEvents(db, []models.UsageEvent{ + {EventKey: "event-1", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 9, 0, 0, 0, time.UTC), InputTokens: 1000, OutputTokens: 500, CachedTokens: 100, ReasoningTokens: 50, TotalTokens: 1650}, + {EventKey: "event-2", APIGroupKey: "provider-a", Model: "claude-sonnet", Timestamp: time.Date(2026, 4, 16, 10, 0, 0, 0, time.UTC), InputTokens: 500, OutputTokens: 250, CachedTokens: 0, ReasoningTokens: 25, TotalTokens: 775}, + }); err != nil { + t.Fatalf("InsertUsageEvents returned error: %v", err) + } + + start := time.Date(2026, 4, 16, 0, 0, 0, 0, time.UTC) + end := time.Date(2026, 4, 16, 23, 59, 59, 0, time.UTC) + provider := NewUsageService(db) + overview, err := provider.GetUsageOverview(context.Background(), UsageFilter{Range: "24h", StartTime: &start, EndTime: &end}) + if err != nil { + t.Fatalf("GetUsageOverview returned error: %v", err) + } + if overview.Summary.RequestCount != 2 || overview.Summary.TokenCount != 2425 { + t.Fatalf("expected overview summary counts, got %+v", overview.Summary) + } + if overview.Summary.WindowMinutes != 1440 { + t.Fatalf("expected 24h overview to use exact 1440 minute window, got %+v", overview.Summary) + } + if overview.Series.Requests["2026-04-16T09:00:00Z"] != 1 || overview.Series.Requests["2026-04-16T10:00:00Z"] != 1 { + t.Fatalf("expected hourly request series values, got %+v", overview.Series) + } + if math.Abs(overview.Series.Cost["2026-04-16T09:00:00Z"]-0.01023) > 0.000000001 || math.Abs(overview.Series.Cost["2026-04-16T10:00:00Z"]-0.00525) > 0.000000001 { + t.Fatalf("expected hourly cost series values, got %+v", overview.Series) + } +} diff --git a/internal/service/usage_identities_service.go b/internal/service/usage_identities_service.go new file mode 100644 index 0000000000000000000000000000000000000000..38a8686c3deaafc806925909d60a4323d44a2d30 --- /dev/null +++ b/internal/service/usage_identities_service.go @@ -0,0 +1,25 @@ +package service + +import ( + "context" + + "cpa-usage-keeper/internal/models" + "cpa-usage-keeper/internal/repository" + "gorm.io/gorm" +) + +type UsageIdentityProvider interface { + ListUsageIdentities(context.Context) ([]models.UsageIdentity, error) +} + +type usageIdentityService struct { + db *gorm.DB +} + +func NewUsageIdentityService(db *gorm.DB) UsageIdentityProvider { + return &usageIdentityService{db: db} +} + +func (s *usageIdentityService) ListUsageIdentities(ctx context.Context) ([]models.UsageIdentity, error) { + return repository.ListUsageIdentities(ctx, s.db) +} diff --git a/internal/service/usage_service.go b/internal/service/usage_service.go new file mode 100644 index 0000000000000000000000000000000000000000..ab6ae94d1b77e2db9b455db1abf0cba0994609a0 --- /dev/null +++ b/internal/service/usage_service.go @@ -0,0 +1,16 @@ +package service + +import ( + "context" + + "cpa-usage-keeper/internal/cpa" +) + +type UsageProvider interface { + GetUsageWithFilter(context.Context, UsageFilter) (*cpa.StatisticsSnapshot, error) + GetUsageOverview(context.Context, UsageFilter) (*UsageOverviewSnapshot, error) + ListUsageEvents(context.Context, UsageFilter) (*UsageEventsPage, error) + ListUsageEventFilterOptions(context.Context, UsageFilter) (*UsageEventFilterOptions, error) + ListUsageCredentialStats(context.Context, UsageFilter) ([]UsageCredentialStat, error) + GetUsageAnalysis(context.Context, UsageFilter) (*UsageAnalysisSnapshot, error) +} diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a547bf36d8d11a4f89c59c144f24795749086dd1 --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..75bd518a3b1de8466df3644967283aba7a58b215 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,36 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + js.configs.recommended, + ...tseslint.configs.recommended, + { + files: ['**/*.{ts,tsx,js,jsx}'], + extends: [ + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: { + ...globals.browser, + ...globals.node, + }, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]', argsIgnorePattern: '^_' }], + 'no-unused-vars': 'off', + 'react-refresh/only-export-components': 'off', + }, + }, +]) diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..932abd79f1045f1f8aaa9f24e606421a798b7cbd --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + CPA USAGE KEEPER + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..46262cf1c2698edb18886428072f349e940a62ed --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,5399 @@ +{ + "name": "web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web", + "version": "0.0.0", + "dependencies": { + "chart.js": "^4.5.1", + "i18next": "^26.0.5", + "react": "^19.2.4", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.4", + "react-i18next": "^17.0.4", + "react-router-dom": "^7.9.4", + "recharts": "^3.2.1", + "sass": "^1.99.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.0", + "vite": "^8.0.4", + "vitest": "^3.2.4" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", + "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.2.tgz", + "integrity": "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@kurkle/color": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", + "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.124.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", + "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-oArR/ig8wNTPYsXL+Mzhs0oxhxfuHRfG7Ikw7jXsw8mYOtk71W0OkF2VEVh699pdmzjPQsTjlD1JIOoHkLP1Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-YzeVqOqjPYvUbJSWJ4EDL8ahbmsIXQpgL3JVipmN+MX0XnXMeWomLN3Fb+nwCmP/jfyqte5I3XRSm7OfQrbyxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.15.tgz", + "integrity": "sha512-9Erhx956jeQ0nNTyif1+QWAXDRD38ZNjr//bSHrt6wDwB+QkAfl2q6Mn1k6OBPerznjRmbM10lgRb1Pli4xZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.15.tgz", + "integrity": "sha512-cVwk0w8QbZJGTnP/AHQBs5yNwmpgGYStL88t4UIaqcvYJWBfS0s3oqVLZPwsPU6M0zlW4GqjP0Zq5MnAGwFeGA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-eBZ/u8iAK9SoHGanqe/jrPnY0JvBN6iXbVOsbO38mbz+ZJsaobExAm1Iu+rxa4S1l2FjG0qEZn4Rc6X8n+9M+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-ZvRYMGrAklV9PEkgt4LQM6MjQX2P58HPAuecwYObY2DhS2t35R0I810bKi0wmaYORt6m/2Sm+Z+nFgb0WhXNcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-VDpgGBzgfg5hLg+uBpCLoFG5kVvEyafmfxGUV0UHLcL5irxAK7PKNeC2MwClgk6ZAiNhmo9FLhRYgvMmedLtnQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-y1uXY3qQWCzcPgRJATPSOUP4tCemh4uBdY7e3EZbVwCJTY3gLJWnQABgeUetvED+bt1FQ01OeZwvhLS2bpNrAQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.15.tgz", + "integrity": "sha512-023bTPBod7J3Y/4fzAN6QtpkSABR0rigtrwaP+qSEabUh5zf6ELr9Nc7GujaROuPY3uwdSIXWrvhn1KxOvurWA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.15.tgz", + "integrity": "sha512-witB2O0/hU4CgfOOKUoeFgQ4GktPi1eEbAhaLAIpgD6+ZnhcPkUtPsoKKHRzmOoWPZue46IThdSgdo4XneOLYw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.15.tgz", + "integrity": "sha512-UCL68NJ0Ud5zRipXZE9dF5PmirzJE4E4BCIOOssEnM7wLDsxjc6Qb0sGDxTNRTP53I6MZpygyCpY8Aa8sPfKPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.15.tgz", + "integrity": "sha512-ApLruZq/ig+nhaE7OJm4lDjayUnOHVUa77zGeqnqZ9pn0ovdVbbNPerVibLXDmWeUZXjIYIT8V3xkT58Rm9u5Q==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.9.2", + "@emnapi/runtime": "1.9.2", + "@napi-rs/wasm-runtime": "^1.1.3" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-KmoUoU7HnN+Si5YWJigfTws1jz1bKBYDQKdbLspz0UaqjjFkddHsqorgiW1mxcAj88lYUE6NC/zJNwT+SloqtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.15.tgz", + "integrity": "sha512-3P2A8L+x75qavWLe/Dll3EYBJLQmtkJN8rfh+U/eR3MqMgL/h98PhYI+JFfXuDPgPeCB7iZAKiqii5vqOvnA0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", + "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.0.tgz", + "integrity": "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/type-utils": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.0.tgz", + "integrity": "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.0.tgz", + "integrity": "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.0", + "@typescript-eslint/types": "^8.59.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.0.tgz", + "integrity": "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.0.tgz", + "integrity": "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.0.tgz", + "integrity": "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.0.tgz", + "integrity": "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.0.tgz", + "integrity": "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.0", + "@typescript-eslint/tsconfig-utils": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/visitor-keys": "8.59.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.0.tgz", + "integrity": "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.0", + "@typescript-eslint/types": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.0.tgz", + "integrity": "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.7" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chart.js": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", + "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "license": "MIT", + "dependencies": { + "@kurkle/color": "^0.3.0" + }, + "engines": { + "pnpm": ">=8" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.339", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.339.tgz", + "integrity": "sha512-Is+0BBHJ4NrdpAYiperrmp53pLywG/yV/6lIMTAnhxvzj/Cmn5Q/ogSHC6AKe7X+8kPLxxFk0cs5oc/3j/fxIg==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-toolkit": { + "version": "1.45.1", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz", + "integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.2.tgz", + "integrity": "sha512-hmgTH57GfzoTFjVN0yBwTggnsVUF2tcqi7RJZHqi9lIezSs4eFyAMktA68YD4r5kNw1mxyY4dmkyoFDb3FIqrA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.5.0.tgz", + "integrity": "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-parse-stringify": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz", + "integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==", + "license": "MIT", + "dependencies": { + "void-elements": "3.1.0" + } + }, + "node_modules/i18next": { + "version": "26.0.5", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.5.tgz", + "integrity": "sha512-9uHb4T27TdV36phJXcbpnRPt5yzAfqHXVrdASvmHZyPuZJtrLythd+GyXhiaHV5LlpuuskbAqhwPjmfTbKbi8w==", + "funding": [ + { + "type": "individual", + "url": "https://www.locize.com/i18next" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + }, + { + "type": "individual", + "url": "https://www.locize.com" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2" + }, + "peerDependencies": { + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-chartjs-2": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/react-chartjs-2/-/react-chartjs-2-5.3.1.tgz", + "integrity": "sha512-h5IPXKg9EXpjoBzUfyWJvllMjG2mQ4EiuHQFhms/AjUm0XSZHhyRy2xVmLXHKrtcdrPO4mnGqRtYoD0vp95A0A==", + "license": "MIT", + "peerDependencies": { + "chart.js": "^4.1.1", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-i18next": { + "version": "17.0.4", + "resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.4.tgz", + "integrity": "sha512-hQipmK4EF0y6RO6tt6WuqnmWpWYEXmQUUzecmMBuNsIgYd3smXcG4GtYPWhvgxn0pqMOItKlEO8H24HCs5hc3g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.29.2", + "html-parse-stringify": "^3.0.1", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "i18next": ">= 26.0.1", + "react": ">= 16.8.0", + "typescript": "^5 || ^6" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + }, + "react-native": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/react-is": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.5.tgz", + "integrity": "sha512-Dn0t8IQhCmeIT3wu+Apm1/YVsJXsGWi6k4sPdnBIdqMVtHtv0IGi6dcpNpNkNac0zB2uUAqNX3MHzN8c+z2rwQ==", + "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.1.tgz", + "integrity": "sha512-5BCvFskyAAVumqhEKh/iPhLOIkfxcEUz8WqFIARCkMg8hZZzDYX9CtwxXA0e+qT8zAxmMC0x3Ckb9iMONwc5jg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.14.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.1.tgz", + "integrity": "sha512-ZkrQuwwhGibjQLqH1eCdyiZyLWglPxzxdl5tgwgKEyCSGC76vmAjleGocRe3J/MLfzMUIKwaFJWpFVJhK3d2xA==", + "license": "MIT", + "dependencies": { + "react-router": "7.14.1" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/recharts": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz", + "integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==", + "license": "MIT", + "workspaces": [ + "www" + ], + "dependencies": { + "@reduxjs/toolkit": "^1.9.0 || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rolldown": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.15.tgz", + "integrity": "sha512-Ff31guA5zT6WjnGp0SXw76X6hzGRk/OQq2hE+1lcDe+lJdHSgnSX6nK3erbONHyCbpSj9a9E+uX/OvytZoWp2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.124.0", + "@rolldown/pluginutils": "1.0.0-rc.15" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-arm64": "1.0.0-rc.15", + "@rolldown/binding-darwin-x64": "1.0.0-rc.15", + "@rolldown/binding-freebsd-x64": "1.0.0-rc.15", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.15", + "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.15", + "@rolldown/binding-linux-x64-musl": "1.0.0-rc.15", + "@rolldown/binding-openharmony-arm64": "1.0.0-rc.15", + "@rolldown/binding-wasm32-wasi": "1.0.0-rc.15", + "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.15", + "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" + } + }, + "node_modules/rolldown/node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.15", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.15.tgz", + "integrity": "sha512-UromN0peaE53IaBRe9W7CjrZgXl90fqGpK+mIZbA3qSTeYqg3pqpROBdIPvOG3F5ereDHNwoHBI2e50n1BDr1g==", + "dev": true, + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/sass": { + "version": "1.99.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.99.0.tgz", + "integrity": "sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==", + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.0.tgz", + "integrity": "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.0", + "@typescript-eslint/parser": "8.59.0", + "@typescript-eslint/typescript-estree": "8.59.0", + "@typescript-eslint/utils": "8.59.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/victory-vendor": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "8.0.8", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.8.tgz", + "integrity": "sha512-dbU7/iLVa8KZALJyLOBOQ88nOXtNG8vxKuOT4I2mD+Ya70KPceF4IAmDsmU0h1Qsn5bPrvsY9HJstCRh3hG6Uw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.8", + "rolldown": "1.0.0-rc.15", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/void-elements": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz", + "integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.12.tgz", + "integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..94d1d66672f0cc5738181db6252eaee4112051ab --- /dev/null +++ b/web/package.json @@ -0,0 +1,40 @@ +{ + "name": "web", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "preview": "vite preview" + }, + "dependencies": { + "chart.js": "^4.5.1", + "i18next": "^26.0.5", + "react": "^19.2.4", + "react-chartjs-2": "^5.3.1", + "react-dom": "^19.2.4", + "react-i18next": "^17.0.4", + "react-router-dom": "^7.9.4", + "recharts": "^3.2.1", + "sass": "^1.99.0", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@eslint/js": "^9.39.4", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "eslint": "^9.39.4", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.4.0", + "typescript": "^6.0.3", + "typescript-eslint": "^8.59.0", + "vite": "^8.0.4", + "vitest": "^3.2.4" + } +} diff --git a/web/src/App.css b/web/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..4b862aaba41286fac3a0d7e465b11b0889061a1c --- /dev/null +++ b/web/src/App.css @@ -0,0 +1,43 @@ +.app-shell { + min-height: 100svh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px; + box-sizing: border-box; +} + +.hero-card { + width: min(720px, 100%); + text-align: left; + padding: 32px; + border: 1px solid var(--border); + border-radius: 20px; + background: color-mix(in srgb, var(--bg) 92%, var(--accent-bg)); + box-shadow: var(--shadow); +} + +.eyebrow { + margin-bottom: 12px; + font-size: 14px; + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); +} + +.description { + margin-top: 16px; + line-height: 1.7; +} + +.checklist { + margin: 24px 0 0; + padding-left: 20px; + display: grid; + gap: 12px; +} + +.checklist li { + color: var(--text-h); +} diff --git a/web/src/App.tsx b/web/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..62a54024f7f4b82c5c4d3a5085068e1e3369770a --- /dev/null +++ b/web/src/App.tsx @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import './index.css'; +import { ApiError, getSession, login } from './lib/api'; +import { LoginPage } from './pages/LoginPage'; +import { UsagePage } from './pages/UsagePage'; + +type AuthState = 'checking' | 'authenticated' | 'unauthenticated'; + +function App() { + const { t } = useTranslation(); + const [authState, setAuthState] = useState('checking'); + const [loginError, setLoginError] = useState(''); + const [submitting, setSubmitting] = useState(false); + + const loadSession = useCallback(async () => { + const session = await getSession(); + setAuthState(session.authenticated ? 'authenticated' : 'unauthenticated'); + }, []); + + useEffect(() => { + void loadSession().catch(() => { + setAuthState('unauthenticated'); + }); + }, [loadSession]); + + const handleLogin = useCallback(async (password: string) => { + setSubmitting(true); + setLoginError(''); + try { + await login(password); + setAuthState('authenticated'); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + setLoginError(t('auth.invalid_password')); + } else { + setLoginError(t('auth.login_failed')); + } + setAuthState('unauthenticated'); + } finally { + setSubmitting(false); + } + }, [t]); + + if (authState === 'checking') { + return
; + } + + if (authState === 'unauthenticated') { + return ; + } + + return setAuthState('unauthenticated')} />; +} + +export default App; diff --git a/web/src/assets/cli-proxy-api-favicon.png b/web/src/assets/cli-proxy-api-favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..fee98978eb879e7dfe0dc3c8d4ea8b39b69ab9f6 --- /dev/null +++ b/web/src/assets/cli-proxy-api-favicon.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8e0eb56b647e20b27efc315acc1300ac85ac880a1b80fd86c9f259acc57ee925 +size 406557 diff --git a/web/src/components/ui/Button.tsx b/web/src/components/ui/Button.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b55bb429e5fb8f03d4a138ced714739858bb1e88 --- /dev/null +++ b/web/src/components/ui/Button.tsx @@ -0,0 +1,40 @@ +import React, { type ButtonHTMLAttributes, type PropsWithChildren } from 'react'; + +type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger'; +type ButtonSize = 'md' | 'sm'; + +interface ButtonProps extends ButtonHTMLAttributes { + variant?: ButtonVariant; + size?: ButtonSize; + fullWidth?: boolean; + loading?: boolean; +} + +export function Button({ + children, + variant = 'primary', + size = 'md', + fullWidth = false, + loading = false, + className = '', + disabled, + ...rest +}: PropsWithChildren) { + const hasChildren = children !== null && children !== undefined && children !== false; + const classes = [ + 'btn', + `btn-${variant}`, + size === 'sm' ? 'btn-sm' : '', + fullWidth ? 'btn-full' : '', + className + ] + .filter(Boolean) + .join(' '); + + return ( + + ); +} diff --git a/web/src/components/ui/Card.tsx b/web/src/components/ui/Card.tsx new file mode 100644 index 0000000000000000000000000000000000000000..94d36b2644aa7fcf38e5cc18a3d4c6b28132df8f --- /dev/null +++ b/web/src/components/ui/Card.tsx @@ -0,0 +1,21 @@ +import React, { type PropsWithChildren, type ReactNode, type HTMLAttributes } from 'react'; + +interface CardProps extends Omit, 'title'> { + title?: ReactNode; + extra?: ReactNode; + className?: string; +} + +export function Card({ title, extra, children, className, ...props }: PropsWithChildren) { + return ( +
+ {(title || extra) && ( +
+
{title}
+ {extra} +
+ )} + {children} +
+ ); +} diff --git a/web/src/components/ui/EmptyState.tsx b/web/src/components/ui/EmptyState.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e094b30614baeb00eecfb0fa311c662729fc9c1c --- /dev/null +++ b/web/src/components/ui/EmptyState.tsx @@ -0,0 +1,25 @@ +import React, { type ReactNode } from 'react'; +import { IconInbox } from './icons'; + +interface EmptyStateProps { + title: string; + description?: string; + action?: ReactNode; +} + +export function EmptyState({ title, description, action }: EmptyStateProps) { + return ( +
+
+ +
+
{title}
+ {description &&
{description}
} +
+
+ {action &&
{action}
} +
+ ); +} diff --git a/web/src/components/ui/Input.tsx b/web/src/components/ui/Input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2284d57a79b8d47126e1f8c59b8e393c5495a23b --- /dev/null +++ b/web/src/components/ui/Input.tsx @@ -0,0 +1,46 @@ +import { useId, type InputHTMLAttributes, type ReactNode } from 'react'; + +interface InputProps extends InputHTMLAttributes { + label?: string; + hint?: string; + error?: string; + rightElement?: ReactNode; +} + +export function Input({ label, hint, error, rightElement, className = '', id, ...rest }: InputProps) { + const generatedId = useId(); + const inputId = id ?? generatedId; + const hintId = hint ? `${inputId}-hint` : undefined; + const errorId = error ? `${inputId}-error` : undefined; + const describedBy = [rest['aria-describedby'], errorId, hintId].filter(Boolean).join(' ') || undefined; + + return ( +
+ {label && } +
+ + {rightElement && ( +
+ {rightElement} +
+ )} +
+ {hint && ( +
+ {hint} +
+ )} + {error && ( +
+ {error} +
+ )} +
+ ); +} diff --git a/web/src/components/ui/LoadingSpinner.tsx b/web/src/components/ui/LoadingSpinner.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2247b7b3e579dd2b0f4a21b9689a60ad46559768 --- /dev/null +++ b/web/src/components/ui/LoadingSpinner.tsx @@ -0,0 +1,16 @@ +export function LoadingSpinner({ + size = 20, + className = '' +}: { + size?: number; + className?: string; +}) { + return ( +
+ ); +} diff --git a/web/src/components/ui/Modal.tsx b/web/src/components/ui/Modal.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c9563dcfad7e95c76fa0145e7a671d3d188c3c5 --- /dev/null +++ b/web/src/components/ui/Modal.tsx @@ -0,0 +1,312 @@ +import { + useCallback, + useEffect, + useId, + useRef, + useState, + type PropsWithChildren, + type ReactNode, +} from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import { IconX } from './icons'; + +interface ModalProps { + open: boolean; + title?: ReactNode; + onClose: () => void; + footer?: ReactNode; + width?: number | string; + className?: string; + closeDisabled?: boolean; +} + +const CLOSE_ANIMATION_DURATION = 350; +const MODAL_LOCK_CLASS = 'modal-open'; +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); +let activeModalCount = 0; + +const scrollLockSnapshot = { + scrollY: 0, + contentScrollTop: 0, + contentEl: null as HTMLElement | null, + bodyPosition: '', + bodyTop: '', + bodyLeft: '', + bodyRight: '', + bodyWidth: '', + bodyOverflow: '', + htmlOverflow: '', +}; + +const resolveContentScrollContainer = () => { + if (typeof document === 'undefined') return null; + const contentEl = document.querySelector('.content'); + return contentEl instanceof HTMLElement ? contentEl : null; +}; + +const lockScroll = () => { + if (typeof document === 'undefined') return; + if (activeModalCount === 0) { + const body = document.body; + const html = document.documentElement; + const contentEl = resolveContentScrollContainer(); + + scrollLockSnapshot.scrollY = window.scrollY || window.pageYOffset || html.scrollTop || 0; + scrollLockSnapshot.contentEl = contentEl; + scrollLockSnapshot.contentScrollTop = contentEl?.scrollTop ?? 0; + scrollLockSnapshot.bodyPosition = body.style.position; + scrollLockSnapshot.bodyTop = body.style.top; + scrollLockSnapshot.bodyLeft = body.style.left; + scrollLockSnapshot.bodyRight = body.style.right; + scrollLockSnapshot.bodyWidth = body.style.width; + scrollLockSnapshot.bodyOverflow = body.style.overflow; + scrollLockSnapshot.htmlOverflow = html.style.overflow; + + body.classList.add(MODAL_LOCK_CLASS); + html.classList.add(MODAL_LOCK_CLASS); + + body.style.position = 'fixed'; + body.style.top = `-${scrollLockSnapshot.scrollY}px`; + body.style.left = '0'; + body.style.right = '0'; + body.style.width = '100%'; + body.style.overflow = 'hidden'; + html.style.overflow = 'hidden'; + } + activeModalCount += 1; +}; + +const unlockScroll = () => { + if (typeof document === 'undefined') return; + activeModalCount = Math.max(0, activeModalCount - 1); + if (activeModalCount === 0) { + const body = document.body; + const html = document.documentElement; + const scrollY = scrollLockSnapshot.scrollY; + const contentScrollTop = scrollLockSnapshot.contentScrollTop; + const contentEl = scrollLockSnapshot.contentEl; + + body.classList.remove(MODAL_LOCK_CLASS); + html.classList.remove(MODAL_LOCK_CLASS); + + body.style.position = scrollLockSnapshot.bodyPosition; + body.style.top = scrollLockSnapshot.bodyTop; + body.style.left = scrollLockSnapshot.bodyLeft; + body.style.right = scrollLockSnapshot.bodyRight; + body.style.width = scrollLockSnapshot.bodyWidth; + body.style.overflow = scrollLockSnapshot.bodyOverflow; + html.style.overflow = scrollLockSnapshot.htmlOverflow; + + if (contentEl) { + contentEl.scrollTo({ top: contentScrollTop, left: 0, behavior: 'auto' }); + } + window.scrollTo({ top: scrollY, left: 0, behavior: 'auto' }); + + scrollLockSnapshot.scrollY = 0; + scrollLockSnapshot.contentScrollTop = 0; + scrollLockSnapshot.contentEl = null; + } +}; + +export function Modal({ + open, + title, + onClose, + footer, + width = 520, + className, + closeDisabled = false, + children, +}: PropsWithChildren) { + const { t } = useTranslation(); + const titleId = useId(); + const [isVisible, setIsVisible] = useState(false); + const [isClosing, setIsClosing] = useState(false); + const closeTimerRef = useRef | null>(null); + const modalRef = useRef(null); + const closeButtonRef = useRef(null); + const previouslyFocusedRef = useRef(null); + + const getFocusableElements = useCallback(() => { + if (!modalRef.current) return [] as HTMLElement[]; + return Array.from(modalRef.current.querySelectorAll(FOCUSABLE_SELECTOR)).filter( + (element) => !element.hasAttribute('disabled') && element.tabIndex !== -1 + ); + }, []); + + const startClose = useCallback( + (notifyParent: boolean) => { + if (closeTimerRef.current !== null) return; + setIsClosing(true); + closeTimerRef.current = window.setTimeout(() => { + setIsVisible(false); + setIsClosing(false); + closeTimerRef.current = null; + if (notifyParent) { + onClose(); + } + }, CLOSE_ANIMATION_DURATION); + }, + [onClose] + ); + + useEffect(() => { + let cancelled = false; + + if (open) { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + queueMicrotask(() => { + if (cancelled) return; + setIsVisible(true); + setIsClosing(false); + }); + } else if (isVisible) { + queueMicrotask(() => { + if (cancelled) return; + startClose(false); + }); + } + + return () => { + cancelled = true; + }; + }, [open, isVisible, startClose]); + + const handleClose = useCallback(() => { + startClose(true); + }, [startClose]); + + useEffect(() => { + return () => { + if (closeTimerRef.current !== null) { + window.clearTimeout(closeTimerRef.current); + } + }; + }, []); + + const shouldLockScroll = open || isVisible; + + useEffect(() => { + if (!shouldLockScroll) return; + lockScroll(); + return () => unlockScroll(); + }, [shouldLockScroll]); + + useEffect(() => { + if (!open) return; + + previouslyFocusedRef.current = + document.activeElement instanceof HTMLElement ? document.activeElement : null; + + const focusTimer = window.setTimeout(() => { + const firstFocusable = getFocusableElements()[0]; + (firstFocusable ?? closeButtonRef.current ?? modalRef.current)?.focus(); + }, 0); + + return () => { + window.clearTimeout(focusTimer); + }; + }, [getFocusableElements, open]); + + useEffect(() => { + if (open || isVisible) return; + previouslyFocusedRef.current?.focus(); + previouslyFocusedRef.current = null; + }, [isVisible, open]); + + useEffect(() => { + if (!open) return; + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + if (closeDisabled) return; + event.preventDefault(); + handleClose(); + return; + } + + if (event.key !== 'Tab') return; + + const focusableElements = getFocusableElements(); + if (focusableElements.length === 0) { + event.preventDefault(); + modalRef.current?.focus(); + return; + } + + const firstElement = focusableElements[0]; + const lastElement = focusableElements[focusableElements.length - 1]; + const activeElement = document.activeElement as HTMLElement | null; + + if (event.shiftKey) { + if (activeElement === firstElement || activeElement === modalRef.current) { + event.preventDefault(); + lastElement.focus(); + } + return; + } + + if (activeElement === lastElement) { + event.preventDefault(); + firstElement.focus(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [closeDisabled, getFocusableElements, handleClose, open]); + + if (!open && !isVisible) return null; + + const overlayClass = `modal-overlay ${isClosing ? 'modal-overlay-closing' : 'modal-overlay-entering'}`; + const modalClass = `modal ${isClosing ? 'modal-closing' : 'modal-entering'}${className ? ` ${className}` : ''}`; + + const modalContent = ( +
+
+ +
+
+ {title} +
+
+
{children}
+ {footer &&
{footer}
} +
+
+ ); + + if (typeof document === 'undefined') { + return modalContent; + } + + return createPortal(modalContent, document.body); +} diff --git a/web/src/components/ui/Select.module.scss b/web/src/components/ui/Select.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..e29988866ae26c16c76cf9d2df9f20ba97fcab27 --- /dev/null +++ b/web/src/components/ui/Select.module.scss @@ -0,0 +1,135 @@ +@use '../../styles/mixins' as *; +@use '../../styles/variables' as *; + +.wrap { + position: relative; + display: inline-flex; + align-items: center; +} + +.wrapFullWidth { + width: 100%; +} + +.trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + height: 40px; + padding: 0 12px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background-color: var(--bg-primary); + box-shadow: var(--shadow); + color: var(--text-primary); + font-size: 13px; + font-weight: 500; + cursor: pointer; + appearance: none; + text-align: left; + box-sizing: border-box; + + &:hover { + border-color: var(--border-hover); + } + + &:focus { + outline: none; + box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18); + } + + &[aria-expanded='true'] { + border-color: var(--primary-color); + box-shadow: var(--shadow), 0 0 0 3px rgba($primary-color, 0.18); + } +} + +.triggerText { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.placeholder { + color: var(--text-tertiary); +} + +.triggerIcon { + display: inline-flex; + color: var(--text-secondary); + flex-shrink: 0; + transition: transform 0.2s ease; + + [aria-expanded='true'] > & { + transform: rotate(180deg); + } +} + +.dropdown { + position: fixed; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + padding: 6px; + box-shadow: var(--shadow-lg); + display: flex; + flex-direction: column; + gap: 4px; + max-height: 240px; + overflow-y: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; +} + +.option { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 12px; + border-radius: $radius-md; + border: 1px solid transparent; + background: transparent; + color: var(--text-primary); + cursor: pointer; + text-align: left; + font-size: 13px; + font-weight: 500; + transition: background-color 0.15s ease, border-color 0.15s ease; + flex-shrink: 0; + + &:hover { + background: var(--bg-secondary); + } +} + +.optionLabel { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.optionSuffix { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 999px; + background: rgba($success-color, 0.12); + color: var(--success-color); + flex: 0 0 auto; +} + +.optionActive { + border-color: rgba($primary-color, 0.5); + background: rgba($primary-color, 0.1); + font-weight: 600; +} + +.optionHighlighted { + background: var(--bg-secondary); +} diff --git a/web/src/components/ui/Select.tsx b/web/src/components/ui/Select.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c78959b832ac19a021e5c9a0f754dfdf408921fd --- /dev/null +++ b/web/src/components/ui/Select.tsx @@ -0,0 +1,339 @@ +import React, { + useCallback, + useEffect, + useId, + useLayoutEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode +} from 'react'; +import { createPortal } from 'react-dom'; +import { IconChevronDown } from './icons'; +import styles from './Select.module.scss'; + +export interface SelectOption { + value: string; + label: string; + suffix?: ReactNode; + suffixAriaLabel?: string; +} + +interface SelectProps { + value: string; + options: ReadonlyArray; + onChange: (value: string) => void; + placeholder?: string; + className?: string; + disabled?: boolean; + ariaLabel?: string; + ariaLabelledBy?: string; + ariaDescribedBy?: string; + fullWidth?: boolean; + id?: string; +} + +const VIEWPORT_MARGIN = 8; +const DROPDOWN_OFFSET = 6; +const DROPDOWN_MAX_HEIGHT = 240; +const DROPDOWN_Z_INDEX = 2010; + +const clamp = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max); + +const resolveDropdownStyle = (element: HTMLElement): CSSProperties => { + const rect = element.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const width = Math.min(rect.width, Math.max(0, viewportWidth - VIEWPORT_MARGIN * 2)); + const left = clamp( + rect.left, + VIEWPORT_MARGIN, + Math.max(VIEWPORT_MARGIN, viewportWidth - width - VIEWPORT_MARGIN) + ); + const spaceBelow = viewportHeight - rect.bottom - VIEWPORT_MARGIN - DROPDOWN_OFFSET; + const spaceAbove = rect.top - VIEWPORT_MARGIN - DROPDOWN_OFFSET; + const direction = + spaceBelow >= DROPDOWN_MAX_HEIGHT || spaceBelow >= spaceAbove ? 'down' : 'up'; + const maxHeight = Math.max( + 0, + Math.min(DROPDOWN_MAX_HEIGHT, direction === 'down' ? spaceBelow : spaceAbove) + ); + + return direction === 'down' + ? { + position: 'fixed', + top: rect.bottom + DROPDOWN_OFFSET, + left, + width, + maxHeight, + zIndex: DROPDOWN_Z_INDEX + } + : { + position: 'fixed', + bottom: viewportHeight - rect.top + DROPDOWN_OFFSET, + left, + width, + maxHeight, + zIndex: DROPDOWN_Z_INDEX + }; +}; + +export function Select({ + value, + options, + onChange, + placeholder, + className, + disabled = false, + ariaLabel, + ariaLabelledBy, + ariaDescribedBy, + fullWidth = true, + id, +}: SelectProps) { + const generatedId = useId(); + const selectId = id ?? generatedId; + const listboxId = `${selectId}-listbox`; + const [open, setOpen] = useState(false); + const [highlightedIndex, setHighlightedIndex] = useState(-1); + const wrapRef = useRef(null); + const dropdownRef = useRef(null); + const rafRef = useRef(null); + const [dropdownStyle, setDropdownStyle] = useState(null); + const isOpen = open && !disabled; + + useEffect(() => { + if (!open || disabled) return; + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Node; + if (wrapRef.current?.contains(target) || dropdownRef.current?.contains(target)) return; + setOpen(false); + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [disabled, open]); + + const updateDropdownStyle = useCallback(() => { + if (!wrapRef.current) return; + setDropdownStyle(resolveDropdownStyle(wrapRef.current)); + }, []); + + const scheduleDropdownStyleUpdate = useCallback(() => { + if (typeof window === 'undefined') return; + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + } + rafRef.current = window.requestAnimationFrame(() => { + rafRef.current = null; + updateDropdownStyle(); + }); + }, [updateDropdownStyle]); + + useLayoutEffect(() => { + if (!isOpen) { + if (rafRef.current !== null && typeof window !== 'undefined') { + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + return; + } + + updateDropdownStyle(); + + const handleViewportChange = () => { + scheduleDropdownStyleUpdate(); + }; + + const resizeObserver = + typeof ResizeObserver !== 'undefined' && wrapRef.current + ? new ResizeObserver(() => { + scheduleDropdownStyleUpdate(); + }) + : null; + + if (resizeObserver && wrapRef.current) { + resizeObserver.observe(wrapRef.current); + } + + window.addEventListener('resize', handleViewportChange); + window.addEventListener('scroll', handleViewportChange, true); + + return () => { + window.removeEventListener('resize', handleViewportChange); + window.removeEventListener('scroll', handleViewportChange, true); + resizeObserver?.disconnect(); + if (rafRef.current !== null) { + window.cancelAnimationFrame(rafRef.current); + rafRef.current = null; + } + }; + }, [isOpen, scheduleDropdownStyleUpdate, updateDropdownStyle]); + + const selectedIndex = useMemo(() => options.findIndex((option) => option.value === value), [options, value]); + const resolvedHighlightedIndex = + highlightedIndex >= 0 ? highlightedIndex : selectedIndex >= 0 ? selectedIndex : options.length > 0 ? 0 : -1; + const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined; + const displayText = selected?.label ?? placeholder ?? ''; + const isPlaceholder = !selected && placeholder; + + const commitSelection = useCallback( + (nextIndex: number) => { + const nextOption = options[nextIndex]; + if (!nextOption) return; + onChange(nextOption.value); + setOpen(false); + setHighlightedIndex(nextIndex); + }, + [onChange, options] + ); + + const moveHighlight = useCallback( + (direction: 1 | -1) => { + if (options.length === 0) return; + const nextIndex = (resolvedHighlightedIndex + direction + options.length) % options.length; + setHighlightedIndex(nextIndex); + }, + [options.length, resolvedHighlightedIndex] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (disabled) return; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + if (!isOpen) { + setOpen(true); + return; + } + moveHighlight(1); + return; + case 'ArrowUp': + event.preventDefault(); + if (!isOpen) { + setOpen(true); + return; + } + moveHighlight(-1); + return; + case 'Home': + if (!isOpen || options.length === 0) return; + event.preventDefault(); + setHighlightedIndex(0); + return; + case 'End': + if (!isOpen || options.length === 0) return; + event.preventDefault(); + setHighlightedIndex(options.length - 1); + return; + case 'Enter': + case ' ': { + event.preventDefault(); + if (!isOpen) { + setOpen(true); + return; + } + if (resolvedHighlightedIndex >= 0) { + commitSelection(resolvedHighlightedIndex); + } + return; + } + case 'Escape': + if (!isOpen) return; + event.preventDefault(); + setOpen(false); + return; + case 'Tab': + if (isOpen) setOpen(false); + return; + default: + return; + } + }, + [commitSelection, disabled, isOpen, moveHighlight, options.length, resolvedHighlightedIndex] + ); + + useEffect(() => { + if (!isOpen || resolvedHighlightedIndex < 0) return; + const highlightedOption = document.getElementById(`${selectId}-option-${resolvedHighlightedIndex}`); + highlightedOption?.scrollIntoView({ block: 'nearest' }); + }, [isOpen, resolvedHighlightedIndex, selectId]); + + const dropdown = + isOpen && dropdownStyle + ? ( +
+ {options.map((opt, index) => { + const active = opt.value === value; + const highlighted = index === resolvedHighlightedIndex; + return ( + + ); + })} +
+ ) + : null; + + return ( + <> +
+ +
+ {dropdown && (typeof document === 'undefined' ? dropdown : createPortal(dropdown, document.body))} + + ); +} diff --git a/web/src/components/ui/icons.tsx b/web/src/components/ui/icons.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5fe2b2c7d07ed734d539891eb2eef7979ed3dfa8 --- /dev/null +++ b/web/src/components/ui/icons.tsx @@ -0,0 +1,469 @@ +import React, { type SVGProps } from 'react'; + +// Inline SVG icons (Lucide, ISC). We embed paths to keep the WebUI single-file/offline friendly. +// Source: https://github.com/lucide-icons/lucide (via lucide-static). + +export interface IconProps extends SVGProps { + size?: number; +} + +const baseSvgProps: SVGProps = { + xmlns: 'http://www.w3.org/2000/svg', + viewBox: '0 0 24 24', + fill: 'none', + stroke: 'currentColor', + strokeWidth: 2, + strokeLinecap: 'round', + strokeLinejoin: 'round', + 'aria-hidden': 'true', + focusable: 'false', +}; + +const sidebarSvgProps: SVGProps = { + ...baseSvgProps, + strokeWidth: 1.72, + strokeLinecap: 'square', + strokeLinejoin: 'miter', + strokeMiterlimit: 10, +}; + +export function IconSlidersHorizontal({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function IconKey({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconBot({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + ); +} + +export function IconModelCluster({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + + ); +} + +export function IconFilterAll({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + + + + ); +} + +export function IconFileText({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + ); +} + +export function IconShield({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconChartLine({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconSettings({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconScrollText({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconInfo({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconRefreshCw({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconDownload({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconTrash2({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + ); +} + +export function IconChevronUp({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconChevronDown({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconChevronLeft({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconSearch({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconX({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconCheck({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconEye({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconEyeOff({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconInbox({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconSatellite({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + ); +} + +export function IconDiamond({ size = 20, ...props }: IconProps) { + return ( + + + + ); +} + +export function IconTimer({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconTrendingUp({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconDollarSign({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconGithub({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconExternalLink({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconBookOpen({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconCode({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconLayoutDashboard({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconSidebarDashboard({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconSidebarConfig({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconSidebarProviders({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + ); +} + +export function IconSidebarOauth({ size = 20, ...props }: IconProps) { + return ( + + + + + + ); +} + +export function IconSidebarQuota({ size = 20, ...props }: IconProps) { + return ( + + + + + ); +} + +export function IconSidebarUsage({ size = 20, ...props }: IconProps) { + return ( + + + + + + + ); +} + +export function IconSidebarLogs({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + ); +} + +export function IconSidebarSystem({ size = 20, ...props }: IconProps) { + return ( + + + + + + + + + + + + + ); +} diff --git a/web/src/components/usage/ApiDetailsCard.tsx b/web/src/components/usage/ApiDetailsCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da166b12d7167bc8a7a25a0a75289b82f381f048 --- /dev/null +++ b/web/src/components/usage/ApiDetailsCard.tsx @@ -0,0 +1,177 @@ +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card } from '@/components/ui/Card'; +import { formatCompactNumber, formatUsd, type ApiStats } from '@/utils/usage'; +import styles from '@/pages/UsagePage.module.scss'; + +function ApiDetailsTitle({ title, subtitle, eyebrow }: { title: string; subtitle: string; eyebrow: string }) { + return ( +
+ {eyebrow} +

{title}

+

{subtitle}

+
+ ); +} + +export interface ApiDetailsCardProps { + apiStats: ApiStats[]; + loading: boolean; + hasPrices: boolean; +} + +type ApiSortKey = 'endpoint' | 'requests' | 'tokens' | 'cost'; +type SortDir = 'asc' | 'desc'; + +export function ApiDetailsCard({ apiStats, loading, hasPrices }: ApiDetailsCardProps) { + const { t } = useTranslation(); + const [expandedApis, setExpandedApis] = useState>(new Set()); + const [sortKey, setSortKey] = useState('requests'); + const [sortDir, setSortDir] = useState('desc'); + + const toggleExpand = (endpoint: string) => { + setExpandedApis((prev) => { + const newSet = new Set(prev); + if (newSet.has(endpoint)) { + newSet.delete(endpoint); + } else { + newSet.add(endpoint); + } + return newSet; + }); + }; + + const handleSort = (key: ApiSortKey) => { + if (sortKey === key) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortKey(key); + setSortDir(key === 'endpoint' ? 'asc' : 'desc'); + } + }; + + const sorted = useMemo(() => { + const list = [...apiStats]; + const dir = sortDir === 'asc' ? 1 : -1; + list.sort((a, b) => { + switch (sortKey) { + case 'endpoint': return dir * a.displayName.localeCompare(b.displayName); + case 'requests': return dir * (a.totalRequests - b.totalRequests); + case 'tokens': return dir * (a.totalTokens - b.totalTokens); + case 'cost': return dir * (a.totalCost - b.totalCost); + default: return 0; + } + }); + return list; + }, [apiStats, sortKey, sortDir]); + + const arrow = (key: ApiSortKey) => + sortKey === key ? (sortDir === 'asc' ? ' ▲' : ' ▼') : ''; + + return ( + + } + className={styles.detailsFixedCard} + > + {loading ? ( +
{t('common.loading')}
+ ) : sorted.length > 0 ? ( + <> +
+ {([ + ['endpoint', 'usage_stats.api_endpoint'], + ['requests', 'usage_stats.requests_count'], + ['tokens', 'usage_stats.tokens_count'], + ...(hasPrices ? [['cost', 'usage_stats.total_cost']] : []), + ] as [ApiSortKey, string][]).map(([key, labelKey]) => ( + + ))} +
+
+
+ {sorted.map((api, index) => { + const isExpanded = expandedApis.has(api.endpoint); + const panelId = `api-models-${index}`; + + return ( +
+ + {isExpanded && ( +
+ {Object.entries(api.models).map(([model, stats]) => ( +
+ {model} + + + {stats.requests.toLocaleString()} + + ({stats.successCount.toLocaleString()}{' '} + {stats.failureCount.toLocaleString()}) + + + + {formatCompactNumber(stats.tokens)} +
+ ))} +
+ )} +
+ ); + })} +
+
+ + ) : ( +
{t('usage_stats.no_data')}
+ )} +
+ ); +} diff --git a/web/src/components/usage/ApiOverviewCard.tsx b/web/src/components/usage/ApiOverviewCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..29cf3e0f54ea64813ef8b686706e25b5561273ce --- /dev/null +++ b/web/src/components/usage/ApiOverviewCard.tsx @@ -0,0 +1,102 @@ +import { useMemo, useState } from 'react' +import { formatNumber } from '../../lib/usage' +import type { ApiSummaryItem } from '../../lib/types' +import styles from '../../pages/usage/UsagePage.module.css' + +interface ApiOverviewCardProps { + items: ApiSummaryItem[] +} + +type SortKey = 'apiName' | 'totalRequests' | 'totalTokens' | 'totalCost' +type SortDirection = 'asc' | 'desc' + +export function ApiOverviewCard({ items }: ApiOverviewCardProps) { + const [expandedApis, setExpandedApis] = useState([]) + const [sortKey, setSortKey] = useState('totalRequests') + const [sortDirection, setSortDirection] = useState('desc') + + const sortedItems = useMemo(() => { + const direction = sortDirection === 'asc' ? 1 : -1 + return [...items].sort((left, right) => { + if (sortKey === 'apiName') { + return direction * left.apiName.localeCompare(right.apiName) + } + return direction * (left[sortKey] - right[sortKey]) + }) + }, [items, sortDirection, sortKey]) + + function toggleExpand(apiName: string) { + setExpandedApis((current) => + current.includes(apiName) ? current.filter((item) => item !== apiName) : [...current, apiName], + ) + } + + function handleSort(key: SortKey) { + if (sortKey === key) { + setSortDirection((current) => (current === 'asc' ? 'desc' : 'asc')) + return + } + setSortKey(key) + setSortDirection(key === 'apiName' ? 'asc' : 'desc') + } + + return ( +
+
+
+

API details

+

Requests, tokens, and estimated cost grouped by API provider.

+
+ {items.length} APIs +
+ +
+ + + + +
+ +
+ {sortedItems.map((item) => { + const expanded = expandedApis.includes(item.apiName) + return ( +
+ + {expanded ? ( +
+
+ Model + Requests + Success / fail + Tokens + Cost +
+ {item.models.map((model) => ( +
+ {model.modelName} + {formatNumber(model.totalRequests)} + {formatNumber(model.successCount)} / {formatNumber(model.failureCount)} + {formatNumber(model.totalTokens)} + ${formatNumber(model.totalCost)} +
+ ))} +
+ ) : null} +
+ ) + })} +
+
+ ) +} diff --git a/web/src/components/usage/ChartLineSelector.tsx b/web/src/components/usage/ChartLineSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f8e9e88ef6600ac3d4781fbca726bf08d8883b71 --- /dev/null +++ b/web/src/components/usage/ChartLineSelector.tsx @@ -0,0 +1,105 @@ +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import { Select } from '@/components/ui/Select'; +import styles from '@/pages/UsagePage.module.scss'; + +const formatDisplayName = (value: string, allTrafficLabel: string): string => { + const normalized = value.trim(); + if (!normalized) return '-'; + if (normalized === 'all') return allTrafficLabel; + return normalized; +}; + +export interface ChartLineSelectorProps { + chartLines: string[]; + modelNames: string[]; + maxLines?: number; + onChange: (lines: string[]) => void; +} + +export function ChartLineSelector({ + chartLines, + modelNames, + maxLines = 9, + onChange +}: ChartLineSelectorProps) { + const { t } = useTranslation(); + + const handleAdd = () => { + if (chartLines.length >= maxLines) return; + const unusedModel = modelNames.find((m) => !chartLines.includes(m)); + if (unusedModel) { + onChange([...chartLines, unusedModel]); + } else { + onChange([...chartLines, 'all']); + } + }; + + const handleRemove = (index: number) => { + if (chartLines.length <= 1) return; + const newLines = [...chartLines]; + newLines.splice(index, 1); + onChange(newLines); + }; + + const handleChange = (index: number, value: string) => { + const newLines = [...chartLines]; + newLines[index] = value; + onChange(newLines); + }; + + const allTrafficLabel = t('usage_stats.chart_line_all_traffic'); + const options = useMemo( + () => [ + { value: 'all', label: allTrafficLabel }, + ...modelNames.map((name) => ({ value: name, label: formatDisplayName(name, allTrafficLabel) })) + ], + [allTrafficLabel, modelNames] + ); + + return ( + + + {chartLines.length}/{maxLines} + + +
+ } + > +
+ {chartLines.map((line, index) => ( +
+ + {t('usage_stats.chart_line_label', { index: index + 1 })} + + +
+
+ + setPromptPrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+ + setCompletionPrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+ + setCachePrice(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+ +
+
+ +
+

{t('usage_stats.saved_prices')}

+ {Object.keys(modelPrices).length > 0 ? ( +
+ {Object.entries(modelPrices).map(([model, price]) => ( +
+
+ {formatDisplayName(model)} +
+ + {t('usage_stats.model_price_prompt')}: ${price.prompt.toFixed(4)}/1M + + + {t('usage_stats.model_price_completion')}: ${price.completion.toFixed(4)}/1M + + + {t('usage_stats.model_price_cache')}: ${price.cache.toFixed(4)}/1M + +
+
+
+ + +
+
+ ))} +
+ ) : ( +
{t('usage_stats.model_price_empty')}
+ )} +
+ + )} + + + {/* Edit Modal */} + setEditModel(null)} + footer={ +
+ + +
+ } + width={420} + > +
+
+ + setEditPrompt(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+ + setEditCompletion(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+ + setEditCache(e.target.value)} + placeholder="0.00" + step="0.0001" + /> +
+
+
+ + ); +} diff --git a/web/src/components/usage/PricingCard.tsx b/web/src/components/usage/PricingCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a5e56804ea14caeea1112807cc63824b52691cac --- /dev/null +++ b/web/src/components/usage/PricingCard.tsx @@ -0,0 +1,109 @@ +import { useMemo, useState } from 'react' +import { formatNumber } from '../../lib/usage' +import type { PricingEntry } from '../../lib/types' +import styles from '../../pages/usage/UsagePage.module.css' + +interface PricingCardProps { + usedModels: string[] + pricing: PricingEntry[] + saving: boolean + error: string + onSave: (model: string, payload: Omit) => Promise +} + +export function PricingCard({ usedModels, pricing, saving, error, onSave }: PricingCardProps) { + const [selectedModel, setSelectedModel] = useState('') + const [promptPrice, setPromptPrice] = useState('') + const [completionPrice, setCompletionPrice] = useState('') + const [cachePrice, setCachePrice] = useState('') + + const pricingMap = useMemo(() => new Map(pricing.map((entry) => [entry.model, entry])), [pricing]) + const sortedPricing = useMemo(() => [...pricing].sort((left, right) => left.model.localeCompare(right.model)), [pricing]) + + function handleModelChange(model: string) { + setSelectedModel(model) + const existing = pricingMap.get(model) + setPromptPrice(existing ? String(existing.prompt_price_per_1m) : '') + setCompletionPrice(existing ? String(existing.completion_price_per_1m) : '') + setCachePrice(existing ? String(existing.cache_price_per_1m) : '') + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + if (!selectedModel) return + + await onSave(selectedModel, { + prompt_price_per_1m: Number(promptPrice) || 0, + completion_price_per_1m: Number(completionPrice) || 0, + cache_price_per_1m: Number(cachePrice) || 0, + }) + } + + return ( +
+
+
+

Price settings

+

Persist model pricing in the backend so cost analytics can be calculated.

+
+ {usedModels.length} used models +
+ +
+ + + + + +
+ + {error ?

{error}

: null} + +
+
+
+ Model + Prompt / 1M + Completion / 1M + Cache / 1M + Seen in usage +
+ {sortedPricing.length > 0 ? ( + sortedPricing.map((entry) => ( +
+ {entry.model} + ${formatNumber(entry.prompt_price_per_1m)} + ${formatNumber(entry.completion_price_per_1m)} + ${formatNumber(entry.cache_price_per_1m)} + {usedModels.includes(entry.model) ? 'Used' : 'Unknown'} +
+ )) + ) : ( +
No pricing saved yet.
+ )} +
+
+
+ ) +} diff --git a/web/src/components/usage/RequestEventsCard.tsx b/web/src/components/usage/RequestEventsCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65605c026d02b0a6d55fd081af908fbbbed06c03 --- /dev/null +++ b/web/src/components/usage/RequestEventsCard.tsx @@ -0,0 +1,157 @@ +import { useMemo, useState } from 'react' +import { formatNumber } from '../../lib/usage' +import type { EventRow } from '../../lib/types' +import styles from '../../pages/usage/UsagePage.module.css' + +interface RequestEventsCardProps { + items: EventRow[] +} + +const ALL = '__all__' + +function downloadFile(filename: string, content: string, type: string) { + const blob = new Blob([content], { type }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + link.click() + URL.revokeObjectURL(url) +} + +export function RequestEventsCard({ items }: RequestEventsCardProps) { + const [modelFilter, setModelFilter] = useState(ALL) + const [sourceFilter, setSourceFilter] = useState(ALL) + const [authFilter, setAuthFilter] = useState(ALL) + + const modelOptions = useMemo(() => [ALL, ...new Set(items.map((item) => item.modelName))], [items]) + const sourceOptions = useMemo(() => [ALL, ...new Set(items.map((item) => item.source))], [items]) + const authOptions = useMemo(() => [ALL, ...new Set(items.map((item) => item.authIndex))], [items]) + + const filteredItems = useMemo( + () => + items.filter((item) => { + if (modelFilter !== ALL && item.modelName !== modelFilter) return false + if (sourceFilter !== ALL && item.source !== sourceFilter) return false + if (authFilter !== ALL && item.authIndex !== authFilter) return false + return true + }), + [authFilter, items, modelFilter, sourceFilter], + ) + + function handleExportCsv() { + const header = ['time', 'api', 'model', 'source', 'auth', 'status', 'latency_ms', 'input_tokens', 'output_tokens', 'reasoning_tokens', 'cached_tokens', 'total_tokens'] + const rows = filteredItems.map((item) => [ + item.timestamp, + item.apiName, + item.modelName, + item.source, + item.authIndex, + item.failed ? 'failed' : 'success', + item.latencyMs, + item.inputTokens, + item.outputTokens, + item.reasoningTokens, + item.cachedTokens, + item.totalTokens, + ]) + const csv = [header.join(','), ...rows.map((row) => row.map((value) => `"${String(value).replace(/"/g, '""')}"`).join(','))].join('\n') + downloadFile('usage-events.csv', csv, 'text/csv;charset=utf-8') + } + + function handleExportJson() { + downloadFile('usage-events.json', JSON.stringify(filteredItems, null, 2), 'application/json;charset=utf-8') + } + + return ( +
+
+
+

Request event details

+

Filter and export request-level usage details derived from persisted events.

+
+ {filteredItems.length} rows +
+ +
+ + + + + + +
+ +
+
+
+ Time + API / model + Source + Auth + Status + Latency + Input + Output + Reasoning + Cached + Total +
+ {filteredItems.map((event) => ( +
+ {new Date(event.timestamp).toLocaleString()} + + {event.apiName} + {event.modelName} + + {event.source} + {event.authIndex} + {event.failed ? 'Failed' : 'Success'} + {formatNumber(event.latencyMs)} ms + {formatNumber(event.inputTokens)} + {formatNumber(event.outputTokens)} + {formatNumber(event.reasoningTokens)} + {formatNumber(event.cachedTokens)} + {formatNumber(event.totalTokens)} +
+ ))} +
+
+
+ ) +} diff --git a/web/src/components/usage/RequestEventsDetailsCard.test.tsx b/web/src/components/usage/RequestEventsDetailsCard.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..566a3690cf4b054afee5055d2dcebea23339098f --- /dev/null +++ b/web/src/components/usage/RequestEventsDetailsCard.test.tsx @@ -0,0 +1,127 @@ +import React from 'react'; +import { describe, expect, it } from 'vitest'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { RequestEventsDetailsCard } from './RequestEventsDetailsCard'; +import type { UsageEvent } from '@/lib/types'; + +const events: UsageEvent[] = [ + { + id: 101, + timestamp: '2026-04-23T02:00:00.000Z', + model: 'claude-sonnet', + source: 'Provider A', + source_raw: 'source-a', + source_type: 'openai', + auth_index: '1', + failed: false, + latency_ms: 120, + tokens: { + input_tokens: 100, + output_tokens: 60, + reasoning_tokens: 20, + cached_tokens: 20, + total_tokens: 200, + }, + }, +]; + +const renderCard = (props: Partial> = {}) => + renderToStaticMarkup( + undefined} + onPageSizeChange={() => undefined} + onModelFilterChange={() => undefined} + onSourceFilterChange={() => undefined} + onResultFilterChange={() => undefined} + {...props} + />, + ); + +const countOccurrences = (text: string, value: string) => text.split(value).length - 1; + +describe('RequestEventsDetailsCard pagination', () => { + it('renders total events, current page, page size options, and disabled page buttons', () => { + const html = renderCard(); + + expect(html).toContain('120 total events'); + expect(html).toContain('Page (1/6)'); + expect(html).toContain('20'); + expect(html).toContain('50'); + expect(html).toContain('100'); + expect(html).toContain('500'); + expect(html).toContain('1000'); + expect(html).toContain('Previous'); + expect(html).toContain('Next'); + expect(html).toContain('disabled'); + }); + + it('uses backend source values while showing resolved source labels', () => { + const html = renderCard({ sourceFilter: 'source-a' }); + + expect(countOccurrences(html, 'Provider A')).toBeGreaterThanOrEqual(2); + expect(html).toContain('aria-label="Source">Provider A'); + }); + + it('uses backend model and source options instead of current page grouping', () => { + const html = renderCard({ modelFilter: 'claude-opus', sourceFilter: 'source-b' }); + + expect(html).toContain('aria-label="Model">claude-opus'); + expect(html).toContain('aria-label="Source">Provider B'); + }); + + it('renders a Result filter and no Credential filter control', () => { + const html = renderCard({ resultFilter: 'failed' }); + + expect(html).toContain('aria-label="Result"'); + expect(html).toContain('Failure'); + expect(html).not.toContain('aria-label="Credential"'); + }); + + it('keeps selected filters visible when backend options do not include them', () => { + const html = renderCard({ + modelFilter: 'claude-haiku', + sourceFilter: 'source-c', + }); + + expect(html).toContain('claude-haiku'); + expect(html).toContain('source-c'); + }); + + it('falls back to a computed page count when metadata is not populated', () => { + const html = renderCard({ totalPages: 0, totalCount: 120, pageSize: 20 }); + + expect(html).toContain('Page (1/6)'); + }); + + it('groups filters and pager controls in a compact toolbar', () => { + const html = renderCard(); + + expect(html).toContain('_requestEventsFiltersGroup_'); + expect(html).toContain('_requestEventsPaginationControls_'); + expect(html).toContain('_requestEventsPaginationItem_'); + expect(html).toContain('_requestEventsPagerButton_'); + expect(html).toContain('_requestEventsPageSizeSelectCompact_'); + expect(html).toContain('_requestEventsActions_'); + expect(html).toContain('_requestEventsTableMeta_'); + }); + + it('hides export buttons while keeping clear filters available', () => { + const html = renderCard({ modelFilter: 'claude-sonnet' }); + + expect(html).toContain('Clear Filters'); + expect(html).not.toContain('Export CSV'); + expect(html).not.toContain('Export JSON'); + }); +}); diff --git a/web/src/components/usage/RequestEventsDetailsCard.tsx b/web/src/components/usage/RequestEventsDetailsCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..752400039997d7c1dc66c457f9f2aaa0f0f38089 --- /dev/null +++ b/web/src/components/usage/RequestEventsDetailsCard.tsx @@ -0,0 +1,475 @@ +import React, { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { EmptyState } from '@/components/ui/EmptyState'; +import { Select } from '@/components/ui/Select'; +import type { UsageEvent, UsageSourceFilterOption } from '@/lib/types'; +import { formatDurationMs, LATENCY_SOURCE_FIELD, normalizeAuthIndex } from '@/utils/usage'; +import { downloadBlob } from '@/utils/download'; +import styles from '@/pages/UsagePage.module.scss'; + +const ALL_FILTER = '__all__'; + +type SelectOption = { value: string; label: string }; + +const appendSelectedOption = ( + options: SelectOption[], + selectedValue: string, + selectedLabel = selectedValue +) => { + if (selectedValue === ALL_FILTER || options.some((option) => option.value === selectedValue)) { + return options; + } + return [...options, { value: selectedValue, label: selectedLabel }]; +}; + +type RequestEventRow = { + id: string; + timestamp: string; + timestampMs: number; + timestampLabel: string; + model: string; + sourceRaw: string; + source: string; + sourceType: string; + authIndex: string; + failed: boolean; + latencyMs: number | null; + inputTokens: number; + outputTokens: number; + reasoningTokens: number; + cachedTokens: number; + totalTokens: number; +}; + +export interface RequestEventsDetailsCardProps { + events: UsageEvent[]; + loading: boolean; + page: number; + pageSize: number; + pageSizeOptions: readonly number[]; + totalCount: number; + totalPages: number; + modelOptions: string[]; + sourceOptions: UsageSourceFilterOption[]; + modelFilter: string; + sourceFilter: string; + resultFilter: string; + onPageChange: (page: number) => void; + onPageSizeChange: (pageSize: number) => void; + onModelFilterChange: (model: string) => void; + onSourceFilterChange: (source: string) => void; + onResultFilterChange: (result: string) => void; +} + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return parsed; +}; + +const encodeCsv = (value: string | number): string => { + const text = String(value ?? ''); + const trimmedLeft = text.replace(/^\s+/, ''); + const safeText = trimmedLeft && /^[=+\-@]/.test(trimmedLeft) ? `'${text}` : text; + return `"${safeText.replace(/"/g, '""')}"`; +}; + +function RequestEventsTitle({ title, subtitle, eyebrow }: { title: string; subtitle: string; eyebrow: string }) { + return ( +
+ {eyebrow} +

{title}

+

{subtitle}

+
+ ); +} + +export function RequestEventsDetailsCard({ + events, + loading, + page, + pageSize, + pageSizeOptions, + totalCount, + totalPages, + modelOptions: backendModelOptions, + sourceOptions: backendSourceOptions, + modelFilter, + sourceFilter, + resultFilter, + onPageChange, + onPageSizeChange, + onModelFilterChange, + onSourceFilterChange, + onResultFilterChange, +}: RequestEventsDetailsCardProps) { + const { t, i18n } = useTranslation(); + const latencyHint = t('usage_stats.latency_unit_hint', { + field: LATENCY_SOURCE_FIELD, + unit: t('usage_stats.duration_unit_ms'), + }); + + const rows = useMemo(() => { + return events.map((event, index) => { + const timestamp = event.timestamp; + const timestampMs = Date.parse(timestamp); + const date = Number.isNaN(timestampMs) ? null : new Date(timestampMs); + const sourceRaw = String(event.source_raw ?? '').trim() || String(event.source ?? '').trim(); + const authIndexRaw = event.auth_index as unknown; + const authIndex = + authIndexRaw === null || authIndexRaw === undefined || authIndexRaw === '' + ? '-' + : normalizeAuthIndex(authIndexRaw) || '-'; + const source = String(event.source ?? '').trim() || '-'; + const sourceType = String(event.source_type ?? '').trim(); + const model = String(event.model ?? '').trim() || '-'; + const inputTokens = Math.max(toNumber(event.tokens?.input_tokens), 0); + const outputTokens = Math.max(toNumber(event.tokens?.output_tokens), 0); + const reasoningTokens = Math.max(toNumber(event.tokens?.reasoning_tokens), 0); + const cachedTokens = Math.max(toNumber(event.tokens?.cached_tokens), 0); + const totalTokens = Math.max(toNumber(event.tokens?.total_tokens), 0); + const latencyMs = Number.isFinite(event.latency_ms) ? event.latency_ms : null; + + return { + id: event.id ? String(event.id) : `${timestamp}-${model}-${sourceRaw || source}-${authIndex}-${index}`, + timestamp, + timestampMs: Number.isNaN(timestampMs) ? 0 : timestampMs, + timestampLabel: date ? date.toLocaleString(i18n.language) : timestamp || '-', + model, + sourceRaw: sourceRaw || '-', + source, + sourceType, + authIndex, + failed: event.failed === true, + latencyMs, + inputTokens, + outputTokens, + reasoningTokens, + cachedTokens, + totalTokens, + }; + }); + }, [events, i18n.language]); + + const hasLatencyData = useMemo(() => rows.some((row) => row.latencyMs !== null), [rows]); + + const modelOptions = useMemo(() => { + const options = [ + { value: ALL_FILTER, label: t('usage_stats.filter_all') }, + ...backendModelOptions.map((model) => ({ value: model, label: model })), + ]; + return appendSelectedOption(options, modelFilter); + }, [backendModelOptions, modelFilter, t]); + + const sourceOptions = useMemo(() => { + const options = [ + { value: ALL_FILTER, label: t('usage_stats.filter_all') }, + ...backendSourceOptions.map((source) => ({ value: source.value, label: source.label || source.value })), + ]; + const selectedLabel = backendSourceOptions.find((source) => source.value === sourceFilter)?.label; + return appendSelectedOption(options, sourceFilter, selectedLabel || sourceFilter); + }, [backendSourceOptions, sourceFilter, t]); + + const resultOptions = useMemo( + () => [ + { value: ALL_FILTER, label: t('usage_stats.filter_all') }, + { value: 'success', label: t('usage_stats.success') }, + { value: 'failed', label: t('usage_stats.failure') }, + ], + [t] + ); + + const modelOptionSet = useMemo( + () => new Set(modelOptions.map((option) => option.value)), + [modelOptions] + ); + const sourceOptionSet = useMemo( + () => new Set(sourceOptions.map((option) => option.value)), + [sourceOptions] + ); + const resultOptionSet = useMemo( + () => new Set(resultOptions.map((option) => option.value)), + [resultOptions] + ); + + const effectiveModelFilter = modelOptionSet.has(modelFilter) ? modelFilter : ALL_FILTER; + const effectiveSourceFilter = sourceOptionSet.has(sourceFilter) ? sourceFilter : ALL_FILTER; + const effectiveResultFilter = resultOptionSet.has(resultFilter) ? resultFilter : ALL_FILTER; + + const hasActiveFilters = + modelFilter !== ALL_FILTER || + sourceFilter !== ALL_FILTER || + resultFilter !== ALL_FILTER; + + const pageSizeSelectOptions = useMemo( + () => pageSizeOptions.map((option) => ({ value: String(option), label: String(option) })), + [pageSizeOptions] + ); + const computedTotalPages = pageSize > 0 ? Math.ceil(totalCount / pageSize) : 0; + const safeTotalPages = Math.max(totalPages, computedTotalPages, rows.length > 0 ? 1 : 0); + const safePage = safeTotalPages > 0 ? Math.min(Math.max(page, 1), safeTotalPages) : 0; + const pageLabel = safeTotalPages > 0 + ? t('usage_stats.request_events_page_control', { page: safePage, totalPages: safeTotalPages }) + : t('usage_stats.request_events_page_empty'); + + const handleClearFilters = () => { + onModelFilterChange(ALL_FILTER); + onSourceFilterChange(ALL_FILTER); + onResultFilterChange(ALL_FILTER); + }; + + const handleExportCsv = () => { + if (!rows.length) return; + + const csvHeader = [ + 'timestamp', + 'model', + 'source', + 'source_raw', + 'auth_index', + 'result', + ...(hasLatencyData ? ['latency_ms'] : []), + 'input_tokens', + 'output_tokens', + 'reasoning_tokens', + 'cached_tokens', + 'total_tokens', + ]; + + const csvRows = rows.map((row) => + [ + row.timestamp, + row.model, + row.source, + row.sourceRaw, + row.authIndex, + row.failed ? 'failed' : 'success', + ...(hasLatencyData ? [row.latencyMs ?? ''] : []), + row.inputTokens, + row.outputTokens, + row.reasoningTokens, + row.cachedTokens, + row.totalTokens, + ] + .map((value) => encodeCsv(value)) + .join(',') + ); + + const content = [csvHeader.join(','), ...csvRows].join('\n'); + const fileTime = new Date().toISOString().replace(/[:.]/g, '-'); + downloadBlob({ + filename: `usage-events-${fileTime}.csv`, + blob: new Blob([content], { type: 'text/csv;charset=utf-8' }), + }); + }; + + const handleExportJson = () => { + if (!rows.length) return; + + const payload = rows.map((row) => ({ + timestamp: row.timestamp, + model: row.model, + source: row.source, + source_raw: row.sourceRaw, + auth_index: row.authIndex, + failed: row.failed, + ...(hasLatencyData && row.latencyMs !== null ? { latency_ms: row.latencyMs } : {}), + tokens: { + input_tokens: row.inputTokens, + output_tokens: row.outputTokens, + reasoning_tokens: row.reasoningTokens, + cached_tokens: row.cachedTokens, + total_tokens: row.totalTokens, + }, + })); + + const content = JSON.stringify(payload, null, 2); + const fileTime = new Date().toISOString().replace(/[:.]/g, '-'); + downloadBlob({ + filename: `usage-events-${fileTime}.json`, + blob: new Blob([content], { type: 'application/json;charset=utf-8' }), + }); + }; + + void handleExportCsv; + void handleExportJson; + + return ( + + } + extra={ +
+ +
+ } + > +
+
+ +
+
+ {pageLabel} +
+ + +
+
+
+ + + {loading && rows.length === 0 ? ( +
{t('common.loading')}
+ ) : rows.length === 0 ? ( + + ) : ( + <> +
+
+ {t('usage_stats.request_events_count', { count: rows.length })} + {t('usage_stats.request_events_total_count', { count: totalCount })} +
+ {hasLatencyData && {latencyHint}} +
+ +
+ + + + + + + + {hasLatencyData && } + + + + + + + + + {rows.map((row) => ( + + + + + + {hasLatencyData && ( + + )} + + + + + + + ))} + +
{t('usage_stats.request_events_timestamp')}{t('usage_stats.model_name')}{t('usage_stats.request_events_source')}{t('usage_stats.request_events_result')}{t('usage_stats.time')}{t('usage_stats.input_tokens')}{t('usage_stats.output_tokens')}{t('usage_stats.reasoning_tokens')}{t('usage_stats.cached_tokens')}{t('usage_stats.total_tokens')}
+ {row.timestampLabel} + {row.model} + {row.source} + {row.sourceType && ( + {row.sourceType} + )} + + + {row.failed ? t('usage_stats.failure') : t('usage_stats.success')} + + {formatDurationMs(row.latencyMs)}{row.inputTokens.toLocaleString()}{row.outputTokens.toLocaleString()}{row.reasoningTokens.toLocaleString()}{row.cachedTokens.toLocaleString()}{row.totalTokens.toLocaleString()}
+
+ + )} +
+ ); +} diff --git a/web/src/components/usage/ServiceHealthCard.tsx b/web/src/components/usage/ServiceHealthCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4b090519c2f114e1534a5568ef82b0854a6a387d --- /dev/null +++ b/web/src/components/usage/ServiceHealthCard.tsx @@ -0,0 +1,341 @@ +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import type { ServiceHealthData, StatusBlockDetail } from '@/utils/usage'; +import type { UsageOverviewPayload } from './hooks/useUsageData'; +import styles from '@/pages/UsagePage.module.scss'; + +const COLOR_STOPS = [ + { r: 239, g: 68, b: 68 }, // #ef4444 + { r: 250, g: 204, b: 21 }, // #facc15 + { r: 34, g: 197, b: 94 }, // #22c55e +] as const; + +const TOOLTIP_OFFSET = 8; +const TOOLTIP_SAFE_WIDTH = 180; +const TOOLTIP_SAFE_HEIGHT = 72; + +type TooltipHorizontalPosition = 'center' | 'left' | 'right'; +type TooltipVerticalPosition = 'above' | 'below'; + +interface ActiveTooltipState { + idx: number; + anchorEl: HTMLDivElement; + horizontal: TooltipHorizontalPosition; + vertical: TooltipVerticalPosition; + left: number; + top: number; + transform: string; +} + +function rateToColor(rate: number): string { + const t = Math.max(0, Math.min(1, rate)); + const segment = t < 0.5 ? 0 : 1; + const localT = segment === 0 ? t * 2 : (t - 0.5) * 2; + const from = COLOR_STOPS[segment]; + const to = COLOR_STOPS[segment + 1]; + const r = Math.round(from.r + (to.r - from.r) * localT); + const g = Math.round(from.g + (to.g - from.g) * localT); + const b = Math.round(from.b + (to.b - from.b) * localT); + return `rgb(${r}, ${g}, ${b})`; +} + +function formatDateTime(timestamp: number): string { + const date = new Date(timestamp); + const month = (date.getMonth() + 1).toString().padStart(2, '0'); + const day = date.getDate().toString().padStart(2, '0'); + const h = date.getHours().toString().padStart(2, '0'); + const m = date.getMinutes().toString().padStart(2, '0'); + return `${month}/${day} ${h}:${m}`; +} + +function parseTime(value?: string): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +function ServiceHealthTitle({ title, subtitle, eyebrow }: { title: string; subtitle: string; eyebrow: string }) { + return ( +
+ {eyebrow} +

{title}

+

{subtitle}

+
+ ); +} + +export interface ServiceHealthCardProps { + usage: UsageOverviewPayload | null; + loading: boolean; +} + +export function ServiceHealthCard({ usage, loading }: ServiceHealthCardProps) { + const { t } = useTranslation(); + const [activeTooltip, setActiveTooltip] = useState(null); + const gridRef = useRef(null); + + const healthData: ServiceHealthData = useMemo(() => { + const blockDetails = (usage?.service_health?.block_details ?? []).map((block) => ({ + startTime: Date.parse(block.start_time), + endTime: Date.parse(block.end_time), + success: Number(block.success ?? 0), + failure: Number(block.failure ?? 0), + rate: Number(block.rate ?? -1), + })); + const rows = Number(usage?.service_health?.rows ?? 7) || 7; + return { + totalSuccess: Number(usage?.service_health?.total_success ?? 0), + totalFailure: Number(usage?.service_health?.total_failure ?? 0), + successRate: Number(usage?.service_health?.success_rate ?? 0), + rows, + columns: Number(usage?.service_health?.columns ?? Math.max(1, Math.ceil(blockDetails.length / rows))) || 1, + bucketSeconds: Number(usage?.service_health?.bucket_seconds ?? 0), + windowStart: parseTime(usage?.service_health?.window_start), + windowEnd: parseTime(usage?.service_health?.window_end), + blockDetails, + }; + }, [usage]); + + const hasData = healthData.totalSuccess + healthData.totalFailure > 0; + + useEffect(() => { + if (activeTooltip === null) return; + const handler = (e: PointerEvent) => { + if (gridRef.current && !gridRef.current.contains(e.target as Node)) { + setActiveTooltip(null); + } + }; + document.addEventListener('pointerdown', handler); + return () => document.removeEventListener('pointerdown', handler); + }, [activeTooltip]); + + const buildTooltipState = useCallback( + (idx: number, anchorEl: HTMLDivElement | null): ActiveTooltipState | null => { + if (!anchorEl || !anchorEl.isConnected) { + return null; + } + + const rect = anchorEl.getBoundingClientRect(); + const centerX = rect.left + rect.width / 2; + + let horizontal: TooltipHorizontalPosition = 'center'; + let left = centerX; + + if (centerX <= TOOLTIP_SAFE_WIDTH / 2) { + horizontal = 'left'; + left = rect.left; + } else if (centerX >= window.innerWidth - TOOLTIP_SAFE_WIDTH / 2) { + horizontal = 'right'; + left = rect.right; + } + + const vertical: TooltipVerticalPosition = rect.top <= TOOLTIP_SAFE_HEIGHT ? 'below' : 'above'; + const top = vertical === 'below' ? rect.bottom + TOOLTIP_OFFSET : rect.top - TOOLTIP_OFFSET; + const translateX = horizontal === 'center' ? '-50%' : horizontal === 'right' ? '-100%' : '0'; + const translateY = vertical === 'below' ? '0' : '-100%'; + + return { + idx, + anchorEl, + horizontal, + vertical, + left: Math.round(left), + top: Math.round(top), + transform: `translate(${translateX}, ${translateY})`, + }; + }, + [] + ); + + useEffect(() => { + if (!activeTooltip) return; + + const updateTooltipPosition = () => { + if (!document.body.contains(activeTooltip.anchorEl)) { + setActiveTooltip(null); + return; + } + setActiveTooltip(buildTooltipState(activeTooltip.idx, activeTooltip.anchorEl)); + }; + + window.addEventListener('resize', updateTooltipPosition); + window.addEventListener('scroll', updateTooltipPosition, true); + return () => { + window.removeEventListener('resize', updateTooltipPosition); + window.removeEventListener('scroll', updateTooltipPosition, true); + }; + }, [activeTooltip, buildTooltipState]); + + const openTooltip = useCallback( + (idx: number, anchorEl: HTMLDivElement) => { + setActiveTooltip(buildTooltipState(idx, anchorEl)); + }, + [buildTooltipState] + ); + + const handlePointerEnter = useCallback( + (e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'mouse') { + openTooltip(idx, e.currentTarget); + } + }, + [openTooltip] + ); + + const handlePointerLeave = useCallback((e: React.PointerEvent) => { + if (e.pointerType === 'mouse') { + setActiveTooltip(null); + } + }, []); + + const handlePointerDown = useCallback( + (e: React.PointerEvent, idx: number) => { + if (e.pointerType === 'touch') { + e.preventDefault(); + const anchorEl = e.currentTarget; + setActiveTooltip((prev) => (prev?.idx === idx ? null : buildTooltipState(idx, anchorEl))); + } + }, + [buildTooltipState] + ); + + const renderTooltip = (detail: StatusBlockDetail, tooltipState: ActiveTooltipState) => { + const total = detail.success + detail.failure; + const posClass = + tooltipState.horizontal === 'left' + ? styles.healthTooltipLeft + : tooltipState.horizontal === 'right' + ? styles.healthTooltipRight + : ''; + const vertClass = tooltipState.vertical === 'below' ? styles.healthTooltipBelow : ''; + const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`; + const tooltip = ( +
+ {timeRange} + {total > 0 ? ( + + + {t('status_bar.success_short')} {detail.success} + + + {t('status_bar.failure_short')} {detail.failure} + + ({(detail.rate * 100).toFixed(1)}%) + + ) : ( + {t('status_bar.no_requests')} + )} +
+ ); + + return typeof document === 'undefined' ? tooltip : createPortal(tooltip, document.body); + }; + + const rateClass = !hasData + ? '' + : healthData.successRate >= 90 + ? styles.healthRateHigh + : healthData.successRate >= 50 + ? styles.healthRateMedium + : styles.healthRateLow; + + const windowLabel = healthData.windowStart > 0 && healthData.windowEnd > 0 + ? `${formatDateTime(healthData.windowStart)} – ${formatDateTime(healthData.windowEnd)}` + : t('usage_stats.service_health_window'); + const healthCountsLabel = `${t('status_bar.success_short')} ${healthData.totalSuccess}, ${t('status_bar.failure_short')} ${healthData.totalFailure}`; + const gridStyle = useMemo( + () => ({ + '--health-grid-columns': String(healthData.columns), + '--health-grid-rows': String(healthData.rows), + '--health-grid-aspect-columns': String(healthData.columns), + '--health-grid-aspect-rows': String(healthData.rows), + '--health-grid-width': '100%', + }) as React.CSSProperties, + [healthData.columns, healthData.rows] + ); + + return ( +
+
+ +
+
+ {windowLabel} + + {loading ? '--' : hasData ? `${healthData.successRate.toFixed(1)}%` : '--'} + +
+
+ + + + +
+
+
+
+
+ {healthData.blockDetails.map((detail, idx) => { + const isIdle = detail.rate === -1; + const blockStyle = isIdle ? undefined : { backgroundColor: rateToColor(detail.rate) }; + const isActive = activeTooltip?.idx === idx; + const timeRange = `${formatDateTime(detail.startTime)} – ${formatDateTime(detail.endTime)}`; + const summary = detail.success + detail.failure > 0 + ? `${t('status_bar.success_short')} ${detail.success}, ${t('status_bar.failure_short')} ${detail.failure}` + : t('status_bar.no_requests'); + + return ( +
openTooltip(idx, e.currentTarget)} + onBlur={() => setActiveTooltip(null)} + onPointerEnter={(e) => handlePointerEnter(e, idx)} + onPointerLeave={handlePointerLeave} + onPointerDown={(e) => handlePointerDown(e, idx)} + > +
+ {isActive && activeTooltip && renderTooltip(detail, activeTooltip)} +
+ ); + })} +
+
+
+ {t('usage_stats.service_health_oldest')} +
+
+
+
+
+
+ {t('usage_stats.service_health_newest')} +
+
+ ); +} diff --git a/web/src/components/usage/SourceStatsCard.tsx b/web/src/components/usage/SourceStatsCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..eb92f9009dee2ea0e8d7418d7079ee86aff0a7de --- /dev/null +++ b/web/src/components/usage/SourceStatsCard.tsx @@ -0,0 +1,46 @@ +import { formatNumber } from '../../lib/usage' +import styles from '../../pages/usage/UsagePage.module.css' + +interface SourceStat { + source: string + requests: number + tokens: number +} + +interface SourceStatsCardProps { + items: SourceStat[] +} + +export function SourceStatsCard({ items }: SourceStatsCardProps) { + return ( +
+
+
+

Source stats

+

Credential-like source activity summary for the selected range.

+
+ {items.length} sources +
+
+
+
+ Source + Requests + Tokens + Share + Status +
+ {items.map((item, index) => ( +
+ {item.source} + {formatNumber(item.requests)} + {formatNumber(item.tokens)} + #{index + 1} + {item.requests > 0 ? 'Active' : 'Idle'} +
+ ))} +
+
+
+ ) +} diff --git a/web/src/components/usage/StatCards.test.ts b/web/src/components/usage/StatCards.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a11599672995bc4d6295ffb8545dd6b67589c6ca --- /dev/null +++ b/web/src/components/usage/StatCards.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { buildStatCardMetrics } from './StatCards'; +import type { UsagePayload } from './hooks/useUsageData'; + +const usageWithBackendSummary: UsagePayload = { + total_requests: 9, + success_count: 8, + failure_count: 1, + total_tokens: 900, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: { + 'provider-a': { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 100, + models: { + 'claude-sonnet': { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 100, + details: [ + { + timestamp: '2026-04-23T00:00:00.000Z', + latency_ms: 100, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 20, + output_tokens: 30, + reasoning_tokens: 4, + cached_tokens: 5, + total_tokens: 50, + }, + }, + ], + }, + }, + }, + }, + summary: { + request_count: 3, + token_count: 777, + window_minutes: 120, + rpm: 0.025, + tpm: 6.475, + total_cost: 1.234, + cost_available: true, + cached_tokens: 22, + reasoning_tokens: 33, + }, +}; + +describe('buildStatCardMetrics', () => { + it('prefers backend summary values over detail-derived metrics', () => { + const metrics = buildStatCardMetrics({ + usage: usageWithBackendSummary, + }); + + expect(metrics.rateStats.requestCount).toBe(3); + expect(metrics.rateStats.tokenCount).toBe(777); + expect(metrics.rateStats.windowMinutes).toBe(120); + expect(metrics.rateStats.rpm).toBe(0.025); + expect(metrics.rateStats.tpm).toBe(6.475); + expect(metrics.tokenBreakdown.cachedTokens).toBe(22); + expect(metrics.tokenBreakdown.reasoningTokens).toBe(33); + expect(metrics.totalCost).toBe(1.234); + }); + + it('keeps priced total cost visible when availability is partial', () => { + const metrics = buildStatCardMetrics({ + usage: { + ...usageWithBackendSummary, + summary: { + ...usageWithBackendSummary.summary!, + total_cost: 4.56, + cost_available: false, + }, + }, + }); + + expect(metrics.totalCost).toBe(4.56); + expect(metrics.costAvailable).toBe(false); + }); +}); diff --git a/web/src/components/usage/StatCards.tsx b/web/src/components/usage/StatCards.tsx new file mode 100644 index 0000000000000000000000000000000000000000..76b15de0d13153cb51088397ddc26e64203cea61 --- /dev/null +++ b/web/src/components/usage/StatCards.tsx @@ -0,0 +1,226 @@ +import { useMemo, type CSSProperties, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Line } from 'react-chartjs-2'; +import { + IconDiamond, + IconDollarSign, + IconSatellite, + IconTimer, + IconTrendingUp, +} from '@/components/ui/icons'; +import { + formatCompactNumber, + formatPerMinuteValue, + formatUsd, +} from '@/utils/usage'; +import { sparklineOptions } from '@/utils/usage/chartConfig'; +import type { UsageOverviewPayload } from './hooks/useUsageData'; +import type { SparklineBundle } from './hooks/useSparklines'; +import styles from '@/pages/UsagePage.module.scss'; + +interface StatCardData { + key: string; + label: string; + icon: ReactNode; + accent: string; + accentSoft: string; + accentBorder: string; + value: string; + meta?: ReactNode; + trend: SparklineBundle | null; +} + +export interface StatCardsProps { + usage: UsageOverviewPayload | null; + loading: boolean; + sparklines: { + requests: SparklineBundle | null; + tokens: SparklineBundle | null; + rpm: SparklineBundle | null; + tpm: SparklineBundle | null; + cost: SparklineBundle | null; + }; +} + +interface StatCardMetrics { + tokenBreakdown: { cachedTokens: number; reasoningTokens: number }; + rateStats: { rpm: number; tpm: number; windowMinutes: number; requestCount: number; tokenCount: number }; + totalCost: number; + costAvailable: boolean; +} + +export function buildStatCardMetrics({ usage }: { usage: UsageOverviewPayload | null }): StatCardMetrics { + if (!usage?.summary) { + return { + tokenBreakdown: { cachedTokens: 0, reasoningTokens: 0 }, + rateStats: { rpm: 0, tpm: 0, windowMinutes: 1, requestCount: 0, tokenCount: 0 }, + totalCost: 0, + costAvailable: false, + }; + } + + return { + tokenBreakdown: { + cachedTokens: usage.summary.cached_tokens ?? 0, + reasoningTokens: usage.summary.reasoning_tokens ?? 0, + }, + rateStats: { + rpm: usage.summary.rpm ?? 0, + tpm: usage.summary.tpm ?? 0, + windowMinutes: usage.summary.window_minutes ?? 1, + requestCount: usage.summary.request_count ?? 0, + tokenCount: usage.summary.token_count ?? 0, + }, + totalCost: usage.summary.total_cost ?? 0, + costAvailable: usage.summary.cost_available === true, + }; +} + +export function StatCards({ usage, loading, sparklines }: StatCardsProps) { + const { t } = useTranslation(); + const usageSnapshot = usage?.usage ?? null; + const { tokenBreakdown, rateStats, totalCost, costAvailable } = useMemo( + () => buildStatCardMetrics({ usage }), + [usage] + ); + + const statsCards: StatCardData[] = [ + { + key: 'requests', + label: t('usage_stats.total_requests'), + icon: , + accent: '#8b8680', + accentSoft: 'rgba(139, 134, 128, 0.18)', + accentBorder: 'rgba(139, 134, 128, 0.35)', + value: loading ? '-' : (usageSnapshot?.total_requests ?? 0).toLocaleString(), + meta: ( + <> + + + {t('usage_stats.success_requests')}: {loading ? '-' : (usageSnapshot?.success_count ?? 0)} + + + + {t('usage_stats.failed_requests')}: {loading ? '-' : (usageSnapshot?.failure_count ?? 0)} + + + ), + trend: sparklines.requests, + }, + { + key: 'tokens', + label: t('usage_stats.total_tokens'), + icon: , + accent: '#8b5cf6', + accentSoft: 'rgba(139, 92, 246, 0.18)', + accentBorder: 'rgba(139, 92, 246, 0.35)', + value: loading ? '-' : formatCompactNumber(usageSnapshot?.total_tokens ?? 0), + meta: ( + <> + + {t('usage_stats.cached_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(tokenBreakdown.cachedTokens)} + + + {t('usage_stats.reasoning_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(tokenBreakdown.reasoningTokens)} + + + ), + trend: sparklines.tokens, + }, + { + key: 'rpm', + label: t('usage_stats.rpm'), + icon: , + accent: '#22c55e', + accentSoft: 'rgba(34, 197, 94, 0.18)', + accentBorder: 'rgba(34, 197, 94, 0.32)', + value: loading ? '-' : formatPerMinuteValue(rateStats.rpm), + meta: ( + + {t('usage_stats.total_requests')}:{' '} + {loading ? '-' : rateStats.requestCount.toLocaleString()} + + ), + trend: sparklines.rpm, + }, + { + key: 'tpm', + label: t('usage_stats.tpm'), + icon: , + accent: '#f97316', + accentSoft: 'rgba(249, 115, 22, 0.18)', + accentBorder: 'rgba(249, 115, 22, 0.32)', + value: loading ? '-' : formatPerMinuteValue(rateStats.tpm), + meta: ( + + {t('usage_stats.total_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(rateStats.tokenCount)} + + ), + trend: sparklines.tpm, + }, + { + key: 'cost', + label: t('usage_stats.total_cost'), + icon: , + accent: '#f59e0b', + accentSoft: 'rgba(245, 158, 11, 0.18)', + accentBorder: 'rgba(245, 158, 11, 0.32)', + value: loading ? '-' : formatUsd(totalCost), + meta: ( + <> + + {t('usage_stats.total_tokens')}:{' '} + {loading ? '-' : formatCompactNumber(usageSnapshot?.total_tokens ?? 0)} + + {!costAvailable && ( + + {t('usage_stats.cost_need_price')} + + )} + + ), + trend: sparklines.cost, + }, + ]; + + return ( +
+ {statsCards.map((card) => ( +
+
+
+ {card.label} +
+ {card.icon} +
+
{card.value}
+ {card.meta &&
{card.meta}
} +
+ {card.trend ? ( + + ) : ( +
+ )} +
+
+ ))} +
+ ); +} diff --git a/web/src/components/usage/SummaryCard.tsx b/web/src/components/usage/SummaryCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f5eb14564702b5f9ca61db1c33a46c3434b35258 --- /dev/null +++ b/web/src/components/usage/SummaryCard.tsx @@ -0,0 +1,43 @@ +import { useMemo, type CSSProperties } from 'react' +import type { SummaryCardValue } from '../../lib/types' +import styles from '../../pages/usage/UsagePage.module.css' + +interface SummaryCardProps { + card: SummaryCardValue +} + +const ICONS: Record = { + requests: '◉', + tokens: '◆', + rpm: '◌', + tpm: '↗', + cost: '$', +} + +export function SummaryCard({ card }: SummaryCardProps) { + const accentStyle = useMemo( + () => ({ + '--summary-accent': card.accent, + }) as CSSProperties, + [card.accent], + ) + + return ( +
+
+
+

{card.label}

+ +
+

{card.value}

+ {card.hint ? ( +
+ {card.hint} +
+ ) : null} + {card.hint ?

{card.hint}

: null} +
+ ) +} diff --git a/web/src/components/usage/TimeRangeSelector.tsx b/web/src/components/usage/TimeRangeSelector.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7fbf7dc1b8200755269790ccfc9d042cef356f69 --- /dev/null +++ b/web/src/components/usage/TimeRangeSelector.tsx @@ -0,0 +1,28 @@ +import type { UsageTimeRange } from '../../lib/types' +import styles from '../../pages/usage/UsagePage.module.css' + +interface TimeRangeSelectorProps { + value: UsageTimeRange + onChange: (value: UsageTimeRange) => void +} + +const options: Array<{ value: UsageTimeRange; label: string }> = [ + { value: 'all', label: 'All data' }, + { value: 'today', label: 'Today' }, + { value: '7d', label: 'Last 7d' }, +] + +export function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) { + return ( + + ) +} diff --git a/web/src/components/usage/TokenBreakdownCard.tsx b/web/src/components/usage/TokenBreakdownCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c52259c519d52f9422b45598346e4714219d6fa1 --- /dev/null +++ b/web/src/components/usage/TokenBreakdownCard.tsx @@ -0,0 +1,57 @@ +import { formatNumber } from '../../lib/usage' +import styles from '../../pages/usage/UsagePage.module.css' + +interface TokenBreakdownCardProps { + inputTokens: number + outputTokens: number + reasoningTokens: number + cachedTokens: number +} + +export function TokenBreakdownCard({ + inputTokens, + outputTokens, + reasoningTokens, + cachedTokens, +}: TokenBreakdownCardProps) { + const items = [ + { key: 'input', label: 'Input tokens', value: inputTokens, colorClass: styles.tokenInput }, + { key: 'output', label: 'Output tokens', value: outputTokens, colorClass: styles.tokenOutput }, + { key: 'reasoning', label: 'Reasoning tokens', value: reasoningTokens, colorClass: styles.tokenReasoning }, + { key: 'cached', label: 'Cached tokens', value: cachedTokens, colorClass: styles.tokenCached }, + ] + + const total = items.reduce((sum, item) => sum + item.value, 0) + + return ( +
+
+
+

Token breakdown

+

Split of persisted tokens across all request events.

+
+ {formatNumber(total)} total +
+
+ {items.map((item) => { + const percentage = total > 0 ? (item.value / total) * 100 : 0 + return ( +
+
+
+ + {item.label} +
+ {formatNumber(item.value)} +
+
+
+
+ {percentage.toFixed(1)}% +
+ ) + })} +
+
+ ) +} diff --git a/web/src/components/usage/TokenBreakdownChart.tsx b/web/src/components/usage/TokenBreakdownChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ce7069fa181ba3de3b8d67ef01d242e29f7a38e --- /dev/null +++ b/web/src/components/usage/TokenBreakdownChart.tsx @@ -0,0 +1,213 @@ +import { useState, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Line } from 'react-chartjs-2'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import type { TokenCategory } from '@/utils/usage'; +import { buildChartOptions, getHourChartMinWidth } from '@/utils/usage/chartConfig'; +import type { UsageOverviewPayload } from './hooks/useUsageData'; +import styles from '@/pages/UsagePage.module.scss'; + +const TOKEN_COLORS: Record = { + input: { border: '#8b8680', bg: 'rgba(139, 134, 128, 0.25)' }, + output: { border: '#22c55e', bg: 'rgba(34, 197, 94, 0.25)' }, + cached: { border: '#f59e0b', bg: 'rgba(245, 158, 11, 0.25)' }, + reasoning: { border: '#8b5cf6', bg: 'rgba(139, 92, 246, 0.25)' } +}; + +const CATEGORIES: TokenCategory[] = ['input', 'output', 'cached', 'reasoning']; +const HOUR_MS = 60 * 60 * 1000; + +type TokenBreakdownChartPeriod = 'hour' | 'day'; + +type TokenSeriesSource = NonNullable; + +export type TokenBreakdownChartSeries = { + labels: string[]; + dataByCategory: Record; +}; + +export type BuildTokenBreakdownChartSeriesOptions = { + usage: UsageOverviewPayload | null; + period: TokenBreakdownChartPeriod; + hourWindowHours?: number; + endMs?: number; +}; + +const normalizeHourWindow = (hourWindowHours?: number): number => { + if (!Number.isFinite(hourWindowHours) || !hourWindowHours || hourWindowHours <= 0) { + return 24; + } + return Math.min(Math.max(Math.floor(hourWindowHours), 1), 24); +}; + +const formatHourBucketKey = (timestampMs: number): string => `${new Date(timestampMs).toISOString().slice(0, 13)}:00:00Z`; + +const formatChartLabel = (label: string, period: TokenBreakdownChartPeriod) => { + if (period !== 'hour') return label; + const date = new Date(label); + if (Number.isNaN(date.getTime())) return label; + return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; +}; + +const getTokenSource = (usage: UsageOverviewPayload | null, period: TokenBreakdownChartPeriod): TokenSeriesSource | undefined => ( + period === 'hour' ? (usage?.hourly_series ?? usage?.series) : (usage?.daily_series ?? usage?.series) +); + +const buildHourlyLabels = (source: TokenSeriesSource | undefined, hourWindowHours?: number, endMs?: number) => { + const labels = Object.keys(source?.input_tokens ?? {}).sort((a, b) => a.localeCompare(b)); + if (labels.length === 0) return []; + + const bucketCount = normalizeHourWindow(hourWindowHours); + const latestLabelMs = Date.parse(labels[labels.length - 1]); + const requestedEndMs = Number.isFinite(endMs) && endMs && endMs > 0 ? endMs : latestLabelMs; + const currentHour = new Date(requestedEndMs); + currentHour.setUTCMinutes(0, 0, 0); + const earliestTime = currentHour.getTime() - ((bucketCount - 1) * HOUR_MS); + + return Array.from({ length: bucketCount }, (_, index) => formatHourBucketKey(earliestTime + index * HOUR_MS)); +}; + +export const buildTokenBreakdownChartSeries = ({ + usage, + period, + hourWindowHours, + endMs, +}: BuildTokenBreakdownChartSeriesOptions): TokenBreakdownChartSeries => { + const source = getTokenSource(usage, period); + const labels = period === 'hour' + ? buildHourlyLabels(source, hourWindowHours, endMs) + : Object.keys(source?.input_tokens ?? {}).sort((a, b) => a.localeCompare(b)); + + return { + labels: labels.map((label) => formatChartLabel(label, period)), + dataByCategory: { + input: labels.map((label) => Number(source?.input_tokens?.[label] ?? 0)), + output: labels.map((label) => Number(source?.output_tokens?.[label] ?? 0)), + cached: labels.map((label) => Number(source?.cached_tokens?.[label] ?? 0)), + reasoning: labels.map((label) => Number(source?.reasoning_tokens?.[label] ?? 0)), + }, + }; +}; + +export interface TokenBreakdownChartProps { + usage: UsageOverviewPayload | null; + loading: boolean; + isDark: boolean; + isMobile: boolean; + hourWindowHours?: number; + endMs?: number; +} + +export function TokenBreakdownChart({ + usage, + loading, + isDark, + isMobile, + hourWindowHours, + endMs +}: TokenBreakdownChartProps) { + const { t } = useTranslation(); + const [period, setPeriod] = useState<'hour' | 'day'>('hour'); + + const { chartData, chartOptions } = useMemo(() => { + const series = buildTokenBreakdownChartSeries({ usage, period, hourWindowHours, endMs }); + const categoryLabels: Record = { + input: t('usage_stats.input_tokens'), + output: t('usage_stats.output_tokens'), + cached: t('usage_stats.cached_tokens'), + reasoning: t('usage_stats.reasoning_tokens') + }; + + const data = { + labels: series.labels, + datasets: CATEGORIES.map((cat) => ({ + label: categoryLabels[cat], + data: series.dataByCategory[cat], + borderColor: TOKEN_COLORS[cat].border, + backgroundColor: TOKEN_COLORS[cat].bg, + pointBackgroundColor: TOKEN_COLORS[cat].border, + pointBorderColor: TOKEN_COLORS[cat].border, + fill: true, + tension: 0.35 + })) + }; + + const baseOptions = buildChartOptions({ period, labels: series.labels, isDark, isMobile }); + const options = { + ...baseOptions, + scales: { + ...baseOptions.scales, + y: { + ...baseOptions.scales?.y, + stacked: true + }, + x: { + ...baseOptions.scales?.x, + stacked: true + } + } + }; + + return { chartData: data, chartOptions: options }; + }, [usage, period, isDark, isMobile, hourWindowHours, endMs, t]); + + return ( + + + +
+ } + > + {loading ? ( +
{t('common.loading')}
+ ) : chartData.labels.length > 0 ? ( +
+
+ {chartData.datasets.map((dataset, index) => ( +
+ + {dataset.label} +
+ ))} +
+
+
+
+ +
+
+
+
+ ) : ( +
{t('usage_stats.no_data')}
+ )} + + ); +} diff --git a/web/src/components/usage/TrendChartCard.tsx b/web/src/components/usage/TrendChartCard.tsx new file mode 100644 index 0000000000000000000000000000000000000000..affae62439f8f9419fdab48efaf38cafd2981b72 --- /dev/null +++ b/web/src/components/usage/TrendChartCard.tsx @@ -0,0 +1,72 @@ +import { Area, AreaChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' +import { formatNumber } from '../../lib/usage' +import type { TrendSeries } from '../../lib/types' +import styles from '../../pages/usage/UsagePage.module.css' + +interface TrendChartCardProps { + title: string + periodLabel: string + series: TrendSeries[] + valueFormatter?: (value: number) => string +} + +export function TrendChartCard({ title, periodLabel, series, valueFormatter = formatNumber }: TrendChartCardProps) { + const labels = Array.from(new Set(series.flatMap((item) => item.data.map((point) => point.label)))).sort((left, right) => left.localeCompare(right)) + const chartData = labels.map((label) => { + const row: Record = { label } + for (const item of series) { + const point = item.data.find((entry) => entry.label === label) + row[item.key] = point?.value ?? 0 + } + return row + }) + + return ( +
+
+
+

{title}

+

{periodLabel}

+
+ {series.length} series +
+ {chartData.length > 0 ? ( + <> +
+ {series.map((item) => ( +
+ + {item.label} +
+ ))} +
+
+ + + + + + valueFormatter(Number(value ?? 0))} /> + + {series.map((item) => ( + + ))} + + +
+ + ) : ( +
No data in selected range.
+ )} +
+ ) +} diff --git a/web/src/components/usage/UsageChart.tsx b/web/src/components/usage/UsageChart.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b5f1834e6f24b0f7642446a2e0bb74d831dcb155 --- /dev/null +++ b/web/src/components/usage/UsageChart.tsx @@ -0,0 +1,91 @@ +import { useTranslation } from 'react-i18next'; +import type { ChartOptions } from 'chart.js'; +import { Line } from 'react-chartjs-2'; +import { Card } from '@/components/ui/Card'; +import { Button } from '@/components/ui/Button'; +import type { ChartData } from '@/utils/usage'; +import { getHourChartMinWidth } from '@/utils/usage/chartConfig'; +import styles from '@/pages/UsagePage.module.scss'; + +export interface UsageChartProps { + title: string; + period: 'hour' | 'day'; + onPeriodChange: (period: 'hour' | 'day') => void; + chartData: ChartData; + chartOptions: ChartOptions<'line'>; + loading: boolean; + isMobile: boolean; + emptyText: string; +} + +export function UsageChart({ + title, + period, + onPeriodChange, + chartData, + chartOptions, + loading, + isMobile, + emptyText +}: UsageChartProps) { + const { t } = useTranslation(); + + return ( + + + +
+ } + > + {loading ? ( +
{t('common.loading')}
+ ) : chartData.labels.length > 0 ? ( +
+
+ {chartData.datasets.map((dataset, index) => ( +
+ + {dataset.label} +
+ ))} +
+
+
+
+ +
+
+
+
+ ) : ( +
{emptyText}
+ )} + + ); +} diff --git a/web/src/components/usage/hooks/index.ts b/web/src/components/usage/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..6a557b528bf3f36186de1172f48057d6b5a0ed83 --- /dev/null +++ b/web/src/components/usage/hooks/index.ts @@ -0,0 +1,11 @@ +export { useUsageData } from './useUsageData'; +export type { UsagePayload, UseUsageDataReturn } from './useUsageData'; + +export { usePricingData } from './usePricingData'; +export type { UsePricingDataOptions, UsePricingDataReturn } from './usePricingData'; + +export { useSparklines } from './useSparklines'; +export type { SparklineData, SparklineBundle, UseSparklinesOptions, UseSparklinesReturn } from './useSparklines'; + +export { useChartData } from './useChartData'; +export type { UseChartDataOptions, UseChartDataReturn } from './useChartData'; diff --git a/web/src/components/usage/hooks/useChartData.ts b/web/src/components/usage/hooks/useChartData.ts new file mode 100644 index 0000000000000000000000000000000000000000..bf3805289090c198054eab9361fe5d8561ffce5e --- /dev/null +++ b/web/src/components/usage/hooks/useChartData.ts @@ -0,0 +1,100 @@ +import { useState, useMemo } from 'react'; +import type { ChartOptions } from 'chart.js'; +import { buildChartData, type ChartData } from '@/utils/usage'; +import { buildChartOptions } from '@/utils/usage/chartConfig'; +import type { UsageOverviewPayload } from './useUsageData'; +import type { UsageOverviewSeries } from '@/lib/types'; + +const buildChartUsageFromOverview = (usage: UsageOverviewPayload, source?: UsageOverviewSeries) => { + if (!source) return usage.usage; + return { + ...usage.usage, + requests_by_hour: source.requests, + tokens_by_hour: source.tokens, + requests_by_day: source.requests, + tokens_by_day: source.tokens, + model_series: Object.fromEntries(Object.entries(source.models ?? {}).map(([model, series]) => [model, { + requests_by_hour: series.requests, + tokens_by_hour: series.tokens, + requests_by_day: series.requests, + tokens_by_day: series.tokens, + }])), + }; +}; + +export interface UseChartDataOptions { + usage: UsageOverviewPayload | null; + chartLines: string[]; + isDark: boolean; + isMobile: boolean; + hourWindowHours?: number; + endMs?: number; +} + +export interface UseChartDataReturn { + requestsPeriod: 'hour' | 'day'; + setRequestsPeriod: (period: 'hour' | 'day') => void; + tokensPeriod: 'hour' | 'day'; + setTokensPeriod: (period: 'hour' | 'day') => void; + requestsChartData: ChartData; + tokensChartData: ChartData; + requestsChartOptions: ChartOptions<'line'>; + tokensChartOptions: ChartOptions<'line'>; +} + +export function useChartData({ + usage, + chartLines, + isDark, + isMobile, + hourWindowHours, + endMs +}: UseChartDataOptions): UseChartDataReturn { + const [requestsPeriod, setRequestsPeriod] = useState<'hour' | 'day'>('hour'); + const [tokensPeriod, setTokensPeriod] = useState<'hour' | 'day'>('hour'); + + const requestsChartData = useMemo(() => { + if (!usage) return { labels: [], datasets: [] }; + const source = requestsPeriod === 'hour' ? (usage.hourly_series ?? usage.series) : (usage.daily_series ?? usage.series); + return buildChartData(buildChartUsageFromOverview(usage, source), requestsPeriod, 'requests', chartLines, { hourWindowHours, endMs }); + }, [usage, requestsPeriod, chartLines, hourWindowHours, endMs]); + + const tokensChartData = useMemo(() => { + if (!usage) return { labels: [], datasets: [] }; + const source = tokensPeriod === 'hour' ? (usage.hourly_series ?? usage.series) : (usage.daily_series ?? usage.series); + return buildChartData(buildChartUsageFromOverview(usage, source), tokensPeriod, 'tokens', chartLines, { hourWindowHours, endMs }); + }, [usage, tokensPeriod, chartLines, hourWindowHours, endMs]); + + const requestsChartOptions = useMemo( + () => + buildChartOptions({ + period: requestsPeriod, + labels: requestsChartData.labels, + isDark, + isMobile + }), + [requestsPeriod, requestsChartData.labels, isDark, isMobile] + ); + + const tokensChartOptions = useMemo( + () => + buildChartOptions({ + period: tokensPeriod, + labels: tokensChartData.labels, + isDark, + isMobile + }), + [tokensPeriod, tokensChartData.labels, isDark, isMobile] + ); + + return { + requestsPeriod, + setRequestsPeriod, + tokensPeriod, + setTokensPeriod, + requestsChartData, + tokensChartData, + requestsChartOptions, + tokensChartOptions + }; +} diff --git a/web/src/components/usage/hooks/usePricingData.ts b/web/src/components/usage/hooks/usePricingData.ts new file mode 100644 index 0000000000000000000000000000000000000000..cd35c2fb83a68f9a0b475e100987de0730a9d0d2 --- /dev/null +++ b/web/src/components/usage/hooks/usePricingData.ts @@ -0,0 +1,144 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { ApiError, deletePricing, fetchPricing, fetchUsedModels, updatePricing } from '@/lib/api'; +import { useNotificationStore } from '@/stores'; +import { loadModelPrices, saveModelPrices, type ModelPrice } from '@/utils/usage'; + +export interface UsePricingDataOptions { + onAuthRequired?: () => void; + enabled?: boolean; +} + +export interface UsePricingDataReturn { + modelNames: string[]; + modelPrices: Record; + loading: boolean; + error: string; + lastRefreshedAt: Date | null; + loadPricing: () => Promise; + setModelPrices: (prices: Record) => Promise; +} + +const pricingToModelPrice = (entry: { + model: string; + prompt_price_per_1m: number; + completion_price_per_1m: number; + cache_price_per_1m: number; +}): ModelPrice => ({ + prompt: entry.prompt_price_per_1m, + completion: entry.completion_price_per_1m, + cache: entry.cache_price_per_1m, +}); + +export function usePricingData(options: UsePricingDataOptions = {}): UsePricingDataReturn { + const { onAuthRequired, enabled = true } = options; + const { t } = useTranslation(); + const { showNotification } = useNotificationStore(); + const [modelNames, setModelNames] = useState([]); + const [modelPrices, setModelPricesState] = useState>(() => loadModelPrices()); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [lastRefreshedAt, setLastRefreshedAt] = useState(null); + const requestControllerRef = useRef(null); + + const loadPricing = useCallback(async () => { + requestControllerRef.current?.abort(); + const controller = new AbortController(); + requestControllerRef.current = controller; + + setLoading(true); + setError(''); + + try { + const [pricingResponse, usedModelsResponse] = await Promise.all([ + fetchPricing(controller.signal), + fetchUsedModels(controller.signal), + ]); + if (requestControllerRef.current !== controller) { + return; + } + const prices = Object.fromEntries( + pricingResponse.pricing.map((entry) => [entry.model, pricingToModelPrice(entry)]) + ); + saveModelPrices(prices); + setModelPricesState(prices); + setModelNames(usedModelsResponse.models); + setLastRefreshedAt(new Date()); + } catch (error) { + if (controller.signal.aborted) { + return; + } + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + setModelPricesState(loadModelPrices()); + setError(error instanceof Error ? error.message : 'Failed to load pricing'); + } finally { + if (requestControllerRef.current === controller) { + setLoading(false); + requestControllerRef.current = null; + } + } + }, [onAuthRequired]); + + useEffect(() => { + if (!enabled) { + requestControllerRef.current?.abort(); + requestControllerRef.current = null; + setLoading(false); + return; + } + void loadPricing(); + return () => { + requestControllerRef.current?.abort(); + requestControllerRef.current = null; + }; + }, [enabled, loadPricing]); + + const setModelPrices = useCallback(async (prices: Record) => { + const previousPrices = modelPrices; + setModelPricesState(prices); + saveModelPrices(prices); + + try { + const previousModels = new Set(Object.keys(previousPrices)); + const nextModels = new Set(Object.keys(prices)); + await Promise.all([ + ...Object.entries(prices).map(([model, pricing]) => + updatePricing(model, { + prompt_price_per_1m: pricing.prompt, + completion_price_per_1m: pricing.completion, + cache_price_per_1m: pricing.cache, + }) + ), + ...Array.from(previousModels) + .filter((model) => !nextModels.has(model)) + .map((model) => deletePricing(model)), + ]); + setLastRefreshedAt(new Date()); + } catch (error) { + setModelPricesState(previousPrices); + saveModelPrices(previousPrices); + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + const message = error instanceof Error ? error.message : ''; + showNotification( + `${t('notification.upload_failed')}${message ? `: ${message}` : ''}`, + 'error' + ); + } + }, [modelPrices, onAuthRequired, showNotification, t]); + + return { + modelNames, + modelPrices, + loading, + error, + lastRefreshedAt, + loadPricing, + setModelPrices, + }; +} diff --git a/web/src/components/usage/hooks/useSparklines.test.ts b/web/src/components/usage/hooks/useSparklines.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d562c917d7463b08976aa2c393c5a6e6c32f49c --- /dev/null +++ b/web/src/components/usage/hooks/useSparklines.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; +import { buildUsageSparklineSeries } from './useSparklines'; +import type { UsagePayload } from './useUsageData'; + +const usageWithBackendSeries: UsagePayload = { + total_requests: 9, + success_count: 8, + failure_count: 1, + total_tokens: 900, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: { + 'provider-a': { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 100, + models: { + 'claude-sonnet': { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 100, + details: [ + { + timestamp: '2026-04-23T00:00:00.000Z', + latency_ms: 100, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 20, + output_tokens: 30, + reasoning_tokens: 4, + cached_tokens: 5, + total_tokens: 50, + }, + }, + ], + }, + }, + }, + }, + series: { + requests: { + '2026-04-23T10:00:00Z': 2, + '2026-04-23T11:00:00Z': 4, + }, + tokens: { + '2026-04-23T10:00:00Z': 200, + '2026-04-23T11:00:00Z': 800, + }, + rpm: { + '2026-04-23T10:00:00Z': 2 / 60, + '2026-04-23T11:00:00Z': 4 / 60, + }, + tpm: { + '2026-04-23T10:00:00Z': 200 / 60, + '2026-04-23T11:00:00Z': 800 / 60, + }, + cost: { + '2026-04-23T10:00:00Z': 0.2, + '2026-04-23T11:00:00Z': 0.8, + }, + }, +}; + +describe('buildUsageSparklineSeries', () => { + it('prefers backend series over detail-derived fallback values', () => { + const series = buildUsageSparklineSeries({ + usage: usageWithBackendSeries, + }); + + expect(series.labels).toEqual(['2026-04-23T10:00:00Z', '2026-04-23T11:00:00Z']); + expect(series.requests).toEqual([2, 4]); + expect(series.tokens).toEqual([200, 800]); + expect(series.rpm).toEqual([2 / 60, 4 / 60]); + expect(series.tpm).toEqual([200 / 60, 800 / 60]); + expect(series.cost).toEqual([0.2, 0.8]); + }); +}); diff --git a/web/src/components/usage/hooks/useSparklines.ts b/web/src/components/usage/hooks/useSparklines.ts new file mode 100644 index 0000000000000000000000000000000000000000..90ad3cde4cbd35a0afff5dc7b9e2432e6d31cc0e --- /dev/null +++ b/web/src/components/usage/hooks/useSparklines.ts @@ -0,0 +1,132 @@ +import { useCallback, useMemo } from 'react'; +import type { UsageOverviewPayload } from './useUsageData'; + +export interface SparklineData { + labels: string[]; + datasets: [ + { + data: number[]; + borderColor: string; + backgroundColor: string; + fill: boolean; + tension: number; + pointRadius: number; + borderWidth: number; + } + ]; +} + +export interface SparklineBundle { + data: SparklineData; +} + +export interface UseSparklinesOptions { + usage: UsageOverviewPayload | null; + loading: boolean; +} + +export interface UseSparklinesReturn { + requestsSparkline: SparklineBundle | null; + tokensSparkline: SparklineBundle | null; + rpmSparkline: SparklineBundle | null; + tpmSparkline: SparklineBundle | null; + costSparkline: SparklineBundle | null; +} + +export interface UsageSparklineSeries { + labels: string[]; + requests: number[]; + tokens: number[]; + rpm: number[]; + tpm: number[]; + cost: number[]; +} + +export function buildUsageSparklineSeries({ usage }: Omit): UsageSparklineSeries { + if (!usage?.series) { + return { labels: [], requests: [], tokens: [], rpm: [], tpm: [], cost: [] }; + } + + const labels = Object.keys(usage.series.requests ?? {}).sort((a, b) => a.localeCompare(b)); + if (!labels.length) { + return { labels: [], requests: [], tokens: [], rpm: [], tpm: [], cost: [] }; + } + + return { + labels, + requests: labels.map((label) => Number(usage.series?.requests?.[label] ?? 0)), + tokens: labels.map((label) => Number(usage.series?.tokens?.[label] ?? 0)), + rpm: labels.map((label) => Number(usage.series?.rpm?.[label] ?? 0)), + tpm: labels.map((label) => Number(usage.series?.tpm?.[label] ?? 0)), + cost: labels.map((label) => Number(usage.series?.cost?.[label] ?? 0)), + }; +} + +export function useSparklines({ usage, loading }: UseSparklinesOptions): UseSparklinesReturn { + const series = useMemo( + () => buildUsageSparklineSeries({ usage }), + [usage] + ); + + const buildSparkline = useCallback( + ( + input: { labels: string[]; data: number[] }, + color: string, + backgroundColor: string + ): SparklineBundle | null => { + if (loading || !input?.data?.length) { + return null; + } + return { + data: { + labels: input.labels, + datasets: [ + { + data: input.data, + borderColor: color, + backgroundColor, + fill: true, + tension: 0.45, + pointRadius: 0, + borderWidth: 2 + } + ] + } + }; + }, + [loading] + ); + + const requestsSparkline = useMemo( + () => buildSparkline({ labels: series.labels, data: series.requests }, '#8b8680', 'rgba(139, 134, 128, 0.18)'), + [buildSparkline, series.labels, series.requests] + ); + + const tokensSparkline = useMemo( + () => buildSparkline({ labels: series.labels, data: series.tokens }, '#8b5cf6', 'rgba(139, 92, 246, 0.18)'), + [buildSparkline, series.labels, series.tokens] + ); + + const rpmSparkline = useMemo( + () => buildSparkline({ labels: series.labels, data: series.rpm }, '#22c55e', 'rgba(34, 197, 94, 0.18)'), + [buildSparkline, series.labels, series.rpm] + ); + + const tpmSparkline = useMemo( + () => buildSparkline({ labels: series.labels, data: series.tpm }, '#f97316', 'rgba(249, 115, 22, 0.18)'), + [buildSparkline, series.labels, series.tpm] + ); + + const costSparkline = useMemo( + () => buildSparkline({ labels: series.labels, data: series.cost }, '#f59e0b', 'rgba(245, 158, 11, 0.18)'), + [buildSparkline, series.labels, series.cost] + ); + + return { + requestsSparkline, + tokensSparkline, + rpmSparkline, + tpmSparkline, + costSparkline + }; +} diff --git a/web/src/components/usage/hooks/useUsageData.ts b/web/src/components/usage/hooks/useUsageData.ts new file mode 100644 index 0000000000000000000000000000000000000000..7db7c5e3e6c48a879429c54103ebff1d554d940d --- /dev/null +++ b/web/src/components/usage/hooks/useUsageData.ts @@ -0,0 +1,93 @@ +import { useEffect, useCallback } from 'react'; +import { ApiError } from '@/lib/api'; +import type { UsageOverviewResponse, UsageSnapshot, UsageTimeRange } from '@/lib/types'; +import { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from '@/stores'; + +export type UsagePayload = Partial; + +export type UsageOverviewPayload = Omit & { + usage: UsagePayload; +}; + +export interface UseUsageDataReturn { + usage: UsageOverviewPayload | null; + loading: boolean; + error: string; + lastRefreshedAt: Date | null; + loadUsage: () => Promise; +} + +export interface UseUsageDataOptions { + onAuthRequired?: () => void; + range?: UsageTimeRange; + customStart?: string; + customEnd?: string; + enabled?: boolean; +} + +const toRangeQuery = (value: string): UsageTimeRange => ( + value === '4h' || value === '8h' || value === '12h' || value === '24h' || value === 'today' || value === '7d' || value === 'all' || value === 'custom' + ? value + : 'all' +); + +const toCustomDateParam = (value: string | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed && /^\d{4}-\d{2}-\d{2}$/.test(trimmed) ? trimmed : undefined; +}; + +export function useUsageData(options: UseUsageDataOptions = {}): UseUsageDataReturn { + const { onAuthRequired, range = 'all', customStart, customEnd, enabled = true } = options; + const usageSnapshot = useUsageStatsStore((state) => state.usage); + const loading = useUsageStatsStore((state) => state.loading); + const storeError = useUsageStatsStore((state) => state.error); + const lastRefreshedAtTs = useUsageStatsStore((state) => state.lastRefreshedAt); + const loadUsageStats = useUsageStatsStore((state) => state.loadUsageStats); + + const resolvedRange = toRangeQuery(range); + const requestStart = resolvedRange === 'custom' ? toCustomDateParam(customStart) : undefined; + const requestEnd = resolvedRange === 'custom' ? toCustomDateParam(customEnd) : undefined; + const customRangeReady = resolvedRange !== 'custom' || (requestStart !== undefined && requestEnd !== undefined); + + const loadUsage = useCallback(async () => { + if (!customRangeReady) return; + try { + await loadUsageStats({ + force: true, + staleTimeMs: USAGE_STATS_STALE_TIME_MS, + range: resolvedRange, + start: requestStart, + end: requestEnd, + }); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + } + throw error; + } + }, [customRangeReady, loadUsageStats, onAuthRequired, requestEnd, requestStart, resolvedRange]); + + useEffect(() => { + if (!enabled || !customRangeReady) { + return; + } + void loadUsageStats({ + staleTimeMs: USAGE_STATS_STALE_TIME_MS, + range: resolvedRange, + start: requestStart, + end: requestEnd, + }).catch((error) => { + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + } + }); + }, [customRangeReady, enabled, loadUsageStats, onAuthRequired, requestEnd, requestStart, resolvedRange]); + + return { + usage: usageSnapshot as UsageOverviewPayload | null, + loading, + error: storeError || '', + lastRefreshedAt: lastRefreshedAtTs ? new Date(lastRefreshedAtTs) : null, + loadUsage, + }; +} diff --git a/web/src/components/usage/index.ts b/web/src/components/usage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..04faa573755d6c4077f4aef99efe69be3f6ee0d2 --- /dev/null +++ b/web/src/components/usage/index.ts @@ -0,0 +1,15 @@ +export { StatCards } from './StatCards'; +export { UsageChart } from './UsageChart'; +export { ChartLineSelector } from './ChartLineSelector'; +export { ApiDetailsCard } from './ApiDetailsCard'; +export { ModelStatsCard } from './ModelStatsCard'; +export { PriceSettingsCard } from './PriceSettingsCard'; +export { CredentialStatsCard, CredentialTopChartCard } from './CredentialStatsCard'; +export { RequestEventsDetailsCard } from './RequestEventsDetailsCard'; +export { TokenBreakdownChart } from './TokenBreakdownChart'; +export { CostTrendChart } from './CostTrendChart'; +export { ServiceHealthCard } from './ServiceHealthCard'; +export { useUsageData } from './hooks/useUsageData'; +export { usePricingData } from './hooks/usePricingData'; +export { useSparklines } from './hooks/useSparklines'; +export { useChartData } from './hooks/useChartData'; diff --git a/web/src/hooks/index.ts b/web/src/hooks/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..7401439565349dfc2f90507c265dd7f9f722139d --- /dev/null +++ b/web/src/hooks/index.ts @@ -0,0 +1,6 @@ +/** + * Hooks 统一导出 + */ + +export { useMediaQuery } from './useMediaQuery'; +export { useHeaderRefresh } from './useHeaderRefresh'; diff --git a/web/src/hooks/useHeaderRefresh.ts b/web/src/hooks/useHeaderRefresh.ts new file mode 100644 index 0000000000000000000000000000000000000000..a620376f4fecd5ba145827bec86592019e6d3e92 --- /dev/null +++ b/web/src/hooks/useHeaderRefresh.ts @@ -0,0 +1,34 @@ +import { useEffect, useRef } from 'react'; + +export type HeaderRefreshHandler = () => void | Promise; + +let activeHeaderRefreshHandler: HeaderRefreshHandler | null = null; + +export const triggerHeaderRefresh = async () => { + if (!activeHeaderRefreshHandler) return; + await activeHeaderRefreshHandler(); +}; + +export const useHeaderRefresh = (handler?: HeaderRefreshHandler | null, enabled = true) => { + const lastHandlerRef = useRef(null); + + useEffect(() => { + const previousHandler = lastHandlerRef.current; + lastHandlerRef.current = handler ?? null; + + if (!enabled || !handler) { + if (previousHandler && activeHeaderRefreshHandler === previousHandler) { + activeHeaderRefreshHandler = null; + } + return; + } + + activeHeaderRefreshHandler = handler; + + return () => { + if (activeHeaderRefreshHandler === handler) { + activeHeaderRefreshHandler = null; + } + }; + }, [enabled, handler]); +}; diff --git a/web/src/hooks/useMediaQuery.ts b/web/src/hooks/useMediaQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..fa4e63f6a5ce3d8326eded2fcccb4f46269cc60c --- /dev/null +++ b/web/src/hooks/useMediaQuery.ts @@ -0,0 +1,27 @@ +/** + * 媒体查询 Hook + */ + +import { useState, useEffect } from 'react'; + +export function useMediaQuery(query: string): boolean { + const [matches, setMatches] = useState(() => { + return window.matchMedia(query).matches; + }); + + useEffect(() => { + const media = window.matchMedia(query); + + const listener = (event: MediaQueryListEvent) => { + setMatches(event.matches); + }; + + // Set initial value via listener to avoid direct setState in effect + listener({ matches: media.matches } as MediaQueryListEvent); + + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, [query]); + + return matches; +} diff --git a/web/src/i18n/index.ts b/web/src/i18n/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac4cfc033d1def50e97943074beb3ba86bc671e7 --- /dev/null +++ b/web/src/i18n/index.ts @@ -0,0 +1,400 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; + +const LANGUAGE_STORAGE_KEY = 'cpa-usage-keeper-language'; +const DEFAULT_LANGUAGE = 'en'; +const SUPPORTED_LANGUAGES = ['en', 'zh'] as const; + +type SupportedLanguage = (typeof SUPPORTED_LANGUAGES)[number]; + +const getInitialLanguage = (): SupportedLanguage => { + if (typeof window === 'undefined') return DEFAULT_LANGUAGE; + const saved = window.localStorage.getItem(LANGUAGE_STORAGE_KEY); + return saved === 'zh' ? 'zh' : DEFAULT_LANGUAGE; +}; + +const resources = { + en: { + translation: { + common: { + loading: 'Loading...', + save: 'Save', + cancel: 'Cancel', + edit: 'Edit', + delete: 'Delete', + close: 'Close' + }, + notification: { + download_failed: 'Download failed', + upload_failed: 'Upload failed', + refresh_failed: 'Refresh failed' + }, + status_bar: { + success_short: 'OK', + failure_short: 'Fail', + no_requests: 'No requests' + }, + auth: { + login_title: 'Protected Usage Access', + login_subtitle: 'Enter the configured login password to continue into the usage dashboard.', + password_label: 'Login Password', + password_placeholder: 'Enter password', + login_submit: 'Continue', + invalid_password: 'Incorrect password', + login_failed: 'Unable to complete login right now', + session_expired: 'Your session expired. Please sign in again.' + }, + usage_stats: { + title: 'Usage', + refresh: 'Refresh', + sync_now: 'Sync Now', + export: 'Export', + import: 'Import', + export_success: 'Usage exported', + import_invalid: 'Invalid import file', + import_success: 'Import complete: added {{added}}, skipped {{skipped}}, total {{total}}, failed {{failed}}', + last_updated: 'Last Updated', + tabs_aria_label: 'Usage sections', + tab_overview: 'Overview', + tab_analysis: 'API & Models', + tab_events: 'Request Events', + tab_credentials: 'Credentials', + tab_pricing: 'Pricing', + range_filter: 'Range', + range_all: 'All', + range_4h: '4h', + range_8h: '8h', + range_12h: '12h', + range_24h: '24h', + range_today: 'Today', + range_7d: '7d', + range_custom: 'Custom', + custom_start: 'Start Date', + custom_end: 'End Date', + custom_invalid: 'Start date must be earlier than or equal to end date', + custom_incomplete: 'Select both start and end dates to apply a custom range', + no_data: 'No Data', + requests_trend: 'Requests Trend', + tokens_trend: 'Tokens Trend', + by_hour: 'By Hour', + by_day: 'By Day', + total_requests: 'Total Requests', + success_requests: 'Success', + failed_requests: 'Failed', + avg_time: 'Avg Time', + total_time: 'Total Time', + time: 'Time', + total_tokens: 'Total Tokens', + cached_tokens: 'Cached', + reasoning_tokens: 'Reasoning', + rpm: 'RPM', + tpm: 'TPM', + rpm_30m: 'RPM (30m)', + tpm_30m: 'TPM (30m)', + total_cost: 'Total Cost', + cost_need_price: 'Set pricing to calculate cost', + cost_no_data: 'No cost data', + latency_unit_hint: 'Using {{field}} in {{unit}}', + duration_unit_ms: 'ms', + duration_unit_s: 's', + duration_unit_m: 'm', + duration_unit_h: 'h', + duration_unit_d: 'd', + filter_all: 'All', + clear_filters: 'Clear Filters', + export_csv: 'Export CSV', + export_json: 'Export JSON', + request_events_title: 'Request Event Log', + request_events_eyebrow: 'Event Stream', + request_events_subtitle: 'Filter, inspect, and export the raw request events behind every aggregate on the page.', + request_events_showing: 'Showing {{shown}} of {{total}}', + request_events_filtered: '{{count}} filtered', + request_events_filter_model: 'Model', + request_events_filter_source: 'Source', + request_events_filter_result: 'Result', + request_events_filter_auth_index: 'Credential', + request_events_empty_title: 'No Request Events Yet', + request_events_empty_desc: 'Request events will appear here after traffic is recorded.', + request_events_no_result_title: 'No Matching Request Events', + request_events_no_result_desc: 'Try clearing filters or broadening the selected criteria.', + request_events_count: '{{count}} events on this page', + request_events_total_count: '{{count}} total events', + request_events_rows_per_page: 'Size', + request_events_page_control: 'Page ({{page}}/{{totalPages}})', + request_events_page_label: 'Page {{page}} of {{totalPages}}', + request_events_page_empty: 'Page 0 of 0', + request_events_previous_page: 'Previous', + request_events_next_page: 'Next', + request_events_timestamp: 'Timestamp', + request_events_source: 'Source', + request_events_auth_index: 'Credential', + request_events_result: 'Result', + model_name: 'Model', + source_name: 'Source', + auth_index: 'Auth Index', + result: 'Result', + success: 'Success', + failure: 'Failure', + requests_count: 'Requests', + tokens_count: 'Tokens', + input_tokens: 'Input', + output_tokens: 'Output', + latency: 'Latency', + credential_stats: 'Credential Stats', + credential_name: 'Credential', + success_rate: 'Success Rate', + api_details: 'API Details', + api_endpoint: 'API', + model_stats: 'Model Stats', + model_price_settings: 'Price Settings', + model_price_settings_title: 'Model Pricing Table', + model_price_settings_subtitle: 'Maintain backend pricing so token activity can resolve into cost analytics.', + model_price_settings_eyebrow: 'Pricing', + model_price_select_placeholder: 'Select Model', + model_price_configured: 'Configured', + model_price_prompt: 'Prompt', + model_price_completion: 'Completion', + model_price_cache: 'Cache', + saved_prices: 'Saved Prices', + model_price_empty: 'No saved prices', + token_breakdown: 'Token Breakdown', + token_breakdown_title: 'Token Breakdown', + cost_trend: 'Cost Trend', + cost_trend_title: 'Cost Trend', + service_health: 'Service Health', + service_health_title: 'Request Health Timeline', + service_health_subtitle: 'A compact reliability strip mapped to the selected range, with timeline detail capped at 7 days.', + service_health_eyebrow: 'Reliability', + service_health_window: 'Last 7 Days', + service_health_oldest: 'Oldest', + service_health_newest: 'Newest', + service_health_block_label: '{{timeRange}}: {{summary}}', + healthy: 'Healthy', + unhealthy: 'Unhealthy', + chart_line_title: 'Chart Line Selection', + chart_line_add: 'Add Line', + chart_line_delete: 'Remove', + chart_line_hint: 'Choose the model lines you want to compare in the trend charts.', + chart_line_label: 'Line {{index}}', + chart_line_all_traffic: 'All Traffic', + theme_light: 'Light', + theme_dark: 'Dark', + theme_auto: 'Auto', + theme_switch: 'Theme', + language_switch: 'Language', + api_details_title: 'API Usage Breakdown', + api_details_subtitle: 'Compare request volume, token load, and estimated spend across API surfaces.', + api_details_eyebrow: 'API View', + model_stats_title: 'Model Performance Table', + model_stats_subtitle: 'Inspect throughput, latency, reliability, and cost concentration by model.', + model_stats_eyebrow: 'Model View', + credential_stats_title: 'Credential Traffic Table', + credential_stats_subtitle: 'Review request reliability by credential identity and provider source.', + credential_stats_eyebrow: 'Credential View', + credential_top_chart_eyebrow: 'Credential Ranking', + credential_top_chart_title: 'Credential Requests', + credential_top_chart_hint: 'Top 10 credentials ranked by request volume, split by success and failure count' + } + } + }, + zh: { + translation: { + common: { + loading: '加载中...', + save: '保存', + cancel: '取消', + edit: '编辑', + delete: '删除', + close: '关闭' + }, + notification: { + download_failed: '下载失败', + upload_failed: '上传失败', + refresh_failed: '刷新失败' + }, + status_bar: { + success_short: '成功', + failure_short: '失败', + no_requests: '暂无请求' + }, + auth: { + login_title: '受保护的 Usage 访问', + login_subtitle: '请输入已配置的登录密码后再进入 usage dashboard。', + password_label: '登录密码', + password_placeholder: '请输入密码', + login_submit: '继续进入', + invalid_password: '密码错误', + login_failed: '当前无法完成登录', + session_expired: '登录态已失效,请重新登录。' + }, + usage_stats: { + title: '用量', + refresh: '刷新', + sync_now: '立即同步', + export: '导出', + import: '导入', + export_success: '用量已导出', + import_invalid: '导入文件无效', + import_success: '导入完成:新增 {{added}},跳过 {{skipped}},总计 {{total}},失败 {{failed}}', + last_updated: '最近更新', + tabs_aria_label: '用量页面分区', + tab_overview: '概览', + tab_analysis: 'API 与模型', + tab_events: '请求事件', + tab_credentials: '凭证', + tab_pricing: '定价', + range_filter: '范围', + range_all: '全部', + range_4h: '4小时', + range_8h: '8小时', + range_12h: '12小时', + range_24h: '24小时', + range_today: '今天', + range_7d: '7天', + range_custom: '自定义', + custom_start: '开始日期', + custom_end: '结束日期', + custom_invalid: '开始日期不能晚于结束日期', + custom_incomplete: '请选择完整的开始和结束日期', + no_data: '暂无数据', + requests_trend: '请求趋势', + tokens_trend: 'Token 趋势', + by_hour: '按小时', + by_day: '按天', + total_requests: '请求总数', + success_requests: '成功', + failed_requests: '失败', + avg_time: '平均耗时', + total_time: '总耗时', + time: '耗时', + total_tokens: 'Token 总数', + cached_tokens: '缓存', + reasoning_tokens: '推理', + rpm: 'RPM', + tpm: 'TPM', + rpm_30m: 'RPM(30 分钟)', + tpm_30m: 'TPM(30 分钟)', + total_cost: '总成本', + cost_need_price: '请先设置价格以计算成本', + cost_no_data: '暂无成本数据', + latency_unit_hint: '使用 {{field}},单位 {{unit}}', + duration_unit_ms: '毫秒', + duration_unit_s: '秒', + duration_unit_m: '分钟', + duration_unit_h: '小时', + duration_unit_d: '天', + filter_all: '全部', + clear_filters: '清除筛选', + export_csv: '导出 CSV', + export_json: '导出 JSON', + request_events_title: '请求事件日志', + request_events_eyebrow: '事件流', + request_events_subtitle: '筛选、查看并导出页面中各项聚合数据背后的原始请求事件。', + request_events_showing: '显示 {{shown}} / {{total}}', + request_events_filtered: '已筛选 {{count}} 条', + request_events_filter_model: '模型', + request_events_filter_source: '来源', + request_events_filter_result: '结果', + request_events_filter_auth_index: '凭证', + request_events_empty_title: '暂无请求事件', + request_events_empty_desc: '开始产生流量后,请求事件会显示在这里。', + request_events_no_result_title: '没有匹配的请求事件', + request_events_no_result_desc: '可以尝试清除筛选条件或放宽筛选范围。', + request_events_count: '本页 {{count}} 条事件', + request_events_total_count: '共 {{count}} 条事件', + request_events_rows_per_page: '大小', + request_events_page_control: '分页({{page}}/{{totalPages}})', + request_events_page_label: '第 {{page}} / {{totalPages}} 页', + request_events_page_empty: '第 0 / 0 页', + request_events_previous_page: '上一页', + request_events_next_page: '下一页', + request_events_timestamp: '时间', + request_events_source: '来源', + request_events_auth_index: '凭证', + request_events_result: '结果', + model_name: '模型', + source_name: '来源', + auth_index: '认证索引', + result: '结果', + success: '成功', + failure: '失败', + requests_count: '请求数', + tokens_count: 'Token 数', + input_tokens: '输入', + output_tokens: '输出', + latency: '延迟', + credential_stats: '凭证统计', + credential_name: '凭证', + success_rate: '成功率', + api_details: 'API 详情', + api_endpoint: 'API', + model_stats: '模型统计', + model_price_settings: '价格设置', + model_price_settings_title: '模型价格表', + model_price_settings_subtitle: '维护后端定价,让 token 活动可以换算成成本分析。', + model_price_settings_eyebrow: '定价', + model_price_select_placeholder: '选择模型', + model_price_configured: '已设置', + model_price_prompt: '输入', + model_price_completion: '输出', + model_price_cache: '缓存', + saved_prices: '已保存价格', + model_price_empty: '暂无已保存价格', + token_breakdown: 'Token 构成', + token_breakdown_title: 'Token 构成', + cost_trend: '成本趋势', + cost_trend_title: '成本趋势', + service_health: '服务健康', + service_health_title: '请求健康时间线', + service_health_subtitle: '用紧凑的可靠性条带展示当前筛选范围内的请求结果,时间线最多展示 7 天细节。', + service_health_eyebrow: '稳定性', + service_health_window: '最近 7 天', + service_health_oldest: '最早', + service_health_newest: '最新', + service_health_block_label: '{{timeRange}}:{{summary}}', + healthy: '健康', + unhealthy: '异常', + chart_line_title: '图表线条选择', + chart_line_add: '添加线条', + chart_line_delete: '删除', + chart_line_hint: '选择你希望在趋势图中对比展示的模型线条。', + chart_line_label: '线条 {{index}}', + chart_line_all_traffic: '全部流量', + theme_light: '浅色', + theme_dark: '深色', + theme_auto: '自动', + theme_switch: '主题切换', + language_switch: '语言切换', + api_details_title: 'API 用量拆分', + api_details_subtitle: '对比不同 API 面的请求量、Token 负载和预估成本。', + api_details_eyebrow: 'API 视图', + model_stats_title: '模型表现表', + model_stats_subtitle: '按模型查看吞吐、延迟、稳定性与成本分布。', + model_stats_eyebrow: '模型视图', + credential_stats_title: '凭证流量表', + credential_stats_subtitle: '按凭证身份与 provider 来源检查请求可靠性。', + credential_stats_eyebrow: '凭证视图', + credential_top_chart_eyebrow: '凭证排行', + credential_top_chart_title: '凭证请求', + credential_top_chart_hint: '按请求量排名前 10 的凭证,并拆分展示成功与失败请求数' + } + } + } +}; + +if (!i18n.isInitialized) { + void i18n.use(initReactI18next).init({ + resources, + lng: getInitialLanguage(), + fallbackLng: DEFAULT_LANGUAGE, + interpolation: { escapeValue: false } + }); +} + +export const persistLanguage = (language: string) => { + if (typeof window === 'undefined') return; + if (!SUPPORTED_LANGUAGES.includes(language as SupportedLanguage)) return; + window.localStorage.setItem(LANGUAGE_STORAGE_KEY, language); +}; + +export default i18n; diff --git a/web/src/index.css b/web/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..e8023e0f595ae8dbdd0cb28d88a490f5885d8779 --- /dev/null +++ b/web/src/index.css @@ -0,0 +1,128 @@ +:root { + --bg-secondary: #faf9f5; + --bg-primary: #f0eee8; + --bg-tertiary: #e9e6df; + --bg-hover: var(--bg-tertiary); + --bg-quinary: #f6f4ee; + --bg-error-light: rgba(198, 87, 70, 0.1); + --floating-surface: #fffdf9; + --floating-border: #d8d3ca; + --floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14); + + --text-primary: #2d2a26; + --text-secondary: #6d6760; + --text-tertiary: #a29c95; + --text-quaternary: #c0bab3; + + --border-color: #e3e1db; + --border-primary: #d5d2cb; + --border-hover: #cecac4; + + --primary-color: #8b8680; + --primary-hover: #7f7a74; + --primary-active: #726d67; + --primary-contrast: #ffffff; + + --success-color: #10b981; + --warning-color: #c65746; + --error-color: #c65746; + --danger-color: var(--error-color); + + --shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08); + --shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1); + + color-scheme: light; + font-family: 'SF Pro Text', 'Segoe UI', sans-serif; + color: var(--text-primary); + background: var(--bg-secondary); + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +[data-theme='dark'] { + --bg-secondary: #151412; + --bg-primary: #1d1b18; + --bg-tertiary: #262320; + --bg-hover: #2e2a26; + --bg-quinary: #191714; + --bg-error-light: rgba(198, 87, 70, 0.18); + --floating-surface: #2a2723; + --floating-border: #4a443d; + --floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4); + + --text-primary: #f6f4f1; + --text-secondary: #c9c3bb; + --text-tertiary: #9c958d; + --text-quaternary: #6f6962; + + --border-color: #3a3530; + --border-primary: #4a453f; + --border-hover: #5a544d; + + --primary-color: #8b8680; + --primary-hover: #9a948e; + --primary-active: #a6a099; + --primary-contrast: #ffffff; + + --success-color: #10b981; + --warning-color: #c65746; + --error-color: #c65746; + --danger-color: var(--error-color); + + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3); + + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body, +#root { + min-height: 100%; +} + +html { + scrollbar-gutter: stable; +} + +body { + margin: 0; + background: var(--bg-secondary); + color: var(--text-primary); + transition: background-color 200ms ease, color 200ms ease; +} + +button, +input, +select, +textarea { + font: inherit; +} + +#root { + width: 100%; +} + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 999px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--border-hover); +} diff --git a/web/src/lib/api.test.ts b/web/src/lib/api.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a707d166fa85a67cddd24d61c9f8372f3dce5a7d --- /dev/null +++ b/web/src/lib/api.test.ts @@ -0,0 +1,131 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { fetchUsageEventFilterOptions, fetchUsageEvents, fetchUsageIdentities, triggerSync } from './api'; + +describe('fetchUsageEvents', () => { + afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('loads stable filter options without query params', async () => { + vi.stubGlobal('window', { __APP_BASE_PATH__: undefined }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ models: ['claude-sonnet'], sources: [{ value: 'source-a', label: 'Provider A' }] }), + } as Response); + const signal = new AbortController().signal; + + const response = await fetchUsageEventFilterOptions(signal); + + const [url, init] = fetchMock.mock.calls[0]; + const parsed = new URL(String(url), 'http://localhost'); + + expect(response.models).toEqual(['claude-sonnet']); + expect(parsed.pathname).toBe('/api/v1/usage/events/filters'); + expect(parsed.search).toBe(''); + expect(parsed.searchParams.get('range')).toBeNull(); + expect(parsed.searchParams.get('start')).toBeNull(); + expect(parsed.searchParams.get('end')).toBeNull(); + expect(parsed.searchParams.get('page')).toBeNull(); + expect(parsed.searchParams.get('page_size')).toBeNull(); + expect(parsed.searchParams.get('model')).toBeNull(); + expect(parsed.searchParams.get('source')).toBeNull(); + expect(parsed.searchParams.get('result')).toBeNull(); + expect(init).toMatchObject({ credentials: 'include', signal }); + }); + + it('passes pagination and server-side filters as query params', async () => { + vi.stubGlobal('window', { __APP_BASE_PATH__: undefined }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ events: [], models: [], sources: [], total_count: 0, page: 3, page_size: 100, total_pages: 0 }), + } as Response); + const signal = new AbortController().signal; + + await fetchUsageEvents('custom', '2026-04-20T00:00:00Z', '2026-04-21T00:00:00Z', signal, { + page: 3, + pageSize: 100, + model: 'claude-sonnet', + source: 'source-a', + result: 'failed', + }); + + const [url, init] = fetchMock.mock.calls[0]; + const parsed = new URL(String(url), 'http://localhost'); + + expect(parsed.pathname).toBe('/api/v1/usage/events'); + expect(parsed.searchParams.get('range')).toBe('custom'); + expect(parsed.searchParams.get('start')).toBe('2026-04-20T00:00:00Z'); + expect(parsed.searchParams.get('end')).toBe('2026-04-21T00:00:00Z'); + expect(parsed.searchParams.get('page')).toBe('3'); + expect(parsed.searchParams.get('page_size')).toBe('100'); + expect(parsed.searchParams.get('model')).toBe('claude-sonnet'); + expect(parsed.searchParams.get('source')).toBe('source-a'); + expect(parsed.searchParams.get('result')).toBe('failed'); + expect(parsed.searchParams.get('auth_index')).toBeNull(); + expect(init).toMatchObject({ credentials: 'include', signal }); + }); + + it('loads unified usage identities for credential stats', async () => { + vi.stubGlobal('window', { __APP_BASE_PATH__: undefined }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + identities: [ + { + id: 1, + name: 'Claude primary', + auth_type: 2, + auth_type_name: 'apikey', + identity: 'sk-a***1234', + type: 'claude', + provider: 'anthropic', + total_requests: 3, + success_count: 2, + failure_count: 1, + input_tokens: 10, + output_tokens: 20, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 30, + last_aggregated_usage_event_id: 9, + is_deleted: false, + created_at: '2026-05-04T00:00:00Z', + updated_at: '2026-05-04T00:00:00Z', + }, + ], + }), + } as Response); + const signal = new AbortController().signal; + + const response = await fetchUsageIdentities(signal); + + const [url, init] = fetchMock.mock.calls[0]; + const parsed = new URL(String(url), 'http://localhost'); + + expect(response.identities[0].identity).toBe('sk-a***1234'); + expect(response.identities[0].auth_type).toBe(2); + expect(typeof response.identities[0].auth_type).toBe('number'); + expect(parsed.pathname).toBe('/api/v1/usage/identities'); + expect(parsed.search).toBe(''); + expect(init).toMatchObject({ credentials: 'include', signal }); + }); + + it('posts to the manual sync endpoint', async () => { + vi.stubGlobal('window', { __APP_BASE_PATH__: undefined }); + const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ running: true, sync_running: false, last_status: 'completed' }), + } as Response); + const signal = new AbortController().signal; + + const response = await triggerSync(signal); + + const [url, init] = fetchMock.mock.calls[0]; + const parsed = new URL(String(url), 'http://localhost'); + + expect(response.last_status).toBe('completed'); + expect(parsed.pathname).toBe('/api/v1/sync'); + expect(init).toMatchObject({ credentials: 'include', method: 'POST', signal }); + }); +}); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts new file mode 100644 index 0000000000000000000000000000000000000000..30744abf6a5b433e1cbfe8d235a642031cddb512 --- /dev/null +++ b/web/src/lib/api.ts @@ -0,0 +1,221 @@ +import type { AuthSessionResponse, PricingEntry, PricingResponse, StatusResponse, UsageAnalysisResponse, UsageEventFilterOptionsResponse, UsedModelsResponse, UsageIdentitiesResponse, UsageEventsResponse, UsageOverviewResponse } from './types' + +export class ApiError extends Error { + status: number + + constructor(message: string, status: number) { + super(message) + this.name = 'ApiError' + this.status = status + } +} + +const APP_BASE_PATH_PLACEHOLDER = '__APP_BASE_PATH__' + +declare global { + interface Window { + __APP_BASE_PATH__?: string + } +} + +function normalizeBasePath(basePath: string | undefined): string { + if (!basePath || basePath === '/' || basePath === APP_BASE_PATH_PLACEHOLDER) { + return '' + } + return basePath.endsWith('/') ? basePath.slice(0, -1) : basePath +} + +export function apiPath(path: string): string { + const normalizedPath = path.startsWith('/') ? path : `/${path}` + return `${normalizeBasePath(window.__APP_BASE_PATH__)}/api/v1${normalizedPath}` +} + +async function parseApiError(response: Response, fallback: string): Promise { + let message = fallback + try { + const payload = await response.json() as { error?: string } + if (payload.error) { + message = payload.error + } + } catch { + // ignore invalid error payloads + } + throw new ApiError(message, response.status) +} + +async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise { + return fetch(input, { + credentials: 'include', + ...init, + }) +} + +export async function getSession(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/auth/session'), { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load auth session: ${response.status}`) + } + return response.json() +} + +export async function login(password: string): Promise { + const response = await apiFetch(apiPath('/auth/login'), { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ password }), + }) + if (!response.ok) { + await parseApiError(response, `Failed to login: ${response.status}`) + } +} + +export async function fetchUsageOverview(range: string, start?: string, end?: string, signal?: AbortSignal): Promise { + const params = new URLSearchParams() + params.set('range', range) + if (start) { + params.set('start', start) + } + if (end) { + params.set('end', end) + } + const query = params.toString() + const response = await apiFetch(`${apiPath('/usage/overview')}${query ? `?${query}` : ''}`, { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load usage overview: ${response.status}`) + } + return response.json() +} + +export interface FetchUsageEventsOptions { + page?: number + pageSize?: number + model?: string + source?: string + result?: string +} + +export async function fetchUsageEventFilterOptions(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/usage/events/filters'), { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load usage event filters: ${response.status}`) + } + return response.json() +} + +export async function fetchUsageEvents(range: string, start?: string, end?: string, signal?: AbortSignal, options?: FetchUsageEventsOptions): Promise { + const params = new URLSearchParams() + params.set('range', range) + if (start) { + params.set('start', start) + } + if (end) { + params.set('end', end) + } + if (typeof options?.page === 'number' && Number.isFinite(options.page) && options.page > 0) { + params.set('page', String(Math.floor(options.page))) + } + if (typeof options?.pageSize === 'number' && Number.isFinite(options.pageSize) && options.pageSize > 0) { + params.set('page_size', String(Math.floor(options.pageSize))) + } + const model = options?.model?.trim() + if (model) { + params.set('model', model) + } + const source = options?.source?.trim() + if (source) { + params.set('source', source) + } + const result = options?.result?.trim() + if (result) { + params.set('result', result) + } + const query = params.toString() + const response = await apiFetch(`${apiPath('/usage/events')}${query ? `?${query}` : ''}`, { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load usage events: ${response.status}`) + } + return response.json() +} + +export async function fetchUsageIdentities(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/usage/identities'), { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load usage identities: ${response.status}`) + } + return response.json() +} + +export async function fetchUsageAnalysis(range: string, start?: string, end?: string, signal?: AbortSignal): Promise { + const params = new URLSearchParams() + params.set('range', range) + if (start) { + params.set('start', start) + } + if (end) { + params.set('end', end) + } + const query = params.toString() + const response = await apiFetch(`${apiPath('/usage/analysis')}${query ? `?${query}` : ''}`, { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load usage analysis: ${response.status}`) + } + return response.json() +} + +export async function fetchUsedModels(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/models/used'), { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load used models: ${response.status}`) + } + return response.json() +} + +export async function fetchStatus(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/status'), { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load status: ${response.status}`) + } + return response.json() +} + +export async function triggerSync(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/sync'), { method: 'POST', signal }) + if (!response.ok) { + await parseApiError(response, `Failed to start sync: ${response.status}`) + } + return response.json() +} + +export async function fetchPricing(signal?: AbortSignal): Promise { + const response = await apiFetch(apiPath('/pricing'), { signal }) + if (!response.ok) { + await parseApiError(response, `Failed to load pricing: ${response.status}`) + } + return response.json() +} + +export async function updatePricing(model: string, pricing: Omit): Promise { + const response = await apiFetch(apiPath('/pricing'), { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ model, ...pricing }), + }) + if (!response.ok) { + await parseApiError(response, `Failed to update pricing: ${response.status}`) + } + return response.json() +} + +export async function deletePricing(model: string): Promise { + const params = new URLSearchParams({ model }) + const response = await apiFetch(`${apiPath('/pricing')}?${params.toString()}`, { + method: 'DELETE', + }) + if (!response.ok) { + await parseApiError(response, `Failed to delete pricing: ${response.status}`) + } +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..25245cb8354ccef84689df96f1ddf9877647eaf2 --- /dev/null +++ b/web/src/lib/types.ts @@ -0,0 +1,333 @@ +export interface AuthSessionResponse { + authenticated: boolean +} + +export interface StatusResponse { + running: boolean + sync_running: boolean + timezone: string + last_run_at?: string + last_error?: string + last_warning?: string + last_status?: string +} + +export interface UsageTokenStats { + input_tokens: number + output_tokens: number + reasoning_tokens: number + cached_tokens: number + total_tokens: number +} + +export interface UsageDetail { + timestamp: string + latency_ms: number + source: string + source_raw?: string + source_display?: string + source_type?: string + source_key?: string + auth_index: string + failed: boolean + tokens: UsageTokenStats +} + +export interface UsageModelSnapshot { + total_requests: number + success_count: number + failure_count: number + total_tokens: number + details?: UsageDetail[] +} + +export interface UsageApiSnapshot { + display_name?: string + total_requests: number + success_count: number + failure_count: number + total_tokens: number + models: Record +} + +export interface UsageSnapshot { + total_requests: number + success_count: number + failure_count: number + total_tokens: number + requests_by_day: Record + requests_by_hour: Record + tokens_by_day: Record + tokens_by_hour: Record + apis: Record +} + +export interface UsageOverviewSummary { + request_count: number + token_count: number + window_minutes: number + rpm: number + tpm: number + total_cost: number + cost_available: boolean + cached_tokens: number + reasoning_tokens: number +} + +export interface UsageOverviewSeries { + requests: Record + tokens: Record + rpm: Record + tpm: Record + cost: Record + input_tokens: Record + output_tokens: Record + cached_tokens: Record + reasoning_tokens: Record + models?: Record +} + +export interface UsageOverviewServiceHealthBlock { + start_time: string + end_time: string + success: number + failure: number + rate: number +} + +export interface UsageOverviewServiceHealth { + total_success: number + total_failure: number + success_rate: number + rows?: number + columns?: number + bucket_seconds?: number + window_start?: string + window_end?: string + block_details: UsageOverviewServiceHealthBlock[] +} + +export interface UsageOverviewResponse { + usage: UsageSnapshot + summary?: UsageOverviewSummary + series?: UsageOverviewSeries + hourly_series?: UsageOverviewSeries + daily_series?: UsageOverviewSeries + service_health?: UsageOverviewServiceHealth + timezone?: string + range_start?: string + range_end?: string +} + +export interface UsageEventTokens { + input_tokens: number + output_tokens: number + reasoning_tokens: number + cached_tokens: number + total_tokens: number +} + +export interface UsageEvent { + id?: number + timestamp: string + model: string + source: string + source_raw?: string + source_type?: string + source_key?: string + auth_index?: string + failed: boolean + latency_ms: number + tokens: UsageEventTokens +} + +export interface UsageSourceFilterOption { + value: string + label: string +} + +export interface UsageEventsResponse { + events: UsageEvent[] + models: string[] + sources: UsageSourceFilterOption[] + total_count: number + page: number + page_size: number + total_pages: number +} + +export interface UsageEventFilterOptionsResponse { + models: string[] + sources: UsageSourceFilterOption[] +} + +export type UsageIdentityAuthType = 1 | 2 + +export interface UsageIdentity { + id: number + name: string + auth_type: UsageIdentityAuthType + auth_type_name: string + identity: string + type: string + provider: string + total_requests: number + success_count: number + failure_count: number + input_tokens: number + output_tokens: number + reasoning_tokens: number + cached_tokens: number + total_tokens: number + last_aggregated_usage_event_id: number + first_used_at?: string + last_used_at?: string + stats_updated_at?: string + is_deleted: boolean + created_at: string + updated_at: string + deleted_at?: string +} + +export interface UsageIdentitiesResponse { + identities: UsageIdentity[] +} + +export interface UsageAnalysisModel { + model: string + total_requests: number + success_count: number + failure_count: number + input_tokens: number + output_tokens: number + reasoning_tokens: number + cached_tokens: number + total_tokens: number + total_latency_ms: number + latency_sample_count: number +} + +export interface UsageAnalysisApi { + api_key: string + display_name: string + total_requests: number + success_count: number + failure_count: number + input_tokens: number + output_tokens: number + reasoning_tokens: number + cached_tokens: number + total_tokens: number + models: UsageAnalysisModel[] +} + +export interface UsageAnalysisResponse { + apis: UsageAnalysisApi[] + models: UsageAnalysisModel[] +} + +export interface PricingEntry { + model: string + prompt_price_per_1m: number + completion_price_per_1m: number + cache_price_per_1m: number +} + +export interface UsedModelsResponse { + models: string[] +} + +export interface PricingResponse { + pricing: PricingEntry[] +} + +export type UsageTimeRange = 'all' | '4h' | '8h' | '12h' | '24h' | 'today' | '7d' | 'custom' + +export interface UsageFilterWindow { + startMs?: number + endMs?: number + windowMinutes?: number +} + +export type UsageSeriesDimension = 'all' | 'api' | 'model' + +export interface SummaryCardValue { + key: string + label: string + value: string + hint?: string + accent: string +} + +export interface ApiSummaryItem { + apiName: string + totalRequests: number + successCount: number + failureCount: number + totalTokens: number + modelCount: number + totalCost: number + models: Array<{ + modelName: string + totalRequests: number + successCount: number + failureCount: number + totalTokens: number + totalCost: number + }> +} + +export interface ModelSummaryItem { + apiName: string + modelName: string + totalRequests: number + successCount: number + failureCount: number + totalTokens: number + averageLatencyMs: number + totalLatencyMs: number + successRate: number + totalCost: number +} + +export interface EventRow { + timestamp: string + apiName: string + modelName: string + source: string + authIndex: string + failed: boolean + latencyMs: number + inputTokens: number + outputTokens: number + reasoningTokens: number + cachedTokens: number + totalTokens: number +} + +export interface TrendPoint { + label: string + value: number +} + +export interface TrendSeries { + key: string + label: string + color: string + data: TrendPoint[] +} + +export interface TokenBreakdown { + inputTokens: number + outputTokens: number + reasoningTokens: number + cachedTokens: number +} + +export interface RateStats { + rpm: number + tpm: number + requestCount: number + tokenCount: number + windowMinutes: number +} diff --git a/web/src/lib/usage.ts b/web/src/lib/usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..3b3d34cae3e3b3d82670b9c62fc709f5bb090e44 --- /dev/null +++ b/web/src/lib/usage.ts @@ -0,0 +1,395 @@ +import type { + ApiSummaryItem, + EventRow, + ModelSummaryItem, + PricingEntry, + RateStats, + SummaryCardValue, + TokenBreakdown, + TrendPoint, + TrendSeries, + UsageDetail, + UsageSeriesDimension, + UsageSnapshot, + UsageTimeRange, +} from './types' + +interface UsageEventWithNames extends UsageDetail { + apiName: string + modelName: string +} + +const SERIES_COLORS = ['#2563eb', '#7c3aed', '#10b981', '#f97316', '#dc2626', '#0891b2', '#f59e0b'] + +export function formatNumber(value: number): string { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 4 }).format(value) +} + +function getPriceMap(pricing: PricingEntry[]): Map { + return new Map(pricing.map((entry) => [entry.model, entry])) +} + +function formatLocalDayKey(date: Date): string { + const pad = (value: number) => String(value).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + +function calculateEventCost(event: UsageEventWithNames, priceMap: Map): number { + const pricing = priceMap.get(event.modelName) + if (!pricing) return 0 + return ( + (event.tokens.input_tokens / 1_000_000) * pricing.prompt_price_per_1m + + (event.tokens.output_tokens / 1_000_000) * pricing.completion_price_per_1m + + (event.tokens.cached_tokens / 1_000_000) * pricing.cache_price_per_1m + ) +} + +export function collectUsageEvents(usage: UsageSnapshot): UsageEventWithNames[] { + const events: UsageEventWithNames[] = [] + + for (const [apiName, api] of Object.entries(usage.apis)) { + for (const [modelName, model] of Object.entries(api.models)) { + for (const detail of model.details ?? []) { + events.push({ + ...detail, + apiName, + modelName, + }) + } + } + } + + return events.sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp)) +} + +export function filterUsageSnapshot(usage: UsageSnapshot, range: UsageTimeRange): UsageSnapshot { + if (range === 'all' || range === 'custom') { + return usage + } + + const events = collectUsageEvents(usage) + if (events.length === 0) { + return usage + } + + const latestTimestamp = Math.max(...events.map((event) => Date.parse(event.timestamp)).filter((value) => Number.isFinite(value))) + if (!Number.isFinite(latestTimestamp)) { + return usage + } + + const presetWindowMs: Partial> = { + '4h': 4 * 60 * 60 * 1000, + '8h': 8 * 60 * 60 * 1000, + '12h': 12 * 60 * 60 * 1000, + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + } + const nowMs = Date.now() + const threshold = range === 'today' + ? new Date(nowMs).setHours(0, 0, 0, 0) + : latestTimestamp - (presetWindowMs[range] ?? 0) + const upperThreshold = range === 'today' ? nowMs : Number.POSITIVE_INFINITY + + const filtered: UsageSnapshot = { + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: {}, + } + + for (const event of events) { + const timestampMs = Date.parse(event.timestamp) + if (!Number.isFinite(timestampMs) || timestampMs < threshold || timestampMs > upperThreshold) { + continue + } + + const api = filtered.apis[event.apiName] ?? { + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + models: {}, + } + const model = api.models[event.modelName] ?? { + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + details: [], + } + + model.total_requests += 1 + model.total_tokens += event.tokens.total_tokens + const modelDetails = model.details ?? (model.details = []) + modelDetails.push({ + timestamp: event.timestamp, + latency_ms: event.latency_ms, + source: event.source, + auth_index: event.auth_index, + failed: event.failed, + tokens: event.tokens, + }) + + api.total_requests += 1 + api.total_tokens += event.tokens.total_tokens + filtered.total_requests += 1 + filtered.total_tokens += event.tokens.total_tokens + + if (event.failed) { + model.failure_count += 1 + api.failure_count += 1 + filtered.failure_count += 1 + } else { + model.success_count += 1 + api.success_count += 1 + filtered.success_count += 1 + } + + const time = new Date(event.timestamp) + const dayKey = formatLocalDayKey(time) + const hourKey = `${time.toISOString().slice(0, 13)}:00:00Z` + filtered.requests_by_day[dayKey] = (filtered.requests_by_day[dayKey] ?? 0) + 1 + filtered.requests_by_hour[hourKey] = (filtered.requests_by_hour[hourKey] ?? 0) + 1 + filtered.tokens_by_day[dayKey] = (filtered.tokens_by_day[dayKey] ?? 0) + event.tokens.total_tokens + filtered.tokens_by_hour[hourKey] = (filtered.tokens_by_hour[hourKey] ?? 0) + event.tokens.total_tokens + + api.models[event.modelName] = model + filtered.apis[event.apiName] = api + } + + return filtered +} + +export function buildRateStats(usage: UsageSnapshot): RateStats { + const events = collectUsageEvents(usage) + if (events.length === 0) { + return { rpm: 0, tpm: 0, requestCount: 0, tokenCount: 0, windowMinutes: 30 } + } + + const latestTimestamp = Math.max(...events.map((event) => Date.parse(event.timestamp)).filter((value) => Number.isFinite(value))) + const windowMinutes = 30 + const threshold = latestTimestamp - windowMinutes * 60 * 1000 + const windowEvents = events.filter((event) => Date.parse(event.timestamp) >= threshold) + const requestCount = windowEvents.length + const tokenCount = windowEvents.reduce((sum, event) => sum + event.tokens.total_tokens, 0) + + return { + rpm: requestCount / windowMinutes, + tpm: tokenCount / windowMinutes, + requestCount, + tokenCount, + windowMinutes, + } +} + +export function buildSummaryCards(usage: UsageSnapshot, pricing: PricingEntry[]): SummaryCardValue[] { + const rateStats = buildRateStats(usage) + const tokenBreakdown = buildTokenBreakdown(usage) + const totalCost = buildCostSummary(usage, pricing) + const hasPricing = pricing.length > 0 + + return [ + { + key: 'requests', + label: 'Total requests', + value: formatNumber(usage.total_requests), + hint: `${formatNumber(usage.success_count)} success / ${formatNumber(usage.failure_count)} failed`, + accent: '#2563eb', + }, + { + key: 'tokens', + label: 'Total tokens', + value: formatNumber(usage.total_tokens), + hint: `${formatNumber(tokenBreakdown.cachedTokens)} cached / ${formatNumber(tokenBreakdown.reasoningTokens)} reasoning`, + accent: '#7c3aed', + }, + { + key: 'rpm', + label: 'RPM (30m)', + value: formatNumber(Number(rateStats.rpm.toFixed(2))), + hint: `${formatNumber(rateStats.requestCount)} requests in last ${rateStats.windowMinutes}m`, + accent: '#10b981', + }, + { + key: 'tpm', + label: 'TPM (30m)', + value: formatNumber(Number(rateStats.tpm.toFixed(2))), + hint: `${formatNumber(rateStats.tokenCount)} tokens in last ${rateStats.windowMinutes}m`, + accent: '#f97316', + }, + { + key: 'cost', + label: 'Total cost', + value: hasPricing ? `$${formatNumber(totalCost)}` : '--', + hint: hasPricing ? 'Calculated from saved backend pricing' : 'Save model pricing to unlock cost analytics', + accent: '#f59e0b', + }, + ] +} + +export function buildCostSummary(usage: UsageSnapshot, pricing: PricingEntry[]): number { + const priceMap = getPriceMap(pricing) + return collectUsageEvents(usage).reduce((sum, event) => sum + calculateEventCost(event, priceMap), 0) +} + +export function buildApiSummary(usage: UsageSnapshot, pricing: PricingEntry[]): ApiSummaryItem[] { + const priceMap = getPriceMap(pricing) + + return Object.entries(usage.apis) + .map(([apiName, api]) => { + const models = Object.entries(api.models) + .map(([modelName, model]) => { + const detailEvents = (model.details ?? []).map((detail) => ({ ...detail, apiName, modelName })) + const totalCost = detailEvents.reduce((sum, event) => sum + calculateEventCost(event, priceMap), 0) + return { + modelName, + totalRequests: model.total_requests, + successCount: model.success_count, + failureCount: model.failure_count, + totalTokens: model.total_tokens, + totalCost, + } + }) + .sort((a, b) => b.totalTokens - a.totalTokens) + + return { + apiName, + totalRequests: api.total_requests, + successCount: api.success_count, + failureCount: api.failure_count, + totalTokens: api.total_tokens, + modelCount: models.length, + totalCost: models.reduce((sum, model) => sum + model.totalCost, 0), + models, + } + }) + .sort((a, b) => b.totalTokens - a.totalTokens) +} + +export function buildModelSummary(usage: UsageSnapshot, pricing: PricingEntry[]): ModelSummaryItem[] { + const priceMap = getPriceMap(pricing) + + return Object.entries(usage.apis) + .flatMap(([apiName, api]) => + Object.entries(api.models).map(([modelName, model]) => { + const details = model.details ?? [] + const latencyValues = details.map((detail) => detail.latency_ms).filter((value) => Number.isFinite(value)) + const totalLatencyMs = latencyValues.reduce((sum, value) => sum + value, 0) + const totalCost = details + .map((detail) => ({ ...detail, apiName, modelName })) + .reduce((sum, event) => sum + calculateEventCost(event, priceMap), 0) + + return { + apiName, + modelName, + totalRequests: model.total_requests, + successCount: model.success_count, + failureCount: model.failure_count, + totalTokens: model.total_tokens, + averageLatencyMs: latencyValues.length > 0 ? Math.round(totalLatencyMs / latencyValues.length) : 0, + totalLatencyMs, + successRate: model.total_requests > 0 ? (model.success_count / model.total_requests) * 100 : 100, + totalCost, + } + }), + ) + .sort((a, b) => b.totalTokens - a.totalTokens) +} + +export function buildRecentEvents(usage: UsageSnapshot, limit = 12): EventRow[] { + return collectUsageEvents(usage) + .map((event) => ({ + timestamp: event.timestamp, + apiName: event.apiName, + modelName: event.modelName, + source: event.source || '-', + authIndex: event.auth_index || '-', + failed: event.failed, + latencyMs: event.latency_ms, + inputTokens: event.tokens.input_tokens, + outputTokens: event.tokens.output_tokens, + reasoningTokens: event.tokens.reasoning_tokens, + cachedTokens: event.tokens.cached_tokens, + totalTokens: event.tokens.total_tokens, + })) + .slice(0, limit) +} + +export function buildTrendPoints(series: Record): TrendPoint[] { + return Object.entries(series) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([label, value]) => ({ label, value })) +} + +export function buildSeriesTrends(usage: UsageSnapshot, dimension: UsageSeriesDimension, metric: 'requests' | 'tokens'): TrendSeries[] { + if (dimension === 'all') { + return [ + { + key: metric, + label: metric === 'requests' ? 'All requests' : 'All tokens', + color: SERIES_COLORS[0], + data: buildTrendPoints(metric === 'requests' ? usage.requests_by_hour : usage.tokens_by_hour), + }, + ] + } + + const grouped = new Map>() + for (const event of collectUsageEvents(usage)) { + const key = dimension === 'api' ? event.apiName : event.modelName + const hourKey = `${new Date(event.timestamp).toISOString().slice(0, 13)}:00:00Z` + const bucket = grouped.get(key) ?? {} + bucket[hourKey] = (bucket[hourKey] ?? 0) + (metric === 'requests' ? 1 : event.tokens.total_tokens) + grouped.set(key, bucket) + } + + return [...grouped.entries()] + .map(([key, values], index) => ({ + key, + label: key, + color: SERIES_COLORS[index % SERIES_COLORS.length], + data: buildTrendPoints(values), + })) + .sort((left, right) => { + const leftTotal = left.data.reduce((sum, point) => sum + point.value, 0) + const rightTotal = right.data.reduce((sum, point) => sum + point.value, 0) + return rightTotal - leftTotal + }) + .slice(0, 5) +} + +export function buildCostTrendPoints(usage: UsageSnapshot, pricing: PricingEntry[]): TrendPoint[] { + const priceMap = getPriceMap(pricing) + const buckets: Record = {} + for (const event of collectUsageEvents(usage)) { + const hourKey = `${new Date(event.timestamp).toISOString().slice(0, 13)}:00:00Z` + buckets[hourKey] = (buckets[hourKey] ?? 0) + calculateEventCost(event, priceMap) + } + return buildTrendPoints(buckets) +} + +export function buildTokenBreakdown(usage: UsageSnapshot): TokenBreakdown { + return Object.values(usage.apis).reduce( + (totals, api) => { + for (const model of Object.values(api.models)) { + for (const detail of model.details ?? []) { + totals.inputTokens += detail.tokens.input_tokens + totals.outputTokens += detail.tokens.output_tokens + totals.reasoningTokens += detail.tokens.reasoning_tokens + totals.cachedTokens += detail.tokens.cached_tokens + } + } + return totals + }, + { + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cachedTokens: 0, + }, + ) +} diff --git a/web/src/main.tsx b/web/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..974587390c18e8da13ed6275e46fbf81a6a1ed56 --- /dev/null +++ b/web/src/main.tsx @@ -0,0 +1,37 @@ +import { StrictMode, useEffect } from 'react'; +import { createRoot } from 'react-dom/client'; +import { I18nextProvider } from 'react-i18next'; +import App from './App'; +import i18n from './i18n'; +import faviconUrl from './assets/cli-proxy-api-favicon.png'; +import './styles/reset.scss'; +import './styles/variables.scss'; +import './styles/themes.scss'; +import './styles/layout.scss'; +import './styles/components.scss'; +import './styles/global.scss'; +import { useThemeStore } from './stores'; + +const faviconEl = document.querySelector('link[rel="icon"]') ?? document.createElement('link'); +faviconEl.rel = 'icon'; +faviconEl.type = 'image/png'; +faviconEl.href = faviconUrl; +if (!faviconEl.parentNode) { + document.head.appendChild(faviconEl); +} + +function Root() { + const initializeTheme = useThemeStore((state) => state.initializeTheme); + + useEffect(() => initializeTheme(), [initializeTheme]); + + return ; +} + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/web/src/pages/LoginPage.module.scss b/web/src/pages/LoginPage.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..cc9276187ddc1989bed9ed693eeddec23075621e --- /dev/null +++ b/web/src/pages/LoginPage.module.scss @@ -0,0 +1,83 @@ +@use '../styles/variables' as *; +@use '../styles/mixins' as *; + +.pageShell { + min-height: 100vh; + display: flex; + align-items: center; + justify-content: center; + padding: 32px 20px; + background: + radial-gradient(900px 480px at 12% 0%, rgba($primary-color, 0.14), transparent 58%), + radial-gradient(760px 420px at 100% 100%, rgba($primary-color, 0.1), transparent 60%), + var(--bg-secondary); +} + +.frame { + width: min(980px, 100%); + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(320px, 420px); + gap: 28px; + align-items: center; + + @include mobile { + grid-template-columns: 1fr; + gap: 18px; + } +} + +.brandBlock { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.eyebrow { + display: inline-flex; + align-items: center; + width: fit-content; + min-height: 40px; + padding: 0 18px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 80%, transparent); + color: var(--text-primary); + font-size: 15px; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.title { + margin: 0; + font-size: clamp(36px, 5vw, 58px); + line-height: 0.96; + letter-spacing: -0.04em; + color: var(--text-primary); +} + +.subtitle { + margin: 0; + max-width: 480px; + color: var(--text-secondary); + font-size: 15px; + line-height: 1.7; +} + +.loginCard { + padding: 28px; + border-radius: 24px; + background: color-mix(in srgb, var(--bg-primary) 92%, transparent); + box-shadow: var(--floating-shadow); + + @include mobile { + padding: 20px; + } +} + +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5552f3a038e5ee9f48029ce36e21f41196038a0e --- /dev/null +++ b/web/src/pages/LoginPage.tsx @@ -0,0 +1,52 @@ +import { useState, type FormEvent } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { Card } from '@/components/ui/Card'; +import { Input } from '@/components/ui/Input'; +import styles from './LoginPage.module.scss'; + +interface LoginPageProps { + loading?: boolean; + error?: string; + onSubmit: (password: string) => Promise; +} + +export function LoginPage({ loading = false, error = '', onSubmit }: LoginPageProps) { + const { t } = useTranslation(); + const [password, setPassword] = useState(''); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + await onSubmit(password); + }; + + return ( +
+
+
+ CPA Usage Keeper +

{t('auth.login_title')}

+

{t('auth.login_subtitle')}

+
+ + +
void handleSubmit(event)}> + setPassword(event.target.value)} + error={error || undefined} + disabled={loading} + /> + +
+
+
+
+ ); +} diff --git a/web/src/pages/UsagePage.filtering.test.ts b/web/src/pages/UsagePage.filtering.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f709435e65eecf98fb30f8c5a822c1220b2ad026 --- /dev/null +++ b/web/src/pages/UsagePage.filtering.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { filterUsageByWindow } from '@/utils/usage'; +import type { UsageSnapshot } from '@/lib/types'; + +const usage: UsageSnapshot = { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: { + 'provider-a': { + display_name: 'Provider A', + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + models: { + 'claude-sonnet': { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + details: [ + { + timestamp: '2026-04-23T00:00:00.000Z', + latency_ms: 100, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 50, + output_tokens: 50, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 100, + }, + }, + { + timestamp: '2026-04-23T02:00:00.000Z', + latency_ms: 120, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 100, + output_tokens: 100, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 200, + }, + }, + ], + }, + }, + }, + }, +}; + +describe('UsagePage filtering wiring', () => { + it('should feed stat cards with range-filtered usage data', () => { + const filterWindow = { + startMs: Date.parse('2026-04-23T01:00:00.000Z'), + endMs: Date.parse('2026-04-23T03:00:00.000Z'), + windowMinutes: 120, + }; + + const filteredUsage = filterUsageByWindow(usage, filterWindow); + + expect(filteredUsage.total_requests).toBe(1); + expect(filteredUsage.total_tokens).toBe(200); + expect(filteredUsage).not.toBe(usage); + }); +}); diff --git a/web/src/pages/UsagePage.logic.test.ts b/web/src/pages/UsagePage.logic.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c2b744f53fa85318855aafb3a4cc5a5cdefa9dd --- /dev/null +++ b/web/src/pages/UsagePage.logic.test.ts @@ -0,0 +1,371 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildCustomDateRangeQuery, getOverviewChartEndMs, getOverviewDisplayLoading, getOverviewHourWindowHours, getTimeRangeOptions, getUsageTabOptions, refreshPageData, sanitizeRequestEventFilters, scheduleOverviewAutoRefresh, syncCpaData } from './UsagePage'; +import { ApiError } from '@/lib/api'; +import { filterUsageByWindow, type UsageFilterWindow } from '@/utils/usage'; +import type { StatusResponse, UsageSnapshot } from '@/lib/types'; + +const usage: UsageSnapshot = { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: { + 'provider-a': { + display_name: 'Provider A', + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + models: { + 'claude-sonnet': { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + details: [ + { + timestamp: '2026-04-23T00:00:00.000Z', + latency_ms: 100, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 50, + output_tokens: 50, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 100, + }, + }, + { + timestamp: '2026-04-23T02:00:00.000Z', + latency_ms: 120, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 100, + output_tokens: 100, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 200, + }, + }, + ], + }, + }, + }, + }, +}; + +const deriveFilteredUsageLikePage = (input: UsageSnapshot, filterWindow: UsageFilterWindow) => + filterUsageByWindow(input, filterWindow); + +const createAutoRefreshTestDocument = (visibilityState: DocumentVisibilityState = 'visible') => { + const target = new EventTarget(); + return { + get visibilityState() { + return visibilityState; + }, + setVisibilityState(nextVisibilityState: DocumentVisibilityState) { + visibilityState = nextVisibilityState; + }, + addEventListener: target.addEventListener.bind(target), + removeEventListener: target.removeEventListener.bind(target), + dispatchEvent: target.dispatchEvent.bind(target), + }; +}; + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +describe('UsagePage Overview loading display', () => { + it('keeps existing Overview data visible during background refresh', () => { + expect(getOverviewDisplayLoading({ loading: true, hasUsage: true })).toBe(false); + }); + + it('shows loading before Overview data has loaded', () => { + expect(getOverviewDisplayLoading({ loading: true, hasUsage: false })).toBe(true); + }); +}); + +describe('UsagePage Overview auto-refresh', () => { + it('refreshes the Overview tab every 10 seconds', () => { + vi.useFakeTimers(); + const testDocument = createAutoRefreshTestDocument(); + const refreshOverview = vi.fn(); + + const cleanup = scheduleOverviewAutoRefresh({ enabled: true, refreshOverview, documentRef: testDocument }); + + vi.advanceTimersByTime(9_999); + expect(refreshOverview).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(refreshOverview).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('does not schedule refreshes outside the Overview tab', () => { + vi.useFakeTimers(); + const refreshOverview = vi.fn(); + + const cleanup = scheduleOverviewAutoRefresh({ enabled: false, refreshOverview }); + + vi.advanceTimersByTime(10_000); + expect(refreshOverview).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('pauses while the browser tab is hidden', () => { + vi.useFakeTimers(); + const testDocument = createAutoRefreshTestDocument('hidden'); + const refreshOverview = vi.fn(); + + const cleanup = scheduleOverviewAutoRefresh({ enabled: true, refreshOverview, documentRef: testDocument }); + + vi.advanceTimersByTime(10_000); + expect(refreshOverview).not.toHaveBeenCalled(); + + cleanup(); + }); + + it('refreshes once when the browser tab becomes visible again', () => { + vi.useFakeTimers(); + const testDocument = createAutoRefreshTestDocument('hidden'); + const refreshOverview = vi.fn(); + + const cleanup = scheduleOverviewAutoRefresh({ enabled: true, refreshOverview, documentRef: testDocument }); + testDocument.setVisibilityState('visible'); + testDocument.dispatchEvent(new Event('visibilitychange')); + + expect(refreshOverview).toHaveBeenCalledTimes(1); + + cleanup(); + }); + + it('restarts the interval cadence after refreshing on visibility restore', () => { + vi.useFakeTimers(); + const testDocument = createAutoRefreshTestDocument('hidden'); + const refreshOverview = vi.fn(); + + const cleanup = scheduleOverviewAutoRefresh({ enabled: true, refreshOverview, documentRef: testDocument }); + vi.advanceTimersByTime(9_999); + testDocument.setVisibilityState('visible'); + testDocument.dispatchEvent(new Event('visibilitychange')); + + expect(refreshOverview).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(1); + expect(refreshOverview).toHaveBeenCalledTimes(1); + + vi.advanceTimersByTime(9_999); + expect(refreshOverview).toHaveBeenCalledTimes(2); + + cleanup(); + }); + + it('cleans up the interval and visibility listener', () => { + vi.useFakeTimers(); + const testDocument = createAutoRefreshTestDocument(); + const refreshOverview = vi.fn(); + const cleanup = scheduleOverviewAutoRefresh({ enabled: true, refreshOverview, documentRef: testDocument }); + + cleanup(); + vi.advanceTimersByTime(10_000); + testDocument.dispatchEvent(new Event('visibilitychange')); + + expect(refreshOverview).not.toHaveBeenCalled(); + }); +}); + +describe('UsagePage range filtering bug', () => { + it('changes the usage payload that summary metrics read from', () => { + const filterWindow: UsageFilterWindow = { + startMs: Date.parse('2026-04-23T01:00:00.000Z'), + endMs: Date.parse('2026-04-23T03:00:00.000Z'), + windowMinutes: 120, + }; + + const expected = filterUsageByWindow(usage, filterWindow); + const actual = deriveFilteredUsageLikePage(usage, filterWindow); + + expect(expected.total_requests).toBe(1); + expect(actual.total_requests).toBe(expected.total_requests); + }); +}); + +describe('UsagePage request event filters', () => { + it('clears model and source filters that are no longer available', () => { + const next = sanitizeRequestEventFilters( + { + model: 'claude-opus', + source: 'source-b', + result: 'failed', + }, + { + models: ['claude-sonnet'], + sources: [{ value: 'source-a', label: 'Provider A' }], + }, + ); + + expect(next).toEqual({ + model: '__all__', + source: '__all__', + result: 'failed', + }); + }); +}); + +describe('UsagePage time range options', () => { + it('places Today after 24h position and removes 24h from selectable ranges', () => { + const options = getTimeRangeOptions((key) => `translated:${key}`); + + expect(options.map((option) => option.value)).toEqual(['4h', '8h', '12h', 'today', '7d', 'custom']); + expect(options.map((option) => option.label)).toContain('translated:usage_stats.range_today'); + }); +}); + +describe('UsagePage custom date query', () => { + it('keeps custom date query bounds as project-local dates for the backend', () => { + expect(buildCustomDateRangeQuery({ start: '2026-04-20', end: '2026-04-21' })).toEqual({ + valid: true, + start: '2026-04-20', + end: '2026-04-21', + }); + }); + + it('rejects rollover calendar dates before sending them to the backend', () => { + expect(buildCustomDateRangeQuery({ start: '2026-02-31', end: '2026-03-31' })).toEqual({ + valid: false, + start: undefined, + end: undefined, + }); + }); +}); + +describe('UsagePage Overview chart window', () => { + it('uses the backend-resolved range end for Today hourly chart buckets', () => { + const filterWindow: UsageFilterWindow = { + startMs: Date.parse('2026-04-23T00:00:00.000Z'), + endMs: Date.parse('2026-04-23T12:34:56.000Z'), + windowMinutes: (12 * 60) + 34 + (56 / 60), + }; + + expect(getOverviewHourWindowHours({ timeRange: 'today', filterWindow })).toBe(24); + expect(getOverviewChartEndMs({ + timeRange: 'today', + filterWindow, + fallbackEndMs: filterWindow.endMs ?? 0, + resolvedRangeEndMs: Date.parse('2026-04-23T15:59:59.999Z'), + })).toBe(Date.parse('2026-04-23T15:59:59.999Z')); + }); +}); + +describe('UsagePage tab labels', () => { + it('resolves tab labels through translation keys', () => { + const labels = getUsageTabOptions((key) => `translated:${key}`).map((option) => option.label); + + expect(labels).toEqual([ + 'translated:usage_stats.tab_overview', + 'translated:usage_stats.tab_analysis', + 'translated:usage_stats.tab_events', + 'translated:usage_stats.tab_credentials', + 'translated:usage_stats.tab_pricing', + ]); + }); +}); + +describe('UsagePage refresh action', () => { + it('reloads page data without triggering backend sync', async () => { + let refreshCalls = 0; + let syncCalls = 0; + + await refreshPageData({ + refreshActiveTab: async () => { + refreshCalls += 1; + }, + triggerBackendSync: async () => { + syncCalls += 1; + }, + }); + + expect(refreshCalls).toBe(1); + expect(syncCalls).toBe(0); + }); +}); + +describe('UsagePage sync action', () => { + it('triggers backend sync, refreshes active tab data, and reloads status', async () => { + const calls: string[] = []; + let receivedStatus: StatusResponse | null = null; + const syncStatus: StatusResponse = { running: true, sync_running: false, last_status: 'completed' }; + const refreshedStatus: StatusResponse = { + running: true, + sync_running: false, + last_status: 'completed', + last_run_at: '2026-04-26T13:00:00.000Z', + }; + + await syncCpaData({ + triggerBackendSync: async () => { + calls.push('sync'); + return syncStatus; + }, + refreshActiveTab: async () => { + calls.push('refresh'); + }, + refreshStatus: async () => { + calls.push('status'); + return refreshedStatus; + }, + onStatus: (status) => { + calls.push('set-status'); + receivedStatus = status; + }, + }); + + expect(calls).toEqual(['sync', 'refresh', 'status', 'set-status']); + expect(receivedStatus).toBe(refreshedStatus); + }); + + it('reloads status and preserves the sync error when backend sync fails', async () => { + const calls: string[] = []; + let receivedStatus: StatusResponse | null = null; + const refreshedStatus: StatusResponse = { + running: true, + sync_running: false, + last_status: 'completed', + last_run_at: '2026-04-26T13:00:00.000Z', + }; + const syncError = new ApiError('metadata sync failed', 500); + + await expect(syncCpaData({ + triggerBackendSync: async () => { + calls.push('sync'); + throw syncError; + }, + refreshActiveTab: async () => { + calls.push('refresh'); + }, + refreshStatus: async () => { + calls.push('status'); + return refreshedStatus; + }, + onStatus: (status) => { + calls.push('set-status'); + receivedStatus = status; + }, + })).rejects.toBe(syncError); + + expect(calls).toEqual(['sync', 'status', 'set-status']); + expect(receivedStatus).toBe(refreshedStatus); + }); +}); diff --git a/web/src/pages/UsagePage.module.scss b/web/src/pages/UsagePage.module.scss new file mode 100644 index 0000000000000000000000000000000000000000..a142c75c77e7dc79d793af6413474cef30f0380d --- /dev/null +++ b/web/src/pages/UsagePage.module.scss @@ -0,0 +1,1994 @@ +@use '../styles/variables' as *; +@use '../styles/mixins' as *; + +.pageShell { + min-height: 100vh; + background: + radial-gradient(1200px 520px at 12% -8%, rgba($primary-color, 0.12), transparent 58%), + radial-gradient(880px 480px at 100% 0%, rgba(0, 0, 0, 0.06), transparent 52%), + var(--bg-secondary); + padding: 28px 20px 48px; + + @include mobile { + padding: 18px 12px 32px; + } +} + +.pageFrame { + width: min(1245px, 100%); + margin: 0 auto; + display: flex; + flex-direction: column; + gap: 18px; +} + +.topBar { + position: sticky; + top: 16px; + z-index: 10; + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 18px; + padding: 18px 20px; + border: 1px solid var(--border-color); + border-radius: 24px; + background: color-mix(in srgb, var(--bg-primary) 84%, transparent); + backdrop-filter: blur(18px); + -webkit-backdrop-filter: blur(18px); + box-shadow: 0 18px 48px rgba(0, 0, 0, 0.08); + + @include mobile { + position: static; + padding: 16px; + border-radius: 20px; + flex-direction: column; + align-items: stretch; + } +} + +.brandBlock { + display: flex; + align-items: stretch; + min-width: 0; +} + +.eyebrow { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 42px; + padding: 0 20px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 78%, transparent); + color: var(--text-primary); + font-size: 17px; + font-weight: 800; + letter-spacing: 0.1em; + text-transform: uppercase; + white-space: nowrap; +} + +.topBarActions { + display: flex; + align-items: stretch; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; + + @include mobile { + justify-content: space-between; + } +} + +.languageSwitcher { + display: inline-flex; + align-items: stretch; + gap: 4px; + min-height: 42px; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 78%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.languagePill { + border: 0; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; + + &:hover { + color: var(--text-primary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } +} + +.languagePillActive { + background: var(--bg-primary); + color: var(--text-primary); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); +} + +.themeSwitcher { + display: inline-flex; + align-items: stretch; + gap: 4px; + min-height: 42px; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 78%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.themePill { + border: 0; + background: transparent; + color: var(--text-secondary); + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease; + + &:hover { + color: var(--text-primary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } +} + +.themePillActive { + background: var(--bg-primary); + color: var(--text-primary); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); +} + +.syncSwitcher { + display: inline-flex; + align-items: stretch; + min-height: 42px; + padding: 4px; + border-radius: 999px; + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-secondary) 78%, transparent); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.syncPill { + border: 0; + background: var(--bg-primary); + color: var(--text-primary); + font: inherit; + font-size: 12px; + font-weight: 700; + padding: 8px 14px; + border-radius: 999px; + cursor: pointer; + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); + transition: background-color 0.15s ease, color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; + + &:hover:not(:disabled) { + color: var(--text-primary); + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14); + } + + &:disabled { + cursor: wait; + opacity: 0.7; + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } +} + +.refreshButton { + gap: 8px; + min-height: 42px; + min-width: 108px; + justify-content: center; +} + +.contentColumn { + width: 100%; +} + +.container { + width: 100%; + min-height: 100%; + display: flex; + flex-direction: column; + gap: 20px; + position: relative; + padding: 8px 0 0; +} + +.toolbarMetaRow { + display: flex; + justify-content: flex-end; + align-items: center; + padding: 0 2px; + + @include mobile { + justify-content: flex-start; + } +} + +.toolbarRow { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px 16px; + flex-wrap: wrap; + padding: 0 2px; + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + +.tabBar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + flex: 1 1 0; + min-width: 0; +} + +.tabPill { + border: 1px solid var(--border-color); + background: color-mix(in srgb, var(--bg-primary) 82%, transparent); + color: var(--text-secondary); + font: inherit; + font-size: 12px; + font-weight: 600; + padding: 8px 12px; + border-radius: 999px; + cursor: pointer; + transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease; + + &:hover { + color: var(--text-primary); + background: var(--bg-secondary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + } +} + +.tabPillActive { + background: var(--bg-primary); + color: var(--text-primary); + border-color: rgba($primary-color, 0.4); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); +} + +.toolbarActionsRight { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; + flex: 0 1 auto; + margin-left: auto; + + @include mobile { + width: 100%; + align-items: flex-start; + justify-content: flex-start; + margin-left: 0; + } +} + +.lastRefreshed { + font-size: 11px; + color: var(--text-tertiary); + white-space: nowrap; +} + +.timeRangeGroup { + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: nowrap; + min-width: 0; + max-width: 620px; + padding: 8px 10px; + border: 1px solid var(--border-color); + border-radius: 16px; + background: color-mix(in srgb, var(--bg-primary) 82%, transparent); + overflow: hidden; + opacity: 1; + transform: translateX(0); + transition: max-width 340ms cubic-bezier(0.22, 1, 0.36, 1), padding 340ms cubic-bezier(0.22, 1, 0.36, 1), border-width 340ms cubic-bezier(0.22, 1, 0.36, 1), opacity 260ms ease, transform 340ms cubic-bezier(0.22, 1, 0.36, 1); + + @include mobile { + width: 100%; + align-items: center; + flex-wrap: wrap; + } +} + +.timeRangeGroupCollapsed { + max-width: 0; + padding-left: 0; + padding-right: 0; + border-width: 0; + opacity: 0; + pointer-events: none; + transform: translateX(8px); +} + +.customRangeInline { + display: inline-flex; + align-items: center; + min-height: 34px; + min-width: 0; + max-width: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; + transform: translateX(-6px); + transition: max-width 340ms cubic-bezier(0.22, 1, 0.36, 1), opacity 260ms ease, transform 340ms cubic-bezier(0.22, 1, 0.36, 1), margin-left 340ms cubic-bezier(0.22, 1, 0.36, 1); +} + +.customRangeInlineOpen { + max-width: 360px; + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.timeRangeLabel { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; +} + +.timeRangeSelectControl { + min-width: 164px; +} + +.customRangeFields { + display: inline-flex; + align-items: flex-end; + gap: 6px; + white-space: nowrap; +} + +.customRangeField { + display: inline-flex; + flex-direction: column; + gap: 2px; +} + +.customRangeFieldLabel { + font-size: 9px; + line-height: 1; + color: var(--text-secondary); + font-weight: 600; +} + +.customRangeInput { + width: 150px; + height: 34px; + box-sizing: border-box; + margin: 0; + padding: 6px 10px; + font-size: 12px; +} + +.customRangeSeparator { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 12px; + height: 34px; + color: var(--text-tertiary); + font-weight: 600; + font-size: 12px; +} + +.customRangeHint, +.customRangeError { + font-size: 12px; + line-height: 1.4; +} + +.customRangeHint { + color: var(--text-secondary); +} + +.customRangeError { + color: var(--error-color); +} + +@include mobile { + .customRangeInline { + width: 100%; + max-width: 0; + transform: translateY(-4px); + } + + .customRangeInlineOpen { + max-width: 100%; + transform: translateY(0); + } + + .customRangeFields { + width: 100%; + gap: 6px; + flex-wrap: wrap; + } + + .customRangeInput { + width: 132px; + } +} + +@media (prefers-reduced-motion: reduce) { + .timeRangeGroup, + .customRangeInline { + transition: none; + } +} + +.pageTitle { + font-size: clamp(30px, 4vw, 42px); + font-weight: 800; + color: var(--text-primary); + margin: 0; + line-height: 1.02; + letter-spacing: -0.03em; +} + +.sectionTitleBlock { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; +} + +.sectionEyebrow { + display: inline-flex; + align-items: center; + width: fit-content; + padding: 4px 9px; + border-radius: $radius-full; + border: 1px solid rgba($primary-color, 0.2); + background: rgba($primary-color, 0.08); + color: var(--text-tertiary); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.sectionTitle { + margin: 0; + color: var(--text-primary); + font-size: 17px; + font-weight: 700; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.sectionSubtitle { + margin: 0; + color: var(--text-secondary); + font-size: 12px; + line-height: 1.55; + max-width: 620px; +} + +.cardHeaderCompact { + gap: 14px; + align-items: flex-start; +} + +.errorBox { + padding: 10px; + background-color: rgba($error-color, 0.1); + border: 1px solid var(--error-color); + border-radius: $radius-sm; + color: var(--error-color); + font-size: 12px; +} + +.hint { + color: var(--text-secondary); + font-size: 12px; + text-align: center; + padding: 16px; +} + +.loadingOverlay { + position: absolute; + inset: 0; + z-index: 20; + display: flex; + align-items: center; + justify-content: center; + --glass-blur: 6px; + background: var(--glass-bg-secondary); + backdrop-filter: var(--glass-backdrop-filter); + -webkit-backdrop-filter: var(--glass-backdrop-filter); +} + +.loadingOverlayContent { + display: inline-flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + border-radius: $radius-lg; + border: 1px solid var(--border-color); + background: var(--bg-primary); + box-shadow: var(--shadow-lg); +} + +.loadingOverlaySpinner { + border-color: rgba($primary-color, 0.25); + border-top-color: var(--primary-color); + box-shadow: 0 0 10px rgba($primary-color, 0.25); +} + +.loadingOverlayText { + font-size: 13px; + font-weight: 600; + color: var(--text-secondary); +} + +// Stats Grid +.statsGrid { + display: grid; + gap: 14px; + grid-template-columns: repeat(12, minmax(0, 1fr)); + + @include mobile { + grid-template-columns: 1fr; + } +} + +.statCard { + --accent: var(--primary-color); + --accent-soft: rgba($primary-color, 0.18); + --accent-border: rgba($primary-color, 0.35); + + grid-column: span 4; + position: relative; + padding: 18px; + background: + radial-gradient(120% 140% at 12% 0%, var(--accent-soft) 0%, rgba(0, 0, 0, 0) 62%), + linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0)), var(--bg-primary); + border-radius: $radius-lg; + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 10px; + min-height: 176px; + box-shadow: var(--shadow-lg); + transition: + transform $transition-fast, + box-shadow $transition-fast, + border-color $transition-fast; + overflow: hidden; + + &::before { + content: ''; + position: absolute; + top: 0; + left: 0; + height: 3px; + width: 100%; + background: linear-gradient(90deg, var(--accent), rgba(0, 0, 0, 0)); + opacity: 0.95; + } + + &:hover { + transform: translateY(-2px); + border-color: var(--accent-border); + box-shadow: 0 16px 40px rgba(0, 0, 0, 0.22); + } + + @include tablet { + grid-column: span 6; + } + + @include mobile { + grid-column: auto; + min-height: 168px; + } +} + +.statCard:nth-child(-n + 2) { + grid-column: span 6; + + .statValue { + font-size: 32px; + } +} + +@include mobile { + .statCard:nth-child(-n + 2) { + grid-column: auto; + + .statValue { + font-size: 28px; + } + } +} + +.statCardHeader { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.statLabelGroup { + display: flex; + flex-direction: column; + gap: 1px; +} + +.statIconBadge { + width: 34px; + height: 34px; + border-radius: $radius-md; + display: grid; + place-items: center; + color: #fff; + font-size: 13px; + background: var(--accent); + border: 1px solid rgba(255, 255, 255, 0.08); + box-shadow: 0 10px 22px rgba(0, 0, 0, 0.25); + flex-shrink: 0; + + svg { + display: block; + } +} + +.statHeader { + display: flex; + align-items: center; + gap: $spacing-sm; +} + +.statIcon { + font-size: 18px; +} + +.statLabel { + font-size: 12px; + color: var(--text-tertiary); + font-weight: 700; + letter-spacing: 0.02em; +} + +.statValue { + font-size: 28px; + font-weight: 800; + color: var(--text-primary); + line-height: 1.2; + font-variant-numeric: tabular-nums; +} + +.statValueRow { + display: flex; + gap: $spacing-lg; +} + +.statValueSmall { + display: flex; + flex-direction: column; + gap: 2px; +} + +.statValueLabel { + font-size: 12px; + color: var(--text-secondary); +} + +.statValueNum { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.statMeta { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 12px; +} + +.statSuccess { + color: var(--success-color); +} + +.statFailure { + color: var(--danger-color); +} + +.statNeutral { + color: var(--text-secondary); +} + +.statMetaRow { + display: flex; + flex-wrap: wrap; + gap: 8px 10px; + font-size: 12px; + color: var(--text-secondary); +} + +.statMetaItem { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.statMetaDot { + width: 7px; + height: 7px; + border-radius: 50%; + background-color: var(--text-secondary); +} + +.statSubtle { + color: var(--text-tertiary); +} + +.statTrend { + margin-top: auto; + background: var(--bg-tertiary); + border-radius: $radius-md; + padding: 8px; + height: 58px; + border: 1px solid var(--border-color); +} + +.statTrendPlaceholder { + width: 100%; + height: 100%; + background: var(--bg-secondary); + border-radius: $radius-md; +} + +.sparkline { + width: 100%; + height: 100% !important; +} + +.statHint { + color: var(--text-tertiary); + font-style: italic; +} + +// API List (80%比例) +.apiSortBar { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 10px; +} + +.apiSortBtn { + padding: 4px 10px; + border-radius: $radius-full; + border: 1px solid var(--border-color); + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 11px; + font-weight: 500; + cursor: pointer; + transition: + background-color 0.15s ease, + border-color 0.15s ease, + color 0.15s ease; + + &:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 1px; + } +} + +.apiSortBtnActive { + border-color: rgba($primary-color, 0.5); + background: rgba($primary-color, 0.1); + color: var(--text-primary); + font-weight: 600; +} + +.apiList { + display: flex; + flex-direction: column; + gap: 6px; +} + +.apiItem { + background-color: var(--bg-secondary); + border-radius: $radius-sm; + border: 1px solid var(--border-color); + overflow: hidden; +} + +.apiHeader { + width: 100%; + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + border: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--bg-tertiary); + } + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: -2px; + } +} + +.apiInfo { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + flex: 1; +} + +.apiEndpoint { + font-weight: 600; + color: var(--text-primary); + font-size: 13px; + word-break: break-all; +} + +.apiStats { + display: flex; + flex-wrap: wrap; + gap: 3px; +} + +.apiBadge { + font-size: 11px; + color: var(--text-secondary); + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + padding: 2px 8px; + border-radius: $radius-full; +} + +.expandIcon { + color: var(--text-secondary); + font-size: 12px; + margin-left: 6px; +} + +.apiModels { + padding: 10px; + padding-top: 0; + display: flex; + flex-direction: column; + gap: 3px; + border-top: 1px solid var(--border-color); + margin-top: 0; + padding-top: 10px; +} + +.modelRow { + display: grid; + grid-template-columns: 1fr auto auto; + gap: 10px; + padding: 8px 10px; + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: $radius-md; + font-size: 12px; + + @include mobile { + grid-template-columns: 1fr; + gap: 3px; + } +} + +.modelName { + color: var(--text-primary); + font-weight: 500; + word-break: break-all; +} + +.modelStat { + color: var(--text-secondary); + text-align: right; + + @include mobile { + text-align: left; + } +} + +// Fixed-height cards with internal scrolling (API details / model stats) +.detailsFixedCard { + display: flex; + flex-direction: column; + height: 520px; + overflow: hidden; + + @include mobile { + height: 420px; + } +} + +.detailsScroll { + flex: 1 1 auto; + min-height: 0; + overflow: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +.detailsNote { + padding: 0 4px 10px; + font-size: 12px; + color: var(--text-secondary); +} + +// Table (80%比例) +.tableWrapper { + overflow-x: auto; +} + +.table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + + th, + td { + padding: 10px 12px; + text-align: left; + border-bottom: 1px solid var(--border-color); + } + + th { + font-weight: 600; + color: var(--text-tertiary); + background-color: var(--bg-secondary); + white-space: nowrap; + } + + th.sortableHeader { + user-select: none; + transition: color 0.15s ease; + + &:hover { + color: var(--text-primary); + } + } + + td { + color: var(--text-primary); + } + + tbody tr:hover { + background-color: var(--bg-tertiary); + } +} + +.sortHeaderButton { + display: inline-flex; + align-items: center; + width: 100%; + border: 0; + padding: 0; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--primary-color); + outline-offset: 2px; + border-radius: $radius-sm; + } +} + +.modelCell { + font-weight: 500; + max-width: 240px; + word-break: break-all; +} + +.credentialType { + display: inline-flex; + align-items: center; + margin-left: 6px; + padding: 1px 6px; + border-radius: $radius-full; + font-size: 10px; + font-weight: 600; + line-height: 1.4; + color: var(--text-secondary); + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + vertical-align: middle; +} + +.requestCountCell { + display: inline-flex; + align-items: baseline; + gap: 6px; + font-variant-numeric: tabular-nums; +} + +.requestBreakdown { + color: var(--text-secondary); + white-space: nowrap; +} + +.durationCell { + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.credentialChartContent { + display: flex; + flex-direction: column; + gap: 12px; +} + +.credentialChartGrid { + display: grid; + grid-template-columns: minmax(154px, 224px) minmax(0, 1fr); + gap: 14px; + align-items: stretch; + + @include mobile { + grid-template-columns: minmax(105px, 30%) minmax(0, 1fr); + gap: 10px; + overflow-x: auto; + } +} + +.credentialChartLabels { + height: 320px; + min-height: 260px; + display: grid; + align-items: center; + padding: 10px 0; + + @include mobile { + height: 380px; + } +} + +.credentialChartLabelItem { + min-width: 0; + padding-right: 8px; + color: var(--text-primary); + font-size: 12px; + font-weight: 600; + line-height: 1.25; + text-align: right; +} + +.credentialChartLabelName { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.credentialChartArea { + position: relative; + height: 320px; + min-height: 260px; + min-width: 360px; + + @include mobile { + height: 380px; + min-width: 320px; + } + + :global(canvas) { + touch-action: pan-x pan-y !important; + } +} + +// Pricing Section (80%比例) +.pricingFixedCard { + @include mobile { + height: auto; + min-height: 0; + overflow: visible; + } +} + +.pricingSection { + display: flex; + flex-direction: column; + gap: 16px; + flex: 1 1 auto; + min-height: 0; +} + +.priceForm { + padding: 10px; + background-color: var(--bg-secondary); + border-radius: $radius-sm; + border: 1px solid var(--border-color); + flex: 0 0 auto; +} + +.formRow { + display: flex; + gap: 10px; + align-items: flex-end; + flex-wrap: wrap; + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + +.formField { + display: flex; + flex-direction: column; + gap: 3px; + flex: 1; + min-width: 120px; + + label { + font-size: 10px; + color: var(--text-secondary); + font-weight: 500; + } + + // 确保 Input 组件的 form-group 包装器不影响布局 + :global(.form-group) { + margin: 0; + + > label { + display: none; // 隐藏 Input 自带的 label,使用外层的 + } + } + + :global(.input) { + height: 40px; + box-sizing: border-box; + } +} + +.pricesList { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 auto; + min-height: 0; +} + +.pricesTitle { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.pricesGrid { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 auto; + min-height: 0; + overflow: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + + .pricingFixedCard & { + @include mobile { + overflow: visible; + } + } +} + +.priceItem { + display: flex; + justify-content: space-between; + align-items: center; + padding: 10px; + background-color: var(--bg-secondary); + border-radius: $radius-sm; + border: 1px solid var(--border-color); + gap: 10px; + + @include mobile { + flex-direction: column; + align-items: flex-start; + } +} + +.priceInfo { + display: flex; + flex-direction: column; + gap: 3px; + min-width: 0; + flex: 1; +} + +.priceModel { + font-weight: 600; + color: var(--text-primary); + font-size: 11px; + word-break: break-all; +} + +.priceMeta { + display: flex; + gap: 10px; + font-size: 10px; + color: var(--text-secondary); + + @include mobile { + flex-direction: column; + gap: 3px; + } +} + +.priceActions { + display: flex; + gap: 3px; + flex-shrink: 0; + + @include mobile { + width: 100%; + + :global(.btn) { + flex: 1 1 0; + justify-content: center; + } + } +} + +.editModalBody { + display: flex; + flex-direction: column; + gap: 12px; +} + +// Chart Section (80%比例) +.chartSection { + display: flex; + flex-direction: column; + gap: 10px; +} + +.chartControls { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + + @include mobile { + flex-direction: column; + align-items: stretch; + } +} + +.chartWrapper { + padding: 14px; + background-color: var(--bg-secondary); + border-radius: $radius-lg; + border: 1px solid var(--border-color); + display: flex; + flex-direction: column; + gap: 12px; +} + +.chartLegend { + display: flex; + flex-wrap: wrap; + gap: 6px 12px; + align-items: center; + min-width: 0; + + @include mobile { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + padding-bottom: 4px; + } +} + +.legendItem { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 240px; + padding: 4px 10px; + border-radius: $radius-full; + border: 1px solid var(--border-color); + background: var(--bg-primary); + font-size: 12px; + color: var(--text-secondary); + + @include mobile { + max-width: 180px; + } +} + +.legendDot { + width: 10px; + height: 10px; + border-radius: 999px; + flex-shrink: 0; +} + +.legendLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.chartArea { + height: 280px; + + @include mobile { + height: 320px; + } +} + +.chartScroller { + width: 100%; + height: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + + // Chart.js 默认会设置 canvas 的 touch-action: none,导致移动端无法横向滚动 + :global(canvas) { + touch-action: pan-x pan-y !important; + } +} + +.chartCanvas { + position: relative; + height: 100%; + width: 100%; +} + +.periodButtons { + display: flex; + gap: 6px; +} + +.chartsGrid { + display: grid; + gap: 20px; + grid-template-columns: minmax(0, 1fr); + + > * { + min-width: 0; + } + + @include desktop { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.detailsGrid { + display: grid; + gap: 20px; + grid-template-columns: minmax(0, 1fr); + + > * { + min-width: 0; + } +} + +.requestEventsActions { + display: inline-flex; + align-items: center; + gap: $spacing-xs; + flex-wrap: wrap; +} + +.requestEventsToolbar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: $spacing-sm; + flex-wrap: wrap; + margin-bottom: $spacing-sm; + padding: $spacing-sm; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: color-mix(in srgb, var(--bg-secondary) 58%, transparent); +} + +.requestEventsFiltersGroup { + display: inline-flex; + align-items: flex-end; + gap: $spacing-sm; + flex-wrap: wrap; + min-width: 0; +} + +.requestEventsFilterItem { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 150px; +} + +.requestEventsFilterLabel { + font-size: 11px; + color: var(--text-tertiary); + font-weight: 700; + letter-spacing: 0.02em; +} + +.requestEventsSelect { + min-width: 170px; +} + +.requestEventsSelect button, +.requestEventsResultSelect button, +.requestEventsPageSizeSelect button { + height: 36px; +} + +.requestEventsResultSelect { + min-width: 118px; +} + +.requestEventsPageSizeSelect { + min-width: 96px; +} + +.requestEventsPageSizeSelectCompact { + min-width: 86px; + width: 86px; +} + +.requestEventsPaginationControls { + display: inline-flex; + align-items: flex-end; + justify-content: flex-end; + gap: $spacing-md; + flex-wrap: wrap; + margin-left: auto; +} + +.requestEventsPaginationItem { + display: inline-flex; + flex-direction: column; + gap: 4px; +} + +.requestEventsPagerControls { + display: inline-flex; + align-items: stretch; + flex-wrap: nowrap; + overflow: hidden; + height: 36px; + border: 1px solid var(--border-color); + border-radius: $radius-md; + background: var(--bg-secondary); +} + +.requestEventsPagerButton { + appearance: none; + display: inline-flex; + align-items: center; + justify-content: center; + border: 0; + border-radius: 0; + background: transparent; + color: var(--text-primary); + cursor: pointer; + height: 100%; + min-width: 76px; + padding: 0 14px; + font-size: 13px; + font-weight: 600; + line-height: 1; + transition: background-color 0.15s ease, color 0.15s ease; +} + +.requestEventsPagerButton + .requestEventsPagerButton { + border-left: 1px solid var(--border-color); +} + +.requestEventsPagerButton:hover:not(:disabled) { + background: var(--hover-bg); +} + +.requestEventsPagerButton:disabled { + color: var(--text-tertiary); + cursor: not-allowed; + opacity: 0.55; +} + + +.requestEventsTableMeta { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + margin: 0 0 $spacing-xs; + font-size: 12px; + color: var(--text-secondary); + flex-wrap: wrap; +} + +.requestEventsCountGroup { + display: inline-flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; +} + +.requestEventsLimitHint { + color: var(--text-tertiary); +} + +.requestEventsTableWrapper { + overflow: auto; + max-height: 460px; + border: 1px solid var(--border-color); + border-radius: $radius-md; +} + +.requestEventsTimestamp { + white-space: nowrap; + min-width: 160px; +} + +.requestEventsSourceCell, +.requestEventsAuthIndex { + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + font-size: 11px; + max-width: 180px; + word-break: break-all; +} + +.requestEventsResultSuccess { + color: var(--success-badge-text, #065f46); + background: var(--success-badge-bg, #d1fae5); + border: 1px solid var(--success-badge-border, #6ee7b7); +} + +.requestEventsResultFailed { + color: var(--failure-badge-text); + background: var(--failure-badge-bg); + border: 1px solid var(--failure-badge-border); +} + +.requestEventsResultSuccess, +.requestEventsResultFailed { + display: inline-flex; + align-items: center; + padding: 2px 8px; + border-radius: $radius-full; + font-size: 11px; + font-weight: 600; + white-space: nowrap; +} + +.chartLineHeader { + display: inline-flex; + align-items: center; + gap: 10px; +} + +.chartLineList { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; + + @include mobile { + grid-template-columns: 1fr; + } +} + +.chartLineItem { + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: 10px; + padding: 12px; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; + + @include mobile { + grid-template-columns: 1fr; + align-items: stretch; + gap: 8px; + } +} + +.chartLineLabel { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + min-width: 64px; +} + +.chartLineCount { + font-size: 12px; + color: var(--text-secondary); + font-weight: 500; +} + +.chartLineHint { + font-size: 12px; + color: var(--text-tertiary); + margin: 10px 0 0 0; +} + +// Service Health Card +.healthCard { + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; + box-shadow: var(--shadow-lg); + --health-grid-columns: 96; + --health-grid-rows: 7; + --health-grid-aspect-columns: 96; + --health-grid-aspect-rows: 7; + --health-grid-width: 100%; + --health-grid-gap: 3px; +} + +.healthHeader { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} + +.healthTitle { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin: 0; +} + +.healthMeta { + display: grid; + justify-items: end; + gap: 8px; + text-align: right; +} + +.healthTopLine { + display: inline-flex; + align-items: center; + gap: 14px; +} + +.healthWindow { + font-size: 12px; + color: var(--text-tertiary); + line-height: 1; + white-space: nowrap; +} + +.healthMetricPanel { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 12px; +} + +.healthRate { + display: inline-flex; + align-items: center; + font-size: 12px; + font-weight: 600; + white-space: nowrap; + padding: 4px 8px; + border-radius: 6px; + background: var(--bg-tertiary); +} + +.healthCountRow { + display: inline-flex; + align-items: center; + gap: 5px; + color: var(--text-tertiary); + font-size: 11px; + font-weight: 600; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.healthCountDot { + width: 7px; + height: 7px; + border-radius: 2px; +} + +.healthCountDotSuccess { + background: #22c55e; +} + +.healthCountDotFailure { + background: #ef4444; +} + +.healthRateHigh { + color: var(--success-badge-text, #065f46); + background: var(--success-badge-bg, #d1fae5); +} + +.healthRateMedium { + color: var(--warning-text, #92400e); + background: var(--warning-bg, #fef3c7); +} + +.healthRateLow { + color: var(--failure-badge-text); + background: var(--failure-badge-bg); +} + +.healthGridScroller { + width: 100%; + overflow: hidden; +} + +.healthGrid { + display: grid; + width: var(--health-grid-width); + max-width: 100%; + gap: var(--health-grid-gap); + grid-template-columns: repeat(var(--health-grid-columns), minmax(0, 1fr)); + grid-template-rows: repeat(var(--health-grid-rows), minmax(0, 1fr)); + grid-auto-flow: column; + margin-left: auto; + margin-right: auto; + aspect-ratio: var(--health-grid-aspect-columns) / var(--health-grid-aspect-rows); +} + +.healthBlockWrapper { + position: relative; + cursor: pointer; + -webkit-tap-highlight-color: transparent; + width: 100%; + aspect-ratio: 1; + min-width: 0; +} + +.healthBlock { + width: 100%; + height: 100%; + border-radius: 2px; + transition: + transform 0.15s ease, + opacity 0.15s ease; + + .healthBlockWrapper:hover &, + .healthBlockWrapper.healthBlockActive & { + transform: scaleY(1.6); + opacity: 0.85; + } +} + +.healthBlockIdle { + background-color: var(--border-secondary, #e5e7eb); +} + +.healthTooltip { + position: absolute; + bottom: calc(100% + 8px); + left: 50%; + transform: translateX(-50%); + background-color: var(--floating-surface, #ffffff); + background-image: none; + border: 1px solid var(--floating-border, #e5e7eb); + border-radius: 6px; + padding: 6px 10px; + font-size: 11px; + line-height: 1.5; + white-space: nowrap; + box-shadow: var(--floating-shadow, 0 10px 24px rgba(0, 0, 0, 0.16)); + z-index: $z-dropdown; + pointer-events: none; + color: var(--text-primary); + opacity: 1; + background-clip: padding-box; + + &::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 5px solid transparent; + border-top-color: var(--floating-surface, #ffffff); + } + + &::before { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: 6px solid transparent; + border-top-color: var(--floating-border, #e5e7eb); + } +} + +// When tooltip should appear below (for top rows) +.healthTooltipBelow { + bottom: auto; + top: calc(100% + 8px); + + &::after { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--floating-surface, #ffffff); + } + + &::before { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--floating-border, #e5e7eb); + } +} + +.healthTooltipLeft { + left: 0; + transform: translateX(0); + + &::after, + &::before { + left: 8px; + transform: none; + } +} + +.healthTooltipRight { + left: auto; + right: 0; + transform: translateX(0); + + &::after, + &::before { + left: auto; + right: 8px; + transform: none; + } +} + +.healthTooltipTime { + color: var(--text-secondary); + display: block; + margin-bottom: 2px; +} + +.healthTooltipStats { + display: flex; + align-items: center; + gap: 8px; +} + +.healthTooltipSuccess { + color: var(--success-color, #22c55e); +} + +.healthTooltipFailure { + color: var(--danger-color, #ef4444); +} + +.healthTooltipRate { + color: var(--text-secondary); + margin-left: 2px; +} + +.healthLegend { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + flex-wrap: wrap; +} + +.healthLegendLabel { + font-size: 10px; + color: var(--text-tertiary); +} + +.healthLegendColors { + display: flex; + gap: 3px; +} + +.healthLegendBlock { + width: 10px; + height: 10px; + border-radius: 2px; +} + +@include mobile { + .healthCard { + padding: 14px; + gap: 10px; + --health-grid-gap: 2px; + } + + .healthGridScroller { + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + .healthGrid { + min-width: 540px; + } + + .healthBlockWrapper { + min-width: 0; + } + + .healthTooltip { + font-size: 10px; + padding: 4px 8px; + } + + .healthLegendBlock { + width: 8px; + height: 8px; + } +} diff --git a/web/src/pages/UsagePage.tsx b/web/src/pages/UsagePage.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8ed45176c9e09b5df6d0bb967671fd3c801160c --- /dev/null +++ b/web/src/pages/UsagePage.tsx @@ -0,0 +1,1379 @@ +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import i18n, { persistLanguage } from '@/i18n'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + BarController, + Title, + Tooltip, + Legend, + Filler +} from 'chart.js'; +import { ApiError, fetchStatus, fetchUsageAnalysis, fetchUsageEventFilterOptions, fetchUsageEvents, fetchUsageIdentities, triggerSync } from '@/lib/api'; +import type { StatusResponse, UsageAnalysisResponse, UsageEvent, UsageIdentity, UsageSourceFilterOption } from '@/lib/types'; +import { Button } from '@/components/ui/Button'; +import { LoadingSpinner } from '@/components/ui/LoadingSpinner'; +import { Select } from '@/components/ui/Select'; +import { IconRefreshCw } from '@/components/ui/icons'; +import { useMediaQuery } from '@/hooks/useMediaQuery'; +import { useHeaderRefresh } from '@/hooks/useHeaderRefresh'; +import { useThemeStore } from '@/stores'; +import { + StatCards, + UsageChart, + ChartLineSelector, + ApiDetailsCard, + ModelStatsCard, + PriceSettingsCard, + CredentialStatsCard, + CredentialTopChartCard, + RequestEventsDetailsCard, + TokenBreakdownChart, + CostTrendChart, + ServiceHealthCard, + useUsageData, + usePricingData, + useSparklines, + useChartData +} from '@/components/usage'; +import { + getModelNamesFromUsage, + resolveUsageFilterWindow, + sanitizeChartLines, + type UsageFilterWindow, + type UsageTimeRange +} from '@/utils/usage'; +import type { Theme } from '@/types'; +import styles from './UsagePage.module.scss'; + +ChartJS.register( + CategoryScale, + LinearScale, + PointElement, + LineElement, + BarElement, + BarController, + Title, + Tooltip, + Legend, + Filler +); + +const CHART_LINES_STORAGE_KEY = 'cli-proxy-usage-chart-lines-v1'; +const TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-time-range-v1'; +const CUSTOM_TIME_RANGE_STORAGE_KEY = 'cli-proxy-usage-custom-range-v1'; +const DEFAULT_CHART_LINES = ['all']; +const DEFAULT_TIME_RANGE: UsageTimeRange = '8h'; +const DEFAULT_CUSTOM_WINDOW_HOURS = 8; +const MAX_CHART_LINES = 9; +const TIME_RANGE_OPTIONS: ReadonlyArray<{ value: Exclude; labelKey: string }> = [ + { value: '4h', labelKey: 'usage_stats.range_4h' }, + { value: '8h', labelKey: 'usage_stats.range_8h' }, + { value: '12h', labelKey: 'usage_stats.range_12h' }, + { value: 'today', labelKey: 'usage_stats.range_today' }, + { value: '7d', labelKey: 'usage_stats.range_7d' }, + { value: 'custom', labelKey: 'usage_stats.range_custom' }, +]; +const HOUR_WINDOW_BY_TIME_RANGE: Record, number> = { + '4h': 4, + '8h': 8, + '12h': 12, + '24h': 24, + '7d': 7 * 24 +}; +const THEME_OPTIONS: ReadonlyArray<{ value: Theme; labelKey: string }> = [ + { value: 'white', labelKey: 'usage_stats.theme_light' }, + { value: 'dark', labelKey: 'usage_stats.theme_dark' }, + { value: 'auto', labelKey: 'usage_stats.theme_auto' } +]; +const USAGE_TAB_OPTIONS = ['overview', 'analysis', 'events', 'credentials', 'pricing'] as const; +type UsageTab = (typeof USAGE_TAB_OPTIONS)[number]; +type Translate = (key: string) => string; +const USAGE_TAB_LABEL_KEYS: Record = { + overview: 'usage_stats.tab_overview', + analysis: 'usage_stats.tab_analysis', + events: 'usage_stats.tab_events', + credentials: 'usage_stats.tab_credentials', + pricing: 'usage_stats.tab_pricing', +}; +const DEFAULT_USAGE_TAB: UsageTab = 'overview'; +const USAGE_TAB_STORAGE_KEY = 'cli-proxy-usage-tab-v1'; +const REQUEST_EVENTS_PAGE_SIZES = [20, 50, 100, 500, 1000] as const; +const REQUEST_EVENTS_DEFAULT_PAGE_SIZE = 100; +const ALL_REQUEST_EVENTS_FILTER = '__all__'; +const OVERVIEW_AUTO_REFRESH_INTERVAL_MS = 10_000; + +type RequestEventFilterState = { + model: string; + source: string; + result: string; +}; + +type RequestEventFilterOptionsState = { + models: string[]; + sources: UsageSourceFilterOption[]; +}; + +type RefreshPageDataOptions = { + refreshActiveTab: () => Promise; + triggerBackendSync?: () => Promise; +}; + +type OverviewAutoRefreshDocument = Pick; + +type OverviewAutoRefreshOptions = { + enabled: boolean; + refreshOverview: () => void | Promise; + documentRef?: OverviewAutoRefreshDocument; + intervalMs?: number; +}; + +type SyncCpaDataOptions = { + triggerBackendSync: () => Promise; + refreshActiveTab: () => Promise; + refreshStatus: () => Promise; + onStatus: (status: StatusResponse) => void; +}; + +export const refreshPageData = async ({ refreshActiveTab }: RefreshPageDataOptions) => { + await refreshActiveTab(); +}; + +export const getOverviewDisplayLoading = ({ loading, hasUsage }: { loading: boolean; hasUsage: boolean }) => loading && !hasUsage; + +export const scheduleOverviewAutoRefresh = ({ + enabled, + refreshOverview, + documentRef, + intervalMs = OVERVIEW_AUTO_REFRESH_INTERVAL_MS, +}: OverviewAutoRefreshOptions) => { + if (!enabled) { + return () => undefined; + } + + const targetDocument = documentRef ?? (typeof document === 'undefined' ? undefined : document); + if (!targetDocument) { + return () => undefined; + } + + let timer: ReturnType | undefined; + const stopTimer = () => { + if (timer === undefined) return; + clearInterval(timer); + timer = undefined; + }; + const refreshIfVisible = () => { + if (targetDocument.visibilityState === 'hidden') { + stopTimer(); + return; + } + void refreshOverview(); + }; + const startTimer = () => { + if (timer !== undefined) return; + timer = setInterval(refreshIfVisible, intervalMs); + }; + const handleVisibilityChange = () => { + if (targetDocument.visibilityState === 'hidden') { + stopTimer(); + return; + } + void refreshOverview(); + stopTimer(); + startTimer(); + }; + + if (targetDocument.visibilityState !== 'hidden') { + startTimer(); + } + targetDocument.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + stopTimer(); + targetDocument.removeEventListener('visibilitychange', handleVisibilityChange); + }; +}; + +export const syncCpaData = async ({ triggerBackendSync, refreshActiveTab, refreshStatus, onStatus }: SyncCpaDataOptions) => { + try { + await triggerBackendSync(); + await refreshActiveTab(); + const nextStatus = await refreshStatus(); + onStatus(nextStatus); + } catch (error) { + if (!(error instanceof ApiError && error.status === 401)) { + try { + const nextStatus = await refreshStatus(); + onStatus(nextStatus); + } catch { + // 忽略状态刷新失败,继续抛出原始同步错误。 + } + } + throw error; + } +}; + +export const sanitizeRequestEventFilters = ( + filters: RequestEventFilterState, + options: RequestEventFilterOptionsState, +): RequestEventFilterState => { + const model = filters.model === ALL_REQUEST_EVENTS_FILTER || options.models.includes(filters.model) + ? filters.model + : ALL_REQUEST_EVENTS_FILTER; + const source = filters.source === ALL_REQUEST_EVENTS_FILTER || options.sources.some((option) => option.value === filters.source) + ? filters.source + : ALL_REQUEST_EVENTS_FILTER; + const result = filters.result === 'success' || filters.result === 'failed' + ? filters.result + : ALL_REQUEST_EVENTS_FILTER; + + return { model, source, result }; +}; + +const isUsageTimeRange = (value: unknown): value is UsageTimeRange => + value === '4h' || value === '8h' || value === '12h' || value === '24h' || value === 'today' || value === '7d' || value === 'all' || value === 'custom'; + +const toDateInputValue = (timestamp: number): string => { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ''; + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + +const parseCustomDateBoundary = (value: string, endOfDay: boolean): number | undefined => { + if (!value) return undefined; + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (!match) return undefined; + const [, year, month, day] = match; + const yearNumber = Number(year); + const monthNumber = Number(month); + const dayNumber = Number(day); + const date = endOfDay + ? new Date(yearNumber, monthNumber - 1, dayNumber, 23, 59, 59, 999) + : new Date(yearNumber, monthNumber - 1, dayNumber, 0, 0, 0, 0); + if (Number.isNaN(date.getTime())) return undefined; + if (date.getFullYear() !== yearNumber || date.getMonth() !== monthNumber - 1 || date.getDate() !== dayNumber) return undefined; + return date.getTime(); +}; + +const parseCustomDateStart = (value: string): number | undefined => parseCustomDateBoundary(value, false); + +const parseCustomDateEnd = (value: string): number | undefined => parseCustomDateBoundary(value, true); + +export const buildCustomDateRangeQuery = (range: { start: string; end: string }) => { + const startMs = parseCustomDateStart(range.start); + const endMs = parseCustomDateEnd(range.end); + if (!range.start || !range.end || startMs === undefined || endMs === undefined || startMs > endMs) { + return { valid: false, start: undefined, end: undefined }; + } + return { valid: true, start: range.start, end: range.end }; +}; + +const buildDefaultCustomRange = (anchorMs: number) => ({ + start: toDateInputValue(anchorMs - DEFAULT_CUSTOM_WINDOW_HOURS * 60 * 60 * 1000), + end: toDateInputValue(anchorMs) +}); + +const loadCustomTimeRange = () => { + try { + if (typeof localStorage === 'undefined') { + return buildDefaultCustomRange(Date.now()); + } + const raw = localStorage.getItem(CUSTOM_TIME_RANGE_STORAGE_KEY); + if (!raw) { + return buildDefaultCustomRange(Date.now()); + } + const parsed = JSON.parse(raw) as { start?: string; end?: string }; + const start = typeof parsed?.start === 'string' ? parsed.start : ''; + const end = typeof parsed?.end === 'string' ? parsed.end : ''; + if (!start || !end) { + return { start, end }; + } + const startMs = parseCustomDateStart(start); + const endMs = parseCustomDateEnd(end); + if (startMs === undefined || endMs === undefined || startMs > endMs) { + return buildDefaultCustomRange(Date.now()); + } + return { start, end }; + } catch { + return buildDefaultCustomRange(Date.now()); + } +}; + +const normalizeChartLines = (value: unknown, maxLines = MAX_CHART_LINES): string[] => { + if (!Array.isArray(value)) { + return DEFAULT_CHART_LINES; + } + + const filtered = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean) + .slice(0, maxLines); + + return filtered.length ? filtered : DEFAULT_CHART_LINES; +}; + +const loadChartLines = (): string[] => { + try { + if (typeof localStorage === 'undefined') { + return DEFAULT_CHART_LINES; + } + const raw = localStorage.getItem(CHART_LINES_STORAGE_KEY); + if (!raw) { + return DEFAULT_CHART_LINES; + } + return normalizeChartLines(JSON.parse(raw)); + } catch { + return DEFAULT_CHART_LINES; + } +}; + +const loadTimeRange = (): UsageTimeRange => { + try { + if (typeof localStorage === 'undefined') { + return DEFAULT_TIME_RANGE; + } + const raw = localStorage.getItem(TIME_RANGE_STORAGE_KEY); + if (!isUsageTimeRange(raw) || raw === 'all' || raw === '24h') { + return DEFAULT_TIME_RANGE; + } + return raw; + } catch { + return DEFAULT_TIME_RANGE; + } +}; + +const isUsageTab = (value: unknown): value is UsageTab => + typeof value === 'string' && USAGE_TAB_OPTIONS.includes(value as UsageTab); + +export const getUsageTabOptions = (translate: Translate): Array<{ value: UsageTab; label: string }> => + USAGE_TAB_OPTIONS.map((value) => ({ + value, + label: translate(USAGE_TAB_LABEL_KEYS[value]), + })); + +export const getTimeRangeOptions = (translate: Translate) => + TIME_RANGE_OPTIONS.map((option) => ({ + value: option.value, + label: translate(option.labelKey), + })); + +export const getOverviewHourWindowHours = ({ timeRange, filterWindow }: { timeRange: UsageTimeRange; filterWindow: UsageFilterWindow }) => { + if (timeRange === 'all') return 24; + if (timeRange === 'today') return 24; + if (timeRange !== 'custom') return Math.min(HOUR_WINDOW_BY_TIME_RANGE[timeRange], 24); + if (filterWindow.windowMinutes === undefined) return 24; + return Math.min(Math.max(Math.ceil(filterWindow.windowMinutes / 60), 1), 24); +}; + +const toTimestampMs = (value: string | undefined): number | undefined => { + if (!value) return undefined; + const timestamp = Date.parse(value); + return Number.isFinite(timestamp) ? timestamp : undefined; +}; + +export const getOverviewChartEndMs = ({ timeRange, filterWindow, fallbackEndMs, resolvedRangeEndMs }: { timeRange: UsageTimeRange; filterWindow: UsageFilterWindow; fallbackEndMs: number; resolvedRangeEndMs?: number }) => { + if (resolvedRangeEndMs !== undefined) return resolvedRangeEndMs; + if (timeRange === 'today' && filterWindow.startMs !== undefined) { + return filterWindow.startMs + 24 * 60 * 60 * 1000 - 1; + } + return filterWindow.endMs ?? fallbackEndMs; +}; + +const loadUsageTab = (): UsageTab => { + try { + if (typeof localStorage === 'undefined') { + return DEFAULT_USAGE_TAB; + } + const raw = localStorage.getItem(USAGE_TAB_STORAGE_KEY); + return isUsageTab(raw) ? raw : DEFAULT_USAGE_TAB; + } catch { + return DEFAULT_USAGE_TAB; + } +}; + +export function UsagePage({ onAuthRequired }: { onAuthRequired?: () => void }) { + const { t } = useTranslation(); + const currentLanguage = i18n.language === 'zh' ? 'zh' : 'en'; + const isMobile = useMediaQuery('(max-width: 768px)'); + const theme = useThemeStore((state) => state.theme); + const resolvedTheme = useThemeStore((state) => state.resolvedTheme); + const setTheme = useThemeStore((state) => state.setTheme); + const isDark = resolvedTheme === 'dark'; + const [activeTab, setActiveTab] = useState(loadUsageTab); + const [chartLines, setChartLines] = useState(loadChartLines); + const [timeRange, setTimeRange] = useState(loadTimeRange); + const [customTimeRange, setCustomTimeRange] = useState<{ start: string; end: string }>(loadCustomTimeRange); + const isOverviewTab = activeTab === 'overview'; + + const { + usage, + loading, + error, + lastRefreshedAt, + loadUsage + } = useUsageData({ + onAuthRequired, + range: timeRange, + customStart: customTimeRange.start, + customEnd: customTimeRange.end, + enabled: activeTab === 'overview', + }); + const { + modelNames, + modelPrices, + loading: pricingLoading, + error: pricingError, + loadPricing, + setModelPrices, + } = usePricingData({ + onAuthRequired, + enabled: activeTab === 'pricing', + }); + const [status, setStatus] = useState(null); + const [statusError, setStatusError] = useState(''); + const [customRangeError, setCustomRangeError] = useState(''); + const [customRangeHint, setCustomRangeHint] = useState(''); + const [eventsLoading, setEventsLoading] = useState(false); + const [eventsError, setEventsError] = useState(''); + const [eventsData, setEventsData] = useState([]); + const [eventsPage, setEventsPage] = useState(1); + const [eventsPageSize, setEventsPageSize] = useState(REQUEST_EVENTS_DEFAULT_PAGE_SIZE); + const [eventsTotalCount, setEventsTotalCount] = useState(0); + const [eventsTotalPages, setEventsTotalPages] = useState(0); + const [eventsModelOptions, setEventsModelOptions] = useState([]); + const [eventsSourceOptions, setEventsSourceOptions] = useState([]); + const [eventsModelFilter, setEventsModelFilter] = useState(ALL_REQUEST_EVENTS_FILTER); + const [eventsSourceFilter, setEventsSourceFilter] = useState(ALL_REQUEST_EVENTS_FILTER); + const [eventsResultFilter, setEventsResultFilter] = useState(ALL_REQUEST_EVENTS_FILTER); + const eventsRequestControllerRef = useRef(null); + const eventsFilterOptionsRequestControllerRef = useRef(null); + const loadedEventsFilterOptionsKeyRef = useRef(''); + const [manualRefreshLoading, setManualRefreshLoading] = useState(false); + const [manualSyncLoading, setManualSyncLoading] = useState(false); + const [credentialsLoading, setCredentialsLoading] = useState(false); + const [credentialsError, setCredentialsError] = useState(''); + const [credentialsData, setCredentialsData] = useState([]); + const credentialsRequestControllerRef = useRef(null); + const [analysisLoading, setAnalysisLoading] = useState(false); + const [analysisError, setAnalysisError] = useState(''); + const [analysisData, setAnalysisData] = useState({ apis: [], models: [] }); + const [, setAnalysisLastRefreshedAt] = useState(null); + const analysisRequestControllerRef = useRef(null); + + const tabOptions = useMemo(() => getUsageTabOptions(t), [t]); + const timeRangeOptions = useMemo(() => getTimeRangeOptions(t), [t]); + const themeOptions = useMemo( + () => + THEME_OPTIONS.map((option) => ({ + ...option, + label: t(option.labelKey) + })), + [t] + ); + + const resolvedRangeStartMs = toTimestampMs(usage?.range_start); + const resolvedRangeEndMs = toTimestampMs(usage?.range_end); + const filterWindow = useMemo(() => { + if (!usage) return {}; + return resolveUsageFilterWindow(usage.usage, timeRange, { + nowMs: resolvedRangeEndMs ?? lastRefreshedAt?.getTime() ?? Date.now(), + customStart: + timeRange === 'custom' ? (resolvedRangeStartMs ?? parseCustomDateStart(customTimeRange.start)) : customTimeRange.start, + customEnd: + timeRange === 'custom' ? (resolvedRangeEndMs ?? parseCustomDateEnd(customTimeRange.end)) : customTimeRange.end + }); + }, [customTimeRange.end, customTimeRange.start, lastRefreshedAt, resolvedRangeEndMs, resolvedRangeStartMs, timeRange, usage]); + + useEffect(() => { + if (timeRange !== 'custom') { + setCustomRangeError(''); + setCustomRangeHint(''); + return; + } + if (!customTimeRange.start || !customTimeRange.end) { + setCustomRangeError(''); + setCustomRangeHint(t('usage_stats.custom_incomplete')); + return; + } + const startMs = parseCustomDateStart(customTimeRange.start); + const endMs = parseCustomDateEnd(customTimeRange.end); + if (startMs === undefined || endMs === undefined || startMs > endMs) { + setCustomRangeHint(''); + setCustomRangeError(t('usage_stats.custom_invalid')); + return; + } + setCustomRangeError(''); + setCustomRangeHint(''); + }, [customTimeRange.end, customTimeRange.start, t, timeRange]); + + const loadAnalysis = useCallback(async () => { + if (timeRange === 'custom') { + if (!customTimeRange.start || !customTimeRange.end) { + analysisRequestControllerRef.current?.abort(); + analysisRequestControllerRef.current = null; + setAnalysisData({ apis: [], models: [] }); + setAnalysisError(''); + setAnalysisLoading(false); + return; + } + const startMs = parseCustomDateStart(customTimeRange.start); + const endMs = parseCustomDateEnd(customTimeRange.end); + if (startMs === undefined || endMs === undefined || startMs > endMs) { + analysisRequestControllerRef.current?.abort(); + analysisRequestControllerRef.current = null; + setAnalysisData({ apis: [], models: [] }); + setAnalysisError(''); + setAnalysisLoading(false); + return; + } + } + + analysisRequestControllerRef.current?.abort(); + const controller = new AbortController(); + analysisRequestControllerRef.current = controller; + + setAnalysisLoading(true); + setAnalysisError(''); + setAnalysisData({ apis: [], models: [] }); + try { + const queryWindow = timeRange === 'custom' ? buildCustomDateRangeQuery({ start: customTimeRange.start, end: customTimeRange.end }) : { start: undefined, end: undefined }; + const response = await fetchUsageAnalysis(timeRange, queryWindow.start, queryWindow.end, controller.signal); + if (analysisRequestControllerRef.current !== controller) { + return; + } + setAnalysisData(response); + setAnalysisLastRefreshedAt(new Date()); + } catch (error) { + if (controller.signal.aborted) { + return; + } + if (analysisRequestControllerRef.current === controller) { + setAnalysisData({ apis: [], models: [] }); + } + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + setAnalysisError(error instanceof Error ? error.message : 'Failed to load usage analysis'); + } finally { + if (analysisRequestControllerRef.current === controller) { + setAnalysisLoading(false); + analysisRequestControllerRef.current = null; + } + } + }, [customTimeRange.end, customTimeRange.start, onAuthRequired, timeRange]); + const hourWindowHours = useMemo( + () => getOverviewHourWindowHours({ timeRange, filterWindow }), + [filterWindow, timeRange] + ); + const filterWindowEndMs = getOverviewChartEndMs({ + timeRange, + filterWindow, + fallbackEndMs: lastRefreshedAt?.getTime() ?? Date.now(), + resolvedRangeEndMs, + }); + const isCustomRange = timeRange === 'custom'; + + const handleChartLinesChange = useCallback((lines: string[]) => { + setChartLines(normalizeChartLines(lines)); + }, []); + + useEffect(() => { + try { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(CHART_LINES_STORAGE_KEY, JSON.stringify(chartLines)); + } catch { + // Ignore storage errors. + } + }, [chartLines]); + + useEffect(() => { + try { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(TIME_RANGE_STORAGE_KEY, timeRange); + } catch { + // Ignore storage errors. + } + }, [timeRange]); + + useEffect(() => { + try { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(CUSTOM_TIME_RANGE_STORAGE_KEY, JSON.stringify(customTimeRange)); + } catch { + // Ignore storage errors. + } + }, [customTimeRange]); + + useEffect(() => { + try { + if (typeof localStorage === 'undefined') { + return; + } + localStorage.setItem(USAGE_TAB_STORAGE_KEY, activeTab); + } catch { + // Ignore storage errors. + } + }, [activeTab]); + + useEffect(() => { + setEventsPage(1); + }, [customTimeRange.end, customTimeRange.start, timeRange]); + + useEffect(() => { + if (timeRange !== 'custom') return; + if (customTimeRange.start && customTimeRange.end) return; + const anchorMs = lastRefreshedAt?.getTime() ?? Date.now(); + setCustomTimeRange(buildDefaultCustomRange(anchorMs)); + }, [customTimeRange.end, customTimeRange.start, lastRefreshedAt, timeRange]); + + useEffect(() => { + let controller: AbortController | null = null; + const loadStatus = async () => { + controller?.abort(); + const requestController = new AbortController(); + controller = requestController; + try { + const status: StatusResponse = await fetchStatus(requestController.signal); + setStatus(status); + setStatusError(status.last_error || ''); + } catch (error) { + if (requestController.signal.aborted) return; + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + } + }; + void loadStatus(); + const timer = window.setInterval(() => { + void loadStatus(); + }, 30_000); + return () => { + controller?.abort(); + window.clearInterval(timer); + }; + }, [onAuthRequired]); + + const getEventQueryWindow = useCallback(() => { + if (timeRange !== 'custom') { + return { valid: true, start: undefined, end: undefined }; + } + if (!customTimeRange.start || !customTimeRange.end) { + return { valid: false, start: undefined, end: undefined }; + } + const startMs = parseCustomDateStart(customTimeRange.start); + const endMs = parseCustomDateEnd(customTimeRange.end); + if (startMs === undefined || endMs === undefined || startMs > endMs) { + return { valid: false, start: undefined, end: undefined }; + } + return buildCustomDateRangeQuery({ start: customTimeRange.start, end: customTimeRange.end }); + }, [customTimeRange.end, customTimeRange.start, timeRange]); + + const loadEventFilterOptions = useCallback(async () => { + const optionsKey = 'stable'; + + if (loadedEventsFilterOptionsKeyRef.current === optionsKey) { + return; + } + + eventsFilterOptionsRequestControllerRef.current?.abort(); + const controller = new AbortController(); + eventsFilterOptionsRequestControllerRef.current = controller; + + try { + const response = await fetchUsageEventFilterOptions(controller.signal); + if (eventsFilterOptionsRequestControllerRef.current !== controller) { + return; + } + setEventsModelOptions(response.models ?? []); + setEventsSourceOptions(response.sources ?? []); + loadedEventsFilterOptionsKeyRef.current = optionsKey; + } catch (error) { + if (controller.signal.aborted) { + return; + } + if (eventsFilterOptionsRequestControllerRef.current === controller) { + setEventsModelOptions([]); + setEventsSourceOptions([]); + loadedEventsFilterOptionsKeyRef.current = ''; + } + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + } + } finally { + if (eventsFilterOptionsRequestControllerRef.current === controller) { + eventsFilterOptionsRequestControllerRef.current = null; + } + } + }, [onAuthRequired]); + + const loadEvents = useCallback(async () => { + const queryWindow = getEventQueryWindow(); + if (!queryWindow.valid) { + eventsRequestControllerRef.current?.abort(); + eventsRequestControllerRef.current = null; + setEventsData([]); + setEventsTotalCount(0); + setEventsTotalPages(0); + setEventsError(''); + setEventsLoading(false); + return; + } + + eventsRequestControllerRef.current?.abort(); + const controller = new AbortController(); + eventsRequestControllerRef.current = controller; + + setEventsLoading(true); + setEventsError(''); + try { + const response = await fetchUsageEvents(timeRange, queryWindow.start, queryWindow.end, controller.signal, { + page: eventsPage, + pageSize: eventsPageSize, + model: eventsModelFilter === ALL_REQUEST_EVENTS_FILTER ? undefined : eventsModelFilter, + source: eventsSourceFilter === ALL_REQUEST_EVENTS_FILTER ? undefined : eventsSourceFilter, + result: eventsResultFilter === ALL_REQUEST_EVENTS_FILTER ? undefined : eventsResultFilter, + }); + if (eventsRequestControllerRef.current !== controller) { + return; + } + if (response.total_pages > 0 && eventsPage > response.total_pages) { + setEventsPage(response.total_pages); + return; + } + setEventsData(response.events); + setEventsTotalCount(response.total_count); + setEventsTotalPages(response.total_pages); + } catch (error) { + if (controller.signal.aborted) { + return; + } + if (eventsRequestControllerRef.current === controller) { + setEventsData([]); + setEventsTotalCount(0); + setEventsTotalPages(0); + } + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + setEventsError(error instanceof Error ? error.message : 'Failed to load usage events'); + } finally { + if (eventsRequestControllerRef.current === controller) { + setEventsLoading(false); + eventsRequestControllerRef.current = null; + } + } + }, [eventsModelFilter, eventsPage, eventsPageSize, eventsResultFilter, eventsSourceFilter, getEventQueryWindow, onAuthRequired, timeRange]); + + const resetEventsPage = useCallback(() => { + setEventsPage(1); + }, []); + + const handleEventsPageSizeChange = useCallback((pageSize: number) => { + setEventsPageSize(pageSize); + resetEventsPage(); + }, [resetEventsPage]); + + const handleEventsModelFilterChange = useCallback((model: string) => { + setEventsModelFilter(model); + resetEventsPage(); + }, [resetEventsPage]); + + const handleEventsSourceFilterChange = useCallback((source: string) => { + setEventsSourceFilter(source); + resetEventsPage(); + }, [resetEventsPage]); + + const handleEventsResultFilterChange = useCallback((result: string) => { + setEventsResultFilter(result); + resetEventsPage(); + }, [resetEventsPage]); + + const loadCredentials = useCallback(async () => { + credentialsRequestControllerRef.current?.abort(); + const controller = new AbortController(); + credentialsRequestControllerRef.current = controller; + + setCredentialsLoading(true); + setCredentialsError(''); + setCredentialsData([]); + try { + const response = await fetchUsageIdentities(controller.signal); + if (credentialsRequestControllerRef.current !== controller) { + return; + } + setCredentialsData(response.identities); + } catch (error) { + if (controller.signal.aborted) { + return; + } + if (credentialsRequestControllerRef.current === controller) { + setCredentialsData([]); + } + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + setCredentialsError(error instanceof Error ? error.message : 'Failed to load usage identities'); + } finally { + if (credentialsRequestControllerRef.current === controller) { + setCredentialsLoading(false); + credentialsRequestControllerRef.current = null; + } + } + }, [onAuthRequired]); + + const refreshActiveTab = useCallback(async () => { + if (activeTab === 'events') { + await loadEventFilterOptions(); + await loadEvents(); + return; + } + if (activeTab === 'credentials') { + await loadCredentials(); + return; + } + if (activeTab === 'analysis') { + await loadAnalysis(); + return; + } + if (activeTab === 'pricing') { + await loadPricing(); + return; + } + await loadUsage(); + }, [activeTab, loadAnalysis, loadCredentials, loadEventFilterOptions, loadEvents, loadPricing, loadUsage]); + + const handleManualRefresh = useCallback(async () => { + setManualRefreshLoading(true); + try { + await refreshPageData({ refreshActiveTab }); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + setStatusError(error instanceof Error ? error.message : t('notification.refresh_failed')); + } finally { + setManualRefreshLoading(false); + } + }, [onAuthRequired, refreshActiveTab, t]); + + const handleManualSync = useCallback(async () => { + setManualSyncLoading(true); + try { + await syncCpaData({ + triggerBackendSync: triggerSync, + refreshActiveTab, + refreshStatus: fetchStatus, + onStatus: setStatus, + }); + setStatusError(''); + } catch (error) { + if (error instanceof ApiError && error.status === 401) { + onAuthRequired?.(); + return; + } + setStatusError(error instanceof Error ? error.message : t('notification.refresh_failed')); + } finally { + setManualSyncLoading(false); + } + }, [onAuthRequired, refreshActiveTab, t]); + + useEffect(() => scheduleOverviewAutoRefresh({ + enabled: isOverviewTab, + refreshOverview: loadUsage, + }), [isOverviewTab, loadUsage]); + + useHeaderRefresh(refreshActiveTab); + + useEffect(() => { + if (activeTab !== 'events') { + eventsRequestControllerRef.current?.abort(); + eventsRequestControllerRef.current = null; + eventsFilterOptionsRequestControllerRef.current?.abort(); + eventsFilterOptionsRequestControllerRef.current = null; + setEventsLoading(false); + return; + } + void loadEventFilterOptions(); + void loadEvents(); + return () => { + eventsRequestControllerRef.current?.abort(); + eventsRequestControllerRef.current = null; + eventsFilterOptionsRequestControllerRef.current?.abort(); + eventsFilterOptionsRequestControllerRef.current = null; + }; + }, [activeTab, loadEventFilterOptions, loadEvents]); + + useEffect(() => { + if (activeTab !== 'credentials') { + credentialsRequestControllerRef.current?.abort(); + credentialsRequestControllerRef.current = null; + setCredentialsLoading(false); + return; + } + void loadCredentials(); + return () => { + credentialsRequestControllerRef.current?.abort(); + credentialsRequestControllerRef.current = null; + }; + }, [activeTab, loadCredentials]); + + useEffect(() => { + if (activeTab !== 'analysis') { + analysisRequestControllerRef.current?.abort(); + analysisRequestControllerRef.current = null; + setAnalysisLoading(false); + return; + } + void loadAnalysis(); + return () => { + analysisRequestControllerRef.current?.abort(); + analysisRequestControllerRef.current = null; + }; + }, [activeTab, loadAnalysis]); + + useEffect(() => { + const next = sanitizeRequestEventFilters( + { + model: eventsModelFilter, + source: eventsSourceFilter, + result: eventsResultFilter, + }, + { + models: eventsModelOptions, + sources: eventsSourceOptions, + }, + ); + + if (next.model !== eventsModelFilter) { + setEventsModelFilter(next.model); + } + if (next.source !== eventsSourceFilter) { + setEventsSourceFilter(next.source); + } + if (next.result !== eventsResultFilter) { + setEventsResultFilter(next.result); + } + if (next.model !== eventsModelFilter || next.source !== eventsSourceFilter || next.result !== eventsResultFilter) { + resetEventsPage(); + } + }, [eventsModelFilter, eventsModelOptions, eventsResultFilter, eventsSourceFilter, eventsSourceOptions, resetEventsPage]); + + const handleLanguageChange = useCallback(async (language: 'en' | 'zh') => { + if (currentLanguage === language) return; + await i18n.changeLanguage(language); + persistLanguage(language); + }, [currentLanguage]); + + const lastSyncAt = useMemo(() => { + if (!status?.last_run_at) return null; + const parsed = new Date(status.last_run_at); + return Number.isNaN(parsed.getTime()) ? null : parsed; + }, [status?.last_run_at]); + const showRangeControls = activeTab !== 'pricing'; + const { + requestsSparkline, + tokensSparkline, + rpmSparkline, + tpmSparkline, + costSparkline + } = useSparklines({ usage, loading }); + + const { + requestsPeriod, + setRequestsPeriod, + tokensPeriod, + setTokensPeriod, + requestsChartData, + tokensChartData, + requestsChartOptions, + tokensChartOptions + } = useChartData({ usage, chartLines, isDark, isMobile, hourWindowHours, endMs: filterWindowEndMs }); + + const overviewModelNames = useMemo( + () => getModelNamesFromUsage(usage?.usage ?? null), + [usage] + ); + + useEffect(() => { + if (!isOverviewTab) return; + setChartLines((current) => { + const next = sanitizeChartLines(current, overviewModelNames); + if (next.length === current.length && next.every((line, index) => line === current[index])) { + return current; + } + return next; + }); + }, [isOverviewTab, overviewModelNames]); + const apiStats = useMemo( + () => analysisData.apis.map((api) => ({ + endpoint: api.api_key, + displayName: api.display_name || api.api_key, + totalRequests: api.total_requests, + successCount: api.success_count, + failureCount: api.failure_count, + totalTokens: api.total_tokens, + totalCost: api.models.reduce((sum, model) => { + const pricing = modelPrices[model.model]; + if (!pricing) return sum; + const cachedTokens = Math.max(Number(model.cached_tokens) || 0, 0); + const inputTokens = Math.max(Number(model.input_tokens) || 0, 0); + const outputTokens = Math.max(Number(model.output_tokens) || 0, 0); + const promptTokens = Math.max(inputTokens - cachedTokens, 0); + return sum + ((promptTokens / 1_000_000) * pricing.prompt) + ((outputTokens / 1_000_000) * pricing.completion) + ((cachedTokens / 1_000_000) * pricing.cache); + }, 0), + models: Object.fromEntries(api.models.map((model) => [model.model, { + requests: model.total_requests, + successCount: model.success_count, + failureCount: model.failure_count, + tokens: model.total_tokens, + }])) + })), + [analysisData.apis, modelPrices] + ); + const modelStats = useMemo( + () => analysisData.models.map((model) => { + const pricing = modelPrices[model.model]; + const cachedTokens = Math.max(Number(model.cached_tokens) || 0, 0); + const inputTokens = Math.max(Number(model.input_tokens) || 0, 0); + const outputTokens = Math.max(Number(model.output_tokens) || 0, 0); + const promptTokens = Math.max(inputTokens - cachedTokens, 0); + const cost = pricing + ? ((promptTokens / 1_000_000) * pricing.prompt) + ((outputTokens / 1_000_000) * pricing.completion) + ((cachedTokens / 1_000_000) * pricing.cache) + : 0; + return { + model: model.model, + requests: model.total_requests, + successCount: model.success_count, + failureCount: model.failure_count, + tokens: model.total_tokens, + averageLatencyMs: model.latency_sample_count > 0 ? model.total_latency_ms / model.latency_sample_count : null, + totalLatencyMs: model.latency_sample_count > 0 ? model.total_latency_ms : null, + latencySampleCount: model.latency_sample_count, + cost, + }; + }), + [analysisData.models, modelPrices] + ); + const hasPrices = Object.keys(modelPrices).length > 0; + const overviewDisplayLoading = getOverviewDisplayLoading({ loading, hasUsage: Boolean(usage) }); + + return ( +
+
+
+
+ CPA Usage Keeper +
+
+
+ + +
+
+ {themeOptions.map((option) => { + const active = theme === option.value; + return ( + + ); + })} +
+
+ +
+
+
+ +
+
+ {loading && !usage && activeTab === 'overview' && ( +
+
+ + {t('common.loading')} +
+
+ )} + + {lastSyncAt && ( +
+ + {t('usage_stats.last_updated')}: {lastSyncAt.toLocaleTimeString()} + +
+ )} + +
+
+ {tabOptions.map((option) => ( + + ))} +
+ +
+
+ {t('usage_stats.range_filter')} + + setCustomTimeRange((current) => ({ + ...current, + start: event.target.value + })) + } + aria-label={t('usage_stats.custom_start')} + disabled={!isCustomRange} + /> + + + +
+
+
+ {showRangeControls && isCustomRange && customRangeHint && ( + {customRangeHint} + )} + {showRangeControls && isCustomRange && customRangeError && ( + {customRangeError} + )} + +
+
+ + {activeTab === 'overview' && error &&
{error === 'AUTH_REQUIRED' ? t('auth.session_expired') : error}
} + {activeTab === 'pricing' && pricingError &&
{pricingError === 'AUTH_REQUIRED' ? t('auth.session_expired') : pricingError}
} + {!(activeTab === 'overview' ? error : activeTab === 'pricing' ? pricingError : '') && statusError &&
{statusError}
} + + {activeTab === 'overview' && ( + <> + + + + + + +
+ + +
+ + + + + + )} + + {activeTab === 'analysis' && ( + <> + {analysisError &&
{analysisError}
} +
+ + +
+ + )} + + {activeTab === 'events' && ( + <> + {eventsError &&
{eventsError}
} + + + )} + + {activeTab === 'credentials' && ( + <> + {credentialsError &&
{credentialsError}
} + + + + )} + + {activeTab === 'pricing' && ( + + )} +
+ +
+
+ ); +} diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5da47721102db8d0fbaea608dd3a374041e84013 --- /dev/null +++ b/web/src/stores/index.ts @@ -0,0 +1,4 @@ +export { USAGE_STATS_STALE_TIME_MS, useUsageStatsStore } from './useUsageStatsStore'; +export { useConfigStore } from './useConfigStore'; +export { useNotificationStore } from './useNotificationStore'; +export { useThemeStore } from './useThemeStore'; diff --git a/web/src/stores/useConfigStore.ts b/web/src/stores/useConfigStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..59ecf320b4cedc7467b16441097100defc6ef2a3 --- /dev/null +++ b/web/src/stores/useConfigStore.ts @@ -0,0 +1,26 @@ +import { create } from 'zustand'; +import type { GeminiKeyConfig, OpenAIProviderConfig, ProviderKeyConfig } from '@/types'; + +export interface ConfigStateShape { + geminiApiKeys: GeminiKeyConfig[]; + claudeApiKeys: ProviderKeyConfig[]; + codexApiKeys: ProviderKeyConfig[]; + vertexApiKeys: ProviderKeyConfig[]; + openaiCompatibility: OpenAIProviderConfig[]; +} + +interface ConfigState { + config: ConfigStateShape; +} + +const emptyConfig: ConfigStateShape = { + geminiApiKeys: [], + claudeApiKeys: [], + codexApiKeys: [], + vertexApiKeys: [], + openaiCompatibility: [] +}; + +export const useConfigStore = create(() => ({ + config: emptyConfig +})); diff --git a/web/src/stores/useNotificationStore.ts b/web/src/stores/useNotificationStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4877978bd92a90e36ce72369d2ec877598da908 --- /dev/null +++ b/web/src/stores/useNotificationStore.ts @@ -0,0 +1,15 @@ +import { create } from 'zustand'; + +type NotificationLevel = 'success' | 'error' | 'info' | 'warning'; + +interface NotificationState { + showNotification: (message: string, level?: NotificationLevel) => void; +} + +export const useNotificationStore = create(() => ({ + showNotification: (message) => { + if (typeof window !== 'undefined' && message) { + window.console.info(message); + } + } +})); diff --git a/web/src/stores/useThemeStore.ts b/web/src/stores/useThemeStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..758b4079afc50a136ba4be5e5bfb734900a2f2ee --- /dev/null +++ b/web/src/stores/useThemeStore.ts @@ -0,0 +1,114 @@ +/** + * 主题状态管理 + * 从原项目 src/modules/theme.js 迁移 + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import type { Theme } from '@/types'; +import { STORAGE_KEY_THEME } from '@/utils/constants'; + +type ResolvedTheme = 'light' | 'dark'; +type AppliedTheme = ResolvedTheme | 'white'; + +interface ThemeState { + theme: Theme; + resolvedTheme: ResolvedTheme; + setTheme: (theme: Theme) => void; + cycleTheme: () => void; + initializeTheme: () => () => void; +} + +const getSystemTheme = (): ResolvedTheme => { + if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { + return 'dark'; + } + return 'light'; +}; + +const resolveAutoTheme = (): AppliedTheme => { + return getSystemTheme() === 'dark' ? 'dark' : 'white'; +}; + +const normalizeResolvedTheme = (theme: AppliedTheme): ResolvedTheme => { + return theme === 'dark' ? 'dark' : 'light'; +}; + +const resolveTheme = (theme: Theme): AppliedTheme => { + if (theme === 'auto') { + return resolveAutoTheme(); + } + if (theme === 'white') { + return 'white'; + } + return theme; +}; + +const applyTheme = (resolved: AppliedTheme) => { + if (resolved === 'dark') { + document.documentElement.setAttribute('data-theme', 'dark'); + return; + } + + if (resolved === 'white') { + document.documentElement.setAttribute('data-theme', 'white'); + return; + } + + document.documentElement.removeAttribute('data-theme'); +}; + +export const useThemeStore = create()( + persist( + (set, get) => ({ + theme: 'white', + resolvedTheme: 'light', + + setTheme: (theme) => { + const resolved = resolveTheme(theme); + applyTheme(resolved); + set({ + theme, + resolvedTheme: normalizeResolvedTheme(resolved), + }); + }, + + cycleTheme: () => { + const { theme, setTheme } = get(); + const order: Theme[] = ['light', 'white', 'dark', 'auto']; + const currentIndex = order.indexOf(theme); + const nextTheme = order[(currentIndex + 1) % order.length]; + setTheme(nextTheme); + }, + + initializeTheme: () => { + const { theme, setTheme } = get(); + + // 应用已保存的主题 + setTheme(theme); + + // 监听系统主题变化(仅在 auto 模式下生效) + if (!window.matchMedia) { + return () => {}; + } + + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const listener = () => { + const { theme: currentTheme } = get(); + if (currentTheme === 'auto') { + const resolved = resolveAutoTheme(); + applyTheme(resolved); + set({ resolvedTheme: normalizeResolvedTheme(resolved) }); + } + }; + + mediaQuery.addEventListener('change', listener); + + return () => mediaQuery.removeEventListener('change', listener); + }, + }), + { + name: STORAGE_KEY_THEME, + } + ) +); diff --git a/web/src/stores/useUsageStatsStore.ts b/web/src/stores/useUsageStatsStore.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad698a57f4879a8e43cb5037bec237b108ef58e7 --- /dev/null +++ b/web/src/stores/useUsageStatsStore.ts @@ -0,0 +1,107 @@ +import { create } from 'zustand'; +import { ApiError, fetchUsageOverview } from '@/lib/api'; +import type { UsageOverviewResponse, UsageTimeRange } from '@/lib/types'; + +export const USAGE_STATS_STALE_TIME_MS = 60_000; + +interface LoadUsageStatsOptions { + force?: boolean; + staleTimeMs?: number; + range?: UsageTimeRange; + start?: string; + end?: string; +} + +interface UsageStatsState { + usage: UsageOverviewResponse | null; + loading: boolean; + error: string; + lastRefreshedAt: number | null; + lastQueryKey: string | null; + loadUsageStats: (options?: LoadUsageStatsOptions) => Promise; + clearUsageStats: () => void; +} + +let activeRequest: Promise | null = null; +let activeRequestKey: string | null = null; +let activeRequestController: AbortController | null = null; + +const buildQueryKey = (range: UsageTimeRange, start?: string, end?: string): string => + `${range}:${start ?? ''}:${end ?? ''}`; + +export const useUsageStatsStore = create((set, get) => ({ + usage: null, + loading: false, + error: '', + lastRefreshedAt: null, + lastQueryKey: null, + loadUsageStats: async (options = {}) => { + const { + force = false, + staleTimeMs = USAGE_STATS_STALE_TIME_MS, + range = 'all', + start, + end, + } = options; + const { lastRefreshedAt, loading, usage, lastQueryKey } = get(); + const now = Date.now(); + const queryKey = buildQueryKey(range, start, end); + + if (!force && usage && lastRefreshedAt && lastQueryKey === queryKey && now - lastRefreshedAt < staleTimeMs) { + return; + } + + if (loading && activeRequest) { + if (activeRequestKey === queryKey) { + return activeRequest; + } + activeRequestController?.abort(); + } + + const controller = new AbortController(); + activeRequestController = controller; + activeRequestKey = queryKey; + set({ loading: true, error: '' }); + + activeRequest = (async () => { + try { + const response = await fetchUsageOverview(range, start, end, controller.signal); + if (activeRequestController !== controller) { + return; + } + set({ + usage: response, + loading: false, + error: '', + lastRefreshedAt: Date.now(), + lastQueryKey: queryKey, + }); + } catch (error) { + if (controller.signal.aborted) { + return; + } + const message = error instanceof ApiError && error.status === 401 + ? 'AUTH_REQUIRED' + : error instanceof Error + ? error.message + : 'Failed to load usage overview' + if (activeRequestController === controller) { + set({ + loading: false, + error: message + }); + } + throw error; + } finally { + if (activeRequestController === controller) { + activeRequest = null; + activeRequestKey = null; + activeRequestController = null; + } + } + })(); + + return activeRequest; + }, + clearUsageStats: () => set({ usage: null, error: '', loading: false, lastRefreshedAt: null, lastQueryKey: null }) +})); diff --git a/web/src/styles/components.scss b/web/src/styles/components.scss new file mode 100644 index 0000000000000000000000000000000000000000..b596699687d8675131f43a30b8b8c4788cb406ee --- /dev/null +++ b/web/src/styles/components.scss @@ -0,0 +1,802 @@ +@use 'sass:color'; +@use './variables.scss' as *; + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $spacing-sm; + border: 1px solid transparent; + border-radius: $radius-md; + padding: 10px 14px; + font-weight: 600; + cursor: pointer; + transition: all $transition-fast; + background-color: var(--bg-secondary); + color: var(--text-primary); + + &.btn-primary { + background-color: var(--primary-color); + color: var(--primary-contrast, #fff); + border-color: var(--primary-color); + + &:hover { + background-color: var(--primary-hover); + border-color: var(--primary-hover); + } + } + + &.btn-secondary { + background-color: var(--bg-tertiary); + border-color: var(--border-color); + color: var(--text-primary); + + &:hover { + background-color: var(--bg-hover, var(--bg-tertiary)); + border-color: var(--border-hover); + } + } + + &.btn-ghost { + background: transparent; + border-color: transparent; + color: var(--text-secondary); + + &:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + } + } + + &.btn-danger { + background-color: $error-color; + border-color: $error-color; + color: #fff; + + &:hover { + background-color: color.adjust($error-color, $lightness: -5%); + } + } + + &.btn-full { + width: 100%; + } + + &.btn-sm { + padding: 8px 10px; + font-size: 14px; + } + + &:disabled { + opacity: 0.6; + cursor: not-allowed; + } +} + +[data-theme='dark'] { + .btn { + color: #fff; + } + + .btn.btn-secondary, + .btn.btn-ghost { + color: #fff; + } +} + +.input, +textarea { + width: 100%; + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: 10px 12px; + background-color: var(--bg-secondary); + color: var(--text-primary); + transition: border-color $transition-fast, box-shadow $transition-fast; + + &:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 3px rgba($primary-color, 0.18); + } +} + +.form-group { + display: flex; + flex-direction: column; + gap: $spacing-xs; + margin-bottom: $spacing-md; + + label { + font-weight: 600; + color: var(--text-primary); + } + + .hint { + color: var(--text-secondary); + font-size: 13px; + } +} + +.card { + background-color: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: $radius-lg; + box-shadow: var(--shadow); + padding: $spacing-lg; +} + +.card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-sm; + flex-wrap: wrap; + margin-bottom: $spacing-md; + + > * { + min-width: 0; + } + + .title { + flex: 1 1 auto; + min-width: 0; + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + } +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + border-radius: $radius-full; + font-size: 13px; + border: 1px solid var(--border-color); + margin-bottom: $spacing-md; + + // 确保后续内容换行显示 + + * { + display: block; + } + + &.success { + color: $success-color; + border-color: rgba($success-color, 0.35); + background: rgba($success-color, 0.08); + } + + &.warning { + color: $warning-color; + border-color: rgba($warning-color, 0.35); + background: rgba($warning-color, 0.08); + } + + &.error { + color: $error-color; + border-color: rgba($error-color, 0.35); + background: rgba($error-color, 0.08); + } + + &.muted { + color: var(--text-secondary); + } +} + +.notification-container { + position: fixed; + top: $spacing-lg; + right: $spacing-lg; + display: flex; + flex-direction: column; + gap: $spacing-sm; + z-index: $z-notification; + max-width: 360px; +} + +@keyframes notification-enter { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes notification-exit { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +.notification { + padding: $spacing-md; + border-radius: $radius-md; + box-shadow: var(--shadow); + border: 1px solid var(--border-color); + background: var(--bg-primary); + color: var(--text-primary); + display: flex; + justify-content: space-between; + align-items: center; + gap: $spacing-sm; + + // Animation states + &.entering { + animation: notification-enter 0.3s ease-out forwards; + } + + &.exiting { + animation: notification-exit 0.3s ease-in forwards; + } + + &.success { + border-color: rgba($success-color, 0.4); + } + &.warning { + border-color: rgba($warning-color, 0.4); + } + &.error { + border-color: rgba($error-color, 0.4); + } + + .message { + flex: 1; + font-weight: 500; + } + + .close-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + padding: 0; + background: transparent; + border: none; + color: var(--text-secondary); + border-radius: $radius-md; + cursor: pointer; + transition: color 0.15s ease, background-color 0.15s ease; + + svg { + display: block; + } + + &:hover { + color: var(--text-primary); + background: var(--bg-secondary); + } + } +} + +.switch { + position: relative; + display: inline-flex; + align-items: center; + gap: $spacing-sm; + cursor: pointer; + + input { + width: 0; + height: 0; + opacity: 0; + position: absolute; + } + + .track { + width: 44px; + height: 24px; + background: var(--border-color); + border-radius: $radius-full; + position: relative; + transition: background $transition-fast; + } + + .thumb { + position: absolute; + top: 3px; + left: 3px; + width: 18px; + height: 18px; + background: #fff; + border-radius: $radius-full; + box-shadow: $shadow-sm; + transition: transform $transition-fast; + } + + input:checked + .track { + background: var(--primary-color); + } + + input:checked + .track .thumb { + transform: translateX(20px); + } + + .label { + color: var(--text-primary); + font-weight: 600; + } +} + +.switch-label-left { + .label { + order: -1; + } +} + +.pill { + padding: 4px 10px; + border-radius: $radius-full; + background: var(--bg-secondary); + color: var(--text-secondary); + border: 1px solid var(--border-color); + font-size: 12px; +} + +.loading-spinner { + // Fallback: legacy white spinner (in case color-mix is unsupported) + border: 3px solid rgba(255, 255, 255, 0.2); + border-top-color: #fff; + border-radius: 50%; + width: 16px; + height: 16px; + animation: spin 0.8s linear infinite; +} + +@supports (color: color-mix(in srgb, currentColor 22%, transparent)) { + .loading-spinner { + border-color: color-mix(in srgb, currentColor 22%, transparent); + border-top-color: currentColor; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.35); + display: flex; + align-items: center; + justify-content: center; + z-index: $z-modal; + padding: $spacing-lg; + + &.modal-overlay-entering { + animation: modal-overlay-fade-in 0.25s ease-out forwards; + } + + &.modal-overlay-closing { + animation: modal-overlay-fade-out 0.35s ease-in forwards; + } +} + +@keyframes modal-overlay-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes modal-overlay-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.modal { + background: var(--bg-primary); + border-radius: $radius-lg; + border: 1px solid var(--border-color); + box-shadow: $shadow-lg; + max-width: 100%; + max-height: 90vh; + overflow: hidden; + display: flex; + flex-direction: column; + position: relative; + // 关闭按钮中心位置: right 12px + 16px = 28px, top 12px + 16px = 28px + transform-origin: calc(100% - 28px) 28px; + + &.modal-entering { + animation: modal-scale-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1) forwards; + } + + &.modal-closing { + animation: modal-collapse-to-close 0.35s cubic-bezier(0.4, 0, 0.2, 1) forwards; + } +} + +@keyframes modal-scale-in { + from { + opacity: 0; + transform: scale(0.85) translateY(20px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes modal-collapse-to-close { + 0% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0; + transform: scale(0); + } +} + +.modal-close-floating { + position: absolute; + top: 12px; + right: 12px; + width: 32px; + height: 32px; + padding: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + color: var(--text-secondary); + cursor: pointer; + border-radius: $radius-full; + display: inline-flex; + align-items: center; + justify-content: center; + transition: color 0.15s ease, background-color 0.15s ease, transform 0.15s ease; + z-index: 10; + + svg { + display: block; + } + + &:hover { + color: var(--text-primary); + background: var(--bg-tertiary); + transform: scale(1.1); + } + + &:active { + transform: scale(0.95); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.6; + transform: none; + } + + &:disabled:hover { + color: var(--text-secondary); + background: var(--bg-secondary); + transform: none; + } +} + +.modal-header { + display: flex; + align-items: center; + padding: $spacing-md $spacing-lg; + border-bottom: 1px solid var(--border-color); + + .modal-title { + font-weight: 700; + font-size: 18px; + color: var(--text-primary); + } +} + +.modal-body { + padding: $spacing-lg; + overflow: auto; + max-height: 65vh; +} + +.modal-footer { + padding: $spacing-md $spacing-lg; + border-top: 1px solid var(--border-color); + display: flex; + justify-content: flex-end; + gap: $spacing-sm; + background: var(--bg-primary); +} + +@media (max-width: $breakpoint-mobile) { + .modal-overlay { + padding: $spacing-md; + } + + .modal { + max-height: calc(100vh - #{$spacing-md * 2}); + border-radius: $radius-md; + } + + @supports (height: 100dvh) { + .modal { + max-height: calc(100dvh - #{$spacing-md * 2}); + } + } + + .modal-header { + padding: $spacing-md; + padding-right: 52px; + } + + .modal-body { + padding: $spacing-md; + max-height: min(60vh, calc(100vh - 180px)); + } + + @supports (height: 100dvh) { + .modal-body { + max-height: min(60dvh, calc(100dvh - 180px)); + } + } + + .modal-footer { + padding: $spacing-md; + flex-direction: column-reverse; + align-items: stretch; + } + + .modal-footer .btn { + width: 100%; + } +} + +.request-log-modal { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: $spacing-md; + + .status-badge { + margin-bottom: 0; + } +} + +.empty-state { + border: 1px dashed var(--border-color); + border-radius: $radius-lg; + padding: $spacing-lg; + background: var(--bg-secondary); + display: flex; + justify-content: space-between; + gap: $spacing-md; + align-items: center; + + .empty-content { + display: flex; + align-items: center; + gap: $spacing-md; + } + + .empty-icon { + width: 42px; + height: 42px; + border-radius: $radius-full; + border: 2px solid var(--border-color); + display: grid; + place-items: center; + color: var(--text-secondary); + + svg { + display: block; + } + } + + .empty-title { + font-weight: 700; + color: var(--text-primary); + } + + .empty-desc { + color: var(--text-secondary); + margin-top: 4px; + } +} + +.header-input-list { + display: flex; + flex-direction: column; + gap: $spacing-sm; + + .header-input-row { + display: grid; + grid-template-columns: 1fr auto 1fr auto; + align-items: center; + gap: $spacing-sm; + } + + .header-separator { + color: var(--text-secondary); + text-align: center; + } + + .align-start { + width: fit-content; + } +} + +.item-list { + display: flex; + flex-direction: column; + gap: $spacing-sm; +} + +.item-row { + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: $spacing-md; + background: var(--bg-primary); + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + flex-wrap: wrap; + + .item-meta { + display: flex; + flex-direction: column; + gap: 6px; + } + + .item-title { + font-weight: 700; + color: var(--text-primary); + } + + .item-subtitle { + color: var(--text-secondary); + font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', + monospace; + word-break: break-all; + } + + .item-actions { + display: flex; + gap: $spacing-sm; + } +} + +.error-box { + background: rgba($error-color, 0.1); + border: 1px solid rgba($error-color, 0.4); + border-radius: $radius-md; + padding: $spacing-sm $spacing-md; + color: $error-color; +} + +.stack { + display: flex; + flex-direction: column; + gap: $spacing-lg; +} + +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: $spacing-md; + margin-bottom: $spacing-md; + + .filter-item { + display: flex; + flex-direction: column; + gap: $spacing-xs; + } +} + +.table { + display: flex; + flex-direction: column; + border: 1px solid var(--border-color); + border-radius: $radius-md; + overflow: hidden; + + .table-header, + .table-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: $spacing-sm; + padding: $spacing-sm $spacing-md; + align-items: center; + } + + .table-header { + background: var(--bg-secondary); + font-weight: 700; + color: var(--text-primary); + } + + .table-row { + border-top: 1px solid var(--border-color); + } + + .cell { + display: flex; + align-items: center; + gap: $spacing-sm; + flex-wrap: wrap; + } +} + +.pagination { + display: flex; + align-items: center; + gap: $spacing-sm; + margin-top: $spacing-md; +} + +.stat-card { + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: $spacing-md; + background: var(--bg-primary); + display: flex; + flex-direction: column; + gap: $spacing-xs; + + .stat-label { + color: var(--text-secondary); + font-size: 14px; + } + + .stat-value { + font-weight: 800; + color: var(--text-primary); + font-size: 18px; + } +} + +.log-viewer { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; + padding: $spacing-md; + max-height: 520px; + overflow: auto; + white-space: pre-wrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; + color: var(--text-primary); +} + +.log-viewer-lines { + .log-line { + display: block; + padding: 1px 0; + } + + .log-line-warning { + color: var(--warning-text, #92400e); + background: var(--warning-bg, rgba(251, 191, 36, 0.18)); + border-radius: 4px; + padding: 2px 6px; + } +} + +.hint { + color: var(--text-secondary); +} diff --git a/web/src/styles/global.scss b/web/src/styles/global.scss new file mode 100644 index 0000000000000000000000000000000000000000..14ed595698465ff28e06465347b0897feb057850 --- /dev/null +++ b/web/src/styles/global.scss @@ -0,0 +1,79 @@ +/** + * 全局样式 + */ + +@use './variables.scss' as *; +@use './mixins.scss' as *; +@use './reset.scss'; +@use './themes.scss'; +@use './components.scss'; +@use './layout.scss'; + +body { + background-color: var(--bg-secondary); + color: var(--text-primary); + transition: background-color $transition-normal, color $transition-normal; +} + +html.modal-open, +body.modal-open { + overflow: hidden; +} + +body.modal-open .content { + overflow: hidden; +} + +// 滚动条样式 +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-secondary); +} + +::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: $radius-full; + + &:hover { + background: var(--border-hover); + } +} + +// 通用工具类 +.container { + width: 100%; + max-width: 1280px; + margin: 0 auto; + padding: 0 $spacing-md; +} + +.flex-center { + @include flex-center; +} + +.text-ellipsis { + @include text-ellipsis; +} + +// 通用过渡 +.fade-enter { + opacity: 0; +} + +.fade-enter-active { + opacity: 1; + transition: opacity $transition-normal; +} + +.fade-exit { + opacity: 1; +} + +.fade-exit-active { + opacity: 0; + transition: opacity $transition-normal; +} diff --git a/web/src/styles/layout.scss b/web/src/styles/layout.scss new file mode 100644 index 0000000000000000000000000000000000000000..7c38ab0d9250913ea2d114dd0912db1ea9ee714e --- /dev/null +++ b/web/src/styles/layout.scss @@ -0,0 +1,636 @@ +@use './variables.scss' as *; + +:root { + --header-height: 64px; +} + +.app-shell { + display: flex; + flex-direction: column; + min-height: 100vh; + height: 100vh; + overflow: hidden; + background: var(--bg-secondary); + color: var(--text-primary); + + @media (max-width: $breakpoint-mobile) { + height: auto; + min-height: 100vh; + overflow: visible; + overflow-y: auto; + } +} + +.main-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: $spacing-md; + padding: $spacing-md $spacing-lg; + background: var(--bg-primary); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 10; + width: 100%; + + @media (max-width: $breakpoint-mobile) { + position: fixed; + left: 0; + right: 0; + padding: $spacing-sm $spacing-md; + gap: $spacing-sm; + } + + .left { + display: flex; + align-items: center; + gap: $spacing-sm; + min-width: 0; + flex: 0 1 auto; + + .brand-logo { + height: 32px; + width: 32px; + object-fit: contain; + flex-shrink: 0; + border-radius: $radius-sm; + } + } + + .right { + display: flex; + align-items: center; + gap: $spacing-md; + min-width: 0; + flex: 1 1 auto; + justify-content: flex-end; + + @media (max-width: $breakpoint-mobile) { + flex: 0 1 auto; + gap: $spacing-sm; + } + } + + .sidebar-toggle-header { + padding: 0; + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: $radius-md; + color: var(--text-secondary); + cursor: pointer; + font-size: 16px; + font-weight: bold; + transition: + background $transition-fast, + color $transition-fast; + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + line-height: 1; + + &:hover { + background: var(--bg-tertiary, var(--border-color)); + color: var(--text-primary); + } + + @media (max-width: $breakpoint-mobile) { + display: none; + } + } + + .brand-header { + display: flex; + align-items: center; + gap: $spacing-xs; + font-weight: 800; + font-size: 18px; + color: var(--text-primary); + margin-right: $spacing-md; + cursor: pointer; + overflow: hidden; + white-space: nowrap; + flex-shrink: 0; + + .brand-full { + display: inline-block; + max-width: 320px; + opacity: 1; + transform: translateX(0); + transition: + max-width 0.4s ease, + opacity 0.4s ease, + transform 0.4s ease; + } + + .brand-abbr { + display: inline-block; + transform: translateX(12px); + opacity: 0; + pointer-events: none; + transition: + opacity 0.4s ease, + transform 0.4s ease; + } + + &.collapsed { + .brand-full { + max-width: 0; + opacity: 0; + transform: translateX(-12px); + } + + .brand-abbr { + transform: translateX(0); + opacity: 1; + pointer-events: auto; + } + } + + &:hover { + color: var(--primary-color); + } + + // 移动端:禁用动画,只显示缩写 + @media (max-width: $breakpoint-mobile) { + cursor: default; + flex-shrink: 1; + min-width: 0; + margin-right: 0; + + .brand-full, + .brand-abbr { + transition: none; + } + + .brand-full { + display: none; + } + + .brand-abbr { + transform: translateX(0); + opacity: 1; + pointer-events: auto; + } + + &:hover { + color: var(--text-primary); + } + } + } + + .mobile-menu-btn { + display: none; + flex-shrink: 0; + + @media (max-width: $breakpoint-mobile) { + display: inline-flex; + } + } + + .header-actions { + display: flex; + align-items: center; + gap: $spacing-xs; + flex-shrink: 0; + + .language-menu { + position: relative; + display: inline-flex; + align-items: center; + + .language-menu-popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: $z-dropdown; + min-width: 164px; + padding: $spacing-xs; + display: flex; + flex-direction: column; + gap: 2px; + } + + .language-menu-option { + width: 100%; + border: none; + border-radius: $radius-sm; + background: transparent; + color: var(--text-primary); + cursor: pointer; + padding: 8px 10px; + font-size: 14px; + font-weight: 500; + display: flex; + align-items: center; + justify-content: space-between; + transition: + background-color $transition-fast, + color $transition-fast; + + &:hover { + background: var(--bg-secondary); + } + + &:focus-visible { + outline: none; + background: var(--bg-secondary); + box-shadow: 0 0 0 2px rgba($primary-color, 0.22); + } + + &.active { + color: var(--primary-color); + font-weight: 600; + } + } + + .language-menu-check { + font-size: 13px; + line-height: 1; + } + + @media (max-width: $breakpoint-mobile) { + .language-menu-popover { + right: auto; + left: 0; + } + } + } + + .theme-menu { + position: relative; + display: inline-flex; + align-items: center; + + .theme-menu-popover { + position: absolute; + top: calc(100% + 8px); + right: 0; + z-index: $z-dropdown; + padding: $spacing-sm $spacing-sm $spacing-xs; + display: flex; + gap: $spacing-xs; + width: max-content; + max-width: calc(100vw - 16px); + } + + .theme-card { + border: 2px solid transparent; + border-radius: $radius-md; + background: transparent; + cursor: pointer; + padding: 6px 6px 4px; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + transition: + border-color $transition-fast, + background-color $transition-fast; + + &:hover { + background: var(--bg-secondary); + } + + &:focus-visible { + outline: none; + box-shadow: 0 0 0 2px rgba($primary-color, 0.22); + } + + &.active { + border-color: var(--primary-color); + } + } + + .theme-card-preview { + width: 72px; + height: 52px; + border-radius: $radius-sm; + overflow: hidden; + display: flex; + flex-direction: column; + } + + .theme-card-header { + height: 10px; + flex-shrink: 0; + } + + .theme-card-body { + flex: 1; + display: flex; + min-height: 0; + } + + .theme-card-sidebar { + width: 16px; + flex-shrink: 0; + } + + .theme-card-content { + flex: 1; + padding: 5px 8px; + display: flex; + flex-direction: column; + gap: 4px; + justify-content: center; + } + + .theme-card-line { + height: 3px; + border-radius: 1px; + + &.short { + width: 60%; + } + } + + .theme-card-label { + font-size: 11px; + color: var(--text-primary); + font-weight: 500; + white-space: nowrap; + } + + @media (max-width: $breakpoint-mobile) { + .theme-menu-popover { + right: 0; + left: auto; + transform: none; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + justify-content: stretch; + width: min(188px, calc(100vw - 16px)); + } + + .theme-card { + width: 100%; + min-width: 0; + } + + .theme-card-label { + white-space: normal; + text-align: center; + line-height: 1.2; + } + } + } + + svg { + display: block; + } + + @media (max-width: $breakpoint-mobile) { + gap: 2px; + } + } + + .connection { + display: flex; + align-items: center; + gap: $spacing-sm; + color: var(--text-secondary); + min-width: 0; + flex-shrink: 1; + overflow: hidden; + + .status-badge { + flex-shrink: 0; + white-space: nowrap; + margin-bottom: 0; + } + + .base { + font-weight: 600; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 80px; + } + + @media (max-width: $breakpoint-mobile) { + display: none; + } + } +} + +.main-body { + display: flex; + flex: 1; + min-height: 0; + height: calc(100vh - var(--header-height)); + overflow: hidden; + position: relative; + + @supports (height: 100dvh) { + height: calc(100dvh - var(--header-height)); + } + + @media (max-width: $breakpoint-mobile) { + height: auto; + min-height: calc(100vh - var(--header-height)); + overflow: visible; + padding-top: var(--header-height); + + @supports (min-height: 100dvh) { + min-height: calc(100dvh - var(--header-height)); + } + } +} + +.sidebar-backdrop { + display: none; + border: 0; + padding: 0; + margin: 0; + background: rgb(15 23 42 / 0.18); + opacity: 0; + pointer-events: none; + transition: opacity $transition-fast; + + @media (max-width: $breakpoint-mobile) { + display: block; + position: fixed; + inset: var(--header-height) 0 0; + z-index: $z-dropdown - 1; + + &.visible { + opacity: 1; + pointer-events: auto; + } + } +} + +.sidebar { + width: 240px; + background: var(--bg-primary); + border-right: 1px solid var(--border-color); + padding: $spacing-md; + display: flex; + flex-direction: column; + gap: $spacing-lg; + transition: + width $transition-normal, + transform $transition-normal; + overflow-y: auto; + flex-shrink: 0; + height: 100%; + + &.collapsed { + width: 60px; + padding: $spacing-md $spacing-sm; + + .nav-item { + justify-content: center; + padding: 10px; + } + } + + .nav-section { + display: flex; + flex-direction: column; + gap: $spacing-sm; + flex: 1; + } + + .nav-item { + padding: 10px 12px; + border-radius: $radius-md; + border: 1px solid transparent; + color: var(--text-primary); + font-weight: 600; + display: flex; + align-items: center; + gap: $spacing-sm; + cursor: pointer; + transition: + background $transition-fast, + color $transition-fast; + + .nav-icon { + width: 28px; + height: 28px; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0.96; + border-radius: 7px; + background: linear-gradient(180deg, rgb(255 255 255 / 0.18), rgb(255 255 255 / 0)) + var(--bg-secondary); + box-shadow: inset 0 0 0 1px var(--border-primary); + transition: + background $transition-fast, + box-shadow $transition-fast, + color $transition-fast; + + svg { + width: 18px; + height: 18px; + display: block; + } + } + + .nav-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &:hover { + background: var(--bg-secondary); + + .nav-icon { + background: linear-gradient(180deg, rgb(255 255 255 / 0.24), rgb(255 255 255 / 0)) + var(--bg-primary); + box-shadow: inset 0 0 0 1px var(--border-hover); + } + } + + &.active { + background: rgba($primary-color, 0.14); + color: var(--primary-color); + border: 1px solid rgba($primary-color, 0.35); + + .nav-icon { + background: linear-gradient(180deg, rgb(255 255 255 / 0.22), rgb(255 255 255 / 0)) + rgba($primary-color, 0.1); + box-shadow: inset 0 0 0 1px rgba($primary-color, 0.26); + } + } + } + + @media (max-width: $breakpoint-mobile) { + position: fixed; + z-index: $z-dropdown; + left: 0; + top: var(--header-height); + bottom: 0; + transform: translateX(-100%); + box-shadow: $shadow-lg; + + &.open { + transform: translateX(0); + } + } +} + +.content { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow-y: auto; + scrollbar-gutter: stable; + height: 100%; + + &.content-logs { + overflow: hidden; + + @media (max-width: $breakpoint-mobile) { + overflow: visible; + overflow-y: auto; + height: auto; + } + } +} + +.main-content { + flex: 1 0 auto; + padding: $spacing-lg; + display: flex; + flex-direction: column; + gap: $spacing-lg; + overflow-x: hidden; + + &.main-content-logs { + flex: 1 1 auto; + min-height: 0; + overflow: hidden; + + @media (max-width: $breakpoint-mobile) { + flex: 0 0 auto; + min-height: auto; + overflow: visible; + } + } + + @media (max-width: $breakpoint-mobile) { + padding: $spacing-md; + } +} + +.grid { + display: grid; + gap: $spacing-lg; +} + +@media (min-width: $breakpoint-tablet) { + .grid.cols-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/web/src/styles/mixins.scss b/web/src/styles/mixins.scss new file mode 100644 index 0000000000000000000000000000000000000000..c051696ca485e7095ad47547e1f68809d8387c6c --- /dev/null +++ b/web/src/styles/mixins.scss @@ -0,0 +1,63 @@ +/** + * SCSS 混入 + */ + +@use './variables.scss' as *; + +// 响应式断点 +@mixin mobile { + @media (max-width: #{$breakpoint-mobile}) { + @content; + } +} + +@mixin tablet { + @media (min-width: #{$breakpoint-mobile + 1}) and (max-width: #{$breakpoint-tablet}) { + @content; + } +} + +@mixin desktop { + @media (min-width: #{$breakpoint-tablet + 1}) { + @content; + } +} + +// Flexbox 居中 +@mixin flex-center { + display: flex; + align-items: center; + justify-content: center; +} + +// 文本截断 +@mixin text-ellipsis { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +// 多行文本截断 +@mixin text-ellipsis-multiline($lines: 2) { + display: -webkit-box; + -webkit-line-clamp: $lines; + -webkit-box-orient: vertical; + overflow: hidden; +} + +// 按钮重置 +@mixin button-reset { + border: none; + background: none; + padding: 0; + margin: 0; + cursor: pointer; + font: inherit; + color: inherit; + outline: none; + + &:focus { + outline: 2px solid $primary-color; + outline-offset: 2px; + } +} diff --git a/web/src/styles/reset.scss b/web/src/styles/reset.scss new file mode 100644 index 0000000000000000000000000000000000000000..b8f087cb72baad569d37cabedb8bd5548abbbcc0 --- /dev/null +++ b/web/src/styles/reset.scss @@ -0,0 +1,49 @@ +/** + * CSS Reset + */ + +@use './variables.scss' as *; + +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html, +body { + height: 100%; + width: 100%; +} + +body { + margin: 0; + font-family: $font-family; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + line-height: 1.5; +} + +#root { + height: 100%; + width: 100%; +} + +button, +input, +textarea, +select { + font: inherit; +} + +a { + color: inherit; + text-decoration: none; +} + +ul, +ol { + list-style: none; +} diff --git a/web/src/styles/themes.scss b/web/src/styles/themes.scss new file mode 100644 index 0000000000000000000000000000000000000000..80e2f875a9f255f2e455c773d723e9aa0309bc34 --- /dev/null +++ b/web/src/styles/themes.scss @@ -0,0 +1,196 @@ +/** + * 主题样式 + */ + +// 浅色主题(默认) +:root { + // 极简暖灰:浅色模式 + --bg-secondary: #faf9f5; // 页面背景(纸感) + --bg-primary: #f0eee8; // 容器/卡片背景 + --bg-tertiary: #e9e6df; // hover/次级背景 + --bg-hover: var(--bg-tertiary); + --bg-quinary: #f6f4ee; + --bg-error-light: rgba(198, 87, 70, 0.1); + --floating-surface: #fffdf9; + --floating-border: #d8d3ca; + --floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.14); + + --text-primary: #2d2a26; + --text-secondary: #6d6760; + --text-tertiary: #a29c95; + --text-quaternary: #c0bab3; + --text-muted: var(--text-tertiary); + + --border-color: #e3e1db; // 边框/分割线 + --border-secondary: var(--border-color); + --border-primary: #d5d2cb; + --border-hover: #cecac4; + + --primary-color: #8b8680; // 行动色(主色) + --primary-hover: #7f7a74; + --primary-active: #726d67; + --primary-contrast: #ffffff; + + --success-color: #10b981; + --quota-medium-color: #e0aa14; + --warning-color: #c65746; // 错误/警告色 + --error-color: #c65746; + --danger-color: var(--error-color); + --info-color: var(--primary-color); + + --warning-bg: rgba(198, 87, 70, 0.12); + --warning-border: rgba(198, 87, 70, 0.35); + --warning-text: var(--warning-color); + + --success-badge-bg: #d1fae5; + --success-badge-text: #065f46; + --success-badge-border: #6ee7b7; + + --failure-badge-bg: rgba(198, 87, 70, 0.14); + --failure-badge-text: #8a3a30; + --failure-badge-border: rgba(198, 87, 70, 0.35); + + --count-badge-bg: rgba(139, 134, 128, 0.18); + --count-badge-text: var(--primary-active); + + --shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08); + --shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1); + + --radius-md: 8px; + --accent-tertiary: var(--bg-tertiary); + + // Glass effect (blurred surfaces) + --glass-blur: 12px; + --glass-backdrop-filter: blur(var(--glass-blur)); + --glass-filter: blur(var(--glass-blur)); + --glass-bg: color-mix(in srgb, var(--bg-primary) 82%, transparent); + --glass-bg-secondary: color-mix(in srgb, var(--bg-secondary) 82%, transparent); + --glass-border: color-mix(in srgb, var(--border-color) 60%, transparent); +} + +// 纯白主题 +[data-theme='white'] { + --bg-secondary: #ffffff; + --bg-primary: #ffffff; + --bg-tertiary: #f6f6f6; + --bg-hover: var(--bg-tertiary); + --bg-quinary: #ffffff; + --bg-error-light: rgba(198, 87, 70, 0.08); + --floating-surface: #ffffff; + --floating-border: #d9d9d9; + --floating-shadow: 0 12px 26px rgba(0, 0, 0, 0.12); + + --text-primary: #2d2a26; + --text-secondary: #6d6760; + --text-tertiary: #a29c95; + --text-quaternary: #c0bab3; + --text-muted: var(--text-tertiary); + + --border-color: #e5e5e5; + --border-secondary: var(--border-color); + --border-primary: #d9d9d9; + --border-hover: #cccccc; + + --primary-color: #8b8680; + --primary-hover: #7f7a74; + --primary-active: #726d67; + --primary-contrast: #ffffff; + + --success-color: #10b981; + --quota-medium-color: #e0aa14; + --warning-color: #c65746; + --error-color: #c65746; + --danger-color: var(--error-color); + --info-color: var(--primary-color); + + --warning-bg: rgba(198, 87, 70, 0.12); + --warning-border: rgba(198, 87, 70, 0.35); + --warning-text: var(--warning-color); + + --success-badge-bg: #d1fae5; + --success-badge-text: #065f46; + --success-badge-border: #6ee7b7; + + --failure-badge-bg: rgba(198, 87, 70, 0.14); + --failure-badge-text: #8a3a30; + --failure-badge-border: rgba(198, 87, 70, 0.35); + + --count-badge-bg: rgba(139, 134, 128, 0.18); + --count-badge-text: var(--primary-active); + + --shadow: 0 1px 2px 0 rgb(0 0 0 / 0.08); + --shadow-lg: 0 10px 18px -3px rgb(0 0 0 / 0.1); + + --radius-md: 8px; + --accent-tertiary: var(--bg-tertiary); +} + +// 深色主题(#191919) +[data-theme='dark'] { + // 极简暖灰:深色模式(提升对比度与层级) + --bg-secondary: #151412; // 页面背景 + --bg-primary: #1d1b18; // 容器/卡片背景 + --bg-tertiary: #262320; // hover/次级背景 + --bg-hover: #2e2a26; + --bg-quinary: #191714; + --bg-error-light: rgba(198, 87, 70, 0.18); + --floating-surface: #2a2723; + --floating-border: #4a443d; + --floating-shadow: 0 14px 30px rgba(0, 0, 0, 0.4); + + --text-primary: #f6f4f1; + --text-secondary: #c9c3bb; + --text-tertiary: #9c958d; + --text-quaternary: #6f6962; + --text-muted: var(--text-tertiary); + + --border-color: #3a3530; + --border-secondary: var(--border-color); + --border-primary: #4a453f; + --border-hover: #5a544d; + + --primary-color: #8b8680; + --primary-hover: #9a948e; + --primary-active: #a6a099; + --primary-contrast: #ffffff; + + --success-color: #10b981; + --quota-medium-color: #ffd862; + --warning-color: #c65746; + --error-color: #c65746; + --danger-color: var(--error-color); + --info-color: var(--primary-color); + + --warning-bg: rgba(198, 87, 70, 0.22); + --warning-border: rgba(198, 87, 70, 0.45); + --warning-text: #f1b0a6; + + --success-badge-bg: rgba(6, 78, 59, 0.3); + --success-badge-text: #6ee7b7; + --success-badge-border: #059669; + + --failure-badge-bg: rgba(198, 87, 70, 0.24); + --failure-badge-text: #f1b0a6; + --failure-badge-border: rgba(198, 87, 70, 0.5); + + --count-badge-bg: rgba(139, 134, 128, 0.28); + --count-badge-text: var(--primary-active); + + --shadow: 0 1px 3px 0 rgb(0 0 0 / 0.3); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.3); + + --radius-md: 8px; + --accent-tertiary: var(--bg-tertiary); + + --glass-border: color-mix(in srgb, var(--border-color) 55%, transparent); +} + +@media (max-width: 768px), (prefers-reduced-motion: reduce), (prefers-reduced-transparency: reduce) { + :root { + --glass-backdrop-filter: none; + --glass-filter: none; + --glass-bg: var(--bg-primary); + --glass-bg-secondary: var(--bg-secondary); + --glass-border: var(--border-color); + } +} diff --git a/web/src/styles/variables.scss b/web/src/styles/variables.scss new file mode 100644 index 0000000000000000000000000000000000000000..ec7e53a2e74423d5f8399457f573fd475f40983c --- /dev/null +++ b/web/src/styles/variables.scss @@ -0,0 +1,62 @@ +/** + * SCSS 变量定义 + */ + +// 颜色 +$primary-color: #8b8680; +$success-color: #10b981; +$warning-color: #c65746; +$error-color: #c65746; +$info-color: #8b8680; + +// 灰阶 +$gray-50: #f9fafb; +$gray-100: #f3f4f6; +$gray-200: #e5e7eb; +$gray-300: #d1d5db; +$gray-400: #9ca3af; +$gray-500: #6b7280; +$gray-600: #4b5563; +$gray-700: #374151; +$gray-800: #1f2937; +$gray-900: #111827; + +// 间距 +$spacing-xs: 4px; +$spacing-sm: 8px; +$spacing-md: 16px; +$spacing-lg: 24px; +$spacing-xl: 32px; +$spacing-2xl: 48px; + +// 圆角 +$radius-sm: 4px; +$radius-md: 8px; +$radius-lg: 12px; +$radius-full: 9999px; + +// 阴影 +$shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); +$shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); +$shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + +// 过渡 +$transition-fast: 150ms ease; +$transition-normal: 300ms ease; +$transition-slow: 500ms ease; + +// 断点 +$breakpoint-mobile: 768px; +$breakpoint-tablet: 1024px; +$breakpoint-desktop: 1280px; + +// Z-index +$z-dropdown: 1000; +$z-modal: 2000; +$z-notification: 3000; + +// 字体 +$font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', + 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + +$font-mono: 'Courier New', Courier, monospace; diff --git a/web/src/types/index.ts b/web/src/types/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..c7c21ff7a35454a8cc677cbfbe4db7dbca75bfd8 --- /dev/null +++ b/web/src/types/index.ts @@ -0,0 +1,19 @@ +export type Theme = 'light' | 'white' | 'dark' | 'auto'; + +export interface ProviderKeyConfig { + apiKey: string; + prefix?: string; + name?: string; +} + +export type GeminiKeyConfig = ProviderKeyConfig; + +export interface OpenAIApiKeyEntry { + apiKey: string; +} + +export interface OpenAIProviderConfig { + name?: string; + prefix?: string; + apiKeyEntries?: OpenAIApiKeyEntry[]; +} diff --git a/web/src/utils/constants.ts b/web/src/utils/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..89c403bbbb858d92dbb148dc1d1e07ef75e24807 --- /dev/null +++ b/web/src/utils/constants.ts @@ -0,0 +1 @@ +export const STORAGE_KEY_THEME = 'cpa-usage-theme'; diff --git a/web/src/utils/download.ts b/web/src/utils/download.ts new file mode 100644 index 0000000000000000000000000000000000000000..491b0465bbfbca682f881bc0b3c0570ccd103640 --- /dev/null +++ b/web/src/utils/download.ts @@ -0,0 +1,13 @@ +interface DownloadBlobOptions { + filename: string; + blob: Blob; +} + +export function downloadBlob({ filename, blob }: DownloadBlobOptions): void { + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + anchor.click(); + URL.revokeObjectURL(url); +} diff --git a/web/src/utils/usage.test.ts b/web/src/utils/usage.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b9f651aa7ae7d66d8508fbe90060a6117e76b3e2 --- /dev/null +++ b/web/src/utils/usage.test.ts @@ -0,0 +1,197 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { buildChartData, buildUsageFromDetails, filterUsageByWindow, filterUsageSnapshot, resolveUsageFilterWindow, sanitizeChartLines } from '@/utils/usage'; +import type { UsageSnapshot } from '@/lib/types'; + +afterEach(() => { + vi.useRealTimers(); +}); + +const formatTestLocalDayKey = (date: Date): string => { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + +const localDayKeyForBoundarySample = formatTestLocalDayKey(new Date('2026-04-22T16:30:00.000Z')); + +const usage: UsageSnapshot = { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: { + 'provider-a': { + display_name: 'Provider A', + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + models: { + 'claude-sonnet': { + total_requests: 2, + success_count: 2, + failure_count: 0, + total_tokens: 300, + details: [ + { + timestamp: '2026-04-23T00:00:00.000Z', + latency_ms: 100, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 50, + output_tokens: 50, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 100, + }, + }, + { + timestamp: '2026-04-23T02:00:00.000Z', + latency_ms: 120, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 100, + output_tokens: 100, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 200, + }, + }, + ], + }, + }, + }, + }, +}; + +describe('local day usage buckets', () => { + it('rebuilds day aggregate keys from the browser local day', () => { + const rebuilt = buildUsageFromDetails([ + { + timestamp: '2026-04-22T16:30:00.000Z', + latency_ms: 100, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 1, + output_tokens: 2, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 3, + }, + __apiName: 'provider-a', + __apiDisplayName: 'Provider A', + __modelName: 'claude-sonnet', + __timestampMs: Date.parse('2026-04-22T16:30:00.000Z'), + }, + ]); + + expect(rebuilt.requests_by_day).toEqual({ [localDayKeyForBoundarySample]: 1 }); + expect(rebuilt.tokens_by_day).toEqual({ [localDayKeyForBoundarySample]: 3 }); + }); + + it('groups daily chart buckets by local day keys', () => { + const chartData = buildChartData({ + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 3, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: { + 'provider-a': { + display_name: 'Provider A', + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 3, + models: { + 'claude-sonnet': { + total_requests: 1, + success_count: 1, + failure_count: 0, + total_tokens: 3, + details: [{ + timestamp: '2026-04-22T16:30:00.000Z', + latency_ms: 100, + source: 'source-a', + auth_index: '1', + failed: false, + tokens: { + input_tokens: 1, + output_tokens: 2, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 3, + }, + }], + }, + }, + }, + }, + }, 'day', 'requests', ['all']); + + expect(chartData.labels).toEqual([localDayKeyForBoundarySample]); + }); +}); + +describe('filterUsageByWindow', () => { + it('rebuilds aggregate totals from only the details inside the selected time window', () => { + const filtered = filterUsageByWindow(usage, { + startMs: Date.parse('2026-04-23T01:00:00.000Z'), + endMs: Date.parse('2026-04-23T03:00:00.000Z'), + windowMinutes: 120, + }); + + expect(filtered.total_requests).toBe(1); + expect(filtered.total_tokens).toBe(200); + expect(filtered.apis['provider-a']?.total_requests).toBe(1); + expect(filtered.apis['provider-a']?.total_tokens).toBe(200); + expect(filtered.apis['provider-a']?.models['claude-sonnet']?.total_requests).toBe(1); + expect(filtered.apis['provider-a']?.models['claude-sonnet']?.total_tokens).toBe(200); + }); +}); + +describe('filterUsageSnapshot', () => { + it('filters today against the current local day instead of the latest event day', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-24T00:30:00.000Z')); + + const filtered = filterUsageSnapshot(usage, 'today'); + + expect(filtered.total_requests).toBe(0); + expect(filtered.total_tokens).toBe(0); + }); +}); + +describe('resolveUsageFilterWindow', () => { + it('resolves today from local day start through the refresh anchor', () => { + const nowMs = Date.parse('2026-04-23T12:34:56.000Z'); + const expectedStart = new Date(nowMs); + expectedStart.setHours(0, 0, 0, 0); + + const window = resolveUsageFilterWindow(usage, 'today', { nowMs }); + + expect(window).toEqual({ + startMs: expectedStart.getTime(), + endMs: nowMs, + windowMinutes: Math.max((nowMs - expectedStart.getTime()) / 60000, 1), + }); + }); +}); + +describe('sanitizeChartLines', () => { + it('falls back to all when persisted lines no longer exist in the current overview payload', () => { + expect(sanitizeChartLines(['stale-model'], ['gpt-5.4', 'gpt-5.4-mini'])).toEqual(['all']); + }); +}); diff --git a/web/src/utils/usage.ts b/web/src/utils/usage.ts new file mode 100644 index 0000000000000000000000000000000000000000..932b40a4e1d462e23d374f0950bfca5c06f6fcbe --- /dev/null +++ b/web/src/utils/usage.ts @@ -0,0 +1,914 @@ +import type { UsageFilterWindow, UsageTimeRange } from '@/lib/types'; +import type { UsagePayload } from '@/components/usage/hooks/useUsageData'; +import { + LATENCY_SOURCE_FIELD, + LATENCY_SOURCE_UNIT, + extractLatencyMs, + calculateLatencyStatsFromDetails, + formatDurationMs +} from '@/utils/usage/latency'; + +export * from '@/lib/usage'; +export { + LATENCY_SOURCE_FIELD, + LATENCY_SOURCE_UNIT, + extractLatencyMs, + calculateLatencyStatsFromDetails, + formatDurationMs +}; +export type { UsageTimeRange, UsageFilterWindow } from '@/lib/types'; +export type { UsagePayload } from '@/components/usage/hooks/useUsageData'; + +export interface ModelPrice { + prompt: number; + completion: number; + cache: number; +} + +export interface ChartDataset { + label: string; + data: number[]; + borderColor: string; + backgroundColor: string; + pointBackgroundColor?: string; + pointBorderColor?: string; + fill?: boolean; + tension?: number; +} + +export interface ChartData { + labels: string[]; + datasets: ChartDataset[]; +} + +export interface ModelStatsSummary { + model: string; + requests: number; + successCount: number; + failureCount: number; + tokens: number; + averageLatencyMs: number | null; + totalLatencyMs: number | null; + latencySampleCount: number; + cost: number; +} + +export interface ApiStatsModelSummary { + requests: number; + successCount: number; + failureCount: number; + tokens: number; +} + +export interface ApiStats { + endpoint: string; + displayName: string; + totalRequests: number; + successCount: number; + failureCount: number; + totalTokens: number; + totalCost: number; + models: Record; +} + +export type TokenCategory = 'input' | 'output' | 'cached' | 'reasoning'; + +interface UsageModelSeriesLine { + requests_by_hour?: Record; + requests_by_day?: Record; + tokens_by_hour?: Record; + tokens_by_day?: Record; +} + +interface UsagePayloadWithModelSeries { + model_series?: Record; +} + +export interface UsageDetailRecord { + timestamp: string; + source: string; + source_raw?: string; + source_display?: string; + source_type?: string; + source_key?: string; + auth_index: string; + failed: boolean; + latency_ms: number; + tokens: { + input_tokens: number; + output_tokens: number; + reasoning_tokens: number; + cached_tokens: number; + cache_tokens?: number; + total_tokens: number; + }; + __apiName?: string; + __apiDisplayName?: string; + __modelName?: string; + __timestampMs?: number; + [key: string]: unknown; +} + +export interface StatusBlockDetail { + startTime: number; + endTime: number; + success: number; + failure: number; + rate: number; +} + +export interface ServiceHealthData { + totalSuccess: number; + totalFailure: number; + successRate: number; + rows: number; + columns: number; + bucketSeconds: number; + windowStart: number; + windowEnd: number; + blockDetails: StatusBlockDetail[]; +} + +const CHART_COLORS = ['#8b8680', '#8b5cf6', '#22c55e', '#f97316', '#f59e0b', '#06b6d4', '#ef4444', '#6366f1', '#ec4899']; +const SOURCE_PREFIXES = ['sk-', 'gsk_', 'rk-', 'pk-', 'AIza', 'claude-', 'vertex-', 'gemini-']; + +const toNumber = (value: unknown): number => { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; +}; + +const formatLocalDayKey = (date: Date): string => { + const pad = (value: number) => String(value).padStart(2, '0'); + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +}; + +const startOfDayKey = (timestamp: string): string => { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? '' : formatLocalDayKey(date); +}; + +const formatHourBucketKey = (timestampMs: number): string => `${new Date(timestampMs).toISOString().slice(0, 13)}:00:00Z`; + +const startOfHourKey = (timestamp: string): string => { + const date = new Date(timestamp); + return Number.isNaN(date.getTime()) ? '' : formatHourBucketKey(date.getTime()); +}; + +const formatHourLabel = (key: string): string => { + const date = new Date(key); + if (Number.isNaN(date.getTime())) return key; + const md = `${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; + const time = `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`; + return `${md} ${time}`; +}; + +const formatDayLabel = (key: string): string => key; + +const normalizeHourWindow = (hourWindowHours?: number): number => { + if (!Number.isFinite(hourWindowHours) || !hourWindowHours || hourWindowHours <= 0) { + return 24; + } + return Math.min(Math.max(Math.floor(hourWindowHours), 1), 24); +}; + +const resolveHourlyChartWindowHours = (hourWindowHours?: number): number => + normalizeHourWindow(hourWindowHours); + +const buildHourlyWindow = (hourWindowHours?: number, endMs?: number) => { + const resolvedHourWindow = resolveHourlyChartWindowHours(hourWindowHours); + const bucketCount = resolvedHourWindow >= 24 ? 24 : resolvedHourWindow + 1; + const hourMs = 60 * 60 * 1000; + const currentHour = new Date(Number.isFinite(endMs) && endMs && endMs > 0 ? endMs : Date.now()); + currentHour.setUTCMinutes(0, 0, 0); + const earliestTime = currentHour.getTime() - ((bucketCount - 1) * hourMs); + const labels = Array.from({ length: bucketCount }, (_, index) => + formatHourLabel(formatHourBucketKey(earliestTime + index * hourMs)) + ); + return { + hourMs, + earliestTime, + lastBucketTime: earliestTime + (labels.length - 1) * hourMs, + labels + }; +}; + +const resolveHourlyChartEndMs = (details: UsageDetailRecord[], _hourWindowHours?: number, endMs?: number): number | undefined => { + const requestedEndMs = Number.isFinite(endMs) && endMs && endMs > 0 ? endMs : undefined; + if (requestedEndMs !== undefined) return requestedEndMs; + if (!details.length) return undefined; + return getDetailTimestampBounds(details)?.latestMs; +}; + +const sum = (values: number[]) => values.reduce((total, value) => total + value, 0); + +const PRESET_WINDOW_HOURS: Record, number> = { + '4h': 4, + '8h': 8, + '12h': 12, + '24h': 24, + '7d': 24 * 7 +}; + +const toValidTimestamp = (value: unknown): number | null => { + const timestamp = typeof value === 'number' ? value : Date.parse(String(value ?? '')); + return Number.isFinite(timestamp) && timestamp > 0 ? timestamp : null; +}; + +const getDetailTimestampBounds = (details: UsageDetailRecord[]): { earliestMs: number; latestMs: number } | null => { + let earliestMs = Number.POSITIVE_INFINITY; + let latestMs = Number.NEGATIVE_INFINITY; + details.forEach((detail) => { + const timestamp = detail.__timestampMs ?? 0; + if (!Number.isFinite(timestamp) || timestamp <= 0) return; + earliestMs = Math.min(earliestMs, timestamp); + latestMs = Math.max(latestMs, timestamp); + }); + if (!Number.isFinite(earliestMs) || !Number.isFinite(latestMs)) return null; + return { earliestMs, latestMs }; +}; + +export function sanitizeChartLines(chartLines: string[], modelNames: string[]): string[] { + const lines = chartLines.length ? chartLines : ['all']; + const validModels = new Set(modelNames.map((name) => name.trim()).filter(Boolean)); + const sanitized = lines.filter((line) => line === 'all' || validModels.has(line)); + return sanitized.length ? sanitized : ['all']; +} + +export function formatCompactNumber(value: number): string { + const abs = Math.abs(value); + const formatScaled = (scaled: number, suffix: string) => `${scaled.toFixed(2)}${suffix}`; + + if (abs < 1_000) { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: 0 }).format(value); + } + if (abs < 1_000_000) { + return formatScaled(value / 1_000, 'K'); + } + if (abs < 1_000_000_000) { + return formatScaled(value / 1_000_000, 'M'); + } + return formatScaled(value / 1_000_000_000, 'B'); +} + +export function formatFixedTwoDecimals(value: number): string { + return new Intl.NumberFormat(undefined, { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + }).format(value || 0); +} + +export function formatPerMinuteValue(value: number): string { + return new Intl.NumberFormat(undefined, { maximumFractionDigits: value >= 100 ? 0 : value >= 10 ? 1 : 2 }).format(value); +} + +export function formatUsd(value: number): string { + return new Intl.NumberFormat(undefined, { + style: 'currency', + currency: 'USD', + minimumFractionDigits: value < 1 ? 4 : 2, + maximumFractionDigits: value < 1 ? 4 : 2 + }).format(value || 0); +} + +export function normalizeAuthIndex(value: unknown): string { + if (value === null || value === undefined) return ''; + return String(value).trim(); +} + +export function extractTotalTokens(detail: Partial): number { + const tokens = detail.tokens ?? { + input_tokens: 0, + output_tokens: 0, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 0 + }; + const explicit = toNumber(tokens.total_tokens); + if (explicit > 0) return explicit; + return toNumber(tokens.input_tokens) + toNumber(tokens.output_tokens) + toNumber(tokens.reasoning_tokens) + Math.max(toNumber(tokens.cached_tokens), toNumber(tokens.cache_tokens)); +} + +export function collectUsageDetails(usage: UsagePayload | null | undefined): UsageDetailRecord[] { + if (!usage?.apis) return []; + const rows: UsageDetailRecord[] = []; + Object.entries(usage.apis).forEach(([apiName, api]) => { + const apiDisplayName = String(api.display_name ?? apiName).trim() || apiName; + Object.entries(api.models ?? {}).forEach(([modelName, model]) => { + (model.details ?? []).forEach((detail) => { + const timestampMs = Date.parse(detail.timestamp); + rows.push({ + ...detail, + latency_ms: toNumber(detail.latency_ms), + __apiName: apiName, + __apiDisplayName: apiDisplayName, + __modelName: modelName, + __timestampMs: Number.isFinite(timestampMs) ? timestampMs : 0 + }); + }); + }); + }); + return rows.sort((a, b) => (b.__timestampMs ?? 0) - (a.__timestampMs ?? 0)); +} + +export function getModelNamesFromUsage(usage: UsagePayload | null | undefined): string[] { + const names = new Set(); + Object.values(usage?.apis ?? {}).forEach((api) => { + Object.keys(api.models ?? {}).forEach((modelName) => { + const normalized = modelName.trim(); + if (normalized) { + names.add(normalized); + } + }); + }); + return Array.from(names).sort((a, b) => a.localeCompare(b)); +} + +export function resolveUsageFilterWindow( + usage: UsagePayload | null | undefined, + range: UsageTimeRange, + options: { + nowMs?: number; + customStart?: string | number; + customEnd?: string | number; + } = {} +): UsageFilterWindow { + const details = collectUsageDetails(usage); + const bounds = getDetailTimestampBounds(details); + const fallbackNow = toValidTimestamp(options.nowMs) ?? Date.now(); + + if (range === 'all') { + if (!bounds) return {}; + const spanMinutes = Math.max((bounds.latestMs - bounds.earliestMs) / 60000, 1); + return { + startMs: bounds.earliestMs, + endMs: bounds.latestMs, + windowMinutes: spanMinutes + }; + } + + if (range === 'custom') { + const startMs = toValidTimestamp(options.customStart); + const endMs = toValidTimestamp(options.customEnd); + if (startMs === null || endMs === null || startMs > endMs) { + return {}; + } + return { + startMs, + endMs, + windowMinutes: Math.max((endMs - startMs) / 60000, 1) + }; + } + + if (range === 'today') { + const start = new Date(fallbackNow); + start.setHours(0, 0, 0, 0); + const startMs = start.getTime(); + const endMs = fallbackNow; + return { + startMs, + endMs, + windowMinutes: Math.max((endMs - startMs) / 60000, 1) + }; + } + + const windowHours = PRESET_WINDOW_HOURS[range]; + const endMs = fallbackNow; + const startMs = endMs - windowHours * 60 * 60 * 1000; + return { + startMs, + endMs, + windowMinutes: windowHours * 60 + }; +} + +export function filterUsageByWindow(usage: UsagePayload, window: UsageFilterWindow): UsagePayload { + const details = collectUsageDetails(usage); + if (!details.length) return usage; + const { startMs, endMs } = window; + if (startMs === undefined && endMs === undefined) { + return usage; + } + const filteredDetails = details.filter((detail) => { + const timestamp = detail.__timestampMs ?? 0; + if (!Number.isFinite(timestamp) || timestamp <= 0) return false; + if (startMs !== undefined && timestamp < startMs) return false; + if (endMs !== undefined && timestamp > endMs) return false; + return true; + }); + return buildUsageFromDetails(filteredDetails); +} + +export function filterUsageByTimeRange( + usage: UsagePayload, + range: UsageTimeRange, + options: { + nowMs?: number; + customStart?: string | number; + customEnd?: string | number; + } = {} +): UsagePayload { + const window = resolveUsageFilterWindow(usage, range, options); + return filterUsageByWindow(usage, window); +} + +export function loadModelPrices(): Record { + try { + const raw = window.localStorage.getItem('cpa-model-prices'); + if (!raw) return {}; + const parsed = JSON.parse(raw) as Record; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} + +export function saveModelPrices(prices: Record): void { + window.localStorage.setItem('cpa-model-prices', JSON.stringify(prices)); +} + +export function calculateCost(detail: UsageDetailRecord, modelPrices: Record): number { + const modelName = detail.__modelName ?? ''; + const pricing = modelPrices[modelName]; + if (!pricing) return 0; + + const inputTokens = Math.max(toNumber(detail.tokens.input_tokens), 0); + const completionTokens = Math.max(toNumber(detail.tokens.output_tokens), 0); + const cachedTokens = Math.max( + toNumber(detail.tokens.cached_tokens), + toNumber(detail.tokens.cache_tokens) + ); + const promptTokens = Math.max(inputTokens - cachedTokens, 0); + + return ( + (promptTokens / 1_000_000) * pricing.prompt + + (completionTokens / 1_000_000) * pricing.completion + + (cachedTokens / 1_000_000) * pricing.cache + ); +} + +export function buildCandidateUsageSourceIds({ apiKey, prefix }: { apiKey?: string; prefix?: string }): string[] { + const set = new Set(); + if (apiKey?.trim()) { + set.add(apiKey.trim()); + set.add(`t:${apiKey.trim()}`); + } + if (prefix?.trim()) { + set.add(prefix.trim()); + set.add(`t:${prefix.trim()}`); + } + return Array.from(set); +} + +export function getApiStats(usage: UsagePayload | null, modelPrices: Record): ApiStats[] { + if (!usage?.apis) return []; + return Object.entries(usage.apis) + .map(([endpoint, api]) => { + const models: Record = {}; + let totalCost = 0; + Object.entries(api.models ?? {}).forEach(([modelName, model]) => { + models[modelName] = { + requests: toNumber(model.total_requests), + successCount: toNumber(model.success_count), + failureCount: toNumber(model.failure_count), + tokens: toNumber(model.total_tokens) + }; + (model.details ?? []).forEach((detail) => { + totalCost += calculateCost({ ...detail, __modelName: modelName }, modelPrices); + }); + }); + return { + endpoint, + displayName: String(api.display_name ?? endpoint).trim() || endpoint, + totalRequests: toNumber(api.total_requests), + successCount: toNumber(api.success_count), + failureCount: toNumber(api.failure_count), + totalTokens: toNumber(api.total_tokens), + totalCost, + models + }; + }) + .sort((a, b) => b.totalRequests - a.totalRequests); +} + +export function getModelStats(usage: UsagePayload | null, modelPrices: Record): ModelStatsSummary[] { + const grouped = new Map(); + collectUsageDetails(usage).forEach((detail) => { + const model = detail.__modelName || '-'; + const current = grouped.get(model) ?? { + model, + requests: 0, + successCount: 0, + failureCount: 0, + tokens: 0, + averageLatencyMs: null, + totalLatencyMs: 0, + latencySampleCount: 0, + cost: 0 + }; + current.requests += 1; + current.tokens += extractTotalTokens(detail); + current.cost += calculateCost(detail, modelPrices); + if (detail.failed) current.failureCount += 1; + else current.successCount += 1; + const latency = extractLatencyMs(detail); + if (latency !== null) { + current.totalLatencyMs = (current.totalLatencyMs ?? 0) + latency; + current.latencySampleCount += 1; + current.averageLatencyMs = (current.totalLatencyMs ?? 0) / current.latencySampleCount; + } + grouped.set(model, current); + }); + return Array.from(grouped.values()).sort((a, b) => b.requests - a.requests); +} + +export function buildChartData( + usage: UsagePayload, + period: 'hour' | 'day', + metric: 'requests' | 'tokens', + chartLines: string[], + options: { hourWindowHours?: number; endMs?: number } = {} +): ChartData { + const details = collectUsageDetails(usage); + if (!details.length) { + const lines = chartLines.length ? chartLines : ['all']; + const bucketMap = period === 'hour' + ? (metric === 'requests' ? usage.requests_by_hour : usage.tokens_by_hour) + : (metric === 'requests' ? usage.requests_by_day : usage.tokens_by_day); + const rawBucketKeys = Object.keys(bucketMap ?? {}).sort((a, b) => a.localeCompare(b)); + if (!rawBucketKeys.length) { + return { labels: [], datasets: [] }; + } + const bucketKeys = period === 'hour' + ? (() => { + const endMs = options.endMs ?? Date.parse(rawBucketKeys[rawBucketKeys.length - 1]); + const { earliestTime, hourMs, labels } = buildHourlyWindow(options.hourWindowHours, endMs); + return labels.map((_, index) => formatHourBucketKey(earliestTime + index * hourMs)); + })() + : rawBucketKeys; + const datasets: ChartDataset[] = []; + if (lines.includes('all')) { + datasets.push({ + label: 'All', + data: bucketKeys.map((key) => toNumber(bucketMap?.[key])), + borderColor: CHART_COLORS[0], + backgroundColor: `${CHART_COLORS[0]}22`, + pointBackgroundColor: CHART_COLORS[0], + pointBorderColor: CHART_COLORS[0], + fill: false, + tension: 0.35 + }); + } + const modelSeries = (usage as UsagePayload & UsagePayloadWithModelSeries).model_series ?? {}; + lines.filter((line) => line !== 'all').forEach((line) => { + const series = modelSeries[line]; + const lineBucketMap = period === 'hour' + ? (metric === 'requests' ? series?.requests_by_hour : series?.tokens_by_hour) + : (metric === 'requests' ? series?.requests_by_day : series?.tokens_by_day); + if (!lineBucketMap) return; + const color = CHART_COLORS[datasets.length % CHART_COLORS.length]; + datasets.push({ + label: line, + data: bucketKeys.map((key) => toNumber(lineBucketMap[key])), + borderColor: color, + backgroundColor: `${color}22`, + pointBackgroundColor: color, + pointBorderColor: color, + fill: false, + tension: 0.35 + }); + }); + return { + labels: bucketKeys.map((key) => (period === 'hour' ? formatHourLabel(key) : formatDayLabel(key))), + datasets + }; + } + + const lines = chartLines.length ? chartLines : ['all']; + const bucketsByLine = new Map>(); + const orderedKeys = new Set(); + + if (period === 'hour') { + const hourEndMs = resolveHourlyChartEndMs(details, options.hourWindowHours, options.endMs); + const { labels, earliestTime, lastBucketTime, hourMs } = buildHourlyWindow(options.hourWindowHours, hourEndMs); + const bucketKeys = labels.map((_, index) => formatHourBucketKey(earliestTime + index * hourMs)); + bucketKeys.forEach((key) => orderedKeys.add(key)); + + details.forEach((detail) => { + const timestamp = detail.__timestampMs ?? 0; + if (!Number.isFinite(timestamp) || timestamp <= 0) return; + const normalized = new Date(timestamp); + normalized.setUTCMinutes(0, 0, 0); + const bucketStart = normalized.getTime(); + if (bucketStart < earliestTime || bucketStart > lastBucketTime) return; + const key = new Date(bucketStart).toISOString(); + const lineKey = lines.includes(detail.__modelName ?? '') + ? detail.__modelName ?? 'all' + : lines.includes(detail.__apiName ?? '') + ? detail.__apiName ?? 'all' + : 'all'; + if (!lines.includes('all') && lineKey === 'all') return; + const line = bucketsByLine.get(lineKey) ?? new Map(); + const value = metric === 'requests' ? 1 : extractTotalTokens(detail); + line.set(key, (line.get(key) ?? 0) + value); + bucketsByLine.set(lineKey, line); + if (lines.includes('all')) { + const allLine = bucketsByLine.get('all') ?? new Map(); + allLine.set(key, (allLine.get(key) ?? 0) + value); + bucketsByLine.set('all', allLine); + } + }); + + return { + labels, + datasets: Array.from(bucketsByLine.entries()).map(([label, values], index) => ({ + label: label === 'all' ? 'All' : label, + data: bucketKeys.map((key) => values.get(key) ?? 0), + borderColor: CHART_COLORS[index % CHART_COLORS.length], + backgroundColor: `${CHART_COLORS[index % CHART_COLORS.length]}22`, + pointBackgroundColor: CHART_COLORS[index % CHART_COLORS.length], + pointBorderColor: CHART_COLORS[index % CHART_COLORS.length], + fill: false, + tension: 0.35 + })) + }; + } + + details.forEach((detail) => { + const key = startOfDayKey(detail.timestamp); + if (!key) return; + orderedKeys.add(key); + const lineKey = lines.includes(detail.__modelName ?? '') ? detail.__modelName ?? 'all' : lines.includes(detail.__apiName ?? '') ? detail.__apiName ?? 'all' : 'all'; + if (!lines.includes('all') && lineKey === 'all') return; + const line = bucketsByLine.get(lineKey) ?? new Map(); + const value = metric === 'requests' ? 1 : extractTotalTokens(detail); + line.set(key, (line.get(key) ?? 0) + value); + bucketsByLine.set(lineKey, line); + if (lines.includes('all')) { + const allLine = bucketsByLine.get('all') ?? new Map(); + allLine.set(key, (allLine.get(key) ?? 0) + value); + bucketsByLine.set('all', allLine); + } + }); + + const bucketKeys = Array.from(orderedKeys).sort((a, b) => a.localeCompare(b)); + return { + labels: bucketKeys.map((key) => formatDayLabel(key)), + datasets: Array.from(bucketsByLine.entries()).map(([label, values], index) => ({ + label: label === 'all' ? 'All' : label, + data: bucketKeys.map((key) => values.get(key) ?? 0), + borderColor: CHART_COLORS[index % CHART_COLORS.length], + backgroundColor: `${CHART_COLORS[index % CHART_COLORS.length]}22`, + pointBackgroundColor: CHART_COLORS[index % CHART_COLORS.length], + pointBorderColor: CHART_COLORS[index % CHART_COLORS.length], + fill: false, + tension: 0.35 + })) + }; +} + +export function buildHourlyTokenBreakdown(usage: UsagePayload | null, hourWindowHours = 24, endMs?: number) { + return buildTokenBreakdownSeries(usage, 'hour', hourWindowHours, endMs); +} + +export function buildDailyTokenBreakdown(usage: UsagePayload | null) { + return buildTokenBreakdownSeries(usage, 'day'); +} + +function buildTokenBreakdownSeries(usage: UsagePayload | null, period: 'hour' | 'day', hourWindowHours?: number, endMs?: number) { + const details = collectUsageDetails(usage); + if (!details.length) { + return { labels: [], dataByCategory: { input: [], output: [], cached: [], reasoning: [] } as Record }; + } + + if (period === 'hour') { + const hourEndMs = resolveHourlyChartEndMs(details, hourWindowHours, endMs); + const { labels, earliestTime, lastBucketTime, hourMs } = buildHourlyWindow(hourWindowHours, hourEndMs); + const dataByCategory = { + input: new Array(labels.length).fill(0), + output: new Array(labels.length).fill(0), + cached: new Array(labels.length).fill(0), + reasoning: new Array(labels.length).fill(0) + } as Record; + + details.forEach((detail) => { + const timestamp = detail.__timestampMs ?? 0; + if (!Number.isFinite(timestamp) || timestamp <= 0) return; + const normalized = new Date(timestamp); + normalized.setUTCMinutes(0, 0, 0); + const bucketStart = normalized.getTime(); + if (bucketStart < earliestTime || bucketStart > lastBucketTime) return; + const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); + if (bucketIndex < 0 || bucketIndex >= labels.length) return; + dataByCategory.input[bucketIndex] += toNumber(detail.tokens.input_tokens); + dataByCategory.output[bucketIndex] += toNumber(detail.tokens.output_tokens); + dataByCategory.cached[bucketIndex] += Math.max(toNumber(detail.tokens.cached_tokens), toNumber(detail.tokens.cache_tokens)); + dataByCategory.reasoning[bucketIndex] += toNumber(detail.tokens.reasoning_tokens); + }); + + return { labels, dataByCategory }; + } + + const keys = Array.from(new Set(details.map((detail) => startOfDayKey(detail.timestamp)))).filter(Boolean).sort((a, b) => a.localeCompare(b)); + const dataByCategory = { input: [], output: [], cached: [], reasoning: [] } as Record; + keys.forEach((key) => { + const matching = details.filter((detail) => startOfDayKey(detail.timestamp) === key); + dataByCategory.input.push(sum(matching.map((detail) => toNumber(detail.tokens.input_tokens)))); + dataByCategory.output.push(sum(matching.map((detail) => toNumber(detail.tokens.output_tokens)))); + dataByCategory.cached.push(sum(matching.map((detail) => Math.max(toNumber(detail.tokens.cached_tokens), toNumber(detail.tokens.cache_tokens))))); + dataByCategory.reasoning.push(sum(matching.map((detail) => toNumber(detail.tokens.reasoning_tokens)))); + }); + return { labels: keys.map((key) => formatDayLabel(key)), dataByCategory }; +} + +export function buildHourlyCostSeries(usage: UsagePayload | null, modelPrices: Record, hourWindowHours = 24, endMs?: number) { + return buildCostSeries(usage, modelPrices, 'hour', hourWindowHours, endMs); +} + +export function buildDailyCostSeries(usage: UsagePayload | null, modelPrices: Record) { + return buildCostSeries(usage, modelPrices, 'day'); +} + +function buildCostSeries(usage: UsagePayload | null, modelPrices: Record, period: 'hour' | 'day', hourWindowHours?: number, endMs?: number) { + const details = collectUsageDetails(usage); + if (!details.length) return { labels: [], data: [], hasData: false }; + + if (period === 'hour') { + const hourEndMs = resolveHourlyChartEndMs(details, hourWindowHours, endMs); + const { labels, earliestTime, lastBucketTime, hourMs } = buildHourlyWindow(hourWindowHours, hourEndMs); + const data = new Array(labels.length).fill(0); + let hasData = false; + + details.forEach((detail) => { + const timestamp = detail.__timestampMs ?? 0; + if (!Number.isFinite(timestamp) || timestamp <= 0) return; + const normalized = new Date(timestamp); + normalized.setUTCMinutes(0, 0, 0); + const bucketStart = normalized.getTime(); + if (bucketStart < earliestTime || bucketStart > lastBucketTime) return; + const bucketIndex = Math.floor((bucketStart - earliestTime) / hourMs); + if (bucketIndex < 0 || bucketIndex >= labels.length) return; + const cost = calculateCost(detail, modelPrices); + if (cost > 0) { + data[bucketIndex] += cost; + hasData = true; + } + }); + + return { labels, data, hasData }; + } + + const grouped = new Map(); + details.forEach((detail) => { + const key = startOfDayKey(detail.timestamp); + if (!key) return; + grouped.set(key, (grouped.get(key) ?? 0) + calculateCost(detail, modelPrices)); + }); + const keys = Array.from(grouped.keys()).sort((a, b) => a.localeCompare(b)); + const data = keys.map((key) => grouped.get(key) ?? 0); + return { labels: keys.map((key) => formatDayLabel(key)), data, hasData: data.some((value) => value > 0) }; +} + +export function calculateServiceHealthData(details: UsageDetailRecord[]): ServiceHealthData { + const rowCount = 7; + const blockCount = 96; + const windowMs = 15 * 60 * 1000; + const totalBlocks = rowCount * blockCount; + const timelineAnchor = Date.now(); + const currentBucketStart = Math.floor(timelineAnchor / windowMs) * windowMs; + const newestWindowEnd = currentBucketStart + windowMs; + const oldestWindowStart = newestWindowEnd - totalBlocks * windowMs; + + const blockDetails = Array.from({ length: totalBlocks }, (_, index) => { + const startTime = oldestWindowStart + index * windowMs; + const endTime = startTime + windowMs; + const matching = details.filter((detail) => { + const timestamp = detail.__timestampMs ?? 0; + return timestamp >= startTime && timestamp < endTime; + }); + const success = matching.filter((detail) => !detail.failed).length; + const failure = matching.filter((detail) => detail.failed).length; + const total = success + failure; + + return { + startTime, + endTime, + success, + failure, + rate: total > 0 ? success / total : -1 + }; + }); + + const totalSuccess = details.filter((detail) => !detail.failed).length; + const totalFailure = details.filter((detail) => detail.failed).length; + const total = totalSuccess + totalFailure; + + return { + totalSuccess, + totalFailure, + successRate: total > 0 ? (totalSuccess / total) * 100 : 0, + rows: rowCount, + columns: blockCount, + bucketSeconds: Math.floor(windowMs / 1000), + windowStart: oldestWindowStart, + windowEnd: newestWindowEnd, + blockDetails + }; +} + +export function buildUsageFromDetails(details: UsageDetailRecord[]): UsagePayload { + const usage: UsagePayload = { + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + requests_by_day: {}, + requests_by_hour: {}, + tokens_by_day: {}, + tokens_by_hour: {}, + apis: {} + }; + + details.forEach((detail) => { + const apiName = detail.__apiName || 'unknown'; + const modelName = detail.__modelName || 'unknown'; + const tokens = extractTotalTokens(detail); + const dayKey = startOfDayKey(detail.timestamp); + const hourKey = startOfHourKey(detail.timestamp); + + const apis = usage.apis ?? (usage.apis = {}); + const api = apis[apiName] ?? { + display_name: detail.__apiDisplayName || apiName, + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + models: {} + }; + const model = api.models[modelName] ?? { + total_requests: 0, + success_count: 0, + failure_count: 0, + total_tokens: 0, + details: [] + }; + + usage.total_requests = (usage.total_requests ?? 0) + 1; + usage.total_tokens = (usage.total_tokens ?? 0) + tokens; + api.total_requests += 1; + api.total_tokens += tokens; + model.total_requests += 1; + model.total_tokens += tokens; + + if (detail.failed) { + usage.failure_count = (usage.failure_count ?? 0) + 1; + api.failure_count += 1; + model.failure_count += 1; + } else { + usage.success_count = (usage.success_count ?? 0) + 1; + api.success_count += 1; + model.success_count += 1; + } + + const modelDetails = model.details ?? (model.details = []); + modelDetails.push({ + timestamp: detail.timestamp, + latency_ms: toNumber(detail.latency_ms), + source: detail.source ?? '', + source_raw: detail.source_raw ?? '', + source_display: detail.source_display ?? '', + source_type: detail.source_type ?? '', + source_key: detail.source_key ?? '', + auth_index: detail.auth_index ?? '', + failed: detail.failed === true, + tokens: { + input_tokens: toNumber(detail.tokens.input_tokens), + output_tokens: toNumber(detail.tokens.output_tokens), + reasoning_tokens: toNumber(detail.tokens.reasoning_tokens), + cached_tokens: Math.max(toNumber(detail.tokens.cached_tokens), toNumber(detail.tokens.cache_tokens)), + total_tokens: tokens + } + }); + + const requestsByDay = usage.requests_by_day ?? (usage.requests_by_day = {}); + const requestsByHour = usage.requests_by_hour ?? (usage.requests_by_hour = {}); + const tokensByDay = usage.tokens_by_day ?? (usage.tokens_by_day = {}); + const tokensByHour = usage.tokens_by_hour ?? (usage.tokens_by_hour = {}); + requestsByDay[dayKey] = (requestsByDay[dayKey] ?? 0) + 1; + requestsByHour[hourKey] = (requestsByHour[hourKey] ?? 0) + 1; + tokensByDay[dayKey] = (tokensByDay[dayKey] ?? 0) + tokens; + tokensByHour[hourKey] = (tokensByHour[hourKey] ?? 0) + tokens; + + api.models[modelName] = model; + apis[apiName] = api; + }); + + return usage; +} + +export function inferSourceType(source: string): string { + const value = source.trim(); + if (!value) return ''; + if (value.startsWith('t:')) return 'token'; + if (SOURCE_PREFIXES.some((prefix) => value.startsWith(prefix))) return 'api-key'; + return ''; +} diff --git a/web/src/utils/usage/chartConfig.ts b/web/src/utils/usage/chartConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..688da21997240a5313b0ef773f3ba72d2ca5c904 --- /dev/null +++ b/web/src/utils/usage/chartConfig.ts @@ -0,0 +1,142 @@ +/** + * Chart.js configuration utilities for usage statistics + * Extracted from UsagePage.tsx for reusability + */ + +import type { ChartOptions } from 'chart.js'; + +/** + * Static sparkline chart options (no dependencies on theme/mobile) + */ +export const sparklineOptions: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { legend: { display: false }, tooltip: { enabled: false } }, + scales: { x: { display: false }, y: { display: false } }, + elements: { line: { tension: 0.45 }, point: { radius: 0 } } +}; + +export interface ChartConfigOptions { + period: 'hour' | 'day'; + labels: string[]; + isDark: boolean; + isMobile: boolean; +} + +/** + * Build chart options with theme and responsive awareness + */ +export function buildChartOptions({ + period, + labels, + isDark, + isMobile +}: ChartConfigOptions): ChartOptions<'line'> { + const pointRadius = isMobile && period === 'hour' ? 0 : isMobile ? 2 : 4; + const tickFontSize = isMobile ? 10 : 12; + const maxTickLabelCount = isMobile ? (period === 'hour' ? 8 : 6) : period === 'hour' ? 12 : 10; + const gridColor = isDark ? 'rgba(255, 255, 255, 0.06)' : 'rgba(17, 24, 39, 0.06)'; + const axisBorderColor = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)'; + const tickColor = isDark ? 'rgba(255, 255, 255, 0.72)' : 'rgba(17, 24, 39, 0.72)'; + const tooltipBg = isDark ? 'rgba(17, 24, 39, 0.92)' : 'rgba(255, 255, 255, 0.98)'; + const tooltipTitle = isDark ? '#ffffff' : '#111827'; + const tooltipBody = isDark ? 'rgba(255, 255, 255, 0.86)' : '#374151'; + const tooltipBorder = isDark ? 'rgba(255, 255, 255, 0.10)' : 'rgba(17, 24, 39, 0.10)'; + + return { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: 'index', + intersect: false + }, + plugins: { + legend: { display: false }, + tooltip: { + backgroundColor: tooltipBg, + titleColor: tooltipTitle, + bodyColor: tooltipBody, + borderColor: tooltipBorder, + borderWidth: 1, + padding: 10, + displayColors: true, + usePointStyle: true + } + }, + scales: { + x: { + grid: { + color: gridColor, + drawTicks: false + }, + border: { + color: axisBorderColor + }, + ticks: { + color: tickColor, + font: { size: tickFontSize }, + maxRotation: isMobile ? 0 : 45, + minRotation: 0, + autoSkip: true, + maxTicksLimit: maxTickLabelCount, + callback: (value) => { + const index = typeof value === 'number' ? value : Number(value); + const raw = + Number.isFinite(index) && labels[index] ? labels[index] : typeof value === 'string' ? value : ''; + + if (period === 'hour') { + const [md, time] = raw.split(' '); + if (!time) return raw; + if (time.startsWith('00:')) { + return md ? [md, time] : time; + } + return time; + } + + if (isMobile) { + const parts = raw.split('-'); + if (parts.length === 3) { + return `${parts[1]}-${parts[2]}`; + } + } + return raw; + } + } + }, + y: { + beginAtZero: true, + grid: { + color: gridColor + }, + border: { + color: axisBorderColor + }, + ticks: { + color: tickColor, + font: { size: tickFontSize } + } + } + }, + elements: { + line: { + tension: 0.35, + borderWidth: isMobile ? 1.5 : 2 + }, + point: { + borderWidth: 2, + radius: pointRadius, + hoverRadius: 4 + } + } + }; +} + +/** + * Calculate minimum chart width for hourly data on mobile devices + */ +export function getHourChartMinWidth(labelCount: number, isMobile: boolean): string | undefined { + if (!isMobile || labelCount <= 0) return undefined; + const perPoint = 56; + const minWidth = Math.min(labelCount * perPoint, 3000); + return `${minWidth}px`; +} diff --git a/web/src/utils/usage/index.ts b/web/src/utils/usage/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..45a9fa83d9bc73990c267125337e87c2d51b8c5d --- /dev/null +++ b/web/src/utils/usage/index.ts @@ -0,0 +1,6 @@ +// Chart configuration utilities +export { sparklineOptions, buildChartOptions, getHourChartMinWidth } from './chartConfig'; +export type { ChartConfigOptions } from './chartConfig'; + +// Re-export everything from the main usage.ts for backwards compatibility +export * from '../usage'; diff --git a/web/src/utils/usage/latency.ts b/web/src/utils/usage/latency.ts new file mode 100644 index 0000000000000000000000000000000000000000..b109e6b9732558cc63b9767aac439f7a260b1566 --- /dev/null +++ b/web/src/utils/usage/latency.ts @@ -0,0 +1,199 @@ +import i18n from '@/i18n'; + +export const LATENCY_SOURCE_FIELD = 'latency_ms'; +export const LATENCY_SOURCE_UNIT = 'ms'; + +export interface LatencyStats { + averageMs: number | null; + totalMs: number | null; + sampleCount: number; +} + +export interface DurationFormatOptions { + maxUnits?: number; + invalidText?: string; + secondDecimals?: number | 'auto'; + locale?: string; +} + +export interface LatencyAccumulator { + totalMs: number; + sampleCount: number; +} + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const normalizeDurationMaxUnits = (value: number | undefined): number => { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed <= 0) { + return 2; + } + return Math.min(Math.floor(parsed), 4); +}; + +const resolveSecondDecimalPlaces = ( + seconds: number, + secondDecimals: number | 'auto' | undefined +): number => { + if (secondDecimals === 'auto' || secondDecimals === undefined) { + return seconds < 10 ? 2 : 1; + } + + const parsed = Math.floor(Number(secondDecimals)); + if (!Number.isFinite(parsed) || parsed < 0) { + return seconds < 10 ? 2 : 1; + } + return Math.min(parsed, 3); +}; + +const resolveDurationLocale = (locale?: string): string | undefined => + locale?.trim() || i18n.resolvedLanguage || i18n.language || undefined; + +const formatDurationNumber = ( + value: number, + locale: string | undefined, + options: Intl.NumberFormatOptions = {} +): string => { + try { + return new Intl.NumberFormat(locale, { + useGrouping: false, + ...options, + }).format(value); + } catch { + return String(value); + } +}; + +const getDurationUnitLabel = (unit: 'd' | 'h' | 'm' | 's' | 'ms'): string => + i18n.t(`usage_stats.duration_unit_${unit}`, { defaultValue: unit }); + +const formatDurationPart = ( + value: number, + unit: 'd' | 'h' | 'm' | 's' | 'ms', + locale: string | undefined, + options: Intl.NumberFormatOptions = {} +): string => `${formatDurationNumber(value, locale, options)}${getDurationUnitLabel(unit)}`; + +/** + * 从后端字段 latency_ms 提取耗时,并按毫秒解释。 + */ +export function extractLatencyMs(detail: unknown): number | null { + const record = isRecord(detail) ? detail : null; + const rawValue = record?.[LATENCY_SOURCE_FIELD]; + if ( + rawValue === null || + rawValue === undefined || + (typeof rawValue === 'string' && rawValue.trim() === '') + ) { + return null; + } + + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 0) { + return null; + } + return parsed; +} + +export const createLatencyAccumulator = (): LatencyAccumulator => ({ + totalMs: 0, + sampleCount: 0, +}); + +export const addLatencySample = ( + accumulator: LatencyAccumulator, + latencyMs: number | null | undefined +): void => { + if (latencyMs === null || latencyMs === undefined) { + return; + } + + const parsed = Number(latencyMs); + if (!Number.isFinite(parsed) || parsed < 0) { + return; + } + + accumulator.totalMs += parsed; + accumulator.sampleCount += 1; +}; + +export const finalizeLatencyStats = (accumulator: LatencyAccumulator): LatencyStats => ({ + averageMs: accumulator.sampleCount > 0 ? accumulator.totalMs / accumulator.sampleCount : null, + totalMs: accumulator.sampleCount > 0 ? accumulator.totalMs : null, + sampleCount: accumulator.sampleCount, +}); + +/** + * 从明细列表计算耗时统计 + */ +export function calculateLatencyStatsFromDetails(details: Iterable): LatencyStats { + const accumulator = createLatencyAccumulator(); + for (const detail of details) { + addLatencySample(accumulator, extractLatencyMs(detail)); + } + return finalizeLatencyStats(accumulator); +} + +/** + * 按当前语言格式化耗时显示。 + */ +export function formatDurationMs( + value: number | null | undefined, + options: DurationFormatOptions = {} +): string { + const invalidText = options.invalidText ?? '--'; + if (value === null || value === undefined) { + return invalidText; + } + + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return invalidText; + } + + const locale = resolveDurationLocale(options.locale); + + if (parsed < 1000) { + return formatDurationPart(Math.round(parsed), 'ms', locale); + } + + const seconds = parsed / 1000; + if (seconds < 60) { + const secondDecimalPlaces = resolveSecondDecimalPlaces(seconds, options.secondDecimals); + return formatDurationPart(seconds, 's', locale, { + minimumFractionDigits: 0, + maximumFractionDigits: secondDecimalPlaces, + }); + } + + const totalSeconds = Math.floor(seconds); + let remainingSeconds = totalSeconds; + const days = Math.floor(remainingSeconds / 86_400); + remainingSeconds -= days * 86_400; + const hours = Math.floor(remainingSeconds / 3_600); + remainingSeconds -= hours * 3_600; + const minutes = Math.floor(remainingSeconds / 60); + remainingSeconds -= minutes * 60; + + const parts = [ + { unit: 'd' as const, value: days }, + { unit: 'h' as const, value: hours }, + { unit: 'm' as const, value: minutes }, + { unit: 's' as const, value: remainingSeconds }, + ].filter((part) => part.value > 0); + + if (!parts.length) { + return formatDurationPart(0, 's', locale); + } + + return parts + .slice(0, normalizeDurationMaxUnits(options.maxUnits)) + .map((part, index) => + formatDurationPart(part.value, part.unit, locale, { + minimumIntegerDigits: index > 0 && (part.unit === 'm' || part.unit === 's') ? 2 : 1, + maximumFractionDigits: 0, + }) + ) + .join(' '); +} diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..d5b4552a36fbc057e4b785660be35ab6bd436809 --- /dev/null +++ b/web/src/vite-env.d.ts @@ -0,0 +1,18 @@ +/// + +declare module '*.module.scss' { + const classes: Record; + export default classes; +} + +declare module '*.module.css' { + const classes: Record; + export default classes; +} + +declare module '*.scss'; +declare module '*.css'; +declare module '*.png' { + const src: string; + export default src; +} diff --git a/web/static.go b/web/static.go new file mode 100644 index 0000000000000000000000000000000000000000..25f5d21415974867cae837a7579dd8160f19c47a --- /dev/null +++ b/web/static.go @@ -0,0 +1,19 @@ +package web + +import ( + "embed" + "io/fs" +) + +//go:embed all:dist +var dist embed.FS + +var Static fs.FS = mustSub(dist, "dist") + +func mustSub(fsys fs.FS, dir string) fs.FS { + sub, err := fs.Sub(fsys, dir) + if err != nil { + panic(err) + } + return sub +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..14686e4cf60877d65fc7f2ae7691b6417ec46f18 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ES2022"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "ignoreDeprecations": "6.0", + "baseUrl": ".", + "paths": { + "@/*": ["src/*"] + } + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"], + "references": [] +} diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..30a22c7e3b830d4dd03e8845e98d711f69e7a061 --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,16 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +export default defineConfig({ + base: './', + plugins: [react()], + resolve: { + alias: { + '@': path.resolve(__dirname, 'src'), + }, + }, +})