A KVM tab: paste an address, get the device's own page
Ham Lab proxies each KVM from its own origin, which is what makes the simple version possible. A KVM that speaks only plain HTTP would otherwise be blocked outright as mixed content in a TLS console; served from here it loads, it is behind this login rather than a second one, and the iframe is same-origin. Nothing in it knows anything about any particular device. The prefix /kvm/<id>/ is only the entry point — a device UI asks for its assets by absolute path, which no prefix can rewrite away, so any request Ham Lab does not serve itself is routed back to whichever KVM the asking page belongs to, worked out from the Referer and from a cookie when there is no Referer, as on a WebSocket upgrade. Ham Lab's own routes always match first, so a device can never shadow the console, and the fallback is gated on a live session. The list is hub-side on purpose: a KVM earns its keep when the machine is down, which is when that machine's own Ham Lab cannot answer. On the keyboard, a page cannot forward keys into an iframe and does not need to — one with focus receives them directly. Fullscreen plus navigator.keyboard.lock() is what takes Escape, Tab and Ctrl-W back from the browser; that is Chromium-only and the tab says so rather than pretending otherwise. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
e6625a540c
commit
fd88c4ff78
@@ -0,0 +1,272 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
// IP-KVMs: a device's own web page, shown inside Ham Lab.
|
||||||
|
//
|
||||||
|
// The whole difficulty is that Ham Lab is served over TLS and a KVM usually is not, so a
|
||||||
|
// browser refuses to put the device's page in an iframe — mixed content, blocked before
|
||||||
|
// anything loads. The fix is to serve the device from Ham Lab's own origin, which also
|
||||||
|
// means it inherits Ham Lab's login instead of needing a second one, and makes the iframe
|
||||||
|
// same-origin.
|
||||||
|
//
|
||||||
|
// Proxying an arbitrary web app under a path prefix is the hard part, and nothing here
|
||||||
|
// knows anything about any particular device. A device UI asks for its assets by absolute
|
||||||
|
// path — "/assets/app.js", "/api/whatever" — which a prefix cannot rewrite away. So the
|
||||||
|
// prefix is only the entry point: any request Ham Lab does not serve itself is routed to
|
||||||
|
// whichever KVM the asking page belongs to, worked out from the Referer, and from a cookie
|
||||||
|
// when there is no Referer (a WebSocket upgrade, say). Ham Lab's own routes always win, so
|
||||||
|
// a device can never shadow the console.
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"hamlab/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
const kvmPrefix = "/kvm/"
|
||||||
|
const kvmCookie = "hamlab_kvm"
|
||||||
|
|
||||||
|
type kvmProxies struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
m map[string]*httputil.ReverseProxy
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) kvm(id string) (config.KVM, bool) {
|
||||||
|
var found config.KVM
|
||||||
|
ok := false
|
||||||
|
s.cfg.View(func(c *config.Config) {
|
||||||
|
for _, k := range c.KVMs {
|
||||||
|
if k.ID == id {
|
||||||
|
found, ok = k, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return found, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvmProxy is the reverse proxy for one device, made once and kept.
|
||||||
|
func (s *Server) kvmProxy(k config.KVM) (*httputil.ReverseProxy, error) {
|
||||||
|
s.kvms.mu.Lock()
|
||||||
|
defer s.kvms.mu.Unlock()
|
||||||
|
if s.kvms.m == nil {
|
||||||
|
s.kvms.m = map[string]*httputil.ReverseProxy{}
|
||||||
|
}
|
||||||
|
if p, ok := s.kvms.m[k.ID]; ok {
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
target, err := url.Parse(k.URL)
|
||||||
|
if err != nil || target.Host == "" {
|
||||||
|
return nil, errors.New("this KVM's address is not a URL")
|
||||||
|
}
|
||||||
|
prefix := kvmPrefix + k.ID
|
||||||
|
p := &httputil.ReverseProxy{
|
||||||
|
Director: func(r *http.Request) {
|
||||||
|
r.URL.Scheme, r.URL.Host = target.Scheme, target.Host
|
||||||
|
// Entry through the prefix keeps the path after it; anything else arrived by its
|
||||||
|
// own absolute path and is passed through untouched.
|
||||||
|
if rest, ok := strings.CutPrefix(r.URL.Path, prefix); ok {
|
||||||
|
if rest == "" {
|
||||||
|
rest = "/"
|
||||||
|
}
|
||||||
|
r.URL.Path = rest
|
||||||
|
}
|
||||||
|
r.URL.RawPath = ""
|
||||||
|
r.Host = target.Host
|
||||||
|
// The device gets its own host and none of Ham Lab's session.
|
||||||
|
r.Header.Del("Origin")
|
||||||
|
stripCookie(r, cookieName, kvmCookie)
|
||||||
|
},
|
||||||
|
FlushInterval: -1, // a video stream must not be buffered
|
||||||
|
ModifyResponse: func(resp *http.Response) error {
|
||||||
|
// The device's own framing and transport policy would stop it being embedded, or
|
||||||
|
// send a browser off to plain HTTP. Ham Lab is the origin now, and says what it
|
||||||
|
// serves; its own headers are added on the way out.
|
||||||
|
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 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) {
|
||||||
|
u.Scheme, u.Host = "", ""
|
||||||
|
u.Path = prefix + u.Path
|
||||||
|
resp.Header.Set("Location", u.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": k.Name + " unreachable: " + err.Error()})
|
||||||
|
},
|
||||||
|
}
|
||||||
|
s.kvms.m[k.ID] = p
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
cs := r.Cookies()
|
||||||
|
kept := cs[:0]
|
||||||
|
for _, c := range cs {
|
||||||
|
drop := false
|
||||||
|
for _, n := range names {
|
||||||
|
if c.Name == n {
|
||||||
|
drop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !drop {
|
||||||
|
kept = append(kept, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Header.Del("Cookie")
|
||||||
|
for _, c := range kept {
|
||||||
|
r.AddCookie(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) forgetKVMProxy(id string) {
|
||||||
|
s.kvms.mu.Lock()
|
||||||
|
delete(s.kvms.m, id)
|
||||||
|
s.kvms.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveKVM is the entry point: /kvm/<id>/… . It also drops a cookie naming the device, so
|
||||||
|
// that requests the page makes later by absolute path can be traced back to it.
|
||||||
|
func (s *Server) serveKVM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
k, ok := s.kvm(id)
|
||||||
|
if !ok {
|
||||||
|
jsonErr(w, 404, "no such KVM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := s.kvmProxy(k)
|
||||||
|
if err != nil {
|
||||||
|
jsonErr(w, 400, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: kvmCookie, Value: id, Path: "/", HttpOnly: true, Secure: secure(r), SameSite: http.SameSiteLaxMode})
|
||||||
|
p.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvmFor works out which device a request belongs to when it arrived by absolute path
|
||||||
|
// rather than through the prefix: the Referer names the page that asked, and a cookie
|
||||||
|
// covers what has no Referer at all. Nothing device-specific: it never has to know which
|
||||||
|
// paths a given KVM uses.
|
||||||
|
func (s *Server) kvmFor(r *http.Request) (config.KVM, bool) {
|
||||||
|
if ref := r.Header.Get("Referer"); ref != "" {
|
||||||
|
if u, err := url.Parse(ref); err == nil {
|
||||||
|
if rest, ok := strings.CutPrefix(u.Path, kvmPrefix); ok {
|
||||||
|
if id, _, _ := strings.Cut(rest, "/"); id != "" {
|
||||||
|
if k, ok := s.kvm(id); ok {
|
||||||
|
return k, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// asked for by a page that is not a KVM: not ours to answer
|
||||||
|
return config.KVM{}, false
|
||||||
|
}
|
||||||
|
if c, err := r.Cookie(kvmCookie); err == nil && c.Value != "" {
|
||||||
|
return s.kvm(c.Value)
|
||||||
|
}
|
||||||
|
return config.KVM{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// kvmFallback answers a request Ham Lab does not serve itself, when it came from inside a
|
||||||
|
// KVM's page. It returns false when the request has nothing to do with a KVM, and the
|
||||||
|
// caller carries on with its own 404.
|
||||||
|
func (s *Server) kvmFallback(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
k, ok := s.kvmFor(r)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p, err := s.kvmProxy(k)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p.ServeHTTP(w, r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- the list ----
|
||||||
|
|
||||||
|
func (s *Server) listKVMs(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
out := []config.KVM{}
|
||||||
|
s.cfg.View(func(c *config.Config) { out = append(out, c.KVMs...) })
|
||||||
|
jsonOK(w, 200, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) addKVM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body struct{ Name, URL, Machine string }
|
||||||
|
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&body); err != nil {
|
||||||
|
jsonErr(w, 400, "bad request")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw := strings.TrimSpace(body.URL)
|
||||||
|
if raw != "" && !strings.Contains(raw, "://") {
|
||||||
|
raw = "http://" + raw // what you would type: an address, not a scheme
|
||||||
|
}
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||||
|
jsonErr(w, 400, "address: http(s)://host, or just the host")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(body.Name)
|
||||||
|
if name == "" {
|
||||||
|
name = u.Host
|
||||||
|
}
|
||||||
|
if len(name) > maxNameLen {
|
||||||
|
jsonErr(w, 400, "that name is too long")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b := make([]byte, 6)
|
||||||
|
if _, err := rand.Read(b); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: strings.TrimSpace(body.Machine), Added: time.Now()}
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) { c.KVMs = append(c.KVMs, k) }); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Printf("kvm %q added (%s) by %s", k.Name, k.URL, clientIP(r))
|
||||||
|
jsonOK(w, 201, k)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) removeKVM(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.PathValue("id")
|
||||||
|
found := false
|
||||||
|
if err := s.cfg.Update(func(c *config.Config) {
|
||||||
|
kept := c.KVMs[:0]
|
||||||
|
for _, k := range c.KVMs {
|
||||||
|
if k.ID == id {
|
||||||
|
found = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, k)
|
||||||
|
}
|
||||||
|
c.KVMs = kept
|
||||||
|
}); err != nil {
|
||||||
|
jsonErr(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
jsonErr(w, 404, "no such KVM")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.forgetKVMProxy(id)
|
||||||
|
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
||||||
|
w.WriteHeader(204)
|
||||||
|
}
|
||||||
+32
-1
@@ -69,6 +69,7 @@ type Server struct {
|
|||||||
storage *storage.Manager
|
storage *storage.Manager
|
||||||
space *space.Manager
|
space *space.Manager
|
||||||
update updateState
|
update updateState
|
||||||
|
kvms kvmProxies
|
||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
nodes *nodes.Manager
|
nodes *nodes.Manager
|
||||||
@@ -284,7 +285,20 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
|
|||||||
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
m.HandleFunc("PUT /api/prefs/sampler", s.auth(s.putSampler))
|
||||||
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
m.HandleFunc("GET /api/prefs/history", s.auth(s.getHistoryWindow))
|
||||||
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
m.HandleFunc("PUT /api/prefs/history", s.auth(s.putHistoryWindow))
|
||||||
m.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { jsonErr(w, 404, "not found") })
|
m.HandleFunc("GET /api/kvms", s.auth(s.listKVMs))
|
||||||
|
m.HandleFunc("POST /api/kvms", s.auth(s.addKVM))
|
||||||
|
m.HandleFunc("DELETE /api/kvms/{id}", s.auth(s.removeKVM))
|
||||||
|
m.HandleFunc("/kvm/{id}/{rest...}", s.auth(s.serveKVM))
|
||||||
|
m.HandleFunc("/kvm/{id}", s.auth(s.serveKVM))
|
||||||
|
// A path Ham Lab does not serve may still belong to a KVM's page, which asks for its
|
||||||
|
// own assets by absolute path. Ham Lab's own routes matched first, so this can only
|
||||||
|
// catch what would otherwise have been a 404.
|
||||||
|
m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if s.kvmFallback(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jsonErr(w, 404, "not found")
|
||||||
|
}))
|
||||||
m.HandleFunc("/", s.static)
|
m.HandleFunc("/", s.static)
|
||||||
s.mux = m
|
s.mux = m
|
||||||
return s
|
return s
|
||||||
@@ -355,6 +369,17 @@ func (s *Server) setCookie(w http.ResponseWriter, r *http.Request, tok string, m
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// signedIn reports whether a request carries a live browser session, without the
|
||||||
|
// side effects of the auth middleware.
|
||||||
|
func (s *Server) signedIn(r *http.Request) bool {
|
||||||
|
c, err := r.Cookie(cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := s.sessions.Lookup(c.Value, clientKey(r))
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
// auth accepts the browser session cookie or a bearer API token issued to a hub.
|
||||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -2445,6 +2470,12 @@ func (s *Server) static(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A path with no file of ours behind it may belong to a KVM's page, which asks for its
|
||||||
|
// own assets by absolute path. Checked here rather than in the router because this
|
||||||
|
// handler is the catch-all — and gated on a session, since this handler is public.
|
||||||
|
if s.signedIn(r) && s.kvmFallback(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
// SPA fallback
|
// SPA fallback
|
||||||
w.Header().Set("Cache-Control", "no-cache")
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
http.ServeFileFS(w, r, s.ui, "index.html")
|
http.ServeFileFS(w, r, s.ui, "index.html")
|
||||||
|
|||||||
@@ -61,6 +61,21 @@ type UpdateSource struct {
|
|||||||
Check string `json:"check,omitempty"`
|
Check string `json:"check,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// KVM is an IP-KVM: a device with a web page that gives screen, keyboard and mouse access
|
||||||
|
// to a machine, including when that machine has no OS running. Ham Lab holds the list on
|
||||||
|
// the hub and proxies each one, because a KVM matters exactly when the machine it is
|
||||||
|
// attached to cannot answer for itself.
|
||||||
|
type KVM struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
// URL is the device's own address, as you would type it into a browser on the LAN.
|
||||||
|
URL string `json:"url"`
|
||||||
|
// Machine is which host it is attached to, for display only — it need not be a machine
|
||||||
|
// Ham Lab manages, and usually the point is that it is not answering.
|
||||||
|
Machine string `json:"machine,omitempty"`
|
||||||
|
Added time.Time `json:"added"`
|
||||||
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Listen string `json:"listen"`
|
Listen string `json:"listen"`
|
||||||
// PasswordHash is the single login of installs before users existed. On load it becomes
|
// PasswordHash is the single login of installs before users existed. On load it becomes
|
||||||
@@ -70,6 +85,7 @@ type Config struct {
|
|||||||
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
TLS string `json:"tls,omitempty"` // "auto" → self-signed cert in the data dir; "" → plain HTTP
|
||||||
Tokens []Token `json:"tokens,omitempty"`
|
Tokens []Token `json:"tokens,omitempty"`
|
||||||
Nodes []Node `json:"nodes,omitempty"`
|
Nodes []Node `json:"nodes,omitempty"`
|
||||||
|
KVMs []KVM `json:"kvms,omitempty"`
|
||||||
// Updates: where a newer build comes from. See UpdateSource.
|
// Updates: where a newer build comes from. See UpdateSource.
|
||||||
Updates UpdateSource `json:"update,omitempty"`
|
Updates UpdateSource `json:"update,omitempty"`
|
||||||
|
|
||||||
|
|||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
// IP-KVMs: a device's own web page, shown inside Ham Lab.
|
||||||
|
//
|
||||||
|
// Paste the address, and the device appears. Nothing here knows anything about any
|
||||||
|
// particular KVM — it is an iframe pointed at Ham Lab's own proxy, so whatever web UI the
|
||||||
|
// device serves is what you get.
|
||||||
|
//
|
||||||
|
// On the keyboard: a page cannot capture keys and forward them into an iframe, and does not
|
||||||
|
// need to. An iframe with focus receives them directly. What the browser itself swallows —
|
||||||
|
// Escape, Tab, Ctrl-W, the function keys — is what `navigator.keyboard.lock()` releases,
|
||||||
|
// and that needs fullscreen and only exists in Chromium. Everywhere else you get the plain
|
||||||
|
// version, which is most keys, and the device's own on-screen buttons for the rest.
|
||||||
|
import { FormEvent, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { KVM as Device, MachineStatus, api } from './api'
|
||||||
|
import { Btn, Loading, askYes, toast, useEscape } from './ui'
|
||||||
|
|
||||||
|
// lock() releases the keys the browser would otherwise keep for itself. Chromium only; a
|
||||||
|
// browser without it simply does not get the extra keys.
|
||||||
|
type KeyboardLock = { lock?: (keys?: string[]) => Promise<void>; unlock?: () => void }
|
||||||
|
const keyboardAPI = (): KeyboardLock | undefined => (navigator as unknown as { keyboard?: KeyboardLock }).keyboard
|
||||||
|
|
||||||
|
export default function KVM({ machines }: { machines: MachineStatus[] }) {
|
||||||
|
const [list, setList] = useState<Device[] | null>(null)
|
||||||
|
const [sel, setSel] = useState<string>('')
|
||||||
|
const [adding, setAdding] = useState(false)
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [full, setFull] = useState(false)
|
||||||
|
const box = useRef<HTMLDivElement>(null)
|
||||||
|
const frame = useRef<HTMLIFrameElement>(null)
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
try { const l = await api.kvms(); setList(l); setSel((s) => (l.some((k) => k.id === s) ? s : l[0]?.id ?? '')) }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
||||||
|
}, [])
|
||||||
|
useEffect(() => { load() }, [load])
|
||||||
|
|
||||||
|
// Fullscreen plus a keyboard lock is what makes Escape and Tab reach the machine rather
|
||||||
|
// than the browser. Both are undone together when fullscreen ends, however it ends.
|
||||||
|
useEffect(() => {
|
||||||
|
const onChange = () => {
|
||||||
|
const on = !!document.fullscreenElement
|
||||||
|
setFull(on)
|
||||||
|
if (!on) keyboardAPI()?.unlock?.()
|
||||||
|
}
|
||||||
|
document.addEventListener('fullscreenchange', onChange)
|
||||||
|
return () => { document.removeEventListener('fullscreenchange', onChange); keyboardAPI()?.unlock?.() }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const goFullscreen = async () => {
|
||||||
|
try {
|
||||||
|
await box.current?.requestFullscreen()
|
||||||
|
await keyboardAPI()?.lock?.()
|
||||||
|
frame.current?.focus()
|
||||||
|
} catch (e) { toast(e instanceof Error ? e.message : 'fullscreen refused', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
const remove = async (k: Device) => {
|
||||||
|
if (!await askYes(`Remove ${k.name}? The device is not touched — this only forgets its address.`, 'Remove')) return
|
||||||
|
try { await api.removeKVM(k.id); toast(`${k.name} removed`); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err && !list) return <div className="dash"><div className="error">{err}</div></div>
|
||||||
|
if (!list) return <div className="dash"><Loading label="Reading the list…" /></div>
|
||||||
|
|
||||||
|
const cur = list.find((k) => k.id === sel)
|
||||||
|
const locks = !!keyboardAPI()?.lock
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="kvm">
|
||||||
|
<div className="kvm-bar">
|
||||||
|
{list.length > 0 && (
|
||||||
|
<span className="chips">
|
||||||
|
{list.map((k) => (
|
||||||
|
<button key={k.id} className={'chip' + (k.id === sel ? ' on' : '')} onClick={() => setSel(k.id)}
|
||||||
|
title={`${k.url}${k.machine ? ` — attached to ${k.machine}` : ''}`}>{k.name}</button>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="spacer" />
|
||||||
|
{cur && <>
|
||||||
|
<span className="muted small mono kvm-url">{cur.url}</span>
|
||||||
|
<Btn className="btn" onClick={goFullscreen} title={locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>Fullscreen</Btn>
|
||||||
|
<a className="btn" href={cur.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab">Open ↗</a>
|
||||||
|
<Btn className="btn danger" onClick={() => remove(cur)}>Remove</Btn>
|
||||||
|
</>}
|
||||||
|
<Btn className="btn on" onClick={() => setAdding(true)}>+ Add</Btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{cur ? (
|
||||||
|
<div className={'kvm-frame' + (full ? ' full' : '')} ref={box}>
|
||||||
|
<iframe ref={frame} key={cur.id} src={`/kvm/${cur.id}/`} title={cur.name}
|
||||||
|
allow="fullscreen; clipboard-read; clipboard-write" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="svc-empty muted">
|
||||||
|
{list.length === 0
|
||||||
|
? 'No KVM yet. Add one with its address and its web page appears here.'
|
||||||
|
: 'Pick a KVM.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cur && !full && (
|
||||||
|
<div className="kvm-hint muted small">
|
||||||
|
Click the picture, then type — the keys go to the machine.
|
||||||
|
{locks ? ' Fullscreen also takes Escape, Tab and Ctrl-W back from the browser.' : ' This browser keeps Escape, Tab and Ctrl-W for itself; use the device’s own on-screen keys for those, or a Chromium browser.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{adding && <AddDialog machines={machines} onClose={() => setAdding(false)} onAdded={(k) => { setAdding(false); setSel(k.id); load() }} />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddDialog({ machines, onClose, onAdded }: { machines: MachineStatus[]; onClose: () => void; onAdded: (k: Device) => void }) {
|
||||||
|
const [url, setUrl] = useState('')
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [machine, setMachine] = useState('')
|
||||||
|
const [err, setErr] = useState('')
|
||||||
|
const [busy, setBusy] = useState(false)
|
||||||
|
useEscape(onClose)
|
||||||
|
const save = async (e: FormEvent) => {
|
||||||
|
e.preventDefault(); setErr(''); setBusy(true)
|
||||||
|
try { onAdded(await api.addKVM(name.trim(), url.trim(), machine)) }
|
||||||
|
catch (e) { setErr(e instanceof Error ? e.message : 'failed'); setBusy(false) }
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="modal-back" onClick={onClose}>
|
||||||
|
<form className="modal" onClick={(e) => e.stopPropagation()} onSubmit={save}>
|
||||||
|
<h3>Add a KVM</h3>
|
||||||
|
<label className="muted">Address — whatever you would type into a browser
|
||||||
|
<input autoFocus className="mono" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="10.0.0.217" spellCheck={false} autoCapitalize="off" required />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Name — optional; the address is used otherwise
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="mac mini KVM" maxLength={64} />
|
||||||
|
</label>
|
||||||
|
<label className="muted">Attached to — optional, and only a label
|
||||||
|
<select value={machine} onChange={(e) => setMachine(e.target.value)}>
|
||||||
|
<option value="">—</option>
|
||||||
|
{machines.map((m) => <option key={m.name} value={m.name}>{m.host || m.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="muted small">
|
||||||
|
Ham Lab serves the device from its own address, so a KVM that only speaks plain HTTP
|
||||||
|
still works here, and it is behind this login rather than needing a second one. The
|
||||||
|
device itself is not changed and is never reachable from outside.
|
||||||
|
</div>
|
||||||
|
{err && <div className="error">{err}</div>}
|
||||||
|
<div className="svc-actions">
|
||||||
|
<button type="submit" className="btn on" disabled={busy || !url.trim()}>Add</button>
|
||||||
|
<span className="spacer" />
|
||||||
|
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
+7
-4
@@ -12,6 +12,7 @@ import Jobs from './Jobs'
|
|||||||
import Updates from './Updates'
|
import Updates from './Updates'
|
||||||
import Disks from './Disks'
|
import Disks from './Disks'
|
||||||
import ZFS from './ZFS'
|
import ZFS from './ZFS'
|
||||||
|
import KVM from './KVM'
|
||||||
import Files from './Files'
|
import Files from './Files'
|
||||||
import Top from './Top'
|
import Top from './Top'
|
||||||
import Machines from './Machines'
|
import Machines from './Machines'
|
||||||
@@ -63,14 +64,15 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'files', label: 'Files', icon: FolderIcon },
|
{ id: 'files', label: 'Files', icon: FolderIcon },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
{ id: 'machines', label: 'Machines', icon: '⌂' },
|
||||||
|
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
||||||
{ id: 'users', label: 'Users', icon: '⚇' },
|
{ id: 'users', label: 'Users', icon: '⚇' },
|
||||||
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
{ id: 'widgets', label: 'Widgets', icon: '▦' },
|
||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
||||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', 'kvm', 'users', 'settings', 'alerts']
|
||||||
|
|
||||||
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
// URL hash is the source of truth for where you are: #/<machine|hub>/<view>. Back and
|
||||||
// forward work, reload restores the tab, and links can point at a page on a machine.
|
// forward work, reload restores the tab, and links can point at a page on a machine.
|
||||||
@@ -269,6 +271,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<main className="main" key={machine ?? 'hub'}>
|
<main className="main" key={machine ?? 'hub'}>
|
||||||
{view === 'settings' && <Settings me={hubMe} />}
|
{view === 'settings' && <Settings me={hubMe} />}
|
||||||
{view === 'users' && <Users me={hubMe} />}
|
{view === 'users' && <Users me={hubMe} />}
|
||||||
|
{view === 'kvm' && <KVM machines={machines} />}
|
||||||
{view === 'usage' && <Usage key={machine ?? ''} />}
|
{view === 'usage' && <Usage key={machine ?? ''} />}
|
||||||
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
{view === 'machines' && <Machines hub={hubMe} onChanged={loadMachines} onOpen={(n) => { switchMachine(n); setView('procs') }} />}
|
||||||
{view === 'widgets' && <Gallery />}
|
{view === 'widgets' && <Gallery />}
|
||||||
@@ -306,7 +309,7 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
<span>{machine ? `${title} · ` : ''}{me.version.startsWith('v') ? me.version : 'v' + me.version}</span>
|
||||||
<span>up {fmtUptime(me.uptime)}</span>
|
<span>up {fmtUptime(me.uptime)}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
<span className="muted">{view === 'widgets' ? 'Widgets' : view === 'docker' ? 'Docker' : view === 'alerts' ? 'Alerts' : view === 'services' ? 'Services' : view === 'jobs' ? 'Jobs' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'zfs' ? 'ZFS' : view === 'usage' ? 'Usage' : view === 'files' ? 'Files' : view === 'procs' ? 'Processes' : view === 'machines' ? 'Machines' : view === 'kvm' ? 'KVM' : view === 'users' ? 'Users' : view === 'settings' ? 'Settings' : activeTerm ? activeTerm.name : ''}</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -133,6 +133,8 @@ export interface SpaceNode { name: string; dir: boolean; size: number; apparent:
|
|||||||
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
export interface SpaceTree extends SpaceNode { path: string; children?: SpaceTree[]; more_n?: number; more_size?: number; total: number }
|
||||||
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
export interface SpaceFile { path: string; size: number; apparent: number; mtime: number }
|
||||||
|
|
||||||
|
export interface KVM { id: string; name: string; url: string; machine?: string; added: string }
|
||||||
|
|
||||||
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
export interface ZFSPolicy { id: string; name: string; dataset: string; recursive: boolean; every: 'hourly' | 'daily' | 'weekly' | 'monthly' | 'custom'; at: string; weekday: number; day: number; cron?: string; keep_for: string; keep_min: number; prefix?: string; enabled: boolean }
|
||||||
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
export interface ZFSRun { at?: string; taken?: string; pruned?: string[]; error?: string }
|
||||||
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
export interface ZFSExternal { tool: string; schedule: string; command: string; user: string; source: string; id: string; next?: number; last?: number }
|
||||||
@@ -184,6 +186,9 @@ export const api = {
|
|||||||
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
storage: () => req<{ devices: StorageDevice[]; zfs: ZFSInfo; smartctl: boolean }>('GET', '/api/storage'),
|
||||||
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
smart: (dev: string, force = false) => req<SmartInfo>('GET', `/api/storage/smart/${dev}${force ? '?force=1' : ''}`),
|
||||||
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
smartTest: (dev: string, kind: 'short' | 'long') => req<void>('POST', `/api/storage/smart/${dev}/test/${kind}`),
|
||||||
|
kvms: () => req<KVM[]>('GET', '/api/kvms', undefined, true),
|
||||||
|
addKVM: (name: string, url: string, machine: string) => req<KVM>('POST', '/api/kvms', { name, url, machine }, true),
|
||||||
|
removeKVM: (id: string) => req<void>('DELETE', `/api/kvms/${encodeURIComponent(id)}`, undefined, true),
|
||||||
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
zfsOverview: () => req<ZFSOverview>('GET', '/api/zfs'),
|
||||||
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
saveZfsSchedule: (p: Partial<ZFSPolicy>) => req<ZFSPolicy>('PUT', '/api/zfs/schedules', p),
|
||||||
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
deleteZfsSchedule: (id: string) => req<void>('DELETE', `/api/zfs/schedules/${encodeURIComponent(id)}`),
|
||||||
|
|||||||
@@ -740,3 +740,13 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
/* ZFS: the schedule dialog's controls match the rest of the modal forms */
|
/* ZFS: the schedule dialog's controls match the rest of the modal forms */
|
||||||
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
.modal select, .modal input[type=time], .modal input[type=number] { padding: 9px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 16px; }
|
||||||
.modal .chips { margin-top: 2px; }
|
.modal .chips { margin-top: 2px; }
|
||||||
|
|
||||||
|
/* ---- KVM: a device's own web page, framed ---- */
|
||||||
|
.kvm { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); min-height: 0; }
|
||||||
|
.kvm-bar { display: flex; align-items: center; gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); background: var(--bg2); flex-wrap: wrap; }
|
||||||
|
.kvm-bar .kvm-url { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 28ch; }
|
||||||
|
.kvm-frame { flex: 1; min-height: 0; background: #000; position: relative; }
|
||||||
|
.kvm-frame iframe { width: 100%; height: 100%; border: 0; display: block; background: #000; }
|
||||||
|
.kvm-frame.full { background: #000; }
|
||||||
|
.kvm-hint { padding: 6px 12px; border-top: 1px solid var(--border); }
|
||||||
|
@media (max-width: 800px) { .kvm-bar .kvm-url { display: none; } }
|
||||||
|
|||||||
Reference in New Issue
Block a user