mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-09 02:47:45 +08:00
Compare commits
160
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e96b9d870 | ||
|
|
d3c0b1aa7f | ||
|
|
66bfd3e592 | ||
|
|
392c557831 | ||
|
|
c34565b02b | ||
|
|
f57fe6e13e | ||
|
|
7c678b715f | ||
|
|
bd4e5f3a5d | ||
|
|
165d9eab8f | ||
|
|
dfb95f09e1 | ||
|
|
4d5c591654 | ||
|
|
46737d32f8 | ||
|
|
25d38ae632 | ||
|
|
aa83b4a7a7 | ||
|
|
913ce2dbcb | ||
|
|
cae5e520ac | ||
|
|
772f2ea601 | ||
|
|
28fa03451c | ||
|
|
6984984c22 | ||
|
|
1209c835c7 | ||
|
|
e4ebd5cca1 | ||
|
|
f573110725 | ||
|
|
a8620e133a | ||
|
|
ddd6adbcf7 | ||
|
|
b570aaac48 | ||
|
|
086efe6efe | ||
|
|
56f3c95763 | ||
|
|
8a6a961900 | ||
|
|
6e55968487 | ||
|
|
8d8cddcef6 | ||
|
|
b90d5095f1 | ||
|
|
a4505b1281 | ||
|
|
1d72a8f9c1 | ||
|
|
3d5b6141a5 | ||
|
|
203cd5a9d5 | ||
|
|
696ec65175 | ||
|
|
cbb66a5667 | ||
|
|
53ef35ec80 | ||
|
|
1af3067303 | ||
|
|
d026398bab | ||
|
|
684689a82b | ||
|
|
eeb5f41bad | ||
|
|
7180eaea88 | ||
|
|
7cb204f18a | ||
|
|
0342f609d0 | ||
|
|
59840fa419 | ||
|
|
d390d46ee8 | ||
|
|
37eada9682 | ||
|
|
73a5325a38 | ||
|
|
460eb5434d | ||
|
|
8a21cb9a55 | ||
|
|
d0df52ce35 | ||
|
|
c1ed42fd3a | ||
|
|
c4bb6b8161 | ||
|
|
d480aa11f3 | ||
|
|
d63d5eff85 | ||
|
|
5dae2a4792 | ||
|
|
dcbd7dc219 | ||
|
|
2dcf8b8414 | ||
|
|
438f16094f | ||
|
|
40e0b82fa0 | ||
|
|
b9b0a75fe4 | ||
|
|
b6cc0bc3a7 | ||
|
|
d2f1431269 | ||
|
|
4ecaefbade | ||
|
|
c97c9332eb | ||
|
|
c070e5a9f6 | ||
|
|
8cd3a69803 | ||
|
|
791c9c98dc | ||
|
|
025e979935 | ||
|
|
131471a13f | ||
|
|
7ff63077c3 | ||
|
|
faba0cbd07 | ||
|
|
75f17935f9 | ||
|
|
60842fbbb5 | ||
|
|
d58c27d22d | ||
|
|
65550159bb | ||
|
|
3c7ad81d62 | ||
|
|
6b23c9b3ce | ||
|
|
900e54d740 | ||
|
|
8cc70934da | ||
|
|
f92b0943b5 | ||
|
|
920a383136 | ||
|
|
693e37d2df | ||
|
|
9c6036a103 | ||
|
|
751a4d9111 | ||
|
|
aafd332198 | ||
|
|
337cd0c505 | ||
|
|
b15ce9977a | ||
|
|
e0286aebe3 | ||
|
|
45985f1c04 | ||
|
|
776dd2f8ea | ||
|
|
bdfe4adc98 | ||
|
|
00a0371997 | ||
|
|
ded6b5b081 | ||
|
|
e9678ea899 | ||
|
|
8d69f72e2a | ||
|
|
127b4e11de | ||
|
|
22093bed4d | ||
|
|
ebd53ad679 | ||
|
|
280c604327 | ||
|
|
ad31cdbf85 | ||
|
|
0112ab752b | ||
|
|
66fec80e79 | ||
|
|
fddfaecf5e | ||
|
|
71ae1a2307 | ||
|
|
4d338ebd3d | ||
|
|
dc440f1507 | ||
|
|
5c732f844a | ||
|
|
c4044ba0b1 | ||
|
|
a8159b7bda | ||
|
|
0ab20be667 | ||
|
|
3f048d373f | ||
|
|
8b49a3d264 | ||
|
|
6e51a3f45d | ||
|
|
0d770d1c4d | ||
|
|
b45f021bba | ||
|
|
1a1bce3e8c | ||
|
|
6aeb5d40ab | ||
|
|
31ef2d134e | ||
|
|
85b50e67e1 | ||
|
|
9353f89af0 | ||
|
|
380d69e096 | ||
|
|
02e2f4f500 | ||
|
|
0dbfefa834 | ||
|
|
04ab4bd9f2 | ||
|
|
4955166b85 | ||
|
|
6235c772ac | ||
|
|
e2ec3f7942 | ||
|
|
a816235efb | ||
|
|
1e39ab3c2e | ||
|
|
85aa66c76d | ||
|
|
9a4817faf8 | ||
|
|
7b0c80a0c4 | ||
|
|
6ec8df97e8 | ||
|
|
f82964217e | ||
|
|
cfa5535f6e | ||
|
|
2d846b2c58 | ||
|
|
f40e8037dd | ||
|
|
2b21a75982 | ||
|
|
9ee27308db | ||
|
|
1518223de6 | ||
|
|
3063938a82 | ||
|
|
86449cae52 | ||
|
|
7c580e843f | ||
|
|
c9f0685b40 | ||
|
|
afd0dcf2ff | ||
|
|
68d4df71d8 | ||
|
|
596227659a | ||
|
|
3b0dbadb1e | ||
|
|
8a8bc999d2 | ||
|
|
57c7cca556 | ||
|
|
9e6578a71b | ||
|
|
bf818e3b61 | ||
|
|
9e7f291aaf | ||
|
|
46bab1b97f | ||
|
|
0258d01ee6 | ||
|
|
4999a1a0a8 | ||
|
|
8cb8666456 | ||
|
|
258be3b640 |
@@ -49,6 +49,40 @@ ADMIN_PASSWORD=admin123456
|
|||||||
# max-requests-jitter 会自动设置为 MAX_REQUESTS/20 (5%)
|
# max-requests-jitter 会自动设置为 MAX_REQUESTS/20 (5%)
|
||||||
# MAX_REQUESTS=4000
|
# MAX_REQUESTS=4000
|
||||||
|
|
||||||
|
# glibc malloc arena 上限(默认 2)
|
||||||
|
# 降低 malloc 内存碎片,减少 gunicorn worker RSS
|
||||||
|
# MALLOC_ARENA_MAX=2
|
||||||
|
|
||||||
|
# HTTP 连接池上限(默认总预算约 200,按 worker 平分)
|
||||||
|
# 如果容器内存偏高,可继续下调;例如 2 worker 时设为 80-100
|
||||||
|
# HTTP_MAX_CONNECTIONS=100
|
||||||
|
|
||||||
|
# HTTP 保活连接数(默认约为 max_connections 的 30%)
|
||||||
|
# HTTP_KEEPALIVE_CONNECTIONS=30
|
||||||
|
|
||||||
|
# HTTP 代理/Tunnel 客户端空闲清理(默认每 5 分钟扫描,空闲 600 秒即关闭)
|
||||||
|
# HTTP_CLIENT_IDLE_CLEANUP_INTERVAL_MINUTES=5
|
||||||
|
# HTTP_CLIENT_IDLE_CLEANUP_MAX_SECONDS=600
|
||||||
|
|
||||||
|
# curl_cffi session 池上限(默认 20,按 impersonate + proxy 组合缓存)
|
||||||
|
# CURL_CFFI_MAX_SESSIONS=20
|
||||||
|
|
||||||
|
# 流式响应块缓存上限(单位 MB,默认 2)
|
||||||
|
# 说明:
|
||||||
|
# - 这是单个流式请求可保留的“解析后响应块”内存上限,不是全局上限
|
||||||
|
# - 粗略峰值内存 ≈ 并发流数量 × RESPONSE_CHUNKS_MAX_SIZE_MB
|
||||||
|
# 例如:100 并发、2MB 上限,理论峰值约 200MB
|
||||||
|
# - 建议:
|
||||||
|
# - 内存敏感环境:1
|
||||||
|
# - 通用生产环境:2(默认)
|
||||||
|
# - 需要更多调试上下文:4
|
||||||
|
# RESPONSE_CHUNKS_MAX_SIZE_MB=2
|
||||||
|
|
||||||
|
# 流式空闲超时(单位秒,默认 30)
|
||||||
|
# 当流已经开始但连续一段时间没有任何新 chunk 时,提前中断并返回 504,
|
||||||
|
# 避免一直等到 worker 超时(如 300s)
|
||||||
|
# STREAM_IDLE_TIMEOUT_SECONDS=30
|
||||||
|
|
||||||
# API Key 前缀(默认 sk)
|
# API Key 前缀(默认 sk)
|
||||||
# API_KEY_PREFIX=sk
|
# API_KEY_PREFIX=sk
|
||||||
|
|
||||||
@@ -60,6 +94,14 @@ ADMIN_PASSWORD=admin123456
|
|||||||
# 默认: * (允许所有源)
|
# 默认: * (允许所有源)
|
||||||
# CORS_ORIGINS=*
|
# CORS_ORIGINS=*
|
||||||
|
|
||||||
|
# 启动预热配置(默认启用,降低首请求冷启动延迟)
|
||||||
|
# 是否启用启动期预热任务(默认 true)
|
||||||
|
# STARTUP_WARMUP_ENABLED=true
|
||||||
|
# /readyz 是否等待预热完成(默认 true)
|
||||||
|
# STARTUP_WARMUP_GATE_READINESS=true
|
||||||
|
# 预热时优先 bootstrap 的 provider_type 列表(逗号分隔;留空表示自动探测)
|
||||||
|
# STARTUP_WARMUP_PROVIDER_TYPES=codex,kiro
|
||||||
|
|
||||||
# ==================== 计费系统(可选) ====================
|
# ==================== 计费系统(可选) ====================
|
||||||
# Video/Image/Audio 缺失 billing_rule 时是否拒绝请求(默认 false:允许请求但 cost=0 并告警)
|
# Video/Image/Audio 缺失 billing_rule 时是否拒绝请求(默认 false:允许请求但 cost=0 并告警)
|
||||||
# BILLING_REQUIRE_RULE=false
|
# BILLING_REQUIRE_RULE=false
|
||||||
|
|||||||
@@ -7,12 +7,6 @@ on:
|
|||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
packages: write
|
|
||||||
|
|
||||||
env:
|
|
||||||
REGISTRY: ghcr.io
|
|
||||||
GHCR_IMAGE: fawney19/aether-hub
|
|
||||||
DOCKERHUB_IMAGE: fawney19/aether-hub
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -30,7 +24,7 @@ jobs:
|
|||||||
use_cross: true
|
use_cross: true
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -65,7 +59,7 @@ jobs:
|
|||||||
tar czf ../../../../aether-hub-${{ matrix.name }}.tar.gz aether-hub
|
tar czf ../../../../aether-hub-${{ matrix.name }}.tar.gz aether-hub
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: aether-hub-${{ matrix.name }}
|
name: aether-hub-${{ matrix.name }}
|
||||||
path: aether-hub-*.tar.gz
|
path: aether-hub-*.tar.gz
|
||||||
@@ -77,7 +71,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
path: artifacts
|
path: artifacts
|
||||||
@@ -95,78 +89,3 @@ jobs:
|
|||||||
artifacts/aether-hub-*
|
artifacts/aether-hub-*
|
||||||
artifacts/SHA256SUMS.txt
|
artifacts/SHA256SUMS.txt
|
||||||
fail_on_unmatched_files: true
|
fail_on_unmatched_files: true
|
||||||
|
|
||||||
docker:
|
|
||||||
needs: build
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
pattern: aether-hub-linux-*
|
|
||||||
merge-multiple: true
|
|
||||||
path: artifacts
|
|
||||||
|
|
||||||
- name: Prepare binaries
|
|
||||||
run: |
|
|
||||||
mkdir -p aether-hub/build/linux-amd64 aether-hub/build/linux-arm64
|
|
||||||
tar xzf artifacts/aether-hub-linux-amd64.tar.gz -C aether-hub/build/linux-amd64
|
|
||||||
tar xzf artifacts/aether-hub-linux-arm64.tar.gz -C aether-hub/build/linux-arm64
|
|
||||||
|
|
||||||
- name: Generate CI Dockerfile
|
|
||||||
run: |
|
|
||||||
cat > aether-hub/Dockerfile.ci << 'EOF'
|
|
||||||
FROM debian:bookworm-slim
|
|
||||||
ARG TARGETARCH
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
|
||||||
COPY build/linux-${TARGETARCH}/aether-hub /usr/local/bin/aether-hub
|
|
||||||
EXPOSE 8085
|
|
||||||
ENTRYPOINT ["/usr/local/bin/aether-hub"]
|
|
||||||
CMD ["--bind", "0.0.0.0:8085"]
|
|
||||||
EOF
|
|
||||||
|
|
||||||
- name: Set up QEMU
|
|
||||||
uses: docker/setup-qemu-action@v3
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v3
|
|
||||||
|
|
||||||
- name: Log in to GHCR
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
registry: ${{ env.REGISTRY }}
|
|
||||||
username: ${{ github.actor }}
|
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Log in to Docker Hub
|
|
||||||
uses: docker/login-action@v3
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Extract metadata
|
|
||||||
id: meta
|
|
||||||
uses: docker/metadata-action@v5
|
|
||||||
with:
|
|
||||||
images: |
|
|
||||||
${{ env.REGISTRY }}/${{ env.GHCR_IMAGE }}
|
|
||||||
docker.io/${{ env.DOCKERHUB_IMAGE }}
|
|
||||||
tags: |
|
|
||||||
type=match,pattern=hub-v(.*),group=1
|
|
||||||
type=match,pattern=hub-v(\d+\.\d+),group=1
|
|
||||||
type=sha,prefix=
|
|
||||||
flavor: |
|
|
||||||
latest=auto
|
|
||||||
|
|
||||||
- name: Build and push
|
|
||||||
uses: docker/build-push-action@v5
|
|
||||||
with:
|
|
||||||
context: ./aether-hub
|
|
||||||
file: ./aether-hub/Dockerfile.ci
|
|
||||||
push: true
|
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
|
||||||
platforms: linux/amd64,linux/arm64
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ jobs:
|
|||||||
use_cross: false
|
use_cross: false
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Rust toolchain
|
- name: Install Rust toolchain
|
||||||
uses: dtolnay/rust-toolchain@stable
|
uses: dtolnay/rust-toolchain@stable
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
|
7z a ../../../../aether-proxy-${{ matrix.name }}.zip aether-proxy.exe
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v5
|
||||||
with:
|
with:
|
||||||
name: aether-proxy-${{ matrix.name }}
|
name: aether-proxy-${{ matrix.name }}
|
||||||
path: |
|
path: |
|
||||||
@@ -101,7 +101,7 @@ jobs:
|
|||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
path: artifacts
|
path: artifacts
|
||||||
@@ -125,10 +125,10 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Download Linux artifacts
|
- name: Download Linux artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v5
|
||||||
with:
|
with:
|
||||||
pattern: aether-proxy-linux-*
|
pattern: aether-proxy-linux-*
|
||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
@@ -174,7 +174,7 @@ jobs:
|
|||||||
latest=auto
|
latest=auto
|
||||||
|
|
||||||
- name: Build and push
|
- name: Build and push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: ./aether-proxy
|
context: ./aether-proxy
|
||||||
push: true
|
push: true
|
||||||
@@ -187,7 +187,7 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
if: startsWith(github.ref, 'refs/tags/')
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
with:
|
with:
|
||||||
ref: master
|
ref: master
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: '20'
|
node-version: '22'
|
||||||
cache: 'npm'
|
cache: 'npm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
run: cp frontend/dist/index.html frontend/dist/404.html
|
run: cp frontend/dist/index.html frontend/dist/404.html
|
||||||
|
|
||||||
- name: Setup Pages
|
- name: Setup Pages
|
||||||
uses: actions/configure-pages@v4
|
uses: actions/configure-pages@v5
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v3
|
uses: actions/upload-pages-artifact@v3
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
outputs:
|
outputs:
|
||||||
base_changed: ${{ steps.check.outputs.base_changed }}
|
base_changed: ${{ steps.check.outputs.base_changed }}
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Log in to Container Registry
|
- name: Log in to Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
@@ -108,7 +108,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -164,7 +164,7 @@ jobs:
|
|||||||
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
org.opencontainers.image.base.hash=${{ steps.hash.outputs.hash }}
|
||||||
|
|
||||||
- name: Build and push base image
|
- name: Build and push base image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.base
|
file: ./Dockerfile.base
|
||||||
@@ -204,7 +204,7 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
packages: write
|
packages: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
@@ -271,7 +271,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push app image (amd64)
|
- name: Build and push app image (amd64)
|
||||||
id: build-amd64
|
id: build-amd64
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.app
|
file: ./Dockerfile.app
|
||||||
@@ -287,7 +287,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build and push app image (arm64)
|
- name: Build and push app image (arm64)
|
||||||
id: build-arm64
|
id: build-arm64
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile.app
|
file: ./Dockerfile.app
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||||
|
|
||||||
# AI Assistant Configuration
|
# AI Assistant Configuration
|
||||||
|
.codex/
|
||||||
.claude/
|
.claude/
|
||||||
.serena/
|
.serena/
|
||||||
.gemini*/
|
.gemini*/
|
||||||
@@ -203,6 +204,7 @@ logs/
|
|||||||
|
|
||||||
# Git backup
|
# Git backup
|
||||||
.git.backup/
|
.git.backup/
|
||||||
|
.worktrees/
|
||||||
|
|
||||||
# Database backups
|
# Database backups
|
||||||
backups/
|
backups/
|
||||||
|
|||||||
+65
-4
@@ -25,7 +25,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
nginx \
|
nginx \
|
||||||
supervisor \
|
supervisor \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
curl
|
curl \
|
||||||
|
libjemalloc2
|
||||||
|
RUN set -eux; \
|
||||||
|
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
|
||||||
|
[ -n "$jemalloc_path" ]; \
|
||||||
|
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
|
||||||
# 从 base 镜像复制 Python 包
|
# 从 base 镜像复制 Python 包
|
||||||
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
|
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
|
||||||
# 只复制需要的 Python 可执行文件
|
# 只复制需要的 Python 可执行文件
|
||||||
@@ -85,6 +90,11 @@ RUN printf '%s\n' \
|
|||||||
' "" $remote_addr;' \
|
' "" $remote_addr;' \
|
||||||
'}' \
|
'}' \
|
||||||
'' \
|
'' \
|
||||||
|
'map $http_upgrade $connection_upgrade {' \
|
||||||
|
' default upgrade;' \
|
||||||
|
' "" "";' \
|
||||||
|
'}' \
|
||||||
|
'' \
|
||||||
'server {' \
|
'server {' \
|
||||||
' listen 80;' \
|
' listen 80;' \
|
||||||
' server_name _;' \
|
' server_name _;' \
|
||||||
@@ -124,6 +134,25 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
' proxy_set_header Upgrade $http_upgrade;' \
|
' proxy_set_header Upgrade $http_upgrade;' \
|
||||||
' proxy_set_header Connection "upgrade";' \
|
' proxy_set_header Connection "upgrade";' \
|
||||||
|
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
|
||||||
|
' proxy_hide_header CF-Connecting-IP;' \
|
||||||
|
' proxy_hide_header CF-IPCountry;' \
|
||||||
|
' proxy_hide_header CF-Ray;' \
|
||||||
|
' proxy_hide_header CF-Visitor;' \
|
||||||
|
' proxy_hide_header CDN-Loop;' \
|
||||||
|
' proxy_hide_header True-Client-IP;' \
|
||||||
|
' proxy_hide_header CF-Worker;' \
|
||||||
|
' proxy_hide_header CF-EW-Via;' \
|
||||||
|
' proxy_hide_header CF-Warp-Tag-ID;' \
|
||||||
|
' proxy_set_header CF-Connecting-IP "";' \
|
||||||
|
' proxy_set_header CF-IPCountry "";' \
|
||||||
|
' proxy_set_header CF-Ray "";' \
|
||||||
|
' proxy_set_header CF-Visitor "";' \
|
||||||
|
' proxy_set_header CDN-Loop "";' \
|
||||||
|
' proxy_set_header True-Client-IP "";' \
|
||||||
|
' proxy_set_header CF-Worker "";' \
|
||||||
|
' proxy_set_header CF-EW-Via "";' \
|
||||||
|
' proxy_set_header CF-Warp-Tag-ID "";' \
|
||||||
' proxy_read_timeout 86400s;' \
|
' proxy_read_timeout 86400s;' \
|
||||||
' proxy_send_timeout 86400s;' \
|
' proxy_send_timeout 86400s;' \
|
||||||
' }' \
|
' }' \
|
||||||
@@ -136,12 +165,22 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header X-Real-IP $real_ip;' \
|
' proxy_set_header X-Real-IP $real_ip;' \
|
||||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
' proxy_set_header Connection "";' \
|
' proxy_set_header Upgrade $http_upgrade;' \
|
||||||
|
' proxy_set_header Connection $connection_upgrade;' \
|
||||||
' proxy_set_header Accept $http_accept;' \
|
' proxy_set_header Accept $http_accept;' \
|
||||||
' proxy_set_header Content-Type $content_type;' \
|
' proxy_set_header Content-Type $content_type;' \
|
||||||
' proxy_set_header Authorization $http_authorization;' \
|
' proxy_set_header Authorization $http_authorization;' \
|
||||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||||
' # 剥离 CF 头,防止泄露给上游 AI 提供商' \
|
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
|
||||||
|
' proxy_hide_header CF-Connecting-IP;' \
|
||||||
|
' proxy_hide_header CF-IPCountry;' \
|
||||||
|
' proxy_hide_header CF-Ray;' \
|
||||||
|
' proxy_hide_header CF-Visitor;' \
|
||||||
|
' proxy_hide_header CDN-Loop;' \
|
||||||
|
' proxy_hide_header True-Client-IP;' \
|
||||||
|
' proxy_hide_header CF-Worker;' \
|
||||||
|
' proxy_hide_header CF-EW-Via;' \
|
||||||
|
' proxy_hide_header CF-Warp-Tag-ID;' \
|
||||||
' proxy_set_header CF-Connecting-IP "";' \
|
' proxy_set_header CF-Connecting-IP "";' \
|
||||||
' proxy_set_header CF-IPCountry "";' \
|
' proxy_set_header CF-IPCountry "";' \
|
||||||
' proxy_set_header CF-Ray "";' \
|
' proxy_set_header CF-Ray "";' \
|
||||||
@@ -150,6 +189,7 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header True-Client-IP "";' \
|
' proxy_set_header True-Client-IP "";' \
|
||||||
' proxy_set_header CF-Worker "";' \
|
' proxy_set_header CF-Worker "";' \
|
||||||
' proxy_set_header CF-EW-Via "";' \
|
' proxy_set_header CF-EW-Via "";' \
|
||||||
|
' proxy_set_header CF-Warp-Tag-ID "";' \
|
||||||
' proxy_buffering off;' \
|
' proxy_buffering off;' \
|
||||||
' proxy_cache off;' \
|
' proxy_cache off;' \
|
||||||
' proxy_request_buffering off;' \
|
' proxy_request_buffering off;' \
|
||||||
@@ -169,6 +209,25 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header X-Real-IP $real_ip;' \
|
' proxy_set_header X-Real-IP $real_ip;' \
|
||||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
|
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
|
||||||
|
' proxy_hide_header CF-Connecting-IP;' \
|
||||||
|
' proxy_hide_header CF-IPCountry;' \
|
||||||
|
' proxy_hide_header CF-Ray;' \
|
||||||
|
' proxy_hide_header CF-Visitor;' \
|
||||||
|
' proxy_hide_header CDN-Loop;' \
|
||||||
|
' proxy_hide_header True-Client-IP;' \
|
||||||
|
' proxy_hide_header CF-Worker;' \
|
||||||
|
' proxy_hide_header CF-EW-Via;' \
|
||||||
|
' proxy_hide_header CF-Warp-Tag-ID;' \
|
||||||
|
' proxy_set_header CF-Connecting-IP "";' \
|
||||||
|
' proxy_set_header CF-IPCountry "";' \
|
||||||
|
' proxy_set_header CF-Ray "";' \
|
||||||
|
' proxy_set_header CF-Visitor "";' \
|
||||||
|
' proxy_set_header CDN-Loop "";' \
|
||||||
|
' proxy_set_header True-Client-IP "";' \
|
||||||
|
' proxy_set_header CF-Worker "";' \
|
||||||
|
' proxy_set_header CF-EW-Via "";' \
|
||||||
|
' proxy_set_header CF-Warp-Tag-ID "";' \
|
||||||
' }' \
|
' }' \
|
||||||
'' \
|
'' \
|
||||||
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
||||||
@@ -199,7 +258,7 @@ RUN printf '%s\n' \
|
|||||||
'stdout_logfile_maxbytes=0' \
|
'stdout_logfile_maxbytes=0' \
|
||||||
'stderr_logfile=/dev/stderr' \
|
'stderr_logfile=/dev/stderr' \
|
||||||
'stderr_logfile_maxbytes=0' \
|
'stderr_logfile_maxbytes=0' \
|
||||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
|
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' \
|
||||||
'' \
|
'' \
|
||||||
'[program:tunnel-hub]' \
|
'[program:tunnel-hub]' \
|
||||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||||
@@ -220,6 +279,8 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
PYTHONIOENCODING=utf-8 \
|
PYTHONIOENCODING=utf-8 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
LC_ALL=C.UTF-8 \
|
LC_ALL=C.UTF-8 \
|
||||||
|
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
|
||||||
|
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
|
||||||
PORT=8084 \
|
PORT=8084 \
|
||||||
GUNICORN_WORKERS=2 \
|
GUNICORN_WORKERS=2 \
|
||||||
MAX_REQUESTS=4000
|
MAX_REQUESTS=4000
|
||||||
|
|||||||
+89
-16
@@ -20,6 +20,11 @@ ARG HUB_RELEASE_REPO=fawney19/Aether
|
|||||||
ARG HUB_TAG
|
ARG HUB_TAG
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG GITHUB_TOKEN
|
ARG GITHUB_TOKEN
|
||||||
|
# GitHub 下载镜像前缀,国内构建时传入可用的镜像加速地址
|
||||||
|
# 用法: --build-arg GITHUB_MIRROR=https://ghfast.top
|
||||||
|
# 或: --build-arg GITHUB_MIRROR=https://gh-proxy.com
|
||||||
|
# 或: --build-arg GITHUB_MIRROR=https://mirror.ghproxy.com
|
||||||
|
ARG GITHUB_MIRROR
|
||||||
|
|
||||||
# 运行时依赖(使用清华镜像源 + BuildKit 缓存加速)
|
# 运行时依赖(使用清华镜像源 + BuildKit 缓存加速)
|
||||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||||
@@ -29,7 +34,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
nginx \
|
nginx \
|
||||||
supervisor \
|
supervisor \
|
||||||
libpq5 \
|
libpq5 \
|
||||||
curl
|
curl \
|
||||||
|
libjemalloc2
|
||||||
|
RUN set -eux; \
|
||||||
|
jemalloc_path="$(find /usr/lib -type f -name 'libjemalloc.so.2' | head -n1)"; \
|
||||||
|
[ -n "$jemalloc_path" ]; \
|
||||||
|
ln -sf "$jemalloc_path" /usr/local/lib/libjemalloc.so.2
|
||||||
|
|
||||||
# 从 base 镜像复制 Python 包
|
# 从 base 镜像复制 Python 包
|
||||||
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
|
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
|
||||||
@@ -39,9 +49,20 @@ COPY --from=builder /usr/local/bin/gunicorn /usr/local/bin/
|
|||||||
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
COPY --from=builder /usr/local/bin/uvicorn /usr/local/bin/
|
||||||
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
COPY --from=builder /usr/local/bin/alembic /usr/local/bin/
|
||||||
|
|
||||||
# Hub 预编译二进制(构建时从 GitHub Release 下载)
|
# Hub 预编译二进制
|
||||||
|
# 国内构建: --build-arg GITHUB_MIRROR=https://ghfast.top 即可走镜像下载
|
||||||
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
|
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
|
||||||
RUN set -eux; \
|
RUN set -eux; \
|
||||||
|
arch="${TARGETARCH:-}"; \
|
||||||
|
if [ -z "$arch" ]; then \
|
||||||
|
arch="$(dpkg --print-architecture)"; \
|
||||||
|
fi; \
|
||||||
|
case "$arch" in \
|
||||||
|
amd64|arm64) ;; \
|
||||||
|
x86_64) arch="amd64" ;; \
|
||||||
|
aarch64) arch="arm64" ;; \
|
||||||
|
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
|
||||||
|
esac; \
|
||||||
auth_header=""; \
|
auth_header=""; \
|
||||||
if [ -n "${GITHUB_TOKEN:-}" ]; then \
|
if [ -n "${GITHUB_TOKEN:-}" ]; then \
|
||||||
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
|
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
|
||||||
@@ -54,18 +75,14 @@ RUN set -eux; \
|
|||||||
echo "Failed to resolve hub release tag"; \
|
echo "Failed to resolve hub release tag"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
fi; \
|
fi; \
|
||||||
arch="${TARGETARCH:-}"; \
|
|
||||||
if [ -z "$arch" ]; then \
|
|
||||||
arch="$(dpkg --print-architecture)"; \
|
|
||||||
fi; \
|
|
||||||
case "$arch" in \
|
|
||||||
amd64|arm64) ;; \
|
|
||||||
x86_64) arch="amd64" ;; \
|
|
||||||
aarch64) arch="arm64" ;; \
|
|
||||||
*) echo "Unsupported architecture: $arch"; exit 1 ;; \
|
|
||||||
esac; \
|
|
||||||
echo "Using Hub release tag: $tag"; \
|
echo "Using Hub release tag: $tag"; \
|
||||||
url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
|
origin_url="https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
|
||||||
|
if [ -n "${GITHUB_MIRROR:-}" ]; then \
|
||||||
|
url="${GITHUB_MIRROR}/https://github.com/${HUB_RELEASE_REPO}/releases/download/${tag}/aether-hub-linux-${arch}.tar.gz"; \
|
||||||
|
echo "Using mirror: ${GITHUB_MIRROR}"; \
|
||||||
|
else \
|
||||||
|
url="$origin_url"; \
|
||||||
|
fi; \
|
||||||
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
|
curl -L --fail -o /tmp/aether-hub.tar.gz "$url"; \
|
||||||
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
|
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
|
||||||
chmod +x /usr/local/bin/aether-hub; \
|
chmod +x /usr/local/bin/aether-hub; \
|
||||||
@@ -95,6 +112,11 @@ RUN printf '%s\n' \
|
|||||||
' "" $remote_addr;' \
|
' "" $remote_addr;' \
|
||||||
'}' \
|
'}' \
|
||||||
'' \
|
'' \
|
||||||
|
'map $http_upgrade $connection_upgrade {' \
|
||||||
|
' default upgrade;' \
|
||||||
|
' "" "";' \
|
||||||
|
'}' \
|
||||||
|
'' \
|
||||||
'server {' \
|
'server {' \
|
||||||
' listen 80;' \
|
' listen 80;' \
|
||||||
' server_name _;' \
|
' server_name _;' \
|
||||||
@@ -134,6 +156,25 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
' proxy_set_header Upgrade $http_upgrade;' \
|
' proxy_set_header Upgrade $http_upgrade;' \
|
||||||
' proxy_set_header Connection "upgrade";' \
|
' proxy_set_header Connection "upgrade";' \
|
||||||
|
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
|
||||||
|
' proxy_hide_header CF-Connecting-IP;' \
|
||||||
|
' proxy_hide_header CF-IPCountry;' \
|
||||||
|
' proxy_hide_header CF-Ray;' \
|
||||||
|
' proxy_hide_header CF-Visitor;' \
|
||||||
|
' proxy_hide_header CDN-Loop;' \
|
||||||
|
' proxy_hide_header True-Client-IP;' \
|
||||||
|
' proxy_hide_header CF-Worker;' \
|
||||||
|
' proxy_hide_header CF-EW-Via;' \
|
||||||
|
' proxy_hide_header CF-Warp-Tag-ID;' \
|
||||||
|
' proxy_set_header CF-Connecting-IP "";' \
|
||||||
|
' proxy_set_header CF-IPCountry "";' \
|
||||||
|
' proxy_set_header CF-Ray "";' \
|
||||||
|
' proxy_set_header CF-Visitor "";' \
|
||||||
|
' proxy_set_header CDN-Loop "";' \
|
||||||
|
' proxy_set_header True-Client-IP "";' \
|
||||||
|
' proxy_set_header CF-Worker "";' \
|
||||||
|
' proxy_set_header CF-EW-Via "";' \
|
||||||
|
' proxy_set_header CF-Warp-Tag-ID "";' \
|
||||||
' proxy_read_timeout 86400s;' \
|
' proxy_read_timeout 86400s;' \
|
||||||
' proxy_send_timeout 86400s;' \
|
' proxy_send_timeout 86400s;' \
|
||||||
' }' \
|
' }' \
|
||||||
@@ -146,12 +187,22 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header X-Real-IP $real_ip;' \
|
' proxy_set_header X-Real-IP $real_ip;' \
|
||||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
' proxy_set_header Connection "";' \
|
' proxy_set_header Upgrade $http_upgrade;' \
|
||||||
|
' proxy_set_header Connection $connection_upgrade;' \
|
||||||
' proxy_set_header Accept $http_accept;' \
|
' proxy_set_header Accept $http_accept;' \
|
||||||
' proxy_set_header Content-Type $content_type;' \
|
' proxy_set_header Content-Type $content_type;' \
|
||||||
' proxy_set_header Authorization $http_authorization;' \
|
' proxy_set_header Authorization $http_authorization;' \
|
||||||
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
' proxy_set_header X-Api-Key $http_x_api_key;' \
|
||||||
' # 剥离 CF 头,防止泄露给上游 AI 提供商' \
|
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
|
||||||
|
' proxy_hide_header CF-Connecting-IP;' \
|
||||||
|
' proxy_hide_header CF-IPCountry;' \
|
||||||
|
' proxy_hide_header CF-Ray;' \
|
||||||
|
' proxy_hide_header CF-Visitor;' \
|
||||||
|
' proxy_hide_header CDN-Loop;' \
|
||||||
|
' proxy_hide_header True-Client-IP;' \
|
||||||
|
' proxy_hide_header CF-Worker;' \
|
||||||
|
' proxy_hide_header CF-EW-Via;' \
|
||||||
|
' proxy_hide_header CF-Warp-Tag-ID;' \
|
||||||
' proxy_set_header CF-Connecting-IP "";' \
|
' proxy_set_header CF-Connecting-IP "";' \
|
||||||
' proxy_set_header CF-IPCountry "";' \
|
' proxy_set_header CF-IPCountry "";' \
|
||||||
' proxy_set_header CF-Ray "";' \
|
' proxy_set_header CF-Ray "";' \
|
||||||
@@ -160,6 +211,7 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header True-Client-IP "";' \
|
' proxy_set_header True-Client-IP "";' \
|
||||||
' proxy_set_header CF-Worker "";' \
|
' proxy_set_header CF-Worker "";' \
|
||||||
' proxy_set_header CF-EW-Via "";' \
|
' proxy_set_header CF-EW-Via "";' \
|
||||||
|
' proxy_set_header CF-Warp-Tag-ID "";' \
|
||||||
' proxy_buffering off;' \
|
' proxy_buffering off;' \
|
||||||
' proxy_cache off;' \
|
' proxy_cache off;' \
|
||||||
' proxy_request_buffering off;' \
|
' proxy_request_buffering off;' \
|
||||||
@@ -179,6 +231,25 @@ RUN printf '%s\n' \
|
|||||||
' proxy_set_header X-Real-IP $real_ip;' \
|
' proxy_set_header X-Real-IP $real_ip;' \
|
||||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||||
|
' # 剥离 CF 头,防止泄露给上游或返回给客户端' \
|
||||||
|
' proxy_hide_header CF-Connecting-IP;' \
|
||||||
|
' proxy_hide_header CF-IPCountry;' \
|
||||||
|
' proxy_hide_header CF-Ray;' \
|
||||||
|
' proxy_hide_header CF-Visitor;' \
|
||||||
|
' proxy_hide_header CDN-Loop;' \
|
||||||
|
' proxy_hide_header True-Client-IP;' \
|
||||||
|
' proxy_hide_header CF-Worker;' \
|
||||||
|
' proxy_hide_header CF-EW-Via;' \
|
||||||
|
' proxy_hide_header CF-Warp-Tag-ID;' \
|
||||||
|
' proxy_set_header CF-Connecting-IP "";' \
|
||||||
|
' proxy_set_header CF-IPCountry "";' \
|
||||||
|
' proxy_set_header CF-Ray "";' \
|
||||||
|
' proxy_set_header CF-Visitor "";' \
|
||||||
|
' proxy_set_header CDN-Loop "";' \
|
||||||
|
' proxy_set_header True-Client-IP "";' \
|
||||||
|
' proxy_set_header CF-Worker "";' \
|
||||||
|
' proxy_set_header CF-EW-Via "";' \
|
||||||
|
' proxy_set_header CF-Warp-Tag-ID "";' \
|
||||||
' }' \
|
' }' \
|
||||||
'' \
|
'' \
|
||||||
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
' # 所有其他路由 → 前端 SPA(先尝试静态文件,再回退到 index.html)' \
|
||||||
@@ -210,7 +281,7 @@ RUN printf '%s\n' \
|
|||||||
'stdout_logfile_maxbytes=0' \
|
'stdout_logfile_maxbytes=0' \
|
||||||
'stderr_logfile=/dev/stderr' \
|
'stderr_logfile=/dev/stderr' \
|
||||||
'stderr_logfile_maxbytes=0' \
|
'stderr_logfile_maxbytes=0' \
|
||||||
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true' \
|
'environment=PYTHONUNBUFFERED=1,PYTHONIOENCODING=utf-8,LANG=C.UTF-8,LC_ALL=C.UTF-8,DOCKER_CONTAINER=true,LD_PRELOAD=/usr/local/lib/libjemalloc.so.2,MALLOC_CONF="background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000"' \
|
||||||
'' \
|
'' \
|
||||||
'[program:tunnel-hub]' \
|
'[program:tunnel-hub]' \
|
||||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||||
@@ -234,6 +305,8 @@ ENV PYTHONUNBUFFERED=1 \
|
|||||||
PYTHONIOENCODING=utf-8 \
|
PYTHONIOENCODING=utf-8 \
|
||||||
LANG=C.UTF-8 \
|
LANG=C.UTF-8 \
|
||||||
LC_ALL=C.UTF-8 \
|
LC_ALL=C.UTF-8 \
|
||||||
|
LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 \
|
||||||
|
MALLOC_CONF=background_thread:true,dirty_decay_ms:5000,muzzy_decay_ms:5000 \
|
||||||
PORT=8084 \
|
PORT=8084 \
|
||||||
GUNICORN_WORKERS=2 \
|
GUNICORN_WORKERS=2 \
|
||||||
MAX_REQUESTS=4000
|
MAX_REQUESTS=4000
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ Aether 是一个自托管的 AI API 网关,为团队和个人提供多租户
|
|||||||
|
|
||||||
## 部署
|
## 部署
|
||||||
|
|
||||||
### Docker Compose(推荐:本地构建镜像)
|
### Docker Compose(推荐:预构建镜像)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 克隆代码
|
# 1. 克隆代码
|
||||||
@@ -64,6 +64,7 @@ cp .env.example .env
|
|||||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||||
|
|
||||||
# 3. 部署 / 更新(自动构建、启动、迁移)
|
# 3. 部署 / 更新(自动构建、启动、迁移)
|
||||||
|
git pull
|
||||||
./deploy.sh
|
./deploy.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Generated
+794
-11
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "aether-hub"
|
name = "aether-hub"
|
||||||
version = "0.1.4"
|
version = "0.2.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Tunnel Hub for Aether - frame router between workers and proxies"
|
description = "Tunnel Hub for Aether - frame router between workers and proxies"
|
||||||
|
|
||||||
@@ -16,6 +16,10 @@ dashmap = "6"
|
|||||||
parking_lot = "0.12"
|
parking_lot = "0.12"
|
||||||
flate2 = "1"
|
flate2 = "1"
|
||||||
futures-util = "0.3"
|
futures-util = "0.3"
|
||||||
|
bytes = "1"
|
||||||
|
async-stream = "0.3"
|
||||||
|
http-body-util = "0.1"
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
@@ -28,6 +28,11 @@ cd /path/to/Aether
|
|||||||
- `--load`: 加载到本地 Docker(单平台)
|
- `--load`: 加载到本地 Docker(单平台)
|
||||||
- `--latest`: 额外打 `latest` tag
|
- `--latest`: 额外打 `latest` tag
|
||||||
|
|
||||||
|
## 运行时参数
|
||||||
|
|
||||||
|
- `TUNNEL_HUB_WORKER_IDLE_TIMEOUT`:worker 心跳空闲超时,默认 `60` 秒
|
||||||
|
- `TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY`:单连接出站队列容量,默认 `128`;队列打满时会把连接视为拥塞并主动关闭,避免 Hub 内存无限增长
|
||||||
|
|
||||||
## 与部署脚本关系
|
## 与部署脚本关系
|
||||||
|
|
||||||
- `./deploy.sh`: 本地构建部署(会本地构建 app/base,并在构建 app 时从 GitHub Release 下载 Hub,可用 `--hub-tag` 固定版本)。
|
- `./deploy.sh`: 本地构建部署(会本地构建 app/base,并在构建 app 时从 GitHub Release 下载 Hub,可用 `--hub-tag` 固定版本)。
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct ControlPlaneClient {
|
||||||
|
client: Option<Client>,
|
||||||
|
base_url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ControlPlaneClient {
|
||||||
|
pub fn new(base_url: String) -> Self {
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.ok();
|
||||||
|
Self { client, base_url }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
client: None,
|
||||||
|
base_url: String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn heartbeat_ack(&self, payload: &[u8]) -> Result<Vec<u8>, String> {
|
||||||
|
let Some(client) = &self.client else {
|
||||||
|
return Ok(b"{}".to_vec());
|
||||||
|
};
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/internal/hub/heartbeat",
|
||||||
|
self.base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(payload.to_vec())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("heartbeat callback request failed: {e}"))?;
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!(
|
||||||
|
"heartbeat callback failed with status {}",
|
||||||
|
response.status()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
response
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map(|bytes| bytes.to_vec())
|
||||||
|
.map_err(|e| format!("heartbeat callback body read failed: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn push_node_status(
|
||||||
|
&self,
|
||||||
|
node_id: &str,
|
||||||
|
connected: bool,
|
||||||
|
conn_count: usize,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let Some(client) = &self.client else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let url = format!(
|
||||||
|
"{}/api/internal/hub/node-status",
|
||||||
|
self.base_url.trim_end_matches('/')
|
||||||
|
);
|
||||||
|
let response = client
|
||||||
|
.post(&url)
|
||||||
|
.json(&serde_json::json!({
|
||||||
|
"node_id": node_id,
|
||||||
|
"connected": connected,
|
||||||
|
"conn_count": conn_count,
|
||||||
|
}))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("node-status callback request failed: {e}"))?;
|
||||||
|
if response.status().is_success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!(
|
||||||
|
"node-status callback failed with status {}",
|
||||||
|
response.status()
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+621
-461
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,262 @@
|
|||||||
|
use std::io;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use async_stream::stream;
|
||||||
|
use axum::body::{Body, Bytes};
|
||||||
|
use axum::extract::{ConnectInfo, Path, Request, State};
|
||||||
|
use axum::http::{HeaderMap, HeaderName, HeaderValue, Response, StatusCode};
|
||||||
|
use axum::response::IntoResponse;
|
||||||
|
use bytes::BytesMut;
|
||||||
|
use futures_util::StreamExt;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::hub::{LocalBodyEvent, LocalStream};
|
||||||
|
use crate::protocol;
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
pub const TUNNEL_ERROR_HEADER: &str = "x-aether-tunnel-error";
|
||||||
|
const MAX_RELAY_META_LEN: usize = 256 * 1024;
|
||||||
|
|
||||||
|
struct StreamGuard {
|
||||||
|
hub: std::sync::Arc<crate::hub::HubRouter>,
|
||||||
|
stream_id: u64,
|
||||||
|
finished: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for StreamGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if !self.finished {
|
||||||
|
self.hub
|
||||||
|
.cancel_local_stream(self.stream_id, "local relay client dropped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn relay_request(
|
||||||
|
Path(node_id): Path<String>,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
||||||
|
request: Request,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
if !addr.ip().is_loopback() {
|
||||||
|
return tunnel_error_response(
|
||||||
|
StatusCode::FORBIDDEN,
|
||||||
|
"forbidden",
|
||||||
|
"local relay only accepts loopback requests",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut body_stream = request.into_body().into_data_stream();
|
||||||
|
let mut envelope_buf = BytesMut::new();
|
||||||
|
let mut meta: Option<protocol::RequestMeta> = None;
|
||||||
|
let mut stream: Option<std::sync::Arc<LocalStream>> = None;
|
||||||
|
|
||||||
|
while let Some(chunk_result) = body_stream.next().await {
|
||||||
|
let chunk = match chunk_result {
|
||||||
|
Ok(chunk) => chunk,
|
||||||
|
Err(error) => {
|
||||||
|
if let Some(active_stream) = &stream {
|
||||||
|
state
|
||||||
|
.hub
|
||||||
|
.cancel_local_stream(active_stream.id, "failed to read relay request body");
|
||||||
|
}
|
||||||
|
warn!(error = %error, "failed to read local relay request body");
|
||||||
|
return tunnel_error_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"relay",
|
||||||
|
"failed to read relay request body",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if stream.is_none() {
|
||||||
|
envelope_buf.extend_from_slice(&chunk);
|
||||||
|
let Some((parsed_meta, body_offset)) = (match try_decode_envelope_meta(&envelope_buf) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(error) => {
|
||||||
|
return tunnel_error_response(StatusCode::BAD_REQUEST, "bad_request", &error);
|
||||||
|
}
|
||||||
|
}) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let opened_stream = match state.hub.open_local_stream(&node_id, &parsed_meta) {
|
||||||
|
Ok(stream) => stream,
|
||||||
|
Err(error) => {
|
||||||
|
return tunnel_error_response(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"connect",
|
||||||
|
&error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if envelope_buf.len() > body_offset {
|
||||||
|
let first_body_chunk = Bytes::copy_from_slice(&envelope_buf[body_offset..]);
|
||||||
|
if let Err(error) =
|
||||||
|
state
|
||||||
|
.hub
|
||||||
|
.push_local_request_body(opened_stream.id, first_body_chunk, false)
|
||||||
|
{
|
||||||
|
state.hub.cancel_local_stream(opened_stream.id, &error);
|
||||||
|
return tunnel_error_response(
|
||||||
|
StatusCode::SERVICE_UNAVAILABLE,
|
||||||
|
"connect",
|
||||||
|
&error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
envelope_buf.clear();
|
||||||
|
meta = Some(parsed_meta);
|
||||||
|
stream = Some(opened_stream);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(active_stream) = &stream else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if let Err(error) = state
|
||||||
|
.hub
|
||||||
|
.push_local_request_body(active_stream.id, chunk, false)
|
||||||
|
{
|
||||||
|
state.hub.cancel_local_stream(active_stream.id, &error);
|
||||||
|
return tunnel_error_response(StatusCode::SERVICE_UNAVAILABLE, "connect", &error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let (meta, stream) = match (meta, stream) {
|
||||||
|
(Some(meta), Some(stream)) => (meta, stream),
|
||||||
|
_ => {
|
||||||
|
return tunnel_error_response(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
"bad_request",
|
||||||
|
"relay envelope metadata truncated",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = state
|
||||||
|
.hub
|
||||||
|
.push_local_request_body(stream.id, Bytes::new(), true)
|
||||||
|
{
|
||||||
|
state.hub.cancel_local_stream(stream.id, &error);
|
||||||
|
return tunnel_error_response(StatusCode::SERVICE_UNAVAILABLE, "connect", &error);
|
||||||
|
}
|
||||||
|
|
||||||
|
let request_guard = StreamGuard {
|
||||||
|
hub: state.hub.clone(),
|
||||||
|
stream_id: stream.id,
|
||||||
|
finished: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let wait_timeout = Duration::from_secs(meta.timeout.clamp(5, 300));
|
||||||
|
let response_head = match stream.wait_headers(wait_timeout).await {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
state.hub.cancel_local_stream(stream.id, &error);
|
||||||
|
return tunnel_error_response(StatusCode::GATEWAY_TIMEOUT, "timeout", &error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(mut body_rx) = stream.take_body_receiver() else {
|
||||||
|
state
|
||||||
|
.hub
|
||||||
|
.cancel_local_stream(stream.id, "missing relay response body receiver");
|
||||||
|
return tunnel_error_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"relay",
|
||||||
|
"missing relay response body receiver",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
let hub = state.hub.clone();
|
||||||
|
let stream_id = stream.id;
|
||||||
|
let body_stream = stream! {
|
||||||
|
let mut guard = request_guard;
|
||||||
|
guard.hub = hub;
|
||||||
|
guard.stream_id = stream_id;
|
||||||
|
while let Some(event) = body_rx.recv().await {
|
||||||
|
match event {
|
||||||
|
LocalBodyEvent::Chunk(chunk) => yield Ok::<Bytes, io::Error>(chunk),
|
||||||
|
LocalBodyEvent::End => {
|
||||||
|
guard.finished = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
LocalBodyEvent::Error(error) => {
|
||||||
|
guard.finished = true;
|
||||||
|
yield Err(io::Error::other(error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
guard.finished = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut builder = Response::builder().status(response_head.status);
|
||||||
|
if let Some(headers) = builder.headers_mut() {
|
||||||
|
append_headers(headers, &response_head.headers);
|
||||||
|
}
|
||||||
|
match builder.body(Body::from_stream(body_stream)) {
|
||||||
|
Ok(response) => response,
|
||||||
|
Err(error) => {
|
||||||
|
warn!(error = %error, "failed to build relay response");
|
||||||
|
tunnel_error_response(
|
||||||
|
StatusCode::BAD_GATEWAY,
|
||||||
|
"relay",
|
||||||
|
"failed to build relay response",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn try_decode_envelope_meta(
|
||||||
|
buffer: &BytesMut,
|
||||||
|
) -> Result<Option<(protocol::RequestMeta, usize)>, String> {
|
||||||
|
if buffer.len() < 4 {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let meta_len = u32::from_be_bytes([buffer[0], buffer[1], buffer[2], buffer[3]]) as usize;
|
||||||
|
if meta_len > MAX_RELAY_META_LEN {
|
||||||
|
return Err("relay metadata too large".to_string());
|
||||||
|
}
|
||||||
|
let meta_end = 4usize
|
||||||
|
.checked_add(meta_len)
|
||||||
|
.ok_or_else(|| "relay envelope length overflow".to_string())?;
|
||||||
|
if buffer.len() < meta_end {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let meta = serde_json::from_slice::<protocol::RequestMeta>(&buffer[4..meta_end])
|
||||||
|
.map_err(|e| format!("invalid relay metadata: {e}"))?;
|
||||||
|
Ok(Some((meta, meta_end)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn append_headers(target: &mut HeaderMap, headers: &[(String, String)]) {
|
||||||
|
for (name, value) in headers {
|
||||||
|
let Ok(name) = HeaderName::from_bytes(name.as_bytes()) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok(value) = HeaderValue::from_str(value) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
target.append(name, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tunnel_error_response(status: StatusCode, kind: &str, message: &str) -> Response<Body> {
|
||||||
|
let mut builder = Response::builder().status(status);
|
||||||
|
if let Some(headers) = builder.headers_mut() {
|
||||||
|
headers.insert(
|
||||||
|
HeaderName::from_static(TUNNEL_ERROR_HEADER),
|
||||||
|
HeaderValue::from_str(kind).unwrap_or_else(|_| HeaderValue::from_static("relay")),
|
||||||
|
);
|
||||||
|
headers.insert(
|
||||||
|
axum::http::header::CONTENT_TYPE,
|
||||||
|
HeaderValue::from_static("text/plain; charset=utf-8"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
builder
|
||||||
|
.body(Body::from(message.to_string()))
|
||||||
|
.unwrap_or_else(|_| Response::new(Body::from("relay error")))
|
||||||
|
}
|
||||||
+44
-36
@@ -1,20 +1,23 @@
|
|||||||
|
mod control_plane;
|
||||||
mod hub;
|
mod hub;
|
||||||
|
mod local_relay;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
mod proxy_conn;
|
mod proxy_conn;
|
||||||
mod worker_conn;
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::net::SocketAddr;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use axum::extract::ws::WebSocketUpgrade;
|
use axum::extract::ws::WebSocketUpgrade;
|
||||||
use axum::extract::State;
|
use axum::extract::State;
|
||||||
use axum::response::{IntoResponse, Json};
|
use axum::response::{IntoResponse, Json};
|
||||||
use axum::routing::get;
|
use axum::routing::{get, post};
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
|
|
||||||
use crate::hub::HubRouter;
|
use crate::control_plane::ControlPlaneClient;
|
||||||
|
use crate::hub::{ConnConfig, HubRouter};
|
||||||
|
use crate::local_relay::relay_request;
|
||||||
|
|
||||||
#[derive(Parser, Debug)]
|
#[derive(Parser, Debug)]
|
||||||
#[command(name = "aether-hub", about = "Tunnel Hub for Aether")]
|
#[command(name = "aether-hub", about = "Tunnel Hub for Aether")]
|
||||||
@@ -23,14 +26,10 @@ struct Args {
|
|||||||
#[arg(long, default_value = "0.0.0.0:8085", env = "TUNNEL_HUB_BIND")]
|
#[arg(long, default_value = "0.0.0.0:8085", env = "TUNNEL_HUB_BIND")]
|
||||||
bind: String,
|
bind: String,
|
||||||
|
|
||||||
/// Proxy-side idle timeout in seconds
|
/// Proxy-side idle timeout in seconds (0 to disable)
|
||||||
#[arg(long, default_value_t = 90, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
|
#[arg(long, default_value_t = 0, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
|
||||||
proxy_idle_timeout: u64,
|
proxy_idle_timeout: u64,
|
||||||
|
|
||||||
/// Worker-side idle timeout in seconds
|
|
||||||
#[arg(long, default_value_t = 60, env = "TUNNEL_HUB_WORKER_IDLE_TIMEOUT")]
|
|
||||||
worker_idle_timeout: u64,
|
|
||||||
|
|
||||||
/// Ping interval in seconds (for both sides)
|
/// Ping interval in seconds (for both sides)
|
||||||
#[arg(long, default_value_t = 15, env = "TUNNEL_HUB_PING_INTERVAL")]
|
#[arg(long, default_value_t = 15, env = "TUNNEL_HUB_PING_INTERVAL")]
|
||||||
ping_interval: u64,
|
ping_interval: u64,
|
||||||
@@ -38,15 +37,29 @@ struct Args {
|
|||||||
/// Max concurrent streams per proxy connection
|
/// Max concurrent streams per proxy connection
|
||||||
#[arg(long, default_value_t = 2048, env = "TUNNEL_HUB_MAX_STREAMS")]
|
#[arg(long, default_value_t = 2048, env = "TUNNEL_HUB_MAX_STREAMS")]
|
||||||
max_streams: usize,
|
max_streams: usize,
|
||||||
|
|
||||||
|
/// Per-connection outbound queue capacity before treating the socket as congested
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value_t = 128,
|
||||||
|
env = "TUNNEL_HUB_OUTBOUND_QUEUE_CAPACITY"
|
||||||
|
)]
|
||||||
|
outbound_queue_capacity: usize,
|
||||||
|
|
||||||
|
/// Local Aether app base URL for control-plane callbacks
|
||||||
|
#[arg(
|
||||||
|
long,
|
||||||
|
default_value = "http://127.0.0.1:8084",
|
||||||
|
env = "TUNNEL_HUB_APP_BASE_URL"
|
||||||
|
)]
|
||||||
|
app_base_url: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
pub struct AppState {
|
||||||
hub: Arc<HubRouter>,
|
pub hub: std::sync::Arc<HubRouter>,
|
||||||
proxy_idle_timeout: Duration,
|
pub proxy_conn_cfg: ConnConfig,
|
||||||
worker_idle_timeout: Duration,
|
pub max_streams: usize,
|
||||||
ping_interval: Duration,
|
|
||||||
max_streams: usize,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
@@ -61,12 +74,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
let hub = HubRouter::new();
|
let hub = HubRouter::new(ControlPlaneClient::new(args.app_base_url));
|
||||||
|
let outbound_queue_capacity = args.outbound_queue_capacity.clamp(8, 4096);
|
||||||
|
let ping_interval = Duration::from_secs(args.ping_interval);
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
hub,
|
hub,
|
||||||
proxy_idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
|
proxy_conn_cfg: ConnConfig {
|
||||||
worker_idle_timeout: Duration::from_secs(args.worker_idle_timeout),
|
ping_interval,
|
||||||
ping_interval: Duration::from_secs(args.ping_interval),
|
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
|
||||||
|
outbound_queue_capacity,
|
||||||
|
},
|
||||||
max_streams: args.max_streams,
|
max_streams: args.max_streams,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -74,13 +91,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
.route("/stats", get(stats))
|
.route("/stats", get(stats))
|
||||||
.route("/proxy", get(ws_proxy))
|
.route("/proxy", get(ws_proxy))
|
||||||
.route("/worker", get(ws_worker))
|
.route("/local/relay/{node_id}", post(relay_request))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
|
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
|
||||||
info!(bind = %args.bind, "aether-hub started");
|
info!(bind = %args.bind, "aether-hub started");
|
||||||
|
|
||||||
axum::serve(listener, app).await?;
|
axum::serve(
|
||||||
|
listener,
|
||||||
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,21 +160,8 @@ async fn ws_proxy(
|
|||||||
node_id,
|
node_id,
|
||||||
node_name,
|
node_name,
|
||||||
max_streams,
|
max_streams,
|
||||||
state.ping_interval,
|
state.proxy_conn_cfg,
|
||||||
state.proxy_idle_timeout,
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.into_response()
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn ws_worker(ws: WebSocketUpgrade, State(state): State<AppState>) -> impl IntoResponse {
|
|
||||||
ws.max_frame_size(64 * 1024 * 1024)
|
|
||||||
.on_upgrade(move |socket| {
|
|
||||||
worker_conn::handle_worker_connection(
|
|
||||||
socket,
|
|
||||||
state.hub,
|
|
||||||
state.ping_interval,
|
|
||||||
state.worker_idle_timeout,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
+79
-135
@@ -22,8 +22,6 @@ pub const PONG: u8 = 0x11;
|
|||||||
pub const GOAWAY: u8 = 0x12;
|
pub const GOAWAY: u8 = 0x12;
|
||||||
pub const HEARTBEAT_DATA: u8 = 0x13;
|
pub const HEARTBEAT_DATA: u8 = 0x13;
|
||||||
pub const HEARTBEAT_ACK: u8 = 0x14;
|
pub const HEARTBEAT_ACK: u8 = 0x14;
|
||||||
pub const NODE_STATUS: u8 = 0x15;
|
|
||||||
|
|
||||||
// Flags
|
// Flags
|
||||||
pub const FLAG_END_STREAM: u8 = 0x01;
|
pub const FLAG_END_STREAM: u8 = 0x01;
|
||||||
pub const FLAG_GZIP_COMPRESSED: u8 = 0x02;
|
pub const FLAG_GZIP_COMPRESSED: u8 = 0x02;
|
||||||
@@ -50,162 +48,89 @@ impl FrameHeader {
|
|||||||
payload_len: u32::from_be_bytes([data[6], data[7], data[8], data[9]]),
|
payload_len: u32::from_be_bytes([data[6], data[7], data[8], data[9]]),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if this is a stream-terminating frame
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
#[inline]
|
pub struct RequestMeta {
|
||||||
pub fn is_stream_terminal(&self) -> bool {
|
pub method: String,
|
||||||
self.msg_type == STREAM_END || self.msg_type == STREAM_ERROR
|
pub url: String,
|
||||||
|
pub headers: std::collections::HashMap<String, String>,
|
||||||
|
#[serde(default = "default_timeout", deserialize_with = "deserialize_timeout")]
|
||||||
|
pub timeout: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_timeout() -> u64 {
|
||||||
|
60
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_timeout<'de, D>(deserializer: D) -> Result<u64, D::Error>
|
||||||
|
where
|
||||||
|
D: serde::Deserializer<'de>,
|
||||||
|
{
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
enum TimeoutValue {
|
||||||
|
Int(u64),
|
||||||
|
Float(f64),
|
||||||
|
}
|
||||||
|
|
||||||
|
match <TimeoutValue as serde::Deserialize>::deserialize(deserializer)? {
|
||||||
|
TimeoutValue::Int(v) => Ok(v),
|
||||||
|
TimeoutValue::Float(v) => {
|
||||||
|
if !v.is_finite() || v < 0.0 {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"timeout must be a non-negative finite number",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if v.fract() != 0.0 {
|
||||||
|
return Err(serde::de::Error::custom("timeout must be integer seconds"));
|
||||||
|
}
|
||||||
|
if v > (u64::MAX as f64) {
|
||||||
|
return Err(serde::de::Error::custom("timeout is too large"));
|
||||||
|
}
|
||||||
|
Ok(v as u64)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct RequestHeadersExtracted {
|
pub struct ResponseMeta {
|
||||||
pub node_id: String,
|
pub status: u16,
|
||||||
pub rebuilt_frame: Vec<u8>,
|
pub headers: Vec<(String, String)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encode_frame(stream_id: u32, msg_type: u8, flags: u8, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
|
||||||
|
buf.extend_from_slice(&stream_id.to_be_bytes());
|
||||||
|
buf.push(msg_type);
|
||||||
|
buf.push(flags);
|
||||||
|
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
||||||
|
buf.extend_from_slice(payload);
|
||||||
|
buf
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a STREAM_ERROR frame for a given stream_id with an error message
|
/// Encode a STREAM_ERROR frame for a given stream_id with an error message
|
||||||
pub fn encode_stream_error(stream_id: u32, msg: &str) -> Vec<u8> {
|
pub fn encode_stream_error(stream_id: u32, msg: &str) -> Vec<u8> {
|
||||||
let payload = msg.as_bytes();
|
encode_frame(stream_id, STREAM_ERROR, 0, msg.as_bytes())
|
||||||
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
|
|
||||||
buf.extend_from_slice(&stream_id.to_be_bytes());
|
|
||||||
buf.push(STREAM_ERROR);
|
|
||||||
buf.push(0); // flags
|
|
||||||
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
|
||||||
buf.extend_from_slice(payload);
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Encode a NODE_STATUS frame (stream_id=0, Hub-generated)
|
|
||||||
pub fn encode_node_status(node_id: &str, connected: bool, conn_count: usize) -> Vec<u8> {
|
|
||||||
let payload = serde_json::json!({
|
|
||||||
"node_id": node_id,
|
|
||||||
"connected": connected,
|
|
||||||
"conn_count": conn_count,
|
|
||||||
});
|
|
||||||
let payload_bytes = payload.to_string().into_bytes();
|
|
||||||
let mut buf = Vec::with_capacity(HEADER_SIZE + payload_bytes.len());
|
|
||||||
buf.extend_from_slice(&0u32.to_be_bytes()); // stream_id = 0
|
|
||||||
buf.push(NODE_STATUS);
|
|
||||||
buf.push(0); // flags
|
|
||||||
buf.extend_from_slice(&(payload_bytes.len() as u32).to_be_bytes());
|
|
||||||
buf.extend_from_slice(&payload_bytes);
|
|
||||||
buf
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a PING frame (stream_id=0)
|
/// Encode a PING frame (stream_id=0)
|
||||||
pub fn encode_ping() -> Vec<u8> {
|
pub fn encode_ping() -> Vec<u8> {
|
||||||
let mut buf = Vec::with_capacity(HEADER_SIZE);
|
encode_frame(0, PING, 0, &[])
|
||||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
|
||||||
buf.push(PING);
|
|
||||||
buf.push(0);
|
|
||||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
|
||||||
buf
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a PONG frame (stream_id=0, echo payload)
|
/// Encode a PONG frame (stream_id=0, echo payload)
|
||||||
pub fn encode_pong(payload: &[u8]) -> Vec<u8> {
|
pub fn encode_pong(payload: &[u8]) -> Vec<u8> {
|
||||||
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
|
encode_frame(0, PONG, 0, payload)
|
||||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
|
||||||
buf.push(PONG);
|
|
||||||
buf.push(0);
|
|
||||||
buf.extend_from_slice(&(payload.len() as u32).to_be_bytes());
|
|
||||||
buf.extend_from_slice(payload);
|
|
||||||
buf
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Encode a GOAWAY frame (stream_id=0)
|
/// Encode a GOAWAY frame (stream_id=0)
|
||||||
pub fn encode_goaway() -> Vec<u8> {
|
pub fn encode_goaway() -> Vec<u8> {
|
||||||
let mut buf = Vec::with_capacity(HEADER_SIZE);
|
encode_frame(0, GOAWAY, 0, &[])
|
||||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
|
||||||
buf.push(GOAWAY);
|
|
||||||
buf.push(0);
|
|
||||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
|
||||||
buf
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Rewrite the stream_id in raw frame bytes (first 4 bytes) -- near zero-copy
|
|
||||||
#[inline]
|
|
||||||
pub fn rewrite_stream_id(data: &mut [u8], new_stream_id: u32) {
|
|
||||||
let bytes = new_stream_id.to_be_bytes();
|
|
||||||
data[0] = bytes[0];
|
|
||||||
data[1] = bytes[1];
|
|
||||||
data[2] = bytes[2];
|
|
||||||
data[3] = bytes[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the payload portion of a raw frame (after the 10-byte header)
|
|
||||||
#[inline]
|
|
||||||
pub fn frame_payload(data: &[u8]) -> &[u8] {
|
|
||||||
if data.len() > HEADER_SIZE {
|
|
||||||
&data[HEADER_SIZE..]
|
|
||||||
} else {
|
|
||||||
&[]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse REQUEST_HEADERS payload, extract `node_id`, strip it from JSON,
|
|
||||||
/// and rebuild a new REQUEST_HEADERS frame with `new_stream_id`.
|
|
||||||
///
|
|
||||||
/// If the source frame is gzip-compressed, this function will decode it first,
|
|
||||||
/// then try to re-encode with gzip (only keeps compression when payload shrinks).
|
|
||||||
pub fn rebuild_request_headers_without_node_id(
|
|
||||||
data: &[u8],
|
|
||||||
new_stream_id: u32,
|
|
||||||
) -> Result<RequestHeadersExtracted, String> {
|
|
||||||
let header = FrameHeader::parse(data).ok_or_else(|| "invalid frame header".to_string())?;
|
|
||||||
if header.msg_type != REQUEST_HEADERS {
|
|
||||||
return Err("frame is not REQUEST_HEADERS".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
let payload = frame_payload_by_header(data, &header)
|
|
||||||
.ok_or_else(|| "incomplete REQUEST_HEADERS payload".to_string())?;
|
|
||||||
|
|
||||||
let decoded_payload = if header.flags & FLAG_GZIP_COMPRESSED != 0 {
|
|
||||||
let mut decoder = GzDecoder::new(payload);
|
|
||||||
let mut decoded = Vec::new();
|
|
||||||
decoder
|
|
||||||
.read_to_end(&mut decoded)
|
|
||||||
.map_err(|e| format!("failed to decompress REQUEST_HEADERS: {e}"))?;
|
|
||||||
decoded
|
|
||||||
} else {
|
|
||||||
payload.to_vec()
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut meta: serde_json::Value = serde_json::from_slice(&decoded_payload)
|
|
||||||
.map_err(|e| format!("invalid REQUEST_HEADERS JSON: {e}"))?;
|
|
||||||
let obj = meta
|
|
||||||
.as_object_mut()
|
|
||||||
.ok_or_else(|| "REQUEST_HEADERS payload must be a JSON object".to_string())?;
|
|
||||||
|
|
||||||
let node_id = obj
|
|
||||||
.remove("node_id")
|
|
||||||
.and_then(|v| v.as_str().map(|s| s.to_string()))
|
|
||||||
.map(|s| s.trim().to_string())
|
|
||||||
.filter(|s| !s.is_empty())
|
|
||||||
.ok_or_else(|| "missing node_id in REQUEST_HEADERS".to_string())?;
|
|
||||||
|
|
||||||
let stripped_payload = serde_json::to_vec(&meta)
|
|
||||||
.map_err(|e| format!("failed to encode REQUEST_HEADERS payload: {e}"))?;
|
|
||||||
let (final_payload, flags) =
|
|
||||||
maybe_recompress_payload(&stripped_payload, header.flags & FLAG_GZIP_COMPRESSED != 0)
|
|
||||||
.map_err(|e| format!("failed to recompress REQUEST_HEADERS payload: {e}"))?;
|
|
||||||
|
|
||||||
let mut rebuilt = Vec::with_capacity(HEADER_SIZE + final_payload.len());
|
|
||||||
rebuilt.extend_from_slice(&new_stream_id.to_be_bytes());
|
|
||||||
rebuilt.push(REQUEST_HEADERS);
|
|
||||||
rebuilt.push(flags);
|
|
||||||
rebuilt.extend_from_slice(&(final_payload.len() as u32).to_be_bytes());
|
|
||||||
rebuilt.extend_from_slice(&final_payload);
|
|
||||||
|
|
||||||
Ok(RequestHeadersExtracted {
|
|
||||||
node_id,
|
|
||||||
rebuilt_frame: rebuilt,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[inline]
|
#[inline]
|
||||||
fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&'a [u8]> {
|
pub fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&'a [u8]> {
|
||||||
let payload_len = header.payload_len as usize;
|
let payload_len = header.payload_len as usize;
|
||||||
let end = HEADER_SIZE.checked_add(payload_len)?;
|
let end = HEADER_SIZE.checked_add(payload_len)?;
|
||||||
if data.len() < end {
|
if data.len() < end {
|
||||||
@@ -214,6 +139,25 @@ fn frame_payload_by_header<'a>(data: &'a [u8], header: &FrameHeader) -> Option<&
|
|||||||
Some(&data[HEADER_SIZE..end])
|
Some(&data[HEADER_SIZE..end])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn decode_payload(data: &[u8], header: &FrameHeader) -> Result<Vec<u8>, String> {
|
||||||
|
let payload = frame_payload_by_header(data, header)
|
||||||
|
.ok_or_else(|| "incomplete frame payload".to_string())?;
|
||||||
|
if header.flags & FLAG_GZIP_COMPRESSED != 0 {
|
||||||
|
let mut decoder = GzDecoder::new(payload);
|
||||||
|
let mut decoded = Vec::new();
|
||||||
|
decoder
|
||||||
|
.read_to_end(&mut decoded)
|
||||||
|
.map_err(|e| format!("failed to decompress payload: {e}"))?;
|
||||||
|
Ok(decoded)
|
||||||
|
} else {
|
||||||
|
Ok(payload.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compress_payload(payload: &[u8]) -> Result<(Vec<u8>, u8), std::io::Error> {
|
||||||
|
maybe_recompress_payload(payload, true)
|
||||||
|
}
|
||||||
|
|
||||||
fn maybe_recompress_payload(
|
fn maybe_recompress_payload(
|
||||||
payload: &[u8],
|
payload: &[u8],
|
||||||
prefer_gzip: bool,
|
prefer_gzip: bool,
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use axum::extract::ws::{Message, WebSocket};
|
use axum::extract::ws::{Message, WebSocket};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::{mpsc, watch};
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
use crate::hub::{HubRouter, ProxyConn};
|
use crate::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
|
||||||
use crate::protocol;
|
use crate::protocol;
|
||||||
|
|
||||||
/// Maximum single frame size: 64 MB
|
/// Maximum single frame size: 64 MB
|
||||||
@@ -22,69 +22,71 @@ pub async fn handle_proxy_connection(
|
|||||||
node_id: String,
|
node_id: String,
|
||||||
node_name: String,
|
node_name: String,
|
||||||
max_streams: usize,
|
max_streams: usize,
|
||||||
ping_interval: Duration,
|
cfg: ConnConfig,
|
||||||
idle_timeout: Duration,
|
|
||||||
) {
|
) {
|
||||||
let conn_id = hub.alloc_conn_id();
|
let conn_id = hub.alloc_conn_id();
|
||||||
let (mut ws_tx, ws_rx) = ws.split();
|
let (mut ws_tx, ws_rx) = ws.split();
|
||||||
|
|
||||||
// Create channel for outbound messages
|
let (tx, mut rx) = mpsc::channel::<Message>(cfg.outbound_queue_capacity);
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
|
let (close_tx, mut close_rx) = watch::channel(false);
|
||||||
|
|
||||||
let conn = Arc::new(ProxyConn::new(
|
let conn = Arc::new(ProxyConn::new(
|
||||||
conn_id,
|
conn_id,
|
||||||
node_id.clone(),
|
node_id.clone(),
|
||||||
node_name.clone(),
|
node_name.clone(),
|
||||||
tx,
|
tx,
|
||||||
|
close_tx,
|
||||||
max_streams,
|
max_streams,
|
||||||
));
|
));
|
||||||
|
|
||||||
hub.register_proxy(conn.clone());
|
hub.register_proxy(conn.clone());
|
||||||
|
|
||||||
// Spawn writer task: drains channel -> WebSocket
|
|
||||||
let writer = tokio::spawn(async move {
|
let writer = tokio::spawn(async move {
|
||||||
while let Some(msg) = rx.recv().await {
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = rx.recv() => match msg {
|
||||||
|
Some(msg) => {
|
||||||
if ws_tx.send(msg).await.is_err() {
|
if ws_tx.send(msg).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
None => break,
|
||||||
|
},
|
||||||
|
changed = close_rx.changed() => {
|
||||||
|
if changed.is_err() || *close_rx.borrow() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
let _ = ws_tx.close().await;
|
let _ = ws_tx.close().await;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spawn ping task
|
let ping_conn = conn.clone();
|
||||||
let ping_tx = conn.tx.clone();
|
let ping_interval = cfg.ping_interval;
|
||||||
let ping_task = tokio::spawn(async move {
|
let ping_task = tokio::spawn(async move {
|
||||||
loop {
|
loop {
|
||||||
tokio::time::sleep(ping_interval).await;
|
tokio::time::sleep(ping_interval).await;
|
||||||
let ping = protocol::encode_ping();
|
let ping = protocol::encode_ping();
|
||||||
if ping_tx.send(Message::Binary(ping.into())).is_err() {
|
if !matches!(
|
||||||
|
ping_conn.send(Message::Binary(ping.into())),
|
||||||
|
SendStatus::Queued
|
||||||
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Spawn reader task
|
|
||||||
let reader_hub = hub.clone();
|
let reader_hub = hub.clone();
|
||||||
let reader_node_id = node_id.clone();
|
let reader_conn = conn.clone();
|
||||||
let reader_tx = conn.tx.clone();
|
|
||||||
let reader = tokio::spawn(async move {
|
let reader = tokio::spawn(async move {
|
||||||
run_proxy_reader(
|
run_proxy_reader(ws_rx, reader_hub, reader_conn, cfg.idle_timeout).await;
|
||||||
ws_rx,
|
|
||||||
reader_hub,
|
|
||||||
conn_id,
|
|
||||||
reader_node_id,
|
|
||||||
reader_tx,
|
|
||||||
idle_timeout,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wait for reader to end, then cleanup.
|
|
||||||
let _ = reader.await;
|
let _ = reader.await;
|
||||||
ping_task.abort();
|
ping_task.abort();
|
||||||
|
conn.request_close();
|
||||||
hub.unregister_proxy(conn_id, &node_id);
|
hub.unregister_proxy(conn_id, &node_id);
|
||||||
// conn still holds an Arc<ProxyConn> with a channel sender clone.
|
|
||||||
// Drop it so the writer can drain and exit.
|
|
||||||
drop(conn);
|
drop(conn);
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||||
writer.abort();
|
writer.abort();
|
||||||
@@ -94,20 +96,24 @@ pub async fn handle_proxy_connection(
|
|||||||
async fn run_proxy_reader(
|
async fn run_proxy_reader(
|
||||||
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
||||||
hub: Arc<HubRouter>,
|
hub: Arc<HubRouter>,
|
||||||
conn_id: u64,
|
conn: Arc<ProxyConn>,
|
||||||
node_id: String,
|
|
||||||
tx: mpsc::UnboundedSender<Message>,
|
|
||||||
idle_timeout: Duration,
|
idle_timeout: Duration,
|
||||||
) {
|
) {
|
||||||
|
let idle_enabled = !idle_timeout.is_zero();
|
||||||
let mut oversized_count = 0u32;
|
let mut oversized_count = 0u32;
|
||||||
loop {
|
loop {
|
||||||
let msg = tokio::select! {
|
let msg = if idle_enabled {
|
||||||
|
tokio::select! {
|
||||||
msg = ws_rx.next() => msg,
|
msg = ws_rx.next() => msg,
|
||||||
_ = tokio::time::sleep(idle_timeout) => {
|
_ = tokio::time::sleep(idle_timeout) => {
|
||||||
warn!(conn_id = conn_id, node_id = %node_id, "proxy idle timeout");
|
warn!(conn_id = conn.id, node_id = %conn.node_id, "proxy idle timeout");
|
||||||
let _ = tx.send(Message::Binary(protocol::encode_goaway().into()));
|
let _ = conn.send(Message::Binary(protocol::encode_goaway().into()));
|
||||||
|
conn.request_close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ws_rx.next().await
|
||||||
};
|
};
|
||||||
|
|
||||||
match msg {
|
match msg {
|
||||||
@@ -116,12 +122,13 @@ async fn run_proxy_reader(
|
|||||||
if data.len() > MAX_FRAME_SIZE {
|
if data.len() > MAX_FRAME_SIZE {
|
||||||
oversized_count += 1;
|
oversized_count += 1;
|
||||||
warn!(
|
warn!(
|
||||||
conn_id = conn_id,
|
conn_id = conn.id,
|
||||||
size = data.len(),
|
size = data.len(),
|
||||||
"oversized frame from proxy"
|
"oversized frame from proxy"
|
||||||
);
|
);
|
||||||
if oversized_count >= 5 {
|
if oversized_count >= 5 {
|
||||||
warn!(conn_id = conn_id, "too many oversized frames, closing");
|
warn!(conn_id = conn.id, "too many oversized frames, closing");
|
||||||
|
conn.request_close();
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
@@ -129,21 +136,21 @@ async fn run_proxy_reader(
|
|||||||
oversized_count = 0;
|
oversized_count = 0;
|
||||||
|
|
||||||
if data.len() < protocol::HEADER_SIZE {
|
if data.len() < protocol::HEADER_SIZE {
|
||||||
debug!(conn_id = conn_id, "frame too small, skipping");
|
debug!(conn_id = conn.id, "frame too small, skipping");
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
hub.handle_proxy_frame(conn_id, &mut data);
|
hub.handle_proxy_frame(conn.id, &mut data).await;
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
Some(Ok(Message::Close(_))) | None => {
|
||||||
info!(conn_id = conn_id, node_id = %node_id, "proxy WebSocket closed");
|
info!(conn_id = conn.id, node_id = %conn.node_id, "proxy WebSocket closed");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
Some(Err(e)) => {
|
Some(Err(e)) => {
|
||||||
warn!(conn_id = conn_id, error = %e, "proxy WebSocket error");
|
warn!(conn_id = conn.id, error = %e, "proxy WebSocket error");
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
_ => {} // Ignore text/ping/pong at WS level
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,136 +0,0 @@
|
|||||||
/// Worker-side WebSocket connection handler
|
|
||||||
///
|
|
||||||
/// Handles the lifecycle of a single Gunicorn worker connection:
|
|
||||||
/// accept -> read loop (route frames via Hub) -> cleanup
|
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use axum::extract::ws::{Message, WebSocket};
|
|
||||||
use futures_util::{SinkExt, StreamExt};
|
|
||||||
use tokio::sync::mpsc;
|
|
||||||
use tracing::{debug, info, warn};
|
|
||||||
|
|
||||||
use crate::hub::{HubRouter, WorkerConn};
|
|
||||||
use crate::protocol;
|
|
||||||
|
|
||||||
pub async fn handle_worker_connection(
|
|
||||||
ws: WebSocket,
|
|
||||||
hub: Arc<HubRouter>,
|
|
||||||
ping_interval: Duration,
|
|
||||||
idle_timeout: Duration,
|
|
||||||
) {
|
|
||||||
let conn_id = hub.alloc_conn_id();
|
|
||||||
let (mut ws_tx, ws_rx) = ws.split();
|
|
||||||
|
|
||||||
// Create channel for outbound messages
|
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
|
|
||||||
|
|
||||||
let conn = Arc::new(WorkerConn::new(conn_id, tx));
|
|
||||||
hub.register_worker(conn.clone());
|
|
||||||
|
|
||||||
// Spawn writer task
|
|
||||||
let writer = tokio::spawn(async move {
|
|
||||||
while let Some(msg) = rx.recv().await {
|
|
||||||
if ws_tx.send(msg).await.is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = ws_tx.close().await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spawn ping task
|
|
||||||
let ping_tx = conn.tx.clone();
|
|
||||||
let ping_task = tokio::spawn(async move {
|
|
||||||
loop {
|
|
||||||
tokio::time::sleep(ping_interval).await;
|
|
||||||
let ping = protocol::encode_ping();
|
|
||||||
if ping_tx.send(Message::Binary(ping.into())).is_err() {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Spawn reader task
|
|
||||||
let reader_hub = hub.clone();
|
|
||||||
let reader_tx = conn.tx.clone();
|
|
||||||
let reader = tokio::spawn(async move {
|
|
||||||
run_worker_reader(
|
|
||||||
ws_rx,
|
|
||||||
reader_hub,
|
|
||||||
conn_id,
|
|
||||||
conn.clone(),
|
|
||||||
reader_tx,
|
|
||||||
idle_timeout,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wait for reader to end, then cleanup.
|
|
||||||
let _ = reader.await;
|
|
||||||
ping_task.abort();
|
|
||||||
// Unregister first so all channel senders are dropped (reader_tx dropped
|
|
||||||
// when reader completes, ping_tx dropped by abort, conn.tx dropped when
|
|
||||||
// the last Arc<WorkerConn> is removed from hub). This lets the writer
|
|
||||||
// drain buffered messages (e.g. GOAWAY) before we force-abort it.
|
|
||||||
hub.unregister_worker(conn_id);
|
|
||||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
||||||
writer.abort();
|
|
||||||
let _ = writer.await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn run_worker_reader(
|
|
||||||
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
|
||||||
hub: Arc<HubRouter>,
|
|
||||||
conn_id: u64,
|
|
||||||
conn: Arc<WorkerConn>,
|
|
||||||
tx: mpsc::UnboundedSender<Message>,
|
|
||||||
idle_timeout: Duration,
|
|
||||||
) {
|
|
||||||
loop {
|
|
||||||
let msg = tokio::select! {
|
|
||||||
msg = ws_rx.next() => msg,
|
|
||||||
_ = tokio::time::sleep(idle_timeout) => {
|
|
||||||
warn!(worker_id = conn_id, "worker idle timeout");
|
|
||||||
let _ = tx.send(Message::Binary(protocol::encode_goaway().into()));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match msg {
|
|
||||||
Some(Ok(Message::Binary(data))) => {
|
|
||||||
let mut data = data.to_vec();
|
|
||||||
if data.len() < protocol::HEADER_SIZE {
|
|
||||||
debug!(worker_id = conn_id, "frame too small, skipping");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let header = match protocol::FrameHeader::parse(&data) {
|
|
||||||
Some(h) => h,
|
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
// HEARTBEAT_ACK from worker -> route back to proxy
|
|
||||||
if header.msg_type == protocol::HEARTBEAT_ACK {
|
|
||||||
hub.handle_worker_heartbeat_ack(&mut data);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Regular frames: route via hub
|
|
||||||
if let Some(err_msg) = hub.handle_worker_frame(conn_id, &mut data) {
|
|
||||||
// Send STREAM_ERROR back to worker
|
|
||||||
let err_frame = protocol::encode_stream_error(header.stream_id, &err_msg);
|
|
||||||
let _ = conn.send(Message::Binary(err_frame.into()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
|
||||||
info!(worker_id = conn_id, "worker WebSocket closed");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
Some(Err(e)) => {
|
|
||||||
warn!(worker_id = conn_id, error = %e, "worker WebSocket error");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
_ => {} // Ignore text/ping/pong at WS level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
+1
-1
@@ -10,7 +10,7 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aether-proxy"
|
name = "aether-proxy"
|
||||||
version = "0.2.3"
|
version = "0.2.4"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arc-swap",
|
"arc-swap",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "aether-proxy"
|
name = "aether-proxy"
|
||||||
version = "0.2.4"
|
version = "0.2.5"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Tunnel proxy for Aether"
|
description = "Tunnel proxy for Aether"
|
||||||
|
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ docker compose up -d
|
|||||||
<!-- DOWNLOAD_TABLE_START -->
|
<!-- DOWNLOAD_TABLE_START -->
|
||||||
| Platform | Download |
|
| Platform | Download |
|
||||||
|----------|----------|
|
|----------|----------|
|
||||||
| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-linux-amd64.tar.gz) |
|
| Linux x86_64 | [aether-proxy-linux-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-amd64.tar.gz) |
|
||||||
| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-linux-arm64.tar.gz) |
|
| Linux ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-linux-arm64.tar.gz) |
|
||||||
| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-macos-amd64.tar.gz) |
|
| macOS x86_64 | [aether-proxy-macos-amd64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-amd64.tar.gz) |
|
||||||
| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-macos-arm64.tar.gz) |
|
| macOS ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-macos-arm64.tar.gz) |
|
||||||
| Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/aether-proxy-windows-amd64.zip) |
|
| Windows x86_64 | [aether-proxy-windows-amd64.zip](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.5/aether-proxy-windows-amd64.zip) |
|
||||||
<!-- DOWNLOAD_TABLE_END -->
|
<!-- DOWNLOAD_TABLE_END -->
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ where
|
|||||||
|
|
||||||
MsgType::StreamEnd | MsgType::StreamError => {
|
MsgType::StreamEnd | MsgType::StreamError => {
|
||||||
// Client-side cancellation or end
|
// Client-side cancellation or end
|
||||||
streams.remove(&frame.stream_id);
|
if let Some(tx) = streams.remove(&frame.stream_id) {
|
||||||
|
let _ = tx.send(frame).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MsgType::Ping => {
|
MsgType::Ping => {
|
||||||
|
|||||||
@@ -3,22 +3,27 @@
|
|||||||
//! Receives request frames, executes the upstream HTTP request,
|
//! Receives request frames, executes the upstream HTTP request,
|
||||||
//! and sends response frames back through the writer channel.
|
//! and sends response frames back through the writer channel.
|
||||||
|
|
||||||
|
use std::io;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
|
use futures_util::stream;
|
||||||
use futures_util::StreamExt;
|
use futures_util::StreamExt;
|
||||||
use http_body_util::BodyExt;
|
use http_body_util::BodyExt;
|
||||||
|
use hyper::body::Frame as BodyFrame;
|
||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
use crate::state::{AppState, ServerContext};
|
use crate::state::{AppState, ServerContext};
|
||||||
use crate::target_filter;
|
use crate::target_filter;
|
||||||
use crate::upstream_client::{self, UpstreamRequestBody};
|
use crate::upstream_client;
|
||||||
|
|
||||||
use super::protocol::{
|
use super::protocol::{
|
||||||
compress_payload, decompress_if_gzip, flags, Frame, MsgType, RequestMeta, ResponseMeta,
|
compress_payload, decompress_if_gzip, flags, Frame as TunnelFrame, MsgType, RequestMeta,
|
||||||
|
ResponseMeta,
|
||||||
};
|
};
|
||||||
use super::writer::FrameSender;
|
use super::writer::FrameSender;
|
||||||
|
|
||||||
@@ -64,13 +69,13 @@ pub async fn handle_stream(
|
|||||||
server: Arc<ServerContext>,
|
server: Arc<ServerContext>,
|
||||||
stream_id: u32,
|
stream_id: u32,
|
||||||
meta: RequestMeta,
|
meta: RequestMeta,
|
||||||
mut body_rx: mpsc::Receiver<Frame>,
|
body_rx: mpsc::Receiver<TunnelFrame>,
|
||||||
frame_tx: FrameSender,
|
frame_tx: FrameSender,
|
||||||
) {
|
) {
|
||||||
server.active_connections.fetch_add(1, Ordering::Release);
|
server.active_connections.fetch_add(1, Ordering::Release);
|
||||||
|
|
||||||
let connect_elapsed =
|
let connect_elapsed =
|
||||||
handle_stream_inner(&state, &server, stream_id, meta, &mut body_rx, &frame_tx).await;
|
handle_stream_inner(&state, &server, stream_id, meta, body_rx, &frame_tx).await;
|
||||||
|
|
||||||
server.active_connections.fetch_sub(1, Ordering::Release);
|
server.active_connections.fetch_sub(1, Ordering::Release);
|
||||||
if let Some(d) = connect_elapsed {
|
if let Some(d) = connect_elapsed {
|
||||||
@@ -79,7 +84,7 @@ pub async fn handle_stream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send a frame to the writer with a timeout. Returns false if send failed.
|
/// Send a frame to the writer with a timeout. Returns false if send failed.
|
||||||
async fn send_frame(tx: &FrameSender, frame: Frame) -> bool {
|
async fn send_frame(tx: &FrameSender, frame: TunnelFrame) -> bool {
|
||||||
match tokio::time::timeout(FRAME_SEND_TIMEOUT, tx.send(frame)).await {
|
match tokio::time::timeout(FRAME_SEND_TIMEOUT, tx.send(frame)).await {
|
||||||
Ok(Ok(())) => true,
|
Ok(Ok(())) => true,
|
||||||
Ok(Err(_)) => {
|
Ok(Err(_)) => {
|
||||||
@@ -102,62 +107,9 @@ async fn handle_stream_inner(
|
|||||||
server: &ServerContext,
|
server: &ServerContext,
|
||||||
stream_id: u32,
|
stream_id: u32,
|
||||||
meta: RequestMeta,
|
meta: RequestMeta,
|
||||||
body_rx: &mut mpsc::Receiver<Frame>,
|
body_rx: mpsc::Receiver<TunnelFrame>,
|
||||||
frame_tx: &FrameSender,
|
frame_tx: &FrameSender,
|
||||||
) -> Option<Duration> {
|
) -> Option<Duration> {
|
||||||
// Collect request body
|
|
||||||
let mut body_parts: Vec<Bytes> = Vec::new();
|
|
||||||
let mut body_done = false;
|
|
||||||
|
|
||||||
// Drain body frames
|
|
||||||
while !body_done {
|
|
||||||
match body_rx.recv().await {
|
|
||||||
Some(frame) => {
|
|
||||||
if frame.msg_type == MsgType::RequestBody {
|
|
||||||
let payload = match decompress_if_gzip(&frame) {
|
|
||||||
Ok(d) => d,
|
|
||||||
Err(e) => {
|
|
||||||
send_error(
|
|
||||||
frame_tx,
|
|
||||||
stream_id,
|
|
||||||
&format!("gzip decompress failed: {e}"),
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
if !payload.is_empty() {
|
|
||||||
body_parts.push(payload);
|
|
||||||
}
|
|
||||||
if frame.is_end_stream() {
|
|
||||||
body_done = true;
|
|
||||||
}
|
|
||||||
} else if frame.msg_type == MsgType::StreamEnd
|
|
||||||
|| frame.msg_type == MsgType::StreamError
|
|
||||||
{
|
|
||||||
body_done = true;
|
|
||||||
if frame.msg_type == MsgType::StreamError {
|
|
||||||
return None; // Client cancelled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => return None, // Channel closed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let body: Bytes = if body_parts.is_empty() {
|
|
||||||
Bytes::new()
|
|
||||||
} else if body_parts.len() == 1 {
|
|
||||||
body_parts.into_iter().next().unwrap()
|
|
||||||
} else {
|
|
||||||
let total: usize = body_parts.iter().map(|b| b.len()).sum();
|
|
||||||
let mut combined = Vec::with_capacity(total);
|
|
||||||
for part in &body_parts {
|
|
||||||
combined.extend_from_slice(part);
|
|
||||||
}
|
|
||||||
Bytes::from(combined)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Validate target
|
// Validate target
|
||||||
let target_url = match url::Url::parse(&meta.url) {
|
let target_url = match url::Url::parse(&meta.url) {
|
||||||
Ok(u) => u,
|
Ok(u) => u,
|
||||||
@@ -207,12 +159,14 @@ async fn handle_stream_inner(
|
|||||||
// Execute upstream request
|
// Execute upstream request
|
||||||
let client = &state.upstream_client;
|
let client = &state.upstream_client;
|
||||||
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
|
let timeout = Duration::from_secs(meta.timeout.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS));
|
||||||
|
let request_body_size = Arc::new(AtomicUsize::new(0));
|
||||||
|
let request_body = build_streaming_request_body(body_rx, Arc::clone(&request_body_size));
|
||||||
|
|
||||||
let method: hyper::Method = meta.method.parse().unwrap_or(hyper::Method::GET);
|
let method: hyper::Method = meta.method.parse().unwrap_or(hyper::Method::GET);
|
||||||
let mut request = match hyper::Request::builder()
|
let mut request = match hyper::Request::builder()
|
||||||
.method(method)
|
.method(method)
|
||||||
.uri(meta.url.as_str())
|
.uri(meta.url.as_str())
|
||||||
.body(UpstreamRequestBody::new(body.clone()))
|
.body(request_body)
|
||||||
{
|
{
|
||||||
Ok(request) => request,
|
Ok(request) => request,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -240,7 +194,6 @@ async fn handle_stream_inner(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let body_size = body.len();
|
|
||||||
let mut captured_connection = upstream_client::capture_connection(&mut request);
|
let mut captured_connection = upstream_client::capture_connection(&mut request);
|
||||||
let connection_start = Instant::now();
|
let connection_start = Instant::now();
|
||||||
let connection_capture = tokio::spawn(async move {
|
let connection_capture = tokio::spawn(async move {
|
||||||
@@ -313,7 +266,7 @@ async fn handle_stream_inner(
|
|||||||
"upstream_processing_ms": request_timing.response_wait_ms,
|
"upstream_processing_ms": request_timing.response_wait_ms,
|
||||||
"timing_source": "instrumented_connector",
|
"timing_source": "instrumented_connector",
|
||||||
"total_ms": connect_elapsed.as_millis() as u64,
|
"total_ms": connect_elapsed.as_millis() as u64,
|
||||||
"body_size": body_size,
|
"body_size": request_body_size.load(Ordering::Relaxed),
|
||||||
"mode": "tunnel",
|
"mode": "tunnel",
|
||||||
});
|
});
|
||||||
resp_headers.push(("x-proxy-timing".to_string(), timing.to_string()));
|
resp_headers.push(("x-proxy-timing".to_string(), timing.to_string()));
|
||||||
@@ -325,7 +278,7 @@ async fn handle_stream_inner(
|
|||||||
let (meta_payload, meta_flags) = compress_payload(meta_json);
|
let (meta_payload, meta_flags) = compress_payload(meta_json);
|
||||||
if !send_frame(
|
if !send_frame(
|
||||||
frame_tx,
|
frame_tx,
|
||||||
Frame::new(
|
TunnelFrame::new(
|
||||||
stream_id,
|
stream_id,
|
||||||
MsgType::ResponseHeaders,
|
MsgType::ResponseHeaders,
|
||||||
meta_flags,
|
meta_flags,
|
||||||
@@ -350,7 +303,7 @@ async fn handle_stream_inner(
|
|||||||
let (payload, extra_flags) = compress_payload(chunk);
|
let (payload, extra_flags) = compress_payload(chunk);
|
||||||
if !send_frame(
|
if !send_frame(
|
||||||
frame_tx,
|
frame_tx,
|
||||||
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
TunnelFrame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -365,7 +318,12 @@ async fn handle_stream_inner(
|
|||||||
let (payload, extra_flags) = compress_payload(slice);
|
let (payload, extra_flags) = compress_payload(slice);
|
||||||
if !send_frame(
|
if !send_frame(
|
||||||
frame_tx,
|
frame_tx,
|
||||||
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
TunnelFrame::new(
|
||||||
|
stream_id,
|
||||||
|
MsgType::ResponseBody,
|
||||||
|
extra_flags,
|
||||||
|
payload,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
@@ -387,7 +345,7 @@ async fn handle_stream_inner(
|
|||||||
// Send STREAM_END
|
// Send STREAM_END
|
||||||
let _ = send_frame(
|
let _ = send_frame(
|
||||||
frame_tx,
|
frame_tx,
|
||||||
Frame::new(
|
TunnelFrame::new(
|
||||||
stream_id,
|
stream_id,
|
||||||
MsgType::StreamEnd,
|
MsgType::StreamEnd,
|
||||||
flags::END_STREAM,
|
flags::END_STREAM,
|
||||||
@@ -404,7 +362,7 @@ async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
|
|||||||
// Error frames use best-effort delivery — don't block if writer is congested
|
// Error frames use best-effort delivery — don't block if writer is congested
|
||||||
let _ = send_frame(
|
let _ = send_frame(
|
||||||
tx,
|
tx,
|
||||||
Frame::new(
|
TunnelFrame::new(
|
||||||
stream_id,
|
stream_id,
|
||||||
MsgType::StreamError,
|
MsgType::StreamError,
|
||||||
0,
|
0,
|
||||||
@@ -413,3 +371,136 @@ async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
|
|||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_streaming_request_body(
|
||||||
|
body_rx: mpsc::Receiver<TunnelFrame>,
|
||||||
|
body_size: Arc<AtomicUsize>,
|
||||||
|
) -> upstream_client::UpstreamRequestBody {
|
||||||
|
let body_stream = stream::unfold(
|
||||||
|
(body_rx, body_size, false),
|
||||||
|
|(mut body_rx, body_size, finished)| async move {
|
||||||
|
if finished {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let frame = match body_rx.recv().await {
|
||||||
|
Some(frame) => frame,
|
||||||
|
None => return None,
|
||||||
|
};
|
||||||
|
|
||||||
|
match frame.msg_type {
|
||||||
|
MsgType::RequestBody => {
|
||||||
|
let end_stream = frame.is_end_stream();
|
||||||
|
let payload = match decompress_if_gzip(&frame) {
|
||||||
|
Ok(payload) => payload,
|
||||||
|
Err(error) => {
|
||||||
|
let err =
|
||||||
|
io::Error::other(format!("gzip decompress failed: {error}"));
|
||||||
|
return Some((Err(err), (body_rx, body_size, true)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if payload.is_empty() {
|
||||||
|
if end_stream {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
body_size.fetch_add(payload.len(), Ordering::Relaxed);
|
||||||
|
return Some((
|
||||||
|
Ok(BodyFrame::data(payload)),
|
||||||
|
(body_rx, body_size, end_stream),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
MsgType::StreamError => {
|
||||||
|
let message = String::from_utf8(frame.payload.to_vec())
|
||||||
|
.unwrap_or_else(|_| "client cancelled request body".to_string());
|
||||||
|
return Some((Err(io::Error::other(message)), (body_rx, body_size, true)));
|
||||||
|
}
|
||||||
|
MsgType::StreamEnd => return None,
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
upstream_client::stream_request_body(body_stream)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn streaming_request_body_yields_chunks_and_tracks_size() {
|
||||||
|
let (tx, rx) = mpsc::channel(4);
|
||||||
|
let body_size = Arc::new(AtomicUsize::new(0));
|
||||||
|
let mut body = build_streaming_request_body(rx, Arc::clone(&body_size));
|
||||||
|
|
||||||
|
tx.send(TunnelFrame::new(
|
||||||
|
1,
|
||||||
|
MsgType::RequestBody,
|
||||||
|
0,
|
||||||
|
Bytes::from_static(b"abc"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("send first chunk");
|
||||||
|
tx.send(TunnelFrame::new(
|
||||||
|
1,
|
||||||
|
MsgType::RequestBody,
|
||||||
|
flags::END_STREAM,
|
||||||
|
Bytes::from_static(b"def"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("send final chunk");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let first = body
|
||||||
|
.frame()
|
||||||
|
.await
|
||||||
|
.expect("first frame")
|
||||||
|
.expect("first frame ok")
|
||||||
|
.into_data()
|
||||||
|
.expect("first data frame");
|
||||||
|
let second = body
|
||||||
|
.frame()
|
||||||
|
.await
|
||||||
|
.expect("second frame")
|
||||||
|
.expect("second frame ok")
|
||||||
|
.into_data()
|
||||||
|
.expect("second data frame");
|
||||||
|
|
||||||
|
assert_eq!(first, Bytes::from_static(b"abc"));
|
||||||
|
assert_eq!(second, Bytes::from_static(b"def"));
|
||||||
|
assert!(body.frame().await.is_none());
|
||||||
|
assert_eq!(body_size.load(Ordering::Relaxed), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn streaming_request_body_surfaces_client_cancel_as_error() {
|
||||||
|
let (tx, rx) = mpsc::channel(4);
|
||||||
|
let body_size = Arc::new(AtomicUsize::new(0));
|
||||||
|
let mut body = build_streaming_request_body(rx, Arc::clone(&body_size));
|
||||||
|
|
||||||
|
tx.send(TunnelFrame::new(
|
||||||
|
1,
|
||||||
|
MsgType::StreamError,
|
||||||
|
0,
|
||||||
|
Bytes::from_static(b"client cancelled"),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.expect("send cancel frame");
|
||||||
|
drop(tx);
|
||||||
|
|
||||||
|
let err = body
|
||||||
|
.frame()
|
||||||
|
.await
|
||||||
|
.expect("error frame present")
|
||||||
|
.expect_err("body should surface cancellation error");
|
||||||
|
assert!(err.to_string().contains("client cancelled"));
|
||||||
|
assert!(body.frame().await.is_none());
|
||||||
|
assert_eq!(body_size.load(Ordering::Relaxed), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ use std::task::{Context, Poll};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
use http_body_util::Full;
|
use futures_util::Stream;
|
||||||
|
use http_body_util::combinators::UnsyncBoxBody;
|
||||||
|
use http_body_util::{BodyExt, StreamBody};
|
||||||
|
use hyper::body::Frame;
|
||||||
use hyper::rt;
|
use hyper::rt;
|
||||||
use hyper::Response;
|
use hyper::Response;
|
||||||
use hyper::Uri;
|
use hyper::Uri;
|
||||||
@@ -30,9 +33,16 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
|||||||
type PlainStream = TokioIo<TcpStream>;
|
type PlainStream = TokioIo<TcpStream>;
|
||||||
type TlsStream = TokioIo<tokio_rustls::client::TlsStream<TcpStream>>;
|
type TlsStream = TokioIo<tokio_rustls::client::TlsStream<TcpStream>>;
|
||||||
|
|
||||||
pub type UpstreamRequestBody = Full<Bytes>;
|
pub type UpstreamRequestBody = UnsyncBoxBody<Bytes, io::Error>;
|
||||||
pub type UpstreamClient = Client<InstrumentedConnector, UpstreamRequestBody>;
|
pub type UpstreamClient = Client<InstrumentedConnector, UpstreamRequestBody>;
|
||||||
|
|
||||||
|
pub fn stream_request_body<S>(stream: S) -> UpstreamRequestBody
|
||||||
|
where
|
||||||
|
S: Stream<Item = Result<Frame<Bytes>, io::Error>> + Send + 'static,
|
||||||
|
{
|
||||||
|
StreamBody::new(stream).boxed_unsync()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, Default)]
|
#[derive(Clone, Copy, Debug, Default)]
|
||||||
pub struct ConnectTiming {
|
pub struct ConnectTiming {
|
||||||
pub connect_ms: u64,
|
pub connect_ms: u64,
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ from alembic import context
|
|||||||
|
|
||||||
# 添加项目根目录到 Python 路径
|
# 添加项目根目录到 Python 路径
|
||||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
# 添加 alembic 目录,让迁移文件可以 `from helpers import ...`
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
|
|
||||||
# 加载 .env 文件(本地开发时需要)
|
# 加载 .env 文件(本地开发时需要)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
"""Shared idempotent helpers for Alembic migrations.
|
|
||||||
|
|
||||||
All metadata lookups are batched: one query loads an entire table's column info
|
|
||||||
or all FK delete rules, then results are cached for the migration's lifetime.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Batch metadata cache
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
class _SchemaCache:
|
|
||||||
"""Lazy, per-migration cache for information_schema lookups.
|
|
||||||
|
|
||||||
Call ``load_columns(tables)`` / ``load_fk_rules(tables)`` once at the top
|
|
||||||
of ``upgrade()`` or ``downgrade()`` to prime the cache. Subsequent
|
|
||||||
``column_exists`` / ``column_type`` / ``fk_ondelete`` calls are pure
|
|
||||||
dict lookups -- zero extra DB round-trips.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
# {table_name: {column_name: data_type}}
|
|
||||||
self._columns: dict[str, dict[str, str]] = {}
|
|
||||||
# {(table_name, constraint_name): delete_rule}
|
|
||||||
self._fk_rules: dict[tuple[str, str], str] = {}
|
|
||||||
self._fk_loaded_tables: set[str] = set()
|
|
||||||
|
|
||||||
# -- loaders (one query per call) --------------------------------------
|
|
||||||
|
|
||||||
def load_columns(self, tables: list[str]) -> None:
|
|
||||||
"""Fetch column names + data types for *tables* in one query."""
|
|
||||||
need = [t for t in tables if t not in self._columns]
|
|
||||||
if not need:
|
|
||||||
return
|
|
||||||
bind = op.get_bind()
|
|
||||||
rows = bind.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT table_name, column_name, data_type "
|
|
||||||
"FROM information_schema.columns "
|
|
||||||
"WHERE table_name = ANY(:tables)"
|
|
||||||
),
|
|
||||||
{"tables": need},
|
|
||||||
).fetchall()
|
|
||||||
# Initialise even empty tables so we don't re-query
|
|
||||||
for t in need:
|
|
||||||
self._columns.setdefault(t, {})
|
|
||||||
for table, col, dtype in rows:
|
|
||||||
self._columns[table][col] = dtype
|
|
||||||
|
|
||||||
def load_fk_rules(self, tables: list[str]) -> None:
|
|
||||||
"""Fetch FK delete rules for *tables* in one query."""
|
|
||||||
need = [t for t in tables if t not in self._fk_loaded_tables]
|
|
||||||
if not need:
|
|
||||||
return
|
|
||||||
bind = op.get_bind()
|
|
||||||
rows = bind.execute(
|
|
||||||
sa.text(
|
|
||||||
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
|
||||||
"FROM information_schema.referential_constraints rc "
|
|
||||||
"JOIN information_schema.table_constraints tc "
|
|
||||||
" ON rc.constraint_name = tc.constraint_name "
|
|
||||||
"WHERE tc.table_name = ANY(:tables)"
|
|
||||||
),
|
|
||||||
{"tables": need},
|
|
||||||
).fetchall()
|
|
||||||
for table, name, rule in rows:
|
|
||||||
self._fk_rules[(table, name)] = rule
|
|
||||||
self._fk_loaded_tables.update(need)
|
|
||||||
|
|
||||||
# -- lookups (pure dict, zero DB) --------------------------------------
|
|
||||||
|
|
||||||
def column_exists(self, table: str, column: str) -> bool:
|
|
||||||
return column in self._columns.get(table, {})
|
|
||||||
|
|
||||||
def column_type(self, table: str, column: str) -> str | None:
|
|
||||||
return self._columns.get(table, {}).get(column)
|
|
||||||
|
|
||||||
def is_numeric(self, table: str, column: str) -> bool:
|
|
||||||
return self.column_type(table, column) == "numeric"
|
|
||||||
|
|
||||||
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
|
||||||
return self._fk_rules.get((table, constraint))
|
|
||||||
|
|
||||||
def invalidate_columns(self, table: str) -> None:
|
|
||||||
"""Force re-load on next load_columns() for *table* (after ADD/DROP COLUMN)."""
|
|
||||||
self._columns.pop(table, None)
|
|
||||||
|
|
||||||
|
|
||||||
def new_cache() -> _SchemaCache:
|
|
||||||
"""Create a fresh schema cache for a single migration run."""
|
|
||||||
return _SchemaCache()
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Idempotent DDL helpers
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def replace_fk_if_needed(
|
|
||||||
cache: _SchemaCache,
|
|
||||||
constraint_name: str,
|
|
||||||
table_name: str,
|
|
||||||
ref_table: str,
|
|
||||||
local_cols: list[str],
|
|
||||||
remote_cols: list[str],
|
|
||||||
desired_ondelete: str,
|
|
||||||
) -> None:
|
|
||||||
"""Drop and recreate a FK only if the current ON DELETE rule differs."""
|
|
||||||
current = cache.fk_ondelete(table_name, constraint_name)
|
|
||||||
if current and current.upper() == desired_ondelete.upper():
|
|
||||||
return
|
|
||||||
if current:
|
|
||||||
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
|
||||||
op.create_foreign_key(
|
|
||||||
constraint_name,
|
|
||||||
table_name,
|
|
||||||
ref_table,
|
|
||||||
local_cols,
|
|
||||||
remote_cols,
|
|
||||||
ondelete=desired_ondelete,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def index_exists(index_name: str) -> bool:
|
|
||||||
bind = op.get_bind()
|
|
||||||
result = bind.execute(
|
|
||||||
sa.text("SELECT 1 FROM pg_indexes WHERE indexname = :name"),
|
|
||||||
{"name": index_name},
|
|
||||||
)
|
|
||||||
return result.scalar() is not None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Batch ALTER TYPE helper
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
|
|
||||||
def batch_alter_type(
|
|
||||||
cache: _SchemaCache,
|
|
||||||
columns: list[tuple[str, str, bool, str | None]],
|
|
||||||
cast_suffix: str,
|
|
||||||
type_fn: Callable[[str], str],
|
|
||||||
) -> None:
|
|
||||||
"""Group columns by table and issue ONE ``ALTER TABLE`` per table.
|
|
||||||
|
|
||||||
Skips columns that don't exist. Each tuple is
|
|
||||||
``(table_name, column_name, nullable, server_default)``.
|
|
||||||
"""
|
|
||||||
by_table: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
|
||||||
for table, col, _nullable, _default in columns:
|
|
||||||
if not cache.column_exists(table, col):
|
|
||||||
continue
|
|
||||||
by_table[table].append((col, type_fn(col)))
|
|
||||||
|
|
||||||
bind = op.get_bind()
|
|
||||||
for table, col_types in by_table.items():
|
|
||||||
parts = [
|
|
||||||
f"ALTER COLUMN {col} TYPE {target} USING {col}::{cast_suffix}"
|
|
||||||
for col, target in col_types
|
|
||||||
]
|
|
||||||
if parts:
|
|
||||||
bind.execute(sa.text(f"ALTER TABLE {table} " + ", ".join(parts)))
|
|
||||||
+28
-5
@@ -53,7 +53,23 @@ def _index_exists(table_name: str, index_name: str) -> bool:
|
|||||||
def _to_decimal(value: object | None) -> Decimal:
|
def _to_decimal(value: object | None) -> Decimal:
|
||||||
if value is None:
|
if value is None:
|
||||||
return Decimal("0")
|
return Decimal("0")
|
||||||
return Decimal(str(value)).quantize(_MONEY_QUANT, rounding=ROUND_HALF_UP)
|
d = Decimal(str(value))
|
||||||
|
if d.is_nan() or d.is_infinite():
|
||||||
|
return Decimal("0")
|
||||||
|
return d.quantize(_MONEY_QUANT, rounding=ROUND_HALF_UP)
|
||||||
|
|
||||||
|
|
||||||
|
# NUMERIC(20, 8) -> 整数部分最多 12 位 -> 绝对值上限
|
||||||
|
_NUMERIC_20_8_CAP = Decimal("999999999999.99999999")
|
||||||
|
|
||||||
|
|
||||||
|
def _clamp(value: Decimal) -> Decimal:
|
||||||
|
"""Clamp a Decimal to fit NUMERIC(20, 8) range."""
|
||||||
|
if value > _NUMERIC_20_8_CAP:
|
||||||
|
return _NUMERIC_20_8_CAP
|
||||||
|
if value < -_NUMERIC_20_8_CAP:
|
||||||
|
return -_NUMERIC_20_8_CAP
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
def _insert_wallet(
|
def _insert_wallet(
|
||||||
@@ -191,6 +207,11 @@ def _backfill_wallets(conn: sa.Connection) -> None:
|
|||||||
total_adjusted = Decimal("0") if is_unlimited else max(quota, Decimal("0"))
|
total_adjusted = Decimal("0") if is_unlimited else max(quota, Decimal("0"))
|
||||||
total_consumed = max(total if total > 0 else used, Decimal("0"))
|
total_consumed = max(total if total > 0 else used, Decimal("0"))
|
||||||
|
|
||||||
|
recharge_balance = _clamp(recharge_balance)
|
||||||
|
gift_balance = _clamp(gift_balance)
|
||||||
|
total_adjusted = _clamp(total_adjusted)
|
||||||
|
total_consumed = _clamp(total_consumed)
|
||||||
|
|
||||||
total_balance = recharge_balance + gift_balance
|
total_balance = recharge_balance + gift_balance
|
||||||
wallet_id = _insert_wallet(
|
wallet_id = _insert_wallet(
|
||||||
conn,
|
conn,
|
||||||
@@ -208,7 +229,7 @@ def _backfill_wallets(conn: sa.Connection) -> None:
|
|||||||
_insert_wallet_migration_tx(
|
_insert_wallet_migration_tx(
|
||||||
conn,
|
conn,
|
||||||
wallet_id=wallet_id,
|
wallet_id=wallet_id,
|
||||||
balance=total_balance,
|
balance=_clamp(total_balance),
|
||||||
recharge_balance=recharge_balance,
|
recharge_balance=recharge_balance,
|
||||||
gift_balance=gift_balance,
|
gift_balance=gift_balance,
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
@@ -235,7 +256,9 @@ def _backfill_wallets(conn: sa.Connection) -> None:
|
|||||||
|
|
||||||
current_balance = _to_decimal(row["current_balance_usd"])
|
current_balance = _to_decimal(row["current_balance_usd"])
|
||||||
used_balance = _to_decimal(row["balance_used_usd"])
|
used_balance = _to_decimal(row["balance_used_usd"])
|
||||||
recharge_balance = current_balance - used_balance
|
recharge_balance = _clamp(current_balance - used_balance)
|
||||||
|
total_recharged = _clamp(max(current_balance, Decimal("0")))
|
||||||
|
total_consumed = _clamp(max(used_balance, Decimal("0")))
|
||||||
|
|
||||||
wallet_id = _insert_wallet(
|
wallet_id = _insert_wallet(
|
||||||
conn,
|
conn,
|
||||||
@@ -244,8 +267,8 @@ def _backfill_wallets(conn: sa.Connection) -> None:
|
|||||||
balance=recharge_balance,
|
balance=recharge_balance,
|
||||||
gift_balance=Decimal("0"),
|
gift_balance=Decimal("0"),
|
||||||
limit_mode="finite",
|
limit_mode="finite",
|
||||||
total_recharged=max(current_balance, Decimal("0")),
|
total_recharged=total_recharged,
|
||||||
total_consumed=max(used_balance, Decimal("0")),
|
total_consumed=total_consumed,
|
||||||
total_adjusted=Decimal("0"),
|
total_adjusted=Decimal("0"),
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
updated_at=row["updated_at"],
|
updated_at=row["updated_at"],
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ Create Date: 2026-03-08 03:48:49.622091+00:00
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from helpers import new_cache, replace_fk_if_needed
|
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
@@ -20,8 +21,108 @@ depends_on = None
|
|||||||
_TABLES = ["usage", "stats_user_daily", "stats_daily_api_key"]
|
_TABLES = ["usage", "stats_user_daily", "stats_daily_api_key"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._columns: dict[str, dict[str, str]] = {}
|
||||||
|
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||||
|
self._fk_loaded_tables: set[str] = set()
|
||||||
|
|
||||||
|
def load_columns(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._columns]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT table_name, column_name, data_type "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_name = ANY(:tables) "
|
||||||
|
" AND table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for t in need:
|
||||||
|
self._columns.setdefault(t, {})
|
||||||
|
for table, col, dtype in rows:
|
||||||
|
self._columns[table][col] = dtype
|
||||||
|
|
||||||
|
def load_fk_rules(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||||
|
"FROM information_schema.referential_constraints rc "
|
||||||
|
"JOIN information_schema.table_constraints tc "
|
||||||
|
" ON rc.constraint_name = tc.constraint_name "
|
||||||
|
" AND rc.constraint_schema = tc.constraint_schema "
|
||||||
|
"WHERE tc.table_name = ANY(:tables) "
|
||||||
|
" AND tc.table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for table, name, rule in rows:
|
||||||
|
self._fk_rules[(table, name)] = rule
|
||||||
|
self._fk_loaded_tables.update(need)
|
||||||
|
|
||||||
|
def column_exists(self, table: str, column: str) -> bool:
|
||||||
|
return column in self._columns.get(table, {})
|
||||||
|
|
||||||
|
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||||
|
return self._fk_rules.get((table, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fk_if_needed(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
constraint_name: str,
|
||||||
|
table_name: str,
|
||||||
|
ref_table: str,
|
||||||
|
local_cols: list[str],
|
||||||
|
remote_cols: list[str],
|
||||||
|
desired_ondelete: str,
|
||||||
|
) -> None:
|
||||||
|
current = cache.fk_ondelete(table_name, constraint_name)
|
||||||
|
if current and current.upper() == desired_ondelete.upper():
|
||||||
|
return
|
||||||
|
if current or _fk_exists(constraint_name, table_name):
|
||||||
|
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
constraint_name,
|
||||||
|
table_name,
|
||||||
|
ref_table,
|
||||||
|
local_cols,
|
||||||
|
remote_cols,
|
||||||
|
ondelete=desired_ondelete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_columns(_TABLES)
|
c.load_columns(_TABLES)
|
||||||
c.load_fk_rules(["stats_user_daily", "stats_daily_api_key"])
|
c.load_fk_rules(["stats_user_daily", "stats_daily_api_key"])
|
||||||
|
|
||||||
@@ -37,7 +138,7 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
|
# --- StatsUserDaily: CASCADE -> SET NULL, add username snapshot ---
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"stats_user_daily_user_id_fkey",
|
"stats_user_daily_user_id_fkey",
|
||||||
"stats_user_daily",
|
"stats_user_daily",
|
||||||
@@ -59,7 +160,7 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
|
# --- StatsDailyApiKey: CASCADE -> SET NULL, add api_key_name snapshot ---
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"stats_daily_api_key_api_key_id_fkey",
|
"stats_daily_api_key_api_key_id_fkey",
|
||||||
"stats_daily_api_key",
|
"stats_daily_api_key",
|
||||||
@@ -82,7 +183,7 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_columns(["stats_daily_api_key", "stats_user_daily", "usage"])
|
c.load_columns(["stats_daily_api_key", "stats_user_daily", "usage"])
|
||||||
c.load_fk_rules(["stats_daily_api_key", "stats_user_daily"])
|
c.load_fk_rules(["stats_daily_api_key", "stats_user_daily"])
|
||||||
|
|
||||||
@@ -97,7 +198,7 @@ def downgrade() -> None:
|
|||||||
op.drop_column("usage", "username")
|
op.drop_column("usage", "username")
|
||||||
|
|
||||||
# --- StatsDailyApiKey: SET NULL -> CASCADE ---
|
# --- StatsDailyApiKey: SET NULL -> CASCADE ---
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"stats_daily_api_key_api_key_id_fkey",
|
"stats_daily_api_key_api_key_id_fkey",
|
||||||
"stats_daily_api_key",
|
"stats_daily_api_key",
|
||||||
@@ -111,7 +212,7 @@ def downgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- StatsUserDaily: SET NULL -> CASCADE ---
|
# --- StatsUserDaily: SET NULL -> CASCADE ---
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"stats_user_daily_user_id_fkey",
|
"stats_user_daily_user_id_fkey",
|
||||||
"stats_user_daily",
|
"stats_user_daily",
|
||||||
|
|||||||
+112
-11
@@ -6,8 +6,9 @@ Create Date: 2026-03-08 12:15:00.000000+00:00
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
from helpers import new_cache, replace_fk_if_needed
|
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
|
|
||||||
@@ -20,8 +21,108 @@ depends_on = None
|
|||||||
_TABLES = ["request_candidates", "video_tasks"]
|
_TABLES = ["request_candidates", "video_tasks"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._columns: dict[str, dict[str, str]] = {}
|
||||||
|
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||||
|
self._fk_loaded_tables: set[str] = set()
|
||||||
|
|
||||||
|
def load_columns(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._columns]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT table_name, column_name, data_type "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_name = ANY(:tables) "
|
||||||
|
" AND table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for t in need:
|
||||||
|
self._columns.setdefault(t, {})
|
||||||
|
for table, col, dtype in rows:
|
||||||
|
self._columns[table][col] = dtype
|
||||||
|
|
||||||
|
def load_fk_rules(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||||
|
"FROM information_schema.referential_constraints rc "
|
||||||
|
"JOIN information_schema.table_constraints tc "
|
||||||
|
" ON rc.constraint_name = tc.constraint_name "
|
||||||
|
" AND rc.constraint_schema = tc.constraint_schema "
|
||||||
|
"WHERE tc.table_name = ANY(:tables) "
|
||||||
|
" AND tc.table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for table, name, rule in rows:
|
||||||
|
self._fk_rules[(table, name)] = rule
|
||||||
|
self._fk_loaded_tables.update(need)
|
||||||
|
|
||||||
|
def column_exists(self, table: str, column: str) -> bool:
|
||||||
|
return column in self._columns.get(table, {})
|
||||||
|
|
||||||
|
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||||
|
return self._fk_rules.get((table, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fk_if_needed(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
constraint_name: str,
|
||||||
|
table_name: str,
|
||||||
|
ref_table: str,
|
||||||
|
local_cols: list[str],
|
||||||
|
remote_cols: list[str],
|
||||||
|
desired_ondelete: str,
|
||||||
|
) -> None:
|
||||||
|
current = cache.fk_ondelete(table_name, constraint_name)
|
||||||
|
if current and current.upper() == desired_ondelete.upper():
|
||||||
|
return
|
||||||
|
if current or _fk_exists(constraint_name, table_name):
|
||||||
|
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
constraint_name,
|
||||||
|
table_name,
|
||||||
|
ref_table,
|
||||||
|
local_cols,
|
||||||
|
remote_cols,
|
||||||
|
ondelete=desired_ondelete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_columns(_TABLES)
|
c.load_columns(_TABLES)
|
||||||
c.load_fk_rules(_TABLES)
|
c.load_fk_rules(_TABLES)
|
||||||
|
|
||||||
@@ -43,7 +144,7 @@ def upgrade() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
# --- request_candidates: CASCADE -> SET NULL ---
|
# --- request_candidates: CASCADE -> SET NULL ---
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"request_candidates_user_id_fkey",
|
"request_candidates_user_id_fkey",
|
||||||
"request_candidates",
|
"request_candidates",
|
||||||
@@ -52,7 +153,7 @@ def upgrade() -> None:
|
|||||||
["id"],
|
["id"],
|
||||||
"SET NULL",
|
"SET NULL",
|
||||||
)
|
)
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"request_candidates_api_key_id_fkey",
|
"request_candidates_api_key_id_fkey",
|
||||||
"request_candidates",
|
"request_candidates",
|
||||||
@@ -81,7 +182,7 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
# --- video_tasks: CASCADE -> SET NULL, user_id nullable ---
|
# --- video_tasks: CASCADE -> SET NULL, user_id nullable ---
|
||||||
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True)
|
op.alter_column("video_tasks", "user_id", existing_type=sa.String(length=36), nullable=True)
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"video_tasks_user_id_fkey",
|
"video_tasks_user_id_fkey",
|
||||||
"video_tasks",
|
"video_tasks",
|
||||||
@@ -90,7 +191,7 @@ def upgrade() -> None:
|
|||||||
["id"],
|
["id"],
|
||||||
"SET NULL",
|
"SET NULL",
|
||||||
)
|
)
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"video_tasks_api_key_id_fkey",
|
"video_tasks_api_key_id_fkey",
|
||||||
"video_tasks",
|
"video_tasks",
|
||||||
@@ -102,12 +203,12 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_columns(_TABLES)
|
c.load_columns(_TABLES)
|
||||||
c.load_fk_rules(_TABLES)
|
c.load_fk_rules(_TABLES)
|
||||||
|
|
||||||
# --- video_tasks: SET NULL -> default (no action), restore NOT NULL ---
|
# --- video_tasks: SET NULL -> default (no action), restore NOT NULL ---
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"video_tasks_api_key_id_fkey",
|
"video_tasks_api_key_id_fkey",
|
||||||
"video_tasks",
|
"video_tasks",
|
||||||
@@ -116,7 +217,7 @@ def downgrade() -> None:
|
|||||||
["id"],
|
["id"],
|
||||||
"NO ACTION",
|
"NO ACTION",
|
||||||
)
|
)
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"video_tasks_user_id_fkey",
|
"video_tasks_user_id_fkey",
|
||||||
"video_tasks",
|
"video_tasks",
|
||||||
@@ -132,7 +233,7 @@ def downgrade() -> None:
|
|||||||
op.drop_column("video_tasks", "username")
|
op.drop_column("video_tasks", "username")
|
||||||
|
|
||||||
# --- request_candidates: SET NULL -> CASCADE ---
|
# --- request_candidates: SET NULL -> CASCADE ---
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"request_candidates_api_key_id_fkey",
|
"request_candidates_api_key_id_fkey",
|
||||||
"request_candidates",
|
"request_candidates",
|
||||||
@@ -141,7 +242,7 @@ def downgrade() -> None:
|
|||||||
["id"],
|
["id"],
|
||||||
"CASCADE",
|
"CASCADE",
|
||||||
)
|
)
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
"request_candidates_user_id_fkey",
|
"request_candidates_user_id_fkey",
|
||||||
"request_candidates",
|
"request_candidates",
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ Create Date: 2026-03-08 15:30:00.000000+00:00
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
from alembic import op
|
from alembic import op
|
||||||
from helpers import batch_alter_type, index_exists, new_cache
|
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = "2053ab8ed764"
|
revision = "2053ab8ed764"
|
||||||
@@ -79,13 +86,106 @@ _COST_COLUMNS: list[tuple[str, str, bool, str | None]] = [
|
|||||||
_ALL_TABLES = list({t for t, *_ in _COST_COLUMNS})
|
_ALL_TABLES = list({t for t, *_ in _COST_COLUMNS})
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._columns: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def load_columns(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._columns]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT table_name, column_name, data_type "
|
||||||
|
"FROM information_schema.columns "
|
||||||
|
"WHERE table_name = ANY(:tables) "
|
||||||
|
" AND table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for t in need:
|
||||||
|
self._columns.setdefault(t, {})
|
||||||
|
for table, col, dtype in rows:
|
||||||
|
self._columns[table][col] = dtype
|
||||||
|
|
||||||
|
def column_exists(self, table: str, column: str) -> bool:
|
||||||
|
return column in self._columns.get(table, {})
|
||||||
|
|
||||||
|
def column_type(self, table: str, column: str) -> str | None:
|
||||||
|
return self._columns.get(table, {}).get(column)
|
||||||
|
|
||||||
|
def is_numeric(self, table: str, column: str) -> bool:
|
||||||
|
return self.column_type(table, column) == "numeric"
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_indexes "
|
||||||
|
"WHERE indexname = :name AND schemaname = current_schema()::text"
|
||||||
|
),
|
||||||
|
{"name": index_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _numeric_max(type_spec: str) -> float | None:
|
||||||
|
m = re.match(r"NUMERIC\((\d+),(\d+)\)", type_spec, re.IGNORECASE)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
precision, scale = int(m.group(1)), int(m.group(2))
|
||||||
|
return 10 ** (precision - scale) - 10 ** (-scale)
|
||||||
|
|
||||||
|
|
||||||
|
def _batch_alter_type(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
columns: list[tuple[str, str, bool, str | None]],
|
||||||
|
cast_suffix: str,
|
||||||
|
type_fn: Callable[[str], str],
|
||||||
|
) -> None:
|
||||||
|
by_table: dict[str, list[tuple[str, str]]] = defaultdict(list)
|
||||||
|
for table, col, _nullable, _default in columns:
|
||||||
|
if not cache.column_exists(table, col):
|
||||||
|
continue
|
||||||
|
by_table[table].append((col, type_fn(col)))
|
||||||
|
|
||||||
|
bind = op.get_bind()
|
||||||
|
for table, col_types in by_table.items():
|
||||||
|
for col, target in col_types:
|
||||||
|
cap = _numeric_max(target)
|
||||||
|
if cap is not None:
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
f"UPDATE {table} SET {col} = :cap "
|
||||||
|
f"WHERE {col} IS NOT NULL AND abs({col}) > :cap"
|
||||||
|
),
|
||||||
|
{"cap": cap},
|
||||||
|
)
|
||||||
|
parts = [
|
||||||
|
f"ALTER COLUMN {col} TYPE {target} USING {col}::{cast_suffix}"
|
||||||
|
for col, target in col_types
|
||||||
|
]
|
||||||
|
if parts:
|
||||||
|
bind.execute(sa.text(f"ALTER TABLE {table} " + ", ".join(parts)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _type_spec(col: str) -> str:
|
def _type_spec(col: str) -> str:
|
||||||
"""Return the SQL type literal for a given column name."""
|
"""Return the SQL type literal for a given column name."""
|
||||||
return "NUMERIC(10,6)" if col == "rate_multiplier" else "NUMERIC(20,8)"
|
return "NUMERIC(10,6)" if col == "rate_multiplier" else "NUMERIC(20,8)"
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_columns(_ALL_TABLES)
|
c.load_columns(_ALL_TABLES)
|
||||||
|
|
||||||
# -- 1. cost fields: Float -> Numeric (batched per table)
|
# -- 1. cost fields: Float -> Numeric (batched per table)
|
||||||
@@ -94,10 +194,10 @@ def upgrade() -> None:
|
|||||||
for t, col, n, d in _COST_COLUMNS
|
for t, col, n, d in _COST_COLUMNS
|
||||||
if c.column_exists(t, col) and not c.is_numeric(t, col)
|
if c.column_exists(t, col) and not c.is_numeric(t, col)
|
||||||
]
|
]
|
||||||
batch_alter_type(c, cols_to_convert, cast_suffix="numeric", type_fn=_type_spec)
|
_batch_alter_type(c, cols_to_convert, cast_suffix="numeric", type_fn=_type_spec)
|
||||||
|
|
||||||
# -- 2. provider_api_keys composite index
|
# -- 2. provider_api_keys composite index
|
||||||
if not index_exists("idx_provider_api_keys_provider_active"):
|
if not _index_exists("idx_provider_api_keys_provider_active"):
|
||||||
op.create_index(
|
op.create_index(
|
||||||
"idx_provider_api_keys_provider_active",
|
"idx_provider_api_keys_provider_active",
|
||||||
"provider_api_keys",
|
"provider_api_keys",
|
||||||
@@ -107,14 +207,14 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
# -- 2. drop composite index
|
# -- 2. drop composite index
|
||||||
if index_exists("idx_provider_api_keys_provider_active"):
|
if _index_exists("idx_provider_api_keys_provider_active"):
|
||||||
op.drop_index(
|
op.drop_index(
|
||||||
"idx_provider_api_keys_provider_active",
|
"idx_provider_api_keys_provider_active",
|
||||||
table_name="provider_api_keys",
|
table_name="provider_api_keys",
|
||||||
)
|
)
|
||||||
|
|
||||||
# -- 1. Numeric -> Float (batched per table)
|
# -- 1. Numeric -> Float (batched per table)
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_columns(_ALL_TABLES)
|
c.load_columns(_ALL_TABLES)
|
||||||
|
|
||||||
cols_to_revert = [
|
cols_to_revert = [
|
||||||
@@ -122,7 +222,7 @@ def downgrade() -> None:
|
|||||||
for t, col, n, d in _COST_COLUMNS
|
for t, col, n, d in _COST_COLUMNS
|
||||||
if c.column_exists(t, col) and c.is_numeric(t, col)
|
if c.column_exists(t, col) and c.is_numeric(t, col)
|
||||||
]
|
]
|
||||||
batch_alter_type(
|
_batch_alter_type(
|
||||||
c,
|
c,
|
||||||
cols_to_revert,
|
cols_to_revert,
|
||||||
cast_suffix="double precision",
|
cast_suffix="double precision",
|
||||||
|
|||||||
+86
-5
@@ -6,7 +6,11 @@ Create Date: 2026-03-09 01:00:00.000000+00:00
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from helpers import new_cache, replace_fk_if_needed
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
# revision identifiers, used by Alembic.
|
# revision identifiers, used by Alembic.
|
||||||
revision = "d7649c1f8e21"
|
revision = "d7649c1f8e21"
|
||||||
@@ -18,10 +22,87 @@ _TABLE = "video_tasks"
|
|||||||
_FK_NAME = "video_tasks_key_id_fkey"
|
_FK_NAME = "video_tasks_key_id_fkey"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inline helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class _SchemaCache:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._fk_rules: dict[tuple[str, str], str] = {}
|
||||||
|
self._fk_loaded_tables: set[str] = set()
|
||||||
|
|
||||||
|
def load_fk_rules(self, tables: list[str]) -> None:
|
||||||
|
need = [t for t in tables if t not in self._fk_loaded_tables]
|
||||||
|
if not need:
|
||||||
|
return
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT tc.table_name, tc.constraint_name, rc.delete_rule "
|
||||||
|
"FROM information_schema.referential_constraints rc "
|
||||||
|
"JOIN information_schema.table_constraints tc "
|
||||||
|
" ON rc.constraint_name = tc.constraint_name "
|
||||||
|
" AND rc.constraint_schema = tc.constraint_schema "
|
||||||
|
"WHERE tc.table_name = ANY(:tables) "
|
||||||
|
" AND tc.table_schema = current_schema()"
|
||||||
|
),
|
||||||
|
{"tables": need},
|
||||||
|
).fetchall()
|
||||||
|
for table, name, rule in rows:
|
||||||
|
self._fk_rules[(table, name)] = rule
|
||||||
|
self._fk_loaded_tables.update(need)
|
||||||
|
|
||||||
|
def fk_ondelete(self, table: str, constraint: str) -> str | None:
|
||||||
|
return self._fk_rules.get((table, constraint))
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _replace_fk_if_needed(
|
||||||
|
cache: _SchemaCache,
|
||||||
|
constraint_name: str,
|
||||||
|
table_name: str,
|
||||||
|
ref_table: str,
|
||||||
|
local_cols: list[str],
|
||||||
|
remote_cols: list[str],
|
||||||
|
desired_ondelete: str,
|
||||||
|
) -> None:
|
||||||
|
current = cache.fk_ondelete(table_name, constraint_name)
|
||||||
|
if current and current.upper() == desired_ondelete.upper():
|
||||||
|
return
|
||||||
|
if current or _fk_exists(constraint_name, table_name):
|
||||||
|
op.drop_constraint(constraint_name, table_name, type_="foreignkey")
|
||||||
|
op.create_foreign_key(
|
||||||
|
constraint_name,
|
||||||
|
table_name,
|
||||||
|
ref_table,
|
||||||
|
local_cols,
|
||||||
|
remote_cols,
|
||||||
|
ondelete=desired_ondelete,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
def upgrade() -> None:
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_fk_rules([_TABLE])
|
c.load_fk_rules([_TABLE])
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
_FK_NAME,
|
_FK_NAME,
|
||||||
_TABLE,
|
_TABLE,
|
||||||
@@ -33,9 +114,9 @@ def upgrade() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
def downgrade() -> None:
|
||||||
c = new_cache()
|
c = _SchemaCache()
|
||||||
c.load_fk_rules([_TABLE])
|
c.load_fk_rules([_TABLE])
|
||||||
replace_fk_if_needed(
|
_replace_fk_if_needed(
|
||||||
c,
|
c,
|
||||||
_FK_NAME,
|
_FK_NAME,
|
||||||
_TABLE,
|
_TABLE,
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Strip request_results_window from health_by_format JSON.
|
||||||
|
|
||||||
|
This data is now maintained in process memory only, no longer persisted to DB.
|
||||||
|
|
||||||
|
Revision ID: a3f1b7c9d2e4
|
||||||
|
Revises: d7649c1f8e21
|
||||||
|
Create Date: 2026-03-10 12:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "a3f1b7c9d2e4"
|
||||||
|
down_revision = "d7649c1f8e21"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute("""
|
||||||
|
UPDATE provider_api_keys
|
||||||
|
SET health_by_format = (
|
||||||
|
SELECT jsonb_object_agg(
|
||||||
|
fmt_key,
|
||||||
|
fmt_value - 'request_results_window'
|
||||||
|
)
|
||||||
|
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
|
||||||
|
)
|
||||||
|
WHERE health_by_format IS NOT NULL
|
||||||
|
AND health_by_format != '{}'::jsonb
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM jsonb_each(health_by_format) AS x(fmt_key, fmt_value)
|
||||||
|
WHERE fmt_value ? 'request_results_window'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# No-op: window data is rebuilt from scratch on process start
|
||||||
|
pass
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
"""tighten usage billing state machine
|
||||||
|
|
||||||
|
Revision ID: 9e4f1a2b3c4d
|
||||||
|
Revises: a3f1b7c9d2e4
|
||||||
|
Create Date: 2026-03-11 19:00:00.000000+00:00
|
||||||
|
|
||||||
|
This migration does two things:
|
||||||
|
1. Change new `usage.billing_status` default from `settled` to `pending`.
|
||||||
|
2. Repair only the clearly-safe inconsistent historical rows for production:
|
||||||
|
- failed/cancelled zero-cost rows that were marked settled are converted to void
|
||||||
|
- terminal rows missing finalized_at are backfilled from created_at
|
||||||
|
|
||||||
|
Ambiguous positive-cost settled rows are intentionally left untouched for manual audit.
|
||||||
|
|
||||||
|
All data updates are batched (10000 rows per iteration) to avoid long-held locks
|
||||||
|
and excessive WAL generation on large usage tables.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9e4f1a2b3c4d"
|
||||||
|
down_revision: str | None = "a3f1b7c9d2e4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
BATCH_SIZE = 10000
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return table_name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return column_name in [col["name"] for col in insp.get_columns(table_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _table_exists("usage"):
|
||||||
|
return
|
||||||
|
|
||||||
|
if _column_exists("usage", "billing_status"):
|
||||||
|
op.alter_column(
|
||||||
|
"usage",
|
||||||
|
"billing_status",
|
||||||
|
existing_type=sa.String(length=20),
|
||||||
|
server_default="pending",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
required_columns = {
|
||||||
|
"billing_status",
|
||||||
|
"status",
|
||||||
|
"total_cost_usd",
|
||||||
|
"request_cost_usd",
|
||||||
|
"actual_total_cost_usd",
|
||||||
|
"actual_request_cost_usd",
|
||||||
|
"wallet_balance_after",
|
||||||
|
"finalized_at",
|
||||||
|
"created_at",
|
||||||
|
}
|
||||||
|
if not required_columns.issubset(
|
||||||
|
{col for col in required_columns if _column_exists("usage", col)}
|
||||||
|
):
|
||||||
|
return
|
||||||
|
|
||||||
|
conn = op.get_bind()
|
||||||
|
|
||||||
|
# Step 1: billing_status IS NULL -> 'pending' (batched)
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
WITH batch AS (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE billing_status IS NULL
|
||||||
|
LIMIT :batch_size
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE usage
|
||||||
|
SET billing_status = 'pending'
|
||||||
|
FROM batch WHERE usage.id = batch.id
|
||||||
|
"""),
|
||||||
|
{"batch_size": BATCH_SIZE},
|
||||||
|
)
|
||||||
|
if result.rowcount < BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Step 2: failed/cancelled zero-cost settled -> void (batched)
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
WITH batch AS (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE billing_status = 'settled'
|
||||||
|
AND status IN ('failed', 'cancelled')
|
||||||
|
AND COALESCE(total_cost_usd, 0) = 0
|
||||||
|
AND wallet_balance_after IS NULL
|
||||||
|
LIMIT :batch_size
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE usage
|
||||||
|
SET billing_status = 'void',
|
||||||
|
finalized_at = COALESCE(usage.finalized_at, usage.created_at),
|
||||||
|
total_cost_usd = 0,
|
||||||
|
request_cost_usd = 0,
|
||||||
|
actual_total_cost_usd = 0,
|
||||||
|
actual_request_cost_usd = 0
|
||||||
|
FROM batch WHERE usage.id = batch.id
|
||||||
|
"""),
|
||||||
|
{"batch_size": BATCH_SIZE},
|
||||||
|
)
|
||||||
|
if result.rowcount < BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Step 3: backfill finalized_at for terminal rows (batched)
|
||||||
|
while True:
|
||||||
|
result = conn.execute(
|
||||||
|
sa.text("""
|
||||||
|
WITH batch AS (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE billing_status IN ('settled', 'void')
|
||||||
|
AND finalized_at IS NULL
|
||||||
|
LIMIT :batch_size
|
||||||
|
FOR UPDATE SKIP LOCKED
|
||||||
|
)
|
||||||
|
UPDATE usage
|
||||||
|
SET finalized_at = COALESCE(usage.finalized_at, usage.created_at)
|
||||||
|
FROM batch WHERE usage.id = batch.id
|
||||||
|
"""),
|
||||||
|
{"batch_size": BATCH_SIZE},
|
||||||
|
)
|
||||||
|
if result.rowcount < BATCH_SIZE:
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if not _table_exists("usage") or not _column_exists("usage", "billing_status"):
|
||||||
|
return
|
||||||
|
|
||||||
|
op.alter_column(
|
||||||
|
"usage",
|
||||||
|
"billing_status",
|
||||||
|
existing_type=sa.String(length=20),
|
||||||
|
server_default="settled",
|
||||||
|
existing_nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
"""add wallet daily usage ledgers
|
||||||
|
|
||||||
|
Revision ID: d4e5f6a7b8c9
|
||||||
|
Revises: 9e4f1a2b3c4d
|
||||||
|
Create Date: 2026-03-11 21:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "d4e5f6a7b8c9"
|
||||||
|
down_revision: str | None = "9e4f1a2b3c4d"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _table_exists(table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return table_name in insp.get_table_names()
|
||||||
|
|
||||||
|
|
||||||
|
def _column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return column_name in [col["name"] for col in insp.get_columns(table_name)]
|
||||||
|
|
||||||
|
|
||||||
|
def _index_exists(table_name: str, index_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
insp = inspect(bind)
|
||||||
|
insp.clear_cache()
|
||||||
|
return any(idx["name"] == index_name for idx in insp.get_indexes(table_name))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not _table_exists("wallet_daily_usage_ledgers"):
|
||||||
|
op.create_table(
|
||||||
|
"wallet_daily_usage_ledgers",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("wallet_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("billing_date", sa.Date(), nullable=False),
|
||||||
|
sa.Column("billing_timezone", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("total_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"),
|
||||||
|
sa.Column("total_requests", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("input_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("output_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("cache_creation_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("cache_read_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("first_finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("last_finalized_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("aggregated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(["wallet_id"], ["wallets.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint(
|
||||||
|
"wallet_id",
|
||||||
|
"billing_date",
|
||||||
|
"billing_timezone",
|
||||||
|
name="uq_wallet_daily_usage_ledgers_wallet_date_tz",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_wallet_daily_usage_wallet_date",
|
||||||
|
"wallet_daily_usage_ledgers",
|
||||||
|
["wallet_id", "billing_date"],
|
||||||
|
)
|
||||||
|
if not _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
|
||||||
|
op.create_index(
|
||||||
|
"idx_wallet_daily_usage_date",
|
||||||
|
"wallet_daily_usage_ledgers",
|
||||||
|
["billing_date"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (
|
||||||
|
_table_exists("usage")
|
||||||
|
and all(
|
||||||
|
_column_exists("usage", col) for col in ["billing_status", "finalized_at", "wallet_id"]
|
||||||
|
)
|
||||||
|
and not _index_exists("usage", "idx_usage_billing_finalized_wallet")
|
||||||
|
):
|
||||||
|
op.create_index(
|
||||||
|
"idx_usage_billing_finalized_wallet",
|
||||||
|
"usage",
|
||||||
|
["billing_status", "finalized_at", "wallet_id"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if _table_exists("usage") and _index_exists("usage", "idx_usage_billing_finalized_wallet"):
|
||||||
|
op.drop_index("idx_usage_billing_finalized_wallet", table_name="usage")
|
||||||
|
|
||||||
|
if _table_exists("wallet_daily_usage_ledgers"):
|
||||||
|
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_date"):
|
||||||
|
op.drop_index("idx_wallet_daily_usage_date", table_name="wallet_daily_usage_ledgers")
|
||||||
|
if _index_exists("wallet_daily_usage_ledgers", "idx_wallet_daily_usage_wallet_date"):
|
||||||
|
op.drop_index(
|
||||||
|
"idx_wallet_daily_usage_wallet_date",
|
||||||
|
table_name="wallet_daily_usage_ledgers",
|
||||||
|
)
|
||||||
|
op.drop_table("wallet_daily_usage_ledgers")
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""add provider_api_keys usage total columns
|
||||||
|
|
||||||
|
Revision ID: 9b7c6d5e4f3a
|
||||||
|
Revises: d4e5f6a7b8c9
|
||||||
|
Create Date: 2026-03-11 22:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "9b7c6d5e4f3a"
|
||||||
|
down_revision: str | None = "d4e5f6a7b8c9"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("provider_api_keys", "total_tokens"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("total_tokens", sa.BigInteger(), nullable=False, server_default="0"),
|
||||||
|
)
|
||||||
|
if column_exists("provider_api_keys", "total_tokens"):
|
||||||
|
op.alter_column("provider_api_keys", "total_tokens", server_default=None)
|
||||||
|
|
||||||
|
if not column_exists("provider_api_keys", "total_cost_usd"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column(
|
||||||
|
"total_cost_usd",
|
||||||
|
sa.Numeric(20, 8),
|
||||||
|
nullable=False,
|
||||||
|
server_default="0.0",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if column_exists("provider_api_keys", "total_cost_usd"):
|
||||||
|
op.alter_column("provider_api_keys", "total_cost_usd", server_default=None)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "total_cost_usd"):
|
||||||
|
op.drop_column("provider_api_keys", "total_cost_usd")
|
||||||
|
|
||||||
|
if column_exists("provider_api_keys", "total_tokens"):
|
||||||
|
op.drop_column("provider_api_keys", "total_tokens")
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"""cleanup stale provider references after provider deletion
|
||||||
|
|
||||||
|
Revision ID: c1d2e3f4a5b6
|
||||||
|
Revises: 9b7c6d5e4f3a
|
||||||
|
Create Date: 2026-03-11 23:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "c1d2e3f4a5b6"
|
||||||
|
down_revision = "9b7c6d5e4f3a"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_users = sa.table(
|
||||||
|
"users",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("allowed_providers", sa.JSON()),
|
||||||
|
)
|
||||||
|
_api_keys = sa.table(
|
||||||
|
"api_keys",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("allowed_providers", sa.JSON()),
|
||||||
|
)
|
||||||
|
_user_preferences = sa.table(
|
||||||
|
"user_preferences",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("default_provider_id", sa.String(36)),
|
||||||
|
)
|
||||||
|
_video_tasks = sa.table(
|
||||||
|
"video_tasks",
|
||||||
|
sa.column("id", sa.String(36)),
|
||||||
|
sa.column("provider_id", sa.String(36)),
|
||||||
|
sa.column("endpoint_id", sa.String(36)),
|
||||||
|
)
|
||||||
|
_providers = sa.table("providers", sa.column("id", sa.String(36)))
|
||||||
|
_provider_endpoints = sa.table("provider_endpoints", sa.column("id", sa.String(36)))
|
||||||
|
|
||||||
|
|
||||||
|
def _load_valid_ids(conn: sa.Connection, table: sa.Table) -> set[str]:
|
||||||
|
return {str(row[0]) for row in conn.execute(sa.select(table.c.id)).fetchall() if row[0]}
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup_allowed_providers(
|
||||||
|
conn: sa.Connection,
|
||||||
|
table: sa.Table,
|
||||||
|
valid_provider_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
rows = conn.execute(
|
||||||
|
sa.select(table.c.id, table.c.allowed_providers).where(
|
||||||
|
table.c.allowed_providers.isnot(None)
|
||||||
|
)
|
||||||
|
).fetchall()
|
||||||
|
for row_id, allowed_providers in rows:
|
||||||
|
if not isinstance(allowed_providers, list):
|
||||||
|
continue
|
||||||
|
filtered = [
|
||||||
|
provider_id for provider_id in allowed_providers if provider_id in valid_provider_ids
|
||||||
|
]
|
||||||
|
if filtered == allowed_providers:
|
||||||
|
continue
|
||||||
|
conn.execute(table.update().where(table.c.id == row_id).values(allowed_providers=filtered))
|
||||||
|
|
||||||
|
|
||||||
|
def _nullify_missing_fk(
|
||||||
|
conn: sa.Connection,
|
||||||
|
table: sa.Table,
|
||||||
|
id_column: sa.ColumnElement[str],
|
||||||
|
fk_column: sa.ColumnElement[str],
|
||||||
|
valid_ids: set[str],
|
||||||
|
) -> None:
|
||||||
|
rows = conn.execute(sa.select(id_column, fk_column).where(fk_column.isnot(None))).fetchall()
|
||||||
|
invalid_row_ids = [row_id for row_id, fk_value in rows if fk_value not in valid_ids]
|
||||||
|
if not invalid_row_ids:
|
||||||
|
return
|
||||||
|
conn.execute(table.update().where(id_column.in_(invalid_row_ids)).values({fk_column.key: None}))
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
valid_provider_ids = _load_valid_ids(conn, _providers)
|
||||||
|
valid_endpoint_ids = _load_valid_ids(conn, _provider_endpoints)
|
||||||
|
|
||||||
|
_cleanup_allowed_providers(conn, _users, valid_provider_ids)
|
||||||
|
_cleanup_allowed_providers(conn, _api_keys, valid_provider_ids)
|
||||||
|
_nullify_missing_fk(
|
||||||
|
conn,
|
||||||
|
_user_preferences,
|
||||||
|
_user_preferences.c.id,
|
||||||
|
_user_preferences.c.default_provider_id,
|
||||||
|
valid_provider_ids,
|
||||||
|
)
|
||||||
|
_nullify_missing_fk(
|
||||||
|
conn,
|
||||||
|
_video_tasks,
|
||||||
|
_video_tasks.c.id,
|
||||||
|
_video_tasks.c.provider_id,
|
||||||
|
valid_provider_ids,
|
||||||
|
)
|
||||||
|
_nullify_missing_fk(
|
||||||
|
conn,
|
||||||
|
_video_tasks,
|
||||||
|
_video_tasks.c.id,
|
||||||
|
_video_tasks.c.endpoint_id,
|
||||||
|
valid_endpoint_ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""decouple request_candidates.key_id foreign key from provider_api_keys lifecycle
|
||||||
|
|
||||||
|
Revision ID: b7c8d9e0f1a2
|
||||||
|
Revises: c1d2e3f4a5b6
|
||||||
|
Create Date: 2026-03-12 19:15:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "b7c8d9e0f1a2"
|
||||||
|
down_revision = "c1d2e3f4a5b6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _fk_exists(constraint_name: str, table_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
result = bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"SELECT 1 FROM pg_constraint c "
|
||||||
|
"JOIN pg_class r ON c.conrelid = r.oid "
|
||||||
|
"JOIN pg_namespace n ON r.relnamespace = n.oid "
|
||||||
|
"WHERE c.conname = :name AND r.relname = :table "
|
||||||
|
" AND n.nspname = current_schema() AND c.contype = 'f'"
|
||||||
|
),
|
||||||
|
{"name": constraint_name, "table": table_name},
|
||||||
|
)
|
||||||
|
return result.scalar() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
|
||||||
|
op.drop_constraint(
|
||||||
|
"request_candidates_key_id_fkey", "request_candidates", type_="foreignkey"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
bind.execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE request_candidates rc "
|
||||||
|
"SET key_id = NULL "
|
||||||
|
"WHERE key_id IS NOT NULL "
|
||||||
|
" AND NOT EXISTS ("
|
||||||
|
" SELECT 1 FROM provider_api_keys pak WHERE pak.id = rc.key_id"
|
||||||
|
" )"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not _fk_exists("request_candidates_key_id_fkey", "request_candidates"):
|
||||||
|
op.create_foreign_key(
|
||||||
|
"request_candidates_key_id_fkey",
|
||||||
|
"request_candidates",
|
||||||
|
"provider_api_keys",
|
||||||
|
["key_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="CASCADE",
|
||||||
|
)
|
||||||
+54
@@ -0,0 +1,54 @@
|
|||||||
|
"""add user rate_limit and backfill normal api key limits
|
||||||
|
|
||||||
|
Revision ID: b7e8f9a0c1d2
|
||||||
|
Revises: b7c8d9e0f1a2
|
||||||
|
Create Date: 2026-03-13 12:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "b7e8f9a0c1d2"
|
||||||
|
down_revision: str | None = "b7c8d9e0f1a2"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("users", "rate_limit"):
|
||||||
|
op.add_column("users", sa.Column("rate_limit", sa.Integer(), nullable=True))
|
||||||
|
|
||||||
|
# 普通 Key 新语义不再允许 NULL;存量 NULL 统一回填为 0(不限制)。
|
||||||
|
op.execute(sa.text("""
|
||||||
|
UPDATE api_keys
|
||||||
|
SET rate_limit = 0
|
||||||
|
WHERE is_standalone = FALSE
|
||||||
|
AND rate_limit IS NULL
|
||||||
|
"""))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# 恢复普通 Key 的 rate_limit 为 NULL(与 upgrade 中回填 0 对应)
|
||||||
|
op.execute(sa.text("""
|
||||||
|
UPDATE api_keys
|
||||||
|
SET rate_limit = NULL
|
||||||
|
WHERE is_standalone = FALSE
|
||||||
|
AND rate_limit = 0
|
||||||
|
"""))
|
||||||
|
|
||||||
|
if column_exists("users", "rate_limit"):
|
||||||
|
op.drop_column("users", "rate_limit")
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"""add user sessions table for device-level auth
|
||||||
|
|
||||||
|
Revision ID: f6e7d8c9b0a1
|
||||||
|
Revises: b7e8f9a0c1d2
|
||||||
|
Create Date: 2026-03-15 12:00:00.000000+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = "f6e7d8c9b0a1"
|
||||||
|
down_revision = "b7e8f9a0c1d2"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = sa.inspect(bind)
|
||||||
|
if "user_sessions" in inspector.get_table_names():
|
||||||
|
return
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"user_sessions",
|
||||||
|
sa.Column("id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("user_id", sa.String(length=36), nullable=False),
|
||||||
|
sa.Column("client_device_id", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("device_label", sa.String(length=120), nullable=True),
|
||||||
|
sa.Column("device_type", sa.String(length=20), nullable=False, server_default="unknown"),
|
||||||
|
sa.Column("browser_name", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("browser_version", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("os_name", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("os_version", sa.String(length=50), nullable=True),
|
||||||
|
sa.Column("device_model", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column("ip_address", sa.String(length=45), nullable=True),
|
||||||
|
sa.Column("user_agent", sa.String(length=1000), nullable=True),
|
||||||
|
sa.Column("client_hints", sa.JSON(), nullable=True),
|
||||||
|
sa.Column("refresh_token_hash", sa.String(length=64), nullable=False),
|
||||||
|
sa.Column("prev_refresh_token_hash", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("rotated_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"last_seen_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("revoke_reason", sa.String(length=100), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_index("ix_user_sessions_user_id", "user_sessions", ["user_id"], unique=False)
|
||||||
|
op.create_index(
|
||||||
|
"ix_user_sessions_client_device_id",
|
||||||
|
"user_sessions",
|
||||||
|
["client_device_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_user_sessions_user_active",
|
||||||
|
"user_sessions",
|
||||||
|
["user_id", "revoked_at", "expires_at"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_user_sessions_user_device",
|
||||||
|
"user_sessions",
|
||||||
|
["user_id", "client_device_id"],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("idx_user_sessions_user_device", table_name="user_sessions")
|
||||||
|
op.drop_index("idx_user_sessions_user_active", table_name="user_sessions")
|
||||||
|
op.drop_index("ix_user_sessions_client_device_id", table_name="user_sessions")
|
||||||
|
op.drop_index("ix_user_sessions_user_id", table_name="user_sessions")
|
||||||
|
op.drop_table("user_sessions")
|
||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
"""add status_snapshot column to provider_api_keys
|
||||||
|
|
||||||
|
Revision ID: c9d8e7f6a5b4
|
||||||
|
Revises: f6e7d8c9b0a1
|
||||||
|
Create Date: 2026-03-20 12:00:00.000000
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c9d8e7f6a5b4"
|
||||||
|
down_revision: str | None = "f6e7d8c9b0a1"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if not column_exists("provider_api_keys", "status_snapshot"):
|
||||||
|
op.add_column(
|
||||||
|
"provider_api_keys",
|
||||||
|
sa.Column("status_snapshot", sa.JSON(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
if column_exists("provider_api_keys", "status_snapshot"):
|
||||||
|
op.drop_column("provider_api_keys", "status_snapshot")
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
"""usage token semantics v2
|
||||||
|
|
||||||
|
Revision ID: c3d4e5f6a7b8
|
||||||
|
Revises: c9d8e7f6a5b4
|
||||||
|
Create Date: 2026-03-24 14:00:00.000000+00:00
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import inspect
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "c3d4e5f6a7b8"
|
||||||
|
down_revision: str | None = "c9d8e7f6a5b4"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
BACKFILL_BATCH_SIZE = 2000
|
||||||
|
|
||||||
|
# 最大批次数,防止因数据异常导致死循环(2000 * 500000 = 10亿行上限)
|
||||||
|
_MAX_BATCHES = 500000
|
||||||
|
|
||||||
|
# 使用子查询中间层展开 input_output_total_tokens 的计算,
|
||||||
|
# 确保 total_tokens 引用的是本次 SET 后的新值而非旧值。
|
||||||
|
_UPGRADE_BACKFILL_SQL = sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE usage
|
||||||
|
SET
|
||||||
|
input_output_total_tokens = src.new_iot,
|
||||||
|
input_context_tokens = src.new_ict,
|
||||||
|
total_tokens = src.new_total,
|
||||||
|
cache_creation_cost_usd_5m = src.new_cc5m,
|
||||||
|
cache_creation_cost_usd_1h = src.new_cc1h,
|
||||||
|
actual_cache_creation_cost_usd_5m = src.new_acc5m,
|
||||||
|
actual_cache_creation_cost_usd_1h = src.new_acc1h,
|
||||||
|
actual_cache_cost_usd = src.new_accu,
|
||||||
|
cache_creation_price_per_1m_5m = src.new_cp5m,
|
||||||
|
cache_creation_price_per_1m_1h = src.new_cp1h,
|
||||||
|
cache_cost_usd = src.new_ccu
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
COALESCE(input_output_total_tokens,
|
||||||
|
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
AS new_iot,
|
||||||
|
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
|
||||||
|
AS new_ict,
|
||||||
|
/* total_tokens 引用本行计算出的 new_iot,避免依赖 SET 顺序 */
|
||||||
|
COALESCE(input_output_total_tokens,
|
||||||
|
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
+ COALESCE(cache_creation_input_tokens, 0)
|
||||||
|
+ COALESCE(cache_read_input_tokens, 0)
|
||||||
|
AS new_total,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END AS new_cc5m,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END AS new_cc1h,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END AS new_acc5m,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END AS new_acc1h,
|
||||||
|
COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
+ COALESCE(actual_cache_read_cost_usd, 0) AS new_accu,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN cache_creation_price_per_1m
|
||||||
|
ELSE NULL
|
||||||
|
END AS new_cp5m,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN cache_creation_price_per_1m
|
||||||
|
ELSE NULL
|
||||||
|
END AS new_cp1h,
|
||||||
|
COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
+ COALESCE(cache_read_cost_usd, 0) AS new_ccu
|
||||||
|
FROM usage
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id FROM usage
|
||||||
|
WHERE
|
||||||
|
input_output_total_tokens IS DISTINCT FROM
|
||||||
|
COALESCE(input_output_total_tokens,
|
||||||
|
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
OR input_context_tokens IS DISTINCT FROM
|
||||||
|
COALESCE(input_tokens, 0) + COALESCE(cache_read_input_tokens, 0)
|
||||||
|
OR total_tokens IS DISTINCT FROM (
|
||||||
|
COALESCE(input_output_total_tokens,
|
||||||
|
COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
+ COALESCE(cache_creation_input_tokens, 0)
|
||||||
|
+ COALESCE(cache_read_input_tokens, 0)
|
||||||
|
)
|
||||||
|
OR cache_creation_cost_usd_5m IS DISTINCT FROM (
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
OR cache_creation_cost_usd_1h IS DISTINCT FROM (
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
OR actual_cache_creation_cost_usd_5m IS DISTINCT FROM (
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_5m, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
OR actual_cache_creation_cost_usd_1h IS DISTINCT FROM (
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens, 0) > 0
|
||||||
|
THEN COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
* (COALESCE(cache_creation_input_tokens_1h, 0) * 1.0
|
||||||
|
/ GREATEST(COALESCE(cache_creation_input_tokens, 0), 1))
|
||||||
|
ELSE 0
|
||||||
|
END
|
||||||
|
)
|
||||||
|
OR actual_cache_cost_usd IS DISTINCT FROM (
|
||||||
|
COALESCE(actual_cache_creation_cost_usd, 0)
|
||||||
|
+ COALESCE(actual_cache_read_cost_usd, 0)
|
||||||
|
)
|
||||||
|
OR cache_creation_price_per_1m_5m IS DISTINCT FROM (
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_5m, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_1h, 0) = 0
|
||||||
|
THEN cache_creation_price_per_1m
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
OR cache_creation_price_per_1m_1h IS DISTINCT FROM (
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(cache_creation_input_tokens_1h, 0) > 0
|
||||||
|
AND COALESCE(cache_creation_input_tokens_5m, 0) = 0
|
||||||
|
THEN cache_creation_price_per_1m
|
||||||
|
ELSE NULL
|
||||||
|
END
|
||||||
|
)
|
||||||
|
OR cache_cost_usd IS DISTINCT FROM (
|
||||||
|
COALESCE(cache_creation_cost_usd, 0) + COALESCE(cache_read_cost_usd, 0)
|
||||||
|
)
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT :batch_size
|
||||||
|
)
|
||||||
|
) AS src
|
||||||
|
WHERE usage.id = src.id
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
_DOWNGRADE_BACKFILL_SQL = sa.text(
|
||||||
|
"""
|
||||||
|
UPDATE usage
|
||||||
|
SET total_tokens = COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
WHERE id IN (
|
||||||
|
SELECT id
|
||||||
|
FROM usage
|
||||||
|
WHERE total_tokens IS DISTINCT FROM
|
||||||
|
COALESCE(input_output_total_tokens, COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0))
|
||||||
|
ORDER BY id
|
||||||
|
LIMIT :batch_size
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def column_exists(table_name: str, column_name: str) -> bool:
|
||||||
|
bind = op.get_bind()
|
||||||
|
inspector = inspect(bind)
|
||||||
|
columns = [c["name"] for c in inspector.get_columns(table_name)]
|
||||||
|
return column_name in columns
|
||||||
|
|
||||||
|
|
||||||
|
def run_backfill_in_batches(sql: sa.TextClause, batch_size: int = BACKFILL_BATCH_SIZE) -> None:
|
||||||
|
context = op.get_context()
|
||||||
|
for _ in range(_MAX_BATCHES):
|
||||||
|
# Commit the preceding schema transaction before each batch so PostgreSQL
|
||||||
|
# does not keep ALTER TABLE locks for the entire data backfill.
|
||||||
|
with context.autocommit_block():
|
||||||
|
rowcount = op.get_bind().execute(sql, {"batch_size": batch_size}).rowcount
|
||||||
|
if rowcount == 0:
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Backfill did not converge after {_MAX_BATCHES} batches "
|
||||||
|
f"(batch_size={batch_size}). Possible infinite loop due to data anomaly."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
if column_exists("usage", "total_tokens") and not column_exists("usage", "input_output_total_tokens"):
|
||||||
|
with op.batch_alter_table("usage") as batch_op:
|
||||||
|
batch_op.alter_column(
|
||||||
|
"total_tokens",
|
||||||
|
new_column_name="input_output_total_tokens",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
with op.batch_alter_table("usage") as batch_op:
|
||||||
|
if not column_exists("usage", "input_context_tokens"):
|
||||||
|
batch_op.add_column(sa.Column("input_context_tokens", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "total_tokens"):
|
||||||
|
batch_op.add_column(sa.Column("total_tokens", sa.Integer(), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "actual_cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_5m", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "actual_cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.add_column(sa.Column("actual_cache_creation_cost_usd_1h", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "actual_cache_cost_usd"):
|
||||||
|
batch_op.add_column(sa.Column("actual_cache_cost_usd", sa.Numeric(20, 8), nullable=False, server_default="0"))
|
||||||
|
if not column_exists("usage", "cache_creation_price_per_1m_5m"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_price_per_1m_5m", sa.Numeric(20, 8), nullable=True))
|
||||||
|
if not column_exists("usage", "cache_creation_price_per_1m_1h"):
|
||||||
|
batch_op.add_column(sa.Column("cache_creation_price_per_1m_1h", sa.Numeric(20, 8), nullable=True))
|
||||||
|
|
||||||
|
run_backfill_in_batches(_UPGRADE_BACKFILL_SQL)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
run_backfill_in_batches(_DOWNGRADE_BACKFILL_SQL)
|
||||||
|
|
||||||
|
with op.batch_alter_table("usage") as batch_op:
|
||||||
|
if column_exists("usage", "cache_creation_price_per_1m_1h"):
|
||||||
|
batch_op.drop_column("cache_creation_price_per_1m_1h")
|
||||||
|
if column_exists("usage", "cache_creation_price_per_1m_5m"):
|
||||||
|
batch_op.drop_column("cache_creation_price_per_1m_5m")
|
||||||
|
if column_exists("usage", "actual_cache_cost_usd"):
|
||||||
|
batch_op.drop_column("actual_cache_cost_usd")
|
||||||
|
if column_exists("usage", "actual_cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.drop_column("actual_cache_creation_cost_usd_1h")
|
||||||
|
if column_exists("usage", "actual_cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.drop_column("actual_cache_creation_cost_usd_5m")
|
||||||
|
if column_exists("usage", "cache_creation_cost_usd_1h"):
|
||||||
|
batch_op.drop_column("cache_creation_cost_usd_1h")
|
||||||
|
if column_exists("usage", "cache_creation_cost_usd_5m"):
|
||||||
|
batch_op.drop_column("cache_creation_cost_usd_5m")
|
||||||
|
if column_exists("usage", "input_context_tokens"):
|
||||||
|
batch_op.drop_column("input_context_tokens")
|
||||||
|
if column_exists("usage", "total_tokens"):
|
||||||
|
batch_op.drop_column("total_tokens")
|
||||||
|
if column_exists("usage", "input_output_total_tokens"):
|
||||||
|
batch_op.alter_column(
|
||||||
|
"input_output_total_tokens",
|
||||||
|
new_column_name="total_tokens",
|
||||||
|
existing_type=sa.Integer(),
|
||||||
|
existing_nullable=True,
|
||||||
|
)
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
# 部署/更新: ./deploy.sh (自动检测所有变化)
|
# 部署/更新: ./deploy.sh (自动检测所有变化)
|
||||||
# 指定 Hub 版本: ./deploy.sh --hub-tag hub-v0.1.0
|
# 指定 Hub 版本: ./deploy.sh --hub-tag hub-v0.1.0
|
||||||
# 更新 Hub: ./deploy.sh --update-hub
|
# 更新 Hub: ./deploy.sh --update-hub
|
||||||
|
# GitHub 镜像: ./deploy.sh --mirror https://ghfast.top
|
||||||
# 强制重建: ./deploy.sh --rebuild-base
|
# 强制重建: ./deploy.sh --rebuild-base
|
||||||
# 强制全部重建: ./deploy.sh --force
|
# 强制全部重建: ./deploy.sh --force
|
||||||
|
|
||||||
@@ -44,6 +45,7 @@ Usage: ./deploy.sh [options]
|
|||||||
Options:
|
Options:
|
||||||
--hub-tag <hub-vX.Y.Z> 指定 Hub Release tag(例如 hub-v0.1.0)
|
--hub-tag <hub-vX.Y.Z> 指定 Hub Release tag(例如 hub-v0.1.0)
|
||||||
--update-hub 强制刷新 Hub 版本标记(下次构建会重新下载)
|
--update-hub 强制刷新 Hub 版本标记(下次构建会重新下载)
|
||||||
|
--mirror <url> GitHub 下载镜像(例如 https://ghfast.top)
|
||||||
--rebuild-base, -r 仅重建 base 镜像
|
--rebuild-base, -r 仅重建 base 镜像
|
||||||
--force, -f 强制重建全部(hub/base/app)并重启
|
--force, -f 强制重建全部(hub/base/app)并重启
|
||||||
-h, --help 显示帮助
|
-h, --help 显示帮助
|
||||||
@@ -54,6 +56,7 @@ FORCE_REBUILD_ALL=false
|
|||||||
REBUILD_BASE_ONLY=false
|
REBUILD_BASE_ONLY=false
|
||||||
FORCE_UPDATE_HUB=false
|
FORCE_UPDATE_HUB=false
|
||||||
HUB_TAG="${HUB_TAG:-}"
|
HUB_TAG="${HUB_TAG:-}"
|
||||||
|
GITHUB_MIRROR="${GITHUB_MIRROR:-}"
|
||||||
RESOLVED_HUB_TAG=""
|
RESOLVED_HUB_TAG=""
|
||||||
|
|
||||||
while [ $# -gt 0 ]; do
|
while [ $# -gt 0 ]; do
|
||||||
@@ -70,6 +73,14 @@ while [ $# -gt 0 ]; do
|
|||||||
FORCE_UPDATE_HUB=true
|
FORCE_UPDATE_HUB=true
|
||||||
shift
|
shift
|
||||||
;;
|
;;
|
||||||
|
--mirror)
|
||||||
|
if [ $# -lt 2 ]; then
|
||||||
|
echo "ERROR: --mirror needs a URL, e.g. https://ghfast.top"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
GITHUB_MIRROR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
--rebuild-base|-r)
|
--rebuild-base|-r)
|
||||||
REBUILD_BASE_ONLY=true
|
REBUILD_BASE_ONLY=true
|
||||||
shift
|
shift
|
||||||
@@ -97,27 +108,26 @@ if [ -n "$HUB_TAG" ]; then
|
|||||||
esac
|
esac
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 提取 pyproject.toml 中"会影响运行时依赖安装"的最小指纹(与 CI 保持一致):
|
# 提取 pyproject.toml 中会影响运行时依赖安装的字段指纹(纯 shell,无需 Python)
|
||||||
# - [build-system] requires / build-backend
|
# 用 sed 提取 dependencies / requires 数组块和单值字段,排序后输出稳定文本
|
||||||
# - [project] requires-python / dependencies
|
|
||||||
# 使用 Python tomllib 解析,不受 TOML 格式变化影响。
|
|
||||||
pyproject_deps_fingerprint() {
|
pyproject_deps_fingerprint() {
|
||||||
python3 - <<'PY'
|
local file="pyproject.toml"
|
||||||
import json, pathlib, tomllib
|
# 提取 "key = [..." 多行数组块(从 key 行到 ] 行)
|
||||||
|
extract_array() {
|
||||||
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
sed -n "/^$1[[:space:]]*=[[:space:]]*\[/,/\]/p" "$file" | grep '"' | sed 's/.*"\(.*\)".*/\1/' | sort
|
||||||
project = data.get("project") or {}
|
}
|
||||||
build = data.get("build-system") or {}
|
# 提取 "key = "value"" 单行值
|
||||||
|
extract_value() {
|
||||||
fingerprint = {
|
grep -m1 "^$1[[:space:]]*=" "$file" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/'
|
||||||
"requires-python": project.get("requires-python"),
|
}
|
||||||
"dependencies": sorted(project.get("dependencies") or []),
|
{
|
||||||
"build-backend": build.get("build-backend"),
|
echo "requires-python=$(extract_value requires-python)"
|
||||||
"build-requires": sorted(build.get("requires") or []),
|
echo "build-backend=$(extract_value build-backend)"
|
||||||
}
|
echo "dependencies:"
|
||||||
|
extract_array dependencies
|
||||||
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
echo "build-requires:"
|
||||||
PY
|
extract_array requires
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
||||||
@@ -286,10 +296,15 @@ build_app() {
|
|||||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||||
token_args=(--build-arg "GITHUB_TOKEN=${GITHUB_TOKEN}")
|
token_args=(--build-arg "GITHUB_TOKEN=${GITHUB_TOKEN}")
|
||||||
fi
|
fi
|
||||||
|
local mirror_args=()
|
||||||
|
if [ -n "${GITHUB_MIRROR:-}" ]; then
|
||||||
|
mirror_args=(--build-arg "GITHUB_MIRROR=${GITHUB_MIRROR}")
|
||||||
|
fi
|
||||||
docker build --pull=false \
|
docker build --pull=false \
|
||||||
--build-arg HUB_RELEASE_REPO="$GITHUB_REPO" \
|
--build-arg HUB_RELEASE_REPO="$GITHUB_REPO" \
|
||||||
--build-arg HUB_TAG="$RESOLVED_HUB_TAG" \
|
--build-arg HUB_TAG="$RESOLVED_HUB_TAG" \
|
||||||
"${token_args[@]}" \
|
"${token_args[@]}" \
|
||||||
|
"${mirror_args[@]}" \
|
||||||
-f Dockerfile.app.local \
|
-f Dockerfile.app.local \
|
||||||
-t aether-app:latest .
|
-t aether-app:latest .
|
||||||
save_code_hash
|
save_code_hash
|
||||||
@@ -372,10 +387,6 @@ if [ "$FORCE_UPDATE_HUB" = true ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 拉取最新代码
|
|
||||||
echo ">>> Pulling latest code..."
|
|
||||||
git pull
|
|
||||||
|
|
||||||
# 标记是否需要重启
|
# 标记是否需要重启
|
||||||
NEED_RESTART=false
|
NEED_RESTART=false
|
||||||
BASE_REBUILT=false
|
BASE_REBUILT=false
|
||||||
|
|||||||
@@ -11,10 +11,16 @@ set +a
|
|||||||
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}"
|
export DATABASE_URL="postgresql://${DB_USER:-postgres}:${DB_PASSWORD}@${DB_HOST:-localhost}:${DB_PORT:-5432}/${DB_NAME:-aether}"
|
||||||
export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0
|
export REDIS_URL=redis://:${REDIS_PASSWORD}@${REDIS_HOST:-localhost}:${REDIS_PORT:-6379}/0
|
||||||
|
|
||||||
# 启动 uvicorn(热重载模式)
|
# 开发环境连接池低配(节省内存)
|
||||||
echo "🚀 启动本地开发服务器..."
|
export DB_POOL_SIZE=${DB_POOL_SIZE:-5}
|
||||||
echo "📍 后端地址: http://localhost:8084"
|
export DB_MAX_OVERFLOW=${DB_MAX_OVERFLOW:-5}
|
||||||
echo "📊 数据库: ${DATABASE_URL}"
|
export HTTP_MAX_CONNECTIONS=${HTTP_MAX_CONNECTIONS:-20}
|
||||||
|
export HTTP_KEEPALIVE_CONNECTIONS=${HTTP_KEEPALIVE_CONNECTIONS:-5}
|
||||||
|
|
||||||
|
# 启动 uvicorn(热重载模式,只监视 src 目录)
|
||||||
|
echo "=> 启动本地开发服务器..."
|
||||||
|
echo "=> 后端地址: http://localhost:8084"
|
||||||
|
echo "=> 数据库: ${DATABASE_URL}"
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
uv run uvicorn src.main:app --reload --port 8084
|
uv run uvicorn src.main:app --reload --reload-dir src --port 8084
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
|
# Wait for PostgreSQL to be ready
|
||||||
|
MAX_ATTEMPTS=30
|
||||||
|
ATTEMPT=0
|
||||||
|
until python -c "
|
||||||
|
from sqlalchemy import create_engine, text
|
||||||
|
import os
|
||||||
|
engine = create_engine(os.environ['DATABASE_URL'])
|
||||||
|
with engine.connect() as conn:
|
||||||
|
conn.execute(text('SELECT 1'))
|
||||||
|
" 2>/dev/null; do
|
||||||
|
ATTEMPT=$((ATTEMPT + 1))
|
||||||
|
if [ "$ATTEMPT" -ge "$MAX_ATTEMPTS" ]; then
|
||||||
|
echo "Database not ready after $MAX_ATTEMPTS attempts, exiting."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for database... (attempt $ATTEMPT/$MAX_ATTEMPTS)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "Database is ready."
|
||||||
|
|
||||||
echo "Running database migrations..."
|
echo "Running database migrations..."
|
||||||
alembic upgrade head
|
alembic upgrade head
|
||||||
|
|
||||||
|
|||||||
Generated
+7
@@ -24,6 +24,7 @@
|
|||||||
"marked": "^16.0.0",
|
"marked": "^16.0.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"pinyin-pro": "^3.28.0",
|
||||||
"radix-vue": "^1.9.17",
|
"radix-vue": "^1.9.17",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
@@ -4718,6 +4719,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pinyin-pro": {
|
||||||
|
"version": "3.28.0",
|
||||||
|
"resolved": "https://registry.npmmirror.com/pinyin-pro/-/pinyin-pro-3.28.0.tgz",
|
||||||
|
"integrity": "sha512-mMRty6RisoyYNphJrTo3pnvp3w8OMZBrXm9YSWkxhAfxKj1KZk2y8T2PDIZlDDRsvZ0No+Hz6FI4sZpA6Ey25g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/pirates": {
|
"node_modules/pirates": {
|
||||||
"version": "4.0.7",
|
"version": "4.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz",
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"marked": "^16.0.0",
|
"marked": "^16.0.0",
|
||||||
"otpauth": "^9.5.0",
|
"otpauth": "^9.5.0",
|
||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
|
"pinyin-pro": "^3.28.0",
|
||||||
"radix-vue": "^1.9.17",
|
"radix-vue": "^1.9.17",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
"three": "^0.180.0",
|
"three": "^0.180.0",
|
||||||
|
|||||||
+66
-2
@@ -5,12 +5,14 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onErrorCaptured } from 'vue'
|
import { onMounted, onErrorCaptured, onUnmounted } from 'vue'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import ToastContainer from '@/components/ToastContainer.vue'
|
import ToastContainer from '@/components/ToastContainer.vue'
|
||||||
import ConfirmContainer from '@/components/ConfirmContainer.vue'
|
import ConfirmContainer from '@/components/ConfirmContainer.vue'
|
||||||
import apiClient from '@/api/client'
|
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
|
||||||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||||||
|
import router from '@/router'
|
||||||
|
import { hasAuthIdentityChanged } from '@/utils/authToken'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
@@ -86,7 +88,59 @@ if (typeof window !== 'undefined') {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function syncExternalAuthState(nextToken: string | null): Promise<void> {
|
||||||
|
const previousToken = authStore.token
|
||||||
|
const previousUser = authStore.user
|
||||||
|
? {
|
||||||
|
id: authStore.user.id,
|
||||||
|
role: authStore.user.role,
|
||||||
|
}
|
||||||
|
: null
|
||||||
|
|
||||||
|
authStore.syncToken()
|
||||||
|
|
||||||
|
if (!nextToken) {
|
||||||
|
if (previousToken || previousUser) {
|
||||||
|
authStore.applyExternalLogout()
|
||||||
|
await router.replace('/')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const identityChanged = hasAuthIdentityChanged(previousToken, nextToken, previousUser)
|
||||||
|
if (!identityChanged && previousUser) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await authStore.fetchCurrentUser()
|
||||||
|
if (!user) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (router.currentRoute.value.path.startsWith('/admin') && user.role !== 'admin') {
|
||||||
|
await router.replace('/dashboard')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAuthStorageChange(event: StorageEvent): void {
|
||||||
|
if (event.key !== 'access_token') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
syncExternalAuthState(event.newValue).catch((err) => log.error('syncExternalAuthState failed', err))
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLocalAuthStateChange(event: Event): void {
|
||||||
|
const authEvent = event as CustomEvent<{ token: string | null }>
|
||||||
|
syncExternalAuthState(authEvent.detail?.token ?? apiClient.getToken()).catch((err) => log.error('syncExternalAuthState failed', err))
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('storage', handleAuthStorageChange)
|
||||||
|
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handleLocalAuthStateChange as (event: Event) => void)
|
||||||
|
}
|
||||||
|
|
||||||
// 延迟检查认证状态,让页面先加载
|
// 延迟检查认证状态,让页面先加载
|
||||||
setTimeout(async () => {
|
setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -97,4 +151,14 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
}, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL)
|
}, AUTH_CONFIG.TOKEN_REFRESH_INTERVAL)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.removeEventListener('storage', handleAuthStorageChange)
|
||||||
|
window.removeEventListener(
|
||||||
|
AUTH_STATE_CHANGE_EVENT,
|
||||||
|
handleLocalAuthStateChange as (event: Event) => void,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import apiClient, { AUTH_STATE_CHANGE_EVENT } from '@/api/client'
|
||||||
|
|
||||||
|
describe('apiClient auth state change event', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
apiClient.clearAuth()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
localStorage.clear()
|
||||||
|
apiClient.clearAuth()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('dispatches a same-tab auth change event when clearing auth', () => {
|
||||||
|
const handler = vi.fn()
|
||||||
|
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
|
||||||
|
|
||||||
|
apiClient.setToken('access-token')
|
||||||
|
apiClient.clearAuth()
|
||||||
|
|
||||||
|
expect(localStorage.getItem('access_token')).toBeNull()
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const event = handler.mock.calls[0][0] as CustomEvent<{ token: string | null }>
|
||||||
|
expect(event.detail).toEqual({ token: null })
|
||||||
|
|
||||||
|
window.removeEventListener(AUTH_STATE_CHANGE_EVENT, handler as EventListener)
|
||||||
|
})
|
||||||
|
})
|
||||||
+4
-312
@@ -86,6 +86,7 @@ export interface UserExport {
|
|||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_api_formats?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
|
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||||
model_capability_settings?: Record<string, Record<string, boolean>>
|
model_capability_settings?: Record<string, Record<string, boolean>>
|
||||||
unlimited?: boolean
|
unlimited?: boolean
|
||||||
wallet?: BillingSummary | null
|
wallet?: BillingSummary | null
|
||||||
@@ -102,7 +103,7 @@ export interface UserApiKeyExport {
|
|||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_api_formats?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
rate_limit?: number | null // null = 无限制
|
rate_limit?: number | null // legacy/null 兼容;1.3+ standalone null = 跟随系统默认
|
||||||
concurrent_limit?: number | null
|
concurrent_limit?: number | null
|
||||||
force_capabilities?: Record<string, boolean>
|
force_capabilities?: Record<string, boolean>
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -349,7 +350,7 @@ export interface AdminApiKey {
|
|||||||
total_requests?: number
|
total_requests?: number
|
||||||
total_tokens?: number
|
total_tokens?: number
|
||||||
total_cost_usd?: number
|
total_cost_usd?: number
|
||||||
rate_limit?: number | null // null = 无限制
|
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||||
allowed_providers?: string[] | null // 允许的提供商列表
|
allowed_providers?: string[] | null // 允许的提供商列表
|
||||||
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
||||||
allowed_models?: string[] | null // 允许的模型列表
|
allowed_models?: string[] | null // 允许的模型列表
|
||||||
@@ -365,7 +366,7 @@ export interface CreateStandaloneApiKeyRequest {
|
|||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_api_formats?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
rate_limit?: number | null // null = 无限制
|
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||||
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
|
expires_at?: string | null // ISO 日期字符串,如 "2025-12-31",null = 永不过期
|
||||||
initial_balance_usd: number | null // 初始余额,null = 无限制
|
initial_balance_usd: number | null // 初始余额,null = 无限制
|
||||||
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
|
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
|
||||||
@@ -379,102 +380,6 @@ export interface AdminApiKeysResponse {
|
|||||||
skip: number
|
skip: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LeaderboardItem {
|
|
||||||
rank: number
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
value: number
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface LeaderboardResponse {
|
|
||||||
items: LeaderboardItem[]
|
|
||||||
total: number
|
|
||||||
metric: string
|
|
||||||
start_date?: string | null
|
|
||||||
end_date?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CostForecastResponse {
|
|
||||||
history: Array<{ date: string; total_cost: number }>
|
|
||||||
forecast: Array<{ date: string; total_cost: number }>
|
|
||||||
slope: number
|
|
||||||
intercept: number
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CostSavingsResponse {
|
|
||||||
cache_read_tokens: number
|
|
||||||
cache_read_cost: number
|
|
||||||
cache_creation_cost: number
|
|
||||||
estimated_full_cost: number
|
|
||||||
cache_savings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuotaUsageProvider {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
quota_usd: number
|
|
||||||
used_usd: number
|
|
||||||
remaining_usd: number
|
|
||||||
usage_percent: number
|
|
||||||
quota_expires_at?: string | null
|
|
||||||
estimated_exhaust_at?: string | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface QuotaUsageResponse {
|
|
||||||
providers: QuotaUsageProvider[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PercentileItem {
|
|
||||||
date: string
|
|
||||||
p50_response_time_ms?: number | null
|
|
||||||
p90_response_time_ms?: number | null
|
|
||||||
p99_response_time_ms?: number | null
|
|
||||||
p50_first_byte_time_ms?: number | null
|
|
||||||
p90_first_byte_time_ms?: number | null
|
|
||||||
p99_first_byte_time_ms?: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorDistributionItem {
|
|
||||||
category: string
|
|
||||||
count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorTrendItem {
|
|
||||||
date: string
|
|
||||||
total: number
|
|
||||||
categories: Record<string, number>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ErrorDistributionResponse {
|
|
||||||
distribution: ErrorDistributionItem[]
|
|
||||||
trend: ErrorTrendItem[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonMetric {
|
|
||||||
total_requests: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
actual_total_cost: number
|
|
||||||
avg_response_time_ms: number
|
|
||||||
error_requests: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ComparisonResponse {
|
|
||||||
current: ComparisonMetric
|
|
||||||
comparison: ComparisonMetric
|
|
||||||
change_percent: Record<string, number | null>
|
|
||||||
current_start: string
|
|
||||||
current_end: string
|
|
||||||
comparison_start: string
|
|
||||||
comparison_end: string
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface ApiKeyToggleResponse {
|
export interface ApiKeyToggleResponse {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
@@ -752,197 +657,6 @@ export const adminApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// Stats / Leaderboards
|
|
||||||
async getLeaderboardUsers(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
metric?: 'requests' | 'tokens' | 'cost'
|
|
||||||
order?: 'asc' | 'desc'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
include_inactive?: boolean
|
|
||||||
exclude_admin?: boolean
|
|
||||||
}): Promise<LeaderboardResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:users', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/users', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLeaderboardApiKeys(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
metric?: 'requests' | 'tokens' | 'cost'
|
|
||||||
order?: 'asc' | 'desc'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
include_inactive?: boolean
|
|
||||||
exclude_admin?: boolean
|
|
||||||
}): Promise<LeaderboardResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:api-keys', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/api-keys', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getLeaderboardModels(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
metric?: 'requests' | 'tokens' | 'cost'
|
|
||||||
order?: 'asc' | 'desc'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
}): Promise<LeaderboardResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:leaderboard:models', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<LeaderboardResponse>('/api/admin/stats/leaderboard/models', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCostForecast(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
days?: number
|
|
||||||
forecast_days?: number
|
|
||||||
}): Promise<CostForecastResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:cost:forecast', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<CostForecastResponse>('/api/admin/stats/cost/forecast', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getCostSavings(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
provider_name?: string
|
|
||||||
model?: string
|
|
||||||
}): Promise<CostSavingsResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:cost:savings', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<CostSavingsResponse>('/api/admin/stats/cost/savings', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getQuotaUsage(): Promise<QuotaUsageResponse> {
|
|
||||||
return cachedRequest(
|
|
||||||
'admin:stats:providers:quota-usage',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<QuotaUsageResponse>('/api/admin/stats/providers/quota-usage')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPercentiles(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}): Promise<PercentileItem[]> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:performance:percentiles', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<PercentileItem[]>('/api/admin/stats/performance/percentiles', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getErrorDistribution(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}): Promise<ErrorDistributionResponse> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:errors:distribution', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ErrorDistributionResponse>('/api/admin/stats/errors/distribution', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getComparison(params: {
|
|
||||||
current_start: string
|
|
||||||
current_end: string
|
|
||||||
comparison_type?: 'period' | 'year'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}): Promise<ComparisonResponse> {
|
|
||||||
const response = await apiClient.get<ComparisonResponse>('/api/admin/stats/comparison', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 数据清空
|
// 数据清空
|
||||||
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
|
purgeConfig: () => purge<{ message: string; deleted: Record<string, number> }>('config'),
|
||||||
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
|
purgeUsers: () => purge<{ message: string; deleted: Record<string, number> }>('users'),
|
||||||
@@ -951,26 +665,4 @@ export const adminApi = {
|
|||||||
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
|
purgeRequestBodies: () => purge<{ message: string; cleaned: Record<string, number> }>('request-bodies'),
|
||||||
purgeStats: () => purge<{ message: string }>('stats'),
|
purgeStats: () => purge<{ message: string }>('stats'),
|
||||||
|
|
||||||
async getTimeSeries(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
user_id?: string
|
|
||||||
model?: string
|
|
||||||
provider_name?: string
|
|
||||||
}): Promise<Array<Record<string, unknown>>> {
|
|
||||||
const cacheKey = buildCacheKey('admin:stats:time-series', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<Array<Record<string, unknown>>>('/api/admin/stats/time-series', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,396 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||||
|
import type { ActivityHeatmap } from '@/types/activity'
|
||||||
|
|
||||||
|
export type AnalyticsScopeKind = 'global' | 'me' | 'user' | 'api_key'
|
||||||
|
export type AnalyticsGranularity = 'hour' | 'day' | 'week' | 'month'
|
||||||
|
export type AnalyticsBreakdownDimension = 'model' | 'provider' | 'api_format' | 'api_key' | 'user'
|
||||||
|
export type AnalyticsBreakdownMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd' | 'actual_total_cost_usd'
|
||||||
|
export type AnalyticsLeaderboardEntity = 'user' | 'api_key'
|
||||||
|
export type AnalyticsLeaderboardMetric = 'requests_total' | 'total_tokens' | 'total_cost_usd'
|
||||||
|
|
||||||
|
export interface AnalyticsScope {
|
||||||
|
kind: AnalyticsScopeKind
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimeRange {
|
||||||
|
start_date?: string
|
||||||
|
end_date?: string
|
||||||
|
preset?: string
|
||||||
|
granularity?: AnalyticsGranularity
|
||||||
|
timezone?: string | null
|
||||||
|
tz_offset_minutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFilters {
|
||||||
|
user_ids?: string[]
|
||||||
|
provider_names?: string[]
|
||||||
|
models?: string[]
|
||||||
|
target_models?: string[]
|
||||||
|
api_key_ids?: string[]
|
||||||
|
api_formats?: string[]
|
||||||
|
request_types?: string[]
|
||||||
|
statuses?: string[]
|
||||||
|
error_categories?: string[]
|
||||||
|
is_stream?: boolean | null
|
||||||
|
has_format_conversion?: boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBaseRequest {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
time_range: AnalyticsTimeRange
|
||||||
|
filters?: AnalyticsFilters
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSummary {
|
||||||
|
requests_total: number
|
||||||
|
requests_success: number
|
||||||
|
requests_error: number
|
||||||
|
requests_stream: number
|
||||||
|
success_rate: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
input_output_total_tokens: number
|
||||||
|
cache_creation_input_tokens: number
|
||||||
|
cache_creation_input_tokens_5m: number
|
||||||
|
cache_creation_input_tokens_1h: number
|
||||||
|
cache_read_input_tokens: number
|
||||||
|
input_context_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
cache_hit_rate: number
|
||||||
|
input_cost_usd: number
|
||||||
|
output_cost_usd: number
|
||||||
|
cache_creation_cost_usd: number
|
||||||
|
cache_creation_cost_usd_5m: number
|
||||||
|
cache_creation_cost_usd_1h: number
|
||||||
|
cache_read_cost_usd: number
|
||||||
|
cache_cost_usd: number
|
||||||
|
request_cost_usd: number
|
||||||
|
total_cost_usd: number
|
||||||
|
actual_total_cost_usd: number
|
||||||
|
actual_cache_cost_usd: number
|
||||||
|
avg_response_time_ms: number
|
||||||
|
avg_first_byte_time_ms: number
|
||||||
|
format_conversion_count: number
|
||||||
|
models_used_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsCompositionSegment {
|
||||||
|
key: string
|
||||||
|
value: number
|
||||||
|
percentage: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsOverviewResponse {
|
||||||
|
query_context: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
time_range: AnalyticsTimeRange
|
||||||
|
}
|
||||||
|
summary: AnalyticsSummary
|
||||||
|
composition: {
|
||||||
|
token_segments: AnalyticsCompositionSegment[]
|
||||||
|
cost_segments: AnalyticsCompositionSegment[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimeseriesBucket extends AnalyticsSummary {
|
||||||
|
bucket_start: string
|
||||||
|
bucket_end: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsTimeseriesResponse {
|
||||||
|
buckets: AnalyticsTimeseriesBucket[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBreakdownRequest extends AnalyticsBaseRequest {
|
||||||
|
dimension: AnalyticsBreakdownDimension
|
||||||
|
metric?: AnalyticsBreakdownMetric
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBreakdownRow extends AnalyticsSummary {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
share_of_total_cost: number
|
||||||
|
share_of_total_tokens: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsBreakdownResponse {
|
||||||
|
dimension: AnalyticsBreakdownDimension
|
||||||
|
metric: AnalyticsBreakdownMetric
|
||||||
|
rows: AnalyticsBreakdownRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRecordsRequest extends AnalyticsBaseRequest {
|
||||||
|
search?: {
|
||||||
|
text?: string | null
|
||||||
|
request_id?: string | null
|
||||||
|
}
|
||||||
|
pagination?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRecord {
|
||||||
|
id: string
|
||||||
|
request_id: string
|
||||||
|
created_at: string | null
|
||||||
|
user_id: string | null
|
||||||
|
username: string | null
|
||||||
|
api_key_id: string | null
|
||||||
|
api_key_name: string | null
|
||||||
|
provider_api_key_name: string | null
|
||||||
|
provider_name: string | null
|
||||||
|
model: string
|
||||||
|
target_model: string | null
|
||||||
|
api_format: string | null
|
||||||
|
request_type: string | null
|
||||||
|
status: string
|
||||||
|
billing_status: string
|
||||||
|
is_stream: boolean
|
||||||
|
has_format_conversion: boolean | null
|
||||||
|
has_fallback?: boolean
|
||||||
|
has_retry?: boolean
|
||||||
|
status_code: number | null
|
||||||
|
error_message: string | null
|
||||||
|
error_category: string | null
|
||||||
|
response_time_ms: number | null
|
||||||
|
first_byte_time_ms: number | null
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
input_output_total_tokens: number
|
||||||
|
cache_creation_input_tokens: number
|
||||||
|
cache_creation_input_tokens_5m: number
|
||||||
|
cache_creation_input_tokens_1h: number
|
||||||
|
cache_read_input_tokens: number
|
||||||
|
input_context_tokens: number
|
||||||
|
total_tokens: number
|
||||||
|
input_cost_usd: number
|
||||||
|
output_cost_usd: number
|
||||||
|
cache_creation_cost_usd: number
|
||||||
|
cache_creation_cost_usd_5m: number
|
||||||
|
cache_creation_cost_usd_1h: number
|
||||||
|
cache_read_cost_usd: number
|
||||||
|
cache_cost_usd: number
|
||||||
|
request_cost_usd: number
|
||||||
|
total_cost_usd: number
|
||||||
|
actual_total_cost_usd: number
|
||||||
|
actual_cache_cost_usd: number
|
||||||
|
rate_multiplier: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsRecordsResponse {
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
records: AnalyticsRecord[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFilterOption {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFilterOptionsResponse {
|
||||||
|
providers: AnalyticsFilterOption[]
|
||||||
|
models: AnalyticsFilterOption[]
|
||||||
|
target_models: AnalyticsFilterOption[]
|
||||||
|
api_formats: AnalyticsFilterOption[]
|
||||||
|
request_types: AnalyticsFilterOption[]
|
||||||
|
error_categories: AnalyticsFilterOption[]
|
||||||
|
statuses: AnalyticsFilterOption[]
|
||||||
|
users?: AnalyticsFilterOption[]
|
||||||
|
api_keys?: AnalyticsFilterOption[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsLeaderboardRequest extends AnalyticsBaseRequest {
|
||||||
|
entity: AnalyticsLeaderboardEntity
|
||||||
|
metric: AnalyticsLeaderboardMetric
|
||||||
|
limit?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsLeaderboardItem {
|
||||||
|
rank: number
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
requests_total: number
|
||||||
|
total_tokens: number
|
||||||
|
total_cost_usd: number
|
||||||
|
actual_total_cost_usd: number
|
||||||
|
metric_value: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsLeaderboardResponse {
|
||||||
|
entity: AnalyticsLeaderboardEntity
|
||||||
|
metric: AnalyticsLeaderboardMetric
|
||||||
|
items: AnalyticsLeaderboardItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPercentilePoint {
|
||||||
|
date: string
|
||||||
|
p50_response_time_ms: number | null
|
||||||
|
p90_response_time_ms: number | null
|
||||||
|
p99_response_time_ms: number | null
|
||||||
|
p50_first_byte_time_ms: number | null
|
||||||
|
p90_first_byte_time_ms: number | null
|
||||||
|
p99_first_byte_time_ms: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsErrorCategory {
|
||||||
|
category: string
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsErrorTrendItem {
|
||||||
|
date: string
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsProviderHealthItem {
|
||||||
|
provider_name: string
|
||||||
|
requests_total: number
|
||||||
|
success_rate: number
|
||||||
|
error_rate: number
|
||||||
|
avg_response_time_ms: number
|
||||||
|
avg_first_byte_time_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsPerformanceResponse {
|
||||||
|
latency: {
|
||||||
|
response_time_ms: {
|
||||||
|
avg: number
|
||||||
|
p50: number | null
|
||||||
|
p90: number | null
|
||||||
|
p99: number | null
|
||||||
|
}
|
||||||
|
first_byte_time_ms: {
|
||||||
|
avg: number
|
||||||
|
p50: number | null
|
||||||
|
p90: number | null
|
||||||
|
p99: number | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
percentiles: AnalyticsPercentilePoint[]
|
||||||
|
errors: {
|
||||||
|
total: number
|
||||||
|
rate: number
|
||||||
|
categories: AnalyticsErrorCategory[]
|
||||||
|
trend: AnalyticsErrorTrendItem[]
|
||||||
|
}
|
||||||
|
provider_health: AnalyticsProviderHealthItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsActiveRequest {
|
||||||
|
id: string
|
||||||
|
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_creation_input_tokens?: number | null
|
||||||
|
cache_read_input_tokens?: number | null
|
||||||
|
cost?: number
|
||||||
|
total_cost_usd?: number
|
||||||
|
actual_cost?: number | null
|
||||||
|
actual_total_cost_usd?: number | null
|
||||||
|
rate_multiplier?: number | null
|
||||||
|
response_time_ms: number | null
|
||||||
|
first_byte_time_ms: number | null
|
||||||
|
provider?: string | null
|
||||||
|
provider_name?: string | null
|
||||||
|
api_key_name?: string | null
|
||||||
|
api_format?: string | null
|
||||||
|
endpoint_api_format?: string | null
|
||||||
|
has_format_conversion?: boolean | null
|
||||||
|
target_model?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsActiveRequestsResponse {
|
||||||
|
requests: AnalyticsActiveRequest[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const analyticsApi = {
|
||||||
|
async getOverview(payload: AnalyticsBaseRequest): Promise<AnalyticsOverviewResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsOverviewResponse>('/api/analytics/overview', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTimeseries(payload: AnalyticsBaseRequest): Promise<AnalyticsTimeseriesResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsTimeseriesResponse>('/api/analytics/timeseries', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getBreakdown(payload: AnalyticsBreakdownRequest): Promise<AnalyticsBreakdownResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsBreakdownResponse>('/api/analytics/breakdown', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getRecords(payload: AnalyticsRecordsRequest): Promise<AnalyticsRecordsResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsRecordsResponse>('/api/analytics/records', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getFilterOptions(payload: AnalyticsBaseRequest): Promise<AnalyticsFilterOptionsResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsFilterOptionsResponse>('/api/analytics/filter-options', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getLeaderboard(payload: AnalyticsLeaderboardRequest): Promise<AnalyticsLeaderboardResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsLeaderboardResponse>('/api/analytics/leaderboard', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getPerformance(payload: AnalyticsBaseRequest): Promise<AnalyticsPerformanceResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsPerformanceResponse>('/api/analytics/performance', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getHeatmap(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
}): Promise<ActivityHeatmap> {
|
||||||
|
const cacheKey = buildCacheKey('analytics:heatmap', payload as Record<string, unknown>)
|
||||||
|
return cachedRequest(
|
||||||
|
cacheKey,
|
||||||
|
async () => {
|
||||||
|
const response = await apiClient.post<ActivityHeatmap>('/api/analytics/heatmap', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
60000,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async getActiveRequests(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
ids?: string[]
|
||||||
|
}): Promise<AnalyticsActiveRequestsResponse> {
|
||||||
|
const response = await apiClient.post<AnalyticsActiveRequestsResponse>('/api/analytics/active-requests', {
|
||||||
|
...payload,
|
||||||
|
ids: payload.ids ?? [],
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async analyzeCacheAffinityTTL(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
hours?: number
|
||||||
|
}): Promise<Record<string, unknown>> {
|
||||||
|
const response = await apiClient.post('/api/analytics/cache-affinity/ttl-analysis', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async analyzeCacheAffinityHit(payload: {
|
||||||
|
scope: AnalyticsScope
|
||||||
|
user_id?: string | null
|
||||||
|
api_key_id?: string | null
|
||||||
|
hours?: number
|
||||||
|
}): Promise<Record<string, unknown>> {
|
||||||
|
const response = await apiClient.post('/api/analytics/cache-affinity/hit-analysis', payload)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -33,6 +33,203 @@ export interface AuditFilters {
|
|||||||
offset?: number
|
offset?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MonitoringMetricStatus = 'ok' | 'warning' | 'danger' | 'degraded' | 'error' | 'unknown'
|
||||||
|
|
||||||
|
export interface MonitoringCpuMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
usage_percent: number | null
|
||||||
|
load_percent: number | null
|
||||||
|
core_count: number
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringMemoryMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
used_percent: number | null
|
||||||
|
used_bytes: number | null
|
||||||
|
available_bytes: number | null
|
||||||
|
total_bytes: number | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringServiceMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
latency_ms: number | null
|
||||||
|
memory_status?: MonitoringMetricStatus
|
||||||
|
memory_label?: string | null
|
||||||
|
used_memory_bytes?: number | null
|
||||||
|
peak_memory_bytes?: number | null
|
||||||
|
maxmemory_bytes?: number | null
|
||||||
|
memory_ceiling_bytes?: number | null
|
||||||
|
memory_source?: 'configured' | 'maxmemory' | 'system' | 'unknown'
|
||||||
|
available_memory_bytes?: number | null
|
||||||
|
memory_percent?: number | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringPostgresMetric {
|
||||||
|
status: MonitoringMetricStatus
|
||||||
|
label: string
|
||||||
|
usage_percent: number | null
|
||||||
|
pool_usage_percent?: number | null
|
||||||
|
checked_out: number
|
||||||
|
pool_size: number
|
||||||
|
overflow: number
|
||||||
|
max_capacity: number
|
||||||
|
pool_timeout: number
|
||||||
|
server_connections?: number | null
|
||||||
|
server_max_connections?: number | null
|
||||||
|
server_usage_percent?: number | null
|
||||||
|
storage_status?: MonitoringMetricStatus
|
||||||
|
storage_label?: string | null
|
||||||
|
storage_total_bytes?: number | null
|
||||||
|
storage_free_bytes?: number | null
|
||||||
|
storage_free_percent?: number | null
|
||||||
|
database_size_bytes?: number | null
|
||||||
|
storage_message?: string | null
|
||||||
|
message?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MonitoringSystemStatus {
|
||||||
|
timestamp: string
|
||||||
|
users: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
providers: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
api_keys: {
|
||||||
|
total: number
|
||||||
|
active: number
|
||||||
|
}
|
||||||
|
today_stats: {
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost_usd: number
|
||||||
|
}
|
||||||
|
recent_errors: number
|
||||||
|
system_metrics?: {
|
||||||
|
cpu: MonitoringCpuMetric
|
||||||
|
memory: MonitoringMemoryMetric
|
||||||
|
redis: MonitoringServiceMetric
|
||||||
|
postgres: MonitoringPostgresMetric
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumericLike(value: unknown): number {
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().replace(/[$,\s]/g, '')
|
||||||
|
if (!normalized) return 0
|
||||||
|
const parsed = Number(normalized)
|
||||||
|
return Number.isFinite(parsed) ? parsed : 0
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNullableNumericLike(value: unknown): number | null {
|
||||||
|
if (value == null) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const normalized = value.trim().replace(/[$,\s]/g, '')
|
||||||
|
if (!normalized) return null
|
||||||
|
const parsed = Number(normalized)
|
||||||
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonitoringSystemStatus(data: MonitoringSystemStatus): MonitoringSystemStatus {
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
users: {
|
||||||
|
total: parseNumericLike(data.users?.total),
|
||||||
|
active: parseNumericLike(data.users?.active),
|
||||||
|
},
|
||||||
|
providers: {
|
||||||
|
total: parseNumericLike(data.providers?.total),
|
||||||
|
active: parseNumericLike(data.providers?.active),
|
||||||
|
},
|
||||||
|
api_keys: {
|
||||||
|
total: parseNumericLike(data.api_keys?.total),
|
||||||
|
active: parseNumericLike(data.api_keys?.active),
|
||||||
|
},
|
||||||
|
today_stats: {
|
||||||
|
requests: parseNumericLike(data.today_stats?.requests),
|
||||||
|
tokens: parseNumericLike(data.today_stats?.tokens),
|
||||||
|
cost_usd: parseNumericLike(data.today_stats?.cost_usd),
|
||||||
|
},
|
||||||
|
recent_errors: parseNumericLike(data.recent_errors),
|
||||||
|
system_metrics: data.system_metrics ? {
|
||||||
|
cpu: {
|
||||||
|
status: data.system_metrics.cpu?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.cpu?.label ?? '未知',
|
||||||
|
usage_percent: parseNullableNumericLike(data.system_metrics.cpu?.usage_percent),
|
||||||
|
load_percent: parseNullableNumericLike(data.system_metrics.cpu?.load_percent),
|
||||||
|
core_count: parseNumericLike(data.system_metrics.cpu?.core_count),
|
||||||
|
message: data.system_metrics.cpu?.message ?? null,
|
||||||
|
},
|
||||||
|
memory: {
|
||||||
|
status: data.system_metrics.memory?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.memory?.label ?? '未知',
|
||||||
|
used_percent: parseNullableNumericLike(data.system_metrics.memory?.used_percent),
|
||||||
|
used_bytes: parseNullableNumericLike(data.system_metrics.memory?.used_bytes),
|
||||||
|
available_bytes: parseNullableNumericLike(data.system_metrics.memory?.available_bytes),
|
||||||
|
total_bytes: parseNullableNumericLike(data.system_metrics.memory?.total_bytes),
|
||||||
|
message: data.system_metrics.memory?.message ?? null,
|
||||||
|
},
|
||||||
|
redis: {
|
||||||
|
status: data.system_metrics.redis?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.redis?.label ?? '未知',
|
||||||
|
latency_ms: parseNullableNumericLike(data.system_metrics.redis?.latency_ms),
|
||||||
|
memory_status: data.system_metrics.redis?.memory_status ?? 'unknown',
|
||||||
|
memory_label: data.system_metrics.redis?.memory_label ?? '未知',
|
||||||
|
used_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.used_memory_bytes),
|
||||||
|
peak_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.peak_memory_bytes),
|
||||||
|
maxmemory_bytes: parseNullableNumericLike(data.system_metrics.redis?.maxmemory_bytes),
|
||||||
|
memory_ceiling_bytes: parseNullableNumericLike(data.system_metrics.redis?.memory_ceiling_bytes),
|
||||||
|
memory_source: (data.system_metrics.redis?.memory_source as MonitoringServiceMetric['memory_source']) ?? 'unknown',
|
||||||
|
available_memory_bytes: parseNullableNumericLike(data.system_metrics.redis?.available_memory_bytes),
|
||||||
|
memory_percent: parseNullableNumericLike(data.system_metrics.redis?.memory_percent),
|
||||||
|
message: data.system_metrics.redis?.message ?? null,
|
||||||
|
},
|
||||||
|
postgres: {
|
||||||
|
status: data.system_metrics.postgres?.status ?? 'unknown',
|
||||||
|
label: data.system_metrics.postgres?.label ?? '未知',
|
||||||
|
usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.usage_percent),
|
||||||
|
pool_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.pool_usage_percent),
|
||||||
|
checked_out: parseNumericLike(data.system_metrics.postgres?.checked_out),
|
||||||
|
pool_size: parseNumericLike(data.system_metrics.postgres?.pool_size),
|
||||||
|
overflow: parseNumericLike(data.system_metrics.postgres?.overflow),
|
||||||
|
max_capacity: parseNumericLike(data.system_metrics.postgres?.max_capacity),
|
||||||
|
pool_timeout: parseNumericLike(data.system_metrics.postgres?.pool_timeout),
|
||||||
|
server_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_connections),
|
||||||
|
server_max_connections: parseNullableNumericLike(data.system_metrics.postgres?.server_max_connections),
|
||||||
|
server_usage_percent: parseNullableNumericLike(data.system_metrics.postgres?.server_usage_percent),
|
||||||
|
storage_status: data.system_metrics.postgres?.storage_status ?? 'unknown',
|
||||||
|
storage_label: data.system_metrics.postgres?.storage_label ?? '未知',
|
||||||
|
storage_total_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_total_bytes),
|
||||||
|
storage_free_bytes: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_bytes),
|
||||||
|
storage_free_percent: parseNullableNumericLike(data.system_metrics.postgres?.storage_free_percent),
|
||||||
|
database_size_bytes: parseNullableNumericLike(data.system_metrics.postgres?.database_size_bytes),
|
||||||
|
storage_message: data.system_metrics.postgres?.storage_message ?? null,
|
||||||
|
message: data.system_metrics.postgres?.message ?? null,
|
||||||
|
},
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
|
function normalizeAuditResponse(data: Record<string, unknown>): AuditLogsResponse {
|
||||||
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
|
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
|
||||||
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
|
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
|
||||||
@@ -78,6 +275,11 @@ export const auditApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getSystemStatus(): Promise<MonitoringSystemStatus> {
|
||||||
|
const response = await apiClient.get<MonitoringSystemStatus>('/api/admin/monitoring/system-status')
|
||||||
|
return normalizeMonitoringSystemStatus(response.data)
|
||||||
|
},
|
||||||
|
|
||||||
// 分析用户行为 (管理员)
|
// 分析用户行为 (管理员)
|
||||||
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
|
async analyzeUserBehavior(userId: number, days: number = 7): Promise<{
|
||||||
analysis: Record<string, unknown>
|
analysis: Record<string, unknown>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ export interface LoginRequest {
|
|||||||
|
|
||||||
export interface LoginResponse {
|
export interface LoginResponse {
|
||||||
access_token: string
|
access_token: string
|
||||||
refresh_token?: string
|
|
||||||
token_type?: string
|
token_type?: string
|
||||||
expires_in?: number
|
expires_in?: number
|
||||||
user_id?: string // UUID
|
user_id?: string // UUID
|
||||||
@@ -25,13 +24,6 @@ export interface UserPreferences {
|
|||||||
[key: string]: unknown // 允许扩展其他偏好设置
|
[key: string]: unknown // 允许扩展其他偏好设置
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UserStats {
|
|
||||||
total_requests?: number
|
|
||||||
total_cost?: number
|
|
||||||
last_request_at?: string
|
|
||||||
[key: string]: unknown // 允许扩展其他统计数据
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendVerificationCodeRequest {
|
export interface SendVerificationCodeRequest {
|
||||||
email: string
|
email: string
|
||||||
}
|
}
|
||||||
@@ -81,6 +73,7 @@ export interface RegistrationSettingsResponse {
|
|||||||
enable_registration: boolean
|
enable_registration: boolean
|
||||||
require_email_verification: boolean
|
require_email_verification: boolean
|
||||||
email_configured: boolean
|
email_configured: boolean
|
||||||
|
password_policy_level: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuthSettingsResponse {
|
export interface AuthSettingsResponse {
|
||||||
@@ -113,23 +106,15 @@ export interface User {
|
|||||||
role: string // 'admin' or 'user'
|
role: string // 'admin' or 'user'
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
billing?: BillingSummary
|
billing?: BillingSummary
|
||||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
|
||||||
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
|
|
||||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
|
||||||
created_at: string
|
created_at: string
|
||||||
last_login_at?: string
|
last_login_at?: string
|
||||||
preferences?: UserPreferences
|
preferences?: UserPreferences
|
||||||
stats?: UserStats
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
export const authApi = {
|
||||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||||
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
|
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
|
||||||
apiClient.setToken(response.data.access_token)
|
apiClient.setToken(response.data.access_token)
|
||||||
// 后端暂时没有返回 refresh_token
|
|
||||||
if (response.data.refresh_token) {
|
|
||||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
|
||||||
}
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -151,14 +136,9 @@ export const authApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async refreshToken(refreshToken: string): Promise<LoginResponse> {
|
async refreshToken(): Promise<LoginResponse> {
|
||||||
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {
|
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {})
|
||||||
refresh_token: refreshToken
|
|
||||||
})
|
|
||||||
apiClient.setToken(response.data.access_token)
|
apiClient.setToken(response.data.access_token)
|
||||||
if (response.data.refresh_token) {
|
|
||||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
|
||||||
}
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
|
|||||||
api_key_id?: string
|
api_key_id?: string
|
||||||
hours?: number
|
hours?: number
|
||||||
}): Promise<TTLAnalysisResponse> {
|
}): Promise<TTLAnalysisResponse> {
|
||||||
const response = await api.get('/api/admin/usage/cache-affinity/ttl-analysis', { params })
|
const response = await api.post('/api/analytics/cache-affinity/ttl-analysis', {
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
user_id: params?.user_id ?? null,
|
||||||
|
api_key_id: params?.api_key_id ?? null,
|
||||||
|
hours: params?.hours,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
|
|||||||
api_key_id?: string
|
api_key_id?: string
|
||||||
hours?: number
|
hours?: number
|
||||||
}): Promise<CacheHitAnalysisResponse> {
|
}): Promise<CacheHitAnalysisResponse> {
|
||||||
const response = await api.get('/api/admin/usage/cache-affinity/hit-analysis', { params })
|
const response = await api.post('/api/analytics/cache-affinity/hit-analysis', {
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
user_id: params?.user_id ?? null,
|
||||||
|
api_key_id: params?.api_key_id ?? null,
|
||||||
|
hours: params?.hours,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
|
|||||||
return cachedRequest(
|
return cachedRequest(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
async () => {
|
||||||
const response = await api.get('/api/admin/usage/cache-affinity/interval-timeline', { params })
|
const response = await api.post('/api/analytics/interval-timeline', {
|
||||||
|
scope: params?.user_id ? { kind: 'user', user_id: params.user_id } : { kind: 'global' },
|
||||||
|
user_id: params?.user_id ?? null,
|
||||||
|
hours: params?.hours,
|
||||||
|
limit: params?.limit,
|
||||||
|
include_user_info: params?.include_user_info ?? false,
|
||||||
|
})
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
30000
|
30000
|
||||||
|
|||||||
+87
-48
@@ -3,10 +3,13 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosReq
|
|||||||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||||||
import { isDemoMode } from '@/config/demo'
|
import { isDemoMode } from '@/config/demo'
|
||||||
import { handleMockRequest, setMockUserToken } from '@/mocks'
|
import { handleMockRequest, setMockUserToken } from '@/mocks'
|
||||||
|
import { getClientDeviceId } from '@/utils/deviceId'
|
||||||
|
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
|
||||||
import { log } from '@/utils/logger'
|
import { log } from '@/utils/logger'
|
||||||
|
|
||||||
// 在开发环境下使用代理,生产环境使用环境变量
|
// 在开发环境下使用代理,生产环境使用环境变量
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
const API_BASE_URL = import.meta.env.VITE_API_URL || ''
|
||||||
|
export const AUTH_STATE_CHANGE_EVENT = 'aether-auth-state-change'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断请求是否为公共端点
|
* 判断请求是否为公共端点
|
||||||
@@ -31,17 +34,15 @@ function isAuthRequest(url?: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 判断是否为可刷新的认证错误
|
* 判断 403 错误是否表示用户账号级别的问题(需要清除认证并跳转)
|
||||||
*/
|
*/
|
||||||
function isRefreshableAuthError(errorDetail: string): boolean {
|
function isAccountLevelForbidden(status: number, errorDetail: string): boolean {
|
||||||
const nonRefreshableErrors = [
|
if (status !== 403) return false
|
||||||
|
const accountErrors = [
|
||||||
'用户不存在或已禁用',
|
'用户不存在或已禁用',
|
||||||
'需要管理员权限',
|
|
||||||
'权限不足',
|
|
||||||
'用户已禁用',
|
'用户已禁用',
|
||||||
]
|
]
|
||||||
|
return accountErrors.some((msg) => errorDetail.includes(msg))
|
||||||
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -83,12 +84,21 @@ class ApiClient {
|
|||||||
private client: AxiosInstance
|
private client: AxiosInstance
|
||||||
private token: string | null = null
|
private token: string | null = null
|
||||||
private isRefreshing = false
|
private isRefreshing = false
|
||||||
private refreshPromise: Promise<AxiosResponse> | null = null
|
private refreshPromise: Promise<string> | null = null
|
||||||
|
private readonly refreshCoordinator = new CrossTabRefreshCoordinator()
|
||||||
|
|
||||||
|
private readonly onStorageSync = (event: StorageEvent): void => {
|
||||||
|
if (event.key !== 'access_token') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.syncTokenState(event.newValue)
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.client = axios.create({
|
this.client = axios.create({
|
||||||
baseURL: API_BASE_URL,
|
baseURL: API_BASE_URL,
|
||||||
timeout: NETWORK_CONFIG.API_TIMEOUT,
|
timeout: NETWORK_CONFIG.API_TIMEOUT,
|
||||||
|
withCredentials: true,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
@@ -99,6 +109,7 @@ class ApiClient {
|
|||||||
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
|
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
|
||||||
|
|
||||||
this.setupInterceptors()
|
this.setupInterceptors()
|
||||||
|
this.setupCrossTabAuthSync()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -108,6 +119,10 @@ class ApiClient {
|
|||||||
// 请求拦截器 - 仅处理认证
|
// 请求拦截器 - 仅处理认证
|
||||||
this.client.interceptors.request.use(
|
this.client.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
|
if (config.url?.includes('/api/')) {
|
||||||
|
config.headers['X-Client-Device-Id'] = getClientDeviceId()
|
||||||
|
}
|
||||||
|
|
||||||
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
||||||
config.url?.includes('/api/')
|
config.url?.includes('/api/')
|
||||||
|
|
||||||
@@ -129,6 +144,23 @@ class ApiClient {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setupCrossTabAuthSync(): void {
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('storage', this.onStorageSync)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitAuthStateChange(token: string | null): void {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent<{ token: string | null }>(AUTH_STATE_CHANGE_EVENT, {
|
||||||
|
detail: { token },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理响应错误
|
* 处理响应错误
|
||||||
*/
|
*/
|
||||||
@@ -155,8 +187,22 @@ class ApiClient {
|
|||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const status = error.response?.status ?? 0
|
||||||
|
|
||||||
|
// 处理 403 用户账号级别错误(被禁用/删除)
|
||||||
|
if (status === 403) {
|
||||||
|
const rawDetail = (error.response?.data as Record<string, unknown>)?.detail
|
||||||
|
const errorDetail = typeof rawDetail === 'string' ? rawDetail : ''
|
||||||
|
if (isAccountLevelForbidden(status, errorDetail)) {
|
||||||
|
log.info('User account issue detected, clearing auth', { errorDetail })
|
||||||
|
this.clearAuth()
|
||||||
|
window.location.href = '/'
|
||||||
|
return Promise.reject(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 处理401错误
|
// 处理401错误
|
||||||
if (error.response?.status === 401) {
|
if (status === 401) {
|
||||||
return this.handle401Error(error, originalRequest)
|
return this.handle401Error(error, originalRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,25 +223,7 @@ class ApiClient {
|
|||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorDetail = (error.response?.data as Record<string, unknown>)?.detail as string || ''
|
log.debug('Got 401 error, attempting token refresh')
|
||||||
log.debug('Got 401 error, attempting token refresh', { errorDetail })
|
|
||||||
|
|
||||||
// 检查是否为业务相关的401错误(用户被禁用/删除等)
|
|
||||||
if (!isRefreshableAuthError(errorDetail)) {
|
|
||||||
log.info('User account issue detected, logging out and redirecting to home', { errorDetail })
|
|
||||||
this.clearAuth()
|
|
||||||
// 跳转到首页
|
|
||||||
window.location.href = '/'
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取refresh token
|
|
||||||
const refreshToken = localStorage.getItem('refresh_token')
|
|
||||||
if (!refreshToken) {
|
|
||||||
log.info('No refresh token available, clearing invalid token')
|
|
||||||
this.clearAuth()
|
|
||||||
return Promise.reject(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记为已重试
|
// 标记为已重试
|
||||||
originalRequest._retry = true
|
originalRequest._retry = true
|
||||||
@@ -210,8 +238,8 @@ class ApiClient {
|
|||||||
// 如果正在刷新,等待刷新完成
|
// 如果正在刷新,等待刷新完成
|
||||||
if (this.isRefreshing) {
|
if (this.isRefreshing) {
|
||||||
try {
|
try {
|
||||||
await this.refreshPromise
|
const accessToken = await this.refreshPromise
|
||||||
originalRequest.headers.Authorization = `Bearer ${this.getToken()}`
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||||
return this.client.request(originalRequest)
|
return this.client.request(originalRequest)
|
||||||
} catch {
|
} catch {
|
||||||
return Promise.reject(error)
|
return Promise.reject(error)
|
||||||
@@ -219,29 +247,27 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 开始刷新token
|
// 开始刷新token
|
||||||
return this.refreshTokenAndRetry(refreshToken, originalRequest, error)
|
return this.refreshTokenAndRetry(originalRequest, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 刷新token并重试原始请求
|
* 刷新token并重试原始请求
|
||||||
*/
|
*/
|
||||||
private async refreshTokenAndRetry(
|
private async refreshTokenAndRetry(
|
||||||
refreshToken: string,
|
|
||||||
originalRequest: InternalAxiosRequestConfig,
|
originalRequest: InternalAxiosRequestConfig,
|
||||||
originalError: import('axios').AxiosError
|
originalError: import('axios').AxiosError
|
||||||
): Promise<AxiosResponse> {
|
): Promise<AxiosResponse> {
|
||||||
this.isRefreshing = true
|
this.isRefreshing = true
|
||||||
this.refreshPromise = this.refreshToken(refreshToken)
|
this.refreshPromise = this.coordinatedRefresh()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await this.refreshPromise
|
const accessToken = await this.refreshPromise
|
||||||
this.setToken(response.data.access_token)
|
this.setToken(accessToken)
|
||||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
|
||||||
this.isRefreshing = false
|
this.isRefreshing = false
|
||||||
this.refreshPromise = null
|
this.refreshPromise = null
|
||||||
|
|
||||||
// 重试原始请求
|
// 重试原始请求
|
||||||
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||||
return this.client.request(originalRequest)
|
return this.client.request(originalRequest)
|
||||||
} catch (refreshError: unknown) {
|
} catch (refreshError: unknown) {
|
||||||
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
|
log.error('Token refresh failed', refreshError instanceof Error ? refreshError.message : String(refreshError))
|
||||||
@@ -252,15 +278,29 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setToken(token: string): void {
|
private async coordinatedRefresh(): Promise<string> {
|
||||||
|
return this.refreshCoordinator.run(async () => {
|
||||||
|
const response = await this.refreshToken()
|
||||||
|
const accessToken = response.data.access_token
|
||||||
|
if (!accessToken) {
|
||||||
|
throw new Error('Refresh response missing access token')
|
||||||
|
}
|
||||||
|
return accessToken
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private syncTokenState(token: string | null): void {
|
||||||
this.token = token
|
this.token = token
|
||||||
localStorage.setItem('access_token', token)
|
|
||||||
// 同步到 mock handler
|
|
||||||
if (isDemoMode()) {
|
if (isDemoMode()) {
|
||||||
setMockUserToken(token)
|
setMockUserToken(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setToken(token: string): void {
|
||||||
|
this.syncTokenState(token)
|
||||||
|
localStorage.setItem('access_token', token)
|
||||||
|
}
|
||||||
|
|
||||||
getToken(): string | null {
|
getToken(): string | null {
|
||||||
if (!this.token) {
|
if (!this.token) {
|
||||||
this.token = localStorage.getItem('access_token')
|
this.token = localStorage.getItem('access_token')
|
||||||
@@ -273,18 +313,17 @@ class ApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearAuth(): void {
|
clearAuth(): void {
|
||||||
this.token = null
|
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
|
||||||
|
this.syncTokenState(null)
|
||||||
localStorage.removeItem('access_token')
|
localStorage.removeItem('access_token')
|
||||||
localStorage.removeItem('refresh_token')
|
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
|
||||||
// 同步清除 mock token
|
if (hadAuth) {
|
||||||
if (isDemoMode()) {
|
this.emitAuthStateChange(null)
|
||||||
setMockUserToken(null)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async refreshToken(refreshToken: string): Promise<AxiosResponse> {
|
async refreshToken(): Promise<AxiosResponse> {
|
||||||
// refreshToken 会通过 adapter 处理 Demo 模式
|
return this.client.post('/api/auth/refresh', {})
|
||||||
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理
|
// 以下方法直接委托给 axios client,Demo 模式由 adapter 统一处理
|
||||||
|
|||||||
@@ -1,370 +0,0 @@
|
|||||||
import apiClient from './client'
|
|
||||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
|
||||||
|
|
||||||
export interface DashboardStat {
|
|
||||||
name: string
|
|
||||||
value: string
|
|
||||||
subValue?: string
|
|
||||||
change?: string
|
|
||||||
changeType?: 'increase' | 'decrease' | 'neutral'
|
|
||||||
extraBadge?: string
|
|
||||||
icon: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentRequest {
|
|
||||||
id: string // UUID
|
|
||||||
user: string
|
|
||||||
model: string
|
|
||||||
tokens: number
|
|
||||||
time: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderStatus {
|
|
||||||
name: string
|
|
||||||
status: 'active' | 'inactive'
|
|
||||||
requests: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 系统健康指标(管理员专用)
|
|
||||||
export interface SystemHealth {
|
|
||||||
avg_response_time: number
|
|
||||||
error_rate: number
|
|
||||||
error_requests: number
|
|
||||||
fallback_count: number
|
|
||||||
total_requests: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 成本统计(管理员专用)
|
|
||||||
export interface CostStats {
|
|
||||||
total_cost: number
|
|
||||||
total_actual_cost: number
|
|
||||||
cost_savings: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 缓存统计
|
|
||||||
export interface CacheStats {
|
|
||||||
cache_creation_tokens: number
|
|
||||||
cache_read_tokens: number
|
|
||||||
cache_creation_cost?: number
|
|
||||||
cache_read_cost?: number
|
|
||||||
cache_hit_rate?: number
|
|
||||||
total_cache_tokens: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// 用户统计(管理员专用)
|
|
||||||
export interface UserStats {
|
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token 详细分类
|
|
||||||
export interface TokenBreakdown {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
cache_creation: number
|
|
||||||
cache_read: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DashboardStatsResponse {
|
|
||||||
stats: DashboardStat[]
|
|
||||||
today?: {
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
actual_cost?: number
|
|
||||||
cache_creation_tokens?: number
|
|
||||||
cache_read_tokens?: number
|
|
||||||
}
|
|
||||||
api_keys?: {
|
|
||||||
total: number
|
|
||||||
active: number
|
|
||||||
}
|
|
||||||
tokens?: {
|
|
||||||
month: number
|
|
||||||
}
|
|
||||||
// 管理员专用字段
|
|
||||||
system_health?: SystemHealth
|
|
||||||
cost_stats?: CostStats
|
|
||||||
cache_stats?: CacheStats
|
|
||||||
users?: UserStats
|
|
||||||
token_breakdown?: TokenBreakdown
|
|
||||||
// 普通用户专用字段
|
|
||||||
monthly_cost?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RecentRequestsResponse {
|
|
||||||
requests: RecentRequest[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderStatusResponse {
|
|
||||||
providers: ProviderStatus[]
|
|
||||||
}
|
|
||||||
|
|
||||||
// 视频/图像/音频计费信息
|
|
||||||
export interface VideoBilling {
|
|
||||||
task_type: 'video' | 'image' | 'audio'
|
|
||||||
duration_seconds?: number // 视频时长(秒)
|
|
||||||
resolution?: string // 分辨率
|
|
||||||
video_price_per_second?: number // 每秒单价
|
|
||||||
video_cost?: number // 视频费用
|
|
||||||
cost?: number // 总费用
|
|
||||||
rule_name?: string // 计费规则名称
|
|
||||||
expression?: string // 计费公式
|
|
||||||
status?: string // 计费状态
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface RequestDetail {
|
|
||||||
id: string // UUID
|
|
||||||
request_id: string
|
|
||||||
user: {
|
|
||||||
id: string // UUID
|
|
||||||
username: string
|
|
||||||
email: string
|
|
||||||
}
|
|
||||||
api_key: {
|
|
||||||
id: string // UUID
|
|
||||||
name: string
|
|
||||||
display: string
|
|
||||||
}
|
|
||||||
provider: string
|
|
||||||
api_format?: string
|
|
||||||
model: string
|
|
||||||
target_model?: string | null // 映射后的目标模型名
|
|
||||||
tokens: {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
cost: {
|
|
||||||
input: number
|
|
||||||
output: number
|
|
||||||
total: number
|
|
||||||
}
|
|
||||||
// Additional token fields
|
|
||||||
input_tokens?: number
|
|
||||||
output_tokens?: number
|
|
||||||
total_tokens?: number
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_creation_input_tokens_5m?: number
|
|
||||||
cache_creation_input_tokens_1h?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
// Additional cost fields
|
|
||||||
input_cost?: number
|
|
||||||
output_cost?: number
|
|
||||||
total_cost?: number
|
|
||||||
cache_creation_cost?: number
|
|
||||||
cache_read_cost?: number
|
|
||||||
request_cost?: number // 按次计费费用
|
|
||||||
// Historical pricing fields (per 1M tokens)
|
|
||||||
input_price_per_1m?: number
|
|
||||||
output_price_per_1m?: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
price_per_request?: number // 按次计费价格
|
|
||||||
request_type: string
|
|
||||||
is_stream: boolean
|
|
||||||
status_code: number
|
|
||||||
status?: string // pending, streaming, completed, failed, cancelled
|
|
||||||
error_message?: string
|
|
||||||
response_time_ms: number
|
|
||||||
created_at: string
|
|
||||||
request_headers?: Record<string, unknown>
|
|
||||||
request_body?: Record<string, unknown>
|
|
||||||
provider_request_headers?: Record<string, unknown>
|
|
||||||
provider_request_body?: Record<string, unknown>
|
|
||||||
response_headers?: Record<string, unknown>
|
|
||||||
client_response_headers?: Record<string, unknown>
|
|
||||||
response_body?: Record<string, unknown>
|
|
||||||
client_response_body?: Record<string, unknown>
|
|
||||||
has_request_body?: boolean
|
|
||||||
has_provider_request_body?: boolean
|
|
||||||
has_response_body?: boolean
|
|
||||||
has_client_response_body?: boolean
|
|
||||||
metadata?: Record<string, unknown>
|
|
||||||
// 阶梯计费信息
|
|
||||||
tiered_pricing?: {
|
|
||||||
total_input_context: number // 总输入上下文 (input + cache_read)
|
|
||||||
tier_index: number // 命中的阶梯索引 (0-based)
|
|
||||||
tier_count: number // 阶梯总数
|
|
||||||
source?: 'provider' | 'global' // 定价来源: 提供商或全局
|
|
||||||
current_tier: { // 当前命中的阶梯配置
|
|
||||||
up_to?: number | null
|
|
||||||
input_price_per_1m: number
|
|
||||||
output_price_per_1m: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
cache_ttl_pricing?: Array<{
|
|
||||||
ttl_minutes: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
}>
|
|
||||||
}
|
|
||||||
tiers: Array<{ // 完整阶梯配置列表
|
|
||||||
up_to?: number | null
|
|
||||||
input_price_per_1m: number
|
|
||||||
output_price_per_1m: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
cache_ttl_pricing?: Array<{
|
|
||||||
ttl_minutes: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
}>
|
|
||||||
}>
|
|
||||||
} | null
|
|
||||||
// 视频/图像/音频计费信息
|
|
||||||
video_billing?: VideoBilling | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CurlData {
|
|
||||||
url: string
|
|
||||||
method: string
|
|
||||||
headers: Record<string, string>
|
|
||||||
body: Record<string, unknown>
|
|
||||||
curl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplayRequest {
|
|
||||||
provider_id?: string
|
|
||||||
endpoint_id?: string
|
|
||||||
api_key_id?: string
|
|
||||||
body_override?: Record<string, unknown>
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReplayResponse {
|
|
||||||
url: string
|
|
||||||
provider: string
|
|
||||||
status_code: number
|
|
||||||
response_headers: Record<string, string>
|
|
||||||
response_body: Record<string, unknown>
|
|
||||||
response_time_ms: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelBreakdown {
|
|
||||||
model: string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ModelSummary {
|
|
||||||
model: string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
avg_response_time: number
|
|
||||||
cost_per_request: number
|
|
||||||
tokens_per_request: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProviderSummary {
|
|
||||||
provider: string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyStat {
|
|
||||||
date: string // ISO date string
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
avg_response_time: number // in seconds
|
|
||||||
unique_models: number
|
|
||||||
unique_providers?: number // 仅管理员返回
|
|
||||||
model_breakdown: ModelBreakdown[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DailyStatsResponse {
|
|
||||||
daily_stats: DailyStat[]
|
|
||||||
model_summary: ModelSummary[]
|
|
||||||
provider_summary?: ProviderSummary[] // 仅管理员返回
|
|
||||||
period: {
|
|
||||||
start_date: string
|
|
||||||
end_date: string
|
|
||||||
days: number
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TimeRangeParams {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const dashboardApi = {
|
|
||||||
// 获取仪表盘统计数据
|
|
||||||
async getStats(params?: TimeRangeParams): Promise<DashboardStatsResponse> {
|
|
||||||
const cacheKey = buildCacheKey('dashboard:stats', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<DashboardStatsResponse>('/api/dashboard/stats', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
10 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取最近的请求记录
|
|
||||||
async getRecentRequests(limit: number = 10): Promise<RecentRequest[]> {
|
|
||||||
const response = await apiClient.get<RecentRequestsResponse>('/api/dashboard/recent-requests', {
|
|
||||||
params: { limit }
|
|
||||||
})
|
|
||||||
return response.data.requests
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取提供商状态
|
|
||||||
async getProviderStatus(): Promise<ProviderStatus[]> {
|
|
||||||
return cachedRequest(
|
|
||||||
'dashboard:provider-status',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ProviderStatusResponse>('/api/dashboard/provider-status')
|
|
||||||
return response.data.providers
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取请求详情
|
|
||||||
// NOTE: This method now calls the new RESTful API at /api/admin/usage/{id}
|
|
||||||
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
|
|
||||||
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
|
||||||
params: { include_bodies: options.includeBodies ?? true },
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取每日统计数据
|
|
||||||
async getDailyStats(params?: TimeRangeParams & { days?: number }): Promise<DailyStatsResponse> {
|
|
||||||
const cacheKey = buildCacheKey('dashboard:daily-stats', params)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<DailyStatsResponse>('/api/dashboard/daily-stats', {
|
|
||||||
params
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
20 * 1000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取 cURL 命令数据(含明文 API Key)
|
|
||||||
async getCurlData(requestId: string): Promise<CurlData> {
|
|
||||||
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 回放请求到提供商
|
|
||||||
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
|
|
||||||
const response = await apiClient.post<ReplayResponse>(
|
|
||||||
`/api/admin/usage/${requestId}/replay`,
|
|
||||||
params || {}
|
|
||||||
)
|
|
||||||
return response.data
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,34 @@ import type { EndpointAPIKey, AllowedModels } from './types'
|
|||||||
// Re-export types for convenience
|
// Re-export types for convenience
|
||||||
export type { EndpointAPIKey, AllowedModels }
|
export type { EndpointAPIKey, AllowedModels }
|
||||||
|
|
||||||
|
export interface GroupedFormatKey {
|
||||||
|
id: string
|
||||||
|
provider_id: string
|
||||||
|
name: string
|
||||||
|
auth_type?: string
|
||||||
|
api_key_masked: string
|
||||||
|
internal_priority: number
|
||||||
|
global_priority_by_format: Record<string, number> | null
|
||||||
|
format_priority: number | null
|
||||||
|
rate_multipliers: Record<string, number> | null
|
||||||
|
is_active: boolean
|
||||||
|
provider_active: boolean
|
||||||
|
pool_enabled: boolean
|
||||||
|
circuit_breaker_open: boolean
|
||||||
|
provider_name: string
|
||||||
|
api_format: string
|
||||||
|
api_formats: string[]
|
||||||
|
capabilities: string[]
|
||||||
|
health_score: number | null
|
||||||
|
success_rate: number | null
|
||||||
|
avg_response_time_ms: number | null
|
||||||
|
request_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumberOrNull(value: unknown): number | null {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 能力定义类型
|
* 能力定义类型
|
||||||
*/
|
*/
|
||||||
@@ -125,6 +153,51 @@ export async function getProviderKeys(providerId: string): Promise<EndpointAPIKe
|
|||||||
return allKeys
|
return allKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取按 API 格式分组的 Key 列表
|
||||||
|
*/
|
||||||
|
export async function getKeysGroupedByFormat(): Promise<Record<string, GroupedFormatKey[]>> {
|
||||||
|
const response = await client.get('/api/admin/endpoints/keys/grouped-by-format')
|
||||||
|
const grouped = response.data as Record<string, Array<Record<string, unknown>>>
|
||||||
|
const result: Record<string, GroupedFormatKey[]> = {}
|
||||||
|
|
||||||
|
for (const [apiFormat, keys] of Object.entries(grouped || {})) {
|
||||||
|
if (!Array.isArray(keys)) continue
|
||||||
|
|
||||||
|
result[apiFormat] = keys.map((key) => ({
|
||||||
|
id: String(key.id || ''),
|
||||||
|
provider_id: String(key.provider_id || ''),
|
||||||
|
name: String(key.name || 'Unnamed Key'),
|
||||||
|
auth_type: typeof key.auth_type === 'string' ? key.auth_type : undefined,
|
||||||
|
api_key_masked: String(key.api_key_masked || '***'),
|
||||||
|
internal_priority: toNumberOrNull(key.internal_priority) ?? 0,
|
||||||
|
global_priority_by_format:
|
||||||
|
key.global_priority_by_format && typeof key.global_priority_by_format === 'object'
|
||||||
|
? (key.global_priority_by_format as Record<string, number>)
|
||||||
|
: null,
|
||||||
|
format_priority: toNumberOrNull(key.format_priority),
|
||||||
|
rate_multipliers:
|
||||||
|
key.rate_multipliers && typeof key.rate_multipliers === 'object'
|
||||||
|
? (key.rate_multipliers as Record<string, number>)
|
||||||
|
: null,
|
||||||
|
is_active: key.is_active !== false,
|
||||||
|
provider_active: key.provider_active !== false,
|
||||||
|
pool_enabled: key.pool_enabled === true,
|
||||||
|
circuit_breaker_open: key.circuit_breaker_open === true,
|
||||||
|
provider_name: String(key.provider_name || 'Unknown Provider'),
|
||||||
|
api_format: typeof key.api_format === 'string' ? key.api_format : apiFormat,
|
||||||
|
api_formats: Array.isArray(key.api_formats) ? key.api_formats.map(item => String(item)) : [apiFormat],
|
||||||
|
capabilities: Array.isArray(key.capabilities) ? key.capabilities.map(item => String(item)) : [],
|
||||||
|
health_score: toNumberOrNull(key.health_score),
|
||||||
|
success_rate: toNumberOrNull(key.success_rate),
|
||||||
|
avg_response_time_ms: toNumberOrNull(key.avg_response_time_ms),
|
||||||
|
request_count: toNumberOrNull(key.request_count) ?? 0,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 为 Provider 添加 Key
|
* 为 Provider 添加 Key
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import client from '../client'
|
import client from '../client'
|
||||||
import { dedupedRequest } from '@/utils/cache'
|
import { dedupedRequest } from '@/utils/cache'
|
||||||
import type { AllowedModels, ProxyConfig } from './types/provider'
|
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
|
||||||
|
import type { ProviderKeyStatusSnapshot } from './types/statusSnapshot'
|
||||||
|
|
||||||
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ export interface PoolStatusResponse {
|
|||||||
* 获取 Provider 的号池状态
|
* 获取 Provider 的号池状态
|
||||||
*/
|
*/
|
||||||
export async function getPoolStatus(providerId: string): Promise<PoolStatusResponse> {
|
export async function getPoolStatus(providerId: string): Promise<PoolStatusResponse> {
|
||||||
const response = await client.get(`/api/admin/providers/${providerId}/pool-status`)
|
const response = await client.get<PoolStatusResponse>(`/api/admin/providers/${providerId}/pool-status`)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ export async function clearPoolCooldown(
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
const response = await client.post(
|
const response = await client.post<{ message: string }>(
|
||||||
`/api/admin/providers/${providerId}/pool/clear-cooldown/${keyId}`,
|
`/api/admin/providers/${providerId}/pool/clear-cooldown/${keyId}`,
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
@@ -53,7 +54,7 @@ export async function resetPoolCost(
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
keyId: string,
|
keyId: string,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
const response = await client.post(
|
const response = await client.post<{ message: string }>(
|
||||||
`/api/admin/providers/${providerId}/pool/reset-cost/${keyId}`,
|
`/api/admin/providers/${providerId}/pool/reset-cost/${keyId}`,
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
@@ -99,9 +100,20 @@ export interface PoolKeyDetail {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
auth_type: string
|
auth_type: string
|
||||||
oauth_expires_at?: number | null
|
oauth_expires_at?: number | null
|
||||||
oauth_invalid_at?: number | null
|
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
oauth_invalid_reason?: string | null
|
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
oauth_plan_type?: string | null
|
oauth_plan_type?: string | null
|
||||||
|
oauth_account_id?: string | null
|
||||||
|
oauth_account_user_id?: string | null
|
||||||
|
oauth_account_name?: string | null
|
||||||
|
oauth_organizations?: OAuthOrganizationInfo[] | null
|
||||||
|
account_status_code?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
|
account_status_label?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
|
account_status_reason?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
|
account_status_blocked?: boolean // 兼容字段;优先使用 status_snapshot.account
|
||||||
|
account_status_recoverable?: boolean // 兼容字段;优先使用 status_snapshot.account
|
||||||
|
account_status_source?: string | null // 兼容字段;优先使用 status_snapshot.account
|
||||||
|
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||||
quota_updated_at?: number | null
|
quota_updated_at?: number | null
|
||||||
health_score?: number
|
health_score?: number
|
||||||
circuit_breaker_open?: boolean
|
circuit_breaker_open?: boolean
|
||||||
@@ -125,6 +137,8 @@ export interface PoolKeyDetail {
|
|||||||
cost_window_usage: number
|
cost_window_usage: number
|
||||||
cost_limit: number | null
|
cost_limit: number | null
|
||||||
request_count: number
|
request_count: number
|
||||||
|
total_tokens: number
|
||||||
|
total_cost_usd: string
|
||||||
sticky_sessions: number
|
sticky_sessions: number
|
||||||
lru_score: number | null
|
lru_score: number | null
|
||||||
created_at: string | null
|
created_at: string | null
|
||||||
@@ -167,6 +181,24 @@ export interface PoolKeysQuery {
|
|||||||
page_size?: number
|
page_size?: number
|
||||||
search?: string
|
search?: string
|
||||||
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
status?: 'all' | 'active' | 'cooldown' | 'inactive'
|
||||||
|
quick_selectors?: string[]
|
||||||
|
search_scope?: 'name' | 'full'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolKeySelectionRequest {
|
||||||
|
search?: string
|
||||||
|
quick_selectors?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolKeySelectionItem {
|
||||||
|
key_id: string
|
||||||
|
key_name: string
|
||||||
|
auth_type: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PoolKeySelectionResponse {
|
||||||
|
total: number
|
||||||
|
items: PoolKeySelectionItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PoolBatchAction {
|
export interface PoolBatchAction {
|
||||||
@@ -201,13 +233,29 @@ export async function listPoolKeys(
|
|||||||
providerId: string,
|
providerId: string,
|
||||||
params: PoolKeysQuery = {},
|
params: PoolKeysQuery = {},
|
||||||
): Promise<PoolKeysPageResponse> {
|
): Promise<PoolKeysPageResponse> {
|
||||||
const key = `pool:keys:${providerId}|${params.page ?? ''}|${params.page_size ?? ''}|${params.search ?? ''}|${params.status ?? ''}`
|
const normalizedParams = {
|
||||||
|
...params,
|
||||||
|
quick_selectors: params.quick_selectors?.length ? params.quick_selectors.join(',') : undefined,
|
||||||
|
}
|
||||||
|
const key = `pool:keys:${providerId}|${normalizedParams.page ?? ''}|${normalizedParams.page_size ?? ''}|${normalizedParams.search ?? ''}|${normalizedParams.status ?? ''}|${normalizedParams.quick_selectors ?? ''}|${normalizedParams.search_scope ?? ''}`
|
||||||
return dedupedRequest(key, async () => {
|
return dedupedRequest(key, async () => {
|
||||||
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params })
|
const response = await client.get<PoolKeysPageResponse>(`/api/admin/pool/${providerId}/keys`, { params: normalizedParams })
|
||||||
return response.data
|
return response.data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function resolvePoolKeySelection(
|
||||||
|
providerId: string,
|
||||||
|
body: PoolKeySelectionRequest,
|
||||||
|
): Promise<PoolKeySelectionResponse> {
|
||||||
|
const response = await client.post<PoolKeySelectionResponse>(
|
||||||
|
`/api/admin/pool/${providerId}/keys/resolve-selection`,
|
||||||
|
body,
|
||||||
|
{ timeout: POOL_BATCH_ACTION_TIMEOUT_MS },
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
export async function batchActionPoolKeys(
|
export async function batchActionPoolKeys(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
body: PoolBatchAction,
|
body: PoolBatchAction,
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ export interface ProviderOAuthCompleteResponse {
|
|||||||
expires_at?: number | null
|
expires_at?: number | null
|
||||||
has_refresh_token: boolean
|
has_refresh_token: boolean
|
||||||
email?: string | null
|
email?: string | null
|
||||||
|
account_state_recheck_attempted?: boolean
|
||||||
|
account_state_recheck_error?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProviderOAuthCompleteResponseWithKey {
|
export interface ProviderOAuthCompleteResponseWithKey {
|
||||||
@@ -92,7 +94,7 @@ export async function completeProviderLevelOAuth(
|
|||||||
|
|
||||||
export async function importProviderRefreshToken(
|
export async function importProviderRefreshToken(
|
||||||
providerId: string,
|
providerId: string,
|
||||||
data: { refresh_token: string; name?: string; proxy_node_id?: string }
|
data: { refresh_token?: string; access_token?: string; name?: string; proxy_node_id?: string }
|
||||||
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
): Promise<ProviderOAuthCompleteResponseWithKey> {
|
||||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||||
return resp.data
|
return resp.data
|
||||||
|
|||||||
@@ -114,8 +114,36 @@ export async function createProvider(
|
|||||||
/**
|
/**
|
||||||
* 删除 Provider
|
* 删除 Provider
|
||||||
*/
|
*/
|
||||||
export async function deleteProvider(providerId: string): Promise<{ message: string }> {
|
export interface ProviderDeleteSubmitResponse {
|
||||||
const response = await client.delete(`/api/admin/providers/${providerId}`)
|
task_id: string
|
||||||
|
status: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderDeleteTaskResponse {
|
||||||
|
task_id: string
|
||||||
|
provider_id: string
|
||||||
|
status: string
|
||||||
|
stage: string
|
||||||
|
total_keys: number
|
||||||
|
deleted_keys: number
|
||||||
|
total_endpoints: number
|
||||||
|
deleted_endpoints: number
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProvider(providerId: string): Promise<ProviderDeleteSubmitResponse> {
|
||||||
|
const response = await client.delete<ProviderDeleteSubmitResponse>(`/api/admin/providers/${providerId}`)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProviderDeleteTask(
|
||||||
|
providerId: string,
|
||||||
|
taskId: string,
|
||||||
|
): Promise<ProviderDeleteTaskResponse> {
|
||||||
|
const response = await client.get<ProviderDeleteTaskResponse>(
|
||||||
|
`/api/admin/providers/${providerId}/delete-task/${taskId}`,
|
||||||
|
)
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +194,8 @@ export interface TestModelFailoverRequest {
|
|||||||
api_format?: string
|
api_format?: string
|
||||||
endpoint_id?: string
|
endpoint_id?: string
|
||||||
message?: string
|
message?: string
|
||||||
|
request_headers?: Record<string, unknown>
|
||||||
|
request_body?: Record<string, unknown>
|
||||||
request_id?: string
|
request_id?: string
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
}
|
}
|
||||||
@@ -184,6 +214,11 @@ export interface TestAttemptDetail {
|
|||||||
error_message?: string | null
|
error_message?: string | null
|
||||||
status_code?: number | null
|
status_code?: number | null
|
||||||
latency_ms?: number | null
|
latency_ms?: number | null
|
||||||
|
request_url?: string | null
|
||||||
|
request_headers?: Record<string, unknown> | null
|
||||||
|
request_body?: unknown
|
||||||
|
response_headers?: Record<string, unknown> | null
|
||||||
|
response_body?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestModelFailoverResponse {
|
export interface TestModelFailoverResponse {
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 代理配置类型
|
* 代理配置类型
|
||||||
* 支持两种模式:
|
* 支持两种模式:
|
||||||
@@ -12,6 +14,13 @@ export interface ProxyConfig {
|
|||||||
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
enabled?: boolean // 是否启用代理(false 时保留配置但不使用)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface OAuthOrganizationInfo {
|
||||||
|
id?: string | null
|
||||||
|
title?: string | null
|
||||||
|
is_default?: boolean | null
|
||||||
|
role?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求头规则类型
|
* 请求头规则类型
|
||||||
* - set: 设置/覆盖请求头
|
* - set: 设置/覆盖请求头
|
||||||
@@ -35,8 +44,6 @@ export interface HeaderRuleRename {
|
|||||||
to: string
|
to: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 请求体规则类型
|
* 请求体规则类型
|
||||||
* - set: 设置/覆盖字段
|
* - set: 设置/覆盖字段
|
||||||
@@ -129,10 +136,28 @@ export type BodyRuleConditionOp =
|
|||||||
| 'exists' | 'not_exists'
|
| 'exists' | 'not_exists'
|
||||||
| 'in' | 'type_is'
|
| 'in' | 'type_is'
|
||||||
|
|
||||||
export interface BodyRuleCondition {
|
export interface BodyRuleConditionLeaf {
|
||||||
path: string
|
path: string
|
||||||
op: BodyRuleConditionOp
|
op: BodyRuleConditionOp
|
||||||
value?: unknown // exists / not_exists 不需要 value
|
value?: unknown // exists / not_exists 不需要 value
|
||||||
|
source?: 'original' | 'current'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyRuleConditionAll {
|
||||||
|
all: BodyRuleCondition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BodyRuleConditionAny {
|
||||||
|
any: BodyRuleCondition[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BodyRuleCondition =
|
||||||
|
| BodyRuleConditionLeaf
|
||||||
|
| BodyRuleConditionAll
|
||||||
|
| BodyRuleConditionAny
|
||||||
|
|
||||||
|
export type HeaderRule = (HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename) & {
|
||||||
|
condition?: BodyRuleCondition
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -264,8 +289,13 @@ export interface EndpointAPIKey {
|
|||||||
oauth_email?: string | null // OAuth 授权的邮箱
|
oauth_email?: string | null // OAuth 授权的邮箱
|
||||||
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
|
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
|
||||||
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
|
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
|
||||||
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳)
|
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
|
||||||
oauth_invalid_reason?: string | null // OAuth Token 失效原因
|
oauth_account_name?: string | null
|
||||||
|
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
|
||||||
|
oauth_temporary?: boolean | null // 是否为仅 Access Token 导入的临时 OAuth 账号
|
||||||
|
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
|
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||||
|
status_snapshot?: ProviderKeyStatusSnapshot | null
|
||||||
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
|
// 上游元数据(由上游响应采集,如 Codex 额度信息 / Antigravity 配额信息)
|
||||||
upstream_metadata?: UpstreamMetadata | null
|
upstream_metadata?: UpstreamMetadata | null
|
||||||
// Key 级别代理配置(覆盖 Provider 级别代理)
|
// Key 级别代理配置(覆盖 Provider 级别代理)
|
||||||
@@ -565,6 +595,7 @@ export interface HealthSummary {
|
|||||||
total: number
|
total: number
|
||||||
active: number
|
active: number
|
||||||
unhealthy: number
|
unhealthy: number
|
||||||
|
circuit_open?: number
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export interface OAuthStatusSnapshot {
|
||||||
|
code: 'none' | 'valid' | 'expiring' | 'expired' | 'invalid' | 'check_failed'
|
||||||
|
label?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
expires_at?: number | null
|
||||||
|
invalid_at?: number | null
|
||||||
|
source?: string | null
|
||||||
|
requires_reauth?: boolean
|
||||||
|
expiring_soon?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AccountStatusSnapshot {
|
||||||
|
code: string
|
||||||
|
label?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
blocked: boolean
|
||||||
|
source?: string | null
|
||||||
|
recoverable?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuotaStatusSnapshot {
|
||||||
|
code: 'unknown' | 'ok' | 'exhausted'
|
||||||
|
label?: string | null
|
||||||
|
reason?: string | null
|
||||||
|
exhausted: boolean
|
||||||
|
usage_ratio?: number | null
|
||||||
|
updated_at?: number | null
|
||||||
|
reset_seconds?: number | null
|
||||||
|
plan_type?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProviderKeyStatusSnapshot {
|
||||||
|
oauth: OAuthStatusSnapshot
|
||||||
|
account: AccountStatusSnapshot
|
||||||
|
quota: QuotaStatusSnapshot
|
||||||
|
}
|
||||||
+36
-175
@@ -1,8 +1,9 @@
|
|||||||
import apiClient from './client'
|
import apiClient from './client'
|
||||||
import type { ActivityHeatmap } from '@/types/activity'
|
|
||||||
import type { TieredPricingConfig } from './endpoints/types'
|
import type { TieredPricingConfig } from './endpoints/types'
|
||||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
|
||||||
import type { BillingSummary } from './auth'
|
import type { BillingSummary } from './auth'
|
||||||
|
import type { UserSession } from '@/types/session'
|
||||||
|
|
||||||
|
export type { UserSession }
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
@@ -22,8 +23,6 @@ export interface Profile {
|
|||||||
export interface UserPreferences {
|
export interface UserPreferences {
|
||||||
avatar_url?: string
|
avatar_url?: string
|
||||||
bio?: string
|
bio?: string
|
||||||
default_provider_id?: string // UUID
|
|
||||||
default_provider?: Record<string, unknown>
|
|
||||||
theme: string
|
theme: string
|
||||||
language: string
|
language: string
|
||||||
timezone?: string
|
timezone?: string
|
||||||
@@ -34,87 +33,6 @@ export interface UserPreferences {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 提供商配置接口
|
|
||||||
export interface ProviderConfig {
|
|
||||||
provider_id: string
|
|
||||||
priority: number // 优先级(越高越优先)
|
|
||||||
weight: number // 负载均衡权重
|
|
||||||
enabled: boolean // 是否启用
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用记录接口
|
|
||||||
export interface UsageRecordDetail {
|
|
||||||
id: string
|
|
||||||
provider: string
|
|
||||||
model: string
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
total_tokens: number
|
|
||||||
cost: number // 官方费率
|
|
||||||
actual_cost?: number // 倍率消耗(仅管理员可见)
|
|
||||||
rate_multiplier?: number // 成本倍率(仅管理员可见)
|
|
||||||
response_time_ms?: number
|
|
||||||
is_stream: boolean
|
|
||||||
created_at: string
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
status_code: number
|
|
||||||
error_message?: string
|
|
||||||
input_price_per_1m: number
|
|
||||||
output_price_per_1m: number
|
|
||||||
cache_creation_price_per_1m?: number
|
|
||||||
cache_read_price_per_1m?: number
|
|
||||||
price_per_request?: number // 按次计费价格
|
|
||||||
api_key?: {
|
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
display: string
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 模型统计接口
|
|
||||||
export interface ModelSummary {
|
|
||||||
model: string
|
|
||||||
requests: number
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost_usd: number
|
|
||||||
actual_total_cost_usd?: number // 倍率消耗(仅管理员可见)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提供商统计接口
|
|
||||||
export interface ProviderSummary {
|
|
||||||
provider: string
|
|
||||||
requests: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost_usd: number
|
|
||||||
success_rate: number | null
|
|
||||||
avg_response_time_ms: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用统计响应接口
|
|
||||||
export interface UsageResponse {
|
|
||||||
total_requests: number
|
|
||||||
total_input_tokens: number
|
|
||||||
total_output_tokens: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number // 官方费率
|
|
||||||
total_actual_cost?: number // 倍率消耗(仅管理员可见)
|
|
||||||
avg_response_time: number
|
|
||||||
billing: BillingSummary
|
|
||||||
summary_by_model: ModelSummary[]
|
|
||||||
summary_by_provider?: ProviderSummary[]
|
|
||||||
pagination?: {
|
|
||||||
total: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
has_more: boolean
|
|
||||||
}
|
|
||||||
records: UsageRecordDetail[]
|
|
||||||
activity_heatmap?: ActivityHeatmap | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
id: string // UUID
|
id: string // UUID
|
||||||
name: string
|
name: string
|
||||||
@@ -126,7 +44,7 @@ export interface ApiKey {
|
|||||||
created_at: string
|
created_at: string
|
||||||
total_requests?: number
|
total_requests?: number
|
||||||
total_cost_usd?: number
|
total_cost_usd?: number
|
||||||
allowed_providers?: ProviderConfig[]
|
rate_limit?: number | null
|
||||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,14 +77,36 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async listSessions(): Promise<UserSession[]> {
|
||||||
|
const response = await apiClient.get<UserSession[]>('/api/users/me/sessions')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateSessionLabel(sessionId: string, deviceLabel: string): Promise<UserSession> {
|
||||||
|
const response = await apiClient.patch<UserSession>(`/api/users/me/sessions/${sessionId}`, {
|
||||||
|
device_label: deviceLabel,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeSession(sessionId: string): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.delete(`/api/users/me/sessions/${sessionId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeOtherSessions(): Promise<{ message: string; revoked_count: number }> {
|
||||||
|
const response = await apiClient.delete('/api/users/me/sessions/others')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
// API密钥管理
|
// API密钥管理
|
||||||
async getApiKeys(): Promise<ApiKey[]> {
|
async getApiKeys(): Promise<ApiKey[]> {
|
||||||
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')
|
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
async createApiKey(name: string): Promise<ApiKey> {
|
async createApiKey(data: { name: string; rate_limit?: number }): Promise<ApiKey> {
|
||||||
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', { name })
|
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -196,48 +136,14 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 使用统计
|
async updateApiKey(
|
||||||
async getUsage(params?: {
|
keyId: string,
|
||||||
start_date?: string
|
data: { name?: string; rate_limit?: number | null }
|
||||||
end_date?: string
|
): Promise<ApiKey & { message: string }> {
|
||||||
preset?: string
|
const response = await apiClient.put<ApiKey & { message: string }>(
|
||||||
timezone?: string
|
`/api/users/me/api-keys/${keyId}`,
|
||||||
tz_offset_minutes?: number
|
data
|
||||||
search?: string // 通用搜索:密钥名、模型名
|
)
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}): Promise<UsageResponse> {
|
|
||||||
const response = await apiClient.get<UsageResponse>('/api/users/me/usage', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取活跃请求状态(用于轮询更新)
|
|
||||||
async getActiveRequests(ids?: string): Promise<{
|
|
||||||
requests: Array<{
|
|
||||||
id: string
|
|
||||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
cache_creation_input_tokens?: number | null
|
|
||||||
cache_read_input_tokens?: number | null
|
|
||||||
cost: number
|
|
||||||
actual_cost?: number | null
|
|
||||||
rate_multiplier?: number | null
|
|
||||||
response_time_ms: number | null
|
|
||||||
first_byte_time_ms: number | null
|
|
||||||
api_format?: string | null
|
|
||||||
endpoint_api_format?: string | null
|
|
||||||
has_format_conversion?: boolean | null
|
|
||||||
}>
|
|
||||||
}> {
|
|
||||||
const params = ids ? { ids } : {}
|
|
||||||
const response = await apiClient.get('/api/users/me/usage/active', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// 获取可用的提供商
|
|
||||||
async getAvailableProviders(): Promise<Array<Record<string, unknown>>> {
|
|
||||||
const response = await apiClient.get('/api/users/me/providers')
|
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -281,16 +187,6 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 提供商绑定管理相关方法已移除,改为直接从可用提供商中选择
|
|
||||||
|
|
||||||
// API密钥提供商关联
|
|
||||||
async updateApiKeyProviders(keyId: string, data: {
|
|
||||||
allowed_providers?: ProviderConfig[]
|
|
||||||
}): Promise<{ message: string }> {
|
|
||||||
const response = await apiClient.put(`/api/users/me/api-keys/${keyId}/providers`, data)
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
// API密钥能力配置
|
// API密钥能力配置
|
||||||
async updateApiKeyCapabilities(keyId: string, data: {
|
async updateApiKeyCapabilities(keyId: string, data: {
|
||||||
force_capabilities?: Record<string, boolean> | null
|
force_capabilities?: Record<string, boolean> | null
|
||||||
@@ -317,39 +213,4 @@ export const meApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取请求间隔时间线(用于散点图)
|
|
||||||
async getIntervalTimeline(params?: {
|
|
||||||
hours?: number
|
|
||||||
limit?: number
|
|
||||||
}): Promise<{
|
|
||||||
analysis_period_hours: number
|
|
||||||
total_points: number
|
|
||||||
points: Array<{ x: string; y: number; model?: string }>
|
|
||||||
models?: string[]
|
|
||||||
}> {
|
|
||||||
const cacheKey = buildCacheKey('me:interval-timeline', params as Record<string, unknown> | undefined)
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get('/api/users/me/usage/interval-timeline', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30000
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取活跃度热力图数据(用户)
|
|
||||||
* 后端已缓存5分钟
|
|
||||||
*/
|
|
||||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
|
||||||
return cachedRequest(
|
|
||||||
'me-activity-heatmap',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ActivityHeatmap>('/api/users/me/usage/heatmap')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
60000
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import apiClient from './client'
|
||||||
|
|
||||||
|
export interface VideoBilling {
|
||||||
|
task_type: 'video' | 'image' | 'audio'
|
||||||
|
duration_seconds?: number
|
||||||
|
resolution?: string
|
||||||
|
video_price_per_second?: number
|
||||||
|
video_cost?: number
|
||||||
|
cost?: number
|
||||||
|
rule_name?: string
|
||||||
|
expression?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RequestDetail {
|
||||||
|
id: string
|
||||||
|
request_id: string
|
||||||
|
user: {
|
||||||
|
id: string | null
|
||||||
|
username: string | null
|
||||||
|
email: string | null
|
||||||
|
}
|
||||||
|
api_key: {
|
||||||
|
id: string | null
|
||||||
|
name: string | null
|
||||||
|
display: string | null
|
||||||
|
}
|
||||||
|
provider_api_key: {
|
||||||
|
id: string | null
|
||||||
|
name: string | null
|
||||||
|
}
|
||||||
|
provider: string
|
||||||
|
api_format?: string
|
||||||
|
model: string
|
||||||
|
target_model?: string | null
|
||||||
|
tokens: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
cost: {
|
||||||
|
input: number
|
||||||
|
output: number
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
input_tokens?: number
|
||||||
|
output_tokens?: number
|
||||||
|
total_tokens?: number
|
||||||
|
cache_creation_input_tokens?: number
|
||||||
|
cache_creation_input_tokens_5m?: number
|
||||||
|
cache_creation_input_tokens_1h?: number
|
||||||
|
cache_read_input_tokens?: number
|
||||||
|
input_cost?: number
|
||||||
|
output_cost?: number
|
||||||
|
total_cost?: number
|
||||||
|
cache_creation_cost?: number
|
||||||
|
cache_read_cost?: number
|
||||||
|
request_cost?: number
|
||||||
|
input_price_per_1m?: number
|
||||||
|
output_price_per_1m?: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
price_per_request?: number
|
||||||
|
request_type: string
|
||||||
|
is_stream: boolean
|
||||||
|
status_code: number
|
||||||
|
status?: string
|
||||||
|
error_message?: string
|
||||||
|
response_time_ms: number
|
||||||
|
created_at: string
|
||||||
|
request_headers?: Record<string, unknown>
|
||||||
|
request_body?: Record<string, unknown>
|
||||||
|
provider_request_headers?: Record<string, unknown>
|
||||||
|
provider_request_body?: Record<string, unknown>
|
||||||
|
response_headers?: Record<string, unknown>
|
||||||
|
client_response_headers?: Record<string, unknown>
|
||||||
|
response_body?: Record<string, unknown>
|
||||||
|
client_response_body?: Record<string, unknown>
|
||||||
|
has_request_body?: boolean
|
||||||
|
has_provider_request_body?: boolean
|
||||||
|
has_response_body?: boolean
|
||||||
|
has_client_response_body?: boolean
|
||||||
|
metadata?: Record<string, unknown>
|
||||||
|
tiered_pricing?: {
|
||||||
|
total_input_context: number
|
||||||
|
tier_index: number
|
||||||
|
tier_count: number
|
||||||
|
source?: 'provider' | 'global'
|
||||||
|
current_tier: {
|
||||||
|
up_to?: number | null
|
||||||
|
input_price_per_1m: number
|
||||||
|
output_price_per_1m: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
cache_ttl_pricing?: Array<{
|
||||||
|
ttl_minutes: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
tiers: Array<{
|
||||||
|
up_to?: number | null
|
||||||
|
input_price_per_1m: number
|
||||||
|
output_price_per_1m: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
cache_ttl_pricing?: Array<{
|
||||||
|
ttl_minutes: number
|
||||||
|
cache_creation_price_per_1m?: number
|
||||||
|
cache_read_price_per_1m?: number
|
||||||
|
}>
|
||||||
|
}>
|
||||||
|
} | null
|
||||||
|
video_billing?: VideoBilling | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurlData {
|
||||||
|
url: string
|
||||||
|
method: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
body: Record<string, unknown>
|
||||||
|
curl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayRequest {
|
||||||
|
provider_id?: string
|
||||||
|
endpoint_id?: string
|
||||||
|
api_key_id?: string
|
||||||
|
body_override?: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplayResponse {
|
||||||
|
url: string
|
||||||
|
provider: string
|
||||||
|
status_code: number
|
||||||
|
response_headers: Record<string, string>
|
||||||
|
response_body: Record<string, unknown>
|
||||||
|
response_time_ms: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const requestDetailsApi = {
|
||||||
|
async getRequestDetail(requestId: string, options: { includeBodies?: boolean } = {}): Promise<RequestDetail> {
|
||||||
|
const response = await apiClient.get<RequestDetail>(`/api/admin/usage/${requestId}`, {
|
||||||
|
params: { include_bodies: options.includeBodies ?? true },
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCurlData(requestId: string): Promise<CurlData> {
|
||||||
|
const response = await apiClient.get<CurlData>(`/api/admin/usage/${requestId}/curl`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async replayRequest(requestId: string, params?: ReplayRequest): Promise<ReplayResponse> {
|
||||||
|
const response = await apiClient.post<ReplayResponse>(
|
||||||
|
`/api/admin/usage/${requestId}/replay`,
|
||||||
|
params || {},
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,241 +0,0 @@
|
|||||||
import apiClient from './client'
|
|
||||||
import { cachedRequest, dedupedRequest, buildCacheKey } from '@/utils/cache'
|
|
||||||
import type { ActivityHeatmap } from '@/types/activity'
|
|
||||||
|
|
||||||
export interface UsageRecord {
|
|
||||||
id: string // UUID
|
|
||||||
user_id: string // UUID
|
|
||||||
username?: string
|
|
||||||
provider_id?: string // UUID
|
|
||||||
provider_name?: string
|
|
||||||
model: string
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
cache_creation_input_tokens?: number
|
|
||||||
cache_read_input_tokens?: number
|
|
||||||
total_tokens: number
|
|
||||||
cost?: number
|
|
||||||
response_time?: number
|
|
||||||
created_at: string
|
|
||||||
has_fallback?: boolean // 🆕 是否发生了 fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageStats {
|
|
||||||
total_requests: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
total_actual_cost?: number
|
|
||||||
avg_response_time: number
|
|
||||||
today?: {
|
|
||||||
requests: number
|
|
||||||
tokens: number
|
|
||||||
cost: number
|
|
||||||
}
|
|
||||||
activity_heatmap?: ActivityHeatmap | null
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByModel {
|
|
||||||
model: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
avg_response_time?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByUser {
|
|
||||||
user_id: string // UUID
|
|
||||||
email: string
|
|
||||||
username: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByProvider {
|
|
||||||
provider_id: string
|
|
||||||
provider: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
actual_cost: number
|
|
||||||
avg_response_time_ms: number
|
|
||||||
success_rate: number
|
|
||||||
error_count: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageByApiFormat {
|
|
||||||
api_format: string
|
|
||||||
request_count: number
|
|
||||||
total_tokens: number
|
|
||||||
total_cost: number
|
|
||||||
actual_cost: number
|
|
||||||
avg_response_time_ms: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UsageFilters {
|
|
||||||
user_id?: string // UUID
|
|
||||||
provider_id?: string // UUID
|
|
||||||
model?: string
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
page?: number
|
|
||||||
page_size?: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export const usageApi = {
|
|
||||||
async getUsageRecords(filters?: UsageFilters): Promise<{
|
|
||||||
records: UsageRecord[]
|
|
||||||
total: number
|
|
||||||
page: number
|
|
||||||
page_size: number
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get('/api/usage', { params: filters })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageStats(filters?: UsageFilters): Promise<UsageStats> {
|
|
||||||
// 为统计数据添加30秒缓存
|
|
||||||
const cacheKey = `usage-stats-${JSON.stringify(filters || {})}`
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<UsageStats>('/api/admin/usage/stats', { params: filters })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30000 // 30秒缓存
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get usage aggregation by dimension (RESTful API)
|
|
||||||
* @param groupBy Aggregation dimension: 'model', 'user', 'provider', or 'api_format'
|
|
||||||
* @param filters Optional filters
|
|
||||||
*/
|
|
||||||
async getUsageAggregation<T = UsageByModel[] | UsageByUser[] | UsageByProvider[] | UsageByApiFormat[]>(
|
|
||||||
groupBy: 'model' | 'user' | 'provider' | 'api_format',
|
|
||||||
filters?: UsageFilters & { limit?: number }
|
|
||||||
): Promise<T> {
|
|
||||||
const cacheKey = `usage-aggregation-${groupBy}-${JSON.stringify(filters || {})}`
|
|
||||||
return cachedRequest(
|
|
||||||
cacheKey,
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<T>('/api/admin/usage/aggregation/stats', {
|
|
||||||
params: { group_by: groupBy, ...filters }
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
30000 // 30秒缓存
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
// Shorthand methods using getUsageAggregation
|
|
||||||
async getUsageByModel(filters?: UsageFilters & { limit?: number }): Promise<UsageByModel[]> {
|
|
||||||
return this.getUsageAggregation<UsageByModel[]>('model', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageByUser(filters?: UsageFilters & { limit?: number }): Promise<UsageByUser[]> {
|
|
||||||
return this.getUsageAggregation<UsageByUser[]>('user', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageByProvider(filters?: UsageFilters & { limit?: number }): Promise<UsageByProvider[]> {
|
|
||||||
return this.getUsageAggregation<UsageByProvider[]>('provider', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUsageByApiFormat(filters?: UsageFilters & { limit?: number }): Promise<UsageByApiFormat[]> {
|
|
||||||
return this.getUsageAggregation<UsageByApiFormat[]>('api_format', filters)
|
|
||||||
},
|
|
||||||
|
|
||||||
async getUserUsage(userId: string, filters?: UsageFilters): Promise<{
|
|
||||||
records: UsageRecord[]
|
|
||||||
stats: UsageStats
|
|
||||||
}> {
|
|
||||||
const response = await apiClient.get(`/api/users/${userId}/usage`, { params: filters })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async exportUsage(format: 'csv' | 'json', filters?: UsageFilters): Promise<Blob> {
|
|
||||||
const response = await apiClient.get('/api/usage/export', {
|
|
||||||
params: { ...filters, format },
|
|
||||||
responseType: 'blob'
|
|
||||||
})
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
async getAllUsageRecords(params?: {
|
|
||||||
start_date?: string
|
|
||||||
end_date?: string
|
|
||||||
preset?: string
|
|
||||||
granularity?: 'hour' | 'day' | 'week' | 'month'
|
|
||||||
timezone?: string
|
|
||||||
tz_offset_minutes?: number
|
|
||||||
search?: string // 通用搜索:用户名、密钥名、模型名、提供商名
|
|
||||||
user_id?: string // UUID
|
|
||||||
username?: string
|
|
||||||
model?: string
|
|
||||||
provider?: string
|
|
||||||
api_format?: string // API 格式筛选(如 openai:chat, claude:chat)
|
|
||||||
status?: string // 'stream' | 'standard' | 'error'
|
|
||||||
limit?: number
|
|
||||||
offset?: number
|
|
||||||
}): Promise<{
|
|
||||||
records: Array<Record<string, unknown>>
|
|
||||||
total: number
|
|
||||||
limit: number
|
|
||||||
offset: number
|
|
||||||
}> {
|
|
||||||
const key = buildCacheKey('usage:records', params as Record<string, unknown> | undefined)
|
|
||||||
return dedupedRequest(key, async () => {
|
|
||||||
const response = await apiClient.get('/api/admin/usage/records', { params })
|
|
||||||
return response.data
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取活跃请求的状态(轻量级接口,用于轮询更新)
|
|
||||||
* @param ids 可选,逗号分隔的请求 ID 列表
|
|
||||||
*/
|
|
||||||
async getActiveRequests(ids?: string[]): Promise<{
|
|
||||||
requests: Array<{
|
|
||||||
id: string
|
|
||||||
status: 'pending' | 'streaming' | 'completed' | 'failed' | 'cancelled'
|
|
||||||
input_tokens: number
|
|
||||||
output_tokens: number
|
|
||||||
cache_creation_input_tokens?: number | null
|
|
||||||
cache_read_input_tokens?: number | null
|
|
||||||
cost: number
|
|
||||||
actual_cost?: number | null
|
|
||||||
rate_multiplier?: number | null
|
|
||||||
response_time_ms: number | null
|
|
||||||
first_byte_time_ms: number | null
|
|
||||||
provider?: string | null
|
|
||||||
api_key_name?: string | null
|
|
||||||
api_format?: string | null
|
|
||||||
endpoint_api_format?: string | null
|
|
||||||
has_format_conversion?: boolean | null
|
|
||||||
target_model?: string | null
|
|
||||||
}>
|
|
||||||
}> {
|
|
||||||
const params = ids?.length ? { ids: ids.join(',') } : {}
|
|
||||||
const response = await apiClient.get('/api/admin/usage/active', { params })
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取活跃度热力图数据(管理员)
|
|
||||||
* 后端已缓存5分钟
|
|
||||||
*/
|
|
||||||
async getActivityHeatmap(): Promise<ActivityHeatmap> {
|
|
||||||
return cachedRequest(
|
|
||||||
'admin-usage-activity-heatmap',
|
|
||||||
async () => {
|
|
||||||
const response = await apiClient.get<ActivityHeatmap>('/api/admin/usage/heatmap')
|
|
||||||
return response.data
|
|
||||||
},
|
|
||||||
60000
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,6 +10,7 @@ export interface User {
|
|||||||
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
allowed_providers: string[] | null // 允许使用的提供商 ID 列表
|
||||||
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
|
allowed_api_formats: string[] | null // 允许使用的 API 格式列表
|
||||||
allowed_models: string[] | null // 允许使用的模型名称列表
|
allowed_models: string[] | null // 允许使用的模型名称列表
|
||||||
|
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||||
created_at: string
|
created_at: string
|
||||||
updated_at?: string
|
updated_at?: string
|
||||||
last_login_at?: string | null
|
last_login_at?: string | null
|
||||||
@@ -25,6 +26,7 @@ export interface CreateUserRequest {
|
|||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_api_formats?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
|
rate_limit?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateUserRequest {
|
export interface UpdateUserRequest {
|
||||||
@@ -36,6 +38,7 @@ export interface UpdateUserRequest {
|
|||||||
allowed_providers?: string[] | null
|
allowed_providers?: string[] | null
|
||||||
allowed_api_formats?: string[] | null
|
allowed_api_formats?: string[] | null
|
||||||
allowed_models?: string[] | null
|
allowed_models?: string[] | null
|
||||||
|
rate_limit?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ApiKey {
|
export interface ApiKey {
|
||||||
@@ -49,11 +52,18 @@ export interface ApiKey {
|
|||||||
is_active: boolean
|
is_active: boolean
|
||||||
is_locked: boolean // 管理员锁定标志
|
is_locked: boolean // 管理员锁定标志
|
||||||
is_standalone: boolean // 是否为独立余额Key
|
is_standalone: boolean // 是否为独立余额Key
|
||||||
rate_limit?: number // 速率限制(请求/分钟)
|
rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认
|
||||||
total_requests?: number // 总请求数
|
total_requests?: number // 总请求数
|
||||||
total_cost_usd?: number // 总费用
|
total_cost_usd?: number // 总费用
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpsertUserApiKeyRequest {
|
||||||
|
name?: string
|
||||||
|
rate_limit?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { UserSession } from '@/types/session'
|
||||||
|
|
||||||
export const usersApi = {
|
export const usersApi = {
|
||||||
async getAllUsers(): Promise<User[]> {
|
async getAllUsers(): Promise<User[]> {
|
||||||
const response = await apiClient.get<User[]>('/api/admin/users')
|
const response = await apiClient.get<User[]>('/api/admin/users')
|
||||||
@@ -84,8 +94,41 @@ export const usersApi = {
|
|||||||
return response.data.api_keys
|
return response.data.api_keys
|
||||||
},
|
},
|
||||||
|
|
||||||
async createApiKey(userId: string, name?: string): Promise<ApiKey & { key: string }> {
|
async getUserSessions(userId: string): Promise<UserSession[]> {
|
||||||
const response = await apiClient.post<ApiKey & { key: string }>(`/api/admin/users/${userId}/api-keys`, { name })
|
const response = await apiClient.get<UserSession[]>(`/api/admin/users/${userId}/sessions`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeUserSession(userId: string, sessionId: string): Promise<{ message: string }> {
|
||||||
|
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions/${sessionId}`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async revokeAllUserSessions(userId: string): Promise<{ message: string; revoked_count: number }> {
|
||||||
|
const response = await apiClient.delete(`/api/admin/users/${userId}/sessions`)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async createApiKey(
|
||||||
|
userId: string,
|
||||||
|
data: UpsertUserApiKeyRequest
|
||||||
|
): Promise<ApiKey & { key: string }> {
|
||||||
|
const response = await apiClient.post<ApiKey & { key: string }>(
|
||||||
|
`/api/admin/users/${userId}/api-keys`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateApiKey(
|
||||||
|
userId: string,
|
||||||
|
keyId: string,
|
||||||
|
data: UpsertUserApiKeyRequest
|
||||||
|
): Promise<ApiKey & { message: string }> {
|
||||||
|
const response = await apiClient.put<ApiKey & { message: string }>(
|
||||||
|
`/api/admin/users/${userId}/api-keys/${keyId}`,
|
||||||
|
data
|
||||||
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -98,10 +141,5 @@ export const usersApi = {
|
|||||||
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
|
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
|
||||||
)
|
)
|
||||||
return response.data
|
return response.data
|
||||||
},
|
|
||||||
// 管理员统计
|
|
||||||
async getUsageStats(): Promise<Record<string, unknown>> {
|
|
||||||
const response = await apiClient.get('/api/admin/usage/stats')
|
|
||||||
return response.data
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,36 @@ export interface WalletTransactionsResponse extends WalletBalanceResponse {
|
|||||||
offset: number
|
offset: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DailyUsageRecord {
|
||||||
|
id?: string | null
|
||||||
|
date: string | null
|
||||||
|
timezone?: string | null
|
||||||
|
total_cost: number
|
||||||
|
total_requests: number
|
||||||
|
input_tokens: number
|
||||||
|
output_tokens: number
|
||||||
|
cache_creation_tokens: number
|
||||||
|
cache_read_tokens: number
|
||||||
|
first_finalized_at?: string | null
|
||||||
|
last_finalized_at?: string | null
|
||||||
|
aggregated_at?: string | null
|
||||||
|
is_today: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FlowItem =
|
||||||
|
| { type: 'transaction'; data: WalletTransaction }
|
||||||
|
| { type: 'daily_usage'; data: DailyUsageRecord }
|
||||||
|
|
||||||
|
export interface WalletFlowResponse extends WalletBalanceResponse {
|
||||||
|
today_entry: DailyUsageRecord | null
|
||||||
|
items: FlowItem[]
|
||||||
|
total: number
|
||||||
|
limit: number
|
||||||
|
offset: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TodayCostResponse = DailyUsageRecord
|
||||||
|
|
||||||
export interface PaymentOrder {
|
export interface PaymentOrder {
|
||||||
id: string
|
id: string
|
||||||
order_no: string
|
order_no: string
|
||||||
@@ -131,6 +161,16 @@ export const walletApi = {
|
|||||||
return response.data
|
return response.data
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async getFlow(params?: { limit?: number; offset?: number }): Promise<WalletFlowResponse> {
|
||||||
|
const response = await apiClient.get<WalletFlowResponse>('/api/wallet/flow', { params })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
|
async getTodayCost(): Promise<TodayCostResponse> {
|
||||||
|
const response = await apiClient.get<TodayCostResponse>('/api/wallet/today-cost')
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
|
||||||
async createRechargeOrder(payload: WalletRechargeCreateRequest): Promise<{
|
async createRechargeOrder(payload: WalletRechargeCreateRequest): Promise<{
|
||||||
order: PaymentOrder
|
order: PaymentOrder
|
||||||
payment_instructions: Record<string, unknown>
|
payment_instructions: Record<string, unknown>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div
|
||||||
|
class="w-full"
|
||||||
|
:style="{ height: `${props.height}px` }"
|
||||||
|
>
|
||||||
<canvas ref="chartRef" />
|
<canvas ref="chartRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -18,6 +21,7 @@ import {
|
|||||||
type ChartData,
|
type ChartData,
|
||||||
type ChartOptions
|
type ChartOptions
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
|
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 300,
|
height: 300,
|
||||||
@@ -44,6 +48,7 @@ interface Props {
|
|||||||
|
|
||||||
const chartRef = ref<HTMLCanvasElement>()
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
let chart: ChartJS<'bar'> | null = null
|
let chart: ChartJS<'bar'> | null = null
|
||||||
|
let stopThemeObserver: (() => void) | null = null
|
||||||
|
|
||||||
const defaultOptions: ChartOptions<'bar'> = {
|
const defaultOptions: ChartOptions<'bar'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createChart() {
|
function buildChartOptions(): ChartOptions<'bar'> {
|
||||||
if (!chartRef.value) return
|
|
||||||
|
|
||||||
const stackedOptions = props.stacked ? {
|
const stackedOptions = props.stacked ? {
|
||||||
scales: {
|
scales: {
|
||||||
x: { ...defaultOptions.scales?.x, stacked: true },
|
x: { ...defaultOptions.scales?.x, stacked: true },
|
||||||
@@ -106,20 +109,26 @@ function createChart() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
chart = new ChartJS(chartRef.value, {
|
return resolveChartTheme({
|
||||||
type: 'bar',
|
|
||||||
data: props.data,
|
|
||||||
options: {
|
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
...stackedOptions,
|
...stackedOptions,
|
||||||
...props.options
|
...props.options
|
||||||
}
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function createChart() {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
chart = new ChartJS(chartRef.value, {
|
||||||
|
type: 'bar',
|
||||||
|
data: resolveChartTheme(props.data),
|
||||||
|
options: buildChartOptions()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.data = props.data
|
chart.data = resolveChartTheme(props.data)
|
||||||
chart.update('none')
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -127,9 +136,17 @@ function updateChart() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
createChart()
|
createChart()
|
||||||
|
stopThemeObserver = observeChartThemeChanges(() => {
|
||||||
|
if (!chart) return
|
||||||
|
chart.data = resolveChartTheme(props.data)
|
||||||
|
chart.options = buildChartOptions()
|
||||||
|
chart.update('none')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopThemeObserver?.()
|
||||||
|
stopThemeObserver = null
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy()
|
chart.destroy()
|
||||||
chart = null
|
chart = null
|
||||||
@@ -139,11 +156,8 @@ onUnmounted(() => {
|
|||||||
watch(() => props.data, updateChart, { deep: true })
|
watch(() => props.data, updateChart, { deep: true })
|
||||||
watch(() => props.options, () => {
|
watch(() => props.options, () => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.options = {
|
chart.options = buildChartOptions()
|
||||||
...defaultOptions,
|
chart.update('none')
|
||||||
...props.options
|
|
||||||
}
|
|
||||||
chart.update()
|
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div
|
||||||
|
class="w-full"
|
||||||
|
:style="{ height: `${props.height}px` }"
|
||||||
|
>
|
||||||
<canvas ref="chartRef" />
|
<canvas ref="chartRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,10 +19,12 @@ import {
|
|||||||
type ChartData,
|
type ChartData,
|
||||||
type ChartOptions
|
type ChartOptions
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
|
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 300,
|
height: 300,
|
||||||
options: undefined
|
options: undefined,
|
||||||
|
showLegend: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
@@ -34,15 +39,22 @@ interface Props {
|
|||||||
data: ChartData<'doughnut'>
|
data: ChartData<'doughnut'>
|
||||||
options?: ChartOptions<'doughnut'>
|
options?: ChartOptions<'doughnut'>
|
||||||
height?: number
|
height?: number
|
||||||
|
showLegend?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartRef = ref<HTMLCanvasElement>()
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
let chart: ChartJS<'doughnut'> | null = null
|
let chart: ChartJS<'doughnut'> | null = null
|
||||||
|
let stopThemeObserver: (() => void) | null = null
|
||||||
|
|
||||||
const defaultOptions: ChartOptions<'doughnut'> = {
|
const defaultOptions: ChartOptions<'doughnut'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
maintainAspectRatio: false,
|
maintainAspectRatio: false,
|
||||||
cutout: '60%',
|
cutout: '60%',
|
||||||
|
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
|
||||||
|
interaction: {
|
||||||
|
mode: 'nearest',
|
||||||
|
intersect: true,
|
||||||
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'right',
|
position: 'right',
|
||||||
@@ -71,22 +83,59 @@ const defaultOptions: ChartOptions<'doughnut'> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolvedOptions(): ChartOptions<'doughnut'> {
|
||||||
|
const incomingOptions = props.options ?? {}
|
||||||
|
const incomingPlugins = (incomingOptions.plugins ?? {}) as Record<string, unknown>
|
||||||
|
const incomingLegend = (incomingPlugins.legend ?? {}) as Record<string, unknown>
|
||||||
|
const incomingTooltip = (incomingPlugins.tooltip ?? {}) as Record<string, unknown>
|
||||||
|
const defaultPlugins = (defaultOptions.plugins ?? {}) as Record<string, unknown>
|
||||||
|
const defaultLegend = (defaultPlugins.legend ?? {}) as Record<string, unknown>
|
||||||
|
const defaultTooltip = (defaultPlugins.tooltip ?? {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
return resolveChartTheme({
|
||||||
|
...defaultOptions,
|
||||||
|
...incomingOptions,
|
||||||
|
interaction: {
|
||||||
|
...defaultOptions.interaction,
|
||||||
|
...incomingOptions.interaction,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
...defaultPlugins,
|
||||||
|
...incomingPlugins,
|
||||||
|
legend: {
|
||||||
|
...defaultLegend,
|
||||||
|
...incomingLegend,
|
||||||
|
display: incomingLegend.display ?? props.showLegend,
|
||||||
|
labels: {
|
||||||
|
...(defaultLegend.labels as Record<string, unknown> | undefined),
|
||||||
|
...(incomingLegend.labels as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
...defaultTooltip,
|
||||||
|
...incomingTooltip,
|
||||||
|
callbacks: {
|
||||||
|
...(defaultTooltip.callbacks as Record<string, unknown> | undefined),
|
||||||
|
...(incomingTooltip.callbacks as Record<string, unknown> | undefined),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function createChart() {
|
function createChart() {
|
||||||
if (!chartRef.value) return
|
if (!chartRef.value) return
|
||||||
|
|
||||||
chart = new ChartJS(chartRef.value, {
|
chart = new ChartJS(chartRef.value, {
|
||||||
type: 'doughnut',
|
type: 'doughnut',
|
||||||
data: props.data,
|
data: resolveChartTheme(props.data),
|
||||||
options: {
|
options: resolvedOptions(),
|
||||||
...defaultOptions,
|
|
||||||
...props.options
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.data = props.data
|
chart.data = resolveChartTheme(props.data)
|
||||||
chart.update('none')
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -94,9 +143,17 @@ function updateChart() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
createChart()
|
createChart()
|
||||||
|
stopThemeObserver = observeChartThemeChanges(() => {
|
||||||
|
if (!chart) return
|
||||||
|
chart.data = resolveChartTheme(props.data)
|
||||||
|
chart.options = resolvedOptions()
|
||||||
|
chart.update('none')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopThemeObserver?.()
|
||||||
|
stopThemeObserver = null
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy()
|
chart.destroy()
|
||||||
chart = null
|
chart = null
|
||||||
@@ -106,8 +163,15 @@ onUnmounted(() => {
|
|||||||
watch(() => props.data, updateChart, { deep: true })
|
watch(() => props.data, updateChart, { deep: true })
|
||||||
watch(() => props.options, () => {
|
watch(() => props.options, () => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.options = { ...defaultOptions, ...props.options }
|
chart.options = resolvedOptions()
|
||||||
chart.update()
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(() => props.showLegend, () => {
|
||||||
|
if (chart) {
|
||||||
|
chart.options = resolvedOptions()
|
||||||
|
chart.update('none')
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="w-full h-full">
|
<div
|
||||||
|
class="w-full"
|
||||||
|
:style="{ height: `${props.height}px` }"
|
||||||
|
>
|
||||||
<canvas ref="chartRef" />
|
<canvas ref="chartRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -16,9 +19,11 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend,
|
Legend,
|
||||||
|
Filler,
|
||||||
type ChartData,
|
type ChartData,
|
||||||
type ChartOptions
|
type ChartOptions
|
||||||
} from 'chart.js'
|
} from 'chart.js'
|
||||||
|
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
height: 300,
|
height: 300,
|
||||||
@@ -34,7 +39,8 @@ ChartJS.register(
|
|||||||
LineController,
|
LineController,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Legend
|
Legend,
|
||||||
|
Filler
|
||||||
)
|
)
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -45,6 +51,18 @@ interface Props {
|
|||||||
|
|
||||||
const chartRef = ref<HTMLCanvasElement>()
|
const chartRef = ref<HTMLCanvasElement>()
|
||||||
let chart: ChartJS<'line'> | null = null
|
let chart: ChartJS<'line'> | null = null
|
||||||
|
let stopThemeObserver: (() => void) | null = null
|
||||||
|
|
||||||
|
function buildChartOptions(): ChartOptions<'line'> {
|
||||||
|
return resolveChartTheme({
|
||||||
|
...defaultOptions,
|
||||||
|
...props.options
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildChartData(): ChartData<'line'> {
|
||||||
|
return resolveChartTheme(props.data)
|
||||||
|
}
|
||||||
|
|
||||||
const defaultOptions: ChartOptions<'line'> = {
|
const defaultOptions: ChartOptions<'line'> = {
|
||||||
responsive: true,
|
responsive: true,
|
||||||
@@ -88,17 +106,14 @@ function createChart() {
|
|||||||
|
|
||||||
chart = new ChartJS(chartRef.value, {
|
chart = new ChartJS(chartRef.value, {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
data: props.data,
|
data: buildChartData(),
|
||||||
options: {
|
options: buildChartOptions()
|
||||||
...defaultOptions,
|
|
||||||
...props.options
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.data = props.data
|
chart.data = buildChartData()
|
||||||
chart.update('none') // 禁用动画以提高性能
|
chart.update('none') // 禁用动画以提高性能
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,24 +121,29 @@ function updateChart() {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
createChart()
|
createChart()
|
||||||
|
stopThemeObserver = observeChartThemeChanges(() => {
|
||||||
|
if (!chart) return
|
||||||
|
chart.data = buildChartData()
|
||||||
|
chart.options = buildChartOptions()
|
||||||
|
chart.update('none')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
|
stopThemeObserver?.()
|
||||||
|
stopThemeObserver = null
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.destroy()
|
chart.destroy()
|
||||||
chart = null
|
chart = null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听数据变化
|
// 监听引用变化,避免深监听触发整图重算
|
||||||
watch(() => props.data, updateChart, { deep: true })
|
watch(() => props.data, updateChart)
|
||||||
watch(() => props.options, () => {
|
watch(() => props.options, () => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.options = {
|
chart.options = buildChartOptions()
|
||||||
...defaultOptions,
|
chart.update('none')
|
||||||
...props.options
|
|
||||||
}
|
}
|
||||||
chart.update()
|
})
|
||||||
}
|
|
||||||
}, { deep: true })
|
|
||||||
</script>
|
</script>
|
||||||
@@ -119,6 +119,11 @@ let chart: ChartJS<'scatter'> | null = null
|
|||||||
const crosshairY = ref<number | null>(null)
|
const crosshairY = ref<number | null>(null)
|
||||||
const gapInfoList = ref<GapInfo[]>([])
|
const gapInfoList = ref<GapInfo[]>([])
|
||||||
|
|
||||||
|
interface PreparedRenderData {
|
||||||
|
chartData: ChartData<'scatter'>
|
||||||
|
gaps: GapInfo[]
|
||||||
|
}
|
||||||
|
|
||||||
const crosshairStats = computed<CrosshairStats | null>(() => {
|
const crosshairStats = computed<CrosshairStats | null>(() => {
|
||||||
if (crosshairY.value === null || !props.data.datasets) return null
|
if (crosshairY.value === null || !props.data.datasets) return null
|
||||||
|
|
||||||
@@ -294,6 +299,22 @@ function transformData(data: ChartData<'scatter'>): ChartData<'scatter'> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function prepareRenderData(): PreparedRenderData {
|
||||||
|
let dataToUse = props.data
|
||||||
|
let gaps: GapInfo[] = []
|
||||||
|
|
||||||
|
if (props.compressGaps) {
|
||||||
|
const compressedResult = compressTimeGaps(props.data)
|
||||||
|
dataToUse = compressedResult.data
|
||||||
|
gaps = compressedResult.gaps
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
chartData: transformData(dataToUse),
|
||||||
|
gaps
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 格式化时长
|
// 格式化时长
|
||||||
function formatDuration(ms: number): string {
|
function formatDuration(ms: number): string {
|
||||||
const hours = Math.floor(ms / (1000 * 60 * 60))
|
const hours = Math.floor(ms / (1000 * 60 * 60))
|
||||||
@@ -516,22 +537,12 @@ function handleMouseLeave() {
|
|||||||
function createChart() {
|
function createChart() {
|
||||||
if (!chartRef.value) return
|
if (!chartRef.value) return
|
||||||
|
|
||||||
let dataToUse = props.data
|
const { chartData, gaps } = prepareRenderData()
|
||||||
gapInfoList.value = []
|
|
||||||
|
|
||||||
// 如果启用间隙压缩
|
|
||||||
if (props.compressGaps) {
|
|
||||||
const { data: compressedData, gaps } = compressTimeGaps(props.data)
|
|
||||||
dataToUse = compressedData
|
|
||||||
gapInfoList.value = gaps
|
gapInfoList.value = gaps
|
||||||
}
|
|
||||||
|
|
||||||
// 转换数据
|
|
||||||
const transformedData = transformData(dataToUse)
|
|
||||||
|
|
||||||
chart = new ChartJS(chartRef.value, {
|
chart = new ChartJS(chartRef.value, {
|
||||||
type: 'scatter',
|
type: 'scatter',
|
||||||
data: transformedData,
|
data: chartData,
|
||||||
options: {
|
options: {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
...props.options
|
...props.options
|
||||||
@@ -544,16 +555,9 @@ function createChart() {
|
|||||||
|
|
||||||
function updateChart() {
|
function updateChart() {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
let dataToUse = props.data
|
const { chartData, gaps } = prepareRenderData()
|
||||||
gapInfoList.value = []
|
|
||||||
|
|
||||||
if (props.compressGaps) {
|
|
||||||
const { data: compressedData, gaps } = compressTimeGaps(props.data)
|
|
||||||
dataToUse = compressedData
|
|
||||||
gapInfoList.value = gaps
|
gapInfoList.value = gaps
|
||||||
}
|
chart.data = chartData
|
||||||
|
|
||||||
chart.data = transformData(dataToUse)
|
|
||||||
chart.update('none')
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -573,21 +577,22 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(() => props.data, updateChart, { deep: true })
|
watch(
|
||||||
watch(() => props.compressGaps, () => {
|
[
|
||||||
if (chart) {
|
() => props.data,
|
||||||
chart.destroy()
|
() => props.compressGaps,
|
||||||
chart = null
|
() => props.gapThreshold,
|
||||||
}
|
() => props.compressedGapSize
|
||||||
createChart()
|
],
|
||||||
})
|
updateChart
|
||||||
|
)
|
||||||
watch(() => props.options, () => {
|
watch(() => props.options, () => {
|
||||||
if (chart) {
|
if (chart) {
|
||||||
chart.options = {
|
chart.options = {
|
||||||
...defaultOptions,
|
...defaultOptions,
|
||||||
...props.options
|
...props.options
|
||||||
}
|
}
|
||||||
chart.update()
|
chart.update('none')
|
||||||
}
|
}
|
||||||
}, { deep: true })
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
ref="fileInputRef"
|
ref="fileInputRef"
|
||||||
type="file"
|
type="file"
|
||||||
:accept="accept"
|
:accept="accept"
|
||||||
multiple
|
:multiple="multiple"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="handleFileSelect"
|
@change="handleFileSelect"
|
||||||
>
|
>
|
||||||
@@ -94,6 +94,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
resetKey?: string | number
|
resetKey?: string | number
|
||||||
accept?: string
|
accept?: string
|
||||||
|
multiple?: boolean
|
||||||
dropTitle?: string
|
dropTitle?: string
|
||||||
dropHint?: string
|
dropHint?: string
|
||||||
manualLabel?: string
|
manualLabel?: string
|
||||||
@@ -106,6 +107,7 @@ const props = withDefaults(defineProps<{
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
resetKey: '',
|
resetKey: '',
|
||||||
accept: '.json,.txt',
|
accept: '.json,.txt',
|
||||||
|
multiple: true,
|
||||||
dropTitle: '拖入导入文件或点击选择',
|
dropTitle: '拖入导入文件或点击选择',
|
||||||
dropHint: '支持 .json / .txt,可多选',
|
dropHint: '支持 .json / .txt,可多选',
|
||||||
manualLabel: '',
|
manualLabel: '',
|
||||||
@@ -218,13 +220,19 @@ function mergeFileContents(contents: string[]): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function readFiles(files: File[]) {
|
async function readFiles(files: File[]) {
|
||||||
const validFiles = files.filter(isValidFileType)
|
const sourceFiles = props.multiple ? files : files.slice(0, 1)
|
||||||
|
|
||||||
|
if (!props.multiple && files.length > 1) {
|
||||||
|
emitError('仅支持选择 1 个文件,已读取第一个文件', '提示')
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles = sourceFiles.filter(isValidFileType)
|
||||||
if (validFiles.length === 0) {
|
if (validFiles.length === 0) {
|
||||||
emitError('仅支持 .json 或 .txt 文件', '格式错误')
|
emitError('仅支持 .json 或 .txt 文件', '格式错误')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (validFiles.length < files.length) {
|
if (validFiles.length < sourceFiles.length) {
|
||||||
emitError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
|
emitError(`已忽略 ${sourceFiles.length - validFiles.length} 个不支持的文件`, '提示')
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-2">
|
|
||||||
<Label class="text-sm font-medium">允许的模型</Label>
|
|
||||||
<div class="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="w-full h-10 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors"
|
|
||||||
@click="isOpen = !isOpen"
|
|
||||||
>
|
|
||||||
<span :class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'">
|
|
||||||
{{ modelValue.length ? `已选择 ${modelValue.length} 个` : '全部可用' }}
|
|
||||||
<span
|
|
||||||
v-if="invalidModels.length"
|
|
||||||
class="text-destructive"
|
|
||||||
>({{ invalidModels.length }} 个已失效)</span>
|
|
||||||
</span>
|
|
||||||
<ChevronDown
|
|
||||||
class="h-4 w-4 text-muted-foreground transition-transform"
|
|
||||||
:class="isOpen ? 'rotate-180' : ''"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
<div
|
|
||||||
v-if="isOpen"
|
|
||||||
class="fixed inset-0 z-[80]"
|
|
||||||
@click.stop="isOpen = false"
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
v-if="isOpen"
|
|
||||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<!-- 失效模型(置顶显示,只能取消选择) -->
|
|
||||||
<div
|
|
||||||
v-for="modelName in invalidModels"
|
|
||||||
:key="modelName"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer bg-destructive/5"
|
|
||||||
@click="removeModel(modelName)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="true"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
|
||||||
@click.stop
|
|
||||||
@change="removeModel(modelName)"
|
|
||||||
>
|
|
||||||
<span class="text-sm text-destructive">{{ modelName }}</span>
|
|
||||||
<span class="text-xs text-destructive/70">(已失效)</span>
|
|
||||||
</div>
|
|
||||||
<!-- 有效模型 -->
|
|
||||||
<div
|
|
||||||
v-for="model in models"
|
|
||||||
:key="model.name"
|
|
||||||
class="flex items-center gap-2 px-3 py-2 hover:bg-muted/50 cursor-pointer"
|
|
||||||
@click="toggleModel(model.name)"
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
:checked="modelValue.includes(model.name)"
|
|
||||||
class="h-4 w-4 rounded border-gray-300 cursor-pointer"
|
|
||||||
@click.stop
|
|
||||||
@change="toggleModel(model.name)"
|
|
||||||
>
|
|
||||||
<span class="text-sm">{{ model.name }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="models.length === 0 && invalidModels.length === 0"
|
|
||||||
class="px-3 py-2 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
暂无可用模型
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
import { Label } from '@/components/ui'
|
|
||||||
import { ChevronDown } from 'lucide-vue-next'
|
|
||||||
import { useInvalidModels } from '@/composables/useInvalidModels'
|
|
||||||
|
|
||||||
export interface ModelWithName {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<{
|
|
||||||
modelValue: string[]
|
|
||||||
models: ModelWithName[]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const emit = defineEmits<{
|
|
||||||
'update:modelValue': [value: string[]]
|
|
||||||
}>()
|
|
||||||
|
|
||||||
const isOpen = ref(false)
|
|
||||||
|
|
||||||
// 检测失效模型
|
|
||||||
const { invalidModels } = useInvalidModels(
|
|
||||||
computed(() => props.modelValue),
|
|
||||||
computed(() => props.models)
|
|
||||||
)
|
|
||||||
|
|
||||||
function toggleModel(name: string) {
|
|
||||||
const newValue = [...props.modelValue]
|
|
||||||
const index = newValue.indexOf(name)
|
|
||||||
if (index === -1) {
|
|
||||||
newValue.push(name)
|
|
||||||
} else {
|
|
||||||
newValue.splice(index, 1)
|
|
||||||
}
|
|
||||||
emit('update:modelValue', newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeModel(name: string) {
|
|
||||||
const newValue = props.modelValue.filter(m => m !== name)
|
|
||||||
emit('update:modelValue', newValue)
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -2,21 +2,28 @@
|
|||||||
<div class="relative">
|
<div class="relative">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:class="cn(
|
:class="
|
||||||
'h-9 px-3 border rounded-lg bg-background text-left flex items-center justify-between hover:bg-muted/50 transition-colors gap-1',
|
cn(
|
||||||
|
'flex h-10 w-full items-center justify-between rounded-lg border bg-background px-3 text-left transition-colors',
|
||||||
|
disabled ? 'cursor-not-allowed opacity-60 hover:bg-background' : 'hover:bg-muted/50',
|
||||||
triggerClass,
|
triggerClass,
|
||||||
)"
|
)
|
||||||
|
"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="isOpen = !isOpen"
|
@click="isOpen = !isOpen"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
:class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'"
|
:class="modelValue.length ? 'text-foreground' : 'text-muted-foreground'"
|
||||||
class="text-xs truncate"
|
class="truncate text-sm"
|
||||||
>
|
>
|
||||||
{{ displayText }}
|
{{ displayText }}
|
||||||
|
<span
|
||||||
|
v-if="invalidItems.length"
|
||||||
|
class="text-destructive"
|
||||||
|
>({{ invalidItems.length }} 个已失效)</span>
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown
|
<ChevronDown
|
||||||
class="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform"
|
class="h-4 w-4 shrink-0 text-muted-foreground transition-transform"
|
||||||
:class="isOpen ? 'rotate-180' : ''"
|
:class="isOpen ? 'rotate-180' : ''"
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
@@ -27,76 +34,163 @@
|
|||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
class="absolute z-[90] w-full mt-1 bg-popover border rounded-lg shadow-lg max-h-48 overflow-y-auto"
|
class="absolute z-[90] w-full mt-1 rounded-lg border bg-popover shadow-lg"
|
||||||
:style="dropdownMinWidth ? { minWidth: dropdownMinWidth } : undefined"
|
:style="dropdownMinWidth ? { minWidth: dropdownMinWidth } : undefined"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="item in options"
|
v-if="showSearch"
|
||||||
|
class="sticky top-0 z-10 border-b bg-popover/95 p-1 backdrop-blur supports-[backdrop-filter]:bg-popover/85"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
v-model="searchQuery"
|
||||||
|
:placeholder="searchPlaceholder"
|
||||||
|
class="h-8 rounded-md border-border/60 bg-popover pl-8 text-xs"
|
||||||
|
@keydown.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="max-h-48 overflow-y-auto">
|
||||||
|
<div
|
||||||
|
v-for="item in filteredInvalidItems"
|
||||||
|
:key="'invalid-' + item"
|
||||||
|
class="flex cursor-pointer items-center gap-2 bg-destructive/5 px-3 py-2 hover:bg-muted/50"
|
||||||
|
@click="remove(item)"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="true"
|
||||||
|
class="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300"
|
||||||
|
@click.stop
|
||||||
|
@change="remove(item)"
|
||||||
|
>
|
||||||
|
<span class="min-w-0 truncate text-sm text-destructive">{{ item }}</span>
|
||||||
|
<span class="shrink-0 text-xs text-destructive/70">(已失效)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-for="item in filteredOptions"
|
||||||
:key="item.value"
|
:key="item.value"
|
||||||
class="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 cursor-pointer text-xs"
|
class="flex cursor-pointer items-center gap-2 px-3 py-2 hover:bg-muted/50"
|
||||||
@click="toggle(item.value)"
|
@click="toggle(item.value)"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="modelValue.includes(item.value)"
|
:checked="modelValue.includes(item.value)"
|
||||||
class="h-4 w-4 rounded border-border/60 bg-card/80 text-primary shadow-sm accent-primary cursor-pointer"
|
class="h-4 w-4 shrink-0 cursor-pointer rounded border-gray-300"
|
||||||
@click.stop
|
@click.stop
|
||||||
@change="toggle(item.value)"
|
@change="toggle(item.value)"
|
||||||
>
|
>
|
||||||
<span class="text-sm">{{ item.label }}</span>
|
<span class="min-w-0 truncate text-sm">{{ item.label }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="options.length === 0"
|
v-if="filteredOptions.length === 0 && filteredInvalidItems.length === 0"
|
||||||
class="px-3 py-2 text-sm text-muted-foreground"
|
class="px-3 py-2 text-sm text-muted-foreground"
|
||||||
>
|
>
|
||||||
{{ emptyText }}
|
{{ searchQuery.trim() ? noResultsText : emptyText }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import { ChevronDown } from 'lucide-vue-next'
|
import { ChevronDown, Search } from 'lucide-vue-next'
|
||||||
|
import { Input } from '@/components/ui'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { matchesSearchQuery } from '@/utils/search'
|
||||||
|
|
||||||
export interface MultiSelectOption {
|
export interface MultiSelectOption {
|
||||||
value: string
|
value: string
|
||||||
label: string
|
label: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
modelValue: string[]
|
modelValue: string[]
|
||||||
options: MultiSelectOption[]
|
options: MultiSelectOption[]
|
||||||
placeholder?: string
|
placeholder?: string
|
||||||
emptyText?: string
|
emptyText?: string
|
||||||
|
noResultsText?: string
|
||||||
triggerClass?: string
|
triggerClass?: string
|
||||||
dropdownMinWidth?: string
|
dropdownMinWidth?: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
}>(), {
|
searchable?: boolean
|
||||||
|
searchThreshold?: number
|
||||||
|
searchPlaceholder?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
placeholder: '请选择',
|
placeholder: '请选择',
|
||||||
emptyText: '暂无选项',
|
emptyText: '暂无选项',
|
||||||
|
noResultsText: '未找到匹配项',
|
||||||
triggerClass: '',
|
triggerClass: '',
|
||||||
dropdownMinWidth: undefined,
|
dropdownMinWidth: undefined,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
})
|
searchable: true,
|
||||||
|
searchThreshold: 8,
|
||||||
|
searchPlaceholder: '输入关键词搜索...',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string[]]
|
'update:modelValue': [value: string[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const isOpen = ref(false)
|
const isOpen = ref(false)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
const validValues = computed(() => new Set(props.options.map(o => o.value)))
|
||||||
|
|
||||||
|
const invalidItems = computed(() =>
|
||||||
|
props.modelValue.filter(v => !validValues.value.has(v)),
|
||||||
|
)
|
||||||
|
|
||||||
|
const totalCount = computed(() => props.options.length + invalidItems.value.length)
|
||||||
|
|
||||||
|
const showSearch = computed(
|
||||||
|
() => props.searchable && totalCount.value >= props.searchThreshold,
|
||||||
|
)
|
||||||
|
|
||||||
|
const filteredInvalidItems = computed(() => {
|
||||||
|
if (!showSearch.value || !searchQuery.value.trim()) {
|
||||||
|
return invalidItems.value
|
||||||
|
}
|
||||||
|
return invalidItems.value.filter((item) =>
|
||||||
|
matchesSearchQuery(searchQuery.value, item),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
const filteredOptions = computed(() => {
|
||||||
|
if (!showSearch.value || !searchQuery.value.trim()) {
|
||||||
|
return props.options
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.options.filter((item) =>
|
||||||
|
matchesSearchQuery(searchQuery.value, item.label, item.value),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const displayText = computed(() => {
|
const displayText = computed(() => {
|
||||||
if (props.modelValue.length === 0) return props.placeholder
|
if (props.modelValue.length === 0) return props.placeholder
|
||||||
if (props.modelValue.length <= 2) {
|
if (props.modelValue.length <= 2) {
|
||||||
return props.modelValue
|
return props.modelValue
|
||||||
.map(v => props.options.find(o => o.value === v)?.label ?? v)
|
.map((v) => props.options.find((o) => o.value === v)?.label ?? v)
|
||||||
.join(', ')
|
.join(', ')
|
||||||
}
|
}
|
||||||
return `已选择 ${props.modelValue.length} 项`
|
return `已选择 ${props.modelValue.length} 项`
|
||||||
})
|
})
|
||||||
|
|
||||||
|
watch(isOpen, (open) => {
|
||||||
|
if (!open) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function toggle(value: string) {
|
function toggle(value: string) {
|
||||||
const newValue = [...props.modelValue]
|
const newValue = [...props.modelValue]
|
||||||
const index = newValue.indexOf(value)
|
const index = newValue.indexOf(value)
|
||||||
@@ -107,4 +201,8 @@ function toggle(value: string) {
|
|||||||
}
|
}
|
||||||
emit('update:modelValue', newValue)
|
emit('update:modelValue', newValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function remove(value: string) {
|
||||||
|
emit('update:modelValue', props.modelValue.filter(v => v !== value))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,39 +3,24 @@
|
|||||||
<Select
|
<Select
|
||||||
v-model="selectedPreset"
|
v-model="selectedPreset"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
|
<SelectTrigger :class="presetTriggerClass">
|
||||||
<SelectValue placeholder="选择时间段" />
|
<SelectValue placeholder="选择时间段" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="today">
|
<SelectItem value="today">
|
||||||
今天
|
今天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="yesterday">
|
|
||||||
昨天
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last7days">
|
<SelectItem value="last7days">
|
||||||
最近7天
|
最近7天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="last30days">
|
<SelectItem value="last30days">
|
||||||
最近30天
|
最近30天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="last90days">
|
<SelectItem value="last180days">
|
||||||
最近90天
|
最近180天
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="this_week">
|
<SelectItem value="last1year">
|
||||||
本周
|
最近一年
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last_week">
|
|
||||||
上周
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="this_month">
|
|
||||||
本月
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="last_month">
|
|
||||||
上月
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="this_year">
|
|
||||||
今年
|
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
<SelectItem value="custom">
|
<SelectItem value="custom">
|
||||||
自定义
|
自定义
|
||||||
@@ -50,13 +35,13 @@
|
|||||||
<Input
|
<Input
|
||||||
v-model="startDate"
|
v-model="startDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="h-8 w-36 text-xs border-border/60"
|
:class="dateInputClass"
|
||||||
/>
|
/>
|
||||||
<span class="text-xs text-muted-foreground">至</span>
|
<span class="text-xs text-muted-foreground">至</span>
|
||||||
<Input
|
<Input
|
||||||
v-model="endDate"
|
v-model="endDate"
|
||||||
type="date"
|
type="date"
|
||||||
class="h-8 w-36 text-xs border-border/60"
|
:class="dateInputClass"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -64,10 +49,16 @@
|
|||||||
v-if="showGranularity"
|
v-if="showGranularity"
|
||||||
v-model="selectedGranularity"
|
v-model="selectedGranularity"
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
|
<SelectTrigger :class="granularityTriggerClass">
|
||||||
<SelectValue placeholder="粒度" />
|
<SelectValue placeholder="粒度" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
<SelectItem
|
||||||
|
v-if="includeAutoGranularity"
|
||||||
|
value="auto"
|
||||||
|
>
|
||||||
|
自动
|
||||||
|
</SelectItem>
|
||||||
<SelectItem
|
<SelectItem
|
||||||
v-if="allowHourly && canUseHourly"
|
v-if="allowHourly && canUseHourly"
|
||||||
value="hour"
|
value="hour"
|
||||||
@@ -100,26 +91,51 @@ import {
|
|||||||
} from '@/components/ui'
|
} from '@/components/ui'
|
||||||
import type { DateRangeParams } from '@/features/usage/types'
|
import type { DateRangeParams } from '@/features/usage/types'
|
||||||
|
|
||||||
|
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
modelValue: DateRangeParams
|
modelValue: DateRangeParams
|
||||||
showGranularity?: boolean
|
showGranularity?: boolean
|
||||||
allowHourly?: boolean
|
allowHourly?: boolean
|
||||||
|
includeAutoGranularity?: boolean
|
||||||
|
compact?: boolean
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: DateRangeParams]
|
'update:modelValue': [value: DateRangeParams]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const selectedPreset = ref(props.modelValue.preset || 'last7days')
|
function getDefaultGranularity(): PickerGranularity {
|
||||||
|
return props.includeAutoGranularity ? 'auto' : 'day'
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedPreset = ref(props.modelValue.preset || 'today')
|
||||||
const startDate = ref(props.modelValue.start_date || '')
|
const startDate = ref(props.modelValue.start_date || '')
|
||||||
const endDate = ref(props.modelValue.end_date || '')
|
const endDate = ref(props.modelValue.end_date || '')
|
||||||
const selectedGranularity = ref(props.modelValue.granularity || 'day')
|
const selectedGranularity = ref<PickerGranularity>(props.modelValue.granularity || getDefaultGranularity())
|
||||||
|
|
||||||
const showGranularity = computed(() => props.showGranularity !== false)
|
const showGranularity = computed(() => props.showGranularity !== false)
|
||||||
const allowHourly = computed(() => props.allowHourly === true)
|
const allowHourly = computed(() => props.allowHourly === true)
|
||||||
|
const includeAutoGranularity = computed(() => props.includeAutoGranularity === true)
|
||||||
|
const compact = computed(() => props.compact === true)
|
||||||
|
const presetTriggerClass = computed(() =>
|
||||||
|
compact.value
|
||||||
|
? 'h-8 w-28 text-xs border-border/60'
|
||||||
|
: 'h-8 w-32 text-xs border-border/60',
|
||||||
|
)
|
||||||
|
const dateInputClass = computed(() =>
|
||||||
|
compact.value
|
||||||
|
? 'h-8 w-32 text-xs border-border/60'
|
||||||
|
: 'h-8 w-36 text-xs border-border/60',
|
||||||
|
)
|
||||||
|
const granularityTriggerClass = computed(() =>
|
||||||
|
compact.value
|
||||||
|
? 'h-8 w-20 text-xs border-border/60'
|
||||||
|
: 'h-8 w-24 text-xs border-border/60',
|
||||||
|
)
|
||||||
|
|
||||||
const canUseHourly = computed(() => {
|
const canUseHourly = computed(() => {
|
||||||
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
|
if (selectedPreset.value === 'today') return true
|
||||||
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
|
if (selectedPreset.value === 'custom' && startDate.value && endDate.value) {
|
||||||
return startDate.value === endDate.value
|
return startDate.value === endDate.value
|
||||||
}
|
}
|
||||||
@@ -165,7 +181,7 @@ watch(() => props.modelValue, (value) => {
|
|||||||
if (value.preset) selectedPreset.value = value.preset
|
if (value.preset) selectedPreset.value = value.preset
|
||||||
if (value.start_date !== undefined) startDate.value = value.start_date || ''
|
if (value.start_date !== undefined) startDate.value = value.start_date || ''
|
||||||
if (value.end_date !== undefined) endDate.value = value.end_date || ''
|
if (value.end_date !== undefined) endDate.value = value.end_date || ''
|
||||||
if (value.granularity) selectedGranularity.value = value.granularity
|
selectedGranularity.value = value.granularity || getDefaultGranularity()
|
||||||
// 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit
|
// 同步更新 lastEmittedValue,避免外部设置值后触发重复 emit
|
||||||
lastEmittedValue = getValueKey(value)
|
lastEmittedValue = getValueKey(value)
|
||||||
}, { deep: true })
|
}, { deep: true })
|
||||||
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
|
|||||||
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
||||||
if (!allowHourly.value || !canUseHourly.value) {
|
if (!allowHourly.value || !canUseHourly.value) {
|
||||||
if (selectedGranularity.value === 'hour') {
|
if (selectedGranularity.value === 'hour') {
|
||||||
selectedGranularity.value = 'day'
|
selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,5 @@ export { default as AlertDialog } from './AlertDialog.vue'
|
|||||||
export { default as LoadingState } from './LoadingState.vue'
|
export { default as LoadingState } from './LoadingState.vue'
|
||||||
|
|
||||||
// 表单组件
|
// 表单组件
|
||||||
export { default as ModelMultiSelect } from './ModelMultiSelect.vue'
|
|
||||||
export { default as MultiSelect } from './MultiSelect.vue'
|
export { default as MultiSelect } from './MultiSelect.vue'
|
||||||
export { default as TimeRangePicker } from './TimeRangePicker.vue'
|
export { default as TimeRangePicker } from './TimeRangePicker.vue'
|
||||||
|
|||||||
@@ -13,8 +13,11 @@
|
|||||||
<p class="mt-0.5">
|
<p class="mt-0.5">
|
||||||
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
|
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-[11px] text-muted-foreground">
|
<p class="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||||
成本 {{ formatCurrency(tooltip.day.total_cost) }}
|
{{ valueLabel }} {{ formatCurrency(tooltip.day.total_cost) }}
|
||||||
|
<template v-if="actualValueLabel && tooltip.day.actual_total_cost !== undefined">
|
||||||
|
· {{ actualValueLabel }} {{ formatCurrency(tooltip.day.actual_total_cost) }}
|
||||||
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</Teleport>
|
</Teleport>
|
||||||
@@ -90,7 +93,7 @@
|
|||||||
class="flex items-center invisible"
|
class="flex items-center invisible"
|
||||||
>周六</span>
|
>周六</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-[200px]">
|
<div class="flex-1 min-w-0">
|
||||||
<div
|
<div
|
||||||
ref="heatmapWrapper"
|
ref="heatmapWrapper"
|
||||||
class="relative block w-full"
|
class="relative block w-full"
|
||||||
@@ -100,12 +103,12 @@
|
|||||||
:style="horizontalGapStyle"
|
:style="horizontalGapStyle"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(week, weekIndex) in weekColumns"
|
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||||
:key="`month-${weekIndex}`"
|
:key="`month-${weekIndex}`"
|
||||||
:style="monthCellStyle"
|
:style="monthCellStyle"
|
||||||
class="text-center"
|
class="text-center"
|
||||||
>
|
>
|
||||||
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
|
<span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
@@ -113,7 +116,7 @@
|
|||||||
:style="horizontalGapStyle"
|
:style="horizontalGapStyle"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-for="(week, weekIndex) in weekColumns"
|
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||||
:key="weekIndex"
|
:key="weekIndex"
|
||||||
class="flex flex-col"
|
class="flex flex-col"
|
||||||
:style="verticalGapStyle"
|
:style="verticalGapStyle"
|
||||||
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
|
|||||||
title?: string
|
title?: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
showHeader?: boolean
|
showHeader?: boolean
|
||||||
|
valueLabel?: string
|
||||||
|
actualValueLabel?: string
|
||||||
}>(), {
|
}>(), {
|
||||||
data: undefined,
|
data: undefined,
|
||||||
title: undefined,
|
title: undefined,
|
||||||
subtitle: undefined,
|
subtitle: undefined,
|
||||||
showHeader: true
|
showHeader: true,
|
||||||
|
valueLabel: '费用',
|
||||||
|
actualValueLabel: undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||||
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
|
|||||||
if (month === lastMonth) {
|
if (month === lastMonth) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
markers[index] = `${month + 1}月`
|
markers[index] = String(month + 1)
|
||||||
|
lastMonth = month
|
||||||
|
})
|
||||||
|
|
||||||
|
return markers
|
||||||
|
})
|
||||||
|
|
||||||
|
const MIN_CELL_SIZE = 6
|
||||||
|
|
||||||
|
// 根据容器宽度计算最多能放多少周,然后从后面截取(保留最新数据)
|
||||||
|
const maxVisibleWeeks = computed(() => {
|
||||||
|
const total = weekColumns.value.length
|
||||||
|
if (!heatmapWidth.value || !total) return total
|
||||||
|
// 先尝试用理想尺寸放全部
|
||||||
|
const totalGapAll = Math.max(total - 1, 0) * cellGap.value
|
||||||
|
const rawSizeAll = (heatmapWidth.value - totalGapAll) / total
|
||||||
|
if (rawSizeAll >= MIN_CELL_SIZE) return total
|
||||||
|
// 放不下,算容器最多能容纳几周(用最小尺寸)
|
||||||
|
// n * MIN_CELL_SIZE + (n-1) * gap <= width
|
||||||
|
const maxN = Math.floor((heatmapWidth.value + cellGap.value) / (MIN_CELL_SIZE + cellGap.value))
|
||||||
|
return Math.max(1, Math.min(maxN, total))
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleWeekColumns = computed(() => {
|
||||||
|
const all = weekColumns.value
|
||||||
|
const n = maxVisibleWeeks.value
|
||||||
|
if (n >= all.length) return all
|
||||||
|
return all.slice(all.length - n)
|
||||||
|
})
|
||||||
|
|
||||||
|
const visibleMonthMarkers = computed(() => {
|
||||||
|
const markers: Record<number, string> = {}
|
||||||
|
const columns = visibleWeekColumns.value
|
||||||
|
let lastMonth: number | null = null
|
||||||
|
|
||||||
|
columns.forEach((week, index) => {
|
||||||
|
const firstValid = week.find((day): day is DayWithMeta => day !== null)
|
||||||
|
if (!firstValid) return
|
||||||
|
const month = firstValid.dateObj.getUTCMonth()
|
||||||
|
if (month === lastMonth) return
|
||||||
|
markers[index] = String(month + 1)
|
||||||
lastMonth = month
|
lastMonth = month
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
|
|||||||
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
|
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
|
||||||
|
|
||||||
const recalcCellSize = () => {
|
const recalcCellSize = () => {
|
||||||
const columnCount = weekColumns.value.length
|
const columnCount = visibleWeekColumns.value.length
|
||||||
if (!columnCount || !heatmapWidth.value) {
|
if (!columnCount || !heatmapWidth.value) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
|
|||||||
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
||||||
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
||||||
const rawSize = availableSpace / columnCount
|
const rawSize = availableSpace / columnCount
|
||||||
// 自适应尺寸,最小 6px
|
// 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
|
||||||
cellSize.value = Math.max(6, rawSize)
|
cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
|
[() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
|
||||||
() => {
|
() => {
|
||||||
recalcCellSize()
|
recalcCellSize()
|
||||||
},
|
},
|
||||||
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
|
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
|
||||||
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
const tooltipWidth = 200
|
const tooltipWidth = 280
|
||||||
const tooltipHeight = 72
|
const tooltipHeight = 72
|
||||||
|
|
||||||
// Calculate horizontal position (centered on cell)
|
// Calculate horizontal position (centered on cell)
|
||||||
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
|
|||||||
|
|
||||||
function buildTooltip(day: ActivityHeatmapDay): string {
|
function buildTooltip(day: ActivityHeatmapDay): string {
|
||||||
const dateLabel = day.date
|
const dateLabel = day.date
|
||||||
const costLabel = formatCurrency(day.total_cost || 0)
|
const costLabel = `${props.valueLabel} ${formatCurrency(day.total_cost || 0)}`
|
||||||
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
|
const parts = [`${dateLabel}`, `${day.requests} 次请求`, `${formatTokens(day.total_tokens)} tokens`, costLabel]
|
||||||
if (day.actual_total_cost !== undefined) {
|
if (props.actualValueLabel && day.actual_total_cost !== undefined) {
|
||||||
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
|
parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
|
||||||
}
|
}
|
||||||
return parts.join(' · ')
|
return parts.join(' · ')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="p-4 space-y-2">
|
|
||||||
<div class="text-xs text-muted-foreground">
|
|
||||||
{{ label }}
|
|
||||||
</div>
|
|
||||||
<div class="text-lg font-semibold">
|
|
||||||
{{ value }}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="text-xs"
|
|
||||||
:class="changeClass"
|
|
||||||
>
|
|
||||||
<span v-if="changePercent !== null">{{ changePercent }}%</span>
|
|
||||||
<span v-else>--</span>
|
|
||||||
<span class="ml-1 text-muted-foreground">vs 对比期</span>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import { Card } from '@/components/ui'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
changePercent: number | null
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
|
||||||
|
|
||||||
const changeClass = computed(() => {
|
|
||||||
if (props.changePercent === null) return 'text-muted-foreground'
|
|
||||||
if (props.changePercent > 0) return 'text-emerald-500'
|
|
||||||
if (props.changePercent < 0) return 'text-rose-500'
|
|
||||||
return 'text-muted-foreground'
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
<template>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
{{ title }}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
v-if="subtitle"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>{{ subtitle }}</span>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="p-6"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="h-[280px]"
|
|
||||||
>
|
|
||||||
<LineChart
|
|
||||||
:data="chartData"
|
|
||||||
:options="chartOptions"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
|
||||||
import { LoadingState } from '@/components/common'
|
|
||||||
import { formatCurrency } from '@/utils/format'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
history: Array<{ date: string; total_cost: number }>
|
|
||||||
forecast: Array<{ date: string; total_cost: number }>
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
|
||||||
subtitle: undefined,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
|
|
||||||
const labels = computed(() => [
|
|
||||||
...props.history.map(item => item.date),
|
|
||||||
...props.forecast.map(item => item.date)
|
|
||||||
])
|
|
||||||
|
|
||||||
const chartData = computed(() => {
|
|
||||||
const historyValues = props.history.map(item => item.total_cost)
|
|
||||||
const forecastValues = props.forecast.map(item => item.total_cost)
|
|
||||||
return {
|
|
||||||
labels: labels.value,
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
label: '实际成本',
|
|
||||||
data: historyValues.concat(new Array(forecastValues.length).fill(null)),
|
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
|
||||||
backgroundColor: 'rgba(59, 130, 246, 0.15)',
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: '预测成本',
|
|
||||||
data: new Array(historyValues.length).fill(null).concat(forecastValues),
|
|
||||||
borderColor: 'rgb(234, 179, 8)',
|
|
||||||
backgroundColor: 'rgba(234, 179, 8, 0.15)',
|
|
||||||
borderDash: [6, 4],
|
|
||||||
tension: 0.25,
|
|
||||||
pointRadius: 2
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
|
||||||
plugins: {
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (context: { parsed?: { y?: number }; dataset: { label?: string } }) => {
|
|
||||||
const value = context.parsed?.y ?? 0
|
|
||||||
return `${context.dataset.label}: ${formatCurrency(value)}`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
</script>
|
|
||||||
@@ -1,14 +1,34 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<p
|
||||||
v-if="subtitle"
|
v-if="subtitle"
|
||||||
class="text-xs text-muted-foreground"
|
class="mt-1 text-[11px] text-muted-foreground"
|
||||||
>{{ subtitle }}</span>
|
>
|
||||||
|
{{ subtitle }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!unavailable"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<div class="rounded-full border border-rose-500/20 bg-rose-500/8 px-2.5 py-1 text-[10px] font-medium text-rose-700 dark:text-rose-300">
|
||||||
|
总错误 {{ totalErrors }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="topCategory"
|
||||||
|
class="rounded-full border border-border/70 bg-muted/20 px-2.5 py-1 text-[10px] font-medium text-muted-foreground"
|
||||||
|
>
|
||||||
|
TOP {{ topCategory.label }} · {{ topCategory.count }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="loading"
|
v-if="loading"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
@@ -16,57 +36,116 @@
|
|||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="unavailable"
|
||||||
class="h-[260px]"
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
>
|
>
|
||||||
|
{{ unavailableText }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!hasData"
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无错误分布数据
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else
|
||||||
|
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||||
|
>
|
||||||
|
<div class="mx-auto w-full max-w-[320px]">
|
||||||
<DoughnutChart
|
<DoughnutChart
|
||||||
:data="chartData"
|
:data="chartData"
|
||||||
:options="chartOptions"
|
:options="chartOptions"
|
||||||
|
:show-legend="true"
|
||||||
|
:height="248"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js'
|
||||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||||
import { LoadingState } from '@/components/common'
|
import { LoadingState } from '@/components/common'
|
||||||
import type { ErrorDistributionItem } from '@/api/admin'
|
|
||||||
|
interface ErrorDistributionItem {
|
||||||
|
category: string
|
||||||
|
label: string
|
||||||
|
count: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
subtitle?: string
|
subtitle?: string
|
||||||
distribution: ErrorDistributionItem[]
|
distribution: ErrorDistributionItem[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
unavailable?: boolean
|
||||||
|
unavailableText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
subtitle: undefined,
|
subtitle: undefined,
|
||||||
loading: false
|
loading: false,
|
||||||
|
unavailable: false,
|
||||||
|
unavailableText: '数据暂不可用',
|
||||||
})
|
})
|
||||||
|
|
||||||
const chartData = computed(() => ({
|
const chartColors = [
|
||||||
labels: props.distribution.map(item => item.category),
|
'rgba(226, 85, 118, 0.88)',
|
||||||
|
'rgba(84, 135, 237, 0.86)',
|
||||||
|
'rgba(225, 165, 52, 0.84)',
|
||||||
|
'rgba(68, 187, 132, 0.82)',
|
||||||
|
'rgba(148, 163, 184, 0.8)',
|
||||||
|
'rgba(145, 118, 214, 0.82)',
|
||||||
|
]
|
||||||
|
|
||||||
|
const totalErrors = computed(() => props.distribution.reduce((sum, item) => sum + item.count, 0))
|
||||||
|
const hasData = computed(() => totalErrors.value > 0)
|
||||||
|
const distributionWithShare = computed(() => props.distribution.map(item => ({
|
||||||
|
...item,
|
||||||
|
label: item.label?.trim() || item.category,
|
||||||
|
share: totalErrors.value > 0 ? `${((item.count / totalErrors.value) * 100).toFixed(1)}%` : '0.0%',
|
||||||
|
})))
|
||||||
|
const topCategory = computed(() => (
|
||||||
|
distributionWithShare.value.slice().sort((left, right) => right.count - left.count)[0] ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const chartData = computed<ChartData<'doughnut'>>(() => ({
|
||||||
|
labels: distributionWithShare.value.map(item => item.label),
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
data: props.distribution.map(item => item.count),
|
data: distributionWithShare.value.map(item => item.count),
|
||||||
backgroundColor: [
|
backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
|
||||||
'rgba(239, 68, 68, 0.7)',
|
borderColor: 'rgba(255,255,255,0.92)',
|
||||||
'rgba(59, 130, 246, 0.7)',
|
borderWidth: 2,
|
||||||
'rgba(234, 179, 8, 0.7)',
|
hoverOffset: 6,
|
||||||
'rgba(34, 197, 94, 0.7)',
|
},
|
||||||
'rgba(148, 163, 184, 0.7)'
|
|
||||||
],
|
],
|
||||||
borderWidth: 0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||||
|
cutout: '68%',
|
||||||
plugins: {
|
plugins: {
|
||||||
legend: {
|
legend: {
|
||||||
position: 'bottom' as const
|
position: 'bottom',
|
||||||
}
|
align: 'center',
|
||||||
}
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
boxWidth: 10,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
label: (context) => {
|
||||||
|
const value = Number(context.raw ?? 0)
|
||||||
|
const percentage = totalErrors.value > 0 ? ((value / totalErrors.value) * 100).toFixed(1) : '0.0'
|
||||||
|
return `${context.label ?? '错误'}: ${value} (${percentage}%)`
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,34 +1,30 @@
|
|||||||
<template>
|
<template>
|
||||||
<TableCard :title="title">
|
<TableCard :title="title">
|
||||||
<template #actions>
|
<template #actions>
|
||||||
<Select
|
<div
|
||||||
v-if="showMetricSelect"
|
v-if="isRefreshing"
|
||||||
:model-value="metric"
|
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||||
@update:model-value="emitMetric"
|
|
||||||
>
|
>
|
||||||
<SelectTrigger class="h-8 text-xs w-28">
|
更新中
|
||||||
<SelectValue placeholder="指标" />
|
</div>
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="requests">
|
|
||||||
请求数
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="tokens">
|
|
||||||
Tokens
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="cost">
|
|
||||||
成本
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="loading"
|
v-if="showLoadingState"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
>
|
>
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="showUnavailableState"
|
||||||
|
class="p-6"
|
||||||
|
>
|
||||||
|
<EmptyState
|
||||||
|
type="error"
|
||||||
|
:title="unavailableTitle"
|
||||||
|
:description="unavailableDescription"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="items.length === 0"
|
v-else-if="items.length === 0"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
@@ -38,20 +34,29 @@
|
|||||||
description="当前时间范围内没有统计结果"
|
description="当前时间范围内没有统计结果"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Table v-else>
|
<Table
|
||||||
|
v-else
|
||||||
|
class="table-fixed"
|
||||||
|
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead class="w-16">
|
<TableHead class="w-14 px-2.5">
|
||||||
排名
|
排名
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead>名称</TableHead>
|
<TableHead class="w-[34%] px-2.5">
|
||||||
<TableHead class="text-right">
|
名称
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-[15%] px-2.5 text-right">
|
||||||
请求数
|
请求数
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead class="w-[18%] px-2.5 text-right">
|
||||||
Tokens
|
Tokens
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead class="text-right">
|
<TableHead class="w-[16%] px-2.5 text-right">
|
||||||
|
费用
|
||||||
|
</TableHead>
|
||||||
|
<TableHead class="w-[17%] px-2.5 text-right">
|
||||||
成本
|
成本
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
@@ -60,68 +65,118 @@
|
|||||||
<TableRow
|
<TableRow
|
||||||
v-for="item in items"
|
v-for="item in items"
|
||||||
:key="item.id"
|
:key="item.id"
|
||||||
|
class="cursor-pointer"
|
||||||
|
:class="item.id === selectedId
|
||||||
|
? 'bg-[linear-gradient(90deg,rgba(232,145,89,0.16),rgba(232,145,89,0.05),transparent)] shadow-[inset_4px_0_0_0_rgba(232,145,89,0.9)] hover:bg-[linear-gradient(90deg,rgba(232,145,89,0.2),rgba(232,145,89,0.06),transparent)]'
|
||||||
|
: 'hover:bg-muted/25'"
|
||||||
|
@click="emit('select', item.id)"
|
||||||
|
>
|
||||||
|
<TableCell class="px-2.5 py-2.5">
|
||||||
|
<div
|
||||||
|
class="inline-flex h-7 min-w-7 items-center justify-center rounded-full border px-2 text-[11px] font-semibold tabular-nums transition-colors"
|
||||||
|
:class="item.id === selectedId
|
||||||
|
? 'border-amber-500/70 bg-amber-500/15 text-amber-700 dark:text-amber-300'
|
||||||
|
: 'border-border/70 bg-muted/35 text-muted-foreground'"
|
||||||
>
|
>
|
||||||
<TableCell class="font-medium">
|
|
||||||
{{ item.rank }}
|
{{ item.rank }}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{{ item.name }}</TableCell>
|
<TableCell class="px-2.5 py-2.5">
|
||||||
<TableCell class="text-right">
|
<div class="min-w-0">
|
||||||
{{ item.requests }}
|
<div
|
||||||
|
class="truncate text-sm font-medium"
|
||||||
|
:title="item.name"
|
||||||
|
>
|
||||||
|
{{ item.name }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-0.5 truncate text-[10px] text-muted-foreground">
|
||||||
|
费用优先
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
|
||||||
|
{{ item.requests.toLocaleString() }}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums">
|
||||||
{{ formatTokens(item.tokens) }}
|
{{ formatTokens(item.tokens) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell class="text-right">
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] font-medium tabular-nums text-amber-700 dark:text-amber-300">
|
||||||
{{ formatCurrency(item.cost) }}
|
{{ formatCurrency(item.cost) }}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
|
||||||
|
{{ formatCurrency(item.actualCost) }}
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
<template v-if="showPagination" #pagination>
|
||||||
|
<Pagination
|
||||||
|
:current="currentPage"
|
||||||
|
:total="totalItems"
|
||||||
|
:page-size="pageSize"
|
||||||
|
:show-page-size-selector="false"
|
||||||
|
@update:current="emit('update:currentPage', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
</TableCard>
|
</TableCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { EmptyState, LoadingState } from '@/components/common'
|
import { EmptyState, LoadingState } from '@/components/common'
|
||||||
import { TableCard } from '@/components/ui'
|
import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from '@/components/ui'
|
|
||||||
import { formatCurrency, formatTokens } from '@/utils/format'
|
import { formatCurrency, formatTokens } from '@/utils/format'
|
||||||
import type { LeaderboardItem } from '@/api/admin'
|
|
||||||
|
interface LeaderboardItem {
|
||||||
|
rank: number
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
actualCost: number
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
items: LeaderboardItem[]
|
items: LeaderboardItem[]
|
||||||
metric: 'requests' | 'tokens' | 'cost'
|
selectedId?: string
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
showMetricSelect?: boolean
|
hasLoaded?: boolean
|
||||||
|
currentPage?: number
|
||||||
|
totalItems?: number
|
||||||
|
pageSize?: number
|
||||||
|
showPagination?: boolean
|
||||||
|
unavailable?: boolean
|
||||||
|
unavailableTitle?: string
|
||||||
|
unavailableDescription?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
selectedId: undefined,
|
||||||
loading: false,
|
loading: false,
|
||||||
showMetricSelect: true
|
hasLoaded: false,
|
||||||
|
currentPage: 1,
|
||||||
|
totalItems: 0,
|
||||||
|
pageSize: 5,
|
||||||
|
showPagination: false,
|
||||||
|
unavailable: false,
|
||||||
|
unavailableTitle: '排行榜暂不可用',
|
||||||
|
unavailableDescription: '接口未返回结果,请稍后重试',
|
||||||
})
|
})
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'update:metric', value: 'requests' | 'tokens' | 'cost'): void
|
(e: 'select', value: string): void
|
||||||
|
(e: 'update:currentPage', value: number): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const metric = computed(() => props.metric)
|
const selectedId = computed(() => props.selectedId)
|
||||||
|
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
|
||||||
function emitMetric(value: string) {
|
const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
|
||||||
if (value === 'requests' || value === 'tokens' || value === 'cost') {
|
const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
|
||||||
emit('update:metric', value)
|
const currentPage = computed(() => props.currentPage)
|
||||||
}
|
const totalItems = computed(() => props.totalItems)
|
||||||
}
|
const pageSize = computed(() => props.pageSize)
|
||||||
|
const showPagination = computed(() => props.showPagination && props.totalItems > props.pageSize)
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,27 +1,59 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-3">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||||
|
<div>
|
||||||
<h3 class="text-sm font-semibold">
|
<h3 class="text-sm font-semibold">
|
||||||
{{ title }}
|
{{ title }}
|
||||||
</h3>
|
</h3>
|
||||||
<span
|
<p
|
||||||
v-if="subtitle"
|
v-if="subtitle"
|
||||||
class="text-xs text-muted-foreground"
|
class="mt-1 text-[11px] text-muted-foreground"
|
||||||
>{{ subtitle }}</span>
|
>
|
||||||
|
{{ subtitle }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="summaryMetrics.length > 0 && !unavailable"
|
||||||
|
class="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-for="metric in summaryMetrics"
|
||||||
|
:key="metric.label"
|
||||||
|
class="rounded-full border px-2.5 py-1 text-[10px] font-medium"
|
||||||
|
:class="metric.className"
|
||||||
|
>
|
||||||
|
{{ metric.label }} {{ metric.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="loading"
|
v-if="loading"
|
||||||
class="p-6"
|
class="p-6"
|
||||||
>
|
>
|
||||||
<LoadingState />
|
<LoadingState />
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="unavailable"
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
{{ unavailableText }}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!hasData"
|
||||||
|
class="flex h-[272px] items-center justify-center rounded-2xl border border-dashed border-border/70 bg-muted/10 text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
暂无延迟数据
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
class="h-[260px]"
|
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||||
>
|
>
|
||||||
<LineChart
|
<LineChart
|
||||||
:data="chartData"
|
:data="chartData"
|
||||||
:options="chartOptions"
|
:options="chartOptions"
|
||||||
|
:height="248"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,9 +61,19 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
|
import type { ChartData, ChartOptions } from 'chart.js'
|
||||||
import LineChart from '@/components/charts/LineChart.vue'
|
import LineChart from '@/components/charts/LineChart.vue'
|
||||||
import { LoadingState } from '@/components/common'
|
import { LoadingState } from '@/components/common'
|
||||||
import type { PercentileItem } from '@/api/admin'
|
|
||||||
|
interface PercentileItem {
|
||||||
|
date: string
|
||||||
|
p50_response_time_ms?: number | null
|
||||||
|
p90_response_time_ms?: number | null
|
||||||
|
p99_response_time_ms?: number | null
|
||||||
|
p50_first_byte_time_ms?: number | null
|
||||||
|
p90_first_byte_time_ms?: number | null
|
||||||
|
p99_first_byte_time_ms?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
title: string
|
title: string
|
||||||
@@ -39,61 +81,159 @@ interface Props {
|
|||||||
series: PercentileItem[]
|
series: PercentileItem[]
|
||||||
mode: 'response' | 'ttfb'
|
mode: 'response' | 'ttfb'
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
|
unavailable?: boolean
|
||||||
|
unavailableText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
subtitle: undefined,
|
subtitle: undefined,
|
||||||
loading: false
|
loading: false,
|
||||||
|
unavailable: false,
|
||||||
|
unavailableText: '数据暂不可用',
|
||||||
})
|
})
|
||||||
|
|
||||||
const labels = computed(() => props.series.map(item => item.date))
|
|
||||||
|
|
||||||
// 毫秒转秒
|
|
||||||
function msToSeconds(ms: number | null | undefined): number | null {
|
function msToSeconds(ms: number | null | undefined): number | null {
|
||||||
if (ms == null) return null
|
if (ms == null) return null
|
||||||
return ms / 1000
|
return Number((ms / 1000).toFixed(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
const chartData = computed(() => {
|
function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
|
||||||
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
|
if (props.mode === 'response') {
|
||||||
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
|
if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
|
||||||
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
|
if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
|
||||||
|
return msToSeconds(item.p99_response_time_ms)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
if (percentile === '50') return msToSeconds(item.p50_first_byte_time_ms)
|
||||||
|
if (percentile === '90') return msToSeconds(item.p90_first_byte_time_ms)
|
||||||
|
return msToSeconds(item.p99_first_byte_time_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLatestMetricValue(percentile: '50' | '90' | '99') {
|
||||||
|
for (let index = props.series.length - 1; index >= 0; index -= 1) {
|
||||||
|
const value = getPercentileValue(props.series[index], percentile)
|
||||||
|
if (value != null) return value
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeconds(value: number | null) {
|
||||||
|
if (value == null) return '--'
|
||||||
|
if (value < 1) return `${Math.round(value * 1000)}ms`
|
||||||
|
return `${value.toFixed(2)}s`
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels = computed(() => props.series.map(item => item.date?.slice(5) ?? ''))
|
||||||
|
const hasData = computed(() => props.series.some(item => (
|
||||||
|
getPercentileValue(item, '50') != null
|
||||||
|
|| getPercentileValue(item, '90') != null
|
||||||
|
|| getPercentileValue(item, '99') != null
|
||||||
|
)))
|
||||||
|
|
||||||
|
const summaryMetrics = computed(() => ([
|
||||||
|
{
|
||||||
|
label: 'P50',
|
||||||
|
value: formatSeconds(getLatestMetricValue('50')),
|
||||||
|
className: 'border-sky-500/20 bg-sky-500/8 text-sky-700 dark:text-sky-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'P90',
|
||||||
|
value: formatSeconds(getLatestMetricValue('90')),
|
||||||
|
className: 'border-amber-500/20 bg-amber-500/8 text-amber-700 dark:text-amber-300',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'P99',
|
||||||
|
value: formatSeconds(getLatestMetricValue('99')),
|
||||||
|
className: 'border-rose-500/20 bg-rose-500/8 text-rose-700 dark:text-rose-300',
|
||||||
|
},
|
||||||
|
]))
|
||||||
|
|
||||||
|
const chartData = computed<ChartData<'line'>>(() => ({
|
||||||
labels: labels.value,
|
labels: labels.value,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: 'P50',
|
label: 'P50',
|
||||||
data: props.series.map(item => msToSeconds(item[p50Key])),
|
data: props.series.map(item => getPercentileValue(item, '50')),
|
||||||
borderColor: 'rgb(59, 130, 246)',
|
borderColor: 'rgb(70, 136, 240)',
|
||||||
tension: 0.25,
|
backgroundColor: 'rgba(70, 136, 240, 0.10)',
|
||||||
pointRadius: 2
|
borderWidth: 2.4,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.32,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'P90',
|
label: 'P90',
|
||||||
data: props.series.map(item => msToSeconds(item[p90Key])),
|
data: props.series.map(item => getPercentileValue(item, '90')),
|
||||||
borderColor: 'rgb(234, 179, 8)',
|
borderColor: 'rgb(223, 166, 55)',
|
||||||
tension: 0.25,
|
backgroundColor: 'rgba(223, 166, 55, 0.08)',
|
||||||
pointRadius: 2
|
borderWidth: 2.1,
|
||||||
|
fill: false,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'P99',
|
label: 'P99',
|
||||||
data: props.series.map(item => msToSeconds(item[p99Key])),
|
data: props.series.map(item => getPercentileValue(item, '99')),
|
||||||
borderColor: 'rgb(239, 68, 68)',
|
borderColor: 'rgb(214, 92, 111)',
|
||||||
tension: 0.25,
|
backgroundColor: 'rgba(214, 92, 111, 0.08)',
|
||||||
pointRadius: 2
|
borderWidth: 2,
|
||||||
}
|
borderDash: [6, 4],
|
||||||
]
|
fill: false,
|
||||||
}
|
tension: 0.28,
|
||||||
})
|
pointRadius: 0,
|
||||||
|
pointHoverRadius: 4,
|
||||||
|
pointHitRadius: 14,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}))
|
||||||
|
|
||||||
const chartOptions = computed(() => ({
|
const chartOptions = computed<ChartOptions<'line'>>(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: {
|
||||||
|
mode: 'index',
|
||||||
|
intersect: false,
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
boxWidth: 10,
|
||||||
|
padding: 14,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
callbacks: {
|
||||||
|
title: items => props.series[items[0]?.dataIndex ?? -1]?.date ?? '',
|
||||||
|
label: context => `${context.dataset.label}: ${formatSeconds(Number(context.raw ?? 0))}`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
scales: {
|
scales: {
|
||||||
y: {
|
x: {
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.06)',
|
||||||
|
},
|
||||||
ticks: {
|
ticks: {
|
||||||
callback: (value: number) => `${value}s`
|
color: 'rgb(100, 116, 139)',
|
||||||
}
|
maxRotation: 0,
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
|
y: {
|
||||||
|
beginAtZero: true,
|
||||||
|
grid: {
|
||||||
|
color: 'rgba(148, 163, 184, 0.07)',
|
||||||
|
},
|
||||||
|
ticks: {
|
||||||
|
color: 'rgb(100, 116, 139)',
|
||||||
|
callback: value => formatSeconds(Number(value)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<template>
|
|
||||||
<Card class="p-4 space-y-4">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h3 class="text-sm font-semibold">
|
|
||||||
{{ title }}
|
|
||||||
</h3>
|
|
||||||
<span
|
|
||||||
v-if="subtitle"
|
|
||||||
class="text-xs text-muted-foreground"
|
|
||||||
>{{ subtitle }}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="loading"
|
|
||||||
class="p-4"
|
|
||||||
>
|
|
||||||
<LoadingState />
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else-if="providers.length === 0"
|
|
||||||
class="p-4"
|
|
||||||
>
|
|
||||||
<EmptyState
|
|
||||||
title="暂无数据"
|
|
||||||
description="暂无月卡配额数据"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="space-y-4"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
v-for="provider in providers"
|
|
||||||
:key="provider.id"
|
|
||||||
class="space-y-2"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between text-xs">
|
|
||||||
<span class="font-medium">{{ provider.name }}</span>
|
|
||||||
<span class="text-muted-foreground">
|
|
||||||
{{ formatCurrency(provider.used_usd) }} / {{ formatCurrency(provider.quota_usd) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div class="h-2 rounded-full bg-muted">
|
|
||||||
<div
|
|
||||||
class="h-2 rounded-full bg-primary"
|
|
||||||
:style="{ width: `${Math.min(provider.usage_percent, 100)}%` }"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
|
||||||
<span>剩余 {{ formatCurrency(provider.remaining_usd) }}</span>
|
|
||||||
<span v-if="provider.estimated_exhaust_at">
|
|
||||||
预计耗尽 {{ formatDate(provider.estimated_exhaust_at) }}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { Card } from '@/components/ui'
|
|
||||||
import { EmptyState, LoadingState } from '@/components/common'
|
|
||||||
import { formatCurrency } from '@/utils/format'
|
|
||||||
import type { QuotaUsageProvider } from '@/api/admin'
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
title: string
|
|
||||||
subtitle?: string
|
|
||||||
providers: QuotaUsageProvider[]
|
|
||||||
loading?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
withDefaults(defineProps<Props>(), {
|
|
||||||
subtitle: undefined,
|
|
||||||
loading: false
|
|
||||||
})
|
|
||||||
|
|
||||||
function formatDate(value: string) {
|
|
||||||
return new Date(value).toLocaleDateString()
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
|
export { default as ActivityHeatmap } from './ActivityHeatmap.vue'
|
||||||
export { default as LeaderboardTable } from './LeaderboardTable.vue'
|
export { default as LeaderboardTable } from './LeaderboardTable.vue'
|
||||||
export { default as CostForecastChart } from './CostForecastChart.vue'
|
|
||||||
export { default as QuotaProgressCard } from './QuotaProgressCard.vue'
|
|
||||||
export { default as PercentileChart } from './PercentileChart.vue'
|
export { default as PercentileChart } from './PercentileChart.vue'
|
||||||
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
|
export { default as ErrorDistributionChart } from './ErrorDistributionChart.vue'
|
||||||
export { default as ComparisonCard } from './ComparisonCard.vue'
|
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ const props = defineProps<{
|
|||||||
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
zIndex?: number // Custom z-index for nested dialogs (default: 60)
|
||||||
noPadding?: boolean // Disable default content padding
|
noPadding?: boolean // Disable default content padding
|
||||||
persistent?: boolean // Prevent closing on backdrop click
|
persistent?: boolean // Prevent closing on backdrop click
|
||||||
|
closeOnBackdrop?: boolean // Allow closing on backdrop click (default: true)
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// Emits 定义
|
// Emits 定义
|
||||||
@@ -145,7 +146,7 @@ function handleClose() {
|
|||||||
|
|
||||||
// 处理背景点击
|
// 处理背景点击
|
||||||
function handleBackdropClick() {
|
function handleBackdropClick() {
|
||||||
if (!props.persistent) {
|
if (!props.persistent && props.closeOnBackdrop !== false) {
|
||||||
handleClose()
|
handleClose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<input
|
<input
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:class="inputClass"
|
:class="inputClass"
|
||||||
:style="inputStyle"
|
:style="maskStyle"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:type="effectiveType"
|
:type="effectiveType"
|
||||||
:autocomplete="autocompleteAttr"
|
:autocomplete="autocompleteAttr"
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
v-else
|
v-else
|
||||||
ref="inputRef"
|
ref="inputRef"
|
||||||
:class="inputClass"
|
:class="inputClass"
|
||||||
:style="inputStyle"
|
:style="maskStyle"
|
||||||
:value="modelValue"
|
:value="modelValue"
|
||||||
:type="effectiveType"
|
:type="effectiveType"
|
||||||
:autocomplete="autocompleteAttr"
|
:autocomplete="autocompleteAttr"
|
||||||
@@ -69,24 +69,6 @@ const emit = defineEmits<{
|
|||||||
'update:modelValue': [value: string]
|
'update:modelValue': [value: string]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
// 开发环境警告:type="password" 已被弃用
|
|
||||||
const warnPasswordType = import.meta.env.DEV
|
|
||||||
? (() => {
|
|
||||||
let warned = false
|
|
||||||
return () => {
|
|
||||||
if (!warned) {
|
|
||||||
warned = true
|
|
||||||
// eslint-disable-next-line no-console
|
|
||||||
console.warn(
|
|
||||||
'[Input] type="password" 已被弃用,请使用 masked 属性代替。\n' +
|
|
||||||
'示例:<Input v-model="apiKey" masked />\n' +
|
|
||||||
'masked 属性使用 CSS 遮蔽而非 password 类型,不会触发浏览器密码管理器。'
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})()
|
|
||||||
: () => {}
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
modelValue?: string | number
|
modelValue?: string | number
|
||||||
class?: string
|
class?: string
|
||||||
@@ -99,9 +81,8 @@ interface Props {
|
|||||||
size?: 'default' | 'sm'
|
size?: 'default' | 'sm'
|
||||||
/**
|
/**
|
||||||
* 遮蔽显示内容(用于 API Key 等敏感信息)
|
* 遮蔽显示内容(用于 API Key 等敏感信息)
|
||||||
* 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器
|
* 始终使用 text 输入框,隐藏态通过样式进行遮蔽
|
||||||
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
|
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
|
||||||
* 注意:Firefox 不支持 -webkit-text-security,会显示明文(但仍可通过按钮切换)
|
|
||||||
*/
|
*/
|
||||||
masked?: boolean
|
masked?: boolean
|
||||||
/**
|
/**
|
||||||
@@ -134,17 +115,23 @@ const shouldDisableAutofill = computed(() => {
|
|||||||
return props.disableAutofill ?? false
|
return props.disableAutofill ?? false
|
||||||
})
|
})
|
||||||
|
|
||||||
// 始终使用 text 类型,永远不用 password
|
|
||||||
const effectiveType = computed(() => {
|
const effectiveType = computed(() => {
|
||||||
const attrType = attrs.type as string | undefined
|
const attrType = (attrs.type as string | undefined) ?? 'text'
|
||||||
// 如果传入 password,强制转为 text(配合 masked 使用)
|
if (props.masked) {
|
||||||
if (attrType === 'password') {
|
|
||||||
warnPasswordType()
|
|
||||||
return 'text'
|
return 'text'
|
||||||
}
|
}
|
||||||
return attrType
|
return attrType
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const maskStyle = computed(() => {
|
||||||
|
if (!props.masked || isVisible.value) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
WebkitTextSecurity: 'disc'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// 过滤掉 type 和 class 属性,因为我们会单独处理
|
// 过滤掉 type 和 class 属性,因为我们会单独处理
|
||||||
const filteredAttrs = computed(() => {
|
const filteredAttrs = computed(() => {
|
||||||
const { type: _type, class: _class, ...rest } = attrs
|
const { type: _type, class: _class, ...rest } = attrs
|
||||||
@@ -182,16 +169,6 @@ const inputClass = computed(() =>
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// 当 masked 为 true 且未显示时,用 CSS 遮蔽文字
|
|
||||||
const inputStyle = computed(() => {
|
|
||||||
if (props.masked && !isVisible.value) {
|
|
||||||
// 使用 -webkit-text-security(Chrome, Safari, Edge 支持)
|
|
||||||
// Firefox 不支持此属性,会显示明文,但仍可通过小眼睛按钮切换
|
|
||||||
return { '-webkit-text-security': 'disc' }
|
|
||||||
}
|
|
||||||
return undefined
|
|
||||||
})
|
|
||||||
|
|
||||||
function handleInput(event: Event) {
|
function handleInput(event: Event) {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
emit('update:modelValue', target.value)
|
emit('update:modelValue', target.value)
|
||||||
|
|||||||
@@ -10,7 +10,32 @@
|
|||||||
:align-offset="alignOffset"
|
:align-offset="alignOffset"
|
||||||
>
|
>
|
||||||
<SelectViewport :class="viewportClass">
|
<SelectViewport :class="viewportClass">
|
||||||
|
<div
|
||||||
|
v-if="showSearchInput"
|
||||||
|
class="sticky top-0 z-10 bg-card/95 px-1 pt-1 pb-2 backdrop-blur supports-[backdrop-filter]:bg-card/85"
|
||||||
|
>
|
||||||
|
<div class="relative">
|
||||||
|
<Search
|
||||||
|
class="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
ref="searchInputRef"
|
||||||
|
v-model="searchQuery"
|
||||||
|
:placeholder="searchPlaceholder"
|
||||||
|
class="h-9 rounded-xl border-border/60 bg-background/80 pl-9 pr-3 text-sm"
|
||||||
|
@keydown.stop
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="showEmptyState"
|
||||||
|
class="px-3 py-2 text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
未找到匹配项
|
||||||
|
</div>
|
||||||
</SelectViewport>
|
</SelectViewport>
|
||||||
</SelectContentPrimitive>
|
</SelectContentPrimitive>
|
||||||
</SelectPortal>
|
</SelectPortal>
|
||||||
@@ -22,9 +47,25 @@ import {
|
|||||||
SelectPortal,
|
SelectPortal,
|
||||||
SelectViewport,
|
SelectViewport,
|
||||||
} from 'radix-vue'
|
} from 'radix-vue'
|
||||||
|
import { Search } from 'lucide-vue-next'
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
nextTick,
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
provide,
|
||||||
|
ref,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import Input from './input.vue'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { computed, inject } from 'vue'
|
|
||||||
import { DIALOG_CONTEXT_KEY } from './dialog/context'
|
import { DIALOG_CONTEXT_KEY } from './dialog/context'
|
||||||
|
import {
|
||||||
|
SELECT_SEARCH_CONTEXT_KEY,
|
||||||
|
type RegisteredSelectItem,
|
||||||
|
} from './select-search-context'
|
||||||
|
import { matchesSearchQuery, preloadPinyin } from '@/utils/search'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string
|
class?: string
|
||||||
@@ -34,6 +75,9 @@ interface Props {
|
|||||||
align?: 'start' | 'center' | 'end'
|
align?: 'start' | 'center' | 'end'
|
||||||
alignOffset?: number
|
alignOffset?: number
|
||||||
disablePortal?: boolean
|
disablePortal?: boolean
|
||||||
|
searchable?: boolean
|
||||||
|
searchThreshold?: number
|
||||||
|
searchPlaceholder?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = withDefaults(defineProps<Props>(), {
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
@@ -44,19 +88,105 @@ const props = withDefaults(defineProps<Props>(), {
|
|||||||
align: undefined,
|
align: undefined,
|
||||||
alignOffset: undefined,
|
alignOffset: undefined,
|
||||||
disablePortal: false,
|
disablePortal: false,
|
||||||
|
searchable: true,
|
||||||
|
searchThreshold: 8,
|
||||||
|
searchPlaceholder: '输入关键词搜索...',
|
||||||
})
|
})
|
||||||
|
|
||||||
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
|
const isInsideDialog = inject(DIALOG_CONTEXT_KEY, false)
|
||||||
const shouldDisablePortal = computed(() => props.disablePortal || isInsideDialog)
|
const shouldDisablePortal = computed(
|
||||||
|
() => props.disablePortal || isInsideDialog,
|
||||||
|
)
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const searchInputRef = ref<InstanceType<typeof Input> | null>(null)
|
||||||
|
const registeredItems = ref<Record<string, RegisteredSelectItem>>({})
|
||||||
|
|
||||||
|
const hiddenValues = computed(() => {
|
||||||
|
const query = searchQuery.value.trim()
|
||||||
|
if (!query) return new Set<string>()
|
||||||
|
|
||||||
|
const hidden = new Set<string>()
|
||||||
|
for (const item of Object.values(registeredItems.value)) {
|
||||||
|
if (!matchesSearchQuery(query, item.value, item.text)) {
|
||||||
|
hidden.add(item.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return hidden
|
||||||
|
})
|
||||||
|
|
||||||
|
provide(SELECT_SEARCH_CONTEXT_KEY, {
|
||||||
|
searchQuery,
|
||||||
|
hiddenValues,
|
||||||
|
registerItem(id, item) {
|
||||||
|
registeredItems.value = {
|
||||||
|
...registeredItems.value,
|
||||||
|
[id]: item,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
updateItem(id, item) {
|
||||||
|
const currentItem = registeredItems.value[id]
|
||||||
|
if (!currentItem) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registeredItems.value = {
|
||||||
|
...registeredItems.value,
|
||||||
|
[id]: {
|
||||||
|
...currentItem,
|
||||||
|
...item,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
unregisterItem(id) {
|
||||||
|
if (!(id in registeredItems.value)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextItems = { ...registeredItems.value }
|
||||||
|
delete nextItems[id]
|
||||||
|
registeredItems.value = nextItems
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const itemCount = computed(() => Object.keys(registeredItems.value).length)
|
||||||
|
const showSearchInput = computed(
|
||||||
|
() => props.searchable && itemCount.value >= props.searchThreshold,
|
||||||
|
)
|
||||||
|
const showEmptyState = computed(
|
||||||
|
() =>
|
||||||
|
showSearchInput.value &&
|
||||||
|
searchQuery.value.trim().length > 0 &&
|
||||||
|
hiddenValues.value.size === itemCount.value,
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.searchable) {
|
||||||
|
preloadPinyin()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(showSearchInput, async (visible) => {
|
||||||
|
if (!visible) {
|
||||||
|
searchQuery.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await nextTick()
|
||||||
|
searchInputRef.value?.inputRef?.focus()
|
||||||
|
})
|
||||||
|
|
||||||
const contentClass = computed(() =>
|
const contentClass = computed(() =>
|
||||||
cn(
|
cn(
|
||||||
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
|
'z-[200] max-h-96 min-w-[8rem] overflow-hidden rounded-2xl border border-border bg-card text-foreground shadow-2xl backdrop-blur-xl pointer-events-auto',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
props.class
|
props.class,
|
||||||
)
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
const viewportClass = 'p-1 max-h-[var(--radix-select-content-available-height)]'
|
const viewportClass = 'p-1 max-h-[var(--radix-select-content-available-height)]'
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
searchQuery.value = ''
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,40 +1,116 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SelectItem as SelectItemPrimitive, SelectItemIndicator, SelectItemText } from 'radix-vue'
|
import {
|
||||||
|
SelectItem as SelectItemPrimitive,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
} from 'radix-vue'
|
||||||
import { Check } from 'lucide-vue-next'
|
import { Check } from 'lucide-vue-next'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { computed } from 'vue'
|
import {
|
||||||
|
computed,
|
||||||
|
getCurrentInstance,
|
||||||
|
inject,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onMounted,
|
||||||
|
useSlots,
|
||||||
|
watch,
|
||||||
|
} from 'vue'
|
||||||
|
import { SELECT_SEARCH_CONTEXT_KEY } from './select-search-context'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
class?: string
|
class?: string
|
||||||
value: string
|
value: string
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
|
textValue?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
|
const slots = useSlots()
|
||||||
|
const searchContext = inject(SELECT_SEARCH_CONTEXT_KEY, null)
|
||||||
|
const instance = getCurrentInstance()
|
||||||
|
const itemId = instance?.uid
|
||||||
|
? `select-item-${instance.uid}`
|
||||||
|
: `select-item-${Math.random().toString(36).slice(2, 10)}`
|
||||||
|
|
||||||
|
function extractText(node: unknown): string {
|
||||||
|
if (typeof node === 'string' || typeof node === 'number') {
|
||||||
|
return String(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node)) {
|
||||||
|
return node.map(extractText).join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node && typeof node === 'object') {
|
||||||
|
const vnode = node as { children?: unknown }
|
||||||
|
return extractText(vnode.children)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedText = computed(() => {
|
||||||
|
const slotText = extractText(slots.default?.()).replace(/\s+/g, ' ').trim()
|
||||||
|
return (props.textValue ?? slotText ?? props.value).trim()
|
||||||
|
})
|
||||||
|
|
||||||
|
const isHidden = computed(
|
||||||
|
() => searchContext?.hiddenValues.value.has(props.value) ?? false,
|
||||||
|
)
|
||||||
const itemClass = computed(() =>
|
const itemClass = computed(() =>
|
||||||
cn(
|
cn(
|
||||||
'relative flex w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
|
'relative flex min-w-0 w-full cursor-pointer select-none items-center rounded-lg py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||||
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
|
'data-[highlighted]:bg-accent focus:bg-accent text-foreground',
|
||||||
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
props.class
|
isHidden.value && 'hidden',
|
||||||
)
|
props.class,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
searchContext?.registerItem(itemId, {
|
||||||
|
value: props.value,
|
||||||
|
text: normalizedText.value,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.value, normalizedText.value] as const,
|
||||||
|
([value, text]) => {
|
||||||
|
searchContext?.updateItem(itemId, {
|
||||||
|
value,
|
||||||
|
text,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
searchContext?.unregisterItem(itemId)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<SelectItemPrimitive
|
<SelectItemPrimitive
|
||||||
:class="itemClass"
|
:class="itemClass"
|
||||||
:value="value"
|
:value="value"
|
||||||
:disabled="disabled"
|
:disabled="disabled || isHidden"
|
||||||
|
:text-value="normalizedText"
|
||||||
>
|
>
|
||||||
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span class="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectItemIndicator>
|
<SelectItemIndicator>
|
||||||
<Check class="h-4 w-4" />
|
<Check class="h-4 w-4" />
|
||||||
</SelectItemIndicator>
|
</SelectItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectItemText>
|
<SelectItemText
|
||||||
|
as="div"
|
||||||
|
class="min-w-0 flex-1 overflow-hidden"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
class="block w-full min-w-0 truncate"
|
||||||
|
:title="normalizedText"
|
||||||
|
>
|
||||||
<slot />
|
<slot />
|
||||||
|
</span>
|
||||||
</SelectItemText>
|
</SelectItemText>
|
||||||
</SelectItemPrimitive>
|
</SelectItemPrimitive>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { InjectionKey, Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface RegisteredSelectItem {
|
||||||
|
value: string
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectSearchContext {
|
||||||
|
searchQuery: Ref<string>
|
||||||
|
hiddenValues: Ref<Set<string>>
|
||||||
|
registerItem: (id: string, item: RegisteredSelectItem) => void
|
||||||
|
updateItem: (id: string, item: Partial<RegisteredSelectItem>) => void
|
||||||
|
unregisterItem: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SELECT_SEARCH_CONTEXT_KEY: InjectionKey<SelectSearchContext> =
|
||||||
|
Symbol('select-search-context')
|
||||||
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
|
|||||||
:class="triggerClass"
|
:class="triggerClass"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
>
|
>
|
||||||
<span class="truncate">
|
<span class="min-w-0 flex-1 truncate">
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
|
<ChevronDown class="h-4 w-4 opacity-50 pointer-events-none flex-shrink-0" />
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { useLeaderboardData } from './useLeaderboardData'
|
||||||
|
export type { LeaderboardDimension } from './useLeaderboardData'
|
||||||
|
export { usePerformanceData } from './usePerformanceData'
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import type { AnalyticsLeaderboardItem } from '@/api/analytics'
|
||||||
|
|
||||||
|
export interface LeaderboardTableRow {
|
||||||
|
rank: number
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
actualCost: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapAnalyticsLeaderboardItem(item: AnalyticsLeaderboardItem): LeaderboardTableRow {
|
||||||
|
return {
|
||||||
|
rank: item.rank,
|
||||||
|
id: item.id,
|
||||||
|
name: item.label,
|
||||||
|
requests: item.requests_total,
|
||||||
|
tokens: item.total_tokens,
|
||||||
|
cost: item.total_cost_usd,
|
||||||
|
actualCost: item.actual_total_cost_usd,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { computed, onMounted, onUnmounted, ref, watch, type Ref } from 'vue'
|
||||||
|
import { analyticsApi, type AnalyticsFilterOption, type AnalyticsLeaderboardItem, type AnalyticsTimeseriesBucket } from '@/api/analytics'
|
||||||
|
import { buildTimeRangeParams, createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||||
|
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
|
||||||
|
import { isAnalyticsSingleDayRange, resolveAnalyticsAutoGranularity, type AnalyticsGranularityOption } from '@/utils/analyticsGranularity'
|
||||||
|
import { mapAnalyticsLeaderboardItem, type LeaderboardTableRow } from './mappers'
|
||||||
|
|
||||||
|
export type LeaderboardDimension = 'users' | 'api_keys'
|
||||||
|
|
||||||
|
export interface LeaderboardSummary {
|
||||||
|
total_requests: number
|
||||||
|
total_tokens: number
|
||||||
|
total_cost: number
|
||||||
|
total_actual_cost: number
|
||||||
|
avg_response_time: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function dimensionToEntity(dimension: LeaderboardDimension): 'user' | 'api_key' {
|
||||||
|
return dimension === 'users' ? 'user' : 'api_key'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useLeaderboardData(dimension: Ref<LeaderboardDimension>) {
|
||||||
|
const filters = useInjectedAnalyticsFilters()
|
||||||
|
|
||||||
|
const leaderboardItems = ref<AnalyticsLeaderboardItem[]>([])
|
||||||
|
const latestUserOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const latestApiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
const leaderboardLoading = ref(false)
|
||||||
|
const leaderboardError = ref(false)
|
||||||
|
const leaderboardHasLoaded = ref(false)
|
||||||
|
const selectedId = ref<string>('')
|
||||||
|
|
||||||
|
const selectedSummary = ref<LeaderboardSummary | null>(null)
|
||||||
|
const selectedTrendBuckets = ref<AnalyticsTimeseriesBucket[]>([])
|
||||||
|
const panelLoading = ref(false)
|
||||||
|
const panelError = ref(false)
|
||||||
|
const panelHasLoaded = ref(false)
|
||||||
|
|
||||||
|
const leaderboardGuard = createRequestGuard()
|
||||||
|
const filterOptionsGuard = createRequestGuard()
|
||||||
|
const panelGuard = createRequestGuard()
|
||||||
|
|
||||||
|
const entityOptions = computed(() => (
|
||||||
|
dimension.value === 'users' ? latestUserOptions.value : latestApiKeyOptions.value
|
||||||
|
))
|
||||||
|
const entityLabelMap = computed(() => (
|
||||||
|
new Map(entityOptions.value.map(option => [option.value, option.label]))
|
||||||
|
))
|
||||||
|
|
||||||
|
const leaderboard = computed<LeaderboardTableRow[]>(() => (
|
||||||
|
leaderboardItems.value.map(item => ({
|
||||||
|
...mapAnalyticsLeaderboardItem(item),
|
||||||
|
name: entityLabelMap.value.get(item.id) ?? item.label,
|
||||||
|
}))
|
||||||
|
))
|
||||||
|
|
||||||
|
const selectedItem = computed(() => (
|
||||||
|
leaderboard.value.find(item => item.id === selectedId.value) ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const selectedLabel = computed(() => (
|
||||||
|
selectedItem.value?.name ?? entityLabelMap.value.get(selectedId.value) ?? null
|
||||||
|
))
|
||||||
|
|
||||||
|
const resolvedTrendGranularity = computed(() => resolveAnalyticsAutoGranularity(filters.timeRange.value))
|
||||||
|
const baseTimeRangePayload = computed(() => ({
|
||||||
|
start_date: filters.timeRange.value.start_date,
|
||||||
|
end_date: filters.timeRange.value.end_date,
|
||||||
|
preset: filters.timeRange.value.preset,
|
||||||
|
timezone: filters.timeRange.value.timezone,
|
||||||
|
tz_offset_minutes: filters.timeRange.value.tz_offset_minutes,
|
||||||
|
}))
|
||||||
|
const trendTimeRangePayload = computed(() => ({
|
||||||
|
...baseTimeRangePayload.value,
|
||||||
|
granularity: resolvedTrendGranularity.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const currentTrendGranularitySelection = computed<AnalyticsGranularityOption>({
|
||||||
|
get: () => filters.timeRange.value.granularity || 'auto',
|
||||||
|
set: (value) => {
|
||||||
|
if (value === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
|
||||||
|
value = 'auto'
|
||||||
|
}
|
||||||
|
filters.timeRange.value = {
|
||||||
|
...filters.timeRange.value,
|
||||||
|
granularity: value,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function resetPanelData(invalidate = false) {
|
||||||
|
if (invalidate) panelGuard.invalidate()
|
||||||
|
panelLoading.value = false
|
||||||
|
panelError.value = false
|
||||||
|
selectedSummary.value = null
|
||||||
|
selectedTrendBuckets.value = []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFilterOptions() {
|
||||||
|
const requestId = filterOptionsGuard.next()
|
||||||
|
const response = await analyticsApi.getFilterOptions({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: baseTimeRangePayload.value,
|
||||||
|
filters: {},
|
||||||
|
}).catch(() => null)
|
||||||
|
|
||||||
|
if (filterOptionsGuard.isStale(requestId) || !response) return
|
||||||
|
latestUserOptions.value = response.users ?? []
|
||||||
|
latestApiKeyOptions.value = response.api_keys ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLeaderboard() {
|
||||||
|
const requestId = leaderboardGuard.next()
|
||||||
|
leaderboardLoading.value = true
|
||||||
|
leaderboardError.value = false
|
||||||
|
try {
|
||||||
|
const response = await analyticsApi.getLeaderboard({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: baseTimeRangePayload.value,
|
||||||
|
filters: {
|
||||||
|
statuses: ['completed', 'failed', 'cancelled'],
|
||||||
|
},
|
||||||
|
entity: dimensionToEntity(dimension.value),
|
||||||
|
metric: 'total_cost_usd',
|
||||||
|
limit: 20,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (leaderboardGuard.isStale(requestId)) return
|
||||||
|
leaderboardItems.value = response.items
|
||||||
|
|
||||||
|
const nextSelectedId = response.items.some(item => item.id === selectedId.value)
|
||||||
|
? selectedId.value
|
||||||
|
: (response.items[0]?.id ?? '')
|
||||||
|
|
||||||
|
if (selectedId.value !== nextSelectedId) {
|
||||||
|
selectedId.value = nextSelectedId
|
||||||
|
}
|
||||||
|
leaderboardHasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (leaderboardGuard.isStale(requestId)) return
|
||||||
|
leaderboardError.value = true
|
||||||
|
} finally {
|
||||||
|
if (leaderboardGuard.isCurrent(requestId)) {
|
||||||
|
leaderboardLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPanel() {
|
||||||
|
if (!selectedId.value) {
|
||||||
|
resetPanelData(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = panelGuard.next()
|
||||||
|
panelLoading.value = true
|
||||||
|
panelError.value = false
|
||||||
|
try {
|
||||||
|
const scope = dimension.value === 'users'
|
||||||
|
? { kind: 'user' as const, user_id: selectedId.value }
|
||||||
|
: { kind: 'api_key' as const, api_key_id: selectedId.value }
|
||||||
|
|
||||||
|
const basePayload = {
|
||||||
|
scope,
|
||||||
|
time_range: baseTimeRangePayload.value,
|
||||||
|
filters: {
|
||||||
|
statuses: ['completed', 'failed', 'cancelled'],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const [overview, timeseries] = await Promise.all([
|
||||||
|
analyticsApi.getOverview(basePayload),
|
||||||
|
analyticsApi.getTimeseries({
|
||||||
|
...basePayload,
|
||||||
|
time_range: trendTimeRangePayload.value,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (panelGuard.isStale(requestId)) return
|
||||||
|
selectedSummary.value = {
|
||||||
|
total_requests: overview.summary.requests_total,
|
||||||
|
total_tokens: overview.summary.total_tokens,
|
||||||
|
total_cost: overview.summary.total_cost_usd,
|
||||||
|
total_actual_cost: overview.summary.actual_total_cost_usd,
|
||||||
|
avg_response_time: overview.summary.avg_response_time_ms / 1000,
|
||||||
|
}
|
||||||
|
selectedTrendBuckets.value = fillMissingTimeseriesBuckets(
|
||||||
|
timeseries.buckets,
|
||||||
|
trendTimeRangePayload.value,
|
||||||
|
)
|
||||||
|
panelHasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (panelGuard.isStale(requestId)) return
|
||||||
|
panelError.value = true
|
||||||
|
} finally {
|
||||||
|
if (panelGuard.isCurrent(requestId)) {
|
||||||
|
panelLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const filterOptionsLoader = createLoader(loadFilterOptions)
|
||||||
|
const leaderboardLoader = createLoader(loadLeaderboard)
|
||||||
|
const panelLoader = createLoader(loadPanel)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [filters.timeRange.value.preset, filters.timeRange.value.start_date, filters.timeRange.value.end_date],
|
||||||
|
() => {
|
||||||
|
if (filters.timeRange.value.granularity === 'hour' && !isAnalyticsSingleDayRange(filters.timeRange.value)) {
|
||||||
|
filters.timeRange.value = {
|
||||||
|
...filters.timeRange.value,
|
||||||
|
granularity: 'auto',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [baseTimeRangePayload.value, dimension.value],
|
||||||
|
() => {
|
||||||
|
filterOptionsLoader.schedule()
|
||||||
|
leaderboardLoader.schedule()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
selectedId.value,
|
||||||
|
dimension.value,
|
||||||
|
baseTimeRangePayload.value,
|
||||||
|
trendTimeRangePayload.value,
|
||||||
|
],
|
||||||
|
() => panelLoader.schedule(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void (async () => {
|
||||||
|
await filterOptionsLoader.execute()
|
||||||
|
await leaderboardLoader.execute()
|
||||||
|
await panelLoader.execute()
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
filterOptionsLoader.cleanup()
|
||||||
|
leaderboardLoader.cleanup()
|
||||||
|
panelLoader.cleanup()
|
||||||
|
filterOptionsGuard.invalidate()
|
||||||
|
leaderboardGuard.invalidate()
|
||||||
|
panelGuard.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
leaderboard,
|
||||||
|
leaderboardLoading,
|
||||||
|
leaderboardError,
|
||||||
|
leaderboardHasLoaded,
|
||||||
|
selectedId,
|
||||||
|
selectedItem,
|
||||||
|
selectedLabel,
|
||||||
|
selectedSummary,
|
||||||
|
selectedTrendBuckets,
|
||||||
|
panelLoading,
|
||||||
|
panelError,
|
||||||
|
panelHasLoaded,
|
||||||
|
resolvedTrendGranularity,
|
||||||
|
currentTrendGranularitySelection,
|
||||||
|
reloadLeaderboard: leaderboardLoader.schedule,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { analyticsApi, type AnalyticsErrorCategory, type AnalyticsErrorTrendItem, type AnalyticsPercentilePoint, type AnalyticsPerformanceResponse, type AnalyticsProviderHealthItem } from '@/api/analytics'
|
||||||
|
import { auditApi, type MonitoringSystemStatus } from '@/api/audit'
|
||||||
|
import { getHealthSummary, type HealthSummary } from '@/api/endpoints'
|
||||||
|
import { createLoader, createRequestGuard, useInjectedAnalyticsFilters } from '@/composables/useAnalyticsFilters'
|
||||||
|
|
||||||
|
const EMPTY_LATENCY: AnalyticsPerformanceResponse['latency'] = {
|
||||||
|
response_time_ms: { avg: 0, p50: null, p90: null, p99: null },
|
||||||
|
first_byte_time_ms: { avg: 0, p50: null, p90: null, p99: null },
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePerformanceData() {
|
||||||
|
const filters = useInjectedAnalyticsFilters()
|
||||||
|
|
||||||
|
const latency = ref<AnalyticsPerformanceResponse['latency']>(EMPTY_LATENCY)
|
||||||
|
const percentiles = ref<AnalyticsPercentilePoint[]>([])
|
||||||
|
const errorDistribution = ref<AnalyticsErrorCategory[]>([])
|
||||||
|
const errorTrend = ref<AnalyticsErrorTrendItem[]>([])
|
||||||
|
const errorTotal = ref(0)
|
||||||
|
const errorRate = ref(0)
|
||||||
|
const providerStatus = ref<AnalyticsProviderHealthItem[]>([])
|
||||||
|
const healthSummary = ref<HealthSummary | null>(null)
|
||||||
|
const systemStatus = ref<MonitoringSystemStatus | null>(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref(false)
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
const guard = createRequestGuard()
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const requestId = guard.next()
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
const [response, summaryResponse, systemResponse] = await Promise.all([
|
||||||
|
analyticsApi.getPerformance({
|
||||||
|
scope: { kind: 'global' },
|
||||||
|
time_range: filters.getTimeRangeParams(),
|
||||||
|
filters: {
|
||||||
|
statuses: ['completed', 'failed', 'cancelled'],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
getHealthSummary().catch(() => null),
|
||||||
|
auditApi.getSystemStatus().catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
latency.value = response.latency
|
||||||
|
percentiles.value = response.percentiles
|
||||||
|
errorTotal.value = response.errors.total
|
||||||
|
errorRate.value = response.errors.rate
|
||||||
|
errorDistribution.value = response.errors.categories
|
||||||
|
errorTrend.value = response.errors.trend
|
||||||
|
providerStatus.value = response.provider_health
|
||||||
|
if (summaryResponse) {
|
||||||
|
healthSummary.value = summaryResponse
|
||||||
|
}
|
||||||
|
if (systemResponse) {
|
||||||
|
systemStatus.value = systemResponse
|
||||||
|
}
|
||||||
|
hasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
loadError.value = true
|
||||||
|
} finally {
|
||||||
|
if (guard.isCurrent(requestId)) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = createLoader(load)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [
|
||||||
|
filters.getTimeRangeParams(),
|
||||||
|
],
|
||||||
|
() => loader.schedule(),
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => { void loader.execute() })
|
||||||
|
onUnmounted(() => { loader.cleanup(); guard.invalidate() })
|
||||||
|
|
||||||
|
return {
|
||||||
|
latency,
|
||||||
|
percentiles,
|
||||||
|
errorDistribution,
|
||||||
|
errorTrend,
|
||||||
|
errorTotal,
|
||||||
|
errorRate,
|
||||||
|
providerStatus,
|
||||||
|
healthSummary,
|
||||||
|
systemStatus,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
hasLoaded,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { inject, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
|
||||||
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建时间范围 API 参数(从各页面提取的公共逻辑)
|
||||||
|
*/
|
||||||
|
export function buildTimeRangeParams(timeRange: DateRangeParams) {
|
||||||
|
return {
|
||||||
|
start_date: timeRange.start_date,
|
||||||
|
end_date: timeRange.end_date,
|
||||||
|
preset: timeRange.preset,
|
||||||
|
timezone: timeRange.timezone,
|
||||||
|
tz_offset_minutes: timeRange.tz_offset_minutes,
|
||||||
|
granularity: timeRange.granularity && timeRange.granularity !== 'auto'
|
||||||
|
? timeRange.granularity
|
||||||
|
: 'day',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 通用防抖加载器工厂
|
||||||
|
* 封装 120ms debounce + in-flight promise 去重 + requestId 过期检查
|
||||||
|
*/
|
||||||
|
export function createLoader(loadFn: () => Promise<void>) {
|
||||||
|
let loadPromise: Promise<void> | null = null
|
||||||
|
let hasPending = false
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = setTimeout(() => {
|
||||||
|
debounceTimer = null
|
||||||
|
void execute()
|
||||||
|
}, 120)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute() {
|
||||||
|
if (loadPromise) {
|
||||||
|
hasPending = true
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
loadPromise = loadFn()
|
||||||
|
.finally(() => {
|
||||||
|
loadPromise = null
|
||||||
|
if (hasPending) {
|
||||||
|
hasPending = false
|
||||||
|
void execute()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return loadPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (debounceTimer) {
|
||||||
|
clearTimeout(debounceTimer)
|
||||||
|
debounceTimer = null
|
||||||
|
}
|
||||||
|
hasPending = false
|
||||||
|
loadPromise = null
|
||||||
|
}
|
||||||
|
|
||||||
|
return { schedule, execute, cleanup }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求 ID 守卫:用于确保异步回调中的数据不被过期请求覆盖
|
||||||
|
*/
|
||||||
|
export function createRequestGuard() {
|
||||||
|
let id = 0
|
||||||
|
function next() { return ++id }
|
||||||
|
function isStale(requestId: number) { return requestId !== id }
|
||||||
|
function isCurrent(requestId: number) { return requestId === id }
|
||||||
|
function invalidate() { id++ }
|
||||||
|
return { next, isStale, isCurrent, invalidate }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsFiltersOptions {
|
||||||
|
defaultPreset?: PeriodValue
|
||||||
|
syncToUrl?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一的分析页面筛选状态
|
||||||
|
* - timeRange: 时间范围
|
||||||
|
* - modelFilter / providerFilter / apiKeyFilter / userFilter: 多维度筛选
|
||||||
|
* - activeTab: 当前 Tab
|
||||||
|
* - URL query 双向同步
|
||||||
|
*/
|
||||||
|
export function useAnalyticsFilters(options: AnalyticsFiltersOptions = {}) {
|
||||||
|
const { defaultPreset = 'last30days', syncToUrl = true } = options
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// 从 URL query 初始化,无则用默认值
|
||||||
|
const initialPreset = (syncToUrl && route.query.preset as PeriodValue) || defaultPreset
|
||||||
|
const initialTab = (syncToUrl && route.query.tab as string) || 'detail'
|
||||||
|
|
||||||
|
const timeRange = ref<DateRangeParams>(getDateRangeFromPeriod(initialPreset))
|
||||||
|
const activeTab = ref(initialTab)
|
||||||
|
const modelFilter = ref<string[]>([])
|
||||||
|
const providerFilter = ref<string[]>([])
|
||||||
|
const apiKeyFilter = ref<string[]>([])
|
||||||
|
const userFilter = ref<string[]>([])
|
||||||
|
|
||||||
|
// URL 同步:仅同步 preset 和 tab(各类多选筛选太冗长不入 URL)
|
||||||
|
if (syncToUrl) {
|
||||||
|
watch([() => timeRange.value.preset, activeTab], ([preset, tab]) => {
|
||||||
|
const query: Record<string, string> = {}
|
||||||
|
if (preset && preset !== defaultPreset) query.preset = preset
|
||||||
|
if (tab && tab !== 'detail') query.tab = tab
|
||||||
|
// 保留其他 query 参数
|
||||||
|
const currentQuery = { ...route.query }
|
||||||
|
delete currentQuery.preset
|
||||||
|
delete currentQuery.tab
|
||||||
|
router.replace({ query: { ...currentQuery, ...query } })
|
||||||
|
}, { flush: 'post' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeRangeParams() {
|
||||||
|
return buildTimeRangeParams(timeRange.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRange,
|
||||||
|
activeTab,
|
||||||
|
modelFilter,
|
||||||
|
providerFilter,
|
||||||
|
apiKeyFilter,
|
||||||
|
userFilter,
|
||||||
|
getTimeRangeParams,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnalyticsFilters = ReturnType<typeof useAnalyticsFilters>
|
||||||
|
|
||||||
|
export function useInjectedAnalyticsFilters(): AnalyticsFilters {
|
||||||
|
const filters = inject<AnalyticsFilters>('analyticsFilters', null)
|
||||||
|
if (!filters) {
|
||||||
|
throw new Error('analyticsFilters provider is missing')
|
||||||
|
}
|
||||||
|
return filters
|
||||||
|
}
|
||||||
@@ -68,6 +68,86 @@ export function getProbeCountdown(nextProbeAt: string | null | undefined, _tick:
|
|||||||
return '探测中'
|
return '探测中'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Codex 配额重置倒计时状态
|
||||||
|
*/
|
||||||
|
export interface CodexResetStatus {
|
||||||
|
text: string
|
||||||
|
isUrgent: boolean
|
||||||
|
isCritical: boolean
|
||||||
|
isExpired: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算 Codex 配额重置倒计时
|
||||||
|
* @param resetAt 绝对重置时间(Unix 秒)
|
||||||
|
* @param resetSecs 相对剩余秒数(用于 fallback)
|
||||||
|
* @param updatedAt 元数据更新时间(Unix 秒)
|
||||||
|
* @param _tick 响应式触发器(传入 tick.value 以触发响应式更新)
|
||||||
|
* @param remainingPercent 当前窗口剩余额度百分比(0-100,100 表示满额不启动倒计时)
|
||||||
|
*/
|
||||||
|
export function getCodexResetCountdown(
|
||||||
|
resetAt: number | null | undefined,
|
||||||
|
resetSecs: number | null | undefined,
|
||||||
|
updatedAt: number | null | undefined,
|
||||||
|
_tick: number,
|
||||||
|
remainingPercent?: number | null
|
||||||
|
): CodexResetStatus | null {
|
||||||
|
void _tick
|
||||||
|
|
||||||
|
if (remainingPercent != null) {
|
||||||
|
const normalizedRemaining = Number(remainingPercent)
|
||||||
|
if (Number.isFinite(normalizedRemaining) && normalizedRemaining >= 100) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nowSec = Math.floor(Date.now() / 1000)
|
||||||
|
let remaining: number
|
||||||
|
|
||||||
|
if (resetAt != null && resetAt > 0) {
|
||||||
|
// 优先绝对时间戳,避免相对秒数快照漂移。
|
||||||
|
remaining = resetAt - nowSec
|
||||||
|
} else if (resetSecs != null && resetSecs >= 0) {
|
||||||
|
if (updatedAt != null && updatedAt > 0) {
|
||||||
|
// 时钟偏移下 updatedAt 可能晚于当前时间,elapsed 需要下限钳制到 0。
|
||||||
|
const elapsedSec = Math.max(nowSec - updatedAt, 0)
|
||||||
|
remaining = resetSecs - elapsedSec
|
||||||
|
} else {
|
||||||
|
remaining = resetSecs
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (remaining <= 0) {
|
||||||
|
return { text: '已重置', isUrgent: false, isCritical: false, isExpired: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = Math.floor(remaining)
|
||||||
|
const days = Math.floor(total / 86400)
|
||||||
|
const hours = Math.floor((total % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((total % 3600) / 60)
|
||||||
|
const seconds = total % 60
|
||||||
|
const pad = (n: number) => n.toString().padStart(2, '0')
|
||||||
|
|
||||||
|
let text: string
|
||||||
|
if (days > 0) {
|
||||||
|
text = `${days}天 ${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||||
|
} else if (hours > 0) {
|
||||||
|
text = `${hours}:${pad(minutes)}:${pad(seconds)}`
|
||||||
|
} else {
|
||||||
|
text = `${minutes}:${pad(seconds)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
isUrgent: total < 3600,
|
||||||
|
isCritical: total < 300,
|
||||||
|
isExpired: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OAuth Token 状态信息
|
* OAuth Token 状态信息
|
||||||
*/
|
*/
|
||||||
@@ -94,15 +174,18 @@ export function getOAuthExpiresCountdown(
|
|||||||
invalidReason?: string | null
|
invalidReason?: string | null
|
||||||
): OAuthStatusInfo | null {
|
): OAuthStatusInfo | null {
|
||||||
void _tick
|
void _tick
|
||||||
|
const normalizedInvalidReason = typeof invalidReason === 'string'
|
||||||
|
? invalidReason.trim()
|
||||||
|
: ''
|
||||||
|
|
||||||
// 优先检查失效状态(失效比过期更严重)
|
// 优先检查失效状态(失效比过期更严重)
|
||||||
if (invalidAt != null) {
|
if (invalidAt != null || normalizedInvalidReason) {
|
||||||
return {
|
return {
|
||||||
text: '已失效',
|
text: '已失效',
|
||||||
isExpired: false,
|
isExpired: false,
|
||||||
isExpiringSoon: false,
|
isExpiringSoon: false,
|
||||||
isInvalid: true,
|
isInvalid: true,
|
||||||
invalidReason: invalidReason || undefined
|
invalidReason: normalizedInvalidReason || undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { computed, type Ref, type ComputedRef } from 'vue'
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检测失效模型的 composable
|
|
||||||
*
|
|
||||||
* 用于检测 allowed_models 中已不存在于 globalModels 的模型名称,
|
|
||||||
* 这些模型可能已被删除但引用未清理。
|
|
||||||
*
|
|
||||||
* @example
|
|
||||||
* ```typescript
|
|
||||||
* const { invalidModels } = useInvalidModels(
|
|
||||||
* computed(() => form.value.allowed_models),
|
|
||||||
* globalModels
|
|
||||||
* )
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
export interface ModelWithName {
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInvalidModels<T extends ModelWithName>(
|
|
||||||
allowedModels: Ref<string[]> | ComputedRef<string[]>,
|
|
||||||
globalModels: Ref<T[]>
|
|
||||||
): { invalidModels: ComputedRef<string[]> } {
|
|
||||||
const validModelNames = computed(() =>
|
|
||||||
new Set(globalModels.value.map(m => m.name))
|
|
||||||
)
|
|
||||||
|
|
||||||
const invalidModels = computed(() =>
|
|
||||||
allowedModels.value.filter(name => !validModelNames.value.has(name))
|
|
||||||
)
|
|
||||||
|
|
||||||
return { invalidModels }
|
|
||||||
}
|
|
||||||
@@ -15,6 +15,8 @@ export interface StartTestParams {
|
|||||||
apiFormat?: string
|
apiFormat?: string
|
||||||
endpointId?: string
|
endpointId?: string
|
||||||
message?: string
|
message?: string
|
||||||
|
requestHeaders?: Record<string, unknown>
|
||||||
|
requestBody?: Record<string, unknown>
|
||||||
concurrency?: number
|
concurrency?: number
|
||||||
onSuccess?: (result: TestModelFailoverResponse) => void
|
onSuccess?: (result: TestModelFailoverResponse) => void
|
||||||
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
|
/** Return `true` to indicate the failure has been handled; otherwise the composable sets `testResult`. */
|
||||||
@@ -61,6 +63,16 @@ export function useModelTest(options: UseModelTestOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshTraceSnapshot(reqId: string) {
|
||||||
|
try {
|
||||||
|
const trace = await requestTraceApi.getRequestTrace(reqId, { attemptedOnly: false })
|
||||||
|
if (requestId.value !== reqId) return
|
||||||
|
testTrace.value = trace
|
||||||
|
} catch (err: unknown) {
|
||||||
|
if (isAxiosError(err) && err.response?.status === 404) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function stopPolling(opts: { clearState?: boolean } = {}) {
|
function stopPolling(opts: { clearState?: boolean } = {}) {
|
||||||
tracePollToken += 1
|
tracePollToken += 1
|
||||||
if (tracePollTimer) {
|
if (tracePollTimer) {
|
||||||
@@ -117,13 +129,19 @@ export function useModelTest(options: UseModelTestOptions) {
|
|||||||
startPolling(reqId)
|
startPolling(reqId)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const normalizedMessage = typeof params.message === 'string' && params.message.trim()
|
||||||
|
? params.message.trim()
|
||||||
|
: undefined
|
||||||
|
|
||||||
const result = await testModelFailover({
|
const result = await testModelFailover({
|
||||||
provider_id: providerId(),
|
provider_id: providerId(),
|
||||||
mode: params.mode,
|
mode: params.mode,
|
||||||
model_name: params.modelName,
|
model_name: params.modelName,
|
||||||
api_format: params.apiFormat,
|
api_format: params.apiFormat,
|
||||||
endpoint_id: params.endpointId,
|
endpoint_id: params.endpointId,
|
||||||
message: params.message ?? 'hello',
|
...(normalizedMessage ? { message: normalizedMessage } : {}),
|
||||||
|
...(params.requestHeaders ? { request_headers: params.requestHeaders } : {}),
|
||||||
|
...(params.requestBody ? { request_body: params.requestBody } : {}),
|
||||||
request_id: reqId,
|
request_id: reqId,
|
||||||
concurrency: params.concurrency,
|
concurrency: params.concurrency,
|
||||||
}, {
|
}, {
|
||||||
@@ -131,6 +149,9 @@ export function useModelTest(options: UseModelTestOptions) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
await refreshTraceSnapshot(reqId)
|
||||||
|
stopPolling({ clearState: false })
|
||||||
|
testResult.value = result
|
||||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
|
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
|
||||||
@@ -138,10 +159,10 @@ export function useModelTest(options: UseModelTestOptions) {
|
|||||||
: ''
|
: ''
|
||||||
params.onSuccess?.(result)
|
params.onSuccess?.(result)
|
||||||
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
|
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
|
||||||
resetState()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await refreshTraceSnapshot(reqId)
|
||||||
stopPolling({ clearState: false })
|
stopPolling({ clearState: false })
|
||||||
const handled = params.onFailure?.(result)
|
const handled = params.onFailure?.(result)
|
||||||
if (!handled) {
|
if (!handled) {
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { computed, ref, watch, onMounted, onUnmounted, type Ref } from 'vue'
|
||||||
|
import {
|
||||||
|
analyticsApi,
|
||||||
|
type AnalyticsBreakdownRow,
|
||||||
|
type AnalyticsFilterOption,
|
||||||
|
type AnalyticsScope,
|
||||||
|
type AnalyticsSummary,
|
||||||
|
type AnalyticsTimeseriesBucket,
|
||||||
|
} from '@/api/analytics'
|
||||||
|
import type { DateRangeParams, PeriodValue } from '@/features/usage/types'
|
||||||
|
import { getDateRangeFromPeriod } from '@/features/usage/composables'
|
||||||
|
import { createLoader, createRequestGuard, buildTimeRangeParams } from '@/composables/useAnalyticsFilters'
|
||||||
|
import { fillMissingTimeseriesBuckets } from '@/utils/analyticsTimeseries'
|
||||||
|
import { getAnalyticsRangeDaysInclusive, resolveAnalyticsAutoGranularity, type ResolvedAnalyticsGranularity } from '@/utils/analyticsGranularity'
|
||||||
|
import { type DailyUsageBreakdown } from '@/utils/usageBreakdown'
|
||||||
|
|
||||||
|
export interface ModelSummary {
|
||||||
|
model: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
inputTokens: number
|
||||||
|
outputTokens: number
|
||||||
|
cacheCreationTokens: number
|
||||||
|
cacheReadTokens: number
|
||||||
|
cacheHitRate: number
|
||||||
|
avg_response_time?: number
|
||||||
|
avg_first_byte_time?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReportBucketStat {
|
||||||
|
bucket_start: string
|
||||||
|
bucket_end: string
|
||||||
|
requests: number
|
||||||
|
tokens: number
|
||||||
|
cost: number
|
||||||
|
avg_response_time: number
|
||||||
|
avg_first_byte_time: number
|
||||||
|
models_used_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBucketToReportStat(bucket: AnalyticsTimeseriesBucket): ReportBucketStat {
|
||||||
|
return {
|
||||||
|
bucket_start: bucket.bucket_start,
|
||||||
|
bucket_end: bucket.bucket_end,
|
||||||
|
requests: bucket.requests_total,
|
||||||
|
tokens: bucket.total_tokens,
|
||||||
|
cost: bucket.total_cost_usd,
|
||||||
|
avg_response_time: bucket.avg_response_time_ms / 1000,
|
||||||
|
avg_first_byte_time: bucket.avg_first_byte_time_ms / 1000,
|
||||||
|
models_used_count: bucket.models_used_count,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBucketToBreakdown(bucket: AnalyticsTimeseriesBucket): DailyUsageBreakdown {
|
||||||
|
return {
|
||||||
|
date: bucket.bucket_start,
|
||||||
|
inputTokens: bucket.input_tokens,
|
||||||
|
outputTokens: bucket.output_tokens,
|
||||||
|
cacheCreationTokens: bucket.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: bucket.cache_read_input_tokens,
|
||||||
|
cacheCreationCost: bucket.cache_creation_cost_usd,
|
||||||
|
cacheReadCost: bucket.cache_read_cost_usd,
|
||||||
|
cacheHitRate: bucket.cache_hit_rate,
|
||||||
|
totalCacheCost: bucket.cache_cost_usd,
|
||||||
|
totalCacheTokens: bucket.cache_creation_input_tokens + bucket.cache_read_input_tokens,
|
||||||
|
totalTrackedTokens: bucket.total_tokens,
|
||||||
|
baseTokens: bucket.input_output_total_tokens,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapBreakdownRowToModelSummary(row: AnalyticsBreakdownRow): ModelSummary {
|
||||||
|
const requests = row.requests_total
|
||||||
|
const tokens = row.total_tokens
|
||||||
|
const cost = row.total_cost_usd
|
||||||
|
return {
|
||||||
|
model: row.label,
|
||||||
|
requests,
|
||||||
|
tokens,
|
||||||
|
cost,
|
||||||
|
inputTokens: row.input_tokens,
|
||||||
|
outputTokens: row.output_tokens,
|
||||||
|
cacheCreationTokens: row.cache_creation_input_tokens,
|
||||||
|
cacheReadTokens: row.cache_read_input_tokens,
|
||||||
|
cacheHitRate: row.cache_hit_rate,
|
||||||
|
avg_response_time: row.avg_response_time_ms / 1000,
|
||||||
|
avg_first_byte_time: row.avg_first_byte_time_ms / 1000,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportGranularity = ResolvedAnalyticsGranularity
|
||||||
|
|
||||||
|
function resolveReportGranularity(range: DateRangeParams): ReportGranularity {
|
||||||
|
return resolveAnalyticsAutoGranularity(range)
|
||||||
|
}
|
||||||
|
|
||||||
|
function areStringArraysEqual(left: string[], right: string[]): boolean {
|
||||||
|
if (left.length !== right.length) return false
|
||||||
|
return left.every((value, index) => value === right[index])
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseReportsDataOptions {
|
||||||
|
scope?: AnalyticsScope
|
||||||
|
defaultPreset?: PeriodValue
|
||||||
|
timeRange?: Ref<DateRangeParams>
|
||||||
|
userFilter?: Ref<string[]>
|
||||||
|
apiKeyFilter?: Ref<string[]>
|
||||||
|
loadApiKeyOptions?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useReportsData(options: UseReportsDataOptions = {}) {
|
||||||
|
const {
|
||||||
|
scope = { kind: 'me' as const },
|
||||||
|
defaultPreset = 'today',
|
||||||
|
timeRange: externalTimeRange,
|
||||||
|
userFilter: externalUserFilter,
|
||||||
|
apiKeyFilter: externalApiKeyFilter,
|
||||||
|
loadApiKeyOptions = scope.kind === 'me',
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const timeRange = externalTimeRange ?? ref<DateRangeParams>({
|
||||||
|
...getDateRangeFromPeriod(defaultPreset),
|
||||||
|
granularity: 'auto',
|
||||||
|
})
|
||||||
|
const userFilter = externalUserFilter ?? ref<string[]>([])
|
||||||
|
const apiKeyFilter = externalApiKeyFilter ?? ref<string[]>([])
|
||||||
|
const apiKeyOptions = ref<AnalyticsFilterOption[]>([])
|
||||||
|
|
||||||
|
const summary = ref<AnalyticsSummary | null>(null)
|
||||||
|
const bucketStats = ref<ReportBucketStat[]>([])
|
||||||
|
const bucketBreakdowns = ref<Record<string, DailyUsageBreakdown>>({})
|
||||||
|
const modelSummary = ref<ModelSummary[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const loadError = ref(false)
|
||||||
|
const hasLoaded = ref(false)
|
||||||
|
const guard = createRequestGuard()
|
||||||
|
const apiKeysGuard = createRequestGuard()
|
||||||
|
let suppressNextApiKeyFilterLoad = false
|
||||||
|
const resolvedGranularity = computed(() => resolveReportGranularity(timeRange.value))
|
||||||
|
const timeseriesTimeRange = computed(() => ({
|
||||||
|
...buildTimeRangeParams(timeRange.value),
|
||||||
|
granularity: resolvedGranularity.value,
|
||||||
|
}))
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [timeRange.value.preset, timeRange.value.start_date, timeRange.value.end_date],
|
||||||
|
() => {
|
||||||
|
if (timeRange.value.granularity === 'hour' && getAnalyticsRangeDaysInclusive(timeRange.value) !== 1) {
|
||||||
|
timeRange.value = {
|
||||||
|
...timeRange.value,
|
||||||
|
granularity: 'auto',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const requestId = guard.next()
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = false
|
||||||
|
try {
|
||||||
|
const params = buildTimeRangeParams(timeRange.value)
|
||||||
|
const timeseriesParams = timeseriesTimeRange.value
|
||||||
|
const basePayload = {
|
||||||
|
scope,
|
||||||
|
time_range: params,
|
||||||
|
filters: {
|
||||||
|
user_ids: userFilter.value,
|
||||||
|
api_key_ids: apiKeyFilter.value,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const [overviewData, timeseriesData, modelBreakdownData] = await Promise.all([
|
||||||
|
analyticsApi.getOverview(basePayload),
|
||||||
|
analyticsApi.getTimeseries({
|
||||||
|
...basePayload,
|
||||||
|
time_range: timeseriesParams,
|
||||||
|
}),
|
||||||
|
analyticsApi.getBreakdown({
|
||||||
|
...basePayload,
|
||||||
|
dimension: 'model',
|
||||||
|
limit: 50,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
|
||||||
|
const filledBuckets = fillMissingTimeseriesBuckets(timeseriesData.buckets, timeseriesParams)
|
||||||
|
|
||||||
|
summary.value = overviewData.summary
|
||||||
|
bucketStats.value = filledBuckets.map(mapBucketToReportStat)
|
||||||
|
bucketBreakdowns.value = Object.fromEntries(
|
||||||
|
filledBuckets.map(bucket => {
|
||||||
|
const breakdown = mapBucketToBreakdown(bucket)
|
||||||
|
return [breakdown.date, breakdown]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
modelSummary.value = modelBreakdownData.rows.map(mapBreakdownRowToModelSummary)
|
||||||
|
hasLoaded.value = true
|
||||||
|
} catch {
|
||||||
|
if (guard.isStale(requestId)) return
|
||||||
|
loadError.value = true
|
||||||
|
} finally {
|
||||||
|
if (guard.isCurrent(requestId)) {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadApiKeys() {
|
||||||
|
if (!loadApiKeyOptions) return
|
||||||
|
const requestId = apiKeysGuard.next()
|
||||||
|
const filterOptions = await analyticsApi.getFilterOptions({
|
||||||
|
scope,
|
||||||
|
time_range: buildTimeRangeParams(timeRange.value),
|
||||||
|
filters: {},
|
||||||
|
}).catch(() => null)
|
||||||
|
if (apiKeysGuard.isStale(requestId)) return
|
||||||
|
|
||||||
|
const nextOptions = filterOptions?.api_keys ?? []
|
||||||
|
|
||||||
|
apiKeyOptions.value = nextOptions
|
||||||
|
const validOptionValues = new Set(nextOptions.map(option => option.value))
|
||||||
|
const nextFilter = apiKeyFilter.value.filter(value => validOptionValues.has(value))
|
||||||
|
if (!areStringArraysEqual(nextFilter, apiKeyFilter.value)) {
|
||||||
|
suppressNextApiKeyFilterLoad = true
|
||||||
|
apiKeyFilter.value = nextFilter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = createLoader(load)
|
||||||
|
const apiKeysLoader = createLoader(loadApiKeys)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
timeRange,
|
||||||
|
() => {
|
||||||
|
void (async () => {
|
||||||
|
if (loadApiKeyOptions) {
|
||||||
|
await apiKeysLoader.execute()
|
||||||
|
}
|
||||||
|
loader.schedule()
|
||||||
|
})()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[userFilter, apiKeyFilter],
|
||||||
|
() => {
|
||||||
|
if (suppressNextApiKeyFilterLoad) {
|
||||||
|
suppressNextApiKeyFilterLoad = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loader.schedule()
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
void (async () => {
|
||||||
|
if (loadApiKeyOptions) {
|
||||||
|
await apiKeysLoader.execute()
|
||||||
|
}
|
||||||
|
await loader.execute()
|
||||||
|
})()
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
loader.cleanup()
|
||||||
|
apiKeysLoader.cleanup()
|
||||||
|
guard.invalidate()
|
||||||
|
apiKeysGuard.invalidate()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRange,
|
||||||
|
apiKeyFilter,
|
||||||
|
apiKeyOptions,
|
||||||
|
summary,
|
||||||
|
bucketStats,
|
||||||
|
bucketBreakdowns,
|
||||||
|
modelSummary,
|
||||||
|
loading,
|
||||||
|
loadError,
|
||||||
|
hasLoaded,
|
||||||
|
resolvedGranularity,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { useRoute, useRouter, type LocationQueryValue, type LocationQuery } from 'vue-router'
|
||||||
|
|
||||||
|
type QueryValue = LocationQueryValue | LocationQueryValue[]
|
||||||
|
|
||||||
|
function normalizeQueryValue(value: QueryValue): string | undefined {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.length > 0 ? (value[value.length - 1] ?? undefined) : undefined
|
||||||
|
}
|
||||||
|
return typeof value === 'string' ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function queriesEqual(left: LocationQuery, right: LocationQuery): boolean {
|
||||||
|
const keys = new Set([...Object.keys(left), ...Object.keys(right)])
|
||||||
|
for (const key of keys) {
|
||||||
|
if (normalizeQueryValue(left[key]) !== normalizeQueryValue(right[key])) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useRouteQuery() {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function getQueryValue(key: string): string | undefined {
|
||||||
|
return normalizeQueryValue(route.query[key])
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchQuery(patch: Record<string, string | undefined | null>) {
|
||||||
|
const next: LocationQuery = { ...route.query }
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value == null || value.trim() === '') {
|
||||||
|
delete next[key]
|
||||||
|
} else {
|
||||||
|
next[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (queriesEqual(route.query, next)) return
|
||||||
|
void router.replace({ query: next }).catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return { route, router, getQueryValue, patchQuery }
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user