feat: make network insights optional

This commit is contained in:
JetSprow
2026-04-30 15:45:25 +10:00
parent 0289246db7
commit d2ff80abc3
14 changed files with 131 additions and 31 deletions

View File

@@ -786,6 +786,7 @@ model AppConfig {
reminderDispatchIntervalMinutes Int @default(60) reminderDispatchIntervalMinutes Int @default(60)
trafficSyncEnabled Boolean @default(true) trafficSyncEnabled Boolean @default(true)
trafficSyncIntervalSeconds Int @default(60) trafficSyncIntervalSeconds Int @default(60)
networkInsightsEnabled Boolean @default(false)
subscriptionRiskEnabled Boolean @default(true) subscriptionRiskEnabled Boolean @default(true)
subscriptionRiskAutoSuspend Boolean @default(true) subscriptionRiskAutoSuspend Boolean @default(true)
subscriptionRiskWindowHours Int @default(24) subscriptionRiskWindowHours Int @default(24)

View File

@@ -27,6 +27,7 @@ const settingsSchema = z.object({
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(), reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
trafficSyncEnabled: z.string().optional(), trafficSyncEnabled: z.string().optional(),
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(), trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
networkInsightsEnabled: z.string().optional(),
subscriptionRiskEnabled: z.string().optional(), subscriptionRiskEnabled: z.string().optional(),
subscriptionRiskAutoSuspend: z.string().optional(), subscriptionRiskAutoSuspend: z.string().optional(),
subscriptionRiskWindowHours: z.coerce.number().int().min(1).max(168).optional(), subscriptionRiskWindowHours: z.coerce.number().int().min(1).max(168).optional(),
@@ -137,6 +138,10 @@ function buildSettingsUpdate(parsed: z.infer<typeof settingsSchema>, current: Aw
trafficSyncEnabled: optionalBoolean(parsed.trafficSyncEnabled, current.trafficSyncEnabled), trafficSyncEnabled: optionalBoolean(parsed.trafficSyncEnabled, current.trafficSyncEnabled),
trafficSyncIntervalSeconds: trafficSyncIntervalSeconds:
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds, parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
networkInsightsEnabled: optionalBoolean(
parsed.networkInsightsEnabled,
current.networkInsightsEnabled,
),
subscriptionRiskEnabled: optionalBoolean( subscriptionRiskEnabled: optionalBoolean(
parsed.subscriptionRiskEnabled, parsed.subscriptionRiskEnabled,
current.subscriptionRiskEnabled, current.subscriptionRiskEnabled,
@@ -223,6 +228,7 @@ function revalidateSettingsViews() {
revalidatePath("/login"); revalidatePath("/login");
revalidatePath("/register"); revalidatePath("/register");
revalidatePath("/dashboard"); revalidatePath("/dashboard");
revalidatePath("/store");
revalidatePath("/subscriptions"); revalidatePath("/subscriptions");
revalidatePath("/admin/nodes"); revalidatePath("/admin/nodes");
revalidatePath("/account"); revalidatePath("/account");

View File

@@ -37,6 +37,7 @@ export default async function AdminSettingsPage() {
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes, reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
trafficSyncEnabled: config.trafficSyncEnabled, trafficSyncEnabled: config.trafficSyncEnabled,
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds, trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
networkInsightsEnabled: config.networkInsightsEnabled,
subscriptionRiskEnabled: config.subscriptionRiskEnabled, subscriptionRiskEnabled: config.subscriptionRiskEnabled,
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend, subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
subscriptionRiskWindowHours: config.subscriptionRiskWindowHours, subscriptionRiskWindowHours: config.subscriptionRiskWindowHours,

View File

@@ -2,7 +2,7 @@
import { useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Bell, ChevronDown, Clock3, Gift, LifeBuoy, Mail, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react"; import { Bell, ChevronDown, Clock3, Gift, LifeBuoy, Mail, RadioTower, Send, Settings2, ShieldAlert, ShieldCheck } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button, buttonVariants } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -26,6 +26,7 @@ interface AppConfig {
reminderDispatchIntervalMinutes: number; reminderDispatchIntervalMinutes: number;
trafficSyncEnabled: boolean; trafficSyncEnabled: boolean;
trafficSyncIntervalSeconds: number; trafficSyncIntervalSeconds: number;
networkInsightsEnabled: boolean;
subscriptionRiskEnabled: boolean; subscriptionRiskEnabled: boolean;
subscriptionRiskAutoSuspend: boolean; subscriptionRiskAutoSuspend: boolean;
subscriptionRiskWindowHours: number; subscriptionRiskWindowHours: number;
@@ -245,6 +246,29 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
</div> </div>
</section> </section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<div className="flex items-center gap-2 text-sm font-semibold">
<RadioTower className="size-4 text-primary" /> 线
</div>
<div className="grid gap-5 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="networkInsightsEnabled">线</Label>
<select
id="networkInsightsEnabled"
name="networkInsightsEnabled"
defaultValue={String(config.networkInsightsEnabled)}
className={selectClassName}
>
<option value="false"></option>
<option value="true"></option>
</select>
<p className="text-xs leading-5 text-muted-foreground">
访线
</p>
</div>
</div>
</section>
<section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3"> <section className="space-y-4 rounded-lg border border-border bg-muted/25 p-3">
<button <button
type="button" type="button"

View File

@@ -30,7 +30,7 @@ export const metadata: Metadata = {
export default async function StorePage() { export default async function StorePage() {
const session = await getActiveSession(); const session = await getActiveSession();
const { plans, availabilityMap, pendingOrder, latencyRecommendations } = await getStorePageData(session?.user.id); const { plans, availabilityMap, pendingOrder, networkInsightsEnabled, latencyRecommendations } = await getStorePageData(session?.user.id);
const proxyPlans = getProxyPlans(plans); const proxyPlans = getProxyPlans(plans);
const streamingPlans = getStreamingPlans(plans); const streamingPlans = getStreamingPlans(plans);
const proxyCards = sortPlansForDisplay(proxyPlans.map((plan) => toProxyPlanCard(plan, availabilityMap.get(plan.id)))); const proxyCards = sortPlansForDisplay(proxyPlans.map((plan) => toProxyPlanCard(plan, availabilityMap.get(plan.id))));
@@ -77,7 +77,7 @@ export default async function StorePage() {
<PendingOrderBanner order={pendingOrder} /> <PendingOrderBanner order={pendingOrder} />
{proxyCards.length > 0 && ( {networkInsightsEnabled && proxyCards.length > 0 && (
<StoreLatencyRecommendations initialItems={latencyRecommendations} /> <StoreLatencyRecommendations initialItems={latencyRecommendations} />
)} )}
@@ -89,7 +89,7 @@ export default async function StorePage() {
gridClassName="lg:grid-cols-2 xl:grid-cols-3" gridClassName="lg:grid-cols-2 xl:grid-cols-3"
after={( after={(
<> <>
{proxyNodeIds.length > 0 && ( {networkInsightsEnabled && proxyNodeIds.length > 0 && (
<> <>
<LatencyLoader nodeIds={proxyNodeIds} /> <LatencyLoader nodeIds={proxyNodeIds} />
<TraceLoader nodeIds={proxyNodeIds} /> <TraceLoader nodeIds={proxyNodeIds} />
@@ -99,7 +99,11 @@ export default async function StorePage() {
)} )}
> >
{proxyCards.map((plan) => ( {proxyCards.map((plan) => (
<ProxyPlanCard key={plan.id} plan={plan} /> <ProxyPlanCard
key={plan.id}
plan={plan}
networkInsightsEnabled={networkInsightsEnabled}
/>
))} ))}
</StorePlanSection> </StorePlanSection>
)} )}

View File

@@ -34,9 +34,10 @@ interface Props {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
plan: ProxyPlan; plan: ProxyPlan;
networkInsightsEnabled: boolean;
} }
export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) { export function ProxyDetailDialog({ open, onOpenChange, plan, networkInsightsEnabled }: Props) {
const fixedTrafficGb = plan.fixedTrafficGb ?? plan.minTrafficGb; const fixedTrafficGb = plan.fixedTrafficGb ?? plan.minTrafficGb;
const [trafficGb, setTrafficGb] = useState( const [trafficGb, setTrafficGb] = useState(
plan.pricingMode === "FIXED_PACKAGE" ? fixedTrafficGb : plan.minTrafficGb, plan.pricingMode === "FIXED_PACKAGE" ? fixedTrafficGb : plan.minTrafficGb,
@@ -121,7 +122,7 @@ export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) {
</span> </span>
</div> </div>
<div className="grid items-start gap-6 lg:grid-cols-[1fr_20rem]"> <div className={`grid items-start gap-6 ${networkInsightsEnabled ? "lg:grid-cols-[1fr_20rem]" : ""}`}>
{/* Left: purchase config — always visible without scrolling */} {/* Left: purchase config — always visible without scrolling */}
<div className="space-y-3"> <div className="space-y-3">
<ProxyInboundSelect <ProxyInboundSelect
@@ -188,7 +189,7 @@ export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) {
)} )}
</div> </div>
{/* Right: signal data — supplementary, scrolls independently on desktop */} {networkInsightsEnabled && (
<div className="min-w-0 lg:max-h-[60vh] lg:overflow-y-auto lg:-mr-3 lg:pr-3"> <div className="min-w-0 lg:max-h-[60vh] lg:overflow-y-auto lg:-mr-3 lg:pr-3">
<ProxySignalPanel <ProxySignalPanel
latencyItems={latencyItems} latencyItems={latencyItems}
@@ -197,23 +198,28 @@ export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) {
onLatencyClick={() => setLatencyDialogOpen(true)} onLatencyClick={() => setLatencyDialogOpen(true)}
/> />
</div> </div>
)}
</div> </div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{networkInsightsEnabled && (
<ProxyTraceDetailDialog <ProxyTraceDetailDialog
trace={selectedTrace} trace={selectedTrace}
onOpenChange={(open) => { onOpenChange={(open) => {
if (!open) setSelectedTrace(null); if (!open) setSelectedTrace(null);
}} }}
/> />
)}
{networkInsightsEnabled && (
<LatencyDetailDialog <LatencyDetailDialog
nodeId={plan.nodeId} nodeId={plan.nodeId}
open={latencyDialogOpen} open={latencyDialogOpen}
onOpenChange={setLatencyDialogOpen} onOpenChange={setLatencyDialogOpen}
/> />
)}
</> </>
); );
} }

View File

@@ -10,9 +10,10 @@ import type { ProxyPlan } from "./proxy-plan-types";
interface Props { interface Props {
plan: ProxyPlan; plan: ProxyPlan;
networkInsightsEnabled: boolean;
} }
export function ProxyPlanCard({ plan }: Props) { export function ProxyPlanCard({ plan, networkInsightsEnabled }: Props) {
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const hasInboundOptions = plan.inboundOptions.length > 0; const hasInboundOptions = plan.inboundOptions.length > 0;
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE"; const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
@@ -80,7 +81,12 @@ export function ProxyPlanCard({ plan }: Props) {
</div> </div>
</article> </article>
<ProxyDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} plan={plan} /> <ProxyDetailDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
plan={plan}
networkInsightsEnabled={networkInsightsEnabled}
/>
</> </>
); );
} }

View File

@@ -2,9 +2,11 @@ import { prisma } from "@/lib/prisma";
import { normalizeTraceText } from "@/lib/trace-normalize"; import { normalizeTraceText } from "@/lib/trace-normalize";
import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability"; import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability";
import { getLatencyRecommendations } from "@/services/latency-recommendations"; import { getLatencyRecommendations } from "@/services/latency-recommendations";
import { getAppConfig } from "@/services/app-config";
export async function getStorePageData(userId?: string) { export async function getStorePageData(userId?: string) {
const [plans, pendingOrder, latencyRecommendations] = await Promise.all([ const [config, plans, pendingOrder] = await Promise.all([
getAppConfig(),
prisma.subscriptionPlan.findMany({ prisma.subscriptionPlan.findMany({
where: { isActive: true }, where: { isActive: true },
include: { include: {
@@ -25,8 +27,10 @@ export async function getStorePageData(userId?: string) {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}) })
: null, : null,
getLatencyRecommendations(),
]); ]);
const latencyRecommendations = config.networkInsightsEnabled
? await getLatencyRecommendations()
: [];
const availabilityMap = new Map<string, PlanAvailability>(); const availabilityMap = new Map<string, PlanAvailability>();
await Promise.all( await Promise.all(
@@ -39,6 +43,7 @@ export async function getStorePageData(userId?: string) {
return { return {
plans, plans,
availabilityMap, availabilityMap,
networkInsightsEnabled: config.networkInsightsEnabled,
latencyRecommendations, latencyRecommendations,
pendingOrder: pendingOrder pendingOrder: pendingOrder
? { ? {

View File

@@ -1,6 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { authenticateAgent, isAuthError } from "@/lib/agent-auth"; import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
import { getAppConfig } from "@/services/app-config";
/** /**
* POST /api/agent/latency * POST /api/agent/latency
@@ -11,6 +12,11 @@ export async function POST(req: Request) {
const auth = await authenticateAgent(req); const auth = await authenticateAgent(req);
if (isAuthError(auth)) return auth; if (isAuthError(auth)) return auth;
const { nodeId } = auth; const { nodeId } = auth;
const config = await getAppConfig();
if (!config.networkInsightsEnabled) {
return NextResponse.json({ ok: true, skipped: true });
}
let body: { let body: {
latencies: Array<{ carrier: string; latencyMs: number }>; latencies: Array<{ carrier: string; latencyMs: number }>;

View File

@@ -3,11 +3,17 @@ import { prisma } from "@/lib/prisma";
import { authenticateAgent, isAuthError } from "@/lib/agent-auth"; import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
import { normalizeTraceHops, normalizeTraceText } from "@/lib/trace-normalize"; import { normalizeTraceHops, normalizeTraceText } from "@/lib/trace-normalize";
import { classifyTraceRoute } from "@/lib/route-classify"; import { classifyTraceRoute } from "@/lib/route-classify";
import { getAppConfig } from "@/services/app-config";
export async function POST(req: Request) { export async function POST(req: Request) {
const auth = await authenticateAgent(req); const auth = await authenticateAgent(req);
if (isAuthError(auth)) return auth; if (isAuthError(auth)) return auth;
const { nodeId } = auth; const { nodeId } = auth;
const config = await getAppConfig();
if (!config.networkInsightsEnabled) {
return NextResponse.json({ ok: true, skipped: true });
}
let body: { let body: {
traces: Array<{ traces: Array<{

View File

@@ -1,5 +1,6 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getAppConfig } from "@/services/app-config";
const RANGES: Record<string, { ms: number; bucketMs: number }> = { const RANGES: Record<string, { ms: number; bucketMs: number }> = {
"1d": { ms: 24 * 60 * 60 * 1000, bucketMs: 5 * 60 * 1000 }, "1d": { ms: 24 * 60 * 60 * 1000, bucketMs: 5 * 60 * 1000 },
@@ -8,6 +9,14 @@ const RANGES: Record<string, { ms: number; bucketMs: number }> = {
}; };
export async function GET(req: Request) { export async function GET(req: Request) {
const config = await getAppConfig();
if (!config.networkInsightsEnabled) {
return NextResponse.json(
{ carriers: [], points: [], sufficient: false },
{ headers: { "Cache-Control": "no-store" } },
);
}
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const nodeId = searchParams.get("nodeId"); const nodeId = searchParams.get("nodeId");
const range = searchParams.get("range") ?? "1d"; const range = searchParams.get("range") ?? "1d";

View File

@@ -1,7 +1,21 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { getLatencyRecommendations } from "@/services/latency-recommendations"; import { getLatencyRecommendations } from "@/services/latency-recommendations";
import { getAppConfig } from "@/services/app-config";
export async function GET() { export async function GET() {
const config = await getAppConfig();
if (!config.networkInsightsEnabled) {
return NextResponse.json({
items: [],
updatedAt: new Date().toISOString(),
refreshIntervalMs: 5 * 60 * 1000,
}, {
headers: {
"Cache-Control": "no-store",
},
});
}
const items = await getLatencyRecommendations(); const items = await getLatencyRecommendations();
return NextResponse.json({ return NextResponse.json({
items, items,

View File

@@ -1,9 +1,15 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getAppConfig } from "@/services/app-config";
const MAX_NODE_IDS = 100; const MAX_NODE_IDS = 100;
export async function GET(req: Request) { export async function GET(req: Request) {
const config = await getAppConfig();
if (!config.networkInsightsEnabled) {
return NextResponse.json({}, { headers: { "Cache-Control": "no-store" } });
}
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const nodeIds = [...new Set(searchParams.get("nodeIds")?.split(",").filter(Boolean) ?? [])] const nodeIds = [...new Set(searchParams.get("nodeIds")?.split(",").filter(Boolean) ?? [])]
.slice(0, MAX_NODE_IDS); .slice(0, MAX_NODE_IDS);

View File

@@ -2,10 +2,16 @@ import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { classifyTraceRoute } from "@/lib/route-classify"; import { classifyTraceRoute } from "@/lib/route-classify";
import { normalizeTraceHops } from "@/lib/trace-normalize"; import { normalizeTraceHops } from "@/lib/trace-normalize";
import { getAppConfig } from "@/services/app-config";
const MAX_NODE_IDS = 100; const MAX_NODE_IDS = 100;
export async function GET(req: Request) { export async function GET(req: Request) {
const config = await getAppConfig();
if (!config.networkInsightsEnabled) {
return NextResponse.json({}, { headers: { "Cache-Control": "no-store" } });
}
const { searchParams } = new URL(req.url); const { searchParams } = new URL(req.url);
const nodeIds = [...new Set(searchParams.get("nodeIds")?.split(",").filter(Boolean) ?? [])] const nodeIds = [...new Set(searchParams.get("nodeIds")?.split(",").filter(Boolean) ?? [])]
.slice(0, MAX_NODE_IDS); .slice(0, MAX_NODE_IDS);