mirror of
https://github.com/JetSprow/J-Board-Lite.git
synced 2026-05-01 01:14:10 +05:30
feat: make network insights optional
This commit is contained in:
@@ -786,6 +786,7 @@ model AppConfig {
|
||||
reminderDispatchIntervalMinutes Int @default(60)
|
||||
trafficSyncEnabled Boolean @default(true)
|
||||
trafficSyncIntervalSeconds Int @default(60)
|
||||
networkInsightsEnabled Boolean @default(false)
|
||||
subscriptionRiskEnabled Boolean @default(true)
|
||||
subscriptionRiskAutoSuspend Boolean @default(true)
|
||||
subscriptionRiskWindowHours Int @default(24)
|
||||
|
||||
@@ -27,6 +27,7 @@ const settingsSchema = z.object({
|
||||
reminderDispatchIntervalMinutes: z.coerce.number().int().positive().optional(),
|
||||
trafficSyncEnabled: z.string().optional(),
|
||||
trafficSyncIntervalSeconds: z.coerce.number().int().min(10).optional(),
|
||||
networkInsightsEnabled: z.string().optional(),
|
||||
subscriptionRiskEnabled: z.string().optional(),
|
||||
subscriptionRiskAutoSuspend: z.string().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),
|
||||
trafficSyncIntervalSeconds:
|
||||
parsed.trafficSyncIntervalSeconds ?? current.trafficSyncIntervalSeconds,
|
||||
networkInsightsEnabled: optionalBoolean(
|
||||
parsed.networkInsightsEnabled,
|
||||
current.networkInsightsEnabled,
|
||||
),
|
||||
subscriptionRiskEnabled: optionalBoolean(
|
||||
parsed.subscriptionRiskEnabled,
|
||||
current.subscriptionRiskEnabled,
|
||||
@@ -223,6 +228,7 @@ function revalidateSettingsViews() {
|
||||
revalidatePath("/login");
|
||||
revalidatePath("/register");
|
||||
revalidatePath("/dashboard");
|
||||
revalidatePath("/store");
|
||||
revalidatePath("/subscriptions");
|
||||
revalidatePath("/admin/nodes");
|
||||
revalidatePath("/account");
|
||||
|
||||
@@ -37,6 +37,7 @@ export default async function AdminSettingsPage() {
|
||||
reminderDispatchIntervalMinutes: config.reminderDispatchIntervalMinutes,
|
||||
trafficSyncEnabled: config.trafficSyncEnabled,
|
||||
trafficSyncIntervalSeconds: config.trafficSyncIntervalSeconds,
|
||||
networkInsightsEnabled: config.networkInsightsEnabled,
|
||||
subscriptionRiskEnabled: config.subscriptionRiskEnabled,
|
||||
subscriptionRiskAutoSuspend: config.subscriptionRiskAutoSuspend,
|
||||
subscriptionRiskWindowHours: config.subscriptionRiskWindowHours,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
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 { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -26,6 +26,7 @@ interface AppConfig {
|
||||
reminderDispatchIntervalMinutes: number;
|
||||
trafficSyncEnabled: boolean;
|
||||
trafficSyncIntervalSeconds: number;
|
||||
networkInsightsEnabled: boolean;
|
||||
subscriptionRiskEnabled: boolean;
|
||||
subscriptionRiskAutoSuspend: boolean;
|
||||
subscriptionRiskWindowHours: number;
|
||||
@@ -245,6 +246,29 @@ export function SettingsForm({ config, coupons }: { config: AppConfig; coupons:
|
||||
</div>
|
||||
</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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -30,7 +30,7 @@ export const metadata: Metadata = {
|
||||
|
||||
export default async function StorePage() {
|
||||
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 streamingPlans = getStreamingPlans(plans);
|
||||
const proxyCards = sortPlansForDisplay(proxyPlans.map((plan) => toProxyPlanCard(plan, availabilityMap.get(plan.id))));
|
||||
@@ -77,7 +77,7 @@ export default async function StorePage() {
|
||||
|
||||
<PendingOrderBanner order={pendingOrder} />
|
||||
|
||||
{proxyCards.length > 0 && (
|
||||
{networkInsightsEnabled && proxyCards.length > 0 && (
|
||||
<StoreLatencyRecommendations initialItems={latencyRecommendations} />
|
||||
)}
|
||||
|
||||
@@ -89,7 +89,7 @@ export default async function StorePage() {
|
||||
gridClassName="lg:grid-cols-2 xl:grid-cols-3"
|
||||
after={(
|
||||
<>
|
||||
{proxyNodeIds.length > 0 && (
|
||||
{networkInsightsEnabled && proxyNodeIds.length > 0 && (
|
||||
<>
|
||||
<LatencyLoader nodeIds={proxyNodeIds} />
|
||||
<TraceLoader nodeIds={proxyNodeIds} />
|
||||
@@ -99,7 +99,11 @@ export default async function StorePage() {
|
||||
)}
|
||||
>
|
||||
{proxyCards.map((plan) => (
|
||||
<ProxyPlanCard key={plan.id} plan={plan} />
|
||||
<ProxyPlanCard
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
networkInsightsEnabled={networkInsightsEnabled}
|
||||
/>
|
||||
))}
|
||||
</StorePlanSection>
|
||||
)}
|
||||
|
||||
@@ -34,9 +34,10 @@ interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
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 [trafficGb, setTrafficGb] = useState(
|
||||
plan.pricingMode === "FIXED_PACKAGE" ? fixedTrafficGb : plan.minTrafficGb,
|
||||
@@ -121,7 +122,7 @@ export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) {
|
||||
</span>
|
||||
</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 */}
|
||||
<div className="space-y-3">
|
||||
<ProxyInboundSelect
|
||||
@@ -188,32 +189,37 @@ export function ProxyDetailDialog({ open, onOpenChange, plan }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: signal data — supplementary, scrolls independently on desktop */}
|
||||
<div className="min-w-0 lg:max-h-[60vh] lg:overflow-y-auto lg:-mr-3 lg:pr-3">
|
||||
<ProxySignalPanel
|
||||
latencyItems={latencyItems}
|
||||
traceItems={traceItems}
|
||||
onTraceSelect={setSelectedTrace}
|
||||
onLatencyClick={() => setLatencyDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
{networkInsightsEnabled && (
|
||||
<div className="min-w-0 lg:max-h-[60vh] lg:overflow-y-auto lg:-mr-3 lg:pr-3">
|
||||
<ProxySignalPanel
|
||||
latencyItems={latencyItems}
|
||||
traceItems={traceItems}
|
||||
onTraceSelect={setSelectedTrace}
|
||||
onLatencyClick={() => setLatencyDialogOpen(true)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ProxyTraceDetailDialog
|
||||
trace={selectedTrace}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedTrace(null);
|
||||
}}
|
||||
/>
|
||||
{networkInsightsEnabled && (
|
||||
<ProxyTraceDetailDialog
|
||||
trace={selectedTrace}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setSelectedTrace(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<LatencyDetailDialog
|
||||
nodeId={plan.nodeId}
|
||||
open={latencyDialogOpen}
|
||||
onOpenChange={setLatencyDialogOpen}
|
||||
/>
|
||||
{networkInsightsEnabled && (
|
||||
<LatencyDetailDialog
|
||||
nodeId={plan.nodeId}
|
||||
open={latencyDialogOpen}
|
||||
onOpenChange={setLatencyDialogOpen}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,9 +10,10 @@ import type { ProxyPlan } from "./proxy-plan-types";
|
||||
|
||||
interface Props {
|
||||
plan: ProxyPlan;
|
||||
networkInsightsEnabled: boolean;
|
||||
}
|
||||
|
||||
export function ProxyPlanCard({ plan }: Props) {
|
||||
export function ProxyPlanCard({ plan, networkInsightsEnabled }: Props) {
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const hasInboundOptions = plan.inboundOptions.length > 0;
|
||||
const isFixedPackage = plan.pricingMode === "FIXED_PACKAGE";
|
||||
@@ -80,7 +81,12 @@ export function ProxyPlanCard({ plan }: Props) {
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<ProxyDetailDialog open={dialogOpen} onOpenChange={setDialogOpen} plan={plan} />
|
||||
<ProxyDetailDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
plan={plan}
|
||||
networkInsightsEnabled={networkInsightsEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { prisma } from "@/lib/prisma";
|
||||
import { normalizeTraceText } from "@/lib/trace-normalize";
|
||||
import { getPlanAvailability, type PlanAvailability } from "@/services/plan-availability";
|
||||
import { getLatencyRecommendations } from "@/services/latency-recommendations";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
export async function getStorePageData(userId?: string) {
|
||||
const [plans, pendingOrder, latencyRecommendations] = await Promise.all([
|
||||
const [config, plans, pendingOrder] = await Promise.all([
|
||||
getAppConfig(),
|
||||
prisma.subscriptionPlan.findMany({
|
||||
where: { isActive: true },
|
||||
include: {
|
||||
@@ -25,8 +27,10 @@ export async function getStorePageData(userId?: string) {
|
||||
orderBy: { createdAt: "desc" },
|
||||
})
|
||||
: null,
|
||||
getLatencyRecommendations(),
|
||||
]);
|
||||
const latencyRecommendations = config.networkInsightsEnabled
|
||||
? await getLatencyRecommendations()
|
||||
: [];
|
||||
|
||||
const availabilityMap = new Map<string, PlanAvailability>();
|
||||
await Promise.all(
|
||||
@@ -39,6 +43,7 @@ export async function getStorePageData(userId?: string) {
|
||||
return {
|
||||
plans,
|
||||
availabilityMap,
|
||||
networkInsightsEnabled: config.networkInsightsEnabled,
|
||||
latencyRecommendations,
|
||||
pendingOrder: pendingOrder
|
||||
? {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
/**
|
||||
* POST /api/agent/latency
|
||||
@@ -11,6 +12,11 @@ export async function POST(req: Request) {
|
||||
const auth = await authenticateAgent(req);
|
||||
if (isAuthError(auth)) return auth;
|
||||
const { nodeId } = auth;
|
||||
const config = await getAppConfig();
|
||||
|
||||
if (!config.networkInsightsEnabled) {
|
||||
return NextResponse.json({ ok: true, skipped: true });
|
||||
}
|
||||
|
||||
let body: {
|
||||
latencies: Array<{ carrier: string; latencyMs: number }>;
|
||||
|
||||
@@ -3,11 +3,17 @@ import { prisma } from "@/lib/prisma";
|
||||
import { authenticateAgent, isAuthError } from "@/lib/agent-auth";
|
||||
import { normalizeTraceHops, normalizeTraceText } from "@/lib/trace-normalize";
|
||||
import { classifyTraceRoute } from "@/lib/route-classify";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const auth = await authenticateAgent(req);
|
||||
if (isAuthError(auth)) return auth;
|
||||
const { nodeId } = auth;
|
||||
const config = await getAppConfig();
|
||||
|
||||
if (!config.networkInsightsEnabled) {
|
||||
return NextResponse.json({ ok: true, skipped: true });
|
||||
}
|
||||
|
||||
let body: {
|
||||
traces: Array<{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
const RANGES: Record<string, { ms: number; bucketMs: number }> = {
|
||||
"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) {
|
||||
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 nodeId = searchParams.get("nodeId");
|
||||
const range = searchParams.get("range") ?? "1d";
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getLatencyRecommendations } from "@/services/latency-recommendations";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
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();
|
||||
return NextResponse.json({
|
||||
items,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
const MAX_NODE_IDS = 100;
|
||||
|
||||
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 nodeIds = [...new Set(searchParams.get("nodeIds")?.split(",").filter(Boolean) ?? [])]
|
||||
.slice(0, MAX_NODE_IDS);
|
||||
|
||||
@@ -2,10 +2,16 @@ import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { classifyTraceRoute } from "@/lib/route-classify";
|
||||
import { normalizeTraceHops } from "@/lib/trace-normalize";
|
||||
import { getAppConfig } from "@/services/app-config";
|
||||
|
||||
const MAX_NODE_IDS = 100;
|
||||
|
||||
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 nodeIds = [...new Set(searchParams.get("nodeIds")?.split(",").filter(Boolean) ?? [])]
|
||||
.slice(0, MAX_NODE_IDS);
|
||||
|
||||
Reference in New Issue
Block a user