admin_page
This commit is contained in:
parent
6a653b55df
commit
f5d4206127
@ -9,6 +9,14 @@ const API_BASE_URL = (ENV_API_BASE_URL || (IS_LOCALHOST ? "http://localhost:8000
|
||||
.replace(/\/api$/, "");
|
||||
const REQUEST_TIMEOUT_MS = 12000;
|
||||
|
||||
function truncateText(value: string, maxLength = 180) {
|
||||
return value.length > maxLength ? `${value.slice(0, maxLength - 3)}...` : value;
|
||||
}
|
||||
|
||||
function looksLikeHtml(value: string) {
|
||||
return /<!doctype html|<html[\s>]|<head[\s>]|<body[\s>]/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeApiPath(url: string) {
|
||||
const parsed = new URL(url, "https://frontend.local");
|
||||
const pathname = parsed.pathname.startsWith("/api")
|
||||
@ -29,10 +37,60 @@ export function resolveApiUrl(url: string) {
|
||||
return `${API_BASE_URL}${normalizedPath}`;
|
||||
}
|
||||
|
||||
async function getResponseErrorMessage(res: Response) {
|
||||
const contentType = res.headers.get("content-type") || "";
|
||||
|
||||
if (contentType.includes("application/json")) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
const detail =
|
||||
(typeof data?.detail === "string" && data.detail) ||
|
||||
(typeof data?.message === "string" && data.message) ||
|
||||
(typeof data?.error === "string" && data.error);
|
||||
if (detail) {
|
||||
return `${res.status}: ${detail}`;
|
||||
}
|
||||
} catch {
|
||||
// Fall back to response text below.
|
||||
}
|
||||
}
|
||||
|
||||
const text = ((await res.text()) || res.statusText).trim();
|
||||
if (!text) {
|
||||
return `${res.status}: ${res.statusText}`;
|
||||
}
|
||||
if (looksLikeHtml(text)) {
|
||||
return `${res.status}: Unexpected HTML response from the API. Check backend routing or deployment config.`;
|
||||
}
|
||||
|
||||
return `${res.status}: ${truncateText(text.replace(/\s+/g, " "))}`;
|
||||
}
|
||||
|
||||
async function throwIfResNotOk(res: Response) {
|
||||
if (!res.ok) {
|
||||
const text = (await res.text()) || res.statusText;
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
throw new Error(await getResponseErrorMessage(res));
|
||||
}
|
||||
}
|
||||
|
||||
async function throwIfUnexpectedApiPayload(res: Response) {
|
||||
if (res.status === 204) {
|
||||
return;
|
||||
}
|
||||
|
||||
const contentType = (res.headers.get("content-type") || "").toLowerCase();
|
||||
if (contentType.includes("application/json")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const body = (await res.clone().text()).trim();
|
||||
if (!body) {
|
||||
return;
|
||||
}
|
||||
if (body.startsWith("{") || body.startsWith("[")) {
|
||||
return;
|
||||
}
|
||||
if (looksLikeHtml(body)) {
|
||||
throw new Error("Unexpected HTML response from the API. Check backend routing or deployment config.");
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,12 +120,16 @@ export async function apiRequest(
|
||||
): Promise<Response> {
|
||||
const res = await fetchWithTimeout(resolveApiUrl(url), {
|
||||
method,
|
||||
headers: data ? { "Content-Type": "application/json" } : {},
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
...(data ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: data ? JSON.stringify(data) : undefined,
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
await throwIfUnexpectedApiPayload(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
@ -78,6 +140,9 @@ export const getQueryFn: <T>(options: {
|
||||
({ on401: unauthorizedBehavior }) =>
|
||||
async ({ queryKey }) => {
|
||||
const res = await fetchWithTimeout(resolveApiUrl(queryKey.join("/") as string), {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
@ -86,6 +151,7 @@ export const getQueryFn: <T>(options: {
|
||||
}
|
||||
|
||||
await throwIfResNotOk(res);
|
||||
await throwIfUnexpectedApiPayload(res);
|
||||
return await res.json();
|
||||
};
|
||||
|
||||
|
||||
@ -12,7 +12,17 @@ export default function AdminInvariants() {
|
||||
});
|
||||
|
||||
if (invariantsQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading invariants…</div>;
|
||||
return <div className="text-sm text-muted-foreground">Loading invariants...</div>;
|
||||
}
|
||||
|
||||
if (invariantsQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{invariantsQuery.error instanceof Error
|
||||
? invariantsQuery.error.message
|
||||
: "Failed to load invariant data."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!invariantsQuery.data) {
|
||||
@ -36,7 +46,7 @@ export default function AdminInvariants() {
|
||||
>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-muted-foreground">{key}</p>
|
||||
<p className={`mt-2 text-2xl font-semibold ${value ? "text-red-400" : "text-emerald-400"}`}>
|
||||
{value}
|
||||
{String(value)}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -21,7 +21,17 @@ export default function AdminOverview() {
|
||||
});
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading overview…</div>;
|
||||
return <div className="text-sm text-muted-foreground">Loading overview...</div>;
|
||||
}
|
||||
|
||||
if (overviewQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{overviewQuery.error instanceof Error
|
||||
? overviewQuery.error.message
|
||||
: "Failed to load admin overview."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!overviewQuery.data) {
|
||||
@ -66,7 +76,7 @@ export default function AdminOverview() {
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">Run Status</p>
|
||||
<div className="h-64 mt-4">
|
||||
<div className="mt-4 h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={runStatusData}>
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} />
|
||||
@ -79,7 +89,7 @@ export default function AdminOverview() {
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">Activity (last 24h)</p>
|
||||
<div className="h-64 mt-4">
|
||||
<div className="mt-4 h-64">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={activityData}>
|
||||
<XAxis dataKey="name" tickLine={false} axisLine={false} />
|
||||
@ -94,7 +104,7 @@ export default function AdminOverview() {
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">Latest Errors</p>
|
||||
{data.top_errors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground mt-4">No recent errors.</p>
|
||||
<p className="mt-4 text-sm text-muted-foreground">No recent errors.</p>
|
||||
) : (
|
||||
<div className="mt-4 space-y-3">
|
||||
{data.top_errors.map((err, idx) => (
|
||||
@ -107,7 +117,7 @@ export default function AdminOverview() {
|
||||
<span className="text-xs text-muted-foreground">{err.ts}</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{err.source} • {err.user_id ?? "unknown"} • {err.run_id ?? "n/a"}
|
||||
{err.source} | {err.user_id ?? "unknown"} | {err.run_id ?? "n/a"}
|
||||
</p>
|
||||
<p className="mt-1">{err.message ?? "No message"}</p>
|
||||
</div>
|
||||
|
||||
@ -31,7 +31,12 @@ export default function AdminPage() {
|
||||
|
||||
const checkAccess = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch(resolveApiUrl("admin/overview"), { credentials: "include" });
|
||||
const res = await fetch(resolveApiUrl("admin/overview"), {
|
||||
credentials: "include",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
});
|
||||
if (res.status === 401) {
|
||||
setAccessState("unauthenticated");
|
||||
return;
|
||||
@ -40,6 +45,11 @@ export default function AdminPage() {
|
||||
setAccessState("forbidden");
|
||||
return;
|
||||
}
|
||||
const contentType = (res.headers.get("content-type") || "").toLowerCase();
|
||||
if (res.ok && contentType.includes("text/html")) {
|
||||
setAccessState("forbidden");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setAccessState("forbidden");
|
||||
return;
|
||||
|
||||
@ -13,7 +13,15 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
|
||||
});
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading run…</div>;
|
||||
return <div className="text-sm text-muted-foreground">Loading run...</div>;
|
||||
}
|
||||
|
||||
if (detailQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{detailQuery.error instanceof Error ? detailQuery.error.message : "Failed to load run."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detailQuery.data) {
|
||||
@ -27,7 +35,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<Link href="/admin/runs">
|
||||
<a className="text-xs text-primary hover:underline">← Back to runs</a>
|
||||
<a className="text-xs text-primary hover:underline"><- Back to runs</a>
|
||||
</Link>
|
||||
<h2 className="mt-2 text-2xl font-semibold">{run.run_id}</h2>
|
||||
<p className="text-xs text-muted-foreground">User: {run.user_id}</p>
|
||||
@ -38,17 +46,17 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
|
||||
<p className="text-sm font-semibold">Metadata</p>
|
||||
<div className="mt-3 space-y-1 text-xs text-muted-foreground">
|
||||
<p>Status: {run.status}</p>
|
||||
<p>Mode: {run.mode ?? "—"}</p>
|
||||
<p>Strategy: {run.strategy ?? "—"}</p>
|
||||
<p>Started: {run.started_at ?? "—"}</p>
|
||||
<p>Last event: {run.last_event_time ?? "—"}</p>
|
||||
<p>Mode: {run.mode ?? "-"}</p>
|
||||
<p>Strategy: {run.strategy ?? "-"}</p>
|
||||
<p>Started: {run.started_at ?? "-"}</p>
|
||||
<p>Last event: {run.last_event_time ?? "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">Engine Status</p>
|
||||
<div className="mt-3 space-y-1 text-xs text-muted-foreground">
|
||||
<p>Status: {engine_status?.status ?? "—"}</p>
|
||||
<p>Updated: {engine_status?.last_updated ?? "—"}</p>
|
||||
<p>Status: {engine_status?.status ?? "-"}</p>
|
||||
<p>Updated: {engine_status?.last_updated ?? "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -56,13 +64,13 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">Config</p>
|
||||
<pre className="mt-3 text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
<pre className="mt-3 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{JSON.stringify(config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">State Snapshot</p>
|
||||
<pre className="mt-3 text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
<pre className="mt-3 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{JSON.stringify(state_snapshot, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@ -74,9 +82,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
|
||||
{Object.entries(invariants).map(([key, value]) => (
|
||||
<div key={key} className="flex items-center justify-between">
|
||||
<span>{key}</span>
|
||||
<span className={value ? "text-red-400" : "text-emerald-400"}>
|
||||
{value}
|
||||
</span>
|
||||
<span className={value ? "text-red-400" : "text-emerald-400"}>{String(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -97,7 +103,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm overflow-x-auto">
|
||||
<p className="text-sm font-semibold">Orders</p>
|
||||
<table className="min-w-full text-xs mt-3">
|
||||
<table className="mt-3 min-w-full text-xs">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left">ID</th>
|
||||
@ -119,7 +125,7 @@ export default function AdminRunDetail({ runId }: { runId: string }) {
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm overflow-x-auto">
|
||||
<p className="text-sm font-semibold">Trades</p>
|
||||
<table className="min-w-full text-xs mt-3">
|
||||
<table className="mt-3 min-w-full text-xs">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left">ID</th>
|
||||
|
||||
@ -32,7 +32,15 @@ export default function AdminRuns() {
|
||||
});
|
||||
|
||||
if (runsQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading runs…</div>;
|
||||
return <div className="text-sm text-muted-foreground">Loading runs...</div>;
|
||||
}
|
||||
|
||||
if (runsQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{runsQuery.error instanceof Error ? runsQuery.error.message : "Failed to load runs."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = runsQuery.data;
|
||||
@ -97,7 +105,7 @@ export default function AdminRuns() {
|
||||
</td>
|
||||
<td className="px-4 py-2 text-xs">{run.user_id}</td>
|
||||
<td className="px-4 py-2 text-xs">{run.status}</td>
|
||||
<td className="px-4 py-2 text-xs">{run.mode ?? "—"}</td>
|
||||
<td className="px-4 py-2 text-xs">{run.mode ?? "-"}</td>
|
||||
<td className="px-4 py-2 text-xs">{run.order_count}</td>
|
||||
<td className="px-4 py-2 text-xs">{run.trade_count}</td>
|
||||
</tr>
|
||||
|
||||
@ -25,7 +25,7 @@ type TicketsResponse = {
|
||||
|
||||
export default function AdminSupportTickets() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data, isLoading } = useQuery<TicketsResponse>({
|
||||
const { data, isLoading, isError, error } = useQuery<TicketsResponse>({
|
||||
queryKey: ["admin/support-tickets"],
|
||||
queryFn: getQueryFn<TicketsResponse>({ on401: "throw" }),
|
||||
});
|
||||
@ -46,6 +46,14 @@ export default function AdminSupportTickets() {
|
||||
return <div className="text-muted-foreground">Loading tickets...</div>;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{error instanceof Error ? error.message : "Failed to load support tickets."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@ -13,7 +13,15 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
|
||||
});
|
||||
|
||||
if (detailQuery.isLoading) {
|
||||
return <div className="text-sm text-muted-foreground">Loading user…</div>;
|
||||
return <div className="text-sm text-muted-foreground">Loading user...</div>;
|
||||
}
|
||||
|
||||
if (detailQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{detailQuery.error instanceof Error ? detailQuery.error.message : "Failed to load user."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detailQuery.data) {
|
||||
@ -26,7 +34,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<Link href="/admin/users">
|
||||
<a className="text-xs text-primary hover:underline">← Back to users</a>
|
||||
<a className="text-xs text-primary hover:underline"><- Back to users</a>
|
||||
</Link>
|
||||
<h2 className="mt-2 text-2xl font-semibold">{user.username}</h2>
|
||||
<p className="text-xs text-muted-foreground">User ID: {user.user_id}</p>
|
||||
@ -36,17 +44,17 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">Capital Summary</p>
|
||||
<div className="mt-3 space-y-1 text-xs text-muted-foreground">
|
||||
<p>Cash: {capital_summary.cash ?? "—"}</p>
|
||||
<p>Invested: {capital_summary.invested ?? "—"}</p>
|
||||
<p>MTM: {capital_summary.mtm ?? "—"}</p>
|
||||
<p>Equity: {capital_summary.equity ?? "—"}</p>
|
||||
<p>PnL: {capital_summary.pnl ?? "—"}</p>
|
||||
<p>Cash: {capital_summary.cash ?? "-"}</p>
|
||||
<p>Invested: {capital_summary.invested ?? "-"}</p>
|
||||
<p>MTM: {capital_summary.mtm ?? "-"}</p>
|
||||
<p>Equity: {capital_summary.equity ?? "-"}</p>
|
||||
<p>PnL: {capital_summary.pnl ?? "-"}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-border/60 bg-card/70 p-6 shadow-sm">
|
||||
<p className="text-sm font-semibold">Current Config</p>
|
||||
<pre className="mt-3 text-xs text-muted-foreground whitespace-pre-wrap">
|
||||
<pre className="mt-3 whitespace-pre-wrap text-xs text-muted-foreground">
|
||||
{JSON.stringify(current_config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@ -61,7 +69,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
|
||||
<a className="text-primary hover:underline">{run.run_id}</a>
|
||||
</Link>
|
||||
<span className="text-muted-foreground">{run.status}</span>
|
||||
<span className="text-muted-foreground">{run.created_at ?? "—"}</span>
|
||||
<span className="text-muted-foreground">{run.created_at ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@ -74,7 +82,7 @@ export default function AdminUserDetail({ userId }: { userId: string }) {
|
||||
<div key={`${evt.ts}-${idx}`} className="text-xs text-muted-foreground">
|
||||
<span className="text-foreground">{evt.event}</span>{" "}
|
||||
<span>({evt.source})</span>{" "}
|
||||
<span>{evt.ts ?? "—"}</span>
|
||||
<span>{evt.ts ?? "-"}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -109,6 +109,14 @@ export default function AdminUsers() {
|
||||
return <div className="text-sm text-muted-foreground">Loading users...</div>;
|
||||
}
|
||||
|
||||
if (usersQuery.isError) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-destructive/30 bg-destructive/5 p-4 text-sm text-destructive">
|
||||
{usersQuery.error instanceof Error ? usersQuery.error.message : "Failed to load users."}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const data = usersQuery.data;
|
||||
if (!data) {
|
||||
return <div className="text-sm text-muted-foreground">No users.</div>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user