MVP: Go daemon, password auth, persistent PTY terminals, React/xterm UI, PWA

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-17 10:25:35 -07:00
co-authored by Claude Fable 5.1
parent 190604247f
commit 436732dac0
35 changed files with 3576 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
bin/
internal/web/dist/
web/node_modules/
web/dist/
+26
View File
@@ -0,0 +1,26 @@
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
GO ?= go
PREFIX ?= /usr/local/bin
.PHONY: all web build dev deploy clean
all: build
web:
cd web && npm ci --no-audit --no-fund && npm run build
build: web
$(GO) build -trimpath -ldflags "-s -w -X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet
# backend only (assumes web/dist already built)
go:
$(GO) build -ldflags "-X main.version=$(VERSION)" -o bin/hamlet ./cmd/hamlet
dev:
cd web && npm run dev
deploy: build
./deploy/install.sh
clean:
rm -rf bin internal/web/dist web/dist
+160
View File
@@ -0,0 +1,160 @@
// Hamlet — Proxmox-style web console for a bare-metal Debian host.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"os/user"
"path/filepath"
"syscall"
"time"
"golang.org/x/term"
"hamlet/internal/api"
"hamlet/internal/auth"
"hamlet/internal/config"
"hamlet/internal/store"
hterm "hamlet/internal/term"
"hamlet/internal/web"
)
var version = "dev"
func main() {
defaultCfg := os.Getenv("HAMLET_CONFIG")
if defaultCfg == "" {
home, _ := os.UserHomeDir()
defaultCfg = filepath.Join(home, "apps", "hamlet", "config.json")
}
cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: hamlet [-config path] [serve|passwd|version]\n")
flag.PrintDefaults()
}
flag.Parse()
cmd := "serve"
if flag.NArg() > 0 {
cmd = flag.Arg(0)
}
cfg, err := config.Load(*cfgPath)
if err != nil {
log.Fatal(err)
}
switch cmd {
case "version":
fmt.Println("hamlet", version)
case "passwd":
if err := passwd(cfg); err != nil {
log.Fatal(err)
}
case "serve":
if err := serve(cfg); err != nil {
log.Fatal(err)
}
default:
flag.Usage()
os.Exit(2)
}
}
func passwd(cfg *config.Config) error {
fmt.Fprint(os.Stderr, "New password: ")
p1, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
fmt.Fprint(os.Stderr, "Again: ")
p2, err := term.ReadPassword(int(os.Stdin.Fd()))
fmt.Fprintln(os.Stderr)
if err != nil {
return err
}
if string(p1) != string(p2) {
return errors.New("passwords do not match")
}
if len(p1) < 12 {
return errors.New("password must be at least 12 characters")
}
h, err := auth.HashPassword(string(p1))
if err != nil {
return err
}
cfg.PasswordHash = h
if err := cfg.Save(); err != nil {
return err
}
fmt.Fprintln(os.Stderr, "password set in", cfg.Path())
return nil
}
func serve(cfg *config.Config) error {
if cfg.PasswordHash == "" {
return fmt.Errorf("no password set — run: hamlet -config %s passwd", cfg.Path())
}
if err := os.MkdirAll(cfg.DataDir(), 0o700); err != nil {
return err
}
st, err := store.Open(filepath.Join(cfg.DataDir(), "state.json"))
if err != nil {
return err
}
sessions := auth.NewSessions(st)
u, err := user.Current()
if err != nil {
return err
}
shell := os.Getenv("SHELL")
if shell == "" {
shell = "/bin/bash"
}
mgr := hterm.NewManager(shell, u.HomeDir)
mgr.OnChange = func(list []hterm.Info) {
_ = st.Update(func(s *store.State) {
s.Terminals = s.Terminals[:0]
for _, t := range list {
s.Terminals = append(s.Terminals, store.Terminal{ID: t.ID, Name: t.Name})
}
})
}
// Re-open the shells that existed before the last restart (fresh processes,
// same names/order) so the sidebar comes back as it was.
var saved []store.Terminal
st.View(func(s *store.State) { saved = append(saved, s.Terminals...) })
for _, t := range saved {
if _, err := mgr.Create(t.ID, t.Name); err != nil {
log.Printf("restore terminal %q: %v", t.Name, err)
}
}
srv := api.New(cfg, sessions, mgr, web.FS(), version)
hs := &http.Server{
Addr: cfg.Listen,
Handler: srv.Handler(),
ReadHeaderTimeout: 10 * time.Second,
}
go func() {
sig := make(chan os.Signal, 1)
signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM)
<-sig
log.Print("shutting down")
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = hs.Shutdown(ctx)
mgr.Shutdown()
}()
log.Printf("hamlet %s listening on %s as %s (shell %s)", version, cfg.Listen, u.Username, shell)
if err := hs.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
+18
View File
@@ -0,0 +1,18 @@
[Unit]
Description=Hamlet web console
After=network.target
[Service]
Type=simple
User=core
Group=core
WorkingDirectory=/home/core
Environment=HAMLET_CONFIG=/home/core/apps/hamlet/config.json
ExecStart=/usr/local/bin/hamlet serve
Restart=always
RestartSec=3
KillMode=control-group
TimeoutStopSec=10
[Install]
WantedBy=multi-user.target
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
# Install the built binary and unit on this host, then (re)start the service.
set -eu
cd "$(dirname "$0")/.."
sudo install -m 0755 bin/hamlet /usr/local/bin/hamlet
sudo install -m 0644 deploy/hamlet.service /etc/systemd/system/hamlet.service
sudo systemctl daemon-reload
sudo systemctl enable --now hamlet.service >/dev/null
sudo systemctl restart hamlet.service
sleep 1
systemctl is-active hamlet.service && /usr/local/bin/hamlet version
+12
View File
@@ -0,0 +1,12 @@
module hamlet
go 1.26.0
require (
github.com/coder/websocket v1.8.15
github.com/creack/pty v1.1.24
golang.org/x/crypto v0.57.0
golang.org/x/term v0.46.0
)
require golang.org/x/sys v0.48.0 // indirect
+10
View File
@@ -0,0 +1,10 @@
github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA=
github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE=
golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc=
+326
View File
@@ -0,0 +1,326 @@
// Package api is the HTTP/WebSocket surface: auth, terminal CRUD, attach, static UI.
package api
import (
"context"
"encoding/json"
"errors"
"io"
"io/fs"
"log"
"net/http"
"os"
"strings"
"time"
"github.com/coder/websocket"
"hamlet/internal/auth"
"hamlet/internal/config"
"hamlet/internal/term"
)
const cookieName = "hamlet_session"
type Server struct {
cfg *config.Config
sessions *auth.Sessions
limiter auth.Limiter
mgr *term.Manager
version string
started time.Time
user string
host string
ui fs.FS
mux *http.ServeMux
}
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, ui fs.FS, version string) *Server {
host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")}
if s.user == "" {
s.user = "core"
}
m := http.NewServeMux()
m.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(204) })
m.HandleFunc("POST /api/login", s.login)
m.HandleFunc("POST /api/logout", s.auth(s.logout))
m.HandleFunc("GET /api/me", s.auth(s.me))
m.HandleFunc("GET /api/terminals", s.auth(s.listTerminals))
m.HandleFunc("POST /api/terminals", s.auth(s.createTerminal))
m.HandleFunc("PATCH /api/terminals/{id}", s.auth(s.renameTerminal))
m.HandleFunc("DELETE /api/terminals/{id}", s.auth(s.deleteTerminal))
m.HandleFunc("GET /api/terminals/{id}/attach", s.auth(s.attach))
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
m.HandleFunc("/", s.static)
s.mux = m
return s
}
func (s *Server) Handler() http.Handler { return s.mux }
// ---- helpers ----
func jsonOK(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func jsonErr(w http.ResponseWriter, code int, msg string) {
jsonOK(w, code, map[string]string{"error": msg})
}
func secure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: cookieName, Value: tok, Path: "/", MaxAge: maxAge,
HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteStrictMode,
})
}
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
c, err := r.Cookie(cookieName)
if err != nil || !s.sessions.Valid(c.Value) {
jsonErr(w, 401, "unauthenticated")
return
}
next(w, r)
}
}
// ---- auth ----
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
if err := s.limiter.Allow(); err != nil {
jsonErr(w, 429, "too many attempts, try again later")
return
}
var body struct {
Password string `json:"password"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
if !auth.VerifyPassword(s.cfg.PasswordHash, body.Password) {
s.limiter.Fail()
log.Printf("login failed from %s", clientIP(r))
jsonErr(w, 401, "wrong password")
return
}
s.limiter.Reset()
tok, err := s.sessions.Create()
if err != nil {
jsonErr(w, 500, "session error")
return
}
s.setCookie(w, r, tok, int(auth.SessionTTL.Seconds()))
log.Printf("login ok from %s", clientIP(r))
w.WriteHeader(204)
}
func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(cookieName); err == nil {
s.sessions.Delete(c.Value)
}
s.setCookie(w, r, "", -1)
w.WriteHeader(204)
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
return strings.TrimSpace(strings.Split(xff, ",")[0]) + " via " + r.RemoteAddr
}
return r.RemoteAddr
}
// ---- me ----
func (s *Server) me(w http.ResponseWriter, _ *http.Request) {
load := ""
if b, err := os.ReadFile("/proc/loadavg"); err == nil {
f := strings.Fields(string(b))
if len(f) >= 3 {
load = strings.Join(f[:3], " ")
}
}
uptime := 0.0
if b, err := os.ReadFile("/proc/uptime"); err == nil {
if f := strings.Fields(string(b)); len(f) > 0 {
uptime, _ = json.Number(f[0]).Float64()
}
}
jsonOK(w, 200, map[string]any{
"host": s.host, "user": s.user, "version": s.version,
"load": load, "uptime": uptime, "service_started": s.started,
})
}
// ---- terminals ----
func (s *Server) listTerminals(w http.ResponseWriter, _ *http.Request) {
jsonOK(w, 200, s.mgr.List())
}
func (s *Server) createTerminal(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
_ = json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body)
name := strings.TrimSpace(body.Name)
if name == "" {
name = "shell " + time.Now().Format("15:04")
}
sess, err := s.mgr.Create("", name)
if err != nil {
log.Printf("create terminal: %v", err)
jsonErr(w, 500, err.Error())
return
}
jsonOK(w, 201, map[string]string{"id": sess.ID, "name": sess.Name})
}
func (s *Server) renameTerminal(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil || strings.TrimSpace(body.Name) == "" {
jsonErr(w, 400, "name required")
return
}
if err := s.mgr.Rename(r.PathValue("id"), strings.TrimSpace(body.Name)); err != nil {
jsonErr(w, 404, err.Error())
return
}
w.WriteHeader(204)
}
func (s *Server) deleteTerminal(w http.ResponseWriter, r *http.Request) {
if err := s.mgr.Close(r.PathValue("id")); err != nil {
jsonErr(w, 404, err.Error())
return
}
w.WriteHeader(204)
}
type resizeMsg struct {
Type string `json:"type"`
Cols uint16 `json:"cols"`
Rows uint16 `json:"rows"`
}
func (s *Server) attach(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
client, snapshot, running, err := s.mgr.Attach(id)
if err != nil {
jsonErr(w, 404, err.Error())
return
}
// Origin must match Host (coder/websocket default) — blocks cross-site attaches.
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{CompressionMode: websocket.CompressionDisabled})
if err != nil {
s.mgr.Detach(client)
return
}
defer s.mgr.Detach(client)
defer conn.Close(websocket.StatusNormalClosure, "")
conn.SetReadLimit(1 << 20)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
if len(snapshot) > 0 {
if err := conn.Write(ctx, websocket.MessageBinary, snapshot); err != nil {
return
}
}
if !running {
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"exit"}`))
}
// writer: PTY → socket, plus keepalive pings so idle proxies don't cut us.
go func() {
defer cancel()
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-ctx.Done():
return
case <-ping.C:
pctx, pc := context.WithTimeout(ctx, 10*time.Second)
err := conn.Ping(pctx)
pc()
if err != nil {
return
}
case msg, ok := <-client.Ch:
if !ok {
_ = conn.Write(ctx, websocket.MessageText, []byte(`{"type":"closed"}`))
return
}
if msg.Data != nil {
if err := conn.Write(ctx, websocket.MessageBinary, msg.Data); err != nil {
return
}
continue
}
if msg.Event == "exit" {
b, _ := json.Marshal(map[string]any{"type": "exit", "code": msg.Code})
_ = conn.Write(ctx, websocket.MessageText, b)
}
}
}
}()
// reader: socket → PTY (binary) or control (text JSON).
for {
typ, data, err := conn.Read(ctx)
if err != nil {
return
}
switch typ {
case websocket.MessageBinary:
if err := s.mgr.Write(id, data); err != nil && !errors.Is(err, term.ErrNotFound) {
log.Printf("pty write %s: %v", id, err)
}
case websocket.MessageText:
var m resizeMsg
if json.Unmarshal(data, &m) == nil && m.Type == "resize" {
_ = s.mgr.Resize(id, m.Cols, m.Rows)
}
}
}
}
// ---- static UI with SPA fallback ----
func (s *Server) static(w http.ResponseWriter, r *http.Request) {
p := strings.TrimPrefix(r.URL.Path, "/")
if p == "" {
p = "index.html"
}
f, err := s.ui.Open(p)
if err == nil {
st, _ := f.Stat()
f.Close()
if st != nil && !st.IsDir() {
if strings.HasPrefix(p, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
http.ServeFileFS(w, r, s.ui, p)
return
}
}
// SPA fallback
w.Header().Set("Cache-Control", "no-cache")
http.ServeFileFS(w, r, s.ui, "index.html")
}
+160
View File
@@ -0,0 +1,160 @@
// Package auth: argon2id password hashing, cookie sessions, login rate limiting.
package auth
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
"hamlet/internal/store"
)
const (
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 2
argonKeyLen = 32
SessionTTL = 30 * 24 * time.Hour
)
func HashPassword(pw string) (string, error) {
salt := make([]byte, 16)
if _, err := rand.Read(salt); err != nil {
return "", err
}
key := argon2.IDKey([]byte(pw), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
enc := base64.RawStdEncoding.EncodeToString
return fmt.Sprintf("$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads, enc(salt), enc(key)), nil
}
func VerifyPassword(hash, pw string) bool {
parts := strings.Split(hash, "$")
if len(parts) != 6 || parts[1] != "argon2id" {
return false
}
var m, t uint32
var p uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &m, &t, &p); err != nil {
return false
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false
}
want, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false
}
got := argon2.IDKey([]byte(pw), salt, t, m, p, uint32(len(want)))
return subtle.ConstantTimeCompare(got, want) == 1
}
// Sessions is a token store backed by the state file. Expiry is sliding.
type Sessions struct {
st *store.Store
}
func NewSessions(st *store.Store) *Sessions {
s := &Sessions{st: st}
_ = st.Update(func(state *store.State) { pruneExpired(state) })
return s
}
func pruneExpired(state *store.State) {
now := time.Now()
for tok, exp := range state.Sessions {
if exp.Before(now) {
delete(state.Sessions, tok)
}
}
}
func (s *Sessions) Create() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
tok := hex.EncodeToString(b)
err := s.st.Update(func(state *store.State) {
pruneExpired(state)
state.Sessions[tok] = time.Now().Add(SessionTTL)
})
return tok, err
}
// Valid reports whether tok is live, sliding its expiry (persisted at most hourly).
func (s *Sessions) Valid(tok string) bool {
if tok == "" {
return false
}
ok := false
extend := false
s.st.View(func(state *store.State) {
exp, found := state.Sessions[tok]
if found && exp.After(time.Now()) {
ok = true
extend = time.Until(exp) < SessionTTL-time.Hour
}
})
if ok && extend {
_ = s.st.Update(func(state *store.State) {
if _, found := state.Sessions[tok]; found {
state.Sessions[tok] = time.Now().Add(SessionTTL)
}
})
}
return ok
}
func (s *Sessions) Delete(tok string) {
_ = s.st.Update(func(state *store.State) { delete(state.Sessions, tok) })
}
// Limiter is a single global backoff: after 5 failures, each further failure
// doubles a lockout starting at 30s, capped at 10 minutes. Single-user box.
type Limiter struct {
mu sync.Mutex
failures int
lockUntil time.Time
}
var ErrLocked = errors.New("too many failed logins")
func (l *Limiter) Allow() error {
l.mu.Lock()
defer l.mu.Unlock()
if time.Now().Before(l.lockUntil) {
return ErrLocked
}
return nil
}
func (l *Limiter) Fail() {
l.mu.Lock()
defer l.mu.Unlock()
l.failures++
if l.failures >= 5 {
d := 30 * time.Second << uint(l.failures-5)
if d > 10*time.Minute {
d = 10 * time.Minute
}
l.lockUntil = time.Now().Add(d)
}
}
func (l *Limiter) Reset() {
l.mu.Lock()
defer l.mu.Unlock()
l.failures = 0
l.lockUntil = time.Time{}
}
+55
View File
@@ -0,0 +1,55 @@
// Package config loads and saves hamlet's JSON config file.
package config
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
type Config struct {
Listen string `json:"listen"`
PasswordHash string `json:"password_hash"`
path string
}
func Default(path string) *Config {
return &Config{Listen: "127.0.0.1:8100", path: path}
}
// Load reads the config at path. A missing file yields the defaults.
func Load(path string) (*Config, error) {
c := Default(path)
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return c, nil
}
if err != nil {
return nil, err
}
if err := json.Unmarshal(b, c); err != nil {
return nil, fmt.Errorf("parse %s: %w", path, err)
}
return c, nil
}
func (c *Config) Path() string { return c.path }
func (c *Config) DataDir() string { return filepath.Dir(c.path) }
func (c *Config) Save() error {
if err := os.MkdirAll(c.DataDir(), 0o700); err != nil {
return err
}
b, err := json.MarshalIndent(c, "", " ")
if err != nil {
return err
}
tmp := c.path + ".tmp"
if err := os.WriteFile(tmp, append(b, '\n'), 0o600); err != nil {
return err
}
return os.Rename(tmp, c.path)
}
+71
View File
@@ -0,0 +1,71 @@
// Package store persists small runtime state (terminal names, login sessions)
// to a JSON file with atomic writes.
package store
import (
"encoding/json"
"errors"
"os"
"sync"
"time"
)
type Terminal struct {
ID string `json:"id"`
Name string `json:"name"`
}
type State struct {
Terminals []Terminal `json:"terminals"`
Sessions map[string]time.Time `json:"sessions"` // token -> expiry
}
type Store struct {
mu sync.Mutex
path string
state State
}
func Open(path string) (*Store, error) {
s := &Store{path: path}
b, err := os.ReadFile(path)
if err != nil && !errors.Is(err, os.ErrNotExist) {
return nil, err
}
if err == nil {
if err := json.Unmarshal(b, &s.state); err != nil {
return nil, err
}
}
if s.state.Sessions == nil {
s.state.Sessions = map[string]time.Time{}
}
return s, nil
}
// Update applies fn under the lock and writes the result to disk.
func (s *Store) Update(fn func(*State)) error {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.state)
return s.save()
}
// View reads state under the lock.
func (s *Store) View(fn func(*State)) {
s.mu.Lock()
defer s.mu.Unlock()
fn(&s.state)
}
func (s *Store) save() error {
b, err := json.MarshalIndent(&s.state, "", " ")
if err != nil {
return err
}
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
+287
View File
@@ -0,0 +1,287 @@
// Package term owns PTY-backed shell sessions that outlive any one browser tab.
package term
import (
"crypto/rand"
"encoding/hex"
"errors"
"os"
"os/exec"
"sort"
"sync"
"syscall"
"time"
"github.com/creack/pty"
)
const (
ringSize = 256 * 1024
clientBuf = 512
killGrace = 3 * time.Second
readChunk = 32 * 1024
defaultCol = 120
defaultRow = 30
)
var ErrNotFound = errors.New("terminal not found")
// Msg is one unit of output to a client: raw PTY bytes, or a control event.
type Msg struct {
Data []byte // non-nil for PTY output
Event string // "exit" when the shell has ended
Code int
}
type Client struct {
Ch chan Msg
session *Session
dropped bool
}
type Info struct {
ID string `json:"id"`
Name string `json:"name"`
Running bool `json:"running"`
ExitCode int `json:"exit_code"`
Created time.Time `json:"created"`
Clients int `json:"clients"`
}
type Session struct {
ID string
Name string
Created time.Time
mu sync.Mutex
ptmx *os.File
cmd *exec.Cmd
ring *Ring
clients map[*Client]struct{}
running bool
exitCode int
}
type Manager struct {
mu sync.RWMutex
sessions map[string]*Session
shell string
home string
OnChange func([]Info) // called (without locks held) when the list changes
}
func NewManager(shell, home string) *Manager {
return &Manager{sessions: map[string]*Session{}, shell: shell, home: home}
}
func newID() string {
b := make([]byte, 4)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// Create starts a new login shell. id may be empty.
func (m *Manager) Create(id, name string) (*Session, error) {
if id == "" {
id = newID()
}
cmd := exec.Command(m.shell, "-l")
cmd.Dir = m.home
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLET=1")
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Cols: defaultCol, Rows: defaultRow})
if err != nil {
return nil, err
}
s := &Session{
ID: id, Name: name, Created: time.Now(),
ptmx: ptmx, cmd: cmd, ring: NewRing(ringSize),
clients: map[*Client]struct{}{}, running: true,
}
m.mu.Lock()
m.sessions[id] = s
m.mu.Unlock()
go m.pump(s)
m.changed()
return s, nil
}
func (m *Manager) pump(s *Session) {
buf := make([]byte, readChunk)
for {
n, err := s.ptmx.Read(buf)
if n > 0 {
chunk := make([]byte, n)
copy(chunk, buf[:n])
s.mu.Lock()
s.ring.Write(chunk)
s.broadcast(Msg{Data: chunk})
s.mu.Unlock()
}
if err != nil {
break // EIO when the slave side closes
}
}
_ = s.cmd.Wait()
code := s.cmd.ProcessState.ExitCode()
s.mu.Lock()
s.running = false
s.exitCode = code
s.broadcast(Msg{Event: "exit", Code: code})
s.mu.Unlock()
_ = s.ptmx.Close()
m.changed()
}
// broadcast requires s.mu. Slow clients are dropped rather than blocking the PTY.
func (s *Session) broadcast(msg Msg) {
for c := range s.clients {
select {
case c.Ch <- msg:
default:
if !c.dropped {
c.dropped = true
close(c.Ch)
}
delete(s.clients, c)
}
}
}
func (m *Manager) get(id string) (*Session, error) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.sessions[id]
if !ok {
return nil, ErrNotFound
}
return s, nil
}
func (m *Manager) List() []Info {
m.mu.RLock()
out := make([]Info, 0, len(m.sessions))
for _, s := range m.sessions {
s.mu.Lock()
out = append(out, Info{ID: s.ID, Name: s.Name, Running: s.running, ExitCode: s.exitCode,
Created: s.Created, Clients: len(s.clients)})
s.mu.Unlock()
}
m.mu.RUnlock()
sort.Slice(out, func(i, j int) bool { return out[i].Created.Before(out[j].Created) })
return out
}
func (m *Manager) Rename(id, name string) error {
s, err := m.get(id)
if err != nil {
return err
}
s.mu.Lock()
s.Name = name
s.mu.Unlock()
m.changed()
return nil
}
// Attach subscribes a client and returns the scrollback snapshot to replay first.
func (m *Manager) Attach(id string) (*Client, []byte, bool, error) {
s, err := m.get(id)
if err != nil {
return nil, nil, false, err
}
c := &Client{Ch: make(chan Msg, clientBuf), session: s}
s.mu.Lock()
snap := s.ring.Bytes()
running := s.running
s.clients[c] = struct{}{}
s.mu.Unlock()
m.changed()
return c, snap, running, nil
}
func (m *Manager) Detach(c *Client) {
s := c.session
s.mu.Lock()
if _, ok := s.clients[c]; ok {
delete(s.clients, c)
if !c.dropped {
c.dropped = true
close(c.Ch)
}
}
s.mu.Unlock()
m.changed()
}
func (m *Manager) Write(id string, p []byte) error {
s, err := m.get(id)
if err != nil {
return err
}
s.mu.Lock()
running := s.running
s.mu.Unlock()
if !running {
return nil
}
_, err = s.ptmx.Write(p)
return err
}
func (m *Manager) Resize(id string, cols, rows uint16) error {
s, err := m.get(id)
if err != nil {
return err
}
if cols == 0 || rows == 0 {
return nil
}
return pty.Setsize(s.ptmx, &pty.Winsize{Cols: cols, Rows: rows})
}
// Close ends the shell (SIGHUP to its process group, SIGKILL after a grace
// period) and forgets the session.
func (m *Manager) Close(id string) error {
m.mu.Lock()
s, ok := m.sessions[id]
if ok {
delete(m.sessions, id)
}
m.mu.Unlock()
if !ok {
return ErrNotFound
}
s.mu.Lock()
running := s.running
for c := range s.clients {
if !c.dropped {
c.dropped = true
close(c.Ch)
}
delete(s.clients, c)
}
s.mu.Unlock()
if running && s.cmd.Process != nil {
pid := s.cmd.Process.Pid
_ = syscall.Kill(-pid, syscall.SIGHUP)
go func() {
time.Sleep(killGrace)
_ = syscall.Kill(-pid, syscall.SIGKILL)
}()
}
m.changed()
return nil
}
func (m *Manager) changed() {
if m.OnChange != nil {
m.OnChange(m.List())
}
}
// Shutdown hangs up every shell. Called on daemon exit.
func (m *Manager) Shutdown() {
for _, s := range m.List() {
_ = m.Close(s.ID)
}
}
+37
View File
@@ -0,0 +1,37 @@
package term
// Ring is a fixed-size byte ring buffer holding the most recent output.
type Ring struct {
buf []byte
start int
n int
}
func NewRing(size int) *Ring { return &Ring{buf: make([]byte, size)} }
func (r *Ring) Write(p []byte) {
size := len(r.buf)
if len(p) >= size {
copy(r.buf, p[len(p)-size:])
r.start, r.n = 0, size
return
}
end := (r.start + r.n) % size
first := copy(r.buf[end:], p)
copy(r.buf, p[first:])
if r.n+len(p) > size {
r.start = (end + len(p)) % size
r.n = size
} else {
r.n += len(p)
}
}
func (r *Ring) Bytes() []byte {
out := make([]byte, r.n)
first := copy(out, r.buf[r.start:])
if first < r.n {
copy(out[first:], r.buf[:r.n-first])
}
return out
}
+18
View File
@@ -0,0 +1,18 @@
// Package web embeds the built frontend (web/dist, produced by `make web`).
package web
import (
"embed"
"io/fs"
)
//go:embed all:dist
var dist embed.FS
func FS() fs.FS {
sub, err := fs.Sub(dist, "dist")
if err != nil {
panic(err)
}
return sub
}
+20
View File
@@ -0,0 +1,20 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content" />
<meta name="theme-color" content="#111418" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Hamlet" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon-192.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Hamlet</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1777
View File
File diff suppressed because it is too large Load Diff
+25
View File
@@ -0,0 +1,25 @@
{
"name": "hamlet-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"icons": "node scripts/icons.mjs"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"typescript": "^5.6.3",
"vite": "^5.4.11"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"name": "Hamlet",
"short_name": "Hamlet",
"description": "Linux management console",
"start_url": "/",
"scope": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#111418",
"theme_color": "#111418",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
{ "src": "/icons/icon-512-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
]
}
+17
View File
@@ -0,0 +1,17 @@
// Hamlet service worker: cache hashed assets, never the API, network-first for the shell.
const CACHE = 'hamlet-v1';
self.addEventListener('install', () => self.skipWaiting());
self.addEventListener('activate', (e) => {
e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== CACHE).map((k) => caches.delete(k)))).then(() => self.clients.claim()));
});
self.addEventListener('fetch', (e) => {
const url = new URL(e.request.url);
if (e.request.method !== 'GET' || url.origin !== location.origin || url.pathname.startsWith('/api/')) return;
if (url.pathname.startsWith('/assets/') || url.pathname.startsWith('/icons/')) {
e.respondWith(caches.open(CACHE).then(async (c) => (await c.match(e.request)) || fetch(e.request).then((r) => { if (r.ok) c.put(e.request, r.clone()); return r; })));
return;
}
// navigations + everything else: network first, fall back to cached index
e.respondWith(fetch(e.request).then((r) => { if (r.ok && e.request.mode === 'navigate') caches.open(CACHE).then((c) => c.put('/', r.clone())); return r; })
.catch(() => caches.match(e.request.mode === 'navigate' ? '/' : e.request)));
});
+26
View File
@@ -0,0 +1,26 @@
// Renders the PWA icons with node-canvas (borrowed from ../../calorie-counter/node_modules).
import { createRequire } from 'node:module'
import { writeFileSync } from 'node:fs'
const require = createRequire(import.meta.url)
const { createCanvas } = require('/home/core/projects/calorie-counter/node_modules/canvas')
function draw(size, { pad = 0, radius = 0 }) {
const c = createCanvas(size, size), g = c.getContext('2d')
g.fillStyle = '#111418'
if (radius) { g.beginPath(); g.roundRect(0, 0, size, size, radius); g.fill() } else g.fillRect(0, 0, size, size)
const inner = size - pad * 2
g.fillStyle = '#e3a33b'
g.font = `bold ${inner * 0.62}px "DejaVu Serif", serif`
g.textAlign = 'center'; g.textBaseline = 'middle'
g.fillText('H', size / 2, size / 2 + inner * 0.03)
// prompt caret
g.fillStyle = '#58a6ff'
g.font = `bold ${inner * 0.22}px "DejaVu Sans Mono", monospace`
g.fillText('>_', size / 2 + inner * 0.27, size / 2 + inner * 0.27)
return c.toBuffer('image/png')
}
writeFileSync('public/icons/icon-192.png', draw(192, {}))
writeFileSync('public/icons/icon-512.png', draw(512, {}))
writeFileSync('public/icons/icon-512-maskable.png', draw(512, { pad: 80 }))
writeFileSync('public/icons/apple-touch-icon.png', draw(180, {}))
console.log('icons written')
+23
View File
@@ -0,0 +1,23 @@
import { useCallback, useEffect, useState } from 'react'
import { api, Me, Unauthenticated } from './api'
import Login from './Login'
import Shell from './Shell'
export default function App() {
const [me, setMe] = useState<Me | null | undefined>(undefined)
const refresh = useCallback(async () => {
try { setMe(await api.me()) } catch (e) { if (e instanceof Unauthenticated) setMe(null) }
}, [])
useEffect(() => { refresh() }, [refresh])
useEffect(() => {
if (!me) return
const t = setInterval(refresh, 30000)
return () => clearInterval(t)
}, [me, refresh])
if (me === undefined) return <div className="center muted">…</div>
if (me === null) return <Login onLogin={refresh} />
return <Shell me={me} onLoggedOut={() => setMe(null)} />
}
+18
View File
@@ -0,0 +1,18 @@
import { useState } from 'react'
const KEYS: [string, string][] = [
['Esc', '\x1b'], ['Tab', '\t'], ['↑', '\x1b[A'], ['↓', '\x1b[B'], ['←', '\x1b[D'], ['→', '\x1b[C'],
['Home', '\x1b[H'], ['End', '\x1b[F'], ['|', '|'], ['~', '~'], ['-', '-'], ['/', '/'], ['^C', '\x03'], ['^D', '\x04'],
]
export default function KeyBar({ send, setCtrl }: { send: (s: string) => void; setCtrl: (v: boolean) => void }) {
const [ctrl, setCtrlState] = useState(false)
const toggle = () => { const v = !ctrl; setCtrlState(v); setCtrl(v) }
const press = (s: string) => { send(s); if (ctrl) { setCtrlState(false); setCtrl(false) } }
return (
<div className="keybar" onMouseDown={(e) => e.preventDefault()}>
<button className={ctrl ? 'on' : ''} onClick={toggle}>Ctrl</button>
{KEYS.map(([label, seq]) => <button key={label} onClick={() => press(seq)}>{label}</button>)}
</div>
)
}
+28
View File
@@ -0,0 +1,28 @@
import { FormEvent, useState } from 'react'
import { api } from './api'
export default function Login({ onLogin }: { onLogin: () => void }) {
const [pw, setPw] = useState('')
const [err, setErr] = useState('')
const [busy, setBusy] = useState(false)
async function submit(e: FormEvent) {
e.preventDefault()
setBusy(true); setErr('')
try { await api.login(pw); onLogin() }
catch (e) { setErr(e instanceof Error ? e.message : 'login failed') }
finally { setBusy(false) }
}
return (
<div className="center">
<form className="login" onSubmit={submit}>
<h1>Hamlet</h1>
<input type="password" autoFocus autoComplete="current-password" placeholder="password"
value={pw} onChange={(e) => setPw(e.target.value)} />
<button disabled={busy || !pw}>{busy ? '…' : 'Sign in'}</button>
{err && <div className="error">{err}</div>}
</form>
</div>
)
}
+49
View File
@@ -0,0 +1,49 @@
import { useState } from 'react'
import { Term } from './api'
export interface Group { id: string; label: string; icon: string; soon?: boolean }
interface Props {
groups: Group[]; terms: Term[]; active: string | null
onSelect: (id: string) => void; onNew: () => void
onRename: (t: Term) => void; onClose: (t: Term) => void
}
export default function Rail({ groups, terms, active, onSelect, onNew, onRename, onClose }: Props) {
const [open, setOpen] = useState<string>('terminal')
return (
<nav className="rail">
{groups.map((g) => {
const expanded = open === g.id
return (
<div key={g.id} className={'group' + (expanded ? ' open' : '') + (g.soon ? ' soon' : '')}>
<button className="group-head" onClick={() => !g.soon && setOpen(expanded ? '' : g.id)} disabled={g.soon}>
<span className="chev">{expanded ? '▾' : '▸'}</span>
<span className="gicon">{g.icon}</span>
<span>{g.label}</span>
{g.soon && <span className="badge">soon</span>}
</button>
{expanded && g.id === 'terminal' && (
<div className="leaves">
{terms.map((t) => (
<div key={t.id} className={'leaf' + (t.id === active ? ' active' : '') + (t.running ? '' : ' exited')}
onClick={() => onSelect(t.id)}>
<span className={'sdot' + (t.running ? ' run' : '')} />
<span className="lname" title={t.name}>{t.name}</span>
{t.id === active && (
<span className="leaf-actions">
<button className="icon-btn" title="rename" onClick={(e) => { e.stopPropagation(); onRename(t) }}>✎</button>
<button className="icon-btn" title="close" onClick={(e) => { e.stopPropagation(); onClose(t) }}>✕</button>
</span>
)}
</div>
))}
<button className="leaf new" onClick={onNew}>+ New Terminal</button>
</div>
)}
</div>
)
})}
</nav>
)
}
+100
View File
@@ -0,0 +1,100 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { api, Me, Term } from './api'
import Rail, { Group } from './Rail'
import TerminalPane, { TermHandle } from './TerminalPane'
import KeyBar from './KeyBar'
const GROUPS: Group[] = [
{ id: 'terminal', label: 'Terminal', icon: '>_' },
{ id: 'services', label: 'Services', icon: '⚙', soon: true },
{ id: 'cron', label: 'Cron', icon: '⏱', soon: true },
{ id: 'zfs', label: 'ZFS', icon: '▤', soon: true },
{ id: 'dashboard', label: 'Dashboard', icon: '◔', soon: true },
]
function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m}m` : `${m}m`
}
export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () => void }) {
const [terms, setTerms] = useState<Term[]>([])
const [active, setActive] = useState<string | null>(null)
const [drawer, setDrawer] = useState(false)
const [online, setOnline] = useState(true)
const handles = useRef<Record<string, TermHandle>>({})
const reload = useCallback(async () => {
const list = await api.terminals()
setTerms(list)
setActive((a) => (a && list.some((t) => t.id === a) ? a : list[0]?.id ?? null))
}, [])
useEffect(() => { reload() }, [reload])
async function create() {
const t = await api.createTerminal()
await reload()
setActive(t.id)
setDrawer(false)
}
async function rename(t: Term) {
const name = window.prompt('Rename terminal', t.name)
if (name && name.trim() && name !== t.name) { await api.renameTerminal(t.id, name.trim()); await reload() }
}
async function close(t: Term) {
if (t.running && !window.confirm(`Close "${t.name}"? The shell will be hung up.`)) return
await api.deleteTerminal(t.id)
delete handles.current[t.id]
await reload()
}
async function logout() { await api.logout(); onLoggedOut() }
const activeTerm = terms.find((t) => t.id === active)
return (
<div className={'app' + (drawer ? ' drawer-open' : '')}>
<header className="header">
<button className="icon-btn menu" onClick={() => setDrawer(!drawer)} aria-label="menu">☰</button>
<span className="brand">Hamlet</span>
<span className="host">{me.user}@{me.host}</span>
<span className="spacer" />
<span className="stat" title="load average">{me.load}</span>
<span className={'dot ' + (online ? 'ok' : 'bad')} title={online ? 'connected' : 'disconnected'} />
<button className="link" onClick={logout}>logout</button>
</header>
<div className="body">
<Rail groups={GROUPS} terms={terms} active={active}
onSelect={(id) => { setActive(id); setDrawer(false) }}
onNew={create} onRename={rename} onClose={close} />
<div className="backdrop" onClick={() => setDrawer(false)} />
<main className="main">
{terms.length === 0 && (
<div className="center muted">
<div>No terminals.</div>
<button className="primary" onClick={create}>+ New Terminal</button>
</div>
)}
{terms.map((t) => (
<TerminalPane key={t.id} id={t.id} active={t.id === active}
register={(h) => { if (h) handles.current[t.id] = h; else delete handles.current[t.id] }}
onStatus={(ok) => { if (t.id === active) setOnline(ok) }}
onExit={reload} />
))}
</main>
</div>
{activeTerm && <KeyBar send={(s) => handles.current[activeTerm.id]?.input(s)}
setCtrl={(v) => handles.current[activeTerm.id]?.armCtrl(v)} />}
<footer className="footer">
<span>v{me.version}</span>
<span>up {fmtUptime(me.uptime)}</span>
<span className="spacer" />
<span className="muted">{activeTerm ? activeTerm.name : ''}</span>
</footer>
</div>
)
}
+128
View File
@@ -0,0 +1,128 @@
import { useEffect, useRef } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { WebLinksAddon } from '@xterm/addon-web-links'
import { wsURL } from './api'
export interface TermHandle { input: (s: string) => void; armCtrl: (v: boolean) => void }
interface Props {
id: string; active: boolean
register: (h: TermHandle | null) => void
onStatus: (ok: boolean) => void
onExit: () => void
}
const THEME = {
background: '#0d1117', foreground: '#d0d7de', cursor: '#d0d7de', selectionBackground: '#264f78',
black: '#0d1117', red: '#ff7b72', green: '#3fb950', yellow: '#d29922', blue: '#58a6ff',
magenta: '#bc8cff', cyan: '#39c5cf', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198',
brightGreen: '#56d364', brightYellow: '#e3b341', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff',
brightCyan: '#56d4dd', brightWhite: '#f0f6fc',
}
export default function TerminalPane({ id, active, register, onStatus, onExit }: Props) {
const box = useRef<HTMLDivElement>(null)
const activeRef = useRef(active)
activeRef.current = active
const cbs = useRef({ onStatus, onExit })
cbs.current = { onStatus, onExit }
const fitRef = useRef<() => void>(() => {})
useEffect(() => {
const el = box.current!
const term = new Terminal({
theme: THEME, fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace',
fontSize: window.matchMedia('(pointer: coarse)').matches ? 13 : 14,
cursorBlink: true, scrollback: 5000, allowProposedApi: true,
})
const fit = new FitAddon()
term.loadAddon(fit)
term.loadAddon(new WebLinksAddon())
term.open(el)
let ws: WebSocket | null = null
let closed = false
let ctrlArmed = false
let retry = 0
let retryTimer: number | undefined
let lastSize = ''
const enc = new TextEncoder()
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) }
const sendResize = () => {
const key = `${term.cols}x${term.rows}`
if (key === lastSize) return
lastSize = key
if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
const doFit = () => {
if (!activeRef.current || el.clientWidth === 0 || el.clientHeight === 0) return
fit.fit()
sendResize()
}
const connect = () => {
if (closed) return
const sock = new WebSocket(wsURL(id))
sock.binaryType = 'arraybuffer'
ws = sock
sock.onopen = () => { retry = 0; cbs.current.onStatus(true); lastSize = ''; doFit() }
sock.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
try {
const m = JSON.parse(ev.data)
if (m.type === 'exit') { term.write('\r\n\x1b[90m[process exited' + (m.code !== undefined ? ` (${m.code})` : '') + ']\x1b[0m\r\n'); closed = true; cbs.current.onExit() }
if (m.type === 'closed') closed = true
} catch { /* ignore */ }
}
sock.onclose = () => {
if (ws !== sock) return
cbs.current.onStatus(false)
if (closed) return
retryTimer = window.setTimeout(connect, Math.min(1000 * 2 ** retry++, 15000))
}
sock.onerror = () => sock.close()
}
connect()
term.onData((d) => {
if (ctrlArmed && d.length === 1) {
ctrlArmed = false
const c = d.toUpperCase().charCodeAt(0)
if (c >= 64 && c <= 95) { send(String.fromCharCode(c & 0x1f)); return }
}
send(d)
})
term.onBinary((d) => send(d))
term.onResize(sendResize)
fitRef.current = () => { doFit(); term.focus() }
const ro = new ResizeObserver(() => doFit())
ro.observe(el)
const onVis = () => { if (document.visibilityState === 'visible' && ws && ws.readyState !== WebSocket.OPEN && !closed) { clearTimeout(retryTimer); connect() } }
document.addEventListener('visibilitychange', onVis)
register({ input: send, armCtrl: (v) => { ctrlArmed = v; term.focus() } })
return () => {
register(null)
closed = true
clearTimeout(retryTimer)
document.removeEventListener('visibilitychange', onVis)
ro.disconnect()
ws?.close()
term.dispose()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
// Refit + focus whenever this pane becomes the visible one (after the display change applies).
useEffect(() => {
if (!active) return
const t = window.setTimeout(() => fitRef.current(), 0)
return () => clearTimeout(t)
}, [active])
return <div ref={box} className="term" style={{ display: active ? 'block' : 'none' }} />
}
+36
View File
@@ -0,0 +1,36 @@
export class Unauthenticated extends Error {}
async function req<T>(method: string, path: string, body?: unknown): Promise<T> {
const r = await fetch(path, {
method,
credentials: 'same-origin',
headers: body !== undefined ? { 'Content-Type': 'application/json' } : undefined,
body: body !== undefined ? JSON.stringify(body) : undefined,
})
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) {
let msg = r.statusText
try { msg = (await r.json()).error ?? msg } catch { /* ignore */ }
throw new Error(msg)
}
if (r.status === 204) return undefined as T
return r.json()
}
export interface Me { host: string; user: string; version: string; load: string; uptime: number }
export interface Term { id: string; name: string; running: boolean; exit_code: number; created: string; clients: number }
export const api = {
login: (password: string) => req<void>('POST', '/api/login', { password }),
logout: () => req<void>('POST', '/api/logout'),
me: () => req<Me>('GET', '/api/me'),
terminals: () => req<Term[]>('GET', '/api/terminals'),
createTerminal: (name?: string) => req<{ id: string; name: string }>('POST', '/api/terminals', { name }),
renameTerminal: (id: string, name: string) => req<void>('PATCH', `/api/terminals/${id}`, { name }),
deleteTerminal: (id: string) => req<void>('DELETE', `/api/terminals/${id}`),
}
export function wsURL(id: string): string {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
return `${proto}//${location.host}/api/terminals/${id}/attach`
}
+15
View File
@@ -0,0 +1,15 @@
import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import '@xterm/xterm/css/xterm.css'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
if ('serviceWorker' in navigator && location.protocol === 'https:') {
window.addEventListener('load', () => { navigator.serviceWorker.register('/sw.js').catch(() => {}) })
}
+78
View File
@@ -0,0 +1,78 @@
:root {
--bg: #111418; --bg2: #171b21; --bg3: #1e242c; --border: #2a313b;
--fg: #d0d7de; --muted: #7d8590; --accent: #e3a33b; --accent2: #58a6ff;
--ok: #3fb950; --bad: #ff7b72;
--header-h: 44px; --footer-h: 28px; --rail-w: 220px;
}
* { box-sizing: border-box; }
html, body, #root { height: 100%; margin: 0; }
body { background: var(--bg); color: var(--fg); font: 14px/1.4 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; overscroll-behavior: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
.spacer { flex: 1; }
.muted { color: var(--muted); }
.center { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; }
/* login */
.login { display: flex; flex-direction: column; gap: 10px; width: min(320px, 90vw); }
.login h1 { margin: 0 0 8px; font-weight: 600; color: var(--accent); letter-spacing: .5px; }
.login input { padding: 10px 12px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
.login button, .primary { padding: 10px 12px; background: var(--accent); color: #111; border-radius: 6px; font-weight: 600; }
.login button:disabled { opacity: .5; }
.error { color: var(--bad); font-size: 13px; }
/* layout */
.app { height: 100%; display: flex; flex-direction: column; padding-top: env(safe-area-inset-top); }
.header { height: var(--header-h); display: flex; align-items: center; gap: 12px; padding: 0 12px; background: var(--bg2); border-bottom: 1px solid var(--border); }
.brand { font-weight: 700; color: var(--accent); letter-spacing: .5px; }
.host { color: var(--muted); font-family: ui-monospace, monospace; font-size: 13px; }
.stat { font-family: ui-monospace, monospace; font-size: 12px; color: var(--muted); }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--muted); }
.dot.ok { background: var(--ok); } .dot.bad { background: var(--bad); }
.link { color: var(--muted); } .link:hover { color: var(--fg); }
.icon-btn { padding: 2px 6px; color: var(--muted); border-radius: 4px; } .icon-btn:hover { color: var(--fg); background: var(--bg3); }
.menu { display: none; font-size: 18px; }
.body { flex: 1; display: flex; min-height: 0; position: relative; }
.footer { height: var(--footer-h); display: flex; align-items: center; gap: 16px; padding: 0 12px; background: var(--bg2); border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); padding-bottom: env(safe-area-inset-bottom); box-sizing: content-box; }
/* rail */
.rail { width: var(--rail-w); background: var(--bg2); border-right: 1px solid var(--border); overflow-y: auto; padding: 6px 0; flex-shrink: 0; }
.group-head { width: 100%; display: flex; align-items: center; gap: 8px; padding: 8px 12px; text-align: left; }
.group-head:hover { background: var(--bg3); }
.group.soon .group-head { color: var(--muted); cursor: default; }
.group.soon .group-head:hover { background: none; }
.chev { width: 10px; color: var(--muted); font-size: 11px; }
.gicon { width: 18px; text-align: center; color: var(--accent2); font-family: ui-monospace, monospace; font-size: 12px; }
.badge { margin-left: auto; font-size: 10px; text-transform: uppercase; border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; }
.leaves { padding: 2px 0 6px; }
.leaf { display: flex; align-items: center; gap: 8px; padding: 7px 12px 7px 30px; cursor: pointer; width: 100%; text-align: left; }
.leaf:hover { background: var(--bg3); }
.leaf.active { background: var(--bg3); box-shadow: inset 3px 0 0 var(--accent); }
.leaf.exited .lname { color: var(--muted); text-decoration: line-through; }
.leaf.new { color: var(--accent2); }
.lname { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.leaf-actions { display: flex; gap: 2px; }
.sdot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); flex-shrink: 0; }
.sdot.run { background: var(--ok); }
/* main + terminal */
.main { flex: 1; min-width: 0; position: relative; background: #0d1117; }
.term { position: absolute; inset: 0; padding: 6px 0 0 6px; }
.term .xterm { height: 100%; }
.xterm-viewport::-webkit-scrollbar { width: 8px; } .xterm-viewport::-webkit-scrollbar-thumb { background: var(--border); }
/* key bar (touch devices / narrow screens) */
.keybar { display: none; gap: 4px; padding: 4px 6px; background: var(--bg2); border-top: 1px solid var(--border); overflow-x: auto; white-space: nowrap; -webkit-overflow-scrolling: touch; }
.keybar button { flex: 0 0 auto; padding: 8px 10px; min-width: 40px; background: var(--bg3); border: 1px solid var(--border); border-radius: 5px; font-family: ui-monospace, monospace; font-size: 13px; }
.keybar button.on { background: var(--accent); color: #111; border-color: var(--accent); }
@media (pointer: coarse), (max-width: 800px) { .keybar { display: flex; } }
/* mobile: rail becomes a drawer */
.backdrop { display: none; }
@media (max-width: 800px) {
.menu { display: inline-block; }
.host, .stat { display: none; }
.rail { position: absolute; top: 0; bottom: 0; left: 0; z-index: 10; transform: translateX(-100%); transition: transform .15s ease; box-shadow: 4px 0 16px rgba(0,0,0,.5); }
.drawer-open .rail { transform: none; }
.drawer-open .backdrop { display: block; position: absolute; inset: 0; z-index: 9; background: rgba(0,0,0,.5); }
.footer { font-size: 11px; }
}
+15
View File
@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"resolveJsonModule": true
},
"include": ["src"]
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: { outDir: '../internal/web/dist', emptyOutDir: true },
server: {
proxy: { '/api': { target: 'http://127.0.0.1:8100', ws: true } },
},
})