Idle sessions sign themselves out; a KVM's login is remembered; the compose editor knows YAML

A session unused for seven days is dropped, the Sessions list marks a day's idleness and
can sign all of those out at once. The hub keeps a KVM's own cookies from any browser that
has them and lends them to one that hasn't, on the wire and back to the page. The stack
editor and the new-stack dialog use CodeEditor: line numbers, YAML and .env colour under a
real textarea, Tab/Shift+Tab/Enter indentation, and a key bar on touch screens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-10-06 01:20:27 -07:00
co-authored by Claude Fable 5.1
parent faaa6aaf9d
commit f3ca3d8f73
13 changed files with 531 additions and 20 deletions
+20 -8
View File
@@ -707,10 +707,11 @@ before its own login).
1. **Treemap blank space.** Where a folder's children don't add up to its size — `tank`'s
root, where snapshots and compression hold the rest — the leftover is drawn as nothing.
An "unaccounted" tile would say what it is.
2. **Sessions pile up.** Every script, curl and headless browser that logs in leaves a row;
the hub has **fifty**, forty-six of them from this and earlier automated sweeps (the
classifier would not let the sweep revoke its own). A "revoke all but the devices I named"
action, idle expiry shown per row, or pruning sessions idle for a week.
2. ✅ **Sessions pile up** — built 2026-10-06. Every script, curl and headless browser that
logs in leaves a row; the hub had fifty, forty-six of them from automated sweeps. Now a
session unused for seven days signs itself out (`auth.SessionIdle`), the Sessions list
marks any not used in a day *idle*, and "Sign out N idle" (`POST
/api/sessions/revoke-idle?hours=24`) drops those while leaving every device used since.
3. **The Mac's GPU box takes half the Processes page** to say Asahi exposes no counters; a
GPU with nothing measurable could be one line.
4. **A host without systemd** still gets Services chips reading "All 0" and a "systemd timers
@@ -723,12 +724,23 @@ before its own login).
four-column props grid.
7. **Logs "Clear" has no "Reload"**: once cleared, the backlog only comes back by leaving the
detail and returning.
8. **NanoKVM asks for its own login every time** inside the frame. Ham Lab could remember the
device's cookie per browser, or the hub could hold its credentials — a trade worth stating.
8. ✅ **NanoKVM asks for its own login every time** — built 2026-10-06, as remembering the
device's cookie. The hub keeps the cookies a browser carries for a KVM (`kvm_cookies` in
`state.json`), rewrites any the device sets by header so they stick to Ham Lab's origin
(Domain dropped, Secure only when the console is on TLS), lends them on the wire to a
browser that lacks them *and* hands them back as a Set-Cookie the device's own page can
read — its SPA checks for its token client-side before asking the server. A logout from the
device, or removing the KVM, forgets them. Covered by `kvm_test.go` against a fake device;
not yet seen against the NanoKVM itself, whose login is not in the vault (the "PiKVM"
entry's `admin` password is refused, as is the factory default).
9. **Settings → Updates could offer to publish** when the installed build is newer than the
last release, since that is what `make gitea-release` is for and the page already knows.
10. **The compose editor is a plain textarea**: no line numbers, no YAML highlighting, and a
phone keyboard's autocorrect is off but its indentation is a fight.
10. ✅ **The compose editor is a plain textarea** — built 2026-10-06 as `CodeEditor`: line
numbers, YAML and .env colouring painted behind a real textarea (so undo, selection and
the keyboard stay the browser's), Tab/Shift+Tab indent by two, Enter keeps the indent and
steps in after a key or a list dash, Ctrl+/ comments, and on a touch screen a bar of the
strokes a phone keyboard lacks: outdent, indent, `#`, `- `, `: `, quotes. Used by the stack
editor (compose and .env) and the new-stack dialog.
11. **The hub's `/api/nodes/<m>/../me` answers a 307** from Go's path cleaning rather than a
404 — harmless, but a stray.
12. **Guest logins can open Users and Settings** — by design ("a guest can do everything you
+124
View File
@@ -31,6 +31,7 @@ import (
"time"
"hamlab/internal/config"
"hamlab/internal/store"
)
const kvmPrefix = "/kvm/"
@@ -82,9 +83,25 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
}
r.URL.RawPath = ""
r.Host = target.Host
// Whether the console itself is on TLS: the response rewriter needs to know, and by
// then only the outbound request is in hand.
if secure(r) {
r.Header.Set(kvmSecureHeader, "1")
} else {
r.Header.Del(kvmSecureHeader)
}
// The device gets its own host and none of Ham Lab's session.
r.Header.Del("Origin")
stripCookie(r, cookieName, kvmCookie)
// What is left is the device's own login. A browser that has it teaches the hub; one
// that has not is lent what the hub remembers, so the device sees a signed-in client.
if own := r.Cookies(); len(own) > 0 {
s.rememberKVMCookies(k.ID, own)
} else {
for name, val := range s.kvmCookies(k.ID) {
r.AddCookie(&http.Cookie{Name: name, Value: val})
}
}
},
FlushInterval: -1, // a video stream must not be buffered
ModifyResponse: func(resp *http.Response) error {
@@ -94,6 +111,24 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
for _, h := range []string{"X-Frame-Options", "Content-Security-Policy", "Content-Security-Policy-Report-Only", "Strict-Transport-Security", "Referrer-Policy"} {
resp.Header.Del(h)
}
// A cookie the device sets by header is kept for Ham Lab's origin — so its Domain
// (the device's) goes, Secure follows the console rather than the device, and the
// hub remembers it too.
if set := resp.Cookies(); len(set) > 0 {
https := resp.Request != nil && resp.Request.Header.Get(kvmSecureHeader) == "1"
resp.Header.Del("Set-Cookie")
for _, c := range set {
c.Domain = ""
if c.Path == "" {
c.Path = "/"
}
c.Secure = c.Secure && https
if v := c.String(); v != "" {
resp.Header.Add("Set-Cookie", v)
}
}
s.rememberKVMCookies(k.ID, set)
}
// A redirect to the device's own root has to come back through the prefix.
if loc := resp.Header.Get("Location"); loc != "" {
if u, err := url.Parse(loc); err == nil && (u.Host == "" || u.Host == target.Host) && strings.HasPrefix(u.Path, "/") && !strings.HasPrefix(u.Path, prefix) {
@@ -114,6 +149,93 @@ func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
return p, nil
}
// kvmSecureHeader rides on the outbound request to say whether the console was reached
// over TLS; the device ignores it.
const kvmSecureHeader = "X-Hamlab-Secure"
// kvmCookies is what the hub remembers of a device's own cookies — its login, in practice.
func (s *Server) kvmCookies(id string) map[string]string {
var out map[string]string
s.store.View(func(st *store.State) {
if m := st.KVMCookies[id]; len(m) > 0 {
out = make(map[string]string, len(m))
for k, v := range m {
out[k] = v
}
}
})
return out
}
// rememberKVMCookies keeps the cookies a browser (or the device's own Set-Cookie) carries
// for a KVM, so that a browser without them can be lent them. A cookie emptied or expired
// by the device is forgotten. Written only when something changed: this runs on every
// proxied request.
func (s *Server) rememberKVMCookies(id string, cs []*http.Cookie) {
have := s.kvmCookies(id)
changed := false
for _, c := range cs {
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
continue
}
gone := c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now()))
if gone {
if _, ok := have[c.Name]; ok {
changed = true
}
continue
}
if have[c.Name] != c.Value {
changed = true
}
}
if !changed {
return
}
_ = s.store.Update(func(st *store.State) {
if st.KVMCookies == nil {
st.KVMCookies = map[string]map[string]string{}
}
m := st.KVMCookies[id]
if m == nil {
m = map[string]string{}
}
for _, c := range cs {
if c.Name == "" || c.Name == cookieName || c.Name == kvmCookie {
continue
}
if c.Value == "" || c.MaxAge < 0 || (!c.Expires.IsZero() && c.Expires.Before(time.Now())) {
delete(m, c.Name)
} else {
m[c.Name] = c.Value
}
}
if len(m) == 0 {
delete(st.KVMCookies, id)
} else {
st.KVMCookies[id] = m
}
})
}
// forgetKVMCookies drops a device's remembered login — when the device is removed.
func (s *Server) forgetKVMCookies(id string) {
_ = s.store.Update(func(st *store.State) { delete(st.KVMCookies, id) })
}
// lendKVMCookies hands the browser the device cookies the hub remembers and it lacks, as
// the device's own page would have set them: readable by its scripts, for Ham Lab's whole
// origin, kept for half a year. The device's SPA checks for its token client-side before it
// ever asks the server, so lending it only on the wire would still land on the login form.
func (s *Server) lendKVMCookies(w http.ResponseWriter, r *http.Request, id string) {
for name, val := range s.kvmCookies(id) {
if _, err := r.Cookie(name); err == nil {
continue
}
http.SetCookie(w, &http.Cookie{Name: name, Value: val, Path: "/", MaxAge: 180 * 24 * 3600, Secure: secure(r), SameSite: http.SameSiteLaxMode})
}
}
// stripCookie removes named cookies from a request, so Ham Lab's session is never handed to
// a device and the device never sees which KVM the browser is looking at.
func stripCookie(r *http.Request, names ...string) {
@@ -157,6 +279,7 @@ func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
return
}
http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode})
s.lendKVMCookies(w, r, id)
p.ServeHTTP(w, r)
}
@@ -305,6 +428,7 @@ func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
return
}
s.forgetKVMProxy(id)
s.forgetKVMCookies(id)
log.Printf("kvm %s removed by %s", id, clientIP(r))
w.WriteHeader(204)
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"hamlab/internal/auth"
"hamlab/internal/config"
"hamlab/internal/store"
)
// A KVM's own login is a cookie its page sets in one browser. The hub remembers it from the
// requests that carry it, lends it to a browser that lacks it — both on the wire to the
// device and back to the browser, since the device's page checks for it client-side — and
// forgets it when the device says it is gone or the KVM is removed.
func TestKVMCookiesAreRememberedAndLent(t *testing.T) {
var seen []string // the Cookie header the fake device received, per request
device := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
seen = append(seen, r.Header.Get("Cookie"))
if r.URL.Path == "/api/auth/login" {
// a device that sets its token by header, with its own domain and Secure
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "tok-1", Domain: "10.0.0.217", Secure: true})
}
if r.URL.Path == "/api/auth/logout" {
http.SetCookie(w, &http.Cookie{Name: "nano-kvm-token", Value: "", MaxAge: -1})
}
w.WriteHeader(200)
}))
defer device.Close()
dir := t.TempDir()
cfg := config.Default(filepath.Join(dir, "config.json"))
if err := cfg.Update(func(c *config.Config) {
c.KVMs = []config.KVM{{ID: "abc123", Name: "test", URL: device.URL, Machine: ""}}
}); err != nil {
t.Fatal(err)
}
st, err := store.Open(filepath.Join(dir, "state.json"))
if err != nil {
t.Fatal(err)
}
s := &Server{cfg: cfg, store: st, sessions: auth.NewSessions(st)}
k, _ := s.kvm("abc123")
p, err := s.kvmProxy(k)
if err != nil {
t.Fatal(err)
}
ask := func(path string, cookies ...*http.Cookie) *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", path, nil)
r.SetPathValue("id", "abc123")
for _, c := range cookies {
r.AddCookie(c)
}
w := httptest.NewRecorder()
if strings.HasPrefix(path, kvmPrefix) {
s.serveKVM(w, r)
} else {
p.ServeHTTP(w, r)
}
return w
}
// 1. the device sets its token by header: it reaches the browser without the device's
// domain or Secure (plain-http console), and the hub remembers it
w := ask("/api/auth/login", &http.Cookie{Name: cookieName, Value: "hamlab-session"})
var set *http.Cookie
for _, c := range w.Result().Cookies() {
if c.Name == "nano-kvm-token" {
set = c
}
}
if set == nil || set.Domain != "" || set.Secure || set.Value != "tok-1" || set.Path != "/" {
t.Fatalf("rewritten Set-Cookie = %+v, want nano-kvm-token=tok-1 for / without Domain or Secure", set)
}
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-1" {
t.Fatalf("remembered = %v, want the token", got)
}
if strings.Contains(seen[0], "hamlab_session") {
t.Fatalf("the device was handed Ham Lab's session: %q", seen[0])
}
// 2. a browser that has the cookie teaches a newer value
ask("/api/vm/info", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"}, &http.Cookie{Name: cookieName, Value: "x"})
if got := s.kvmCookies("abc123"); got["nano-kvm-token"] != "tok-2" {
t.Fatalf("remembered = %v, want tok-2", got)
}
if seen[1] != "nano-kvm-token=tok-2" {
t.Fatalf("device saw %q, want the browser's own cookie only", seen[1])
}
// 3. a browser without it is lent it: on the wire, and back as a Set-Cookie it can read
w = ask(kvmPrefix+"abc123/", &http.Cookie{Name: cookieName, Value: "x"})
if seen[2] != "nano-kvm-token=tok-2" {
t.Fatalf("device saw %q from a browser with no cookie, want the remembered token", seen[2])
}
lent := false
for _, c := range w.Result().Cookies() {
if c.Name == "nano-kvm-token" && c.Value == "tok-2" && !c.HttpOnly && c.Path == "/" && c.MaxAge > 0 {
lent = true
}
}
if !lent {
t.Fatalf("browser was not lent the token: %v", w.Result().Cookies())
}
// 4. the device logging out forgets it
ask("/api/auth/logout", &http.Cookie{Name: "nano-kvm-token", Value: "tok-2"})
if got := s.kvmCookies("abc123"); got != nil {
t.Fatalf("after logout remembered = %v, want nothing", got)
}
// 5. removing the KVM clears whatever is left
s.rememberKVMCookies("abc123", []*http.Cookie{{Name: "nano-kvm-token", Value: "tok-3"}})
s.forgetKVMCookies("abc123")
if got := s.kvmCookies("abc123"); got != nil {
t.Fatalf("after remove remembered = %v, want nothing", got)
}
_ = url.Parse
}
+1
View File
@@ -320,6 +320,7 @@ func (s *Server) routes() *http.ServeMux {
m.HandleFunc("GET /api/sessions", s.auth(s.listSessions))
m.HandleFunc("DELETE /api/sessions/{id}", s.auth(s.revokeSession))
m.HandleFunc("POST /api/sessions/revoke-others", s.auth(s.revokeOtherSessions))
m.HandleFunc("POST /api/sessions/revoke-idle", s.auth(s.revokeIdleSessions))
m.HandleFunc("GET /api/prefs/ui", s.auth(s.getUIPrefs))
m.HandleFunc("PUT /api/prefs/ui", s.auth(s.putUIPrefs))
// Go runtime profiles, behind the login, for chasing memory/CPU (bug 14).
+24
View File
@@ -12,6 +12,7 @@ import (
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
@@ -264,6 +265,29 @@ func (s *Server) revokeSession(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204)
}
// revokeIdleSessions drops the caller's sessions that have not been used for ?hours (24 by
// default): the scripts and headless browsers that signed in once and never came back,
// without touching the phone that was used this morning.
func (s *Server) revokeIdleSessions(w http.ResponseWriter, r *http.Request) {
a := actorOf(r)
if a.User == "" {
jsonErr(w, 403, "only a signed-in user may do that")
return
}
hours := 24
if h := r.URL.Query().Get("hours"); h != "" {
n, err := strconv.Atoi(h)
if err != nil || n < 1 || n > 24*365 {
jsonErr(w, 400, "hours is a whole number from 1 to 8760")
return
}
hours = n
}
n := s.sessions.RevokeIdle(a.User, a.token, time.Duration(hours)*time.Hour)
log.Printf("%d idle session(s) of %q revoked (idle > %dh, by %s)", n, a.User, hours, clientIP(r))
jsonOK(w, 200, map[string]int{"revoked": n})
}
func (s *Server) revokeOtherSessions(w http.ResponseWriter, r *http.Request) {
a := actorOf(r)
if a.User == "" {
+42 -4
View File
@@ -28,6 +28,10 @@ const (
argonKeyLen = 32
SessionTTL = 30 * 24 * time.Hour
// SessionIdle is how long a session may go unused before it is dropped, whatever its
// expiry says. Every script, curl and headless browser that signs in leaves a session;
// a phone that is used keeps its own alive by being used.
SessionIdle = 7 * 24 * time.Hour
)
func HashPassword(pw string) (string, error) {
@@ -189,12 +193,26 @@ func NewSessions(st *store.Store) *Sessions {
func pruneExpired(state *store.State) {
now := time.Now()
for tok, ses := range state.Sessions {
if ses.Expires.Before(now) {
if !live(ses, now) {
delete(state.Sessions, tok)
}
}
}
// live is whether a session still counts: unexpired, and used within SessionIdle. A
// session from before visits were recorded is judged by its creation, or failing that by
// its expiry alone.
func live(ses store.Session, now time.Time) bool {
if !ses.Expires.After(now) {
return false
}
seen := ses.LastSeen
if seen.IsZero() {
seen = ses.Created
}
return seen.IsZero() || now.Sub(seen) <= SessionIdle
}
func randomHex(n int) (string, error) {
b := make([]byte, n)
if _, err := rand.Read(b); err != nil {
@@ -234,7 +252,7 @@ func (s *Sessions) Lookup(tok, ip string) (store.Session, bool) {
ok, touch := false, false
s.st.View(func(state *store.State) {
found, have := state.Sessions[tok]
if have && found.Expires.After(time.Now()) {
if have && live(found, time.Now()) {
ses, ok = found, true
touch = time.Until(found.Expires) < SessionTTL-time.Hour || time.Since(found.LastSeen) > time.Hour
}
@@ -264,7 +282,7 @@ func (s *Sessions) List(user string) []store.Session {
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if ses.User == user && ses.Expires.After(now) {
if ses.User == user && live(ses, now) {
out = append(out, ses)
}
}
@@ -279,7 +297,7 @@ func (s *Sessions) Count() map[string]int {
s.st.View(func(state *store.State) {
now := time.Now()
for _, ses := range state.Sessions {
if ses.Expires.After(now) {
if live(ses, now) {
n[ses.User]++
}
}
@@ -315,6 +333,26 @@ func (s *Sessions) RevokeOthers(user, keep string) int {
return n
}
// RevokeIdle ends every session of user not seen for longer than idle, except the one
// holding keep. Returns how many.
func (s *Sessions) RevokeIdle(user, keep string, idle time.Duration) int {
n := 0
now := time.Now()
_ = s.st.Update(func(state *store.State) {
for tok, ses := range state.Sessions {
seen := ses.LastSeen
if seen.IsZero() {
seen = ses.Created
}
if ses.User == user && tok != keep && now.Sub(seen) > idle {
delete(state.Sessions, tok)
n++
}
}
})
return n
}
// RevokeUser ends every session of user (the account is going away).
func (s *Sessions) RevokeUser(user string) { s.RevokeOthers(user, "") }
+7 -3
View File
@@ -68,9 +68,13 @@ type State struct {
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
// orchestrator that has since been removed can still be found on disk. See
// internal/api/stacks.go.
Mounts []RememberedMount `json:"mounts,omitempty"`
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
Mounts []RememberedMount `json:"mounts,omitempty"`
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
// the next browser — or this one after it has forgotten them — is signed into the device
// already. See internal/api/kvm.go.
KVMCookies map[string]map[string]string `json:"kvm_cookies,omitempty"`
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
}
// UserState returns the prefs of an account, making an empty one if needed.
+150
View File
@@ -0,0 +1,150 @@
// A textarea that knows what it is holding: line numbers in a gutter, YAML (or a .env)
// coloured underneath the text, and the keys a compose file needs — Tab indents by two,
// Shift+Tab outdents, Enter keeps the indentation of the line above (one step deeper after
// a key or a list dash), and a phone, whose keyboard has none of those, gets a small bar of
// them. The text itself stays in a real textarea, so selection, undo and the keyboard are
// the browser's own; the colour is a <pre> drawn behind it, kept in step line for line.
import { useEffect, useMemo, useRef, useState, ReactNode } from 'react'
export type Language = 'yaml' | 'env' | 'plain'
interface Props {
value: string; onChange: (v: string) => void; language?: Language; className?: string
readOnly?: boolean; placeholder?: string; spellCheck?: boolean
}
const INDENT = ' '
const coarse = () => typeof window !== 'undefined' && window.matchMedia('(pointer: coarse)').matches
// One line of YAML, split into spans by what each part is. Deliberately shallow: a compose
// file is keys, scalars, lists and comments; anything cleverer is a parser, which this is not.
function yamlLine(line: string): ReactNode[] {
const out: ReactNode[] = []
let i = 0
const push = (cls: string, text: string) => { if (text) out.push(cls ? <span key={out.length} className={cls}>{text}</span> : text) }
const m = /^(\s*)(-\s+)?/.exec(line)!
push('', m[1]); push('y-dash', m[2] ?? ''); i = m[0].length
const rest = line.slice(i)
if (rest.startsWith('#')) { push('y-comment', rest); return out }
// a key: up to the first colon that is followed by a space or the end
const key = /^("[^"]*"|'[^']*'|[^\s:#][^:#]*?)(:)(?=\s|$)/.exec(rest)
let tail = rest
if (key) { push('y-key', key[1]); push('y-colon', ':'); tail = rest.slice(key[0].length) }
// the value, with a trailing comment cut off
let value = tail, comment = ''
const c = / #.*$/.exec(tail) ?? (tail.startsWith('#') ? /#.*$/.exec(tail) : null)
if (c) { value = tail.slice(0, c.index); comment = tail.slice(c.index) }
const v = value.trim()
const lead = value.slice(0, value.length - value.trimStart().length)
push('', lead)
if (!v) { /* nothing */ }
else if (/^(["']).*\1$/.test(v)) push('y-string', v)
else if (/^(true|false|yes|no|on|off|null|~)$/i.test(v)) push('y-bool', v)
else if (/^[-+]?(\d[\d_]*(\.\d+)?|\.\d+)([eE][-+]?\d+)?$/.test(v) || /^0x[0-9a-fA-F]+$/.test(v)) push('y-number', v)
else if (/^[&*!]/.test(v)) push('y-anchor', v)
else if (/^[|>][-+]?\d*$/.test(v)) push('y-block', v)
else push(key ? 'y-value' : (m[2] ? 'y-value' : ''), v)
push('', value.slice(lead.length + v.length))
push('y-comment', comment)
return out
}
function envLine(line: string): ReactNode[] {
if (/^\s*#/.test(line)) return [<span key="c" className="y-comment">{line}</span>]
const m = /^(\s*(?:export\s+)?)([A-Za-z_][A-Za-z0-9_.-]*)(=)(.*)$/.exec(line)
if (!m) return [line]
return [m[1], <span key="k" className="y-key">{m[2]}</span>, <span key="e" className="y-colon">=</span>, <span key="v" className="y-string">{m[4]}</span>]
}
function highlight(text: string, lang: Language): ReactNode[] {
const lines = text.split('\n')
const f = lang === 'yaml' ? yamlLine : lang === 'env' ? envLine : (l: string) => [l] as ReactNode[]
return lines.map((l, i) => <div key={i} className="code-line">{f(l)}{l === '' ? '​' : ''}</div>)
}
// setText replaces [start, end) in a textarea with s, through the browser's own edit so Undo
// still works, and leaves the caret where asked.
function setText(ta: HTMLTextAreaElement, start: number, end: number, s: string, caret: number | [number, number]) {
ta.focus()
ta.setSelectionRange(start, end)
let done = false
try { done = document.execCommand('insertText', false, s) } catch { /* not every browser */ }
if (!done || ta.value.slice(start, start + s.length) !== s) { ta.setRangeText(s, start, end, 'end'); ta.dispatchEvent(new Event('input', { bubbles: true })) }
if (Array.isArray(caret)) ta.setSelectionRange(caret[0], caret[1]); else ta.setSelectionRange(caret, caret)
}
export default function CodeEditor({ value, onChange, language = 'yaml', className = '', readOnly, placeholder, spellCheck = false }: Props) {
const ta = useRef<HTMLTextAreaElement>(null)
const pre = useRef<HTMLPreElement>(null)
const gutter = useRef<HTMLDivElement>(null)
const [touch] = useState(coarse)
const lines = useMemo(() => value.split('\n').length, [value])
const painted = useMemo(() => highlight(value, language), [value, language])
// the colour and the numbers follow the text's own scrolling
const sync = () => { const t = ta.current; if (!t) return; if (pre.current) { pre.current.scrollTop = t.scrollTop; pre.current.scrollLeft = t.scrollLeft } if (gutter.current) gutter.current.scrollTop = t.scrollTop }
useEffect(sync, [value])
// lines touched by the selection, for indent and outdent
const lineRange = (t: HTMLTextAreaElement) => {
const s = t.value.lastIndexOf('\n', t.selectionStart - 1) + 1
let e = t.value.indexOf('\n', Math.max(t.selectionEnd - (t.selectionEnd > t.selectionStart && t.value[t.selectionEnd - 1] === '\n' ? 1 : 0), t.selectionStart))
if (e < 0) e = t.value.length
return [s, e] as const
}
const indent = (out: boolean) => {
const t = ta.current; if (!t || readOnly) return
const [s, e] = lineRange(t)
const block = t.value.slice(s, e)
const sel = t.selectionStart !== t.selectionEnd || block.includes('\n')
if (!sel && !out) { const at = t.selectionStart; setText(t, at, at, INDENT, at + INDENT.length); return }
const next = block.split('\n').map((l) => (out ? l.replace(/^ {1,2}/, '') : INDENT + l)).join('\n')
if (next === block) return
const d0 = out ? -(block.length - next.length) : INDENT.length
setText(t, s, e, next, sel ? [s, s + next.length] : [Math.max(s, t.selectionStart + d0), Math.max(s, t.selectionStart + d0)])
}
const comment = () => {
const t = ta.current; if (!t || readOnly) return
const [s, e] = lineRange(t)
const block = t.value.slice(s, e), ls = block.split('\n')
const all = ls.every((l) => /^\s*#/.test(l) || !l.trim())
const next = ls.map((l) => (!l.trim() ? l : all ? l.replace(/^(\s*)# ?/, '$1') : l.replace(/^(\s*)/, '$1# '))).join('\n')
setText(t, s, e, next, [s, s + next.length])
}
const newline = () => {
const t = ta.current; if (!t || readOnly) return
const at = t.selectionStart
const lineStart = t.value.lastIndexOf('\n', at - 1) + 1
const line = t.value.slice(lineStart, at)
let ind = /^\s*/.exec(line)![0]
// one step deeper after a key or a list item that opens a block
if (language === 'yaml' && /(:|^\s*-)\s*(#.*)?$/.test(line) && line.trim() && !/^\s*#/.test(line)) ind += INDENT
setText(t, at, t.selectionEnd, '\n' + ind, at + 1 + ind.length)
}
const onKey = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab' && !e.ctrlKey && !e.metaKey && !e.altKey) { e.preventDefault(); indent(e.shiftKey) }
else if (e.key === 'Enter' && !e.ctrlKey && !e.metaKey && !e.altKey && !e.shiftKey) { e.preventDefault(); newline() }
else if (e.key === '/' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); comment() }
}
return (
<div className={'code ' + className + (readOnly ? ' ro' : '')}>
<div className="code-gutter" ref={gutter} aria-hidden="true">{Array.from({ length: lines }, (_, i) => <div key={i}>{i + 1}</div>)}</div>
<div className="code-body">
<pre className="code-paint" ref={pre} aria-hidden="true">{painted}</pre>
<textarea ref={ta} className="code-text" value={value} onChange={(e) => onChange(e.target.value)} onScroll={sync} onKeyDown={onKey}
readOnly={readOnly} placeholder={placeholder} spellCheck={spellCheck} autoCapitalize="off" autoCorrect="off" autoComplete="off" wrap="off" />
</div>
{touch && !readOnly && (
<div className="code-keys" onMouseDown={(e) => e.preventDefault()}>
<button type="button" title="outdent" onClick={() => indent(true)}>⇤</button>
<button type="button" title="indent" onClick={() => indent(false)}>⇥</button>
<button type="button" title="comment" onClick={comment}>#</button>
<button type="button" title="list item" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '- ', t.selectionStart + 2) }}>-</button>
<button type="button" title="colon" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, ': ', t.selectionStart + 2) }}>:</button>
<button type="button" title="quotes" onClick={() => { const t = ta.current; if (t) setText(t, t.selectionStart, t.selectionEnd, '"' + t.value.slice(t.selectionStart, t.selectionEnd) + '"', t.selectionEnd + 1) }}>"</button>
</div>
)}
</div>
)
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { FormEvent, useCallback, useEffect, useMemo, useRef, useState } from 're
import { api, DockerContainer, DockerEvent, DockerHealth, DockerInfo, DockerLogLine, ImageCheck, MachineUpdates, Stack, StackDiff, UpdateJob, UpdateJobRow, wsURLPath } from './api'
import TaskOutput from './TaskOutput'
import StackNew, { DupFrom } from './StackNew'
import CodeEditor from './CodeEditor'
import { Sensor, fetchHistories, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel'
import { fmtWindowSec, useHistorySec, useRefreshMs } from './prefs'
@@ -357,7 +358,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
<CodeEditor className="stack-editor" language={file === 'env' ? 'env' : 'yaml'} value={text} onChange={setText} />}
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
+2 -1
View File
@@ -6,6 +6,7 @@
import { useEffect, useMemo, useState } from 'react'
import { api, StackHome } from './api'
import { askYes, Btn, Loading, useEscape } from './ui'
import CodeEditor from './CodeEditor'
interface Form { image: string; ports: string; volumes: string; env: string; labels: string; restart: string; command: string; network: string }
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
@@ -119,7 +120,7 @@ export default function StackNew({ mode, from, networks = [], taken, onClose, on
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
<pre className="unit-file stack-preview">{built}</pre>
</> : <>
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<CodeEditor className="stack-editor" language="yaml" value={text} onChange={setText} />
{from?.hasEnv && <label className="check"><input type="checkbox" checked={copyEnv} onChange={(e) => setCopyEnv(e.target.checked)} /> <span>Copy {from.name}'s .env too <span className="muted">— copied on the machine itself; its values are never shown here</span></span></label>}
{!(from && copyEnv) && <label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
+13 -2
View File
@@ -133,6 +133,13 @@ function Sessions() {
catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const others = list?.filter((s) => !s.current).length ?? 0
// a day unused is the line between a device that is used and a script that signed in once
const IDLE_H = 24
const idle = list?.filter((s) => !s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3).length ?? 0
const revokeIdle = async () => {
if (!await askYes(`Sign out ${idle} ${idle === 1 ? 'session' : 'sessions'} not used in the last ${IDLE_H} hours? Devices used since then, and this one, stay signed in.`, 'Revoke')) return
try { const r = await api.revokeIdleSessions(IDLE_H); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const revokeOthers = async () => {
if (!await askYes(`Sign out ${others} other ${others === 1 ? 'device' : 'devices'}? This one stays signed in.`)) return
try { const r = await api.revokeOtherSessions(); toast(`${r.revoked} signed out`); load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
@@ -145,11 +152,15 @@ function Sessions() {
{list && list.map((s) => (
<div className="row" key={s.id}>
<span className="k">{device(s.agent)}{s.current && <span className="badge" style={{ marginLeft: 6 }}>this device</span>}</span>
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}</span>
<span className="muted small">{s.ip ?? '?'} · seen {ago(s.last_seen)} · since {new Date(s.created).toLocaleDateString()}{!s.current && Date.now() - new Date(s.last_seen).getTime() > IDLE_H * 3600e3 ? <span className="badge" style={{ marginLeft: 6 }}>idle</span> : null}</span>
<Btn className={'btn' + (s.current ? '' : ' danger')} onClick={() => revoke(s)}>{s.current ? 'Sign out' : 'Revoke'}</Btn>
</div>
))}
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}><Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn></div>}
{others > 0 && <div className="svc-actions" style={{ marginTop: 8 }}>
{idle > 0 && <Btn className="btn" onClick={revokeIdle}>Sign out {idle} idle</Btn>}
<Btn className="btn danger" onClick={revokeOthers}>Sign out everywhere else</Btn>
<span className="muted small">A session unused for a week signs itself out.</span>
</div>}
</section>
</>
)
+1
View File
@@ -344,6 +344,7 @@ export const api = {
sessions: () => req<Session[]>('GET', '/api/sessions', undefined, true),
revokeSession: (id: string) => req<void>('DELETE', `/api/sessions/${encodeURIComponent(id)}`, undefined, true),
revokeOtherSessions: () => req<{ revoked: number }>('POST', '/api/sessions/revoke-others', undefined, true),
revokeIdleSessions: (hours = 24) => req<{ revoked: number }>('POST', `/api/sessions/revoke-idle?hours=${hours}`, undefined, true),
logout: () => req<void>('POST', '/api/logout', undefined, true),
meHere: () => req<Me>('GET', '/api/me', undefined, true),
me: () => req<Me>('GET', '/api/me'),
+22 -1
View File
@@ -607,7 +607,28 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
.docker .svc-list { min-width: 0; max-width: 100%; } .docker .svc-rows { overflow-x: hidden; } .docker .svc-name .d { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.docker:not(.has-sel) .svc-list { flex: 1 1 0; }
.stack-editor { flex: 1; min-height: 46vh; font-size: 13px; tab-size: 2; white-space: pre; overflow: auto; }
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } }
/* The code editor: a gutter of line numbers, a painted copy of the text behind a textarea
whose own text is transparent, both in the same monospace at the same size so they line up
glyph for glyph. .code takes the sizing class the textarea used to (.stack-editor), so it
sits where that did. */
.code { display: flex; align-items: stretch; background: var(--term-bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; position: relative; padding: 0; min-height: 260px; }
.code.ro .code-text { color: var(--muted); }
.code-gutter { flex: 0 0 auto; min-width: 2.6em; padding: 10px 6px 10px 0; text-align: right; color: var(--muted); opacity: .7; background: var(--bg2); border-right: 1px solid var(--border); overflow: hidden; user-select: none; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
.code-body { flex: 1; position: relative; min-width: 0; }
.code-paint, .code-text { margin: 0; padding: 10px; font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; tab-size: 2; white-space: pre; letter-spacing: 0; word-spacing: 0; }
.code-paint { position: absolute; inset: 0; overflow: hidden; color: var(--fg); pointer-events: none; }
.code-text { position: absolute; inset: 0; width: 100%; height: 100%; background: transparent; color: transparent; caret-color: var(--fg); border: 0; outline: 0; resize: none; overflow: auto; }
.code-text::placeholder { color: var(--muted); }
.code-text::selection { background: rgba(127, 127, 127, .35); }
.stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 13px; }
.y-key { color: var(--accent2); } .y-colon, .y-dash { color: var(--muted); } .y-string { color: var(--ok); } .y-number, .y-bool { color: var(--s2); } .y-comment { color: var(--muted); font-style: italic; } .y-anchor, .y-block { color: var(--accent); } .y-value { color: var(--fg); }
/* a phone has no Tab key: a bar of the strokes a compose file needs, pinned to the editor's foot */
.code-keys { position: absolute; left: 0; right: 0; bottom: 0; display: flex; gap: 6px; padding: 6px 8px; background: var(--bg2); border-top: 1px solid var(--border); z-index: 1; }
.code-keys button { min-width: 40px; height: 32px; padding: 0 10px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg3); color: var(--fg); font: 15px ui-monospace, monospace; }
.code-keys ~ * { display: none; }
@media (pointer: coarse) { .code .code-body { padding-bottom: 45px; } .code .code-gutter { padding-bottom: 55px; } }
@media (max-width: 800px) { .docker .svc-list { width: 100%; flex: 1 1 auto; } .stack-editor { min-height: 52vh; font-size: 12px; } .stack-editor.code, .stack-editor .code-paint, .stack-editor .code-text, .stack-editor .code-gutter { font-size: 12px; } }
/* stacks: diff, images, .env */
.stack-extra { display: flex; flex-direction: column; gap: 6px; border-top: 1px solid var(--border); padding-top: 10px; } .stack-extra .sub-head .k { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; }