mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-06 10:06:36 +08:00
Implement per-tunnel traffic quota feature: - Add TunnelQuota model with daily/monthly usage tracking - Integrate quota enforcement into flow accumulation path - Pause forwards and disable tunnel when quota exceeded - Block new forward creation/resume when tunnel quota disabled - Auto-reset daily/monthly windows at 00:05 via maintenance job - Add manual reset API endpoint for admins - Include quota config in tunnel backup/restore - Add frontend UI for quota settings and usage display Entire-Checkpoint: e629b27ca437
This commit is contained in:
@@ -18,6 +18,7 @@ import type {
|
||||
UserMutationPayload,
|
||||
NodeMutationPayload,
|
||||
TunnelMutationPayload,
|
||||
TunnelQuotaResetPayload,
|
||||
UserTunnelAssignPayload,
|
||||
UserTunnelListQuery,
|
||||
UserTunnelRemovePayload,
|
||||
@@ -117,6 +118,8 @@ export const getTunnelById = (id: number) =>
|
||||
Network.post<TunnelApiItem>("/tunnel/get", { id });
|
||||
export const updateTunnel = (data: TunnelMutationPayload) =>
|
||||
Network.post("/tunnel/update", data);
|
||||
export const resetTunnelQuota = (data: TunnelQuotaResetPayload) =>
|
||||
Network.post("/tunnel/quota/reset", data);
|
||||
export const deleteTunnel = (id: number) =>
|
||||
Network.post("/tunnel/delete", { id });
|
||||
export const diagnoseTunnel = (tunnelId: number) =>
|
||||
|
||||
@@ -36,6 +36,19 @@ export interface TunnelApiItem {
|
||||
name: string;
|
||||
type: number;
|
||||
status: number;
|
||||
flow?: number;
|
||||
trafficRatio?: number;
|
||||
inIp?: string;
|
||||
ipPreference?: string;
|
||||
inNodeId?: TunnelChainNodePayload[];
|
||||
outNodeId?: TunnelChainNodePayload[];
|
||||
chainNodes?: TunnelChainNodePayload[][];
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
quotaDisabledAt?: number;
|
||||
entryNodeId: number;
|
||||
exitNodeId: number;
|
||||
inx?: number;
|
||||
@@ -250,6 +263,8 @@ export interface TunnelMutationPayload {
|
||||
status?: number;
|
||||
flow?: number;
|
||||
trafficRatio?: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
inIp?: string;
|
||||
ipPreference?: string;
|
||||
inNodeId?: TunnelChainNodePayload[];
|
||||
@@ -257,6 +272,11 @@ export interface TunnelMutationPayload {
|
||||
chainNodes?: TunnelChainNodePayload[][];
|
||||
}
|
||||
|
||||
export interface TunnelQuotaResetPayload {
|
||||
tunnelId: number;
|
||||
scope?: "daily" | "monthly" | "all";
|
||||
}
|
||||
|
||||
export interface UserTunnelAssignPayload {
|
||||
userId?: number;
|
||||
id?: number;
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
createTunnel,
|
||||
getTunnelList,
|
||||
updateTunnel,
|
||||
resetTunnelQuota,
|
||||
deleteTunnel,
|
||||
getNodeList,
|
||||
diagnoseTunnel,
|
||||
@@ -87,6 +88,12 @@ interface Tunnel {
|
||||
protocol?: string;
|
||||
flow: number; // 1: 单向, 2: 双向
|
||||
trafficRatio: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
quotaDisabledAt?: number;
|
||||
ipPreference?: string;
|
||||
status: number;
|
||||
createdTime: string;
|
||||
@@ -111,6 +118,8 @@ interface TunnelForm {
|
||||
chainNodes?: ChainTunnel[][]; // 转发链节点列表,二维数组,外层是跳数,内层是该跳的节点
|
||||
flow: number;
|
||||
trafficRatio: number;
|
||||
dailyQuotaGB: number;
|
||||
monthlyQuotaGB: number;
|
||||
inIp: string; // 入口IP
|
||||
ipPreference: string;
|
||||
status: number;
|
||||
@@ -124,6 +133,32 @@ interface BatchProgressState {
|
||||
|
||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||
|
||||
const formatBytes = (bytes?: number) => {
|
||||
const value = Number(bytes ?? 0);
|
||||
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return "0 B";
|
||||
}
|
||||
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
const index = Math.min(
|
||||
Math.floor(Math.log(value) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
|
||||
return `${(value / 1024 ** index).toFixed(index === 0 ? 0 : 2)} ${units[index]}`;
|
||||
};
|
||||
|
||||
const formatQuotaLimit = (value?: number) => {
|
||||
const limit = Number(value ?? 0);
|
||||
|
||||
if (!Number.isFinite(limit) || limit <= 0) {
|
||||
return "不限";
|
||||
}
|
||||
|
||||
return `${limit} GB`;
|
||||
};
|
||||
|
||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
return (items || []).map((tunnel) => ({
|
||||
...tunnel,
|
||||
@@ -134,6 +169,12 @@ const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||
inIp: tunnel.inIp || "",
|
||||
flow: tunnel.flow ?? 1,
|
||||
trafficRatio: tunnel.trafficRatio ?? 1,
|
||||
dailyQuotaGB: tunnel.dailyQuotaGB ?? 0,
|
||||
monthlyQuotaGB: tunnel.monthlyQuotaGB ?? 0,
|
||||
dailyUsedBytes: tunnel.dailyUsedBytes ?? 0,
|
||||
monthlyUsedBytes: tunnel.monthlyUsedBytes ?? 0,
|
||||
disabledByQuota: tunnel.disabledByQuota ?? 0,
|
||||
quotaDisabledAt: tunnel.quotaDisabledAt ?? 0,
|
||||
status: typeof tunnel.status === "number" ? tunnel.status : 0,
|
||||
createdTime: tunnel.createdTime || "",
|
||||
}));
|
||||
@@ -156,6 +197,7 @@ export default function TunnelPage() {
|
||||
const [diagnosisModalOpen, setDiagnosisModalOpen] = useState(false);
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [submitLoading, setSubmitLoading] = useState(false);
|
||||
const [quotaResetLoading, setQuotaResetLoading] = useState(false);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [diagnosisLoading, setDiagnosisLoading] = useState(false);
|
||||
const [tunnelToDelete, setTunnelToDelete] = useState<Tunnel | null>(null);
|
||||
@@ -325,6 +367,11 @@ export default function TunnelPage() {
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const editingTunnel = useMemo(
|
||||
() => tunnels.find((item) => item.id === form.id) || null,
|
||||
[form.id, tunnels],
|
||||
);
|
||||
|
||||
// 新增隧道
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false);
|
||||
@@ -347,6 +394,8 @@ export default function TunnelPage() {
|
||||
chainNodes: tunnel.chainNodes || [],
|
||||
flow: tunnel.flow,
|
||||
trafficRatio: tunnel.trafficRatio,
|
||||
dailyQuotaGB: tunnel.dailyQuotaGB ?? 0,
|
||||
monthlyQuotaGB: tunnel.monthlyQuotaGB ?? 0,
|
||||
inIp: tunnel.inIp
|
||||
? tunnel.inIp
|
||||
.split(",")
|
||||
@@ -404,6 +453,28 @@ export default function TunnelPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
|
||||
if (!form.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
setQuotaResetLoading(true);
|
||||
try {
|
||||
const response = await resetTunnelQuota({ tunnelId: form.id, scope });
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success("隧道配额已重置");
|
||||
await refreshTunnelList(false);
|
||||
} else {
|
||||
toast.error(response.msg || "重置隧道配额失败");
|
||||
}
|
||||
} catch {
|
||||
toast.error("重置隧道配额失败");
|
||||
} finally {
|
||||
setQuotaResetLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 隧道类型改变时的处理
|
||||
const handleTypeChange = (type: number) => {
|
||||
setForm((prev) => ({
|
||||
@@ -1357,6 +1428,29 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 mt-2">
|
||||
<div className="rounded bg-default-50 dark:bg-default-100/30 p-2">
|
||||
<div className="text-xs text-default-500">
|
||||
每日配额
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-foreground">
|
||||
{formatBytes(tunnel.dailyUsedBytes)} / {formatQuotaLimit(tunnel.dailyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded bg-default-50 dark:bg-default-100/30 p-2">
|
||||
<div className="text-xs text-default-500">
|
||||
每月配额
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-foreground">
|
||||
{formatBytes(tunnel.monthlyUsedBytes)} / {formatQuotaLimit(tunnel.monthlyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tunnel.disabledByQuota ? (
|
||||
<Alert color="danger" title="已因流量配额超额自动禁用并暂停相关转发" />
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mt-3">
|
||||
@@ -1554,6 +1648,111 @@ export default function TunnelPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Input
|
||||
errorMessage={errors.dailyQuotaGB}
|
||||
isInvalid={!!errors.dailyQuotaGB}
|
||||
label="每日配额 (GB)"
|
||||
min={0}
|
||||
placeholder="0 表示不限"
|
||||
type="number"
|
||||
value={String(form.dailyQuotaGB ?? 0)}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
dailyQuotaGB: Math.max(0, Number(e.target.value) || 0),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
|
||||
<Input
|
||||
errorMessage={errors.monthlyQuotaGB}
|
||||
isInvalid={!!errors.monthlyQuotaGB}
|
||||
label="每月配额 (GB)"
|
||||
min={0}
|
||||
placeholder="0 表示不限"
|
||||
type="number"
|
||||
value={String(form.monthlyQuotaGB ?? 0)}
|
||||
variant="bordered"
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
monthlyQuotaGB: Math.max(0, Number(e.target.value) || 0),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isEdit && editingTunnel && (
|
||||
<div className="space-y-3 rounded-xl border border-default-200 bg-default-50/60 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-foreground">
|
||||
当前配额状态
|
||||
</h3>
|
||||
<p className="text-xs text-default-500">
|
||||
按现有计费口径统计,重置后会自动恢复该次配额暂停的转发
|
||||
</p>
|
||||
</div>
|
||||
{editingTunnel.disabledByQuota ? (
|
||||
<Chip color="danger" size="sm" variant="flat">
|
||||
配额已触发禁用
|
||||
</Chip>
|
||||
) : (
|
||||
<Chip color="success" size="sm" variant="flat">
|
||||
配额正常
|
||||
</Chip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-default-500">每日用量</div>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||
{formatBytes(editingTunnel.dailyUsedBytes)} / {formatQuotaLimit(editingTunnel.dailyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-background p-3">
|
||||
<div className="text-xs text-default-500">每月用量</div>
|
||||
<div className="mt-1 text-sm font-semibold text-foreground">
|
||||
{formatBytes(editingTunnel.monthlyUsedBytes)} / {formatQuotaLimit(editingTunnel.monthlyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("daily")}
|
||||
>
|
||||
重置每日配额
|
||||
</Button>
|
||||
<Button
|
||||
color="warning"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("monthly")}
|
||||
>
|
||||
重置每月配额
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={quotaResetLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleQuotaReset("all")}
|
||||
>
|
||||
全部重置并恢复
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Textarea
|
||||
description="入口IP由系统自动从入口节点采集,无需手动填写。支持多个IP,每行一个地址,留空则使用入口节点IP"
|
||||
errorMessage={errors.inIp}
|
||||
|
||||
@@ -8,6 +8,8 @@ interface TunnelFormInput {
|
||||
inNodeId: TunnelChainNode[];
|
||||
outNodeId?: TunnelChainNode[];
|
||||
trafficRatio: number;
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
}
|
||||
|
||||
interface TunnelNodeInput {
|
||||
@@ -24,6 +26,8 @@ export const createTunnelFormDefaults = () => {
|
||||
chainNodes: [],
|
||||
flow: 1,
|
||||
trafficRatio: 1.0,
|
||||
dailyQuotaGB: 0,
|
||||
monthlyQuotaGB: 0,
|
||||
inIp: "",
|
||||
ipPreference: "",
|
||||
status: 1,
|
||||
@@ -60,6 +64,14 @@ export const validateTunnelForm = (
|
||||
errors.trafficRatio = "流量倍率须大于0,支持小数(如 0.5)";
|
||||
}
|
||||
|
||||
if ((form.dailyQuotaGB ?? 0) < 0) {
|
||||
errors.dailyQuotaGB = "每日配额不能小于 0";
|
||||
}
|
||||
|
||||
if ((form.monthlyQuotaGB ?? 0) < 0) {
|
||||
errors.monthlyQuotaGB = "每月配额不能小于 0";
|
||||
}
|
||||
|
||||
if (form.type === 2) {
|
||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||
errors.outNodeId = "请至少选择一个出口节点";
|
||||
|
||||
@@ -83,6 +83,11 @@ export interface Tunnel {
|
||||
exitNodeName?: string;
|
||||
status?: number;
|
||||
flow?: number; // 流量计算类型
|
||||
dailyQuotaGB?: number;
|
||||
monthlyQuotaGB?: number;
|
||||
dailyUsedBytes?: number;
|
||||
monthlyUsedBytes?: number;
|
||||
disabledByQuota?: number;
|
||||
}
|
||||
|
||||
export interface SpeedLimit {
|
||||
|
||||
Reference in New Issue
Block a user