webterm: fix screenshot tile bootstrap and add request logging

Fix screenshot generation for requested dashboard route keys by preserving non-empty route_key lookups when no session exists yet, allowing lazy session creation instead of returning 404.

Add server-side observability with HTTP request logs (method, URI, status, bytes, duration, remote address) and websocket connection lifecycle logs for connect/disconnect and unexpected read errors.

Update README title/positioning and expand feature bullets to document Ghostty WASM rendering and mobile/touch keyboard support.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-14 18:46:36 +00:00
parent 4110963c9f
commit 1cfced1052
3 changed files with 100 additions and 6 deletions
+5 -1
View File
@@ -1,19 +1,23 @@
# webterm (Go) # webterm
![Icon](docs/icon-256.png) ![Icon](docs/icon-256.png)
`webterm` serves terminal sessions over HTTP/WebSocket, with a dashboard mode for multiple sessions and Docker-aware tiles. `webterm` serves terminal sessions over HTTP/WebSocket, with a dashboard mode for multiple sessions and Docker-aware tiles.
This repository is the Go port of the original Python implementation, which is preserved in the `python` branch.
![Screenshot](docs/screenshot.png) ![Screenshot](docs/screenshot.png)
## Features ## Features
- Web terminal with reconnect support - Web terminal with reconnect support
- Ghostty WebAssembly terminal engine for fast rendering
- Session dashboard with live SVG screenshots - Session dashboard with live SVG screenshots
- Docker watch mode (`webterm-command` / `webterm-theme` labels) - Docker watch mode (`webterm-command` / `webterm-theme` labels)
- Docker compose manifest ingestion - Docker compose manifest ingestion
- CPU sparkline tiles for compose services - CPU sparkline tiles for compose services
- SSE activity updates for fast dashboard refresh - SSE activity updates for fast dashboard refresh
- Mobile/touch support with virtual keyboard + draggable keybar
- Theme/font controls for terminal rendering - Theme/font controls for terminal rendering
## Install ## Install
+77 -3
View File
@@ -1,11 +1,14 @@
package webterm package webterm
import ( import (
"bufio"
"context" "context"
"crypto/sha1" "crypto/sha1"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
"log"
"net"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -58,6 +61,59 @@ type wsOutbound struct {
payload []byte payload []byte
} }
type loggingResponseWriter struct {
http.ResponseWriter
status int
bytes int
}
func (w *loggingResponseWriter) WriteHeader(statusCode int) {
w.status = statusCode
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *loggingResponseWriter) Write(payload []byte) (int, error) {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := w.ResponseWriter.Write(payload)
w.bytes += n
return n, err
}
func (w *loggingResponseWriter) ReadFrom(r io.Reader) (int64, error) {
if rf, ok := w.ResponseWriter.(io.ReaderFrom); ok {
if w.status == 0 {
w.status = http.StatusOK
}
n, err := rf.ReadFrom(r)
w.bytes += int(n)
return n, err
}
return io.Copy(w.ResponseWriter, r)
}
func (w *loggingResponseWriter) Flush() {
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (w *loggingResponseWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hijacker, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, fmt.Errorf("response writer does not support hijacking")
}
return hijacker.Hijack()
}
func (w *loggingResponseWriter) Push(target string, opts *http.PushOptions) error {
if pusher, ok := w.ResponseWriter.(http.Pusher); ok {
return pusher.Push(target, opts)
}
return http.ErrNotSupported
}
type LocalServer struct { type LocalServer struct {
host string host string
port int port int
@@ -300,6 +356,19 @@ func parseResizePayload(value any) (int, int) {
return clampInt(width, 1, 500), clampInt(height, 1, 500) return clampInt(width, 1, 500), clampInt(height, 1, 500)
} }
func (s *LocalServer) loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
lw := &loggingResponseWriter{ResponseWriter: w}
next.ServeHTTP(lw, r)
status := lw.status
if status == 0 {
status = http.StatusOK
}
log.Printf("%s %s status=%d bytes=%d duration=%s remote=%s", r.Method, r.URL.RequestURI(), status, lw.bytes, time.Since(start), r.RemoteAddr)
})
}
func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
routeKey := strings.TrimPrefix(r.URL.Path, "/ws/") routeKey := strings.TrimPrefix(r.URL.Path, "/ws/")
if routeKey == "" { if routeKey == "" {
@@ -308,8 +377,11 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
} }
conn, err := s.upgrader.Upgrade(w, r, nil) conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil { if err != nil {
log.Printf("websocket upgrade failed route=%s remote=%s err=%v", routeKey, r.RemoteAddr, err)
return return
} }
log.Printf("websocket connected route=%s remote=%s", routeKey, r.RemoteAddr)
defer log.Printf("websocket disconnected route=%s remote=%s", routeKey, r.RemoteAddr)
defer conn.Close() defer conn.Close()
client := &wsClient{ client := &wsClient{
@@ -361,6 +433,9 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
for { for {
messageType, payload, err := conn.ReadMessage() messageType, payload, err := conn.ReadMessage()
if err != nil { if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseNormalClosure, websocket.CloseGoingAway) {
log.Printf("websocket read error route=%s remote=%s err=%v", routeKey, r.RemoteAddr, err)
}
return return
} }
if messageType != websocket.TextMessage { if messageType != websocket.TextMessage {
@@ -428,12 +503,11 @@ func (s *LocalServer) chooseRouteForScreenshot(requested string) (string, Sessio
if session != nil { if session != nil {
return requested, session, true return requested, session, true
} }
return requested, nil, false
} }
if requested == "" {
if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok { if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok {
return routeKey, session, true return routeKey, session, true
} }
}
return "", nil, false return "", nil, false
} }
@@ -823,7 +897,7 @@ func (s *LocalServer) Handler() http.Handler {
if strings.TrimSpace(s.staticPath) != "" { if strings.TrimSpace(s.staticPath) != "" {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath)))) mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath))))
} }
return mux return s.loggingMiddleware(mux)
} }
func (s *LocalServer) Run(ctx context.Context) error { func (s *LocalServer) Run(ctx context.Context) error {
+16
View File
@@ -192,6 +192,22 @@ func TestScreenshotAndETag(t *testing.T) {
} }
} }
func TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell")
if err != nil {
t.Fatalf("screenshot request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d body=%q", resp.StatusCode, string(body))
}
if !strings.Contains(string(body), "<svg") {
t.Fatalf("expected svg body")
}
}
func TestRootTerminalPageAndSparklineValidation(t *testing.T) { func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false) _, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/?route_key=shell") resp, err := http.Get(httpServer.URL + "/?route_key=shell")