diff --git a/src/api.ts b/src/api.ts index 1f65dba..611d93d 100644 --- a/src/api.ts +++ b/src/api.ts @@ -408,6 +408,30 @@ export interface TransactionTreeDiff { changed: { a: SubInvocationExtended; b: SubInvocationExtended }[]; } +export interface NodeMetrics { + url: string; + latencyAvgMs: number | null; + latencyP95Ms: number | null; + errorRate: number; + uptime: number; + lastLedger: number; + sampleCount: number; + history: number[]; +} + +export interface DoctorReport { + runtimes: Record; + database: { connected: boolean; message: string }; + env: Record; + ports: Record; + system: { + disk: { status: string; freeGB?: string; message: string }; + memory: { status: string; totalGB?: string; freeGB?: string; message: string }; + }; + gitHooks: { status: string; message: string }; + docker: { status: string; message: string }; +} + export const api = { events: (params: { contract?: string; @@ -672,4 +696,60 @@ export const api = { if (filter?.function) q.set("function", filter.function); return `${BASE}/sub-invocations/stream?${q}`; }, + + // RPC metrics + rpcMetrics: () => get("/rpc-metrics"), + + // Admin Rate Limit Analytics + adminRateLimitHits: (token: string, minutes: number = 60) => + fetch(`${BASE}/admin/analytics/rate-limit-hits?minutes=${minutes}`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => { + if (!r.ok) throw new Error(`API ${r.status}`); + return r.json(); + }), + adminTopUsers: (token: string, window: string = "24h") => + fetch(`${BASE}/admin/analytics/top-users?window=${window}`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => { + if (!r.ok) throw new Error(`API ${r.status}`); + return r.json(); + }), + adminViolationHeatmap: (token: string) => + fetch(`${BASE}/admin/analytics/violation-heatmap`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => { + if (!r.ok) throw new Error(`API ${r.status}`); + return r.json(); + }), + adminUpgradeRecommendations: (token: string) => + fetch(`${BASE}/admin/analytics/upgrade-recommendations`, { + headers: { Authorization: `Bearer ${token}` }, + }).then((r) => { + if (!r.ok) throw new Error(`API ${r.status}`); + return r.json(); + }), + + // Setup Page + setupDoctor: () => get("/setup/doctor"), + setupTestDb: (databaseUrl: string) => + fetch(`${BASE}/setup/test-db`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ databaseUrl }), + }).then((r) => r.json()), + setupSaveConfig: (config: { sorobanRpcUrl: string; databaseUrl: string; pollMs: string }) => + fetch(`${BASE}/setup/save-config`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(config), + }).then((r) => r.json()), + setupDbInit: () => + fetch(`${BASE}/setup/db-init`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + }).then((r) => { + if (!r.ok) throw new Error(`API ${r.status}`); + return r.json(); + }), }; diff --git a/src/main.tsx b/src/main.tsx index 2bd9b8e..6b457f3 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -6,7 +6,15 @@ import App from "./App"; import { NetworkProvider } from "./contexts/NetworkContext"; import "./index.css"; -const qc = new QueryClient(); +const qc = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 5000, + retry: 3, + refetchOnWindowFocus: false, + }, + }, +}); ReactDOM.createRoot(document.getElementById("root")!).render( diff --git a/src/pages/BatchMultiCall.tsx b/src/pages/BatchMultiCall.tsx index 70e8417..373bd5e 100644 --- a/src/pages/BatchMultiCall.tsx +++ b/src/pages/BatchMultiCall.tsx @@ -4,6 +4,7 @@ */ import React, { useState, useCallback } from "react"; +import { useMutation } from "@tanstack/react-query"; import { api } from "../api"; import BatchFlowChart from "../components/BatchFlowChart"; import { @@ -50,101 +51,59 @@ export default function BatchMultiCall() { useState("sequential"); const [sourceAccount, setSourceAccount] = useState(""); const [simResult, setSimResult] = useState(null); - const [loading, setLoading] = useState(false); + const simulateMutation = useMutation({ + mutationFn: ({ batchCalls }: { batchCalls: BatchCall[] }) => + api.batchSimulate(batchCalls, sourceAccount || "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN"), + onSuccess: (data) => setSimResult(data), + onError: (e: any) => setSimResult({ success: false, error: e.message }) + }); + + const estimateGasMutation = useMutation({ + mutationFn: () => api.batchEstimateGas(calls, sourceAccount), + onSuccess: (data: any) => setSimResult({ success: true, totalGas: data.totalGas, estimates: data.estimates }), + onError: (e: any) => setSimResult({ success: false, error: e.message }) + }); + + const validateMutation = useMutation({ + mutationFn: () => api.batchValidate(calls, sourceAccount), + onSuccess: (data: any) => setSimResult({ success: data.valid, conflicts: data.conflicts, errors: data.errors }), + onError: (e: any) => setSimResult({ success: false, error: e.message }) + }); + + const optimizeMutation = useMutation({ + mutationFn: () => api.batchOptimize(calls, sourceAccount), + onSuccess: (data: any) => setSimResult({ success: true, optimizedOrder: data.optimizedOrder }), + onError: (e: any) => setSimResult({ success: false, error: e.message }) + }); + + const loading = simulateMutation.isPending || estimateGasMutation.isPending || validateMutation.isPending || optimizeMutation.isPending; const handleSimulate = useCallback( - async (mode: ExecutionMode, batchCalls: BatchCall[]) => { + (_mode: ExecutionMode, batchCalls: BatchCall[]) => { if (!batchCalls.length) return; - - setLoading(true); setSimResult(null); - - try { - const response = await fetch("/api/batch/simulate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - calls: batchCalls, - sourceAccount: - sourceAccount || - "GAAZI4TCR3TY5OJHCTJC2A4QSY6CJWJH5IAJTGKIN2ER7LBNVKOCCWN", - }), - }); - const data = await response.json(); - setSimResult(data); - } catch (e: any) { - setSimResult({ success: false, error: e.message }); - } finally { - setLoading(false); - } + simulateMutation.mutate({ batchCalls }); }, - [sourceAccount], + [simulateMutation], ); - const handleEstimateGas = useCallback(async () => { + const handleEstimateGas = useCallback(() => { if (!calls.length) return; + setSimResult(null); + estimateGasMutation.mutate(); + }, [calls, estimateGasMutation]); - setLoading(true); - try { - const response = await fetch("/api/batch/estimate-gas", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ calls, sourceAccount }), - }); - const data = await response.json(); - setSimResult({ - success: true, - totalGas: data.totalGas, - estimates: data.estimates, - }); - } catch (e: any) { - setSimResult({ success: false, error: e.message }); - } finally { - setLoading(false); - } - }, [calls, sourceAccount]); - - const handleValidate = useCallback(async () => { + const handleValidate = useCallback(() => { if (!calls.length) return; + setSimResult(null); + validateMutation.mutate(); + }, [calls, validateMutation]); - setLoading(true); - try { - const response = await fetch("/api/batch/validate", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ calls, sourceAccount }), - }); - const data = await response.json(); - setSimResult({ - success: data.valid, - conflicts: data.conflicts, - errors: data.errors, - }); - } catch (e: any) { - setSimResult({ success: false, error: e.message }); - } finally { - setLoading(false); - } - }, [calls, sourceAccount]); - - const handleOptimize = useCallback(async () => { + const handleOptimize = useCallback(() => { if (!calls.length) return; - - setLoading(true); - try { - const response = await fetch("/api/batch/optimize", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ calls, sourceAccount }), - }); - const data = await response.json(); - setSimResult({ success: true, optimizedOrder: data.optimizedOrder }); - } catch (e: any) { - setSimResult({ success: false, error: e.message }); - } finally { - setLoading(false); - } - }, [calls, sourceAccount]); + setSimResult(null); + optimizeMutation.mutate(); + }, [calls, optimizeMutation]); const exportAsHardhat = useCallback(() => { downloadText(api.exportBatchAsHardhat(calls), "batch-script.ts"); diff --git a/src/pages/RateLimitDashboard.tsx b/src/pages/RateLimitDashboard.tsx index f8f3ba7..f357464 100644 --- a/src/pages/RateLimitDashboard.tsx +++ b/src/pages/RateLimitDashboard.tsx @@ -2,7 +2,9 @@ * Rate Limit Analytics Dashboard * Polls analytics endpoints every 5 seconds. Requires admin authentication. */ -import { useEffect, useState, useCallback } from "react"; +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; import RateLimitHitsChart from "../components/RateLimitHitsChart"; import TopUsersTable from "../components/TopUsersTable"; import ViolationHeatmap from "../components/ViolationHeatmap"; @@ -47,63 +49,53 @@ export default function RateLimitDashboard() { () => sessionStorage.getItem("admin_token") ?? "", ); const [tokenInput, setTokenInput] = useState(""); - const [authed, setAuthed] = useState(false); - - const [hitsData, setHitsData] = useState([]); - const [topUsers, setTopUsers] = useState([]); - const [heatmap, setHeatmap] = useState([]); - const [recommendations, setRecommendations] = useState([]); const [topWindow, setTopWindow] = useState("24h"); - const [lastUpdated, setLastUpdated] = useState(null); - const [error, setError] = useState(null); - - const headers = { Authorization: `Bearer ${adminToken}` }; - - const fetchAll = useCallback(async () => { - if (!adminToken) return; - try { - const [hitsRes, topRes, heatmapRes, recsRes] = await Promise.all([ - fetch("/api/admin/analytics/rate-limit-hits?minutes=60", { headers }), - fetch(`/api/admin/analytics/top-users?window=${topWindow}`, { - headers, - }), - fetch("/api/admin/analytics/violation-heatmap", { headers }), - fetch("/api/admin/analytics/upgrade-recommendations", { headers }), - ]); - if (hitsRes.status === 401) { - setAuthed(false); - setError("Invalid or expired admin token."); - return; - } - - setHitsData(await hitsRes.json()); - setTopUsers(await topRes.json()); - setHeatmap(await heatmapRes.json()); - setRecommendations(await recsRes.json()); - setLastUpdated(new Date()); - setAuthed(true); - setError(null); - } catch (e: any) { - setError(e.message); - } - }, [adminToken, topWindow]); - - useEffect(() => { - if (!adminToken) return; - fetchAll(); - const id = setInterval(fetchAll, 5_000); - return () => clearInterval(id); - }, [fetchAll]); + const queryOpts = { + enabled: !!adminToken, + refetchInterval: 5000, + retry: false, + }; + + const hitsQuery = useQuery({ + queryKey: ["rateLimitHits", adminToken, 60], + queryFn: () => api.adminRateLimitHits(adminToken, 60), + ...queryOpts, + }); + + const topUsersQuery = useQuery({ + queryKey: ["topUsers", adminToken, topWindow], + queryFn: () => api.adminTopUsers(adminToken, topWindow), + ...queryOpts, + }); + + const heatmapQuery = useQuery({ + queryKey: ["violationHeatmap", adminToken], + queryFn: () => api.adminViolationHeatmap(adminToken), + ...queryOpts, + }); + + const recsQuery = useQuery({ + queryKey: ["upgradeRecommendations", adminToken], + queryFn: () => api.adminUpgradeRecommendations(adminToken), + ...queryOpts, + }); + + const queries = [hitsQuery, topUsersQuery, heatmapQuery, recsQuery]; + + const isUnauthorized = queries.some( + (q) => q.error instanceof Error && q.error.message.includes("401") + ); - // Re-fetch top users when window changes - useEffect(() => { - if (!authed) return; - fetch(`/api/admin/analytics/top-users?window=${topWindow}`, { headers }) - .then((r) => r.json()) - .then(setTopUsers) - .catch(() => {}); - }, [topWindow]); + const error = isUnauthorized ? "Invalid or expired admin token." : queries.find((q) => q.error)?.error?.toString() || null; + const authed = !!adminToken && hitsQuery.isSuccess && !isUnauthorized; + + const hitsData = hitsQuery.data || []; + const topUsers = topUsersQuery.data || []; + const heatmap = heatmapQuery.data || []; + const recommendations = recsQuery.data || []; + + const lastUpdated = queries[0].dataUpdatedAt ? new Date(queries[0].dataUpdatedAt) : null; // Login screen if (!adminToken || !authed) { @@ -202,7 +194,6 @@ export default function RateLimitDashboard() { onClick={() => { sessionStorage.removeItem("admin_token"); setAdminToken(""); - setAuthed(false); }} style={{ padding: "4px 12px", diff --git a/src/pages/RpcMetricsDashboard.tsx b/src/pages/RpcMetricsDashboard.tsx index eed3910..71f5f3a 100644 --- a/src/pages/RpcMetricsDashboard.tsx +++ b/src/pages/RpcMetricsDashboard.tsx @@ -2,18 +2,8 @@ * RPC Node Performance Dashboard * Polls /api/rpc-metrics every 15 s and renders latency sparklines + uptime. */ -import { useEffect, useState } from "react"; - -interface NodeMetrics { - url: string; - latencyAvgMs: number | null; - latencyP95Ms: number | null; - errorRate: number; - uptime: number; - lastLedger: number; - sampleCount: number; - history: number[]; -} +import { useQuery } from "@tanstack/react-query"; +import { api } from "../api"; function Sparkline({ values }: { values: number[] }) { if (!values.length) return no data; @@ -54,27 +44,14 @@ function StatusBadge({ healthy }: { healthy: boolean }) { } export default function RpcMetricsDashboard() { - const [metrics, setMetrics] = useState([]); - const [lastUpdated, setLastUpdated] = useState(null); - const [error, setError] = useState(null); - - const fetchMetrics = async () => { - try { - const res = await fetch("/api/rpc-metrics"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - setMetrics(await res.json()); - setLastUpdated(new Date()); - setError(null); - } catch (e: any) { - setError(e.message); - } - }; + const { data: metrics = [], error, dataUpdatedAt } = useQuery({ + queryKey: ["rpcMetrics"], + queryFn: api.rpcMetrics, + refetchInterval: 15000, + }); - useEffect(() => { - fetchMetrics(); - const id = setInterval(fetchMetrics, 15_000); - return () => clearInterval(id); - }, []); + const lastUpdated = dataUpdatedAt ? new Date(dataUpdatedAt) : null; + const errorMessage = error instanceof Error ? error.message : error ? String(error) : null; return (
@@ -94,7 +71,7 @@ export default function RpcMetricsDashboard() { )}
- {error && ( + {errorMessage && (
- Failed to load metrics: {error}. The backend may be unavailable — data + Failed to load metrics: {errorMessage}. The backend may be unavailable — data will refresh automatically when it comes back online.
)} - {!error && metrics.length === 0 && lastUpdated ? ( + {!errorMessage && metrics.length === 0 && lastUpdated ? (
- ) : !error && metrics.length === 0 ? ( + ) : !errorMessage && metrics.length === 0 ? (

Loading…

) : null} diff --git a/src/pages/SetupPage.tsx b/src/pages/SetupPage.tsx index 26c6b98..5ea0f76 100644 --- a/src/pages/SetupPage.tsx +++ b/src/pages/SetupPage.tsx @@ -1,25 +1,6 @@ import { useState, useEffect } from "react"; - -interface DoctorReport { - runtimes: Record< - string, - { status: string; version?: string; message: string } - >; - database: { connected: boolean; message: string }; - env: Record; - ports: Record; - system: { - disk: { status: string; freeGB?: string; message: string }; - memory: { - status: string; - totalGB?: string; - freeGB?: string; - message: string; - }; - }; - gitHooks: { status: string; message: string }; - docker: { status: string; message: string }; -} +import { useQuery, useMutation } from "@tanstack/react-query"; +import { api } from "../api"; export default function SetupPage() { // Form state @@ -29,123 +10,63 @@ export default function SetupPage() { ); const [pollMs, setPollMs] = useState("5000"); - // Health report state - const [report, setReport] = useState(null); - const [loadingDoctor, setLoadingDoctor] = useState(true); - const [doctorError, setDoctorError] = useState(""); + const [dbTestResult, setDbTestResult] = useState<{ success: boolean; error?: string; } | null>(null); + const [saveResult, setSaveResult] = useState(null); + const [dbInitResult, setDbInitResult] = useState<{ success: boolean; error?: string; } | null>(null); - // Action states - const [testingDb, setTestingDb] = useState(false); - const [dbTestResult, setDbTestResult] = useState<{ - success: boolean; - error?: string; - } | null>(null); + const { data: report, error, isPending: loadingDoctor, refetch: fetchDiagnostics } = useQuery({ + queryKey: ["setupDoctor"], + queryFn: api.setupDoctor, + }); - const [savingConfig, setSavingConfig] = useState(false); - const [saveResult, setSaveResult] = useState(null); + const doctorError = error instanceof Error ? `Failed to load health diagnostics: ${error.message}` : error ? String(error) : ""; - const [initializingDb, setInitializingDb] = useState(false); - const [dbInitResult, setDbInitResult] = useState<{ - success: boolean; - error?: string; - } | null>(null); - - // Fetch diagnostics - const fetchDiagnostics = async () => { - setLoadingDoctor(true); - setDoctorError(""); - try { - const res = await fetch("/api/setup/doctor"); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data = await res.json(); - setReport(data); - - // Pre-fill form from report values if configured - if (data.env.SOROBAN_RPC_URL?.value !== "Not set") { - setRpcUrl(data.env.SOROBAN_RPC_URL.value); + useEffect(() => { + if (report) { + if (report.env.SOROBAN_RPC_URL?.value !== "Not set") { + setRpcUrl(report.env.SOROBAN_RPC_URL.value); } - if (data.env.DATABASE_URL?.value !== "Not set") { - setDbUrl(data.env.DATABASE_URL.value); + if (report.env.DATABASE_URL?.value !== "Not set") { + setDbUrl(report.env.DATABASE_URL.value); } - } catch (err: any) { - setDoctorError(`Failed to load health diagnostics: ${err.message}`); - } finally { - setLoadingDoctor(false); } - }; + }, [report]); - useEffect(() => { - fetchDiagnostics(); - }, []); - - // Handlers - const handleTestConnection = async () => { - setTestingDb(true); - setDbTestResult(null); - try { - const res = await fetch("/api/setup/test-db", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ databaseUrl: dbUrl }), - }); - const data = await res.json(); - setDbTestResult(data); - } catch (err: any) { - setDbTestResult({ success: false, error: err.message }); - } finally { - setTestingDb(false); - } - }; + const testDbMutation = useMutation({ + mutationFn: () => api.setupTestDb(dbUrl), + onSuccess: (data) => setDbTestResult(data), + onError: (e: any) => setDbTestResult({ success: false, error: e.message }) + }); - const handleSaveConfig = async () => { - setSavingConfig(true); - setSaveResult(null); - try { - const res = await fetch("/api/setup/save-config", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - sorobanRpcUrl: rpcUrl, - databaseUrl: dbUrl, - pollMs, - }), - }); - const data = await res.json(); + const saveConfigMutation = useMutation({ + mutationFn: () => api.setupSaveConfig({ sorobanRpcUrl: rpcUrl, databaseUrl: dbUrl, pollMs }), + onSuccess: (data) => { setSaveResult(data.success); + if (data.success) fetchDiagnostics(); + }, + onError: () => setSaveResult(false) + }); + + const initDbMutation = useMutation({ + mutationFn: () => api.setupDbInit(), + onSuccess: (data) => { if (data.success) { - fetchDiagnostics(); // refresh - } - } catch { - setSaveResult(false); - } finally { - setSavingConfig(false); - } - }; - - const handleInitDatabase = async () => { - setInitializingDb(true); - setDbInitResult(null); - try { - const res = await fetch("/api/setup/db-init", { - method: "POST", - headers: { "Content-Type": "application/json" }, - }); - const data = await res.json(); - if (res.ok && data.success) { setDbInitResult({ success: true }); fetchDiagnostics(); } else { - setDbInitResult({ - success: false, - error: data.error || "Initialization failed", - }); + setDbInitResult({ success: false, error: data.error || "Initialization failed" }); } - } catch (err: any) { - setDbInitResult({ success: false, error: err.message }); - } finally { - setInitializingDb(false); - } - }; + }, + onError: (e: any) => setDbInitResult({ success: false, error: e.message }) + }); + + const testingDb = testDbMutation.isPending; + const savingConfig = saveConfigMutation.isPending; + const initializingDb = initDbMutation.isPending; + + const handleTestConnection = () => testDbMutation.mutate(); + const handleSaveConfig = () => saveConfigMutation.mutate(); + const handleInitDatabase = () => initDbMutation.mutate(); // Diagnostic helper functions const getBadgeClass = (status: string) => { @@ -413,7 +334,7 @@ export default function SetupPage() { >

System Health & Prerequisites