Files
webterm/go/webterm/server.go
T
GitHub Copilot 218aabc8ca Allow visible dashboard screenshots while unfocused
Relax screenshot request gating to depend on tab visibility only, so thumbnail updates continue when the dashboard tab is visible but the window is not focused.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-02-14 19:59:55 +00:00

1502 lines
43 KiB
Go

package webterm
import (
"bufio"
"compress/gzip"
"context"
"crypto/sha1"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/gorilla/websocket"
)
const (
wsSendQueueMax = 256
wsSendTimeout = 2 * time.Second
stdinWriteTimeout = 2 * time.Second
screenshotCacheSeconds = 300 * time.Millisecond
maxScreenshotCacheTTL = 20 * time.Second
)
type ServerOptions struct {
Host string
Port int
Theme string
FontFamily string
FontSize int
LandingApps []App
ComposeMode bool
ComposeProject string
DockerWatch bool
StaticPath string
}
type screenshotCacheEntry struct {
when time.Time
svg string
etag string
}
type wsClient struct {
routeKey string
conn *websocket.Conn
send chan wsOutbound
done chan struct{}
closed atomic.Bool
}
type wsOutbound struct {
messageType int
payload []byte
}
type loggingResponseWriter struct {
http.ResponseWriter
status int
bytes int
}
type gzipResponseWriter struct {
http.ResponseWriter
writer *gzip.Writer
}
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
}
func (w *gzipResponseWriter) WriteHeader(statusCode int) {
w.Header().Del("Content-Length")
w.Header().Set("Content-Encoding", "gzip")
w.Header().Add("Vary", "Accept-Encoding")
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *gzipResponseWriter) Write(payload []byte) (int, error) {
if w.Header().Get("Content-Encoding") == "" {
w.WriteHeader(http.StatusOK)
}
return w.writer.Write(payload)
}
func (w *gzipResponseWriter) ReadFrom(r io.Reader) (int64, error) {
if w.Header().Get("Content-Encoding") == "" {
w.WriteHeader(http.StatusOK)
}
return io.Copy(w.writer, r)
}
func (w *gzipResponseWriter) Flush() {
_ = w.writer.Flush()
if flusher, ok := w.ResponseWriter.(http.Flusher); ok {
flusher.Flush()
}
}
func (w *gzipResponseWriter) 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
theme string
fontFamily string
fontSize int
sessionManager *SessionManager
landingApps []App
composeMode bool
composeProject string
dockerWatch bool
staticPath string
upgrader websocket.Upgrader
mu sync.RWMutex
wsClients map[string]*wsClient
screenshotCache map[string]screenshotCacheEntry
routeLastActivity map[string]time.Time
routeLastSSE map[string]time.Time
sseSubscribers map[chan string]struct{}
slugToService map[string]string
dockerStats *DockerStatsCollector
dockerWatcher *DockerWatcher
screenshotForceRedraw bool
}
type localClientConnector struct {
server *LocalServer
sessionID string
routeKey string
}
func (c *localClientConnector) OnData(data []byte) {
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, data)
}
func (c *localClientConnector) OnBinary(payload []byte) {
c.server.enqueueWSFrame(c.routeKey, websocket.BinaryMessage, payload)
}
func (c *localClientConnector) OnMeta(meta map[string]any) {
if changed, ok := meta["screen_changed"].(bool); ok && changed {
c.server.markRouteActivity(c.routeKey)
}
}
func (c *localClientConnector) OnClose() {
c.server.sessionManager.OnSessionEnd(c.sessionID)
c.server.stopWSClient(c.routeKey)
}
func NewLocalServer(config Config, options ServerOptions) *LocalServer {
host := options.Host
if host == "" {
host = DefaultHost
}
port := options.Port
if port == 0 {
port = DefaultPort
}
theme := strings.TrimSpace(options.Theme)
if theme == "" {
theme = DefaultTheme
}
fontSize := options.FontSize
if fontSize <= 0 {
fontSize = DefaultFontSize
}
apps := append([]App{}, config.Apps...)
for _, app := range options.LandingApps {
apps = append(apps, app)
}
server := &LocalServer{
host: host,
port: port,
theme: theme,
fontFamily: options.FontFamily,
fontSize: fontSize,
sessionManager: NewSessionManager(apps),
landingApps: append([]App{}, options.LandingApps...),
composeMode: options.ComposeMode,
composeProject: options.ComposeProject,
dockerWatch: options.DockerWatch,
staticPath: options.StaticPath,
upgrader: websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
},
wsClients: map[string]*wsClient{},
screenshotCache: map[string]screenshotCacheEntry{},
routeLastActivity: map[string]time.Time{},
routeLastSSE: map[string]time.Time{},
sseSubscribers: map[chan string]struct{}{},
slugToService: map[string]string{},
screenshotForceRedraw: EnvBool(ScreenshotForceRedrawEnv),
}
if server.staticPath == "" {
server.staticPath = findStaticPath()
}
return server
}
func findStaticPath() string {
if p := strings.TrimSpace(os.Getenv("WEBTERM_STATIC_PATH")); p != "" {
if stat, err := os.Stat(p); err == nil && stat.IsDir() {
return p
}
}
candidates := []string{
filepath.Join(".", "webterm", "static"),
filepath.Join(".", "go", "webterm", "static"),
filepath.Join("..", "webterm", "static"),
filepath.Join("..", "go", "webterm", "static"),
filepath.Join("..", "..", "webterm", "static"),
}
for _, candidate := range candidates {
if stat, err := os.Stat(candidate); err == nil && stat.IsDir() {
return candidate
}
}
return ""
}
func (s *LocalServer) markRouteActivity(routeKey string) {
now := time.Now()
s.mu.Lock()
s.routeLastActivity[routeKey] = now
last := s.routeLastSSE[routeKey]
if now.Sub(last) < 500*time.Millisecond {
s.mu.Unlock()
return
}
s.routeLastSSE[routeKey] = now
subscribers := make([]chan string, 0, len(s.sseSubscribers))
for subscriber := range s.sseSubscribers {
subscribers = append(subscribers, subscriber)
}
s.mu.Unlock()
for _, subscriber := range subscribers {
select {
case subscriber <- routeKey:
default:
}
}
}
func (s *LocalServer) enqueueWSFrame(routeKey string, messageType int, data []byte) {
s.mu.RLock()
client := s.wsClients[routeKey]
s.mu.RUnlock()
if client == nil || client.closed.Load() {
return
}
frame := wsOutbound{
messageType: messageType,
payload: append([]byte{}, data...),
}
select {
case client.send <- frame:
default:
// Drop oldest, try again
select {
case <-client.send:
default:
}
select {
case client.send <- frame:
default:
}
}
}
func (s *LocalServer) stopWSClient(routeKey string) {
s.mu.Lock()
client := s.wsClients[routeKey]
delete(s.wsClients, routeKey)
s.mu.Unlock()
if client == nil {
return
}
client.closed.Store(true)
close(client.send)
<-client.done
}
func (s *LocalServer) wsSender(client *wsClient) {
defer close(client.done)
for outbound := range client.send {
_ = client.conn.SetWriteDeadline(time.Now().Add(wsSendTimeout))
if err := client.conn.WriteMessage(outbound.messageType, outbound.payload); err != nil {
return
}
}
}
func (s *LocalServer) createTerminalSession(routeKey string, width, height int) error {
app, ok := s.sessionManager.AppBySlug(routeKey)
if !ok {
app, ok = s.sessionManager.GetDefaultApp()
if !ok {
return fmt.Errorf("no apps configured")
}
}
sessionID := GenerateID(identitySize)
session, err := s.sessionManager.NewSession(app.Slug, sessionID, routeKey, width, height)
if err != nil {
return err
}
connector := &localClientConnector{
server: s,
sessionID: sessionID,
routeKey: routeKey,
}
session.UpdateConnector(connector)
return session.Start(connector)
}
func clampInt(value, minValue, maxValue int) int {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}
func parseResizePayload(value any) (int, int) {
width, height := 80, 24
payload, ok := value.(map[string]any)
if !ok {
return width, height
}
if raw, ok := payload["width"]; ok {
width = toInt(raw)
}
if raw, ok := payload["height"]; ok {
height = toInt(raw)
}
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) gzipMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
next.ServeHTTP(w, r)
return
}
if strings.EqualFold(strings.TrimSpace(r.Header.Get("Upgrade")), "websocket") {
next.ServeHTTP(w, r)
return
}
gz, err := gzip.NewWriterLevel(w, gzip.BestSpeed)
if err != nil {
next.ServeHTTP(w, r)
return
}
defer func() { _ = gz.Close() }()
next.ServeHTTP(&gzipResponseWriter{ResponseWriter: w, writer: gz}, r)
})
}
func (s *LocalServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
routeKey := strings.TrimPrefix(r.URL.Path, "/ws/")
if routeKey == "" {
http.Error(w, "missing route key", http.StatusBadRequest)
return
}
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{
routeKey: routeKey,
conn: conn,
send: make(chan wsOutbound, wsSendQueueMax),
done: make(chan struct{}),
}
s.mu.Lock()
s.wsClients[routeKey] = client
s.mu.Unlock()
go s.wsSender(client)
defer s.stopWSClient(routeKey)
// Helper to send JSON through the send channel (avoids concurrent conn writes)
sendJSON := func(v any) {
data, err := json.Marshal(v)
if err != nil || client.closed.Load() {
return
}
frame := wsOutbound{
messageType: websocket.TextMessage,
payload: data,
}
select {
case client.send <- frame:
default:
}
}
sessionCreated := false
sessionID, ok := s.sessionManager.GetSessionIDByRouteKey(routeKey)
if ok {
session := s.sessionManager.GetSession(sessionID)
if session != nil && session.IsRunning() {
sessionCreated = true
replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil)
if len(replay) > 0 {
s.enqueueWSFrame(routeKey, websocket.BinaryMessage, replay)
}
} else {
s.sessionManager.OnSessionEnd(sessionID)
}
}
_ = conn.SetReadDeadline(time.Time{})
conn.SetPongHandler(func(string) error { return nil })
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 {
continue
}
var envelope []any
if err := json.Unmarshal(payload, &envelope); err != nil || len(envelope) == 0 {
continue
}
msgType, _ := envelope[0].(string)
switch msgType {
case "stdin":
s.markRouteActivity(routeKey)
session := s.sessionManager.GetSessionByRouteKey(routeKey)
if session != nil {
data := ""
if len(envelope) > 1 {
data, _ = envelope[1].(string)
}
done := make(chan struct{})
go func() {
defer close(done)
_ = session.SendBytes([]byte(data))
}()
select {
case <-done:
case <-time.After(stdinWriteTimeout):
}
}
case "resize":
s.markRouteActivity(routeKey)
width, height := 80, 24
if len(envelope) > 1 {
width, height = parseResizePayload(envelope[1])
}
session := s.sessionManager.GetSessionByRouteKey(routeKey)
if session == nil {
if err := s.createTerminalSession(routeKey, width, height); err == nil {
sessionCreated = true
} else {
sendJSON([]any{"error", "Failed to create session"})
}
} else {
_ = session.SetTerminalSize(width, height)
s.mu.Lock()
delete(s.screenshotCache, routeKey)
s.mu.Unlock()
}
case "ping":
value := ""
if len(envelope) > 1 {
value, _ = envelope[1].(string)
}
sendJSON([]any{"pong", value})
}
if !sessionCreated && msgType == "resize" {
sessionCreated = true
}
}
}
func (s *LocalServer) chooseRouteForScreenshot(requested string) (string, Session, bool) {
if requested != "" {
session := s.sessionManager.GetSessionByRouteKey(requested)
if session != nil {
return requested, session, true
}
return requested, nil, false
}
if routeKey, session, ok := s.sessionManager.GetFirstRunningSession(); ok {
return routeKey, session, true
}
return "", nil, false
}
func (s *LocalServer) screenshotTTL(routeKey string) time.Duration {
s.mu.RLock()
lastActivity := s.routeLastActivity[routeKey]
s.mu.RUnlock()
idle := time.Since(lastActivity)
switch {
case idle < 3*time.Second:
return screenshotCacheSeconds
case idle < 15*time.Second:
return 2 * time.Second
case idle < 120*time.Second:
return 5 * time.Second
default:
return maxScreenshotCacheTTL
}
}
func etagMatches(ifNoneMatch, etag string) bool {
etag = strings.Trim(strings.TrimSpace(etag), `"`)
if etag == "" {
return false
}
for _, candidate := range strings.Split(ifNoneMatch, ",") {
value := strings.TrimSpace(candidate)
if value == "*" {
return true
}
value = strings.TrimPrefix(value, "W/")
value = strings.Trim(value, `"`)
if value == etag {
return true
}
}
return false
}
func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
routeKey := r.URL.Query().Get("route_key")
routeKey, session, ok := s.chooseRouteForScreenshot(routeKey)
if !ok && routeKey != "" {
if _, exists := s.sessionManager.AppBySlug(routeKey); exists {
_ = s.createTerminalSession(routeKey, DefaultTerminalWidth, DefaultTerminalHeight)
deadline := time.Now().Add(500 * time.Millisecond)
for {
session = s.sessionManager.GetSessionByRouteKey(routeKey)
if session != nil {
ok = true
break
}
if time.Now().After(deadline) {
break
}
time.Sleep(20 * time.Millisecond)
}
}
}
if !ok || session == nil {
http.Error(w, "Session not found", http.StatusNotFound)
return
}
s.mu.RLock()
cached, hasCached := s.screenshotCache[routeKey]
lastActivity := s.routeLastActivity[routeKey]
s.mu.RUnlock()
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) {
if etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
if !lastActivity.After(cached.when) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.WriteHeader(http.StatusNotModified)
return
}
} else {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, cached.svg)
return
}
}
if s.screenshotForceRedraw {
_ = session.ForceRedraw()
}
snapshot := session.GetScreenSnapshot()
if hasCached && !snapshot.HasChanges {
if etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", cached.etag)
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, cached.svg)
return
}
app, _ := s.sessionManager.AppBySlug(routeKey)
theme := strings.ToLower(strings.TrimSpace(app.Theme))
if theme == "" {
theme = strings.ToLower(s.theme)
}
palette := ThemePalettes[theme]
if palette == nil {
palette = ThemePalettes["xterm"]
}
background := palette["background"]
if background == "" {
background = ThemeBackgrounds["xterm"]
}
foreground := palette["foreground"]
if foreground == "" {
foreground = "#e5e5e5"
}
svg := RenderTerminalSVG(snapshot.Buffer, snapshot.Width, snapshot.Height, "webterm", background, foreground, palette)
hash := sha1.Sum([]byte(svg))
etag := fmt.Sprintf(`"%x"`, hash[:])
s.mu.Lock()
s.screenshotCache[routeKey] = screenshotCacheEntry{when: time.Now(), svg: svg, etag: etag}
s.mu.Unlock()
if etagMatches(r.Header.Get("If-None-Match"), etag) {
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
w.WriteHeader(http.StatusNotModified)
return
}
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("ETag", etag)
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, svg)
}
func (s *LocalServer) handleCPUSparkline(w http.ResponseWriter, r *http.Request) {
container := r.URL.Query().Get("container")
if strings.TrimSpace(container) == "" {
http.Error(w, "Missing container parameter", http.StatusBadRequest)
return
}
width := clampInt(toIntFromQuery(r.URL.Query().Get("width"), 100), 50, 300)
height := clampInt(toIntFromQuery(r.URL.Query().Get("height"), 20), 10, 100)
values := []float64{}
s.mu.RLock()
stats := s.dockerStats
serviceName := s.slugToService[container]
s.mu.RUnlock()
if serviceName == "" {
serviceName = container
}
if stats != nil {
values = stats.GetCPUHistory(serviceName)
}
w.Header().Set("Cache-Control", "no-cache, max-age=0")
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = io.WriteString(w, RenderSparklineSVG(values, width, height))
}
func (s *LocalServer) handleEvents(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
channel := make(chan string, 100)
s.mu.Lock()
s.sseSubscribers[channel] = struct{}{}
s.mu.Unlock()
defer func() {
s.mu.Lock()
delete(s.sseSubscribers, channel)
s.mu.Unlock()
}()
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
notify := r.Context().Done()
for {
select {
case <-notify:
return
case routeKey := <-channel:
_, _ = fmt.Fprintf(w, "event: activity\ndata: %s\n\n", routeKey)
flusher.Flush()
case <-ticker.C:
_, _ = io.WriteString(w, ": keepalive\n\n")
flusher.Flush()
}
}
}
func toIntFromQuery(value string, fallback int) int {
if n, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
return n
}
return fallback
}
func (s *LocalServer) dashboardTiles() []map[string]string {
var apps []App
if s.dockerWatch {
apps = s.sessionManager.Apps()
} else {
apps = append([]App{}, s.landingApps...)
}
tiles := make([]map[string]string, 0, len(apps))
for _, app := range apps {
command := app.Command
if command == AutoCommandSentinel {
command = ""
}
tiles = append(tiles, map[string]string{
"slug": app.Slug,
"name": app.Name,
"command": command,
})
}
return tiles
}
func (s *LocalServer) handleTiles(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(s.dashboardTiles())
}
func (s *LocalServer) getWSURL(r *http.Request, routeKey string) string {
header := func(name string) string {
value := strings.TrimSpace(strings.Split(r.Header.Get(name), ",")[0])
return strings.ToLower(value)
}
forwardedProto := header("X-Forwarded-Proto")
forwardedHost := header("X-Forwarded-Host")
forwardedPort := header("X-Forwarded-Port")
wsProto := "ws"
if forwardedProto == "https" || forwardedProto == "wss" {
wsProto = "wss"
} else if forwardedProto == "" && r.TLS != nil {
wsProto = "wss"
}
host := forwardedHost
if host == "" {
host = r.Host
}
if host == "" {
if s.host == "0.0.0.0" {
host = "localhost"
} else {
host = s.host
}
if s.port != 80 && s.port != 443 {
host = fmt.Sprintf("%s:%d", host, s.port)
}
}
if forwardedPort != "" && !strings.Contains(host, ":") && forwardedPort != "80" && forwardedPort != "443" {
host += ":" + forwardedPort
}
return fmt.Sprintf("%s://%s/ws/%s", wsProto, host, routeKey)
}
func (s *LocalServer) handleRoot(w http.ResponseWriter, r *http.Request) {
routeKeyParam := r.URL.Query().Get("route_key")
showDashboard := (len(s.landingApps) > 0 || s.dockerWatch) && routeKeyParam == ""
if showDashboard {
tilesJSON, _ := json.Marshal(s.dashboardTiles())
composeModeJS := "false"
if s.composeMode || s.dockerWatch {
composeModeJS = "true"
}
dockerWatchJS := "false"
if s.dockerWatch {
dockerWatchJS = "true"
}
html := fmt.Sprintf(`<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Session Dashboard</title>
<link rel="manifest" href="/static/manifest.json">
<meta name="theme-color" content="#0d1117">
<link rel="icon" href="/static/icons/webterm-192.png" sizes="192x192">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 16px; background: #0f172a; color: #e2e8f0; }
h1 { margin-bottom: 8px; }
.subtitle { color: #64748b; font-size: 14px; margin-bottom: 16px; }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.tile { background: #1e293b; border: 1px solid #334155; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 6px rgba(0,0,0,0.4); cursor: pointer; transition: border-color 0.15s; }
.tile:hover { border-color: #475569; }
.tile.selected { border-color: #3b82f6; box-shadow: 0 0 0 2px rgba(59,130,246,0.3); }
.tile-header { padding: 10px 12px; font-weight: bold; border-bottom: 1px solid #334155; display: flex; align-items: center; justify-content: space-between; }
.tile-title { display: flex; align-items: center; gap: 8px; }
.sparkline { opacity: 0.9; }
.tile-body { padding: 0; }
.thumb { width: 100%%; height: 180px; object-fit: contain; background: #0b1220; display: block; }
.meta { padding: 8px 12px; color: #94a3b8; font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.empty { color: #64748b; text-align: center; padding: 40px; }
.floating-results { position: fixed; top: 50%%; left: 50%%; transform: translate(-50%%, -50%%); width: 400px; max-width: 90vw; max-height: 70vh; overflow-y: auto; background: #1e293b; border: 1px solid #475569; border-radius: 8px; box-shadow: 0 8px 32px rgba(0,0,0,0.5); padding: 16px; z-index: 1000; }
.floating-results.hidden { display: none; }
.floating-results .search-header { margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #334155; display: flex; align-items: center; gap: 8px; }
.floating-results .search-query { font-size: 18px; font-weight: bold; color: #3b82f6; }
.floating-results .result-item { display: flex; align-items: center; gap: 12px; padding: 12px; margin: 6px 0; border: 1px solid #334155; border-radius: 6px; cursor: pointer; transition: all 0.15s; }
.floating-results .result-item:hover, .floating-results .result-item.active { background: #334155; border-color: #3b82f6; }
.floating-results .result-thumb { width: 96px; height: 72px; flex: 0 0 auto; border-radius: 4px; border: 1px solid #334155; background: #0b1220; object-fit: contain; }
.floating-results .result-content { display: flex; flex-direction: column; gap: 2px; }
.floating-results .result-title { font-weight: bold; margin-bottom: 4px; }
.floating-results .result-meta { font-size: 12px; color: #94a3b8; }
.floating-results .no-results { color: #64748b; text-align: center; padding: 20px; }
.key-indicator { position: fixed; bottom: 16px; left: 16px; display: flex; gap: 4px; z-index: 1000; }
.key-box { display: inline-flex; align-items: center; justify-content: center; background: #334155; color: #e2e8f0; font-size: 12px; font-weight: bold; border-radius: 4px; box-shadow: 0 2px 4px rgba(0,0,0,0.3); opacity: 1; transition: opacity 0.3s; }
.key-box.square { width: 28px; height: 28px; }
.key-box.rectangle { padding: 4px 8px; }
.key-box.fade-out { opacity: 0; }
.help-hint { position: fixed; bottom: 16px; right: 16px; color: #64748b; font-size: 12px; }
</style>
</head>
<body>
<h1>Sessions</h1>
<div class="subtitle" id="subtitle"></div>
<div class="grid" id="grid"></div>
<div class="floating-results hidden" id="floating-results"></div>
<div class="key-indicator" id="key-indicator"></div>
<div class="help-hint">Type to search • ↑↓ to navigate • Enter to open • Esc to clear</div>
<script>
let tiles = %s;
const composeMode = %s;
const dockerWatchMode = %s;
let cardsBySlug = {};
let searchQuery = '';
let activeResultIndex = -1;
let filteredResults = [];
const floatingResultsEl = document.getElementById('floating-results');
const keyIndicatorEl = document.getElementById('key-indicator');
const thumbnailCache = {};
const activeObjectURLBySlug = {};
const etagBySlug = {};
const refreshQueue = [];
const queuedRefresh = {};
let screenshotRequestInFlight = false;
const grid = document.getElementById('grid');
const subtitle = document.getElementById('subtitle');
function makeTile(tile) {
const card = document.createElement('div');
card.className = 'tile';
const header = document.createElement('div');
header.className = 'tile-header';
const titleSpan = document.createElement('div');
titleSpan.className = 'tile-title';
titleSpan.innerHTML = '<span>' + tile.name + '</span>';
header.appendChild(titleSpan);
if (composeMode) {
const sparkline = document.createElement('img');
sparkline.className = 'sparkline';
sparkline.width = 80;
sparkline.height = 16;
sparkline.alt = 'CPU';
header.appendChild(sparkline);
card.sparkline = sparkline;
}
const body = document.createElement('div');
body.className = 'tile-body';
const img = document.createElement('img');
img.className = 'thumb';
img.alt = tile.name;
const meta = document.createElement('div');
meta.className = 'meta';
meta.textContent = tile.command || '';
meta.title = tile.command || '';
body.appendChild(img);
card.appendChild(header);
card.appendChild(body);
card.appendChild(meta);
card.onclick = () => openTile(tile);
card.img = img;
return card;
}
function normalizeText(value) {
return (value || '').toString().toLowerCase();
}
function getTileTitle(tile) {
return tile.name || tile.slug || 'Unknown';
}
function getTileCommand(tile) {
return tile.command || '';
}
function getThumbnailSrc(tile) {
const slug = tile.slug || '';
if (!slug) return '';
const card = cardsBySlug[slug];
if (card && card.img && card.img.src) {
thumbnailCache[slug] = { src: card.img.src, updatedAt: Date.now() };
return card.img.src;
}
const existing = thumbnailCache[slug];
return existing ? existing.src : '';
}
function updateTileSelection() {
Object.values(cardsBySlug).forEach((c) => c.classList.remove('selected'));
if (filteredResults.length > 0 && activeResultIndex >= 0) {
const selected = filteredResults[activeResultIndex];
if (selected && selected.slug) {
const card = cardsBySlug[selected.slug];
if (card) card.classList.add('selected');
}
}
}
function renderFloatingResults() {
floatingResultsEl.innerHTML = '';
if (searchQuery === '') {
floatingResultsEl.classList.add('hidden');
activeResultIndex = -1;
filteredResults = [];
updateTileSelection();
return;
}
const query = normalizeText(searchQuery);
filteredResults = tiles.filter((t) => {
if (!t) return false;
const name = normalizeText(t.name);
const command = normalizeText(t.command);
const slug = normalizeText(t.slug);
return name.includes(query) || command.includes(query) || slug.includes(query);
});
const header = document.createElement('div');
header.className = 'search-header';
header.innerHTML = '<span>Search:</span><span class="search-query">' + searchQuery + '</span>';
floatingResultsEl.appendChild(header);
if (filteredResults.length === 0) {
const noResults = document.createElement('div');
noResults.className = 'no-results';
noResults.textContent = 'No matches found';
floatingResultsEl.appendChild(noResults);
} else {
if (activeResultIndex < 0 || activeResultIndex >= filteredResults.length) {
activeResultIndex = 0;
}
filteredResults.forEach((tile, index) => {
const item = document.createElement('div');
item.className = 'result-item' + (index === activeResultIndex ? ' active' : '');
const thumb = document.createElement('img');
thumb.className = 'result-thumb';
const title = getTileTitle(tile);
const command = getTileCommand(tile);
const thumbSrc = getThumbnailSrc(tile);
thumb.alt = title;
if (thumbSrc) {
thumb.src = thumbSrc;
} else {
thumb.style.display = 'none';
}
const content = document.createElement('div');
content.className = 'result-content';
content.innerHTML = '<div class="result-title">' + title + '</div><div class="result-meta">' + command + '</div>';
item.appendChild(thumb);
item.appendChild(content);
item.onclick = () => openTile(tile);
floatingResultsEl.appendChild(item);
});
}
floatingResultsEl.classList.remove('hidden');
updateTileSelection();
}
function showKeyIndicator(key) {
const arrowKeyMap = { ArrowLeft: '←', ArrowRight: '→', ArrowUp: '↑', ArrowDown: '↓' };
const keyDisplay = arrowKeyMap[key] || key;
const keyBox = document.createElement('div');
keyBox.className = 'key-box ' + (key.length > 1 ? 'rectangle' : 'square');
keyBox.textContent = keyDisplay;
keyIndicatorEl.appendChild(keyBox);
setTimeout(() => {
keyBox.classList.add('fade-out');
setTimeout(() => keyBox.remove(), 300);
}, 1500);
}
function openTile(tile) {
if (!tile || !tile.slug) return;
const url = '/?route_key=' + encodeURIComponent(tile.slug);
const target = 'webterm-' + tile.slug;
let win = window.open(url, target);
if (!win) {
window.location.href = url;
return;
}
if (win.closed) {
win = window.open(url, target);
}
if (win && typeof win.focus === 'function') {
win.focus();
}
searchQuery = '';
activeResultIndex = -1;
renderFloatingResults();
}
function handleKeydown(event) {
if (event.target.tagName === 'INPUT' || event.target.tagName === 'TEXTAREA') return;
showKeyIndicator(event.key);
if (event.key === 'Escape') {
searchQuery = '';
activeResultIndex = -1;
renderFloatingResults();
return;
}
if (event.key === 'Backspace') {
searchQuery = searchQuery.slice(0, -1);
renderFloatingResults();
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
if (filteredResults.length > 0) {
activeResultIndex = (activeResultIndex - 1 + filteredResults.length) %% filteredResults.length;
renderFloatingResults();
}
return;
}
if (event.key === 'ArrowDown') {
event.preventDefault();
if (filteredResults.length > 0) {
activeResultIndex = (activeResultIndex + 1) %% filteredResults.length;
renderFloatingResults();
}
return;
}
if (event.key === 'Enter') {
if (filteredResults.length > 0 && activeResultIndex >= 0) {
openTile(filteredResults[activeResultIndex]);
} else if (filteredResults.length === 1) {
openTile(filteredResults[0]);
}
return;
}
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey && !event.altKey) {
searchQuery += event.key.toLowerCase();
renderFloatingResults();
}
}
document.addEventListener('keydown', handleKeydown);
function dashboardCanRequestScreenshots() {
return document.visibilityState === 'visible';
}
function onDashboardFocusChanged() {
if (dashboardCanRequestScreenshots()) {
processRefreshQueue();
}
}
document.addEventListener('visibilitychange', onDashboardFocusChanged);
window.addEventListener('focus', onDashboardFocusChanged);
window.addEventListener('blur', onDashboardFocusChanged);
function processRefreshQueue() {
if (screenshotRequestInFlight || refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return;
const slug = refreshQueue.shift();
delete queuedRefresh[slug];
const card = cardsBySlug[slug];
if (!card || !card.img) {
setTimeout(processRefreshQueue, 0);
return;
}
screenshotRequestInFlight = true;
const img = card.img;
const url = '/screenshot.svg?route_key=' + encodeURIComponent(slug);
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
const headers = {};
if (etagBySlug[slug]) {
headers['If-None-Match'] = etagBySlug[slug];
}
fetch(url, { cache: 'no-cache', headers, signal: controller.signal })
.then((resp) => {
const nextETag = resp.headers.get('ETag');
if (nextETag) {
etagBySlug[slug] = nextETag;
}
if (resp.status === 304) return null;
if (!resp.ok) throw new Error('screenshot fetch failed');
return resp.blob();
})
.then((blob) => {
if (!blob) return;
const previous = activeObjectURLBySlug[slug];
const objectURL = URL.createObjectURL(blob);
activeObjectURLBySlug[slug] = objectURL;
img.src = objectURL;
thumbnailCache[slug] = { src: objectURL, updatedAt: Date.now() };
if (previous) {
URL.revokeObjectURL(previous);
}
})
.catch(() => {})
.finally(() => {
clearTimeout(timeout);
screenshotRequestInFlight = false;
setTimeout(processRefreshQueue, 0);
});
}
function queueTileRefresh(slug) {
if (!slug || queuedRefresh[slug]) return;
queuedRefresh[slug] = true;
refreshQueue.push(slug);
processRefreshQueue();
}
function refreshTile(slug) {
queueTileRefresh(slug);
}
function refreshAll() {
for (const tile of tiles) {
queueTileRefresh(tile.slug);
}
}
async function refreshTilesList() {
try {
const resp = await fetch('/tiles');
const newTiles = await resp.json();
const oldSlugs = tiles.map((t) => t.slug).sort().join(',');
const newSlugs = newTiles.map((t) => t.slug).sort().join(',');
if (oldSlugs !== newSlugs) {
tiles = newTiles;
renderTiles();
}
} catch (_) {}
}
function refreshSparklines() {
if (!composeMode) return;
for (const tile of tiles) {
const card = cardsBySlug[tile.slug];
if (card && card.sparkline) {
card.sparkline.src = '/cpu-sparkline.svg?container=' + encodeURIComponent(tile.slug) + '&width=80&height=16&_t=' + Date.now();
}
}
}
const pendingRefresh = {};
const lastRefresh = {};
const REFRESH_DEBOUNCE_MS = 500;
function scheduleRefreshTile(slug) {
const now = Date.now();
const last = lastRefresh[slug] || 0;
if (now - last < REFRESH_DEBOUNCE_MS) {
if (!pendingRefresh[slug]) {
pendingRefresh[slug] = setTimeout(() => {
pendingRefresh[slug] = null;
refreshTile(slug);
lastRefresh[slug] = Date.now();
}, REFRESH_DEBOUNCE_MS - (now - last));
}
return;
}
refreshTile(slug);
lastRefresh[slug] = now;
}
function renderTiles() {
grid.innerHTML = '';
cardsBySlug = {};
refreshQueue.length = 0;
screenshotRequestInFlight = false;
for (const key in queuedRefresh) {
delete queuedRefresh[key];
}
for (const key in activeObjectURLBySlug) {
URL.revokeObjectURL(activeObjectURLBySlug[key]);
delete activeObjectURLBySlug[key];
}
if (tiles.length === 0) {
grid.innerHTML = '<div class="empty">No containers found. Start containers with the webterm-command label.</div>';
subtitle.textContent = dockerWatchMode ? 'Watching for containers with webterm-command label...' : '';
return;
}
subtitle.textContent = '';
if (dockerWatchMode) {
console.log(tiles.length + ' container(s) found');
}
for (const tile of tiles) {
const card = makeTile(tile);
grid.appendChild(card);
cardsBySlug[tile.slug] = card;
}
refreshAll();
renderFloatingResults();
refreshSparklines();
}
let source = null;
function startSSE() {
if (source) return;
source = new EventSource('/events');
source.addEventListener('activity', (e) => {
if (e.data === '__dashboard__') {
refreshTilesList();
} else {
scheduleRefreshTile(e.data);
}
});
source.onerror = () => {
source.close();
source = null;
setTimeout(startSSE, 2000);
};
}
renderTiles();
if (!document.hidden) startSSE();
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
if (source) {
source.close();
source = null;
}
} else {
startSSE();
}
});
if (composeMode) {
refreshSparklines();
setInterval(refreshSparklines, 30000);
}
</script>
</body>
</html>`, string(tilesJSON), composeModeJS, dockerWatchJS)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, html)
return
}
var app App
var ok bool
if routeKeyParam != "" {
app, ok = s.sessionManager.AppBySlug(routeKeyParam)
}
if !ok {
app, ok = s.sessionManager.GetDefaultApp()
}
if !ok {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>Webterm Server</title></head><body><h2>No Apps Available</h2><p>No terminal applications are configured.</p></body></html>")
return
}
routeKey := routeKeyParam
if routeKey == "" {
if runningKey, _, exists := s.sessionManager.GetFirstRunningSession(); exists {
routeKey = runningKey
} else {
routeKey = strings.ToLower(GenerateID(identitySize))
}
}
wsURL := s.getWSURL(r, routeKey)
theme := app.Theme
if strings.TrimSpace(theme) == "" {
theme = s.theme
}
themeBG := ThemeBackgrounds[strings.ToLower(theme)]
if themeBG == "" {
themeBG = "#000000"
}
fontFamily := s.fontFamily
if strings.TrimSpace(fontFamily) == "" {
fontFamily = "var(--webterm-mono)"
}
escapedFont := strings.ReplaceAll(fontFamily, `"`, "&quot;")
dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), s.fontSize, htmlAttrEscape(theme), escapedFont)
page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css"><style>html,body{width:100%%;height:100%%}body{background:%s;margin:0;padding:0;overflow:hidden;font-family:var(--webterm-mono)}.webterm-terminal{width:100%%;height:100%%;display:block;overflow:hidden}</style></head><body><div id="terminal" class="webterm-terminal" %s></div><script type="module" src="/static/js/terminal.js"></script></body></html>`, htmlEscape(app.Name), themeBG, dataAttrs)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = io.WriteString(w, page)
}
func htmlEscape(value string) string {
return strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;").Replace(value)
}
func htmlAttrEscape(value string) string {
return strings.NewReplacer("&", "&amp;", `"`, "&quot;", "<", "&lt;", ">", "&gt;").Replace(value)
}
func (s *LocalServer) handleHealth(w http.ResponseWriter, _ *http.Request) {
_, _ = io.WriteString(w, "Local server is running")
}
func (s *LocalServer) setupDockerFeatures() {
if (s.composeMode && len(s.landingApps) > 0) || s.dockerWatch {
stats := NewDockerStatsCollector("", s.composeProject)
if stats.Available() {
serviceNames := []string{}
apps := s.landingApps
if s.dockerWatch {
apps = s.sessionManager.Apps()
}
for _, app := range apps {
serviceNames = append(serviceNames, app.Name)
s.slugToService[app.Slug] = app.Name
}
stats.Start(serviceNames)
s.dockerStats = stats
}
}
if s.dockerWatch {
watcher := NewDockerWatcher(
s.sessionManager,
"",
func(slug, name, _ string) {
s.mu.Lock()
s.slugToService[slug] = name
if s.dockerStats != nil {
s.dockerStats.AddService(name)
}
s.mu.Unlock()
s.markRouteActivity("__dashboard__")
},
func(slug string) {
s.mu.Lock()
serviceName := s.slugToService[slug]
delete(s.slugToService, slug)
delete(s.screenshotCache, slug)
if s.dockerStats != nil && serviceName != "" {
s.dockerStats.RemoveService(serviceName)
}
s.mu.Unlock()
s.markRouteActivity("__dashboard__")
},
)
s.dockerWatcher = watcher
watcher.Start()
}
}
func (s *LocalServer) shutdown() {
if s.dockerWatcher != nil {
s.dockerWatcher.Stop()
}
if s.dockerStats != nil {
s.dockerStats.Stop()
}
s.sessionManager.CloseAll()
s.mu.Lock()
clients := map[string]*wsClient{}
for key, client := range s.wsClients {
clients[key] = client
}
s.wsClients = map[string]*wsClient{}
s.mu.Unlock()
for _, client := range clients {
client.closed.Store(true)
close(client.send)
<-client.done
_ = client.conn.Close()
}
}
func (s *LocalServer) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/ws/", s.handleWebSocket)
mux.HandleFunc("/screenshot.svg", s.handleScreenshot)
mux.HandleFunc("/cpu-sparkline.svg", s.handleCPUSparkline)
mux.HandleFunc("/events", s.handleEvents)
mux.HandleFunc("/health", s.handleHealth)
mux.HandleFunc("/tiles", s.handleTiles)
mux.HandleFunc("/", s.handleRoot)
if strings.TrimSpace(s.staticPath) != "" {
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir(s.staticPath))))
}
return s.loggingMiddleware(s.gzipMiddleware(mux))
}
func (s *LocalServer) Run(ctx context.Context) error {
s.setupDockerFeatures()
server := &http.Server{
Addr: fmt.Sprintf("%s:%d", s.host, s.port),
Handler: s.Handler(),
}
errCh := make(chan error, 1)
go func() {
err := server.ListenAndServe()
if err != nil && err != http.ErrServerClosed {
errCh <- err
return
}
errCh <- nil
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = server.Shutdown(shutdownCtx)
cancel()
s.shutdown()
<-errCh
return nil
case err := <-errCh:
s.shutdown()
return err
}
}