mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-02 08:56:38 +08:00
8ebde9dca9
* chore: update AGENTS.md with next release info * feat: node OS logo, UI rate fix, and monitor trend updates
162 lines
5.2 KiB
TypeScript
162 lines
5.2 KiB
TypeScript
import type { MonitorNodeApiItem } from "@/api/types";
|
||
|
||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||
import toast from "react-hot-toast";
|
||
import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } 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";
|
||
import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view";
|
||
|
||
type MonitorNode = {
|
||
id: number;
|
||
name: string;
|
||
connectionStatus: "online" | "offline";
|
||
version?: string;
|
||
};
|
||
|
||
type MonitorTab = "nodes" | "tunnels";
|
||
|
||
export default function MonitorPage() {
|
||
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
|
||
const [nodesLoading, setNodesLoading] = useState(false);
|
||
const [nodesError, setNodesError] = useState<string | null>(null);
|
||
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
|
||
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
|
||
|
||
const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
|
||
const silent = options?.silent ?? false;
|
||
if (!silent) 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;
|
||
}
|
||
|
||
if (!silent) toast.error(response.msg || "加载节点失败");
|
||
} catch {
|
||
if (!silent) toast.error("加载节点失败");
|
||
} finally {
|
||
if (!silent) setNodesLoading(false);
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
void loadNodes();
|
||
}, [loadNodes]);
|
||
|
||
useEffect(() => {
|
||
const timer = window.setInterval(() => {
|
||
void loadNodes({ silent: true });
|
||
}, 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",
|
||
version: n.version,
|
||
}));
|
||
|
||
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>
|
||
<div className="flex items-center gap-2">
|
||
<Button
|
||
isIconOnly
|
||
size="sm"
|
||
variant="flat"
|
||
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
|
||
>
|
||
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
|
||
</Button>
|
||
{activeTab === "nodes" && (
|
||
<Button
|
||
isLoading={nodesLoading}
|
||
size="sm"
|
||
variant="flat"
|
||
onPress={() => loadNodes()}
|
||
>
|
||
<RefreshCw className="w-4 h-4 mr-1" />
|
||
刷新节点
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab Switcher */}
|
||
<div className="flex items-center gap-1 p-1 rounded-xl bg-default-100 dark:bg-default-50/10 w-fit">
|
||
<button
|
||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||
activeTab === "nodes"
|
||
? "bg-background shadow-sm text-foreground"
|
||
: "text-default-500 hover:text-foreground"
|
||
}`}
|
||
onClick={() => setActiveTab("nodes")}
|
||
>
|
||
<Server className="w-4 h-4" />
|
||
节点
|
||
</button>
|
||
<button
|
||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200 ${
|
||
activeTab === "tunnels"
|
||
? "bg-background shadow-sm text-foreground"
|
||
: "text-default-500 hover:text-foreground"
|
||
}`}
|
||
onClick={() => setActiveTab("tunnels")}
|
||
>
|
||
<ArrowRightLeft className="w-4 h-4" />
|
||
隧道
|
||
</button>
|
||
</div>
|
||
|
||
{nodesError && activeTab === "nodes" ? (
|
||
<Card>
|
||
<CardHeader>
|
||
<h3 className="text-sm font-semibold">节点列表</h3>
|
||
</CardHeader>
|
||
<CardBody>
|
||
<div className="text-sm text-default-600">{nodesError}</div>
|
||
</CardBody>
|
||
</Card>
|
||
) : null}
|
||
</div>
|
||
|
||
{activeTab === "nodes" ? (
|
||
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
|
||
) : (
|
||
<TunnelMonitorView viewMode={viewMode} />
|
||
)}
|
||
</AnimatedPage>
|
||
);
|
||
}
|