Reorganize project into standard root layout

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
GitHub Copilot
2026-02-14 22:40:29 +00:00
parent 374b69bd7e
commit 516f1b1946
62 changed files with 34 additions and 32 deletions
+18
View File
@@ -0,0 +1,18 @@
package webterm
import (
"embed"
"io/fs"
"net/http"
)
//go:embed static static/* static/js/* static/icons/*
var embeddedStaticAssets embed.FS
func embeddedStaticFS() (http.FileSystem, bool) {
sub, err := fs.Sub(embeddedStaticAssets, "static")
if err != nil {
return nil, false
}
return http.FS(sub), true
}
+106
View File
@@ -0,0 +1,106 @@
package webterm
import (
"context"
"flag"
"fmt"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
)
func RunCLI(args []string) error {
fs := flag.NewFlagSet("webterm", flag.ContinueOnError)
fs.SetOutput(os.Stdout)
port := DefaultPort
host := DefaultHost
landingManifest := ""
composeManifest := ""
dockerWatch := false
theme := DefaultTheme
fontFamily := ""
fontSize := DefaultFontSize
showVersion := false
fs.IntVar(&port, "port", DefaultPort, "Port for server.")
fs.IntVar(&port, "p", DefaultPort, "Port for server.")
fs.StringVar(&host, "host", DefaultHost, "Host for server.")
fs.StringVar(&host, "H", DefaultHost, "Host for server.")
fs.StringVar(&landingManifest, "landing-manifest", "", "YAML manifest describing landing page tiles.")
fs.StringVar(&landingManifest, "L", "", "YAML manifest describing landing page tiles.")
fs.StringVar(&composeManifest, "compose-manifest", "", "Docker compose YAML; services with label \"webterm-command\" become landing tiles.")
fs.StringVar(&composeManifest, "C", "", "Docker compose YAML; services with label \"webterm-command\" become landing tiles.")
fs.BoolVar(&dockerWatch, "docker-watch", false, "Watch Docker for containers with labels and add/remove sessions dynamically.")
fs.BoolVar(&dockerWatch, "D", false, "Watch Docker for containers with labels and add/remove sessions dynamically.")
fs.StringVar(&theme, "theme", DefaultTheme, "Terminal color theme.")
fs.StringVar(&theme, "t", DefaultTheme, "Terminal color theme.")
fs.StringVar(&fontFamily, "font-family", "", "Terminal font family (CSS font stack).")
fs.StringVar(&fontFamily, "f", "", "Terminal font family (CSS font stack).")
fs.IntVar(&fontSize, "font-size", DefaultFontSize, "Terminal font size in pixels.")
fs.IntVar(&fontSize, "s", DefaultFontSize, "Terminal font size in pixels.")
fs.BoolVar(&showVersion, "version", false, "Print version and exit.")
fs.BoolVar(&showVersion, "v", false, "Print version and exit.")
if err := fs.Parse(args); err != nil {
return err
}
if showVersion {
_, _ = fmt.Fprintln(os.Stdout, Version)
return nil
}
command := strings.TrimSpace(strings.Join(fs.Args(), " "))
config := DefaultConfig()
landingApps := []App{}
composeMode := false
composeProject := ""
if landingManifest != "" {
apps, err := LoadLandingYAML(landingManifest)
if err != nil {
return err
}
landingApps = apps
}
if composeManifest != "" {
apps, project, err := LoadComposeManifest(composeManifest)
if err != nil {
return err
}
landingApps = apps
composeMode = true
composeProject = project
}
if composeProject == "" && composeManifest != "" {
composeProject = filepath.Base(filepath.Dir(composeManifest))
}
server := NewLocalServer(config, ServerOptions{
Host: host,
Port: port,
Theme: theme,
FontFamily: fontFamily,
FontSize: fontSize,
LandingApps: landingApps,
ComposeMode: composeMode,
ComposeProject: composeProject,
DockerWatch: dockerWatch,
})
if command != "" {
server.sessionManager.AddApp("Terminal", command, "", true, "")
} else if !dockerWatch && len(landingApps) == 0 {
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/sh"
}
server.sessionManager.AddApp("Terminal", shell, "", true, "")
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return server.Run(ctx)
}
+9
View File
@@ -0,0 +1,9 @@
package webterm
import "testing"
func TestRunCLIVersion(t *testing.T) {
if err := RunCLI([]string{"--version"}); err != nil {
t.Fatalf("RunCLI(--version) error = %v", err)
}
}
+146
View File
@@ -0,0 +1,146 @@
package webterm
import (
"errors"
"os"
"path/filepath"
"gopkg.in/yaml.v3"
)
type App struct {
Name string `yaml:"name" json:"name"`
Slug string `yaml:"slug" json:"slug"`
Path string `yaml:"path" json:"-"`
Color string `yaml:"color" json:"-"`
Command string `yaml:"command" json:"command"`
Terminal bool `yaml:"terminal" json:"-"`
Theme string `yaml:"theme" json:"-"`
}
type Config struct {
Apps []App
}
func DefaultConfig() Config {
return Config{Apps: []App{}}
}
func LoadLandingYAML(manifestPath string) ([]App, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, err
}
var entries []map[string]any
if err := yaml.Unmarshal(data, &entries); err != nil {
return nil, err
}
apps := make([]App, 0, len(entries))
for _, entry := range entries {
name, _ := entry["name"].(string)
command, _ := entry["command"].(string)
if name == "" || command == "" {
continue
}
slug := asString(entry["slug"])
if slug == "" {
slug = Slugify(name)
}
path := asString(entry["path"])
if path == "" {
path = "./"
}
terminal := true
if value, ok := entry["terminal"].(bool); ok {
terminal = value
}
apps = append(apps, App{
Name: name,
Slug: slug,
Command: command,
Path: path,
Color: asString(entry["color"]),
Terminal: terminal,
Theme: asString(entry["theme"]),
})
}
return apps, nil
}
func LoadComposeManifest(manifestPath string) ([]App, string, error) {
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, "", err
}
var root map[string]any
if err := yaml.Unmarshal(data, &root); err != nil {
return nil, "", err
}
servicesAny, ok := root["services"]
if !ok {
return []App{}, filepath.Base(filepath.Dir(manifestPath)), nil
}
services, ok := servicesAny.(map[string]any)
if !ok {
return nil, "", errors.New("compose services must be mapping")
}
apps := make([]App, 0, len(services))
for name, serviceAny := range services {
service, ok := serviceAny.(map[string]any)
if !ok {
continue
}
command := extractLabel(service["labels"], "webterm-command")
if command == "" {
continue
}
theme := extractLabel(service["labels"], "webterm-theme")
workingDir := asString(service["working_dir"])
if workingDir == "" {
workingDir = "./"
}
apps = append(apps, App{
Name: name,
Slug: Slugify(name),
Command: command,
Path: workingDir,
Terminal: true,
Theme: theme,
})
}
return apps, filepath.Base(filepath.Dir(manifestPath)), nil
}
func extractLabel(labels any, key string) string {
switch raw := labels.(type) {
case map[string]any:
return asString(raw[key])
case []any:
for _, item := range raw {
text, ok := item.(string)
if !ok {
continue
}
for i := 0; i < len(text); i++ {
if text[i] != '=' {
continue
}
if text[:i] == key {
return text[i+1:]
}
break
}
}
}
return ""
}
func asString(value any) string {
if value == nil {
return ""
}
if s, ok := value.(string); ok {
return os.ExpandEnv(s)
}
return ""
}
+97
View File
@@ -0,0 +1,97 @@
package webterm
import (
"os"
"path/filepath"
"testing"
)
func TestLoadLandingYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "landing.yaml")
content := `
- name: Shell
command: /bin/sh
slug: shell
- name: Missing Command
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
apps, err := LoadLandingYAML(path)
if err != nil {
t.Fatalf("LoadLandingYAML() error = %v", err)
}
if len(apps) != 1 {
t.Fatalf("expected 1 app, got %d", len(apps))
}
if apps[0].Slug != "shell" || apps[0].Command != "/bin/sh" {
t.Fatalf("unexpected app: %+v", apps[0])
}
}
func TestLoadComposeManifestReadsLabels(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "compose.yaml")
content := `
services:
web:
labels:
webterm-command: auto
webterm-theme: monokai
db:
labels:
- webterm-command=docker exec -it db psql
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
apps, project, err := LoadComposeManifest(path)
if err != nil {
t.Fatalf("LoadComposeManifest() error = %v", err)
}
if project != filepath.Base(dir) {
t.Fatalf("unexpected project name: %q", project)
}
if len(apps) != 2 {
t.Fatalf("expected 2 apps, got %d", len(apps))
}
if apps[0].Theme != "monokai" && apps[1].Theme != "monokai" {
t.Fatalf("expected theme to be parsed")
}
}
func FuzzExtractLabel(f *testing.F) {
f.Add("webterm-command=auto", "webterm-command")
f.Add("webterm-theme=monokai", "webterm-theme")
f.Add("no-equals-sign", "no-equals-sign")
f.Add("key=", "key")
f.Add("=value", "")
f.Add("key=val=ue", "key")
f.Add("", "")
f.Fuzz(func(t *testing.T, labelEntry, key string) {
// Test list-style labels
listLabels := []any{labelEntry}
result := extractLabel(listLabels, key)
_ = result // Must not panic
// Test map-style labels — note asString() applies os.ExpandEnv
mapLabels := map[string]any{key: labelEntry}
result2 := extractLabel(mapLabels, key)
// Result should be the env-expanded version of the entry
_ = result2 // Must not panic
// Test nil labels
result3 := extractLabel(nil, key)
if result3 != "" {
t.Errorf("extractLabel(nil, %q) = %q, want empty", key, result3)
}
// Test unsupported type
result4 := extractLabel(42, key)
if result4 != "" {
t.Errorf("extractLabel(42, %q) = %q, want empty", key, result4)
}
})
}
+56
View File
@@ -0,0 +1,56 @@
package webterm
import (
"os"
"runtime"
"strings"
)
const (
DefaultHost = "0.0.0.0"
DefaultPort = 8080
DefaultTheme = "xterm"
DefaultFontSize = 16
DefaultTerminalWidth = 132
DefaultTerminalHeight = 45
ScreenshotForceRedrawEnv = "WEBTERM_SCREENSHOT_FORCE_REDRAW"
DockerUsernameEnv = "WEBTERM_DOCKER_USERNAME"
DockerAutoCommandEnv = "WEBTERM_DOCKER_AUTO_COMMAND"
DockerHostEnv = "DOCKER_HOST"
AutoCommandSentinel = "__docker_exec__"
)
var Version = "dev"
var Windows = runtime.GOOS == "windows"
func init() {
if strings.TrimSpace(Version) != "" && Version != "dev" {
return
}
for _, candidate := range []string{"VERSION", "../VERSION", "../../VERSION"} {
data, err := os.ReadFile(candidate)
if err != nil {
continue
}
if v := strings.TrimSpace(string(data)); v != "" {
Version = v
return
}
}
}
func EnvBool(name string) bool {
v, ok := os.LookupEnv(name)
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(v)) {
case "1", "true", "yes", "on":
return true
default:
return false
}
}
+772
View File
@@ -0,0 +1,772 @@
package webterm
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"reflect"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
func newUnixHTTPTestServer(t *testing.T, handler http.Handler) (string, func()) {
t.Helper()
socket := filepath.Join(t.TempDir(), "docker.sock")
ln, err := net.Listen("unix", socket)
if err != nil {
t.Fatalf("listen unix socket: %v", err)
}
srv := &http.Server{Handler: handler}
go func() { _ = srv.Serve(ln) }()
cleanup := func() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = srv.Shutdown(ctx)
_ = ln.Close()
_ = os.Remove(socket)
}
return socket, cleanup
}
func TestDockerSocketPathVariants(t *testing.T) {
t.Setenv(DockerHostEnv, "")
if got := DockerSocketPath(); got != defaultDockerSocket {
t.Fatalf("empty %s: got %q", DockerHostEnv, got)
}
t.Setenv(DockerHostEnv, "unix:///tmp/docker.sock")
if got := DockerSocketPath(); got != "/tmp/docker.sock" {
t.Fatalf("unix:// host: got %q", got)
}
t.Setenv(DockerHostEnv, "/tmp/direct.sock")
if got := DockerSocketPath(); got != "/tmp/direct.sock" {
t.Fatalf("absolute host: got %q", got)
}
t.Setenv(DockerHostEnv, "tcp://127.0.0.1:2375")
if got := DockerSocketPath(); got != defaultDockerSocket {
t.Fatalf("unsupported host should fallback: got %q", got)
}
}
func TestUnixJSONRequestAndSharedClient(t *testing.T) {
sharedClientsMu.Lock()
sharedClients = map[string]*http.Client{}
sharedClientsMu.Unlock()
handler := http.NewServeMux()
handler.HandleFunc("/ping", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "method": r.Method})
})
handler.HandleFunc("/echo", func(w http.ResponseWriter, r *http.Request) {
var payload map[string]any
_ = json.NewDecoder(r.Body).Decode(&payload)
_ = json.NewEncoder(w).Encode(payload)
})
socket, cleanup := newUnixHTTPTestServer(t, handler)
defer cleanup()
status, body, err := unixJSONRequest(socket, http.MethodGet, "/ping", nil)
if err != nil || status != http.StatusOK {
t.Fatalf("unixJSONRequest GET: status=%d err=%v", status, err)
}
if !strings.Contains(string(body), `"ok":true`) {
t.Fatalf("unexpected GET body: %s", string(body))
}
status, body, err = unixJSONRequest(socket, http.MethodPost, "/echo", map[string]any{"x": 1})
if err != nil || status != http.StatusOK {
t.Fatalf("unixJSONRequest POST: status=%d err=%v", status, err)
}
if !strings.Contains(string(body), `"x":1`) {
t.Fatalf("unexpected POST body: %s", string(body))
}
c1 := sharedUnixClient(socket)
c2 := sharedUnixClient(socket)
if c1 != c2 {
t.Fatalf("sharedUnixClient should cache by socket path")
}
}
func TestNoopConnectorMethods(t *testing.T) {
var c noopConnector
c.OnData([]byte("x"))
c.OnBinary([]byte("y"))
c.OnMeta(map[string]any{"k": "v"})
c.OnClose()
}
func TestSessionManagerAPIsAndClosePaths(t *testing.T) {
manager := NewSessionManager([]App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}})
manager.SetSessionFactory(func(app App, sessionID string) Session { return newFakeSession() })
apps := manager.Apps()
if len(apps) != 1 || apps[0].Slug != "shell" {
t.Fatalf("unexpected apps: %+v", apps)
}
if app, ok := manager.GetDefaultApp(); !ok || app.Slug != "shell" {
t.Fatalf("GetDefaultApp failed: app=%+v ok=%v", app, ok)
}
session, err := manager.NewSession("shell", "sid-1", "route-1", 80, 24)
if err != nil || session == nil {
t.Fatalf("NewSession failed: %v", err)
}
route, running, ok := manager.GetFirstRunningSession()
if !ok || route != "route-1" || running == nil {
t.Fatalf("GetFirstRunningSession failed: route=%q ok=%v", route, ok)
}
manager.CloseSession("sid-1")
if manager.GetSessionByRouteKey("route-1") != nil {
t.Fatalf("CloseSession should remove route mapping")
}
_, _ = manager.NewSession("shell", "sid-2", "route-2", 80, 24)
_, _ = manager.NewSession("shell", "sid-3", "route-3", 80, 24)
manager.CloseAll()
if s := manager.GetSession("sid-2"); s != nil {
t.Fatalf("CloseAll should remove session sid-2")
}
if s := manager.GetSession("sid-3"); s != nil {
t.Fatalf("CloseAll should remove session sid-3")
}
if manager.GetSessionByRouteKey("route-2") != nil || manager.GetSessionByRouteKey("route-3") != nil {
t.Fatalf("CloseAll should remove route mappings")
}
}
func TestLocalClientConnectorAndHelpers(t *testing.T) {
server := NewLocalServer(Config{}, ServerOptions{})
connector := &localClientConnector{server: server, sessionID: "sid", routeKey: "rk"}
connector.OnData([]byte("abc"))
connector.OnBinary([]byte("def"))
connector.OnMeta(map[string]any{"x": 1})
connector.OnClose()
if got := toIntFromQuery("42", 7); got != 42 {
t.Fatalf("toIntFromQuery valid: got %d", got)
}
if got := toIntFromQuery("not-a-number", 7); got != 7 {
t.Fatalf("toIntFromQuery fallback: got %d", got)
}
}
func TestHandleEventsDisconnect(t *testing.T) {
server := NewLocalServer(Config{}, ServerOptions{})
ctx, cancel := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodGet, "/events", nil).WithContext(ctx)
w := httptest.NewRecorder()
done := make(chan struct{})
go func() {
server.handleEvents(w, req)
close(done)
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(2 * time.Second):
t.Fatalf("handleEvents did not exit on context cancellation")
}
}
func TestRunWithCanceledContext(t *testing.T) {
server := NewLocalServer(Config{}, ServerOptions{Host: "127.0.0.1", Port: 0})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := server.Run(ctx); err != nil {
t.Fatalf("Run with canceled context failed: %v", err)
}
}
func TestDockerWatcherHelpersAndContainerLifecycle(t *testing.T) {
manager := NewSessionManager(nil)
manager.SetSessionFactory(func(app App, sessionID string) Session { return newFakeSession() })
var addedCount atomic.Int32
var removedCount atomic.Int32
watcher := NewDockerWatcher(
manager,
"/tmp/does-not-exist.sock",
func(slug, name, command string) { addedCount.Add(1) },
func(slug string) { removedCount.Add(1) },
)
if !hasWebtermLabel(map[string]string{WebtermLabelName: "auto"}) {
t.Fatalf("expected command label to match")
}
if !hasWebtermLabel(map[string]string{WebtermThemeLabel: "nord"}) {
t.Fatalf("expected theme label to match")
}
if hasWebtermLabel(map[string]string{"other": "x"}) {
t.Fatalf("unexpected label match")
}
if !isAutoLabel("") || !isAutoLabel("auto") || !isAutoLabel(" AUTO ") {
t.Fatalf("expected auto labels")
}
if isAutoLabel("bash") {
t.Fatalf("non-auto label incorrectly matched")
}
container := map[string]any{
"Id": "123456789012",
"Names": []any{"/my_container.1"},
"Labels": map[string]any{
WebtermLabelName: "auto",
WebtermThemeLabel: "nord",
},
}
watcher.addContainer(container)
watcher.addContainer(container) // duplicate should be ignored
slug := watcher.containerToSlug(container)
if app, ok := manager.AppBySlug(slug); !ok || app.Theme != "nord" || app.Command != AutoCommandSentinel {
t.Fatalf("added app mismatch: app=%+v ok=%v", app, ok)
}
if addedCount.Load() != 1 {
t.Fatalf("expected exactly one add callback, got %d", addedCount.Load())
}
manager.sessions["sid"] = newFakeSession()
_ = manager.routes.Set(slug, "sid")
watcher.removeContainer("1234567")
if _, ok := manager.AppBySlug(slug); ok {
t.Fatalf("removeContainer should remove app %q", slug)
}
if removedCount.Load() != 1 {
t.Fatalf("expected remove callback")
}
}
func TestDockerWatcherListAndHandleEvent(t *testing.T) {
manager := NewSessionManager(nil)
watcher := NewDockerWatcher(manager, "", nil, nil)
handler := http.NewServeMux()
handler.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
containers := []map[string]any{
{
"Id": "abc123",
"Names": []any{"/svc1"},
"Labels": map[string]any{
WebtermLabelName: "auto",
},
},
}
_ = json.NewEncoder(w).Encode(containers)
})
handler.HandleFunc("/containers/evt123/json", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"Name": "/evt_container",
"Config": map[string]any{
"Labels": map[string]any{
WebtermLabelName: "auto",
},
},
})
})
socket, cleanup := newUnixHTTPTestServer(t, handler)
defer cleanup()
watcher.socketPath = socket
containers, err := watcher.listLabeledContainers()
if err != nil {
t.Fatalf("listLabeledContainers error: %v", err)
}
if len(containers) != 1 || asString(containers[0]["Id"]) != "abc123" {
t.Fatalf("unexpected containers payload: %+v", containers)
}
watcher.handleEvent(map[string]any{
"Action": "start",
"Actor": map[string]any{"ID": "evt123"},
})
if _, ok := manager.AppBySlug("evt-container"); !ok {
t.Fatalf("start event should add app")
}
watcher.handleEvent(map[string]any{
"Action": "die",
"Actor": map[string]any{"ID": "evt123"},
})
if _, ok := manager.AppBySlug("evt-container"); ok {
t.Fatalf("die event should remove app")
}
}
func TestDockerStatsCollectorLifecycleAndPolling(t *testing.T) {
handler := http.NewServeMux()
handler.HandleFunc("/_ping", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("OK"))
})
handler.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "deadbeefcafebabe",
"Names": []any{"/svc"},
"Labels": map[string]any{
"com.docker.compose.project": "proj",
"com.docker.compose.service": "svc",
},
},
})
})
handler.HandleFunc("/containers/deadbeefcafe/stats", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"cpu_stats": map[string]any{
"cpu_usage": map[string]any{"total_usage": 200.0, "percpu_usage": []any{1.0, 2.0}},
"system_cpu_usage": 400.0,
"online_cpus": 2.0,
},
"precpu_stats": map[string]any{
"cpu_usage": map[string]any{"total_usage": 100.0},
"system_cpu_usage": 200.0,
},
})
})
socket, cleanup := newUnixHTTPTestServer(t, handler)
defer cleanup()
collector := NewDockerStatsCollector(socket, "proj")
if !collector.Available() {
t.Fatalf("collector should be available against test unix socket")
}
collector.AddService("svc")
collector.AddService("svc") // no duplicate
collector.AddService("other")
collector.RemoveService("other")
if got := collector.serviceList; !reflect.DeepEqual(got, []string{"svc"}) {
t.Fatalf("unexpected service list: %+v", got)
}
mapping := collector.discoverContainers([]string{"svc"})
if mapping["svc"] != "deadbeefcafe" {
t.Fatalf("unexpected mapping: %+v", mapping)
}
collector.pollContainer("svc", "deadbeefcafe")
history := collector.GetCPUHistory("svc")
if len(history) != 1 || history[0] <= 0 {
t.Fatalf("expected one positive CPU sample, got %+v", history)
}
collector.Start([]string{"svc"})
time.Sleep(20 * time.Millisecond)
collector.Stop()
}
type recorderConnector struct {
mu sync.Mutex
data [][]byte
closed bool
}
func (r *recorderConnector) OnData(data []byte) {
r.mu.Lock()
defer r.mu.Unlock()
r.data = append(r.data, append([]byte{}, data...))
}
func (r *recorderConnector) OnBinary(payload []byte) { r.OnData(payload) }
func (r *recorderConnector) OnMeta(meta map[string]any) {}
func (r *recorderConnector) OnClose() {
r.mu.Lock()
defer r.mu.Unlock()
r.closed = true
}
func TestTerminalSessionMethodsAndReadLoop(t *testing.T) {
s := NewTerminalSession("sid", "unterminated '")
if err := s.Open(80, 24); err == nil {
t.Fatalf("expected shlex parse error")
}
if err := s.Start(&recorderConnector{}); err == nil {
t.Fatalf("expected Start error when session is not open")
}
conn := &recorderConnector{}
s.UpdateConnector(conn)
s.tracker = terminalstate.NewTracker(80, 24)
s.handleOutput([]byte("abc"))
if got := string(s.GetReplayBuffer()); got != "abc" {
t.Fatalf("unexpected replay buffer: %q", got)
}
pipeR, pipeW, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer pipeR.Close()
defer pipeW.Close()
s.ptyFile = pipeW
if ok := s.SendBytes([]byte("x")); !ok {
t.Fatalf("SendBytes should succeed with writable ptyFile")
}
buf := make([]byte, 1)
if _, err := pipeR.Read(buf); err != nil || string(buf) != "x" {
t.Fatalf("pipe read failed: %v %q", err, string(buf))
}
s.ptyFile = nil
if err := s.SetTerminalSize(80, 24); err == nil {
t.Fatalf("expected SetTerminalSize error when closed")
}
if err := s.ForceRedraw(); err == nil {
t.Fatalf("expected ForceRedraw error when closed")
}
if !s.SendMeta(map[string]any{"k": "v"}) {
t.Fatalf("SendMeta should return true")
}
if s.IsRunning() {
t.Fatalf("new session should not be running")
}
s.width, s.height = 10, 2
s.tracker = nil
snapshot := s.GetScreenSnapshot()
if snapshot.Width != 10 || snapshot.Height != 2 {
t.Fatalf("unexpected snapshot dimensions: %dx%d", snapshot.Width, snapshot.Height)
}
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("pipe: %v", err)
}
defer reader.Close()
s2 := NewTerminalSession("sid2", "/bin/sh")
rc := &recorderConnector{}
s2.connector = rc
s2.tracker = terminalstate.NewTracker(80, 24)
s2.running = true
go s2.readLoop(reader)
_, _ = writer.Write([]byte("hello"))
_ = writer.Close()
if err := s2.Wait(); err != nil {
t.Fatalf("unexpected wait error: %v", err)
}
rc.mu.Lock()
defer rc.mu.Unlock()
if !rc.closed || len(rc.data) == 0 {
t.Fatalf("expected readLoop to forward data and close connector")
}
cmd := exec.Command("true")
s3 := NewTerminalSession("sid3", "/bin/sh")
s3.cmd = cmd
_ = s3.Close()
}
func TestDockerExecSessionMethodsAndAPI(t *testing.T) {
spec := DockerExecSpec{Container: "my/container", Command: []string{"sh", "-lc", "echo hi"}, User: "root"}
s := NewDockerExecSession("sid", spec, "/tmp/none.sock")
if err := s.Start(&recorderConnector{}); err == nil {
t.Fatalf("expected Start error when not open")
}
if s.SendBytes([]byte("x")) {
t.Fatalf("SendBytes should fail when conn is nil")
}
if !s.SendMeta(map[string]any{"k": "v"}) {
t.Fatalf("SendMeta should return true")
}
if s.IsRunning() {
t.Fatalf("new DockerExecSession should not be running")
}
s.tracker = terminalstate.NewTracker(80, 24)
conn := &recorderConnector{}
s.UpdateConnector(conn)
s.handleOutput([]byte("abc"))
if got := string(s.GetReplayBuffer()); got != "abc" {
t.Fatalf("unexpected replay: %q", got)
}
c1, c2 := net.Pipe()
defer c2.Close()
s.conn = c1
readCh := make(chan []byte, 1)
go func() {
buf := make([]byte, 1)
if _, err := io.ReadFull(c2, buf); err != nil {
readCh <- nil
return
}
readCh <- buf
}()
if !s.SendBytes([]byte("z")) {
t.Fatalf("SendBytes should succeed with active conn")
}
select {
case read := <-readCh:
if string(read) != "z" {
t.Fatalf("pipe read mismatch: %q", string(read))
}
case <-time.After(2 * time.Second):
t.Fatalf("timed out waiting for net.Pipe read")
}
s.execID = ""
if err := s.SetTerminalSize(100, 40); err != nil {
t.Fatalf("SetTerminalSize with empty execID should not error: %v", err)
}
if err := s.ForceRedraw(); err != nil {
t.Fatalf("ForceRedraw failed: %v", err)
}
s.width, s.height = 10, 2
s.tracker = nil
snap := s.GetScreenSnapshot()
if snap.Width != 10 || snap.Height != 2 {
t.Fatalf("unexpected snapshot dimensions: %dx%d", snap.Width, snap.Height)
}
r1, r2 := net.Pipe()
defer r2.Close()
s2 := NewDockerExecSession("sid2", spec, "/tmp/none.sock")
rc := &recorderConnector{}
s2.connector = rc
s2.tracker = terminalstate.NewTracker(80, 24)
s2.running = true
go s2.readLoop(r1)
_, _ = r2.Write([]byte("hello"))
_ = r2.Close()
if err := s2.Wait(); err != nil {
t.Fatalf("unexpected wait error: %v", err)
}
rc.mu.Lock()
defer rc.mu.Unlock()
if !rc.closed || len(rc.data) == 0 {
t.Fatalf("expected readLoop to forward data and close connector")
}
// API-level tests for createExec/startExecSocket/resizeExec
mux := http.NewServeMux()
mux.HandleFunc("/containers/", func(w http.ResponseWriter, r *http.Request) {
if !strings.HasSuffix(r.URL.Path, "/exec") {
http.NotFound(w, r)
return
}
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "exec123"})
})
mux.HandleFunc("/exec/exec123/resize", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = io.WriteString(w, "ok")
})
mux.HandleFunc("/exec/exec123/start", func(w http.ResponseWriter, r *http.Request) {
hj, ok := w.(http.Hijacker)
if !ok {
t.Fatalf("response writer is not a hijacker")
}
conn, rw, err := hj.Hijack()
if err != nil {
t.Fatalf("hijack failed: %v", err)
}
_, _ = fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n")
_, _ = rw.WriteString("ok")
_ = rw.Flush()
_ = conn.Close()
})
socket, cleanup := newUnixHTTPTestServer(t, mux)
defer cleanup()
s3 := NewDockerExecSession("sid3", spec, socket)
execID, err := s3.createExec()
if err != nil || execID != "exec123" {
t.Fatalf("createExec failed: id=%q err=%v", execID, err)
}
c, err := s3.startExecSocket(execID)
if err != nil {
t.Fatalf("startExecSocket failed: %v", err)
}
defer c.Close()
reader := bufio.NewReader(c)
reply, _ := reader.ReadString('k')
if reply == "" {
t.Fatalf("expected upgraded stream payload")
}
s3.execID = execID
if err := s3.resizeExec(80, 24); err != nil {
t.Fatalf("resizeExec failed: %v", err)
}
_ = s3.Close()
}
func TestDockerExecSessionOpenAndStart(t *testing.T) {
spec := DockerExecSpec{Container: "c1", Command: []string{"sh"}, User: ""}
mux := http.NewServeMux()
mux.HandleFunc("/containers/c1/exec", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"Id": "exec-open"})
})
mux.HandleFunc("/exec/exec-open/resize", func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
})
mux.HandleFunc("/exec/exec-open/start", func(w http.ResponseWriter, r *http.Request) {
hj, _ := w.(http.Hijacker)
conn, rw, _ := hj.Hijack()
_, _ = fmt.Fprintf(conn, "HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n")
_, _ = rw.WriteString("hello")
_ = rw.Flush()
_ = conn.Close()
})
socket, cleanup := newUnixHTTPTestServer(t, mux)
defer cleanup()
s := NewDockerExecSession("sid-open", spec, socket)
if err := s.Open(0, 0); err != nil {
t.Fatalf("Open failed: %v", err)
}
rc := &recorderConnector{}
if err := s.Start(rc); err != nil {
t.Fatalf("Start failed: %v", err)
}
if err := s.Wait(); err != nil && !errors.Is(err, io.EOF) {
t.Fatalf("Wait failed: %v", err)
}
}
func TestTerminalSessionOpenStartAndResize(t *testing.T) {
s := NewTerminalSession("sid-term", "/bin/sh -lc 'printf ok'")
if err := s.Open(0, 0); err != nil {
t.Fatalf("Open failed: %v", err)
}
rc := &recorderConnector{}
if err := s.Start(rc); err != nil {
t.Fatalf("Start failed: %v", err)
}
if err := s.SetTerminalSize(100, 30); err != nil {
t.Fatalf("SetTerminalSize failed: %v", err)
}
if err := s.Wait(); err != nil {
t.Fatalf("Wait failed: %v", err)
}
_ = s.Close()
}
func TestTerminalSessionOpenWhenRunningNoop(t *testing.T) {
s := NewTerminalSession("sid-running", "/bin/true")
s.running = true
if err := s.Open(80, 24); err != nil {
t.Fatalf("Open should be a no-op when already running: %v", err)
}
}
func TestSessionManagerDefaultSessionFactory(t *testing.T) {
manager := NewSessionManager(nil)
t.Setenv(DockerAutoCommandEnv, "tmux new-session -ADs {container}")
t.Setenv(DockerUsernameEnv, "alice")
auto := manager.defaultSessionFactory(App{Name: "svc1", Command: AutoCommandSentinel}, "sid")
execSession, ok := auto.(*DockerExecSession)
if !ok {
t.Fatalf("expected DockerExecSession, got %T", auto)
}
if len(execSession.spec.Command) == 0 || execSession.spec.Command[len(execSession.spec.Command)-1] != "svc1" {
t.Fatalf("expected container placeholder expansion, got %+v", execSession.spec.Command)
}
if execSession.spec.User != "alice" {
t.Fatalf("expected docker user from env, got %q", execSession.spec.User)
}
plain := manager.defaultSessionFactory(App{Name: "term", Command: "/bin/sh"}, "sid2")
if _, ok := plain.(*TerminalSession); !ok {
t.Fatalf("expected TerminalSession, got %T", plain)
}
}
func TestDefaultConfigAndCPUSparklineEndpoint(t *testing.T) {
if cfg := DefaultConfig(); len(cfg.Apps) != 0 {
t.Fatalf("DefaultConfig expected empty apps")
}
mux := http.NewServeMux()
mux.HandleFunc("/_ping", func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte("OK"))
})
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]map[string]any{
{
"Id": "cafebabedeadbeef",
"Names": []any{"/svc"},
"Labels": map[string]any{
"com.docker.compose.project": "proj",
"com.docker.compose.service": "svc",
},
},
})
})
mux.HandleFunc("/containers/cafebabedead/stats", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{
"cpu_stats": map[string]any{
"cpu_usage": map[string]any{"total_usage": 300.0, "percpu_usage": []any{1.0, 2.0}},
"system_cpu_usage": 600.0,
"online_cpus": 2.0,
},
"precpu_stats": map[string]any{
"cpu_usage": map[string]any{"total_usage": 150.0},
"system_cpu_usage": 300.0,
},
})
})
socket, cleanup := newUnixHTTPTestServer(t, mux)
defer cleanup()
t.Setenv(DockerHostEnv, "unix://"+socket)
server := NewLocalServer(
Config{},
ServerOptions{
ComposeMode: true,
ComposeProject: "proj",
LandingApps: []App{{Name: "svc", Slug: "svc", Command: "/bin/sh", Terminal: true}},
},
)
server.setupDockerFeatures()
defer server.shutdown()
req := httptest.NewRequest(http.MethodGet, "/cpu-sparkline.svg?container=svc&width=x&height=y", nil)
rr := httptest.NewRecorder()
server.handleCPUSparkline(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", rr.Code)
}
if !strings.Contains(rr.Body.String(), "<svg") {
t.Fatalf("expected SVG response")
}
}
func TestDockerWatcherStartStop(t *testing.T) {
manager := NewSessionManager(nil)
mux := http.NewServeMux()
mux.HandleFunc("/containers/json", func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode([]map[string]any{})
})
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, `{"Action":"die","Actor":{"ID":"none"}}`+"\n")
})
socket, cleanup := newUnixHTTPTestServer(t, mux)
defer cleanup()
watcher := NewDockerWatcher(manager, socket, nil, nil)
watcher.Start()
time.Sleep(20 * time.Millisecond)
watcher.Stop()
}
+323
View File
@@ -0,0 +1,323 @@
package webterm
import (
"bufio"
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"strings"
"sync"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
type DockerExecSpec struct {
Container string
Command []string
User string
}
type readBufferedConn struct {
net.Conn
reader *bufio.Reader
}
func (r *readBufferedConn) Read(p []byte) (int, error) {
return r.reader.Read(p)
}
type DockerExecSession struct {
sessionID string
spec DockerExecSpec
socket string
mu sync.RWMutex
connector SessionConnector
execID string
conn net.Conn
tracker *terminalstate.Tracker
replay *ReplayBuffer
escapeBuffer []byte
width int
height int
running bool
started bool
done chan struct{}
doneOnce sync.Once
waitErr error
writeMu sync.Mutex
}
func NewDockerExecSession(sessionID string, spec DockerExecSpec, socketPath string) *DockerExecSession {
if socketPath == "" {
socketPath = DockerSocketPath()
}
return &DockerExecSession{
sessionID: sessionID,
spec: spec,
socket: socketPath,
connector: noopConnector{},
replay: NewReplayBuffer(replayBufferSize),
done: make(chan struct{}),
width: DefaultTerminalWidth,
height: DefaultTerminalHeight,
}
}
func (s *DockerExecSession) Open(width, height int) error {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
execID, err := s.createExec()
if err != nil {
return err
}
conn, err := s.startExecSocket(execID)
if err != nil {
return err
}
s.mu.Lock()
s.execID = execID
s.conn = conn
s.tracker = terminalstate.NewTracker(width, height)
s.width = width
s.height = height
s.running = true
s.mu.Unlock()
_ = s.resizeExec(width, height)
return nil
}
func (s *DockerExecSession) Start(connector SessionConnector) error {
s.mu.Lock()
if connector != nil {
s.connector = connector
}
if s.started {
s.mu.Unlock()
return nil
}
if s.conn == nil {
s.mu.Unlock()
return errors.New("docker session not open")
}
s.started = true
conn := s.conn
s.mu.Unlock()
go s.readLoop(conn)
return nil
}
func (s *DockerExecSession) readLoop(conn net.Conn) {
buf := make([]byte, 32*1024)
for {
n, err := conn.Read(buf)
if n > 0 {
s.handleOutput(buf[:n])
}
if err != nil {
if !errors.Is(err, io.EOF) {
s.mu.Lock()
s.waitErr = err
s.mu.Unlock()
}
break
}
}
s.mu.Lock()
s.running = false
connector := s.connector
s.mu.Unlock()
connector.OnClose()
s.doneOnce.Do(func() { close(s.done) })
}
func (s *DockerExecSession) handleOutput(data []byte) {
s.mu.Lock()
filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer)
s.escapeBuffer = escapeBuffer
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
dispatchSessionOutput(filtered, tracker, s.replay, connector)
}
func (s *DockerExecSession) createExec() (string, error) {
payload := map[string]any{
"AttachStdin": true,
"AttachStdout": true,
"AttachStderr": true,
"Tty": true,
"Cmd": s.spec.Command,
}
if strings.TrimSpace(s.spec.User) != "" {
payload["User"] = s.spec.User
}
path := fmt.Sprintf("/containers/%s/exec", url.PathEscape(s.spec.Container))
status, body, err := unixJSONRequest(s.socket, http.MethodPost, path, payload)
if err != nil {
return "", err
}
if status < 200 || status >= 300 {
return "", fmt.Errorf("docker exec create failed (%d): %s", status, string(body))
}
var resp map[string]any
if err := json.Unmarshal(body, &resp); err != nil {
return "", err
}
id, _ := resp["Id"].(string)
if id == "" {
return "", errors.New("docker exec id missing")
}
return id, nil
}
func (s *DockerExecSession) startExecSocket(execID string) (net.Conn, error) {
conn, err := net.Dial("unix", s.socket)
if err != nil {
return nil, err
}
payload, _ := json.Marshal(map[string]any{"Detach": false, "Tty": true})
req, err := http.NewRequest(http.MethodPost, "http://unix/exec/"+url.PathEscape(execID)+"/start", bytes.NewReader(payload))
if err != nil {
_ = conn.Close()
return nil, err
}
req.Header.Set("Host", "localhost")
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Connection", "Upgrade")
req.Header.Set("Upgrade", "tcp")
req.ContentLength = int64(len(payload))
if err := req.Write(conn); err != nil {
_ = conn.Close()
return nil, err
}
reader := bufio.NewReader(conn)
resp, err := http.ReadResponse(reader, req)
if err != nil {
_ = conn.Close()
return nil, err
}
if resp.StatusCode != http.StatusSwitchingProtocols && (resp.StatusCode < 200 || resp.StatusCode >= 300) {
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
_ = conn.Close()
return nil, fmt.Errorf("docker exec start failed (%d): %s", resp.StatusCode, string(body))
}
_ = resp.Body.Close()
return &readBufferedConn{Conn: conn, reader: reader}, nil
}
func (s *DockerExecSession) resizeExec(width, height int) error {
s.mu.RLock()
execID := s.execID
s.mu.RUnlock()
if execID == "" {
return nil
}
path := fmt.Sprintf("/exec/%s/resize?h=%d&w=%d", url.PathEscape(execID), height, width)
status, body, err := unixJSONRequest(s.socket, http.MethodPost, path, nil)
if err != nil {
return err
}
if status < 200 || status >= 300 {
return fmt.Errorf("docker resize failed (%d): %s", status, string(body))
}
return nil
}
func (s *DockerExecSession) Close() error {
s.mu.Lock()
conn := s.conn
s.conn = nil
s.running = false
s.mu.Unlock()
if conn != nil {
_ = conn.Close()
}
s.doneOnce.Do(func() { close(s.done) })
return nil
}
func (s *DockerExecSession) Wait() error {
<-s.done
s.mu.RLock()
defer s.mu.RUnlock()
return s.waitErr
}
func (s *DockerExecSession) SetTerminalSize(width, height int) error {
if width <= 0 {
width = 1
}
if height <= 0 {
height = 1
}
s.mu.Lock()
s.width = width
s.height = height
if s.tracker != nil {
s.tracker.Resize(width, height)
}
s.mu.Unlock()
return s.resizeExec(width, height)
}
func (s *DockerExecSession) ForceRedraw() error {
s.mu.RLock()
width, height := s.width, s.height
s.mu.RUnlock()
return s.SetTerminalSize(width, height)
}
func (s *DockerExecSession) SendBytes(data []byte) bool {
s.mu.RLock()
conn := s.conn
s.mu.RUnlock()
if conn == nil {
return false
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := conn.Write(data)
return err == nil
}
func (s *DockerExecSession) SendMeta(_ map[string]any) bool {
return true
}
func (s *DockerExecSession) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.running
}
func (s *DockerExecSession) GetReplayBuffer() []byte {
return s.replay.Bytes()
}
func (s *DockerExecSession) GetScreenSnapshot() terminalstate.Snapshot {
s.mu.RLock()
tracker := s.tracker
width, height := s.width, s.height
s.mu.RUnlock()
return snapshotFromTracker(tracker, width, height)
}
func (s *DockerExecSession) UpdateConnector(connector SessionConnector) {
if connector == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.connector = connector
}
+90
View File
@@ -0,0 +1,90 @@
package webterm
import (
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"os"
"strings"
"sync"
"time"
)
var (
sharedClientsMu sync.RWMutex
sharedClients = map[string]*http.Client{}
)
const defaultDockerSocket = "/var/run/docker.sock"
func DockerSocketPath() string {
dockerHost := strings.TrimSpace(os.Getenv(DockerHostEnv))
if dockerHost == "" {
return defaultDockerSocket
}
if strings.HasPrefix(dockerHost, "unix://") {
return strings.TrimPrefix(dockerHost, "unix://")
}
if strings.HasPrefix(dockerHost, "/") {
return dockerHost
}
return defaultDockerSocket
}
func newUnixHTTPClient(socketPath string, timeout time.Duration) *http.Client {
transport := &http.Transport{
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
var dialer net.Dialer
return dialer.DialContext(ctx, "unix", socketPath)
},
}
return &http.Client{Transport: transport, Timeout: timeout}
}
func sharedUnixClient(socketPath string) *http.Client {
sharedClientsMu.RLock()
client, ok := sharedClients[socketPath]
sharedClientsMu.RUnlock()
if ok {
return client
}
sharedClientsMu.Lock()
defer sharedClientsMu.Unlock()
if client, ok = sharedClients[socketPath]; ok {
return client
}
client = newUnixHTTPClient(socketPath, 15*time.Second)
sharedClients[socketPath] = client
return client
}
func unixJSONRequest(socketPath, method, path string, payload any) (int, []byte, error) {
var body io.Reader
if payload != nil {
data, err := json.Marshal(payload)
if err != nil {
return 0, nil, err
}
body = bytes.NewReader(data)
}
req, err := http.NewRequest(method, "http://unix"+path, body)
if err != nil {
return 0, nil, err
}
if payload != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := sharedUnixClient(socketPath).Do(req)
if err != nil {
return 0, nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return 0, nil, err
}
return resp.StatusCode, respBody, nil
}
+389
View File
@@ -0,0 +1,389 @@
package webterm
import (
"encoding/json"
"fmt"
"math"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
StatsHistorySize = 180
PollInterval = 10 * time.Second
)
type DockerStatsCollector struct {
socketPath string
composeProject string
mu sync.RWMutex
cpuHistory map[string][]float64
prevCPU map[string][2]uint64
serviceList []string
running bool
stopCh chan struct{}
doneCh chan struct{}
}
func NewDockerStatsCollector(socketPath, composeProject string) *DockerStatsCollector {
if socketPath == "" {
socketPath = DockerSocketPath()
}
return &DockerStatsCollector{
socketPath: socketPath,
composeProject: composeProject,
cpuHistory: map[string][]float64{},
prevCPU: map[string][2]uint64{},
stopCh: make(chan struct{}),
doneCh: make(chan struct{}),
}
}
func (d *DockerStatsCollector) Available() bool {
path := d.socketPath
if !filepath.IsAbs(path) {
path = filepath.Clean(path)
}
if _, err := os.Stat(path); err != nil {
return false
}
client := newUnixHTTPClient(d.socketPath, 2*time.Second)
resp, err := client.Get("http://unix/_ping")
if err != nil {
return false
}
_ = resp.Body.Close()
return true
}
func (d *DockerStatsCollector) Start(serviceNames []string) {
d.mu.Lock()
if d.running {
d.mu.Unlock()
return
}
d.stopCh = make(chan struct{})
d.doneCh = make(chan struct{})
d.serviceList = append([]string{}, serviceNames...)
d.running = true
d.mu.Unlock()
go d.pollLoop()
}
func (d *DockerStatsCollector) Stop() {
d.mu.Lock()
if !d.running {
d.mu.Unlock()
return
}
d.running = false
close(d.stopCh)
d.mu.Unlock()
<-d.doneCh
}
func (d *DockerStatsCollector) AddService(name string) {
d.mu.Lock()
defer d.mu.Unlock()
for _, existing := range d.serviceList {
if existing == name {
return
}
}
d.serviceList = append(d.serviceList, name)
}
func (d *DockerStatsCollector) RemoveService(name string) {
d.mu.Lock()
defer d.mu.Unlock()
filtered := d.serviceList[:0]
for _, item := range d.serviceList {
if item != name {
filtered = append(filtered, item)
}
}
d.serviceList = filtered
delete(d.cpuHistory, name)
delete(d.prevCPU, name)
}
func (d *DockerStatsCollector) GetCPUHistory(name string) []float64 {
d.mu.RLock()
defer d.mu.RUnlock()
history := d.cpuHistory[name]
out := make([]float64, len(history))
copy(out, history)
return out
}
func (d *DockerStatsCollector) pollLoop() {
defer close(d.doneCh)
ticker := time.NewTicker(PollInterval)
defer ticker.Stop()
serviceToContainer := map[string]string{}
refreshCounter := 0
for {
select {
case <-d.stopCh:
return
default:
}
d.mu.RLock()
services := append([]string{}, d.serviceList...)
d.mu.RUnlock()
if refreshCounter%30 == 0 || len(serviceToContainer) != len(services) {
serviceToContainer = d.discoverContainers(services)
}
refreshCounter++
for _, service := range services {
containerID := serviceToContainer[service]
if containerID == "" {
continue
}
d.pollContainer(service, containerID)
}
select {
case <-d.stopCh:
return
case <-ticker.C:
}
}
}
func (d *DockerStatsCollector) discoverContainers(serviceNames []string) map[string]string {
status, body, err := unixJSONRequest(d.socketPath, "GET", "/containers/json", nil)
if err != nil || status != 200 {
return map[string]string{}
}
var containers []map[string]any
if err := json.Unmarshal(body, &containers); err != nil {
return map[string]string{}
}
mapping := map[string]string{}
for _, container := range containers {
labels := toStringMap(container["Labels"])
if d.composeProject != "" && labels["com.docker.compose.project"] != d.composeProject {
continue
}
service := labels["com.docker.compose.service"]
containerID := asString(container["Id"])
if len(containerID) > 12 {
containerID = containerID[:12]
}
for _, target := range serviceNames {
if service == target {
mapping[target] = containerID
break
}
names := toStringSlice(container["Names"])
for _, name := range names {
clean := strings.TrimPrefix(name, "/")
if clean == target || strings.Contains(clean, target) {
mapping[target] = containerID
break
}
}
}
}
return mapping
}
func (d *DockerStatsCollector) pollContainer(serviceName, containerID string) {
path := fmt.Sprintf("/containers/%s/stats?stream=false", containerID)
status, body, err := unixJSONRequest(d.socketPath, "GET", path, nil)
if err != nil || status != 200 {
return
}
var stats map[string]any
if err := json.Unmarshal(body, &stats); err != nil {
return
}
cpuStats := toAnyMap(stats["cpu_stats"])
precpuStats := toAnyMap(stats["precpu_stats"])
value, ok := d.calculateCPUPercent(serviceName, cpuStats, precpuStats)
if !ok {
return
}
d.mu.Lock()
defer d.mu.Unlock()
history := append(d.cpuHistory[serviceName], value)
if len(history) > StatsHistorySize {
history = history[len(history)-StatsHistorySize:]
}
d.cpuHistory[serviceName] = history
}
func (d *DockerStatsCollector) calculateCPUPercent(container string, cpuStats, precpuStats map[string]any) (float64, bool) {
cpuUsage := toAnyMap(cpuStats["cpu_usage"])
precpuUsage := toAnyMap(precpuStats["cpu_usage"])
cpuTotal := toUint(cpuUsage["total_usage"])
preTotal := toUint(precpuUsage["total_usage"])
systemCPU := toUint(cpuStats["system_cpu_usage"])
preSystem := toUint(precpuStats["system_cpu_usage"])
d.mu.Lock()
if preTotal == 0 {
if previous, ok := d.prevCPU[container]; ok {
preTotal = previous[0]
preSystem = previous[1]
}
}
d.prevCPU[container] = [2]uint64{cpuTotal, systemCPU}
d.mu.Unlock()
cpuDelta := int64(cpuTotal) - int64(preTotal)
systemDelta := int64(systemCPU) - int64(preSystem)
if cpuDelta < 0 || systemDelta <= 0 {
return 0, false
}
onlineCPUs := toInt(cpuStats["online_cpus"])
if onlineCPUs <= 0 {
perCPU := toAnySlice(cpuUsage["percpu_usage"])
if len(perCPU) == 0 {
onlineCPUs = 1
} else {
onlineCPUs = len(perCPU)
}
}
percent := (float64(cpuDelta) / float64(systemDelta)) * float64(onlineCPUs) * 100.0
maxValue := float64(onlineCPUs) * 100.0
return math.Min(percent, maxValue), true
}
func RenderSparklineSVG(values []float64, width, height int) string {
if width <= 0 {
width = 100
}
if height <= 0 {
height = 20
}
if len(values) == 0 {
return fmt.Sprintf(`<svg width="%d" height="%d" xmlns="http://www.w3.org/2000/svg"></svg>`, width, height)
}
points := make([]float64, 0, len(values)+1)
points = append(points, 0)
points = append(points, values...)
maxValue := 1.0
for _, value := range points {
if value > maxValue {
maxValue = value
}
}
if maxValue <= 0 {
maxValue = 1
}
xStep := float64(width) / float64(max(1, len(points)-1))
line := strings.Builder{}
fill := strings.Builder{}
for i, value := range points {
x := float64(i) * xStep
y := float64(height) - ((value / maxValue) * float64(height-2)) - 1
if i > 0 {
line.WriteByte(' ')
fill.WriteByte(' ')
}
line.WriteString(fmt.Sprintf("%.1f,%.1f", x, y))
fill.WriteString(fmt.Sprintf("%.1f,%.1f", x, y))
}
fill.WriteString(fmt.Sprintf(" %d,%d 0,%d", width, height, height))
return fmt.Sprintf(`<svg width="%d" height="%d" xmlns="http://www.w3.org/2000/svg"><polygon points="%s" fill="rgba(74, 222, 128, 0.2)" /><polyline points="%s" fill="none" stroke="#4ade80" stroke-width="1.5" /></svg>`, width, height, fill.String(), line.String())
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func toAnyMap(value any) map[string]any {
switch raw := value.(type) {
case map[string]any:
return raw
case map[any]any:
out := map[string]any{}
for key, val := range raw {
if text, ok := key.(string); ok {
out[text] = val
}
}
return out
default:
return map[string]any{}
}
}
func toStringMap(value any) map[string]string {
out := map[string]string{}
for key, val := range toAnyMap(value) {
out[key] = asString(val)
}
return out
}
func toAnySlice(value any) []any {
switch raw := value.(type) {
case []any:
return raw
default:
return nil
}
}
func toStringSlice(value any) []string {
raw := toAnySlice(value)
out := make([]string, 0, len(raw))
for _, item := range raw {
if text, ok := item.(string); ok {
out = append(out, text)
}
}
return out
}
func toUint(value any) uint64 {
switch v := value.(type) {
case uint64:
return v
case int:
if v > 0 {
return uint64(v)
}
case int64:
if v > 0 {
return uint64(v)
}
case float64:
if v > 0 {
return uint64(v)
}
case json.Number:
n, _ := v.Int64()
if n > 0 {
return uint64(n)
}
}
return 0
}
func toInt(value any) int {
switch v := value.(type) {
case int:
return v
case int64:
return int(v)
case float64:
return int(v)
case json.Number:
n, _ := v.Int64()
return int(n)
}
return 0
}
+103
View File
@@ -0,0 +1,103 @@
package webterm
import (
"encoding/json"
"strings"
"testing"
)
func TestRenderSparklineSVG(t *testing.T) {
svg := RenderSparklineSVG([]float64{10, 20, 30}, 120, 24)
if !strings.Contains(svg, "<polyline") {
t.Fatalf("expected polyline in sparkline svg")
}
if !strings.Contains(svg, `width="120"`) {
t.Fatalf("expected width to be present")
}
}
func TestCalculateCPUPercentUsesPreviousStats(t *testing.T) {
collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "")
cpuStats := map[string]any{
"cpu_usage": map[string]any{
"total_usage": float64(200),
"percpu_usage": []any{1, 2},
},
"system_cpu_usage": float64(400),
"online_cpus": float64(2),
}
preCPU := map[string]any{
"cpu_usage": map[string]any{"total_usage": float64(100)},
"system_cpu_usage": float64(200),
}
value, ok := collector.calculateCPUPercent("svc", cpuStats, preCPU)
if !ok || value <= 0 {
t.Fatalf("expected cpu percent, got ok=%v value=%v", ok, value)
}
}
func TestDockerStatsHelperConversions(t *testing.T) {
m := toAnyMap(map[any]any{"a": 1, "b": "x", 7: "ignored"})
if len(m) != 2 || m["a"] != 1 || m["b"] != "x" {
t.Fatalf("unexpected map conversion: %+v", m)
}
if got := toAnyMap("not-map"); len(got) != 0 {
t.Fatalf("expected empty map for invalid input")
}
if got := toAnySlice([]any{1, "x"}); len(got) != 2 {
t.Fatalf("unexpected slice conversion: %+v", got)
}
if got := toAnySlice("not-slice"); got != nil {
t.Fatalf("expected nil for non-slice input")
}
ss := toStringSlice([]any{"a", 1, "b"})
if len(ss) != 2 || ss[0] != "a" || ss[1] != "b" {
t.Fatalf("unexpected string slice conversion: %+v", ss)
}
if got := toUint(uint64(9)); got != 9 {
t.Fatalf("toUint(uint64) mismatch: %d", got)
}
if got := toUint(int64(5)); got != 5 {
t.Fatalf("toUint(int64) mismatch: %d", got)
}
if got := toUint(float64(7)); got != 7 {
t.Fatalf("toUint(float64) mismatch: %d", got)
}
if got := toUint(json.Number("11")); got != 11 {
t.Fatalf("toUint(json.Number) mismatch: %d", got)
}
if got := toUint(int(-1)); got != 0 {
t.Fatalf("toUint should clamp negatives to zero: %d", got)
}
if got := toInt(int64(3)); got != 3 {
t.Fatalf("toInt(int64) mismatch: %d", got)
}
if got := toInt(float64(4)); got != 4 {
t.Fatalf("toInt(float64) mismatch: %d", got)
}
if got := toInt(json.Number("6")); got != 6 {
t.Fatalf("toInt(json.Number) mismatch: %d", got)
}
if got := toInt("bad"); got != 0 {
t.Fatalf("toInt invalid should fallback to 0, got %d", got)
}
if got := max(1, 2); got != 2 {
t.Fatalf("max mismatch: %d", got)
}
if got := max(5, 2); got != 5 {
t.Fatalf("max mismatch: %d", got)
}
}
func TestDockerStatsCollectorCanRestart(t *testing.T) {
collector := NewDockerStatsCollector("/tmp/does-not-exist.sock", "")
collector.Start([]string{"svc"})
collector.Stop()
collector.Start([]string{"svc"})
collector.Stop()
}
+293
View File
@@ -0,0 +1,293 @@
package webterm
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"net/url"
"strings"
"sync"
"time"
)
const (
WebtermLabelName = "webterm-command"
WebtermThemeLabel = "webterm-theme"
)
type DockerWatcher struct {
sessionManager *SessionManager
socketPath string
onContainerAdded func(slug, name, command string)
onContainerRemoved func(slug string)
mu sync.RWMutex
managed map[string]string
running bool
cancel context.CancelFunc
waitDone chan struct{}
httpClient *http.Client
}
func NewDockerWatcher(
sessionManager *SessionManager,
socketPath string,
onAdded func(slug, name, command string),
onRemoved func(slug string),
) *DockerWatcher {
if socketPath == "" {
socketPath = DockerSocketPath()
}
return &DockerWatcher{
sessionManager: sessionManager,
socketPath: socketPath,
onContainerAdded: onAdded,
onContainerRemoved: onRemoved,
managed: map[string]string{},
waitDone: make(chan struct{}),
httpClient: newUnixHTTPClient(socketPath, 0),
}
}
func hasWebtermLabel(labels map[string]string) bool {
_, hasCommand := labels[WebtermLabelName]
_, hasTheme := labels[WebtermThemeLabel]
return hasCommand || hasTheme
}
func isAutoLabel(value string) bool {
if strings.TrimSpace(value) == "" {
return true
}
return strings.EqualFold(strings.TrimSpace(value), "auto")
}
func (w *DockerWatcher) getContainerCommand(container map[string]any) string {
labels := toStringMap(container["Labels"])
value := labels[WebtermLabelName]
if isAutoLabel(value) {
return AutoCommandSentinel
}
return value
}
func (w *DockerWatcher) getContainerTheme(container map[string]any) string {
labels := toStringMap(container["Labels"])
return strings.TrimSpace(labels[WebtermThemeLabel])
}
func (w *DockerWatcher) getContainerName(container map[string]any) string {
names := toStringSlice(container["Names"])
if len(names) > 0 {
return strings.TrimPrefix(names[0], "/")
}
id := asString(container["Id"])
if len(id) > 12 {
id = id[:12]
}
return id
}
func (w *DockerWatcher) containerToSlug(container map[string]any) string {
name := w.getContainerName(container)
return strings.NewReplacer("_", "-", ".", "-").Replace(name)
}
func (w *DockerWatcher) addContainer(container map[string]any) {
slug := w.containerToSlug(container)
name := w.getContainerName(container)
command := w.getContainerCommand(container)
theme := w.getContainerTheme(container)
containerID := asString(container["Id"])
w.mu.Lock()
if _, exists := w.managed[slug]; exists {
w.mu.Unlock()
return
}
w.managed[slug] = containerID
w.mu.Unlock()
w.sessionManager.AddApp(name, command, slug, true, theme)
log.Printf("docker event: added container id=%s slug=%s name=%s", containerID, slug, name)
if w.onContainerAdded != nil {
w.onContainerAdded(slug, name, command)
}
}
func (w *DockerWatcher) removeContainer(containerID string) {
w.mu.Lock()
slug := ""
for s, id := range w.managed {
if id == containerID || strings.HasPrefix(id, containerID) {
slug = s
delete(w.managed, s)
break
}
}
w.mu.Unlock()
if slug == "" {
return
}
if sessionID, ok := w.sessionManager.GetSessionIDByRouteKey(slug); ok {
w.sessionManager.CloseSession(sessionID)
}
w.sessionManager.RemoveApp(slug)
log.Printf("docker event: removed container id=%s slug=%s", containerID, slug)
if w.onContainerRemoved != nil {
w.onContainerRemoved(slug)
}
}
func (w *DockerWatcher) listLabeledContainers() ([]map[string]any, error) {
seen := map[string]bool{}
containers := []map[string]any{}
for _, label := range []string{WebtermLabelName, WebtermThemeLabel} {
path := fmt.Sprintf(`/containers/json?filters={"label":["%s"]}`, label)
status, body, err := unixJSONRequest(w.socketPath, http.MethodGet, path, nil)
if err != nil || status != http.StatusOK {
continue
}
var payload []map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
continue
}
for _, container := range payload {
id := asString(container["Id"])
if id == "" || seen[id] {
continue
}
seen[id] = true
containers = append(containers, container)
}
}
return containers, nil
}
func (w *DockerWatcher) handleEvent(event map[string]any) {
action := asString(event["Action"])
actor := toAnyMap(event["Actor"])
containerID := asString(actor["ID"])
if containerID == "" {
return
}
switch action {
case "start":
log.Printf("docker event: action=start container=%s", containerID)
path := fmt.Sprintf("/containers/%s/json", url.PathEscape(containerID))
status, body, err := unixJSONRequest(w.socketPath, http.MethodGet, path, nil)
if err != nil || status != http.StatusOK {
log.Printf("docker event: start inspect failed container=%s status=%d err=%v", containerID, status, err)
return
}
var detail map[string]any
if err := json.Unmarshal(body, &detail); err != nil {
log.Printf("docker event: start decode failed container=%s err=%v", containerID, err)
return
}
config := toAnyMap(detail["Config"])
labels := toStringMap(config["Labels"])
if !hasWebtermLabel(labels) {
return
}
container := map[string]any{
"Id": containerID,
"Names": []any{"/" + strings.TrimPrefix(asString(detail["Name"]), "/")},
"Labels": config["Labels"],
}
w.addContainer(container)
case "die":
log.Printf("docker event: action=die container=%s", containerID)
w.removeContainer(containerID)
}
}
func (w *DockerWatcher) watchEvents(ctx context.Context, waitDone chan struct{}) {
defer close(waitDone)
filters := url.QueryEscape(`{"event":["start","die"],"type":["container"]}`)
requestURL := "http://unix/events?filters=" + filters
for {
select {
case <-ctx.Done():
return
default:
}
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil)
resp, err := w.httpClient.Do(req)
if err != nil {
log.Printf("docker event stream connect failed err=%v", err)
select {
case <-ctx.Done():
return
case <-time.After(5 * time.Second):
continue
}
}
log.Printf("docker event stream connected")
decoder := json.NewDecoder(resp.Body)
for {
var event map[string]any
if err := decoder.Decode(&event); err != nil {
log.Printf("docker event stream decode error err=%v", err)
break
}
w.handleEvent(event)
}
_ = resp.Body.Close()
select {
case <-ctx.Done():
return
case <-time.After(2 * time.Second):
}
}
}
func (w *DockerWatcher) ScanExisting() {
containers, err := w.listLabeledContainers()
if err != nil {
log.Printf("docker scan failed err=%v", err)
return
}
log.Printf("docker scan found %d labeled container(s)", len(containers))
for _, container := range containers {
w.addContainer(container)
}
}
func (w *DockerWatcher) Start() {
w.mu.Lock()
if w.running {
w.mu.Unlock()
return
}
waitDone := make(chan struct{})
ctx, cancel := context.WithCancel(context.Background())
w.cancel = cancel
w.waitDone = waitDone
w.running = true
w.mu.Unlock()
log.Printf("docker watcher started")
w.ScanExisting()
go w.watchEvents(ctx, waitDone)
}
func (w *DockerWatcher) Stop() {
w.mu.Lock()
if !w.running {
w.mu.Unlock()
return
}
w.running = false
cancel := w.cancel
waitDone := w.waitDone
w.mu.Unlock()
if cancel != nil {
cancel()
}
<-waitDone
log.Printf("docker watcher stopped")
}
+31
View File
@@ -0,0 +1,31 @@
package webterm
import "testing"
func TestDockerWatcherCommandAndThemeParsing(t *testing.T) {
watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/docker.sock", nil, nil)
container := map[string]any{
"Labels": map[string]any{
WebtermLabelName: "auto",
WebtermThemeLabel: "nord",
},
"Names": []any{"/my_container.1"},
}
if cmd := watcher.getContainerCommand(container); cmd != AutoCommandSentinel {
t.Fatalf("expected auto command sentinel, got %q", cmd)
}
if theme := watcher.getContainerTheme(container); theme != "nord" {
t.Fatalf("unexpected theme: %q", theme)
}
if slug := watcher.containerToSlug(container); slug != "my-container-1" {
t.Fatalf("unexpected slug: %q", slug)
}
}
func TestDockerWatcherCanRestart(t *testing.T) {
watcher := NewDockerWatcher(NewSessionManager(nil), "/tmp/does-not-exist.sock", nil, nil)
watcher.Start()
watcher.Stop()
watcher.Start()
watcher.Stop()
}
+36
View File
@@ -0,0 +1,36 @@
package webterm
import (
"crypto/rand"
)
const (
identityAlphabet = "0123456789ABCDEFGHJKMNPQRSTUVWYZ"
identitySize = 12
)
func GenerateID(size int) string {
if size <= 0 {
size = identitySize
}
const alphabetLen = len(identityAlphabet) // 31
// Largest multiple of 31 that fits in a byte: 31*8 = 248
const maxUnbiased = alphabetLen * (256 / alphabetLen) // 248
out := make([]byte, size)
buf := make([]byte, size+16) // extra bytes for rejection sampling
filled := 0
for filled < size {
_, _ = rand.Read(buf)
for _, b := range buf {
if int(b) >= maxUnbiased {
continue // reject to avoid modulo bias
}
out[filled] = identityAlphabet[int(b)%alphabetLen]
filled++
if filled >= size {
break
}
}
}
return string(out)
}
+60
View File
@@ -0,0 +1,60 @@
package webterm
import (
"strings"
"testing"
)
func TestGenerateIDLength(t *testing.T) {
for _, size := range []int{1, 5, 12, 50} {
id := GenerateID(size)
if len(id) != size {
t.Errorf("GenerateID(%d) length = %d", size, len(id))
}
}
}
func TestGenerateIDDefaultSize(t *testing.T) {
id := GenerateID(0)
if len(id) != identitySize {
t.Errorf("GenerateID(0) length = %d, want %d", len(id), identitySize)
}
}
func TestGenerateIDAlphabet(t *testing.T) {
id := GenerateID(1000)
for _, ch := range id {
if !strings.ContainsRune(identityAlphabet, ch) {
t.Errorf("GenerateID produced char %q not in alphabet", string(ch))
}
}
}
func FuzzGenerateID(f *testing.F) {
f.Add(0)
f.Add(1)
f.Add(12)
f.Add(100)
f.Add(-5)
f.Add(500)
f.Fuzz(func(t *testing.T, size int) {
// Cap size to avoid excessive allocation
if size > 10000 {
size = 10000
}
id := GenerateID(size)
expectedLen := size
if size <= 0 {
expectedLen = identitySize
}
if len(id) != expectedLen {
t.Errorf("GenerateID(%d) length = %d, want %d", size, len(id), expectedLen)
}
for _, ch := range id {
if !strings.ContainsRune(identityAlphabet, ch) {
t.Errorf("GenerateID(%d) produced char %q not in alphabet %q", size, string(ch), identityAlphabet)
}
}
})
}
+30
View File
@@ -0,0 +1,30 @@
package webterm
import (
"bytes"
"regexp"
)
var (
daResponsePattern = regexp.MustCompile(`\x1b\[[?>=][\d;]*c`)
daPartialPattern = regexp.MustCompile(`\x1b(?:\[(?:[?>=][\d;]*)?)?$`)
)
func FilterDASequences(data []byte, escapeBuffer []byte) ([]byte, []byte) {
merged := append(append([]byte{}, escapeBuffer...), data...)
if len(merged) == 0 {
return nil, nil
}
filtered := daResponsePattern.ReplaceAll(merged, nil)
if len(filtered) == 0 {
return nil, nil
}
match := daPartialPattern.FindIndex(filtered)
if match == nil {
return filtered, nil
}
if match[0] == len(filtered)-1 || bytes.HasPrefix(filtered[match[0]:], []byte("\x1b[")) || bytes.Equal(filtered[match[0]:], []byte("\x1b")) {
return filtered[:match[0]], filtered[match[0]:]
}
return filtered, nil
}
+55
View File
@@ -0,0 +1,55 @@
package webterm
import (
"bytes"
"testing"
)
func TestFilterDASequencesCompleteAndPartial(t *testing.T) {
data := []byte("a\x1b[?1;10;0cb")
filtered, buffer := FilterDASequences(data, nil)
if string(buffer) != "" {
t.Fatalf("expected empty buffer, got %q", string(buffer))
}
if string(filtered) != "ab" {
t.Fatalf("unexpected filtered output: %q", string(filtered))
}
part1, partBuffer := FilterDASequences([]byte("x\x1b[?1;10"), nil)
if string(part1) != "x" {
t.Fatalf("unexpected part1 output: %q", string(part1))
}
if string(partBuffer) == "" {
t.Fatalf("expected buffered partial sequence")
}
part2, partBuffer2 := FilterDASequences([]byte(";0cy"), partBuffer)
if string(partBuffer2) != "" {
t.Fatalf("expected empty buffer after completion")
}
if string(part2) != "y" {
t.Fatalf("unexpected part2 output: %q", string(part2))
}
}
func FuzzFilterDASequences(f *testing.F) {
f.Add([]byte("a\x1b[?1;10;0cb"), []byte{})
f.Add([]byte("plain text"), []byte{})
f.Add([]byte{0x1b, '[', '?'}, []byte{})
f.Add([]byte(";0cy"), []byte("\x1b[?1;10"))
f.Add([]byte("\x1b[>0c\x1b[=1c"), []byte{})
f.Add([]byte{}, []byte{0x1b})
f.Add([]byte("no escape"), []byte{})
f.Fuzz(func(t *testing.T, data []byte, buffer []byte) {
out, remaining := FilterDASequences(data, buffer)
_ = out
// Remaining should be empty or a partial escape sequence starting with ESC
if len(remaining) > 0 && remaining[0] != 0x1b {
t.Errorf("remaining buffer doesn't start with ESC: %q", remaining)
}
// Complete DA sequences should not appear in output
if bytes.Contains(out, []byte("\x1b[?1;10;0c")) {
t.Errorf("DA sequence leaked through filter")
}
})
}
+51
View File
@@ -0,0 +1,51 @@
package webterm
import "sync"
const replayBufferSize = 256 * 1024
type ReplayBuffer struct {
mu sync.Mutex
parts [][]byte
size int
limit int
}
func NewReplayBuffer(limit int) *ReplayBuffer {
if limit <= 0 {
limit = replayBufferSize
}
return &ReplayBuffer{limit: limit}
}
func (r *ReplayBuffer) Add(data []byte) {
if len(data) == 0 {
return
}
r.mu.Lock()
defer r.mu.Unlock()
chunk := append([]byte{}, data...)
r.parts = append(r.parts, chunk)
r.size += len(chunk)
evicted := 0
for r.size > r.limit && evicted < len(r.parts) {
r.size -= len(r.parts[evicted])
evicted++
}
if evicted > 0 {
// Copy remaining to a new slice to release old backing array
remaining := make([][]byte, len(r.parts)-evicted)
copy(remaining, r.parts[evicted:])
r.parts = remaining
}
}
func (r *ReplayBuffer) Bytes() []byte {
r.mu.Lock()
defer r.mu.Unlock()
joined := make([]byte, 0, r.size)
for _, chunk := range r.parts {
joined = append(joined, chunk...)
}
return joined
}
+68
View File
@@ -0,0 +1,68 @@
package webterm
import "testing"
func TestReplayBufferTrimsOldData(t *testing.T) {
buffer := NewReplayBuffer(5)
buffer.Add([]byte("abc"))
buffer.Add([]byte("de"))
buffer.Add([]byte("f"))
if got := string(buffer.Bytes()); got != "def" {
t.Fatalf("expected trimmed replay buffer, got %q", got)
}
}
func FuzzReplayBuffer(f *testing.F) {
f.Add([]byte("hello"), []byte("world"), 100)
f.Add([]byte{}, []byte("a"), 1)
f.Add([]byte("abcdef"), []byte("ghijkl"), 5)
f.Add(make([]byte, 300), []byte{0xff}, 256)
f.Fuzz(func(t *testing.T, chunk1, chunk2 []byte, limit int) {
if limit <= 0 {
limit = 1
}
if limit > 1024*1024 {
limit = 1024 * 1024
}
buf := NewReplayBuffer(limit)
buf.Add(chunk1)
buf.Add(chunk2)
result := buf.Bytes()
// Result size must not exceed limit
if len(result) > limit {
t.Errorf("replay buffer size %d exceeds limit %d", len(result), limit)
}
// If both chunks fit, all data should be present
if len(chunk1)+len(chunk2) <= limit {
combined := append(append([]byte{}, chunk1...), chunk2...)
if string(result) != string(combined) {
t.Errorf("expected full data when within limit")
}
}
})
}
func FuzzReplayBufferRapid(f *testing.F) {
f.Add([]byte("a"), 10)
f.Add([]byte("abcdefghij"), 5)
f.Fuzz(func(t *testing.T, chunk []byte, count int) {
if count < 0 {
count = 0
}
if count > 200 {
count = 200
}
buf := NewReplayBuffer(256)
for i := 0; i < count; i++ {
buf.Add(chunk)
}
result := buf.Bytes()
if len(result) > 256 {
t.Errorf("replay buffer exceeded limit: %d", len(result))
}
})
}
+1503
View File
File diff suppressed because it is too large Load Diff
+37
View File
@@ -0,0 +1,37 @@
package webterm
import "testing"
func FuzzToIntFromQuery(f *testing.F) {
f.Add("42", 7)
f.Add("-5", 10)
f.Add(" 123 ", 0)
f.Add("not-a-number", 99)
f.Add("", 11)
f.Fuzz(func(t *testing.T, value string, fallback int) {
got := toIntFromQuery(value, fallback)
// Must not panic and should preserve fallback semantics for non-numeric values.
if value == "" && got != fallback {
t.Fatalf("empty value should use fallback: got=%d fallback=%d", got, fallback)
}
})
}
func FuzzHTMLHelpers(f *testing.F) {
f.Add(`plain text`)
f.Add(`<script>alert("x")</script>`)
f.Add(`a&b<c>d"e`)
f.Add(``)
f.Fuzz(func(t *testing.T, value string) {
escaped := htmlEscape(value)
attrEscaped := htmlAttrEscape(value)
if len(escaped) == 0 && len(value) > 0 {
t.Fatalf("htmlEscape unexpectedly empty for %q", value)
}
if len(attrEscaped) == 0 && len(value) > 0 {
t.Fatalf("htmlAttrEscape unexpectedly empty for %q", value)
}
})
}
+269
View File
@@ -0,0 +1,269 @@
package webterm
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
)
func newServerForTests(t *testing.T, withLanding bool) (*LocalServer, *httptest.Server, *syncSessionMap) {
t.Helper()
config := Config{
Apps: []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}},
}
options := ServerOptions{}
if withLanding {
options.LandingApps = []App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}}
}
server := NewLocalServer(config, options)
sessions := &syncSessionMap{m: map[string]*fakeSession{}}
server.sessionManager.SetSessionFactory(func(app App, sessionID string) Session {
s := newFakeSession()
sessions.mu.Lock()
sessions.m[sessionID] = s
sessions.mu.Unlock()
return s
})
httpServer := httptest.NewServer(server.Handler())
t.Cleanup(httpServer.Close)
return server, httpServer, sessions
}
type syncSessionMap struct {
mu sync.Mutex
m map[string]*fakeSession
}
func TestHealthAndTilesEndpoints(t *testing.T) {
_, httpServer, _ := newServerForTests(t, true)
resp, err := http.Get(httpServer.URL + "/health")
if err != nil {
t.Fatalf("health request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if !strings.Contains(string(body), "Local server is running") {
t.Fatalf("unexpected health response: %q", string(body))
}
resp, err = http.Get(httpServer.URL + "/tiles")
if err != nil {
t.Fatalf("tiles request error = %v", err)
}
var tiles []map[string]string
if err := json.NewDecoder(resp.Body).Decode(&tiles); err != nil {
t.Fatalf("decode tiles: %v", err)
}
_ = resp.Body.Close()
if len(tiles) != 1 || tiles[0]["slug"] != "shell" {
t.Fatalf("unexpected tiles: %+v", tiles)
}
}
func TestWebSocketPingResizeAndStdin(t *testing.T) {
server, httpServer, sessions := newServerForTests(t, false)
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("ws dial error = %v", err)
}
defer conn.Close()
if err := conn.WriteJSON([]any{"ping", "ok"}); err != nil {
t.Fatalf("write ping: %v", err)
}
_, payload, err := conn.ReadMessage()
if err != nil {
t.Fatalf("read pong: %v", err)
}
var pong []any
if err := json.Unmarshal(payload, &pong); err != nil {
t.Fatalf("decode pong: %v", err)
}
if pong[0] != "pong" || pong[1] != "ok" {
t.Fatalf("unexpected pong payload: %v", pong)
}
if err := conn.WriteJSON([]any{"resize", map[string]any{"width": 100, "height": 40}}); err != nil {
t.Fatalf("write resize: %v", err)
}
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) && server.sessionManager.GetSessionByRouteKey("shell") == nil {
time.Sleep(10 * time.Millisecond)
}
if session := server.sessionManager.GetSessionByRouteKey("shell"); session == nil {
t.Fatalf("expected session to be created on resize")
}
if err := conn.WriteJSON([]any{"stdin", "ls\n"}); err != nil {
t.Fatalf("write stdin: %v", err)
}
time.Sleep(20 * time.Millisecond)
found := false
sessions.mu.Lock()
for _, session := range sessions.m {
session.mu.Lock()
if len(session.received) > 0 && string(session.received[0]) == "ls\n" {
found = true
}
session.mu.Unlock()
}
sessions.mu.Unlock()
if !found {
t.Fatalf("expected stdin to reach session")
}
}
func TestWebSocketReplayOnReconnect(t *testing.T) {
_, httpServer, sessions := newServerForTests(t, false)
wsURL := "ws" + strings.TrimPrefix(httpServer.URL, "http") + "/ws/shell"
conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("first dial error = %v", err)
}
if err := conn.WriteJSON([]any{"resize", map[string]any{"width": 80, "height": 24}}); err != nil {
t.Fatalf("resize write: %v", err)
}
time.Sleep(20 * time.Millisecond)
_ = conn.Close()
sessions.mu.Lock()
for _, session := range sessions.m {
session.mu.Lock()
session.replay = []byte("abc\x1b[?1;10;0cdef")
session.mu.Unlock()
}
sessions.mu.Unlock()
conn2, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
if err != nil {
t.Fatalf("second dial error = %v", err)
}
defer conn2.Close()
_ = conn2.SetReadDeadline(time.Now().Add(2 * time.Second))
msgType, replay, err := conn2.ReadMessage()
if err != nil {
t.Fatalf("read replay: %v", err)
}
if msgType != websocket.BinaryMessage {
t.Fatalf("expected binary replay message, got %d", msgType)
}
if string(replay) != "abcdef" {
t.Fatalf("unexpected replay payload: %q", string(replay))
}
}
func TestScreenshotAndETag(t *testing.T) {
server, httpServer, _ := newServerForTests(t, false)
if _, err := server.sessionManager.NewSession("shell", "sid", "shell", 80, 24); err != nil {
t.Fatalf("NewSession error = %v", err)
}
resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell")
if err != nil {
t.Fatalf("screenshot request error = %v", err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
etag := resp.Header.Get("ETag")
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if etag == "" || !strings.Contains(string(body), "<svg") {
t.Fatalf("expected svg body and etag")
}
req, _ := http.NewRequest(http.MethodGet, httpServer.URL+"/screenshot.svg?route_key=shell", nil)
req.Header.Set("If-None-Match", etag)
resp2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("etag request error = %v", err)
}
_ = resp2.Body.Close()
if resp2.StatusCode != http.StatusNotModified {
t.Fatalf("expected 304, got %d", resp2.StatusCode)
}
}
func TestScreenshotCreatesSessionFromRequestedRoute(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/screenshot.svg?route_key=shell")
if err != nil {
t.Fatalf("screenshot request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d body=%q", resp.StatusCode, string(body))
}
if !strings.Contains(string(body), "<svg") {
t.Fatalf("expected svg body")
}
}
func TestRootTerminalPageAndSparklineValidation(t *testing.T) {
_, httpServer, _ := newServerForTests(t, false)
resp, err := http.Get(httpServer.URL + "/?route_key=shell")
if err != nil {
t.Fatalf("root request error = %v", err)
}
body, _ := io.ReadAll(resp.Body)
_ = resp.Body.Close()
text := string(body)
if !strings.Contains(text, "/static/js/terminal.js") || !strings.Contains(text, "data-session-websocket-url") {
t.Fatalf("unexpected root page: %q", text)
}
respStatic, err := http.Get(httpServer.URL + "/static/manifest.json")
if err != nil {
t.Fatalf("static request error = %v", err)
}
_ = respStatic.Body.Close()
if respStatic.StatusCode != http.StatusOK {
t.Fatalf("expected 200 for static manifest, got %d", respStatic.StatusCode)
}
resp2, err := http.Get(httpServer.URL + "/cpu-sparkline.svg")
if err != nil {
t.Fatalf("sparkline request error = %v", err)
}
_ = resp2.Body.Close()
if resp2.StatusCode != http.StatusBadRequest {
t.Fatalf("expected 400 for missing container, got %d", resp2.StatusCode)
}
}
func TestMarkRouteActivityBroadcastsWithoutBlockingGlobalLock(t *testing.T) {
server := NewLocalServer(Config{}, ServerOptions{})
ready := make(chan string, 1)
full := make(chan string, 1)
full <- "occupied"
server.mu.Lock()
server.sseSubscribers[ready] = struct{}{}
server.sseSubscribers[full] = struct{}{}
server.routeLastSSE["route-a"] = time.Now().Add(-time.Second)
server.mu.Unlock()
start := time.Now()
server.markRouteActivity("route-a")
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("markRouteActivity took too long: %s", elapsed)
}
select {
case got := <-ready:
if got != "route-a" {
t.Fatalf("unexpected broadcast payload: %q", got)
}
default:
t.Fatalf("expected route activity broadcast")
}
}
+64
View File
@@ -0,0 +1,64 @@
package webterm
import (
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
type SessionConnector interface {
OnData(data []byte)
OnBinary(payload []byte)
OnMeta(meta map[string]any)
OnClose()
}
type Session interface {
Open(width, height int) error
Start(connector SessionConnector) error
Close() error
Wait() error
SetTerminalSize(width, height int) error
SendBytes(data []byte) bool
SendMeta(meta map[string]any) bool
IsRunning() bool
GetReplayBuffer() []byte
GetScreenSnapshot() terminalstate.Snapshot
ForceRedraw() error
UpdateConnector(connector SessionConnector)
}
type noopConnector struct{}
func (noopConnector) OnData([]byte) {}
func (noopConnector) OnBinary([]byte) {}
func (noopConnector) OnMeta(map[string]any) {}
func (noopConnector) OnClose() {}
func dispatchSessionOutput(filtered []byte, tracker *terminalstate.Tracker, replay *ReplayBuffer, connector SessionConnector) {
if len(filtered) == 0 {
return
}
replay.Add(filtered)
hasVisualChange := false
if tracker != nil {
_ = tracker.Feed(filtered)
hasVisualChange = tracker.ConsumeActivityChanged()
}
connector.OnData(filtered)
if hasVisualChange {
connector.OnMeta(map[string]any{"screen_changed": true})
}
}
func snapshotFromTracker(tracker *terminalstate.Tracker, width, height int) terminalstate.Snapshot {
if tracker != nil {
return tracker.Snapshot()
}
if height < 0 {
height = 0
}
return terminalstate.Snapshot{
Width: width,
Height: height,
Buffer: make([][]terminalstate.Cell, height),
}
}
+64
View File
@@ -0,0 +1,64 @@
package webterm
import (
"testing"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
type captureConnector struct {
data [][]byte
}
func (c *captureConnector) OnData(data []byte) {
c.data = append(c.data, append([]byte{}, data...))
}
func (c *captureConnector) OnBinary([]byte) {}
func (c *captureConnector) OnMeta(map[string]any) {}
func (c *captureConnector) OnClose() {}
func TestDispatchSessionOutput(t *testing.T) {
replay := NewReplayBuffer(1024)
connector := &captureConnector{}
tracker := terminalstate.NewTracker(80, 24)
dispatchSessionOutput([]byte("hello\n"), tracker, replay, connector)
if got := string(replay.Bytes()); got != "hello\n" {
t.Fatalf("unexpected replay: %q", got)
}
if len(connector.data) != 1 || string(connector.data[0]) != "hello\n" {
t.Fatalf("unexpected connector data: %+v", connector.data)
}
}
func TestDispatchSessionOutputEmpty(t *testing.T) {
replay := NewReplayBuffer(1024)
connector := &captureConnector{}
dispatchSessionOutput(nil, nil, replay, connector)
dispatchSessionOutput([]byte{}, nil, replay, connector)
if got := string(replay.Bytes()); got != "" {
t.Fatalf("expected empty replay, got %q", got)
}
if len(connector.data) != 0 {
t.Fatalf("expected no connector events")
}
}
func TestSnapshotFromTrackerFallback(t *testing.T) {
snap := snapshotFromTracker(nil, 10, -2)
if snap.Width != 10 || snap.Height != 0 || len(snap.Buffer) != 0 {
t.Fatalf("unexpected fallback snapshot: %+v", snap)
}
}
func TestSnapshotFromTrackerWithTracker(t *testing.T) {
tracker := terminalstate.NewTracker(4, 2)
_ = tracker.Feed([]byte("ab"))
snap := snapshotFromTracker(tracker, 1, 1)
if snap.Width != 4 || snap.Height != 2 {
t.Fatalf("unexpected tracker snapshot dimensions: %+v", snap)
}
}
+232
View File
@@ -0,0 +1,232 @@
package webterm
import (
"fmt"
"os"
"strings"
"sync"
)
type SessionManager struct {
mu sync.RWMutex
apps []App
appsBySlug map[string]App
sessions map[string]Session
routes *TwoWayMap[string, string]
sessionFactory func(app App, sessionID string) Session
}
func NewSessionManager(apps []App) *SessionManager {
m := &SessionManager{
apps: append([]App{}, apps...),
appsBySlug: map[string]App{},
sessions: map[string]Session{},
routes: NewTwoWayMap[string, string](),
}
for _, app := range m.apps {
m.appsBySlug[app.Slug] = app
}
m.sessionFactory = m.defaultSessionFactory
return m
}
func (m *SessionManager) SetSessionFactory(factory func(app App, sessionID string) Session) {
m.mu.Lock()
defer m.mu.Unlock()
m.sessionFactory = factory
}
func (m *SessionManager) defaultSessionFactory(app App, sessionID string) Session {
if app.Command == AutoCommandSentinel {
auto := os.Getenv(DockerAutoCommandEnv)
if strings.TrimSpace(auto) == "" {
auto = "/bin/bash"
}
auto = strings.ReplaceAll(auto, "{container}", app.Name)
spec := DockerExecSpec{
Container: app.Name,
Command: splitCommand(auto),
User: os.Getenv(DockerUsernameEnv),
}
return NewDockerExecSession(sessionID, spec, "")
}
return NewTerminalSession(sessionID, app.Command)
}
func splitCommand(command string) []string {
argv, err := shlexSplit(command)
if err != nil || len(argv) == 0 {
return []string{"/bin/sh", "-lc", command}
}
return argv
}
func shlexSplit(command string) ([]string, error) {
return shlexSplitImpl(command)
}
func (m *SessionManager) AddApp(name, command, slug string, terminal bool, theme string) string {
m.mu.Lock()
defer m.mu.Unlock()
if strings.TrimSpace(slug) == "" {
slug = strings.ToLower(GenerateID(12))
}
app := App{
Name: name,
Slug: slug,
Path: "./",
Command: command,
Terminal: terminal,
Theme: theme,
}
m.apps = append(m.apps, app)
m.appsBySlug[slug] = app
return slug
}
func (m *SessionManager) RemoveApp(slug string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.appsBySlug, slug)
filtered := m.apps[:0]
for _, app := range m.apps {
if app.Slug != slug {
filtered = append(filtered, app)
}
}
m.apps = filtered
}
func (m *SessionManager) Apps() []App {
m.mu.RLock()
defer m.mu.RUnlock()
return append([]App{}, m.apps...)
}
func (m *SessionManager) AppBySlug(slug string) (App, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
app, ok := m.appsBySlug[slug]
return app, ok
}
func (m *SessionManager) GetDefaultApp() (App, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
if len(m.apps) == 0 {
return App{}, false
}
return m.apps[0], true
}
func (m *SessionManager) NewSession(slug, sessionID, routeKey string, width, height int) (Session, error) {
m.mu.Lock()
app, ok := m.appsBySlug[slug]
if !ok {
m.mu.Unlock()
return nil, fmt.Errorf("app not found")
}
// Check if this routeKey already has an active session
if existingID, exists := m.routes.UnsafeForward()[routeKey]; exists {
if existingSession, alive := m.sessions[existingID]; alive && existingSession.IsRunning() {
m.mu.Unlock()
return existingSession, nil
}
// Stale mapping — clean up
delete(m.sessions, existingID)
m.routes.DeleteKey(routeKey)
}
factory := m.sessionFactory
m.mu.Unlock()
session := factory(app, sessionID)
if session == nil {
return nil, fmt.Errorf("session factory returned nil")
}
if err := session.Open(width, height); err != nil {
return nil, err
}
m.mu.Lock()
defer m.mu.Unlock()
// Re-check after re-acquiring lock
if existingID, exists := m.routes.UnsafeForward()[routeKey]; exists {
if existingSession, alive := m.sessions[existingID]; alive && existingSession.IsRunning() {
// Another goroutine won the race; close ours and return theirs
go func() { _ = session.Close() }()
return existingSession, nil
}
delete(m.sessions, existingID)
m.routes.DeleteKey(routeKey)
}
m.sessions[sessionID] = session
_ = m.routes.Set(routeKey, sessionID)
return session, nil
}
func (m *SessionManager) OnSessionEnd(sessionID string) {
m.mu.Lock()
defer m.mu.Unlock()
delete(m.sessions, sessionID)
if route, ok := m.routes.GetKey(sessionID); ok {
m.routes.DeleteKey(route)
}
}
func (m *SessionManager) CloseAll() {
m.mu.RLock()
sessionIDs := make([]string, 0, len(m.sessions))
for sessionID := range m.sessions {
sessionIDs = append(sessionIDs, sessionID)
}
m.mu.RUnlock()
for _, sessionID := range sessionIDs {
m.CloseSession(sessionID)
}
}
func (m *SessionManager) CloseSession(sessionID string) {
m.mu.RLock()
session := m.sessions[sessionID]
m.mu.RUnlock()
if session == nil {
return
}
_ = session.Close()
_ = session.Wait()
m.OnSessionEnd(sessionID)
}
func (m *SessionManager) GetSession(sessionID string) Session {
m.mu.RLock()
defer m.mu.RUnlock()
return m.sessions[sessionID]
}
func (m *SessionManager) GetSessionByRouteKey(routeKey string) Session {
m.mu.RLock()
defer m.mu.RUnlock()
sessionID, ok := m.routes.Get(routeKey)
if !ok {
return nil
}
return m.sessions[sessionID]
}
func (m *SessionManager) GetSessionIDByRouteKey(routeKey string) (string, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
return m.routes.Get(routeKey)
}
func (m *SessionManager) GetFirstRunningSession() (string, Session, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
for routeKey, sessionID := range m.routes.UnsafeForward() {
session := m.sessions[sessionID]
if session != nil && session.IsRunning() {
return routeKey, session, true
}
}
return "", nil, false
}
+36
View File
@@ -0,0 +1,36 @@
package webterm
import (
"testing"
)
func TestSessionManagerRouteMappingAndCleanup(t *testing.T) {
manager := NewSessionManager([]App{{Name: "Shell", Slug: "shell", Command: "/bin/sh", Terminal: true}})
var created *fakeSession
manager.SetSessionFactory(func(app App, sessionID string) Session {
created = newFakeSession()
return created
})
session, err := manager.NewSession("shell", "sid-1", "route-1", 80, 24)
if err != nil {
t.Fatalf("NewSession() error = %v", err)
}
if session == nil || created == nil {
t.Fatalf("expected session to be created")
}
if got := manager.GetSessionByRouteKey("route-1"); got == nil {
t.Fatalf("expected session lookup by route key")
}
manager.OnSessionEnd("sid-1")
if got := manager.GetSessionByRouteKey("route-1"); got != nil {
t.Fatalf("expected route mapping to be removed")
}
}
func TestSplitCommandWithFallback(t *testing.T) {
parts := splitCommand(`echo "hello world"`)
if len(parts) != 2 || parts[1] != "hello world" {
t.Fatalf("unexpected split command: %#v", parts)
}
}
+7
View File
@@ -0,0 +1,7 @@
package webterm
import "github.com/google/shlex"
func shlexSplitImpl(command string) ([]string, error) {
return shlex.Split(command)
}
+38
View File
@@ -0,0 +1,38 @@
package webterm
import "testing"
func TestShlexSplit(t *testing.T) {
parts, err := shlexSplitImpl("echo 'hello world'")
if err != nil {
t.Fatalf("shlexSplit error = %v", err)
}
if len(parts) != 2 || parts[0] != "echo" || parts[1] != "hello world" {
t.Fatalf("unexpected split result: %v", parts)
}
}
func FuzzShlexSplit(f *testing.F) {
f.Add("echo hello")
f.Add("echo 'hello world'")
f.Add(`echo "hello world"`)
f.Add("")
f.Add("a b c d e f g h i j")
f.Add(`echo "it's a test"`)
f.Add("echo \\n")
f.Add("'unclosed")
f.Add(`"unclosed`)
f.Add("a\x00b")
f.Fuzz(func(t *testing.T, command string) {
// Must not panic; errors are acceptable for malformed input
parts, err := shlexSplitImpl(command)
if err != nil {
return
}
// If no error, parts should be non-nil
if parts == nil {
t.Errorf("shlexSplit(%q) returned nil parts without error", command)
}
})
}
+18
View File
@@ -0,0 +1,18 @@
package webterm
import (
"regexp"
"strings"
)
var (
slugNonWord = regexp.MustCompile(`[^\w\s-]`)
slugSpaces = regexp.MustCompile(`[-\s]+`)
)
func Slugify(value string) string {
v := strings.ToLower(value)
v = slugNonWord.ReplaceAllString(v, "")
v = slugSpaces.ReplaceAllString(v, "-")
return strings.Trim(v, "-_")
}
+53
View File
@@ -0,0 +1,53 @@
package webterm
import (
"regexp"
"testing"
)
var validSlugPattern = regexp.MustCompile(`^[a-z0-9_-]*$`)
func TestSlugifyBasic(t *testing.T) {
cases := []struct {
in, want string
}{
{"Hello World", "hello-world"},
{"My App 2.0!", "my-app-20"},
{"---padded---", "padded"},
{"", ""},
}
for _, tc := range cases {
got := Slugify(tc.in)
if got != tc.want {
t.Errorf("Slugify(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func FuzzSlugify(f *testing.F) {
f.Add("Hello World")
f.Add("My App 2.0!")
f.Add("---padded---")
f.Add("")
f.Add("café résumé")
f.Add("日本語テスト")
f.Add("a" + string([]byte{0x00, 0x01}) + "b")
f.Add(string(make([]byte, 1024)))
f.Fuzz(func(t *testing.T, input string) {
result := Slugify(input)
// Result must only contain lowercase alphanumeric and hyphens
if !validSlugPattern.MatchString(result) {
t.Errorf("Slugify(%q) = %q contains invalid characters", input, result)
}
// Result must not start or end with hyphen/underscore
if len(result) > 0 {
if result[0] == '-' || result[0] == '_' {
t.Errorf("Slugify(%q) = %q starts with separator", input, result)
}
if result[len(result)-1] == '-' || result[len(result)-1] == '_' {
t.Errorf("Slugify(%q) = %q ends with separator", input, result)
}
}
})
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
+21
View File
@@ -0,0 +1,21 @@
{
"name": "Webterm Dashboard",
"short_name": "webterm",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{
"src": "/static/icons/webterm-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "/static/icons/webterm-512.png",
"sizes": "512x512",
"type": "image/png"
}
]
}
+78
View File
@@ -0,0 +1,78 @@
/* Generic monospace font stack for terminal rendering.
Prefers system monospace fonts, with optional Fira Code / Roboto Mono if available.
We avoid external font fetching (e.g. Google Fonts) to keep local server self-contained.
*/
: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;
--terminal-min-width: 10px;
--terminal-min-height: 5px;
}
html, body {
height: 100vh;
width: 100vw;
margin: 0;
padding: 0;
overflow: hidden;
box-sizing: border-box;
font-family: var(--webterm-mono);
}
/* Prevent scrollbar gutter space reservation */
html {
scrollbar-gutter: auto;
overflow: hidden;
}
/* Terminal container - works with ghostty-web canvas renderer */
.webterm-terminal {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
min-width: var(--terminal-min-width);
min-height: var(--terminal-min-height);
position: relative;
overflow: hidden;
contain: strict; /* Performance optimization */
}
/* ghostty-web renders to a canvas element */
.webterm-terminal canvas {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: block;
width: 100%;
height: 100%;
}
/* Hidden textarea for keyboard input */
.webterm-terminal textarea {
position: absolute;
opacity: 0;
pointer-events: none;
}
/* High DPI display handling */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.webterm-terminal {
/* ghostty-web handles DPI scaling via devicePixelRatio */
}
}
/* Fallback for older browsers */
@supports not (display: flex) {
.webterm-terminal {
display: block;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
}
}
+150
View File
@@ -0,0 +1,150 @@
package webterm
import (
"fmt"
"html"
"strings"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
var ansiColors = map[string]string{
"black": "#000000",
"red": "#cc0000",
"green": "#4e9a06",
"yellow": "#c4a000",
"blue": "#3465a4",
"magenta": "#75507b",
"cyan": "#06989a",
"white": "#d3d7cf",
"brightblack": "#555753",
"brightred": "#ef2929",
"brightgreen": "#8ae234",
"brightyellow": "#fce94f",
"brightblue": "#729fcf",
"brightmagenta": "#ad7fa8",
"brightcyan": "#34e2e2",
"brightwhite": "#eeeeec",
"gray": "#555753",
"grey": "#555753",
"lightgray": "#d3d7cf",
"lightgrey": "#d3d7cf",
"brown": "#c4a000",
}
func RenderTerminalSVG(
buffer [][]terminalstate.Cell,
width, height int,
title, background, foreground string,
palette map[string]string,
) string {
if background == "" {
background = "#000000"
}
if foreground == "" {
foreground = "#d3d7cf"
}
if title == "" {
title = "webterm"
}
if palette == nil {
palette = ansiColors
}
fontSize := 14.0
charWidth := 8.0
lineHeight := 1.2
cellHeight := fontSize * lineHeight
svgWidth := float64(width)*charWidth + 20
svgHeight := float64(height)*cellHeight + 20
var b strings.Builder
b.WriteString(fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %.1f %.1f" class="terminal-svg">`, svgWidth, svgHeight))
b.WriteString("<title>" + html.EscapeString(title) + "</title>")
b.WriteString(`<defs><style>.terminal-bg{fill:` + background + `}.terminal-text{font-family:ui-monospace,"SFMono-Regular","Fira Code",Menlo,Monaco,Consolas,"Liberation Mono","DejaVu Sans Mono","Courier New",monospace;font-size:14px;fill:` + foreground + `;white-space:pre;text-rendering:optimizeLegibility}.bold{font-weight:bold}.italic{font-style:italic}.underline{text-decoration:underline}</style></defs>`)
b.WriteString(fmt.Sprintf(`<rect class="terminal-bg" x="0" y="0" width="%.1f" height="%.1f"/>`, svgWidth, svgHeight))
b.WriteString(`<g class="terminal-text">`)
for rowIdx := 0; rowIdx < len(buffer); rowIdx++ {
row := buffer[rowIdx]
rectY := 10 + float64(rowIdx)*cellHeight
textY := rectY + fontSize
var rowText strings.Builder
for col := 0; col < len(row); col++ {
cell := row[col]
charData := cell.Data
if charData == "" {
continue
}
x := 10 + float64(col)*charWidth
fg := colorToHex(cell.FG, true, palette, foreground, background)
bg := colorToHex(cell.BG, false, palette, foreground, background)
if cell.Reverse {
fg, bg = bg, fg
}
if bg != background {
b.WriteString(fmt.Sprintf(`<rect x="%.1f" y="%.1f" width="%.1f" height="%.1f" fill="%s"/>`, x, rectY, charWidth+0.5, cellHeight+0.5, bg))
}
attrs := []string{fmt.Sprintf(`x="%.1f"`, x)}
if fg != foreground {
attrs = append(attrs, `fill="`+fg+`"`)
}
classes := []string{}
if cell.Bold {
classes = append(classes, "bold")
}
if cell.Italics {
classes = append(classes, "italic")
}
if cell.Underscore {
classes = append(classes, "underline")
}
if len(classes) > 0 {
attrs = append(attrs, `class="`+strings.Join(classes, " ")+`"`)
}
rowText.WriteString(`<tspan ` + strings.Join(attrs, " ") + `>` + html.EscapeString(charData) + `</tspan>`)
}
if rowText.Len() > 0 {
b.WriteString(fmt.Sprintf(`<text y="%.1f">%s</text>`, textY, rowText.String()))
}
}
b.WriteString(`</g></svg>`)
return b.String()
}
func colorToHex(color string, isFG bool, palette map[string]string, defaultFG, defaultBG string) string {
if color == "" || strings.EqualFold(color, "default") {
if isFG {
return defaultFG
}
return defaultBG
}
if strings.HasPrefix(color, "#") {
return color
}
if len(color) == 6 && isHex(color) {
return "#" + color
}
key := strings.ToLower(color)
if value, ok := palette[key]; ok {
return value
}
if value, ok := ansiColors[key]; ok {
return value
}
if isFG {
return defaultFG
}
return defaultBG
}
func isHex(value string) bool {
for _, ch := range value {
switch {
case ch >= '0' && ch <= '9':
case ch >= 'a' && ch <= 'f':
case ch >= 'A' && ch <= 'F':
default:
return false
}
}
return true
}
+103
View File
@@ -0,0 +1,103 @@
package webterm
import (
"strings"
"testing"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
func TestRenderTerminalSVG(t *testing.T) {
buffer := [][]terminalstate.Cell{
{
{Data: "A", FG: "red", BG: "default", Bold: true},
},
}
svg := RenderTerminalSVG(buffer, 1, 1, "webterm", "#000000", "#ffffff", ThemePalettes["xterm"])
if !strings.Contains(svg, "<svg") || !strings.Contains(svg, "<tspan") {
t.Fatalf("expected svg output with tspan, got %q", svg)
}
if !strings.Contains(svg, "A") {
t.Fatalf("expected rendered cell data")
}
}
func FuzzColorToHex(f *testing.F) {
f.Add("default", true)
f.Add("red", false)
f.Add("#ff0000", true)
f.Add("aabbcc", false)
f.Add("", true)
f.Add("nonexistent", false)
f.Add("AABBCC", true)
f.Add("123", false)
f.Add("brightmagenta", true)
f.Fuzz(func(t *testing.T, color string, isFG bool) {
result := colorToHex(color, isFG, ansiColors, "#ffffff", "#000000")
// Result must never be empty
if result == "" {
t.Errorf("colorToHex(%q, %v) returned empty string", color, isFG)
}
// Result must start with # (all paths return hex or default which starts with #)
if result[0] != '#' {
t.Errorf("colorToHex(%q, %v) = %q, doesn't start with #", color, isFG, result)
}
})
}
func FuzzIsHex(f *testing.F) {
f.Add("aabbcc")
f.Add("AABBCC")
f.Add("123456")
f.Add("")
f.Add("gggggg")
f.Add("zz")
f.Add("0123456789abcdefABCDEF")
f.Fuzz(func(t *testing.T, value string) {
result := isHex(value)
// Verify against reference implementation
expected := true
for _, ch := range value {
if !((ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F')) {
expected = false
break
}
}
if result != expected {
t.Errorf("isHex(%q) = %v, want %v", value, result, expected)
}
})
}
func FuzzRenderTerminalSVG(f *testing.F) {
f.Add("hello", "red", "blue", true, false, true)
f.Add("<script>", "default", "default", false, false, false)
f.Add("&amp;", "#ff0000", "#000000", false, true, false)
f.Add("", "nonexistent", "", false, false, false)
f.Fuzz(func(t *testing.T, data, fg, bg string, bold, italic, reverse bool) {
cell := terminalstate.Cell{
Data: data,
FG: fg,
BG: bg,
Bold: bold,
Italics: italic,
Reverse: reverse,
}
buffer := [][]terminalstate.Cell{{cell}}
result := RenderTerminalSVG(buffer, 1, 1, "test", "#000", "#fff", nil)
// Must produce valid SVG wrapper
if !strings.HasPrefix(result, "<svg") {
t.Errorf("output doesn't start with <svg")
}
if !strings.HasSuffix(result, "</svg>") {
t.Errorf("output doesn't end with </svg>")
}
// HTML special chars in data must be escaped
if strings.Contains(data, "<") && strings.Contains(result, "<script>") {
t.Errorf("unescaped HTML in SVG output")
}
})
}
+240
View File
@@ -0,0 +1,240 @@
package webterm
import (
"errors"
"os"
"os/exec"
"strings"
"sync"
"syscall"
"github.com/creack/pty"
"github.com/google/shlex"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
type TerminalSession struct {
sessionID string
command string
mu sync.RWMutex
connector SessionConnector
cmd *exec.Cmd
ptyFile *os.File
tracker *terminalstate.Tracker
replay *ReplayBuffer
escapeBuffer []byte
width int
height int
started bool
running bool
done chan struct{}
doneOnce sync.Once
waitErr error
writeMu sync.Mutex
}
func NewTerminalSession(sessionID string, command string) *TerminalSession {
return &TerminalSession{
sessionID: sessionID,
command: command,
connector: noopConnector{},
replay: NewReplayBuffer(replayBufferSize),
done: make(chan struct{}),
width: DefaultTerminalWidth,
height: DefaultTerminalHeight,
}
}
func (s *TerminalSession) Open(width, height int) error {
if width <= 0 {
width = 80
}
if height <= 0 {
height = 24
}
s.mu.Lock()
defer s.mu.Unlock()
if s.running {
return nil
}
command := strings.TrimSpace(s.command)
if command == "" {
command = os.Getenv("SHELL")
}
if command == "" {
command = "/bin/sh"
}
argv, err := shlex.Split(command)
if err != nil {
return err
}
if len(argv) == 0 {
return errors.New("empty command")
}
cmd := exec.Command(argv[0], argv[1:]...)
cmd.Env = append(os.Environ(), "TERM_PROGRAM=webterm-go", "TERM_PROGRAM_VERSION="+Version)
file, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)})
if err != nil {
return err
}
s.cmd = cmd
s.ptyFile = file
s.tracker = terminalstate.NewTracker(width, height)
s.width = width
s.height = height
s.running = true
return nil
}
func (s *TerminalSession) Start(connector SessionConnector) error {
s.mu.Lock()
if connector != nil {
s.connector = connector
}
if s.started {
s.mu.Unlock()
return nil
}
if s.ptyFile == nil {
s.mu.Unlock()
return errors.New("session not open")
}
s.started = true
file := s.ptyFile
s.mu.Unlock()
go s.readLoop(file)
return nil
}
func (s *TerminalSession) readLoop(file *os.File) {
buf := make([]byte, 32*1024)
for {
n, err := file.Read(buf)
if n > 0 {
s.handleOutput(buf[:n])
}
if err != nil {
break
}
}
s.mu.Lock()
if s.cmd != nil {
s.waitErr = s.cmd.Wait()
}
s.running = false
connector := s.connector
s.mu.Unlock()
connector.OnClose()
s.doneOnce.Do(func() { close(s.done) })
}
func (s *TerminalSession) handleOutput(data []byte) {
s.mu.Lock()
filtered, escapeBuffer := FilterDASequences(data, s.escapeBuffer)
s.escapeBuffer = escapeBuffer
tracker := s.tracker
connector := s.connector
s.mu.Unlock()
dispatchSessionOutput(filtered, tracker, s.replay, connector)
}
func (s *TerminalSession) Close() error {
s.mu.Lock()
file := s.ptyFile
cmd := s.cmd
s.ptyFile = nil
s.running = false
s.mu.Unlock()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Signal(syscall.SIGHUP)
}
if file != nil {
_ = file.Close()
}
s.doneOnce.Do(func() { close(s.done) })
return nil
}
func (s *TerminalSession) Wait() error {
<-s.done
s.mu.RLock()
defer s.mu.RUnlock()
return s.waitErr
}
func (s *TerminalSession) SetTerminalSize(width, height int) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.ptyFile == nil {
return errors.New("session closed")
}
if width <= 0 {
width = 1
}
if height <= 0 {
height = 1
}
if err := pty.Setsize(s.ptyFile, &pty.Winsize{Cols: uint16(width), Rows: uint16(height)}); err != nil {
return err
}
s.width = width
s.height = height
if s.tracker != nil {
s.tracker.Resize(width, height)
}
return nil
}
func (s *TerminalSession) ForceRedraw() error {
s.mu.RLock()
width := s.width
height := s.height
s.mu.RUnlock()
return s.SetTerminalSize(width, height)
}
func (s *TerminalSession) SendBytes(data []byte) bool {
s.mu.RLock()
file := s.ptyFile
s.mu.RUnlock()
if file == nil {
return false
}
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := file.Write(data)
return err == nil
}
func (s *TerminalSession) SendMeta(_ map[string]any) bool {
return true
}
func (s *TerminalSession) IsRunning() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.running
}
func (s *TerminalSession) GetReplayBuffer() []byte {
return s.replay.Bytes()
}
func (s *TerminalSession) GetScreenSnapshot() terminalstate.Snapshot {
s.mu.RLock()
tracker := s.tracker
width, height := s.width, s.height
s.mu.RUnlock()
return snapshotFromTracker(tracker, width, height)
}
func (s *TerminalSession) UpdateConnector(connector SessionConnector) {
if connector == nil {
return
}
s.mu.Lock()
defer s.mu.Unlock()
s.connector = connector
}
+102
View File
@@ -0,0 +1,102 @@
package webterm
import (
"sync"
"github.com/rcarmo/webterm-go-port/internal/terminalstate"
)
type fakeSession struct {
mu sync.Mutex
running bool
replay []byte
snapshot terminalstate.Snapshot
received [][]byte
width int
height int
connector SessionConnector
}
func newFakeSession() *fakeSession {
return &fakeSession{
snapshot: terminalstate.Snapshot{
Width: 80,
Height: 24,
Buffer: [][]terminalstate.Cell{{{Data: "h", FG: "default", BG: "default"}}},
HasChanges: true,
},
}
}
func (f *fakeSession) Open(width, height int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.running = true
f.width = width
f.height = height
f.snapshot.Width = width
f.snapshot.Height = height
return nil
}
func (f *fakeSession) Start(connector SessionConnector) error {
f.mu.Lock()
defer f.mu.Unlock()
f.connector = connector
return nil
}
func (f *fakeSession) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
f.running = false
return nil
}
func (f *fakeSession) Wait() error { return nil }
func (f *fakeSession) SetTerminalSize(width, height int) error {
f.mu.Lock()
defer f.mu.Unlock()
f.width = width
f.height = height
f.snapshot.Width = width
f.snapshot.Height = height
return nil
}
func (f *fakeSession) SendBytes(data []byte) bool {
f.mu.Lock()
defer f.mu.Unlock()
chunk := append([]byte{}, data...)
f.received = append(f.received, chunk)
return true
}
func (f *fakeSession) SendMeta(_ map[string]any) bool { return true }
func (f *fakeSession) IsRunning() bool {
f.mu.Lock()
defer f.mu.Unlock()
return f.running
}
func (f *fakeSession) GetReplayBuffer() []byte {
f.mu.Lock()
defer f.mu.Unlock()
return append([]byte{}, f.replay...)
}
func (f *fakeSession) GetScreenSnapshot() terminalstate.Snapshot {
f.mu.Lock()
defer f.mu.Unlock()
return f.snapshot
}
func (f *fakeSession) ForceRedraw() error { return nil }
func (f *fakeSession) UpdateConnector(connector SessionConnector) {
f.mu.Lock()
defer f.mu.Unlock()
f.connector = connector
}
@@ -0,0 +1,3 @@
go test fuzz v1
string("$0")
string("0")
+2
View File
@@ -0,0 +1,2 @@
go test fuzz v1
string("0_0")
+219
View File
@@ -0,0 +1,219 @@
package webterm
var ThemeBackgrounds = map[string]string{
"tango": "#000000",
"xterm": "#000000",
"monokai": "#2d2a2e",
"ristretto": "#2d2525",
"dark": "#1e1e1e",
"light": "#ffffff",
"dracula": "#282a36",
"catppuccin": "#1e1e2e",
"nord": "#2e3440",
"gruvbox": "#282828",
"solarized": "#002b36",
"tokyo": "#1a1b26",
}
var ThemePalettes = map[string]map[string]string{
"xterm": {
"background": "#000000",
"foreground": "#e5e5e5",
"black": "#000000",
"red": "#cd0000",
"green": "#00cd00",
"yellow": "#cdcd00",
"blue": "#0000cd",
"magenta": "#cd00cd",
"cyan": "#00cdcd",
"white": "#e5e5e5",
"brightblack": "#4d4d4d",
"brightred": "#ff0000",
"brightgreen": "#00ff00",
"brightyellow": "#ffff00",
"brightblue": "#0000ff",
"brightmagenta": "#ff00ff",
"brightcyan": "#00ffff",
"brightwhite": "#ffffff",
},
"monokai": {
"background": "#2d2a2e",
"foreground": "#fcfcfa",
"black": "#403e41",
"red": "#ff6188",
"green": "#a9dc76",
"yellow": "#ffd866",
"blue": "#fc9867",
"magenta": "#ab9df2",
"cyan": "#78dce8",
"white": "#fcfcfa",
"brightblack": "#727072",
"brightred": "#ff6188",
"brightgreen": "#a9dc76",
"brightyellow": "#ffd866",
"brightblue": "#fc9867",
"brightmagenta": "#ab9df2",
"brightcyan": "#78dce8",
"brightwhite": "#fcfcfa",
},
"dark": {
"background": "#1e1e1e",
"foreground": "#d4d4d4",
"black": "#000000",
"red": "#cd3131",
"green": "#0dbc79",
"yellow": "#e5e510",
"blue": "#2472c8",
"magenta": "#bc3fbc",
"cyan": "#11a8cd",
"white": "#e5e5e5",
"brightblack": "#666666",
"brightred": "#f14c4c",
"brightgreen": "#23d18b",
"brightyellow": "#f5f543",
"brightblue": "#3b8eea",
"brightmagenta": "#d670d6",
"brightcyan": "#29b8db",
"brightwhite": "#ffffff",
},
"light": {
"background": "#ffffff",
"foreground": "#383a42",
"black": "#000000",
"red": "#e45649",
"green": "#50a14f",
"yellow": "#c18401",
"blue": "#4078f2",
"magenta": "#a626a4",
"cyan": "#0184bc",
"white": "#a0a1a7",
"brightblack": "#5c6370",
"brightred": "#e06c75",
"brightgreen": "#98c379",
"brightyellow": "#d19a66",
"brightblue": "#61afef",
"brightmagenta": "#c678dd",
"brightcyan": "#56b6c2",
"brightwhite": "#ffffff",
},
"dracula": {
"background": "#282a36",
"foreground": "#f8f8f2",
"black": "#21222c",
"red": "#ff5555",
"green": "#50fa7b",
"yellow": "#f1fa8c",
"blue": "#bd93f9",
"magenta": "#ff79c6",
"cyan": "#8be9fd",
"white": "#f8f8f2",
"brightblack": "#6272a4",
"brightred": "#ff6e6e",
"brightgreen": "#69ff94",
"brightyellow": "#ffffa5",
"brightblue": "#d6acff",
"brightmagenta": "#ff92df",
"brightcyan": "#a4ffff",
"brightwhite": "#ffffff",
},
"catppuccin": {
"background": "#1e1e2e",
"foreground": "#cdd6f4",
"black": "#45475a",
"red": "#f38ba8",
"green": "#a6e3a1",
"yellow": "#f9e2af",
"blue": "#89b4fa",
"magenta": "#f5c2e7",
"cyan": "#94e2d5",
"white": "#bac2de",
"brightblack": "#585b70",
"brightred": "#f38ba8",
"brightgreen": "#a6e3a1",
"brightyellow": "#f9e2af",
"brightblue": "#89b4fa",
"brightmagenta": "#f5c2e7",
"brightcyan": "#94e2d5",
"brightwhite": "#a6adc8",
},
"nord": {
"background": "#2e3440",
"foreground": "#d8dee9",
"black": "#3b4252",
"red": "#bf616a",
"green": "#a3be8c",
"yellow": "#ebcb8b",
"blue": "#81a1c1",
"magenta": "#b48ead",
"cyan": "#88c0d0",
"white": "#e5e9f0",
"brightblack": "#4c566a",
"brightred": "#bf616a",
"brightgreen": "#a3be8c",
"brightyellow": "#ebcb8b",
"brightblue": "#81a1c1",
"brightmagenta": "#b48ead",
"brightcyan": "#8fbcbb",
"brightwhite": "#eceff4",
},
"gruvbox": {
"background": "#282828",
"foreground": "#ebdbb2",
"black": "#282828",
"red": "#cc241d",
"green": "#98971a",
"yellow": "#d79921",
"blue": "#458588",
"magenta": "#b16286",
"cyan": "#689d6a",
"white": "#a89984",
"brightblack": "#928374",
"brightred": "#fb4934",
"brightgreen": "#b8bb26",
"brightyellow": "#fabd2f",
"brightblue": "#83a598",
"brightmagenta": "#d3869b",
"brightcyan": "#8ec07c",
"brightwhite": "#ebdbb2",
},
"solarized": {
"background": "#002b36",
"foreground": "#839496",
"black": "#073642",
"red": "#dc322f",
"green": "#859900",
"yellow": "#b58900",
"blue": "#268bd2",
"magenta": "#d33682",
"cyan": "#2aa198",
"white": "#eee8d5",
"brightblack": "#586e75",
"brightred": "#cb4b16",
"brightgreen": "#586e75",
"brightyellow": "#657b83",
"brightblue": "#839496",
"brightmagenta": "#6c71c4",
"brightcyan": "#93a1a1",
"brightwhite": "#fdf6e3",
},
"tokyo": {
"background": "#1a1b26",
"foreground": "#a9b1d6",
"black": "#15161e",
"red": "#f7768e",
"green": "#9ece6a",
"yellow": "#e0af68",
"blue": "#7aa2f7",
"magenta": "#bb9af7",
"cyan": "#7dcfff",
"white": "#a9b1d6",
"brightblack": "#414868",
"brightred": "#f7768e",
"brightgreen": "#9ece6a",
"brightyellow": "#e0af68",
"brightblue": "#7aa2f7",
"brightmagenta": "#bb9af7",
"brightcyan": "#7dcfff",
"brightwhite": "#c0caf5",
},
}
+72
View File
@@ -0,0 +1,72 @@
package webterm
import (
"fmt"
"sync"
)
type TwoWayMap[K comparable, V comparable] struct {
mu sync.RWMutex
forward map[K]V
reverse map[V]K
}
func NewTwoWayMap[K comparable, V comparable]() *TwoWayMap[K, V] {
return &TwoWayMap[K, V]{
forward: map[K]V{},
reverse: map[V]K{},
}
}
func (m *TwoWayMap[K, V]) Set(key K, value V) error {
m.mu.Lock()
defer m.mu.Unlock()
if old, ok := m.forward[key]; ok && old != value {
delete(m.reverse, old)
}
if existingKey, ok := m.reverse[value]; ok && existingKey != key {
return fmt.Errorf("value already mapped")
}
m.forward[key] = value
m.reverse[value] = key
return nil
}
func (m *TwoWayMap[K, V]) DeleteKey(key K) {
m.mu.Lock()
defer m.mu.Unlock()
if value, ok := m.forward[key]; ok {
delete(m.forward, key)
delete(m.reverse, value)
}
}
func (m *TwoWayMap[K, V]) Get(key K) (V, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
v, ok := m.forward[key]
return v, ok
}
func (m *TwoWayMap[K, V]) GetKey(value V) (K, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
k, ok := m.reverse[value]
return k, ok
}
func (m *TwoWayMap[K, V]) Keys() []K {
m.mu.RLock()
defer m.mu.RUnlock()
keys := make([]K, 0, len(m.forward))
for key := range m.forward {
keys = append(keys, key)
}
return keys
}
// UnsafeForward returns the forward map directly. Caller must hold external synchronization.
func (m *TwoWayMap[K, V]) UnsafeForward() map[K]V {
return m.forward
}
+65
View File
@@ -0,0 +1,65 @@
package webterm
import "testing"
func TestTwoWayMapBasic(t *testing.T) {
m := NewTwoWayMap[string, int]()
if err := m.Set("a", 1); err != nil {
t.Fatalf("Set error: %v", err)
}
v, ok := m.Get("a")
if !ok || v != 1 {
t.Fatalf("Get(a) = %d, %v", v, ok)
}
k, ok := m.GetKey(1)
if !ok || k != "a" {
t.Fatalf("GetKey(1) = %q, %v", k, ok)
}
m.DeleteKey("a")
_, ok = m.Get("a")
if ok {
t.Fatalf("expected key to be deleted")
}
}
func FuzzTwoWayMap(f *testing.F) {
f.Add("key1", "val1", "key2", "val2", true)
f.Add("a", "b", "a", "c", false)
f.Add("x", "y", "z", "y", true)
f.Add("", "", "", "", false)
f.Fuzz(func(t *testing.T, k1, v1, k2, v2 string, deleteFirst bool) {
m := NewTwoWayMap[string, string]()
// Set first pair
_ = m.Set(k1, v1)
// Verify invariant: Get and GetKey are consistent
if val, ok := m.Get(k1); ok {
if key, ok2 := m.GetKey(val); !ok2 || key != k1 {
t.Errorf("bidirectional invariant broken for (%q, %q)", k1, v1)
}
}
if deleteFirst {
m.DeleteKey(k1)
if _, ok := m.Get(k1); ok {
t.Errorf("key %q still present after delete", k1)
}
if _, ok := m.GetKey(v1); ok {
t.Errorf("value %q still present after delete of key %q", v1, k1)
}
}
// Set second pair
_ = m.Set(k2, v2)
// Keys list should be consistent with forward map
keys := m.Keys()
for _, key := range keys {
if _, ok := m.Get(key); !ok {
t.Errorf("Keys() returned %q but Get(%q) failed", key, key)
}
}
})
}