Skip to content

Commit 2ec899d

Browse files
committed
feat: structured volume rendering, cross-service volume table, mobile layout
- Replace raw volume strings in cards with parsed grid (source → target mode) - Add Volumes tab with cross-service comparison table (host paths × services) - Add volume comparison markdown export (Copy Volume Table button) - Add mobile-responsive CSS (@media max-width: 640px) - Extract shared buildVolumeMatrix() into volume-utils.ts - Support compound volume modes (ro,z) and nocopy option
1 parent f4c470d commit 2ec899d

9 files changed

Lines changed: 588 additions & 21 deletions

File tree

‎index.html‎

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,123 @@
350350
color: var(--text);
351351
}
352352

353+
/* Volume mini-table in cards */
354+
.vol-grid {
355+
display: grid;
356+
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
357+
gap: 0.15rem 0.5rem;
358+
font-family: var(--mono);
359+
font-size: 0.8rem;
360+
color: var(--text);
361+
}
362+
363+
.vol-source {
364+
overflow: hidden;
365+
text-overflow: ellipsis;
366+
white-space: nowrap;
367+
}
368+
369+
.vol-arrow {
370+
color: var(--text-muted);
371+
user-select: none;
372+
}
373+
374+
.vol-target {
375+
overflow: hidden;
376+
text-overflow: ellipsis;
377+
white-space: nowrap;
378+
}
379+
380+
.vol-mode {
381+
color: var(--text-muted);
382+
font-size: 0.75rem;
383+
}
384+
385+
/* Cross-service volume comparison table */
386+
.volume-table-wrap {
387+
overflow-x: auto;
388+
max-height: 600px;
389+
overflow-y: auto;
390+
padding: 0.5rem 0;
391+
}
392+
393+
.volume-table {
394+
width: 100%;
395+
border-collapse: collapse;
396+
font-family: var(--mono);
397+
font-size: 0.8rem;
398+
}
399+
400+
.volume-table th,
401+
.volume-table td {
402+
padding: 0.35rem 0.6rem;
403+
border: 1px solid var(--border);
404+
text-align: left;
405+
white-space: nowrap;
406+
}
407+
408+
.volume-table th {
409+
background: var(--surface);
410+
color: var(--primary);
411+
font-weight: 600;
412+
position: sticky;
413+
top: 0;
414+
z-index: 1;
415+
}
416+
417+
.volume-table td {
418+
background: var(--bg);
419+
color: var(--text);
420+
}
421+
422+
.volume-table td.vol-empty {
423+
color: var(--text-muted);
424+
text-align: center;
425+
}
426+
427+
/* Mobile responsive */
428+
@media (max-width: 640px) {
429+
#app {
430+
padding: 1rem 0.75rem;
431+
}
432+
433+
.code-textarea {
434+
font-size: 0.78rem;
435+
}
436+
437+
.cards-container {
438+
grid-template-columns: 1fr;
439+
}
440+
441+
.card-section {
442+
flex-direction: column;
443+
gap: 0.2rem;
444+
}
445+
446+
.card-label {
447+
min-width: unset;
448+
}
449+
450+
.tab-btn {
451+
padding: 0.4rem 0.8rem;
452+
font-size: 0.85rem;
453+
}
454+
455+
.volume-table-wrap {
456+
overflow-x: auto;
457+
-webkit-overflow-scrolling: touch;
458+
}
459+
460+
.actions {
461+
flex-direction: column;
462+
}
463+
464+
.actions .btn {
465+
width: 100%;
466+
text-align: center;
467+
}
468+
}
469+
353470
.hidden { display: none !important; }
354471
</style>
355472
</head>

‎src/cards.ts‎

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,73 @@
11
import { el } from './dom'
22
import type { ServiceInfo } from './services'
33

4+
const VOLUME_MODES: ReadonlySet<string> = new Set([
5+
'ro', 'rw', 'z', 'Z', 'shared', 'slave', 'private', 'rshared', 'rslave', 'rprivate',
6+
'consistent', 'cached', 'delegated', 'nocopy',
7+
])
8+
9+
function isVolumeMode(segment: string): boolean {
10+
if (segment === '') return false
11+
return segment.split(',').every(opt => VOLUME_MODES.has(opt))
12+
}
13+
14+
export interface ParsedVolume {
15+
readonly source: string
16+
readonly target: string
17+
readonly mode: string
18+
}
19+
20+
export function parseVolume(vol: string): ParsedVolume {
21+
const parts = vol.split(':')
22+
if (parts.length >= 3) {
23+
const last = parts[parts.length - 1]!
24+
if (isVolumeMode(last)) {
25+
return {
26+
source: parts.slice(0, -2).join(':'),
27+
target: parts[parts.length - 2]!,
28+
mode: last,
29+
}
30+
}
31+
}
32+
if (parts.length >= 2) {
33+
return { source: parts[0]!, target: parts.slice(1).join(':'), mode: '' }
34+
}
35+
return { source: vol, target: '', mode: '' }
36+
}
37+
38+
function renderVolumesSection(volumes: readonly string[]): HTMLElement {
39+
const section = el('div', { className: 'card-section' })
40+
const labelEl = el('div', { className: 'card-label' })
41+
labelEl.textContent = 'Volumes'
42+
section.appendChild(labelEl)
43+
44+
const grid = el('div', { className: 'vol-grid' })
45+
for (const vol of volumes) {
46+
const parsed = parseVolume(vol)
47+
48+
const sourceEl = el('span', { className: 'vol-source' })
49+
sourceEl.textContent = parsed.source
50+
sourceEl.title = parsed.source
51+
grid.appendChild(sourceEl)
52+
53+
const arrowEl = el('span', { className: 'vol-arrow' })
54+
arrowEl.textContent = '\u2192'
55+
grid.appendChild(arrowEl)
56+
57+
const targetEl = el('span', { className: 'vol-target' })
58+
targetEl.textContent = parsed.target
59+
targetEl.title = parsed.target
60+
grid.appendChild(targetEl)
61+
62+
const modeEl = el('span', { className: 'vol-mode' })
63+
modeEl.textContent = parsed.mode
64+
grid.appendChild(modeEl)
65+
}
66+
67+
section.appendChild(grid)
68+
return section
69+
}
70+
471
function renderListSection(label: string, items: readonly string[]): HTMLElement {
572
const section = el('div', { className: 'card-section' })
673
const labelEl = el('div', { className: 'card-label' })
@@ -52,7 +119,7 @@ function renderCard(service: ServiceInfo): HTMLElement {
52119
}
53120

54121
if (service.volumes.length > 0) {
55-
card.appendChild(renderListSection('Volumes', service.volumes))
122+
card.appendChild(renderVolumesSection(service.volumes))
56123
}
57124

58125
if (service.networks.length > 0) {

‎src/main.ts‎

Lines changed: 52 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ import { copyToClipboard, openPrivateBin, openGist } from './clipboard'
99
import { createShortNotice, createPiiWarning, createFullDisclaimer } from './disclaimer'
1010
import { el } from './dom'
1111
import { parseServices } from './services'
12-
import { generateMarkdownTable } from './markdown'
12+
import { generateMarkdownTable, generateVolumeComparisonMarkdown } from './markdown'
1313
import { renderCards } from './cards'
14+
import { renderVolumeTable } from './volume-table'
1415

1516
const MAX_INPUT_BYTES = 512 * 1024
1617

@@ -197,6 +198,9 @@ function init(): void {
197198
const cardsTab = el('button', { className: 'tab-btn' })
198199
cardsTab.textContent = 'Cards'
199200
tabBar.appendChild(cardsTab)
201+
const volumesTab = el('button', { className: 'tab-btn' })
202+
volumesTab.textContent = 'Volumes'
203+
tabBar.appendChild(volumesTab)
200204
app.appendChild(tabBar)
201205

202206
// Output textarea (YAML view)
@@ -213,22 +217,35 @@ function init(): void {
213217
const cardsContainer = el('div', { id: 'cards', className: 'cards-container hidden' })
214218
app.appendChild(cardsContainer)
215219

220+
// Volumes container (hidden by default)
221+
const volumesContainer = el('div', { id: 'volumes', className: 'hidden' })
222+
app.appendChild(volumesContainer)
223+
216224
// Track current parsed object for markdown generation
217225
let currentParsed: Record<string, unknown> | null = null
218226

219227
// Tab switching
220-
yamlTab.addEventListener('click', () => {
221-
yamlTab.classList.add('active')
222-
cardsTab.classList.remove('active')
223-
output.classList.remove('hidden')
224-
cardsContainer.classList.add('hidden')
225-
})
226-
cardsTab.addEventListener('click', () => {
227-
cardsTab.classList.add('active')
228-
yamlTab.classList.remove('active')
229-
cardsContainer.classList.remove('hidden')
230-
output.classList.add('hidden')
231-
})
228+
const tabs = [
229+
{ btn: yamlTab, panel: output },
230+
{ btn: cardsTab, panel: cardsContainer },
231+
{ btn: volumesTab, panel: volumesContainer },
232+
] as const
233+
234+
function switchTab(activeBtn: HTMLElement): void {
235+
for (const tab of tabs) {
236+
if (tab.btn === activeBtn) {
237+
tab.btn.classList.add('active')
238+
tab.panel.classList.remove('hidden')
239+
} else {
240+
tab.btn.classList.remove('active')
241+
tab.panel.classList.add('hidden')
242+
}
243+
}
244+
}
245+
246+
yamlTab.addEventListener('click', () => switchTab(yamlTab))
247+
cardsTab.addEventListener('click', () => switchTab(cardsTab))
248+
volumesTab.addEventListener('click', () => switchTab(volumesTab))
232249

233250
// Action buttons
234251
const actions = el('div', { id: 'actions', className: 'actions hidden' })
@@ -258,6 +275,21 @@ function init(): void {
258275
})
259276
actions.appendChild(mdBtn)
260277

278+
const volMdBtn = el('button', { className: 'btn btn-secondary' })
279+
volMdBtn.textContent = 'Copy Volume Table'
280+
volMdBtn.addEventListener('click', async () => {
281+
if (currentParsed) {
282+
const services = parseServices(currentParsed)
283+
const md = generateVolumeComparisonMarkdown(services)
284+
const ok = await copyToClipboard(md || 'No volumes found')
285+
volMdBtn.textContent = ok ? 'Copied!' : 'Copy failed'
286+
} else {
287+
volMdBtn.textContent = 'No data'
288+
}
289+
setTimeout(() => { volMdBtn.textContent = 'Copy Volume Table' }, 1500)
290+
})
291+
actions.appendChild(volMdBtn)
292+
261293
const pbBtn = el('button', { className: 'btn btn-secondary' })
262294
pbBtn.textContent = 'Open PrivateBin'
263295
pbBtn.addEventListener('click', async () => {
@@ -300,6 +332,7 @@ function init(): void {
300332
const hideOutput = () => {
301333
output.classList.add('hidden')
302334
cardsContainer.classList.add('hidden')
335+
volumesContainer.classList.add('hidden')
303336
tabBar.classList.add('hidden')
304337
piiWarning.classList.add('hidden')
305338
actions.classList.add('hidden')
@@ -338,13 +371,11 @@ function init(): void {
338371
currentParsed = result.parsed
339372

340373
// Reset to YAML tab
341-
yamlTab.classList.add('active')
342-
cardsTab.classList.remove('active')
343-
output.classList.remove('hidden')
344-
cardsContainer.classList.add('hidden')
374+
switchTab(yamlTab)
345375

346-
// Render cards
376+
// Render cards + volume table
347377
cardsContainer.replaceChildren()
378+
volumesContainer.replaceChildren()
348379
if (result.parsed) {
349380
const services = parseServices(result.parsed)
350381
if (services.length > 0) {
@@ -353,6 +384,9 @@ function init(): void {
353384
while (cards.firstChild) {
354385
cardsContainer.appendChild(cards.firstChild)
355386
}
387+
// Render volume comparison table
388+
const volTable = renderVolumeTable(services)
389+
volumesContainer.appendChild(volTable)
356390
}
357391
}
358392

‎src/markdown.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { buildVolumeMatrix } from './volume-utils'
12
import type { ServiceInfo } from './services'
23

34
function escapeCell(value: string): string {
@@ -8,6 +9,29 @@ function joinField(values: readonly string[]): string {
89
return values.join(', ')
910
}
1011

12+
export function generateVolumeComparisonMarkdown(services: readonly ServiceInfo[]): string {
13+
if (services.length === 0) return ''
14+
15+
const { hostPaths, matrix } = buildVolumeMatrix(services)
16+
if (hostPaths.length === 0) return ''
17+
18+
const header = `| Host Path | ${services.map(s => escapeCell(s.name)).join(' | ')} |`
19+
const separator = `| --- | ${services.map(() => '---').join(' | ')} |`
20+
21+
const rows = hostPaths.map(hp => {
22+
const serviceMap = matrix.get(hp) ?? new Map()
23+
const cells = services.map(svc => {
24+
const mapping = serviceMap.get(svc.name)
25+
if (!mapping) return '\u2014'
26+
const cell = mapping.mode ? `${mapping.target} (${mapping.mode})` : mapping.target
27+
return escapeCell(cell)
28+
})
29+
return `| ${escapeCell(hp)} | ${cells.join(' | ')} |`
30+
})
31+
32+
return [header, separator, ...rows].join('\n')
33+
}
34+
1135
export function generateMarkdownTable(services: readonly ServiceInfo[]): string {
1236
if (services.length === 0) return ''
1337

0 commit comments

Comments
 (0)