mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat: implement monitor page list view (#345)
This commit is contained in:
@@ -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`.
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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,6 +84,15 @@ export default function MonitorPage() {
|
|||||||
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
|
实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP)
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
<Button
|
<Button
|
||||||
isLoading={nodesLoading}
|
isLoading={nodesLoading}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -93,6 +103,7 @@ export default function MonitorPage() {
|
|||||||
刷新节点
|
刷新节点
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{nodesError ? (
|
{nodesError ? (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -106,7 +117,7 @@ export default function MonitorPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MonitorView nodeMap={nodeMap} />
|
<MonitorView nodeMap={nodeMap} viewMode={viewMode} />
|
||||||
</AnimatedPage>
|
</AnimatedPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,6 +1185,7 @@ 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>
|
||||||
|
|
||||||
|
{viewMode === "grid" ? (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||||
{nodes.map((node) => {
|
{nodes.map((node) => {
|
||||||
const metric = realtimeNodeMetrics[node.id] || null;
|
const metric = realtimeNodeMetrics[node.id] || null;
|
||||||
@@ -1199,6 +1202,117 @@ export function MonitorView({ nodeMap }: MonitorViewProps) {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user