Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { status: string; version?: string; message: string }>;
database: { connected: boolean; message: string };
env: Record<string, { status: string; value: string; message: string }>;
ports: Record<string, { status: string; inUse: boolean; message: string }>;
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;
Expand Down Expand Up @@ -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<NodeMetrics[]>("/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<DoctorReport>("/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();
}),
};
10 changes: 9 additions & 1 deletion src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
Expand Down
125 changes: 42 additions & 83 deletions src/pages/BatchMultiCall.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -50,101 +51,59 @@ export default function BatchMultiCall() {
useState<ExecutionMode>("sequential");
const [sourceAccount, setSourceAccount] = useState("");
const [simResult, setSimResult] = useState<SimResult | null>(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");
Expand Down
103 changes: 47 additions & 56 deletions src/pages/RateLimitDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<Window>("24h");
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [error, setError] = useState<string | null>(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) {
Expand Down Expand Up @@ -202,7 +194,6 @@ export default function RateLimitDashboard() {
onClick={() => {
sessionStorage.removeItem("admin_token");
setAdminToken("");
setAuthed(false);
}}
style={{
padding: "4px 12px",
Expand Down
Loading
Loading