feat: Phase 3.4 Developer API Management - API Keys, OpenAPI Clients, Call Stats, Slow Logs
This commit is contained in:
1 parent
1d32385f17
commit
e7d1c39c18
8 files changed
+1359
-4
No files matched your search
@@ -0,0 +1,658 @@
|
|||||||
|
"""
|
||||||
|
Developer API Management - Admin endpoints for API Keys, OpenAPI Clients,
|
||||||
|
API Call Statistics, and Slow Request Logs.
|
||||||
|
"""
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func, desc, asc
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ...database import get_db
|
||||||
|
from ...models import (
|
||||||
|
ApiKey, OpenApiClient, ApiCallStat, SlowRequestLog, User
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
CST_TZ = timezone(timedelta(hours=8))
|
||||||
|
|
||||||
|
|
||||||
|
def cst_now():
|
||||||
|
return datetime.now(CST_TZ)
|
||||||
|
|
||||||
|
|
||||||
|
def fmt_time(dt):
|
||||||
|
if dt is None:
|
||||||
|
return None
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=CST_TZ).isoformat()
|
||||||
|
return dt.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Pydantic Models
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class ApiKeyCreate(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
name: str = ""
|
||||||
|
description: str = ""
|
||||||
|
rate_limit: int = 60
|
||||||
|
expired_at: str = None
|
||||||
|
|
||||||
|
|
||||||
|
class ApiKeyUpdate(BaseModel):
|
||||||
|
name: str = None
|
||||||
|
description: str = None
|
||||||
|
rate_limit: int = None
|
||||||
|
status: str = None
|
||||||
|
expired_at: str = None
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIClientCreate(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
name: str
|
||||||
|
allowed_models: list = None
|
||||||
|
rate_limit: int = 60
|
||||||
|
|
||||||
|
|
||||||
|
class OpenAPIClientUpdate(BaseModel):
|
||||||
|
name: str = None
|
||||||
|
allowed_models: list = None
|
||||||
|
rate_limit: int = None
|
||||||
|
status: str = None
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Helper functions
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
def _generate_api_key():
|
||||||
|
"""Generate a unique API key string."""
|
||||||
|
return "sk-" + secrets.token_hex(24)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_secret():
|
||||||
|
"""Generate a secret string."""
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_client_id():
|
||||||
|
"""Generate a unique client ID."""
|
||||||
|
return "cli_" + secrets.token_hex(12)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_client_secret():
|
||||||
|
"""Generate a client secret."""
|
||||||
|
return secrets.token_hex(32)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_api_key(key, user=None):
|
||||||
|
return {
|
||||||
|
"id": key.id,
|
||||||
|
"user_id": key.user_id,
|
||||||
|
"username": user.username if user else None,
|
||||||
|
"api_key": key.api_key,
|
||||||
|
"name": key.name,
|
||||||
|
"description": getattr(key, "description", "") or "",
|
||||||
|
"rate_limit": getattr(key, "rate_limit", 60) or 60,
|
||||||
|
"total_calls": getattr(key, "total_calls", 0) or 0,
|
||||||
|
"status": key.status,
|
||||||
|
"last_used_at": fmt_time(key.last_used_at),
|
||||||
|
"created_at": fmt_time(key.created_at),
|
||||||
|
"expired_at": fmt_time(key.expired_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_client(client, user=None):
|
||||||
|
return {
|
||||||
|
"id": client.id,
|
||||||
|
"user_id": client.user_id,
|
||||||
|
"username": user.username if user else None,
|
||||||
|
"name": client.name,
|
||||||
|
"client_id": client.client_id,
|
||||||
|
"allowed_models": client.allowed_models or [],
|
||||||
|
"rate_limit": client.rate_limit,
|
||||||
|
"total_calls": getattr(client, "total_calls", 0) or 0,
|
||||||
|
"last_used_at": fmt_time(getattr(client, "last_used_at", None)),
|
||||||
|
"status": client.status,
|
||||||
|
"created_at": fmt_time(client.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_call_stat(stat):
|
||||||
|
avg_time = 0.0
|
||||||
|
if stat.call_count and stat.call_count > 0:
|
||||||
|
avg_time = round(stat.total_response_time / stat.call_count, 3)
|
||||||
|
error_rate = 0.0
|
||||||
|
if stat.call_count and stat.call_count > 0:
|
||||||
|
error_rate = round(stat.error_count / stat.call_count * 100, 2)
|
||||||
|
return {
|
||||||
|
"id": stat.id,
|
||||||
|
"endpoint": stat.endpoint,
|
||||||
|
"method": stat.method,
|
||||||
|
"call_count": stat.call_count,
|
||||||
|
"error_count": stat.error_count,
|
||||||
|
"error_rate": error_rate,
|
||||||
|
"avg_response_time": avg_time,
|
||||||
|
"total_response_time": round(stat.total_response_time, 3),
|
||||||
|
"last_called_at": fmt_time(stat.last_called_at),
|
||||||
|
"created_at": fmt_time(stat.created_at),
|
||||||
|
"updated_at": fmt_time(stat.updated_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_slow_log(log):
|
||||||
|
return {
|
||||||
|
"id": log.id,
|
||||||
|
"request_id": log.request_id,
|
||||||
|
"method": log.method,
|
||||||
|
"endpoint": log.endpoint,
|
||||||
|
"status_code": log.status_code,
|
||||||
|
"response_time": round(log.response_time, 3),
|
||||||
|
"threshold": round(log.threshold, 2),
|
||||||
|
"created_at": fmt_time(log.created_at),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Stats Overview
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/stats/overview")
|
||||||
|
async def get_stats_overview(db: Session = Depends(get_db)):
|
||||||
|
"""Get developer API dashboard statistics."""
|
||||||
|
now = cst_now()
|
||||||
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
today_start_naive = today_start.replace(tzinfo=None)
|
||||||
|
|
||||||
|
# API Key stats
|
||||||
|
total_keys = db.query(func.count(ApiKey.id)).scalar() or 0
|
||||||
|
active_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "active").scalar() or 0
|
||||||
|
revoked_keys = db.query(func.count(ApiKey.id)).filter(ApiKey.status == "revoked").scalar() or 0
|
||||||
|
|
||||||
|
# OpenAPI Client stats
|
||||||
|
total_clients = db.query(func.count(OpenApiClient.id)).scalar() or 0
|
||||||
|
active_clients = db.query(func.count(OpenApiClient.id)).filter(OpenApiClient.status == "active").scalar() or 0
|
||||||
|
|
||||||
|
# Call stats
|
||||||
|
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
|
||||||
|
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
|
||||||
|
total_response_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
|
||||||
|
avg_response_time = round(total_response_time / total_calls, 3) if total_calls > 0 else 0.0
|
||||||
|
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0
|
||||||
|
|
||||||
|
# Today's calls (approximate by last_called_at)
|
||||||
|
today_calls = db.query(func.sum(ApiCallStat.call_count)).filter(
|
||||||
|
ApiCallStat.last_called_at >= today_start_naive
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Slow request count
|
||||||
|
slow_log_count = db.query(func.count(SlowRequestLog.id)).scalar() or 0
|
||||||
|
today_slow = db.query(func.count(SlowRequestLog.id)).filter(
|
||||||
|
SlowRequestLog.created_at >= today_start_naive
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
# Top endpoints by call count
|
||||||
|
top_endpoints = db.query(ApiCallStat).order_by(desc(ApiCallStat.call_count)).limit(5).all()
|
||||||
|
top_endpoints_data = [
|
||||||
|
{
|
||||||
|
"endpoint": s.endpoint,
|
||||||
|
"method": s.method,
|
||||||
|
"call_count": s.call_count,
|
||||||
|
"error_count": s.error_count,
|
||||||
|
}
|
||||||
|
for s in top_endpoints
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"api_keys": {
|
||||||
|
"total": total_keys,
|
||||||
|
"active": active_keys,
|
||||||
|
"revoked": revoked_keys,
|
||||||
|
},
|
||||||
|
"openapi_clients": {
|
||||||
|
"total": total_clients,
|
||||||
|
"active": active_clients,
|
||||||
|
},
|
||||||
|
"call_stats": {
|
||||||
|
"total_calls": total_calls,
|
||||||
|
"today_calls": today_calls,
|
||||||
|
"total_errors": total_errors,
|
||||||
|
"error_rate": error_rate,
|
||||||
|
"avg_response_time": avg_response_time,
|
||||||
|
},
|
||||||
|
"slow_logs": {
|
||||||
|
"total": slow_log_count,
|
||||||
|
"today": today_slow,
|
||||||
|
},
|
||||||
|
"top_endpoints": top_endpoints_data,
|
||||||
|
"server_time": cst_now().isoformat(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API Key Management
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/api-keys")
|
||||||
|
async def list_api_keys(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
status: str = Query(None),
|
||||||
|
keyword: str = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all API keys with pagination."""
|
||||||
|
query = db.query(ApiKey, User).outerjoin(User, ApiKey.user_id == User.id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(ApiKey.status == status)
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
(ApiKey.name.ilike(f"%{keyword}%")) |
|
||||||
|
(ApiKey.api_key.ilike(f"%{keyword}%")) |
|
||||||
|
(User.username.ilike(f"%{keyword}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
items = query.order_by(desc(ApiKey.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [_serialize_api_key(k, u) for k, u in items],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api-keys")
|
||||||
|
async def create_api_key(body: ApiKeyCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Create a new API key."""
|
||||||
|
# Verify user exists
|
||||||
|
user = db.query(User).filter(User.id == body.user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
# Check if model has description/rate_limit/total_calls columns
|
||||||
|
new_key = ApiKey(
|
||||||
|
user_id=body.user_id,
|
||||||
|
api_key=_generate_api_key(),
|
||||||
|
secret=_generate_secret(),
|
||||||
|
name=body.name,
|
||||||
|
status="active",
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Set optional fields if they exist on the model
|
||||||
|
try:
|
||||||
|
new_key.description = body.description
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
new_key.rate_limit = body.rate_limit
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
new_key.total_calls = 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if body.expired_at:
|
||||||
|
try:
|
||||||
|
new_key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.add(new_key)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_key)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": _serialize_api_key(new_key, user),
|
||||||
|
"message": "API Key created successfully",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api-keys/{key_id}")
|
||||||
|
async def update_api_key(key_id: str, body: ApiKeyUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Update an API key."""
|
||||||
|
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="API Key not found")
|
||||||
|
|
||||||
|
if body.name is not None:
|
||||||
|
key.name = body.name
|
||||||
|
if body.status is not None:
|
||||||
|
key.status = body.status
|
||||||
|
if body.rate_limit is not None:
|
||||||
|
try:
|
||||||
|
key.rate_limit = body.rate_limit
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if body.description is not None:
|
||||||
|
try:
|
||||||
|
key.description = body.description
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
if body.expired_at is not None:
|
||||||
|
try:
|
||||||
|
key.expired_at = datetime.fromisoformat(body.expired_at.replace("Z", ""))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(key)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == key.user_id).first()
|
||||||
|
return {"code": 0, "data": _serialize_api_key(key, user), "message": "Updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api-keys/{key_id}/revoke")
|
||||||
|
async def revoke_api_key(key_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Revoke an API key."""
|
||||||
|
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="API Key not found")
|
||||||
|
|
||||||
|
key.status = "revoked"
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"code": 0, "message": "API Key revoked"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api-keys/{key_id}")
|
||||||
|
async def delete_api_key(key_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Delete an API key permanently."""
|
||||||
|
key = db.query(ApiKey).filter(ApiKey.id == key_id).first()
|
||||||
|
if not key:
|
||||||
|
raise HTTPException(status_code=404, detail="API Key not found")
|
||||||
|
|
||||||
|
db.delete(key)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"code": 0, "message": "API Key deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# OpenAPI Client Management
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/openapi-clients")
|
||||||
|
async def list_openapi_clients(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
status: str = Query(None),
|
||||||
|
keyword: str = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all OpenAPI clients."""
|
||||||
|
query = db.query(OpenApiClient, User).outerjoin(User, OpenApiClient.user_id == User.id)
|
||||||
|
|
||||||
|
if status:
|
||||||
|
query = query.filter(OpenApiClient.status == status)
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(
|
||||||
|
(OpenApiClient.name.ilike(f"%{keyword}%")) |
|
||||||
|
(OpenApiClient.client_id.ilike(f"%{keyword}%")) |
|
||||||
|
(User.username.ilike(f"%{keyword}%"))
|
||||||
|
)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
items = query.order_by(desc(OpenApiClient.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [_serialize_client(c, u) for c, u in items],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/openapi-clients")
|
||||||
|
async def create_openapi_client(body: OpenAPIClientCreate, db: Session = Depends(get_db)):
|
||||||
|
"""Create a new OpenAPI client."""
|
||||||
|
user = db.query(User).filter(User.id == body.user_id).first()
|
||||||
|
if not user:
|
||||||
|
raise HTTPException(status_code=404, detail="User not found")
|
||||||
|
|
||||||
|
new_client = OpenApiClient(
|
||||||
|
user_id=body.user_id,
|
||||||
|
name=body.name,
|
||||||
|
client_id=_generate_client_id(),
|
||||||
|
client_secret=_generate_client_secret(),
|
||||||
|
allowed_models=body.allowed_models,
|
||||||
|
rate_limit=body.rate_limit,
|
||||||
|
status="active",
|
||||||
|
created_at=datetime.now(),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
new_client.total_calls = 0
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
new_client.last_used_at = None
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
db.add(new_client)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(new_client)
|
||||||
|
|
||||||
|
result = _serialize_client(new_client, user)
|
||||||
|
result["client_secret"] = new_client.client_secret # Only show on create
|
||||||
|
|
||||||
|
return {"code": 0, "data": result, "message": "OpenAPI Client created"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/openapi-clients/{client_id}")
|
||||||
|
async def update_openapi_client(client_id: str, body: OpenAPIClientUpdate, db: Session = Depends(get_db)):
|
||||||
|
"""Update an OpenAPI client."""
|
||||||
|
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
|
||||||
|
|
||||||
|
if body.name is not None:
|
||||||
|
client.name = body.name
|
||||||
|
if body.allowed_models is not None:
|
||||||
|
client.allowed_models = body.allowed_models
|
||||||
|
if body.rate_limit is not None:
|
||||||
|
client.rate_limit = body.rate_limit
|
||||||
|
if body.status is not None:
|
||||||
|
client.status = body.status
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(client)
|
||||||
|
|
||||||
|
user = db.query(User).filter(User.id == client.user_id).first()
|
||||||
|
return {"code": 0, "data": _serialize_client(client, user), "message": "Updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/openapi-clients/{client_id}/reset-secret")
|
||||||
|
async def reset_client_secret(client_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Reset the client secret."""
|
||||||
|
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
|
||||||
|
|
||||||
|
client.client_secret = _generate_client_secret()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(client)
|
||||||
|
|
||||||
|
return {"code": 0, "data": {"client_secret": client.client_secret}, "message": "Secret reset"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/openapi-clients/{client_id}")
|
||||||
|
async def delete_openapi_client(client_id: str, db: Session = Depends(get_db)):
|
||||||
|
"""Delete an OpenAPI client."""
|
||||||
|
client = db.query(OpenApiClient).filter(OpenApiClient.id == client_id).first()
|
||||||
|
if not client:
|
||||||
|
raise HTTPException(status_code=404, detail="OpenAPI Client not found")
|
||||||
|
|
||||||
|
db.delete(client)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"code": 0, "message": "OpenAPI Client deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# API Call Statistics
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/call-stats")
|
||||||
|
async def list_call_stats(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
sort_by: str = Query("call_count"),
|
||||||
|
sort_order: str = Query("desc"),
|
||||||
|
keyword: str = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List API call statistics."""
|
||||||
|
query = db.query(ApiCallStat)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(ApiCallStat.endpoint.ilike(f"%{keyword}%"))
|
||||||
|
|
||||||
|
sort_column = getattr(ApiCallStat, sort_by, ApiCallStat.call_count)
|
||||||
|
if sort_order == "asc":
|
||||||
|
query = query.order_by(asc(sort_column))
|
||||||
|
else:
|
||||||
|
query = query.order_by(desc(sort_column))
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
items = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [_serialize_call_stat(s) for s in items],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/call-stats/overview")
|
||||||
|
async def call_stats_overview(db: Session = Depends(get_db)):
|
||||||
|
"""Get call stats summary."""
|
||||||
|
total_endpoints = db.query(func.count(ApiCallStat.id)).scalar() or 0
|
||||||
|
total_calls = db.query(func.sum(ApiCallStat.call_count)).scalar() or 0
|
||||||
|
total_errors = db.query(func.sum(ApiCallStat.error_count)).scalar() or 0
|
||||||
|
avg_time = 0.0
|
||||||
|
if total_calls and total_calls > 0:
|
||||||
|
total_time = db.query(func.sum(ApiCallStat.total_response_time)).scalar() or 0.0
|
||||||
|
avg_time = round(total_time / total_calls, 3)
|
||||||
|
|
||||||
|
# Method distribution
|
||||||
|
method_stats = db.query(
|
||||||
|
ApiCallStat.method,
|
||||||
|
func.sum(ApiCallStat.call_count).label("count")
|
||||||
|
).group_by(ApiCallStat.method).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"total_endpoints": total_endpoints,
|
||||||
|
"total_calls": total_calls,
|
||||||
|
"total_errors": total_errors,
|
||||||
|
"error_rate": round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0.0,
|
||||||
|
"avg_response_time": avg_time,
|
||||||
|
"method_distribution": [
|
||||||
|
{"method": m, "count": c} for m, c in method_stats
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Slow Request Logs
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/slow-logs")
|
||||||
|
async def list_slow_logs(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
keyword: str = Query(None),
|
||||||
|
min_time: float = Query(None),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List slow request logs."""
|
||||||
|
query = db.query(SlowRequestLog)
|
||||||
|
|
||||||
|
if keyword:
|
||||||
|
query = query.filter(SlowRequestLog.endpoint.ilike(f"%{keyword}%"))
|
||||||
|
if min_time is not None:
|
||||||
|
query = query.filter(SlowRequestLog.response_time >= min_time)
|
||||||
|
|
||||||
|
total = query.count()
|
||||||
|
items = query.order_by(desc(SlowRequestLog.created_at)).offset((page - 1) * page_size).limit(page_size).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"page_size": page_size,
|
||||||
|
"items": [_serialize_slow_log(l) for l in items],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/slow-logs")
|
||||||
|
async def clear_slow_logs(db: Session = Depends(get_db)):
|
||||||
|
"""Clear all slow request logs."""
|
||||||
|
deleted = db.query(SlowRequestLog).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"code": 0, "message": f"Cleared {deleted} slow request logs"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/slow-logs/stats")
|
||||||
|
async def slow_logs_stats(db: Session = Depends(get_db)):
|
||||||
|
"""Get slow request log statistics."""
|
||||||
|
now = cst_now()
|
||||||
|
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||||||
|
|
||||||
|
total = db.query(func.count(SlowRequestLog.id)).scalar() or 0
|
||||||
|
today = db.query(func.count(SlowRequestLog.id)).filter(
|
||||||
|
SlowRequestLog.created_at >= today_start
|
||||||
|
).scalar() or 0
|
||||||
|
|
||||||
|
avg_time = db.query(func.avg(SlowRequestLog.response_time)).scalar() or 0.0
|
||||||
|
max_time = db.query(func.max(SlowRequestLog.response_time)).scalar() or 0.0
|
||||||
|
|
||||||
|
# Distribution by status code
|
||||||
|
status_dist = db.query(
|
||||||
|
SlowRequestLog.status_code,
|
||||||
|
func.count(SlowRequestLog.id).label("count")
|
||||||
|
).group_by(SlowRequestLog.status_code).all()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"total": total,
|
||||||
|
"today": today,
|
||||||
|
"avg_response_time": round(float(avg_time), 3),
|
||||||
|
"max_response_time": round(float(max_time), 3),
|
||||||
|
"status_distribution": [
|
||||||
|
{"status_code": s, "count": c} for s, c in status_dist
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,6 +65,9 @@ class ApiKey(Base):
|
|||||||
last_used_at = Column(DateTime, default=None)
|
last_used_at = Column(DateTime, default=None)
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
expired_at = Column(DateTime, default=None)
|
expired_at = Column(DateTime, default=None)
|
||||||
|
description = Column(String(500), default=None, comment="密钥描述")
|
||||||
|
rate_limit = Column(Integer, default=60, comment="速率限制(请求/分钟)")
|
||||||
|
total_calls = Column(BigInteger, default=0, comment="总调用次数")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -770,6 +773,8 @@ class OpenApiClient(Base):
|
|||||||
rate_limit = Column(Integer, default=60)
|
rate_limit = Column(Integer, default=60)
|
||||||
status = Column(String(10), nullable=False, default="active")
|
status = Column(String(10), nullable=False, default="active")
|
||||||
created_at = Column(DateTime, default=datetime.now)
|
created_at = Column(DateTime, default=datetime.now)
|
||||||
|
total_calls = Column(BigInteger, default=0, comment="总调用次数")
|
||||||
|
last_used_at = Column(DateTime, default=None, comment="最后使用时间")
|
||||||
|
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|||||||
|
|
||||||
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
from app.api import auth, chat, channels, knowledge, market, token_usage, payment, profile, notifications, assets, evolution
|
||||||
|
|
||||||
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin
|
from app.api.admin import dashboard, users, models, settings, payment_admin, services, membership_admin, ads, market_admin, logs, skills_admin, agents_admin, knowledge_admin, storage_admin, automation_admin, mcp_admin, developer_admin
|
||||||
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
from app.config import setup_file_logging, SLOW_REQUEST_THRESHOLD
|
||||||
|
|
||||||
from app.database import SessionLocal
|
from app.database import SessionLocal
|
||||||
@@ -422,6 +422,7 @@ app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", ta
|
|||||||
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
app.include_router(storage_admin.router, prefix="/app/api/admin/storage", tags=["后台-存储配置"])
|
||||||
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
|
app.include_router(automation_admin.router, prefix="/app/api/admin/automation", tags=["后台-自动化任务"])
|
||||||
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
|
app.include_router(mcp_admin.router, prefix="/app/api/admin/mcp", tags=["后台-MCP服务"])
|
||||||
|
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -75,7 +75,8 @@ import AdminAgents from '@/pages/admin/Agents'
|
|||||||
import AdminKnowledge from '@/pages/admin/Knowledge';
|
import AdminKnowledge from '@/pages/admin/Knowledge';
|
||||||
import AdminStorage from '@/pages/admin/Storage';
|
import AdminStorage from '@/pages/admin/Storage';
|
||||||
import AdminMcp from '@/pages/admin/Mcp';
|
import AdminMcp from '@/pages/admin/Mcp';
|
||||||
import AdminAutomation from '@/pages/admin/Automation';;
|
import AdminAutomation from '@/pages/admin/Automation';
|
||||||
|
import AdminDeveloper from '@/pages/admin/Developer';;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -236,6 +237,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="knowledge" element={<AdminKnowledge />} />
|
<Route path="knowledge" element={<AdminKnowledge />} />
|
||||||
<Route path="storage" element={<AdminStorage />} />
|
<Route path="storage" element={<AdminStorage />} />
|
||||||
<Route path="automation" element={<AdminAutomation />} />
|
<Route path="automation" element={<AdminAutomation />} />
|
||||||
|
<Route path="developer" element={<AdminDeveloper />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -542,6 +542,40 @@ export const adminAPI = {
|
|||||||
testAdminMcpServer: (id: string, data?: any) => post<any>(`/admin/mcp/${id}/test`, data || {}),
|
testAdminMcpServer: (id: string, data?: any) => post<any>(`/admin/mcp/${id}/test`, data || {}),
|
||||||
getAdminMcpServerTools: (id: string) => get<any>(`/admin/mcp/${id}/tools`),
|
getAdminMcpServerTools: (id: string) => get<any>(`/admin/mcp/${id}/tools`),
|
||||||
getAdminMcpServerResources: (id: string) => get<any>(`/admin/mcp/${id}/resources`),
|
getAdminMcpServerResources: (id: string) => get<any>(`/admin/mcp/${id}/resources`),
|
||||||
|
// Developer API Management
|
||||||
|
getAdminDeveloperStats: () => get<any>('/admin/developer/stats/overview'),
|
||||||
|
getAdminApiKeys: (params?: any) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||||
|
return get<any>(`/admin/developer/api-keys${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||||
|
},
|
||||||
|
createAdminApiKey: (data: any) => post<any>('/admin/developer/api-keys/', data),
|
||||||
|
updateAdminApiKey: (id: string, data: any) => put<any>(`/admin/developer/api-keys/${id}`, data),
|
||||||
|
revokeAdminApiKey: (id: string) => post<any>(`/admin/developer/api-keys/${id}/revoke`),
|
||||||
|
deleteAdminApiKey: (id: string) => del<any>(`/admin/developer/api-keys/${id}`),
|
||||||
|
getAdminOpenApiClients: (params?: any) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||||
|
return get<any>(`/admin/developer/openapi-clients${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||||
|
},
|
||||||
|
createAdminOpenApiClient: (data: any) => post<any>('/admin/developer/openapi-clients/', data),
|
||||||
|
updateAdminOpenApiClient: (id: string, data: any) => put<any>(`/admin/developer/openapi-clients/${id}`, data),
|
||||||
|
resetAdminOpenApiClientSecret: (id: string) => post<any>(`/admin/developer/openapi-clients/${id}/reset-secret`),
|
||||||
|
deleteAdminOpenApiClient: (id: string) => del<any>(`/admin/developer/openapi-clients/${id}`),
|
||||||
|
getAdminCallStats: (params?: any) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||||
|
return get<any>(`/admin/developer/call-stats${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||||
|
},
|
||||||
|
getAdminCallStatsOverview: () => get<any>('/admin/developer/call-stats/overview'),
|
||||||
|
getAdminSlowLogs: (params?: any) => {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
if (params) Object.entries(params).forEach(([k, v]) => { if (v) sp.set(k, String(v)); });
|
||||||
|
return get<any>(`/admin/developer/slow-logs${sp.toString() ? '?' + sp.toString() : ''}`);
|
||||||
|
},
|
||||||
|
clearAdminSlowLogs: () => del<any>('/admin/developer/slow-logs'),
|
||||||
|
getAdminSlowLogsStats: () => get<any>('/admin/developer/slow-logs/stats'),
|
||||||
|
|
||||||
|
|
||||||
};
|
};
|
||||||
// ===== AI自进化API =====
|
// ===== AI自进化API =====
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ const adminNavItems = [
|
|||||||
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
|
{ path: '/admin/storage', icon: 'storage', label: '存储配置' },
|
||||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||||
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
{ path: '/admin/skills', icon: 'skills', label: '技能管理' },
|
||||||
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' },];
|
{ path: '/admin/mcp', icon: 'mcp', label: 'MCP Servers' },
|
||||||
|
{ path: '/admin/developer', icon: 'developer', label: '开发者API' },];
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +69,8 @@ const adminIconPaths: Record<string, JSX.Element> = {
|
|||||||
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
skills: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg>,
|
||||||
automation: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2"/><path d="M12 21v2"/><path d="M4.22 4.22l1.42 1.42"/><path d="M18.36 18.36l1.42 1.42"/><path d="M1 12h2"/><path d="M21 12h2"/><path d="M4.22 19.78l1.42-1.42"/><path d="M18.36 5.64l1.42-1.42"/></svg>,
|
automation: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 1v2"/><path d="M12 21v2"/><path d="M4.22 4.22l1.42 1.42"/><path d="M18.36 18.36l1.42 1.42"/><path d="M1 12h2"/><path d="M21 12h2"/><path d="M4.22 19.78l1.42-1.42"/><path d="M18.36 5.64l1.42-1.42"/></svg>,
|
||||||
storage: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
|
storage: <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="9" ry="3"/><path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/><path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/></svg>,
|
||||||
};
|
|
||||||
|
developer: (<svg width='18' height='18' viewBox='0 0 24 24' fill='none' stroke='currentColor' strokeWidth='2' strokeLinecap='round' strokeLinejoin='round'><polyline points='16 18 22 12 16 6'/><polyline points='8 6 2 12 8 18'/></svg>),};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
.admin-developer { padding: 20px; max-width: 1400px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.dev-header { margin-bottom: 24px; }
|
||||||
|
.dev-header h2 { font-size: 22px; margin: 0 0 6px 0; }
|
||||||
|
.dev-header p { color: var(--text-secondary, #666); font-size: 14px; margin: 0; }
|
||||||
|
|
||||||
|
/* Stats Grid */
|
||||||
|
.dev-stats-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 16px; margin-bottom: 24px; }
|
||||||
|
.dev-stat-card {
|
||||||
|
display: flex; align-items: center; gap: 14px;
|
||||||
|
padding: 18px; border-radius: 10px;
|
||||||
|
background: var(--card-bg, #fff);
|
||||||
|
border: 1px solid var(--border-color, #e8e8e8);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||||||
|
}
|
||||||
|
.stat-icon { font-size: 28px; width: 48px; height: 48px; display: flex; align-items: center; justify-content: center; border-radius: 10px; flex-shrink: 0; }
|
||||||
|
.icon-key { background: rgba(24,144,255,0.1); }
|
||||||
|
.icon-client { background: rgba(82,196,26,0.1); }
|
||||||
|
.icon-call { background: rgba(250,173,20,0.1); }
|
||||||
|
.icon-error { background: rgba(245,34,45,0.1); }
|
||||||
|
.icon-time { background: rgba(114,46,209,0.1); }
|
||||||
|
.stat-info { display: flex; flex-direction: column; }
|
||||||
|
.stat-value { font-size: 24px; font-weight: 700; line-height: 1.2; }
|
||||||
|
.stat-label { font-size: 13px; color: var(--text-secondary, #666); margin-top: 2px; }
|
||||||
|
.stat-sub { font-size: 11px; color: var(--text-tertiary, #999); margin-top: 2px; }
|
||||||
|
|
||||||
|
/* Tabs */
|
||||||
|
.dev-tabs { display: flex; gap: 4px; border-bottom: 2px solid var(--border-color, #e8e8e8); margin-bottom: 20px; }
|
||||||
|
.dev-tab {
|
||||||
|
padding: 10px 20px; border: none; background: none; cursor: pointer;
|
||||||
|
font-size: 14px; color: var(--text-secondary, #666);
|
||||||
|
border-bottom: 2px solid transparent; margin-bottom: -2px; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.dev-tab.active { color: var(--primary, #1890ff); border-bottom-color: var(--primary, #1890ff); font-weight: 600; }
|
||||||
|
.dev-tab:hover { color: var(--primary, #1890ff); }
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.dev-toolbar { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.dev-input {
|
||||||
|
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
|
||||||
|
font-size: 14px; flex: 1; min-width: 200px; max-width: 400px;
|
||||||
|
background: var(--input-bg, #fff); color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
.dev-input:focus { outline: none; border-color: var(--primary, #1890ff); box-shadow: 0 0 0 2px rgba(24,144,255,0.1); }
|
||||||
|
.dev-select {
|
||||||
|
padding: 8px 12px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
|
||||||
|
font-size: 14px; background: var(--input-bg, #fff); color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.dev-btn {
|
||||||
|
padding: 8px 16px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 6px;
|
||||||
|
font-size: 14px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
|
||||||
|
transition: all 0.2s; white-space: nowrap;
|
||||||
|
}
|
||||||
|
.dev-btn:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
|
||||||
|
.dev-btn.primary { background: var(--primary, #1890ff); color: #fff; border-color: var(--primary, #1890ff); }
|
||||||
|
.dev-btn.primary:hover { background: var(--primary-dark, #096dd9); color: #fff; }
|
||||||
|
.dev-btn.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
|
||||||
|
.dev-btn.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
|
||||||
|
.dev-btn-sm {
|
||||||
|
padding: 4px 10px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
|
||||||
|
font-size: 12px; cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333);
|
||||||
|
margin-right: 4px; transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.dev-btn-sm:hover { border-color: var(--primary, #1890ff); color: var(--primary, #1890ff); }
|
||||||
|
.dev-btn-sm.warn { color: var(--warning, #faad14); border-color: var(--warning, #faad14); }
|
||||||
|
.dev-btn-sm.warn:hover { background: var(--warning, #faad14); color: #fff; }
|
||||||
|
.dev-btn-sm.danger { color: var(--danger, #ff4d4f); border-color: var(--danger, #ff4d4f); }
|
||||||
|
.dev-btn-sm.danger:hover { background: var(--danger, #ff4d4f); color: #fff; }
|
||||||
|
|
||||||
|
/* Table */
|
||||||
|
.dev-table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
.dev-table th {
|
||||||
|
text-align: left; padding: 10px 12px; background: var(--table-header-bg, #fafafa);
|
||||||
|
border-bottom: 2px solid var(--border-color, #e8e8e8); font-weight: 600; white-space: nowrap;
|
||||||
|
color: var(--text-primary, #333);
|
||||||
|
}
|
||||||
|
.dev-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-color, #f0f0f0); }
|
||||||
|
.dev-table tbody tr:hover { background: var(--table-hover-bg, #f5f5f5); }
|
||||||
|
.mono-text { font-family: 'Courier New', monospace; font-size: 12px; }
|
||||||
|
.dev-actions { white-space: nowrap; }
|
||||||
|
.dev-empty { text-align: center; color: var(--text-tertiary, #999); padding: 40px 12px !important; }
|
||||||
|
.dev-loading { text-align: center; padding: 40px; color: var(--text-secondary, #666); }
|
||||||
|
|
||||||
|
/* Badges */
|
||||||
|
.dev-badge {
|
||||||
|
display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 12px; font-weight: 500;
|
||||||
|
}
|
||||||
|
.badge-active { background: rgba(82,196,26,0.1); color: #52c41a; }
|
||||||
|
.badge-revoked { background: rgba(245,34,45,0.1); color: #f5222d; }
|
||||||
|
.badge-disabled { background: rgba(250,173,20,0.1); color: #faad14; }
|
||||||
|
|
||||||
|
/* Method badges */
|
||||||
|
.method-badge { display: inline-block; padding: 2px 6px; border-radius: 3px; font-size: 11px; font-weight: 600; }
|
||||||
|
.method-get { background: rgba(82,196,26,0.1); color: #52c41a; }
|
||||||
|
.method-post { background: rgba(24,144,255,0.1); color: #1890ff; }
|
||||||
|
.method-put { background: rgba(250,173,20,0.1); color: #faad14; }
|
||||||
|
.method-delete { background: rgba(245,34,45,0.1); color: #f5222d; }
|
||||||
|
.method-patch { background: rgba(114,46,209,0.1); color: #722ed1; }
|
||||||
|
|
||||||
|
/* Text colors */
|
||||||
|
.text-danger { color: var(--danger, #f5222d); }
|
||||||
|
.text-warn { color: var(--warning, #faad14); }
|
||||||
|
|
||||||
|
/* Pagination */
|
||||||
|
.dev-pagination { display: flex; align-items: center; justify-content: center; gap: 16px; margin-top: 16px; }
|
||||||
|
.dev-pagination button {
|
||||||
|
padding: 6px 14px; border: 1px solid var(--border-color, #d9d9d9); border-radius: 4px;
|
||||||
|
cursor: pointer; background: var(--btn-bg, #fff); color: var(--text-primary, #333); font-size: 13px;
|
||||||
|
}
|
||||||
|
.dev-pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.dev-pagination span { font-size: 13px; color: var(--text-secondary, #666); }
|
||||||
|
|
||||||
|
/* Modal */
|
||||||
|
.dev-modal-overlay {
|
||||||
|
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||||
|
background: rgba(0,0,0,0.45); display: flex; align-items: center; justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
.dev-modal {
|
||||||
|
background: var(--modal-bg, #fff); border-radius: 10px; padding: 24px;
|
||||||
|
width: 460px; max-width: 90vw; max-height: 90vh; overflow-y: auto;
|
||||||
|
box-shadow: 0 6px 30px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
.dev-modal h3 { margin: 0 0 20px 0; font-size: 18px; }
|
||||||
|
.dev-form-group { margin-bottom: 16px; }
|
||||||
|
.dev-form-group label { display: block; font-size: 13px; margin-bottom: 6px; color: var(--text-secondary, #666); }
|
||||||
|
.dev-form-group .dev-input { width: 100%; max-width: none; box-sizing: border-box; }
|
||||||
|
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 12px; margin-top: 20px; }
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.dev-stats-grid { grid-template-columns: repeat(3, 1fr); }
|
||||||
|
}
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
|
.dev-table { font-size: 12px; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import './Developer.css';
|
||||||
|
|
||||||
|
const API_BASE = '/app/api';
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
api_keys: { total: number; active: number; revoked: number };
|
||||||
|
openapi_clients: { total: number; active: number };
|
||||||
|
call_stats: { total_calls: number; today_calls: number; total_errors: number; error_rate: number; avg_response_time: number };
|
||||||
|
slow_logs: { total: number; today: number };
|
||||||
|
top_endpoints: { endpoint: string; method: string; call_count: number; error_count: number }[];
|
||||||
|
server_time: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiKey {
|
||||||
|
id: string; user_id: string; username: string | null; api_key: string; name: string;
|
||||||
|
description: string; rate_limit: number; total_calls: number; status: string;
|
||||||
|
last_used_at: string | null; created_at: string; expired_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OpenApiClient {
|
||||||
|
id: string; user_id: string; username: string | null; name: string; client_id: string;
|
||||||
|
allowed_models: string[]; rate_limit: number; total_calls: number;
|
||||||
|
last_used_at: string | null; status: string; created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CallStat {
|
||||||
|
id: string; endpoint: string; method: string; call_count: number; error_count: number;
|
||||||
|
error_rate: number; avg_response_time: number; total_response_time: number;
|
||||||
|
last_called_at: string | null; created_at: string; updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SlowLog {
|
||||||
|
id: string; request_id: string | null; method: string; endpoint: string;
|
||||||
|
status_code: number; response_time: number; threshold: number; created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PageData<T> { total: number; page: number; page_size: number; items: T[] }
|
||||||
|
|
||||||
|
async function api(path: string, options?: RequestInit) {
|
||||||
|
const token = localStorage.getItem('token');
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
...(options?.headers || {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (res.status === 307) {
|
||||||
|
const location = res.headers.get('Location');
|
||||||
|
if (location) return api(location.replace(API_BASE, ''), options);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminDeveloper() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'keys' | 'clients' | 'stats' | 'logs'>('keys');
|
||||||
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// API Keys state
|
||||||
|
const [keys, setKeys] = useState<PageData<ApiKey>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||||
|
const [keySearch, setKeySearch] = useState('');
|
||||||
|
const [keyStatusFilter, setKeyStatusFilter] = useState('');
|
||||||
|
const [showKeyModal, setShowKeyModal] = useState(false);
|
||||||
|
const [keyForm, setKeyForm] = useState({ user_id: '', name: '', description: '', rate_limit: 60 });
|
||||||
|
|
||||||
|
// OpenAPI Clients state
|
||||||
|
const [clients, setClients] = useState<PageData<OpenApiClient>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||||
|
const [clientSearch, setClientSearch] = useState('');
|
||||||
|
const [showClientModal, setShowClientModal] = useState(false);
|
||||||
|
const [clientForm, setClientForm] = useState({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
|
||||||
|
|
||||||
|
// Call Stats state
|
||||||
|
const [callStats, setCallStats] = useState<PageData<CallStat>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||||
|
const [statSearch, setStatSearch] = useState('');
|
||||||
|
const [statSortBy, setStatSortBy] = useState('call_count');
|
||||||
|
|
||||||
|
// Slow Logs state
|
||||||
|
const [slowLogs, setSlowLogs] = useState<PageData<SlowLog>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||||
|
const [logSearch, setLogSearch] = useState('');
|
||||||
|
|
||||||
|
const loadStats = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/developer/stats/overview');
|
||||||
|
if (res.code === 0) setStats(res.data);
|
||||||
|
} catch (e) { console.error('Stats error:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadKeys = useCallback(async (page = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), page_size: '20' });
|
||||||
|
if (keyStatusFilter) params.set('status', keyStatusFilter);
|
||||||
|
if (keySearch) params.set('keyword', keySearch);
|
||||||
|
const res = await api(`/admin/developer/api-keys?${params}`);
|
||||||
|
if (res.code === 0) setKeys(res.data);
|
||||||
|
} catch (e) { console.error('Keys error:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [keySearch, keyStatusFilter]);
|
||||||
|
|
||||||
|
const loadClients = useCallback(async (page = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), page_size: '20' });
|
||||||
|
if (clientSearch) params.set('keyword', clientSearch);
|
||||||
|
const res = await api(`/admin/developer/openapi-clients?${params}`);
|
||||||
|
if (res.code === 0) setClients(res.data);
|
||||||
|
} catch (e) { console.error('Clients error:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [clientSearch]);
|
||||||
|
|
||||||
|
const loadCallStats = useCallback(async (page = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), page_size: '20', sort_by: statSortBy });
|
||||||
|
if (statSearch) params.set('keyword', statSearch);
|
||||||
|
const res = await api(`/admin/developer/call-stats?${params}`);
|
||||||
|
if (res.code === 0) setCallStats(res.data);
|
||||||
|
} catch (e) { console.error('Stats error:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [statSearch, statSortBy]);
|
||||||
|
|
||||||
|
const loadSlowLogs = useCallback(async (page = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const params = new URLSearchParams({ page: String(page), page_size: '20' });
|
||||||
|
if (logSearch) params.set('keyword', logSearch);
|
||||||
|
const res = await api(`/admin/developer/slow-logs?${params}`);
|
||||||
|
if (res.code === 0) setSlowLogs(res.data);
|
||||||
|
} catch (e) { console.error('Logs error:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, [logSearch]);
|
||||||
|
|
||||||
|
useEffect(() => { loadStats(); }, [loadStats]);
|
||||||
|
useEffect(() => { if (activeTab === 'keys') loadKeys(); }, [activeTab, loadKeys]);
|
||||||
|
useEffect(() => { if (activeTab === 'clients') loadClients(); }, [activeTab, loadClients]);
|
||||||
|
useEffect(() => { if (activeTab === 'stats') loadCallStats(); }, [activeTab, loadCallStats]);
|
||||||
|
useEffect(() => { if (activeTab === 'logs') loadSlowLogs(); }, [activeTab, loadSlowLogs]);
|
||||||
|
|
||||||
|
const handleCreateKey = async () => {
|
||||||
|
try {
|
||||||
|
const body: any = { user_id: keyForm.user_id, name: keyForm.name, description: keyForm.description, rate_limit: keyForm.rate_limit };
|
||||||
|
await api('/admin/developer/api-keys/', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
setShowKeyModal(false);
|
||||||
|
setKeyForm({ user_id: '', name: '', description: '', rate_limit: 60 });
|
||||||
|
loadKeys();
|
||||||
|
loadStats();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeKey = async (id: string) => {
|
||||||
|
if (!confirm('Revoke this API key?')) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/developer/api-keys/${id}/revoke`, { method: 'POST' });
|
||||||
|
loadKeys(); loadStats();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteKey = async (id: string) => {
|
||||||
|
if (!confirm('Permanently delete this API key?')) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/developer/api-keys/${id}`, { method: 'DELETE' });
|
||||||
|
loadKeys(); loadStats();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateClient = async () => {
|
||||||
|
try {
|
||||||
|
const models = clientForm.allowed_models ? clientForm.allowed_models.split(',').map(s => s.trim()).filter(Boolean) : null;
|
||||||
|
const body: any = { user_id: clientForm.user_id, name: clientForm.name, allowed_models: models, rate_limit: clientForm.rate_limit };
|
||||||
|
const res = await api('/admin/developer/openapi-clients/', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
setShowClientModal(false);
|
||||||
|
setClientForm({ user_id: '', name: '', allowed_models: '', rate_limit: 60 });
|
||||||
|
loadClients(); loadStats();
|
||||||
|
if (res.data?.client_secret) {
|
||||||
|
alert(`Client created! Secret: ${res.data.client_secret}\nSave this secret - it won't be shown again.`);
|
||||||
|
}
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleClient = async (client: OpenApiClient) => {
|
||||||
|
const newStatus = client.status === 'active' ? 'disabled' : 'active';
|
||||||
|
try {
|
||||||
|
await api(`/admin/developer/openapi-clients/${client.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
|
||||||
|
loadClients();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteClient = async (id: string) => {
|
||||||
|
if (!confirm('Delete this OpenAPI client?')) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/developer/openapi-clients/${id}`, { method: 'DELETE' });
|
||||||
|
loadClients(); loadStats();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetSecret = async (id: string) => {
|
||||||
|
if (!confirm('Reset client secret? The old secret will be invalidated.')) return;
|
||||||
|
try {
|
||||||
|
const res = await api(`/admin/developer/openapi-clients/${id}/reset-secret`, { method: 'POST' });
|
||||||
|
if (res.data?.client_secret) alert(`New secret: ${res.data.client_secret}`);
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClearLogs = async () => {
|
||||||
|
if (!confirm('Clear ALL slow request logs?')) return;
|
||||||
|
try {
|
||||||
|
await api('/admin/developer/slow-logs', { method: 'DELETE' });
|
||||||
|
loadSlowLogs(); loadStats();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const fmtTime = (t: string | null) => {
|
||||||
|
if (!t) return '-';
|
||||||
|
try { return new Date(t).toLocaleString('zh-CN'); } catch { return t; }
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusBadge = (status: string) => {
|
||||||
|
const cls = status === 'active' ? 'badge-active' : status === 'revoked' ? 'badge-revoked' : 'badge-disabled';
|
||||||
|
return <span className={`dev-badge ${cls}`}>{status}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-developer">
|
||||||
|
<div className="dev-header">
|
||||||
|
<h2>开发者API管理</h2>
|
||||||
|
<p>管理API密钥、OpenAPI客户端、调用统计和慢请求日志</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
{stats && (
|
||||||
|
<div className="dev-stats-grid">
|
||||||
|
<div className="dev-stat-card">
|
||||||
|
<div className="stat-icon icon-key">🔑</div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{stats.api_keys.total}</span>
|
||||||
|
<span className="stat-label">API密钥总数</span>
|
||||||
|
<span className="stat-sub">活跃 {stats.api_keys.active} / 已撤销 {stats.api_keys.revoked}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dev-stat-card">
|
||||||
|
<div className="stat-icon icon-client">🔌</div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{stats.openapi_clients.total}</span>
|
||||||
|
<span className="stat-label">OpenAPI客户端</span>
|
||||||
|
<span className="stat-sub">活跃 {stats.openapi_clients.active}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dev-stat-card">
|
||||||
|
<div className="stat-icon icon-call">📞</div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{stats.call_stats.total_calls.toLocaleString()}</span>
|
||||||
|
<span className="stat-label">API总调用</span>
|
||||||
|
<span className="stat-sub">今日 {stats.call_stats.today_calls.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dev-stat-card">
|
||||||
|
<div className="stat-icon icon-error">⚠️</div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{stats.call_stats.error_rate}%</span>
|
||||||
|
<span className="stat-label">错误率</span>
|
||||||
|
<span className="stat-sub">错误 {stats.call_stats.total_errors.toLocaleString()} 次</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dev-stat-card">
|
||||||
|
<div className="stat-icon icon-time">⏱️</div>
|
||||||
|
<div className="stat-info">
|
||||||
|
<span className="stat-value">{stats.call_stats.avg_response_time}s</span>
|
||||||
|
<span className="stat-label">平均响应时间</span>
|
||||||
|
<span className="stat-sub">慢请求 {stats.slow_logs.total} 条</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="dev-tabs">
|
||||||
|
<button className={`dev-tab ${activeTab === 'keys' ? 'active' : ''}`} onClick={() => setActiveTab('keys')}>API密钥</button>
|
||||||
|
<button className={`dev-tab ${activeTab === 'clients' ? 'active' : ''}`} onClick={() => setActiveTab('clients')}>OpenAPI客户端</button>
|
||||||
|
<button className={`dev-tab ${activeTab === 'stats' ? 'active' : ''}`} onClick={() => setActiveTab('stats')}>调用统计</button>
|
||||||
|
<button className={`dev-tab ${activeTab === 'logs' ? 'active' : ''}`} onClick={() => setActiveTab('logs')}>慢请求日志</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="dev-tab-content">
|
||||||
|
{loading && <div className="dev-loading">加载中...</div>}
|
||||||
|
|
||||||
|
{/* API Keys Tab */}
|
||||||
|
{activeTab === 'keys' && !loading && (
|
||||||
|
<div>
|
||||||
|
<div className="dev-toolbar">
|
||||||
|
<input className="dev-input" placeholder="搜索密钥名称/用户..." value={keySearch} onChange={e => setKeySearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadKeys()} />
|
||||||
|
<select className="dev-select" value={keyStatusFilter} onChange={e => setKeyStatusFilter(e.target.value)}>
|
||||||
|
<option value="">全部状态</option>
|
||||||
|
<option value="active">活跃</option>
|
||||||
|
<option value="revoked">已撤销</option>
|
||||||
|
</select>
|
||||||
|
<button className="dev-btn primary" onClick={() => setShowKeyModal(true)}>+ 新建密钥</button>
|
||||||
|
</div>
|
||||||
|
<table className="dev-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>名称</th><th>API Key</th><th>用户</th><th>速率限制</th><th>状态</th><th>最后使用</th><th>创建时间</th><th>操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{keys.items.map(k => (
|
||||||
|
<tr key={k.id}>
|
||||||
|
<td>{k.name || '-'}</td>
|
||||||
|
<td className="mono-text">{k.api_key.slice(0, 12)}...</td>
|
||||||
|
<td>{k.username || k.user_id.slice(0, 8)}</td>
|
||||||
|
<td>{k.rate_limit}/min</td>
|
||||||
|
<td>{statusBadge(k.status)}</td>
|
||||||
|
<td>{fmtTime(k.last_used_at)}</td>
|
||||||
|
<td>{fmtTime(k.created_at)}</td>
|
||||||
|
<td className="dev-actions">
|
||||||
|
{k.status === 'active' && <button className="dev-btn-sm warn" onClick={() => handleRevokeKey(k.id)}>撤销</button>}
|
||||||
|
<button className="dev-btn-sm danger" onClick={() => handleDeleteKey(k.id)}>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{keys.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无API密钥</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{keys.total > 20 && (
|
||||||
|
<div className="dev-pagination">
|
||||||
|
<button disabled={keys.page <= 1} onClick={() => loadKeys(keys.page - 1)}>上一页</button>
|
||||||
|
<span>第 {keys.page} 页 / 共 {Math.ceil(keys.total / 20)} 页</span>
|
||||||
|
<button disabled={keys.page >= Math.ceil(keys.total / 20)} onClick={() => loadKeys(keys.page + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* OpenAPI Clients Tab */}
|
||||||
|
{activeTab === 'clients' && !loading && (
|
||||||
|
<div>
|
||||||
|
<div className="dev-toolbar">
|
||||||
|
<input className="dev-input" placeholder="搜索客户端名称/ID..." value={clientSearch} onChange={e => setClientSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadClients()} />
|
||||||
|
<button className="dev-btn primary" onClick={() => setShowClientModal(true)}>+ 新建客户端</button>
|
||||||
|
</div>
|
||||||
|
<table className="dev-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>名称</th><th>Client ID</th><th>用户</th><th>允许模型</th><th>速率限制</th><th>状态</th><th>创建时间</th><th>操作</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{clients.items.map(c => (
|
||||||
|
<tr key={c.id}>
|
||||||
|
<td>{c.name}</td>
|
||||||
|
<td className="mono-text">{c.client_id}</td>
|
||||||
|
<td>{c.username || c.user_id.slice(0, 8)}</td>
|
||||||
|
<td>{c.allowed_models?.length > 0 ? `${c.allowed_models.length} 个模型` : '全部'}</td>
|
||||||
|
<td>{c.rate_limit}/min</td>
|
||||||
|
<td>{statusBadge(c.status)}</td>
|
||||||
|
<td>{fmtTime(c.created_at)}</td>
|
||||||
|
<td className="dev-actions">
|
||||||
|
<button className="dev-btn-sm" onClick={() => handleToggleClient(c)}>{c.status === 'active' ? '禁用' : '启用'}</button>
|
||||||
|
<button className="dev-btn-sm warn" onClick={() => handleResetSecret(c.id)}>重置密钥</button>
|
||||||
|
<button className="dev-btn-sm danger" onClick={() => handleDeleteClient(c.id)}>删除</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{clients.items.length === 0 && <tr><td colSpan={8} className="dev-empty">暂无OpenAPI客户端</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{clients.total > 20 && (
|
||||||
|
<div className="dev-pagination">
|
||||||
|
<button disabled={clients.page <= 1} onClick={() => loadClients(clients.page - 1)}>上一页</button>
|
||||||
|
<span>第 {clients.page} 页 / 共 {Math.ceil(clients.total / 20)} 页</span>
|
||||||
|
<button disabled={clients.page >= Math.ceil(clients.total / 20)} onClick={() => loadClients(clients.page + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Call Stats Tab */}
|
||||||
|
{activeTab === 'stats' && !loading && (
|
||||||
|
<div>
|
||||||
|
<div className="dev-toolbar">
|
||||||
|
<input className="dev-input" placeholder="搜索端点..." value={statSearch} onChange={e => setStatSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadCallStats()} />
|
||||||
|
<select className="dev-select" value={statSortBy} onChange={e => setStatSortBy(e.target.value)}>
|
||||||
|
<option value="call_count">按调用次数</option>
|
||||||
|
<option value="error_count">按错误数</option>
|
||||||
|
<option value="total_response_time">按总耗时</option>
|
||||||
|
<option value="last_called_at">按最后调用</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<table className="dev-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>端点</th><th>方法</th><th>调用次数</th><th>错误数</th><th>错误率</th><th>平均耗时</th><th>最后调用</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{callStats.items.map(s => (
|
||||||
|
<tr key={s.id}>
|
||||||
|
<td className="mono-text">{s.endpoint}</td>
|
||||||
|
<td><span className={`method-badge method-${s.method.toLowerCase()}`}>{s.method}</span></td>
|
||||||
|
<td>{s.call_count.toLocaleString()}</td>
|
||||||
|
<td className={s.error_count > 0 ? 'text-danger' : ''}>{s.error_count}</td>
|
||||||
|
<td className={s.error_rate > 5 ? 'text-danger' : ''}>{s.error_rate}%</td>
|
||||||
|
<td>{s.avg_response_time}s</td>
|
||||||
|
<td>{fmtTime(s.last_called_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{callStats.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无调用统计</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{callStats.total > 20 && (
|
||||||
|
<div className="dev-pagination">
|
||||||
|
<button disabled={callStats.page <= 1} onClick={() => loadCallStats(callStats.page - 1)}>上一页</button>
|
||||||
|
<span>第 {callStats.page} 页 / 共 {Math.ceil(callStats.total / 20)} 页</span>
|
||||||
|
<button disabled={callStats.page >= Math.ceil(callStats.total / 20)} onClick={() => loadCallStats(callStats.page + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Slow Logs Tab */}
|
||||||
|
{activeTab === 'logs' && !loading && (
|
||||||
|
<div>
|
||||||
|
<div className="dev-toolbar">
|
||||||
|
<input className="dev-input" placeholder="搜索端点..." value={logSearch} onChange={e => setLogSearch(e.target.value)} onKeyDown={e => e.key === 'Enter' && loadSlowLogs()} />
|
||||||
|
<button className="dev-btn danger" onClick={handleClearLogs}>清空日志</button>
|
||||||
|
</div>
|
||||||
|
<table className="dev-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>请求ID</th><th>方法</th><th>端点</th><th>状态码</th><th>响应时间</th><th>阈值</th><th>时间</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{slowLogs.items.map(l => (
|
||||||
|
<tr key={l.id}>
|
||||||
|
<td className="mono-text">{l.request_id?.slice(0, 12) || '-'}</td>
|
||||||
|
<td><span className={`method-badge method-${l.method.toLowerCase()}`}>{l.method}</span></td>
|
||||||
|
<td className="mono-text">{l.endpoint.length > 60 ? l.endpoint.slice(0, 60) + '...' : l.endpoint}</td>
|
||||||
|
<td className={l.status_code >= 400 ? 'text-danger' : ''}>{l.status_code}</td>
|
||||||
|
<td className="text-warn">{l.response_time}s</td>
|
||||||
|
<td>{l.threshold}s</td>
|
||||||
|
<td>{fmtTime(l.created_at)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{slowLogs.items.length === 0 && <tr><td colSpan={7} className="dev-empty">暂无慢请求日志</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{slowLogs.total > 20 && (
|
||||||
|
<div className="dev-pagination">
|
||||||
|
<button disabled={slowLogs.page <= 1} onClick={() => loadSlowLogs(slowLogs.page - 1)}>上一页</button>
|
||||||
|
<span>第 {slowLogs.page} 页 / 共 {Math.ceil(slowLogs.total / 20)} 页</span>
|
||||||
|
<button disabled={slowLogs.page >= Math.ceil(slowLogs.total / 20)} onClick={() => loadSlowLogs(slowLogs.page + 1)}>下一页</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Create API Key Modal */}
|
||||||
|
{showKeyModal && (
|
||||||
|
<div className="dev-modal-overlay" onClick={() => setShowKeyModal(false)}>
|
||||||
|
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>新建API密钥</h3>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>用户ID *</label>
|
||||||
|
<input className="dev-input" value={keyForm.user_id} onChange={e => setKeyForm({ ...keyForm, user_id: e.target.value })} placeholder="输入用户ID" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>名称</label>
|
||||||
|
<input className="dev-input" value={keyForm.name} onChange={e => setKeyForm({ ...keyForm, name: e.target.value })} placeholder="给密钥起个名字" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>描述</label>
|
||||||
|
<input className="dev-input" value={keyForm.description} onChange={e => setKeyForm({ ...keyForm, description: e.target.value })} placeholder="密钥用途描述" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>速率限制 (请求/分钟)</label>
|
||||||
|
<input type="number" className="dev-input" value={keyForm.rate_limit} onChange={e => setKeyForm({ ...keyForm, rate_limit: parseInt(e.target.value) || 60 })} />
|
||||||
|
</div>
|
||||||
|
<div className="dev-modal-actions">
|
||||||
|
<button className="dev-btn" onClick={() => setShowKeyModal(false)}>取消</button>
|
||||||
|
<button className="dev-btn primary" onClick={handleCreateKey} disabled={!keyForm.user_id}>创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Client Modal */}
|
||||||
|
{showClientModal && (
|
||||||
|
<div className="dev-modal-overlay" onClick={() => setShowClientModal(false)}>
|
||||||
|
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>新建OpenAPI客户端</h3>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>用户ID *</label>
|
||||||
|
<input className="dev-input" value={clientForm.user_id} onChange={e => setClientForm({ ...clientForm, user_id: e.target.value })} placeholder="输入用户ID" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>名称 *</label>
|
||||||
|
<input className="dev-input" value={clientForm.name} onChange={e => setClientForm({ ...clientForm, name: e.target.value })} placeholder="客户端名称" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>允许的模型 (逗号分隔,留空=全部)</label>
|
||||||
|
<input className="dev-input" value={clientForm.allowed_models} onChange={e => setClientForm({ ...clientForm, allowed_models: e.target.value })} placeholder="deepseek-v3, gpt-4o" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>速率限制 (请求/分钟)</label>
|
||||||
|
<input type="number" className="dev-input" value={clientForm.rate_limit} onChange={e => setClientForm({ ...clientForm, rate_limit: parseInt(e.target.value) || 60 })} />
|
||||||
|
</div>
|
||||||
|
<div className="dev-modal-actions">
|
||||||
|
<button className="dev-btn" onClick={() => setShowClientModal(false)}>取消</button>
|
||||||
|
<button className="dev-btn primary" onClick={handleCreateClient} disabled={!clientForm.user_id || !clientForm.name}>创建</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user