fuzzyworld-tools / commit
CI pages: countdown bar and "Next reload in N s." (CSS only)
README.md | 4 +++- server/gen.py | 27 +++++++++++++++++++++++++-- tests/test_gen.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index d9c1a8f..b630563 100644 --- a/README.md +++ b/README.md @@ -235,7 +235,9 @@ coordinator service; the coordinator is files on DreamHost. **Watching CI:** `code.fuzzyworld.net/ci/` shows what is running (each job ✓/✗/● with its time), what is queued and the last 50 results. While anything is queued or running, that page and the running pipeline's page reload -themselves every 15 seconds (`<meta http-equiv="refresh">`; no JavaScript). Private repos are never named there, +themselves every 15 seconds (`<meta http-equiv="refresh">`; no JavaScript). A thin bar along the top empties as the reload nears, and "Next reload in N s." +counts down beside the reload note, both done in CSS (the site allows no scripts; the countdown needs a browser with +`@property`, otherwise only the bar shows; the bar is hidden for `prefers-reduced-motion`). Private repos are never named there, only counted. In the terminal, `fw ci-watch` shows everything, private repos included. **Job definitions: `.fw-ci.yml`**, at the repo root, in a subset of GitLab CI's YAML format, so an old diff --git a/server/gen.py b/server/gen.py index eacb455..e6e3789 100755 --- a/server/gen.py +++ b/server/gen.py @@ -243,11 +243,34 @@ def page(title, body, crumbs=None, view=None, site="code", refresh=None, section {body} </main> {footer} +{reload_timer(refresh)} </body> </html> """ +def reload_timer(refresh): + """A thin bar along the top that empties as the page's meta refresh nears, and a countdown in + any `.reload-in` element (user request, 2026-11-03: the CI page said it reloads every 15 + seconds but not when). Pure CSS, because the site's CSP is `script-src 'none'`: the countdown is + an animated integer property shown through a counter. Starts at page load, as the refresh does. + Browsers without @property support show the bar only.""" + if not refresh: + return "" + n = int(refresh) + return f"""<div class="reload-bar" aria-hidden="true"></div> +<style> +.reload-bar {{ position: fixed; top: 0; left: 0; height: 3px; width: 100%; background: currentColor; opacity: .45; + transform-origin: left; animation: reload-bar {n}s linear forwards; }} +@keyframes reload-bar {{ from {{ transform: scaleX(1); }} to {{ transform: scaleX(0); }} }} +@property --reload-left {{ syntax: "<integer>"; inherits: false; initial-value: {n}; }} +@keyframes reload-count {{ from {{ --reload-left: {n}; }} to {{ --reload-left: 0; }} }} +.reload-in::before {{ content: "Next reload in " counter(reload-left) " s."; counter-reset: reload-left var(--reload-left); + animation: reload-count {n}s steps({n}) forwards; }} +@media (prefers-reduced-motion: reduce) {{ .reload-bar {{ display: none; }} }} +</style>""" + + def write(path, content): os.makedirs(os.path.dirname(path), exist_ok=True) tmp = path + ".tmp" @@ -393,7 +416,7 @@ def build_ci_pages(view): f'<p class="meta">Branch <code>{esc(p.get("ref", ""))}</code> · ' f'<a href="{url(name, "commit", sha)}.html">commit</a> · started {esc(p.get("started", "").replace("T", " ").replace("Z", " UTC"))} · ' f'{"running" if running else duration(p.get("duration"))} · runner {esc(p.get("runner", ""))}</p>' - + (f'<p class="meta">This page reloads every {CI_REFRESH} seconds while the pipeline runs.</p>' if running else "") + + (f'<p class="meta">This page reloads every {CI_REFRESH} seconds while the pipeline runs. <span class="reload-in"></span></p>' if running else "") + (f'<p>{esc(p["message"])}</p>' if p.get("message") else "") + "".join(jobs)) write(page_path, page(f"{name} CI {sha[:12]}", body, [f'<a href="{url(name)}/">{esc(name)}</a>', "CI"], view=view, @@ -512,7 +535,7 @@ def build_ci_dashboard(): notice = runner_notice(pending) body = ('<h1>CI</h1>' '<p class="meta">Pipelines run on whichever runner is available (a home server, sometimes a laptop), one at a time, as pushes queue them. ' - + (f'This page reloads every {CI_REFRESH} seconds while anything is queued or running.' if busy else "Nothing is running.") + + (f'This page reloads every {CI_REFRESH} seconds while anything is queued or running. <span class="reload-in"></span>' if busy else "Nothing is running.") + (f' Also {hidden} job{"s" if hidden != 1 else ""} for private repositories.' if hidden else "") + '</p>' + (f'<p><strong>{esc(notice)}</strong></p>' if notice else "") + (f'<h2>Running</h2><table class="log">{run_rows}</table>' if run_rows else "") diff --git a/tests/test_gen.py b/tests/test_gen.py new file mode 100644 index 0000000..5ffdd8d --- /dev/null +++ b/tests/test_gen.py @@ -0,0 +1,32 @@ +"""Tests for the page furniture in server/gen.py. Run: python3 -m unittest discover tests""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "server")) +import gen # noqa: E402 + + +class ReloadTimer(unittest.TestCase): + def test_refreshing_page_has_meta_refresh_bar_and_countdown(self): + html = gen.page("T", '<p>x <span class="reload-in"></span></p>', refresh=15) + self.assertIn('<meta http-equiv="refresh" content="15">', html) + self.assertIn('class="reload-bar"', html) + self.assertIn("animation: reload-bar 15s linear", html) # the bar's length is the refresh interval + self.assertIn("counter-reset: reload-left var(--reload-left)", html) # the text countdown is CSS-only + self.assertIn("steps(15)", html) # one step a second + self.assertNotIn("<script", html) # the site's CSP is script-src 'none' + self.assertIn("prefers-reduced-motion", html) + + def test_page_without_refresh_has_none_of_it(self): + html = gen.page("T", "<p>x</p>") + for marker in ("http-equiv", "reload-bar", "reload-in"): + self.assertNotIn(marker, html) + + def test_reload_timer_empty_without_interval(self): + self.assertEqual(gen.reload_timer(None), "") + self.assertEqual(gen.reload_timer(0), "") + + +if __name__ == "__main__": + unittest.main()