feat: add web terminal UI with WebSocket console and clone management API
Introduces a browser-based terminal interface backed by xterm.js, served
directly from the binary via an embedded HTML asset.
New HTTP server (`serve [addr]`, default :8080):
GET / — xterm.js terminal UI; ?id=N selects a clone
GET /clones — JSON list of running clone IDs
POST /clones — spawn a new clone; returns {"id": N}
DELETE /clones/{id} — destroy a clone by ID
GET /ws/{id} — WebSocket console for clone {id}
binary frames = raw PTY I/O
text frames = JSON resize {"rows":N,"cols":M}
Supporting changes:
- orchestrator: add SpawnSingle() and KillClone(id) for per-clone lifecycle
management from the HTTP layer
- console: add a resize sideband Unix socket (console-resize.sock) that
accepts newline-delimited JSON {"rows","cols"} messages and applies them
to the PTY master via pty.Setsize; the WebSocket handler writes to this
socket on text frames so browser window resizes propagate into the VM
- deps: add gorilla/websocket v1.5.3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -305,6 +305,50 @@ func (o *Orchestrator) Spawn(count int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SpawnSingle spawns exactly one new clone and returns its ID.
|
||||
// It is safe to call from multiple goroutines (nextCloneID is serialised by the
|
||||
// filesystem scan, and each clone gets its own directory/tap).
|
||||
func (o *Orchestrator) SpawnSingle() (int, error) {
|
||||
goldenDir := o.goldenDir()
|
||||
for _, f := range []string{"vmstate", "mem"} {
|
||||
if _, err := os.Stat(filepath.Join(goldenDir, f)); err != nil {
|
||||
return 0, fmt.Errorf("golden %s not found — run golden first", f)
|
||||
}
|
||||
}
|
||||
os.MkdirAll(o.clonesDir(), 0o755)
|
||||
os.MkdirAll(o.pidsDir(), 0o755)
|
||||
if o.cfg.Bridge != "none" {
|
||||
if err := o.setupBridge(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
id := o.nextCloneID()
|
||||
if err := o.spawnOne(id); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// KillClone kills a single clone by ID: terminates its proxy process,
|
||||
// destroys its tap device, and removes its working directory.
|
||||
func (o *Orchestrator) KillClone(id int) error {
|
||||
pidFile := filepath.Join(o.pidsDir(), fmt.Sprintf("clone-%d.proxy.pid", id))
|
||||
if data, err := os.ReadFile(pidFile); err == nil {
|
||||
if pid, err := strconv.Atoi(strings.TrimSpace(string(data))); err == nil {
|
||||
if p, err := os.FindProcess(pid); err == nil {
|
||||
_ = p.Kill()
|
||||
o.log.Infof("clone %d: killed proxy pid %d", id, pid)
|
||||
}
|
||||
}
|
||||
os.Remove(pidFile) //nolint:errcheck
|
||||
}
|
||||
tapName := fmt.Sprintf("fctap%d", id)
|
||||
destroyTap(tapName)
|
||||
os.RemoveAll(filepath.Join(o.clonesDir(), strconv.Itoa(id))) //nolint:errcheck
|
||||
o.log.Infof("clone %d: destroyed", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *Orchestrator) spawnOne(id int) error {
|
||||
goldenDir := o.goldenDir()
|
||||
cloneDir := filepath.Join(o.clonesDir(), strconv.Itoa(id))
|
||||
|
||||
Reference in New Issue
Block a user