mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-08 10:46:37 +08:00
feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355)
This commit is contained in:
@@ -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.
|
||||||
@@ -262,6 +262,7 @@ export default function LimitPage() {
|
|||||||
{/* 统一卡片网格 */}
|
{/* 统一卡片网格 */}
|
||||||
{filteredRules.length > 0 ? (
|
{filteredRules.length > 0 ? (
|
||||||
viewMode === "list" ? (
|
viewMode === "list" ? (
|
||||||
|
<Card>
|
||||||
<Table
|
<Table
|
||||||
aria-label="限速规则列表"
|
aria-label="限速规则列表"
|
||||||
className="overflow-x-auto min-w-full"
|
className="overflow-x-auto min-w-full"
|
||||||
@@ -274,27 +275,32 @@ export default function LimitPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn>规则名称</TableColumn>
|
<TableColumn>规则名称</TableColumn>
|
||||||
<TableColumn>速度限制</TableColumn>
|
<TableColumn>速度限制</TableColumn>
|
||||||
<TableColumn>状态</TableColumn>
|
<TableColumn>操作</TableColumn>
|
||||||
<TableColumn align="center">操作</TableColumn>
|
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody items={filteredRules}>
|
<TableBody items={filteredRules}>
|
||||||
{(rule) => (
|
{(rule) => (
|
||||||
<TableRow key={rule.id}>
|
<TableRow key={rule.id}>
|
||||||
<TableCell className="font-medium text-foreground">{rule.name}</TableCell>
|
|
||||||
<TableCell>{rule.speed} Mbps</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Chip
|
<div className="flex items-center gap-2">
|
||||||
color={rule.status === 1 ? "success" : "danger"}
|
<div
|
||||||
size="sm"
|
className={`shrink-0 w-2 h-2 rounded-full ${
|
||||||
variant="flat"
|
rule.status === 1 ? "bg-success" : "bg-danger"
|
||||||
>
|
}`}
|
||||||
{rule.status === 1 ? "运行" : "异常"}
|
/>
|
||||||
</Chip>
|
<span className="font-medium text-foreground text-sm">
|
||||||
|
{rule.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2 justify-center">
|
<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
|
<Button
|
||||||
color="primary"
|
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"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleEdit(rule)}
|
onPress={() => handleEdit(rule)}
|
||||||
@@ -302,7 +308,7 @@ export default function LimitPage() {
|
|||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="danger"
|
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"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleDelete(rule)}
|
onPress={() => handleDelete(rule)}
|
||||||
@@ -315,6 +321,7 @@ export default function LimitPage() {
|
|||||||
)}
|
)}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</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">
|
<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) => (
|
{filteredRules.map((rule) => (
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
getMonitorTunnelQuality,
|
getMonitorTunnelQuality,
|
||||||
getMonitorTunnelQualityHistory,
|
getMonitorTunnelQualityHistory,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis";
|
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
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) {
|
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
||||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||||
@@ -117,6 +190,8 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
|
|
||||||
// Quality data from backend periodic probing (latest per tunnel)
|
// Quality data from backend periodic probing (latest per tunnel)
|
||||||
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
|
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
|
||||||
|
const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
|
||||||
|
const initialHistoryFetched = useRef(false);
|
||||||
const [qualityLoading, setQualityLoading] = useState(false);
|
const [qualityLoading, setQualityLoading] = useState(false);
|
||||||
const qualityTimerRef = useRef<number | null>(null);
|
const qualityTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
@@ -178,6 +253,45 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [loadTunnels]);
|
}, [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) ---
|
// --- Load quality snapshots (auto-polling every 10s) ---
|
||||||
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
|
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
|
||||||
const silent = options?.silent ?? false;
|
const silent = options?.silent ?? false;
|
||||||
@@ -190,6 +304,19 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
map[q.tunnelId] = q;
|
map[q.tunnelId] = q;
|
||||||
}
|
}
|
||||||
setQualityMap(map);
|
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 {
|
} catch {
|
||||||
// Silently ignore quality load failures
|
// Silently ignore quality load failures
|
||||||
@@ -609,9 +736,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
{tunnels.map((tunnel) => {
|
{tunnels.map((tunnel) => {
|
||||||
const quality = qualityMap[tunnel.id];
|
const quality = qualityMap[tunnel.id];
|
||||||
const isEnabled = tunnel.status === 1;
|
const isEnabled = tunnel.status === 1;
|
||||||
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
|
|
||||||
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -637,11 +761,6 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{overallQuality && (
|
|
||||||
<Chip size="sm" color={overallQuality.color} variant="flat">
|
|
||||||
{overallQuality.text}
|
|
||||||
</Chip>
|
|
||||||
)}
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
|
<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" />
|
<Zap className="w-3 h-3" />
|
||||||
入口→出口
|
入口→出口
|
||||||
</div>
|
</div>
|
||||||
<LatencyDisplay value={quality?.entryToExitLatency} />
|
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||||
<Globe className="w-3 h-3" />
|
<Globe className="w-3 h-3" />
|
||||||
出口→Bing
|
出口→Bing
|
||||||
</div>
|
</div>
|
||||||
<LatencyDisplay value={quality?.exitToBingLatency} />
|
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -695,16 +814,12 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
<TableColumn>名称</TableColumn>
|
<TableColumn>名称</TableColumn>
|
||||||
<TableColumn>入口→出口</TableColumn>
|
<TableColumn>入口→出口</TableColumn>
|
||||||
<TableColumn>出口→Bing</TableColumn>
|
<TableColumn>出口→Bing</TableColumn>
|
||||||
<TableColumn>质量</TableColumn>
|
|
||||||
<TableColumn>更新时间</TableColumn>
|
<TableColumn>更新时间</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody emptyContent="暂无隧道">
|
<TableBody emptyContent="暂无隧道">
|
||||||
{tunnels.map((tunnel) => {
|
{tunnels.map((tunnel) => {
|
||||||
const quality = qualityMap[tunnel.id];
|
const quality = qualityMap[tunnel.id];
|
||||||
const isEnabled = tunnel.status === 1;
|
const isEnabled = tunnel.status === 1;
|
||||||
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
|
|
||||||
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
<TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
|
||||||
@@ -721,19 +836,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
|||||||
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<LatencyDisplay value={quality?.entryToExitLatency} />
|
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<LatencyDisplay value={quality?.exitToBingLatency} />
|
<UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{overallQuality ? (
|
|
||||||
<Chip size="sm" color={overallQuality.color} variant="flat">
|
|
||||||
{overallQuality.text}
|
|
||||||
</Chip>
|
|
||||||
) : (
|
|
||||||
<span className="text-xs text-default-400">-</span>
|
|
||||||
)}
|
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{quality?.timestamp ? (
|
{quality?.timestamp ? (
|
||||||
|
|||||||
@@ -1578,6 +1578,7 @@ export default function TunnelPage() {
|
|||||||
{/* 隧道卡片网格 */}
|
{/* 隧道卡片网格 */}
|
||||||
{tunnels.length > 0 ? (
|
{tunnels.length > 0 ? (
|
||||||
viewMode === "list" ? (
|
viewMode === "list" ? (
|
||||||
|
<Card>
|
||||||
<Table
|
<Table
|
||||||
aria-label="隧道列表"
|
aria-label="隧道列表"
|
||||||
className="overflow-x-auto min-w-full"
|
className="overflow-x-auto min-w-full"
|
||||||
@@ -1598,7 +1599,7 @@ export default function TunnelPage() {
|
|||||||
<TableColumn>类型</TableColumn>
|
<TableColumn>类型</TableColumn>
|
||||||
<TableColumn>拓扑</TableColumn>
|
<TableColumn>拓扑</TableColumn>
|
||||||
<TableColumn>流量统计</TableColumn>
|
<TableColumn>流量统计</TableColumn>
|
||||||
<TableColumn align="center">操作</TableColumn>
|
<TableColumn>操作</TableColumn>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody items={sortedTunnels}>
|
<TableBody items={sortedTunnels}>
|
||||||
{(tunnel) => {
|
{(tunnel) => {
|
||||||
@@ -1616,7 +1617,18 @@ export default function TunnelPage() {
|
|||||||
onValueChange={() => toggleSelect(tunnel.id)}
|
onValueChange={() => toggleSelect(tunnel.id)}
|
||||||
/>
|
/>
|
||||||
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
|
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
|
||||||
<TableCell className="font-medium text-foreground">{tunnel.name}</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>
|
<TableCell>
|
||||||
<Chip
|
<Chip
|
||||||
className={tunnelTypeChipClassName}
|
className={tunnelTypeChipClassName}
|
||||||
@@ -1656,40 +1668,30 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center justify-center gap-1">
|
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
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"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => handleEdit(tunnel)}
|
onPress={() => handleEdit(tunnel)}
|
||||||
color="primary"
|
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
编辑
|
||||||
<path d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
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"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="warning"
|
|
||||||
onPress={() => handleDiagnose(tunnel)}
|
onPress={() => handleDiagnose(tunnel)}
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
诊断
|
||||||
<path clipRule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" fillRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
isIconOnly
|
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"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
color="danger"
|
|
||||||
onPress={() => handleDelete(tunnel)}
|
onPress={() => handleDelete(tunnel)}
|
||||||
>
|
>
|
||||||
<svg aria-hidden="true" className="w-3.5 h-3.5" fill="currentColor" viewBox="0 0 20 20">
|
删除
|
||||||
<path clipRule="evenodd" d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z" fillRule="evenodd" />
|
|
||||||
<path clipRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8 7a1 1 0 012 0v4a1 1 0 11-2 0V7zM12 7a1 1 0 012 0v4a1 1 0 11-2 0V7z" fillRule="evenodd" />
|
|
||||||
</svg>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1698,6 +1700,7 @@ export default function TunnelPage() {
|
|||||||
}}
|
}}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
|
|||||||
Reference in New Issue
Block a user