mirror of
https://github.com/saymrwulf/swisspost-evoting-go-poc.git
synced 2026-11-03 04:41:16 +00:00
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:
parent
e8b4247089
commit
a1e4ec6ce7
2 changed files with 50 additions and 17 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue