diff --git a/frontend/components/ops/health/HealthPageClient.tsx b/frontend/components/ops/health/HealthPageClient.tsx index c6ff1992..78f7d812 100644 --- a/frontend/components/ops/health/HealthPageClient.tsx +++ b/frontend/components/ops/health/HealthPageClient.tsx @@ -4,6 +4,10 @@ import { useEffect, useState } from "react"; import { RefreshCcw, CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; import { Card, CardContent } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, + ResponsiveContainer, Cell, +} from "recharts"; type ServiceResult = { ok: boolean; status?: number; latency_ms?: number; error?: string }; type HealthPayload = { ok: boolean; checked_at: string; services: Record }; @@ -70,6 +74,14 @@ export function HealthPageClient() { const services = Object.entries(data.services); const okCount = services.filter(([, v]) => v.ok).length; + const latencyData = services + .filter(([, svc]) => svc.ok && svc.latency_ms != null) + .map(([key, svc]) => ({ + name: LABELS[key] || key, + latency: svc.latency_ms ?? 0, + })) + .sort((a, b) => a.latency - b.latency); + return (
@@ -87,6 +99,35 @@ export function HealthPageClient() {
+ {latencyData.length > 0 && ( + + +

服务响应延迟对比 (ms)

+ + + + + + [`${value} ms`, "延迟"]} + /> + + {latencyData.map((d, i) => { + const colors = ["#22c55e", "#10b981", "#14b8a6", "#06b6d4", "#0ea5e9", "#3b82f6", "#6366f1", "#8b5cf6", "#a855f7", "#d946ef", "#ec4899", "#f43f5e", "#ef4444"]; + return ; + })} + + + +
+
+ )} +
{services.map(([key, svc]) => ( diff --git a/frontend/components/ops/overview/OverviewPageClient.tsx b/frontend/components/ops/overview/OverviewPageClient.tsx index da831e0d..391827d4 100644 --- a/frontend/components/ops/overview/OverviewPageClient.tsx +++ b/frontend/components/ops/overview/OverviewPageClient.tsx @@ -11,6 +11,7 @@ import type { SystemStatusPayload, MembershipsPayload, MembershipEntry } from "@ import { PieChart, Pie, Cell, ResponsiveContainer, Tooltip, BarChart, Bar, XAxis, YAxis, CartesianGrid, + AreaChart, Area, Legend, } from "recharts"; function KpiCard({ href, icon: Icon, label, value, color, sub }: { @@ -37,18 +38,21 @@ export function OverviewPageClient() { const [status, setStatus] = useState(null); const [memberships, setMemberships] = useState([]); const [funnel, setFunnel] = useState<{ steps: { label: string; count: number; pct_of_prev?: number }[] } | null>(null); + const [growth, setGrowth] = useState<{ date: string; trial: number; paid: number; total: number; cumulative: number }[]>([]); const load = async () => { setLoading(true); try { - const [s, m, f] = await Promise.all([ + const [s, m, f, g] = await Promise.all([ opsApi.systemStatus() as Promise, opsApi.memberships() as Promise, opsApi.funnel(30), + opsApi.membershipsGrowth(30), ]); setStatus(s); setMemberships((m as MembershipsPayload).memberships ?? []); setFunnel(f); + setGrowth(g?.daily ?? []); } catch { /* */ } setLoading(false); }; @@ -138,6 +142,54 @@ export function OverviewPageClient() {
+ {/* Membership Growth Trend */} + {growth.length > 0 && ( + + + 会员增长趋势 — 近 30 天 + + +
+ {/* Cumulative area chart */} +
+

累计会员

+ + + + + + + + + + + + + + + +
+ + {/* Daily stack chart */} +
+

每日新增

+ + + + + + + + + + + +
+
+
+
+ )} + {/* Main grid: 2 columns */}
diff --git a/frontend/components/ops/payments/PaymentsPageClient.tsx b/frontend/components/ops/payments/PaymentsPageClient.tsx index 8bc5297a..0e654dc1 100644 --- a/frontend/components/ops/payments/PaymentsPageClient.tsx +++ b/frontend/components/ops/payments/PaymentsPageClient.tsx @@ -6,6 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { opsApi } from "@/lib/ops-api"; import type { PaymentRuntimePayload, PaymentIncident } from "@/types/ops"; +import { + PieChart, Pie, Cell, ResponsiveContainer, Tooltip, +} from "recharts"; export function PaymentsPageClient() { const [loading, setLoading] = useState(true); @@ -43,6 +46,19 @@ export function PaymentsPageClient() { if (loading) return
加载中...
; + const reasonCounts: Record = {}; + incidents.forEach((inc) => { + const r = inc.reason || "unknown"; + reasonCounts[r] = (reasonCounts[r] || 0) + 1; + }); + + const incidentPieData = Object.entries(reasonCounts).map(([name, value]) => ({ + name, + value, + })); + + const COLORS = ["#ef4444", "#f59e0b", "#3b82f6", "#10b981", "#a855f7", "#6366f1", "#ec4899"]; + return (
@@ -52,29 +68,72 @@ export function PaymentsPageClient() {
- - 支付运行时 - -
- {runtime ? Object.entries({ - chain_id: runtime.chain_id, - last_scanned_block: runtime.last_scanned_block, - audit_events_count: runtime.audit_events_count, - }).map(([k, v]) => ( -
-
{k}
-
{String(v ?? "—")}
+
+ + 支付运行时 + +
+ {runtime ? Object.entries({ + chain_id: runtime.chain_id, + last_scanned_block: runtime.last_scanned_block, + audit_events_count: runtime.audit_events_count, + }).map(([k, v]) => ( +
+
{k}
+
{String(v ?? "—")}
+
+ )) : 无数据} +
+ {runtime?.receiver_contract ? ( +
+
receiver_contract
+ {runtime.receiver_contract}
- )) : 无数据} -
- {runtime?.receiver_contract ? ( -
-
receiver_contract
- {runtime.receiver_contract} -
- ) : null} -
-
+ ) : null} + + + + + 异常原因分布 + + {incidentPieData.length === 0 ? ( + 暂无异常数据 + ) : ( +
+
+ + + + {incidentPieData.map((d, i) => ( + + ))} + + + + +
+
+ {incidentPieData.map((d, i) => ( +
+ + {d.name} + {d.value} +
+ ))} +
+
+ )} +
+
+
支付异常 ({incidents.length}) diff --git a/frontend/lib/ops-api.ts b/frontend/lib/ops-api.ts index 30b58613..853b688d 100644 --- a/frontend/lib/ops-api.ts +++ b/frontend/lib/ops-api.ts @@ -64,6 +64,12 @@ export const opsApi = { memberships() { return opsFetch>("/api/ops/memberships?limit=200"); }, + membershipsGrowth(days = 90) { + return opsFetch<{ + days: number; + daily: { date: string; trial: number; paid: number; total: number; cumulative: number }[]; + }>(`/api/ops/memberships/growth?days=${days}`); + }, incidents(limit = 20, reason?: string) { const params = new URLSearchParams({ limit: String(limit) }); if (reason) params.set("reason", reason);