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)
`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)
## Features
- Web terminal with reconnect support
- Ghostty WebAssembly terminal engine for fast rendering
- Session dashboard with live SVG screenshots
- Docker watch mode (`webterm-command` / `webterm-theme` labels)
- Docker compose manifest ingestion
- CPU sparkline tiles for compose services
- SSE activity updates for fast dashboard refresh
- Mobile/touch support with virtual keyboard + draggable keybar
- Theme/font controls for terminal rendering
## Install
+79 -5
View File
@@ -1,11 +1,14 @@
package webterm
import (
"bufio"
"context"
"crypto/sha1"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
@@ -58,6 +61,59 @@ type wsOutbound struct {
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 {
host string
port int
@@ -300,6 +356,19 @@ func parseResizePayload(value any) (int, int) {
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) {
routeKey := strings.TrimPrefix(r.URL.Path, "/ws/")
if routeKey == "" {
@@ -308,8 +377,11 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
}
conn, err := s.upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("websocket upgrade failed route=%s remote=%s err=%v", routeKey, r.RemoteAddr, err)
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()
client := &wsClient{
@@ -361,6 +433,9 @@ func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
for {
messageType, payload, err := conn.ReadMessage()
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
}
if messageType != websocket.TextMessage {
@@ -428,11 +503,10 @@ func (s *LocalServer) chooseRouteForScreenshot(requested string) (string, Sessio
if session != nil {
return requested, session, true
}
return requested, nil, false
}
if requested == "" {
if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok {
return routeKey, session, true
}
if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok {
return routeKey, session, true
}
return "", nil, false
}
@@ -823,7 +897,7 @@ func (s *LocalServer) Handler() http.Handler {
if strings.TrimSpace(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 {
+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) {
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/?route_key=shell")