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:
1
go.mod
1
go.mod
@@ -27,6 +27,7 @@ require (
|
|||||||
github.com/go-openapi/validate v0.22.0 // indirect
|
github.com/go-openapi/validate v0.22.0 // indirect
|
||||||
github.com/go-stack/stack v1.8.1 // indirect
|
github.com/go-stack/stack v1.8.1 // indirect
|
||||||
github.com/google/uuid v1.3.0 // indirect
|
github.com/google/uuid v1.3.0 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/hashicorp/errwrap v1.0.0 // indirect
|
github.com/hashicorp/errwrap v1.0.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
github.com/josharian/intern v1.0.0 // indirect
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
|
|||||||
2
go.sum
2
go.sum
@@ -408,6 +408,8 @@ github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z
|
|||||||
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
|
||||||
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de4/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||||
|
|||||||
7
main.go
7
main.go
@@ -80,6 +80,12 @@ func main() {
|
|||||||
fatal(orch.Kill())
|
fatal(orch.Kill())
|
||||||
case "cleanup":
|
case "cleanup":
|
||||||
fatal(orch.Cleanup())
|
fatal(orch.Cleanup())
|
||||||
|
case "serve":
|
||||||
|
addr := ":8080"
|
||||||
|
if len(os.Args) > 2 {
|
||||||
|
addr = os.Args[2]
|
||||||
|
}
|
||||||
|
fatal(orchestrator.Serve(orch, addr))
|
||||||
case "console":
|
case "console":
|
||||||
if len(os.Args) < 3 {
|
if len(os.Args) < 3 {
|
||||||
fmt.Fprintf(os.Stderr, "usage: %s console <id>\n", os.Args[0])
|
fmt.Fprintf(os.Stderr, "usage: %s console <id>\n", os.Args[0])
|
||||||
@@ -116,6 +122,7 @@ Commands:
|
|||||||
init Download kernel + create Alpine rootfs
|
init Download kernel + create Alpine rootfs
|
||||||
golden Boot golden VM → pause → snapshot
|
golden Boot golden VM → pause → snapshot
|
||||||
spawn [N] Restore N clones from golden snapshot (default: 1)
|
spawn [N] Restore N clones from golden snapshot (default: 1)
|
||||||
|
serve [addr] Start terminal web UI (default: :8080)
|
||||||
console <id> Attach to the serial console of a running clone (Ctrl+] to detach)
|
console <id> Attach to the serial console of a running clone (Ctrl+] to detach)
|
||||||
status Show running clones
|
status Show running clones
|
||||||
kill Kill all running VMs
|
kill Kill all running VMs
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package orchestrator
|
package orchestrator
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
@@ -150,6 +152,15 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
|
|||||||
return fmt.Errorf("listen on console socket: %w", err)
|
return fmt.Errorf("listen on console socket: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Create resize sideband socket ---
|
||||||
|
resizeSockPath := filepath.Join(cloneDir, "console-resize.sock")
|
||||||
|
os.Remove(resizeSockPath) //nolint:errcheck
|
||||||
|
resizeListener, err := net.Listen("unix", resizeSockPath)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warnf("could not create resize socket, terminal resize will be unavailable: %v", err)
|
||||||
|
resizeListener = nil
|
||||||
|
}
|
||||||
|
|
||||||
// --- Serve until VM exits ---
|
// --- Serve until VM exits ---
|
||||||
vmDone := make(chan struct{})
|
vmDone := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
@@ -157,11 +168,18 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
|
|||||||
close(vmDone)
|
close(vmDone)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
if resizeListener != nil {
|
||||||
|
go serveResize(resizeListener, ptm, vmDone, logger)
|
||||||
|
}
|
||||||
serveConsole(listener, ptm, vmDone, logger)
|
serveConsole(listener, ptm, vmDone, logger)
|
||||||
|
|
||||||
listener.Close()
|
listener.Close()
|
||||||
|
if resizeListener != nil {
|
||||||
|
resizeListener.Close()
|
||||||
|
}
|
||||||
ptm.Close()
|
ptm.Close()
|
||||||
os.Remove(consoleSockPath) //nolint:errcheck
|
os.Remove(consoleSockPath) //nolint:errcheck
|
||||||
|
os.Remove(resizeSockPath) //nolint:errcheck
|
||||||
|
|
||||||
if tapName != "" {
|
if tapName != "" {
|
||||||
destroyTap(tapName)
|
destroyTap(tapName)
|
||||||
@@ -171,6 +189,48 @@ func RunConsoleProxy(cfg Config, id int, tapName string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resizeMsg is the JSON payload sent over the resize sideband socket.
|
||||||
|
type resizeMsg struct {
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveResize accepts connections on the resize sideband listener and applies
|
||||||
|
// PTY window size changes as JSON resize messages arrive.
|
||||||
|
func serveResize(listener net.Listener, ptm *os.File, vmDone <-chan struct{}, logger *log.Entry) {
|
||||||
|
go func() {
|
||||||
|
<-vmDone
|
||||||
|
listener.Close()
|
||||||
|
}()
|
||||||
|
for {
|
||||||
|
conn, err := listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go handleResize(conn, ptm, logger)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleResize reads newline-delimited JSON resize messages from conn and
|
||||||
|
// applies each one to the PTY master.
|
||||||
|
func handleResize(conn net.Conn, ptm *os.File, logger *log.Entry) {
|
||||||
|
defer conn.Close()
|
||||||
|
scanner := bufio.NewScanner(conn)
|
||||||
|
for scanner.Scan() {
|
||||||
|
var msg resizeMsg
|
||||||
|
if err := json.Unmarshal(scanner.Bytes(), &msg); err != nil {
|
||||||
|
logger.Warnf("resize: bad message: %v", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if msg.Rows == 0 || msg.Cols == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := pty.Setsize(ptm, &pty.Winsize{Rows: msg.Rows, Cols: msg.Cols}); err != nil {
|
||||||
|
logger.Warnf("resize pty: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// atomicWriter wraps an io.Writer behind a read/write lock so it can be
|
// atomicWriter wraps an io.Writer behind a read/write lock so it can be
|
||||||
// swapped between connections without holding the lock during writes.
|
// swapped between connections without holding the lock during writes.
|
||||||
type atomicWriter struct {
|
type atomicWriter struct {
|
||||||
|
|||||||
@@ -305,6 +305,50 @@ func (o *Orchestrator) Spawn(count int) error {
|
|||||||
return nil
|
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 {
|
func (o *Orchestrator) spawnOne(id int) error {
|
||||||
goldenDir := o.goldenDir()
|
goldenDir := o.goldenDir()
|
||||||
cloneDir := filepath.Join(o.clonesDir(), strconv.Itoa(id))
|
cloneDir := filepath.Join(o.clonesDir(), strconv.Itoa(id))
|
||||||
|
|||||||
218
orchestrator/serve.go
Normal file
218
orchestrator/serve.go
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
package orchestrator
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
_ "embed"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
//go:embed web/terminal.html
|
||||||
|
var terminalHTML []byte
|
||||||
|
|
||||||
|
var upgrader = websocket.Upgrader{
|
||||||
|
// Allow all origins for local/dev use. Add an origin check for production.
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve starts an HTTP server that exposes:
|
||||||
|
//
|
||||||
|
// GET / — terminal UI (xterm.js); ?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 clone {id}
|
||||||
|
// GET /ws/{id} — WebSocket console for clone {id}
|
||||||
|
//
|
||||||
|
// Binary WebSocket frames carry raw terminal bytes (stdin/stdout).
|
||||||
|
// Text WebSocket frames carry JSON resize commands: {"rows":N,"cols":M}.
|
||||||
|
func Serve(orch *Orchestrator, addr string) error {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path != "/" {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
w.Write(terminalHTML) //nolint:errcheck
|
||||||
|
})
|
||||||
|
|
||||||
|
// /clones — list (GET) or spawn (POST)
|
||||||
|
mux.HandleFunc("/clones", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet, "":
|
||||||
|
ids := runningCloneIDs(orch.cfg)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(ids) //nolint:errcheck
|
||||||
|
case http.MethodPost:
|
||||||
|
id, err := orch.SpawnSingle()
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
json.NewEncoder(w).Encode(map[string]int{"id": id}) //nolint:errcheck
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// /clones/{id} — destroy (DELETE)
|
||||||
|
mux.HandleFunc("/clones/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := strings.TrimPrefix(r.URL.Path, "/clones/")
|
||||||
|
if idStr == "" {
|
||||||
|
// redirect bare /clones/ to /clones
|
||||||
|
http.Redirect(w, r, "/clones", http.StatusMovedPermanently)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid clone id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := orch.KillClone(id); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("/ws/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
idStr := strings.TrimPrefix(r.URL.Path, "/ws/")
|
||||||
|
id, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "invalid clone id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleConsoleWS(orch.cfg, id, w, r)
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Infof("terminal UI: http://%s", addr)
|
||||||
|
return http.ListenAndServe(addr, mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleConsoleWS upgrades the request to a WebSocket and bridges it to the
|
||||||
|
// clone's console.sock (I/O) and console-resize.sock (terminal resize).
|
||||||
|
func handleConsoleWS(cfg Config, id int, w http.ResponseWriter, r *http.Request) {
|
||||||
|
cloneDir := filepath.Join(cfg.BaseDir, "clones", strconv.Itoa(id))
|
||||||
|
consoleSock := filepath.Join(cloneDir, "console.sock")
|
||||||
|
resizeSock := filepath.Join(cloneDir, "console-resize.sock")
|
||||||
|
|
||||||
|
if _, err := os.Stat(consoleSock); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("clone %d is not running", id), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ws, err := upgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer ws.Close()
|
||||||
|
|
||||||
|
consoleConn, err := net.Dial("unix", consoleSock)
|
||||||
|
if err != nil {
|
||||||
|
writeWSError(ws, fmt.Sprintf("console unavailable: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer consoleConn.Close()
|
||||||
|
|
||||||
|
resizeConn, err := net.Dial("unix", resizeSock)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("clone %d: could not connect to resize socket, resize disabled: %v", id, err)
|
||||||
|
resizeConn = nil
|
||||||
|
}
|
||||||
|
if resizeConn != nil {
|
||||||
|
defer resizeConn.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("ws: clone %d: client connected from %s", id, r.RemoteAddr)
|
||||||
|
bridgeWS(ws, consoleConn, resizeConn)
|
||||||
|
log.Infof("ws: clone %d: client disconnected", id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bridgeWS proxies between a WebSocket connection and a console Unix socket.
|
||||||
|
//
|
||||||
|
// - Binary WS frames → consoleConn (terminal input)
|
||||||
|
// - Text WS frames → resizeConn as a JSON line (resize command)
|
||||||
|
// - consoleConn reads → binary WS frames (terminal output)
|
||||||
|
func bridgeWS(ws *websocket.Conn, consoleConn net.Conn, resizeConn net.Conn) {
|
||||||
|
// console → WebSocket
|
||||||
|
sockDone := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(sockDone)
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
n, err := consoleConn.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
if werr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// WebSocket → console / resize
|
||||||
|
for {
|
||||||
|
msgType, data, err := ws.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
switch msgType {
|
||||||
|
case websocket.BinaryMessage:
|
||||||
|
consoleConn.Write(data) //nolint:errcheck
|
||||||
|
case websocket.TextMessage:
|
||||||
|
if resizeConn != nil {
|
||||||
|
// Append newline so the scanner in handleResize sees a complete line.
|
||||||
|
resizeConn.Write(append(data, '\n')) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
consoleConn.Close()
|
||||||
|
<-sockDone
|
||||||
|
}
|
||||||
|
|
||||||
|
// runningCloneIDs returns clone IDs that have a live console socket.
|
||||||
|
func runningCloneIDs(cfg Config) []int {
|
||||||
|
clonesDir := filepath.Join(cfg.BaseDir, "clones")
|
||||||
|
entries, err := os.ReadDir(clonesDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var ids []int
|
||||||
|
for _, e := range entries {
|
||||||
|
if !e.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
id, err := strconv.Atoi(e.Name())
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sock := filepath.Join(clonesDir, e.Name(), "console.sock")
|
||||||
|
if _, err := os.Stat(sock); err == nil {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeWSError(ws *websocket.Conn, msg string) {
|
||||||
|
ws.WriteMessage(websocket.TextMessage, //nolint:errcheck
|
||||||
|
[]byte("\r\n\x1b[31m["+msg+"]\x1b[0m\r\n"))
|
||||||
|
}
|
||||||
298
orchestrator/web/terminal.html
Normal file
298
orchestrator/web/terminal.html
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>fc-orch console</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5/css/xterm.css"/>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm@5/lib/xterm.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8/lib/xterm-addon-fit.js"></script>
|
||||||
|
<style>
|
||||||
|
*, *::before, *::after { box-sizing: border-box; }
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0;
|
||||||
|
width: 100%; height: 100%;
|
||||||
|
background: #0d0d0d;
|
||||||
|
font-family: monospace;
|
||||||
|
color: #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── index / clone picker ── */
|
||||||
|
#index {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
#index h1 { margin: 0; font-size: 1.4rem; color: #8be; }
|
||||||
|
|
||||||
|
#clone-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0; margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
.clone-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
border: 1px solid #444;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.clone-entry a {
|
||||||
|
display: block;
|
||||||
|
padding: .4rem 1rem;
|
||||||
|
color: #8be;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.clone-entry a:hover { background: #1e2e3e; }
|
||||||
|
.clone-entry button.destroy {
|
||||||
|
padding: .4rem .6rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid #444;
|
||||||
|
color: #c44;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: .8rem;
|
||||||
|
transition: background .15s;
|
||||||
|
}
|
||||||
|
.clone-entry button.destroy:hover { background: #2a1a1a; }
|
||||||
|
.clone-entry button.destroy:disabled { color: #555; cursor: default; }
|
||||||
|
|
||||||
|
#index .none { color: #666; font-size: .9rem; }
|
||||||
|
|
||||||
|
#spawn-btn {
|
||||||
|
padding: .45rem 1.2rem;
|
||||||
|
background: #1a2e1a;
|
||||||
|
border: 1px solid #4c4;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: #4c4;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: monospace;
|
||||||
|
font-size: .9rem;
|
||||||
|
transition: background .15s, opacity .15s;
|
||||||
|
}
|
||||||
|
#spawn-btn:hover:not(:disabled) { background: #243e24; }
|
||||||
|
#spawn-btn:disabled { opacity: .5; cursor: default; }
|
||||||
|
|
||||||
|
#error-msg {
|
||||||
|
color: #c44;
|
||||||
|
font-size: .85rem;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── terminal view ── */
|
||||||
|
#terminal-wrap {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
#topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .75rem;
|
||||||
|
padding: .35rem .75rem;
|
||||||
|
background: #111;
|
||||||
|
border-bottom: 1px solid #222;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
#topbar .title { font-size: .85rem; color: #8be; }
|
||||||
|
#status {
|
||||||
|
font-size: .75rem;
|
||||||
|
padding: .15rem .5rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: #1a2a1a;
|
||||||
|
color: #4c4;
|
||||||
|
}
|
||||||
|
#status.disconnected { background: #2a1a1a; color: #c44; }
|
||||||
|
#terminal-container { flex: 1; overflow: hidden; padding: 4px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<!-- clone picker (shown when no ?id= param) -->
|
||||||
|
<div id="index">
|
||||||
|
<h1>fc-orch console</h1>
|
||||||
|
<ul id="clone-list"></ul>
|
||||||
|
<p class="none" id="no-clones" style="display:none">No running clones.</p>
|
||||||
|
<button id="spawn-btn">+ Spawn clone</button>
|
||||||
|
<p id="error-msg"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- terminal (shown when ?id=N) -->
|
||||||
|
<div id="terminal-wrap">
|
||||||
|
<div id="topbar">
|
||||||
|
<span class="title" id="topbar-title"></span>
|
||||||
|
<span id="status">connecting…</span>
|
||||||
|
<a href="/" style="margin-left:auto;font-size:.75rem;color:#666;text-decoration:none">← all clones</a>
|
||||||
|
</div>
|
||||||
|
<div id="terminal-container"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const id = params.get('id');
|
||||||
|
|
||||||
|
// ── index view ──────────────────────────────────────────────
|
||||||
|
if (!id) {
|
||||||
|
const ul = document.getElementById('clone-list');
|
||||||
|
const noneEl = document.getElementById('no-clones');
|
||||||
|
const spawnBtn = document.getElementById('spawn-btn');
|
||||||
|
const errEl = document.getElementById('error-msg');
|
||||||
|
|
||||||
|
function showError(msg) {
|
||||||
|
errEl.textContent = msg;
|
||||||
|
errEl.style.display = '';
|
||||||
|
}
|
||||||
|
function clearError() {
|
||||||
|
errEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCloneEntry(c) {
|
||||||
|
noneEl.style.display = 'none';
|
||||||
|
const li = document.createElement('li');
|
||||||
|
li.className = 'clone-entry';
|
||||||
|
li.dataset.id = c;
|
||||||
|
li.innerHTML =
|
||||||
|
`<a href="/?id=${c}">clone ${c}</a>` +
|
||||||
|
`<button class="destroy" title="Destroy clone ${c}">✕</button>`;
|
||||||
|
li.querySelector('.destroy').addEventListener('click', () => destroyClone(c, li));
|
||||||
|
ul.appendChild(li);
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshList() {
|
||||||
|
fetch('/clones')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(clones => {
|
||||||
|
ul.innerHTML = '';
|
||||||
|
if (!clones || clones.length === 0) {
|
||||||
|
noneEl.style.display = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
noneEl.style.display = 'none';
|
||||||
|
clones.forEach(addCloneEntry);
|
||||||
|
})
|
||||||
|
.catch(() => { noneEl.style.display = ''; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyClone(cid, li) {
|
||||||
|
const btn = li.querySelector('.destroy');
|
||||||
|
btn.disabled = true;
|
||||||
|
clearError();
|
||||||
|
fetch(`/clones/${cid}`, { method: 'DELETE' })
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) return r.text().then(t => { throw new Error(t); });
|
||||||
|
li.remove();
|
||||||
|
if (ul.children.length === 0) noneEl.style.display = '';
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
btn.disabled = false;
|
||||||
|
showError(`destroy failed: ${e.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
spawnBtn.addEventListener('click', () => {
|
||||||
|
spawnBtn.disabled = true;
|
||||||
|
spawnBtn.textContent = 'Spawning…';
|
||||||
|
clearError();
|
||||||
|
fetch('/clones', { method: 'POST' })
|
||||||
|
.then(r => {
|
||||||
|
if (!r.ok) return r.text().then(t => { throw new Error(t); });
|
||||||
|
return r.json();
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
location.href = `/?id=${data.id}`;
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
|
spawnBtn.disabled = false;
|
||||||
|
spawnBtn.textContent = '+ Spawn clone';
|
||||||
|
showError(`spawn failed: ${e.message}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
refreshList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── terminal view ────────────────────────────────────────────
|
||||||
|
document.getElementById('index').style.display = 'none';
|
||||||
|
const wrap = document.getElementById('terminal-wrap');
|
||||||
|
wrap.style.display = 'flex';
|
||||||
|
document.getElementById('topbar-title').textContent = `clone ${id}`;
|
||||||
|
document.title = `clone ${id} — fc-orch`;
|
||||||
|
|
||||||
|
const term = new Terminal({
|
||||||
|
cursorBlink: true,
|
||||||
|
scrollback: 5000,
|
||||||
|
theme: {
|
||||||
|
background: '#0d0d0d',
|
||||||
|
foreground: '#d0d0d0',
|
||||||
|
cursor: '#8be',
|
||||||
|
selectionBackground: '#2a4a6a',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fitAddon = new FitAddon.FitAddon();
|
||||||
|
term.loadAddon(fitAddon);
|
||||||
|
term.open(document.getElementById('terminal-container'));
|
||||||
|
fitAddon.fit();
|
||||||
|
|
||||||
|
const statusEl = document.getElementById('status');
|
||||||
|
function setStatus(text, ok) {
|
||||||
|
statusEl.textContent = text;
|
||||||
|
statusEl.className = ok ? '' : 'disconnected';
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
const ws = new WebSocket(`${proto}//${location.host}/ws/${id}`);
|
||||||
|
ws.binaryType = 'arraybuffer';
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setStatus('connected', true);
|
||||||
|
sendResize();
|
||||||
|
term.focus();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = e => {
|
||||||
|
if (e.data instanceof ArrayBuffer) {
|
||||||
|
term.write(new Uint8Array(e.data));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setStatus('disconnected', false);
|
||||||
|
term.write('\r\n\x1b[31m[connection closed]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => setStatus('error', false);
|
||||||
|
|
||||||
|
// Keystrokes → VM (binary frame so the server can distinguish from resize)
|
||||||
|
term.onData(data => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
const bytes = new TextEncoder().encode(data);
|
||||||
|
ws.send(bytes.buffer);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize handling
|
||||||
|
function sendResize() {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ rows: term.rows, cols: term.cols }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
term.onResize(() => sendResize());
|
||||||
|
|
||||||
|
let resizeTimer;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
clearTimeout(resizeTimer);
|
||||||
|
resizeTimer = setTimeout(() => { fitAddon.fit(); }, 50);
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user