Phase 3.9: Developer Tools Enhancement - Webhooks, SDK Center, API Docs, Usage Dashboard
This commit is contained in:
1 parent
065d2f05f6
commit
49dcea0a11
6 files changed
+1460
No files matched your search
@@ -0,0 +1,899 @@
|
|||||||
|
"""
|
||||||
|
Developer Tools Enhancement - Webhooks, SDK Center, API Docs, Usage Dashboard.
|
||||||
|
Phase 3.9: Extends the developer module with webhook management,
|
||||||
|
SDK download center, interactive API documentation, and enhanced analytics.
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import text
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ...database import get_db, SessionLocal
|
||||||
|
from ...auth import get_current_admin
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_raw(db, sql, params=None):
|
||||||
|
"""Execute raw SQL and return list of dicts."""
|
||||||
|
result = db.execute(text(sql), params or {})
|
||||||
|
rows = []
|
||||||
|
for row in result:
|
||||||
|
rows.append(dict(row._mapping))
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Pydantic Models
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class WebhookCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
url: str
|
||||||
|
events: list = []
|
||||||
|
secret: str = ""
|
||||||
|
headers: dict = {}
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookUpdate(BaseModel):
|
||||||
|
name: str = None
|
||||||
|
url: str = None
|
||||||
|
events: list = None
|
||||||
|
secret: str = None
|
||||||
|
headers: dict = None
|
||||||
|
status: str = None
|
||||||
|
|
||||||
|
|
||||||
|
class WebhookTest(BaseModel):
|
||||||
|
event: str = "test"
|
||||||
|
payload: dict = {"message": "Test webhook delivery"}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Webhook Management
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
SUPPORTED_EVENTS = [
|
||||||
|
{"value": "app.created", "label": "App Created", "desc": "Triggered when a new AI app is created"},
|
||||||
|
{"value": "app.published", "label": "App Published", "desc": "Triggered when an app is published to market"},
|
||||||
|
{"value": "agent.created", "label": "Agent Created", "desc": "Triggered when a new agent is created"},
|
||||||
|
{"value": "knowledge.updated", "label": "Knowledge Updated", "desc": "Triggered when knowledge base is updated"},
|
||||||
|
{"value": "api_key.created", "label": "API Key Created", "desc": "Triggered when a new API key is generated"},
|
||||||
|
{"value": "api_key.revoked", "label": "API Key Revoked", "desc": "Triggered when an API key is revoked"},
|
||||||
|
{"value": "user.registered", "label": "User Registered", "desc": "Triggered when a new user registers"},
|
||||||
|
{"value": "model.connected", "label": "Model Connected", "desc": "Triggered when a model connection test passes"},
|
||||||
|
{"value": "workflow.completed", "label": "Workflow Completed", "desc": "Triggered when a workflow execution finishes"},
|
||||||
|
{"value": "storage.threshold", "label": "Storage Threshold", "desc": "Triggered when storage usage exceeds threshold"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhooks", summary="List webhooks")
|
||||||
|
async def list_webhooks(
|
||||||
|
status: str = Query(None),
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""List all webhook configurations."""
|
||||||
|
where_clause = "WHERE 1=1"
|
||||||
|
params = {}
|
||||||
|
if status:
|
||||||
|
where_clause += " AND status = :status"
|
||||||
|
params["status"] = status
|
||||||
|
|
||||||
|
count_sql = f"SELECT COUNT(*) as cnt FROM webhook_configs {where_clause}"
|
||||||
|
count_rows = _execute_raw(db, count_sql, params)
|
||||||
|
total = count_rows[0]["cnt"] if count_rows else 0
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
list_sql = f"""SELECT id, name, url, events, secret, headers, status,
|
||||||
|
last_triggered_at, last_status, total_deliveries, success_count, fail_count,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM webhook_configs {where_clause}
|
||||||
|
ORDER BY created_at DESC LIMIT :limit OFFSET :offset"""
|
||||||
|
list_params = dict(params)
|
||||||
|
list_params["limit"] = page_size
|
||||||
|
list_params["offset"] = offset
|
||||||
|
rows = _execute_raw(db, list_sql, list_params)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
events_raw = r.get("events")
|
||||||
|
if isinstance(events_raw, str):
|
||||||
|
try:
|
||||||
|
events_val = json.loads(events_raw)
|
||||||
|
except Exception:
|
||||||
|
events_val = []
|
||||||
|
else:
|
||||||
|
events_val = events_raw or []
|
||||||
|
|
||||||
|
headers_raw = r.get("headers")
|
||||||
|
if isinstance(headers_raw, str):
|
||||||
|
try:
|
||||||
|
headers_val = json.loads(headers_raw)
|
||||||
|
except Exception:
|
||||||
|
headers_val = {}
|
||||||
|
else:
|
||||||
|
headers_val = headers_raw or {}
|
||||||
|
|
||||||
|
success_rate = 0
|
||||||
|
if r.get("total_deliveries", 0) > 0:
|
||||||
|
success_rate = round(r["success_count"] / r["total_deliveries"] * 100, 1)
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"name": r["name"],
|
||||||
|
"url": r["url"],
|
||||||
|
"events": events_val,
|
||||||
|
"has_secret": bool(r.get("secret")),
|
||||||
|
"headers": headers_val,
|
||||||
|
"status": r["status"],
|
||||||
|
"last_triggered_at": fmt_time(r.get("last_triggered_at")),
|
||||||
|
"last_status": r.get("last_status"),
|
||||||
|
"total_deliveries": r.get("total_deliveries", 0),
|
||||||
|
"success_count": r.get("success_count", 0),
|
||||||
|
"fail_count": r.get("fail_count", 0),
|
||||||
|
"success_rate": success_rate,
|
||||||
|
"created_at": fmt_time(r.get("created_at")),
|
||||||
|
"updated_at": fmt_time(r.get("updated_at")),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhooks/events", summary="List supported webhook events")
|
||||||
|
async def list_webhook_events(
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""List all supported webhook event types."""
|
||||||
|
return {"code": 0, "data": {"events": SUPPORTED_EVENTS}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/", summary="Create webhook")
|
||||||
|
async def create_webhook(
|
||||||
|
body: WebhookCreate,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a new webhook configuration."""
|
||||||
|
webhook_id = "wh_" + secrets.token_hex(12)
|
||||||
|
now = cst_now()
|
||||||
|
|
||||||
|
secret_val = body.secret if body.secret else "whsec_" + secrets.token_hex(16)
|
||||||
|
|
||||||
|
insert_sql = """INSERT INTO webhook_configs
|
||||||
|
(id, name, url, events, secret, headers, status, last_triggered_at,
|
||||||
|
last_status, total_deliveries, success_count, fail_count, created_at, updated_at)
|
||||||
|
VALUES (:id, :name, :url, :events, :secret, :headers, 'active',
|
||||||
|
NULL, NULL, 0, 0, 0, :now, :now)"""
|
||||||
|
params = {
|
||||||
|
"id": webhook_id,
|
||||||
|
"name": body.name,
|
||||||
|
"url": body.url,
|
||||||
|
"events": json.dumps(body.events),
|
||||||
|
"secret": secret_val,
|
||||||
|
"headers": json.dumps(body.headers),
|
||||||
|
"now": now,
|
||||||
|
}
|
||||||
|
db.execute(text(insert_sql), params)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"code": 0, "data": {"id": webhook_id, "name": body.name, "secret": secret_val, "message": "Webhook created"}}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/webhooks/{webhook_id}", summary="Update webhook")
|
||||||
|
async def update_webhook(
|
||||||
|
webhook_id: str,
|
||||||
|
body: WebhookUpdate,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Update an existing webhook configuration."""
|
||||||
|
check_sql = "SELECT id FROM webhook_configs WHERE id = :id"
|
||||||
|
rows = _execute_raw(db, check_sql, {"id": webhook_id})
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
params = {"id": webhook_id, "now": cst_now()}
|
||||||
|
if body.name is not None:
|
||||||
|
updates.append("name = :name")
|
||||||
|
params["name"] = body.name
|
||||||
|
if body.url is not None:
|
||||||
|
updates.append("url = :url")
|
||||||
|
params["url"] = body.url
|
||||||
|
if body.events is not None:
|
||||||
|
updates.append("events = :events")
|
||||||
|
params["events"] = json.dumps(body.events)
|
||||||
|
if body.secret is not None:
|
||||||
|
updates.append("secret = :secret")
|
||||||
|
params["secret"] = body.secret
|
||||||
|
if body.headers is not None:
|
||||||
|
updates.append("headers = :headers")
|
||||||
|
params["headers"] = json.dumps(body.headers)
|
||||||
|
if body.status is not None:
|
||||||
|
updates.append("status = :status")
|
||||||
|
params["status"] = body.status
|
||||||
|
|
||||||
|
if not updates:
|
||||||
|
return {"code": 0, "message": "No changes"}
|
||||||
|
|
||||||
|
updates.append("updated_at = :now")
|
||||||
|
update_sql = f"UPDATE webhook_configs SET {', '.join(updates)} WHERE id = :id"
|
||||||
|
db.execute(text(update_sql), params)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"code": 0, "message": "Webhook updated"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/webhooks/{webhook_id}", summary="Delete webhook")
|
||||||
|
async def delete_webhook(
|
||||||
|
webhook_id: str,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Delete a webhook configuration."""
|
||||||
|
check_sql = "SELECT id FROM webhook_configs WHERE id = :id"
|
||||||
|
rows = _execute_raw(db, check_sql, {"id": webhook_id})
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||||
|
|
||||||
|
db.execute(text("DELETE FROM webhook_configs WHERE id = :id"), {"id": webhook_id})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {"code": 0, "message": "Webhook deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/webhooks/{webhook_id}/test", summary="Test webhook delivery")
|
||||||
|
async def test_webhook(
|
||||||
|
webhook_id: str,
|
||||||
|
body: WebhookTest = None,
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Send a test payload to the webhook endpoint."""
|
||||||
|
select_sql = "SELECT * FROM webhook_configs WHERE id = :id"
|
||||||
|
rows = _execute_raw(db, select_sql, {"id": webhook_id})
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Webhook not found")
|
||||||
|
|
||||||
|
wh = rows[0]
|
||||||
|
url = wh["url"]
|
||||||
|
secret = wh.get("secret", "")
|
||||||
|
|
||||||
|
test_event = body.event if body else "test"
|
||||||
|
test_payload = body.payload if body else {"message": "Test webhook delivery"}
|
||||||
|
full_payload = {
|
||||||
|
"event": test_event,
|
||||||
|
"timestamp": cst_now().isoformat(),
|
||||||
|
"webhook_id": webhook_id,
|
||||||
|
"data": test_payload,
|
||||||
|
}
|
||||||
|
payload_bytes = json.dumps(full_payload).encode("utf-8")
|
||||||
|
|
||||||
|
# Build headers
|
||||||
|
headers = {"Content-Type": "application/json"}
|
||||||
|
headers_raw = wh.get("headers")
|
||||||
|
if isinstance(headers_raw, str):
|
||||||
|
try:
|
||||||
|
headers.update(json.loads(headers_raw))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
elif isinstance(headers_raw, dict):
|
||||||
|
headers.update(headers_raw)
|
||||||
|
|
||||||
|
# HMAC signature
|
||||||
|
if secret:
|
||||||
|
signature = hmac.new(secret.encode("utf-8"), payload_bytes, hashlib.sha256).hexdigest()
|
||||||
|
headers["X-Webhook-Signature"] = signature
|
||||||
|
|
||||||
|
# Send request
|
||||||
|
delivery_id = "dlv_" + secrets.token_hex(12)
|
||||||
|
now = cst_now()
|
||||||
|
response_code = None
|
||||||
|
response_body = None
|
||||||
|
response_time_ms = None
|
||||||
|
status = "failed"
|
||||||
|
error_message = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
req = urllib.request.Request(url, data=payload_bytes, headers=headers, method="POST")
|
||||||
|
start_time = datetime.now()
|
||||||
|
resp = urllib.request.urlopen(req, timeout=10)
|
||||||
|
end_time = datetime.now()
|
||||||
|
response_code = resp.getcode()
|
||||||
|
response_body = resp.read().decode("utf-8", errors="replace")[:2000]
|
||||||
|
response_time_ms = int((end_time - start_time).total_seconds() * 1000)
|
||||||
|
if 200 <= response_code < 300:
|
||||||
|
status = "success"
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
response_code = e.code
|
||||||
|
try:
|
||||||
|
response_body = e.read().decode("utf-8", errors="replace")[:2000]
|
||||||
|
except Exception:
|
||||||
|
response_body = str(e)
|
||||||
|
error_message = f"HTTP {e.code}: {str(e)[:200]}"
|
||||||
|
except Exception as e:
|
||||||
|
error_message = str(e)[:500]
|
||||||
|
|
||||||
|
# Record delivery
|
||||||
|
insert_sql = """INSERT INTO webhook_deliveries
|
||||||
|
(id, webhook_id, event, payload, status, response_code, response_body,
|
||||||
|
response_time_ms, attempts, error_message, created_at)
|
||||||
|
VALUES (:id, :wid, :event, :payload, :status, :rcode, :rbody,
|
||||||
|
:rtime, 1, :err, :now)"""
|
||||||
|
db.execute(text(insert_sql), {
|
||||||
|
"id": delivery_id, "wid": webhook_id, "event": test_event,
|
||||||
|
"payload": json.dumps(full_payload), "status": status,
|
||||||
|
"rcode": response_code, "rbody": response_body,
|
||||||
|
"rtime": response_time_ms, "err": error_message, "now": now,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Update webhook stats
|
||||||
|
total_deliveries = (wh.get("total_deliveries") or 0) + 1
|
||||||
|
success_count = (wh.get("success_count") or 0) + (1 if status == "success" else 0)
|
||||||
|
fail_count = (wh.get("fail_count") or 0) + (1 if status != "success" else 0)
|
||||||
|
db.execute(text("""UPDATE webhook_configs SET
|
||||||
|
total_deliveries = :td, success_count = :sc, fail_count = :fc,
|
||||||
|
last_triggered_at = :now, last_status = :ls, updated_at = :now
|
||||||
|
WHERE id = :id"""), {
|
||||||
|
"td": total_deliveries, "sc": success_count, "fc": fail_count,
|
||||||
|
"now": now, "ls": status, "id": webhook_id,
|
||||||
|
})
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"delivery_id": delivery_id,
|
||||||
|
"status": status,
|
||||||
|
"response_code": response_code,
|
||||||
|
"response_time_ms": response_time_ms,
|
||||||
|
"response_body": response_body[:500] if response_body else None,
|
||||||
|
"error_message": error_message,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/webhooks/{webhook_id}/deliveries", summary="Webhook delivery history")
|
||||||
|
async def webhook_deliveries(
|
||||||
|
webhook_id: str,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get delivery history for a specific webhook."""
|
||||||
|
count_sql = "SELECT COUNT(*) as cnt FROM webhook_deliveries WHERE webhook_id = :wid"
|
||||||
|
count_rows = _execute_raw(db, count_sql, {"wid": webhook_id})
|
||||||
|
total = count_rows[0]["cnt"] if count_rows else 0
|
||||||
|
|
||||||
|
offset = (page - 1) * page_size
|
||||||
|
list_sql = """SELECT id, webhook_id, event, payload, status, response_code,
|
||||||
|
response_body, response_time_ms, attempts, error_message, created_at
|
||||||
|
FROM webhook_deliveries WHERE webhook_id = :wid
|
||||||
|
ORDER BY created_at DESC LIMIT :limit OFFSET :offset"""
|
||||||
|
rows = _execute_raw(db, list_sql, {"wid": webhook_id, "limit": page_size, "offset": offset})
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for r in rows:
|
||||||
|
payload_raw = r.get("payload")
|
||||||
|
if isinstance(payload_raw, str):
|
||||||
|
try:
|
||||||
|
payload_val = json.loads(payload_raw)
|
||||||
|
except Exception:
|
||||||
|
payload_val = payload_raw
|
||||||
|
else:
|
||||||
|
payload_val = payload_raw
|
||||||
|
|
||||||
|
items.append({
|
||||||
|
"id": r["id"],
|
||||||
|
"event": r["event"],
|
||||||
|
"status": r["status"],
|
||||||
|
"response_code": r.get("response_code"),
|
||||||
|
"response_time_ms": r.get("response_time_ms"),
|
||||||
|
"attempts": r.get("attempts", 0),
|
||||||
|
"error_message": r.get("error_message"),
|
||||||
|
"payload": payload_val,
|
||||||
|
"response_body": (r.get("response_body") or "")[:500],
|
||||||
|
"created_at": fmt_time(r.get("created_at")),
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"code": 0, "data": {"total": total, "page": page, "page_size": page_size, "items": items}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# SDK Download Center
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
SDK_PACKAGES = [
|
||||||
|
{
|
||||||
|
"language": "python",
|
||||||
|
"name": "Python SDK",
|
||||||
|
"version": "1.2.0",
|
||||||
|
"description": "Official Python SDK for Qihu-AIFlow platform. Supports chat completions, knowledge base, agent execution, and file upload.",
|
||||||
|
"install_cmd": "pip install qihu-aiflow",
|
||||||
|
"download_url": "https://pypi.org/project/qihu-aiflow/",
|
||||||
|
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-python",
|
||||||
|
"min_version": "Python 3.8+",
|
||||||
|
"features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "Webhook verification", "Async support"],
|
||||||
|
"code_example": '''from qihu_aiflow import QihuClient
|
||||||
|
|
||||||
|
client = QihuClient(api_key="sk-your-api-key", base_url="http://210.16.161.218:18080")
|
||||||
|
|
||||||
|
# Chat completion
|
||||||
|
response = client.chat.create(
|
||||||
|
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||||
|
messages=[{"role": "user", "content": "Hello!"}]
|
||||||
|
)
|
||||||
|
print(response.choices[0].message.content)
|
||||||
|
|
||||||
|
# Streaming
|
||||||
|
for chunk in client.chat.create(
|
||||||
|
model="deepseek-ai/DeepSeek-V4-Flash",
|
||||||
|
messages=[{"role": "user", "content": "Tell me a story"}],
|
||||||
|
stream=True
|
||||||
|
):
|
||||||
|
print(chunk.choices[0].delta.content, end="")''',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"language": "javascript",
|
||||||
|
"name": "JavaScript SDK",
|
||||||
|
"version": "1.1.0",
|
||||||
|
"description": "Official JavaScript/TypeScript SDK for browser and Node.js. Full TypeScript support with type definitions.",
|
||||||
|
"install_cmd": "npm install @qihu/aiflow",
|
||||||
|
"download_url": "https://www.npmjs.com/package/@qihu/aiflow",
|
||||||
|
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-js",
|
||||||
|
"min_version": "Node.js 16+ / Browser",
|
||||||
|
"features": ["Chat completions (streaming)", "Knowledge base queries", "Agent execution", "File upload", "WebSocket support", "TypeScript types"],
|
||||||
|
"code_example": '''import { QihuClient } from "@qihu/aiflow";
|
||||||
|
|
||||||
|
const client = new QihuClient({
|
||||||
|
apiKey: "sk-your-api-key",
|
||||||
|
baseUrl: "http://210.16.161.218:18080",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Chat completion
|
||||||
|
const response = await client.chat.create({
|
||||||
|
model: "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
|
messages: [{ role: "user", content: "Hello!" }],
|
||||||
|
});
|
||||||
|
console.log(response.choices[0].message.content);
|
||||||
|
|
||||||
|
// Streaming
|
||||||
|
const stream = await client.chat.create({
|
||||||
|
model: "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
|
messages: [{ role: "user", content: "Tell me a story" }],
|
||||||
|
stream: true,
|
||||||
|
});
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
process.stdout.write(chunk.choices[0].delta.content || "");
|
||||||
|
}''',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"language": "go",
|
||||||
|
"name": "Go SDK",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Official Go SDK for Qihu-AIFlow. Lightweight and fast, suitable for microservices and CLI tools.",
|
||||||
|
"install_cmd": "go get github.com/qihuyun/qihu-aiflow-sdk-go",
|
||||||
|
"download_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go/releases",
|
||||||
|
"github_url": "https://github.com/qihuyun/qihu-aiflow-sdk-go",
|
||||||
|
"min_version": "Go 1.19+",
|
||||||
|
"features": ["Chat completions", "Knowledge base queries", "Agent execution", "Context cancellation", "Retry logic", "Connection pooling"],
|
||||||
|
"code_example": '''package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"github.com/qihuyun/qihu-aiflow-sdk-go/qihu"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
client := qihu.NewClient("sk-your-api-key", "http://210.16.161.218:18080")
|
||||||
|
|
||||||
|
resp, err := client.Chat.Create(context.Background(), &qihu.ChatRequest{
|
||||||
|
Model: "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
|
Messages: []qihu.Message{
|
||||||
|
{Role: "user", Content: "Hello!"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println(resp.Choices[0].Message.Content)
|
||||||
|
}''',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"language": "curl",
|
||||||
|
"name": "REST API (cURL)",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Direct REST API access without SDK. Use any HTTP client to interact with the platform.",
|
||||||
|
"install_cmd": "# No installation needed - use curl directly",
|
||||||
|
"download_url": "https://docs.qihuyun.com/api-reference",
|
||||||
|
"github_url": "",
|
||||||
|
"min_version": "Any HTTP client",
|
||||||
|
"features": ["Chat completions", "Knowledge base queries", "Agent execution", "File upload", "Webhook management", "No dependencies"],
|
||||||
|
"code_example": '''# Chat completion
|
||||||
|
curl -X POST http://210.16.161.218:18080/app/api/chat/completions \\
|
||||||
|
-H "Authorization: Bearer sk-your-api-key" \\
|
||||||
|
-H "Content-Type: application/json" \\
|
||||||
|
-d '{
|
||||||
|
"model": "deepseek-ai/DeepSeek-V4-Flash",
|
||||||
|
"messages": [{"role": "user", "content": "Hello!"}]
|
||||||
|
}'
|
||||||
|
|
||||||
|
# List knowledge bases
|
||||||
|
curl http://210.16.161.218:18080/app/api/knowledge-bases \\
|
||||||
|
-H "Authorization: Bearer sk-your-api-key"''',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sdk/packages", summary="List SDK packages")
|
||||||
|
async def list_sdk_packages(
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""List all available SDK packages with download info and code examples."""
|
||||||
|
return {"code": 0, "data": {"packages": SDK_PACKAGES}}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Interactive API Documentation
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
API_DOC_SECTIONS = [
|
||||||
|
{
|
||||||
|
"section": "Authentication",
|
||||||
|
"icon": "lock",
|
||||||
|
"description": "All API requests require authentication via Bearer token.",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/app/api/auth/login",
|
||||||
|
"title": "User Login",
|
||||||
|
"description": "Authenticate and obtain an access token.",
|
||||||
|
"params": [
|
||||||
|
{"name": "username", "type": "string", "required": True, "desc": "Username"},
|
||||||
|
{"name": "password", "type": "string", "required": True, "desc": "Password"},
|
||||||
|
],
|
||||||
|
"example_request": '{"username": "admin", "password": "admin123"}',
|
||||||
|
"example_response": '{"code": 0, "data": {"access_token": "eyJ...", "token_type": "bearer", "user": {"id": "admin-001", "username": "admin"}}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Chat & Completions",
|
||||||
|
"icon": "chat",
|
||||||
|
"description": "AI chat completion endpoints for conversational AI.",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/app/api/chat/completions",
|
||||||
|
"title": "Chat Completion",
|
||||||
|
"description": "Create a chat completion with the specified model.",
|
||||||
|
"params": [
|
||||||
|
{"name": "model", "type": "string", "required": True, "desc": "Model ID (e.g. deepseek-ai/DeepSeek-V4-Flash)"},
|
||||||
|
{"name": "messages", "type": "array", "required": True, "desc": "Array of message objects"},
|
||||||
|
{"name": "stream", "type": "boolean", "required": False, "desc": "Enable streaming response"},
|
||||||
|
{"name": "temperature", "type": "number", "required": False, "desc": "Sampling temperature (0-2)"},
|
||||||
|
{"name": "max_tokens", "type": "integer", "required": False, "desc": "Maximum tokens to generate"},
|
||||||
|
],
|
||||||
|
"example_request": '{"model": "deepseek-ai/DeepSeek-V4-Flash", "messages": [{"role": "user", "content": "Hello"}]}',
|
||||||
|
"example_response": '{"code": 0, "data": {"id": "chat-xxx", "choices": [{"message": {"role": "assistant", "content": "Hi there!"}}]}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Knowledge Base",
|
||||||
|
"icon": "book",
|
||||||
|
"description": "Manage knowledge bases and documents for RAG.",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/app/api/knowledge-bases",
|
||||||
|
"title": "List Knowledge Bases",
|
||||||
|
"description": "Get all knowledge bases for the current user.",
|
||||||
|
"params": [],
|
||||||
|
"example_request": "",
|
||||||
|
"example_response": '{"code": 0, "data": [{"id": "kb-001", "name": "Product Docs", "document_count": 15}]}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/app/api/knowledge-bases/{kb_id}/documents",
|
||||||
|
"title": "Upload Document",
|
||||||
|
"description": "Upload a document to a knowledge base for processing.",
|
||||||
|
"params": [
|
||||||
|
{"name": "file", "type": "file", "required": True, "desc": "Document file (PDF, TXT, DOCX, MD)"},
|
||||||
|
],
|
||||||
|
"example_request": "multipart/form-data: file=@document.pdf",
|
||||||
|
"example_response": '{"code": 0, "data": {"document_id": "doc-001", "status": "processing"}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Agents",
|
||||||
|
"icon": "robot",
|
||||||
|
"description": "Agent management and execution endpoints.",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/app/api/agents",
|
||||||
|
"title": "List Agents",
|
||||||
|
"description": "Get all available agents.",
|
||||||
|
"params": [],
|
||||||
|
"example_request": "",
|
||||||
|
"example_response": '{"code": 0, "data": [{"id": "agent-001", "name": "Code Assistant", "type": "agent"}]}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/app/api/agents/{agent_id}/execute",
|
||||||
|
"title": "Execute Agent",
|
||||||
|
"description": "Execute an agent with a given input.",
|
||||||
|
"params": [
|
||||||
|
{"name": "input", "type": "string", "required": True, "desc": "Input text for the agent"},
|
||||||
|
{"name": "context", "type": "object", "required": False, "desc": "Additional context"},
|
||||||
|
],
|
||||||
|
"example_request": '{"input": "Help me write a Python function"}',
|
||||||
|
"example_response": '{"code": 0, "data": {"output": "Here is a Python function...", "tool_calls": []}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Workflows",
|
||||||
|
"icon": "workflow",
|
||||||
|
"description": "DAG workflow orchestration endpoints.",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/app/api/admin/workflows/templates",
|
||||||
|
"title": "List Workflow Templates",
|
||||||
|
"description": "Get predefined workflow templates.",
|
||||||
|
"params": [],
|
||||||
|
"example_request": "",
|
||||||
|
"example_response": '{"code": 0, "data": [{"id": "tpl-001", "name": "Q&A Pipeline", "nodes": [...]}]}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/app/api/admin/workflows/{id}/execute",
|
||||||
|
"title": "Execute Workflow",
|
||||||
|
"description": "Execute a workflow with input data.",
|
||||||
|
"params": [
|
||||||
|
{"name": "input", "type": "string", "required": True, "desc": "Input for the workflow"},
|
||||||
|
],
|
||||||
|
"example_request": '{"input": "Summarize this article"}',
|
||||||
|
"example_response": '{"code": 0, "data": {"execution_id": "exec-001", "status": "completed", "result": "..."}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "File Storage",
|
||||||
|
"icon": "storage",
|
||||||
|
"description": "File upload and storage management.",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/app/api/files/upload",
|
||||||
|
"title": "Upload File",
|
||||||
|
"description": "Upload a file to storage (MinIO/OSS).",
|
||||||
|
"params": [
|
||||||
|
{"name": "file", "type": "file", "required": True, "desc": "File to upload"},
|
||||||
|
{"name": "category", "type": "string", "required": False, "desc": "File category"},
|
||||||
|
],
|
||||||
|
"example_request": "multipart/form-data: file=@image.png",
|
||||||
|
"example_response": '{"code": 0, "data": {"url": "https://...", "size": 102400, "file_type": "image"}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"section": "Webhooks",
|
||||||
|
"icon": "webhook",
|
||||||
|
"description": "Webhook configuration and management.",
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/app/api/admin/developer-tools/webhooks",
|
||||||
|
"title": "List Webhooks",
|
||||||
|
"description": "List all configured webhooks.",
|
||||||
|
"params": [],
|
||||||
|
"example_request": "",
|
||||||
|
"example_response": '{"code": 0, "data": {"total": 2, "items": [{"id": "wh_xxx", "name": "My Webhook", "url": "https://...", "status": "active"}]}}',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"path": "/app/api/admin/developer-tools/webhooks/",
|
||||||
|
"title": "Create Webhook",
|
||||||
|
"description": "Create a new webhook endpoint.",
|
||||||
|
"params": [
|
||||||
|
{"name": "name", "type": "string", "required": True, "desc": "Webhook name"},
|
||||||
|
{"name": "url", "type": "string", "required": True, "desc": "Webhook URL"},
|
||||||
|
{"name": "events", "type": "array", "required": True, "desc": "Events to subscribe"},
|
||||||
|
],
|
||||||
|
"example_request": '{"name": "My Webhook", "url": "https://example.com/webhook", "events": ["app.created"]}',
|
||||||
|
"example_response": '{"code": 0, "data": {"id": "wh_xxx", "secret": "whsec_..."}}',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api-docs", summary="Interactive API documentation")
|
||||||
|
async def get_api_docs(
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
"""Get structured API documentation for the interactive docs viewer."""
|
||||||
|
total_endpoints = sum(len(s["endpoints"]) for s in API_DOC_SECTIONS)
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"sections": API_DOC_SECTIONS,
|
||||||
|
"total_sections": len(API_DOC_SECTIONS),
|
||||||
|
"total_endpoints": total_endpoints,
|
||||||
|
"base_url": "http://210.16.161.218:18080",
|
||||||
|
"api_version": "v1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Enhanced Usage Dashboard
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get("/usage/dashboard", summary="Enhanced usage dashboard")
|
||||||
|
async def usage_dashboard(
|
||||||
|
days: int = Query(7, ge=1, le=90),
|
||||||
|
admin_user=Depends(get_current_admin),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Enhanced usage statistics dashboard with trend data."""
|
||||||
|
now = cst_now()
|
||||||
|
start_date = now - timedelta(days=days)
|
||||||
|
|
||||||
|
# API call trends (daily)
|
||||||
|
trend_sql = """SELECT DATE(created_at) as date, COUNT(*) as calls,
|
||||||
|
SUM(CASE WHEN error_count > 0 THEN 1 ELSE 0 END) as errors,
|
||||||
|
SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time
|
||||||
|
FROM api_call_stats
|
||||||
|
WHERE created_at >= :start
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date"""
|
||||||
|
trend_rows = _execute_raw(db, trend_sql, {"start": start_date})
|
||||||
|
|
||||||
|
trend = []
|
||||||
|
for r in trend_rows:
|
||||||
|
trend.append({
|
||||||
|
"date": r["date"].strftime("%Y-%m-%d") if r.get("date") else "",
|
||||||
|
"calls": r.get("calls", 0),
|
||||||
|
"errors": r.get("errors", 0),
|
||||||
|
"avg_time": round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Top endpoints by call count
|
||||||
|
top_sql = """SELECT endpoint, method, call_count, error_count,
|
||||||
|
last_called_at, total_response_time
|
||||||
|
FROM api_call_stats
|
||||||
|
ORDER BY call_count DESC
|
||||||
|
LIMIT 10"""
|
||||||
|
top_rows = _execute_raw(db, top_sql)
|
||||||
|
|
||||||
|
top_endpoints = []
|
||||||
|
for r in top_rows:
|
||||||
|
top_endpoints.append({
|
||||||
|
"endpoint": r.get("endpoint", ""),
|
||||||
|
"method": r.get("method", ""),
|
||||||
|
"call_count": r.get("call_count", 0),
|
||||||
|
"error_count": r.get("error_count", 0),
|
||||||
|
"error_rate": round(r.get("error_count", 0) / max(r.get("call_count", 1), 1) * 100, 2) if r.get("call_count", 0) > 0 else 0,
|
||||||
|
"avg_response_time": round(r.get("total_response_time", 0) / max(r.get("call_count", 1), 1), 2) if r.get("call_count", 0) > 0 else 0,
|
||||||
|
"last_called_at": fmt_time(r.get("last_called_at")),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Error distribution by status code
|
||||||
|
error_sql = """SELECT
|
||||||
|
CASE
|
||||||
|
WHEN error_count > 0 THEN 'has_errors'
|
||||||
|
ELSE 'success'
|
||||||
|
END as status_group,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(call_count) as total_calls
|
||||||
|
FROM api_call_stats
|
||||||
|
GROUP BY status_group"""
|
||||||
|
error_rows = _execute_raw(db, error_sql)
|
||||||
|
|
||||||
|
error_distribution = {"success": 0, "has_errors": 0, "success_calls": 0, "error_calls": 0}
|
||||||
|
for r in error_rows:
|
||||||
|
if r["status_group"] == "success":
|
||||||
|
error_distribution["success"] = r["count"]
|
||||||
|
error_distribution["success_calls"] = r.get("total_calls", 0)
|
||||||
|
else:
|
||||||
|
error_distribution["has_errors"] = r["count"]
|
||||||
|
error_distribution["error_calls"] = r.get("total_calls", 0)
|
||||||
|
|
||||||
|
# Response time distribution
|
||||||
|
latency_sql = """SELECT
|
||||||
|
CASE
|
||||||
|
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 100 THEN 'fast'
|
||||||
|
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 500 THEN 'normal'
|
||||||
|
WHEN (CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) < 1000 THEN 'slow'
|
||||||
|
ELSE 'very_slow'
|
||||||
|
END as latency_bucket,
|
||||||
|
COUNT(*) as count,
|
||||||
|
SUM(CASE WHEN call_count > 0 THEN total_response_time / call_count ELSE 0 END) as avg_time
|
||||||
|
FROM api_call_stats
|
||||||
|
WHERE total_response_time IS NOT NULL AND call_count > 0
|
||||||
|
GROUP BY latency_bucket"""
|
||||||
|
latency_rows = _execute_raw(db, latency_sql)
|
||||||
|
|
||||||
|
latency_distribution = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0}
|
||||||
|
latency_avg = {"fast": 0, "normal": 0, "slow": 0, "very_slow": 0}
|
||||||
|
for r in latency_rows:
|
||||||
|
bucket = r["latency_bucket"]
|
||||||
|
latency_distribution[bucket] = r["count"]
|
||||||
|
latency_avg[bucket] = round(r.get("avg_time", 0), 2) if r.get("avg_time") else 0
|
||||||
|
|
||||||
|
# API key usage distribution
|
||||||
|
key_sql = """SELECT k.name, k.status, COUNT(s.id) as endpoint_count,
|
||||||
|
COALESCE(SUM(s.call_count), 0) as total_calls,
|
||||||
|
COALESCE(SUM(s.error_count), 0) as total_errors
|
||||||
|
FROM api_keys k
|
||||||
|
LEFT JOIN api_call_stats s ON s.endpoint LIKE CONCAT('%api-key%', k.id, '%')
|
||||||
|
GROUP BY k.id, k.name, k.status
|
||||||
|
ORDER BY total_calls DESC
|
||||||
|
LIMIT 10"""
|
||||||
|
key_rows = _execute_raw(db, key_sql)
|
||||||
|
|
||||||
|
key_usage = []
|
||||||
|
for r in key_rows:
|
||||||
|
key_usage.append({
|
||||||
|
"name": r.get("name", "Unknown"),
|
||||||
|
"status": r.get("status", "unknown"),
|
||||||
|
"total_calls": r.get("total_calls", 0),
|
||||||
|
"total_errors": r.get("total_errors", 0),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
total_calls = sum(t["calls"] for t in trend)
|
||||||
|
total_errors = sum(t["errors"] for t in trend)
|
||||||
|
avg_response = sum(t["avg_time"] for t in trend) / len(trend) if trend else 0
|
||||||
|
error_rate = round(total_errors / total_calls * 100, 2) if total_calls > 0 else 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 0,
|
||||||
|
"data": {
|
||||||
|
"summary": {
|
||||||
|
"total_calls": total_calls,
|
||||||
|
"total_errors": total_errors,
|
||||||
|
"error_rate": error_rate,
|
||||||
|
"avg_response_time": round(avg_response, 2),
|
||||||
|
"active_api_keys": error_distribution["success"] + error_distribution["has_errors"],
|
||||||
|
},
|
||||||
|
"trend": trend,
|
||||||
|
"top_endpoints": top_endpoints,
|
||||||
|
"error_distribution": error_distribution,
|
||||||
|
"latency_distribution": latency_distribution,
|
||||||
|
"latency_avg": latency_avg,
|
||||||
|
"key_usage": key_usage,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,8 @@ 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, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_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, workflow_admin, storage_admin, automation_admin, mcp_admin, developer_admin, apps_admin
|
||||||
|
from app.api.admin.developer_tools_admin import router as developer_tools_router
|
||||||
|
from app.api.admin import asset_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
|
||||||
@@ -420,10 +422,12 @@ app.include_router(skills_admin.router, prefix="/app/api/admin/skills", tags=["
|
|||||||
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
app.include_router(agents_admin.router, prefix="/app/api/admin/agents", tags=["后台-Agent管理"])
|
||||||
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
app.include_router(knowledge_admin.router, prefix="/app/api/admin/knowledge", tags=["后台-知识库管理"])
|
||||||
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
|
app.include_router(workflow_admin.router, prefix="/app/api/admin/workflows", tags=["后台-工作流编排"])
|
||||||
|
app.include_router(asset_admin.router, prefix="/app/api/admin/assets", tags=["后台-资产管理"])
|
||||||
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"])
|
app.include_router(developer_admin.router, prefix="/app/api/admin/developer", tags=["后台-开发者API"])
|
||||||
|
app.include_router(developer_tools_router, prefix="/app/api/admin/developer-tools", tags=["dev-tools"])
|
||||||
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
|
app.include_router(apps_admin.router, prefix="/app/api/admin/apps", tags=["admin-ai-apps"])
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const AdminKnowledge = lazy(() => import('@/pages/admin/Knowledge'));
|
|||||||
const AdminApps = lazy(() => import('@/pages/admin/Apps'));
|
const AdminApps = lazy(() => import('@/pages/admin/Apps'));
|
||||||
const AdminAutomation = lazy(() => import('@/pages/admin/Automation'));
|
const AdminAutomation = lazy(() => import('@/pages/admin/Automation'));
|
||||||
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
const AdminDeveloper = lazy(() => import('@/pages/admin/Developer'));
|
||||||
|
const AdminDeveloperTools = lazy(() => import("@/pages/admin/DeveloperTools"));
|
||||||
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
const AdminMcp = lazy(() => import('@/pages/admin/Mcp'));
|
||||||
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
const AdminStorage = lazy(() => import('@/pages/admin/Storage'));
|
||||||
const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter'));
|
const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter'));
|
||||||
@@ -132,6 +133,7 @@ const App: React.FC = () => {
|
|||||||
<Route path="asset-center" element={<AdminAssetCenter />} />
|
<Route path="asset-center" element={<AdminAssetCenter />} />
|
||||||
<Route path="automation" element={<AdminAutomation />} />
|
<Route path="automation" element={<AdminAutomation />} />
|
||||||
<Route path="developer" element={<AdminDeveloper />} />
|
<Route path="developer" element={<AdminDeveloper />} />
|
||||||
|
<Route path="developer-tools" element={<AdminDeveloperTools />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/" />} />
|
<Route path="*" element={<Navigate to="/" />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const navGroups: NavGroup[] = [
|
|||||||
{ path: '/admin/asset-center', icon: 'database', label: '资产中心' },
|
{ path: '/admin/asset-center', icon: 'database', label: '资产中心' },
|
||||||
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
{ path: '/admin/automation', icon: 'automation', label: '自动化任务' },
|
||||||
{ path: '/admin/developer', icon: 'developer', label: '开发者工具' },
|
{ path: '/admin/developer', icon: 'developer', label: '开发者工具' },
|
||||||
|
{ path: "/admin/developer-tools", label: "开发者工具", icon: "🔧" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -136,3 +136,20 @@
|
|||||||
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
.dev-stats-grid { grid-template-columns: repeat(2, 1fr); }
|
||||||
.dev-table { font-size: 12px; }
|
.dev-table { font-size: 12px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Phase 3.9: Developer Tools styles */
|
||||||
|
.dev-method { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; font-family: monospace; }
|
||||||
|
.method-get { background: rgba(82,196,26,0.2); color: #52c41a; }
|
||||||
|
.method-post { background: rgba(22,119,255,0.2); color: #1677ff; }
|
||||||
|
.method-put { background: rgba(250,173,20,0.2); color: #faad14; }
|
||||||
|
.method-delete { background: rgba(255,77,79,0.2); color: #ff4d4f; }
|
||||||
|
|
||||||
|
.dev-modal-overlay { position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 1000; }
|
||||||
|
.dev-modal { background: #1a1a2e; border: 1px solid rgba(255,255,255,0.1); border-radius: 12px; padding: 24px; width: 500px; max-width: 90vw; max-height: 80vh; overflow-y: auto; }
|
||||||
|
.dev-modal h3 { margin: 0 0 16px; color: rgba(255,255,255,0.85); }
|
||||||
|
.dev-form-group { margin-bottom: 14px; }
|
||||||
|
.dev-form-group label { display: block; font-size: 13px; color: rgba(255,255,255,0.55); margin-bottom: 4px; }
|
||||||
|
.dev-modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }
|
||||||
|
|
||||||
|
.dev-muted { color: rgba(255,255,255,0.35); font-size: 12px; }
|
||||||
|
.dev-test-result { margin: 12px 0; padding: 12px; border-radius: 8px; font-size: 14px; }
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import './Developer.css';
|
||||||
|
|
||||||
|
const API_BASE = '/app/api';
|
||||||
|
|
||||||
|
interface Webhook {
|
||||||
|
id: string; name: string; url: string; events: string[];
|
||||||
|
has_secret: boolean; headers: Record<string, string>; status: string;
|
||||||
|
last_triggered_at: string | null; last_status: string | null;
|
||||||
|
total_deliveries: number; success_count: number; fail_count: number;
|
||||||
|
success_rate: number; created_at: string; updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookEvent {
|
||||||
|
value: string; label: string; desc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WebhookDelivery {
|
||||||
|
id: string; event: string; status: string; response_code: number | null;
|
||||||
|
response_time_ms: number | null; attempts: number; error_message: string | null;
|
||||||
|
payload: any; response_body: string; created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SDKPackage {
|
||||||
|
language: string; name: string; version: string; description: string;
|
||||||
|
install_cmd: string; download_url: string; github_url: string;
|
||||||
|
min_version: string; features: string[]; code_example: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ApiDocSection {
|
||||||
|
section: string; icon: string; description: string;
|
||||||
|
endpoints: Array<{
|
||||||
|
method: string; path: string; title: string; description: string;
|
||||||
|
params: Array<{ name: string; type: string; required: boolean; desc: string }>;
|
||||||
|
example_request: string; example_response: 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 AdminDeveloperTools() {
|
||||||
|
const [activeTab, setActiveTab] = useState<'webhooks' | 'sdk' | 'docs'>('webhooks');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
// Webhooks state
|
||||||
|
const [webhooks, setWebhooks] = useState<PageData<Webhook>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||||
|
const [webhookEvents, setWebhookEvents] = useState<WebhookEvent[]>([]);
|
||||||
|
const [showWebhookModal, setShowWebhookModal] = useState(false);
|
||||||
|
const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] });
|
||||||
|
const [selectedWebhook, setSelectedWebhook] = useState<Webhook | null>(null);
|
||||||
|
const [deliveries, setDeliveries] = useState<PageData<WebhookDelivery>>({ total: 0, page: 1, page_size: 20, items: [] });
|
||||||
|
const [testResult, setTestResult] = useState<any>(null);
|
||||||
|
|
||||||
|
// SDK state
|
||||||
|
const [sdkPackages, setSdkPackages] = useState<SDKPackage[]>([]);
|
||||||
|
const [selectedSdk, setSelectedSdk] = useState<string>('python');
|
||||||
|
|
||||||
|
// API Docs state
|
||||||
|
const [docSections, setDocSections] = useState<ApiDocSection[]>([]);
|
||||||
|
const [expandedSection, setExpandedSection] = useState<string | null>(null);
|
||||||
|
const [expandedEndpoint, setExpandedEndpoint] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadWebhooks = useCallback(async (page = 1) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api(`/admin/developer-tools/webhooks?page=${page}&page_size=20`);
|
||||||
|
if (res.code === 0) setWebhooks(res.data);
|
||||||
|
} catch (e) { console.error('Webhooks error:', e); }
|
||||||
|
setLoading(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadWebhookEvents = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/developer-tools/webhooks/events');
|
||||||
|
if (res.code === 0) setWebhookEvents(res.data.events);
|
||||||
|
} catch (e) { console.error('Events error:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadDeliveries = useCallback(async (webhookId: string, page = 1) => {
|
||||||
|
try {
|
||||||
|
const res = await api(`/admin/developer-tools/webhooks/${webhookId}/deliveries?page=${page}&page_size=20`);
|
||||||
|
if (res.code === 0) setDeliveries(res.data);
|
||||||
|
} catch (e) { console.error('Deliveries error:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadSdkPackages = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/developer-tools/sdk/packages');
|
||||||
|
if (res.code === 0) setSdkPackages(res.data.packages);
|
||||||
|
} catch (e) { console.error('SDK error:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadApiDocs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api('/admin/developer-tools/api-docs');
|
||||||
|
if (res.code === 0) setDocSections(res.data.sections);
|
||||||
|
} catch (e) { console.error('Docs error:', e); }
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => { if (activeTab === 'webhooks') { loadWebhooks(); loadWebhookEvents(); } }, [activeTab, loadWebhooks, loadWebhookEvents]);
|
||||||
|
useEffect(() => { if (activeTab === 'sdk') loadSdkPackages(); }, [activeTab, loadSdkPackages]);
|
||||||
|
useEffect(() => { if (activeTab === 'docs') loadApiDocs(); }, [activeTab, loadApiDocs]);
|
||||||
|
|
||||||
|
const handleCreateWebhook = async () => {
|
||||||
|
try {
|
||||||
|
const body = { name: webhookForm.name, url: webhookForm.url, events: webhookForm.events, headers: {} };
|
||||||
|
const res = await api('/admin/developer-tools/webhooks/', { method: 'POST', body: JSON.stringify(body) });
|
||||||
|
setShowWebhookModal(false);
|
||||||
|
setWebhookForm({ name: '', url: '', events: [] });
|
||||||
|
loadWebhooks();
|
||||||
|
if (res.data?.secret) {
|
||||||
|
alert(`Webhook created! Secret: ${res.data.secret}\nSave this secret for HMAC verification.`);
|
||||||
|
}
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteWebhook = async (id: string) => {
|
||||||
|
if (!confirm('Delete this webhook?')) return;
|
||||||
|
try {
|
||||||
|
await api(`/admin/developer-tools/webhooks/${id}`, { method: 'DELETE' });
|
||||||
|
loadWebhooks();
|
||||||
|
setSelectedWebhook(null);
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleWebhook = async (wh: Webhook) => {
|
||||||
|
const newStatus = wh.status === 'active' ? 'paused' : 'active';
|
||||||
|
try {
|
||||||
|
await api(`/admin/developer-tools/webhooks/${wh.id}`, { method: 'PUT', body: JSON.stringify({ status: newStatus }) });
|
||||||
|
loadWebhooks();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestWebhook = async (id: string) => {
|
||||||
|
setTestResult(null);
|
||||||
|
try {
|
||||||
|
const res = await api(`/admin/developer-tools/webhooks/${id}/test`, {
|
||||||
|
method: 'POST', body: JSON.stringify({ event: 'test', payload: { message: 'Test delivery from admin' } })
|
||||||
|
});
|
||||||
|
setTestResult(res.data);
|
||||||
|
loadWebhooks();
|
||||||
|
} catch (e: any) { alert(e.message); }
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDeliveries = (wh: Webhook) => {
|
||||||
|
setSelectedWebhook(wh);
|
||||||
|
setDeliveries({ total: 0, page: 1, page_size: 20, items: [] });
|
||||||
|
loadDeliveries(wh.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
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 === 'paused' ? 'badge-disabled' : status === 'success' ? 'badge-active' : 'badge-revoked';
|
||||||
|
return <span className={`dev-badge ${cls}`}>{status}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const methodBadge = (method: string) => {
|
||||||
|
const colors: Record<string, string> = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' };
|
||||||
|
return <span className={`dev-method ${colors[method] || ''}`}>{method}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyToClipboard = (text: string) => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
alert('Copied to clipboard');
|
||||||
|
}).catch(() => alert('Copy failed'));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="admin-developer">
|
||||||
|
<div className="dev-header">
|
||||||
|
<h2>开发者工具</h2>
|
||||||
|
<p>Webhook 配置、SDK 下载中心、交互式 API 文档</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Navigation */}
|
||||||
|
<div className="dev-tabs">
|
||||||
|
<button className={`dev-tab ${activeTab === 'webhooks' ? 'active' : ''}`} onClick={() => setActiveTab('webhooks')}>Webhook 配置</button>
|
||||||
|
<button className={`dev-tab ${activeTab === 'sdk' ? 'active' : ''}`} onClick={() => setActiveTab('sdk')}>SDK 下载中心</button>
|
||||||
|
<button className={`dev-tab ${activeTab === 'docs' ? 'active' : ''}`} onClick={() => setActiveTab('docs')}>API 文档</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tab Content */}
|
||||||
|
<div className="dev-tab-content">
|
||||||
|
{loading && <div className="dev-loading">Loading...</div>}
|
||||||
|
|
||||||
|
{/* ===== Webhooks Tab ===== */}
|
||||||
|
{activeTab === 'webhooks' && !loading && (
|
||||||
|
<div>
|
||||||
|
{selectedWebhook ? (
|
||||||
|
/* Webhook Detail / Deliveries View */
|
||||||
|
<div>
|
||||||
|
<div className="dev-toolbar">
|
||||||
|
<button className="dev-btn" onClick={() => { setSelectedWebhook(null); setDeliveries({ total: 0, page: 1, page_size: 20, items: [] }); }}>
|
||||||
|
← Back to list
|
||||||
|
</button>
|
||||||
|
<h3 style={{ margin: '0 16px' }}>{selectedWebhook.name} - Delivery History</h3>
|
||||||
|
<button className="dev-btn primary" onClick={() => handleTestWebhook(selectedWebhook.id)}>Send Test</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{testResult && (
|
||||||
|
<div className="dev-test-result" style={{ margin: '12px 0', padding: '12px', background: 'rgba(255,255,255,0.05)', borderRadius: '8px', border: `1px solid ${testResult.status === 'success' ? '#52c41a' : '#ff4d4f'}` }}>
|
||||||
|
<strong>Test Result: </strong>
|
||||||
|
<span style={{ color: testResult.status === 'success' ? '#52c41a' : '#ff4d4f' }}>{testResult.status}</span>
|
||||||
|
{testResult.response_code && <span> | HTTP {testResult.response_code}</span>}
|
||||||
|
{testResult.response_time_ms != null && <span> | {testResult.response_time_ms}ms</span>}
|
||||||
|
{testResult.error_message && <div style={{ color: '#ff4d4f', marginTop: '4px' }}>{testResult.error_message}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<table className="dev-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Event</th><th>Status</th><th>Response Code</th><th>Response Time</th><th>Attempts</th><th>Time</th><th>Error</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{deliveries.items.map(d => (
|
||||||
|
<tr key={d.id}>
|
||||||
|
<td className="mono-text">{d.event}</td>
|
||||||
|
<td>{statusBadge(d.status)}</td>
|
||||||
|
<td>{d.response_code || '-'}</td>
|
||||||
|
<td>{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}</td>
|
||||||
|
<td>{d.attempts}</td>
|
||||||
|
<td>{fmtTime(d.created_at)}</td>
|
||||||
|
<td style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{d.error_message || '-'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{deliveries.items.length === 0 && <tr><td colSpan={7} className="dev-empty">No delivery records</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{deliveries.total > 20 && (
|
||||||
|
<div className="dev-pagination">
|
||||||
|
<button disabled={deliveries.page <= 1} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page - 1)}>Prev</button>
|
||||||
|
<span>Page {deliveries.page} / {Math.ceil(deliveries.total / 20)}</span>
|
||||||
|
<button disabled={deliveries.page >= Math.ceil(deliveries.total / 20)} onClick={() => loadDeliveries(selectedWebhook.id, deliveries.page + 1)}>Next</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
/* Webhook List View */
|
||||||
|
<div>
|
||||||
|
<div className="dev-toolbar">
|
||||||
|
<button className="dev-btn primary" onClick={() => setShowWebhookModal(true)}>+ New Webhook</button>
|
||||||
|
</div>
|
||||||
|
<table className="dev-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>URL</th><th>Events</th><th>Status</th><th>Deliveries</th><th>Success Rate</th><th>Last Triggered</th><th>Actions</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{webhooks.items.map(w => (
|
||||||
|
<tr key={w.id}>
|
||||||
|
<td>{w.name}</td>
|
||||||
|
<td className="mono-text" style={{ maxWidth: '200px', overflow: 'hidden', textOverflow: 'ellipsis' }}>{w.url}</td>
|
||||||
|
<td>
|
||||||
|
{w.events.length > 0 ? (
|
||||||
|
<span>{w.events.length} events</span>
|
||||||
|
) : <span className="dev-muted">All events</span>}
|
||||||
|
</td>
|
||||||
|
<td>{statusBadge(w.status)}</td>
|
||||||
|
<td>{w.total_deliveries}</td>
|
||||||
|
<td>
|
||||||
|
<span style={{ color: w.success_rate >= 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}>
|
||||||
|
{w.success_rate}%
|
||||||
|
</span>
|
||||||
|
<span className="dev-muted"> ({w.success_count}/{w.total_deliveries})</span>
|
||||||
|
</td>
|
||||||
|
<td>{fmtTime(w.last_triggered_at)}</td>
|
||||||
|
<td className="dev-actions">
|
||||||
|
<button className="dev-btn-sm" onClick={() => handleViewDeliveries(w)}>Deliveries</button>
|
||||||
|
<button className="dev-btn-sm" onClick={() => handleTestWebhook(w.id)}>Test</button>
|
||||||
|
<button className="dev-btn-sm" onClick={() => handleToggleWebhook(w)}>{w.status === 'active' ? 'Pause' : 'Resume'}</button>
|
||||||
|
<button className="dev-btn-sm danger" onClick={() => handleDeleteWebhook(w.id)}>Delete</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{webhooks.items.length === 0 && <tr><td colSpan={8} className="dev-empty">No webhooks configured</td></tr>}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{webhooks.total > 20 && (
|
||||||
|
<div className="dev-pagination">
|
||||||
|
<button disabled={webhooks.page <= 1} onClick={() => loadWebhooks(webhooks.page - 1)}>Prev</button>
|
||||||
|
<span>Page {webhooks.page} / {Math.ceil(webhooks.total / 20)}</span>
|
||||||
|
<button disabled={webhooks.page >= Math.ceil(webhooks.total / 20)} onClick={() => loadWebhooks(webhooks.page + 1)}>Next</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Supported Events Info */}
|
||||||
|
{webhookEvents.length > 0 && (
|
||||||
|
<div style={{ marginTop: '20px', padding: '16px', background: 'rgba(255,255,255,0.03)', borderRadius: '8px' }}>
|
||||||
|
<h4 style={{ margin: '0 0 12px', color: 'rgba(255,255,255,0.85)' }}>Supported Events</h4>
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '8px' }}>
|
||||||
|
{webhookEvents.map(ev => (
|
||||||
|
<div key={ev.value} style={{ padding: '8px 12px', background: 'rgba(255,255,255,0.05)', borderRadius: '6px' }}>
|
||||||
|
<code style={{ color: '#69b1ff' }}>{ev.value}</code>
|
||||||
|
<div style={{ fontSize: '12px', color: 'rgba(255,255,255,0.45)', marginTop: '2px' }}>{ev.desc}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Create Webhook Modal */}
|
||||||
|
{showWebhookModal && (
|
||||||
|
<div className="dev-modal-overlay" onClick={() => setShowWebhookModal(false)}>
|
||||||
|
<div className="dev-modal" onClick={e => e.stopPropagation()}>
|
||||||
|
<h3>New Webhook</h3>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>Name</label>
|
||||||
|
<input className="dev-input" value={webhookForm.name} onChange={e => setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>URL</label>
|
||||||
|
<input className="dev-input" value={webhookForm.url} onChange={e => setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" />
|
||||||
|
</div>
|
||||||
|
<div className="dev-form-group">
|
||||||
|
<label>Events (leave empty for all)</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', marginTop: '8px' }}>
|
||||||
|
{webhookEvents.map(ev => (
|
||||||
|
<label key={ev.value} style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer', padding: '4px 10px', background: webhookForm.events.includes(ev.value) ? 'rgba(22,119,255,0.2)' : 'rgba(255,255,255,0.05)', borderRadius: '6px', fontSize: '13px' }}>
|
||||||
|
<input type="checkbox" checked={webhookForm.events.includes(ev.value)} onChange={e => {
|
||||||
|
if (e.target.checked) setWebhookForm({ ...webhookForm, events: [...webhookForm.events, ev.value] });
|
||||||
|
else setWebhookForm({ ...webhookForm, events: webhookForm.events.filter(x => x !== ev.value) });
|
||||||
|
}} />
|
||||||
|
{ev.label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="dev-modal-actions">
|
||||||
|
<button className="dev-btn" onClick={() => setShowWebhookModal(false)}>Cancel</button>
|
||||||
|
<button className="dev-btn primary" onClick={handleCreateWebhook} disabled={!webhookForm.name || !webhookForm.url}>Create</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== SDK Download Center Tab ===== */}
|
||||||
|
{activeTab === 'sdk' && !loading && (
|
||||||
|
<div>
|
||||||
|
{/* Language Selector */}
|
||||||
|
<div className="dev-toolbar">
|
||||||
|
{sdkPackages.map(pkg => (
|
||||||
|
<button key={pkg.language} className={`dev-tab ${selectedSdk === pkg.language ? 'active' : ''}`} onClick={() => setSelectedSdk(pkg.language)}>
|
||||||
|
{pkg.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sdkPackages.filter(p => p.language === selectedSdk).map(pkg => (
|
||||||
|
<div key={pkg.language} style={{ padding: '20px', background: 'rgba(255,255,255,0.03)', borderRadius: '12px' }}>
|
||||||
|
{/* Package Info */}
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
|
<div>
|
||||||
|
<h3 style={{ margin: '0 0 4px' }}>{pkg.name} <span className="dev-badge badge-active">v{pkg.version}</span></h3>
|
||||||
|
<p style={{ margin: '0', color: 'rgba(255,255,255,0.55)' }}>{pkg.description}</p>
|
||||||
|
<p style={{ margin: '4px 0 0', fontSize: '13px', color: 'rgba(255,255,255,0.4)' }}>Requires: {pkg.min_version}</p>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<a href={pkg.download_url} target="_blank" rel="noopener noreferrer" className="dev-btn primary">Download</a>
|
||||||
|
{pkg.github_url && <a href={pkg.github_url} target="_blank" rel="noopener noreferrer" className="dev-btn">GitHub</a>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Install Command */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', marginBottom: '4px', display: 'block' }}>Installation</label>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||||
|
<code style={{ flex: 1, padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '8px', fontFamily: 'monospace', fontSize: '14px' }}>
|
||||||
|
{pkg.install_cmd}
|
||||||
|
</code>
|
||||||
|
<button className="dev-btn-sm" onClick={() => copyToClipboard(pkg.install_cmd)}>Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Features */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<label style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', marginBottom: '8px', display: 'block' }}>Features</label>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px' }}>
|
||||||
|
{pkg.features.map(f => (
|
||||||
|
<span key={f} style={{ padding: '4px 12px', background: 'rgba(82,196,26,0.15)', borderRadius: '16px', fontSize: '13px', color: '#52c41a', border: '1px solid rgba(82,196,26,0.3)' }}>
|
||||||
|
{f}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Code Example */}
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '13px', color: 'rgba(255,255,255,0.5)', marginBottom: '4px', display: 'block' }}>Quick Start Example</label>
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<pre style={{ padding: '16px', background: 'rgba(0,0,0,0.4)', borderRadius: '8px', overflow: 'auto', fontSize: '13px', lineHeight: '1.6', maxHeight: '400px' }}>
|
||||||
|
<code>{pkg.code_example}</code>
|
||||||
|
</pre>
|
||||||
|
<button className="dev-btn-sm" style={{ position: 'absolute', top: '8px', right: '8px' }} onClick={() => copyToClipboard(pkg.code_example)}>Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{sdkPackages.length === 0 && <div className="dev-empty">Loading SDK packages...</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ===== Interactive API Docs Tab ===== */}
|
||||||
|
{activeTab === 'docs' && !loading && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '16px', padding: '12px 16px', background: 'rgba(22,119,255,0.1)', borderRadius: '8px', border: '1px solid rgba(22,119,255,0.3)' }}>
|
||||||
|
<strong style={{ color: '#69b1ff' }}>Base URL:</strong>
|
||||||
|
<code style={{ marginLeft: '8px', color: 'rgba(255,255,255,0.8)' }}>http://210.16.161.218:18080</code>
|
||||||
|
<span style={{ marginLeft: '16px', color: 'rgba(255,255,255,0.4)' }}>API Version: v1</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{docSections.map(section => {
|
||||||
|
const isExpanded = expandedSection === section.section;
|
||||||
|
return (
|
||||||
|
<div key={section.section} style={{ marginBottom: '8px', borderRadius: '8px', overflow: 'hidden', border: '1px solid rgba(255,255,255,0.1)' }}>
|
||||||
|
{/* Section Header */}
|
||||||
|
<div
|
||||||
|
onClick={() => setExpandedSection(isExpanded ? null : section.section)}
|
||||||
|
style={{ padding: '14px 16px', background: 'rgba(255,255,255,0.05)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||||
|
<span style={{ fontSize: '18px' }}>
|
||||||
|
{section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'}
|
||||||
|
</span>
|
||||||
|
<strong style={{ color: 'rgba(255,255,255,0.85)' }}>{section.section}</strong>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.4)', fontSize: '13px' }}>{section.endpoints.length} endpoints</span>
|
||||||
|
</div>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.4)' }}>{isExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Section Content */}
|
||||||
|
{isExpanded && (
|
||||||
|
<div style={{ background: 'rgba(0,0,0,0.2)' }}>
|
||||||
|
<p style={{ padding: '8px 16px', margin: '0', color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>{section.description}</p>
|
||||||
|
{section.endpoints.map(ep => {
|
||||||
|
const epKey = `${section.section}-${ep.path}`;
|
||||||
|
const isEpExpanded = expandedEndpoint === epKey;
|
||||||
|
return (
|
||||||
|
<div key={epKey} style={{ borderTop: '1px solid rgba(255,255,255,0.05)' }}>
|
||||||
|
<div
|
||||||
|
onClick={() => setExpandedEndpoint(isEpExpanded ? null : epKey)}
|
||||||
|
style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }}
|
||||||
|
>
|
||||||
|
{methodBadge(ep.method)}
|
||||||
|
<code style={{ color: 'rgba(255,255,255,0.7)', fontSize: '13px' }}>{ep.path}</code>
|
||||||
|
<span style={{ color: 'rgba(255,255,255,0.5)', fontSize: '13px' }}>{ep.title}</span>
|
||||||
|
<span style={{ marginLeft: 'auto', color: 'rgba(255,255,255,0.3)' }}>{isEpExpanded ? '▼' : '▶'}</span>
|
||||||
|
</div>
|
||||||
|
{isEpExpanded && (
|
||||||
|
<div style={{ padding: '0 16px 16px 16px' }}>
|
||||||
|
<p style={{ color: 'rgba(255,255,255,0.55)', margin: '4px 0 12px', fontSize: '13px' }}>{ep.description}</p>
|
||||||
|
|
||||||
|
{/* Parameters */}
|
||||||
|
{ep.params.length > 0 && (
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<label style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', display: 'block', marginBottom: '6px' }}>Parameters</label>
|
||||||
|
<table className="dev-table" style={{ fontSize: '13px' }}>
|
||||||
|
<thead>
|
||||||
|
<tr><th>Name</th><th>Type</th><th>Required</th><th>Description</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{ep.params.map(p => (
|
||||||
|
<tr key={p.name}>
|
||||||
|
<td className="mono-text">{p.name}</td>
|
||||||
|
<td>{p.type}</td>
|
||||||
|
<td>{p.required ? <span style={{ color: '#ff4d4f' }}>Yes</span> : <span style={{ color: 'rgba(255,255,255,0.4)' }}>No</span>}</td>
|
||||||
|
<td>{p.desc}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Example Request */}
|
||||||
|
{ep.example_request && (
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<label style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', display: 'block', marginBottom: '4px' }}>Example Request</label>
|
||||||
|
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||||
|
<code>{ep.example_request}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Example Response */}
|
||||||
|
{ep.example_response && (
|
||||||
|
<div>
|
||||||
|
<label style={{ fontSize: '12px', color: 'rgba(255,255,255,0.4)', display: 'block', marginBottom: '4px' }}>Example Response</label>
|
||||||
|
<pre style={{ padding: '10px 14px', background: 'rgba(0,0,0,0.3)', borderRadius: '6px', fontSize: '12px', overflow: 'auto' }}>
|
||||||
|
<code>{ep.example_response}</code>
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{docSections.length === 0 && <div className="dev-empty">Loading API documentation...</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in new issue
Block a user