mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
Compare commits
107
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 |
@@ -49,6 +49,10 @@ ADMIN_PASSWORD=admin123456
|
||||
# max-requests-jitter 会自动设置为 MAX_REQUESTS/20 (5%)
|
||||
# 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
|
||||
@@ -56,6 +60,29 @@ ADMIN_PASSWORD=admin123456
|
||||
# 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_PREFIX=sk
|
||||
|
||||
@@ -67,6 +94,14 @@ ADMIN_PASSWORD=admin123456
|
||||
# 默认: * (允许所有源)
|
||||
# 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 并告警)
|
||||
# BILLING_REQUIRE_RULE=false
|
||||
|
||||
@@ -7,12 +7,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
GHCR_IMAGE: fawney19/aether-hub
|
||||
DOCKERHUB_IMAGE: fawney19/aether-hub
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -95,78 +89,3 @@ jobs:
|
||||
artifacts/aether-hub-*
|
||||
artifacts/SHA256SUMS.txt
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
docker:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download Linux artifacts
|
||||
uses: actions/download-artifact@v5
|
||||
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@v6
|
||||
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
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
# Edit at https://www.toptal.com/developers/gitignore?templates=python
|
||||
|
||||
# AI Assistant Configuration
|
||||
.codex/
|
||||
.claude/
|
||||
.serena/
|
||||
.gemini*/
|
||||
@@ -203,6 +204,7 @@ logs/
|
||||
|
||||
# Git backup
|
||||
.git.backup/
|
||||
.worktrees/
|
||||
|
||||
# Database backups
|
||||
backups/
|
||||
|
||||
+66
-5
@@ -25,7 +25,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
nginx \
|
||||
supervisor \
|
||||
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 包
|
||||
COPY --from=builder /usr/local/lib/python3.13/site-packages /usr/local/lib/python3.13/site-packages
|
||||
# 只复制需要的 Python 可执行文件
|
||||
@@ -85,6 +90,11 @@ RUN printf '%s\n' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_upgrade $connection_upgrade {' \
|
||||
' default upgrade;' \
|
||||
' "" "";' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
@@ -124,6 +134,25 @@ RUN printf '%s\n' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Upgrade $http_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_send_timeout 86400s;' \
|
||||
' }' \
|
||||
@@ -136,12 +165,22 @@ RUN printf '%s\n' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' 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 Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' 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-IPCountry "";' \
|
||||
' proxy_set_header CF-Ray "";' \
|
||||
@@ -150,6 +189,7 @@ RUN printf '%s\n' \
|
||||
' 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_buffering off;' \
|
||||
' proxy_cache 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-Forwarded-For $forwarded_for;' \
|
||||
' 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)' \
|
||||
@@ -199,10 +258,10 @@ RUN printf '%s\n' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'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]' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085 --worker-idle-timeout 0' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
@@ -220,6 +279,8 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=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 \
|
||||
GUNICORN_WORKERS=2 \
|
||||
MAX_REQUESTS=4000
|
||||
|
||||
+90
-17
@@ -20,6 +20,11 @@ ARG HUB_RELEASE_REPO=fawney19/Aether
|
||||
ARG HUB_TAG
|
||||
ARG TARGETARCH
|
||||
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 缓存加速)
|
||||
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 \
|
||||
supervisor \
|
||||
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 包
|
||||
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/alembic /usr/local/bin/
|
||||
|
||||
# Hub 预编译二进制(构建时从 GitHub Release 下载)
|
||||
# Hub 预编译二进制
|
||||
# 国内构建: --build-arg GITHUB_MIRROR=https://ghfast.top 即可走镜像下载
|
||||
# GITHUB_TOKEN 可选:未认证 API 限流 60 次/小时,认证后 5000 次/小时
|
||||
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=""; \
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then \
|
||||
auth_header="Authorization: token ${GITHUB_TOKEN}"; \
|
||||
@@ -54,18 +75,14 @@ RUN set -eux; \
|
||||
echo "Failed to resolve hub release tag"; \
|
||||
exit 1; \
|
||||
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"; \
|
||||
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"; \
|
||||
tar xzf /tmp/aether-hub.tar.gz -C /usr/local/bin; \
|
||||
chmod +x /usr/local/bin/aether-hub; \
|
||||
@@ -95,6 +112,11 @@ RUN printf '%s\n' \
|
||||
' "" $remote_addr;' \
|
||||
'}' \
|
||||
'' \
|
||||
'map $http_upgrade $connection_upgrade {' \
|
||||
' default upgrade;' \
|
||||
' "" "";' \
|
||||
'}' \
|
||||
'' \
|
||||
'server {' \
|
||||
' listen 80;' \
|
||||
' server_name _;' \
|
||||
@@ -134,6 +156,25 @@ RUN printf '%s\n' \
|
||||
' proxy_set_header X-Forwarded-Proto $scheme;' \
|
||||
' proxy_set_header Upgrade $http_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_send_timeout 86400s;' \
|
||||
' }' \
|
||||
@@ -146,12 +187,22 @@ RUN printf '%s\n' \
|
||||
' proxy_set_header X-Real-IP $real_ip;' \
|
||||
' proxy_set_header X-Forwarded-For $forwarded_for;' \
|
||||
' 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 Content-Type $content_type;' \
|
||||
' proxy_set_header Authorization $http_authorization;' \
|
||||
' 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-IPCountry "";' \
|
||||
' proxy_set_header CF-Ray "";' \
|
||||
@@ -160,6 +211,7 @@ RUN printf '%s\n' \
|
||||
' 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_buffering off;' \
|
||||
' proxy_cache 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-Forwarded-For $forwarded_for;' \
|
||||
' 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)' \
|
||||
@@ -210,10 +281,10 @@ RUN printf '%s\n' \
|
||||
'stdout_logfile_maxbytes=0' \
|
||||
'stderr_logfile=/dev/stderr' \
|
||||
'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]' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085 --worker-idle-timeout 0' \
|
||||
'command=/usr/local/bin/aether-hub --bind 0.0.0.0:8085' \
|
||||
'autostart=true' \
|
||||
'autorestart=true' \
|
||||
'stdout_logfile=/dev/stdout' \
|
||||
@@ -234,6 +305,8 @@ ENV PYTHONUNBUFFERED=1 \
|
||||
PYTHONIOENCODING=utf-8 \
|
||||
LANG=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 \
|
||||
GUNICORN_WORKERS=2 \
|
||||
MAX_REQUESTS=4000
|
||||
|
||||
@@ -64,6 +64,7 @@ cp .env.example .env
|
||||
python generate_keys.py # 生成密钥, 并将生成的密钥填入 .env
|
||||
|
||||
# 3. 部署 / 更新(自动构建、启动、迁移)
|
||||
git pull
|
||||
./deploy.sh
|
||||
```
|
||||
|
||||
|
||||
Generated
+794
-11
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "aether-hub"
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
edition = "2021"
|
||||
description = "Tunnel Hub for Aether - frame router between workers and proxies"
|
||||
|
||||
@@ -16,6 +16,10 @@ dashmap = "6"
|
||||
parking_lot = "0.12"
|
||||
flate2 = "1"
|
||||
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]
|
||||
lto = true
|
||||
|
||||
@@ -28,6 +28,11 @@ cd /path/to/Aether
|
||||
- `--load`: 加载到本地 Docker(单平台)
|
||||
- `--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` 固定版本)。
|
||||
|
||||
@@ -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()
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
+637
-477
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")))
|
||||
}
|
||||
+42
-34
@@ -1,20 +1,23 @@
|
||||
mod control_plane;
|
||||
mod hub;
|
||||
mod local_relay;
|
||||
mod protocol;
|
||||
mod proxy_conn;
|
||||
mod worker_conn;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::net::SocketAddr;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::WebSocketUpgrade;
|
||||
use axum::extract::State;
|
||||
use axum::response::{IntoResponse, Json};
|
||||
use axum::routing::get;
|
||||
use axum::routing::{get, post};
|
||||
use axum::Router;
|
||||
use clap::Parser;
|
||||
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)]
|
||||
#[command(name = "aether-hub", about = "Tunnel Hub for Aether")]
|
||||
@@ -27,10 +30,6 @@ struct Args {
|
||||
#[arg(long, default_value_t = 0, env = "TUNNEL_HUB_PROXY_IDLE_TIMEOUT")]
|
||||
proxy_idle_timeout: u64,
|
||||
|
||||
/// Worker-side idle timeout in seconds (0 to disable)
|
||||
#[arg(long, default_value_t = 0, env = "TUNNEL_HUB_WORKER_IDLE_TIMEOUT")]
|
||||
worker_idle_timeout: u64,
|
||||
|
||||
/// Ping interval in seconds (for both sides)
|
||||
#[arg(long, default_value_t = 15, env = "TUNNEL_HUB_PING_INTERVAL")]
|
||||
ping_interval: u64,
|
||||
@@ -38,15 +37,29 @@ struct Args {
|
||||
/// Max concurrent streams per proxy connection
|
||||
#[arg(long, default_value_t = 2048, env = "TUNNEL_HUB_MAX_STREAMS")]
|
||||
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)]
|
||||
struct AppState {
|
||||
hub: Arc<HubRouter>,
|
||||
proxy_idle_timeout: Duration,
|
||||
worker_idle_timeout: Duration,
|
||||
ping_interval: Duration,
|
||||
max_streams: usize,
|
||||
pub struct AppState {
|
||||
pub hub: std::sync::Arc<HubRouter>,
|
||||
pub proxy_conn_cfg: ConnConfig,
|
||||
pub max_streams: usize,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
@@ -61,12 +74,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
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 {
|
||||
hub,
|
||||
proxy_idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
|
||||
worker_idle_timeout: Duration::from_secs(args.worker_idle_timeout),
|
||||
ping_interval: Duration::from_secs(args.ping_interval),
|
||||
proxy_conn_cfg: ConnConfig {
|
||||
ping_interval,
|
||||
idle_timeout: Duration::from_secs(args.proxy_idle_timeout),
|
||||
outbound_queue_capacity,
|
||||
},
|
||||
max_streams: args.max_streams,
|
||||
};
|
||||
|
||||
@@ -74,13 +91,17 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.route("/health", get(health))
|
||||
.route("/stats", get(stats))
|
||||
.route("/proxy", get(ws_proxy))
|
||||
.route("/worker", get(ws_worker))
|
||||
.route("/local/relay/{node_id}", post(relay_request))
|
||||
.with_state(state);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&args.bind).await?;
|
||||
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(())
|
||||
}
|
||||
|
||||
@@ -139,21 +160,8 @@ async fn ws_proxy(
|
||||
node_id,
|
||||
node_name,
|
||||
max_streams,
|
||||
state.ping_interval,
|
||||
state.proxy_idle_timeout,
|
||||
state.proxy_conn_cfg,
|
||||
)
|
||||
})
|
||||
.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 HEARTBEAT_DATA: u8 = 0x13;
|
||||
pub const HEARTBEAT_ACK: u8 = 0x14;
|
||||
pub const NODE_STATUS: u8 = 0x15;
|
||||
|
||||
// Flags
|
||||
pub const FLAG_END_STREAM: u8 = 0x01;
|
||||
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]]),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if this is a stream-terminating frame
|
||||
#[inline]
|
||||
pub fn is_stream_terminal(&self) -> bool {
|
||||
self.msg_type == STREAM_END || self.msg_type == STREAM_ERROR
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct RequestMeta {
|
||||
pub method: String,
|
||||
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)]
|
||||
pub struct RequestHeadersExtracted {
|
||||
pub node_id: String,
|
||||
pub rebuilt_frame: Vec<u8>,
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
pub struct ResponseMeta {
|
||||
pub status: u16,
|
||||
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
|
||||
pub fn encode_stream_error(stream_id: u32, msg: &str) -> Vec<u8> {
|
||||
let payload = 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_frame(stream_id, STREAM_ERROR, 0, msg.as_bytes())
|
||||
}
|
||||
|
||||
/// Encode a PING frame (stream_id=0)
|
||||
pub fn encode_ping() -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE);
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf.push(PING);
|
||||
buf.push(0);
|
||||
buf.extend_from_slice(&0u32.to_be_bytes());
|
||||
buf
|
||||
encode_frame(0, PING, 0, &[])
|
||||
}
|
||||
|
||||
/// Encode a PONG frame (stream_id=0, echo payload)
|
||||
pub fn encode_pong(payload: &[u8]) -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE + payload.len());
|
||||
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_frame(0, PONG, 0, payload)
|
||||
}
|
||||
|
||||
/// Encode a GOAWAY frame (stream_id=0)
|
||||
pub fn encode_goaway() -> Vec<u8> {
|
||||
let mut buf = Vec::with_capacity(HEADER_SIZE);
|
||||
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,
|
||||
})
|
||||
encode_frame(0, GOAWAY, 0, &[])
|
||||
}
|
||||
|
||||
#[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 end = HEADER_SIZE.checked_add(payload_len)?;
|
||||
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])
|
||||
}
|
||||
|
||||
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(
|
||||
payload: &[u8],
|
||||
prefer_gzip: bool,
|
||||
|
||||
@@ -7,10 +7,10 @@ use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket};
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::{mpsc, watch};
|
||||
use tracing::{debug, info, warn};
|
||||
|
||||
use crate::hub::{HubRouter, ProxyConn};
|
||||
use crate::hub::{ConnConfig, HubRouter, ProxyConn, SendStatus};
|
||||
use crate::protocol;
|
||||
|
||||
/// Maximum single frame size: 64 MB
|
||||
@@ -22,69 +22,71 @@ pub async fn handle_proxy_connection(
|
||||
node_id: String,
|
||||
node_name: String,
|
||||
max_streams: usize,
|
||||
ping_interval: Duration,
|
||||
idle_timeout: Duration,
|
||||
cfg: ConnConfig,
|
||||
) {
|
||||
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 (tx, mut rx) = mpsc::channel::<Message>(cfg.outbound_queue_capacity);
|
||||
let (close_tx, mut close_rx) = watch::channel(false);
|
||||
|
||||
let conn = Arc::new(ProxyConn::new(
|
||||
conn_id,
|
||||
node_id.clone(),
|
||||
node_name.clone(),
|
||||
tx,
|
||||
close_tx,
|
||||
max_streams,
|
||||
));
|
||||
|
||||
hub.register_proxy(conn.clone());
|
||||
|
||||
// Spawn writer task: drains channel -> WebSocket
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(msg) = rx.recv().await {
|
||||
if ws_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = rx.recv() => match msg {
|
||||
Some(msg) => {
|
||||
if ws_tx.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
changed = close_rx.changed() => {
|
||||
if changed.is_err() || *close_rx.borrow() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ws_tx.close().await;
|
||||
});
|
||||
|
||||
// Spawn ping task
|
||||
let ping_tx = conn.tx.clone();
|
||||
let ping_conn = conn.clone();
|
||||
let ping_interval = cfg.ping_interval;
|
||||
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() {
|
||||
if !matches!(
|
||||
ping_conn.send(Message::Binary(ping.into())),
|
||||
SendStatus::Queued
|
||||
) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Spawn reader task
|
||||
let reader_hub = hub.clone();
|
||||
let reader_node_id = node_id.clone();
|
||||
let reader_tx = conn.tx.clone();
|
||||
let reader_conn = conn.clone();
|
||||
let reader = tokio::spawn(async move {
|
||||
run_proxy_reader(
|
||||
ws_rx,
|
||||
reader_hub,
|
||||
conn_id,
|
||||
reader_node_id,
|
||||
reader_tx,
|
||||
idle_timeout,
|
||||
)
|
||||
.await;
|
||||
run_proxy_reader(ws_rx, reader_hub, reader_conn, cfg.idle_timeout).await;
|
||||
});
|
||||
|
||||
// Wait for reader to end, then cleanup.
|
||||
let _ = reader.await;
|
||||
ping_task.abort();
|
||||
conn.request_close();
|
||||
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);
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
writer.abort();
|
||||
@@ -94,9 +96,7 @@ pub async fn handle_proxy_connection(
|
||||
async fn run_proxy_reader(
|
||||
mut ws_rx: futures_util::stream::SplitStream<WebSocket>,
|
||||
hub: Arc<HubRouter>,
|
||||
conn_id: u64,
|
||||
node_id: String,
|
||||
tx: mpsc::UnboundedSender<Message>,
|
||||
conn: Arc<ProxyConn>,
|
||||
idle_timeout: Duration,
|
||||
) {
|
||||
let idle_enabled = !idle_timeout.is_zero();
|
||||
@@ -106,8 +106,9 @@ async fn run_proxy_reader(
|
||||
tokio::select! {
|
||||
msg = ws_rx.next() => msg,
|
||||
_ = tokio::time::sleep(idle_timeout) => {
|
||||
warn!(conn_id = conn_id, node_id = %node_id, "proxy idle timeout");
|
||||
let _ = tx.send(Message::Binary(protocol::encode_goaway().into()));
|
||||
warn!(conn_id = conn.id, node_id = %conn.node_id, "proxy idle timeout");
|
||||
let _ = conn.send(Message::Binary(protocol::encode_goaway().into()));
|
||||
conn.request_close();
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -121,12 +122,13 @@ async fn run_proxy_reader(
|
||||
if data.len() > MAX_FRAME_SIZE {
|
||||
oversized_count += 1;
|
||||
warn!(
|
||||
conn_id = conn_id,
|
||||
conn_id = conn.id,
|
||||
size = data.len(),
|
||||
"oversized frame from proxy"
|
||||
);
|
||||
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;
|
||||
}
|
||||
continue;
|
||||
@@ -134,21 +136,21 @@ async fn run_proxy_reader(
|
||||
oversized_count = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
hub.handle_proxy_frame(conn_id, &mut data);
|
||||
hub.handle_proxy_frame(conn.id, &mut data).await;
|
||||
}
|
||||
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;
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
warn!(conn_id = conn_id, error = %e, "proxy WebSocket error");
|
||||
warn!(conn_id = conn.id, error = %e, "proxy WebSocket error");
|
||||
break;
|
||||
}
|
||||
_ => {} // Ignore text/ping/pong at WS level
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,141 +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,
|
||||
) {
|
||||
let idle_enabled = !idle_timeout.is_zero();
|
||||
loop {
|
||||
let msg = if idle_enabled {
|
||||
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;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ws_rx.next().await
|
||||
};
|
||||
|
||||
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]]
|
||||
name = "aether-proxy"
|
||||
version = "0.2.3"
|
||||
version = "0.2.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"arc-swap",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "aether-proxy"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
edition = "2021"
|
||||
description = "Tunnel proxy for Aether"
|
||||
|
||||
|
||||
@@ -19,11 +19,11 @@ docker compose up -d
|
||||
<!-- DOWNLOAD_TABLE_START -->
|
||||
| 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 ARM64 | [aether-proxy-linux-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/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 ARM64 | [aether-proxy-macos-arm64.tar.gz](https://github.com/fawney19/Aether/releases/download/proxy-v0.2.4/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) |
|
||||
| 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.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.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.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.5/aether-proxy-windows-amd64.zip) |
|
||||
<!-- DOWNLOAD_TABLE_END -->
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -182,7 +182,9 @@ where
|
||||
|
||||
MsgType::StreamEnd | MsgType::StreamError => {
|
||||
// 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 => {
|
||||
|
||||
@@ -3,22 +3,27 @@
|
||||
//! Receives request frames, executes the upstream HTTP request,
|
||||
//! 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::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::stream;
|
||||
use futures_util::StreamExt;
|
||||
use http_body_util::BodyExt;
|
||||
use hyper::body::Frame as BodyFrame;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::state::{AppState, ServerContext};
|
||||
use crate::target_filter;
|
||||
use crate::upstream_client::{self, UpstreamRequestBody};
|
||||
use crate::upstream_client;
|
||||
|
||||
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;
|
||||
|
||||
@@ -64,13 +69,13 @@ pub async fn handle_stream(
|
||||
server: Arc<ServerContext>,
|
||||
stream_id: u32,
|
||||
meta: RequestMeta,
|
||||
mut body_rx: mpsc::Receiver<Frame>,
|
||||
body_rx: mpsc::Receiver<TunnelFrame>,
|
||||
frame_tx: FrameSender,
|
||||
) {
|
||||
server.active_connections.fetch_add(1, Ordering::Release);
|
||||
|
||||
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);
|
||||
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.
|
||||
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 {
|
||||
Ok(Ok(())) => true,
|
||||
Ok(Err(_)) => {
|
||||
@@ -102,62 +107,9 @@ async fn handle_stream_inner(
|
||||
server: &ServerContext,
|
||||
stream_id: u32,
|
||||
meta: RequestMeta,
|
||||
body_rx: &mut mpsc::Receiver<Frame>,
|
||||
body_rx: mpsc::Receiver<TunnelFrame>,
|
||||
frame_tx: &FrameSender,
|
||||
) -> 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
|
||||
let target_url = match url::Url::parse(&meta.url) {
|
||||
Ok(u) => u,
|
||||
@@ -207,12 +159,14 @@ async fn handle_stream_inner(
|
||||
// Execute upstream request
|
||||
let client = &state.upstream_client;
|
||||
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 mut request = match hyper::Request::builder()
|
||||
.method(method)
|
||||
.uri(meta.url.as_str())
|
||||
.body(UpstreamRequestBody::new(body.clone()))
|
||||
.body(request_body)
|
||||
{
|
||||
Ok(request) => request,
|
||||
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 connection_start = Instant::now();
|
||||
let connection_capture = tokio::spawn(async move {
|
||||
@@ -313,7 +266,7 @@ async fn handle_stream_inner(
|
||||
"upstream_processing_ms": request_timing.response_wait_ms,
|
||||
"timing_source": "instrumented_connector",
|
||||
"total_ms": connect_elapsed.as_millis() as u64,
|
||||
"body_size": body_size,
|
||||
"body_size": request_body_size.load(Ordering::Relaxed),
|
||||
"mode": "tunnel",
|
||||
});
|
||||
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);
|
||||
if !send_frame(
|
||||
frame_tx,
|
||||
Frame::new(
|
||||
TunnelFrame::new(
|
||||
stream_id,
|
||||
MsgType::ResponseHeaders,
|
||||
meta_flags,
|
||||
@@ -350,7 +303,7 @@ async fn handle_stream_inner(
|
||||
let (payload, extra_flags) = compress_payload(chunk);
|
||||
if !send_frame(
|
||||
frame_tx,
|
||||
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||
TunnelFrame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -365,7 +318,12 @@ async fn handle_stream_inner(
|
||||
let (payload, extra_flags) = compress_payload(slice);
|
||||
if !send_frame(
|
||||
frame_tx,
|
||||
Frame::new(stream_id, MsgType::ResponseBody, extra_flags, payload),
|
||||
TunnelFrame::new(
|
||||
stream_id,
|
||||
MsgType::ResponseBody,
|
||||
extra_flags,
|
||||
payload,
|
||||
),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -387,7 +345,7 @@ async fn handle_stream_inner(
|
||||
// Send STREAM_END
|
||||
let _ = send_frame(
|
||||
frame_tx,
|
||||
Frame::new(
|
||||
TunnelFrame::new(
|
||||
stream_id,
|
||||
MsgType::StreamEnd,
|
||||
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
|
||||
let _ = send_frame(
|
||||
tx,
|
||||
Frame::new(
|
||||
TunnelFrame::new(
|
||||
stream_id,
|
||||
MsgType::StreamError,
|
||||
0,
|
||||
@@ -413,3 +371,136 @@ async fn send_error(tx: &FrameSender, stream_id: u32, msg: &str) {
|
||||
)
|
||||
.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 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::Response;
|
||||
use hyper::Uri;
|
||||
@@ -30,9 +33,16 @@ type BoxError = Box<dyn std::error::Error + Send + Sync>;
|
||||
type PlainStream = TokioIo<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 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)]
|
||||
pub struct ConnectTiming {
|
||||
pub connect_ms: u64,
|
||||
|
||||
@@ -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 (自动检测所有变化)
|
||||
# 指定 Hub 版本: ./deploy.sh --hub-tag hub-v0.1.0
|
||||
# 更新 Hub: ./deploy.sh --update-hub
|
||||
# GitHub 镜像: ./deploy.sh --mirror https://ghfast.top
|
||||
# 强制重建: ./deploy.sh --rebuild-base
|
||||
# 强制全部重建: ./deploy.sh --force
|
||||
|
||||
@@ -44,6 +45,7 @@ Usage: ./deploy.sh [options]
|
||||
Options:
|
||||
--hub-tag <hub-vX.Y.Z> 指定 Hub Release tag(例如 hub-v0.1.0)
|
||||
--update-hub 强制刷新 Hub 版本标记(下次构建会重新下载)
|
||||
--mirror <url> GitHub 下载镜像(例如 https://ghfast.top)
|
||||
--rebuild-base, -r 仅重建 base 镜像
|
||||
--force, -f 强制重建全部(hub/base/app)并重启
|
||||
-h, --help 显示帮助
|
||||
@@ -54,6 +56,7 @@ FORCE_REBUILD_ALL=false
|
||||
REBUILD_BASE_ONLY=false
|
||||
FORCE_UPDATE_HUB=false
|
||||
HUB_TAG="${HUB_TAG:-}"
|
||||
GITHUB_MIRROR="${GITHUB_MIRROR:-}"
|
||||
RESOLVED_HUB_TAG=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
@@ -70,6 +73,14 @@ while [ $# -gt 0 ]; do
|
||||
FORCE_UPDATE_HUB=true
|
||||
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_ONLY=true
|
||||
shift
|
||||
@@ -97,27 +108,26 @@ if [ -n "$HUB_TAG" ]; then
|
||||
esac
|
||||
fi
|
||||
|
||||
# 提取 pyproject.toml 中"会影响运行时依赖安装"的最小指纹(与 CI 保持一致):
|
||||
# - [build-system] requires / build-backend
|
||||
# - [project] requires-python / dependencies
|
||||
# 使用 Python tomllib 解析,不受 TOML 格式变化影响。
|
||||
# 提取 pyproject.toml 中会影响运行时依赖安装的字段指纹(纯 shell,无需 Python)
|
||||
# 用 sed 提取 dependencies / requires 数组块和单值字段,排序后输出稳定文本
|
||||
pyproject_deps_fingerprint() {
|
||||
python3 - <<'PY'
|
||||
import json, pathlib, tomllib
|
||||
|
||||
data = tomllib.loads(pathlib.Path("pyproject.toml").read_text("utf-8"))
|
||||
project = data.get("project") or {}
|
||||
build = data.get("build-system") or {}
|
||||
|
||||
fingerprint = {
|
||||
"requires-python": project.get("requires-python"),
|
||||
"dependencies": sorted(project.get("dependencies") or []),
|
||||
"build-backend": build.get("build-backend"),
|
||||
"build-requires": sorted(build.get("requires") or []),
|
||||
}
|
||||
|
||||
print(json.dumps(fingerprint, sort_keys=True, separators=(",", ":")))
|
||||
PY
|
||||
local file="pyproject.toml"
|
||||
# 提取 "key = [..." 多行数组块(从 key 行到 ] 行)
|
||||
extract_array() {
|
||||
sed -n "/^$1[[:space:]]*=[[:space:]]*\[/,/\]/p" "$file" | grep '"' | sed 's/.*"\(.*\)".*/\1/' | sort
|
||||
}
|
||||
# 提取 "key = "value"" 单行值
|
||||
extract_value() {
|
||||
grep -m1 "^$1[[:space:]]*=" "$file" 2>/dev/null | sed 's/.*"\(.*\)".*/\1/'
|
||||
}
|
||||
{
|
||||
echo "requires-python=$(extract_value requires-python)"
|
||||
echo "build-backend=$(extract_value build-backend)"
|
||||
echo "dependencies:"
|
||||
extract_array dependencies
|
||||
echo "build-requires:"
|
||||
extract_array requires
|
||||
}
|
||||
}
|
||||
|
||||
# 计算依赖文件的哈希值(包含 Dockerfile.base.local)
|
||||
@@ -286,10 +296,15 @@ build_app() {
|
||||
if [ -n "${GITHUB_TOKEN:-}" ]; then
|
||||
token_args=(--build-arg "GITHUB_TOKEN=${GITHUB_TOKEN}")
|
||||
fi
|
||||
local mirror_args=()
|
||||
if [ -n "${GITHUB_MIRROR:-}" ]; then
|
||||
mirror_args=(--build-arg "GITHUB_MIRROR=${GITHUB_MIRROR}")
|
||||
fi
|
||||
docker build --pull=false \
|
||||
--build-arg HUB_RELEASE_REPO="$GITHUB_REPO" \
|
||||
--build-arg HUB_TAG="$RESOLVED_HUB_TAG" \
|
||||
"${token_args[@]}" \
|
||||
"${mirror_args[@]}" \
|
||||
-f Dockerfile.app.local \
|
||||
-t aether-app:latest .
|
||||
save_code_hash
|
||||
@@ -372,10 +387,6 @@ if [ "$FORCE_UPDATE_HUB" = true ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 拉取最新代码
|
||||
echo ">>> Pulling latest code..."
|
||||
git pull
|
||||
|
||||
# 标记是否需要重启
|
||||
NEED_RESTART=false
|
||||
BASE_REBUILT=false
|
||||
|
||||
+66
-2
@@ -5,12 +5,14 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onErrorCaptured } from 'vue'
|
||||
import { onMounted, onErrorCaptured, onUnmounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import ToastContainer from '@/components/ToastContainer.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 router from '@/router'
|
||||
import { hasAuthIdentityChanged } from '@/utils/authToken'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
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 () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('storage', handleAuthStorageChange)
|
||||
window.addEventListener(AUTH_STATE_CHANGE_EVENT, handleLocalAuthStateChange as (event: Event) => void)
|
||||
}
|
||||
|
||||
// 延迟检查认证状态,让页面先加载
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
@@ -97,4 +151,14 @@ onMounted(async () => {
|
||||
}
|
||||
}, 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>
|
||||
|
||||
@@ -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_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||
model_capability_settings?: Record<string, Record<string, boolean>>
|
||||
unlimited?: boolean
|
||||
wallet?: BillingSummary | null
|
||||
@@ -102,7 +103,7 @@ export interface UserApiKeyExport {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: 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
|
||||
force_capabilities?: Record<string, boolean>
|
||||
is_active: boolean
|
||||
@@ -349,7 +350,7 @@ export interface AdminApiKey {
|
||||
total_requests?: number
|
||||
total_tokens?: number
|
||||
total_cost_usd?: number
|
||||
rate_limit?: number | null // null = 无限制
|
||||
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||
allowed_providers?: string[] | null // 允许的提供商列表
|
||||
allowed_api_formats?: string[] | null // 允许的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许的模型列表
|
||||
@@ -365,7 +366,7 @@ export interface CreateStandaloneApiKeyRequest {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: 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 = 永不过期
|
||||
initial_balance_usd: number | null // 初始余额,null = 无限制
|
||||
unlimited_balance?: boolean | null // 编辑时仅切换额度模式,不调整余额数值
|
||||
@@ -379,102 +380,6 @@ export interface AdminApiKeysResponse {
|
||||
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 {
|
||||
id: string // UUID
|
||||
is_active: boolean
|
||||
@@ -752,197 +657,6 @@ export const adminApi = {
|
||||
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'),
|
||||
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'),
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
const items: AuditLog[] = (data.items ?? data.logs ?? []) as AuditLog[]
|
||||
const meta: PaginationMeta = (data.meta as PaginationMeta) ?? {
|
||||
@@ -78,6 +275,11 @@ export const auditApi = {
|
||||
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<{
|
||||
analysis: Record<string, unknown>
|
||||
|
||||
@@ -9,7 +9,6 @@ export interface LoginRequest {
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type?: string
|
||||
expires_in?: number
|
||||
user_id?: string // UUID
|
||||
@@ -25,13 +24,6 @@ export interface UserPreferences {
|
||||
[key: string]: unknown // 允许扩展其他偏好设置
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_requests?: number
|
||||
total_cost?: number
|
||||
last_request_at?: string
|
||||
[key: string]: unknown // 允许扩展其他统计数据
|
||||
}
|
||||
|
||||
export interface SendVerificationCodeRequest {
|
||||
email: string
|
||||
}
|
||||
@@ -114,23 +106,15 @@ export interface User {
|
||||
role: string // 'admin' or 'user'
|
||||
is_active: boolean
|
||||
billing?: BillingSummary
|
||||
allowed_providers?: string[] | null // 允许使用的提供商 ID 列表
|
||||
allowed_api_formats?: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_models?: string[] | null // 允许使用的模型名称列表
|
||||
created_at: string
|
||||
last_login_at?: string
|
||||
preferences?: UserPreferences
|
||||
stats?: UserStats
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
async login(credentials: LoginRequest): Promise<LoginResponse> {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/login', credentials)
|
||||
apiClient.setToken(response.data.access_token)
|
||||
// 后端暂时没有返回 refresh_token
|
||||
if (response.data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -152,14 +136,9 @@ export const authApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<LoginResponse> {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {
|
||||
refresh_token: refreshToken
|
||||
})
|
||||
async refreshToken(): Promise<LoginResponse> {
|
||||
const response = await apiClient.post<LoginResponse>('/api/auth/refresh', {})
|
||||
apiClient.setToken(response.data.access_token)
|
||||
if (response.data.refresh_token) {
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
}
|
||||
return response.data
|
||||
},
|
||||
|
||||
|
||||
@@ -288,7 +288,12 @@ export const cacheAnalysisApi = {
|
||||
api_key_id?: string
|
||||
hours?: number
|
||||
}): 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
|
||||
},
|
||||
|
||||
@@ -300,7 +305,12 @@ export const cacheAnalysisApi = {
|
||||
api_key_id?: string
|
||||
hours?: number
|
||||
}): 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
|
||||
},
|
||||
|
||||
@@ -319,7 +329,13 @@ export const cacheAnalysisApi = {
|
||||
return cachedRequest(
|
||||
cacheKey,
|
||||
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
|
||||
},
|
||||
30000
|
||||
|
||||
+87
-48
@@ -3,10 +3,13 @@ import type { AxiosInstance, AxiosRequestConfig, AxiosResponse, InternalAxiosReq
|
||||
import { NETWORK_CONFIG, AUTH_CONFIG } from '@/config/constants'
|
||||
import { isDemoMode } from '@/config/demo'
|
||||
import { handleMockRequest, setMockUserToken } from '@/mocks'
|
||||
import { getClientDeviceId } from '@/utils/deviceId'
|
||||
import { CrossTabRefreshCoordinator } from '@/utils/crossTabRefresh'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
// 在开发环境下使用代理,生产环境使用环境变量
|
||||
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 {
|
||||
const nonRefreshableErrors = [
|
||||
function isAccountLevelForbidden(status: number, errorDetail: string): boolean {
|
||||
if (status !== 403) return false
|
||||
const accountErrors = [
|
||||
'用户不存在或已禁用',
|
||||
'需要管理员权限',
|
||||
'权限不足',
|
||||
'用户已禁用',
|
||||
]
|
||||
|
||||
return !nonRefreshableErrors.some((msg) => errorDetail.includes(msg))
|
||||
return accountErrors.some((msg) => errorDetail.includes(msg))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,12 +84,21 @@ class ApiClient {
|
||||
private client: AxiosInstance
|
||||
private token: string | null = null
|
||||
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() {
|
||||
this.client = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: NETWORK_CONFIG.API_TIMEOUT,
|
||||
withCredentials: true,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
@@ -99,6 +109,7 @@ class ApiClient {
|
||||
this.client.defaults.adapter = createDemoAdapter(defaultAdapter)
|
||||
|
||||
this.setupInterceptors()
|
||||
this.setupCrossTabAuthSync()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,6 +119,10 @@ class ApiClient {
|
||||
// 请求拦截器 - 仅处理认证
|
||||
this.client.interceptors.request.use(
|
||||
(config) => {
|
||||
if (config.url?.includes('/api/')) {
|
||||
config.headers['X-Client-Device-Id'] = getClientDeviceId()
|
||||
}
|
||||
|
||||
const requiresAuth = !isPublicEndpoint(config.url, config.method) &&
|
||||
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)
|
||||
}
|
||||
|
||||
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错误
|
||||
if (error.response?.status === 401) {
|
||||
if (status === 401) {
|
||||
return this.handle401Error(error, originalRequest)
|
||||
}
|
||||
|
||||
@@ -177,25 +223,7 @@ class ApiClient {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
const errorDetail = (error.response?.data as Record<string, unknown>)?.detail as string || ''
|
||||
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)
|
||||
}
|
||||
log.debug('Got 401 error, attempting token refresh')
|
||||
|
||||
// 标记为已重试
|
||||
originalRequest._retry = true
|
||||
@@ -210,8 +238,8 @@ class ApiClient {
|
||||
// 如果正在刷新,等待刷新完成
|
||||
if (this.isRefreshing) {
|
||||
try {
|
||||
await this.refreshPromise
|
||||
originalRequest.headers.Authorization = `Bearer ${this.getToken()}`
|
||||
const accessToken = await this.refreshPromise
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return this.client.request(originalRequest)
|
||||
} catch {
|
||||
return Promise.reject(error)
|
||||
@@ -219,29 +247,27 @@ class ApiClient {
|
||||
}
|
||||
|
||||
// 开始刷新token
|
||||
return this.refreshTokenAndRetry(refreshToken, originalRequest, error)
|
||||
return this.refreshTokenAndRetry(originalRequest, error)
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新token并重试原始请求
|
||||
*/
|
||||
private async refreshTokenAndRetry(
|
||||
refreshToken: string,
|
||||
originalRequest: InternalAxiosRequestConfig,
|
||||
originalError: import('axios').AxiosError
|
||||
): Promise<AxiosResponse> {
|
||||
this.isRefreshing = true
|
||||
this.refreshPromise = this.refreshToken(refreshToken)
|
||||
this.refreshPromise = this.coordinatedRefresh()
|
||||
|
||||
try {
|
||||
const response = await this.refreshPromise
|
||||
this.setToken(response.data.access_token)
|
||||
localStorage.setItem('refresh_token', response.data.refresh_token)
|
||||
const accessToken = await this.refreshPromise
|
||||
this.setToken(accessToken)
|
||||
this.isRefreshing = false
|
||||
this.refreshPromise = null
|
||||
|
||||
// 重试原始请求
|
||||
originalRequest.headers.Authorization = `Bearer ${response.data.access_token}`
|
||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`
|
||||
return this.client.request(originalRequest)
|
||||
} catch (refreshError: unknown) {
|
||||
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
|
||||
localStorage.setItem('access_token', token)
|
||||
// 同步到 mock handler
|
||||
if (isDemoMode()) {
|
||||
setMockUserToken(token)
|
||||
}
|
||||
}
|
||||
|
||||
setToken(token: string): void {
|
||||
this.syncTokenState(token)
|
||||
localStorage.setItem('access_token', token)
|
||||
}
|
||||
|
||||
getToken(): string | null {
|
||||
if (!this.token) {
|
||||
this.token = localStorage.getItem('access_token')
|
||||
@@ -273,18 +313,17 @@ class ApiClient {
|
||||
}
|
||||
|
||||
clearAuth(): void {
|
||||
this.token = null
|
||||
const hadAuth = this.token !== null || localStorage.getItem('access_token') !== null
|
||||
this.syncTokenState(null)
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
// 同步清除 mock token
|
||||
if (isDemoMode()) {
|
||||
setMockUserToken(null)
|
||||
// 同标签页内清理认证状态时不会触发 storage 事件,这里主动广播一次。
|
||||
if (hadAuth) {
|
||||
this.emitAuthStateChange(null)
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken(refreshToken: string): Promise<AxiosResponse> {
|
||||
// refreshToken 会通过 adapter 处理 Demo 模式
|
||||
return this.client.post('/api/auth/refresh', { refresh_token: refreshToken })
|
||||
async refreshToken(): Promise<AxiosResponse> {
|
||||
return this.client.post('/api/auth/refresh', {})
|
||||
}
|
||||
|
||||
// 以下方法直接委托给 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
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取按 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
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import client from '../client'
|
||||
import { dedupedRequest } from '@/utils/cache'
|
||||
import type { AllowedModels, OAuthOrganizationInfo, ProxyConfig } from './types/provider'
|
||||
import type { ProviderKeyStatusSnapshot } from './types/statusSnapshot'
|
||||
|
||||
const POOL_BATCH_ACTION_TIMEOUT_MS = 5 * 60 * 1000
|
||||
|
||||
@@ -99,12 +100,20 @@ export interface PoolKeyDetail {
|
||||
is_active: boolean
|
||||
auth_type: string
|
||||
oauth_expires_at?: number | null
|
||||
oauth_invalid_at?: number | null
|
||||
oauth_invalid_reason?: string | null
|
||||
oauth_invalid_at?: number | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
oauth_invalid_reason?: string | null // 兼容字段;优先使用 status_snapshot.oauth
|
||||
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
|
||||
health_score?: number
|
||||
circuit_breaker_open?: boolean
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface ProviderOAuthCompleteResponse {
|
||||
expires_at?: number | null
|
||||
has_refresh_token: boolean
|
||||
email?: string | null
|
||||
account_state_recheck_attempted?: boolean
|
||||
account_state_recheck_error?: string | null
|
||||
}
|
||||
|
||||
export interface ProviderOAuthCompleteResponseWithKey {
|
||||
@@ -92,7 +94,7 @@ export async function completeProviderLevelOAuth(
|
||||
|
||||
export async function importProviderRefreshToken(
|
||||
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> {
|
||||
const resp = await client.post(`/api/admin/provider-oauth/providers/${providerId}/import-refresh-token`, data)
|
||||
return resp.data
|
||||
|
||||
@@ -194,6 +194,8 @@ export interface TestModelFailoverRequest {
|
||||
api_format?: string
|
||||
endpoint_id?: string
|
||||
message?: string
|
||||
request_headers?: Record<string, unknown>
|
||||
request_body?: Record<string, unknown>
|
||||
request_id?: string
|
||||
concurrency?: number
|
||||
}
|
||||
@@ -212,6 +214,11 @@ export interface TestAttemptDetail {
|
||||
error_message?: string | null
|
||||
status_code?: 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 {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ProviderKeyStatusSnapshot } from './statusSnapshot'
|
||||
|
||||
/**
|
||||
* 代理配置类型
|
||||
* 支持两种模式:
|
||||
@@ -42,8 +44,6 @@ export interface HeaderRuleRename {
|
||||
to: string
|
||||
}
|
||||
|
||||
export type HeaderRule = HeaderRuleSet | HeaderRuleDrop | HeaderRuleRename
|
||||
|
||||
/**
|
||||
* 请求体规则类型
|
||||
* - set: 设置/覆盖字段
|
||||
@@ -136,10 +136,28 @@ export type BodyRuleConditionOp =
|
||||
| 'exists' | 'not_exists'
|
||||
| 'in' | 'type_is'
|
||||
|
||||
export interface BodyRuleCondition {
|
||||
export interface BodyRuleConditionLeaf {
|
||||
path: string
|
||||
op: BodyRuleConditionOp
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -272,9 +290,12 @@ export interface EndpointAPIKey {
|
||||
oauth_plan_type?: string | null // Codex 订阅类型: plus/free/team/enterprise
|
||||
oauth_account_id?: string | null // Codex ChatGPT 账号 ID
|
||||
oauth_account_user_id?: string | null // Codex ChatGPT account-user 联合 ID
|
||||
oauth_account_name?: string | null
|
||||
oauth_organizations?: OAuthOrganizationInfo[] | null // OAuth 关联组织/工作区摘要
|
||||
oauth_invalid_at?: number | null // OAuth Token 失效时间(Unix 时间戳)
|
||||
oauth_invalid_reason?: string | null // OAuth Token 失效原因
|
||||
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 配额信息)
|
||||
upstream_metadata?: UpstreamMetadata | null
|
||||
// Key 级别代理配置(覆盖 Provider 级别代理)
|
||||
@@ -574,6 +595,7 @@ export interface HealthSummary {
|
||||
total: number
|
||||
active: 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 type { ActivityHeatmap } from '@/types/activity'
|
||||
import type { TieredPricingConfig } from './endpoints/types'
|
||||
import { cachedRequest, buildCacheKey } from '@/utils/cache'
|
||||
import type { BillingSummary } from './auth'
|
||||
import type { UserSession } from '@/types/session'
|
||||
|
||||
export type { UserSession }
|
||||
|
||||
export interface Profile {
|
||||
id: string // UUID
|
||||
@@ -22,8 +23,6 @@ export interface Profile {
|
||||
export interface UserPreferences {
|
||||
avatar_url?: string
|
||||
bio?: string
|
||||
default_provider_id?: string // UUID
|
||||
default_provider?: Record<string, unknown>
|
||||
theme: string
|
||||
language: 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 {
|
||||
id: string // UUID
|
||||
name: string
|
||||
@@ -126,7 +44,7 @@ export interface ApiKey {
|
||||
created_at: string
|
||||
total_requests?: number
|
||||
total_cost_usd?: number
|
||||
allowed_providers?: ProviderConfig[]
|
||||
rate_limit?: number | null
|
||||
force_capabilities?: Record<string, boolean> | null // 强制能力配置
|
||||
}
|
||||
|
||||
@@ -159,14 +77,36 @@ export const meApi = {
|
||||
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密钥管理
|
||||
async getApiKeys(): Promise<ApiKey[]> {
|
||||
const response = await apiClient.get<ApiKey[]>('/api/users/me/api-keys')
|
||||
return response.data
|
||||
},
|
||||
|
||||
async createApiKey(name: string): Promise<ApiKey> {
|
||||
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', { name })
|
||||
async createApiKey(data: { name: string; rate_limit?: number }): Promise<ApiKey> {
|
||||
const response = await apiClient.post<ApiKey>('/api/users/me/api-keys', data)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -196,48 +136,14 @@ export const meApi = {
|
||||
return response.data
|
||||
},
|
||||
|
||||
// 使用统计
|
||||
async getUsage(params?: {
|
||||
start_date?: string
|
||||
end_date?: string
|
||||
preset?: string
|
||||
timezone?: string
|
||||
tz_offset_minutes?: number
|
||||
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')
|
||||
async updateApiKey(
|
||||
keyId: string,
|
||||
data: { name?: string; rate_limit?: number | null }
|
||||
): Promise<ApiKey & { message: string }> {
|
||||
const response = await apiClient.put<ApiKey & { message: string }>(
|
||||
`/api/users/me/api-keys/${keyId}`,
|
||||
data
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
|
||||
@@ -281,16 +187,6 @@ export const meApi = {
|
||||
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密钥能力配置
|
||||
async updateApiKeyCapabilities(keyId: string, data: {
|
||||
force_capabilities?: Record<string, boolean> | null
|
||||
@@ -317,39 +213,4 @@ export const meApi = {
|
||||
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_api_formats: string[] | null // 允许使用的 API 格式列表
|
||||
allowed_models: string[] | null // 允许使用的模型名称列表
|
||||
rate_limit?: number | null // null = 跟随系统默认,0 = 不限制
|
||||
created_at: string
|
||||
updated_at?: string
|
||||
last_login_at?: string | null
|
||||
@@ -25,6 +26,7 @@ export interface CreateUserRequest {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number | null
|
||||
}
|
||||
|
||||
export interface UpdateUserRequest {
|
||||
@@ -36,6 +38,7 @@ export interface UpdateUserRequest {
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
allowed_models?: string[] | null
|
||||
rate_limit?: number | null
|
||||
}
|
||||
|
||||
export interface ApiKey {
|
||||
@@ -49,11 +52,18 @@ export interface ApiKey {
|
||||
is_active: boolean
|
||||
is_locked: boolean // 管理员锁定标志
|
||||
is_standalone: boolean // 是否为独立余额Key
|
||||
rate_limit?: number // 速率限制(请求/分钟)
|
||||
rate_limit?: number | null // 普通Key: 0 = 不限制,历史 null 视为跟随系统默认
|
||||
total_requests?: number // 总请求数
|
||||
total_cost_usd?: number // 总费用
|
||||
}
|
||||
|
||||
export interface UpsertUserApiKeyRequest {
|
||||
name?: string
|
||||
rate_limit?: number | null
|
||||
}
|
||||
|
||||
export type { UserSession } from '@/types/session'
|
||||
|
||||
export const usersApi = {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const response = await apiClient.get<User[]>('/api/admin/users')
|
||||
@@ -84,8 +94,41 @@ export const usersApi = {
|
||||
return response.data.api_keys
|
||||
},
|
||||
|
||||
async createApiKey(userId: string, name?: string): Promise<ApiKey & { key: string }> {
|
||||
const response = await apiClient.post<ApiKey & { key: string }>(`/api/admin/users/${userId}/api-keys`, { name })
|
||||
async getUserSessions(userId: string): Promise<UserSession[]> {
|
||||
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
|
||||
},
|
||||
|
||||
@@ -98,10 +141,5 @@ export const usersApi = {
|
||||
`/api/admin/users/${userId}/api-keys/${keyId}/full-key`
|
||||
)
|
||||
return response.data
|
||||
},
|
||||
// 管理员统计
|
||||
async getUsageStats(): Promise<Record<string, unknown>> {
|
||||
const response = await apiClient.get('/api/admin/usage/stats')
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
class="w-full"
|
||||
:style="{ height: `${props.height}px` }"
|
||||
>
|
||||
<canvas ref="chartRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -18,6 +21,7 @@ import {
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300,
|
||||
@@ -44,6 +48,7 @@ interface Props {
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'bar'> | null = null
|
||||
let stopThemeObserver: (() => void) | null = null
|
||||
|
||||
const defaultOptions: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
@@ -91,9 +96,7 @@ const defaultOptions: ChartOptions<'bar'> = {
|
||||
}
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
function buildChartOptions(): ChartOptions<'bar'> {
|
||||
const stackedOptions = props.stacked ? {
|
||||
scales: {
|
||||
x: { ...defaultOptions.scales?.x, stacked: true },
|
||||
@@ -106,20 +109,26 @@ function createChart() {
|
||||
}
|
||||
}
|
||||
|
||||
return resolveChartTheme({
|
||||
...defaultOptions,
|
||||
...stackedOptions,
|
||||
...props.options
|
||||
})
|
||||
}
|
||||
|
||||
function createChart() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'bar',
|
||||
data: props.data,
|
||||
options: {
|
||||
...defaultOptions,
|
||||
...stackedOptions,
|
||||
...props.options
|
||||
}
|
||||
data: resolveChartTheme(props.data),
|
||||
options: buildChartOptions()
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
@@ -127,9 +136,17 @@ function updateChart() {
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
stopThemeObserver = observeChartThemeChanges(() => {
|
||||
if (!chart) return
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.options = buildChartOptions()
|
||||
chart.update('none')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopThemeObserver?.()
|
||||
stopThemeObserver = null
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
@@ -139,11 +156,8 @@ onUnmounted(() => {
|
||||
watch(() => props.data, updateChart, { deep: true })
|
||||
watch(() => props.options, () => {
|
||||
if (chart) {
|
||||
chart.options = {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
chart.update()
|
||||
chart.options = buildChartOptions()
|
||||
chart.update('none')
|
||||
}
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
class="w-full"
|
||||
:style="{ height: `${props.height}px` }"
|
||||
>
|
||||
<canvas ref="chartRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,10 +19,12 @@ import {
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300,
|
||||
options: undefined
|
||||
options: undefined,
|
||||
showLegend: true,
|
||||
})
|
||||
|
||||
ChartJS.register(
|
||||
@@ -34,15 +39,22 @@ interface Props {
|
||||
data: ChartData<'doughnut'>
|
||||
options?: ChartOptions<'doughnut'>
|
||||
height?: number
|
||||
showLegend?: boolean
|
||||
}
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'doughnut'> | null = null
|
||||
let stopThemeObserver: (() => void) | null = null
|
||||
|
||||
const defaultOptions: ChartOptions<'doughnut'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
cutout: '60%',
|
||||
events: ['mousemove', 'mouseout', 'click', 'touchstart', 'touchmove'],
|
||||
interaction: {
|
||||
mode: 'nearest',
|
||||
intersect: true,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
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() {
|
||||
if (!chartRef.value) return
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'doughnut',
|
||||
data: props.data,
|
||||
options: {
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
data: resolveChartTheme(props.data),
|
||||
options: resolvedOptions(),
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.update('none')
|
||||
}
|
||||
}
|
||||
@@ -94,9 +143,17 @@ function updateChart() {
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
stopThemeObserver = observeChartThemeChanges(() => {
|
||||
if (!chart) return
|
||||
chart.data = resolveChartTheme(props.data)
|
||||
chart.options = resolvedOptions()
|
||||
chart.update('none')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopThemeObserver?.()
|
||||
stopThemeObserver = null
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
@@ -106,8 +163,15 @@ onUnmounted(() => {
|
||||
watch(() => props.data, updateChart, { deep: true })
|
||||
watch(() => props.options, () => {
|
||||
if (chart) {
|
||||
chart.options = { ...defaultOptions, ...props.options }
|
||||
chart.update()
|
||||
chart.options = resolvedOptions()
|
||||
chart.update('none')
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
watch(() => props.showLegend, () => {
|
||||
if (chart) {
|
||||
chart.options = resolvedOptions()
|
||||
chart.update('none')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
class="w-full"
|
||||
:style="{ height: `${props.height}px` }"
|
||||
>
|
||||
<canvas ref="chartRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -16,9 +19,11 @@ import {
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend,
|
||||
Filler,
|
||||
type ChartData,
|
||||
type ChartOptions
|
||||
} from 'chart.js'
|
||||
import { observeChartThemeChanges, resolveChartTheme } from '@/utils/chartTheme'
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
height: 300,
|
||||
@@ -34,7 +39,8 @@ ChartJS.register(
|
||||
LineController,
|
||||
Title,
|
||||
Tooltip,
|
||||
Legend
|
||||
Legend,
|
||||
Filler
|
||||
)
|
||||
|
||||
interface Props {
|
||||
@@ -45,12 +51,17 @@ interface Props {
|
||||
|
||||
const chartRef = ref<HTMLCanvasElement>()
|
||||
let chart: ChartJS<'line'> | null = null
|
||||
let stopThemeObserver: (() => void) | null = null
|
||||
|
||||
function buildChartOptions(): ChartOptions<'line'> {
|
||||
return {
|
||||
return resolveChartTheme({
|
||||
...defaultOptions,
|
||||
...props.options
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function buildChartData(): ChartData<'line'> {
|
||||
return resolveChartTheme(props.data)
|
||||
}
|
||||
|
||||
const defaultOptions: ChartOptions<'line'> = {
|
||||
@@ -95,14 +106,14 @@ function createChart() {
|
||||
|
||||
chart = new ChartJS(chartRef.value, {
|
||||
type: 'line',
|
||||
data: props.data,
|
||||
data: buildChartData(),
|
||||
options: buildChartOptions()
|
||||
})
|
||||
}
|
||||
|
||||
function updateChart() {
|
||||
if (chart) {
|
||||
chart.data = props.data
|
||||
chart.data = buildChartData()
|
||||
chart.update('none') // 禁用动画以提高性能
|
||||
}
|
||||
}
|
||||
@@ -110,9 +121,17 @@ function updateChart() {
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
createChart()
|
||||
stopThemeObserver = observeChartThemeChanges(() => {
|
||||
if (!chart) return
|
||||
chart.data = buildChartData()
|
||||
chart.options = buildChartOptions()
|
||||
chart.update('none')
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopThemeObserver?.()
|
||||
stopThemeObserver = null
|
||||
if (chart) {
|
||||
chart.destroy()
|
||||
chart = null
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
ref="fileInputRef"
|
||||
type="file"
|
||||
:accept="accept"
|
||||
multiple
|
||||
:multiple="multiple"
|
||||
class="hidden"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
@@ -94,6 +94,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled?: boolean
|
||||
resetKey?: string | number
|
||||
accept?: string
|
||||
multiple?: boolean
|
||||
dropTitle?: string
|
||||
dropHint?: string
|
||||
manualLabel?: string
|
||||
@@ -106,6 +107,7 @@ const props = withDefaults(defineProps<{
|
||||
disabled: false,
|
||||
resetKey: '',
|
||||
accept: '.json,.txt',
|
||||
multiple: true,
|
||||
dropTitle: '拖入导入文件或点击选择',
|
||||
dropHint: '支持 .json / .txt,可多选',
|
||||
manualLabel: '',
|
||||
@@ -218,13 +220,19 @@ function mergeFileContents(contents: string[]): string {
|
||||
}
|
||||
|
||||
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) {
|
||||
emitError('仅支持 .json 或 .txt 文件', '格式错误')
|
||||
return
|
||||
}
|
||||
if (validFiles.length < files.length) {
|
||||
emitError(`已忽略 ${files.length - validFiles.length} 个不支持的文件`, '提示')
|
||||
if (validFiles.length < sourceFiles.length) {
|
||||
emitError(`已忽略 ${sourceFiles.length - validFiles.length} 个不支持的文件`, '提示')
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -3,39 +3,24 @@
|
||||
<Select
|
||||
v-model="selectedPreset"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-32 text-xs border-border/60">
|
||||
<SelectTrigger :class="presetTriggerClass">
|
||||
<SelectValue placeholder="选择时间段" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="today">
|
||||
今天
|
||||
</SelectItem>
|
||||
<SelectItem value="yesterday">
|
||||
昨天
|
||||
</SelectItem>
|
||||
<SelectItem value="last7days">
|
||||
最近7天
|
||||
</SelectItem>
|
||||
<SelectItem value="last30days">
|
||||
最近30天
|
||||
</SelectItem>
|
||||
<SelectItem value="last90days">
|
||||
最近90天
|
||||
<SelectItem value="last180days">
|
||||
最近180天
|
||||
</SelectItem>
|
||||
<SelectItem value="this_week">
|
||||
本周
|
||||
</SelectItem>
|
||||
<SelectItem value="last_week">
|
||||
上周
|
||||
</SelectItem>
|
||||
<SelectItem value="this_month">
|
||||
本月
|
||||
</SelectItem>
|
||||
<SelectItem value="last_month">
|
||||
上月
|
||||
</SelectItem>
|
||||
<SelectItem value="this_year">
|
||||
今年
|
||||
<SelectItem value="last1year">
|
||||
最近一年
|
||||
</SelectItem>
|
||||
<SelectItem value="custom">
|
||||
自定义
|
||||
@@ -50,13 +35,13 @@
|
||||
<Input
|
||||
v-model="startDate"
|
||||
type="date"
|
||||
class="h-8 w-36 text-xs border-border/60"
|
||||
:class="dateInputClass"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">至</span>
|
||||
<Input
|
||||
v-model="endDate"
|
||||
type="date"
|
||||
class="h-8 w-36 text-xs border-border/60"
|
||||
:class="dateInputClass"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -64,10 +49,16 @@
|
||||
v-if="showGranularity"
|
||||
v-model="selectedGranularity"
|
||||
>
|
||||
<SelectTrigger class="h-8 w-24 text-xs border-border/60">
|
||||
<SelectTrigger :class="granularityTriggerClass">
|
||||
<SelectValue placeholder="粒度" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-if="includeAutoGranularity"
|
||||
value="auto"
|
||||
>
|
||||
自动
|
||||
</SelectItem>
|
||||
<SelectItem
|
||||
v-if="allowHourly && canUseHourly"
|
||||
value="hour"
|
||||
@@ -100,26 +91,51 @@ import {
|
||||
} from '@/components/ui'
|
||||
import type { DateRangeParams } from '@/features/usage/types'
|
||||
|
||||
type PickerGranularity = NonNullable<DateRangeParams['granularity']>
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: DateRangeParams
|
||||
showGranularity?: boolean
|
||||
allowHourly?: boolean
|
||||
includeAutoGranularity?: boolean
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'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 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 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(() => {
|
||||
if (selectedPreset.value === 'today' || selectedPreset.value === 'yesterday') return true
|
||||
if (selectedPreset.value === 'today') return true
|
||||
if (selectedPreset.value === 'custom' && 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.start_date !== undefined) startDate.value = value.start_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 = getValueKey(value)
|
||||
}, { deep: true })
|
||||
@@ -173,7 +189,7 @@ watch(() => props.modelValue, (value) => {
|
||||
watch([selectedPreset, startDate, endDate, selectedGranularity], () => {
|
||||
if (!allowHourly.value || !canUseHourly.value) {
|
||||
if (selectedGranularity.value === 'hour') {
|
||||
selectedGranularity.value = 'day'
|
||||
selectedGranularity.value = includeAutoGranularity.value ? 'auto' : 'day'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,8 +13,11 @@
|
||||
<p class="mt-0.5">
|
||||
{{ tooltip.day.requests }} 次请求 · {{ formatTokens(tooltip.day.total_tokens) }}
|
||||
</p>
|
||||
<p class="text-[11px] text-muted-foreground">
|
||||
成本 {{ formatCurrency(tooltip.day.total_cost) }}
|
||||
<p class="text-[11px] text-muted-foreground whitespace-nowrap">
|
||||
{{ 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>
|
||||
</div>
|
||||
</Teleport>
|
||||
@@ -90,7 +93,7 @@
|
||||
class="flex items-center invisible"
|
||||
>周六</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-[200px]">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div
|
||||
ref="heatmapWrapper"
|
||||
class="relative block w-full"
|
||||
@@ -100,12 +103,12 @@
|
||||
:style="horizontalGapStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(week, weekIndex) in weekColumns"
|
||||
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||
:key="`month-${weekIndex}`"
|
||||
:style="monthCellStyle"
|
||||
class="text-center"
|
||||
>
|
||||
<span v-if="monthMarkers[weekIndex]">{{ monthMarkers[weekIndex] }}</span>
|
||||
<span v-if="visibleMonthMarkers[weekIndex]">{{ visibleMonthMarkers[weekIndex] }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
@@ -113,7 +116,7 @@
|
||||
:style="horizontalGapStyle"
|
||||
>
|
||||
<div
|
||||
v-for="(week, weekIndex) in weekColumns"
|
||||
v-for="(week, weekIndex) in visibleWeekColumns"
|
||||
:key="weekIndex"
|
||||
class="flex flex-col"
|
||||
:style="verticalGapStyle"
|
||||
@@ -161,11 +164,15 @@ const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
subtitle?: string
|
||||
showHeader?: boolean
|
||||
valueLabel?: string
|
||||
actualValueLabel?: string
|
||||
}>(), {
|
||||
data: undefined,
|
||||
title: undefined,
|
||||
subtitle: undefined,
|
||||
showHeader: true
|
||||
showHeader: true,
|
||||
valueLabel: '费用',
|
||||
actualValueLabel: undefined,
|
||||
})
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
@@ -270,7 +277,47 @@ const monthMarkers = computed(() => {
|
||||
if (month === lastMonth) {
|
||||
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
|
||||
})
|
||||
|
||||
@@ -282,7 +329,7 @@ let mediaQuery: MediaQueryList | null = null
|
||||
let mediaQueryHandler: ((event?: MediaQueryListEvent) => void) | null = null
|
||||
|
||||
const recalcCellSize = () => {
|
||||
const columnCount = weekColumns.value.length
|
||||
const columnCount = visibleWeekColumns.value.length
|
||||
if (!columnCount || !heatmapWidth.value) {
|
||||
return
|
||||
}
|
||||
@@ -290,12 +337,12 @@ const recalcCellSize = () => {
|
||||
const totalGap = Math.max(columnCount - 1, 0) * cellGap.value
|
||||
const availableSpace = Math.max(heatmapWidth.value - totalGap, 0)
|
||||
const rawSize = availableSpace / columnCount
|
||||
// 自适应尺寸,最小 6px
|
||||
cellSize.value = Math.max(6, rawSize)
|
||||
// 当所有列都能放下时,让格子继续随容器放大,避免宽屏下出现大块留白。
|
||||
cellSize.value = Math.max(MIN_CELL_SIZE, rawSize)
|
||||
}
|
||||
|
||||
watch(
|
||||
[() => heatmapWidth.value, () => weekColumns.value.length, () => cellGap.value],
|
||||
[() => heatmapWidth.value, () => visibleWeekColumns.value.length, () => cellGap.value],
|
||||
() => {
|
||||
recalcCellSize()
|
||||
},
|
||||
@@ -345,7 +392,7 @@ onBeforeUnmount(() => {
|
||||
|
||||
function handleHover(day: ActivityHeatmapDay, event: MouseEvent) {
|
||||
const cellRect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
const tooltipWidth = 200
|
||||
const tooltipWidth = 280
|
||||
const tooltipHeight = 72
|
||||
|
||||
// Calculate horizontal position (centered on cell)
|
||||
@@ -402,10 +449,10 @@ function getCellStyle(requests: number) {
|
||||
|
||||
function buildTooltip(day: ActivityHeatmapDay): string {
|
||||
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]
|
||||
if (day.actual_total_cost !== undefined) {
|
||||
parts.push(`倍率: ${formatCurrency(day.actual_total_cost)}`)
|
||||
if (props.actualValueLabel && day.actual_total_cost !== undefined) {
|
||||
parts.push(`${props.actualValueLabel} ${formatCurrency(day.actual_total_cost)}`)
|
||||
}
|
||||
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>
|
||||
<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 class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</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
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
@@ -16,57 +36,116 @@
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
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"
|
||||
>
|
||||
<DoughnutChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
{{ 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
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:show-legend="true"
|
||||
:height="248"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import DoughnutChart from '@/components/charts/DoughnutChart.vue'
|
||||
import { LoadingState } from '@/components/common'
|
||||
import type { ErrorDistributionItem } from '@/api/admin'
|
||||
|
||||
interface ErrorDistributionItem {
|
||||
category: string
|
||||
label: string
|
||||
count: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
title: string
|
||||
subtitle?: string
|
||||
distribution: ErrorDistributionItem[]
|
||||
loading?: boolean
|
||||
unavailable?: boolean
|
||||
unavailableText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
subtitle: undefined,
|
||||
loading: false
|
||||
loading: false,
|
||||
unavailable: false,
|
||||
unavailableText: '数据暂不可用',
|
||||
})
|
||||
|
||||
const chartData = computed(() => ({
|
||||
labels: props.distribution.map(item => item.category),
|
||||
const chartColors = [
|
||||
'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: [
|
||||
{
|
||||
data: props.distribution.map(item => item.count),
|
||||
backgroundColor: [
|
||||
'rgba(239, 68, 68, 0.7)',
|
||||
'rgba(59, 130, 246, 0.7)',
|
||||
'rgba(234, 179, 8, 0.7)',
|
||||
'rgba(34, 197, 94, 0.7)',
|
||||
'rgba(148, 163, 184, 0.7)'
|
||||
],
|
||||
borderWidth: 0
|
||||
}
|
||||
]
|
||||
data: distributionWithShare.value.map(item => item.count),
|
||||
backgroundColor: distributionWithShare.value.map((_, index) => chartColors[index % chartColors.length]),
|
||||
borderColor: 'rgba(255,255,255,0.92)',
|
||||
borderWidth: 2,
|
||||
hoverOffset: 6,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
const chartOptions = computed<ChartOptions<'doughnut'>>(() => ({
|
||||
cutout: '68%',
|
||||
plugins: {
|
||||
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>
|
||||
|
||||
@@ -1,34 +1,30 @@
|
||||
<template>
|
||||
<TableCard :title="title">
|
||||
<template #actions>
|
||||
<Select
|
||||
v-if="showMetricSelect"
|
||||
:model-value="metric"
|
||||
@update:model-value="emitMetric"
|
||||
<div
|
||||
v-if="isRefreshing"
|
||||
class="inline-flex h-7 items-center rounded-lg border border-border/70 bg-background px-2.5 text-[10px] text-muted-foreground"
|
||||
>
|
||||
<SelectTrigger class="h-8 text-xs w-28">
|
||||
<SelectValue placeholder="指标" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="requests">
|
||||
请求数
|
||||
</SelectItem>
|
||||
<SelectItem value="tokens">
|
||||
Tokens
|
||||
</SelectItem>
|
||||
<SelectItem value="cost">
|
||||
成本
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
更新中
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="loading"
|
||||
v-if="showLoadingState"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</div>
|
||||
<div
|
||||
v-else-if="showUnavailableState"
|
||||
class="p-6"
|
||||
>
|
||||
<EmptyState
|
||||
type="error"
|
||||
:title="unavailableTitle"
|
||||
:description="unavailableDescription"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="items.length === 0"
|
||||
class="p-6"
|
||||
@@ -38,20 +34,29 @@
|
||||
description="当前时间范围内没有统计结果"
|
||||
/>
|
||||
</div>
|
||||
<Table v-else>
|
||||
<Table
|
||||
v-else
|
||||
class="table-fixed"
|
||||
:class="{ 'opacity-60 transition-opacity': isRefreshing }"
|
||||
>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead class="w-16">
|
||||
<TableHead class="w-14 px-2.5">
|
||||
排名
|
||||
</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead class="text-right">
|
||||
<TableHead class="w-[34%] px-2.5">
|
||||
名称
|
||||
</TableHead>
|
||||
<TableHead class="w-[15%] px-2.5 text-right">
|
||||
请求数
|
||||
</TableHead>
|
||||
<TableHead class="text-right">
|
||||
<TableHead class="w-[18%] px-2.5 text-right">
|
||||
Tokens
|
||||
</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>
|
||||
</TableRow>
|
||||
@@ -60,68 +65,118 @@
|
||||
<TableRow
|
||||
v-for="item in items"
|
||||
: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="font-medium">
|
||||
{{ item.rank }}
|
||||
<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'"
|
||||
>
|
||||
{{ item.rank }}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{{ item.name }}</TableCell>
|
||||
<TableCell class="text-right">
|
||||
{{ item.requests }}
|
||||
<TableCell class="px-2.5 py-2.5">
|
||||
<div class="min-w-0">
|
||||
<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 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) }}
|
||||
</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) }}
|
||||
</TableCell>
|
||||
<TableCell class="px-2.5 py-2.5 text-right text-[11px] tabular-nums text-muted-foreground">
|
||||
{{ formatCurrency(item.actualCost) }}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { EmptyState, LoadingState } from '@/components/common'
|
||||
import { TableCard } from '@/components/ui'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '@/components/ui'
|
||||
import { Pagination, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableCard } from '@/components/ui'
|
||||
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 {
|
||||
title: string
|
||||
items: LeaderboardItem[]
|
||||
metric: 'requests' | 'tokens' | 'cost'
|
||||
selectedId?: string
|
||||
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>(), {
|
||||
selectedId: undefined,
|
||||
loading: false,
|
||||
showMetricSelect: true
|
||||
hasLoaded: false,
|
||||
currentPage: 1,
|
||||
totalItems: 0,
|
||||
pageSize: 5,
|
||||
showPagination: false,
|
||||
unavailable: false,
|
||||
unavailableTitle: '排行榜暂不可用',
|
||||
unavailableDescription: '接口未返回结果,请稍后重试',
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
function emitMetric(value: string) {
|
||||
if (value === 'requests' || value === 'tokens' || value === 'cost') {
|
||||
emit('update:metric', value)
|
||||
}
|
||||
}
|
||||
const selectedId = computed(() => props.selectedId)
|
||||
const showLoadingState = computed(() => props.loading && (!props.hasLoaded || props.items.length === 0))
|
||||
const showUnavailableState = computed(() => props.unavailable && !props.loading && props.items.length === 0)
|
||||
const isRefreshing = computed(() => props.loading && props.hasLoaded && props.items.length > 0)
|
||||
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>
|
||||
|
||||
@@ -1,27 +1,59 @@
|
||||
<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 class="space-y-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="subtitle"
|
||||
class="mt-1 text-[11px] text-muted-foreground"
|
||||
>
|
||||
{{ subtitle }}
|
||||
</p>
|
||||
</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
|
||||
v-if="loading"
|
||||
class="p-6"
|
||||
>
|
||||
<LoadingState />
|
||||
</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
|
||||
v-else
|
||||
class="h-[260px]"
|
||||
class="rounded-2xl border border-border/60 bg-background p-3"
|
||||
>
|
||||
<LineChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
:height="248"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -29,9 +61,19 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import LineChart from '@/components/charts/LineChart.vue'
|
||||
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 {
|
||||
title: string
|
||||
@@ -39,61 +81,159 @@ interface Props {
|
||||
series: PercentileItem[]
|
||||
mode: 'response' | 'ttfb'
|
||||
loading?: boolean
|
||||
unavailable?: boolean
|
||||
unavailableText?: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
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 {
|
||||
if (ms == null) return null
|
||||
return ms / 1000
|
||||
return Number((ms / 1000).toFixed(3))
|
||||
}
|
||||
|
||||
const chartData = computed(() => {
|
||||
const p50Key = props.mode === 'response' ? 'p50_response_time_ms' : 'p50_first_byte_time_ms'
|
||||
const p90Key = props.mode === 'response' ? 'p90_response_time_ms' : 'p90_first_byte_time_ms'
|
||||
const p99Key = props.mode === 'response' ? 'p99_response_time_ms' : 'p99_first_byte_time_ms'
|
||||
|
||||
return {
|
||||
labels: labels.value,
|
||||
datasets: [
|
||||
{
|
||||
label: 'P50',
|
||||
data: props.series.map(item => msToSeconds(item[p50Key])),
|
||||
borderColor: 'rgb(59, 130, 246)',
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'P90',
|
||||
data: props.series.map(item => msToSeconds(item[p90Key])),
|
||||
borderColor: 'rgb(234, 179, 8)',
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
},
|
||||
{
|
||||
label: 'P99',
|
||||
data: props.series.map(item => msToSeconds(item[p99Key])),
|
||||
borderColor: 'rgb(239, 68, 68)',
|
||||
tension: 0.25,
|
||||
pointRadius: 2
|
||||
}
|
||||
]
|
||||
function getPercentileValue(item: PercentileItem, percentile: '50' | '90' | '99') {
|
||||
if (props.mode === 'response') {
|
||||
if (percentile === '50') return msToSeconds(item.p50_response_time_ms)
|
||||
if (percentile === '90') return msToSeconds(item.p90_response_time_ms)
|
||||
return msToSeconds(item.p99_response_time_ms)
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed(() => ({
|
||||
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,
|
||||
datasets: [
|
||||
{
|
||||
label: 'P50',
|
||||
data: props.series.map(item => getPercentileValue(item, '50')),
|
||||
borderColor: 'rgb(70, 136, 240)',
|
||||
backgroundColor: 'rgba(70, 136, 240, 0.10)',
|
||||
borderWidth: 2.4,
|
||||
fill: false,
|
||||
tension: 0.32,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHitRadius: 14,
|
||||
},
|
||||
{
|
||||
label: 'P90',
|
||||
data: props.series.map(item => getPercentileValue(item, '90')),
|
||||
borderColor: 'rgb(223, 166, 55)',
|
||||
backgroundColor: 'rgba(223, 166, 55, 0.08)',
|
||||
borderWidth: 2.1,
|
||||
fill: false,
|
||||
tension: 0.3,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHitRadius: 14,
|
||||
},
|
||||
{
|
||||
label: 'P99',
|
||||
data: props.series.map(item => getPercentileValue(item, '99')),
|
||||
borderColor: 'rgb(214, 92, 111)',
|
||||
backgroundColor: 'rgba(214, 92, 111, 0.08)',
|
||||
borderWidth: 2,
|
||||
borderDash: [6, 4],
|
||||
fill: false,
|
||||
tension: 0.28,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
pointHitRadius: 14,
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
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: {
|
||||
y: {
|
||||
x: {
|
||||
grid: {
|
||||
color: 'rgba(148, 163, 184, 0.06)',
|
||||
},
|
||||
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>
|
||||
|
||||
@@ -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 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 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)
|
||||
noPadding?: boolean // Disable default content padding
|
||||
persistent?: boolean // Prevent closing on backdrop click
|
||||
closeOnBackdrop?: boolean // Allow closing on backdrop click (default: true)
|
||||
}>()
|
||||
|
||||
// Emits 定义
|
||||
@@ -145,7 +146,7 @@ function handleClose() {
|
||||
|
||||
// 处理背景点击
|
||||
function handleBackdropClick() {
|
||||
if (!props.persistent) {
|
||||
if (!props.persistent && props.closeOnBackdrop !== false) {
|
||||
handleClose()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<input
|
||||
ref="inputRef"
|
||||
:class="inputClass"
|
||||
:style="inputStyle"
|
||||
:style="maskStyle"
|
||||
:value="modelValue"
|
||||
:type="effectiveType"
|
||||
:autocomplete="autocompleteAttr"
|
||||
@@ -42,7 +42,7 @@
|
||||
v-else
|
||||
ref="inputRef"
|
||||
:class="inputClass"
|
||||
:style="inputStyle"
|
||||
:style="maskStyle"
|
||||
:value="modelValue"
|
||||
:type="effectiveType"
|
||||
:autocomplete="autocompleteAttr"
|
||||
@@ -69,24 +69,6 @@ const emit = defineEmits<{
|
||||
'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 {
|
||||
modelValue?: string | number
|
||||
class?: string
|
||||
@@ -99,9 +81,8 @@ interface Props {
|
||||
size?: 'default' | 'sm'
|
||||
/**
|
||||
* 遮蔽显示内容(用于 API Key 等敏感信息)
|
||||
* 使用 CSS -webkit-text-security 实现,不会触发浏览器密码管理器
|
||||
* 始终使用 text 输入框,隐藏态通过样式进行遮蔽
|
||||
* 同时会显示一个小眼睛按钮用于切换显示/隐藏
|
||||
* 注意:Firefox 不支持 -webkit-text-security,会显示明文(但仍可通过按钮切换)
|
||||
*/
|
||||
masked?: boolean
|
||||
/**
|
||||
@@ -134,17 +115,23 @@ const shouldDisableAutofill = computed(() => {
|
||||
return props.disableAutofill ?? false
|
||||
})
|
||||
|
||||
// 始终使用 text 类型,永远不用 password
|
||||
const effectiveType = computed(() => {
|
||||
const attrType = attrs.type as string | undefined
|
||||
// 如果传入 password,强制转为 text(配合 masked 使用)
|
||||
if (attrType === 'password') {
|
||||
warnPasswordType()
|
||||
const attrType = (attrs.type as string | undefined) ?? 'text'
|
||||
if (props.masked) {
|
||||
return 'text'
|
||||
}
|
||||
return attrType
|
||||
})
|
||||
|
||||
const maskStyle = computed(() => {
|
||||
if (!props.masked || isVisible.value) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
WebkitTextSecurity: 'disc'
|
||||
}
|
||||
})
|
||||
|
||||
// 过滤掉 type 和 class 属性,因为我们会单独处理
|
||||
const filteredAttrs = computed(() => {
|
||||
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) {
|
||||
const target = event.target as HTMLInputElement
|
||||
emit('update:modelValue', target.value)
|
||||
|
||||
@@ -59,7 +59,7 @@ const isHidden = computed(
|
||||
)
|
||||
const itemClass = computed(() =>
|
||||
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',
|
||||
'transition-colors data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
isHidden.value && 'hidden',
|
||||
@@ -101,8 +101,16 @@ onBeforeUnmount(() => {
|
||||
<Check class="h-4 w-4" />
|
||||
</SelectItemIndicator>
|
||||
</span>
|
||||
<SelectItemText>
|
||||
<slot />
|
||||
<SelectItemText
|
||||
as="div"
|
||||
class="min-w-0 flex-1 overflow-hidden"
|
||||
>
|
||||
<span
|
||||
class="block w-full min-w-0 truncate"
|
||||
:title="normalizedText"
|
||||
>
|
||||
<slot />
|
||||
</span>
|
||||
</SelectItemText>
|
||||
</SelectItemPrimitive>
|
||||
</template>
|
||||
|
||||
@@ -25,7 +25,7 @@ const triggerClass = computed(() =>
|
||||
:class="triggerClass"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<span class="truncate">
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
<slot />
|
||||
</span>
|
||||
<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 '探测中'
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 状态信息
|
||||
*/
|
||||
@@ -94,15 +174,18 @@ export function getOAuthExpiresCountdown(
|
||||
invalidReason?: string | null
|
||||
): OAuthStatusInfo | null {
|
||||
void _tick
|
||||
const normalizedInvalidReason = typeof invalidReason === 'string'
|
||||
? invalidReason.trim()
|
||||
: ''
|
||||
|
||||
// 优先检查失效状态(失效比过期更严重)
|
||||
if (invalidAt != null) {
|
||||
if (invalidAt != null || normalizedInvalidReason) {
|
||||
return {
|
||||
text: '已失效',
|
||||
isExpired: false,
|
||||
isExpiringSoon: false,
|
||||
isInvalid: true,
|
||||
invalidReason: invalidReason || undefined
|
||||
invalidReason: normalizedInvalidReason || undefined
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface StartTestParams {
|
||||
apiFormat?: string
|
||||
endpointId?: string
|
||||
message?: string
|
||||
requestHeaders?: Record<string, unknown>
|
||||
requestBody?: Record<string, unknown>
|
||||
concurrency?: number
|
||||
onSuccess?: (result: TestModelFailoverResponse) => void
|
||||
/** 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 } = {}) {
|
||||
tracePollToken += 1
|
||||
if (tracePollTimer) {
|
||||
@@ -117,13 +129,19 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
startPolling(reqId)
|
||||
|
||||
try {
|
||||
const normalizedMessage = typeof params.message === 'string' && params.message.trim()
|
||||
? params.message.trim()
|
||||
: undefined
|
||||
|
||||
const result = await testModelFailover({
|
||||
provider_id: providerId(),
|
||||
mode: params.mode,
|
||||
model_name: params.modelName,
|
||||
api_format: params.apiFormat,
|
||||
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,
|
||||
concurrency: params.concurrency,
|
||||
}, {
|
||||
@@ -131,6 +149,9 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
await refreshTraceSnapshot(reqId)
|
||||
stopPolling({ clearState: false })
|
||||
testResult.value = result
|
||||
const successAttempt = result.attempts.find(a => a.status === 'success')
|
||||
const latency = successAttempt?.latency_ms != null ? ` (${successAttempt.latency_ms}ms)` : ''
|
||||
const mapped = successAttempt?.effective_model && successAttempt.effective_model !== params.modelName
|
||||
@@ -138,10 +159,10 @@ export function useModelTest(options: UseModelTestOptions) {
|
||||
: ''
|
||||
params.onSuccess?.(result)
|
||||
showSuccess(`${params.displayLabel}${mapped} 测试成功${latency}`)
|
||||
resetState()
|
||||
return
|
||||
}
|
||||
|
||||
await refreshTraceSnapshot(reqId)
|
||||
stopPolling({ clearState: false })
|
||||
const handled = params.onFailure?.(result)
|
||||
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 }
|
||||
}
|
||||
@@ -96,23 +96,6 @@
|
||||
{{ form.expires_at ? '到期后' + (form.auto_delete_on_expiry ? '自动删除' : '仅禁用') + '(当天 23:59 失效)' : '留空表示永不过期' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-rate-limit"
|
||||
class="text-sm font-medium"
|
||||
>速率限制 (请求/分钟)</Label>
|
||||
<Input
|
||||
id="form-rate-limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="1"
|
||||
max="10000"
|
||||
placeholder="留空不限制"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 1, max: 10000 })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:访问限制 -->
|
||||
@@ -190,6 +173,36 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<Label
|
||||
for="form-rate-limit"
|
||||
class="text-sm font-medium"
|
||||
>速率限制 (请求/分钟)</Label>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<Input
|
||||
v-if="!form.rate_limit_inherited"
|
||||
id="form-rate-limit"
|
||||
:model-value="form.rate_limit ?? ''"
|
||||
type="number"
|
||||
min="0"
|
||||
max="10000"
|
||||
placeholder="0 = 不限速"
|
||||
class="h-10"
|
||||
@update:model-value="(v) => form.rate_limit = parseNumberInput(v, { min: 0, max: 10000 })"
|
||||
/>
|
||||
<span
|
||||
v-else
|
||||
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
|
||||
>跟随系统</span>
|
||||
</div>
|
||||
<Switch
|
||||
v-model="form.rate_limit_inherited"
|
||||
class="shrink-0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 额度 -->
|
||||
<div class="space-y-2">
|
||||
<Label class="text-sm font-medium">额度</Label>
|
||||
@@ -207,9 +220,9 @@
|
||||
@update:model-value="(v) => form.initial_balance_usd = parseNumberInput(v, { allowFloat: true, min: 0.01 })"
|
||||
/>
|
||||
<span
|
||||
v-else-if="form.unlimited_balance"
|
||||
v-else
|
||||
class="flex h-10 w-full items-center rounded-lg border bg-background px-3 text-sm text-muted-foreground opacity-60"
|
||||
>无限制</span>
|
||||
>{{ form.unlimited_balance ? '无限制' : '按钱包余额限制' }}</span>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="form.unlimited_balance ?? false"
|
||||
@@ -267,7 +280,7 @@ export interface StandaloneKeyFormData {
|
||||
initial_balance_usd?: number
|
||||
unlimited_balance?: boolean
|
||||
expires_at?: string // ISO 日期字符串,如 "2025-12-31",undefined = 永不过期
|
||||
rate_limit?: number
|
||||
rate_limit?: number | null
|
||||
auto_delete_on_expiry: boolean
|
||||
allowed_providers?: string[] | null
|
||||
allowed_api_formats?: string[] | null
|
||||
@@ -280,6 +293,7 @@ interface StandaloneKeyFormState {
|
||||
initial_balance_usd?: number
|
||||
unlimited_balance?: boolean
|
||||
expires_at?: string
|
||||
rate_limit_inherited: boolean
|
||||
rate_limit?: number
|
||||
auto_delete_on_expiry: boolean
|
||||
provider_unrestricted: boolean
|
||||
@@ -333,6 +347,7 @@ const form = ref<StandaloneKeyFormState>({
|
||||
initial_balance_usd: 10,
|
||||
unlimited_balance: false,
|
||||
expires_at: undefined,
|
||||
rate_limit_inherited: true,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
provider_unrestricted: true,
|
||||
@@ -356,6 +371,7 @@ function resetForm() {
|
||||
initial_balance_usd: 10,
|
||||
unlimited_balance: false,
|
||||
expires_at: undefined,
|
||||
rate_limit_inherited: true,
|
||||
rate_limit: undefined,
|
||||
auto_delete_on_expiry: false,
|
||||
provider_unrestricted: true,
|
||||
@@ -375,7 +391,8 @@ function loadKeyData() {
|
||||
initial_balance_usd: props.apiKey.initial_balance_usd,
|
||||
unlimited_balance: props.apiKey.initial_balance_usd == null,
|
||||
expires_at: props.apiKey.expires_at,
|
||||
rate_limit: props.apiKey.rate_limit,
|
||||
rate_limit_inherited: props.apiKey.rate_limit == null,
|
||||
rate_limit: props.apiKey.rate_limit ?? undefined,
|
||||
auto_delete_on_expiry: props.apiKey.auto_delete_on_expiry,
|
||||
provider_unrestricted: props.apiKey.allowed_providers == null,
|
||||
api_format_unrestricted: props.apiKey.allowed_api_formats == null,
|
||||
@@ -425,7 +442,7 @@ function handleSubmit() {
|
||||
initial_balance_usd: form.value.initial_balance_usd,
|
||||
unlimited_balance: form.value.unlimited_balance,
|
||||
expires_at: form.value.expires_at,
|
||||
rate_limit: form.value.rate_limit,
|
||||
rate_limit: form.value.rate_limit_inherited ? null : (form.value.rate_limit ?? 0),
|
||||
auto_delete_on_expiry: form.value.auto_delete_on_expiry,
|
||||
allowed_providers: form.value.provider_unrestricted ? null : [...form.value.allowed_providers],
|
||||
allowed_api_formats: form.value.api_format_unrestricted ? null : [...form.value.allowed_api_formats],
|
||||
|
||||
@@ -179,7 +179,8 @@
|
||||
<Input
|
||||
id="login-password"
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
type="text"
|
||||
masked
|
||||
required
|
||||
placeholder="输入密码"
|
||||
autocomplete="off"
|
||||
@@ -247,6 +248,7 @@ import { isDemoMode, DEMO_ACCOUNTS } from '@/config/demo'
|
||||
import RegisterDialog from './RegisterDialog.vue'
|
||||
import { authApi } from '@/api/auth'
|
||||
import { oauthApi, type OAuthProviderInfo } from '@/api/oauth'
|
||||
import { getClientDeviceId } from '@/utils/deviceId'
|
||||
import { getApiUrl } from '@/utils/url'
|
||||
import { getOAuthIcon } from '@/utils/oauth-icons'
|
||||
|
||||
@@ -350,7 +352,12 @@ async function handleLogin() {
|
||||
function handleOAuthLogin(providerType: string) {
|
||||
// 如果 sessionStorage 中没有 redirectPath(用户直接点击登录而非被守卫拦截),
|
||||
// 则不设置,让 AuthCallback 使用默认跳转逻辑
|
||||
window.location.href = getApiUrl(`/api/oauth/${providerType}/authorize`)
|
||||
const authorizeUrl = new URL(
|
||||
getApiUrl(`/api/oauth/${providerType}/authorize`),
|
||||
window.location.origin,
|
||||
)
|
||||
authorizeUrl.searchParams.set('client_device_id', getClientDeviceId())
|
||||
window.location.href = authorizeUrl.toString()
|
||||
}
|
||||
|
||||
function handleSwitchToRegister() {
|
||||
|
||||
@@ -151,15 +151,12 @@
|
||||
<Input
|
||||
:id="`pwd-${formNonce}`"
|
||||
v-model="formData.password"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
masked
|
||||
autocomplete="new-password"
|
||||
disable-autofill
|
||||
:name="`pwd-${formNonce}`"
|
||||
:placeholder="getPasswordPolicyPlaceholder(props.passwordPolicyLevel)"
|
||||
required
|
||||
class="-webkit-text-security-disc"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<p
|
||||
@@ -182,17 +179,20 @@
|
||||
<Input
|
||||
:id="`pwd-confirm-${formNonce}`"
|
||||
v-model="formData.confirmPassword"
|
||||
type="text"
|
||||
autocomplete="one-time-code"
|
||||
data-form-type="other"
|
||||
data-lpignore="true"
|
||||
data-1p-ignore="true"
|
||||
masked
|
||||
autocomplete="new-password"
|
||||
disable-autofill
|
||||
:name="`pwd-confirm-${formNonce}`"
|
||||
placeholder="再次输入密码"
|
||||
required
|
||||
class="-webkit-text-security-disc"
|
||||
:disabled="isLoading"
|
||||
/>
|
||||
<p
|
||||
v-if="formData.confirmPassword && formData.password !== formData.confirmPassword"
|
||||
class="text-xs text-destructive"
|
||||
>
|
||||
两次输入的密码不一致
|
||||
</p>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
||||
@@ -457,42 +457,41 @@ function confirmCustomInput(index: number) {
|
||||
}
|
||||
|
||||
function updateCacheCreation(index: number, value: string | number) {
|
||||
const numValue = parseFloatInput(value)
|
||||
if (numValue > 0) {
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: true }
|
||||
localTiers.value[index].cache_creation_price_per_1m = numValue
|
||||
} else {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
// 清空时恢复自动计算
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: false }
|
||||
localTiers.value[index].cache_creation_price_per_1m = undefined
|
||||
} else {
|
||||
const numValue = parseFloatInput(value)
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], creation: true }
|
||||
localTiers.value[index].cache_creation_price_per_1m = numValue
|
||||
}
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateCacheRead(index: number, value: string | number) {
|
||||
const numValue = parseFloatInput(value)
|
||||
if (numValue > 0) {
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], read: true }
|
||||
localTiers.value[index].cache_read_price_per_1m = numValue
|
||||
} else {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
// 清空时恢复自动计算
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], read: false }
|
||||
localTiers.value[index].cache_read_price_per_1m = undefined
|
||||
} else {
|
||||
const numValue = parseFloatInput(value)
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], read: true }
|
||||
localTiers.value[index].cache_read_price_per_1m = numValue
|
||||
}
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
function updateCache1h(index: number, value: string | number) {
|
||||
const numValue = parseFloatInput(value)
|
||||
const tier = localTiers.value[index]
|
||||
if (numValue > 0) {
|
||||
// 手动设置 1 小时缓存创建价格
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: true }
|
||||
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: numValue }]
|
||||
} else {
|
||||
if (value === '' || value === null || value === undefined) {
|
||||
// 清空时恢复自动计算
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: false }
|
||||
tier.cache_ttl_pricing = undefined
|
||||
} else {
|
||||
const numValue = parseFloatInput(value)
|
||||
cacheManuallySet[index] = { ...cacheManuallySet[index], cache1h: true }
|
||||
tier.cache_ttl_pricing = [{ ttl_minutes: 60, cache_creation_price_per_1m: numValue }]
|
||||
}
|
||||
syncToParent()
|
||||
}
|
||||
|
||||
@@ -107,15 +107,15 @@
|
||||
<div class="flex items-center gap-1.5">
|
||||
<span class="text-xs font-medium truncate">{{ key.key_name || '未命名' }}</span>
|
||||
<Badge
|
||||
v-if="isOAuthInvalid(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>OAuth失效</Badge>
|
||||
<Badge
|
||||
v-else
|
||||
variant="outline"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>{{ normalizeAuthTypeLabel(key.auth_type) }}</Badge>
|
||||
<Badge
|
||||
v-if="getStatusBadgeLabel(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getStatusBadgeTitle(key)"
|
||||
>{{ getStatusBadgeLabel(key) }}</Badge>
|
||||
<Badge
|
||||
v-if="key.oauth_plan_type"
|
||||
variant="outline"
|
||||
@@ -125,13 +125,8 @@
|
||||
v-if="getOAuthOrgBadge(key)"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
:title="getOAuthOrgBadge(key)?.id"
|
||||
:title="getOAuthOrgBadge(key)?.title"
|
||||
>{{ getOAuthOrgBadge(key)?.label }}</Badge>
|
||||
<Badge
|
||||
v-if="isBannedKey(key)"
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1 py-0 h-4 shrink-0"
|
||||
>封号</Badge>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 mt-0.5 text-[11px] text-muted-foreground flex-wrap">
|
||||
<span :class="key.is_active ? '' : 'text-destructive'">{{ key.is_active ? '启用' : '禁用' }}</span>
|
||||
@@ -284,6 +279,12 @@ import { exportKey, refreshProviderQuota } from '@/api/endpoints/keys'
|
||||
import { refreshProviderOAuth } from '@/api/endpoints/provider_oauth'
|
||||
import { useProxyNodesStore } from '@/stores/proxy-nodes'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import {
|
||||
getAccountStatusDisplay,
|
||||
getAccountStatusTitle,
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
|
||||
type QuickSelectorValue =
|
||||
| 'banned'
|
||||
@@ -321,12 +322,12 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const QUICK_SELECT_OPTIONS: Array<{ value: QuickSelectorValue; label: string }> = [
|
||||
{ value: 'banned', label: '已封号' },
|
||||
{ value: 'banned', label: '账号异常' },
|
||||
{ value: 'no_5h_limit', label: '无5H限额' },
|
||||
{ value: 'no_weekly_limit', label: '无周限额' },
|
||||
{ value: 'plan_free', label: '全部 Free' },
|
||||
{ value: 'plan_team', label: '全部 Team' },
|
||||
{ value: 'oauth_invalid', label: 'OAuth 失效' },
|
||||
{ value: 'oauth_invalid', label: 'Token 异常' },
|
||||
{ value: 'proxy_unset', label: '未配置代理' },
|
||||
{ value: 'proxy_set', label: '已配置独立代理' },
|
||||
{ value: 'disabled', label: '已禁用' },
|
||||
@@ -426,25 +427,25 @@ function normalizeAuthTypeLabel(authType: string): string {
|
||||
return 'API Key'
|
||||
}
|
||||
|
||||
function isBannedKey(key: PoolKeyDetail): boolean {
|
||||
const reason = normalizeText(key.oauth_invalid_reason)
|
||||
if (reason && /(banned|forbidden|blocked|suspend|封|禁|受限)/.test(reason)) return true
|
||||
if (Array.isArray(key.scheduling_reasons)) {
|
||||
return key.scheduling_reasons.some((item) => {
|
||||
const code = normalizeText(item.code)
|
||||
return code === 'account_banned' || code === 'account_forbidden' || code === 'account_blocked'
|
||||
})
|
||||
}
|
||||
return false
|
||||
function getStatusBadgeLabel(key: PoolKeyDetail): string | null {
|
||||
const account = getAccountStatusDisplay(key)
|
||||
if (account.blocked && account.label) return account.label
|
||||
|
||||
const oauth = getOAuthStatusDisplay(key, 0)
|
||||
if (oauth?.isInvalid) return 'Token 失效'
|
||||
if (oauth?.isExpired) return 'Token 过期'
|
||||
return null
|
||||
}
|
||||
|
||||
function isOAuthInvalid(key: PoolKeyDetail): boolean {
|
||||
if (normalizeText(key.auth_type) !== 'oauth') return false
|
||||
if (key.oauth_invalid_at != null || normalizeText(key.oauth_invalid_reason)) return true
|
||||
if (typeof key.oauth_expires_at === 'number' && key.oauth_expires_at > 0) {
|
||||
return key.oauth_expires_at * 1000 <= Date.now()
|
||||
}
|
||||
return false
|
||||
function getStatusBadgeTitle(key: PoolKeyDetail): string {
|
||||
const label = getStatusBadgeLabel(key)
|
||||
if (!label) return ''
|
||||
|
||||
const accountTitle = getAccountStatusTitle(key)
|
||||
if (accountTitle) return accountTitle
|
||||
|
||||
const oauthTitle = getOAuthStatusTitle(key, 0)
|
||||
return oauthTitle || label
|
||||
}
|
||||
|
||||
function formatRelativeTime(value: string): string {
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">主动探测</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
定期检查 Key 可用性,提前发现异常
|
||||
按固定间隔主动刷新 Key 的账号状态与额度
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -57,9 +57,9 @@
|
||||
</div>
|
||||
<div class="flex items-center justify-between p-3 border rounded-lg bg-muted/50">
|
||||
<div class="space-y-0.5">
|
||||
<span class="text-sm font-medium">封号自动清除</span>
|
||||
<span class="text-sm font-medium">异常自动清除</span>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
检测到账号被封禁时自动从号池中移除
|
||||
仅在检测到不可恢复的账号异常时自动从号池中移除,不处理纯 Token 失效
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
|
||||
@@ -192,7 +192,6 @@ async function handleTestModel(modelName: string) {
|
||||
model_name: modelName,
|
||||
api_key_id: props.keyId,
|
||||
api_format: 'gemini:chat',
|
||||
message: 'hello',
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Group node -->
|
||||
<div
|
||||
v-if="modelValue.kind === 'group'"
|
||||
class="rounded-md border p-2"
|
||||
:class="[
|
||||
nested ? 'bg-muted/30 border-dashed' : 'bg-muted/10 border-border',
|
||||
]"
|
||||
>
|
||||
<!-- Group header: mode selector + actions -->
|
||||
<div class="flex items-center gap-1.5 mb-2">
|
||||
<Button
|
||||
v-if="!nested"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0 text-muted-foreground"
|
||||
title="转回单条件"
|
||||
@click="convertToLeaf"
|
||||
>
|
||||
<ListFilter class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Select
|
||||
:model-value="modelValue.mode"
|
||||
@update:model-value="(value: string) => updateGroupMode(value as ConditionGroupMode)"
|
||||
>
|
||||
<SelectTrigger class="w-[86px] h-6 text-xs font-medium shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
AND
|
||||
</SelectItem>
|
||||
<SelectItem value="any">
|
||||
OR
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div class="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-xs text-muted-foreground"
|
||||
title="添加条件"
|
||||
@click="addLeafChild"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-0.5" />
|
||||
条件
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!nested"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-6 px-1.5 text-xs text-muted-foreground"
|
||||
@click="addGroupChild(modelValue.mode === 'all' ? 'any' : 'all')"
|
||||
>
|
||||
<Plus class="w-3 h-3 mr-0.5" />
|
||||
子组
|
||||
</Button>
|
||||
<Button
|
||||
v-if="removable && nested"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-6 w-6 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- Children with logic connector labels between them -->
|
||||
<div class="space-y-0">
|
||||
<template
|
||||
v-for="(child, index) in modelValue.children"
|
||||
:key="index"
|
||||
>
|
||||
<!-- Logic connector label between children -->
|
||||
<div
|
||||
v-if="index > 0"
|
||||
class="flex items-center gap-2 py-0.5 pl-2"
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-semibold px-1.5 py-0.5 rounded"
|
||||
:class="modelValue.mode === 'all'
|
||||
? 'bg-blue-500/15 text-blue-600 dark:text-blue-400'
|
||||
: 'bg-amber-500/15 text-amber-600 dark:text-amber-400'"
|
||||
>
|
||||
{{ modelValue.mode === 'all' ? 'AND' : 'OR' }}
|
||||
</span>
|
||||
<div class="flex-1 border-t border-dashed border-muted-foreground/20" />
|
||||
</div>
|
||||
|
||||
<EndpointConditionEditor
|
||||
:model-value="child"
|
||||
:path-hint="pathHint"
|
||||
nested
|
||||
removable
|
||||
@update:model-value="(next) => updateChild(index, next)"
|
||||
@remove="removeChild(index)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Leaf node -->
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-wrap items-center gap-1.5 rounded-md px-2 py-1.5"
|
||||
:class="nested ? 'bg-background/60' : 'bg-muted/10 border border-border'"
|
||||
>
|
||||
<!-- Toggle to group mode (icon button at the start, top-level only) -->
|
||||
<Button
|
||||
v-if="!nested"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0 text-muted-foreground"
|
||||
title="转为组合条件 (AND/OR)"
|
||||
@click="convertToGroup('all')"
|
||||
>
|
||||
<ListFilter class="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Select
|
||||
:model-value="modelValue.source"
|
||||
@update:model-value="(value: string) => updateLeafField('source', value)"
|
||||
>
|
||||
<SelectTrigger class="w-[96px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="current">
|
||||
Current
|
||||
</SelectItem>
|
||||
<SelectItem value="original">
|
||||
Original
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
:model-value="modelValue.path"
|
||||
:placeholder="pathHint || '字段路径'"
|
||||
size="sm"
|
||||
class="flex-1 min-w-[120px] h-7 text-xs"
|
||||
@update:model-value="(value) => updateLeafField('path', value)"
|
||||
/>
|
||||
<Select
|
||||
:model-value="modelValue.op"
|
||||
@update:model-value="(value: string) => updateLeafField('op', value)"
|
||||
>
|
||||
<SelectTrigger class="w-[110px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="option in CONDITION_OP_OPTIONS"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
>
|
||||
{{ option.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
v-if="isConditionValueRequired(modelValue.op)"
|
||||
:model-value="modelValue.value"
|
||||
:placeholder="getConditionValuePlaceholder(modelValue.op)"
|
||||
size="sm"
|
||||
class="flex-1 min-w-[120px] h-7 text-xs"
|
||||
@update:model-value="(value) => updateLeafField('value', value)"
|
||||
/>
|
||||
<Button
|
||||
v-if="removable"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0 text-muted-foreground hover:text-destructive"
|
||||
@click="emit('remove')"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Button, Input, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui'
|
||||
import { ListFilter, Plus, X } from 'lucide-vue-next'
|
||||
|
||||
import type { BodyRuleConditionOp } from '@/api/endpoints'
|
||||
import {
|
||||
CONDITION_OP_OPTIONS,
|
||||
cloneEditableCondition,
|
||||
createConditionGroup,
|
||||
createEmptyConditionLeaf,
|
||||
getConditionValuePlaceholder,
|
||||
isConditionValueRequired,
|
||||
type ConditionGroupMode,
|
||||
type ConditionSource,
|
||||
type EditableConditionGroup,
|
||||
type EditableConditionLeaf,
|
||||
type EditableConditionNode,
|
||||
} from './endpoint-rule-condition'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelValue: EditableConditionNode
|
||||
pathHint?: string
|
||||
nested?: boolean
|
||||
removable?: boolean
|
||||
}>(), {
|
||||
pathHint: '',
|
||||
nested: false,
|
||||
removable: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: EditableConditionNode]
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
defineOptions({ name: 'EndpointConditionEditor' })
|
||||
|
||||
function updateLeafField(field: keyof EditableConditionLeaf, rawValue: string): void {
|
||||
if (props.modelValue.kind !== 'leaf') return
|
||||
|
||||
const next = cloneEditableCondition(props.modelValue) as EditableConditionLeaf
|
||||
|
||||
if (field === 'op') {
|
||||
next.op = rawValue as BodyRuleConditionOp
|
||||
if (!isConditionValueRequired(next.op)) next.value = ''
|
||||
} else if (field === 'source') {
|
||||
next.source = rawValue as ConditionSource
|
||||
} else if (field === 'path' || field === 'value') {
|
||||
next[field] = rawValue
|
||||
}
|
||||
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function updateGroupMode(mode: ConditionGroupMode): void {
|
||||
if (props.modelValue.kind !== 'group') return
|
||||
|
||||
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||
next.mode = mode
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function convertToLeaf(): void {
|
||||
emit('update:modelValue', createEmptyConditionLeaf())
|
||||
}
|
||||
|
||||
function convertToGroup(mode: ConditionGroupMode): void {
|
||||
const seed = props.modelValue.kind === 'leaf'
|
||||
? cloneEditableCondition(props.modelValue)
|
||||
: createEmptyConditionLeaf()
|
||||
emit('update:modelValue', createConditionGroup(mode, [seed]))
|
||||
}
|
||||
|
||||
function addLeafChild(): void {
|
||||
if (props.modelValue.kind !== 'group') return
|
||||
|
||||
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||
next.children.push(createEmptyConditionLeaf())
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function addGroupChild(mode: ConditionGroupMode): void {
|
||||
if (props.modelValue.kind !== 'group') return
|
||||
|
||||
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||
next.children.push(createConditionGroup(mode))
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function updateChild(index: number, child: EditableConditionNode): void {
|
||||
if (props.modelValue.kind !== 'group') return
|
||||
|
||||
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||
next.children[index] = child
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
|
||||
function removeChild(index: number): void {
|
||||
if (props.modelValue.kind !== 'group') return
|
||||
|
||||
const next = cloneEditableCondition(props.modelValue) as EditableConditionGroup
|
||||
next.children.splice(index, 1)
|
||||
|
||||
// When all children are removed, if top-level group, emit remove to let parent clear the condition
|
||||
if (next.children.length === 0) {
|
||||
if (!props.nested) {
|
||||
emit('remove')
|
||||
} else {
|
||||
next.children.push(createEmptyConditionLeaf())
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
emit('update:modelValue', next)
|
||||
}
|
||||
</script>
|
||||
@@ -105,7 +105,7 @@
|
||||
<div class="space-y-1.5">
|
||||
<Label class="text-xs text-muted-foreground">自定义路径</Label>
|
||||
<Input
|
||||
:model-value="getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')"
|
||||
:model-value="getDisplayedPath(endpoint)"
|
||||
:placeholder="getDefaultPath(endpoint.api_format, endpoint.base_url) || '留空使用默认'"
|
||||
:disabled="isFixedProvider"
|
||||
@update:model-value="(v) => updateEndpointField(endpoint.id, 'path', v)"
|
||||
@@ -228,105 +228,126 @@
|
||||
<span>拖拽左侧手柄可调整规则执行顺序</span>
|
||||
</div>
|
||||
<!-- 请求头规则列表 - 主题色边框 -->
|
||||
<div
|
||||
<template
|
||||
v-for="(rule, index) in getEndpointEditRules(endpoint.id)"
|
||||
:key="`header-${index}`"
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30"
|
||||
:class="[
|
||||
isHeaderRuleDragging(endpoint.id, index) ? 'opacity-60 border-primary bg-primary/5' : '',
|
||||
isHeaderRuleDragOver(endpoint.id, index) ? 'ring-1 ring-primary/40 bg-primary/10' : ''
|
||||
]"
|
||||
@dragover.prevent="handleHeaderRuleDragOver(endpoint.id, index)"
|
||||
@dragleave="handleHeaderRuleDragLeave(endpoint.id, index)"
|
||||
@drop.prevent="handleHeaderRuleDrop(endpoint.id, index)"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
|
||||
title="拖拽排序"
|
||||
draggable="true"
|
||||
@dragstart="(e) => handleHeaderRuleDragStart(endpoint.id, index, e)"
|
||||
@dragend="() => handleHeaderRuleDragEnd(endpoint.id)"
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-2 py-1.5 rounded-md border-l-4 border-primary/60 bg-muted/30"
|
||||
:class="[
|
||||
isHeaderRuleDragging(endpoint.id, index) ? 'opacity-60 border-primary bg-primary/5' : '',
|
||||
isHeaderRuleDragOver(endpoint.id, index) ? 'ring-1 ring-primary/40 bg-primary/10' : ''
|
||||
]"
|
||||
@dragover.prevent="handleHeaderRuleDragOver(endpoint.id, index)"
|
||||
@dragleave="handleHeaderRuleDragLeave(endpoint.id, index)"
|
||||
@drop.prevent="handleHeaderRuleDrop(endpoint.id, index)"
|
||||
>
|
||||
<GripVertical class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span
|
||||
class="text-[10px] font-semibold text-primary shrink-0"
|
||||
title="请求头"
|
||||
>H</span>
|
||||
<Select
|
||||
:model-value="rule.action"
|
||||
:open="ruleSelectOpen[`${endpoint.id}-${index}`]"
|
||||
@update:model-value="(v) => updateEndpointRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
|
||||
@update:open="(v) => handleRuleSelectOpen(endpoint.id, index, v)"
|
||||
>
|
||||
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="set">
|
||||
覆写
|
||||
</SelectItem>
|
||||
<SelectItem value="drop">
|
||||
删除
|
||||
</SelectItem>
|
||||
<SelectItem value="rename">
|
||||
重命名
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<template v-if="rule.action === 'set'">
|
||||
<Input
|
||||
:model-value="rule.key"
|
||||
placeholder="名称"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">=</span>
|
||||
<Input
|
||||
:model-value="rule.value"
|
||||
placeholder="值"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'drop'">
|
||||
<Input
|
||||
:model-value="rule.key"
|
||||
placeholder="要删除的名称"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'rename'">
|
||||
<Input
|
||||
:model-value="rule.from"
|
||||
placeholder="原名"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'from', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">→</span>
|
||||
<Input
|
||||
:model-value="rule.to"
|
||||
placeholder="新名"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'to', v)"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@click="removeEndpointRule(endpoint.id, index)"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="h-7 w-6 shrink-0 inline-flex items-center justify-center rounded-sm text-muted-foreground/60 hover:text-muted-foreground hover:bg-muted cursor-grab active:cursor-grabbing"
|
||||
title="拖拽排序"
|
||||
draggable="true"
|
||||
@dragstart="(e) => handleHeaderRuleDragStart(endpoint.id, index, e)"
|
||||
@dragend="() => handleHeaderRuleDragEnd(endpoint.id)"
|
||||
>
|
||||
<GripVertical class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<span
|
||||
class="text-[10px] font-semibold text-primary shrink-0"
|
||||
title="请求头"
|
||||
>H</span>
|
||||
<Select
|
||||
:model-value="rule.action"
|
||||
:open="ruleSelectOpen[`${endpoint.id}-${index}`]"
|
||||
@update:model-value="(v) => updateEndpointRuleAction(endpoint.id, index, v as 'set' | 'drop' | 'rename')"
|
||||
@update:open="(v) => handleRuleSelectOpen(endpoint.id, index, v)"
|
||||
>
|
||||
<SelectTrigger class="w-[88px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="set">
|
||||
覆写
|
||||
</SelectItem>
|
||||
<SelectItem value="drop">
|
||||
删除
|
||||
</SelectItem>
|
||||
<SelectItem value="rename">
|
||||
重命名
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
:class="rule.condition ? 'text-primary' : ''"
|
||||
title="条件触发"
|
||||
@click="toggleEndpointRuleCondition(endpoint.id, index)"
|
||||
>
|
||||
<Filter class="w-3 h-3" />
|
||||
</Button>
|
||||
<template v-if="rule.action === 'set'">
|
||||
<Input
|
||||
:model-value="rule.key"
|
||||
placeholder="名称"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">=</span>
|
||||
<Input
|
||||
:model-value="rule.value"
|
||||
placeholder="值"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'value', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'drop'">
|
||||
<Input
|
||||
:model-value="rule.key"
|
||||
placeholder="要删除的名称"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'key', v)"
|
||||
/>
|
||||
</template>
|
||||
<template v-else-if="rule.action === 'rename'">
|
||||
<Input
|
||||
:model-value="rule.from"
|
||||
placeholder="原名"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'from', v)"
|
||||
/>
|
||||
<span class="text-muted-foreground text-xs">→</span>
|
||||
<Input
|
||||
:model-value="rule.to"
|
||||
placeholder="新名"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointRuleField(endpoint.id, index, 'to', v)"
|
||||
/>
|
||||
</template>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
@click="removeEndpointRule(endpoint.id, index)"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<EndpointConditionEditor
|
||||
v-if="rule.condition"
|
||||
:model-value="rule.condition"
|
||||
path-hint="请求体字段路径"
|
||||
removable
|
||||
@update:model-value="(condition) => updateEndpointRuleCondition(endpoint.id, index, condition)"
|
||||
@remove="clearEndpointRuleCondition(endpoint.id, index)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="getEndpointEditBodyRules(endpoint.id).length > 0"
|
||||
@@ -397,14 +418,15 @@
|
||||
<code>exists</code> <code>not_exists</code> 字段存在性<br>
|
||||
<code>in</code> 在列表中(值填 <code>["a","b"]</code>)<br>
|
||||
<code>type_is</code> 类型判断(string/number/boolean/array/object/null)<br>
|
||||
条件路径支持 <code>$item.xxx</code> 引用通配符当前元素
|
||||
条件路径支持 <code>$item.xxx</code> 引用通配符当前元素<br>
|
||||
可切换 <code>Current</code>/<code>Original</code> 数据源,并支持 <code>ALL</code>/<code>ANY</code> 组合条件
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
规则按顺序执行,前面的修改对后续规则可见。
|
||||
</div>
|
||||
<div class="text-muted-foreground">
|
||||
规则在格式转换之后执行,路径需按目标提供商的请求体结构填写。
|
||||
规则默认在格式转换后按目标提供商结构匹配;条件切到 <code>Original</code> 时则按客户端原始请求体匹配。
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
@@ -474,7 +496,7 @@
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-7 w-7 shrink-0"
|
||||
:class="rule.conditionEnabled ? 'text-primary' : ''"
|
||||
:class="rule.condition ? 'text-primary' : ''"
|
||||
title="条件触发"
|
||||
@click="toggleBodyRuleCondition(endpoint.id, index)"
|
||||
>
|
||||
@@ -638,80 +660,14 @@
|
||||
<X class="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<!-- 条件编辑行 -->
|
||||
<div
|
||||
v-if="rule.conditionEnabled"
|
||||
class="flex items-center gap-1.5 px-2 py-1 ml-6 rounded-md bg-muted/20"
|
||||
>
|
||||
<span class="text-[10px] font-semibold text-muted-foreground shrink-0">IF</span>
|
||||
<Input
|
||||
:model-value="rule.conditionPath"
|
||||
:placeholder="rule.path?.includes('[*]') || rule.path?.match(/\[\d+-\d+\]/) ? '$item.字段名' : '字段路径'"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionPath', v)"
|
||||
/>
|
||||
<Select
|
||||
:model-value="rule.conditionOp"
|
||||
@update:model-value="(v: string) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionOp', v)"
|
||||
>
|
||||
<SelectTrigger class="w-[100px] h-7 text-xs shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="eq">
|
||||
等于
|
||||
</SelectItem>
|
||||
<SelectItem value="neq">
|
||||
不等于
|
||||
</SelectItem>
|
||||
<SelectItem value="gt">
|
||||
大于
|
||||
</SelectItem>
|
||||
<SelectItem value="lt">
|
||||
小于
|
||||
</SelectItem>
|
||||
<SelectItem value="gte">
|
||||
大于等于
|
||||
</SelectItem>
|
||||
<SelectItem value="lte">
|
||||
小于等于
|
||||
</SelectItem>
|
||||
<SelectItem value="starts_with">
|
||||
开头匹配
|
||||
</SelectItem>
|
||||
<SelectItem value="ends_with">
|
||||
结尾匹配
|
||||
</SelectItem>
|
||||
<SelectItem value="contains">
|
||||
包含
|
||||
</SelectItem>
|
||||
<SelectItem value="matches">
|
||||
正则匹配
|
||||
</SelectItem>
|
||||
<SelectItem value="exists">
|
||||
存在
|
||||
</SelectItem>
|
||||
<SelectItem value="not_exists">
|
||||
不存在
|
||||
</SelectItem>
|
||||
<SelectItem value="in">
|
||||
在列表中
|
||||
</SelectItem>
|
||||
<SelectItem value="type_is">
|
||||
类型是
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
v-if="rule.conditionOp !== 'exists' && rule.conditionOp !== 'not_exists'"
|
||||
:model-value="rule.conditionValue"
|
||||
:placeholder="rule.conditionOp === 'in' ? '["a", "b"]' : rule.conditionOp === 'type_is' ? 'string/number/boolean/...' : '值'"
|
||||
size="sm"
|
||||
class="flex-1 min-w-0 h-7 text-xs"
|
||||
@update:model-value="(v) => updateEndpointBodyRuleField(endpoint.id, index, 'conditionValue', v)"
|
||||
/>
|
||||
</div>
|
||||
<EndpointConditionEditor
|
||||
v-if="rule.condition"
|
||||
:model-value="rule.condition"
|
||||
:path-hint="getBodyRuleConditionPathPlaceholder(rule.path)"
|
||||
removable
|
||||
@update:model-value="(condition) => updateEndpointBodyRuleCondition(endpoint.id, index, condition)"
|
||||
@remove="clearEndpointBodyRuleCondition(endpoint.id, index)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
@@ -839,6 +795,7 @@ import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import EndpointConditionEditor from './EndpointConditionEditor.vue'
|
||||
import {
|
||||
createEndpoint,
|
||||
getDefaultBodyRules,
|
||||
@@ -850,11 +807,18 @@ import {
|
||||
type BodyRule,
|
||||
type BodyRuleRegexReplace,
|
||||
type BodyRuleNameStyle,
|
||||
type BodyRuleCondition,
|
||||
type BodyRuleConditionOp,
|
||||
} from '@/api/endpoints'
|
||||
import { adminApi } from '@/api/admin'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import {
|
||||
conditionEquals,
|
||||
conditionToEditable,
|
||||
createEmptyConditionLeaf,
|
||||
editableConditionToApi,
|
||||
getBodyRuleConditionPathPlaceholder,
|
||||
type EditableConditionNode,
|
||||
validateEditableCondition,
|
||||
} from './endpoint-rule-condition'
|
||||
|
||||
// 编辑用的规则类型(统一的可编辑结构)
|
||||
interface EditableRule {
|
||||
@@ -863,6 +827,7 @@ interface EditableRule {
|
||||
value: string // set 用
|
||||
from: string // rename 用
|
||||
to: string // rename 用
|
||||
condition: EditableConditionNode | null
|
||||
}
|
||||
|
||||
// 编辑用的请求体规则类型
|
||||
@@ -879,10 +844,7 @@ interface EditableBodyRule {
|
||||
replacement: string // regex_replace 用
|
||||
flags: string // regex_replace 用(i/m/s)
|
||||
style: string // name_style 用(snake_case/camelCase/PascalCase/kebab-case/capitalize)
|
||||
conditionEnabled: boolean // 是否启用条件
|
||||
conditionPath: string
|
||||
conditionOp: string
|
||||
conditionValue: string // JSON 格式字符串(保存时 parse)
|
||||
condition: EditableConditionNode | null
|
||||
}
|
||||
|
||||
// 端点编辑状态(仅 URL、路径、规则,格式转换是直接保存的)
|
||||
@@ -1197,7 +1159,7 @@ function restoreOriginalPlaceholder(value: unknown): unknown {
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (Array.isArray(value)) return value.map(restoreOriginalPlaceholder)
|
||||
if (Array.isArray(value)) return value.map(item => restoreOriginalPlaceholder(item))
|
||||
if (value !== null && typeof value === 'object') {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(value as Record<string, unknown>)) result[k] = restoreOriginalPlaceholder(v)
|
||||
@@ -1300,7 +1262,7 @@ async function loadDefaultBodyRulesForFormat(apiFormat: string, force = false):
|
||||
} catch (error: unknown) {
|
||||
defaultBodyRulesByFormat.value[cacheKey] = []
|
||||
defaultBodyRulesLoaded.value[cacheKey] = true
|
||||
log.warn('加载默认请求体规则失败', apiFormat, error)
|
||||
log.warn('加载默认请求体规则失败', { apiFormat, error })
|
||||
return []
|
||||
} finally {
|
||||
loadingDefaultBodyRulesByFormat.value[cacheKey] = false
|
||||
@@ -1314,10 +1276,19 @@ async function preloadDefaultBodyRules(endpoints: ProviderEndpoint[]): Promise<v
|
||||
|
||||
// 获取指定 API 格式的默认路径
|
||||
function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||
const providerType = (props.provider?.provider_type || '').toLowerCase()
|
||||
if (providerType === 'vertex_ai') {
|
||||
if (apiFormat === 'gemini:chat') {
|
||||
return '/v1/publishers/google/models/{model}:{action}'
|
||||
}
|
||||
if (apiFormat === 'claude:chat') {
|
||||
return '/v1/projects/{project_id}/locations/{region}/publishers/anthropic/models/{model}:{action}'
|
||||
}
|
||||
}
|
||||
|
||||
const format = apiFormats.value.find(f => f.value === apiFormat)
|
||||
const defaultPath = format?.default_path || ''
|
||||
// Codex 端点使用 /responses 而非 /v1/responses
|
||||
const providerType = (props.provider?.provider_type || '').toLowerCase()
|
||||
const isCodex = providerType
|
||||
? providerType === 'codex'
|
||||
: (!!baseUrl && isCodexUrl(baseUrl))
|
||||
@@ -1327,6 +1298,13 @@ function getDefaultPath(apiFormat: string, baseUrl?: string): string {
|
||||
return defaultPath
|
||||
}
|
||||
|
||||
function getDisplayedPath(endpoint: ProviderEndpoint): string {
|
||||
if (isFixedProvider.value) {
|
||||
return getDefaultPath(endpoint.api_format, endpoint.base_url)
|
||||
}
|
||||
return getEndpointEditState(endpoint.id)?.path ?? (endpoint.custom_path || '')
|
||||
}
|
||||
|
||||
// 判断是否是 Codex OAuth 端点
|
||||
function isCodexUrl(baseUrl: string): boolean {
|
||||
const url = baseUrl.replace(/\/+$/, '')
|
||||
@@ -1346,57 +1324,62 @@ function getEndpointUpstreamStreamPolicy(endpoint: ProviderEndpoint): string {
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function emptyHeaderRule(): EditableRule {
|
||||
return { action: 'set', key: '', value: '', from: '', to: '', condition: null }
|
||||
}
|
||||
|
||||
function emptyBodyRule(action: BodyRuleAction = 'set'): EditableBodyRule {
|
||||
return {
|
||||
action,
|
||||
path: '',
|
||||
value: '',
|
||||
from: '',
|
||||
to: '',
|
||||
index: '',
|
||||
pattern: '',
|
||||
replacement: '',
|
||||
flags: '',
|
||||
style: '',
|
||||
condition: null,
|
||||
}
|
||||
}
|
||||
|
||||
// 初始化端点的编辑状态
|
||||
function initEndpointEditState(endpoint: ProviderEndpoint): EndpointEditState {
|
||||
const rules: EditableRule[] = []
|
||||
if (endpoint.header_rules && endpoint.header_rules.length > 0) {
|
||||
for (const rule of endpoint.header_rules) {
|
||||
if (rule.action === 'set') {
|
||||
rules.push({ action: 'set', key: rule.key, value: rule.value || '', from: '', to: '' })
|
||||
rules.push({ ...emptyHeaderRule(), action: 'set', key: rule.key, value: rule.value || '', condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'drop') {
|
||||
rules.push({ action: 'drop', key: rule.key, value: '', from: '', to: '' })
|
||||
rules.push({ ...emptyHeaderRule(), action: 'drop', key: rule.key, condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'rename') {
|
||||
rules.push({ action: 'rename', key: '', value: '', from: rule.from, to: rule.to })
|
||||
rules.push({ ...emptyHeaderRule(), action: 'rename', from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const emptyBodyRule = (): Omit<EditableBodyRule, 'action'> => ({
|
||||
path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', style: '',
|
||||
conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '',
|
||||
})
|
||||
|
||||
const bodyRules: EditableBodyRule[] = []
|
||||
if (endpoint.body_rules && endpoint.body_rules.length > 0) {
|
||||
for (const rule of endpoint.body_rules) {
|
||||
// 提取 condition 信息
|
||||
const conditionFields = rule.condition ? {
|
||||
conditionEnabled: true,
|
||||
conditionPath: rule.condition.path || '',
|
||||
conditionOp: rule.condition.op || 'eq',
|
||||
conditionValue: rule.condition.value !== undefined
|
||||
? (typeof rule.condition.value === 'string' ? rule.condition.value : JSON.stringify(rule.condition.value))
|
||||
: '',
|
||||
} : {}
|
||||
|
||||
if (rule.action === 'set') {
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'set', path: rule.path, value, ...conditionFields })
|
||||
bodyRules.push({ ...emptyBodyRule('set'), path: rule.path, value, condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'drop') {
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'drop', path: rule.path, ...conditionFields })
|
||||
bodyRules.push({ ...emptyBodyRule('drop'), path: rule.path, condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'rename') {
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'rename', from: rule.from, to: rule.to, ...conditionFields })
|
||||
bodyRules.push({ ...emptyBodyRule('rename'), from: rule.from, to: rule.to, condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'append') {
|
||||
// 前端将 append 统一展示为 insert(index 留空),保存时再根据 index 是否为空转回 append
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: '', ...conditionFields })
|
||||
bodyRules.push({ ...emptyBodyRule('insert'), path: rule.path || '', value, index: '', condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'insert') {
|
||||
const { value } = initBodyRuleSetValueForEditor(rule.value)
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'insert', path: rule.path || '', value, index: String(rule.index ?? ''), ...conditionFields })
|
||||
bodyRules.push({ ...emptyBodyRule('insert'), path: rule.path || '', value, index: String(rule.index ?? ''), condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'regex_replace') {
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'regex_replace', path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '', ...conditionFields })
|
||||
bodyRules.push({ ...emptyBodyRule('regex_replace'), path: rule.path || '', pattern: rule.pattern || '', replacement: rule.replacement || '', flags: rule.flags || '', condition: conditionToEditable(rule.condition) })
|
||||
} else if (rule.action === 'name_style') {
|
||||
bodyRules.push({ ...emptyBodyRule(), action: 'name_style', path: rule.path || '', style: rule.style || 'capitalize', ...conditionFields })
|
||||
bodyRules.push({ ...emptyBodyRule('name_style'), path: rule.path || '', style: rule.style || 'capitalize', condition: conditionToEditable(rule.condition) })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1447,7 +1430,7 @@ function getEndpointEditRules(endpointId: string): EditableRule[] {
|
||||
// 添加规则(同时自动展开折叠)
|
||||
function handleAddEndpointRule(endpointId: string) {
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
rules.push({ action: 'set', key: '', value: '', from: '', to: '' })
|
||||
rules.push(emptyHeaderRule())
|
||||
// 自动展开折叠
|
||||
endpointRulesExpanded.value[endpointId] = true
|
||||
}
|
||||
@@ -1464,11 +1447,8 @@ function removeEndpointRule(endpointId: string, index: number) {
|
||||
function updateEndpointRuleAction(endpointId: string, index: number, action: 'set' | 'drop' | 'rename') {
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].action = action
|
||||
rules[index].key = ''
|
||||
rules[index].value = ''
|
||||
rules[index].from = ''
|
||||
rules[index].to = ''
|
||||
const currentCondition = rules[index].condition
|
||||
rules[index] = { ...emptyHeaderRule(), action, condition: currentCondition }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1480,6 +1460,27 @@ function updateEndpointRuleField(endpointId: string, index: number, field: 'key'
|
||||
}
|
||||
}
|
||||
|
||||
function toggleEndpointRuleCondition(endpointId: string, index: number) {
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
|
||||
}
|
||||
}
|
||||
|
||||
function updateEndpointRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].condition = condition
|
||||
}
|
||||
}
|
||||
|
||||
function clearEndpointRuleCondition(endpointId: string, index: number) {
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].condition = null
|
||||
}
|
||||
}
|
||||
|
||||
// 验证规则 key(针对特定端点)
|
||||
function validateRuleKeyForEndpoint(endpointId: string, key: string, index: number): string | null {
|
||||
const trimmedKey = key.trim().toLowerCase()
|
||||
@@ -1587,7 +1588,7 @@ function getEndpointEditBodyRules(endpointId: string): EditableBodyRule[] {
|
||||
// 添加请求体规则(同时自动展开折叠)
|
||||
function handleAddEndpointBodyRule(endpointId: string) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
rules.push({ action: 'set', path: '', value: '', from: '', to: '', index: '', pattern: '', replacement: '', flags: '', style: '', conditionEnabled: false, conditionPath: '', conditionOp: 'eq', conditionValue: '' })
|
||||
rules.push(emptyBodyRule('set'))
|
||||
// 自动展开折叠
|
||||
endpointRulesExpanded.value[endpointId] = true
|
||||
}
|
||||
@@ -1604,32 +1605,37 @@ function removeEndpointBodyRule(endpointId: string, index: number) {
|
||||
function updateEndpointBodyRuleAction(endpointId: string, index: number, action: BodyRuleAction) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].action = action
|
||||
rules[index].path = ''
|
||||
rules[index].value = ''
|
||||
rules[index].from = ''
|
||||
rules[index].to = ''
|
||||
rules[index].index = ''
|
||||
rules[index].pattern = ''
|
||||
rules[index].replacement = ''
|
||||
rules[index].flags = ''
|
||||
rules[index].style = ''
|
||||
const currentCondition = rules[index].condition
|
||||
rules[index] = { ...emptyBodyRule(action), condition: currentCondition }
|
||||
}
|
||||
}
|
||||
|
||||
// 更新请求体规则字段
|
||||
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'style' | 'conditionPath' | 'conditionOp' | 'conditionValue', value: string) {
|
||||
function updateEndpointBodyRuleField(endpointId: string, index: number, field: 'path' | 'value' | 'from' | 'to' | 'index' | 'pattern' | 'replacement' | 'flags' | 'style', value: string) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index][field] = value
|
||||
}
|
||||
}
|
||||
|
||||
// 切换请求体规则的条件启用状态
|
||||
function toggleBodyRuleCondition(endpointId: string, index: number) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].conditionEnabled = !rules[index].conditionEnabled
|
||||
rules[index].condition = rules[index].condition ? null : createEmptyConditionLeaf()
|
||||
}
|
||||
}
|
||||
|
||||
function updateEndpointBodyRuleCondition(endpointId: string, index: number, condition: EditableConditionNode) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].condition = condition
|
||||
}
|
||||
}
|
||||
|
||||
function clearEndpointBodyRuleCondition(endpointId: string, index: number) {
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
if (rules[index]) {
|
||||
rules[index].condition = null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1657,7 +1663,7 @@ function validateBodyRulePathForEndpoint(endpointId: string, path: string, index
|
||||
const currentRule = rules[index]
|
||||
// 任意一方启用了条件,则不视为冲突(条件可能互斥,真正冲突在运行时处理)
|
||||
const duplicate = rules.findIndex(
|
||||
(r, i) => i !== index && !currentRule.conditionEnabled && !r.conditionEnabled && (
|
||||
(r, i) => i !== index && !currentRule.condition && !r.condition && (
|
||||
((r.action === 'set' || r.action === 'drop') && r.path.trim().toLowerCase() === normalizedPath) ||
|
||||
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedPath)
|
||||
)
|
||||
@@ -1689,7 +1695,7 @@ function validateBodyRenameFromForEndpoint(endpointId: string, from: string, ind
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
const currentRule = rules[index]
|
||||
const duplicate = rules.findIndex(
|
||||
(r, i) => i !== index && !currentRule.conditionEnabled && !r.conditionEnabled &&
|
||||
(r, i) => i !== index && !currentRule.condition && !r.condition &&
|
||||
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
||||
(r.action === 'drop' && r.path.trim().toLowerCase() === normalizedFrom) ||
|
||||
(r.action === 'rename' && r.from.trim().toLowerCase() === normalizedFrom))
|
||||
@@ -1721,7 +1727,7 @@ function validateBodyRenameToForEndpoint(endpointId: string, to: string, index:
|
||||
const rules = getEndpointEditBodyRules(endpointId)
|
||||
const currentRule = rules[index]
|
||||
const duplicate = rules.findIndex(
|
||||
(r, i) => i !== index && !currentRule.conditionEnabled && !r.conditionEnabled &&
|
||||
(r, i) => i !== index && !currentRule.condition && !r.condition &&
|
||||
((r.action === 'set' && r.path.trim().toLowerCase() === normalizedTo) ||
|
||||
(r.action === 'rename' && r.to.trim().toLowerCase() === normalizedTo))
|
||||
)
|
||||
@@ -1932,17 +1938,7 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
if (edited.path !== (original.path ?? '')) return true
|
||||
if (edited.style !== (original.style ?? '')) return true
|
||||
}
|
||||
// 条件变更检测
|
||||
const origCond = original.condition
|
||||
if (edited.conditionEnabled !== !!origCond) return true
|
||||
if (edited.conditionEnabled && origCond) {
|
||||
if (edited.conditionPath !== origCond.path) return true
|
||||
if (edited.conditionOp !== origCond.op) return true
|
||||
const origVal = origCond.value !== undefined
|
||||
? (typeof origCond.value === 'string' ? origCond.value : JSON.stringify(origCond.value))
|
||||
: ''
|
||||
if (edited.conditionValue !== origVal) return true
|
||||
}
|
||||
if (!conditionEquals(edited.condition, conditionToEditable(original.condition))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1951,21 +1947,8 @@ function hasBodyRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
function rulesToBodyRules(rules: EditableBodyRule[]): BodyRule[] | null {
|
||||
const result: BodyRule[] = []
|
||||
|
||||
// 构建 condition 对象(如果启用且有效)
|
||||
function buildCondition(rule: EditableBodyRule): BodyRuleCondition | undefined {
|
||||
if (!rule.conditionEnabled || !rule.conditionPath.trim() || !rule.conditionOp.trim()) return undefined
|
||||
const op = rule.conditionOp as BodyRuleConditionOp
|
||||
if (op === 'exists' || op === 'not_exists') {
|
||||
return { path: rule.conditionPath.trim(), op }
|
||||
}
|
||||
const raw = rule.conditionValue.trim()
|
||||
let val: unknown = raw
|
||||
try { val = JSON.parse(raw) } catch { /* 保留原字符串 */ }
|
||||
return { path: rule.conditionPath.trim(), op, value: val }
|
||||
}
|
||||
|
||||
for (const rule of rules) {
|
||||
const condition = buildCondition(rule)
|
||||
const condition = editableConditionToApi(rule.condition)
|
||||
if (rule.action === 'set' && rule.path.trim()) {
|
||||
let value: unknown = rule.value
|
||||
try { value = restoreOriginalPlaceholder(JSON.parse(prepareValueForJsonParse(rule.value.trim()))) } catch { value = rule.value }
|
||||
@@ -2049,6 +2032,9 @@ function getBodyValidationErrorForEndpoint(endpointId: string): string | null {
|
||||
const validStyles = new Set(['snake_case', 'camelCase', 'PascalCase', 'kebab-case', 'capitalize'])
|
||||
if (!rule.style.trim() || !validStyles.has(rule.style.trim())) return `${prefix}请选择有效的命名风格`
|
||||
}
|
||||
|
||||
const conditionErr = validateEditableCondition(rule.condition)
|
||||
if (conditionErr) return `${prefix}${conditionErr}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -2092,6 +2078,7 @@ function hasRulesChanges(endpoint: ProviderEndpoint): boolean {
|
||||
} else if (edited.action === 'rename' && original.action === 'rename') {
|
||||
if (edited.from !== original.from || edited.to !== original.to) return true
|
||||
}
|
||||
if (!conditionEquals(edited.condition, conditionToEditable(original.condition))) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -2143,31 +2130,37 @@ function rulesToHeaderRules(rules: EditableRule[]): HeaderRule[] | null {
|
||||
const result: HeaderRule[] = []
|
||||
|
||||
for (const rule of rules) {
|
||||
const condition = editableConditionToApi(rule.condition)
|
||||
if (rule.action === 'set' && rule.key.trim()) {
|
||||
result.push({ action: 'set', key: rule.key.trim(), value: rule.value })
|
||||
result.push({ action: 'set', key: rule.key.trim(), value: rule.value, ...(condition ? { condition } : {}) })
|
||||
} else if (rule.action === 'drop' && rule.key.trim()) {
|
||||
result.push({ action: 'drop', key: rule.key.trim() })
|
||||
result.push({ action: 'drop', key: rule.key.trim(), ...(condition ? { condition } : {}) })
|
||||
} else if (rule.action === 'rename' && rule.from.trim() && rule.to.trim()) {
|
||||
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim() })
|
||||
result.push({ action: 'rename', from: rule.from.trim(), to: rule.to.trim(), ...(condition ? { condition } : {}) })
|
||||
}
|
||||
}
|
||||
|
||||
return result.length > 0 ? result : null
|
||||
}
|
||||
|
||||
// 检查规则是否有验证错误
|
||||
function hasValidationErrorsForEndpoint(endpointId: string): boolean {
|
||||
function getHeaderValidationErrorForEndpoint(endpointId: string): string | null {
|
||||
const rules = getEndpointEditRules(endpointId)
|
||||
for (let i = 0; i < rules.length; i++) {
|
||||
const rule = rules[i]
|
||||
const prefix = `第 ${i + 1} 条请求头规则:`
|
||||
if (rule.action === 'set' || rule.action === 'drop') {
|
||||
if (validateRuleKeyForEndpoint(endpointId, rule.key, i)) return true
|
||||
const err = validateRuleKeyForEndpoint(endpointId, rule.key, i)
|
||||
if (err) return `${prefix}${err}`
|
||||
} else if (rule.action === 'rename') {
|
||||
if (validateRenameFromForEndpoint(endpointId, rule.from, i)) return true
|
||||
if (validateRenameToForEndpoint(endpointId, rule.to, i)) return true
|
||||
const fromErr = validateRenameFromForEndpoint(endpointId, rule.from, i)
|
||||
if (fromErr) return `${prefix}${fromErr}`
|
||||
const toErr = validateRenameToForEndpoint(endpointId, rule.to, i)
|
||||
if (toErr) return `${prefix}${toErr}`
|
||||
}
|
||||
const conditionErr = validateEditableCondition(rule.condition)
|
||||
if (conditionErr) return `${prefix}${conditionErr}`
|
||||
}
|
||||
return false
|
||||
return null
|
||||
}
|
||||
|
||||
// 新端点选择的格式的默认路径
|
||||
@@ -2243,8 +2236,9 @@ async function saveEndpoint(endpoint: ProviderEndpoint) {
|
||||
if (!state || !state.url) return
|
||||
|
||||
// 检查规则是否有验证错误
|
||||
if (hasValidationErrorsForEndpoint(endpoint.id)) {
|
||||
showError('请修正请求头规则中的错误')
|
||||
const headerErr = getHeaderValidationErrorForEndpoint(endpoint.id)
|
||||
if (headerErr) {
|
||||
showError(headerErr)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -3,50 +3,231 @@
|
||||
variant="default"
|
||||
class="overflow-hidden"
|
||||
>
|
||||
<!-- 标题和筛选器 -->
|
||||
<div class="px-6 py-3.5 border-b border-border/60">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<div class="flex items-center gap-3">
|
||||
<Label class="text-xs text-muted-foreground">回溯时间:</Label>
|
||||
<Select
|
||||
v-model="lookbackHours"
|
||||
<div class="border-b border-border/60 bg-muted/20 px-6 py-5">
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-start xl:justify-between">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<h3 class="text-base font-semibold">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] uppercase tracking-[0.25em]"
|
||||
>
|
||||
{{ isAdmin ? 'Admin' : 'Public' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
异常优先排序,支持状态筛选、详情诊断与自动刷新,首屏先看出问题的格式。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="text-xs text-muted-foreground">
|
||||
最后刷新 {{ generatedAt ? formatRelativeTime(generatedAt) : '未加载' }}
|
||||
</div>
|
||||
<div class="flex items-center gap-2 rounded-xl border border-border/60 bg-card/70 px-3 py-1.5">
|
||||
<Switch v-model="autoRefreshEnabled" />
|
||||
<span class="text-xs font-medium">
|
||||
{{ autoRefreshEnabled ? '自动刷新 15s' : '自动刷新已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
v-if="isAdmin"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9 px-3 text-xs"
|
||||
:disabled="recoveringAllKeys || (healthSummary?.keys.circuit_open ?? 0) <= 0"
|
||||
@click="recoverAllConfirmOpen = true"
|
||||
>
|
||||
<Loader2
|
||||
v-if="recoveringAllKeys"
|
||||
class="mr-2 h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
恢复全部熔断 Key
|
||||
</Button>
|
||||
<RefreshButton
|
||||
:loading="loadingMonitors"
|
||||
@click="refreshData({ silent: false })"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 xl:grid-cols-6">
|
||||
<Card
|
||||
v-for="item in summaryCards"
|
||||
:key="item.label"
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<SelectTrigger class="w-28 h-8 text-xs border-border/60">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
1 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="6">
|
||||
6 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="12">
|
||||
12 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="24">
|
||||
24 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="48">
|
||||
48 小时
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<RefreshButton
|
||||
:loading="loading"
|
||||
@click="refreshData"
|
||||
/>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
|
||||
{{ item.label }}
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 text-xl font-semibold"
|
||||
:class="item.valueClass"
|
||||
>
|
||||
{{ item.value }}
|
||||
</div>
|
||||
<div class="mt-1 text-[11px] text-muted-foreground">
|
||||
{{ item.help }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rounded-xl border border-border/60 p-2"
|
||||
:class="item.iconClass"
|
||||
>
|
||||
<component
|
||||
:is="item.icon"
|
||||
class="h-4 w-4"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
|
||||
<div class="grid flex-1 grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">回溯时间</Label>
|
||||
<Select v-model="lookbackHours">
|
||||
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">
|
||||
1 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="6">
|
||||
6 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="12">
|
||||
12 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="24">
|
||||
24 小时
|
||||
</SelectItem>
|
||||
<SelectItem value="48">
|
||||
48 小时
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">状态筛选</Label>
|
||||
<Select v-model="statusFilter">
|
||||
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部状态
|
||||
</SelectItem>
|
||||
<SelectItem value="unhealthy">
|
||||
仅看异常
|
||||
</SelectItem>
|
||||
<SelectItem value="warning">
|
||||
仅看预警
|
||||
</SelectItem>
|
||||
<SelectItem value="healthy">
|
||||
仅看健康
|
||||
</SelectItem>
|
||||
<SelectItem value="unknown">
|
||||
仅看无流量
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">格式族</Label>
|
||||
<Select v-model="familyFilter">
|
||||
<SelectTrigger class="h-9 border-border/60 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
全部格式
|
||||
</SelectItem>
|
||||
<SelectItem value="claude">
|
||||
Claude
|
||||
</SelectItem>
|
||||
<SelectItem value="openai">
|
||||
OpenAI
|
||||
</SelectItem>
|
||||
<SelectItem value="gemini">
|
||||
Gemini
|
||||
</SelectItem>
|
||||
<SelectItem value="other">
|
||||
其他
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs text-muted-foreground">显示范围</Label>
|
||||
<div class="flex h-9 items-center justify-between rounded-xl border border-border/60 bg-card/70 px-3">
|
||||
<span class="text-xs font-medium">仅看有流量</span>
|
||||
<Switch v-model="onlyTraffic" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>显示 {{ filteredMonitors.length }} / {{ monitors.length }} 个格式</span>
|
||||
<Button
|
||||
v-if="hasActiveFilters"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 px-2 text-xs"
|
||||
@click="resetFilters"
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge
|
||||
variant="success"
|
||||
class="gap-1"
|
||||
>
|
||||
健康
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">成功率 ≥ 95%</span>
|
||||
<Badge
|
||||
variant="warning"
|
||||
class="gap-1"
|
||||
>
|
||||
预警
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">70% - 95%</span>
|
||||
<Badge
|
||||
variant="destructive"
|
||||
class="gap-1"
|
||||
>
|
||||
异常
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">成功率 < 70%</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="gap-1"
|
||||
>
|
||||
无流量
|
||||
</Badge>
|
||||
<span class="text-muted-foreground">窗口内没有请求</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容区域 -->
|
||||
<div class="p-6">
|
||||
<div
|
||||
v-if="loadingMonitors"
|
||||
v-if="showInitialLoading"
|
||||
class="flex items-center justify-center py-12"
|
||||
>
|
||||
<Loader2 class="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
@@ -57,81 +238,149 @@
|
||||
v-else-if="monitors.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||
>
|
||||
<Activity class="w-12 h-12 mb-3 opacity-30" />
|
||||
<Activity class="mb-3 h-12 w-12 opacity-30" />
|
||||
<p>暂无健康监控数据</p>
|
||||
<p class="text-xs mt-1">
|
||||
<p class="mt-1 text-xs">
|
||||
端点尚未产生请求记录
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredMonitors.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-muted-foreground"
|
||||
>
|
||||
<AlertTriangle class="mb-3 h-12 w-12 opacity-30" />
|
||||
<p>当前筛选条件下没有匹配项</p>
|
||||
<p class="mt-1 text-xs">
|
||||
可以放宽状态、格式族或流量条件
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="mt-4"
|
||||
@click="resetFilters"
|
||||
>
|
||||
重置筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-3"
|
||||
>
|
||||
<div
|
||||
v-for="monitor in monitors"
|
||||
v-for="monitor in filteredMonitors"
|
||||
:key="monitor.api_format"
|
||||
class="border border-border/60 rounded-lg p-4 hover:border-primary/50 transition-colors"
|
||||
class="rounded-xl border p-4 transition-colors"
|
||||
:class="getMonitorCardClass(monitor)"
|
||||
>
|
||||
<!-- 响应式布局:窄屏上下两行,宽屏左右结构 -->
|
||||
<div class="flex flex-col sm:flex-row sm:gap-6 sm:items-center">
|
||||
<!-- 第一行/左侧:信息区域 -->
|
||||
<div class="sm:w-52 flex-shrink-0 space-y-1.5 mb-3 sm:mb-0">
|
||||
<!-- API 格式标签和成功率 -->
|
||||
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:gap-6">
|
||||
<div class="space-y-1.5 xl:w-64 xl:flex-shrink-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="font-mono text-xs whitespace-nowrap"
|
||||
class="whitespace-nowrap font-mono text-xs"
|
||||
>
|
||||
{{ formatApiFormat(monitor.api_format) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
:variant="getMonitorStatusVariant(monitor)"
|
||||
class="text-xs whitespace-nowrap"
|
||||
>
|
||||
{{ getMonitorStatusLabel(monitor) }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="monitor.total_attempts > 0"
|
||||
:variant="getSuccessRateVariant(monitor.success_rate)"
|
||||
class="text-xs whitespace-nowrap"
|
||||
>
|
||||
{{ (monitor.success_rate * 100).toFixed(0) }}%
|
||||
{{ formatPercent(monitor.success_rate) }}
|
||||
</Badge>
|
||||
<!-- 提供商信息(仅管理员可见)- 窄屏时显示在同一行 -->
|
||||
<span
|
||||
v-if="showProviderInfo && 'provider_count' in monitor"
|
||||
class="text-xs text-muted-foreground sm:hidden"
|
||||
class="text-xs text-muted-foreground xl:hidden"
|
||||
>
|
||||
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 提供商信息(仅管理员可见)- 宽屏时显示在下方 -->
|
||||
<div
|
||||
v-if="showProviderInfo && 'provider_count' in monitor"
|
||||
class="text-xs text-muted-foreground hidden sm:block"
|
||||
class="hidden text-xs text-muted-foreground xl:block"
|
||||
>
|
||||
{{ monitor.provider_count }} 个提供商 / {{ monitor.key_count }} 个密钥
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ monitor.total_attempts }} 次请求
|
||||
<span class="mx-1">·</span>
|
||||
失败 {{ monitor.failed_count }}
|
||||
<span class="mx-1">·</span>
|
||||
跳过 {{ monitor.skipped_count }}
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-muted-foreground">
|
||||
最近事件 {{ monitor.last_event_at ? formatRelativeTime(monitor.last_event_at) : '暂无' }}
|
||||
</div>
|
||||
|
||||
<div class="pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8 px-3 text-xs"
|
||||
@click="openMonitorDetail(monitor)"
|
||||
>
|
||||
查看详情
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 第二行/右侧:时间线区域 -->
|
||||
<div class="flex-1 min-w-0 sm:flex sm:justify-end">
|
||||
<div class="w-full sm:max-w-5xl">
|
||||
<EndpointHealthTimeline
|
||||
:monitor="monitor"
|
||||
:lookback-hours="parseInt(lookbackHours)"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<EndpointHealthTimeline
|
||||
:monitor="monitor"
|
||||
:lookback-hours="parseInt(lookbackHours)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<HealthMonitorDetailDialog
|
||||
v-model="detailDialogOpen"
|
||||
:monitor="selectedMonitor"
|
||||
:is-admin="isAdmin"
|
||||
:format-keys="selectedFormatKeys"
|
||||
:loading-format-keys="loadingFormatKeys"
|
||||
:recovering-key-id="recoveringKeyId"
|
||||
@navigate-provider="goToProviderManagement"
|
||||
@navigate-pool="goToPoolManagement"
|
||||
@recover-key="recoverFormatKey"
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
v-model="recoverAllConfirmOpen"
|
||||
title="恢复全部熔断 Key"
|
||||
:description="recoverAllDialogDescription"
|
||||
type="warning"
|
||||
confirm-text="立即恢复"
|
||||
:loading="recoveringAllKeys"
|
||||
@confirm="confirmRecoverAllKeys"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import { Activity, Loader2 } from 'lucide-vue-next'
|
||||
import { computed, onMounted, onUnmounted, ref, watch, type Component } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Activity, AlertTriangle, Gauge, KeyRound, Loader2, Radio, ShieldAlert } from 'lucide-vue-next'
|
||||
import AlertDialog from '@/components/common/AlertDialog.vue'
|
||||
import HealthMonitorDetailDialog from './HealthMonitorDetailDialog.vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
@@ -139,12 +388,24 @@ import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import RefreshButton from '@/components/ui/refresh-button.vue'
|
||||
import EndpointHealthTimeline from './EndpointHealthTimeline.vue'
|
||||
import { getEndpointStatusMonitor, getPublicEndpointStatusMonitor } from '@/api/endpoints/health'
|
||||
import type { EndpointStatusMonitor, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
|
||||
import { getKeysGroupedByFormat, type GroupedFormatKey } from '@/api/endpoints/keys'
|
||||
import { getEndpointStatusMonitor, getHealthSummary, getPublicEndpointStatusMonitor, recoverAllKeysHealth, recoverKeyHealth } from '@/api/endpoints/health'
|
||||
import type { EndpointStatusMonitor, HealthSummary, PublicEndpointStatusMonitor } from '@/api/endpoints/types'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
|
||||
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
|
||||
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
|
||||
type SummaryCardItem = {
|
||||
label: string
|
||||
value: string
|
||||
help: string
|
||||
icon: Component
|
||||
valueClass: string
|
||||
iconClass: string
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
isAdmin?: boolean
|
||||
@@ -155,55 +416,510 @@ const props = withDefaults(defineProps<{
|
||||
showProviderInfo: false
|
||||
})
|
||||
|
||||
const { error: showError } = useToast()
|
||||
const AUTO_REFRESH_INTERVAL_MS = 15_000
|
||||
const STATUS_SORT_ORDER: Record<MonitorStatus, number> = {
|
||||
unhealthy: 0,
|
||||
warning: 1,
|
||||
unknown: 2,
|
||||
healthy: 3
|
||||
}
|
||||
|
||||
const router = useRouter()
|
||||
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||
|
||||
const loading = ref(false)
|
||||
const loadingMonitors = ref(false)
|
||||
const monitors = ref<(EndpointStatusMonitor | PublicEndpointStatusMonitor)[]>([])
|
||||
const monitors = ref<MonitorItem[]>([])
|
||||
const generatedAt = ref<string | null>(null)
|
||||
const healthSummary = ref<HealthSummary | null>(null)
|
||||
const lookbackHours = ref('6')
|
||||
const statusFilter = ref<'all' | MonitorStatus>('all')
|
||||
const familyFilter = ref<'all' | 'claude' | 'openai' | 'gemini' | 'other'>('all')
|
||||
const onlyTraffic = ref(false)
|
||||
const autoRefreshEnabled = ref(true)
|
||||
const detailDialogOpen = ref(false)
|
||||
const selectedMonitorFormat = ref<string | null>(null)
|
||||
const recoverAllConfirmOpen = ref(false)
|
||||
const recoveringAllKeys = ref(false)
|
||||
const keysByFormat = ref<Record<string, GroupedFormatKey[]>>({})
|
||||
const loadingFormatKeys = ref(false)
|
||||
const recoveringKeyId = ref<string | null>(null)
|
||||
|
||||
async function loadMonitors() {
|
||||
let autoRefreshTimer: ReturnType<typeof setInterval> | null = null
|
||||
let loadRequestId = 0
|
||||
|
||||
const isAdmin = computed(() => props.isAdmin)
|
||||
const showInitialLoading = computed(() => loadingMonitors.value && monitors.value.length === 0)
|
||||
|
||||
const unhealthyCount = computed(() =>
|
||||
monitors.value.filter(item => getMonitorStatus(item) === 'unhealthy').length
|
||||
)
|
||||
|
||||
const warningCount = computed(() =>
|
||||
monitors.value.filter(item => getMonitorStatus(item) === 'warning').length
|
||||
)
|
||||
|
||||
const trafficCount = computed(() =>
|
||||
monitors.value.filter(item => item.total_attempts > 0).length
|
||||
)
|
||||
|
||||
const overallSuccessRate = computed(() => {
|
||||
const totals = monitors.value.reduce((acc, item) => {
|
||||
acc.success += item.success_count
|
||||
acc.failed += item.failed_count
|
||||
return acc
|
||||
}, { success: 0, failed: 0 })
|
||||
|
||||
const denominator = totals.success + totals.failed
|
||||
if (denominator <= 0) return null
|
||||
return totals.success / denominator
|
||||
})
|
||||
|
||||
const summaryCards = computed<SummaryCardItem[]>(() => {
|
||||
const items: SummaryCardItem[] = [
|
||||
{
|
||||
label: '异常格式',
|
||||
value: `${unhealthyCount.value}`,
|
||||
help: `共 ${monitors.value.length} 个格式`,
|
||||
icon: AlertTriangle,
|
||||
valueClass: unhealthyCount.value > 0 ? 'text-destructive' : 'text-foreground',
|
||||
iconClass: unhealthyCount.value > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
|
||||
},
|
||||
{
|
||||
label: '预警格式',
|
||||
value: `${warningCount.value}`,
|
||||
help: '建议优先观察',
|
||||
icon: Radio,
|
||||
valueClass: warningCount.value > 0 ? 'text-yellow-600 dark:text-yellow-400' : 'text-foreground',
|
||||
iconClass: warningCount.value > 0 ? 'bg-yellow-500/10 text-yellow-600 dark:text-yellow-400' : 'bg-muted/60 text-muted-foreground'
|
||||
},
|
||||
{
|
||||
label: '有流量格式',
|
||||
value: `${trafficCount.value}`,
|
||||
help: `最近 ${lookbackHours.value} 小时`,
|
||||
icon: Activity,
|
||||
valueClass: 'text-foreground',
|
||||
iconClass: 'bg-primary/10 text-primary'
|
||||
},
|
||||
{
|
||||
label: '整体成功率',
|
||||
value: overallSuccessRate.value == null ? '--' : formatPercent(overallSuccessRate.value),
|
||||
help: '按 success / (success + failed)',
|
||||
icon: Gauge,
|
||||
valueClass: overallSuccessRate.value != null && overallSuccessRate.value < 0.95
|
||||
? 'text-yellow-600 dark:text-yellow-400'
|
||||
: 'text-foreground',
|
||||
iconClass: 'bg-primary/10 text-primary'
|
||||
}
|
||||
]
|
||||
|
||||
if (props.isAdmin) {
|
||||
items.push(
|
||||
{
|
||||
label: '熔断 Key',
|
||||
value: `${healthSummary.value?.keys.circuit_open ?? 0}`,
|
||||
help: '已打开熔断器',
|
||||
icon: ShieldAlert,
|
||||
valueClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'text-destructive' : 'text-foreground',
|
||||
iconClass: (healthSummary.value?.keys.circuit_open ?? 0) > 0 ? 'bg-destructive/10 text-destructive' : 'bg-muted/60 text-muted-foreground'
|
||||
},
|
||||
{
|
||||
label: '活跃 Key',
|
||||
value: `${healthSummary.value?.keys.active ?? 0}`,
|
||||
help: '当前可调度密钥',
|
||||
icon: KeyRound,
|
||||
valueClass: 'text-foreground',
|
||||
iconClass: 'bg-primary/10 text-primary'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
const filteredMonitors = computed(() => {
|
||||
const filtered = monitors.value.filter((monitor) => {
|
||||
const status = getMonitorStatus(monitor)
|
||||
const family = getMonitorFamily(monitor.api_format)
|
||||
|
||||
if (statusFilter.value !== 'all' && status !== statusFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (familyFilter.value !== 'all' && family !== familyFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (onlyTraffic.value && monitor.total_attempts <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
return filtered.sort((left, right) => {
|
||||
const statusGap = STATUS_SORT_ORDER[getMonitorStatus(left)] - STATUS_SORT_ORDER[getMonitorStatus(right)]
|
||||
if (statusGap !== 0) return statusGap
|
||||
|
||||
const leftTime = left.last_event_at ? new Date(left.last_event_at).getTime() : 0
|
||||
const rightTime = right.last_event_at ? new Date(right.last_event_at).getTime() : 0
|
||||
if (leftTime !== rightTime) return rightTime - leftTime
|
||||
|
||||
if (left.total_attempts !== right.total_attempts) {
|
||||
return right.total_attempts - left.total_attempts
|
||||
}
|
||||
|
||||
return left.api_format.localeCompare(right.api_format)
|
||||
})
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
statusFilter.value !== 'all' || familyFilter.value !== 'all' || onlyTraffic.value
|
||||
)
|
||||
|
||||
const selectedMonitor = computed<MonitorItem | null>(() => {
|
||||
if (!selectedMonitorFormat.value) return null
|
||||
return monitors.value.find(item => item.api_format === selectedMonitorFormat.value) ?? null
|
||||
})
|
||||
|
||||
|
||||
const selectedFormatKeys = computed<GroupedFormatKey[]>(() => {
|
||||
if (!selectedMonitorFormat.value) return []
|
||||
const keys = keysByFormat.value[selectedMonitorFormat.value] ?? []
|
||||
return [...keys].sort((left, right) => {
|
||||
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
|
||||
if (circuitGap !== 0) return circuitGap
|
||||
|
||||
const leftHealth = left.health_score ?? 1
|
||||
const rightHealth = right.health_score ?? 1
|
||||
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||
|
||||
if (left.request_count !== right.request_count) {
|
||||
return right.request_count - left.request_count
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
const recoverAllDialogDescription = computed(() => {
|
||||
const circuitOpen = healthSummary.value?.keys.circuit_open ?? 0
|
||||
return [
|
||||
'这会重置所有已熔断 Key 的健康状态并关闭熔断器。',
|
||||
`当前检测到 **${circuitOpen}** 个熔断 Key。`,
|
||||
'建议在确认上游服务恢复后执行。'
|
||||
].join('\n')
|
||||
})
|
||||
|
||||
async function loadMonitors(silent = false) {
|
||||
const currentRequestId = ++loadRequestId
|
||||
loadingMonitors.value = true
|
||||
|
||||
try {
|
||||
const params = {
|
||||
lookback_hours: parseInt(lookbackHours.value),
|
||||
per_format_limit: 100
|
||||
per_format_limit: 60
|
||||
}
|
||||
|
||||
const monitorPromise = props.isAdmin
|
||||
? getEndpointStatusMonitor(params)
|
||||
: getPublicEndpointStatusMonitor(params)
|
||||
|
||||
const [monitorResult, summaryResult] = await Promise.allSettled([
|
||||
monitorPromise,
|
||||
props.isAdmin ? getHealthSummary() : Promise.resolve(null)
|
||||
])
|
||||
|
||||
if (currentRequestId !== loadRequestId) return
|
||||
|
||||
if (monitorResult.status === 'fulfilled') {
|
||||
monitors.value = monitorResult.value.formats || []
|
||||
generatedAt.value = monitorResult.value.generated_at ?? null
|
||||
} else if (!silent) {
|
||||
showError(parseApiError(monitorResult.reason, '加载健康监控数据失败'), '错误')
|
||||
}
|
||||
|
||||
if (props.isAdmin) {
|
||||
const data = await getEndpointStatusMonitor(params)
|
||||
monitors.value = data.formats || []
|
||||
if (summaryResult.status === 'fulfilled') {
|
||||
healthSummary.value = summaryResult.value
|
||||
}
|
||||
} else {
|
||||
const data = await getPublicEndpointStatusMonitor(params)
|
||||
monitors.value = data.formats || []
|
||||
healthSummary.value = null
|
||||
}
|
||||
|
||||
if (selectedMonitorFormat.value && !monitors.value.some(item => item.api_format === selectedMonitorFormat.value)) {
|
||||
detailDialogOpen.value = false
|
||||
selectedMonitorFormat.value = null
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
|
||||
if (!silent) {
|
||||
showError(parseApiError(err, '加载健康监控数据失败'), '错误')
|
||||
}
|
||||
} finally {
|
||||
loadingMonitors.value = false
|
||||
if (currentRequestId === loadRequestId) {
|
||||
loadingMonitors.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
loading.value = true
|
||||
async function refreshData(options: { silent?: boolean } = {}) {
|
||||
await loadMonitors(Boolean(options.silent))
|
||||
}
|
||||
|
||||
async function loadKeysByFormat(force = false) {
|
||||
if (!props.isAdmin) return
|
||||
if (!force && Object.keys(keysByFormat.value).length > 0) return
|
||||
|
||||
loadingFormatKeys.value = true
|
||||
try {
|
||||
await loadMonitors()
|
||||
keysByFormat.value = await getKeysGroupedByFormat()
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '加载 Key 诊断数据失败'), '错误')
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingFormatKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getSuccessRateVariant(rate: number): 'default' | 'secondary' | 'destructive' | 'outline' {
|
||||
if (rate >= 0.95) return 'default'
|
||||
if (rate >= 0.8) return 'secondary'
|
||||
return 'destructive'
|
||||
function resetFilters() {
|
||||
statusFilter.value = 'all'
|
||||
familyFilter.value = 'all'
|
||||
onlyTraffic.value = false
|
||||
}
|
||||
|
||||
|
||||
function stopAutoRefresh() {
|
||||
if (!autoRefreshTimer) return
|
||||
clearInterval(autoRefreshTimer)
|
||||
autoRefreshTimer = null
|
||||
}
|
||||
|
||||
function startAutoRefresh() {
|
||||
stopAutoRefresh()
|
||||
if (!autoRefreshEnabled.value) return
|
||||
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
if (document.visibilityState === 'hidden') return
|
||||
void refreshData({ silent: true })
|
||||
}, AUTO_REFRESH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
function handleVisibilityChange() {
|
||||
if (document.visibilityState === 'visible' && autoRefreshEnabled.value) {
|
||||
void refreshData({ silent: true })
|
||||
}
|
||||
}
|
||||
|
||||
async function openMonitorDetail(monitor: MonitorItem) {
|
||||
selectedMonitorFormat.value = monitor.api_format
|
||||
detailDialogOpen.value = true
|
||||
if (props.isAdmin) {
|
||||
await loadKeysByFormat()
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRecoverAllKeys() {
|
||||
if (recoveringAllKeys.value) return
|
||||
|
||||
recoveringAllKeys.value = true
|
||||
try {
|
||||
const result = await recoverAllKeysHealth()
|
||||
recoverAllConfirmOpen.value = false
|
||||
|
||||
if (result.recovered_count > 0) {
|
||||
showSuccess(result.message || `已恢复 ${result.recovered_count} 个熔断 Key`)
|
||||
} else {
|
||||
showWarning(result.message || '当前没有需要恢复的熔断 Key')
|
||||
}
|
||||
|
||||
await refreshData({ silent: false })
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '批量恢复熔断 Key 失败'), '错误')
|
||||
} finally {
|
||||
recoveringAllKeys.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverFormatKey(key: GroupedFormatKey) {
|
||||
if (recoveringKeyId.value || !selectedMonitorFormat.value) return
|
||||
|
||||
recoveringKeyId.value = key.id
|
||||
try {
|
||||
const result = await recoverKeyHealth(key.id, selectedMonitorFormat.value)
|
||||
showSuccess(result.message || '当前格式已恢复')
|
||||
await Promise.all([
|
||||
refreshData({ silent: true }),
|
||||
loadKeysByFormat(true)
|
||||
])
|
||||
} catch (err: unknown) {
|
||||
showError(parseApiError(err, '恢复 Key 健康状态失败'), '错误')
|
||||
} finally {
|
||||
recoveringKeyId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function goToProviderManagement(providerId: string) {
|
||||
void router.push({
|
||||
name: 'ProviderManagement',
|
||||
query: { providerId }
|
||||
})
|
||||
}
|
||||
|
||||
function goToPoolManagement(providerId: string, search?: string) {
|
||||
const query: Record<string, string> = { providerId }
|
||||
const normalizedSearch = search?.trim()
|
||||
if (normalizedSearch) {
|
||||
query.search = normalizedSearch
|
||||
}
|
||||
|
||||
void router.push({
|
||||
name: 'PoolManagement',
|
||||
query
|
||||
})
|
||||
}
|
||||
|
||||
function getMonitorFamily(apiFormat: string): 'claude' | 'openai' | 'gemini' | 'other' {
|
||||
if (apiFormat.startsWith('claude:')) return 'claude'
|
||||
if (apiFormat.startsWith('openai:')) return 'openai'
|
||||
if (apiFormat.startsWith('gemini:')) return 'gemini'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
|
||||
const timeline = monitor.timeline ?? []
|
||||
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
||||
const status = timeline[index]
|
||||
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
|
||||
const timelineStatus = getLatestTimelineStatus(monitor)
|
||||
if (timelineStatus === 'unhealthy') return 'unhealthy'
|
||||
if (timelineStatus === 'warning') return 'warning'
|
||||
|
||||
if (monitor.total_attempts <= 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
if (monitor.success_rate < 0.7) return 'unhealthy'
|
||||
if (monitor.success_rate < 0.95) return 'warning'
|
||||
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function getMonitorStatusLabel(monitor: MonitorItem): string {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'unhealthy':
|
||||
return '异常'
|
||||
case 'warning':
|
||||
return '预警'
|
||||
case 'healthy':
|
||||
return '健康'
|
||||
default:
|
||||
return '无流量'
|
||||
}
|
||||
}
|
||||
|
||||
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'healthy':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'unhealthy':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
function getMonitorCardClass(monitor: MonitorItem): string {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'unhealthy':
|
||||
return 'border-red-300/70 bg-red-500/5 hover:border-red-400/80'
|
||||
case 'warning':
|
||||
return 'border-yellow-300/70 bg-yellow-500/5 hover:border-yellow-400/80'
|
||||
case 'healthy':
|
||||
return 'border-border/60 hover:border-primary/50'
|
||||
default:
|
||||
return 'border-border/40 bg-muted/20 hover:border-border/70'
|
||||
}
|
||||
}
|
||||
|
||||
function getSuccessRateVariant(rate: number): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
if (rate >= 0.95) return 'success'
|
||||
if (rate >= 0.8) return 'warning'
|
||||
if (rate >= 0) return 'destructive'
|
||||
return 'outline'
|
||||
}
|
||||
|
||||
|
||||
function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const diff = Date.now() - new Date(dateString).getTime()
|
||||
if (Number.isNaN(diff)) return '未知'
|
||||
if (diff < 60_000) return '刚刚'
|
||||
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days} 天前`
|
||||
|
||||
return formatAbsoluteTime(dateString)
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
watch(lookbackHours, () => {
|
||||
loadMonitors()
|
||||
void refreshData({ silent: false })
|
||||
})
|
||||
|
||||
watch(() => props.isAdmin, () => {
|
||||
keysByFormat.value = {}
|
||||
void refreshData({ silent: false })
|
||||
})
|
||||
|
||||
watch(autoRefreshEnabled, () => {
|
||||
startAutoRefresh()
|
||||
})
|
||||
|
||||
watch(detailDialogOpen, (open) => {
|
||||
if (!open) {
|
||||
selectedMonitorFormat.value = null
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
onMounted(() => {
|
||||
refreshData()
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange)
|
||||
startAutoRefresh()
|
||||
void refreshData({ silent: false })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange)
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -0,0 +1,936 @@
|
||||
<template>
|
||||
<Dialog
|
||||
v-model="open"
|
||||
size="5xl"
|
||||
close-on-backdrop
|
||||
:title="monitor ? `${formatApiFormat(monitor.api_format)} 详情` : '监控详情'"
|
||||
:description="dialogDescription"
|
||||
>
|
||||
<template #header-actions>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-8 w-8"
|
||||
aria-label="关闭详情"
|
||||
@click="open = false"
|
||||
>
|
||||
<X class="h-4 w-4" />
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="monitor"
|
||||
class="space-y-4"
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3 xl:grid-cols-5">
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
当前状态
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<Badge :variant="getMonitorStatusVariant(monitor)">
|
||||
{{ getMonitorStatusLabel(monitor) }}
|
||||
</Badge>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
成功率
|
||||
</div>
|
||||
<div class="mt-2 text-xl font-semibold">
|
||||
{{ formatPercent(monitor.success_rate) }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
请求总数
|
||||
</div>
|
||||
<div class="mt-2 text-xl font-semibold">
|
||||
{{ monitor.total_attempts }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
最近失败
|
||||
</div>
|
||||
<div class="mt-2 text-xl font-semibold">
|
||||
{{ failureEvents.length }}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-3"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
最近事件
|
||||
</div>
|
||||
<div class="mt-2 text-sm font-medium">
|
||||
{{ monitor.last_event_at ? formatAbsoluteTime(monitor.last_event_at) : '暂无' }}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="monitorApiPath"
|
||||
class="rounded-xl border border-border/60 bg-muted/20 px-4 py-3 text-sm"
|
||||
>
|
||||
<div class="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
|
||||
请求入口
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-foreground">
|
||||
{{ monitorApiPath }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isAdmin"
|
||||
class="grid grid-cols-1 gap-4 xl:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]"
|
||||
>
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">
|
||||
受影响 Provider
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ providerSummaries.length }} 个
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingFormatKeys"
|
||||
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
正在加载 Key 诊断数据...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="providerSummaries.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前格式下没有可诊断的 provider/key 数据。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="provider in providerSummaries"
|
||||
:key="provider.provider_id"
|
||||
class="rounded-xl border p-3 transition-colors"
|
||||
:class="providerFilter === provider.provider_id
|
||||
? 'border-primary/60 bg-primary/5'
|
||||
: 'border-border/50 bg-card/70'"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium">
|
||||
{{ provider.provider_name }}
|
||||
</span>
|
||||
<Badge :variant="provider.circuitOpenKeys > 0 ? 'destructive' : provider.providerActive ? 'success' : 'outline'">
|
||||
{{ provider.circuitOpenKeys > 0 ? `${provider.circuitOpenKeys} 熔断` : provider.providerActive ? '活跃' : '停用' }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ provider.totalKeys }} 个 Key
|
||||
<span class="mx-1">·</span>
|
||||
活跃 {{ provider.activeKeys }}
|
||||
<span class="mx-1">·</span>
|
||||
平均健康 {{ formatScore(provider.avgHealthScore) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-right text-xs text-muted-foreground">
|
||||
<div>{{ provider.requestCount }} 请求</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="toggleProviderFilter(provider.provider_id)"
|
||||
>
|
||||
{{ providerFilter === provider.provider_id ? '取消筛选' : '只看该 Provider' }}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="$emit('navigateProvider', provider.provider_id)"
|
||||
>
|
||||
前往 Provider
|
||||
</Button>
|
||||
<Button
|
||||
v-if="provider.poolEnabled"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="$emit('navigatePool', provider.provider_id)"
|
||||
>
|
||||
前往号池
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h4 class="text-sm font-semibold">
|
||||
Key 诊断
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ filteredFormatKeys.length }} / {{ sortedFormatKeys.length }} 个 Key
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'all' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||
@click="keyScopeFilter = 'all'"
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'circuit-open' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||
@click="keyScopeFilter = 'circuit-open'"
|
||||
>
|
||||
仅看熔断
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="[FILTER_BUTTON_CLASS, keyScopeFilter === 'recoverable' ? 'border-primary/60 bg-primary/5 text-primary' : '']"
|
||||
@click="keyScopeFilter = 'recoverable'"
|
||||
>
|
||||
仅看可恢复项
|
||||
</Button>
|
||||
<Button
|
||||
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="FILTER_BUTTON_CLASS"
|
||||
@click="resetFilters"
|
||||
>
|
||||
清空局部筛选
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="providerFilter !== 'all' || keyScopeFilter !== 'all'"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
<span v-if="providerFilter !== 'all'">
|
||||
Provider:{{ providerFilterName }}
|
||||
</span>
|
||||
<span v-if="providerFilter !== 'all' && keyScopeFilter !== 'all'"> · </span>
|
||||
<span v-if="keyScopeFilter === 'circuit-open'">范围:仅熔断项</span>
|
||||
<span v-else-if="keyScopeFilter === 'recoverable'">范围:仅可恢复项</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="loadingFormatKeys"
|
||||
class="flex items-center gap-2 py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
<Loader2 class="h-4 w-4 animate-spin" />
|
||||
正在加载 Key 诊断数据...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="sortedFormatKeys.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前格式下没有 Key 样本。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredFormatKeys.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前局部筛选下没有匹配的 Key。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="max-h-[420px] space-y-2 overflow-y-auto pr-1"
|
||||
>
|
||||
<div
|
||||
v-for="key in filteredFormatKeys"
|
||||
:key="key.id"
|
||||
class="rounded-xl border border-border/50 bg-card/70 p-3"
|
||||
>
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="text-sm font-medium">
|
||||
{{ key.name }}
|
||||
</span>
|
||||
<Badge variant="outline">
|
||||
{{ key.provider_name }}
|
||||
</Badge>
|
||||
<Badge :variant="key.circuit_breaker_open ? 'destructive' : key.is_active && key.provider_active ? 'success' : 'outline'">
|
||||
{{ key.circuit_breaker_open ? '已熔断' : key.is_active && key.provider_active ? '可用' : '停用' }}
|
||||
</Badge>
|
||||
<Badge
|
||||
v-if="shouldShowRecoverAction(key)"
|
||||
variant="warning"
|
||||
>
|
||||
建议恢复
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="mt-1 font-mono text-xs text-muted-foreground">
|
||||
{{ key.api_key_masked }}
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
<span>健康 {{ formatScore(key.health_score) }}</span>
|
||||
<span v-if="key.success_rate != null">{{ formatPercent(key.success_rate) }}</span>
|
||||
<span>{{ key.request_count }} 请求</span>
|
||||
<span v-if="key.avg_response_time_ms != null">均延迟 {{ formatLatency(key.avg_response_time_ms) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2 sm:justify-end">
|
||||
<Button
|
||||
v-if="key.pool_enabled"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="ACTION_BUTTON_CLASS"
|
||||
@click="$emit('navigatePool', key.provider_id, key.name)"
|
||||
>
|
||||
号池内查看
|
||||
</Button>
|
||||
<Button
|
||||
v-if="shouldShowRecoverAction(key)"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:class="ACTION_BUTTON_CLASS"
|
||||
:disabled="recoveringKeyId !== null"
|
||||
@click="$emit('recoverKey', key)"
|
||||
>
|
||||
<Loader2
|
||||
v-if="recoveringKeyId === key.id"
|
||||
class="mr-2 h-3.5 w-3.5 animate-spin"
|
||||
/>
|
||||
恢复健康度
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">
|
||||
最近失败
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
最近 {{ Math.min(failureEvents.length, 8) }} 条
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="failureEvents.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前窗口内没有失败事件。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="event in failureEvents.slice(0, 8)"
|
||||
:key="buildEventKey(event)"
|
||||
class="rounded-xl border border-border/50 bg-card/70 p-3"
|
||||
>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<Badge variant="destructive">
|
||||
失败
|
||||
</Badge>
|
||||
<span
|
||||
v-if="event.status_code"
|
||||
class="text-xs font-medium text-foreground"
|
||||
>
|
||||
HTTP {{ event.status_code }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{{ formatAbsoluteTime(event.timestamp) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-2 text-sm text-foreground">
|
||||
{{ event.error_type || event.error_message || '未提供错误类型' }}
|
||||
</div>
|
||||
<div class="mt-1 text-xs text-muted-foreground">
|
||||
{{ event.latency_ms != null ? `延迟 ${formatLatency(event.latency_ms)}` : '延迟未知' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3">
|
||||
<h4 class="text-sm font-semibold">
|
||||
错误分布
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Error Type
|
||||
</div>
|
||||
<div
|
||||
v-if="errorTypes.length === 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
当前没有可归类的错误类型。
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="item in errorTypes"
|
||||
:key="item.label"
|
||||
variant="outline"
|
||||
>
|
||||
{{ item.label }} × {{ item.count }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
Status Code
|
||||
</div>
|
||||
<div
|
||||
v-if="statusCodes.length === 0"
|
||||
class="text-sm text-muted-foreground"
|
||||
>
|
||||
当前没有状态码样本。
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-wrap gap-2"
|
||||
>
|
||||
<Badge
|
||||
v-for="item in statusCodes"
|
||||
:key="item.label"
|
||||
variant="outline"
|
||||
>
|
||||
{{ item.label }} × {{ item.count }}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="mb-2 text-xs uppercase tracking-[0.18em] text-muted-foreground">
|
||||
延迟样本
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2 text-sm">
|
||||
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
P50
|
||||
</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{{ latencyStats.p50 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
P95
|
||||
</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{{ latencyStats.p95 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-border/50 bg-card/70 px-3 py-2">
|
||||
<div class="text-[11px] text-muted-foreground">
|
||||
Max
|
||||
</div>
|
||||
<div class="mt-1 font-semibold">
|
||||
{{ latencyStats.max }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
variant="inset"
|
||||
class="p-4"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h4 class="text-sm font-semibold">
|
||||
最近活动
|
||||
</h4>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
最近 {{ Math.min(sortedEvents.length, 12) }} 条事件
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="sortedEvents.length === 0"
|
||||
class="py-6 text-sm text-muted-foreground"
|
||||
>
|
||||
当前没有事件样本。
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="space-y-2"
|
||||
>
|
||||
<div
|
||||
v-for="event in sortedEvents.slice(0, 12)"
|
||||
:key="buildEventKey(event)"
|
||||
class="flex flex-col gap-2 rounded-xl border border-border/50 bg-card/70 p-3 sm:flex-row sm:items-center sm:justify-between"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2 flex-wrap">
|
||||
<Badge :variant="getEventStatusVariant(event.status)">
|
||||
{{ getEventStatusLabel(event.status) }}
|
||||
</Badge>
|
||||
<span
|
||||
v-if="event.status_code"
|
||||
class="text-xs text-foreground"
|
||||
>
|
||||
HTTP {{ event.status_code }}
|
||||
</span>
|
||||
<span
|
||||
v-if="event.latency_ms != null"
|
||||
class="text-xs text-muted-foreground"
|
||||
>
|
||||
{{ formatLatency(event.latency_ms) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="event.error_type"
|
||||
class="truncate text-xs text-muted-foreground"
|
||||
>
|
||||
{{ event.error_type }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-xs text-muted-foreground">
|
||||
{{ formatAbsoluteTime(event.timestamp) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { Loader2, X } from 'lucide-vue-next'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import Badge from '@/components/ui/badge.vue'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Dialog from '@/components/ui/dialog/Dialog.vue'
|
||||
import type { GroupedFormatKey } from '@/api/endpoints/keys'
|
||||
import type { EndpointHealthEvent, EndpointStatusMonitor, PublicEndpointStatusMonitor, PublicHealthEvent } from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
|
||||
type MonitorItem = EndpointStatusMonitor | PublicEndpointStatusMonitor
|
||||
type MonitorEvent = EndpointHealthEvent | PublicHealthEvent
|
||||
type MonitorStatus = 'healthy' | 'warning' | 'unhealthy' | 'unknown'
|
||||
type CountItem = { label: string; count: number }
|
||||
type KeyScopeFilter = 'all' | 'circuit-open' | 'recoverable'
|
||||
type ProviderImpactSummary = {
|
||||
provider_id: string
|
||||
provider_name: string
|
||||
providerActive: boolean
|
||||
poolEnabled: boolean
|
||||
totalKeys: number
|
||||
activeKeys: number
|
||||
circuitOpenKeys: number
|
||||
avgHealthScore: number | null
|
||||
requestCount: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
monitor: MonitorItem | null
|
||||
isAdmin: boolean
|
||||
formatKeys: GroupedFormatKey[]
|
||||
loadingFormatKeys: boolean
|
||||
recoveringKeyId: string | null
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
navigateProvider: [providerId: string]
|
||||
navigatePool: [providerId: string, search?: string]
|
||||
recoverKey: [key: GroupedFormatKey]
|
||||
}>()
|
||||
|
||||
const open = defineModel<boolean>({ default: false })
|
||||
|
||||
const FILTER_BUTTON_CLASS = 'h-8 px-3 text-xs'
|
||||
const ACTION_BUTTON_CLASS = 'h-8 px-3 text-xs'
|
||||
|
||||
const providerFilter = ref<string>('all')
|
||||
const keyScopeFilter = ref<KeyScopeFilter>('all')
|
||||
|
||||
watch(open, (value) => {
|
||||
if (!value) {
|
||||
providerFilter.value = 'all'
|
||||
keyScopeFilter.value = 'all'
|
||||
}
|
||||
})
|
||||
|
||||
const monitorApiPath = computed(() =>
|
||||
props.monitor && 'api_path' in props.monitor ? props.monitor.api_path : null
|
||||
)
|
||||
|
||||
const sortedEvents = computed<MonitorEvent[]>(() => {
|
||||
const events = props.monitor?.events ?? []
|
||||
return [...events].sort((left, right) =>
|
||||
new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime()
|
||||
)
|
||||
})
|
||||
|
||||
const failureEvents = computed<MonitorEvent[]>(() =>
|
||||
sortedEvents.value.filter(event => event.status === 'failed')
|
||||
)
|
||||
|
||||
const sortedFormatKeys = computed<GroupedFormatKey[]>(() =>
|
||||
[...props.formatKeys].sort((left, right) => {
|
||||
const circuitGap = Number(right.circuit_breaker_open) - Number(left.circuit_breaker_open)
|
||||
if (circuitGap !== 0) return circuitGap
|
||||
|
||||
const leftHealth = left.health_score ?? 1
|
||||
const rightHealth = right.health_score ?? 1
|
||||
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||
|
||||
if (left.request_count !== right.request_count) {
|
||||
return right.request_count - left.request_count
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name)
|
||||
})
|
||||
)
|
||||
|
||||
const providerSummaries = computed<ProviderImpactSummary[]>(() => {
|
||||
const grouped = new Map<string, ProviderImpactSummary & { healthValues: number[] }>()
|
||||
|
||||
for (const key of sortedFormatKeys.value) {
|
||||
const current = grouped.get(key.provider_id) ?? {
|
||||
provider_id: key.provider_id,
|
||||
provider_name: key.provider_name,
|
||||
providerActive: key.provider_active,
|
||||
poolEnabled: key.pool_enabled,
|
||||
totalKeys: 0,
|
||||
activeKeys: 0,
|
||||
circuitOpenKeys: 0,
|
||||
avgHealthScore: null,
|
||||
requestCount: 0,
|
||||
healthValues: []
|
||||
}
|
||||
|
||||
current.totalKeys += 1
|
||||
current.requestCount += key.request_count
|
||||
current.providerActive = current.providerActive || key.provider_active
|
||||
current.poolEnabled = current.poolEnabled || key.pool_enabled
|
||||
|
||||
if (key.is_active) current.activeKeys += 1
|
||||
if (key.circuit_breaker_open) current.circuitOpenKeys += 1
|
||||
if (key.health_score != null) current.healthValues.push(key.health_score)
|
||||
|
||||
grouped.set(key.provider_id, current)
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((provider) => ({
|
||||
provider_id: provider.provider_id,
|
||||
provider_name: provider.provider_name,
|
||||
providerActive: provider.providerActive,
|
||||
poolEnabled: provider.poolEnabled,
|
||||
totalKeys: provider.totalKeys,
|
||||
activeKeys: provider.activeKeys,
|
||||
circuitOpenKeys: provider.circuitOpenKeys,
|
||||
avgHealthScore: provider.healthValues.length > 0
|
||||
? provider.healthValues.reduce((sum, value) => sum + value, 0) / provider.healthValues.length
|
||||
: null,
|
||||
requestCount: provider.requestCount,
|
||||
}))
|
||||
.sort((left, right) => {
|
||||
if (left.circuitOpenKeys !== right.circuitOpenKeys) {
|
||||
return right.circuitOpenKeys - left.circuitOpenKeys
|
||||
}
|
||||
|
||||
const leftHealth = left.avgHealthScore ?? 1
|
||||
const rightHealth = right.avgHealthScore ?? 1
|
||||
if (leftHealth !== rightHealth) return leftHealth - rightHealth
|
||||
|
||||
return right.requestCount - left.requestCount
|
||||
})
|
||||
})
|
||||
|
||||
const providerFilterName = computed(() =>
|
||||
providerSummaries.value.find(p => p.provider_id === providerFilter.value)?.provider_name ?? '当前 Provider'
|
||||
)
|
||||
|
||||
const filteredFormatKeys = computed<GroupedFormatKey[]>(() =>
|
||||
sortedFormatKeys.value.filter((key) => {
|
||||
if (providerFilter.value !== 'all' && key.provider_id !== providerFilter.value) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (keyScopeFilter.value === 'circuit-open') {
|
||||
return key.circuit_breaker_open
|
||||
}
|
||||
|
||||
if (keyScopeFilter.value === 'recoverable') {
|
||||
return shouldShowRecoverAction(key)
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
)
|
||||
|
||||
const dialogDescription = computed(() => {
|
||||
if (!props.monitor) return '查看最近失败、错误分布与事件样本'
|
||||
const timeText = props.monitor.last_event_at
|
||||
? `最近事件 ${formatRelativeTime(props.monitor.last_event_at)}`
|
||||
: '当前窗口没有事件'
|
||||
return `${getMonitorStatusLabel(props.monitor)} · ${props.monitor.total_attempts} 次请求 · ${timeText}`
|
||||
})
|
||||
|
||||
const errorTypes = computed<CountItem[]>(() =>
|
||||
buildCountItems(
|
||||
failureEvents.value
|
||||
.map(event => event.error_type?.trim())
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
|
||||
const statusCodes = computed<CountItem[]>(() =>
|
||||
buildCountItems(
|
||||
failureEvents.value
|
||||
.map(event => event.status_code != null ? String(event.status_code) : null)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
|
||||
const latencyStats = computed(() => {
|
||||
const values = sortedEvents.value
|
||||
.map(event => event.latency_ms)
|
||||
.filter((value): value is number => value != null)
|
||||
.sort((left, right) => left - right)
|
||||
|
||||
if (values.length === 0) {
|
||||
return { p50: '--', p95: '--', max: '--' }
|
||||
}
|
||||
|
||||
return {
|
||||
p50: formatLatency(pickPercentile(values, 0.5)),
|
||||
p95: formatLatency(pickPercentile(values, 0.95)),
|
||||
max: formatLatency(values[values.length - 1])
|
||||
}
|
||||
})
|
||||
|
||||
watch(sortedFormatKeys, (keys) => {
|
||||
if (providerFilter.value === 'all') return
|
||||
if (!keys.some(key => key.provider_id === providerFilter.value)) {
|
||||
providerFilter.value = 'all'
|
||||
}
|
||||
})
|
||||
|
||||
function resetFilters() {
|
||||
providerFilter.value = 'all'
|
||||
keyScopeFilter.value = 'all'
|
||||
}
|
||||
|
||||
function toggleProviderFilter(providerId: string) {
|
||||
providerFilter.value = providerFilter.value === providerId ? 'all' : providerId
|
||||
}
|
||||
|
||||
function shouldShowRecoverAction(key: GroupedFormatKey): boolean {
|
||||
if (key.health_score == null) return false
|
||||
return key.health_score < 0.999
|
||||
}
|
||||
|
||||
function getLatestTimelineStatus(monitor: MonitorItem): MonitorStatus | null {
|
||||
const timeline = monitor.timeline ?? []
|
||||
for (let index = timeline.length - 1; index >= 0; index -= 1) {
|
||||
const status = timeline[index]
|
||||
if (status === 'healthy' || status === 'warning' || status === 'unhealthy') {
|
||||
return status
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getMonitorStatus(monitor: MonitorItem): MonitorStatus {
|
||||
const timelineStatus = getLatestTimelineStatus(monitor)
|
||||
if (timelineStatus === 'unhealthy') return 'unhealthy'
|
||||
if (timelineStatus === 'warning') return 'warning'
|
||||
|
||||
if (monitor.total_attempts <= 0) {
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
if (monitor.success_rate < 0.7) return 'unhealthy'
|
||||
if (monitor.success_rate < 0.95) return 'warning'
|
||||
if (timelineStatus === 'healthy' || monitor.success_rate >= 0.95) return 'healthy'
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function getMonitorStatusLabel(monitor: MonitorItem): string {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'unhealthy':
|
||||
return '异常'
|
||||
case 'warning':
|
||||
return '预警'
|
||||
case 'healthy':
|
||||
return '健康'
|
||||
default:
|
||||
return '无流量'
|
||||
}
|
||||
}
|
||||
|
||||
function getMonitorStatusVariant(monitor: MonitorItem): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
switch (getMonitorStatus(monitor)) {
|
||||
case 'healthy':
|
||||
return 'success'
|
||||
case 'warning':
|
||||
return 'warning'
|
||||
case 'unhealthy':
|
||||
return 'destructive'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
function getEventStatusVariant(status: string): 'success' | 'warning' | 'destructive' | 'outline' {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return 'success'
|
||||
case 'failed':
|
||||
return 'destructive'
|
||||
case 'skipped':
|
||||
return 'warning'
|
||||
default:
|
||||
return 'outline'
|
||||
}
|
||||
}
|
||||
|
||||
function getEventStatusLabel(status: string): string {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '成功'
|
||||
case 'failed':
|
||||
return '失败'
|
||||
case 'skipped':
|
||||
return '跳过'
|
||||
case 'started':
|
||||
return '执行中'
|
||||
default:
|
||||
return '未知'
|
||||
}
|
||||
}
|
||||
|
||||
function buildCountItems(values: string[]): CountItem[] {
|
||||
const counter = new Map<string, number>()
|
||||
for (const value of values) {
|
||||
counter.set(value, (counter.get(value) ?? 0) + 1)
|
||||
}
|
||||
return Array.from(counter.entries())
|
||||
.map(([label, count]) => ({ label, count }))
|
||||
.sort((left, right) => right.count - left.count)
|
||||
.slice(0, 8)
|
||||
}
|
||||
|
||||
function pickPercentile(sortedValues: number[], percentile: number): number {
|
||||
if (sortedValues.length === 0) return 0
|
||||
const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * percentile) - 1))
|
||||
return sortedValues[index]
|
||||
}
|
||||
|
||||
function formatPercent(rate: number): string {
|
||||
return `${(rate * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
function formatScore(score: number | null | undefined): string {
|
||||
if (score == null) return '--'
|
||||
return `${(score * 100).toFixed(0)}%`
|
||||
}
|
||||
|
||||
function formatLatency(latencyMs: number): string {
|
||||
if (latencyMs < 1000) return `${latencyMs}ms`
|
||||
return `${(latencyMs / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateString: string): string {
|
||||
const diff = Date.now() - new Date(dateString).getTime()
|
||||
if (Number.isNaN(diff)) return '未知'
|
||||
if (diff < 60_000) return '刚刚'
|
||||
|
||||
const minutes = Math.floor(diff / 60_000)
|
||||
if (minutes < 60) return `${minutes} 分钟前`
|
||||
|
||||
const hours = Math.floor(minutes / 60)
|
||||
if (hours < 24) return `${hours} 小时前`
|
||||
|
||||
const days = Math.floor(hours / 24)
|
||||
if (days < 7) return `${days} 天前`
|
||||
|
||||
return formatAbsoluteTime(dateString)
|
||||
}
|
||||
|
||||
function formatAbsoluteTime(dateString: string): string {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function buildEventKey(event: MonitorEvent): string {
|
||||
return `${event.timestamp}-${event.status}-${event.status_code ?? 'na'}-${event.error_type ?? 'none'}-${event.latency_ms ?? 'na'}`
|
||||
}
|
||||
</script>
|
||||
@@ -79,18 +79,19 @@
|
||||
{{ editingKey ? '' : '*' }}
|
||||
</Label>
|
||||
<template v-if="form.auth_type === 'service_account'">
|
||||
<Textarea
|
||||
:id="apiKeyInputId"
|
||||
<JsonImportInput
|
||||
v-model="form.auth_config_text"
|
||||
:required="!editingKey"
|
||||
:placeholder="editingKey ? '留空表示不修改' : '粘贴完整的 Service Account JSON'"
|
||||
class="min-h-[120px] font-mono text-xs"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
:disabled="saving"
|
||||
:reset-key="formNonce"
|
||||
accept=".json,.txt,application/json,text/plain"
|
||||
:multiple="false"
|
||||
drop-title="拖入 Service Account JSON 或点击选择"
|
||||
drop-hint="支持 .json / .txt,单文件导入"
|
||||
:manual-placeholder="editingKey ? '留空表示不修改,或粘贴完整的 Service Account JSON' : '粘贴完整的 Service Account JSON'"
|
||||
:manual-description="serviceAccountDescription"
|
||||
textarea-class="min-h-[160px] font-mono text-xs break-all !rounded-xl"
|
||||
@error="handleServiceAccountImportError"
|
||||
/>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
JSON 格式,包含 project_id、private_key 等字段
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<Input
|
||||
@@ -330,13 +331,14 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem, Textarea } from '@/components/ui'
|
||||
import { Dialog, Button, Input, Label, Switch, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from '@/components/ui'
|
||||
import { Key, SquarePen } from 'lucide-vue-next'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useFormDialog } from '@/composables/useFormDialog'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { parseNumberInput, parseNullableNumberInput } from '@/utils/form'
|
||||
import { log } from '@/utils/logger'
|
||||
import JsonImportInput from '@/components/common/JsonImportInput.vue'
|
||||
import {
|
||||
addProviderKey,
|
||||
updateProviderKey,
|
||||
@@ -415,6 +417,12 @@ const visibleApiFormats = computed(() => {
|
||||
|
||||
const showAuthTypeSelector = computed(() => props.providerType === 'vertex_ai')
|
||||
|
||||
const serviceAccountDescription = computed(() => (
|
||||
props.editingKey
|
||||
? '留空表示不修改;JSON 格式,包含 project_id、private_key 等字段'
|
||||
: 'JSON 格式,包含 project_id、private_key 等字段'
|
||||
))
|
||||
|
||||
// 默认认证类型
|
||||
const defaultAuthType = 'api_key' as const
|
||||
|
||||
@@ -676,6 +684,10 @@ function parseAuthConfig(): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
function handleServiceAccountImportError(payload: { message: string, title?: string }) {
|
||||
showError(payload.message, payload.title || '错误')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
// 必须有 providerId
|
||||
if (!props.providerId) {
|
||||
|
||||
@@ -282,8 +282,6 @@ export interface AliasGroup {
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
providerId: string
|
||||
/** @deprecated */
|
||||
providerApiFormats?: string[]
|
||||
models: Model[]
|
||||
editingGroup?: AliasGroup | null
|
||||
preselectedModelId?: string | null
|
||||
|
||||
@@ -410,8 +410,8 @@
|
||||
:reset-key="importInputResetKey"
|
||||
drop-title="拖入授权文件或点击选择"
|
||||
drop-hint="支持 .json / .txt,可多选"
|
||||
manual-placeholder="粘贴 Refresh Token 或 JSON 内容"
|
||||
paste-toggle-text="或手动粘贴 Refresh Token"
|
||||
manual-placeholder="粘贴 Refresh Token / Access Token 或 JSON 内容"
|
||||
paste-toggle-text="或手动粘贴 Token"
|
||||
file-toggle-text="或选择 JSON 文件导入"
|
||||
textarea-class="min-h-[200px] text-xs font-mono break-all !rounded-xl"
|
||||
@error="handleImportInputError"
|
||||
@@ -893,7 +893,7 @@ function isBatchImport(text: string): boolean {
|
||||
return lines.length > 1
|
||||
}
|
||||
|
||||
function parseImportText(text: string): { refresh_token: string; name?: string } | null {
|
||||
function parseImportText(text: string): { refresh_token?: string; access_token?: string; name?: string } | null {
|
||||
const trimmed = text.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
@@ -907,9 +907,19 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
|
||||
if (typeof parsed === 'object' && parsed !== null) {
|
||||
const obj = parsed as Record<string, unknown>
|
||||
const refreshToken = obj.refresh_token
|
||||
if (typeof refreshToken === 'string' && refreshToken.trim()) {
|
||||
const refreshTokenCamel = obj.refreshToken
|
||||
const accessToken = obj.access_token
|
||||
const accessTokenCamel = obj.accessToken
|
||||
const normalizedRefreshToken = typeof refreshToken === 'string' && refreshToken.trim()
|
||||
? refreshToken.trim()
|
||||
: (typeof refreshTokenCamel === 'string' && refreshTokenCamel.trim() ? refreshTokenCamel.trim() : undefined)
|
||||
const normalizedAccessToken = typeof accessToken === 'string' && accessToken.trim()
|
||||
? accessToken.trim()
|
||||
: (typeof accessTokenCamel === 'string' && accessTokenCamel.trim() ? accessTokenCamel.trim() : undefined)
|
||||
if (normalizedRefreshToken || normalizedAccessToken) {
|
||||
return {
|
||||
refresh_token: refreshToken.trim(),
|
||||
refresh_token: normalizedRefreshToken,
|
||||
access_token: normalizedAccessToken,
|
||||
name: (typeof obj.name === 'string' ? obj.name : undefined) || (typeof obj.oauth_email === 'string' ? obj.oauth_email : undefined),
|
||||
}
|
||||
}
|
||||
@@ -919,9 +929,34 @@ function parseImportText(text: string): { refresh_token: string; name?: string }
|
||||
// Not JSON: treat as raw token.
|
||||
}
|
||||
|
||||
if (isLikelyJwtToken(trimmed)) {
|
||||
return { access_token: trimmed }
|
||||
}
|
||||
|
||||
return { refresh_token: trimmed }
|
||||
}
|
||||
|
||||
function isLikelyJwtToken(token: string): boolean {
|
||||
const parts = token.trim().split('.')
|
||||
if (parts.length !== 3 || parts.some(part => !part)) return false
|
||||
|
||||
try {
|
||||
const header = JSON.parse(decodeBase64Url(parts[0])) as Record<string, unknown>
|
||||
const payload = JSON.parse(decodeBase64Url(parts[1])) as Record<string, unknown>
|
||||
const tokenType = typeof header.typ === 'string' ? header.typ.toLowerCase() : ''
|
||||
if (tokenType && tokenType !== 'jwt' && tokenType !== 'at+jwt') return false
|
||||
return ['exp', 'aud', 'iss', 'scope', 'scp'].some(key => key in payload)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function decodeBase64Url(value: string): string {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
|
||||
return atob(padded)
|
||||
}
|
||||
|
||||
function handleImportInputError(payload: { message: string; title?: string }) {
|
||||
showError(payload.message, payload.title)
|
||||
}
|
||||
|
||||
@@ -228,17 +228,17 @@
|
||||
? 'border-primary/30 bg-primary/5'
|
||||
: 'border-border/50 bg-background hover:border-border hover:bg-muted/30'
|
||||
]"
|
||||
:draggable="!key.is_pool_aggregate && key.is_active && key.provider_active"
|
||||
@dragstart="(!key.is_pool_aggregate && key.is_active && key.provider_active) && handleKeyDragStart(format, key.id, $event)"
|
||||
:draggable="key.is_active && key.provider_active"
|
||||
@dragstart="(key.is_active && key.provider_active) && handleKeyDragStart(format, key.id, $event)"
|
||||
@dragend="handleKeyDragEnd(format)"
|
||||
@dragover.prevent="!key.is_pool_aggregate && handleKeyDragOver(format, key.id)"
|
||||
@dragover.prevent="handleKeyDragOver(format, key.id)"
|
||||
@dragleave="handleKeyDragLeave(format)"
|
||||
@drop="!key.is_pool_aggregate && handleKeyDrop(format, key.id)"
|
||||
@drop="handleKeyDrop(format, key.id)"
|
||||
>
|
||||
<!-- 拖拽手柄 -->
|
||||
<div
|
||||
class="p-0.5 rounded transition-colors shrink-0"
|
||||
:class="(!key.is_pool_aggregate && key.is_active && key.provider_active)
|
||||
:class="(key.is_active && key.provider_active)
|
||||
? 'cursor-grab active:cursor-grabbing text-muted-foreground/30 group-hover:text-muted-foreground'
|
||||
: 'text-muted-foreground/15 cursor-default'"
|
||||
>
|
||||
@@ -248,7 +248,7 @@
|
||||
<!-- 可编辑序号 -->
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-if="!key.is_pool_aggregate && editingKeyPriority[format] === key.id"
|
||||
v-if="editingKeyPriority[format] === key.id"
|
||||
type="number"
|
||||
min="1"
|
||||
:value="key.priority"
|
||||
@@ -260,12 +260,9 @@
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-[11px] font-medium transition-colors"
|
||||
:class="key.is_pool_aggregate
|
||||
? 'text-muted-foreground/60 cursor-not-allowed'
|
||||
: 'text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary'"
|
||||
:title="key.is_pool_aggregate ? '号池优先级请在号池调度中调整' : '点击编辑优先级'"
|
||||
@click.stop="!key.is_pool_aggregate && startEditKeyPriority(format, key)"
|
||||
class="w-5 h-5 rounded bg-muted/50 flex items-center justify-center text-[11px] font-medium transition-colors text-muted-foreground cursor-pointer hover:bg-primary/10 hover:text-primary"
|
||||
title="点击编辑优先级"
|
||||
@click.stop="startEditKeyPriority(format, key)"
|
||||
>
|
||||
{{ key.priority }}
|
||||
</div>
|
||||
@@ -540,6 +537,12 @@ const dragOverKey = ref<Record<string, string | null>>({})
|
||||
const loadingKeys = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const SAVE_CONCURRENCY = 6
|
||||
|
||||
let originalProviderPriorityById = new Map<string, number>()
|
||||
let originalPoolPriorityByProviderId = new Map<string, number | null>()
|
||||
let originalKeyPriorityById = new Map<string, Record<string, number>>()
|
||||
|
||||
// Key 优先级编辑状态
|
||||
const editingKeyPriority = ref<Record<string, string | null>>({}) // format -> keyId
|
||||
|
||||
@@ -645,6 +648,77 @@ function normalizePriorityMap(
|
||||
return normalized
|
||||
}
|
||||
|
||||
function normalizeOptionalPriority(value: unknown): number | null {
|
||||
const num = Number(value)
|
||||
if (!Number.isFinite(num)) return null
|
||||
return Math.trunc(num)
|
||||
}
|
||||
|
||||
function normalizeRequiredPriority(value: unknown, fallback: number): number {
|
||||
return normalizeOptionalPriority(value) ?? fallback
|
||||
}
|
||||
|
||||
function normalizeProvidersForEditing(
|
||||
providers: ProviderWithEndpointsSummary[]
|
||||
): ProviderWithEndpointsSummary[] {
|
||||
const normalized = providers.map((provider, index) => ({
|
||||
...provider,
|
||||
provider_priority: normalizeRequiredPriority(provider.provider_priority, 100 + index),
|
||||
pool_advanced: provider.pool_advanced
|
||||
? {
|
||||
...provider.pool_advanced,
|
||||
global_priority: normalizeOptionalPriority(provider.pool_advanced.global_priority),
|
||||
}
|
||||
: provider.pool_advanced,
|
||||
}))
|
||||
|
||||
const minProviderPriority = normalized.reduce(
|
||||
(min, provider) => Math.min(min, provider.provider_priority),
|
||||
Number.POSITIVE_INFINITY,
|
||||
)
|
||||
const providerOffset = Number.isFinite(minProviderPriority) && minProviderPriority < 1
|
||||
? 1 - minProviderPriority
|
||||
: 0
|
||||
|
||||
const explicitPoolPriorities = normalized
|
||||
.map((provider) => normalizeOptionalPriority(provider.pool_advanced?.global_priority))
|
||||
.filter((priority): priority is number => priority != null)
|
||||
const minPoolPriority = explicitPoolPriorities.length > 0
|
||||
? Math.min(...explicitPoolPriorities)
|
||||
: null
|
||||
const poolOffset = minPoolPriority != null && minPoolPriority < 1
|
||||
? 1 - minPoolPriority
|
||||
: 0
|
||||
|
||||
return normalized.map((provider) => ({
|
||||
...provider,
|
||||
provider_priority: provider.provider_priority + providerOffset,
|
||||
pool_advanced: provider.pool_advanced
|
||||
? {
|
||||
...provider.pool_advanced,
|
||||
global_priority: provider.pool_advanced.global_priority == null
|
||||
? null
|
||||
: provider.pool_advanced.global_priority + poolOffset,
|
||||
}
|
||||
: provider.pool_advanced,
|
||||
}))
|
||||
}
|
||||
|
||||
function snapshotProviderBaseline(providers: ProviderWithEndpointsSummary[]) {
|
||||
originalProviderPriorityById = new Map(
|
||||
providers.map((provider, index) => [
|
||||
provider.id,
|
||||
normalizeRequiredPriority(provider.provider_priority, 100 + index),
|
||||
])
|
||||
)
|
||||
originalPoolPriorityByProviderId = new Map(
|
||||
providers.map((provider) => [
|
||||
provider.id,
|
||||
normalizeOptionalPriority(provider.pool_advanced?.global_priority),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeRateMultipliers(
|
||||
value: Record<string, unknown> | null | undefined
|
||||
): Record<string, number> | null {
|
||||
@@ -660,6 +734,69 @@ function normalizeRateMultipliers(
|
||||
return Object.keys(normalized).length > 0 ? normalized : null
|
||||
}
|
||||
|
||||
function buildEditableKeyPriorityMap(): Map<string, Record<string, number>> {
|
||||
const priorityMapByKeyId = new Map<string, Record<string, number>>()
|
||||
|
||||
for (const format of Object.keys(keysByFormat.value)) {
|
||||
const normalizedFormat = normalizeApiFormatKey(format)
|
||||
if (!normalizedFormat) continue
|
||||
|
||||
const keys = keysByFormat.value[format].filter((key) => !isPoolManagedKey(key))
|
||||
keys.forEach((key) => {
|
||||
const existing = priorityMapByKeyId.get(key.id) || normalizePriorityMap(key.global_priority_by_format)
|
||||
existing[normalizedFormat] = Math.max(0, Math.trunc(key.priority))
|
||||
priorityMapByKeyId.set(key.id, existing)
|
||||
})
|
||||
}
|
||||
|
||||
return priorityMapByKeyId
|
||||
}
|
||||
|
||||
function snapshotKeyBaseline() {
|
||||
originalKeyPriorityById = new Map(
|
||||
Array.from(buildEditableKeyPriorityMap().entries()).map(([keyId, priorityMap]) => [
|
||||
keyId,
|
||||
{ ...priorityMap },
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
function arePriorityMapsEqual(
|
||||
left: Record<string, number> | undefined,
|
||||
right: Record<string, number> | undefined,
|
||||
): boolean {
|
||||
const leftEntries = Object.entries(left || {}).sort(([a], [b]) => a.localeCompare(b))
|
||||
const rightEntries = Object.entries(right || {}).sort(([a], [b]) => a.localeCompare(b))
|
||||
|
||||
if (leftEntries.length !== rightEntries.length) return false
|
||||
|
||||
return leftEntries.every(([format, priority], index) => {
|
||||
const [rightFormat, rightPriority] = rightEntries[index] || []
|
||||
return format === rightFormat && priority === rightPriority
|
||||
})
|
||||
}
|
||||
|
||||
async function runTasksWithConcurrency(
|
||||
tasks: Array<() => Promise<unknown>>,
|
||||
concurrency: number = SAVE_CONCURRENCY,
|
||||
) {
|
||||
if (tasks.length === 0) return
|
||||
|
||||
let cursor = 0
|
||||
const runNext = async (): Promise<void> => {
|
||||
while (cursor < tasks.length) {
|
||||
const index = cursor++
|
||||
await tasks[index]()
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from(
|
||||
{ length: Math.min(concurrency, tasks.length) },
|
||||
() => runNext(),
|
||||
)
|
||||
await Promise.all(workers)
|
||||
}
|
||||
|
||||
const providerById = computed(() => {
|
||||
const map = new Map<string, ProviderWithEndpointsSummary>()
|
||||
sortedProviders.value.forEach((provider) => {
|
||||
@@ -720,6 +857,17 @@ function isPoolAggregateItem(key: KeyWithMeta): boolean {
|
||||
return key.is_pool_aggregate === true
|
||||
}
|
||||
|
||||
// 将号池聚合项的优先级写回 provider 的 pool_advanced.global_priority(本地状态)
|
||||
function updatePoolGlobalPriority(providerId: string, priority: number) {
|
||||
const provider = sortedProviders.value.find((p) => p.id === providerId)
|
||||
if (!provider) return
|
||||
if (provider.pool_advanced) {
|
||||
provider.pool_advanced.global_priority = priority
|
||||
} else {
|
||||
provider.pool_advanced = { global_priority: priority }
|
||||
}
|
||||
}
|
||||
|
||||
function toNumberOrNull(value: unknown): number | null {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
@@ -849,8 +997,13 @@ watch(internalOpen, async (open) => {
|
||||
async function loadAllProviders() {
|
||||
try {
|
||||
const response = await getProvidersSummary({ page: 1, page_size: 9999 })
|
||||
sortedProviders.value = sortProvidersByActiveAndPriority(response.items)
|
||||
snapshotProviderBaseline(response.items)
|
||||
sortedProviders.value = sortProvidersByActiveAndPriority(
|
||||
normalizeProvidersForEditing(response.items)
|
||||
)
|
||||
} catch {
|
||||
originalProviderPriorityById = new Map()
|
||||
originalPoolPriorityByProviderId = new Map()
|
||||
sortedProviders.value = []
|
||||
}
|
||||
}
|
||||
@@ -987,12 +1140,14 @@ async function loadKeysByFormat() {
|
||||
data[format] = sortKeysByActiveAndPriority(data[format])
|
||||
}
|
||||
keysByFormat.value = data
|
||||
snapshotKeyBaseline()
|
||||
|
||||
const formats = sortApiFormats(Object.keys(data))
|
||||
if (formats.length > 0 && !formats.includes(activeFormatTab.value)) {
|
||||
activeFormatTab.value = formats[0]
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
originalKeyPriorityById = new Map()
|
||||
showError(parseApiError(err, '加载 Key 列表失败'), '错误')
|
||||
} finally {
|
||||
loadingKeys.value = false
|
||||
@@ -1025,7 +1180,6 @@ async function toggleKeyActive(format: string, key: KeyWithMeta) {
|
||||
|
||||
// Key 优先级编辑
|
||||
function startEditKeyPriority(format: string, key: KeyWithMeta) {
|
||||
if (isPoolAggregateItem(key)) return
|
||||
editingKeyPriority.value[format] = key.id
|
||||
}
|
||||
|
||||
@@ -1034,16 +1188,17 @@ function cancelEditKeyPriority(format: string) {
|
||||
}
|
||||
|
||||
function finishEditKeyPriority(format: string, key: KeyWithMeta, event: FocusEvent) {
|
||||
if (isPoolAggregateItem(key)) {
|
||||
editingKeyPriority.value[format] = null
|
||||
return
|
||||
}
|
||||
const input = event.target as HTMLInputElement
|
||||
const newPriority = parseInt(input.value, 10)
|
||||
|
||||
if (!isNaN(newPriority) && newPriority >= 1) {
|
||||
// 每个格式独立管理优先级,只更新当前格式
|
||||
key.priority = newPriority
|
||||
if (isPoolAggregateItem(key) && key.provider_id) {
|
||||
// 号池聚合项:写回 provider 的 pool_advanced.global_priority
|
||||
updatePoolGlobalPriority(key.provider_id, newPriority)
|
||||
} else {
|
||||
// 普通 key:直接更新优先级
|
||||
key.priority = newPriority
|
||||
}
|
||||
// 重新排序当前格式
|
||||
keysByFormat.value[format] = sortKeysByActiveAndPriority(keysByFormat.value[format])
|
||||
}
|
||||
@@ -1166,7 +1321,7 @@ function handleProviderDrop(dropIndex: number) {
|
||||
|
||||
// Key 拖拽处理
|
||||
function getEditableKeysForFormat(format: string): KeyWithMeta[] {
|
||||
return (keysByFormat.value[format] || []).filter((key) => !isPoolManagedKey(key))
|
||||
return displayKeysByFormat.value[format] || []
|
||||
}
|
||||
|
||||
function handleKeyDragStart(format: string, keyId: string, event: DragEvent) {
|
||||
@@ -1267,7 +1422,7 @@ function handleKeyDrop(format: string, dropKeyId: string) {
|
||||
updatedPriorityById.set(key.id, key.priority)
|
||||
})
|
||||
|
||||
// 将新的优先级写回原始数据(含号池 key),但只修改非号池条目
|
||||
// 将新的优先级写回原始数据(普通 key)
|
||||
keysByFormat.value[format] = sortKeysByActiveAndPriority(
|
||||
(keysByFormat.value[format] || []).map((key) => {
|
||||
const nextPriority = updatedPriorityById.get(key.id)
|
||||
@@ -1278,6 +1433,14 @@ function handleKeyDrop(format: string, dropKeyId: string) {
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// 号池聚合项的优先级写回 provider 的 pool_advanced.global_priority
|
||||
for (const item of items) {
|
||||
if (item.is_pool_aggregate && item.provider_id) {
|
||||
updatePoolGlobalPriority(item.provider_id, item.priority)
|
||||
}
|
||||
}
|
||||
|
||||
draggedKey.value[format] = null
|
||||
dragOverKey.value[format] = null
|
||||
}
|
||||
@@ -1291,28 +1454,43 @@ async function save() {
|
||||
|
||||
// 第一步:先保存所有 Provider 和 Key 的优先级数据
|
||||
// 确保优先级数据全部到位后,再切换调度模式,避免瞬态不一致
|
||||
const providerUpdates = sortedProviders.value.map((provider) =>
|
||||
updateProvider(provider.id, { provider_priority: provider.provider_priority })
|
||||
)
|
||||
const providerTasks: Array<() => Promise<unknown>> = []
|
||||
sortedProviders.value.forEach((provider) => {
|
||||
const payload: Parameters<typeof updateProvider>[1] = {}
|
||||
const currentProviderPriority = Math.max(0, Math.trunc(provider.provider_priority))
|
||||
const originalProviderPriority = originalProviderPriorityById.get(provider.id)
|
||||
if (originalProviderPriority == null || originalProviderPriority !== currentProviderPriority) {
|
||||
payload.provider_priority = currentProviderPriority
|
||||
}
|
||||
|
||||
const currentPoolPriority = normalizeOptionalPriority(provider.pool_advanced?.global_priority)
|
||||
const originalPoolPriority = originalPoolPriorityByProviderId.get(provider.id) ?? null
|
||||
if (currentPoolPriority !== originalPoolPriority) {
|
||||
payload.pool_advanced = provider.pool_advanced
|
||||
? {
|
||||
...provider.pool_advanced,
|
||||
global_priority: currentPoolPriority,
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length > 0) {
|
||||
providerTasks.push(() => updateProvider(provider.id, payload))
|
||||
}
|
||||
})
|
||||
|
||||
// 收集每个 Key 的按格式优先级(保留原有其他格式的配置)
|
||||
const keyPriorityByFormatMap = new Map<string, Record<string, number>>()
|
||||
for (const format of Object.keys(keysByFormat.value)) {
|
||||
const keys = keysByFormat.value[format].filter((key) => !isPoolManagedKey(key))
|
||||
keys.forEach((key) => {
|
||||
// 合并原有配置,避免丢失未显示格式的优先级
|
||||
const existing = keyPriorityByFormatMap.get(key.id)
|
||||
|| normalizePriorityMap(key.global_priority_by_format)
|
||||
existing[normalizeApiFormatKey(format)] = key.priority
|
||||
keyPriorityByFormatMap.set(key.id, existing)
|
||||
})
|
||||
}
|
||||
const keyPriorityByFormatMap = buildEditableKeyPriorityMap()
|
||||
const keyTasks = Array.from(keyPriorityByFormatMap.entries())
|
||||
.filter(([keyId, priorityByFormat]) => !arePriorityMapsEqual(
|
||||
originalKeyPriorityById.get(keyId),
|
||||
priorityByFormat,
|
||||
))
|
||||
.map(([keyId, priorityByFormat]) => () =>
|
||||
updateProviderKey(keyId, { global_priority_by_format: priorityByFormat })
|
||||
)
|
||||
|
||||
const keyUpdates = Array.from(keyPriorityByFormatMap.entries()).map(([keyId, priorityByFormat]) =>
|
||||
updateProviderKey(keyId, { global_priority_by_format: priorityByFormat })
|
||||
)
|
||||
|
||||
await Promise.all([...providerUpdates, ...keyUpdates])
|
||||
await runTasksWithConcurrency([...providerTasks, ...keyTasks])
|
||||
|
||||
// 第二步:优先级数据全部就绪后,顺序保存调度配置
|
||||
// 先保存优先级模式,再保存调度模式,确保 Scheduler 状态完整切换
|
||||
@@ -1327,6 +1505,7 @@ async function save() {
|
||||
'调度模式:cache_affinity(缓存亲和模式) 或 load_balance(负载均衡模式) 或 fixed_order(固定顺序模式)'
|
||||
)
|
||||
|
||||
await loadAllProviders()
|
||||
await loadKeysByFormat()
|
||||
|
||||
success('优先级已保存')
|
||||
|
||||
@@ -293,7 +293,7 @@
|
||||
v-if="getOAuthOrgBadge(key)"
|
||||
variant="secondary"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
:title="getOAuthOrgBadge(key)?.id"
|
||||
:title="getOAuthOrgBadge(key)?.title"
|
||||
>
|
||||
{{ getOAuthOrgBadge(key)?.label }}
|
||||
</Badge>
|
||||
@@ -334,7 +334,7 @@
|
||||
<!-- OAuth 状态(失效/过期/倒计时)和刷新按钮 -->
|
||||
<template v-if="getKeyOAuthExpires(key)">
|
||||
<!-- 账号级别异常:醒目提示 + 清除按钮 -->
|
||||
<template v-if="getKeyOAuthExpires(key)?.isInvalid && isAccountLevelBlock(key)">
|
||||
<template v-if="isAccountLevelBlock(key)">
|
||||
<Badge
|
||||
variant="destructive"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0 gap-0.5"
|
||||
@@ -370,12 +370,21 @@
|
||||
>
|
||||
{{ getKeyOAuthExpires(key)?.text }}
|
||||
</span>
|
||||
<Badge
|
||||
v-if="key.oauth_temporary"
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 shrink-0"
|
||||
title="仅通过 Access Token 导入,无法自动刷新,到期后需要重新导入"
|
||||
>
|
||||
临时
|
||||
</Badge>
|
||||
<Button
|
||||
v-else
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="h-4 w-4 shrink-0"
|
||||
:disabled="refreshingOAuthKeyId === key.id"
|
||||
:title="getKeyOAuthExpires(key)?.isInvalid ? '重新授权' : '刷新 Token'"
|
||||
:title="getOAuthRefreshButtonTitle(key)"
|
||||
@click.stop="handleRefreshOAuth(key)"
|
||||
>
|
||||
<RefreshCw
|
||||
@@ -544,12 +553,12 @@
|
||||
v-if="refreshingQuota"
|
||||
class="w-3 h-3 text-muted-foreground/70 animate-spin"
|
||||
/>
|
||||
<span
|
||||
v-if="key.upstream_metadata.codex?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatCodexUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="key.upstream_metadata.codex?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatUpdatedAt(key.upstream_metadata.codex.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 限额并排显示:Team/Plus/Enterprise 账号 2列, Free 账号 1列 -->
|
||||
@@ -573,10 +582,21 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="key.upstream_metadata.codex.primary_reset_seconds"
|
||||
class="text-[9px] text-muted-foreground/70 mt-0.5"
|
||||
v-if="(key.upstream_metadata.codex.primary_reset_at || key.upstream_metadata.codex.primary_reset_seconds) && shouldStartCodexResetCountdown(key.upstream_metadata.codex.primary_used_percent)"
|
||||
class="text-[9px] mt-0.5 tabular-nums"
|
||||
:class="getResetCountdownClass(
|
||||
key.upstream_metadata.codex.primary_reset_at,
|
||||
key.upstream_metadata.codex.primary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.primary_used_percent
|
||||
)"
|
||||
>
|
||||
{{ formatResetTime(key.upstream_metadata.codex.primary_reset_seconds) }}后重置
|
||||
{{ getResetCountdownText(
|
||||
key.upstream_metadata.codex.primary_reset_at,
|
||||
key.upstream_metadata.codex.primary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.primary_used_percent
|
||||
) }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- 5H限额(仅 Team/Plus/Enterprise 显示) -->
|
||||
@@ -594,9 +614,23 @@
|
||||
:style="{ width: `${Math.max(100 - key.upstream_metadata.codex.secondary_used_percent, 0)}%` }"
|
||||
/>
|
||||
</div>
|
||||
<div class="text-[9px] text-muted-foreground/70 mt-0.5">
|
||||
<template v-if="key.upstream_metadata.codex.secondary_reset_seconds">
|
||||
{{ formatResetTime(key.upstream_metadata.codex.secondary_reset_seconds) }}后重置
|
||||
<div
|
||||
v-if="shouldStartCodexResetCountdown(key.upstream_metadata.codex.secondary_used_percent)"
|
||||
class="text-[9px] mt-0.5 tabular-nums"
|
||||
:class="getResetCountdownClass(
|
||||
key.upstream_metadata.codex.secondary_reset_at,
|
||||
key.upstream_metadata.codex.secondary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.secondary_used_percent
|
||||
)"
|
||||
>
|
||||
<template v-if="key.upstream_metadata.codex.secondary_reset_at || key.upstream_metadata.codex.secondary_reset_seconds">
|
||||
{{ getResetCountdownText(
|
||||
key.upstream_metadata.codex.secondary_reset_at,
|
||||
key.upstream_metadata.codex.secondary_reset_seconds,
|
||||
key.upstream_metadata.codex.updated_at,
|
||||
key.upstream_metadata.codex.secondary_used_percent
|
||||
) }}
|
||||
</template>
|
||||
<template v-else>
|
||||
已重置
|
||||
@@ -649,7 +683,7 @@
|
||||
v-if="key.upstream_metadata.antigravity?.updated_at"
|
||||
class="text-[9px] text-muted-foreground/70"
|
||||
>
|
||||
{{ formatAntigravityUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
||||
{{ formatUpdatedAt(key.upstream_metadata.antigravity.updated_at) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -912,6 +946,7 @@
|
||||
ref="modelMappingTabRef"
|
||||
:key="`mapping-${provider.id}`"
|
||||
:provider="provider"
|
||||
:endpoints="endpoints"
|
||||
:provider-keys="providerKeys"
|
||||
:models="providerModels"
|
||||
:mapping-preview="providerMappingPreview"
|
||||
@@ -1063,7 +1098,7 @@ import { Popover, PopoverTrigger, PopoverContent } from '@/components/ui'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useConfirm } from '@/composables/useConfirm'
|
||||
import { useClipboard } from '@/composables/useClipboard'
|
||||
import { useCountdownTimer, formatCountdown, getOAuthExpiresCountdown } from '@/composables/useCountdownTimer'
|
||||
import { useCountdownTimer, formatCountdown, getCodexResetCountdown } from '@/composables/useCountdownTimer'
|
||||
import {
|
||||
getProvider,
|
||||
getProviderEndpoints,
|
||||
@@ -1110,8 +1145,15 @@ import {
|
||||
import type { UpstreamMetadata, AntigravityModelQuota } from '@/api/endpoints/types'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import { isOAuthAccountProviderType, isKeyManagedProviderType } from '../utils/providerTypeUtils'
|
||||
import { isAccountLevelBlockReason, cleanAccountBlockReason } from '@/utils/accountBlock'
|
||||
import { getOAuthOrgBadge } from '@/utils/oauthIdentity'
|
||||
import { getOAuthRefreshFeedback } from '@/utils/oauthRefreshFeedback'
|
||||
import {
|
||||
getAccountStatusDisplay,
|
||||
getAccountStatusTitle,
|
||||
getOAuthRefreshButtonTitle as resolveOAuthRefreshButtonTitle,
|
||||
getOAuthStatusDisplay,
|
||||
getOAuthStatusTitle as resolveOAuthStatusTitle,
|
||||
} from '@/utils/providerKeyStatus'
|
||||
|
||||
// 扩展端点类型,包含密钥列表
|
||||
interface ProviderEndpointWithKeys extends ProviderEndpoint {
|
||||
@@ -1132,7 +1174,7 @@ const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
}>()
|
||||
|
||||
const { error: showError, success: showSuccess } = useToast()
|
||||
const { error: showError, success: showSuccess, warning: showWarning } = useToast()
|
||||
const { confirm } = useConfirm()
|
||||
const { copyToClipboard } = useClipboard()
|
||||
const { tick: countdownTick, start: startCountdownTimer, stop: stopCountdownTimer } = useCountdownTimer()
|
||||
@@ -1617,7 +1659,7 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
refreshingOAuthKeyId.value = key.id
|
||||
try {
|
||||
const result = await refreshProviderOAuth(key.id)
|
||||
showSuccess('Token 刷新成功')
|
||||
let refreshedKey: EndpointAPIKey | null = null
|
||||
// 更新本地数据
|
||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||
if (keyInList) {
|
||||
@@ -1629,8 +1671,19 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
if (freshKeys) {
|
||||
providerKeys.value = freshKeys
|
||||
syncCurrentSelections(endpoints.value, freshKeys)
|
||||
refreshedKey = freshKeys.find(item => item.id === key.id) ?? null
|
||||
}
|
||||
}
|
||||
const feedback = getOAuthRefreshFeedback({
|
||||
accountStateRecheckAttempted: result.account_state_recheck_attempted,
|
||||
accountStateRecheckError: result.account_state_recheck_error,
|
||||
snapshot: refreshedKey,
|
||||
})
|
||||
if (feedback.tone === 'warning') {
|
||||
showWarning(feedback.message)
|
||||
} else {
|
||||
showSuccess(feedback.message)
|
||||
}
|
||||
// Antigravity:token 刷新后可能完成了账号激活,触发配额获取
|
||||
// (不 emit('refresh'),避免触发全局 provider 余额刷新)
|
||||
void autoRefreshQuotaInBackground()
|
||||
@@ -1643,7 +1696,9 @@ async function handleRefreshOAuth(key: EndpointAPIKey) {
|
||||
|
||||
// 判断是否为账号级别的封禁(刷新 token 无法修复)
|
||||
function isAccountLevelBlock(key: EndpointAPIKey): boolean {
|
||||
return isAccountLevelBlockReason(key.oauth_invalid_reason)
|
||||
const account = getAccountStatusDisplay(key)
|
||||
const oauth = getOAuthStatusDisplay(key, countdownTick.value)
|
||||
return account.blocked && !oauth?.isInvalid
|
||||
}
|
||||
|
||||
// 清除 OAuth 失效标记
|
||||
@@ -1652,7 +1707,7 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
|
||||
const confirmed = await confirm({
|
||||
title: '清除账号异常标记',
|
||||
message: `确认账号 "${key.name || key.id.slice(0, 8)}" 已手动完成验证?清除后该 Key 将恢复正常调度。`,
|
||||
message: `确认账号 "${key.name || key.id.slice(0, 8)}" 已手动完成验证?清除后系统会按当前手动开关和调度状态重新评估该 Key。`,
|
||||
confirmText: '确认清除',
|
||||
variant: 'default',
|
||||
})
|
||||
@@ -1661,13 +1716,33 @@ async function handleClearOAuthInvalid(key: EndpointAPIKey) {
|
||||
clearingOAuthInvalidKeyId.value = key.id
|
||||
try {
|
||||
await clearOAuthInvalid(key.id)
|
||||
showSuccess('已清除 OAuth 异常标记,Key 已自动启用')
|
||||
showSuccess('已清除 OAuth 异常标记')
|
||||
// 更新本地数据
|
||||
const keyInList = providerKeys.value.find(k => k.id === key.id)
|
||||
if (keyInList) {
|
||||
keyInList.oauth_invalid_at = null
|
||||
keyInList.oauth_invalid_reason = null
|
||||
keyInList.is_active = true
|
||||
if (keyInList.status_snapshot) {
|
||||
keyInList.status_snapshot = {
|
||||
...keyInList.status_snapshot,
|
||||
oauth: {
|
||||
...keyInList.status_snapshot.oauth,
|
||||
code: 'none',
|
||||
label: null,
|
||||
reason: null,
|
||||
invalid_at: null,
|
||||
requires_reauth: false,
|
||||
},
|
||||
account: {
|
||||
...keyInList.status_snapshot.account,
|
||||
code: 'ok',
|
||||
label: null,
|
||||
reason: null,
|
||||
blocked: false,
|
||||
recoverable: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
await loadEndpoints()
|
||||
} catch (err: unknown) {
|
||||
@@ -1799,8 +1874,11 @@ function shouldAutoRefreshCodexQuota(): boolean {
|
||||
|
||||
// 检查 OAuth Token 是否即将过期(Codex / Antigravity / Kiro)
|
||||
function isTokenExpiringSoon(key: EndpointAPIKey, now: number): boolean {
|
||||
return key.oauth_invalid_at == null
|
||||
&& typeof key.oauth_expires_at === 'number'
|
||||
const oauthCode = String(key.status_snapshot?.oauth?.code || '').trim().toLowerCase()
|
||||
if (oauthCode && oauthCode !== 'valid' && oauthCode !== 'expiring') {
|
||||
return false
|
||||
}
|
||||
return typeof key.oauth_expires_at === 'number'
|
||||
&& (key.oauth_expires_at - now) <= AUTO_TOKEN_REFRESH_SKEW_SECONDS
|
||||
}
|
||||
|
||||
@@ -2402,10 +2480,6 @@ function formatUpdatedAt(updatedAt: number): string {
|
||||
return `${days}天前更新`
|
||||
}
|
||||
|
||||
// 兼容旧函数名
|
||||
const formatCodexUpdatedAt = formatUpdatedAt
|
||||
const formatAntigravityUpdatedAt = formatUpdatedAt
|
||||
|
||||
function secondsUntilReset(resetTime: string): number | null {
|
||||
if (!resetTime) return null
|
||||
const ts = Date.parse(resetTime)
|
||||
@@ -2526,6 +2600,55 @@ function getAntigravityQuotaSummary(metadata: UpstreamMetadata | null | undefine
|
||||
return result
|
||||
}
|
||||
|
||||
function getResetCountdownText(
|
||||
resetAt: number | null | undefined,
|
||||
resetSecs: number | null | undefined,
|
||||
updatedAt: number | null | undefined,
|
||||
usedPercent: number | null | undefined
|
||||
): string {
|
||||
const status = getCodexResetCountdown(
|
||||
resetAt,
|
||||
resetSecs,
|
||||
updatedAt,
|
||||
countdownTick.value,
|
||||
toCodexRemainingPercent(usedPercent)
|
||||
)
|
||||
if (!status) return ''
|
||||
return status.isExpired ? status.text : `${status.text} 后重置`
|
||||
}
|
||||
|
||||
function getResetCountdownClass(
|
||||
resetAt: number | null | undefined,
|
||||
resetSecs: number | null | undefined,
|
||||
updatedAt: number | null | undefined,
|
||||
usedPercent: number | null | undefined
|
||||
): string {
|
||||
const status = getCodexResetCountdown(
|
||||
resetAt,
|
||||
resetSecs,
|
||||
updatedAt,
|
||||
countdownTick.value,
|
||||
toCodexRemainingPercent(usedPercent)
|
||||
)
|
||||
if (!status || status.isExpired) return 'text-muted-foreground/70'
|
||||
if (status.isCritical) return 'text-destructive font-medium animate-pulse'
|
||||
if (status.isUrgent) return 'text-amber-500 dark:text-amber-400'
|
||||
return 'text-muted-foreground/70'
|
||||
}
|
||||
|
||||
function toCodexRemainingPercent(usedPercent: number | null | undefined): number | null {
|
||||
const normalizedUsed = Number(usedPercent)
|
||||
if (!Number.isFinite(normalizedUsed)) return null
|
||||
const clampedUsed = Math.min(Math.max(normalizedUsed, 0), 100)
|
||||
return Math.max(100 - clampedUsed, 0)
|
||||
}
|
||||
|
||||
function shouldStartCodexResetCountdown(usedPercent: number | null | undefined): boolean {
|
||||
const remainingPercent = toCodexRemainingPercent(usedPercent)
|
||||
if (remainingPercent == null) return true
|
||||
return remainingPercent < 100
|
||||
}
|
||||
|
||||
// 格式化重置时间
|
||||
function formatResetTime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
@@ -2559,31 +2682,20 @@ function getOAuthPlanTypeClass(planType: string): string {
|
||||
|
||||
// OAuth 状态信息(包括失效和过期)
|
||||
function getKeyOAuthExpires(key: EndpointAPIKey) {
|
||||
if (key.auth_type !== 'oauth') return null
|
||||
// 即使没有 expires_at,也要检查 invalid_at
|
||||
if (!key.oauth_expires_at && !key.oauth_invalid_at) return null
|
||||
return getOAuthExpiresCountdown(
|
||||
key.oauth_expires_at,
|
||||
countdownTick.value,
|
||||
key.oauth_invalid_at,
|
||||
key.oauth_invalid_reason
|
||||
)
|
||||
return getOAuthStatusDisplay(key, countdownTick.value)
|
||||
}
|
||||
|
||||
function getOAuthRefreshButtonTitle(key: EndpointAPIKey): string {
|
||||
return resolveOAuthRefreshButtonTitle(key, countdownTick.value)
|
||||
}
|
||||
|
||||
// OAuth 状态的 title 提示
|
||||
function getOAuthStatusTitle(key: EndpointAPIKey): string {
|
||||
const status = getKeyOAuthExpires(key)
|
||||
if (!status) return ''
|
||||
if (status.isInvalid) {
|
||||
const cleaned = status.invalidReason && isAccountLevelBlockReason(status.invalidReason)
|
||||
? cleanAccountBlockReason(status.invalidReason)
|
||||
: status.invalidReason
|
||||
return cleaned ? `Token 已失效: ${cleaned}` : 'Token 已失效'
|
||||
const accountTitle = getAccountStatusTitle(key)
|
||||
if (accountTitle && isAccountLevelBlock(key)) {
|
||||
return accountTitle
|
||||
}
|
||||
if (status.isExpired) {
|
||||
return 'Token 已过期,请重新授权'
|
||||
}
|
||||
return `Token 剩余有效期: ${status.text}`
|
||||
return resolveOAuthStatusTitle(key, countdownTick.value)
|
||||
}
|
||||
|
||||
// 健康度颜色
|
||||
|
||||
@@ -45,8 +45,11 @@
|
||||
<SelectItem value="vertex_ai">
|
||||
Vertex AI
|
||||
</SelectItem>
|
||||
<SelectItem value="claude_code">
|
||||
ClaudeCode
|
||||
<SelectItem
|
||||
value="claude_code"
|
||||
disabled
|
||||
>
|
||||
ClaudeCode(暂不可用)
|
||||
</SelectItem>
|
||||
<SelectItem value="codex">
|
||||
Codex
|
||||
@@ -432,6 +435,11 @@ watch(() => form.value.provider_type, () => {
|
||||
|
||||
// 提交表单
|
||||
const handleSubmit = async () => {
|
||||
if (!isEditMode.value && form.value.provider_type === 'claude_code') {
|
||||
showError('ClaudeCode 提供商类型暂时禁用', '验证失败')
|
||||
return
|
||||
}
|
||||
|
||||
// 月卡类型必须设置周期开始时间
|
||||
if (form.value.billing_type === 'monthly_quota' && !form.value.quota_last_reset_at) {
|
||||
showError('月卡类型必须设置周期开始时间', '验证失败')
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { BodyRuleCondition, BodyRuleConditionOp } from '@/api/endpoints'
|
||||
|
||||
export type ConditionSource = 'current' | 'original'
|
||||
export type ConditionGroupMode = 'all' | 'any'
|
||||
|
||||
export interface EditableConditionLeaf {
|
||||
kind: 'leaf'
|
||||
path: string
|
||||
op: BodyRuleConditionOp
|
||||
value: string
|
||||
source: ConditionSource
|
||||
}
|
||||
|
||||
export interface EditableConditionGroup {
|
||||
kind: 'group'
|
||||
mode: ConditionGroupMode
|
||||
children: EditableConditionNode[]
|
||||
}
|
||||
|
||||
export type EditableConditionNode = EditableConditionLeaf | EditableConditionGroup
|
||||
|
||||
export const CONDITION_OP_OPTIONS: Array<{ value: BodyRuleConditionOp; label: string }> = [
|
||||
{ value: 'eq', label: '等于' },
|
||||
{ value: 'neq', label: '不等于' },
|
||||
{ value: 'gt', label: '大于' },
|
||||
{ value: 'lt', label: '小于' },
|
||||
{ value: 'gte', label: '大于等于' },
|
||||
{ value: 'lte', label: '小于等于' },
|
||||
{ value: 'starts_with', label: '开头匹配' },
|
||||
{ value: 'ends_with', label: '结尾匹配' },
|
||||
{ value: 'contains', label: '包含' },
|
||||
{ value: 'matches', label: '正则匹配' },
|
||||
{ value: 'exists', label: '存在' },
|
||||
{ value: 'not_exists', label: '不存在' },
|
||||
{ value: 'in', label: '在列表中' },
|
||||
{ value: 'type_is', label: '类型是' },
|
||||
]
|
||||
|
||||
const NUMERIC_OPS = new Set(['gt', 'lt', 'gte', 'lte'])
|
||||
const STRING_OPS = new Set(['starts_with', 'ends_with'])
|
||||
const TYPE_IS_VALUES = new Set(['string', 'number', 'boolean', 'array', 'object', 'null'])
|
||||
|
||||
export function createEmptyConditionLeaf(): EditableConditionLeaf {
|
||||
return {
|
||||
kind: 'leaf',
|
||||
path: '',
|
||||
op: 'eq',
|
||||
value: '',
|
||||
source: 'current',
|
||||
}
|
||||
}
|
||||
|
||||
export function createConditionGroup(
|
||||
mode: ConditionGroupMode = 'all',
|
||||
children: EditableConditionNode[] = [createEmptyConditionLeaf()],
|
||||
): EditableConditionGroup {
|
||||
return {
|
||||
kind: 'group',
|
||||
mode,
|
||||
children,
|
||||
}
|
||||
}
|
||||
|
||||
export function cloneEditableCondition(node: EditableConditionNode): EditableConditionNode {
|
||||
if (node.kind === 'group') {
|
||||
return {
|
||||
kind: 'group',
|
||||
mode: node.mode,
|
||||
children: node.children.map(cloneEditableCondition),
|
||||
}
|
||||
}
|
||||
return { ...node }
|
||||
}
|
||||
|
||||
export function conditionToEditable(condition?: BodyRuleCondition | null): EditableConditionNode | null {
|
||||
if (!condition) return null
|
||||
if ('all' in condition) {
|
||||
return createConditionGroup(
|
||||
'all',
|
||||
condition.all.map(child => conditionToEditable(child) || createEmptyConditionLeaf()),
|
||||
)
|
||||
}
|
||||
if ('any' in condition) {
|
||||
return createConditionGroup(
|
||||
'any',
|
||||
condition.any.map(child => conditionToEditable(child) || createEmptyConditionLeaf()),
|
||||
)
|
||||
}
|
||||
return {
|
||||
kind: 'leaf',
|
||||
path: condition.path || '',
|
||||
op: condition.op || 'eq',
|
||||
value: condition.value !== undefined
|
||||
? (typeof condition.value === 'string' ? condition.value : JSON.stringify(condition.value))
|
||||
: '',
|
||||
source: condition.source === 'original' ? 'original' : 'current',
|
||||
}
|
||||
}
|
||||
|
||||
export function editableConditionToApi(node: EditableConditionNode | null): BodyRuleCondition | undefined {
|
||||
if (!node) return undefined
|
||||
|
||||
if (node.kind === 'group') {
|
||||
const children = node.children
|
||||
.map(child => editableConditionToApi(child))
|
||||
.filter((child): child is BodyRuleCondition => !!child)
|
||||
if (!children.length) return undefined
|
||||
return node.mode === 'all' ? { all: children } : { any: children }
|
||||
}
|
||||
|
||||
const path = node.path.trim()
|
||||
if (!path) return undefined
|
||||
|
||||
const base = {
|
||||
path,
|
||||
op: node.op,
|
||||
...(node.source === 'original' ? { source: 'original' as const } : {}),
|
||||
}
|
||||
|
||||
if (node.op === 'exists' || node.op === 'not_exists') {
|
||||
return base
|
||||
}
|
||||
|
||||
const raw = node.value.trim()
|
||||
if (!raw) {
|
||||
return { ...base, value: '' }
|
||||
}
|
||||
|
||||
try {
|
||||
return { ...base, value: JSON.parse(raw) }
|
||||
} catch {
|
||||
return { ...base, value: raw }
|
||||
}
|
||||
}
|
||||
|
||||
export function isConditionValueRequired(op: BodyRuleConditionOp): boolean {
|
||||
return op !== 'exists' && op !== 'not_exists'
|
||||
}
|
||||
|
||||
export function getConditionValuePlaceholder(op: BodyRuleConditionOp): string {
|
||||
if (op === 'in') return '["a", "b"]'
|
||||
if (op === 'type_is') return 'string/number/boolean/...'
|
||||
return '值'
|
||||
}
|
||||
|
||||
export function getBodyRuleConditionPathPlaceholder(path: string): string {
|
||||
return path.includes('[*]') || /\[\d+-\d+\]/.test(path) ? '$item.字段名' : '字段路径'
|
||||
}
|
||||
|
||||
export function conditionEquals(
|
||||
left: EditableConditionNode | null,
|
||||
right: EditableConditionNode | null,
|
||||
): boolean {
|
||||
if (left === right) return true
|
||||
if (!left || !right) return false
|
||||
if (left.kind !== right.kind) return false
|
||||
|
||||
if (left.kind === 'group' && right.kind === 'group') {
|
||||
if (left.mode !== right.mode) return false
|
||||
if (left.children.length !== right.children.length) return false
|
||||
return left.children.every((child, i) => conditionEquals(child, right.children[i]))
|
||||
}
|
||||
|
||||
if (left.kind === 'leaf' && right.kind === 'leaf') {
|
||||
return left.path === right.path
|
||||
&& left.op === right.op
|
||||
&& left.value === right.value
|
||||
&& left.source === right.source
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function validateEditableCondition(node: EditableConditionNode | null): string | null {
|
||||
if (!node) return null
|
||||
|
||||
if (node.kind === 'group') {
|
||||
if (!node.children.length) return '组合条件至少需要一个子条件'
|
||||
for (let i = 0; i < node.children.length; i += 1) {
|
||||
const err = validateEditableCondition(node.children[i])
|
||||
if (err) return `子条件 ${i + 1}: ${err}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const path = node.path.trim()
|
||||
if (!path) return '条件路径不能为空'
|
||||
|
||||
if (!isConditionValueRequired(node.op)) return null
|
||||
|
||||
const raw = node.value.trim()
|
||||
let parsed: unknown = raw
|
||||
if (raw) {
|
||||
try {
|
||||
parsed = JSON.parse(raw)
|
||||
} catch {
|
||||
parsed = raw
|
||||
}
|
||||
}
|
||||
|
||||
if (NUMERIC_OPS.has(node.op)) {
|
||||
if (typeof parsed !== 'number' || Number.isNaN(parsed)) return '数值条件必须填写数字'
|
||||
return null
|
||||
}
|
||||
|
||||
if (STRING_OPS.has(node.op)) {
|
||||
if (typeof parsed !== 'string') return '该条件值必须为字符串'
|
||||
return null
|
||||
}
|
||||
|
||||
if (node.op === 'matches') {
|
||||
if (typeof parsed !== 'string' || !parsed) return '正则条件值不能为空'
|
||||
try {
|
||||
new RegExp(parsed)
|
||||
return null
|
||||
} catch (error: unknown) {
|
||||
return `正则表达式无效:${error instanceof Error ? error.message : String(error)}`
|
||||
}
|
||||
}
|
||||
|
||||
if (node.op === 'in') {
|
||||
if (!Array.isArray(parsed)) return 'in 条件值必须是 JSON 数组'
|
||||
return null
|
||||
}
|
||||
|
||||
if (node.op === 'type_is') {
|
||||
if (typeof parsed !== 'string' || !TYPE_IS_VALUES.has(parsed)) {
|
||||
return 'type_is 仅支持 string/number/boolean/array/object/null'
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -165,7 +165,6 @@
|
||||
<ModelMappingDialog
|
||||
v-model:open="dialogOpen"
|
||||
:provider-id="provider.id"
|
||||
:provider-api-formats="providerApiFormats"
|
||||
:models="models"
|
||||
:editing-group="editingGroup"
|
||||
:preselected-model-id="preselectedModelId"
|
||||
@@ -195,7 +194,6 @@ import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
getProviderModels,
|
||||
testModel,
|
||||
API_FORMAT_LABELS,
|
||||
type Model,
|
||||
type ProviderModelAlias
|
||||
} from '@/api/endpoints'
|
||||
@@ -227,16 +225,6 @@ const preselectedModelId = ref<string | null>(null)
|
||||
// 列表展开状态
|
||||
const expandedAliasGroups = ref<Set<string>>(new Set())
|
||||
|
||||
// 获取 Provider 支持的 API 格式
|
||||
const providerApiFormats = computed(() => {
|
||||
const formats = props.provider?.api_formats
|
||||
if (Array.isArray(formats) && formats.length > 0) {
|
||||
const order = Object.keys(API_FORMAT_LABELS)
|
||||
return [...formats].sort((a, b) => order.indexOf(a) - order.indexOf(b))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 生成作用域唯一键
|
||||
function getApiFormatsKey(formats: string[] | undefined): string {
|
||||
if (!formats || formats.length === 0) return ''
|
||||
@@ -391,7 +379,6 @@ async function testMapping(group: AliasGroup, mapping: ProviderModelAlias) {
|
||||
const result = await testModel({
|
||||
provider_id: props.provider.id,
|
||||
model_name: mapping.name, // 使用映射名称进行测试
|
||||
message: "hello",
|
||||
api_format: apiFormat
|
||||
})
|
||||
|
||||
|
||||
@@ -314,10 +314,24 @@
|
||||
:result="modelTest.testResult.value"
|
||||
mode="direct"
|
||||
:selecting-model-name="testingModelName"
|
||||
:endpoints="activeEndpoints"
|
||||
:selected-endpoint="selectedTestEndpoint"
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
:request-headers-draft="testRequestHeadersDraft"
|
||||
:request-headers-reset-value="testRequestHeadersResetValue"
|
||||
:request-headers-error="testRequestHeadersError"
|
||||
:request-body-draft="testRequestBodyDraft"
|
||||
:request-body-reset-value="testRequestBodyResetValue"
|
||||
:request-body-error="testRequestBodyError"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@start="handleStartMappingTest"
|
||||
@update:request-headers-draft="testRequestHeadersDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -335,6 +349,7 @@ import ModelTestDialog from './ModelTestDialog.vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import {
|
||||
type Model,
|
||||
type ProviderEndpoint,
|
||||
type ProviderModelAlias,
|
||||
type ProviderMappingPreviewResponse,
|
||||
} from '@/api/endpoints'
|
||||
@@ -342,6 +357,14 @@ import { type EndpointAPIKey } from '@/api/endpoints/keys'
|
||||
import { updateModel } from '@/api/endpoints/models'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import {
|
||||
buildDefaultModelTestRequestHeaders,
|
||||
buildDefaultModelTestRequestBody,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
POOL_TEST_CONCURRENCY,
|
||||
SINGLE_TEST_CONCURRENCY,
|
||||
} from './model-test-request'
|
||||
|
||||
interface MappingItem {
|
||||
name: string
|
||||
@@ -369,6 +392,7 @@ interface CombinedMapping {
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
endpoints?: ProviderEndpoint[]
|
||||
providerKeys?: EndpointAPIKey[]
|
||||
models?: Model[]
|
||||
mappingPreview?: ProviderMappingPreviewResponse | null
|
||||
@@ -390,8 +414,20 @@ const deleteConfirmOpen = ref(false)
|
||||
const editingGroup = ref<AliasGroup | null>(null)
|
||||
const deletingGroup = ref<AliasGroup | null>(null)
|
||||
const testingMapping = ref<string | null>(null)
|
||||
const pendingMappingKey = ref<string | null>(null)
|
||||
const testingModelName = ref<string | null>(null)
|
||||
const preselectedModelId = ref<string | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const testRequestHeadersDraft = ref('')
|
||||
const testRequestHeadersResetValue = ref('')
|
||||
const testRequestBodyDraft = ref('')
|
||||
const testRequestBodyResetValue = ref('')
|
||||
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
|
||||
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
|
||||
// 使用 props 传入的数据
|
||||
const models = computed(() => props.models ?? [])
|
||||
@@ -626,22 +662,81 @@ async function onDialogSaved() {
|
||||
|
||||
function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingMappingKey.value = null
|
||||
testingModelName.value = null
|
||||
testingMapping.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testRequestHeadersDraft.value = ''
|
||||
testRequestHeadersResetValue.value = ''
|
||||
testRequestBodyDraft.value = ''
|
||||
testRequestBodyResetValue.value = ''
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (modelTest.testing.value) return
|
||||
modelTest.testResult.value = null
|
||||
modelTest.stopPolling()
|
||||
}
|
||||
|
||||
function handleSelectTestEndpoint(endpointId: string) {
|
||||
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
selectedTestEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
// 测试映射(直连测试,带故障转移和实时进度)
|
||||
async function runMappingTest(testingKey: string, modelName: string) {
|
||||
testingMapping.value = testingKey
|
||||
function runMappingTest(testingKey: string, modelName: string) {
|
||||
if (activeEndpoints.value.length === 0) {
|
||||
showError('暂无可用于测试的活跃端点')
|
||||
return
|
||||
}
|
||||
pendingMappingKey.value = testingKey
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
testingMapping.value = null
|
||||
testingModelName.value = modelName
|
||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(modelName)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
}
|
||||
|
||||
async function handleStartMappingTest() {
|
||||
if (modelTest.testing.value || !testingModelName.value) return
|
||||
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
|
||||
if (!endpoint) {
|
||||
showError('请选择要测试的端点')
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestHeaders, error: requestHeadersError } = parsedTestRequestHeaders.value
|
||||
if (!requestHeaders || requestHeadersError) {
|
||||
showError(`测试请求头无效: ${requestHeadersError || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestBody, error } = parsedTestRequestBody.value
|
||||
if (!requestBody || error) {
|
||||
showError(`测试请求体无效: ${error || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const currentMappingKey = pendingMappingKey.value || testingModelName.value
|
||||
testingMapping.value = pendingMappingKey.value ? currentMappingKey : null
|
||||
await modelTest.startTest({
|
||||
mode: 'direct',
|
||||
modelName,
|
||||
displayLabel: `映射 "${modelName}"`,
|
||||
message: 'hello',
|
||||
onSuccess: () => {
|
||||
testingModelName.value = null
|
||||
},
|
||||
modelName: testingModelName.value,
|
||||
displayLabel: `[${endpoint.api_format}] 映射 "${testingModelName.value}"`,
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
||||
})
|
||||
if (pendingMappingKey.value === currentMappingKey) {
|
||||
pendingMappingKey.value = null
|
||||
}
|
||||
testingMapping.value = null
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -221,10 +221,19 @@
|
||||
:testing="modelTest.testing.value"
|
||||
:trace="modelTest.testTrace.value"
|
||||
:request-id="modelTest.requestId.value"
|
||||
:show-endpoint-selector="activeEndpoints.length > 1"
|
||||
:request-headers-draft="testRequestHeadersDraft"
|
||||
:request-headers-reset-value="testRequestHeadersResetValue"
|
||||
:request-headers-error="testRequestHeadersError"
|
||||
:request-body-draft="testRequestBodyDraft"
|
||||
:request-body-reset-value="testRequestBodyResetValue"
|
||||
:request-body-error="testRequestBodyError"
|
||||
:start-disabled="!selectedTestEndpoint || !!testRequestHeadersError || !!testRequestBodyError"
|
||||
@close="handleTestDialogClose"
|
||||
@back="handleTestDialogBack"
|
||||
@start="handleStartPendingTest"
|
||||
@select-endpoint="handleSelectTestEndpoint"
|
||||
@update:request-headers-draft="testRequestHeadersDraft = $event"
|
||||
@update:request-body-draft="testRequestBodyDraft = $event"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -247,6 +256,14 @@ import { parseApiError } from '@/utils/errorParser'
|
||||
import { formatApiFormat } from '@/api/endpoints/types/api-format'
|
||||
import type { ProviderWithEndpointsSummary } from '@/api/endpoints'
|
||||
import ModelTestDialog from './ModelTestDialog.vue'
|
||||
import {
|
||||
buildDefaultModelTestRequestHeaders,
|
||||
buildDefaultModelTestRequestBody,
|
||||
parseModelTestRequestHeadersDraft,
|
||||
parseModelTestRequestBodyDraft,
|
||||
POOL_TEST_CONCURRENCY,
|
||||
SINGLE_TEST_CONCURRENCY,
|
||||
} from './model-test-request'
|
||||
|
||||
const props = defineProps<{
|
||||
provider: ProviderWithEndpointsSummary
|
||||
@@ -272,7 +289,16 @@ const localModels = ref<Model[]>([])
|
||||
const togglingModelId = ref<string | null>(null)
|
||||
const pendingTestModel = ref<Model | null>(null)
|
||||
const selectedTestEndpoint = ref<ProviderEndpoint | null>(null)
|
||||
const testRequestHeadersDraft = ref('')
|
||||
const testRequestHeadersResetValue = ref('')
|
||||
const testRequestBodyDraft = ref('')
|
||||
const testRequestBodyResetValue = ref('')
|
||||
const isPoolManagedProvider = computed(() => Boolean(props.provider.pool_advanced))
|
||||
const activeEndpoints = computed(() => (props.endpoints ?? []).filter(endpoint => endpoint.is_active))
|
||||
const parsedTestRequestHeaders = computed(() => parseModelTestRequestHeadersDraft(testRequestHeadersDraft.value))
|
||||
const testRequestHeadersError = computed(() => parsedTestRequestHeaders.value.error)
|
||||
const parsedTestRequestBody = computed(() => parseModelTestRequestBodyDraft(testRequestBodyDraft.value))
|
||||
const testRequestBodyError = computed(() => parsedTestRequestBody.value.error)
|
||||
const models = computed(() => props.models ?? localModels.value)
|
||||
// 按名称排序的模型列表
|
||||
const sortedModels = computed(() => {
|
||||
@@ -438,19 +464,47 @@ function handleTestDialogClose() {
|
||||
modelTest.resetState()
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
testRequestHeadersDraft.value = ''
|
||||
testRequestHeadersResetValue.value = ''
|
||||
testRequestBodyDraft.value = ''
|
||||
testRequestBodyResetValue.value = ''
|
||||
}
|
||||
|
||||
function handleTestDialogBack() {
|
||||
if (modelTest.testing.value) return
|
||||
modelTest.testResult.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
modelTest.stopPolling()
|
||||
}
|
||||
|
||||
async function handleSelectTestEndpoint(endpointId: string) {
|
||||
if (!pendingTestModel.value) return
|
||||
function handleSelectTestEndpoint(endpointId: string) {
|
||||
const endpoint = activeEndpoints.value.find(item => item.id === endpointId)
|
||||
if (!endpoint) return
|
||||
selectedTestEndpoint.value = endpoint
|
||||
}
|
||||
|
||||
async function handleStartPendingTest() {
|
||||
if (modelTest.testing.value) return
|
||||
if (!pendingTestModel.value) return
|
||||
|
||||
const endpoint = selectedTestEndpoint.value || activeEndpoints.value[0]
|
||||
if (!endpoint) {
|
||||
showError('请选择要测试的端点')
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestHeaders, error: requestHeadersError } = parsedTestRequestHeaders.value
|
||||
if (!requestHeaders || requestHeadersError) {
|
||||
showError(`测试请求头无效: ${requestHeadersError || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
const { value: requestBody, error } = parsedTestRequestBody.value
|
||||
if (!requestBody || error) {
|
||||
showError(`测试请求体无效: ${error || '无效 JSON'}`)
|
||||
return
|
||||
}
|
||||
|
||||
selectedTestEndpoint.value = endpoint
|
||||
const model = pendingTestModel.value
|
||||
const modelName = model.global_model_name || model.provider_model_name
|
||||
const endpointPrefix = `[${formatApiFormat(endpoint.api_format)}] `
|
||||
@@ -460,15 +514,11 @@ async function handleSelectTestEndpoint(endpointId: string) {
|
||||
displayLabel: `${endpointPrefix}${modelName}`,
|
||||
apiFormat: endpoint.api_format,
|
||||
endpointId: endpoint.id,
|
||||
message: 'hello',
|
||||
concurrency: 5,
|
||||
onSuccess: () => {
|
||||
pendingTestModel.value = null
|
||||
selectedTestEndpoint.value = null
|
||||
},
|
||||
requestHeaders,
|
||||
requestBody,
|
||||
concurrency: isPoolManagedProvider.value ? POOL_TEST_CONCURRENCY : SINGLE_TEST_CONCURRENCY,
|
||||
onError: () => {
|
||||
if (activeEndpoints.value.length > 1) {
|
||||
selectedTestEndpoint.value = null
|
||||
return true
|
||||
}
|
||||
},
|
||||
@@ -484,13 +534,15 @@ async function testModelConnection(model: Model) {
|
||||
}
|
||||
|
||||
pendingTestModel.value = model
|
||||
selectedTestEndpoint.value = null
|
||||
selectedTestEndpoint.value = activeEndpoints.value[0] ?? null
|
||||
testRequestHeadersResetValue.value = buildDefaultModelTestRequestHeaders()
|
||||
testRequestHeadersDraft.value = testRequestHeadersResetValue.value
|
||||
testRequestBodyResetValue.value = buildDefaultModelTestRequestBody(
|
||||
model.global_model_name || model.provider_model_name,
|
||||
)
|
||||
testRequestBodyDraft.value = testRequestBodyResetValue.value
|
||||
modelTest.testResult.value = null
|
||||
modelTest.dialogOpen.value = true
|
||||
|
||||
if (activeEndpoints.value.length === 1) {
|
||||
await handleSelectTestEndpoint(activeEndpoints.value[0].id)
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露给父组件
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
const DEFAULT_MODEL_TEST_MESSAGE = 'Hello! This is a test message.'
|
||||
|
||||
/** Pool-managed provider runs concurrent checks; single-key provider does not. */
|
||||
export const POOL_TEST_CONCURRENCY = 5
|
||||
export const SINGLE_TEST_CONCURRENCY = 1
|
||||
|
||||
export function buildDefaultModelTestRequestBody(modelName: string): string {
|
||||
return JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [
|
||||
{
|
||||
role: 'user',
|
||||
content: DEFAULT_MODEL_TEST_MESSAGE,
|
||||
},
|
||||
],
|
||||
max_tokens: 30,
|
||||
temperature: 0.7,
|
||||
stream: true,
|
||||
}, null, 2)
|
||||
}
|
||||
|
||||
export function buildDefaultModelTestRequestHeaders(): string {
|
||||
return JSON.stringify({}, null, 2)
|
||||
}
|
||||
|
||||
function parseModelTestJsonObjectDraft(
|
||||
draft: string,
|
||||
options: {
|
||||
emptyValue: Record<string, unknown> | null
|
||||
emptyError: string | null
|
||||
invalidTypeError: string
|
||||
},
|
||||
): { value: Record<string, unknown> | null; error: string | null } {
|
||||
const normalized = draft.trim()
|
||||
if (!normalized) {
|
||||
return {
|
||||
value: options.emptyValue,
|
||||
error: options.emptyError,
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return {
|
||||
value: null,
|
||||
error: options.invalidTypeError,
|
||||
}
|
||||
}
|
||||
return {
|
||||
value: parsed as Record<string, unknown>,
|
||||
error: null,
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
value: null,
|
||||
error: error instanceof Error ? error.message : '无效的 JSON',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseModelTestRequestBodyDraft(
|
||||
draft: string,
|
||||
): { value: Record<string, unknown> | null; error: string | null } {
|
||||
return parseModelTestJsonObjectDraft(draft, {
|
||||
emptyValue: null,
|
||||
emptyError: '测试请求体不能为空',
|
||||
invalidTypeError: '测试请求体必须是 JSON 对象',
|
||||
})
|
||||
}
|
||||
|
||||
export function parseModelTestRequestHeadersDraft(
|
||||
draft: string,
|
||||
): { value: Record<string, unknown> | null; error: string | null } {
|
||||
return parseModelTestJsonObjectDraft(draft, {
|
||||
emptyValue: {},
|
||||
emptyError: null,
|
||||
invalidTypeError: '测试请求头必须是 JSON 对象',
|
||||
})
|
||||
}
|
||||
@@ -20,26 +20,31 @@
|
||||
</div>
|
||||
<div
|
||||
v-if="isLoading"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
class="flex items-center justify-center text-sm text-muted-foreground"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<Loader2 class="h-5 w-5 animate-spin mr-2" />
|
||||
加载中...
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasError"
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-destructive"
|
||||
class="flex items-center justify-center text-sm text-destructive"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
<AlertCircle class="h-4 w-4 mr-1.5" />
|
||||
加载失败
|
||||
数据暂不可用
|
||||
</div>
|
||||
<ActivityHeatmap
|
||||
v-else-if="hasData"
|
||||
:data="data"
|
||||
:show-header="false"
|
||||
:value-label="valueLabel"
|
||||
:actual-value-label="actualValueLabel"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="h-full min-h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
class="flex items-center justify-center text-sm text-muted-foreground"
|
||||
:style="{ minHeight: `${minHeight}px` }"
|
||||
>
|
||||
暂无活跃数据
|
||||
</div>
|
||||
@@ -58,6 +63,9 @@ const props = defineProps<{
|
||||
title: string
|
||||
isLoading?: boolean
|
||||
hasError?: boolean
|
||||
minHeight?: number
|
||||
valueLabel?: string
|
||||
actualValueLabel?: string
|
||||
}>()
|
||||
|
||||
const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
@@ -65,4 +73,6 @@ const legendLevels = [0.08, 0.25, 0.45, 0.65, 0.85]
|
||||
const hasData = computed(() =>
|
||||
props.data && props.data.days && props.data.days.length > 0
|
||||
)
|
||||
|
||||
const minHeight = computed(() => props.minHeight ?? 160)
|
||||
</script>
|
||||
|
||||
@@ -240,6 +240,10 @@
|
||||
-->{{ proxyTimingBreakdown(currentAttempt.extra_data.proxy) }}<!--
|
||||
-->)</span>
|
||||
</span>
|
||||
<code
|
||||
v-if="typeof currentAttempt.extra_data.proxy.node_id === 'string' && currentAttempt.extra_data.proxy.node_id"
|
||||
class="text-xs font-mono text-muted-foreground"
|
||||
>节点 Key {{ currentAttempt.extra_data.proxy.node_id }}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
@@ -462,7 +466,7 @@ interface UsageData {
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
requestId: string
|
||||
requestId?: string | null
|
||||
/** 外部传入的状态码,用于覆盖 trace.final_status 的判断 */
|
||||
overrideStatusCode?: number
|
||||
/** 请求侧 API 格式(客户端入口格式) */
|
||||
@@ -471,6 +475,12 @@ const props = defineProps<{
|
||||
usageData?: UsageData | null
|
||||
/** 请求元数据(用于号池调度组装) */
|
||||
requestMetadata?: Record<string, unknown> | null
|
||||
/** 已获取的追踪数据;传入时不再内部拉取 */
|
||||
traceData?: RequestTrace | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
selectAttempt: [attempt: CandidateRecord | null]
|
||||
}>()
|
||||
|
||||
// 用量数据(从 props 获取)
|
||||
@@ -525,7 +535,8 @@ const getFinalStatusBadgeVariant = (status: string): BadgeVariant => {
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const trace = ref<RequestTrace | null>(null)
|
||||
const internalTrace = ref<RequestTrace | null>(null)
|
||||
const trace = computed(() => props.traceData ?? internalTrace.value)
|
||||
const selectedGroupIndex = ref(0)
|
||||
const selectedAttemptIndex = ref(0)
|
||||
const hoveredGroupIndex = ref<number | null>(null)
|
||||
@@ -769,11 +780,11 @@ const poolAttemptCandidates = computed<CandidateRecord[]>(() => {
|
||||
}
|
||||
|
||||
merged.status = normalizeTimelineStatus(raw.status ?? merged.status)
|
||||
if (typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
if (typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
||||
if (!merged.provider_id && typeof raw.provider_id === 'string') merged.provider_id = raw.provider_id
|
||||
if (!merged.provider_name && typeof raw.provider_name === 'string') merged.provider_name = raw.provider_name
|
||||
if (!merged.endpoint_id && typeof raw.endpoint_id === 'string') merged.endpoint_id = raw.endpoint_id
|
||||
if (!merged.key_id && typeof raw.key_id === 'string') merged.key_id = raw.key_id
|
||||
if (!merged.key_name && typeof raw.key_name === 'string') merged.key_name = raw.key_name
|
||||
if (typeof raw.status_code === 'number') merged.status_code = raw.status_code
|
||||
if (typeof raw.error_type === 'string') merged.error_type = raw.error_type
|
||||
const rawPoolGroupId = typeof raw.pool_group_id === 'string' ? raw.pool_group_id.trim() : ''
|
||||
@@ -1045,6 +1056,10 @@ const currentAttempt = computed(() => {
|
||||
return selectedGroup.value.allAttempts[selectedAttemptIndex.value] || selectedGroup.value.primary
|
||||
})
|
||||
|
||||
watch(currentAttempt, (attempt) => {
|
||||
emit('selectAttempt', attempt ?? null)
|
||||
}, { immediate: true })
|
||||
|
||||
const currentGroupTitle = computed(() => {
|
||||
if (!selectedGroup.value || !currentAttempt.value) return ''
|
||||
if (selectedGroup.value.isPoolGroup) {
|
||||
@@ -1224,7 +1239,7 @@ const navigateGroup = (direction: number) => {
|
||||
// 加载请求追踪数据
|
||||
const isSilentRefresh = ref(false)
|
||||
const loadTrace = async (silent = false) => {
|
||||
if (!props.requestId) return
|
||||
if (!props.requestId || props.traceData) return
|
||||
|
||||
isSilentRefresh.value = silent
|
||||
|
||||
@@ -1234,7 +1249,7 @@ const loadTrace = async (silent = false) => {
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
trace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
internalTrace.value = await requestTraceApi.getRequestTrace(props.requestId)
|
||||
} catch (err: unknown) {
|
||||
if (!silent) {
|
||||
error.value = parseApiError(err, '加载失败')
|
||||
@@ -1304,12 +1319,31 @@ watch(groupedTimeline, (newGroups) => {
|
||||
selectedAttemptIndex.value = 0
|
||||
}, { immediate: true })
|
||||
|
||||
// 监听 requestId 变化
|
||||
watch(() => props.requestId, () => {
|
||||
selectedGroupIndex.value = 0
|
||||
selectedAttemptIndex.value = 0
|
||||
loadTrace()
|
||||
}, { immediate: true })
|
||||
// 监听 requestId / 外部 trace 变化
|
||||
watch(
|
||||
[() => props.requestId, () => props.traceData],
|
||||
() => {
|
||||
selectedGroupIndex.value = 0
|
||||
selectedAttemptIndex.value = 0
|
||||
|
||||
if (props.traceData) {
|
||||
internalTrace.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!props.requestId) {
|
||||
internalTrace.value = null
|
||||
loading.value = false
|
||||
error.value = null
|
||||
return
|
||||
}
|
||||
|
||||
void loadTrace()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
defineExpose({ refresh: () => loadTrace(true) })
|
||||
|
||||
|
||||
@@ -1,325 +0,0 @@
|
||||
<template>
|
||||
<Card class="p-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<p class="text-sm font-semibold">
|
||||
{{ title }}
|
||||
</p>
|
||||
<div
|
||||
v-if="displayLegendItems.length > 0"
|
||||
class="flex items-center gap-2 flex-wrap justify-end text-[11px]"
|
||||
>
|
||||
<div
|
||||
v-for="item in displayLegendItems"
|
||||
:key="item.id"
|
||||
class="flex items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="w-2.5 h-2.5 rounded-full"
|
||||
:style="{ backgroundColor: item.color }"
|
||||
/>
|
||||
<span class="text-muted-foreground">{{ item.name }}</span>
|
||||
</div>
|
||||
<span
|
||||
v-if="hiddenLegendCount > 0"
|
||||
class="text-muted-foreground"
|
||||
>
|
||||
+{{ hiddenLegendCount }} 更多
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="loading"
|
||||
class="h-[160px] flex items-center justify-center"
|
||||
>
|
||||
<div class="text-sm text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="hasData"
|
||||
class="h-[160px]"
|
||||
>
|
||||
<ScatterChart
|
||||
:data="chartData"
|
||||
:options="chartOptions"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="h-[160px] flex items-center justify-center text-sm text-muted-foreground"
|
||||
>
|
||||
暂无请求间隔数据
|
||||
</div>
|
||||
</Card>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
|
||||
import Card from '@/components/ui/card.vue'
|
||||
import ScatterChart from '@/components/charts/ScatterChart.vue'
|
||||
import { cacheAnalysisApi, type IntervalTimelineResponse } from '@/api/cache'
|
||||
import { meApi } from '@/api/me'
|
||||
import type { ChartData, ChartOptions } from 'chart.js'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
title: string
|
||||
isAdmin: boolean
|
||||
hours?: number
|
||||
}>(), {
|
||||
hours: 24 // 默认当天
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const timelineData = ref<IntervalTimelineResponse | null>(null)
|
||||
const primaryColor = ref('201, 100, 66') // 默认主题色
|
||||
let loadRequestId = 0
|
||||
|
||||
const ADMIN_TIMELINE_LIMIT = 1500
|
||||
const USER_TIMELINE_LIMIT = 1200
|
||||
|
||||
// 获取主题色
|
||||
function getPrimaryColor(): string {
|
||||
if (typeof window === 'undefined') return '201, 100, 66'
|
||||
// CSS 变量定义在 body 上,不是 documentElement
|
||||
const body = document.body
|
||||
const style = getComputedStyle(body)
|
||||
const rgb = style.getPropertyValue('--color-primary-rgb').trim()
|
||||
return rgb || '201, 100, 66'
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
primaryColor.value = getPrimaryColor()
|
||||
void loadData()
|
||||
})
|
||||
|
||||
// 预定义的颜色列表(用于区分不同用户/模型)
|
||||
const COLORS = [
|
||||
'rgba(59, 130, 246, 0.7)', // blue
|
||||
'rgba(236, 72, 153, 0.7)', // pink
|
||||
'rgba(34, 197, 94, 0.7)', // green
|
||||
'rgba(249, 115, 22, 0.7)', // orange
|
||||
'rgba(168, 85, 247, 0.7)', // purple
|
||||
'rgba(234, 179, 8, 0.7)', // yellow
|
||||
'rgba(14, 165, 233, 0.7)', // sky
|
||||
'rgba(239, 68, 68, 0.7)', // red
|
||||
'rgba(20, 184, 166, 0.7)', // teal
|
||||
'rgba(99, 102, 241, 0.7)', // indigo
|
||||
]
|
||||
|
||||
const hasData = computed(() =>
|
||||
timelineData.value && timelineData.value.points && timelineData.value.points.length > 0
|
||||
)
|
||||
|
||||
// 判断是否有多个分组(管理员按用户分组,普通用户按模型分组)
|
||||
const hasMultipleGroups = computed(() => {
|
||||
if (props.isAdmin) {
|
||||
// 管理员视图:按用户分组
|
||||
return timelineData.value?.users && Object.keys(timelineData.value.users).length > 1
|
||||
} else {
|
||||
// 用户视图:按模型分组
|
||||
return timelineData.value?.models && timelineData.value.models.length > 1
|
||||
}
|
||||
})
|
||||
|
||||
// 图例最多显示数量
|
||||
const MAX_LEGEND_ITEMS = 6
|
||||
|
||||
// 图例项(管理员显示用户,普通用户显示模型)
|
||||
const legendItems = computed(() => {
|
||||
if (props.isAdmin && timelineData.value?.users) {
|
||||
// 管理员视图:显示用户图例
|
||||
const users = Object.entries(timelineData.value.users)
|
||||
if (users.length <= 1) return []
|
||||
return users.map(([userId, username], index) => ({
|
||||
id: userId,
|
||||
name: username || userId.slice(0, 8),
|
||||
color: COLORS[index % COLORS.length]
|
||||
}))
|
||||
} else if (timelineData.value?.models && timelineData.value.models.length > 1) {
|
||||
// 用户视图:显示模型图例
|
||||
return timelineData.value.models.map((model, index) => ({
|
||||
id: model,
|
||||
name: formatModelName(model),
|
||||
color: COLORS[index % COLORS.length]
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
// 显示的图例项(限制数量)
|
||||
const displayLegendItems = computed(() => {
|
||||
return legendItems.value.slice(0, MAX_LEGEND_ITEMS)
|
||||
})
|
||||
|
||||
// 隐藏的图例数量
|
||||
const hiddenLegendCount = computed(() => {
|
||||
return Math.max(0, legendItems.value.length - MAX_LEGEND_ITEMS)
|
||||
})
|
||||
|
||||
// 格式化模型名称,使其更简洁
|
||||
function formatModelName(model: string): string {
|
||||
// 常见的简化规则
|
||||
if (model.includes('claude')) {
|
||||
// claude-3-5-sonnet-20241022 -> Claude 3.5 Sonnet
|
||||
const match = model.match(/claude-(\d+)-(\d+)?-?(\w+)?/i)
|
||||
if (match) {
|
||||
const major = match[1]
|
||||
const minor = match[2]
|
||||
const variant = match[3]
|
||||
let name = `Claude ${major}`
|
||||
if (minor) name += `.${minor}`
|
||||
if (variant) name += ` ${variant.charAt(0).toUpperCase() + variant.slice(1)}`
|
||||
return name
|
||||
}
|
||||
}
|
||||
// 其他模型保持原样但截断
|
||||
return model.length > 20 ? `${model.slice(0, 17) }...` : model
|
||||
}
|
||||
|
||||
// 构建图表数据
|
||||
const chartData = computed<ChartData<'scatter'>>(() => {
|
||||
if (!timelineData.value?.points) {
|
||||
return { datasets: [] }
|
||||
}
|
||||
|
||||
const points = timelineData.value.points
|
||||
|
||||
// 管理员视图且有多个用户:按用户分组
|
||||
if (props.isAdmin && timelineData.value.users && Object.keys(timelineData.value.users).length > 1) {
|
||||
const userIds = Object.keys(timelineData.value.users)
|
||||
const userColorMap: Record<string, string> = {}
|
||||
userIds.forEach((userId, index) => {
|
||||
userColorMap[userId] = COLORS[index % COLORS.length]
|
||||
})
|
||||
|
||||
// 按用户分组数据
|
||||
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
||||
for (const point of points) {
|
||||
const userId = point.user_id || 'unknown'
|
||||
if (!groupedData[userId]) {
|
||||
groupedData[userId] = []
|
||||
}
|
||||
groupedData[userId].push({ x: point.x, y: point.y })
|
||||
}
|
||||
|
||||
// 创建每个用户的 dataset
|
||||
const datasets = Object.entries(groupedData).map(([userId, data]) => ({
|
||||
label: timelineData.value?.users?.[userId] || userId.slice(0, 8),
|
||||
data,
|
||||
backgroundColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.6)',
|
||||
borderColor: userColorMap[userId] || 'rgba(59, 130, 246, 0.8)',
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
}))
|
||||
|
||||
return { datasets }
|
||||
}
|
||||
|
||||
// 用户视图且有多个模型:按模型分组
|
||||
if (!props.isAdmin && timelineData.value.models && timelineData.value.models.length > 1) {
|
||||
const models = timelineData.value.models
|
||||
const modelColorMap: Record<string, string> = {}
|
||||
models.forEach((model, index) => {
|
||||
modelColorMap[model] = COLORS[index % COLORS.length]
|
||||
})
|
||||
|
||||
// 按模型分组数据
|
||||
const groupedData: Record<string, Array<{ x: string; y: number }>> = {}
|
||||
for (const point of points) {
|
||||
const model = point.model || 'unknown'
|
||||
if (!groupedData[model]) {
|
||||
groupedData[model] = []
|
||||
}
|
||||
groupedData[model].push({ x: point.x, y: point.y })
|
||||
}
|
||||
|
||||
// 创建每个模型的 dataset
|
||||
const datasets = Object.entries(groupedData).map(([model, data]) => ({
|
||||
label: formatModelName(model),
|
||||
data,
|
||||
backgroundColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.6)',
|
||||
borderColor: modelColorMap[model] || 'rgba(59, 130, 246, 0.8)',
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
}))
|
||||
|
||||
return { datasets }
|
||||
}
|
||||
|
||||
// 单用户或单模型:使用主题色
|
||||
return {
|
||||
datasets: [{
|
||||
label: '请求间隔',
|
||||
data: points.map(p => ({ x: p.x, y: p.y })),
|
||||
backgroundColor: `rgba(${primaryColor.value}, 0.6)`,
|
||||
borderColor: `rgba(${primaryColor.value}, 0.8)`,
|
||||
pointRadius: 3,
|
||||
pointHoverRadius: 5,
|
||||
}]
|
||||
}
|
||||
})
|
||||
|
||||
const chartOptions = computed<ChartOptions<'scatter'>>(() => ({
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false // 使用自定义图例
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (context) => {
|
||||
const point = context.raw as { x: string; y: number; _originalY?: number }
|
||||
const realY = point._originalY ?? point.y
|
||||
const datasetLabel = context.dataset.label || ''
|
||||
if (hasMultipleGroups.value) {
|
||||
return `${datasetLabel}: ${realY.toFixed(1)} 分钟`
|
||||
}
|
||||
return `间隔: ${realY.toFixed(1)} 分钟`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
async function loadData() {
|
||||
const requestId = ++loadRequestId
|
||||
loading.value = true
|
||||
try {
|
||||
const limit = props.isAdmin ? ADMIN_TIMELINE_LIMIT : USER_TIMELINE_LIMIT
|
||||
if (props.isAdmin) {
|
||||
// 管理员:获取所有用户数据(按比例采样)
|
||||
const data = await cacheAnalysisApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
include_user_info: true,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
} else {
|
||||
// 普通用户:获取自己的数据
|
||||
const data = await meApi.getIntervalTimeline({
|
||||
hours: props.hours,
|
||||
limit,
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
timelineData.value = data
|
||||
}
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId) return
|
||||
log.error('加载请求间隔时间线失败:', error)
|
||||
timelineData.value = null
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch([() => props.hours, () => props.isAdmin], () => {
|
||||
void loadData()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
loadRequestId++
|
||||
})
|
||||
</script>
|
||||
@@ -239,6 +239,35 @@
|
||||
|
||||
<!-- 响应结果 -->
|
||||
<template v-if="replayResult">
|
||||
<div
|
||||
v-if="replayResult.mapping"
|
||||
class="border-b"
|
||||
>
|
||||
<div class="px-4 py-2 text-[11px] text-muted-foreground flex flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="font-mono truncate">{{ replayResult.mapping.source_model }}</span>
|
||||
<span class="text-muted-foreground/50">→</span>
|
||||
<span class="font-mono truncate">{{ replayResult.mapping.resolved_model }}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
class="text-[10px] px-1.5 py-0 h-4"
|
||||
>
|
||||
{{ formatReplayMode(replayResult.mapping.replay_mode) }}
|
||||
</Badge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-muted-foreground/60">
|
||||
<span>Provider: {{ replayResult.mapping.target_provider }}</span>
|
||||
<span>Endpoint: {{ replayResult.mapping.target_endpoint_id }}</span>
|
||||
<span>Format: {{ replayResult.mapping.target_api_format || '-' }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="replayResult.mapping.mapping_source && replayResult.mapping.mapping_source !== 'none'"
|
||||
class="text-muted-foreground/50"
|
||||
>
|
||||
{{ formatMappingSource(replayResult.mapping.mapping_source) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 响应头(可折叠) -->
|
||||
<div class="border-b">
|
||||
<button
|
||||
@@ -293,7 +322,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { dashboardApi, type ReplayResponse, type RequestDetail } from '@/api/dashboard'
|
||||
import { requestDetailsApi, type ReplayResponse, type RequestDetail } from '@/api/request-details'
|
||||
import { getProvidersSummary } from '@/api/endpoints/providers'
|
||||
import { getProviderKeys } from '@/api/endpoints/keys'
|
||||
import type { EndpointAPIKey } from '@/api/endpoints/types'
|
||||
@@ -376,6 +405,32 @@ const responseHeaderCount = computed(() => {
|
||||
return Object.keys(replayResult.value.response_headers).length
|
||||
})
|
||||
|
||||
function formatReplayMode(mode?: string) {
|
||||
switch (mode) {
|
||||
case 'same_endpoint_reuse':
|
||||
return '同端点复用'
|
||||
case 'same_provider_remap':
|
||||
return '同 Provider 重映射'
|
||||
case 'cross_provider_remap':
|
||||
return '跨 Provider 重映射'
|
||||
default:
|
||||
return mode || '-'
|
||||
}
|
||||
}
|
||||
|
||||
function formatMappingSource(source?: string) {
|
||||
switch (source) {
|
||||
case 'original_target_model':
|
||||
return '映射来源: 复用原目标模型'
|
||||
case 'model_mapping':
|
||||
return '映射来源: 模型映射'
|
||||
case 'none':
|
||||
return '映射来源: 未映射'
|
||||
default:
|
||||
return source ? `映射来源: ${source}` : ''
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 生命周期 ----
|
||||
|
||||
watch(() => props.isOpen, async (isOpen) => {
|
||||
@@ -427,7 +482,7 @@ async function doReplay() {
|
||||
if (selectedProviderId.value) params.provider_id = selectedProviderId.value
|
||||
if (selectedKeyId.value) params.api_key_id = selectedKeyId.value
|
||||
|
||||
replayResult.value = await dashboardApi.replayRequest(
|
||||
replayResult.value = await requestDetailsApi.replayRequest(
|
||||
props.requestId,
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user