feat: implement monitor page list view (#345)

This commit is contained in:
sagit
2026-03-19 13:54:12 +08:00
committed by GitHub
parent 76ad841231
commit db21ce6bb4
4 changed files with 189 additions and 32 deletions
+3 -3
View File
@@ -6,6 +6,6 @@ Release the current state of `main` (including the monitor tab beautification an
## Tasks ## Tasks
- [x] Update `AGENTS.md` with current date, branch, and anticipated tag. - [x] Update `AGENTS.md` with current date, branch, and anticipated tag.
- [x] Create a release branch `release/2.1.9-alpha5`. - [x] Create a release branch `release/2.1.9-alpha5`.
- [ ] Commit and push the branch. - [x] Commit and push the branch.
- [ ] Create a Pull Request and merge it into `main`. - [x] Create a Pull Request and merge it into `main`.
- [ ] Publish the new tag `2.1.9-alpha5`. - [x] Publish the new tag `2.1.9-alpha5`.
+32
View File
@@ -0,0 +1,32 @@
# 049 - Monitor List View
## Objective
The user requested that the Monitor page should switch its card-based grid view to a list view similar to a provided screenshot, and a view mode toggle should be added to the top right.
## Expected Features
1. View Mode Toggle
- Add a state `viewMode` in `pages/monitor.tsx`.
- Add a toggle button with LayoutGrid/List icons next to the refresh button.
- Pass `viewMode` down to `MonitorView` component.
2. List View Implementation
- Extend `MonitorViewProps` with `viewMode: "list" | "grid"`.
- Render the `ServerCard` grid when `viewMode === "grid"`.
- Render a `Table` when `viewMode === "list"`.
- The list view should include:
- 状态 (Status: Colored dot depending on `isOnline`).
- 名称 (Name: Node name).
- 速率 (Speed: Up/Down speeds styled appropriately).
- 流量 (Traffic: Total Up/Down bytes).
- 开机时长 (Uptime).
- 连接数 (Connections: TCP/UDP).
- CPU (Progress bar).
- RAM (Progress bar).
- 存储 (Storage / Disk Progress bar).
- 操作 (Actions: Eye view icon to open detailed monitor).
## Checklist
- [x] Create plan document.
- [x] Add viewMode state and toggle in `monitor.tsx`.
- [x] Receive viewMode in `MonitorView` and selectively render Grid vs List views.
- [x] Ensure list correctly visualizes node info, speed, traffic, uptime, conns, CPU/RAM/Disk usages, and action icons.
- [x] Fix HeroUI missing TableProps typings (remove `removeWrapper` prop).
+22 -11
View File
@@ -2,7 +2,7 @@ import type { MonitorNodeApiItem } from "@/api/types";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { RefreshCw } from "lucide-react"; import { RefreshCw, LayoutGrid, List } from "lucide-react";
import { AnimatedPage } from "@/components/animated-page"; import { AnimatedPage } from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
@@ -20,6 +20,7 @@ export default function MonitorPage() {
const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]); const [nodes, setNodes] = useState<MonitorNodeApiItem[]>([]);
const [nodesLoading, setNodesLoading] = useState(false); const [nodesLoading, setNodesLoading] = useState(false);
const [nodesError, setNodesError] = useState<string | null>(null); const [nodesError, setNodesError] = useState<string | null>(null);
const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
@@ -83,15 +84,25 @@ export default function MonitorPage() {
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP) 实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
</div> </div>
</div> </div>
<Button <div className="flex items-center gap-2">
isLoading={nodesLoading} <Button
size="sm" isIconOnly
variant="flat" size="sm"
onPress={() => loadNodes()} variant="flat"
> onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
<RefreshCw className="w-4 h-4 mr-1" /> >
刷新节点 {viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button> </Button>
<Button
isLoading={nodesLoading}
size="sm"
variant="flat"
onPress={() => loadNodes()}
>
<RefreshCw className="w-4 h-4 mr-1" />
刷新节点
</Button>
</div>
</div> </div>
{nodesError ? ( {nodesError ? (
@@ -106,7 +117,7 @@ export default function MonitorPage() {
) : null} ) : null}
</div> </div>
<MonitorView nodeMap={nodeMap} /> <MonitorView nodeMap={nodeMap} viewMode={viewMode} />
</AnimatedPage> </AnimatedPage>
); );
} }
+132 -18
View File
@@ -29,6 +29,7 @@ import {
Server, Server,
Clock, Clock,
ArrowLeft, ArrowLeft,
Eye,
} from "lucide-react"; } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
@@ -77,6 +78,7 @@ import { useNodeRealtime } from "@/pages/node/use-node-realtime";
interface MonitorViewProps { interface MonitorViewProps {
nodeMap: Map<number, { id: number; name: string; connectionStatus: string }>; nodeMap: Map<number, { id: number; name: string; connectionStatus: string }>;
viewMode?: "list" | "grid";
} }
type RealtimeNodeMetric = { type RealtimeNodeMetric = {
@@ -274,7 +276,7 @@ const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
maxTimeoutSec: 60, maxTimeoutSec: 60,
}; };
export function MonitorView({ nodeMap }: MonitorViewProps) { export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const [detailNodeId, setDetailNodeId] = useState<number | null>(null); const [detailNodeId, setDetailNodeId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null); const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
const [metrics, setMetrics] = useState<NodeMetricApiItem[]>([]); const [metrics, setMetrics] = useState<NodeMetricApiItem[]>([]);
@@ -1170,7 +1172,7 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
</Card> </Card>
)} )}
{/* ====== GRID VIEW ====== */} {/* ====== GRID/LIST VIEW ====== */}
{!accessDenied && !detailNodeId && ( {!accessDenied && !detailNodeId && (
<> <>
<div className="flex flex-wrap items-center gap-3 mb-1"> <div className="flex flex-wrap items-center gap-3 mb-1">
@@ -1183,22 +1185,134 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
{monitorSummary.stale > 0 && <Chip color="warning" size="sm" variant="flat">陈旧 {monitorSummary.stale}</Chip>} {monitorSummary.stale > 0 && <Chip color="warning" size="sm" variant="flat">陈旧 {monitorSummary.stale}</Chip>}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> {viewMode === "grid" ? (
{nodes.map((node) => { <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
const metric = realtimeNodeMetrics[node.id] || null; {nodes.map((node) => {
return ( const metric = realtimeNodeMetrics[node.id] || null;
<ServerCard return (
key={node.id} <ServerCard
node={node} key={node.id}
metric={metric} node={node}
onPress={() => { metric={metric}
setDetailNodeId(node.id); onPress={() => {
setSelectedNodeId(node.id); setDetailNodeId(node.id);
}} setSelectedNodeId(node.id);
/> }}
); />
})} );
</div> })}
</div>
) : (
<Card className="w-full">
<Table aria-label="节点列表">
<TableHeader>
<TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn>
<TableColumn>速率</TableColumn>
<TableColumn>流量</TableColumn>
<TableColumn>开机时长</TableColumn>
<TableColumn>连接数</TableColumn>
<TableColumn>CPU</TableColumn>
<TableColumn>RAM</TableColumn>
<TableColumn>存储</TableColumn>
<TableColumn align="center">操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无节点">
{nodes.map((node) => {
const metric = realtimeNodeMetrics[node.id] || null;
const isOnline = node.connectionStatus === "online";
return (
<TableRow key={node.id} className="border-b border-divider/50 last:border-b-0">
<TableCell>
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
</TableCell>
<TableCell>
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-success-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span> ↑
</div>
<div className="flex items-center gap-1 font-mono text-primary-500">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span> ↓
</div>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span> ↑
</div>
<div className="flex items-center gap-1 font-mono text-default-600">
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span> ↓
</div>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-xs text-default-500 whitespace-nowrap">
{isOnline && metric ? formatUptime(metric.uptime) : "-"}
</span>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1.5 text-xs font-mono text-default-500">
<div>TCP {isOnline && metric ? metric.tcpConns : "-"}</div>
<div>UDP {isOnline && metric ? metric.udpConns : "-"}</div>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{isOnline && metric ? (
<Progress className="w-[40px] md:w-[60px]" color={getColorByUsage(metric.cpuUsage)} size="sm" value={metric.cpuUsage} />
) : (
<div className="w-[40px] md:w-[60px] h-2 rounded-full bg-default-100" />
)}
<span className="text-xs font-mono w-9 text-right text-default-500">{isOnline && metric ? `${metric.cpuUsage.toFixed(1)}%` : "-"}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{isOnline && metric ? (
<Progress className="w-[40px] md:w-[60px]" color={getColorByUsage(metric.memoryUsage)} size="sm" value={metric.memoryUsage} />
) : (
<div className="w-[40px] md:w-[60px] h-2 rounded-full bg-default-100" />
)}
<span className="text-xs font-mono w-9 text-right text-default-500">{isOnline && metric ? `${metric.memoryUsage.toFixed(1)}%` : "-"}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
{isOnline && metric ? (
<Progress className="w-[40px] md:w-[60px]" color={getColorByUsage(metric.diskUsage)} size="sm" value={metric.diskUsage} />
) : (
<div className="w-[40px] md:w-[60px] h-2 rounded-full bg-default-100" />
)}
<span className="text-xs font-mono w-9 text-right text-default-500">{isOnline && metric ? `${metric.diskUsage.toFixed(1)}%` : "-"}</span>
</div>
</TableCell>
<TableCell>
<div className="flex justify-center">
<Button
isIconOnly
size="sm"
variant="light"
onPress={() => {
setDetailNodeId(node.id);
setSelectedNodeId(node.id);
}}
>
<Eye className="w-4 h-4 text-default-500" />
</Button>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Card>
)}
</> </>
)} )}