41071f8a89
The orange bell outline on tiles could get stuck because: 1. The terminal tab clears its bell localStorage entry on focus/keypress, but the 'storage' event only fires across tabs — the dashboard in the same browser never sees the removal. 2. The only way to clear the bell class was to click the tile to open it (openTile → clearBellState). Simply switching back to the dashboard tab never re-checked localStorage. Now onDashboardFocusChanged() calls syncAllBellStates() which re-reads localStorage for every tile and toggles the bell class accordingly, clearing stale orange outlines as soon as the dashboard becomes visible.
1940 lines
56 KiB
Go
1940 lines
56 KiB
Go
package webterm
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"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
|
|
wsReadTimeout = 90 * time.Second
|
|
wsPingPeriod = 30 * time.Second
|
|
stdinWriteTimeout = 2 * time.Second
|
|
screenshotCacheSeconds = 250 * time.Millisecond
|
|
maxScreenshotCacheTTL = 20 * time.Second
|
|
screenshotEvictInterval = 60 * 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 screenshotPNGCacheEntry struct {
|
|
when time.Time
|
|
png []byte
|
|
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
|
|
screenshotMode string
|
|
|
|
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
|
|
screenshotPNGCache map[string]screenshotPNGCacheEntry
|
|
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)
|
|
if activeSessionID, ok := c.server.sessionManager.GetSessionIDByRouteKey(c.routeKey); ok && activeSessionID != c.sessionID {
|
|
return
|
|
}
|
|
c.server.stopWSClient(c.routeKey, nil)
|
|
}
|
|
|
|
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
|
|
}
|
|
screenshotMode := strings.ToLower(strings.TrimSpace(os.Getenv(ScreenshotModeEnv)))
|
|
if screenshotMode != "svg" {
|
|
screenshotMode = "png"
|
|
}
|
|
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,
|
|
screenshotMode: screenshotMode,
|
|
|
|
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{},
|
|
screenshotPNGCache: map[string]screenshotPNGCacheEntry{},
|
|
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) < 250*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:
|
|
log.Printf("websocket send queue saturated route=%s: disconnecting slow client", routeKey)
|
|
s.stopWSClient(routeKey, client)
|
|
}
|
|
}
|
|
|
|
func (s *LocalServer) stopWSClient(routeKey string, expected *wsClient) {
|
|
s.mu.Lock()
|
|
client := s.wsClients[routeKey]
|
|
if expected != nil && client != expected {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
delete(s.wsClients, routeKey)
|
|
s.mu.Unlock()
|
|
if client == nil {
|
|
return
|
|
}
|
|
if client.closed.Swap(true) {
|
|
return
|
|
}
|
|
close(client.send)
|
|
if client.conn != nil {
|
|
_ = client.conn.Close()
|
|
}
|
|
<-client.done
|
|
}
|
|
|
|
func (s *LocalServer) wsSender(client *wsClient) {
|
|
defer close(client.done)
|
|
pingTicker := time.NewTicker(wsPingPeriod)
|
|
defer pingTicker.Stop()
|
|
for {
|
|
select {
|
|
case outbound, ok := <-client.send:
|
|
if !ok {
|
|
return
|
|
}
|
|
_ = client.conn.SetWriteDeadline(time.Now().Add(wsSendTimeout))
|
|
if err := client.conn.WriteMessage(outbound.messageType, outbound.payload); err != nil {
|
|
client.closed.Store(true)
|
|
_ = client.conn.Close()
|
|
return
|
|
}
|
|
case <-pingTicker.C:
|
|
deadline := time.Now().Add(wsSendTimeout)
|
|
if err := client.conn.WriteControl(websocket.PingMessage, nil, deadline); err != nil {
|
|
client.closed.Store(true)
|
|
_ = client.conn.Close()
|
|
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 r.URL.Path == "/events" {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
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, client)
|
|
defer func() {
|
|
if session := s.sessionManager.GetSessionByRouteKey(routeKey); session != nil {
|
|
session.MarkIdle()
|
|
}
|
|
}()
|
|
|
|
// 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
|
|
// Clear idle state so the output pipeline resumes fully
|
|
connector := &localClientConnector{
|
|
server: s,
|
|
sessionID: sessionID,
|
|
routeKey: routeKey,
|
|
}
|
|
session.UpdateConnector(connector)
|
|
replay := daResponsePattern.ReplaceAll(session.GetReplayBuffer(), nil)
|
|
if len(replay) > 0 {
|
|
s.enqueueWSFrame(routeKey, websocket.BinaryMessage, replay)
|
|
}
|
|
} else {
|
|
s.sessionManager.OnSessionEnd(sessionID)
|
|
}
|
|
}
|
|
|
|
_ = conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
conn.SetPongHandler(func(string) error {
|
|
return conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
})
|
|
type stdinWrite struct {
|
|
session Session
|
|
data string
|
|
}
|
|
stdinQueue := make(chan stdinWrite, wsSendQueueMax)
|
|
defer close(stdinQueue)
|
|
|
|
// Coalesce small stdin writes (e.g. key repeats) to reduce syscall and locking overhead.
|
|
const stdinCoalesceMaxBytes = 4 * 1024
|
|
go func() {
|
|
var buf bytes.Buffer
|
|
for write := range stdinQueue {
|
|
buf.Reset()
|
|
buf.WriteString(write.data)
|
|
for buf.Len() < stdinCoalesceMaxBytes {
|
|
select {
|
|
case next := <-stdinQueue:
|
|
buf.WriteString(next.data)
|
|
default:
|
|
goto flush
|
|
}
|
|
}
|
|
flush:
|
|
if !write.session.SendBytes(buf.Bytes()) {
|
|
log.Printf("stdin write failed route=%s remote=%s", routeKey, r.RemoteAddr)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// Allocate the timeout timer once; avoid time.After() per stdin message.
|
|
stdinTimer := time.NewTimer(stdinWriteTimeout)
|
|
if !stdinTimer.Stop() {
|
|
<-stdinTimer.C
|
|
}
|
|
defer stdinTimer.Stop()
|
|
|
|
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
|
|
}
|
|
_ = conn.SetReadDeadline(time.Now().Add(wsReadTimeout))
|
|
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)
|
|
}
|
|
write := stdinWrite{session: session, data: data}
|
|
select {
|
|
case stdinQueue <- write:
|
|
// queued
|
|
default:
|
|
// Queue is full; wait briefly for it to drain.
|
|
if !stdinTimer.Stop() {
|
|
select {
|
|
case <-stdinTimer.C:
|
|
default:
|
|
}
|
|
}
|
|
stdinTimer.Reset(stdinWriteTimeout)
|
|
select {
|
|
case stdinQueue <- write:
|
|
if !stdinTimer.Stop() {
|
|
select {
|
|
case <-stdinTimer.C:
|
|
default:
|
|
}
|
|
}
|
|
case <-stdinTimer.C:
|
|
log.Printf("stdin queue saturated route=%s remote=%s: disconnecting client", routeKey, r.RemoteAddr)
|
|
sendJSON([]any{"error", "Input backlog detected"})
|
|
return
|
|
}
|
|
}
|
|
}
|
|
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 sanitizeSVGFontFaceURLs(svg string) string {
|
|
return strings.ReplaceAll(svg, `src:url("/static/fonts/FiraCodeNerdFont-Regular.ttf") format("truetype");`, "")
|
|
}
|
|
|
|
func sanitizeFilenameToken(value string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return "webterm"
|
|
}
|
|
var b strings.Builder
|
|
b.Grow(len(trimmed))
|
|
for _, ch := range trimmed {
|
|
switch {
|
|
case ch >= 'a' && ch <= 'z':
|
|
b.WriteRune(ch)
|
|
case ch >= 'A' && ch <= 'Z':
|
|
b.WriteRune(ch)
|
|
case ch >= '0' && ch <= '9':
|
|
b.WriteRune(ch)
|
|
case ch == '-' || ch == '_':
|
|
b.WriteRune(ch)
|
|
default:
|
|
b.WriteByte('-')
|
|
}
|
|
}
|
|
cleaned := strings.Trim(b.String(), "-")
|
|
if cleaned == "" {
|
|
return "webterm"
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
func (s *LocalServer) handleScreenshot(w http.ResponseWriter, r *http.Request) {
|
|
sanitizeFontURLs := r.URL.Query().Get("sanitize_font_urls") == "1"
|
|
download := r.URL.Query().Get("download") == "1"
|
|
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
|
|
}
|
|
|
|
prepareSVG := func(rawSVG string) (string, string) {
|
|
if sanitizeFontURLs {
|
|
rawSVG = sanitizeSVGFontFaceURLs(rawSVG)
|
|
}
|
|
hash := sha1.Sum([]byte(rawSVG))
|
|
return rawSVG, fmt.Sprintf(`"%x"`, hash[:])
|
|
}
|
|
writeNotModified := func(etag string) {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("ETag", etag)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}
|
|
writeSVG := func(svg, etag string) {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
if download {
|
|
filename := sanitizeFilenameToken(routeKey) + "-screenshot.svg"
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
}
|
|
_, _ = io.WriteString(w, svg)
|
|
}
|
|
useConditional := !download
|
|
|
|
s.mu.RLock()
|
|
cached, hasCached := s.screenshotCache[routeKey]
|
|
lastActivity := s.routeLastActivity[routeKey]
|
|
s.mu.RUnlock()
|
|
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) {
|
|
svg, etag := prepareSVG(cached.svg)
|
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
|
|
if !lastActivity.After(cached.when) {
|
|
writeNotModified(etag)
|
|
return
|
|
}
|
|
} else {
|
|
writeSVG(svg, etag)
|
|
return
|
|
}
|
|
}
|
|
|
|
if s.screenshotForceRedraw {
|
|
_ = session.ForceRedraw()
|
|
}
|
|
|
|
snapshot := session.GetScreenSnapshot()
|
|
if hasCached && !snapshot.HasChanges {
|
|
svg, etag := prepareSVG(cached.svg)
|
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
|
|
writeNotModified(etag)
|
|
return
|
|
}
|
|
writeSVG(svg, etag)
|
|
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()
|
|
responseSVG, responseETag := prepareSVG(svg)
|
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), responseETag) {
|
|
writeNotModified(responseETag)
|
|
return
|
|
}
|
|
writeSVG(responseSVG, responseETag)
|
|
}
|
|
|
|
func (s *LocalServer) handleScreenshotPNG(w http.ResponseWriter, r *http.Request) {
|
|
if s.screenshotMode != "png" {
|
|
http.Error(w, "PNG screenshots disabled", http.StatusNotFound)
|
|
return
|
|
}
|
|
download := r.URL.Query().Get("download") == "1"
|
|
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
|
|
}
|
|
|
|
writeNotModified := func(etag string) {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("ETag", etag)
|
|
w.WriteHeader(http.StatusNotModified)
|
|
}
|
|
writePNG := func(pngBytes []byte, etag string) {
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("ETag", etag)
|
|
w.Header().Set("Content-Type", "image/png")
|
|
if download {
|
|
filename := sanitizeFilenameToken(routeKey) + "-screenshot.png"
|
|
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
|
}
|
|
_, _ = w.Write(pngBytes)
|
|
}
|
|
useConditional := !download
|
|
|
|
s.mu.RLock()
|
|
cached, hasCached := s.screenshotPNGCache[routeKey]
|
|
lastActivity := s.routeLastActivity[routeKey]
|
|
s.mu.RUnlock()
|
|
if hasCached && time.Since(cached.when) < s.screenshotTTL(routeKey) && len(cached.png) > 0 {
|
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
|
if !lastActivity.After(cached.when) {
|
|
writeNotModified(cached.etag)
|
|
return
|
|
}
|
|
} else {
|
|
writePNG(cached.png, cached.etag)
|
|
return
|
|
}
|
|
}
|
|
|
|
if s.screenshotForceRedraw {
|
|
_ = session.ForceRedraw()
|
|
}
|
|
|
|
snapshot := session.GetScreenSnapshot()
|
|
if hasCached && !snapshot.HasChanges && len(cached.png) > 0 {
|
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), cached.etag) {
|
|
writeNotModified(cached.etag)
|
|
return
|
|
}
|
|
writePNG(cached.png, cached.etag)
|
|
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"
|
|
}
|
|
|
|
pngBytes, err := RenderTerminalPNG(snapshot.Buffer, snapshot.Width, snapshot.Height, background, foreground, palette)
|
|
if err != nil || len(pngBytes) == 0 {
|
|
http.Error(w, "Screenshot render failed", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
hash := sha1.Sum(pngBytes)
|
|
etag := fmt.Sprintf(`"%x"`, hash[:])
|
|
s.mu.Lock()
|
|
s.screenshotPNGCache[routeKey] = screenshotPNGCacheEntry{when: time.Now(), png: pngBytes, etag: etag}
|
|
s.mu.Unlock()
|
|
if useConditional && etagMatches(r.Header.Get("If-None-Match"), etag) {
|
|
writeNotModified(etag)
|
|
return
|
|
}
|
|
writePNG(pngBytes, etag)
|
|
}
|
|
|
|
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")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
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:
|
|
if _, err := fmt.Fprintf(w, "event: activity\ndata: %s\n\n", routeKey); err != nil {
|
|
return
|
|
}
|
|
flusher.Flush()
|
|
case <-ticker.C:
|
|
if _, err := io.WriteString(w, ": keepalive\n\n"); err != nil {
|
|
return
|
|
}
|
|
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"
|
|
}
|
|
screenshotEndpoint := "/screenshot.svg"
|
|
screenshotDownloadEndpoint := "/screenshot.svg"
|
|
screenshotDownloadQuery := "sanitize_font_urls=1&download=1"
|
|
screenshotDownloadExt := "svg"
|
|
if s.screenshotMode == "png" {
|
|
screenshotEndpoint = "/screenshot.png"
|
|
}
|
|
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>
|
|
@font-face { font-family: "FiraCode Nerd Font"; src: url("/static/fonts/FiraCodeNerdFont-Regular.ttf") format("truetype"); font-style: normal; font-weight: 400; font-display: swap; }
|
|
@font-face { font-family: "FiraMono Nerd Font"; src: url("/static/fonts/FiraCodeNerdFont-Regular.ttf") format("truetype"); font-style: normal; font-weight: 400; font-display: swap; }
|
|
:root { --webterm-mono: ui-monospace, "SFMono-Regular", "FiraCode Nerd Font", "FiraMono Nerd Font", "Fira Code", "Roboto Mono", Menlo, Monaco, Consolas, "Liberation Mono", "DejaVu Sans Mono", "Courier New", monospace; }
|
|
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.bell { border-color: #f59e0b; box-shadow: 0 0 0 2px rgba(245,158,11,0.35); }
|
|
.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;
|
|
const screenshotEndpoint = %q;
|
|
const screenshotDownloadEndpoint = %q;
|
|
const screenshotDownloadQuery = %q;
|
|
const screenshotDownloadExt = %q;
|
|
let cardsBySlug = {};
|
|
const bellStoragePrefix = 'webterm:bell:';
|
|
const dashboardTitle = document.title;
|
|
|
|
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 = {};
|
|
// Allow limited parallelism when fetching thumbnails so large dashboards update faster.
|
|
const MAX_SCREENSHOT_CONCURRENCY = Math.max(2, Math.min(4, Math.floor((navigator.hardwareConcurrency || 4) / 2)));
|
|
let screenshotRequestsInFlight = 0;
|
|
const grid = document.getElementById('grid');
|
|
const subtitle = document.getElementById('subtitle');
|
|
|
|
function bellStorageKey(slug) {
|
|
return bellStoragePrefix + slug;
|
|
}
|
|
|
|
function hasBell(slug) {
|
|
if (!slug) return false;
|
|
return Boolean(localStorage.getItem(bellStorageKey(slug)));
|
|
}
|
|
|
|
function updateDashboardTitle() {
|
|
const anyBell = tiles.some((tile) => tile && tile.slug && hasBell(tile.slug));
|
|
document.title = anyBell ? '🔔 ' + dashboardTitle : dashboardTitle;
|
|
}
|
|
|
|
function applyBellState(slug) {
|
|
if (!slug) return;
|
|
const card = cardsBySlug[slug];
|
|
if (card) {
|
|
card.classList.toggle('bell', hasBell(slug));
|
|
}
|
|
updateDashboardTitle();
|
|
}
|
|
|
|
function syncAllBellStates() {
|
|
for (const tile of tiles) {
|
|
if (tile && tile.slug) {
|
|
applyBellState(tile.slug);
|
|
}
|
|
}
|
|
}
|
|
|
|
function clearBellState(slug) {
|
|
if (!slug) return;
|
|
localStorage.removeItem(bellStorageKey(slug));
|
|
applyBellState(slug);
|
|
}
|
|
|
|
window.addEventListener('storage', (event) => {
|
|
if (!event.key || !event.key.startsWith(bellStoragePrefix)) return;
|
|
const slug = event.key.slice(bellStoragePrefix.length);
|
|
applyBellState(slug);
|
|
});
|
|
|
|
function downloadSanitizedScreenshot(slug) {
|
|
if (!slug) return;
|
|
const link = document.createElement('a');
|
|
link.href = screenshotDownloadEndpoint + '?route_key=' + encodeURIComponent(slug) + '&' + screenshotDownloadQuery + '&_t=' + Date.now();
|
|
link.download = slug + '-screenshot.' + screenshotDownloadExt;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
}
|
|
|
|
function makeTile(tile) {
|
|
const card = document.createElement('div');
|
|
card.className = 'tile';
|
|
if (hasBell(tile.slug)) {
|
|
card.classList.add('bell');
|
|
}
|
|
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.addEventListener('contextmenu', (event) => {
|
|
event.preventDefault();
|
|
downloadSanitizedScreenshot(tile.slug);
|
|
});
|
|
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;
|
|
clearBellState(tile.slug);
|
|
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';
|
|
}
|
|
|
|
let dashboardHiddenAt = 0;
|
|
|
|
function onDashboardFocusChanged() {
|
|
if (dashboardCanRequestScreenshots()) {
|
|
// Re-check bell state for all tiles — the terminal tab may have
|
|
// cleared its localStorage entry while we were hidden, and the
|
|
// storage event only fires across tabs, not within the same tab.
|
|
syncAllBellStates();
|
|
|
|
// If the dashboard was hidden for more than a few seconds,
|
|
// clear ETags and refresh all tiles so the user sees current state.
|
|
const away = dashboardHiddenAt ? (Date.now() - dashboardHiddenAt) : 0;
|
|
dashboardHiddenAt = 0;
|
|
if (away > 3000) {
|
|
for (const key in etagBySlug) {
|
|
delete etagBySlug[key];
|
|
}
|
|
refreshAll();
|
|
} else {
|
|
processRefreshQueue();
|
|
}
|
|
} else {
|
|
dashboardHiddenAt = Date.now();
|
|
}
|
|
}
|
|
|
|
document.addEventListener('visibilitychange', onDashboardFocusChanged);
|
|
window.addEventListener('focus', onDashboardFocusChanged);
|
|
window.addEventListener('blur', onDashboardFocusChanged);
|
|
|
|
// Periodic fallback: refresh all tiles every 10s in case SSE events were missed.
|
|
setInterval(() => {
|
|
if (dashboardCanRequestScreenshots()) {
|
|
refreshAll();
|
|
}
|
|
}, 10000);
|
|
|
|
function processRefreshQueue() {
|
|
if (refreshQueue.length === 0 || !dashboardCanRequestScreenshots()) return;
|
|
|
|
while (screenshotRequestsInFlight < MAX_SCREENSHOT_CONCURRENCY && refreshQueue.length > 0) {
|
|
const slug = refreshQueue.shift();
|
|
delete queuedRefresh[slug];
|
|
const card = cardsBySlug[slug];
|
|
if (!card || !card.img) {
|
|
continue;
|
|
}
|
|
|
|
screenshotRequestsInFlight++;
|
|
const url = screenshotEndpoint + '?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 currentCard = cardsBySlug[slug];
|
|
if (!currentCard || !currentCard.img) return;
|
|
const previous = activeObjectURLBySlug[slug];
|
|
if (previous) URL.revokeObjectURL(previous);
|
|
const objectURL = URL.createObjectURL(blob);
|
|
activeObjectURLBySlug[slug] = objectURL;
|
|
currentCard.img.src = objectURL;
|
|
})
|
|
.catch(() => {})
|
|
.finally(() => {
|
|
clearTimeout(timeout);
|
|
screenshotRequestsInFlight--;
|
|
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 (_) {}
|
|
}
|
|
|
|
const activeSparklineURLBySlug = {};
|
|
function refreshSparklines() {
|
|
if (!composeMode) return;
|
|
for (const tile of tiles) {
|
|
const card = cardsBySlug[tile.slug];
|
|
if (!card || !card.sparkline) continue;
|
|
const slug = tile.slug;
|
|
const url = '/cpu-sparkline.svg?container=' + encodeURIComponent(slug) + '&width=80&height=16&_t=' + Date.now();
|
|
fetch(url).then(r => r.ok ? r.blob() : null).then(blob => {
|
|
if (!blob) return;
|
|
const prev = activeSparklineURLBySlug[slug];
|
|
const objectURL = URL.createObjectURL(blob);
|
|
activeSparklineURLBySlug[slug] = objectURL;
|
|
if (card.sparkline) card.sparkline.src = objectURL;
|
|
if (prev) URL.revokeObjectURL(prev);
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
|
|
const pendingRefresh = {};
|
|
const lastRefresh = {};
|
|
const REFRESH_DEBOUNCE_MS = 250;
|
|
|
|
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;
|
|
screenshotRequestsInFlight = 0;
|
|
for (const key in queuedRefresh) {
|
|
delete queuedRefresh[key];
|
|
}
|
|
for (const key in activeObjectURLBySlug) {
|
|
URL.revokeObjectURL(activeObjectURLBySlug[key]);
|
|
delete activeObjectURLBySlug[key];
|
|
}
|
|
for (const key in activeSparklineURLBySlug) {
|
|
URL.revokeObjectURL(activeSparklineURLBySlug[key]);
|
|
delete activeSparklineURLBySlug[key];
|
|
}
|
|
for (const key in thumbnailCache) {
|
|
delete thumbnailCache[key];
|
|
}
|
|
for (const key in pendingRefresh) {
|
|
clearTimeout(pendingRefresh[key]);
|
|
delete pendingRefresh[key];
|
|
}
|
|
for (const key in etagBySlug) {
|
|
delete etagBySlug[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();
|
|
updateDashboardTitle();
|
|
}
|
|
|
|
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, screenshotEndpoint, screenshotDownloadEndpoint, screenshotDownloadQuery, screenshotDownloadExt)
|
|
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, `"`, """)
|
|
dataAttrs := fmt.Sprintf(`data-session-websocket-url="%s" data-session-route-key="%s" data-session-name="%s" data-font-size="%d" data-scrollback="1000" data-theme="%s" data-font-family="%s"`, htmlAttrEscape(wsURL), htmlAttrEscape(routeKey), htmlAttrEscape(app.Name), s.fontSize, htmlAttrEscape(theme), escapedFont)
|
|
cacheBust := "?v=" + Version
|
|
page := fmt.Sprintf(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>%s</title><link rel="stylesheet" href="/static/monospace.css%s"><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%s"></script></body></html>`, htmlEscape(app.Name), cacheBust, themeBG, dataAttrs, cacheBust)
|
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
_, _ = io.WriteString(w, page)
|
|
}
|
|
|
|
func htmlEscape(value string) string {
|
|
return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(value)
|
|
}
|
|
|
|
func htmlAttrEscape(value string) string {
|
|
return strings.NewReplacer("&", "&", `"`, """, "<", "<", ">", ">").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("/screenshot.png", s.handleScreenshotPNG)
|
|
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))))
|
|
} else if staticFS, ok := embeddedStaticFS(); ok {
|
|
mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(staticFS)))
|
|
}
|
|
return s.loggingMiddleware(s.gzipMiddleware(mux))
|
|
}
|
|
|
|
// evictStaleScreenshots periodically removes screenshot cache entries
|
|
// older than maxScreenshotCacheTTL to prevent unbounded memory growth.
|
|
func (s *LocalServer) evictStaleScreenshots(ctx context.Context) {
|
|
ticker := time.NewTicker(screenshotEvictInterval)
|
|
defer ticker.Stop()
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
s.mu.Lock()
|
|
for key, entry := range s.screenshotCache {
|
|
if time.Since(entry.when) > maxScreenshotCacheTTL {
|
|
delete(s.screenshotCache, key)
|
|
}
|
|
}
|
|
for key, entry := range s.screenshotPNGCache {
|
|
if time.Since(entry.when) > maxScreenshotCacheTTL {
|
|
delete(s.screenshotPNGCache, key)
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *LocalServer) Run(ctx context.Context) error {
|
|
s.setupDockerFeatures()
|
|
go s.evictStaleScreenshots(ctx)
|
|
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
|
|
}
|
|
}
|