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:
@@ -1,19 +1,23 @@
|
|||||||
# webterm (Go)
|
# webterm
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
`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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
## 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
@@ -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 {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user