Five things the tab could not do, all on the container detail. Stats draws CPU and memory over time from the container sensors the sampler already keeps, so there is nothing new to collect. Health shows the check's verdict, the failing streak and what the last five probes actually printed, and says plainly when an image defines no health check at all rather than leaving a blank panel. Events asks Docker what has happened to the container over the last day, week or month — started, died, killed, health going bad, with exit codes — rather than keeping a copy that would only go stale. Shell opens a session inside the container as an ordinary terminal, so it reattaches, resizes and scrolls back like the rest and works on a managed machine through the same proxy. The terminal manager grew a CreateCmd for it; every session is the same thing afterwards. Limits changes memory, CPUs and restart policy on the running container with docker update, live and without recreating it. That is also the catch, and the dialog says so: it does not edit the compose file, so a deploy puts back whatever the file says. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
312 lines
6.6 KiB
Go
312 lines
6.6 KiB
Go
// 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"
|
|
"sync/atomic"
|
|
"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
|
|
closing atomic.Bool
|
|
}
|
|
|
|
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) {
|
|
return m.CreateCmd(id, name, nil)
|
|
}
|
|
|
|
// CreateCmd is Create with something other than a login shell: argv nil is the shell, and
|
|
// anything else runs that instead — `docker exec -it …` for a container's console. Every
|
|
// session is the same thing afterwards, so attach, resize, the scrollback ring and the tab
|
|
// strip work without knowing which it is.
|
|
func (m *Manager) CreateCmd(id, name string, argv []string) (*Session, error) {
|
|
if id == "" {
|
|
id = newID()
|
|
}
|
|
cmd := exec.Command(m.shell, "-l")
|
|
if len(argv) > 0 {
|
|
cmd = exec.Command(argv[0], argv[1:]...)
|
|
}
|
|
cmd.Dir = m.home
|
|
cmd.Env = append(os.Environ(), "TERM=xterm-256color", "COLORTERM=truecolor", "HAMLAB=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()
|
|
// An exited shell stays listed for a while so the exit is visible, then reaps itself.
|
|
time.AfterFunc(10*time.Minute, func() {
|
|
m.mu.Lock()
|
|
cur, ok := m.sessions[s.ID]
|
|
m.mu.Unlock()
|
|
if ok && cur == s && !s.running {
|
|
_ = m.Close(s.ID)
|
|
}
|
|
})
|
|
}
|
|
|
|
// 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.closing.Load() {
|
|
m.OnChange(m.List())
|
|
}
|
|
}
|
|
|
|
// Shutdown hangs up every shell. Called on daemon exit. The saved list is left as it
|
|
// was, because that list is what the next start re-opens.
|
|
func (m *Manager) Shutdown() {
|
|
m.closing.Store(true)
|
|
for _, s := range m.List() {
|
|
_ = m.Close(s.ID)
|
|
}
|
|
}
|