Skip to content

Commit cbd6f36

Browse files
committed
feat: add service overview table with ports, networks, and extras
- Add renderServiceTable showing Service, Image, Ports, Networks + dynamic extras columns - Table tab now shows service overview above volume comparison - Markdown table includes extras columns (restart, hostname, depends_on, etc.) - Markdown preview textarea combines both tables - Remove Volumes column from service table (covered by volume comparison)
1 parent 8e6fb40 commit cbd6f36

5 files changed

Lines changed: 193 additions & 16 deletions

File tree

src/main.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { el } from './dom'
1111
import { parseServices } from './services'
1212
import { generateMarkdownTable, generateVolumeComparisonMarkdown } from './markdown'
1313
import { renderCards } from './cards'
14-
import { renderVolumeTable } from './volume-table'
14+
import { renderServiceTable, renderVolumeTable } from './volume-table'
1515

1616
const MAX_INPUT_BYTES = 512 * 1024
1717

@@ -378,24 +378,33 @@ function init(): void {
378378
while (cards.firstChild) {
379379
cardsContainer.appendChild(cards.firstChild)
380380
}
381+
// Render service overview table
382+
const svcTable = renderServiceTable(services)
383+
volumesContainer.appendChild(svcTable)
384+
381385
// Render volume comparison table
382386
const volTable = renderVolumeTable(services)
383387
volumesContainer.appendChild(volTable)
384388

385389
// Markdown preview textarea
390+
const svcMd = generateMarkdownTable(services)
386391
const volMd = generateVolumeComparisonMarkdown(services)
387-
if (volMd) {
392+
const mdParts: string[] = []
393+
if (svcMd) mdParts.push(svcMd)
394+
if (volMd) mdParts.push(volMd)
395+
if (mdParts.length > 0) {
396+
const combinedMd = mdParts.join('\n\n')
388397
const mdLabel = el('label')
389398
mdLabel.textContent = 'Markdown (for pasting into Discord / GitHub):'
390399
mdLabel.style.marginTop = '0.75rem'
391400
volumesContainer.appendChild(mdLabel)
392401
const mdPreview = el('textarea', {
393402
className: 'code-textarea',
394-
rows: String(Math.min(volMd.split('\n').length + 1, 12)),
403+
rows: String(Math.min(combinedMd.split('\n').length + 1, 18)),
395404
readonly: 'true',
396405
spellcheck: 'false',
397406
})
398-
mdPreview.value = volMd
407+
mdPreview.value = combinedMd
399408
volumesContainer.appendChild(mdPreview)
400409
}
401410
}

src/markdown.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -35,18 +35,31 @@ export function generateVolumeComparisonMarkdown(services: readonly ServiceInfo[
3535
export function generateMarkdownTable(services: readonly ServiceInfo[]): string {
3636
if (services.length === 0) return ''
3737

38-
const header = '| Service | Image | Ports | Volumes | Networks |'
39-
const separator = '| --- | --- | --- | --- | --- |'
38+
// Collect extra keys across all services
39+
const extraKeys: string[] = []
40+
const seen = new Set<string>()
41+
for (const svc of services) {
42+
for (const key of svc.extras.keys()) {
43+
if (!seen.has(key)) {
44+
seen.add(key)
45+
extraKeys.push(key)
46+
}
47+
}
48+
}
49+
50+
const columns = ['Service', 'Image', 'Ports', 'Networks', ...extraKeys]
51+
const header = `| ${columns.join(' | ')} |`
52+
const separator = `| ${columns.map(() => '---').join(' | ')} |`
4053

4154
const rows = services.map(svc => {
42-
const cells = [
55+
const baseCells = [
4356
escapeCell(svc.name),
4457
escapeCell(svc.image),
4558
escapeCell(joinField([...svc.ports])),
46-
escapeCell(joinField([...svc.volumes])),
4759
escapeCell(joinField([...svc.networks])),
4860
]
49-
return `| ${cells.join(' | ')} |`
61+
const extraCells = extraKeys.map(key => escapeCell(svc.extras.get(key) ?? ''))
62+
return `| ${[...baseCells, ...extraCells].join(' | ')} |`
5063
})
5164

5265
return [header, separator, ...rows].join('\n')

src/volume-table.ts

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,81 @@ function formatCell(mapping: VolumeMapping): string {
66
return mapping.mode ? `${mapping.target} (${mapping.mode})` : mapping.target
77
}
88

9+
export function renderServiceTable(services: readonly ServiceInfo[]): HTMLElement {
10+
const wrap = el('div', { className: 'volume-table-wrap' })
11+
if (services.length === 0) return wrap
12+
13+
const table = el('table', { className: 'volume-table' })
14+
15+
// Determine which extra keys exist across all services
16+
const extraKeys: string[] = []
17+
const seen = new Set<string>()
18+
for (const svc of services) {
19+
for (const key of svc.extras.keys()) {
20+
if (!seen.has(key)) {
21+
seen.add(key)
22+
extraKeys.push(key)
23+
}
24+
}
25+
}
26+
27+
// Header
28+
const thead = el('thead')
29+
const headerRow = el('tr')
30+
const columns = ['Service', 'Image', 'Ports', 'Networks', ...extraKeys]
31+
for (const col of columns) {
32+
const th = el('th')
33+
th.textContent = col
34+
headerRow.appendChild(th)
35+
}
36+
thead.appendChild(headerRow)
37+
table.appendChild(thead)
38+
39+
// Body
40+
const tbody = el('tbody')
41+
for (const svc of services) {
42+
const row = el('tr')
43+
44+
const nameTd = el('td')
45+
nameTd.textContent = svc.name
46+
nameTd.style.color = 'var(--primary)'
47+
nameTd.style.fontWeight = '600'
48+
row.appendChild(nameTd)
49+
50+
const imageTd = el('td')
51+
imageTd.textContent = svc.image
52+
row.appendChild(imageTd)
53+
54+
const portsTd = el('td')
55+
portsTd.textContent = svc.ports.length > 0 ? [...svc.ports].join(', ') : '\u2014'
56+
if (svc.ports.length === 0) portsTd.className = 'vol-empty'
57+
row.appendChild(portsTd)
58+
59+
const netTd = el('td')
60+
netTd.textContent = svc.networks.length > 0 ? [...svc.networks].join(', ') : '\u2014'
61+
if (svc.networks.length === 0) netTd.className = 'vol-empty'
62+
row.appendChild(netTd)
63+
64+
for (const key of extraKeys) {
65+
const td = el('td')
66+
const val = svc.extras.get(key)
67+
if (val) {
68+
td.textContent = val
69+
} else {
70+
td.textContent = '\u2014'
71+
td.className = 'vol-empty'
72+
}
73+
row.appendChild(td)
74+
}
75+
76+
tbody.appendChild(row)
77+
}
78+
table.appendChild(tbody)
79+
80+
wrap.appendChild(table)
81+
return wrap
82+
}
83+
984
export function renderVolumeTable(services: readonly ServiceInfo[]): HTMLElement {
1085
const wrap = el('div', { className: 'volume-table-wrap' })
1186

tests/markdown.test.ts

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -31,15 +31,14 @@ describe('generateMarkdownTable', () => {
3131
]
3232
const result = generateMarkdownTable(services)
3333
const lines = result.split('\n')
34-
// Header row
35-
expect(lines[0]).toBe('| Service | Image | Ports | Volumes | Networks |')
34+
// Header row (no Volumes column — volumes are in separate comparison table)
35+
expect(lines[0]).toBe('| Service | Image | Ports | Networks |')
3636
// Separator
37-
expect(lines[1]).toBe('| --- | --- | --- | --- | --- |')
37+
expect(lines[1]).toBe('| --- | --- | --- | --- |')
3838
// Data row
3939
expect(lines[2]).toContain('sonarr')
4040
expect(lines[2]).toContain('linuxserver/sonarr:latest')
4141
expect(lines[2]).toContain('8989:8989')
42-
expect(lines[2]).toContain('/config:/config, /data:/data')
4342
expect(lines[2]).toContain('default')
4443
})
4544

@@ -62,8 +61,25 @@ describe('generateMarkdownTable', () => {
6261
const result = generateMarkdownTable(services)
6362
expect(result).not.toContain('undefined')
6463
const lines = result.split('\n')
65-
// Data row should have empty cells for ports, volumes, networks
66-
expect(lines[2]).toBe('| minimal | nginx | | | |')
64+
// Data row should have empty cells for ports, networks
65+
expect(lines[2]).toBe('| minimal | nginx | | |')
66+
})
67+
68+
it('includes extras columns dynamically', () => {
69+
const services = [
70+
makeService({ name: 'app', image: 'nginx', extras: new Map([['restart', 'unless-stopped'], ['hostname', 'app-host']]) }),
71+
makeService({ name: 'db', image: 'postgres', extras: new Map([['restart', 'always']]) }),
72+
]
73+
const result = generateMarkdownTable(services)
74+
const lines = result.split('\n')
75+
// Header includes extras
76+
expect(lines[0]).toContain('restart')
77+
expect(lines[0]).toContain('hostname')
78+
// app row has both
79+
expect(lines[2]).toContain('unless-stopped')
80+
expect(lines[2]).toContain('app-host')
81+
// db row has restart but empty hostname
82+
expect(lines[3]).toContain('always')
6783
})
6884

6985
it('escapes pipe characters in field values', () => {

tests/volume-table.test.ts

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from 'vitest'
2-
import { renderVolumeTable } from '../src/volume-table'
2+
import { renderServiceTable, renderVolumeTable } from '../src/volume-table'
33
import type { ServiceInfo } from '../src/services'
44

55
function makeService(overrides: Partial<ServiceInfo> & { name: string }): ServiceInfo {
@@ -14,6 +14,70 @@ function makeService(overrides: Partial<ServiceInfo> & { name: string }): Servic
1414
}
1515
}
1616

17+
describe('renderServiceTable', () => {
18+
it('returns empty wrapper for no services', () => {
19+
const container = renderServiceTable([])
20+
expect(container.querySelector('table')).toBeNull()
21+
})
22+
23+
it('renders base columns: Service, Image, Ports, Networks', () => {
24+
const services = [
25+
makeService({ name: 'app', image: 'nginx:latest', ports: ['80:80'], networks: ['frontend'] }),
26+
]
27+
const container = renderServiceTable(services)
28+
const ths = container.querySelectorAll('th')
29+
expect(ths[0]!.textContent).toBe('Service')
30+
expect(ths[1]!.textContent).toBe('Image')
31+
expect(ths[2]!.textContent).toBe('Ports')
32+
expect(ths[3]!.textContent).toBe('Networks')
33+
})
34+
35+
it('renders service data in rows', () => {
36+
const services = [
37+
makeService({ name: 'plex', image: 'plex:latest', ports: ['32400:32400'], networks: ['media'] }),
38+
]
39+
const container = renderServiceTable(services)
40+
const tds = container.querySelectorAll('tbody td')
41+
expect(tds[0]!.textContent).toBe('plex')
42+
expect(tds[1]!.textContent).toBe('plex:latest')
43+
expect(tds[2]!.textContent).toBe('32400:32400')
44+
expect(tds[3]!.textContent).toBe('media')
45+
})
46+
47+
it('shows dash for empty ports and networks', () => {
48+
const services = [makeService({ name: 'app', image: 'nginx' })]
49+
const container = renderServiceTable(services)
50+
const tds = container.querySelectorAll('tbody td')
51+
expect(tds[2]!.textContent).toBe('\u2014')
52+
expect(tds[3]!.textContent).toBe('\u2014')
53+
})
54+
55+
it('includes extras as dynamic columns', () => {
56+
const services = [
57+
makeService({ name: 'app', image: 'nginx', extras: new Map([['restart', 'unless-stopped']]) }),
58+
makeService({ name: 'db', image: 'postgres', extras: new Map([['restart', 'always'], ['hostname', 'pg-host']]) }),
59+
]
60+
const container = renderServiceTable(services)
61+
const ths = container.querySelectorAll('th')
62+
const headers = Array.from(ths).map(th => th.textContent)
63+
expect(headers).toContain('restart')
64+
expect(headers).toContain('hostname')
65+
66+
// app row: has restart, no hostname
67+
const rows = container.querySelectorAll('tbody tr')
68+
const appCells = rows[0]!.querySelectorAll('td')
69+
const restartIdx = headers.indexOf('restart')
70+
const hostnameIdx = headers.indexOf('hostname')
71+
expect(appCells[restartIdx]!.textContent).toBe('unless-stopped')
72+
expect(appCells[hostnameIdx]!.textContent).toBe('\u2014')
73+
74+
// db row: has both
75+
const dbCells = rows[1]!.querySelectorAll('td')
76+
expect(dbCells[restartIdx]!.textContent).toBe('always')
77+
expect(dbCells[hostnameIdx]!.textContent).toBe('pg-host')
78+
})
79+
})
80+
1781
describe('renderVolumeTable', () => {
1882
it('returns empty div for no services', () => {
1983
const table = renderVolumeTable([])

0 commit comments

Comments
 (0)