|
291 | 291 | } |
292 | 292 |
|
293 | 293 | /* Cloud status badge */ |
294 | | -.img-cloud { font-size: 11px; padding: 2px 8px; border-radius: 4px; flex-shrink: 0; font-weight: 600; } |
| 294 | +.img-cloud { font-size: 11px; padding: 2px 8px; border-radius: 4px; flex-shrink: 0; font-weight: 600; cursor: default; } |
295 | 295 | .img-cloud.yes { background: var(--blue-bg); color: var(--blue); border: 1px solid rgba(9,105,218,0.3); } |
296 | 296 | .img-cloud.no { background: #fff; color: var(--text3); border: 1px solid var(--border); } |
| 297 | + |
| 298 | +/* Cloud push button (shown when image is not pushed yet) */ |
| 299 | +.img-cloud-btn { |
| 300 | + font-size: 11px; |
| 301 | + padding: 2px 10px; |
| 302 | + border-radius: 4px; |
| 303 | + flex-shrink: 0; |
| 304 | + font-weight: 600; |
| 305 | + cursor: pointer; |
| 306 | + background: var(--blue); |
| 307 | + color: #fff; |
| 308 | + border: 1px solid var(--blue); |
| 309 | + font-family: inherit; |
| 310 | + line-height: 1.4; |
| 311 | + transition: background 0.15s, transform 0.05s; |
| 312 | +} |
| 313 | +.img-cloud-btn:hover:not(:disabled) { background: #0860c5; } |
| 314 | +.img-cloud-btn:active:not(:disabled) { transform: translateY(1px); } |
| 315 | +.img-cloud-btn:disabled { background: #95a5a6; border-color: #95a5a6; cursor: wait; } |
| 316 | + |
| 317 | +/* Info hint icon (ⓘ) — hover for tooltip via data-tip */ |
| 318 | +.img-hint { |
| 319 | + position: relative; |
| 320 | + display: inline-flex; |
| 321 | + align-items: center; |
| 322 | + justify-content: center; |
| 323 | + width: 16px; |
| 324 | + height: 16px; |
| 325 | + font-size: 12px; |
| 326 | + font-weight: 700; |
| 327 | + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; |
| 328 | + color: var(--text3); |
| 329 | + border: 1px solid var(--border); |
| 330 | + border-radius: 50%; |
| 331 | + cursor: help; |
| 332 | + flex-shrink: 0; |
| 333 | + user-select: none; |
| 334 | +} |
| 335 | +.img-hint:hover { color: var(--blue); border-color: var(--blue); } |
| 336 | +.img-hint::after { |
| 337 | + content: attr(data-tip); |
| 338 | + position: absolute; |
| 339 | + bottom: calc(100% + 8px); |
| 340 | + right: 0; |
| 341 | + width: max-content; |
| 342 | + max-width: 360px; |
| 343 | + padding: 8px 10px; |
| 344 | + background: #24292f; |
| 345 | + color: #fff; |
| 346 | + font-size: 12px; |
| 347 | + font-weight: 400; |
| 348 | + line-height: 1.5; |
| 349 | + border-radius: 6px; |
| 350 | + box-shadow: 0 4px 12px rgba(0,0,0,0.15); |
| 351 | + white-space: normal; |
| 352 | + text-align: left; |
| 353 | + opacity: 0; |
| 354 | + pointer-events: none; |
| 355 | + transition: opacity 0.15s ease; |
| 356 | + z-index: 100; |
| 357 | +} |
| 358 | +.img-hint:hover::after { opacity: 1; } |
297 | 359 | </style> |
298 | 360 | </head> |
299 | 361 | <body> |
|
546 | 608 | const localTxt = e.local ? '本地已有' : '未拉取'; |
547 | 609 | let cloudBadge = ''; |
548 | 610 | if (e.cloud_checkable) { |
| 611 | + // Parse "sparrow-{kind}-{svc}:{version}" to extract the version part for |
| 612 | + // the upload command. e.repo is guaranteed by the backend for basic/app. |
| 613 | + const colonIdx = e.repo.lastIndexOf(':'); |
| 614 | + const version = colonIdx >= 0 ? e.repo.slice(colonIdx + 1) : 'latest'; |
| 615 | + const svcAttr = esc(s.name); |
| 616 | + const verAttr = esc(version); |
| 617 | + |
549 | 618 | // Check if we already have a result for this image |
550 | 619 | const existingResult = checkedCloudImages[e.repo]; |
551 | 620 | if (existingResult === true) { |
552 | | - cloudBadge = `<span class="img-cloud yes" id="cloud-${idx}" style="cursor:pointer;">已推送</span>`; |
| 621 | + // Already pushed — pure status badge, default cursor (not clickable). |
| 622 | + cloudBadge = `<span class="img-cloud yes" id="cloud-${idx}">已推送</span>`; |
553 | 623 | } else if (existingResult === false) { |
554 | | - cloudBadge = `<span class="img-cloud no" id="cloud-${idx}" style="cursor:pointer;">未推送</span>`; |
| 624 | + // Not pushed — actionable push button. |
| 625 | + cloudBadge = `<button class="img-cloud-btn" id="cloud-${idx}" |
| 626 | + onclick="uploadImage(event, '${kind}', '${svcAttr}', '${verAttr}', ${idx})" |
| 627 | + title="点击推送到 ${esc(e.repo)}">↑ 推送</button>`; |
555 | 628 | } else { |
556 | 629 | // Show loading badge, will auto-check later |
557 | 630 | cloudBadge = `<span class="img-cloud no" id="cloud-${idx}" style="cursor:wait;">检查中...</span>`; |
558 | 631 | cloudImages.push({repo: e.repo, idx: idx}); |
559 | 632 | } |
560 | 633 | } |
| 634 | + // OFFICIAL images often show "未拉取" even when actually present, because |
| 635 | + // docker build pulls them into the BuildKit cache or references them as a |
| 636 | + // base layer without giving them an explicit tag. The hint explains this |
| 637 | + // so users don't mistake it for a real problem. |
| 638 | + let officialHint = ''; |
| 639 | + if (kind === 'official' && !e.local) { |
| 640 | + const tip = '“未拉取”不表示本地真的没有。当 docker build 时,official 属于构建依赖,要么被 BuildKit 藏在 builder cache 里,要么作为基础层被引用但没有打上显式 tag,所以 docker images 里看不到。'; |
| 641 | + officialHint = `<span class="img-hint" data-tip="${esc(tip)}">i</span>`; |
| 642 | + } |
561 | 643 | return `<div class="image-row"> |
562 | 644 | <span class="img-kind ${kind}">${kind}</span> |
563 | 645 | <span class="img-repo" title="${esc(e.repo)}">${esc(e.repo)}</span> |
564 | 646 | <span class="img-local ${localCls}">${localTxt}</span> |
| 647 | + ${officialHint} |
565 | 648 | ${cloudBadge} |
566 | 649 | </div>`; |
567 | 650 | }).join(''); |
|
769 | 852 | const d = await r.json(); |
770 | 853 | if (d.ok) { |
771 | 854 | if (d.exists) { |
772 | | - badge.textContent = '已推送'; |
773 | | - badge.className = 'img-cloud yes'; |
| 855 | + // Already pushed — pure status badge, default cursor. |
| 856 | + badge.outerHTML = `<span class="img-cloud yes" id="cloud-${idx}">已推送</span>`; |
774 | 857 | checkedCloudImages[imageRepo] = true; |
775 | 858 | } else { |
776 | | - badge.textContent = '未推送'; |
777 | | - badge.className = 'img-cloud no'; |
| 859 | + // Not pushed — promote the span to an actionable push button. Parse the |
| 860 | + // image repo "sparrow-{kind}-{svc}:{version}" to derive upload args. |
| 861 | + const m = imageRepo.match(/^sparrow-(basic|app)-(.+):(.+)$/); |
| 862 | + if (m) { |
| 863 | + const kind = m[1]; |
| 864 | + const svc = m[2]; |
| 865 | + const version = m[3]; |
| 866 | + badge.outerHTML = `<button class="img-cloud-btn" id="cloud-${idx}" |
| 867 | + onclick="uploadImage(event, '${kind}', '${esc(svc)}', '${esc(version)}', ${idx})" |
| 868 | + title="点击推送到 ${esc(imageRepo)}">↑ 推送</button>`; |
| 869 | + } else { |
| 870 | + // Fallback if parsing fails — keep informational badge. |
| 871 | + badge.outerHTML = `<span class="img-cloud no" id="cloud-${idx}">未推送</span>`; |
| 872 | + } |
778 | 873 | checkedCloudImages[imageRepo] = false; |
779 | 874 | } |
780 | 875 | } else { |
|
789 | 884 | } |
790 | 885 | } |
791 | 886 |
|
| 887 | +async function uploadImage(event, kind, service, version, idx) { |
| 888 | + // Push a local sparrow-{kind}-{service}:{version} image to the configured |
| 889 | + // DockerHub repo via the /api/upload-image backend. The button shows loading |
| 890 | + // state during the push, then swaps itself for the "已推送" status badge on |
| 891 | + // success or reverts to a push button on failure. |
| 892 | + if (event) event.stopPropagation(); |
| 893 | + const btn = document.getElementById(`cloud-${idx}`); |
| 894 | + if (!btn) return; |
| 895 | + const imageRepo = `sparrow-${kind}-${service}:${version}`; |
| 896 | + |
| 897 | + // Confirm before pushing — pushes are slow, network-heavy, and visible to |
| 898 | + // anyone watching the configured DockerHub repo. |
| 899 | + if (!confirm(`推送 ${imageRepo} 到 DockerHub?`)) return; |
| 900 | + |
| 901 | + const origHtml = btn.outerHTML; |
| 902 | + btn.disabled = true; |
| 903 | + btn.textContent = '推送中...'; |
| 904 | + btn.style.cursor = 'wait'; |
| 905 | + |
| 906 | + try { |
| 907 | + const r = await fetch('/api/upload-image', { |
| 908 | + method: 'POST', |
| 909 | + headers: {'Content-Type': 'application/json'}, |
| 910 | + body: JSON.stringify({kind, service, version}) |
| 911 | + }); |
| 912 | + const d = await r.json(); |
| 913 | + if (d.ok) { |
| 914 | + showToast('✓ ' + imageRepo + ' 推送成功', 'ok'); |
| 915 | + checkedCloudImages[imageRepo] = true; |
| 916 | + // Replace the button with the "已推送" status badge. |
| 917 | + btn.outerHTML = `<span class="img-cloud yes" id="cloud-${idx}">已推送</span>`; |
| 918 | + } else { |
| 919 | + showToast('✗ 推送失败: ' + (d.error || '未知错误'), 'err'); |
| 920 | + // Restore the push button so the user can retry. |
| 921 | + btn.outerHTML = origHtml; |
| 922 | + } |
| 923 | + } catch (e) { |
| 924 | + showToast('✗ 请求失败: ' + e.message, 'err'); |
| 925 | + btn.outerHTML = origHtml; |
| 926 | + } |
| 927 | +} |
| 928 | + |
792 | 929 | function esc(s) { |
793 | 930 | return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); |
794 | 931 | } |
|
0 commit comments