mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f45f96063a | |||
| 1780be73b9 | |||
| addf8e2089 | |||
| 75cd60ea3e |
@@ -12,6 +12,7 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
|
||||
}
|
||||
var nodes []model.Node
|
||||
err := r.db.Select("id", "inx", "name", "status", "updated_time").
|
||||
Where("is_remote = ?", 0).
|
||||
Order("inx ASC, id ASC").
|
||||
Find(&nodes).Error
|
||||
return nodes, err
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# Tunnel Quality Uptime Kuma Display
|
||||
**Plan ID**: 056-tunnel-uptime-kuma-bars.md
|
||||
|
||||
## Objective
|
||||
The goal is to modify the "Monitor - Tunnel" UI page to remove the isolated "Quality" column/chip, and replace the specific latency readouts for entry->exit and exit->Bing with an Uptime Kuma style row of visual history bars.
|
||||
|
||||
## Steps
|
||||
- [x] Remove the individual "Quality" column from the tunnel monitor table view.
|
||||
- [x] Remove the individual "Quality" chip from the tunnel monitor grid view.
|
||||
- [x] Implement `<UptimeHistoryBar />` to display a historical sequence of up to 30 metrics, coloring by latency (success, warning, danger) and packet loss (danger).
|
||||
- [x] Modify the front-end to poll/load initial tunnel quality history, effectively padding the history bars instead of them starting empty.
|
||||
- [x] Update `getMonitorTunnelQuality` auto-polling locally to append strictly to the local history state, truncating appropriately.
|
||||
- [x] Retain current latency display next to or below the Uptime bars to allow numerical visibility.
|
||||
|
||||
## Status
|
||||
Completed. The interface will now load history for all displayed tunnels upon open, and continue tracking with bars appending in real time every 10-second polling interval.
|
||||
@@ -0,0 +1,13 @@
|
||||
# 057 Hide Remote Nodes from Monitor View
|
||||
|
||||
## Goal
|
||||
Do not display remote nodes on the Monitoring page.
|
||||
|
||||
## Changes Made
|
||||
- Modified `ListMonitorNodes` in `go-backend/internal/store/repo/repository_monitor_nodes.go` by adding a `.Where("is_remote = ?", 0)` constraint so that remote nodes (imported via Federation feature) are entirely excluded from the returned payload for API `/api/v1/monitor/nodes`.
|
||||
- This efficiently removes remote nodes from both the grid/list displaying Node stats in the Monitoring tab and also eliminates remote nodes from the selection dropdown when creating new Service Monitors.
|
||||
|
||||
## Checklist
|
||||
- [x] Identify how "remote node" is defined in the database structure (`IsRemote` = 1 or 0).
|
||||
- [x] Add SQL query constraint to filter out remote nodes from the `/monitor/nodes` API response.
|
||||
- [x] Verify changes compile successfully.
|
||||
@@ -18,6 +18,15 @@ import {
|
||||
ModalFooter,
|
||||
} from "@/shadcn-bridge/heroui/modal";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import {
|
||||
createSpeedLimit,
|
||||
getSpeedLimitList,
|
||||
@@ -51,6 +60,10 @@ export default function LimitPage() {
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
|
||||
"limit-view-mode",
|
||||
"grid",
|
||||
);
|
||||
|
||||
const filteredRules = useMemo(() => {
|
||||
if (!searchKeyword.trim()) return rules;
|
||||
@@ -231,13 +244,85 @@ export default function LimitPage() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
<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 color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
||||
新增
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 统一卡片网格 */}
|
||||
{filteredRules.length > 0 ? (
|
||||
viewMode === "list" ? (
|
||||
<Card>
|
||||
<Table
|
||||
aria-label="限速规则列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>规则名称</TableColumn>
|
||||
<TableColumn>速度限制</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={filteredRules}>
|
||||
{(rule) => (
|
||||
<TableRow key={rule.id}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||
rule.status === 1 ? "bg-success" : "bg-danger"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{rule.name}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono text-default-600">
|
||||
{rule.speed} Mbps
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(rule)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(rule)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
|
||||
{filteredRules.map((rule) => (
|
||||
<StaggerItem key={rule.id}>
|
||||
@@ -324,6 +409,7 @@ export default function LimitPage() {
|
||||
</StaggerItem>
|
||||
))}
|
||||
</StaggerList>
|
||||
)
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
|
||||
@@ -1768,7 +1768,15 @@ export default function NodePage() {
|
||||
/>
|
||||
) : viewMode === "list" ? (
|
||||
<Card>
|
||||
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
|
||||
<Table
|
||||
aria-label="节点列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
||||
<Checkbox
|
||||
|
||||
@@ -1065,7 +1065,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
</div>
|
||||
) : (
|
||||
<Card className="w-full">
|
||||
<Table aria-label="节点列表">
|
||||
<Table
|
||||
aria-label="节点列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
@@ -1084,7 +1092,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
const isOnline = node.connectionStatus === "online";
|
||||
|
||||
return (
|
||||
<TableRow key={node.id} className="border-b border-divider/50 last:border-b-0">
|
||||
<TableRow key={node.id}>
|
||||
<TableCell>
|
||||
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||
</TableCell>
|
||||
@@ -1536,7 +1544,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
||||
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||
</div>
|
||||
) : modalResults.length > 0 ? (
|
||||
<Table aria-label="监控记录" className="w-full">
|
||||
<Table
|
||||
aria-label="监控记录"
|
||||
className="w-full overflow-x-auto"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>时间</TableColumn>
|
||||
<TableColumn>结果</TableColumn>
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
getMonitorTunnelQuality,
|
||||
getMonitorTunnelQualityHistory,
|
||||
} from "@/api";
|
||||
import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis";
|
||||
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||
@@ -109,6 +109,79 @@ function LiveDot() {
|
||||
);
|
||||
}
|
||||
|
||||
const UPTIME_KUMA_BARS_COUNT = 30;
|
||||
|
||||
function UptimeHistoryBar({
|
||||
history = [],
|
||||
type,
|
||||
latestValue,
|
||||
}: {
|
||||
history: TunnelQualityApiItem[];
|
||||
type: "entryToExit" | "exitToBing";
|
||||
latestValue?: number;
|
||||
}) {
|
||||
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
|
||||
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
|
||||
if (historyIndex >= 0 && historyIndex < history.length) {
|
||||
return history[historyIndex];
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center gap-[3px]">
|
||||
{bars.map((q, idx) => {
|
||||
if (!q) {
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-1.5 h-4 rounded-full bg-default-200 dark:bg-default-100/50"
|
||||
title="暂无数据"
|
||||
/>
|
||||
);
|
||||
}
|
||||
const isEntry = type === "entryToExit";
|
||||
const latency = isEntry ? q.entryToExitLatency : q.exitToBingLatency;
|
||||
const loss = isEntry ? q.entryToExitLoss : q.exitToBingLoss;
|
||||
const timeStr = new Date(q.timestamp).toLocaleTimeString("zh-CN");
|
||||
|
||||
let colorClass = "bg-success";
|
||||
let statusText = "优秀";
|
||||
|
||||
if (q.errorMessage || (loss !== undefined && loss > 0)) {
|
||||
colorClass = "bg-danger";
|
||||
statusText = q.errorMessage ? "错误" : `丢包 ${loss?.toFixed(1)}%`;
|
||||
} else if (latency < 0) {
|
||||
colorClass = "bg-danger";
|
||||
statusText = "探测失败";
|
||||
} else if (latency > 200) {
|
||||
colorClass = "bg-danger";
|
||||
statusText = "极差";
|
||||
} else if (latency > 100) {
|
||||
colorClass = "bg-warning";
|
||||
statusText = "较差";
|
||||
}
|
||||
|
||||
const displayLatency = latency >= 0 ? `${latency.toFixed(0)}ms` : "-";
|
||||
const tooltip = `${timeStr} | ${displayLatency} | ${statusText}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={q.timestamp || idx}
|
||||
className={`w-1.5 h-4 rounded-full transition-colors cursor-help hover:opacity-80 ${colorClass}`}
|
||||
title={tooltip}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div>
|
||||
<LatencyDisplay value={latestValue} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||
@@ -117,6 +190,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
// Quality data from backend periodic probing (latest per tunnel)
|
||||
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
|
||||
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
|
||||
const initialHistoryFetched = useRef(false);
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const qualityTimerRef = useRef<number | null>(null);
|
||||
|
||||
@@ -178,6 +253,45 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTunnels]);
|
||||
|
||||
// --- Initial history load ---
|
||||
useEffect(() => {
|
||||
if (tunnels.length > 0 && !initialHistoryFetched.current) {
|
||||
initialHistoryFetched.current = true;
|
||||
const fetchHistory = async () => {
|
||||
const end = Date.now();
|
||||
// Fetch last 5 minutes (about 30 points)
|
||||
const start = end - 5 * 60 * 1000;
|
||||
|
||||
const promises = tunnels.map((t) =>
|
||||
getMonitorTunnelQualityHistory(t.id, start, end).then(res => {
|
||||
if (res.code === 0 && res.data) {
|
||||
return { id: t.id, data: res.data };
|
||||
}
|
||||
return { id: t.id, data: [] };
|
||||
}).catch(() => ({ id: t.id, data: [] }))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
setQualityHistoryMap(prev => {
|
||||
const nextMap = { ...prev };
|
||||
results.forEach(r => {
|
||||
const existing = nextMap[r.id] || [];
|
||||
const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
|
||||
const unique: TunnelQualityApiItem[] = [];
|
||||
for (const item of merged) {
|
||||
if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) {
|
||||
unique.push(item);
|
||||
}
|
||||
}
|
||||
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
|
||||
});
|
||||
return nextMap;
|
||||
});
|
||||
};
|
||||
void fetchHistory();
|
||||
}
|
||||
}, [tunnels]);
|
||||
|
||||
// --- Load quality snapshots (auto-polling every 10s) ---
|
||||
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
@@ -190,6 +304,19 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
map[q.tunnelId] = q;
|
||||
}
|
||||
setQualityMap(map);
|
||||
|
||||
// Accumulate locally
|
||||
setQualityHistoryMap(prev => {
|
||||
const nextMap = { ...prev };
|
||||
for (const q of response.data) {
|
||||
const currentArr = nextMap[q.tunnelId] || [];
|
||||
const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
|
||||
if (!lastItem || lastItem.timestamp !== q.timestamp) {
|
||||
nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT);
|
||||
}
|
||||
}
|
||||
return nextMap;
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore quality load failures
|
||||
@@ -609,9 +736,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
{tunnels.map((tunnel) => {
|
||||
const quality = qualityMap[tunnel.id];
|
||||
const isEnabled = tunnel.status === 1;
|
||||
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
|
||||
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
@@ -637,11 +761,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{overallQuality && (
|
||||
<Chip size="sm" color={overallQuality.color} variant="flat">
|
||||
{overallQuality.text}
|
||||
</Chip>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
|
||||
@@ -651,14 +770,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<Zap className="w-3 h-3" />
|
||||
入口→出口
|
||||
</div>
|
||||
<LatencyDisplay value={quality?.entryToExitLatency} />
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
出口→Bing
|
||||
</div>
|
||||
<LatencyDisplay value={quality?.exitToBingLatency} />
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -681,25 +800,29 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
</div>
|
||||
) : (
|
||||
<Card className="w-full">
|
||||
<Table aria-label="隧道列表">
|
||||
<Table
|
||||
aria-label="隧道列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn>名称</TableColumn>
|
||||
<TableColumn>入口→出口</TableColumn>
|
||||
<TableColumn>出口→Bing</TableColumn>
|
||||
<TableColumn>质量</TableColumn>
|
||||
<TableColumn>更新时间</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="暂无隧道">
|
||||
{tunnels.map((tunnel) => {
|
||||
const quality = qualityMap[tunnel.id];
|
||||
const isEnabled = tunnel.status === 1;
|
||||
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
|
||||
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TableRow key={tunnel.id} className="border-b border-divider/50 last:border-b-0 cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
||||
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isEnabled ? (
|
||||
@@ -713,19 +836,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<LatencyDisplay value={quality?.entryToExitLatency} />
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<LatencyDisplay value={quality?.exitToBingLatency} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{overallQuality ? (
|
||||
<Chip size="sm" color={overallQuality.color} variant="flat">
|
||||
{overallQuality.text}
|
||||
</Chip>
|
||||
) : (
|
||||
<span className="text-xs text-default-400">-</span>
|
||||
)}
|
||||
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{quality?.timestamp ? (
|
||||
|
||||
@@ -45,6 +45,15 @@ import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
|
||||
import { LayoutGrid, List } from "lucide-react";
|
||||
import {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableColumn,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
} from "@/shadcn-bridge/heroui/table";
|
||||
import {
|
||||
createTunnel,
|
||||
batchDeleteTunnelsWithForwards,
|
||||
@@ -181,6 +190,10 @@ export default function TunnelPage() {
|
||||
"",
|
||||
);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
|
||||
"tunnel-view-mode",
|
||||
"grid",
|
||||
);
|
||||
|
||||
// 模态框状态
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
@@ -1523,6 +1536,14 @@ export default function TunnelPage() {
|
||||
>
|
||||
批量
|
||||
</Button>
|
||||
<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
|
||||
color="primary"
|
||||
size="sm"
|
||||
@@ -1556,6 +1577,131 @@ export default function TunnelPage() {
|
||||
|
||||
{/* 隧道卡片网格 */}
|
||||
{tunnels.length > 0 ? (
|
||||
viewMode === "list" ? (
|
||||
<Card>
|
||||
<Table
|
||||
aria-label="隧道列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
{selectMode ? <TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
||||
<Checkbox
|
||||
isSelected={selectedIds.size === sortedTunnels.length && sortedTunnels.length > 0}
|
||||
onValueChange={(checked) => checked ? selectAll() : deselectAll()}
|
||||
/>
|
||||
</TableColumn> : <TableColumn className="w-0 p-0 overflow-hidden text-[0px]"></TableColumn>}
|
||||
<TableColumn>隧道名称</TableColumn>
|
||||
<TableColumn>类型</TableColumn>
|
||||
<TableColumn>拓扑</TableColumn>
|
||||
<TableColumn>流量统计</TableColumn>
|
||||
<TableColumn>操作</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody items={sortedTunnels}>
|
||||
{(tunnel) => {
|
||||
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
|
||||
const tunnelTypeChipClassName =
|
||||
tunnel.type === 1
|
||||
? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
|
||||
: "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
|
||||
|
||||
return (
|
||||
<TableRow key={tunnel.id}>
|
||||
{selectMode ? <TableCell className="px-4">
|
||||
<Checkbox
|
||||
isSelected={selectedIds.has(tunnel.id)}
|
||||
onValueChange={() => toggleSelect(tunnel.id)}
|
||||
/>
|
||||
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||
tunnel.status === 1 ? "bg-success" : "bg-danger"
|
||||
}`}
|
||||
/>
|
||||
<span className="font-medium text-foreground text-sm">
|
||||
{tunnel.name}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
className={tunnelTypeChipClassName}
|
||||
color={typeDisplay.color as any}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
>
|
||||
{typeDisplay.text}
|
||||
</Chip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="font-semibold text-primary-700 dark:text-primary-400">
|
||||
{tunnel.inNodeId?.length || 0}入口
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
|
||||
{tunnel.type === 2 ? tunnel.chainNodes?.length || 0 : 0}跳
|
||||
</span>
|
||||
<span className="text-default-400">→</span>
|
||||
<span className="font-semibold text-success-700 dark:text-success-400">
|
||||
{tunnel.type === 2 ? tunnel.outNodeId?.length || 0 : tunnel.inNodeId?.length || 0}出口
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-default-500">{getTunnelFlowDisplay(tunnel.flow)}</span>
|
||||
<span className="text-default-300">|</span>
|
||||
<span className="text-default-500">{tunnel.trafficRatio}x</span>
|
||||
{tunnel.type === 2 && tunnel.ipPreference && (
|
||||
<>
|
||||
<span className="text-default-300">|</span>
|
||||
<span className="text-default-500">{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleEdit(tunnel)}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDiagnose(tunnel)}
|
||||
>
|
||||
诊断
|
||||
</Button>
|
||||
<Button
|
||||
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => handleDelete(tunnel)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
}}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</Card>
|
||||
) : (
|
||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||
<SortableContext
|
||||
items={sortableTunnelIds}
|
||||
@@ -1836,6 +1982,7 @@ export default function TunnelPage() {
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
)
|
||||
) : (
|
||||
/* 空状态 */
|
||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||
|
||||
@@ -1080,7 +1080,15 @@ export default function UserPage() {
|
||||
</Card>
|
||||
) : viewMode === "list" ? (
|
||||
<Card>
|
||||
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
|
||||
<Table
|
||||
aria-label="用户列表"
|
||||
className="overflow-x-auto min-w-full"
|
||||
classNames={{
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
<TableColumn>用户名</TableColumn>
|
||||
<TableColumn>流量统计</TableColumn>
|
||||
@@ -1888,8 +1896,9 @@ export default function UserPage() {
|
||||
<Table
|
||||
aria-label="用户隧道权限列表"
|
||||
classNames={{
|
||||
wrapper: "shadow-none",
|
||||
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
|
||||
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||
tr: "hover:bg-default-50/50 transition-colors",
|
||||
}}
|
||||
>
|
||||
<TableHeader>
|
||||
|
||||
Reference in New Issue
Block a user