Cockpit UX: auto-open browser, Replay button, graceful serialization

Make the live cockpit foolproof to run:
- Auto-open the default browser on start (macOS/Linux/Windows; --no-open to
  disable). Binds the listener first, so the browser only opens once the server
  is accepting connections.
- Clear message when the port is already in use instead of a raw bind error.
- A prominent "▶ Run the election again" button appears when the ceremony
  finishes, so replaying needs no arcane page-reload knowledge.
- Second viewer now waits gracefully (mutex) instead of getting a 409.

Verified in-browser: replay button shows on completion; port-busy prints a
friendly message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
saymrwulf 2026-07-07 17:13:20 +02:00
parent e8b4247089
commit a1e4ec6ce7
2 changed files with 50 additions and 17 deletions

View file

@ -6,8 +6,11 @@ import (
"fmt"
"io/fs"
"math/big"
"net"
"net/http"
"sync/atomic"
"os/exec"
"runtime"
"sync"
"time"
"github.com/spf13/cobra"
@ -22,7 +25,8 @@ var (
cockpitOptions int
cockpitDelayMs int
cockpitTmux bool
cockpitRunning atomic.Bool
cockpitNoOpen bool
cockpitMu sync.Mutex // serializes ceremonies (global trace context)
)
var cockpitCmd = &cobra.Command{
@ -53,6 +57,7 @@ func init() {
cockpitCmd.Flags().IntVar(&cockpitOptions, "options", 3, "Number of voting options")
cockpitCmd.Flags().IntVar(&cockpitDelayMs, "delay", 350, "Milliseconds between events (pacing so it's watchable)")
cockpitCmd.Flags().BoolVar(&cockpitTmux, "tmux", false, "Terminal mode: one tmux pane per stakeholder instead of the browser")
cockpitCmd.Flags().BoolVar(&cockpitNoOpen, "no-open", false, "Do not open the browser automatically")
rootCmd.AddCommand(cockpitCmd)
}
@ -83,19 +88,44 @@ func runCockpit() error {
mux.HandleFunc("/events", cockpitEventsHandler)
srv := &http.Server{
Addr: fmt.Sprintf(":%d", cockpitPort),
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
// Bind the listener first so we only open the browser once we're accepting
// connections — and so a busy port fails with a clear message, not a race.
ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", cockpitPort))
if err != nil {
return fmt.Errorf("cannot listen on port %d (is another cockpit already running?): %w", cockpitPort, err)
}
url := fmt.Sprintf("http://localhost:%d/", cockpitPort)
fmt.Println("========================================")
fmt.Println(" Swiss Post E-Voting — Live Crypto Cockpit")
fmt.Println("========================================")
fmt.Printf(" Open %s in your browser.\n", url)
fmt.Printf(" Watching at %s\n", url)
fmt.Printf(" Voters: %d, Options: %d, pacing: %dms/event\n", cockpitVoters, cockpitOptions, cockpitDelayMs)
fmt.Println(" The election runs when the page connects; every crypto operation")
fmt.Println(" is rendered as typeset math with its real live values.")
return srv.ListenAndServe()
fmt.Println(" The election plays automatically when the page opens.")
fmt.Println(" Press Ctrl+C here to stop.")
if !cockpitNoOpen {
go openBrowser(url)
}
return srv.Serve(ln)
}
// openBrowser opens url in the default browser, cross-platform. Best-effort.
func openBrowser(url string) {
time.Sleep(300 * time.Millisecond) // let Serve settle
var cmd string
var args []string
switch runtime.GOOS {
case "darwin":
cmd = "open"
case "windows":
cmd, args = "rundll32", []string{"url.dll,FileProtocolHandler"}
default:
cmd = "xdg-open"
}
_ = exec.Command(cmd, append(args, url)...).Start()
}
// cockpitEventsHandler streams one ceremony's crypto events as Server-Sent
@ -108,12 +138,10 @@ func cockpitEventsHandler(w http.ResponseWriter, r *http.Request) {
return
}
// The trace stream and its party/phase context are global, so only one
// ceremony may run at a time (this is a single-viewer desktop tool).
if !cockpitRunning.CompareAndSwap(false, true) {
http.Error(w, "a ceremony is already streaming; reload after it finishes", http.StatusConflict)
return
}
defer cockpitRunning.Store(false)
// ceremony runs at a time. A second viewer waits gracefully for the first to
// finish rather than getting an error (single-viewer desktop tool).
cockpitMu.Lock()
defer cockpitMu.Unlock()
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")

View file

@ -51,8 +51,11 @@
.k-proof{border-left-color:var(--accent)} .k-proof .badge{background:var(--accent)}
.k-verify{border-left-color:var(--ok)} .k-verify .badge{background:var(--ok)}
.k-note .badge{background:var(--dim)}
#status{padding:10px 22px;border-top:1px solid var(--line);color:var(--dim);font-size:13px;background:var(--soft);}
#status{padding:10px 22px;border-top:1px solid var(--line);color:var(--dim);font-size:13px;background:var(--soft);display:flex;align-items:center;gap:14px;}
.done-banner{color:var(--ok);font-weight:700;}
#replay{margin-left:auto;display:none;border:1px solid var(--accent);background:var(--accent);color:#fff;font:600 13px inherit;padding:6px 16px;border-radius:999px;cursor:pointer;}
#replay:hover{background:#0a5bc4;}
#replay.show{display:inline-block;}
</style>
</head>
<body>
@ -65,7 +68,7 @@
<aside id="parties"><h2>Stakeholders</h2><div id="partyList"></div></aside>
<section id="feed"></section>
</main>
<div id="status">Connecting…</div>
<div id="status"><span id="statusText">Starting the election…</span><button id="replay" onclick="location.reload()">▶ Run the election again</button></div>
<script>
"use strict";
@ -200,7 +203,8 @@ const PARTY_ORDER=["setup-component","control-component-0","control-component-1"
"control-component-2","control-component-3","electoral-board","voting-server","verifier"];
const PHASES=["setup","cards","voting","tally","verify"];
const feed=document.getElementById("feed");
const statusEl=document.getElementById("status");
const statusEl=document.getElementById("statusText");
const replayBtn=document.getElementById("replay");
const partyList=document.getElementById("partyList");
const timeline=document.getElementById("timeline");
const partyEls={};
@ -267,8 +271,9 @@ es.addEventListener("op",ev=>{ const e=JSON.parse(ev.data); count++; addOp(e);
es.addEventListener("done",ev=>{ markLive(null);
for(const s of timeline.children) s.className="phase done";
statusEl.innerHTML='<span class="done-banner">✓ Election complete and verified.</span> '+count+' cryptographic operations rendered live.';
replayBtn.classList.add("show");
es.close(); });
es.onerror=()=>{ if(count===0) statusEl.textContent="Stream ended, or a run is already in progress — reload to restart."; };
es.onerror=()=>{ if(count===0) statusEl.textContent="Waiting for the election to start…"; };
</script>
</body>
</html>