diff --git a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx index 3442d05d87..3a82e76f0e 100644 --- a/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx +++ b/src/app/(dashboard)/dashboard/endpoint/ApiEndpointsTab.tsx @@ -1,46 +1,842 @@ "use client"; +import { useState, useEffect, useMemo } from "react"; import { Card } from "@/shared/components"; -import { useTranslations } from "next-intl"; +/* ─── Types ──────────────────────────────────────────── */ +interface Endpoint { + method: string; + path: string; + tags: string[]; + summary: string; + description: string; + security: boolean; + parameters: any[]; + requestBody: boolean; + responses: string[]; +} + +interface CatalogData { + info: { title?: string; version?: string; description?: string }; + servers: { url: string; description?: string }[]; + tags: { name: string; description?: string }[]; + endpoints: Endpoint[]; + schemas: string[]; +} + +interface WebhookItem { + id: string; + url: string; + events: string[]; + secret: string | null; + enabled: boolean; + description: string; + created_at: string; + last_triggered_at: string | null; + last_status: number | null; + failure_count: number; +} + +interface TryItResult { + status: number; + statusText: string; + headers: Record; + body: any; + latencyMs: number; + contentType: string; +} + +const METHOD_COLORS: Record = { + GET: "bg-emerald-500/15 text-emerald-500 border-emerald-500/30", + POST: "bg-blue-500/15 text-blue-500 border-blue-500/30", + PUT: "bg-amber-500/15 text-amber-500 border-amber-500/30", + PATCH: "bg-orange-500/15 text-orange-500 border-orange-500/30", + DELETE: "bg-red-500/15 text-red-500 border-red-500/30", +}; + +const WEBHOOK_EVENTS = [ + "request.completed", + "request.failed", + "provider.error", + "provider.recovered", + "quota.exceeded", + "combo.switched", +]; + +/* ─── Main Component ─────────────────────────────────── */ export default function ApiEndpointsTab() { - const t = useTranslations("endpoints"); + const [catalog, setCatalog] = useState(null); + const [loading, setLoading] = useState(true); + const [section, setSection] = useState<"catalog" | "webhooks">("catalog"); + const [search, setSearch] = useState(""); + const [expandedEndpoint, setExpandedEndpoint] = useState(null); + const [selectedTag, setSelectedTag] = useState(null); + + // Try It state + const [tryingEndpoint, setTryingEndpoint] = useState(null); + const [tryBody, setTryBody] = useState(""); + const [tryResult, setTryResult] = useState(null); + const [trying, setTrying] = useState(false); + + // Webhooks state + const [webhooks, setWebhooks] = useState([]); + const [webhooksLoading, setWebhooksLoading] = useState(false); + const [showAddWebhook, setShowAddWebhook] = useState(false); + const [whUrl, setWhUrl] = useState(""); + const [whEvents, setWhEvents] = useState(["*"]); + const [whDesc, setWhDesc] = useState(""); + const [testingWebhookId, setTestingWebhookId] = useState(null); + + // Load catalog + const loadCatalog = async () => { + try { + const res = await fetch("/api/openapi/spec"); + if (res.ok) { + const data = await res.json(); + return data; + } + } catch {} + return null; + }; + + useEffect(() => { + let cancelled = false; + loadCatalog().then((data) => { + if (!cancelled) { + setCatalog(data); + setLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, []); + + // Load webhooks + const fetchWebhooksData = async (): Promise => { + try { + const res = await fetch("/api/webhooks"); + if (res.ok) { + const data = await res.json(); + return data.webhooks || []; + } + } catch {} + return []; + }; + + const loadWebhooks = async () => { + setWebhooksLoading(true); + const data = await fetchWebhooksData(); + setWebhooks(data); + setWebhooksLoading(false); + }; + + useEffect(() => { + if (section !== "webhooks") return; + let cancelled = false; + fetchWebhooksData().then((data) => { + if (!cancelled) { + setWebhooks(data); + setWebhooksLoading(false); + } + }); + return () => { + cancelled = true; + }; + }, [section]); + + // Filter endpoints + const filteredEndpoints = useMemo(() => { + if (!catalog) return []; + return catalog.endpoints.filter((ep) => { + const matchesSearch = + !search || + ep.path.toLowerCase().includes(search.toLowerCase()) || + ep.summary.toLowerCase().includes(search.toLowerCase()) || + ep.tags.some((t) => t.toLowerCase().includes(search.toLowerCase())); + const matchesTag = !selectedTag || ep.tags.includes(selectedTag); + return matchesSearch && matchesTag; + }); + }, [catalog, search, selectedTag]); + + // Group by tag + const groupedEndpoints = useMemo(() => { + const groups: Record = {}; + for (const ep of filteredEndpoints) { + const tag = ep.tags[0] || "Other"; + if (!groups[tag]) groups[tag] = []; + groups[tag].push(ep); + } + return groups; + }, [filteredEndpoints]); + + const allTags = useMemo(() => { + if (!catalog) return []; + return catalog.tags.map((t) => t.name); + }, [catalog]); + + // Try It handler + const handleTryIt = async (ep: Endpoint) => { + const key = `${ep.method}:${ep.path}`; + if (tryingEndpoint === key) { + setTryingEndpoint(null); + setTryResult(null); + return; + } + setTryingEndpoint(key); + setTryResult(null); + setTryBody(ep.method === "GET" ? "" : "{\n \n}"); + }; + + const executeTryIt = async (ep: Endpoint) => { + setTrying(true); + try { + const res = await fetch("/api/openapi/try", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + method: ep.method, + path: ep.path.replace("/api/", "/"), + body: tryBody ? JSON.parse(tryBody) : undefined, + }), + }); + if (res.ok) setTryResult(await res.json()); + } catch (err: any) { + setTryResult({ + status: 0, + statusText: "Error", + headers: {}, + body: { error: err.message }, + latencyMs: 0, + contentType: "application/json", + }); + } + setTrying(false); + }; + + // Webhook handlers + const addWebhook = async () => { + if (!whUrl.trim()) return; + try { + await fetch("/api/webhooks", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: whUrl, events: whEvents, description: whDesc }), + }); + setWhUrl(""); + setWhEvents(["*"]); + setWhDesc(""); + setShowAddWebhook(false); + await loadWebhooks(); + } catch {} + }; + + const toggleWebhook = async (wh: WebhookItem) => { + try { + await fetch(`/api/webhooks/${wh.id}`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled: !wh.enabled }), + }); + setWebhooks((prev) => prev.map((w) => (w.id === wh.id ? { ...w, enabled: !w.enabled } : w))); + } catch {} + }; + + const deleteWebhook = async (id: string) => { + if (!confirm("Delete this webhook?")) return; + try { + await fetch(`/api/webhooks/${id}`, { method: "DELETE" }); + setWebhooks((prev) => prev.filter((w) => w.id !== id)); + } catch {} + }; + + const testWebhook = async (id: string) => { + setTestingWebhookId(id); + try { + await fetch(`/api/webhooks/${id}/test`, { method: "POST" }); + await loadWebhooks(); + } catch {} + setTestingWebhookId(null); + }; + + if (loading) { + return ( +
+
+
+
+
+
+ ); + } return ( -
- -
- code -
-

{t("apiEndpointsTitle")}

-

{t("apiEndpointsDescription")}

-
- construction - {t("comingSoon")} -
-
+
+ {/* Header with spec info */} + {catalog && ( + +
+
+
+ api +
+
+
+

{catalog.info.title || "API"}

+ + {catalog.info.version} + +
+

+ {catalog.endpoints.length} endpoints across {allTags.length} categories +

+
+
+ +
+
+ )} - -

{t("plannedFeatures")}

-
    -
  • - check_circle - {t("featureRestApi")} -
  • -
  • - check_circle - {t("featureWebhooks")} -
  • -
  • - check_circle - {t("featureSwagger")} -
  • -
  • - check_circle - {t("featureAuth")} -
  • -
-
+ {/* Section tabs */} +
+ {[ + { id: "catalog" as const, label: "API Catalog", icon: "menu_book" }, + { id: "webhooks" as const, label: "Webhooks", icon: "webhook" }, + ].map((tab) => ( + + ))} +
+ + {/* ═══ API CATALOG ═══ */} + {section === "catalog" && catalog && ( + <> + {/* Search & filter */} +
+
+ + search + + setSearch(e.target.value)} + placeholder="Search endpoints..." + className="w-full pl-9 pr-3 py-2 text-xs rounded-lg border border-black/10 dark:border-white/10 + bg-white dark:bg-black/20 focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ + {allTags.slice(0, 8).map((tag) => ( + + ))} + {allTags.length > 8 && ( + + +{allTags.length - 8} more + + )} +
+
+ + {/* Endpoint groups */} + {Object.entries(groupedEndpoints).map(([tag, endpoints]) => ( + +
+ folder +

+ {tag} +

+ + {endpoints.length} + +
+
+
+ {endpoints.map((ep) => { + const key = `${ep.method}:${ep.path}`; + const isExpanded = expandedEndpoint === key; + const isTrying = tryingEndpoint === key; + + return ( +
+
setExpandedEndpoint(isExpanded ? null : key)} + > + + {ep.method} + + + {ep.path} + + + {ep.summary} + + {ep.security && ( + + lock + + )} + + expand_more + +
+ + {/* Expanded detail */} + {isExpanded && ( +
+
+
+

{ep.summary}

+ {ep.description && ep.description !== ep.summary && ( +

{ep.description}

+ )} +
+ {ep.security && ( + + + lock + + Bearer Auth + + )} + {ep.requestBody && ( + + + description + + Request Body + + )} + + Responses: {ep.responses.join(", ")} + +
+
+ +
+ + {/* curl example */} +
+

+ Example +

+ + curl -X {ep.method} http://localhost:20128 + {ep.path.replace("/api/", "/")} + {ep.security ? ' -H "Authorization: Bearer YOUR_KEY"' : ""} + {ep.requestBody + ? " -H \"Content-Type: application/json\" -d '{...}'" + : ""} + +
+ + {/* Try It panel */} + {isTrying && ( +
+ {ep.method !== "GET" && ( +
+ +