mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-07 10:16:38 +08:00
feat(monitoring): add node/tunnel metrics, service monitors, and health checks
- Add NodeMetric/TunnelMetric/ServiceMonitor models and repository methods - Implement metrics ingestion service with per-minute bucket aggregation - Add health checker for node connectivity monitoring - Wire node metrics from WebSocket SystemInfo messages - Add tunnel metrics ingestion from flow upload endpoint - Create monitoring REST API endpoints for nodes, tunnels, services - Implement service monitor CRUD and execution (TCP/ICMP checks) - Add MonitorPermission for non-admin access control - Create frontend monitor page with node/tunnel/service views - Add tunnel metrics ingestion from agent flow reports - Include schema migration for tunnel_metric unique index - Fix tunnel entry port conflict validation to use transaction Entire-Checkpoint: 030821a7c8e3
This commit is contained in:
@@ -1,7 +1,4 @@
|
||||
import type {
|
||||
BatchOperationFailure,
|
||||
BatchOperationResult,
|
||||
} from "@/api/types";
|
||||
import type { BatchOperationFailure, BatchOperationResult } from "@/api/types";
|
||||
|
||||
import {
|
||||
batchChangeTunnel,
|
||||
@@ -46,7 +43,11 @@ const buildBatchToast = (
|
||||
resultTitle: string,
|
||||
): Pick<
|
||||
ForwardBatchActionOutcome,
|
||||
"toastVariant" | "toastMessage" | "resultTitle" | "resultSummary" | "failureDetails"
|
||||
| "toastVariant"
|
||||
| "toastMessage"
|
||||
| "resultTitle"
|
||||
| "resultSummary"
|
||||
| "failureDetails"
|
||||
> => {
|
||||
if (result.failCount === 0) {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { MonitorNodeApiItem } from "@/api/types";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
|
||||
import { AnimatedPage } from "@/components/animated-page";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { getMonitorNodes } from "@/api";
|
||||
import { MonitorView } from "@/pages/node/monitor-view";
|
||||
|
||||
type MonitorNode = {
|
||||
id: number;
|
||||
name: string;
|
||||
connectionStatus: "online" | "offline";
|
||||
};
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
|
||||
const [nodesLoading, setNodesLoading] = useState(false);
|
||||
const [nodesError, setNodesError] = useState<string | null>(null);
|
||||
|
||||
const loadNodes = useCallback(async () => {
|
||||
setNodesLoading(true);
|
||||
try {
|
||||
const response = await getMonitorNodes();
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
setNodesError(null);
|
||||
setNodes(response.data);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.code === 403) {
|
||||
setNodes([]);
|
||||
setNodesError(response.msg || "暂无监控权限,请联系管理员授权");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(response.msg || "加载节点失败");
|
||||
} catch {
|
||||
toast.error("加载节点失败");
|
||||
} finally {
|
||||
setNodesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadNodes();
|
||||
}, [loadNodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
void loadNodes();
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadNodes]);
|
||||
|
||||
const nodeMap = useMemo(() => {
|
||||
const list: MonitorNode[] = nodes
|
||||
.filter((n) => Number(n.id) > 0)
|
||||
.map((n) => ({
|
||||
id: Number(n.id),
|
||||
name: String(n.name ?? ""),
|
||||
connectionStatus: n.status === 1 ? "online" : "offline",
|
||||
}));
|
||||
|
||||
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
|
||||
}, [nodes]);
|
||||
|
||||
return (
|
||||
<AnimatedPage className="px-3 lg:px-6 py-8">
|
||||
<div className="mb-6 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-xl font-semibold truncate">监控</h2>
|
||||
<div className="text-xs text-default-500 truncate">
|
||||
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
isLoading={nodesLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={loadNodes}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新节点
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{nodesError ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-sm font-semibold">节点列表</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<div className="text-sm text-default-600">{nodesError}</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<MonitorView nodeMap={nodeMap} />
|
||||
</AnimatedPage>
|
||||
);
|
||||
}
|
||||
+305
-121
@@ -1,6 +1,5 @@
|
||||
import { useState, useEffect, useMemo, useCallback } from "react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -67,7 +66,10 @@ import {
|
||||
getNodeRenewalCycleLabel,
|
||||
type NodeRenewalCycle,
|
||||
} from "@/pages/node/renewal";
|
||||
import { buildNodeSystemInfo } from "@/pages/node/system-info";
|
||||
import {
|
||||
buildNodeSystemInfo,
|
||||
type NodeSystemInfo,
|
||||
} from "@/pages/node/system-info";
|
||||
import { useNodeOfflineTimers } from "@/pages/node/use-node-offline-timers";
|
||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||
@@ -99,15 +101,7 @@ interface Node {
|
||||
remoteUrl?: string;
|
||||
syncError?: string;
|
||||
connectionStatus: "online" | "offline";
|
||||
systemInfo?: {
|
||||
cpuUsage: number;
|
||||
memoryUsage: number;
|
||||
uploadTraffic: number;
|
||||
downloadTraffic: number;
|
||||
uploadSpeed: number;
|
||||
downloadSpeed: number;
|
||||
uptime: number;
|
||||
} | null;
|
||||
systemInfo?: NodeSystemInfo | null;
|
||||
copyLoading?: boolean;
|
||||
upgradeLoading?: boolean;
|
||||
rollbackLoading?: boolean;
|
||||
@@ -302,6 +296,13 @@ export default function NodePage() {
|
||||
"node-active-tab",
|
||||
"local",
|
||||
);
|
||||
|
||||
// Backward-compat: older versions stored extra tab values.
|
||||
useEffect(() => {
|
||||
if (activeTab !== "local" && activeTab !== "remote") {
|
||||
setActiveTab("local");
|
||||
}
|
||||
}, [activeTab, setActiveTab]);
|
||||
const [remoteUsageMap, setRemoteUsageMap] = useState<
|
||||
Record<number, RemoteUsageNode>
|
||||
>({});
|
||||
@@ -583,6 +584,43 @@ export default function NodePage() {
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (type === "metric") {
|
||||
clearOfflineTimer(nodeId);
|
||||
setNodeList((prev) =>
|
||||
prev.map((node) => {
|
||||
if (node.id !== nodeId) return node;
|
||||
|
||||
const metric =
|
||||
typeof messageData === "string"
|
||||
? JSON.parse(messageData)
|
||||
: messageData;
|
||||
|
||||
if (!metric || typeof metric !== "object") return node;
|
||||
|
||||
return {
|
||||
...node,
|
||||
connectionStatus: "online",
|
||||
systemInfo: {
|
||||
cpuUsage: metric.cpuUsage ?? metric.cpu_usage ?? 0,
|
||||
memoryUsage: metric.memoryUsage ?? metric.memory_usage ?? 0,
|
||||
uploadTraffic:
|
||||
metric.netOutBytes ?? metric.bytes_transmitted ?? 0,
|
||||
downloadTraffic: metric.netInBytes ?? metric.bytes_received ?? 0,
|
||||
uploadSpeed: metric.netOutSpeed ?? metric.net_out_speed ?? 0,
|
||||
downloadSpeed: metric.netInSpeed ?? metric.net_in_speed ?? 0,
|
||||
uptime: metric.uptime ?? 0,
|
||||
diskUsage: metric.diskUsage ?? metric.disk_usage,
|
||||
load1: metric.load1,
|
||||
load5: metric.load5,
|
||||
load15: metric.load15,
|
||||
tcpConns: metric.tcpConns ?? metric.tcp_conns,
|
||||
udpConns: metric.udpConns ?? metric.udp_conns,
|
||||
netInSpeed: metric.netInSpeed ?? metric.net_in_speed,
|
||||
netOutSpeed: metric.netOutSpeed ?? metric.net_out_speed,
|
||||
},
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1060,7 +1098,9 @@ export default function NodePage() {
|
||||
);
|
||||
|
||||
if (res.code === 0) {
|
||||
toast.success(`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`);
|
||||
toast.success(
|
||||
`批量升级命令已发送到 ${selectedLocalIds.length} 个节点`,
|
||||
);
|
||||
} else {
|
||||
toast.error(res.msg || "批量升级失败");
|
||||
}
|
||||
@@ -1216,6 +1256,7 @@ export default function NodePage() {
|
||||
}
|
||||
|
||||
const nextId = reorderedDisplayIds[reorderedDisplayIndex];
|
||||
|
||||
reorderedDisplayIndex += 1;
|
||||
|
||||
return nextId;
|
||||
@@ -1395,7 +1436,8 @@ export default function NodePage() {
|
||||
return nodes.filter(
|
||||
(node) =>
|
||||
(node.name && node.name.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.remark && node.remark.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.remark &&
|
||||
node.remark.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.serverIp &&
|
||||
node.serverIp.toLowerCase().includes(normalizedKeyword)) ||
|
||||
(node.serverIpV4 &&
|
||||
@@ -1415,34 +1457,31 @@ export default function NodePage() {
|
||||
[sortedNodes],
|
||||
);
|
||||
|
||||
const filteredLocalNodes = useMemo(
|
||||
() => {
|
||||
const keywordFiltered = filterNodesByKeyword(localNodes, localSearchKeyword);
|
||||
const filteredLocalNodes = useMemo(() => {
|
||||
const keywordFiltered = filterNodesByKeyword(
|
||||
localNodes,
|
||||
localSearchKeyword,
|
||||
);
|
||||
|
||||
if (nodeFilterMode === "all") {
|
||||
return keywordFiltered;
|
||||
if (nodeFilterMode === "all") {
|
||||
return keywordFiltered;
|
||||
}
|
||||
|
||||
return keywordFiltered.filter((node) => {
|
||||
const expiryMeta = getNodeExpiryMeta(node.expiryTime, node.renewalCycle);
|
||||
|
||||
switch (nodeFilterMode) {
|
||||
case "expiringSoon":
|
||||
return expiryMeta.state === "expiringSoon";
|
||||
case "expired":
|
||||
return expiryMeta.state === "expired";
|
||||
case "withExpiry":
|
||||
return getNodeReminderEnabled(node);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
|
||||
return keywordFiltered.filter((node) => {
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
);
|
||||
|
||||
switch (nodeFilterMode) {
|
||||
case "expiringSoon":
|
||||
return expiryMeta.state === "expiringSoon";
|
||||
case "expired":
|
||||
return expiryMeta.state === "expired";
|
||||
case "withExpiry":
|
||||
return getNodeReminderEnabled(node);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
});
|
||||
},
|
||||
[filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode],
|
||||
);
|
||||
});
|
||||
}, [filterNodesByKeyword, localNodes, localSearchKeyword, nodeFilterMode]);
|
||||
|
||||
const filteredRemoteNodes = useMemo(
|
||||
() => filterNodesByKeyword(remoteNodes, remoteSearchKeyword),
|
||||
@@ -1464,7 +1503,8 @@ export default function NodePage() {
|
||||
const canUseExpiryFilter = activeTab === "local";
|
||||
const hasKeywordSearch = currentSearchKeyword.trim().length > 0;
|
||||
const hasActiveFilters = nodeFilterMode !== "all";
|
||||
const isDisplayFiltered = hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
|
||||
const isDisplayFiltered =
|
||||
hasKeywordSearch || (canUseExpiryFilter && hasActiveFilters);
|
||||
|
||||
const sortableNodeIds = useMemo(
|
||||
() => displayNodes.map((n) => n.id),
|
||||
@@ -1577,7 +1617,6 @@ export default function NodePage() {
|
||||
<Button
|
||||
isIconOnly
|
||||
aria-label="筛选条件"
|
||||
isDisabled={!canUseExpiryFilter}
|
||||
className={
|
||||
canUseExpiryFilter && nodeFilterMode !== "all"
|
||||
? "bg-primary/20 text-primary relative"
|
||||
@@ -1588,8 +1627,11 @@ export default function NodePage() {
|
||||
? "primary"
|
||||
: "default"
|
||||
}
|
||||
isDisabled={!canUseExpiryFilter}
|
||||
size="sm"
|
||||
title={canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"}
|
||||
title={
|
||||
canUseExpiryFilter ? "筛选条件" : "远程节点不支持到期筛选"
|
||||
}
|
||||
variant="flat"
|
||||
onPress={() => setIsFilterModalOpen(true)}
|
||||
>
|
||||
@@ -1679,7 +1721,9 @@ export default function NodePage() {
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{displayNodes.map((node) => {
|
||||
const isRemoteNode = node.isRemote === 1;
|
||||
const remoteUsage = isRemoteNode ? remoteUsageMap[node.id] : null;
|
||||
const remoteUsage = isRemoteNode
|
||||
? remoteUsageMap[node.id]
|
||||
: null;
|
||||
const expiryMeta = getNodeExpiryMeta(
|
||||
node.expiryTime,
|
||||
node.renewalCycle,
|
||||
@@ -1699,9 +1743,9 @@ export default function NodePage() {
|
||||
<SortableItem key={node.id} id={node.id}>
|
||||
{(listeners) => (
|
||||
<Card
|
||||
data-node-card="true"
|
||||
key={node.id}
|
||||
className={`group relative overflow-visible shadow-sm border border-divider hover:shadow-md transition-shadow duration-200 h-full flex flex-col ${expiryMeta.accentClassName}`}
|
||||
data-node-card="true"
|
||||
>
|
||||
<CardHeader className="pb-3 md:pb-3">
|
||||
<div className="flex justify-between items-start w-full gap-3">
|
||||
@@ -1746,6 +1790,7 @@ export default function NodePage() {
|
||||
<button
|
||||
aria-label={`查看节点信息,共 ${infoCount} 项`}
|
||||
className="relative flex h-7 w-7 items-center justify-center rounded-full border border-divider/80 bg-background/95 text-default-500 shadow-sm transition hover:border-default-300 hover:text-foreground focus-visible:border-default-300 focus-visible:text-foreground focus-visible:outline-none"
|
||||
type="button"
|
||||
onFocus={(event) =>
|
||||
updateInfoPopoverPlacement(
|
||||
node.id,
|
||||
@@ -1758,72 +1803,73 @@ export default function NodePage() {
|
||||
event.currentTarget,
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</svg>
|
||||
{hasRemark && (
|
||||
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5 rounded-full border border-background bg-default-300 shadow-sm dark:bg-default-500" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`pointer-events-none invisible absolute z-[60] w-72 max-w-[min(18rem,calc(100vw-4rem))] rounded-xl border border-divider/80 bg-background/98 p-3 opacity-0 shadow-xl backdrop-blur transition-all duration-150 group-hover/info:visible group-hover/info:pointer-events-auto group-hover/info:opacity-100 group-focus-within/info:visible group-focus-within/info:pointer-events-auto group-focus-within/info:opacity-100 ${
|
||||
infoPlacement === "bottom"
|
||||
? "right-0 top-[calc(100%+0.75rem)] translate-y-1 group-hover/info:translate-y-0 group-focus-within/info:translate-y-0"
|
||||
: "right-[calc(100%+0.75rem)] top-1/2 -translate-y-1/2 translate-x-1 group-hover/info:translate-x-0 group-focus-within/info:translate-x-0"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{hasExpiryInfo && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-medium text-default-500">
|
||||
到期提醒
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Chip
|
||||
className="text-[10px] h-5 px-1 flex-shrink-0"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700">
|
||||
{formatNodeRenewalTime(expiryMeta.nextDueTime)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={1.8}
|
||||
/>
|
||||
</svg>
|
||||
{hasRemark && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-medium text-default-500">
|
||||
备注
|
||||
</div>
|
||||
<div
|
||||
className="max-h-32 overflow-y-auto rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700 break-all [scrollbar-width:thin]"
|
||||
title={node.remark?.trim()}
|
||||
>
|
||||
{node.remark?.trim()}
|
||||
</div>
|
||||
</div>
|
||||
<span className="absolute -right-1 -top-1 flex h-2.5 w-2.5 rounded-full border border-background bg-default-300 shadow-sm dark:bg-default-500" />
|
||||
)}
|
||||
</button>
|
||||
<div
|
||||
className={`pointer-events-none invisible absolute z-[60] w-72 max-w-[min(18rem,calc(100vw-4rem))] rounded-xl border border-divider/80 bg-background/98 p-3 opacity-0 shadow-xl backdrop-blur transition-all duration-150 group-hover/info:visible group-hover/info:pointer-events-auto group-hover/info:opacity-100 group-focus-within/info:visible group-focus-within/info:pointer-events-auto group-focus-within/info:opacity-100 ${
|
||||
infoPlacement === "bottom"
|
||||
? "right-0 top-[calc(100%+0.75rem)] translate-y-1 group-hover/info:translate-y-0 group-focus-within/info:translate-y-0"
|
||||
: "right-[calc(100%+0.75rem)] top-1/2 -translate-y-1/2 translate-x-1 group-hover/info:translate-x-0 group-focus-within/info:translate-x-0"
|
||||
}`}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
{hasExpiryInfo && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-medium text-default-500">
|
||||
到期提醒
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
<Chip
|
||||
className="text-[10px] h-5 px-1 flex-shrink-0"
|
||||
color={expiryMeta.tone}
|
||||
size="sm"
|
||||
title={`${formatNodeRenewalTime(expiryMeta.nextDueTime)} (${getNodeRenewalCycleLabel(node.renewalCycle)})`}
|
||||
variant="flat"
|
||||
>
|
||||
{expiryMeta.label}
|
||||
</Chip>
|
||||
</div>
|
||||
<div className="rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700">
|
||||
{formatNodeRenewalTime(
|
||||
expiryMeta.nextDueTime,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasRemark && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-[11px] font-medium text-default-500">
|
||||
备注
|
||||
</div>
|
||||
<div
|
||||
className="max-h-32 overflow-y-auto rounded-lg border border-divider/80 bg-default-50/80 px-3 py-2 text-xs leading-5 text-default-700 break-all [scrollbar-width:thin]"
|
||||
title={node.remark?.trim()}
|
||||
>
|
||||
{node.remark?.trim()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1917,29 +1963,47 @@ export default function NodePage() {
|
||||
<>
|
||||
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5 space-y-2">
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">远程地址</span>
|
||||
<span className="text-default-500">
|
||||
远程地址
|
||||
</span>
|
||||
<span
|
||||
className="font-mono text-right truncate"
|
||||
title={remoteUsage.remoteUrl || node.remoteUrl || "-"}
|
||||
title={
|
||||
remoteUsage.remoteUrl ||
|
||||
node.remoteUrl ||
|
||||
"-"
|
||||
}
|
||||
>
|
||||
{remoteUsage.remoteUrl || node.remoteUrl || "-"}
|
||||
{remoteUsage.remoteUrl ||
|
||||
node.remoteUrl ||
|
||||
"-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">共享ID</span>
|
||||
<span className="font-mono">#{remoteUsage.shareId}</span>
|
||||
<span className="text-default-500">
|
||||
共享ID
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
#{remoteUsage.shareId}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">流量</span>
|
||||
<span className="text-default-500">
|
||||
流量
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{formatFlow(remoteUsage.currentFlow)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between gap-2">
|
||||
<span className="text-default-500">带宽上限</span>
|
||||
<span className="text-default-500">
|
||||
带宽上限
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
{remoteUsage.maxBandwidth > 0
|
||||
? formatSpeed(remoteUsage.maxBandwidth)
|
||||
? formatSpeed(
|
||||
remoteUsage.maxBandwidth,
|
||||
)
|
||||
: "不限"}
|
||||
</span>
|
||||
</div>
|
||||
@@ -1947,9 +2011,12 @@ export default function NodePage() {
|
||||
|
||||
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-default-500">占用端口</span>
|
||||
<span className="text-default-500">
|
||||
占用端口
|
||||
</span>
|
||||
<span className="font-mono text-default-700 dark:text-default-300">
|
||||
{remoteUsage.usedPorts.length}/{Math.max(
|
||||
{remoteUsage.usedPorts.length}/
|
||||
{Math.max(
|
||||
remoteUsage.portRangeEnd -
|
||||
remoteUsage.portRangeStart +
|
||||
1,
|
||||
@@ -1972,14 +2039,18 @@ export default function NodePage() {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-default-400">暂无占用端口</div>
|
||||
<div className="text-default-400">
|
||||
暂无占用端口
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs rounded-md border border-default-200 dark:border-default-100/30 bg-default-50 dark:bg-default-100/20 p-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-default-500">绑定明细</span>
|
||||
<span className="text-default-500">
|
||||
绑定明细
|
||||
</span>
|
||||
<span className="font-mono text-default-700 dark:text-default-300">
|
||||
{remoteUsage.activeBindingNum}
|
||||
</span>
|
||||
@@ -2003,13 +2074,22 @@ export default function NodePage() {
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-[11px] text-default-500 flex items-center justify-between gap-2">
|
||||
<span>{formatChainType(binding.chainType, binding.hopInx)}</span>
|
||||
<span className="font-mono">端口 {binding.allocatedPort}</span>
|
||||
<span>
|
||||
{formatChainType(
|
||||
binding.chainType,
|
||||
binding.hopInx,
|
||||
)}
|
||||
</span>
|
||||
<span className="font-mono">
|
||||
端口 {binding.allocatedPort}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-default-400">暂无绑定明细</div>
|
||||
<div className="text-default-400">
|
||||
暂无绑定明细
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -2143,6 +2223,111 @@ export default function NodePage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 扩展指标:磁盘/负载/连接 */}
|
||||
{node.connectionStatus === "online" &&
|
||||
node.systemInfo && (
|
||||
<div className="space-y-2">
|
||||
{node.systemInfo.diskUsage !==
|
||||
undefined && (
|
||||
<div>
|
||||
<div className="flex justify-between text-xs mb-1">
|
||||
<span>磁盘</span>
|
||||
<span className="font-mono">
|
||||
{node.systemInfo.diskUsage.toFixed(
|
||||
1,
|
||||
)}
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
aria-label="磁盘使用率"
|
||||
color={getProgressColor(
|
||||
node.systemInfo.diskUsage,
|
||||
false,
|
||||
)}
|
||||
size="sm"
|
||||
value={node.systemInfo.diskUsage}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{(node.systemInfo.load1 !== undefined ||
|
||||
node.systemInfo.load5 !== undefined ||
|
||||
node.systemInfo.load15 !==
|
||||
undefined) && (
|
||||
<div className="grid grid-cols-3 gap-1 text-xs">
|
||||
{node.systemInfo.load1 !==
|
||||
undefined && (
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-500 text-[10px]">
|
||||
负载 1m
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.systemInfo.load1.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.systemInfo.load5 !==
|
||||
undefined && (
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-500 text-[10px]">
|
||||
负载 5m
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.systemInfo.load5.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.systemInfo.load15 !==
|
||||
undefined && (
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-500 text-[10px]">
|
||||
负载 15m
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.systemInfo.load15.toFixed(
|
||||
2,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{(node.systemInfo.tcpConns !==
|
||||
undefined ||
|
||||
node.systemInfo.udpConns !==
|
||||
undefined) && (
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
{node.systemInfo.tcpConns !==
|
||||
undefined && (
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-500 text-[10px]">
|
||||
TCP 连接
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.systemInfo.tcpConns}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{node.systemInfo.udpConns !==
|
||||
undefined && (
|
||||
<div className="text-center p-1.5 bg-default-50 dark:bg-default-100 rounded">
|
||||
<div className="text-default-500 text-[10px]">
|
||||
UDP 连接
|
||||
</div>
|
||||
<div className="font-mono">
|
||||
{node.systemInfo.udpConns}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -2228,7 +2413,6 @@ export default function NodePage() {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)}
|
||||
|
||||
{/* 新增/编辑节点对话框 */}
|
||||
<Modal
|
||||
backdrop="blur"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,14 @@ export interface NodeSystemInfo {
|
||||
uploadSpeed: number;
|
||||
downloadSpeed: number;
|
||||
uptime: number;
|
||||
diskUsage?: number;
|
||||
load1?: number;
|
||||
load5?: number;
|
||||
load15?: number;
|
||||
tcpConns?: number;
|
||||
udpConns?: number;
|
||||
netInSpeed?: number;
|
||||
netOutSpeed?: number;
|
||||
}
|
||||
|
||||
type RawSystemInfo = Record<string, string | number | undefined>;
|
||||
@@ -82,5 +90,13 @@ export const buildNodeSystemInfo = (
|
||||
uploadSpeed,
|
||||
downloadSpeed,
|
||||
uptime,
|
||||
diskUsage: toFloat(raw.disk_usage),
|
||||
load1: toFloat(raw.load1),
|
||||
load5: toFloat(raw.load5),
|
||||
load15: toFloat(raw.load15),
|
||||
tcpConns: toInteger(raw.tcp_conns),
|
||||
udpConns: toInteger(raw.udp_conns),
|
||||
netInSpeed: toInteger(raw.net_in_speed),
|
||||
netOutSpeed: toInteger(raw.net_out_speed),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -381,7 +381,7 @@ export default function TunnelPage() {
|
||||
});
|
||||
setErrors({});
|
||||
setModalOpen(true);
|
||||
};
|
||||
};
|
||||
|
||||
// 删除隧道
|
||||
const handleDelete = (tunnel: Tunnel) => {
|
||||
@@ -918,7 +918,9 @@ export default function TunnelPage() {
|
||||
failures,
|
||||
);
|
||||
} else {
|
||||
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
|
||||
toast.error(
|
||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||
);
|
||||
}
|
||||
setBatchProgress({
|
||||
active: true,
|
||||
@@ -1413,7 +1415,6 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="flex gap-1.5 mt-3">
|
||||
|
||||
@@ -30,6 +30,7 @@ import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
|
||||
import { RadioGroup, Radio } from "@/shadcn-bridge/heroui/radio";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { Switch } from "@/shadcn-bridge/heroui/switch";
|
||||
import { DatePicker } from "@/shadcn-bridge/heroui/date-picker";
|
||||
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
@@ -58,6 +59,9 @@ import {
|
||||
resetUserQuota,
|
||||
getUserGroupList,
|
||||
getUserGroups,
|
||||
getMonitorPermissionList,
|
||||
assignMonitorPermission,
|
||||
removeMonitorPermission,
|
||||
} from "@/api";
|
||||
import {
|
||||
SearchIcon,
|
||||
@@ -229,6 +233,13 @@ export default function UserPage() {
|
||||
onClose: onTunnelModalClose,
|
||||
} = useDisclosure();
|
||||
const [currentUser, setCurrentUser] = useState<User | null>(null);
|
||||
const [monitorPermissionUserIds, setMonitorPermissionUserIds] = useState<
|
||||
Set<number>
|
||||
>(new Set());
|
||||
const [monitorPermissionLoading, setMonitorPermissionLoading] =
|
||||
useState(false);
|
||||
const [monitorPermissionMutatingUserId, setMonitorPermissionMutatingUserId] =
|
||||
useState<number | null>(null);
|
||||
const [userTunnels, setUserTunnels] = useState<UserTunnel[]>([]);
|
||||
const [tunnelListLoading, setTunnelListLoading] = useState(false);
|
||||
|
||||
@@ -395,6 +406,32 @@ export default function UserPage() {
|
||||
} catch {}
|
||||
}, []);
|
||||
|
||||
const loadMonitorPermissions = useCallback(async () => {
|
||||
setMonitorPermissionLoading(true);
|
||||
try {
|
||||
const response = await getMonitorPermissionList();
|
||||
|
||||
if (response.code === 0) {
|
||||
const ids = new Set<number>();
|
||||
|
||||
if (Array.isArray(response.data)) {
|
||||
response.data.forEach((item: any) => {
|
||||
const id = Number(item?.userId ?? 0);
|
||||
|
||||
if (id > 0) ids.add(id);
|
||||
});
|
||||
}
|
||||
setMonitorPermissionUserIds(ids);
|
||||
} else if (response.code !== 403) {
|
||||
toast.error(response.msg || "获取监控权限失败");
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
setMonitorPermissionLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadUserTunnels = useCallback(async (userId: number) => {
|
||||
setTunnelListLoading(true);
|
||||
try {
|
||||
@@ -421,7 +458,8 @@ export default function UserPage() {
|
||||
void loadTunnels();
|
||||
void loadSpeedLimits();
|
||||
void loadUserGroups();
|
||||
}, [loadSpeedLimits, loadTunnels, loadUserGroups]);
|
||||
void loadMonitorPermissions();
|
||||
}, [loadMonitorPermissions, loadSpeedLimits, loadTunnels, loadUserGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadUsers();
|
||||
@@ -597,6 +635,63 @@ export default function UserPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const setUserMonitorPermission = useCallback(
|
||||
async (userId: number, enabled: boolean) => {
|
||||
if (userId <= 0) return;
|
||||
if (monitorPermissionMutatingUserId === userId) return;
|
||||
|
||||
const prevEnabled = monitorPermissionUserIds.has(userId);
|
||||
|
||||
if (prevEnabled === enabled) return;
|
||||
|
||||
setMonitorPermissionMutatingUserId(userId);
|
||||
|
||||
// Optimistic update for better UX.
|
||||
setMonitorPermissionUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
if (enabled) {
|
||||
next.add(userId);
|
||||
} else {
|
||||
next.delete(userId);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = enabled
|
||||
? await assignMonitorPermission(userId)
|
||||
: await removeMonitorPermission(userId);
|
||||
|
||||
if (response.code === 0) {
|
||||
toast.success(enabled ? "已授权监控" : "已撤销监控");
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
toast.error(response.msg || "操作失败");
|
||||
throw new Error("mutation failed");
|
||||
} catch {
|
||||
// Revert optimistic update on failure.
|
||||
setMonitorPermissionUserIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
if (prevEnabled) {
|
||||
next.add(userId);
|
||||
} else {
|
||||
next.delete(userId);
|
||||
}
|
||||
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
setMonitorPermissionMutatingUserId(null);
|
||||
}
|
||||
},
|
||||
[monitorPermissionMutatingUserId, monitorPermissionUserIds],
|
||||
);
|
||||
|
||||
// 隧道权限管理操作
|
||||
const handleManageTunnels = (user: User) => {
|
||||
setCurrentUser(user);
|
||||
@@ -782,6 +877,7 @@ export default function UserPage() {
|
||||
|
||||
const handleQuotaReset = async (scope: "daily" | "monthly" | "all") => {
|
||||
const userId = userForm.id;
|
||||
|
||||
if (!userId) {
|
||||
return;
|
||||
}
|
||||
@@ -1055,17 +1151,15 @@ export default function UserPage() {
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">每日配额</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /
|
||||
{" "}
|
||||
{formatFlow(Number(user.dailyUsedBytes ?? 0))} /{" "}
|
||||
{formatQuotaLimit(user.dailyQuotaGB)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-default-600">每月配额</span>
|
||||
<span className="font-medium text-xs">
|
||||
{formatFlow(Number(user.monthlyUsedBytes ?? 0))} /
|
||||
{" "}
|
||||
{formatQuotaLimit(user.monthlyQuotaGB)}
|
||||
{formatFlow(Number(user.monthlyUsedBytes ?? 0))}{" "}
|
||||
/ {formatQuotaLimit(user.monthlyQuotaGB)}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
@@ -1366,8 +1460,8 @@ export default function UserPage() {
|
||||
<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">
|
||||
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))} /{" "}
|
||||
{formatQuotaLimit(editingUser.monthlyQuotaGB)}
|
||||
{formatFlow(Number(editingUser.monthlyUsedBytes ?? 0))}{" "}
|
||||
/ {formatQuotaLimit(editingUser.monthlyQuotaGB)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1463,9 +1557,43 @@ export default function UserPage() {
|
||||
onClose={onTunnelModalClose}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>用户 {currentUser?.user} 的隧道权限管理</ModalHeader>
|
||||
<ModalHeader>用户 {currentUser?.user} 的权限管理</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="space-y-6">
|
||||
{/* 监控权限部分 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">监控权限</h3>
|
||||
<div className="flex items-center justify-between gap-4 bg-default-100 dark:bg-default-50 p-4 rounded-lg border border-default-200 dark:border-default-100/30">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
允许访问监控功能
|
||||
</div>
|
||||
<div className="text-xs text-default-500 mt-1">
|
||||
授予后,该用户可以访问监控页面并管理服务监控(TCP/ICMP)。
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{monitorPermissionLoading ? <Spinner size="sm" /> : null}
|
||||
<Switch
|
||||
isDisabled={
|
||||
!currentUser ||
|
||||
monitorPermissionLoading ||
|
||||
monitorPermissionMutatingUserId === currentUser.id
|
||||
}
|
||||
isSelected={
|
||||
currentUser
|
||||
? monitorPermissionUserIds.has(currentUser.id)
|
||||
: false
|
||||
}
|
||||
onValueChange={(v) =>
|
||||
currentUser &&
|
||||
void setUserMonitorPermission(currentUser.id, v)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分配新权限部分 */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold mb-4">分配新权限</h3>
|
||||
|
||||
Reference in New Issue
Block a user