Skip to content

Commit ec2829a

Browse files
feat(gui): readable CI progress — full-width bar with %, phase, elapsed, ETA (#265)
The first cut was a 150px "osd" sliver in the row suffix: no numbers, no percent, no time, track barely visible (Daniel, 2026-07-05). Redone: - The bar now sits FULL-WIDTH under its CI row (normal height, no osd), with in-bar text: "NN% · phase · Xm elapsed · ~Ym left". - The ETA is extrapolated from THIS run's measured pace (fraction advanced / elapsed since monitoring started) and only shown once there's honest signal (>=90s and >=3% advanced) — never a made-up countdown. Attaching to a run mid-flight measures pace from the attach point, so the estimate stays real. - Phase labels on every call site: queued / building ISO (zstd-19) / install gate / publishing / uploading to Internet Archive (no % from GitHub — pulsing, with elapsed) / building :candidate / waiting for smoke-boot / smoke-boot + promote / downloading. - Pace state is per-bar and reset on hide, so consecutive runs don't inherit stale ETAs. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent d121e38 commit ec2829a

1 file changed

Lines changed: 67 additions & 25 deletions

File tree

  • tools/iso-builder/mib

tools/iso-builder/mib/ci.py

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -164,21 +164,26 @@ def __init__(self, win):
164164
"CLI authenticated. Keep the window open to be notified.")
165165
page.add(ci)
166166

167-
# A thin progress bar per long-running CI row, driven by real job-step
168-
# data (pulsed through the opaque long steps like the IA upload). Sits
169-
# left of the action button; hidden while idle. id(bar) -> pulse timer.
167+
# A full-width progress bar UNDER each long-running CI row, driven by
168+
# real job-step data. show_text carries "NN% · phase · ~ETA": the ETA
169+
# comes from the measured pace of THIS run (fraction/elapsed), never a
170+
# made-up timer, and the opaque IA-upload step pulses with an honest
171+
# "elapsed" instead of a fake percent. First cut was a 150px "osd"
172+
# sliver in the suffix — unreadable (Daniel, 2026-07-05).
173+
# id(bar) -> pulse-timer source; id(bar) -> {t0, f0} pace state.
170174
self._pulse_ids = {}
175+
self._bar_state = {}
171176

172177
def _ci_row(title, subtitle, btn_label, cb):
173178
row = Adw.ActionRow(title=title, subtitle=subtitle)
174-
bar = Gtk.ProgressBar(valign=Gtk.Align.CENTER, visible=False,
175-
width_request=150)
176-
bar.add_css_class("osd")
177-
row.add_suffix(bar)
178179
btn = Gtk.Button(label=btn_label, valign=Gtk.Align.CENTER)
179180
btn.connect("clicked", cb)
180181
row.add_suffix(btn)
181182
ci.add(row)
183+
bar = Gtk.ProgressBar(show_text=True, hexpand=True, visible=False,
184+
margin_start=12, margin_end=12,
185+
margin_top=2, margin_bottom=8)
186+
ci.add(bar)
182187
return row, btn, bar
183188

184189
self.base_row, self.base_btn, self.base_bar = _ci_row(
@@ -521,17 +526,50 @@ def got(ok, out, _err):
521526
ask()
522527

523528
# -- progress bars -------------------------------------------------------
524-
def _bar_frac(self, bar, f):
525-
"""Show a determinate fraction (stops any pulsing)."""
529+
@staticmethod
530+
def _fmt_dur(seconds):
531+
m = int(seconds // 60)
532+
return f"{m // 60}h {m % 60}m" if m >= 60 else f"{m}m"
533+
534+
def _bar_pace(self, bar, f=None):
535+
"""Per-bar pace state: first call (or re-show) stamps t0 and the
536+
starting fraction, so the ETA reflects THIS run's measured speed."""
537+
st = self._bar_state.get(id(bar))
538+
if st is None or not bar.get_visible():
539+
st = {"t0": GLib.get_monotonic_time() / 1e6,
540+
"f0": f if f is not None else 0.0}
541+
self._bar_state[id(bar)] = st
542+
return st, GLib.get_monotonic_time() / 1e6 - st["t0"]
543+
544+
def _bar_frac(self, bar, f, label=""):
545+
"""Determinate bar with rich in-bar text: 'NN% · label · pace'.
546+
The ETA is extrapolated from this run's own progress rate — shown only
547+
once there's enough signal (>=90s and >=3% advanced) to be honest."""
548+
st, elapsed = self._bar_pace(bar, f)
526549
self._bar_stop_pulse(bar)
550+
f = max(0.0, min(1.0, f))
551+
txt = f"{int(f * 100)}%"
552+
if label:
553+
txt += f" · {label}"
554+
adv = f - st["f0"]
555+
if elapsed >= 90 and adv >= 0.03 and f < 0.995:
556+
eta = (1.0 - f) * (elapsed / adv)
557+
txt += f" · {self._fmt_dur(elapsed)} elapsed · ~{self._fmt_dur(eta)} left"
558+
elif elapsed >= 60:
559+
txt += f" · {self._fmt_dur(elapsed)} elapsed"
527560
bar.set_visible(True)
528-
bar.set_fraction(max(0.0, min(1.0, f)))
529-
530-
def _bar_pulse(self, bar):
531-
"""Show an indeterminate, animating bar (for opaque long steps like the
532-
IA upload, which GitHub exposes no sub-progress for). A 150ms ticker
533-
keeps it lively between the slow 60s poll ticks."""
561+
bar.set_fraction(f)
562+
bar.set_text(txt)
563+
564+
def _bar_pulse(self, bar, label=""):
565+
"""Indeterminate, animating bar for opaque long steps (the IA upload:
566+
GitHub exposes no sub-progress). The text says so honestly — phase +
567+
elapsed — and a 150ms ticker keeps it alive between the 60s polls."""
568+
st, elapsed = self._bar_pace(bar)
534569
bar.set_visible(True)
570+
if label:
571+
bar.set_text(f"{label} · {self._fmt_dur(elapsed)} elapsed"
572+
if elapsed >= 60 else label)
535573
if id(bar) in self._pulse_ids:
536574
return
537575
def _p():
@@ -546,6 +584,7 @@ def _bar_stop_pulse(self, bar):
546584

547585
def _bar_hide(self, bar):
548586
self._bar_stop_pulse(bar)
587+
self._bar_state.pop(id(bar), None)
549588
bar.set_fraction(0.0)
550589
bar.set_visible(False)
551590

@@ -612,10 +651,11 @@ def tick(data):
612651
return True
613652
if data.get("status") != "completed":
614653
bf, _s = _step_frac(_jobdict(data, "")) # primary job's steps
615-
self._bar_frac(self.base_bar, 0.05 + 0.60 * bf) # build owns 5-65%
654+
self._bar_frac(self.base_bar, 0.05 + 0.60 * bf,
655+
"building :candidate") # build owns 5-65%
616656
return True
617657
if data.get("conclusion") == "success":
618-
self._bar_frac(self.base_bar, 0.65)
658+
self._bar_frac(self.base_bar, 0.65, "waiting for smoke-boot")
619659
self.base_row.set_subtitle(":candidate built — waiting for smoke-boot…")
620660
self.win.notify("Base :candidate built",
621661
"QEMU smoke-boot runs next; :stable follows if green.")
@@ -641,7 +681,8 @@ def sb_tick(data):
641681
return True
642682
if data.get("status") != "completed":
643683
sf, _s = _step_frac(_jobdict(data, ""))
644-
self._bar_frac(self.base_bar, 0.65 + 0.35 * sf) # smoke-boot 65-100%
684+
self._bar_frac(self.base_bar, 0.65 + 0.35 * sf,
685+
"smoke-boot + promote") # smoke-boot 65-100%
645686
return True
646687
if data.get("conclusion") == "success":
647688
self._ci_base_done(":stable promoted")
@@ -769,17 +810,18 @@ def tick(data):
769810
if pub_s == "in_progress":
770811
pf, pstep = _step_frac(_jobdict(data, "Publish ISO"))
771812
if any(k in pstep for k in ("Internet Archive", "Upload", "derive")):
772-
self._bar_pulse(self.pub_bar)
813+
self._bar_pulse(self.pub_bar,
814+
"uploading to Internet Archive (no % from GitHub)")
773815
else:
774-
self._bar_frac(self.pub_bar, 0.65 + 0.35 * pf)
816+
self._bar_frac(self.pub_bar, 0.65 + 0.35 * pf, "publishing")
775817
elif iso_s == "completed" and iso_c == "success":
776818
gf, _gs = _step_frac(_jobdict(data, "Automated install gate"))
777-
self._bar_frac(self.pub_bar, 0.50 + 0.15 * gf)
819+
self._bar_frac(self.pub_bar, 0.50 + 0.15 * gf, "install gate")
778820
elif iso_s == "in_progress":
779821
isf, _is = _step_frac(_jobdict(data, "Build Live ISO"))
780-
self._bar_frac(self.pub_bar, 0.50 * isf)
822+
self._bar_frac(self.pub_bar, 0.50 * isf, "building ISO (zstd-19)")
781823
else:
782-
self._bar_frac(self.pub_bar, 0.02) # queued
824+
self._bar_frac(self.pub_bar, 0.02, "queued") # queued
783825

784826
self.pub_row.set_subtitle(
785827
f"run {run_id} · iso: {_state(iso_s, iso_c)} · "
@@ -911,7 +953,7 @@ def progress():
911953
state["rate"] = (0.7 * state["rate"] + 0.3 * inst) if state["prev"] else inst
912954
state["prev"] = have
913955
pct = min(99, int(have * 100 / size)) if size else 0
914-
self._bar_frac(self.dl_bar, (pct or 0) / 100.0)
956+
self._bar_frac(self.dl_bar, (pct or 0) / 100.0, "downloading")
915957
extra = ""
916958
if state["rate"] > 1024:
917959
left = (size - have) / state["rate"] if size else 0
@@ -1063,7 +1105,7 @@ def progress():
10631105
state["rate"] = (0.7 * state["rate"] + 0.3 * inst) if state["prev"] else inst
10641106
state["prev"] = have
10651107
pct = min(99, int(have * 100 / size)) if size else 0
1066-
self._bar_frac(self.dl_bar, (pct or 0) / 100.0)
1108+
self._bar_frac(self.dl_bar, (pct or 0) / 100.0, "downloading")
10671109
extra = ""
10681110
if state["rate"] > 1024:
10691111
left = (size - have) / state["rate"] if size else 0

0 commit comments

Comments
 (0)