diff --git a/backend/python/app/api/admin/developer_tools_admin.py b/backend/python/app/api/admin/developer_tools_admin.py new file mode 100644 index 0000000..29ad7ab --- /dev/null +++ b/backend/python/app/api/admin/developer_tools_admin.py @@ -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, + } + } diff --git a/backend/python/main.py b/backend/python/main.py index 69d2e3a..7ae0175 100644 --- a/backend/python/main.py +++ b/backend/python/main.py @@ -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.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.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(knowledge_admin.router, prefix="/app/api/admin/knowledge", 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(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(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"]) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8712b40..c899707 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -40,6 +40,7 @@ const AdminKnowledge = lazy(() => import('@/pages/admin/Knowledge')); const AdminApps = lazy(() => import('@/pages/admin/Apps')); const AdminAutomation = lazy(() => import('@/pages/admin/Automation')); const AdminDeveloper = lazy(() => import('@/pages/admin/Developer')); +const AdminDeveloperTools = lazy(() => import("@/pages/admin/DeveloperTools")); const AdminMcp = lazy(() => import('@/pages/admin/Mcp')); const AdminStorage = lazy(() => import('@/pages/admin/Storage')); const AdminAssetCenter = lazy(() => import('@/pages/admin/AssetCenter')); @@ -132,6 +133,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx index a8ddf63..658dcd7 100644 --- a/frontend/src/components/layout/AdminLayout.tsx +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -59,6 +59,7 @@ const navGroups: NavGroup[] = [ { path: '/admin/asset-center', icon: 'database', label: '资产中心' }, { path: '/admin/automation', icon: 'automation', label: '自动化任务' }, { path: '/admin/developer', icon: 'developer', label: '开发者工具' }, + { path: "/admin/developer-tools", label: "开发者工具", icon: "🔧" }, ], }, ]; diff --git a/frontend/src/pages/admin/Developer.css b/frontend/src/pages/admin/Developer.css index ce23efc..617b99d 100644 --- a/frontend/src/pages/admin/Developer.css +++ b/frontend/src/pages/admin/Developer.css @@ -136,3 +136,20 @@ .dev-stats-grid { grid-template-columns: repeat(2, 1fr); } .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; } diff --git a/frontend/src/pages/admin/DeveloperTools.tsx b/frontend/src/pages/admin/DeveloperTools.tsx new file mode 100644 index 0000000..a686513 --- /dev/null +++ b/frontend/src/pages/admin/DeveloperTools.tsx @@ -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; 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 { 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>({ total: 0, page: 1, page_size: 20, items: [] }); + const [webhookEvents, setWebhookEvents] = useState([]); + const [showWebhookModal, setShowWebhookModal] = useState(false); + const [webhookForm, setWebhookForm] = useState({ name: '', url: '', events: [] as string[] }); + const [selectedWebhook, setSelectedWebhook] = useState(null); + const [deliveries, setDeliveries] = useState>({ total: 0, page: 1, page_size: 20, items: [] }); + const [testResult, setTestResult] = useState(null); + + // SDK state + const [sdkPackages, setSdkPackages] = useState([]); + const [selectedSdk, setSelectedSdk] = useState('python'); + + // API Docs state + const [docSections, setDocSections] = useState([]); + const [expandedSection, setExpandedSection] = useState(null); + const [expandedEndpoint, setExpandedEndpoint] = useState(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 {status}; + }; + + const methodBadge = (method: string) => { + const colors: Record = { GET: 'method-get', POST: 'method-post', PUT: 'method-put', DELETE: 'method-delete' }; + return {method}; + }; + + const copyToClipboard = (text: string) => { + navigator.clipboard.writeText(text).then(() => { + alert('Copied to clipboard'); + }).catch(() => alert('Copy failed')); + }; + + return ( +
+
+

开发者工具

+

Webhook 配置、SDK 下载中心、交互式 API 文档

+
+ + {/* Tab Navigation */} +
+ + + +
+ + {/* Tab Content */} +
+ {loading &&
Loading...
} + + {/* ===== Webhooks Tab ===== */} + {activeTab === 'webhooks' && !loading && ( +
+ {selectedWebhook ? ( + /* Webhook Detail / Deliveries View */ +
+
+ +

{selectedWebhook.name} - Delivery History

+ +
+ + {testResult && ( +
+ Test Result: + {testResult.status} + {testResult.response_code && | HTTP {testResult.response_code}} + {testResult.response_time_ms != null && | {testResult.response_time_ms}ms} + {testResult.error_message &&
{testResult.error_message}
} +
+ )} + + + + + + + {deliveries.items.map(d => ( + + + + + + + + + + ))} + {deliveries.items.length === 0 && } + +
EventStatusResponse CodeResponse TimeAttemptsTimeError
{d.event}{statusBadge(d.status)}{d.response_code || '-'}{d.response_time_ms != null ? `${d.response_time_ms}ms` : '-'}{d.attempts}{fmtTime(d.created_at)}{d.error_message || '-'}
No delivery records
+ {deliveries.total > 20 && ( +
+ + Page {deliveries.page} / {Math.ceil(deliveries.total / 20)} + +
+ )} +
+ ) : ( + /* Webhook List View */ +
+
+ +
+ + + + + + {webhooks.items.map(w => ( + + + + + + + + + + + ))} + {webhooks.items.length === 0 && } + +
NameURLEventsStatusDeliveriesSuccess RateLast TriggeredActions
{w.name}{w.url} + {w.events.length > 0 ? ( + {w.events.length} events + ) : All events} + {statusBadge(w.status)}{w.total_deliveries} + = 80 ? '#52c41a' : w.success_rate >= 50 ? '#faad14' : '#ff4d4f' }}> + {w.success_rate}% + + ({w.success_count}/{w.total_deliveries}) + {fmtTime(w.last_triggered_at)} + + + + +
No webhooks configured
+ {webhooks.total > 20 && ( +
+ + Page {webhooks.page} / {Math.ceil(webhooks.total / 20)} + +
+ )} + + {/* Supported Events Info */} + {webhookEvents.length > 0 && ( +
+

Supported Events

+
+ {webhookEvents.map(ev => ( +
+ {ev.value} +
{ev.desc}
+
+ ))} +
+
+ )} +
+ )} + + {/* Create Webhook Modal */} + {showWebhookModal && ( +
setShowWebhookModal(false)}> +
e.stopPropagation()}> +

New Webhook

+
+ + setWebhookForm({ ...webhookForm, name: e.target.value })} placeholder="My Webhook" /> +
+
+ + setWebhookForm({ ...webhookForm, url: e.target.value })} placeholder="https://example.com/webhook" /> +
+
+ +
+ {webhookEvents.map(ev => ( + + ))} +
+
+
+ + +
+
+
+ )} +
+ )} + + {/* ===== SDK Download Center Tab ===== */} + {activeTab === 'sdk' && !loading && ( +
+ {/* Language Selector */} +
+ {sdkPackages.map(pkg => ( + + ))} +
+ + {sdkPackages.filter(p => p.language === selectedSdk).map(pkg => ( +
+ {/* Package Info */} +
+
+

{pkg.name} v{pkg.version}

+

{pkg.description}

+

Requires: {pkg.min_version}

+
+
+ Download + {pkg.github_url && GitHub} +
+
+ + {/* Install Command */} +
+ +
+ + {pkg.install_cmd} + + +
+
+ + {/* Features */} +
+ +
+ {pkg.features.map(f => ( + + {f} + + ))} +
+
+ + {/* Code Example */} +
+ +
+
+                      {pkg.code_example}
+                    
+ +
+
+
+ ))} + + {sdkPackages.length === 0 &&
Loading SDK packages...
} +
+ )} + + {/* ===== Interactive API Docs Tab ===== */} + {activeTab === 'docs' && !loading && ( +
+
+ Base URL: + http://210.16.161.218:18080 + API Version: v1 +
+ + {docSections.map(section => { + const isExpanded = expandedSection === section.section; + return ( +
+ {/* Section Header */} +
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' }} + > +
+ + {section.icon === 'lock' ? '🔒' : section.icon === 'chat' ? '💬' : section.icon === 'book' ? '📚' : section.icon === 'robot' ? '🤖' : section.icon === 'workflow' ? '🔄' : section.icon === 'storage' ? '💾' : section.icon === 'webhook' ? '🔗' : '📋'} + + {section.section} + {section.endpoints.length} endpoints +
+ {isExpanded ? '▼' : '▶'} +
+ + {/* Section Content */} + {isExpanded && ( +
+

{section.description}

+ {section.endpoints.map(ep => { + const epKey = `${section.section}-${ep.path}`; + const isEpExpanded = expandedEndpoint === epKey; + return ( +
+
setExpandedEndpoint(isEpExpanded ? null : epKey)} + style={{ padding: '10px 16px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '12px' }} + > + {methodBadge(ep.method)} + {ep.path} + {ep.title} + {isEpExpanded ? '▼' : '▶'} +
+ {isEpExpanded && ( +
+

{ep.description}

+ + {/* Parameters */} + {ep.params.length > 0 && ( +
+ + + + + + + {ep.params.map(p => ( + + + + + + + ))} + +
NameTypeRequiredDescription
{p.name}{p.type}{p.required ? Yes : No}{p.desc}
+
+ )} + + {/* Example Request */} + {ep.example_request && ( +
+ +
+                                      {ep.example_request}
+                                    
+
+ )} + + {/* Example Response */} + {ep.example_response && ( +
+ +
+                                      {ep.example_response}
+                                    
+
+ )} +
+ )} +
+ ); + })} +
+ )} +
+ ); + })} + + {docSections.length === 0 &&
Loading API documentation...
} +
+ )} +
+
+ ); +}