Skip to content

Commit 5f8b4ad

Browse files
committed
feat: show network aliases and IP in card view
Parse Docker Compose dict-form network config to extract aliases and ipv4_address. Cards now display structured network details (e.g., "media (aliases: plex-alias; ip: 172.20.0.10)"). Table and markdown views continue showing only network names for compact display. - Add NetworkInfo interface (name, aliases, ipv4Address) - Update extractNetworks to parse dict-form details immutably - Add renderNetworksSection in cards.ts for structured rendering - Add negative-assertion tests for table/markdown (no alias/ip leak)
1 parent cbd6f36 commit 5f8b4ad

File tree

8 files changed

+219
-18
lines changed

8 files changed

+219
-18
lines changed

src/cards.ts

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { el } from './dom'
2-
import type { ServiceInfo } from './services'
2+
import type { ServiceInfo, NetworkInfo } from './services'
33

44
const VOLUME_MODES: ReadonlySet<string> = new Set([
55
'ro', 'rw', 'z', 'Z', 'shared', 'slave', 'private', 'rshared', 'rslave', 'rprivate',
@@ -68,6 +68,25 @@ function renderVolumesSection(volumes: readonly string[]): HTMLElement {
6868
return section
6969
}
7070

71+
function renderNetworksSection(networks: readonly NetworkInfo[]): HTMLElement {
72+
const section = el('div', { className: 'card-section' })
73+
const labelEl = el('div', { className: 'card-label' })
74+
labelEl.textContent = 'Networks'
75+
section.appendChild(labelEl)
76+
77+
const list = el('div', { className: 'card-value' })
78+
for (const net of networks) {
79+
const line = el('div')
80+
const details: string[] = []
81+
if (net.aliases.length > 0) details.push(`aliases: ${net.aliases.join(', ')}`)
82+
if (net.ipv4Address) details.push(`ip: ${net.ipv4Address}`)
83+
line.textContent = details.length > 0 ? `${net.name} (${details.join('; ')})` : net.name
84+
list.appendChild(line)
85+
}
86+
section.appendChild(list)
87+
return section
88+
}
89+
7190
function renderListSection(label: string, items: readonly string[]): HTMLElement {
7291
const section = el('div', { className: 'card-section' })
7392
const labelEl = el('div', { className: 'card-label' })
@@ -123,7 +142,7 @@ function renderCard(service: ServiceInfo): HTMLElement {
123142
}
124143

125144
if (service.networks.length > 0) {
126-
card.appendChild(renderListSection('Networks', service.networks))
145+
card.appendChild(renderNetworksSection(service.networks))
127146
}
128147

129148
if (service.environment.size > 0) {

src/markdown.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ export function generateMarkdownTable(services: readonly ServiceInfo[]): string
5656
escapeCell(svc.name),
5757
escapeCell(svc.image),
5858
escapeCell(joinField([...svc.ports])),
59-
escapeCell(joinField([...svc.networks])),
59+
escapeCell(joinField(svc.networks.map(n => n.name))),
6060
]
6161
const extraCells = extraKeys.map(key => escapeCell(svc.extras.get(key) ?? ''))
6262
return `| ${[...baseCells, ...extraCells].join(' | ')} |`

src/services.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
import { isRecord } from './patterns'
22

3+
export interface NetworkInfo {
4+
readonly name: string
5+
readonly aliases: readonly string[]
6+
readonly ipv4Address: string
7+
}
8+
39
export interface ServiceInfo {
410
readonly name: string
511
readonly image: string
612
readonly ports: readonly string[]
713
readonly volumes: readonly string[]
8-
readonly networks: readonly string[]
14+
readonly networks: readonly NetworkInfo[]
915
readonly environment: ReadonlyMap<string, string>
1016
readonly extras: ReadonlyMap<string, string>
1117
}
@@ -48,12 +54,24 @@ function normalizeVolumes(volumes: unknown): readonly string[] {
4854
return volumes.map(normalizeVolume)
4955
}
5056

51-
function extractNetworks(networks: unknown): readonly string[] {
57+
function extractNetworks(networks: unknown): readonly NetworkInfo[] {
5258
if (Array.isArray(networks)) {
53-
return [...networks].map(String).sort()
59+
return [...networks]
60+
.map(n => ({ name: String(n), aliases: [] as readonly string[], ipv4Address: '' }))
61+
.sort((a, b) => a.name.localeCompare(b.name))
5462
}
5563
if (isRecord(networks)) {
56-
return Object.keys(networks).sort()
64+
return Object.entries(networks)
65+
.map(([name, config]): NetworkInfo => {
66+
const aliases = isRecord(config) && Array.isArray(config['aliases'])
67+
? config['aliases'].map(String)
68+
: []
69+
const ipv4Address = isRecord(config) && typeof config['ipv4_address'] === 'string'
70+
? config['ipv4_address']
71+
: ''
72+
return { name, aliases, ipv4Address }
73+
})
74+
.sort((a, b) => a.name.localeCompare(b.name))
5775
}
5876
return []
5977
}

src/volume-table.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,8 @@ export function renderServiceTable(services: readonly ServiceInfo[]): HTMLElemen
5757
row.appendChild(portsTd)
5858

5959
const netTd = el('td')
60-
netTd.textContent = svc.networks.length > 0 ? [...svc.networks].join(', ') : '\u2014'
60+
const netNames = svc.networks.map(n => n.name)
61+
netTd.textContent = netNames.length > 0 ? netNames.join(', ') : '\u2014'
6162
if (svc.networks.length === 0) netTd.className = 'vol-empty'
6263
row.appendChild(netTd)
6364

tests/cards.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, it, expect } from 'vitest'
22
import { renderCards, parseVolume } from '../src/cards'
3-
import type { ServiceInfo } from '../src/services'
3+
import type { ServiceInfo, NetworkInfo } from '../src/services'
4+
5+
function net(name: string, opts?: { aliases?: string[]; ipv4Address?: string }): NetworkInfo {
6+
return { name, aliases: opts?.aliases ?? [], ipv4Address: opts?.ipv4Address ?? '' }
7+
}
48

59
function makeService(overrides: Partial<ServiceInfo> & { name: string }): ServiceInfo {
610
return {
@@ -142,6 +146,65 @@ describe('renderCards', () => {
142146
expect(volLabel).toBeDefined()
143147
expect(volLabel!.nextElementSibling!.className).toBe('vol-grid')
144148
})
149+
150+
it('renders network names in card', () => {
151+
const services = [makeService({
152+
name: 'app',
153+
image: 'nginx',
154+
networks: [net('frontend'), net('backend')],
155+
})]
156+
const container = renderCards(services)
157+
const labels = container.querySelectorAll('.card-label')
158+
const netLabel = Array.from(labels).find(l => l.textContent === 'Networks')
159+
expect(netLabel).toBeDefined()
160+
const netSection = netLabel!.parentElement!
161+
expect(netSection.textContent).toContain('frontend')
162+
expect(netSection.textContent).toContain('backend')
163+
})
164+
165+
it('renders network aliases in card', () => {
166+
const services = [makeService({
167+
name: 'plex',
168+
image: 'plex',
169+
networks: [net('media', { aliases: ['plex-media', 'media-server'] })],
170+
})]
171+
const container = renderCards(services)
172+
const labels = container.querySelectorAll('.card-label')
173+
const netLabel = Array.from(labels).find(l => l.textContent === 'Networks')
174+
expect(netLabel).toBeDefined()
175+
const netSection = netLabel!.parentElement!
176+
expect(netSection.textContent).toContain('media')
177+
expect(netSection.textContent).toContain('plex-media')
178+
expect(netSection.textContent).toContain('media-server')
179+
})
180+
181+
it('renders network ipv4 address in card', () => {
182+
const services = [makeService({
183+
name: 'app',
184+
image: 'nginx',
185+
networks: [net('backend', { ipv4Address: '172.20.0.10' })],
186+
})]
187+
const container = renderCards(services)
188+
const labels = container.querySelectorAll('.card-label')
189+
const netLabel = Array.from(labels).find(l => l.textContent === 'Networks')
190+
const netSection = netLabel!.parentElement!
191+
expect(netSection.textContent).toContain('172.20.0.10')
192+
})
193+
194+
it('renders network with both aliases and ip', () => {
195+
const services = [makeService({
196+
name: 'app',
197+
image: 'nginx',
198+
networks: [net('media', { aliases: ['plex-alias'], ipv4Address: '172.20.0.5' })],
199+
})]
200+
const container = renderCards(services)
201+
const labels = container.querySelectorAll('.card-label')
202+
const netLabel = Array.from(labels).find(l => l.textContent === 'Networks')
203+
const netSection = netLabel!.parentElement!
204+
expect(netSection.textContent).toContain('media')
205+
expect(netSection.textContent).toContain('plex-alias')
206+
expect(netSection.textContent).toContain('172.20.0.5')
207+
})
145208
})
146209

147210
describe('parseVolume', () => {

tests/markdown.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, it, expect } from 'vitest'
22
import { generateMarkdownTable, generateVolumeComparisonMarkdown } from '../src/markdown'
3-
import type { ServiceInfo } from '../src/services'
3+
import type { ServiceInfo, NetworkInfo } from '../src/services'
4+
5+
function net(name: string, opts?: { aliases?: string[]; ipv4Address?: string }): NetworkInfo {
6+
return { name, aliases: opts?.aliases ?? [], ipv4Address: opts?.ipv4Address ?? '' }
7+
}
48

59
function makeService(overrides: Partial<ServiceInfo> & { name: string }): ServiceInfo {
610
return {
@@ -26,7 +30,7 @@ describe('generateMarkdownTable', () => {
2630
image: 'linuxserver/sonarr:latest',
2731
ports: ['8989:8989'],
2832
volumes: ['/config:/config', '/data:/data'],
29-
networks: ['default'],
33+
networks: [net('default')],
3034
}),
3135
]
3236
const result = generateMarkdownTable(services)
@@ -97,7 +101,7 @@ describe('generateMarkdownTable', () => {
97101
name: 'app',
98102
image: 'nginx',
99103
ports: ['80:80', '443:443'],
100-
networks: ['frontend', 'backend'],
104+
networks: [net('frontend'), net('backend')],
101105
}),
102106
]
103107
const result = generateMarkdownTable(services)
@@ -114,6 +118,20 @@ describe('generateMarkdownTable', () => {
114118
expect(result).not.toContain('\n\n') // no double newlines in data
115119
expect(result).toContain('nginx latest') // newline replaced with space
116120
})
121+
122+
it('renders only network name, not aliases or ip', () => {
123+
const services = [
124+
makeService({
125+
name: 'app',
126+
image: 'nginx',
127+
networks: [net('media', { aliases: ['plex-alias'], ipv4Address: '172.20.0.5' })],
128+
}),
129+
]
130+
const result = generateMarkdownTable(services)
131+
expect(result).toContain('media')
132+
expect(result).not.toContain('plex-alias')
133+
expect(result).not.toContain('172.20.0.5')
134+
})
117135
})
118136

119137
describe('generateVolumeComparisonMarkdown', () => {

tests/services.test.ts

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ describe('parseServices', () => {
149149

150150
// Network extraction
151151
describe('network extraction', () => {
152-
it('extracts networks from dict form', () => {
152+
it('extracts networks from dict form with NetworkInfo', () => {
153153
const compose = {
154154
services: {
155155
app: {
@@ -159,7 +159,68 @@ describe('parseServices', () => {
159159
},
160160
}
161161
const result = parseServices(compose)
162-
expect(result[0].networks).toEqual(['backend', 'frontend'])
162+
expect(result[0].networks).toEqual([
163+
{ name: 'backend', aliases: [], ipv4Address: '' },
164+
{ name: 'frontend', aliases: [], ipv4Address: '' },
165+
])
166+
})
167+
168+
it('extracts aliases from dict form', () => {
169+
const compose = {
170+
services: {
171+
app: {
172+
image: 'nginx',
173+
networks: {
174+
media: {
175+
aliases: ['plex-media', 'media-server'],
176+
},
177+
},
178+
},
179+
},
180+
}
181+
const result = parseServices(compose)
182+
expect(result[0].networks).toEqual([
183+
{ name: 'media', aliases: ['plex-media', 'media-server'], ipv4Address: '' },
184+
])
185+
})
186+
187+
it('extracts ipv4_address from dict form', () => {
188+
const compose = {
189+
services: {
190+
app: {
191+
image: 'nginx',
192+
networks: {
193+
backend: {
194+
ipv4_address: '172.20.0.10',
195+
},
196+
},
197+
},
198+
},
199+
}
200+
const result = parseServices(compose)
201+
expect(result[0].networks).toEqual([
202+
{ name: 'backend', aliases: [], ipv4Address: '172.20.0.10' },
203+
])
204+
})
205+
206+
it('extracts both aliases and ipv4_address', () => {
207+
const compose = {
208+
services: {
209+
app: {
210+
image: 'nginx',
211+
networks: {
212+
media: {
213+
aliases: ['plex-alias'],
214+
ipv4_address: '172.20.0.5',
215+
},
216+
},
217+
},
218+
},
219+
}
220+
const result = parseServices(compose)
221+
expect(result[0].networks).toEqual([
222+
{ name: 'media', aliases: ['plex-alias'], ipv4Address: '172.20.0.5' },
223+
])
163224
})
164225

165226
it('extracts networks from array form', () => {
@@ -172,7 +233,10 @@ describe('parseServices', () => {
172233
},
173234
}
174235
const result = parseServices(compose)
175-
expect(result[0].networks).toEqual(['backend', 'frontend'])
236+
expect(result[0].networks).toEqual([
237+
{ name: 'backend', aliases: [], ipv4Address: '' },
238+
{ name: 'frontend', aliases: [], ipv4Address: '' },
239+
])
176240
})
177241

178242
it('returns empty array when no networks', () => {

tests/volume-table.test.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, it, expect } from 'vitest'
22
import { renderServiceTable, renderVolumeTable } from '../src/volume-table'
3-
import type { ServiceInfo } from '../src/services'
3+
import type { ServiceInfo, NetworkInfo } from '../src/services'
4+
5+
function net(name: string, opts?: { aliases?: string[]; ipv4Address?: string }): NetworkInfo {
6+
return { name, aliases: opts?.aliases ?? [], ipv4Address: opts?.ipv4Address ?? '' }
7+
}
48

59
function makeService(overrides: Partial<ServiceInfo> & { name: string }): ServiceInfo {
610
return {
@@ -22,7 +26,7 @@ describe('renderServiceTable', () => {
2226

2327
it('renders base columns: Service, Image, Ports, Networks', () => {
2428
const services = [
25-
makeService({ name: 'app', image: 'nginx:latest', ports: ['80:80'], networks: ['frontend'] }),
29+
makeService({ name: 'app', image: 'nginx:latest', ports: ['80:80'], networks: [net('frontend')] }),
2630
]
2731
const container = renderServiceTable(services)
2832
const ths = container.querySelectorAll('th')
@@ -34,7 +38,7 @@ describe('renderServiceTable', () => {
3438

3539
it('renders service data in rows', () => {
3640
const services = [
37-
makeService({ name: 'plex', image: 'plex:latest', ports: ['32400:32400'], networks: ['media'] }),
41+
makeService({ name: 'plex', image: 'plex:latest', ports: ['32400:32400'], networks: [net('media')] }),
3842
]
3943
const container = renderServiceTable(services)
4044
const tds = container.querySelectorAll('tbody td')
@@ -76,6 +80,20 @@ describe('renderServiceTable', () => {
7680
expect(dbCells[restartIdx]!.textContent).toBe('always')
7781
expect(dbCells[hostnameIdx]!.textContent).toBe('pg-host')
7882
})
83+
84+
it('renders only network name, not aliases or ip', () => {
85+
const services = [
86+
makeService({
87+
name: 'app',
88+
image: 'nginx',
89+
networks: [net('media', { aliases: ['plex-alias'], ipv4Address: '172.20.0.5' })],
90+
}),
91+
]
92+
const container = renderServiceTable(services)
93+
const tds = container.querySelectorAll('tbody td')
94+
// Networks column is index 3 (Service, Image, Ports, Networks)
95+
expect(tds[3]!.textContent).toBe('media')
96+
})
7997
})
8098

8199
describe('renderVolumeTable', () => {

0 commit comments

Comments
 (0)