Skip to content

Commit 377e697

Browse files
author
lvsi
committed
fix: fix web
1 parent ad5ea11 commit 377e697

2 files changed

Lines changed: 202 additions & 9 deletions

File tree

.work/web/index.html

Lines changed: 144 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,9 +291,71 @@
291291
}
292292

293293
/* 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; }
295295
.img-cloud.yes { background: var(--blue-bg); color: var(--blue); border: 1px solid rgba(9,105,218,0.3); }
296296
.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; }
297359
</style>
298360
</head>
299361
<body>
@@ -546,22 +608,43 @@
546608
const localTxt = e.local ? '本地已有' : '未拉取';
547609
let cloudBadge = '';
548610
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+
549618
// Check if we already have a result for this image
550619
const existingResult = checkedCloudImages[e.repo];
551620
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>`;
553623
} 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>`;
555628
} else {
556629
// Show loading badge, will auto-check later
557630
cloudBadge = `<span class="img-cloud no" id="cloud-${idx}" style="cursor:wait;">检查中...</span>`;
558631
cloudImages.push({repo: e.repo, idx: idx});
559632
}
560633
}
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+
}
561643
return `<div class="image-row">
562644
<span class="img-kind ${kind}">${kind}</span>
563645
<span class="img-repo" title="${esc(e.repo)}">${esc(e.repo)}</span>
564646
<span class="img-local ${localCls}">${localTxt}</span>
647+
${officialHint}
565648
${cloudBadge}
566649
</div>`;
567650
}).join('');
@@ -769,12 +852,24 @@
769852
const d = await r.json();
770853
if (d.ok) {
771854
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>`;
774857
checkedCloudImages[imageRepo] = true;
775858
} 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+
}
778873
checkedCloudImages[imageRepo] = false;
779874
}
780875
} else {
@@ -789,6 +884,48 @@
789884
}
790885
}
791886

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+
792929
function esc(s) {
793930
return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
794931
}

.work/web/server.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -200,8 +200,8 @@ def get_cloud_image_status(dockerhub_repo_no_comment, image_repo):
200200
print(f"[DEBUG] stdout: {result.stdout}")
201201
print(f"[DEBUG] stderr: {result.stderr}")
202202
print(f"[DEBUG] returncode: {result.returncode}")
203-
# Check output for "find it"
204-
found = "find it" in result.stdout
203+
# Check output for "find it:" but exclude "not find it:" (false positive)
204+
found = "find it:" in result.stdout and "not find it:" not in result.stdout
205205
print(f"[DEBUG] Found: {found}")
206206
return found
207207
except Exception as e:
@@ -493,6 +493,62 @@ def do_POST(self):
493493
self.send_header("Content-Length", len(resp))
494494
self.end_headers()
495495
self.wfile.write(resp)
496+
elif path == "/api/upload-image":
497+
# Push a local sparrow-{kind}-{service}:{version} image to the configured
498+
# DockerHub repo. Wraps `./sparrowtool upload -t {kind} -s {service} -v
499+
# {version} -r true`. -r true is used so the remote tag matches the local
500+
# version (otherwise sparrowtool appends a timestamp suffix), which is
501+
# what the dashboard's "已推送/未推送" check inspects.
502+
length = int(self.headers.get("Content-Length", 0))
503+
body = self.rfile.read(length)
504+
try:
505+
req = json.loads(body)
506+
kind = req.get("kind", "").strip().lower()
507+
service = req.get("service", "").strip()
508+
version = req.get("version", "").strip()
509+
if kind not in {"basic", "app"}:
510+
raise ValueError("kind must be basic or app")
511+
if not service or not re.match(r'^[a-zA-Z0-9_-]+$', service):
512+
raise ValueError("invalid service")
513+
# Version allows dots and a few extra chars commonly seen in semver
514+
# tags (e.g. 1.0.0-alpha, latest, 8.0).
515+
if not version or not re.match(r'^[a-zA-Z0-9._-]+$', version):
516+
raise ValueError("invalid version")
517+
518+
sparrowtool = os.path.join(BASE_PATH, "sparrowtool")
519+
cmd = [sparrowtool, "upload", "-t", kind, "-s", service, "-v", version, "-r", "true"]
520+
label = f"[web] ./sparrowtool upload -t {kind} -s {service} -v {version} -r true"
521+
print(f"\n{'─'*60}")
522+
print(f"▶ {label}")
523+
print(f"{'─'*60}", flush=True)
524+
proc = subprocess.Popen(
525+
cmd,
526+
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
527+
text=True, cwd=BASE_PATH
528+
)
529+
output_lines = []
530+
for line in proc.stdout:
531+
line_stripped = line.rstrip("\n")
532+
print(line_stripped, flush=True)
533+
output_lines.append(line_stripped)
534+
proc.wait()
535+
ok = proc.returncode == 0
536+
print(f"{'─'*60}")
537+
print(f"{'✓' if ok else '✗'} {label} (exit {proc.returncode})")
538+
print(f"{'─'*60}\n", flush=True)
539+
output_text = "\n".join(output_lines[-100:])
540+
resp = json.dumps({
541+
"ok": ok,
542+
"stdout": output_text,
543+
"error": "" if ok else (output_lines[-1] if output_lines else f"exit code {proc.returncode}"),
544+
}, ensure_ascii=False).encode("utf-8")
545+
except Exception as e:
546+
resp = json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False).encode("utf-8")
547+
self.send_response(200)
548+
self.send_header("Content-Type", "application/json; charset=utf-8")
549+
self.send_header("Content-Length", len(resp))
550+
self.end_headers()
551+
self.wfile.write(resp)
496552
else:
497553
self.send_response(404)
498554
self.end_headers()

0 commit comments

Comments
 (0)