|
60 | 60 | const normalizeRuntimeInfoMap = typeof deps.normalizeDockerRuntimeInfoMap === 'function' |
61 | 61 | ? deps.normalizeDockerRuntimeInfoMap |
62 | 62 | : ((value) => (value && typeof value === 'object' ? value : {})); |
| 63 | + const getSafeWebuiUrl = typeof deps.getSafeWebuiUrl === 'function' |
| 64 | + ? deps.getSafeWebuiUrl |
| 65 | + : ((value) => { |
| 66 | + const raw = String(value || '').trim(); |
| 67 | + return raw && !/^javascript:/i.test(raw) ? raw : ''; |
| 68 | + }); |
63 | 69 | const getPrefsOrderedFolderMap = typeof deps.getPrefsOrderedFolderMap === 'function' |
64 | 70 | ? deps.getPrefsOrderedFolderMap |
65 | 71 | : ((folders, prefs) => ( |
|
118 | 124 | const openFolderWebuisFromMenu = typeof deps.openFolderWebuisFromMenu === 'function' |
119 | 125 | ? deps.openFolderWebuisFromMenu |
120 | 126 | : (() => {}); |
| 127 | + const openWebuiInNewTab = typeof deps.openWebuiInNewTab === 'function' ? deps.openWebuiInNewTab : (() => false); |
| 128 | + const openWebuiPopupWindow = typeof deps.openWebuiPopupWindow === 'function' ? deps.openWebuiPopupWindow : (() => false); |
| 129 | + const openTerminal = typeof deps.openTerminal === 'function' ? deps.openTerminal : (() => {}); |
121 | 130 | const toggleFolderPin = typeof deps.toggleFolderPin === 'function' ? deps.toggleFolderPin : (() => Promise.resolve()); |
122 | 131 | const toggleFolderLock = typeof deps.toggleFolderLock === 'function' ? deps.toggleFolderLock : (() => {}); |
123 | 132 | const queueLoadlistRefresh = typeof deps.queueLoadlistRefresh === 'function' |
|
127 | 136 | let rootNode = null; |
128 | 137 | let clickHandler = null; |
129 | 138 | let renderToken = 0; |
| 139 | + let expandedMemberKey = ''; |
130 | 140 |
|
131 | 141 | const getHostTable = () => doc?.querySelector('table#docker_containers') || null; |
132 | 142 | const isFolderToken = (value) => String(value || '').trim().startsWith('folder-'); |
|
218 | 228 | return { state, label: 'stopped', icon: 'fa-stop' }; |
219 | 229 | }; |
220 | 230 |
|
| 231 | + const getMemberKey = (folderId, memberName) => `${String(folderId || '').trim()}::${String(memberName || '').trim()}`; |
| 232 | + |
| 233 | + const dispatchContainerControl = (action, containerId) => { |
| 234 | + const safeAction = String(action || '').trim(); |
| 235 | + const safeContainerId = String(containerId || '').trim(); |
| 236 | + if (!safeAction || !safeContainerId || typeof win?.eventControl !== 'function') { |
| 237 | + return false; |
| 238 | + } |
| 239 | + win.eventControl({ action: safeAction, container: safeContainerId }, 'loadlist'); |
| 240 | + return true; |
| 241 | + }; |
| 242 | + |
| 243 | + const openFolderCardWebuis = (folderCard) => { |
| 244 | + if (!(folderCard instanceof HTMLElement)) { |
| 245 | + return false; |
| 246 | + } |
| 247 | + const urls = Array.from(new Set( |
| 248 | + Array.from(folderCard.querySelectorAll('[data-member-webui-url]')) |
| 249 | + .map((node) => getSafeWebuiUrl(node.getAttribute('data-member-webui-url') || '')) |
| 250 | + .filter(Boolean) |
| 251 | + )); |
| 252 | + if (!urls.length) { |
| 253 | + return false; |
| 254 | + } |
| 255 | + const stamp = Date.now(); |
| 256 | + urls.forEach((url, index) => { |
| 257 | + if (index === 0) { |
| 258 | + openWebuiInNewTab(url); |
| 259 | + return; |
| 260 | + } |
| 261 | + openWebuiPopupWindow(url, `fvw-${stamp}-${index}`); |
| 262 | + }); |
| 263 | + return true; |
| 264 | + }; |
| 265 | + |
| 266 | + const buildMemberMenuButtons = (member) => { |
| 267 | + const actions = []; |
| 268 | + if (member.stateMeta.state === 'stopped') { |
| 269 | + actions.push({ action: 'start', label: 'Start' }); |
| 270 | + } else if (member.stateMeta.state === 'paused') { |
| 271 | + actions.push({ action: 'resume', label: 'Resume' }); |
| 272 | + actions.push({ action: 'stop', label: 'Stop' }); |
| 273 | + } else { |
| 274 | + actions.push({ action: 'stop', label: 'Stop' }); |
| 275 | + actions.push({ action: 'pause', label: 'Pause' }); |
| 276 | + } |
| 277 | + if (member.id) { |
| 278 | + actions.push({ action: 'restart', label: 'Restart' }); |
| 279 | + } |
| 280 | + return actions.map((entry) => ` |
| 281 | + <button type="button" class="fv-docker-command-member-menu-button" data-fv-command-member-action="${escapeHtml(entry.action)}" data-member-name="${escapeHtml(member.name)}"> |
| 282 | + ${escapeHtml(entry.label)} |
| 283 | + </button> |
| 284 | + `).join(''); |
| 285 | + }; |
| 286 | + |
221 | 287 | const computeOrderedFolderIds = (folders, prefs, hostOrder, unraidOrder) => { |
222 | 288 | const folderMap = folders && typeof folders === 'object' ? folders : {}; |
223 | 289 | const baseOrder = reorderFolderSlotsInBaseOrder(unraidOrder, folderMap, prefs); |
|
261 | 327 | const branchContainers = getScopedRuntimeContainersForFolder(folderId, true) || {}; |
262 | 328 | const members = Object.entries(branchContainers).map(([name, entry]) => ({ |
263 | 329 | name, |
| 330 | + id: String(entry?.id || entry?.shortId || '').trim(), |
264 | 331 | icon: sanitizeImageSrc(entry?.icon || DOCKER_ICON_FALLBACK, DOCKER_ICON_FALLBACK), |
| 332 | + webuiUrl: getSafeWebuiUrl(entry?.webui || entry?.info?.State?.WebUi || entry?.info?.State?.TSWebUi || ''), |
| 333 | + shell: String(entry?.shell || entry?.info?.Shell || '/bin/sh').trim() || '/bin/sh', |
265 | 334 | stateMeta: getContainerStateMeta(entry), |
266 | 335 | updateReady: containerHasUpdate(entry), |
267 | 336 | managed: entry?.managed === true || entry?.manager === 'dockerman' |
|
357 | 426 | card.childCount > 0 ? `${card.childCount} child folders` : '' |
358 | 427 | ].filter(Boolean).join(' • '); |
359 | 428 | const memberTiles = card.members.map((member) => ` |
360 | | - <div class="fv-docker-command-member-tile ${escapeHtml(member.stateMeta.state)}${member.updateReady ? ' has-update' : ''}"> |
361 | | - <span class="fv-docker-command-member-icon-wrap"> |
362 | | - <img src="${member.icon}" class="fv-docker-command-member-icon" alt="" loading="lazy" onerror='this.src="${DOCKER_ICON_FALLBACK}"'> |
363 | | - </span> |
364 | | - <span class="fv-docker-command-member-content"> |
365 | | - <span class="fv-docker-command-member-pill">${escapeHtml(member.name)}</span> |
366 | | - <span class="fv-docker-command-member-meta"> |
367 | | - <span class="fv-docker-command-member-state ${escapeHtml(member.stateMeta.state)}"> |
368 | | - <i class="fa ${escapeHtml(member.stateMeta.icon)}" aria-hidden="true"></i> |
369 | | - <span>${escapeHtml(member.stateMeta.label)}</span> |
| 429 | + <div class="fv-docker-command-member-tile ${escapeHtml(member.stateMeta.state)}${member.updateReady ? ' has-update' : ''}${expandedMemberKey === getMemberKey(card.folderId, member.name) ? ' is-expanded' : ''}" data-member-name="${escapeHtml(member.name)}" data-member-id="${escapeHtml(member.id)}" data-member-webui-url="${escapeHtml(member.webuiUrl)}" data-member-shell="${escapeHtml(member.shell)}"> |
| 430 | + <button type="button" class="fv-docker-command-member-surface" data-fv-command-member-action="toggle-menu" data-member-name="${escapeHtml(member.name)}"> |
| 431 | + <span class="fv-docker-command-member-icon-wrap"> |
| 432 | + <img src="${member.icon}" class="fv-docker-command-member-icon" alt="" loading="lazy" onerror='this.src="${DOCKER_ICON_FALLBACK}"'> |
| 433 | + </span> |
| 434 | + <span class="fv-docker-command-member-content"> |
| 435 | + <span class="fv-docker-command-member-pill">${escapeHtml(member.name)}</span> |
| 436 | + <span class="fv-docker-command-member-meta"> |
| 437 | + <span class="fv-docker-command-member-state ${escapeHtml(member.stateMeta.state)}"> |
| 438 | + <i class="fa ${escapeHtml(member.stateMeta.icon)}" aria-hidden="true"></i> |
| 439 | + <span>${escapeHtml(member.stateMeta.label)}</span> |
| 440 | + </span> |
| 441 | + ${member.updateReady ? '<span class="fv-docker-command-member-update">update ready</span>' : ''} |
370 | 442 | </span> |
371 | | - ${member.updateReady ? '<span class="fv-docker-command-member-update">update ready</span>' : ''} |
372 | 443 | </span> |
373 | | - </span> |
| 444 | + <span class="fv-docker-command-member-quick-actions"> |
| 445 | + ${member.webuiUrl ? `<button type="button" class="fv-docker-command-member-icon-button" title="Open WebUI" aria-label="Open WebUI" data-fv-command-member-action="webui" data-member-name="${escapeHtml(member.name)}"><i class="fa fa-globe" aria-hidden="true"></i></button>` : ''} |
| 446 | + <button type="button" class="fv-docker-command-member-icon-button" title="Open console" aria-label="Open console" data-fv-command-member-action="console" data-member-name="${escapeHtml(member.name)}"><i class="fa fa-terminal" aria-hidden="true"></i></button> |
| 447 | + <button type="button" class="fv-docker-command-member-icon-button" title="Open logs" aria-label="Open logs" data-fv-command-member-action="logs" data-member-name="${escapeHtml(member.name)}"><i class="fa fa-bars" aria-hidden="true"></i></button> |
| 448 | + </span> |
| 449 | + </button> |
| 450 | + <div class="fv-docker-command-member-menu"> |
| 451 | + ${buildMemberMenuButtons(member)} |
| 452 | + </div> |
374 | 453 | </div> |
375 | 454 | `).join(''); |
376 | 455 | return ` |
|
427 | 506 | rootNode.removeEventListener('click', clickHandler); |
428 | 507 | } |
429 | 508 | clickHandler = (event) => { |
| 509 | + const memberButton = event.target instanceof Element |
| 510 | + ? event.target.closest('[data-fv-command-member-action]') |
| 511 | + : null; |
| 512 | + if (memberButton instanceof HTMLElement) { |
| 513 | + event.preventDefault(); |
| 514 | + event.stopPropagation(); |
| 515 | + const memberAction = String(memberButton.getAttribute('data-fv-command-member-action') || '').trim(); |
| 516 | + const memberTile = memberButton.closest('.fv-docker-command-member-tile'); |
| 517 | + const folderCard = memberButton.closest('[data-folder-id]'); |
| 518 | + const folderId = folderCard instanceof HTMLElement ? String(folderCard.getAttribute('data-folder-id') || '').trim() : ''; |
| 519 | + const memberName = memberTile instanceof HTMLElement ? String(memberTile.getAttribute('data-member-name') || '').trim() : ''; |
| 520 | + const memberId = memberTile instanceof HTMLElement ? String(memberTile.getAttribute('data-member-id') || '').trim() : ''; |
| 521 | + const memberWebuiUrl = memberTile instanceof HTMLElement ? String(memberTile.getAttribute('data-member-webui-url') || '').trim() : ''; |
| 522 | + const memberShell = memberTile instanceof HTMLElement ? String(memberTile.getAttribute('data-member-shell') || '').trim() || '/bin/sh' : '/bin/sh'; |
| 523 | + if (!folderId || !memberName) { |
| 524 | + return; |
| 525 | + } |
| 526 | + const memberKey = getMemberKey(folderId, memberName); |
| 527 | + if (memberAction === 'toggle-menu') { |
| 528 | + expandedMemberKey = expandedMemberKey === memberKey ? '' : memberKey; |
| 529 | + rootNode.querySelectorAll('.fv-docker-command-member-tile.is-expanded').forEach((node) => { |
| 530 | + if (node !== memberTile) { |
| 531 | + node.classList.remove('is-expanded'); |
| 532 | + } |
| 533 | + }); |
| 534 | + if (memberTile instanceof HTMLElement) { |
| 535 | + memberTile.classList.toggle('is-expanded', expandedMemberKey === memberKey); |
| 536 | + } |
| 537 | + return; |
| 538 | + } |
| 539 | + if (memberAction === 'webui') { |
| 540 | + openWebuiInNewTab(memberWebuiUrl); |
| 541 | + return; |
| 542 | + } |
| 543 | + if (memberAction === 'console') { |
| 544 | + openTerminal('docker', memberName, memberShell); |
| 545 | + return; |
| 546 | + } |
| 547 | + if (memberAction === 'logs') { |
| 548 | + openTerminal('docker', memberName, '.log'); |
| 549 | + return; |
| 550 | + } |
| 551 | + if (memberAction === 'start' || memberAction === 'stop' || memberAction === 'pause' || memberAction === 'resume' || memberAction === 'restart') { |
| 552 | + if (dispatchContainerControl(memberAction, memberId)) { |
| 553 | + queueLoadlistRefresh({ suppressLoadingUi: true }); |
| 554 | + } |
| 555 | + return; |
| 556 | + } |
| 557 | + } |
430 | 558 | const button = event.target instanceof Element |
431 | 559 | ? event.target.closest('[data-fv-command-action]') |
432 | 560 | : null; |
|
466 | 594 | return; |
467 | 595 | } |
468 | 596 | if (action === 'open-webui') { |
469 | | - openFolderWebuisFromMenu(folderId, true, true); |
| 597 | + if (!openFolderCardWebuis(folderCard)) { |
| 598 | + openFolderWebuisFromMenu(folderId, true, true); |
| 599 | + } |
470 | 600 | return; |
471 | 601 | } |
472 | 602 | if (action === 'edit-folder') { |
|
0 commit comments