app proto flow determination improvement
This commit is contained in:
@@ -496,6 +496,10 @@ class DatabasePool:
|
|||||||
stream_kind: str,
|
stream_kind: str,
|
||||||
stream_id: int,
|
stream_id: int,
|
||||||
observed_at_ms: int,
|
observed_at_ms: int,
|
||||||
|
src_ip: str,
|
||||||
|
dst_ip: str,
|
||||||
|
src_port: int,
|
||||||
|
dst_port: int,
|
||||||
enrichment: Dict[str, Any],
|
enrichment: Dict[str, Any],
|
||||||
window_ms: int,
|
window_ms: int,
|
||||||
) -> int:
|
) -> int:
|
||||||
@@ -522,12 +526,26 @@ class DatabasePool:
|
|||||||
AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2)
|
AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2)
|
||||||
AND timestamp BETWEEN $5 AND $6
|
AND timestamp BETWEEN $5 AND $6
|
||||||
AND (
|
AND (
|
||||||
CASE
|
(
|
||||||
WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '')
|
CASE
|
||||||
WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '')
|
WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '')
|
||||||
ELSE ''
|
WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '')
|
||||||
END
|
ELSE ''
|
||||||
) = $4
|
END
|
||||||
|
) = $4
|
||||||
|
OR (
|
||||||
|
src_ip = $7::inet
|
||||||
|
AND dst_ip = $8::inet
|
||||||
|
AND COALESCE(src_port, 0) = $9
|
||||||
|
AND COALESCE(dst_port, 0) = $10
|
||||||
|
)
|
||||||
|
OR (
|
||||||
|
src_ip = $8::inet
|
||||||
|
AND dst_ip = $7::inet
|
||||||
|
AND COALESCE(src_port, 0) = $10
|
||||||
|
AND COALESCE(dst_port, 0) = $9
|
||||||
|
)
|
||||||
|
)
|
||||||
AND (
|
AND (
|
||||||
app_protocol IS NULL
|
app_protocol IS NULL
|
||||||
OR app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
|
OR app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
|
||||||
@@ -546,21 +564,21 @@ class DatabasePool:
|
|||||||
updated_at = NOW(),
|
updated_at = NOW(),
|
||||||
app_protocol = CASE
|
app_protocol = CASE
|
||||||
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
|
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
|
||||||
THEN COALESCE($7, packets.app_protocol)
|
THEN COALESCE($11, packets.app_protocol)
|
||||||
ELSE packets.app_protocol
|
ELSE packets.app_protocol
|
||||||
END,
|
END,
|
||||||
app_category = CASE
|
app_category = CASE
|
||||||
WHEN packets.app_category IS NULL OR packets.app_category IN ('Transport', 'Network', 'Protocol')
|
WHEN packets.app_category IS NULL OR packets.app_category IN ('Transport', 'Network', 'Protocol')
|
||||||
THEN COALESCE($8, packets.app_category)
|
THEN COALESCE($12, packets.app_category)
|
||||||
ELSE packets.app_category
|
ELSE packets.app_category
|
||||||
END,
|
END,
|
||||||
app_confidence = CASE
|
app_confidence = CASE
|
||||||
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
|
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
|
||||||
THEN COALESCE($9, packets.app_confidence)
|
THEN COALESCE($13, packets.app_confidence)
|
||||||
ELSE packets.app_confidence
|
ELSE packets.app_confidence
|
||||||
END,
|
END,
|
||||||
app_hostname = COALESCE(packets.app_hostname, $10),
|
app_hostname = COALESCE(packets.app_hostname, $14),
|
||||||
app_is_encrypted = COALESCE(packets.app_is_encrypted, $11),
|
app_is_encrypted = COALESCE(packets.app_is_encrypted, $15),
|
||||||
flow_id = COALESCE(
|
flow_id = COALESCE(
|
||||||
packets.flow_id,
|
packets.flow_id,
|
||||||
COALESCE(NULLIF(packets.capture_session_id, '') || ':', '') || $3 || ':' || $4
|
COALESCE(NULLIF(packets.capture_session_id, '') || ':', '') || $3 || ':' || $4
|
||||||
@@ -570,7 +588,7 @@ class DatabasePool:
|
|||||||
SELECT DISTINCT source
|
SELECT DISTINCT source
|
||||||
FROM unnest(
|
FROM unnest(
|
||||||
COALESCE(packets.capture_sources, ARRAY[]::text[]) ||
|
COALESCE(packets.capture_sources, ARRAY[]::text[]) ||
|
||||||
COALESCE($12::text[], ARRAY[]::text[])
|
COALESCE($16::text[], ARRAY[]::text[])
|
||||||
) AS source
|
) AS source
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -584,6 +602,10 @@ class DatabasePool:
|
|||||||
str(stream_id),
|
str(stream_id),
|
||||||
lower_bound,
|
lower_bound,
|
||||||
upper_bound,
|
upper_bound,
|
||||||
|
src_ip,
|
||||||
|
dst_ip,
|
||||||
|
src_port,
|
||||||
|
dst_port,
|
||||||
enrichment.get("app_protocol"),
|
enrichment.get("app_protocol"),
|
||||||
enrichment.get("app_category"),
|
enrichment.get("app_category"),
|
||||||
enrichment.get("app_confidence"),
|
enrichment.get("app_confidence"),
|
||||||
|
|||||||
@@ -956,6 +956,10 @@ class TsharkManager:
|
|||||||
stream_kind=stream_kind,
|
stream_kind=stream_kind,
|
||||||
stream_id=stream_id,
|
stream_id=stream_id,
|
||||||
observed_at_ms=int(event["observed_at_ms"]),
|
observed_at_ms=int(event["observed_at_ms"]),
|
||||||
|
src_ip=str(event["src_ip"]),
|
||||||
|
dst_ip=str(event["dst_ip"]),
|
||||||
|
src_port=int(event["src_port"]),
|
||||||
|
dst_port=int(event["dst_port"]),
|
||||||
enrichment=stream_enrichment,
|
enrichment=stream_enrichment,
|
||||||
window_ms=max(settings.tshark_match_window_ms, 10_000),
|
window_ms=max(settings.tshark_match_window_ms, 10_000),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// src/components/Sidebar.tsx
|
// src/components/Sidebar.tsx
|
||||||
import {
|
import {
|
||||||
ApartmentOutlined,
|
ApartmentOutlined,
|
||||||
ClusterOutlined,
|
AreaChartOutlined,
|
||||||
HomeOutlined,
|
HomeOutlined,
|
||||||
InfoCircleOutlined,
|
InfoCircleOutlined,
|
||||||
MonitorOutlined,
|
MonitorOutlined,
|
||||||
@@ -20,14 +20,14 @@ const { Sider } = Layout;
|
|||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' },
|
{ key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' },
|
||||||
{ key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' },
|
{ key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' },
|
||||||
{ key: PATHS.ANALYSIS, icon: <ClusterOutlined style={{ fontSize: '18px' }} />, label: 'Analysis' },
|
{ key: PATHS.FIREWALL, icon: <FirewallIcon style={{ fontSize: '18px' }} />, label: 'Firewall' },
|
||||||
{ key: PATHS.SNIFFING, icon: <MonitorOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' },
|
{ key: PATHS.SNIFFING, icon: <MonitorOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' },
|
||||||
{
|
{
|
||||||
key: PATHS.SCRIPTING,
|
key: PATHS.SCRIPTING,
|
||||||
icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />,
|
icon: <TerminalIcon style={{ fontSize: '18px' }} width={18} height={18} />,
|
||||||
label: 'Scripting',
|
label: 'Scripting',
|
||||||
},
|
},
|
||||||
{ key: PATHS.FIREWALL, icon: <FirewallIcon style={{ fontSize: '18px' }} />, label: 'Firewall' },
|
{ key: PATHS.ANALYSIS, icon: <AreaChartOutlined style={{ fontSize: '18px' }} />, label: 'Analysis' },
|
||||||
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
|
{ key: '/about', icon: <InfoCircleOutlined style={{ fontSize: '18px' }} />, label: 'About' },
|
||||||
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
|
{ key: '/settings', icon: <SettingOutlined style={{ fontSize: '18px' }} />, label: 'Settings' },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ type TopologyLink = {
|
|||||||
source: string;
|
source: string;
|
||||||
target: string;
|
target: string;
|
||||||
value: number;
|
value: number;
|
||||||
|
packetCount: number;
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -124,6 +125,14 @@ function protocolColor(protocol: string) {
|
|||||||
return palette[hash % palette.length];
|
return palette[hash % palette.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number) {
|
||||||
|
return Math.min(max, Math.max(min, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function sankeyVisualWeight(packetCount: number) {
|
||||||
|
return Math.max(1, Math.sqrt(Math.max(0, packetCount)));
|
||||||
|
}
|
||||||
|
|
||||||
function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData {
|
function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData {
|
||||||
const nodes = new Map<string, TopologyNode>();
|
const nodes = new Map<string, TopologyNode>();
|
||||||
const links = new Map<string, TopologyLink>();
|
const links = new Map<string, TopologyLink>();
|
||||||
@@ -157,7 +166,8 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD
|
|||||||
links.set(interfaceHostLinkId, {
|
links.set(interfaceHostLinkId, {
|
||||||
source: interfaceNodeId,
|
source: interfaceNodeId,
|
||||||
target: hostId,
|
target: hostId,
|
||||||
value: host.packet_count,
|
value: sankeyVisualWeight(host.packet_count),
|
||||||
|
packetCount: host.packet_count,
|
||||||
label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`,
|
label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -187,7 +197,8 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD
|
|||||||
links.set(hostProtocolLinkId, {
|
links.set(hostProtocolLinkId, {
|
||||||
source: hostId,
|
source: hostId,
|
||||||
target: protocolId,
|
target: protocolId,
|
||||||
value: protocol.packet_count,
|
value: sankeyVisualWeight(protocol.packet_count),
|
||||||
|
packetCount: protocol.packet_count,
|
||||||
label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_count})`,
|
label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_count})`,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -229,7 +240,13 @@ function SankeyTopology({ data }: { data: TopologyData }) {
|
|||||||
if (!svgRef.current) return;
|
if (!svgRef.current) return;
|
||||||
|
|
||||||
const width = 1160;
|
const width = 1160;
|
||||||
const height = Math.max(420, data.nodes.length * 26);
|
const nodesPerLayer = {
|
||||||
|
interface: data.nodes.filter((node) => node.kind === 'interface').length,
|
||||||
|
host: data.nodes.filter((node) => node.kind === 'host').length,
|
||||||
|
protocol: data.nodes.filter((node) => node.kind === 'protocol').length,
|
||||||
|
};
|
||||||
|
const maxNodesInLayer = Math.max(nodesPerLayer.interface, nodesPerLayer.host, nodesPerLayer.protocol, 1);
|
||||||
|
const height = clamp(maxNodesInLayer * 56 + 120, 260, 760);
|
||||||
const svg = d3.select(svgRef.current);
|
const svg = d3.select(svgRef.current);
|
||||||
svg.selectAll('*').remove();
|
svg.selectAll('*').remove();
|
||||||
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
@@ -246,7 +263,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
|
|||||||
const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>()
|
const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>()
|
||||||
.nodeId((node) => node.id)
|
.nodeId((node) => node.id)
|
||||||
.nodeWidth(18)
|
.nodeWidth(18)
|
||||||
.nodePadding(16)
|
.nodePadding(maxNodesInLayer <= 4 ? 28 : maxNodesInLayer <= 8 ? 22 : 16)
|
||||||
.extent([
|
.extent([
|
||||||
[24, 28],
|
[24, 28],
|
||||||
[width - 24, height - 28],
|
[width - 24, height - 28],
|
||||||
@@ -254,22 +271,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
|
|||||||
|
|
||||||
const layout = sankeyLayout(graph);
|
const layout = sankeyLayout(graph);
|
||||||
|
|
||||||
const defs = svg.append('defs');
|
const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.4);
|
||||||
defs
|
|
||||||
.append('marker')
|
|
||||||
.attr('id', 'sankey-arrow')
|
|
||||||
.attr('markerWidth', 10)
|
|
||||||
.attr('markerHeight', 10)
|
|
||||||
.attr('refX', 8)
|
|
||||||
.attr('refY', 5)
|
|
||||||
.attr('orient', 'auto')
|
|
||||||
.append('path')
|
|
||||||
.attr('d', 'M0,0 L10,5 L0,10 z')
|
|
||||||
.attr('fill', '#8394a7');
|
|
||||||
|
|
||||||
svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#f8fbff');
|
|
||||||
|
|
||||||
const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.35);
|
|
||||||
linkLayer
|
linkLayer
|
||||||
.selectAll('path')
|
.selectAll('path')
|
||||||
.data(layout.links as SankeyLinkDatum[])
|
.data(layout.links as SankeyLinkDatum[])
|
||||||
@@ -280,12 +282,11 @@ function SankeyTopology({ data }: { data: TopologyData }) {
|
|||||||
if (target.kind === 'protocol' && target.protocol) {
|
if (target.kind === 'protocol' && target.protocol) {
|
||||||
return protocolColor(target.protocol);
|
return protocolColor(target.protocol);
|
||||||
}
|
}
|
||||||
return '#7f8ea3';
|
return '#9aa7b5';
|
||||||
})
|
})
|
||||||
.attr('stroke-width', (link) => Math.max(1, link.width || 1))
|
.attr('stroke-width', (link) => Math.max(1, link.width || 1))
|
||||||
.attr('marker-end', 'url(#sankey-arrow)')
|
|
||||||
.append('title')
|
.append('title')
|
||||||
.text((link) => `${link.label}\nPackets: ${link.value}`);
|
.text((link) => `${link.label}\nPackets: ${link.packetCount}`);
|
||||||
|
|
||||||
const nodeLayer = svg.append('g');
|
const nodeLayer = svg.append('g');
|
||||||
const node = nodeLayer
|
const node = nodeLayer
|
||||||
@@ -299,7 +300,6 @@ function SankeyTopology({ data }: { data: TopologyData }) {
|
|||||||
.attr('y', (d) => d.y0 ?? 0)
|
.attr('y', (d) => d.y0 ?? 0)
|
||||||
.attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
|
.attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
|
||||||
.attr('height', (d) => Math.max(8, (d.y1 ?? 0) - (d.y0 ?? 0)))
|
.attr('height', (d) => Math.max(8, (d.y1 ?? 0) - (d.y0 ?? 0)))
|
||||||
.attr('rx', 8)
|
|
||||||
.attr('fill', (d) => {
|
.attr('fill', (d) => {
|
||||||
if (d.kind === 'interface') return '#20405d';
|
if (d.kind === 'interface') return '#20405d';
|
||||||
if (d.kind === 'host') return '#d7e7f5';
|
if (d.kind === 'host') return '#d7e7f5';
|
||||||
@@ -307,7 +307,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
|
|||||||
})
|
})
|
||||||
.attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none'))
|
.attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none'))
|
||||||
.append('title')
|
.append('title')
|
||||||
.text((d) => `${d.label}\nPackets: ${d.value ?? d.packetCount}`);
|
.text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
||||||
|
|
||||||
node
|
node
|
||||||
.append('text')
|
.append('text')
|
||||||
@@ -338,8 +338,8 @@ function ForceTopology({ data }: { data: TopologyData }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!svgRef.current) return;
|
if (!svgRef.current) return;
|
||||||
const width = 1160;
|
const width = 1320;
|
||||||
const height = 720;
|
const height = 860;
|
||||||
const svg = d3.select(svgRef.current);
|
const svg = d3.select(svgRef.current);
|
||||||
svg.selectAll('*').remove();
|
svg.selectAll('*').remove();
|
||||||
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
||||||
@@ -350,6 +350,32 @@ function ForceTopology({ data }: { data: TopologyData }) {
|
|||||||
|
|
||||||
const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node }));
|
const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node }));
|
||||||
const links: ForceLink[] = data.links.map((link) => ({ ...link }));
|
const links: ForceLink[] = data.links.map((link) => ({ ...link }));
|
||||||
|
const groupedNodes = {
|
||||||
|
interface: nodes.filter((node) => node.kind === 'interface').sort((left, right) => left.label.localeCompare(right.label)),
|
||||||
|
host: nodes.filter((node) => node.kind === 'host').sort((left, right) => (left.ipAddress ?? left.macAddress ?? left.label).localeCompare(right.ipAddress ?? right.macAddress ?? right.label)),
|
||||||
|
protocol: nodes.filter((node) => node.kind === 'protocol').sort((left, right) => left.label.localeCompare(right.label)),
|
||||||
|
};
|
||||||
|
|
||||||
|
const distributedY = (group: ForceNode[], top: number, bottom: number) => {
|
||||||
|
const targets = new Map<string, number>();
|
||||||
|
if (group.length === 0) {
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
if (group.length === 1) {
|
||||||
|
targets.set(group[0].id, (top + bottom) / 2);
|
||||||
|
return targets;
|
||||||
|
}
|
||||||
|
const step = (bottom - top) / (group.length - 1);
|
||||||
|
group.forEach((node, index) => {
|
||||||
|
targets.set(node.id, top + step * index);
|
||||||
|
});
|
||||||
|
return targets;
|
||||||
|
};
|
||||||
|
|
||||||
|
const interfaceY = distributedY(groupedNodes.interface, 120, height - 120);
|
||||||
|
const hostY = distributedY(groupedNodes.host, 90, height - 90);
|
||||||
|
const protocolY = distributedY(groupedNodes.protocol, 120, height - 120);
|
||||||
|
const targetY = (node: ForceNode) => interfaceY.get(node.id) ?? hostY.get(node.id) ?? protocolY.get(node.id) ?? height / 2;
|
||||||
|
|
||||||
const simulation = d3
|
const simulation = d3
|
||||||
.forceSimulation<ForceNode>(nodes)
|
.forceSimulation<ForceNode>(nodes)
|
||||||
@@ -360,20 +386,29 @@ function ForceTopology({ data }: { data: TopologyData }) {
|
|||||||
.id((node) => node.id)
|
.id((node) => node.id)
|
||||||
.distance((link) => {
|
.distance((link) => {
|
||||||
const source = link.source as ForceNode;
|
const source = link.source as ForceNode;
|
||||||
return source.kind === 'interface' ? 140 : 120;
|
return source.kind === 'interface' ? 250 : 200;
|
||||||
|
})
|
||||||
|
.strength((link) => {
|
||||||
|
const source = link.source as ForceNode;
|
||||||
|
return source.kind === 'interface' ? 0.45 : 0.35;
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.force('charge', d3.forceManyBody().strength(-280))
|
.force('charge', d3.forceManyBody().strength(-720))
|
||||||
.force('collision', d3.forceCollide<ForceNode>().radius((node) => (node.kind === 'host' ? 38 : 28)))
|
.force('collision', d3.forceCollide<ForceNode>().radius((node) => {
|
||||||
|
if (node.kind === 'interface') return 52;
|
||||||
|
if (node.kind === 'host') return 44;
|
||||||
|
return 34;
|
||||||
|
}))
|
||||||
.force(
|
.force(
|
||||||
'x',
|
'x',
|
||||||
d3.forceX<ForceNode>().x((node) => {
|
d3.forceX<ForceNode>().x((node) => {
|
||||||
if (node.kind === 'interface') return 150;
|
if (node.kind === 'interface') return 180;
|
||||||
if (node.kind === 'host') return width / 2;
|
if (node.kind === 'host') return width / 2;
|
||||||
return width - 180;
|
return width - 200;
|
||||||
}).strength(0.32),
|
}).strength(0.42),
|
||||||
)
|
)
|
||||||
.force('y', d3.forceY<ForceNode>(height / 2).strength(0.08));
|
.force('y', d3.forceY<ForceNode>().y((node) => targetY(node)).strength(0.22))
|
||||||
|
.force('center', d3.forceCenter(width / 2, height / 2).strength(0.06));
|
||||||
|
|
||||||
svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe');
|
svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe');
|
||||||
|
|
||||||
@@ -445,7 +480,7 @@ function ForceTopology({ data }: { data: TopologyData }) {
|
|||||||
return <Empty description="No graph data available yet" />;
|
return <Empty description="No graph data available yet" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return <svg ref={svgRef} style={{ width: '100%', minWidth: 960 }} />;
|
return <svg ref={svgRef} style={{ width: '100%', minWidth: 1120 }} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProtocolHeatmap({ data }: { data: TopologyData }) {
|
function ProtocolHeatmap({ data }: { data: TopologyData }) {
|
||||||
|
|||||||
Reference in New Issue
Block a user