Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`actions/checkout@v7`, and asserts offline dual-stack DrawIO parenting.

### Fixed
- **`Export-NetBox` IPv6 and unknown reachability.** IPv6 identity addresses
go in `primary_ip6` (v6-only leaves `primary_ip4` empty). `Reachable=$null`
no longer maps to `offline`; status is left empty (NetBox has no unknown).
- **`Export-DrawIO` L2-FDB edge style.** Bridge forwarding-table edges (`Confidence='L2-FDB'`)
were falling through to the gray dotted “unknown” default. They now use a dedicated
solid teal stroke (between verified L2-SNMP green and dashed amber heuristic).
Expand Down
23 changes: 18 additions & 5 deletions NetDiagram-PS/Public/Export-NetBox.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ function Export-NetBox {
.SYNOPSIS
Exports topology to NetBox bulk-import CSV
.DESCRIPTION
Writes devices CSV for NetBox with columns name,role,manufacturer,device_type,site,status,primary_ip4.
Writes devices CSV for NetBox with columns name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6.
Role mapping: router/core-router->router, switch/distribution->switch, server->server, workstation->workstation.
Vendor Unknown -> empty manufacturer. Reachable true->active else offline.
Vendor Unknown -> empty manufacturer. Reachable true->active, false->offline; $null leaves status empty
(NetBox has no unknown). Each node has one identity IP: IPv4 -> primary_ip4, IPv6 -> primary_ip6.
.PARAMETER Topology
Topology object with Nodes
.PARAMETER OutFile
Expand Down Expand Up @@ -58,20 +59,32 @@ function Export-NetBox {
$mappedRole = if ($roleMap.ContainsKey($roleKey)) { $roleMap[$roleKey] } else { 'unknown' }
$manufacturer = if ($node.PSObject.Properties['Vendor'] -and $node.Vendor -and $node.Vendor -ne 'Unknown') { [string]$node.Vendor } else { '' }
$deviceType = if ($node.PSObject.Properties['OS'] -and $node.OS -and $node.OS -ne 'Unknown') { [string]$node.OS } else { 'unknown' }
$status = if ($node.Reachable -eq $true) { 'active' } else { 'offline' }
$status = if ($node.Reachable -eq $true) { 'active' } elseif ($node.Reachable -eq $false) { 'offline' } else { '' }
$primaryIp4 = ''
$primaryIp6 = ''
$parsedIp = $null
if ([System.Net.IPAddress]::TryParse([string]$node.IP, [ref]$parsedIp)) {
if ($parsedIp.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetworkV6) {
$primaryIp6 = [string]$node.IP
}
elseif ($parsedIp.AddressFamily -eq [System.Net.Sockets.AddressFamily]::InterNetwork) {
$primaryIp4 = [string]$node.IP
}
}
[pscustomobject]@{
name = [string]$node.Hostname
role = $mappedRole
manufacturer = $manufacturer
device_type = $deviceType
site = $Site
status = $status
primary_ip4 = [string]$node.IP
primary_ip4 = $primaryIp4
primary_ip6 = $primaryIp6
}
}

if ($null -eq $rows -or @($rows).Count -eq 0) {
'name,role,manufacturer,device_type,site,status,primary_ip4' | Out-File -FilePath $OutFile -Encoding utf8 -Force
'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6' | Out-File -FilePath $OutFile -Encoding utf8 -Force
}
else {
$rows | Export-Csv -LiteralPath $OutFile -NoTypeInformation -Encoding utf8 -Force
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -413,7 +413,9 @@ Check the `examples/` directory:
| `workstation` | `workstation` |
| *(other)* | `unknown` |

Reachable `true` → `active`, else `offline`; Vendor `Unknown` → empty `manufacturer`.
Reachable `true` → `active`, `false` → `offline`, `$null` → empty `status`.
IPv4 identity → `primary_ip4`; IPv6 identity → `primary_ip6` (the other column empty).
Vendor `Unknown` → empty `manufacturer`.

---

Expand Down
75 changes: 73 additions & 2 deletions tests/NetDiagram.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -2110,15 +2110,15 @@ Describe 'Export-NetBox (#38 regression)' {
$path = Join-Path $script:TestDataPath 'netbox.csv'
$topo | Export-NetBox -OutFile $path -Site 'HQ' -Force
$header = (Get-Content $path -TotalCount 1) -replace '"',''
$header | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4'
$header | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6'
$rows = Import-Csv $path
$rows.Count | Should -Be 6
($rows | Where-Object name -eq 'r1').role | Should -Be 'router'
($rows | Where-Object name -eq 's1').manufacturer | Should -Be ''
($rows | Where-Object name -eq 'srv,1').name | Should -Be 'srv,1'
($rows | Where-Object name -eq 'r1').status | Should -Be 'active'
($rows | Where-Object name -eq 's1').status | Should -Be 'offline'
($rows | Where-Object name -eq 'srv,1').status | Should -Be 'offline'
($rows | Where-Object name -eq 'srv,1').status | Should -Be ''
($rows | Where-Object name -eq 'd1').role | Should -Be 'switch'
($rows | Where-Object primary_ip4 -eq '10.0.0.4').role | Should -Be 'workstation'
}
Expand All @@ -2128,13 +2128,84 @@ Describe 'Export-NetBox (#38 regression)' {
$path = Join-Path $script:TestDataPath 'netbox-empty.csv'
$topo | Export-NetBox -OutFile $path -Site 'HQ' -Force
(Get-Content $path | Measure-Object).Count | Should -Be 1
((Get-Content $path -TotalCount 1) -replace '"','') | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6'
{ $topo | Export-NetBox -OutFile $path -Site 'HQ' } | Should -Throw "*already exists*Use -Force*"
$whatIf = Join-Path $script:TestDataPath 'netbox-whatif.csv'
$topo | Export-NetBox -OutFile $whatIf -Site 'HQ' -WhatIf
Test-Path $whatIf | Should -Be $false
}
}

Describe 'Export-NetBox (#72 IPv6 columns and unknown reachability)' {
It 'Puts an IPv6-only node in primary_ip6 and leaves primary_ip4 empty' {
$topo = [pscustomobject]@{
Nodes = @(
[pscustomobject]@{ IP='2001:db8::1'; Hostname='v6-router'; Role='router'; Vendor='Cisco'; OS='IOS'; Layer='Core'; Reachable=$true }
)
Edges=@(); Subnets=@()
}
$path = Join-Path $script:TestDataPath 'netbox-v6-only.csv'
$topo | Export-NetBox -OutFile $path -Site 'Lab' -Force
$row = Import-Csv $path
$row.primary_ip6 | Should -Be '2001:db8::1'
$row.primary_ip4 | Should -Be ''
$row.status | Should -Be 'active'
}

It 'Leaves IPv4-only nodes in primary_ip4 with empty primary_ip6' {
$topo = [pscustomobject]@{
Nodes = @(
[pscustomobject]@{ IP='192.0.2.10'; Hostname='v4-server'; Role='server'; Vendor='Dell'; OS='Ubuntu'; Layer='Servers'; Reachable=$false }
)
Edges=@(); Subnets=@()
}
$path = Join-Path $script:TestDataPath 'netbox-v4-only.csv'
$topo | Export-NetBox -OutFile $path -Site 'Lab' -Force
$row = Import-Csv $path
$row.primary_ip4 | Should -Be '192.0.2.10'
$row.primary_ip6 | Should -Be ''
$row.status | Should -Be 'offline'
}

It 'Maps a dual-stack fixture by address family (one identity IP per node)' {
$topo = [pscustomobject]@{
Nodes = @(
[pscustomobject]@{ IP='192.0.2.20'; Hostname='dual-v4'; Role='router'; Vendor='Cisco'; OS='IOS'; Layer='Core'; Reachable=$true }
[pscustomobject]@{ IP='2001:db8::20'; Hostname='dual-v6'; Role='router'; Vendor='Cisco'; OS='IOS'; Layer='Core'; Reachable=$true }
)
Edges=@(); Subnets=@()
}
$path = Join-Path $script:TestDataPath 'netbox-dual-stack.csv'
$topo | Export-NetBox -OutFile $path -Site 'Lab' -Force
$header = (Get-Content $path -TotalCount 1) -replace '"',''
$header | Should -Be 'name,role,manufacturer,device_type,site,status,primary_ip4,primary_ip6'
$rows = Import-Csv $path
$v4 = $rows | Where-Object name -eq 'dual-v4'
$v6 = $rows | Where-Object name -eq 'dual-v6'
$v4.primary_ip4 | Should -Be '192.0.2.20'
$v4.primary_ip6 | Should -Be ''
$v6.primary_ip4 | Should -Be ''
$v6.primary_ip6 | Should -Be '2001:db8::20'
}

It 'Does not export Reachable=$null as offline' {
$topo = [pscustomobject]@{
Nodes = @(
[pscustomobject]@{ IP='192.0.2.30'; Hostname='untested-v4'; Role='switch'; Vendor='X'; OS='X'; Layer='Access'; Reachable=$null }
[pscustomobject]@{ IP='2001:db8::30'; Hostname='untested-v6'; Role='switch'; Vendor='X'; OS='X'; Layer='Access'; Reachable=$null }
)
Edges=@(); Subnets=@()
}
$path = Join-Path $script:TestDataPath 'netbox-null-reachable.csv'
$topo | Export-NetBox -OutFile $path -Site 'Lab' -Force
$rows = Import-Csv $path
foreach ($row in $rows) {
$row.status | Should -Be ''
$row.status | Should -Not -Be 'offline'
}
}
}

Describe 'Export overwrite protection (#27 regression)' {
It 'Refuses to overwrite an existing file without -Force' {
$topology = Import-Inventory -Path $script:TestInventoryPath
Expand Down