feat: Implement server connections API and enhance GraphView with custom edge styling for improved visualization and user interaction in the topology graph.
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m23s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m23s
This commit is contained in:
@@ -305,6 +305,20 @@ const serversRoutes = createJsonDataRoutes('servers.json', (server, i) => {
|
|||||||
app.get('/api/servers', serversRoutes.get);
|
app.get('/api/servers', serversRoutes.get);
|
||||||
app.post('/api/servers', serversRoutes.post);
|
app.post('/api/servers', serversRoutes.post);
|
||||||
|
|
||||||
|
// Server connections for topology graph
|
||||||
|
const serverConnectionsRoutes = createJsonDataRoutes('server-connections.json', (conn, i) => {
|
||||||
|
if (!conn.from || !conn.to || !conn.tunnelType) {
|
||||||
|
return `Connection at index ${i} is missing required fields: from, to, tunnelType`;
|
||||||
|
}
|
||||||
|
if (conn.from === conn.to) {
|
||||||
|
return `Connection at index ${i} has identical from/to values`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/server-connections', serverConnectionsRoutes.get);
|
||||||
|
app.post('/api/server-connections', serverConnectionsRoutes.post);
|
||||||
|
|
||||||
// Billing
|
// Billing
|
||||||
const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) => {
|
const billingRoutes = createJsonDataRoutes('servers-billing.json', (item, i) => {
|
||||||
if (!item.hostName || !item.country || !item.provider) {
|
if (!item.hostName || !item.country || !item.provider) {
|
||||||
|
|||||||
@@ -521,3 +521,39 @@ button:focus-visible,
|
|||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== GraphView / ReactFlow custom styling ===== */
|
||||||
|
|
||||||
|
/* Плавные, аккуратные линии графа */
|
||||||
|
.react-flow__edge-path {
|
||||||
|
stroke-linecap: round;
|
||||||
|
transition: stroke 0.15s ease, stroke-width 0.15s ease, filter 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Лёгкий ховер по связи, без агрессивной анимации */
|
||||||
|
.react-flow__edge:hover .react-flow__edge-path {
|
||||||
|
stroke-width: 3.2px;
|
||||||
|
filter: drop-shadow(0 0 4px rgba(15, 23, 42, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Базовая прозрачность для всех связей, чтобы граф не выглядел «забитым» */
|
||||||
|
.react-flow__edge .react-flow__edge-path {
|
||||||
|
stroke-opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Небольшие отличия по типу туннеля (на будущее, сейчас основное зашито в JS) */
|
||||||
|
.react-flow__edge.edge-tunnel-gre .react-flow__edge-path {
|
||||||
|
/* GRE — базовый синий, уже задан в JS, здесь только подчёркиваем плавность */
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge.edge-tunnel-ipsec .react-flow__edge-path {
|
||||||
|
stroke-dasharray: 6 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge.edge-tunnel-wireguard .react-flow__edge-path {
|
||||||
|
stroke-width: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-flow__edge.edge-tunnel-openvpn .react-flow__edge-path {
|
||||||
|
stroke-dasharray: 3 3;
|
||||||
|
}
|
||||||
+53
-43
@@ -77,6 +77,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
source: String(c.from),
|
source: String(c.from),
|
||||||
target: String(c.to),
|
target: String(c.to),
|
||||||
label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel,
|
label: ipLabel ? `${baseLabel} · ${ipLabel}` : baseLabel,
|
||||||
|
type: 'smoothstep',
|
||||||
style: {
|
style: {
|
||||||
stroke: style.color,
|
stroke: style.color,
|
||||||
strokeWidth: style.width,
|
strokeWidth: style.width,
|
||||||
@@ -89,7 +90,9 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
fill: '#0f172a',
|
fill: '#0f172a',
|
||||||
},
|
},
|
||||||
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 20, height: 20 },
|
className: c.tunnelType ? `edge-tunnel-${String(c.tunnelType).toLowerCase()}` : 'edge-tunnel-default',
|
||||||
|
markerStart: { type: MarkerType.ArrowClosed, color: style.color, width: 16, height: 16 },
|
||||||
|
markerEnd: { type: MarkerType.ArrowClosed, color: style.color, width: 16, height: 16 },
|
||||||
animated: false,
|
animated: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -251,8 +254,7 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
nodeTypes={nodeTypes}
|
nodeTypes={nodeTypes}
|
||||||
fitView
|
fitView
|
||||||
defaultEdgeOptions={{
|
defaultEdgeOptions={{
|
||||||
type: 'default',
|
type: 'smoothstep',
|
||||||
markerEnd: { type: MarkerType.ArrowClosed },
|
|
||||||
}}
|
}}
|
||||||
onInit={onInit}
|
onInit={onInit}
|
||||||
style={{ width: '100%', height: '100%', background: '#f8fafc' }}
|
style={{ width: '100%', height: '100%', background: '#f8fafc' }}
|
||||||
@@ -267,46 +269,54 @@ function GraphView({ servers, connections, onCreateConnection }) {
|
|||||||
{/* Своя панель управления: -, +, Fit, Fullscreen */}
|
{/* Своя панель управления: -, +, Fit, Fullscreen */}
|
||||||
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 10, pointerEvents: 'auto' }}>
|
<div className="position-absolute" style={{ right: 10, top: 10, zIndex: 10, pointerEvents: 'auto' }}>
|
||||||
<div className="btn-group btn-group-sm">
|
<div className="btn-group btn-group-sm">
|
||||||
<button
|
<Tooltip content="Уменьшить масштаб" position="left">
|
||||||
type="button"
|
<button
|
||||||
className="btn btn-outline-secondary"
|
type="button"
|
||||||
onClick={() => instanceRef.current?.zoomOut?.()}
|
className="btn btn-outline-secondary btn-icon"
|
||||||
title="Уменьшить"
|
onClick={() => instanceRef.current?.zoomOut?.()}
|
||||||
>
|
aria-label="Уменьшить масштаб"
|
||||||
<IconZoomOut size={16} />
|
>
|
||||||
</button>
|
<IconZoomOut size={16} />
|
||||||
<button
|
</button>
|
||||||
type="button"
|
</Tooltip>
|
||||||
className="btn btn-outline-secondary"
|
<Tooltip content="Увеличить масштаб" position="left">
|
||||||
onClick={() => instanceRef.current?.zoomIn?.()}
|
<button
|
||||||
title="Увеличить"
|
type="button"
|
||||||
>
|
className="btn btn-outline-secondary btn-icon"
|
||||||
<IconZoomIn size={16} />
|
onClick={() => instanceRef.current?.zoomIn?.()}
|
||||||
</button>
|
aria-label="Увеличить масштаб"
|
||||||
<button
|
>
|
||||||
type="button"
|
<IconZoomIn size={16} />
|
||||||
className="btn btn-outline-secondary"
|
</button>
|
||||||
onClick={() => instanceRef.current?.fitView?.({ padding: 0.2 })}
|
</Tooltip>
|
||||||
title="Подогнать к окну"
|
<Tooltip content="Подогнать граф к окну" position="left">
|
||||||
>
|
<button
|
||||||
Fit
|
type="button"
|
||||||
</button>
|
className="btn btn-outline-secondary"
|
||||||
<button
|
onClick={() => instanceRef.current?.fitView?.({ padding: 0.2 })}
|
||||||
type="button"
|
aria-label="Подогнать граф к окну"
|
||||||
className="btn btn-outline-secondary"
|
>
|
||||||
onClick={() => {
|
Fit
|
||||||
const el = containerRef.current;
|
</button>
|
||||||
if (!el) return;
|
</Tooltip>
|
||||||
if (!document.fullscreenElement) {
|
<Tooltip content={isFullscreen ? 'Выйти из полноэкранного режима' : 'Во весь экран'} position="left">
|
||||||
el.requestFullscreen?.();
|
<button
|
||||||
} else {
|
type="button"
|
||||||
document.exitFullscreen?.();
|
className="btn btn-outline-secondary btn-icon"
|
||||||
}
|
onClick={() => {
|
||||||
}}
|
const el = containerRef.current;
|
||||||
title="Во весь экран"
|
if (!el) return;
|
||||||
>
|
if (!document.fullscreenElement) {
|
||||||
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
|
el.requestFullscreen?.();
|
||||||
</button>
|
} else {
|
||||||
|
document.exitFullscreen?.();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label={isFullscreen ? 'Выйти из полноэкранного режима' : 'Во весь экран'}
|
||||||
|
>
|
||||||
|
{isFullscreen ? <IconMinimize size={16} /> : <IconMaximize size={16} />}
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,10 +90,7 @@ function ServerManager() {
|
|||||||
const [addServerModalOpen, setAddServerModalOpen] = useState(false);
|
const [addServerModalOpen, setAddServerModalOpen] = useState(false);
|
||||||
const [customProvider, setCustomProvider] = useState('');
|
const [customProvider, setCustomProvider] = useState('');
|
||||||
|
|
||||||
const [connections, setConnections] = useState([
|
const [connections, setConnections] = useState([]);
|
||||||
// Пример связи для теста
|
|
||||||
// { from: '192.168.1.1', to: '192.168.1.2', tunnelType: 'GRE', ipA: '10.10.100.1', ipB: '10.10.100.2' }
|
|
||||||
]);
|
|
||||||
const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
const [newConnection, setNewConnection] = useState({ from: '', to: '', tunnelType: 'GRE', ipA: '', ipB: '' });
|
||||||
const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
|
const [graphConnectionModalOpen, setGraphConnectionModalOpen] = useState(false);
|
||||||
|
|
||||||
@@ -105,6 +102,7 @@ function ServerManager() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchServers();
|
fetchServers();
|
||||||
|
fetchConnections();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Загружаем настройки URL из localStorage
|
// Загружаем настройки URL из localStorage
|
||||||
@@ -134,6 +132,20 @@ function ServerManager() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fetchConnections = async () => {
|
||||||
|
try {
|
||||||
|
const response = await api.get(`/server-connections`);
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
setConnections(response.data);
|
||||||
|
} else {
|
||||||
|
setConnections([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching server connections:', error);
|
||||||
|
// Не перекрываем возможную ошибку по серверам, просто логируем
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleAddServer = () => {
|
const handleAddServer = () => {
|
||||||
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim()) {
|
if (!newServer.ip.trim() || !newServer.dns.trim() || !newServer.country.trim() || !newServer.provider.trim() || !newServer.tunnel.trim()) {
|
||||||
setError('Все обязательные поля должны быть заполнены.');
|
setError('Все обязательные поля должны быть заполнены.');
|
||||||
@@ -374,11 +386,12 @@ function ServerManager() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await api.post(`/servers`, { domains: servers });
|
await api.post(`/servers`, { domains: servers });
|
||||||
setSuccess('Изменения успешно сохранены!');
|
await api.post(`/server-connections`, { domains: connections });
|
||||||
|
setSuccess('Изменения по серверам и связям успешно сохранены!');
|
||||||
setTimeout(() => setSuccess(''), 3000);
|
setTimeout(() => setSuccess(''), 3000);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving changes:', error);
|
console.error('Error saving changes:', error);
|
||||||
setError('Не удалось сохранить изменения.');
|
setError('Не удалось сохранить изменения по серверам или связям.');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user