Skip to content

Commit 4d69007

Browse files
authored
fix: port column alignment and enriched port details (#91)
## Summary - Fix column misalignment where IPs rendered in the MAC field and remaining columns shifted right - Fix lipgloss `.Width()` inside `fmt.Sprintf` producing ANSI codes that broke alignment - Add SecurityGroups and AdminStateUp to Port struct (extracted from API) - Selected port now shows inline detail: port ID, admin state, name, and resolved security group names ## Test plan - [ ] Navigate to Networks tab, select a network with ports - [ ] Verify columns align: Status | IPs | Device | Owner | MAC - [ ] Select a port with arrow keys, verify detail rows appear (ID, admin state, SGs) - [ ] Verify ports with admin state down show red "down" indicator - [ ] Verify security group names resolve (not just IDs)
2 parents 27a6325 + 4366c14 commit 4d69007

2 files changed

Lines changed: 144 additions & 57 deletions

File tree

src/internal/network/ports.go

Lines changed: 37 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@ import (
1111

1212
// Port is a simplified representation of a Neutron port.
1313
type Port struct {
14-
ID string
15-
Name string
16-
Status string
17-
MACAddress string
18-
FixedIPs []FixedIP
19-
DeviceOwner string
20-
DeviceID string
21-
NetworkID string
14+
ID string
15+
Name string
16+
Status string
17+
MACAddress string
18+
FixedIPs []FixedIP
19+
DeviceOwner string
20+
DeviceID string
21+
NetworkID string
22+
SecurityGroups []string
23+
AdminStateUp bool
2224
}
2325

2426
// FixedIP is an IP address assigned to a port.
@@ -37,13 +39,15 @@ func ListPortsByDevice(ctx context.Context, client *gophercloud.ServiceClient, d
3739
}
3840
for _, p := range extracted {
3941
port := Port{
40-
ID: p.ID,
41-
Name: p.Name,
42-
Status: p.Status,
43-
MACAddress: p.MACAddress,
44-
DeviceOwner: p.DeviceOwner,
45-
DeviceID: p.DeviceID,
46-
NetworkID: p.NetworkID,
42+
ID: p.ID,
43+
Name: p.Name,
44+
Status: p.Status,
45+
MACAddress: p.MACAddress,
46+
DeviceOwner: p.DeviceOwner,
47+
DeviceID: p.DeviceID,
48+
NetworkID: p.NetworkID,
49+
SecurityGroups: p.SecurityGroups,
50+
AdminStateUp: p.AdminStateUp,
4751
}
4852
for _, ip := range p.FixedIPs {
4953
port.FixedIPs = append(port.FixedIPs, FixedIP{
@@ -71,13 +75,15 @@ func ListPortsBySecurityGroup(ctx context.Context, client *gophercloud.ServiceCl
7175
}
7276
for _, p := range extracted {
7377
port := Port{
74-
ID: p.ID,
75-
Name: p.Name,
76-
Status: p.Status,
77-
MACAddress: p.MACAddress,
78-
DeviceOwner: p.DeviceOwner,
79-
DeviceID: p.DeviceID,
80-
NetworkID: p.NetworkID,
78+
ID: p.ID,
79+
Name: p.Name,
80+
Status: p.Status,
81+
MACAddress: p.MACAddress,
82+
DeviceOwner: p.DeviceOwner,
83+
DeviceID: p.DeviceID,
84+
NetworkID: p.NetworkID,
85+
SecurityGroups: p.SecurityGroups,
86+
AdminStateUp: p.AdminStateUp,
8187
}
8288
for _, ip := range p.FixedIPs {
8389
port.FixedIPs = append(port.FixedIPs, FixedIP{
@@ -105,13 +111,15 @@ func ListPorts(ctx context.Context, client *gophercloud.ServiceClient, networkID
105111
}
106112
for _, p := range extracted {
107113
port := Port{
108-
ID: p.ID,
109-
Name: p.Name,
110-
Status: p.Status,
111-
MACAddress: p.MACAddress,
112-
DeviceOwner: p.DeviceOwner,
113-
DeviceID: p.DeviceID,
114-
NetworkID: p.NetworkID,
114+
ID: p.ID,
115+
Name: p.Name,
116+
Status: p.Status,
117+
MACAddress: p.MACAddress,
118+
DeviceOwner: p.DeviceOwner,
119+
DeviceID: p.DeviceID,
120+
NetworkID: p.NetworkID,
121+
SecurityGroups: p.SecurityGroups,
122+
AdminStateUp: p.AdminStateUp,
115123
}
116124
for _, ip := range p.FixedIPs {
117125
port.FixedIPs = append(port.FixedIPs, FixedIP{

src/internal/ui/networkview/networkview.go

Lines changed: 107 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ type detailLoadedMsg struct {
3939
netID string
4040
ports []network.Port
4141
serverNames map[string]string
42+
sgNames map[string]string
4243
}
4344
type detailErrMsg struct {
4445
netID string
@@ -61,6 +62,7 @@ type Model struct {
6162
// Detail state for currently selected network
6263
ports []network.Port
6364
serverNames map[string]string // DeviceID → server name
65+
sgNames map[string]string // SG ID → name
6466
detailErr string
6567

6668
// Pane focus and cursors
@@ -230,6 +232,7 @@ func (m Model) Update(msg tea.Msg) (Model, tea.Cmd) {
230232
m.detailErr = ""
231233
m.ports = msg.ports
232234
m.serverNames = msg.serverNames
235+
m.sgNames = msg.sgNames
233236
m.clampDetailCursors()
234237
}
235238
return m, nil
@@ -272,6 +275,7 @@ func (m *Model) resetDetailState() {
272275
m.detailErr = ""
273276
m.ports = nil
274277
m.serverNames = nil
278+
m.sgNames = nil
275279
m.subnetCursor = 0
276280
m.subnetsScroll = 0
277281
m.portsCursor = 0
@@ -897,7 +901,15 @@ func (m Model) renderPortsContent(maxWidth, maxHeight int) string {
897901
gap = 2
898902
)
899903

900-
// Calculate device name width
904+
// Calculate dynamic column widths
905+
ipsW := len("IPs")
906+
for _, p := range m.ports {
907+
ipStr := m.portIPStr(p)
908+
if len(ipStr) > ipsW {
909+
ipsW = len(ipStr)
910+
}
911+
}
912+
901913
deviceW := len("Device")
902914
for _, p := range m.ports {
903915
name := m.deviceName(p)
@@ -909,7 +921,6 @@ func (m Model) renderPortsContent(maxWidth, maxHeight int) string {
909921
deviceW = 20
910922
}
911923

912-
// Calculate owner width
913924
ownerW := len("Owner")
914925
for _, p := range m.ports {
915926
owner := shortOwner(p.DeviceOwner)
@@ -921,15 +932,19 @@ func (m Model) renderPortsContent(maxWidth, maxHeight int) string {
921932
ownerW = 15
922933
}
923934

924-
sep := strings.Repeat(" ", gap)
925-
ipsW := maxWidth - 2 - statusW - macW - deviceW - ownerW - gap*4
926-
if ipsW < 10 {
927-
ipsW = 10
935+
// Clamp IPs width to remaining space
936+
maxIPs := maxWidth - 2 - statusW - deviceW - ownerW - macW - gap*4
937+
if maxIPs < 10 {
938+
maxIPs = 10
939+
}
940+
if ipsW > maxIPs {
941+
ipsW = maxIPs
928942
}
929943

944+
sep := strings.Repeat(" ", gap)
930945
headerStyle := lipgloss.NewStyle().Foreground(shared.ColorMuted).Bold(true)
931-
header := fmt.Sprintf(" %-*s%s%-*s%s%-*s%s%-*s%s%s",
932-
statusW, "Status", sep, macW, "MAC", sep, ipsW, "IPs", sep, deviceW, "Device", sep, "Owner")
946+
header := fmt.Sprintf(" %-*s%s%-*s%s%-*s%s%-*s%s%-*s",
947+
statusW, "Status", sep, ipsW, "IPs", sep, deviceW, "Device", sep, ownerW, "Owner", sep, macW, "MAC")
933948
headerLine := headerStyle.Render(header)
934949

935950
visibleLines := maxHeight - 1
@@ -956,23 +971,14 @@ func (m Model) renderPortsContent(maxWidth, maxHeight int) string {
956971
prefix = "\u25b8 "
957972
}
958973

959-
statusColor := shared.ColorSuccess
960-
if p.Status != "ACTIVE" {
961-
statusColor = shared.ColorWarning
962-
}
963-
statusStr := lipgloss.NewStyle().Foreground(statusColor).Width(statusW).Render(shared.StatusIcon(p.Status) + p.Status)
974+
// Build status string as plain text for alignment, color the whole line
975+
statusIcon := shared.StatusIcon(p.Status)
976+
statusPlain := fmt.Sprintf("%-*s", statusW, statusIcon+p.Status)
964977

965-
var ips []string
966-
for _, ip := range p.FixedIPs {
967-
ips = append(ips, ip.IPAddress)
968-
}
969-
ipStr := strings.Join(ips, ", ")
978+
ipStr := m.portIPStr(p)
970979
if len(ipStr) > ipsW {
971980
ipStr = ipStr[:ipsW-1] + "\u2026"
972981
}
973-
if len(ips) == 0 {
974-
ipStr = "\u2014"
975-
}
976982

977983
device := m.deviceName(p)
978984
if len(device) > deviceW {
@@ -984,18 +990,73 @@ func (m Model) renderPortsContent(maxWidth, maxHeight int) string {
984990
owner = owner[:ownerW-1] + "\u2026"
985991
}
986992

987-
line := fmt.Sprintf("%s%s%s%-*s%s%-*s%s%-*s%s%s",
988-
prefix, statusStr, sep, ipsW, ipStr, sep, deviceW, device, sep, ownerW, owner, sep, p.MACAddress)
993+
mac := p.MACAddress
994+
995+
plainLine := fmt.Sprintf("%s%-*s%s%-*s%s%-*s%s%-*s%s%-*s",
996+
prefix, statusW, statusPlain, sep, ipsW, ipStr, sep, deviceW, device, sep, ownerW, owner, sep, macW, mac)
989997

990998
if selected {
991-
line = selectedBg.Render(line)
999+
lines = append(lines, selectedBg.Render(plainLine))
1000+
} else {
1001+
// Color the status portion
1002+
statusColor := shared.ColorSuccess
1003+
if p.Status != "ACTIVE" {
1004+
statusColor = shared.ColorWarning
1005+
}
1006+
if !p.AdminStateUp {
1007+
statusColor = shared.ColorError
1008+
}
1009+
coloredStatus := lipgloss.NewStyle().Foreground(statusColor).Render(statusPlain)
1010+
rest := fmt.Sprintf("%s%-*s%s%-*s%s%-*s%s%-*s",
1011+
sep, ipsW, ipStr, sep, deviceW, device, sep, ownerW, owner, sep, macW, mac)
1012+
lines = append(lines, prefix+coloredStatus+rest)
1013+
}
1014+
1015+
// Show extra detail for the selected port
1016+
if selected {
1017+
detailStyle := lipgloss.NewStyle().Foreground(shared.ColorMuted)
1018+
adminStr := "up"
1019+
if !p.AdminStateUp {
1020+
adminStr = lipgloss.NewStyle().Foreground(shared.ColorError).Render("down")
1021+
}
1022+
detailLine := fmt.Sprintf(" ID: %s Admin: %s", p.ID[:min(8, len(p.ID))]+"\u2026", adminStr)
1023+
if p.Name != "" {
1024+
detailLine += " Name: " + p.Name
1025+
}
1026+
lines = append(lines, detailStyle.Render(detailLine))
1027+
1028+
// Show security groups
1029+
if len(p.SecurityGroups) > 0 {
1030+
var sgStrs []string
1031+
for _, sgID := range p.SecurityGroups {
1032+
if name, ok := m.sgNames[sgID]; ok {
1033+
sgStrs = append(sgStrs, name)
1034+
} else if len(sgID) > 8 {
1035+
sgStrs = append(sgStrs, sgID[:8]+"\u2026")
1036+
} else {
1037+
sgStrs = append(sgStrs, sgID)
1038+
}
1039+
}
1040+
sgLine := " SGs: " + strings.Join(sgStrs, ", ")
1041+
lines = append(lines, detailStyle.Render(sgLine))
1042+
}
9921043
}
993-
lines = append(lines, line)
9941044
}
9951045

9961046
return strings.Join(lines, "\n")
9971047
}
9981048

1049+
func (m Model) portIPStr(p network.Port) string {
1050+
if len(p.FixedIPs) == 0 {
1051+
return "\u2014"
1052+
}
1053+
var ips []string
1054+
for _, ip := range p.FixedIPs {
1055+
ips = append(ips, ip.IPAddress)
1056+
}
1057+
return strings.Join(ips, ", ")
1058+
}
1059+
9991060
func (m Model) deviceName(p network.Port) string {
10001061
if name, ok := m.serverNames[p.DeviceID]; ok && name != "" {
10011062
return name
@@ -1188,8 +1249,27 @@ func (m Model) fetchDetail(netID string) tea.Cmd {
11881249
return fetchedPorts[i].MACAddress < fetchedPorts[j].MACAddress
11891250
})
11901251

1252+
// Resolve security group names
1253+
sgIDs := make(map[string]bool)
1254+
for _, p := range fetchedPorts {
1255+
for _, sgID := range p.SecurityGroups {
1256+
sgIDs[sgID] = true
1257+
}
1258+
}
1259+
sgNameMap := make(map[string]string)
1260+
if len(sgIDs) > 0 {
1261+
sgs, err := network.ListSecurityGroups(context.Background(), networkClient)
1262+
if err == nil {
1263+
for _, sg := range sgs {
1264+
if sgIDs[sg.ID] {
1265+
sgNameMap[sg.ID] = sg.Name
1266+
}
1267+
}
1268+
}
1269+
}
1270+
11911271
if computeClient == nil {
1192-
return detailLoadedMsg{netID: netID, ports: fetchedPorts}
1272+
return detailLoadedMsg{netID: netID, ports: fetchedPorts, sgNames: sgNameMap}
11931273
}
11941274

11951275
// Collect device IDs that look like compute instances
@@ -1215,15 +1295,14 @@ func (m Model) fetchDetail(netID string) tea.Cmd {
12151295
// Also resolve router device IDs
12161296
for _, p := range fetchedPorts {
12171297
if strings.HasPrefix(p.DeviceOwner, "network:router_interface") && p.DeviceID != "" {
1218-
// DeviceID is the router ID — try to get the router name
12191298
router, err := network.GetRouter(context.Background(), networkClient, p.DeviceID)
12201299
if err == nil && router != nil {
12211300
srvNames[p.DeviceID] = router.Name
12221301
}
12231302
}
12241303
}
12251304

1226-
return detailLoadedMsg{netID: netID, ports: fetchedPorts, serverNames: srvNames}
1305+
return detailLoadedMsg{netID: netID, ports: fetchedPorts, serverNames: srvNames, sgNames: sgNameMap}
12271306
}
12281307
}
12291308

0 commit comments

Comments
 (0)