swisspost-evoting-go-poc/cmd/evote/panelview.go
saymrwulf b38b990bc4 Add evote cockpit --tmux: one terminal pane per stakeholder
The terminal systems-view half of the cockpit, fed by the SAME trace stream as
the browser math view — no double-maintenance.

- cockpit_tmux.go: spawns a tmux session with one pane per stakeholder (setup,
  CC0–CC3, electoral board, voting server, verifier, and an aggregate voters
  pane), each running `evote panelview`. The ceremony runs in the background,
  appending paced NDJSON events to a shared temp file the panes tail. Guards for
  missing tmux and for being run inside an existing tmux session.
- panelview.go: a hidden subcommand each pane runs — follows the shared event
  file, filters to its stakeholder, and renders that party's operations as
  colored ASCII/Unicode math with elided live values.
- Attribute setup-phase Schnorr challenges to the generating CC and verify-phase
  challenges to the verifier (trace.SetContext), so every pane gets its ops.

Verified: panelview filters and renders correctly per role; a 9-pane tiled tmux
session builds with the real pane commands. (Interactive attach needs a TTY, so
the live attach is exercised when you run it.)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 15:43:05 +02:00

156 lines
4 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/user/evote/pkg/trace"
)
var (
panelRole string
panelFile string
panelWidth int
)
// panelViewCmd is run inside each tmux pane. It follows a shared NDJSON event
// file and renders the operations belonging to one stakeholder as ASCII/Unicode
// math. It is an internal helper for `evote cockpit --tmux`, but works standalone
// against any NDJSON trace file.
var panelViewCmd = &cobra.Command{
Use: "panelview",
Short: "Render one stakeholder's live crypto activity (used by cockpit --tmux)",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
if panelRole == "" || panelFile == "" {
return fmt.Errorf("--role and --file are required")
}
return runPanelView()
},
}
func init() {
panelViewCmd.Flags().StringVar(&panelRole, "role", "", "Stakeholder role to display (or 'voters', 'all')")
panelViewCmd.Flags().StringVar(&panelFile, "file", "", "Shared NDJSON event file to follow")
panelViewCmd.Flags().IntVar(&panelWidth, "width", 0, "Wrap width (0 = terminal default)")
rootCmd.AddCommand(panelViewCmd)
}
// ANSI colors keyed by operation kind.
var kindColor = map[trace.Kind]string{
trace.KindSign: "\x1b[35m", // magenta
trace.KindShuffle: "\x1b[95m", // bright magenta
trace.KindEncrypt: "\x1b[32m", // green
trace.KindDecrypt: "\x1b[36m", // cyan
trace.KindChallenge: "\x1b[33m", // yellow
trace.KindKeyEx: "\x1b[34m", // blue
trace.KindProof: "\x1b[36m",
trace.KindVerify: "\x1b[92m",
trace.KindSample: "\x1b[36m",
}
const (
ansiReset = "\x1b[0m"
ansiBold = "\x1b[1m"
ansiDim = "\x1b[2m"
)
func panelMatches(role, party string) bool {
if role == "all" {
return true
}
if role == "voters" {
return strings.HasPrefix(party, "voter")
}
return party == role
}
func runPanelView() error {
// Header.
title := panelRole
switch {
case panelRole == "voters":
title = "Voters"
case strings.HasPrefix(panelRole, "control-component-"):
title = "CC" + strings.TrimPrefix(panelRole, "control-component-")
default:
title = strings.Title(strings.ReplaceAll(panelRole, "-", " "))
}
fmt.Printf("%s%s▐ %s ▌%s\n", ansiBold, "\x1b[7m", title, ansiReset)
fmt.Printf("%swaiting for activity…%s\n", ansiDim, ansiReset)
f, err := openWithRetry(panelFile, 5*time.Second)
if err != nil {
return err
}
defer f.Close()
reader := bufio.NewReader(f)
shown := 0
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
line = strings.TrimSpace(line)
if line == "" {
continue
}
if line == `{"done":true}` {
fmt.Printf("\n%s%s✓ ceremony complete%s\n", ansiBold, "\x1b[32m", ansiReset)
return nil
}
var e trace.Event
if json.Unmarshal([]byte(line), &e) == nil && panelMatches(panelRole, e.Party) {
if shown == 0 {
fmt.Print("\x1b[1A\x1b[2K") // erase the "waiting…" line
}
printPanelEvent(e)
shown++
}
}
if err != nil { // EOF: wait for more lines
time.Sleep(80 * time.Millisecond)
}
}
}
func printPanelEvent(e trace.Event) {
col := kindColor[e.Kind]
if col == "" {
col = "\x1b[37m"
}
fmt.Printf("%s%s%-9s%s %s#%d%s %s\n", ansiBold, col, strings.ToUpper(string(e.Kind)), ansiReset,
ansiDim, e.Seq, ansiReset, e.Caption)
if e.ASCII != "" {
fmt.Printf(" %s%s%s\n", col, e.ASCII, ansiReset)
} else if e.LaTeX != "" {
fmt.Printf(" %s%s%s\n", ansiDim, e.LaTeX, ansiReset)
}
if len(e.Values) > 0 {
parts := make([]string, 0, len(e.Values))
for k, v := range e.Values {
parts = append(parts, fmt.Sprintf("%s=%s", k, trace.Short(v)))
}
fmt.Printf(" %s%s%s\n", ansiDim, strings.Join(parts, " "), ansiReset)
}
}
// openWithRetry waits up to timeout for the file to appear (the writer may start
// a moment after the panes).
func openWithRetry(path string, timeout time.Duration) (*os.File, error) {
deadline := time.Now().Add(timeout)
for {
f, err := os.Open(path)
if err == nil {
return f, nil
}
if time.Now().After(deadline) {
return nil, err
}
time.Sleep(50 * time.Millisecond)
}
}