feat: integrate sonner for toast notifications and enhance UI feedback

Added the sonner library for toast notifications across various components, improving user feedback for actions such as saving settings, syncing rules, and handling errors. Updated the layout to include a Toaster component for consistent notification display. Refactored alert messages in the backups, gre, and filters pages to utilize the new notification system, enhancing overall user experience.
This commit is contained in:
Denozordec
2026-05-07 20:49:35 +07:00
parent 84ecd4f061
commit 11ad94f67d
33 changed files with 12350 additions and 252 deletions
+67 -19
View File
@@ -56,12 +56,6 @@ import { Flag } from "@/components/flag"
// ─── Resource metrics (для мини-блока справа; числа детерминированы по id узла) ─
const BOARD_MAP: Record<ServerType, string> = {
"jump-host": "RB5009UG+S+IN",
"exit-node": "RB4011iGS+RM",
"home-router": "hAP ax²",
}
// ─── Backend → frontend (как /servers) ───────────────────────────────────────
interface BackendServerRow {
@@ -707,11 +701,12 @@ function Minimap({ pan, zoom, nodes, greEdges, satPos, wanJhEdges, homeRouters,
stroke={TUNNEL_STYLE[e.tunnel.status].stroke} strokeWidth="5" opacity="0.25" />
))}
{/* WAN edges */}
{wanJhEdges.map((edge, i) => {
{wanJhEdges.map((edge) => {
const sat = satPos[edge.homeId]?.[edge.wanIdx]
const jh = nodes.find(n => n.id === edge.jhId)
if (!sat || !jh) return null
return <line key={i} x1={sat.x} y1={sat.y} x2={jh.x} y2={jh.y}
const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
return <line key={edgeKey} x1={sat.x} y1={sat.y} x2={jh.x} y2={jh.y}
stroke={WAN_COLORS[edge.wanIdx]} strokeWidth="3" opacity="0.25" />
})}
{/* nodes */}
@@ -1333,11 +1328,52 @@ export default function NetworkMapPage() {
const connectedTunnels = selected
? mapGreTunnels.filter((t) => {
if (t.serverId === selected.id) return true
const peer = findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
if (!peer) return false
return t.serverId === selected.id || peer.id === selected.id
if (peer?.id === selected.id) return true
const selectedHost = normalizeGreEndpointAddr(selected.host)
const selectedWanIps = (selected.wanUplinks ?? [])
.map((w) => normalizeGreEndpointAddr(w.ip))
.filter(Boolean)
const remote = normalizeGreEndpointAddr(t.remoteAddress)
const local = normalizeGreEndpointAddr(t.localAddress)
return (
(selectedHost.length > 0 && (remote === selectedHost || local === selectedHost)) ||
selectedWanIps.includes(remote) ||
selectedWanIps.includes(local)
)
})
: []
const grePrimaryByGroupKey = useMemo(() => {
if (!selected) return new Map<string, string>()
const group = new Map<string, GreTunnel[]>()
const score = (t: GreTunnel): number => {
const statusScore = t.status === "up" ? 3 : t.status === "degraded" ? 2 : 1
const enabledScore = t.enabled ? 10 : 0
return enabledScore + statusScore
}
for (const t of connectedTunnels) {
const peer =
t.serverId === selected.id
? findServerByGreRemote(mapServers, t.remoteAddress, greResolvedMap)
: mapServers.find((s) => s.id === t.serverId)
const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress)
const arr = group.get(groupKey) ?? []
arr.push(t)
group.set(groupKey, arr)
}
const primaryByKey = new Map<string, string>()
for (const [groupKey, tunnels] of group.entries()) {
const sorted = [...tunnels].sort((a, b) => {
const d = score(b) - score(a)
if (d !== 0) return d
return a.id.localeCompare(b.id)
})
const first = sorted[0]
if (first) primaryByKey.set(groupKey, first.id)
}
return primaryByKey
}, [connectedTunnels, selected, mapServers, greResolvedMap])
const hoveredNode = hoveredId ? nodes.find(n => n.id === hoveredId) : null
@@ -1619,16 +1655,17 @@ export default function NetworkMapPage() {
})}
{/* ── WAN→JH edges ── */}
{visibleWanJhEdges.map((edge, i) => {
{visibleWanJhEdges.map((edge) => {
const jh = nodeById[edge.jhId]
const satPos = effectiveSatPos[edge.homeId]?.[edge.wanIdx]
if (!jh || !satPos) return null
const edgeKey = `${edge.homeId}-${edge.wanIdx}-${edge.jhId}`
const color = WAN_COLORS[edge.wanIdx] ?? "#888"
const vis = filter === "all" || filter === "home-router" || filter === "jump-host" || filter === "online"
const { mx, my } = edgeBadgePosition(satPos.x, satPos.y, jh.x, jh.y, 0.62, -17)
const isHL = selected?.id === edge.homeId && (selWanIdx === null || selWanIdx === edge.wanIdx)
return (
<g key={`wan-jh-${i}`} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
<g key={edgeKey} opacity={vis ? (isHL ? 1 : 0.45) : 0.05}
style={{ transition: "opacity 0.3s" }}>
<line
x1={satPos.x} y1={satPos.y} x2={jh.x} y2={jh.y}
@@ -1640,7 +1677,7 @@ export default function NetworkMapPage() {
/>
{showAnimDots && edge.active && (
<circle r="3.5" fill={color} opacity="0.85">
<animateMotion dur={`${1.8 + i * 0.3}s`} repeatCount="indefinite"
<animateMotion dur={`${1.8 + (edge.wanIdx % 5) * 0.3}s`} repeatCount="indefinite"
path={`M ${satPos.x} ${satPos.y} L ${jh.x} ${jh.y}`} />
</circle>
)}
@@ -2143,10 +2180,11 @@ export default function NetworkMapPage() {
<p className="text-[9px] uppercase text-muted-foreground tracking-wider mb-1.5">
Подключения к JH
</p>
{myEdges.map((e, ei) => {
{myEdges.map((e) => {
const jh = mapServers.find(s => s.id === e.jhId)
const edgeKey = `${e.homeId}-${e.wanIdx}-${e.jhId}`
return (
<div key={ei} className="flex items-center justify-between py-0.5">
<div key={edgeKey} className="flex items-center justify-between py-0.5">
<span className="text-[10px] font-mono text-muted-foreground truncate">
{jh?.name ?? e.jhId}
</span>
@@ -2186,6 +2224,8 @@ export default function NetworkMapPage() {
? `${fromServer.id}|${toServer.id}|${t.id}|${t.localAddress}|${t.remoteAddress}`
: `${t.serverId}|${t.id}|${t.localAddress}|${t.remoteAddress}`
const baseProbe = greTunnelProbe(t)
const groupKey = peer?.id ?? (normalizeGreEndpointAddr(t.remoteAddress) || t.remoteAddress)
const isPrimary = grePrimaryByGroupKey.get(groupKey) === t.id
const spGre =
fromServer && toServer ? speedProbeByTunnelId.get(tunnelPanelKey) : undefined
const merged = mergeGreMetricsWithSpeedProbe(spGre, baseProbe)
@@ -2197,9 +2237,17 @@ export default function NetworkMapPage() {
<div key={tunnelPanelKey} className="rounded-md border border-border/60 px-3 py-2 bg-muted/20">
<div className="flex items-center justify-between mb-1">
<span className="text-xs font-mono font-medium">{t.name}</span>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
</span>
<div className="flex items-center gap-2">
<span className={cn(
"text-[10px] font-medium px-1.5 py-0.5 rounded-full",
isPrimary ? "bg-emerald-500/10 text-emerald-400" : "bg-muted text-muted-foreground",
)}>
{isPrimary ? "primary" : "backup"}
</span>
<span className="text-[10px] font-medium" style={{ color: ts.stroke }}>
{t.status === "up" ? "Up" : t.status === "degraded" ? "Degraded" : "Down"}
</span>
</div>
</div>
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-muted-foreground font-mono truncate max-w-[200px]">
@@ -2317,7 +2365,7 @@ export default function NetworkMapPage() {
)}
<div className="flex items-center justify-between py-1.5">
<span className="text-[10px] text-muted-foreground">Плата</span>
<span className="text-[10px] font-mono">{BOARD_MAP[selected.type]}</span>
<span className="text-[10px] font-mono">{selected.model || "—"}</span>
</div>
</div>
</div>