Compare commits
2
Commits
829617c932
...
4b238ea93e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b238ea93e | ||
|
|
5edad2de6a |
@@ -417,39 +417,9 @@ something long in one, switch away and back, refresh — it's all still there.
|
|||||||
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
snapshot, the package is purged and the old cron line is gone, with the 515 snapshots it
|
||||||
had taken left untouched and fully readable here — which is what adopting its naming
|
had taken left untouched and fully readable here — which is what adopting its naming
|
||||||
bought. See `hamlet/docs/hosts.md`.
|
bought. See `hamlet/docs/hosts.md`.
|
||||||
- **KVM tab** — **shipped 2026-09-24** (`internal/api/kvm.go`, `KVM.tsx`, `config.KVM`).
|
- **KVM tab** — shipped 2026-09-24, **removed 2026-11-09** at Mitchell's call: not needed —
|
||||||
Paste a device's address; its own web page appears in the tab. **Nothing in it knows
|
the KVMs are reached over WireGuard (see `hamlet/docs/hosts.md`). It proxied a device's web
|
||||||
anything about any particular KVM** — the first device here is a Sipeed NanoKVM on `grid`
|
page through Ham Lab's origin; the code is in git history before that date.
|
||||||
at `10.0.0.217`, but none of that is in the code.
|
|
||||||
**Why it proxies rather than framing the device directly.** Ham Lab is served over TLS and
|
|
||||||
a KVM usually is not, so a bare `http://…` iframe is blocked as mixed content before it
|
|
||||||
loads. Serving the device from Ham Lab's own origin (`/kvm/<id>/…`) fixes that, puts it
|
|
||||||
behind **Ham Lab's** login rather than a second one, and makes the iframe same-origin.
|
|
||||||
**The general problem, and the answer.** A device UI asks for its assets by absolute path
|
|
||||||
— `/assets/app.js`, `/api/whatever` — which no path prefix can rewrite away, and rewriting
|
|
||||||
bodies per device would not be general. 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, read
|
|
||||||
from the **Referer**, falling back to a **cookie** when there is none (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 device never receives Ham Lab's session cookie,
|
|
||||||
and its `X-Frame-Options`/CSP/HSTS are dropped on the way out since Ham Lab is the origin
|
|
||||||
now. Redirects to the device's root are rewritten back through the prefix.
|
|
||||||
**One catch found in testing:** Ham Lab's own `frame-ancestors 'none'` forbids framing even
|
|
||||||
from the same origin, so `/kvm/` paths send `'self'` instead. Without that the iframe was
|
|
||||||
refused before it loaded.
|
|
||||||
**The keyboard** needs less than it sounds: a page cannot forward keys into an iframe and
|
|
||||||
does not need to, since 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 which version you are getting rather than
|
|
||||||
pretending.
|
|
||||||
**One KVM per machine, and the tab follows the machine switcher** (corrected the same day:
|
|
||||||
the first cut was a hub-side list you pointed at machines, which had it backwards). Switch
|
|
||||||
to `grid` and you get grid's; adding a second for a machine replaces the first. The
|
|
||||||
addresses are still kept **on the hub** rather than on each machine, because a KVM earns
|
|
||||||
its keep exactly when the machine is down and its own Ham Lab cannot answer — the hub
|
|
||||||
proxies the device either way — and a KVM need not belong to a machine Ham Lab manages at
|
|
||||||
all. Not done: holding the device's own login on the hub, so its page does not ask for a
|
|
||||||
second password.
|
|
||||||
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
- **Skins** — 13 since 2026-09-19 (`themes.ts`: a palette is one `mk(…)` line): Dark,
|
||||||
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
Light, Cyberpunk, Retro, **HUD** (hairlines of cyan on near-black, corner ticks, thin
|
||||||
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
type), Nord, Gruvbox, Catppuccin + Latte, Dracula, Tokyo Night, Solarized Dark + Light;
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ default or phones home to this lab · **C** cosmetic (placeholder text, comments
|
|||||||
| # | Sev | Where | Text | General form |
|
| # | Sev | Where | Text | General form |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. |
|
| 2.1 | **C** | `web/src/Machines.tsx:116-118` | `placeholder="grid"`, `"https://10.0.0.214:8100"`, `"core"` | `nas`, `https://192.0.2.10:8100`, `admin` or the hub's own user. |
|
||||||
| 2.2 | **C** | `web/src/KVM.tsx:166` | `placeholder="10.0.0.217"` (the NanoKVM on `grid`) | `192.0.2.20` or `kvm.local`. |
|
|
||||||
| 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. |
|
| 2.3 | **C** | `web/src/Settings.tsx:242` | `placeholder="cpu.*, temp.*, zfs.tank.*"` | `cpu.*, temp.*, fs.*`. |
|
||||||
| 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
|
| 2.4 | **C** | `web/src/Settings.tsx:225` | read-only token default name `'glance'` | Empty, or `dashboard`. |
|
||||||
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. |
|
| 2.5 | **C** | `web/src/Alerts.tsx:38, 40` | `"Tank nearly full"`, `"zfs.tank.used_pct"` | `"Disk nearly full"`, `"fs.root.used_pct"`. |
|
||||||
@@ -146,7 +145,6 @@ Listed so the sweep is known to be complete, not because they need work.
|
|||||||
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
|
- `internal/nodes`, `internal/tlsauto`: hub ↔ node pairing, pinned certs, proxy, push.
|
||||||
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
|
- `internal/sensors`, alerts, widgets, embeds, Prometheus: machine-agnostic by design.
|
||||||
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
|
- `internal/files`, `internal/term`, `internal/tasks`, `internal/auth`, `internal/store`.
|
||||||
- `internal/api/kvm.go`: "nothing in it knows anything about any particular KVM" — verified.
|
|
||||||
- `internal/compose` (modulo 3.5).
|
- `internal/compose` (modulo 3.5).
|
||||||
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
|
- The React UI: machine-switcher model, hash routing, per-account prefs, themes.
|
||||||
|
|
||||||
|
|||||||
@@ -1,420 +0,0 @@
|
|||||||
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"
|
|
||||||
"hamlab/internal/store"
|
|
||||||
)
|
|
||||||
|
|
||||||
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
|
|
||||||
// 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 {
|
|
||||||
// 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 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) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
|
||||||
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})
|
|
||||||
s.lendKVMCookies(w, r, id)
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func machineLabel(m string) string {
|
|
||||||
if m == "" {
|
|
||||||
return "this host"
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- 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
|
|
||||||
}
|
|
||||||
machine := strings.TrimSpace(body.Machine)
|
|
||||||
k := config.KVM{ID: hex.EncodeToString(b), Name: name, URL: u.Scheme + "://" + u.Host + strings.TrimSuffix(u.Path, "/"), Machine: machine, Added: time.Now()}
|
|
||||||
// One per machine: a second one for the same host replaces the first rather than sitting
|
|
||||||
// beside it, since the tab shows the machine you are looking at.
|
|
||||||
var replaced string
|
|
||||||
if err := s.cfg.Update(func(c *config.Config) {
|
|
||||||
kept := c.KVMs[:0]
|
|
||||||
for _, old := range c.KVMs {
|
|
||||||
if old.Machine == machine {
|
|
||||||
replaced = old.ID
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
kept = append(kept, old)
|
|
||||||
}
|
|
||||||
c.KVMs = append(kept, k)
|
|
||||||
}); err != nil {
|
|
||||||
jsonErr(w, 500, err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if replaced != "" {
|
|
||||||
s.forgetKVMProxy(replaced)
|
|
||||||
}
|
|
||||||
log.Printf("kvm %q added for %s (%s) by %s", k.Name, machineLabel(machine), 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)
|
|
||||||
s.forgetKVMCookies(id)
|
|
||||||
log.Printf("kvm %s removed by %s", id, clientIP(r))
|
|
||||||
w.WriteHeader(204)
|
|
||||||
}
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
+4
-28
@@ -75,7 +75,6 @@ type Server struct {
|
|||||||
net *network.Watcher
|
net *network.Watcher
|
||||||
update updateState
|
update updateState
|
||||||
events eventLog
|
events eventLog
|
||||||
kvms kvmProxies
|
|
||||||
fsLocal files.FS
|
fsLocal files.FS
|
||||||
fsSudo files.FS // nil without passwordless sudo
|
fsSudo files.FS // nil without passwordless sudo
|
||||||
thumbs *thumbSvc
|
thumbs *thumbSvc
|
||||||
@@ -351,18 +350,7 @@ func (s *Server) routes() *http.ServeMux {
|
|||||||
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("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) {
|
m.HandleFunc("/api/", s.auth(func(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.kvmFallback(w, r) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
jsonErr(w, 404, "not found")
|
jsonErr(w, 404, "not found")
|
||||||
}))
|
}))
|
||||||
m.HandleFunc("/", s.static)
|
m.HandleFunc("/", s.static)
|
||||||
@@ -386,16 +374,10 @@ func sensorPath(p string) bool {
|
|||||||
func (s *Server) Handler() http.Handler {
|
func (s *Server) Handler() http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := w.Header()
|
h := w.Header()
|
||||||
// The console must never be framed. Two exceptions: an embedded widget exists to be
|
// The console must never be framed. The one exception is an embedded widget, which
|
||||||
// (a page that can only read sensors, and only with a read-only token in its URL),
|
// exists to be (a page that can only read sensors, and only with a read-only token in
|
||||||
// and a proxied KVM is framed by the console itself — 'none' forbids that even from
|
// its URL).
|
||||||
// the same origin, so those paths say 'self' instead of opening up.
|
if r.URL.Path != "/embed/widget" {
|
||||||
switch {
|
|
||||||
case r.URL.Path == "/embed/widget":
|
|
||||||
case strings.HasPrefix(r.URL.Path, kvmPrefix):
|
|
||||||
h.Set("X-Frame-Options", "SAMEORIGIN")
|
|
||||||
h.Set("Content-Security-Policy", "frame-ancestors 'self'")
|
|
||||||
default:
|
|
||||||
h.Set("X-Frame-Options", "DENY")
|
h.Set("X-Frame-Options", "DENY")
|
||||||
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
h.Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
}
|
}
|
||||||
@@ -2654,12 +2636,6 @@ 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")
|
||||||
|
|||||||
@@ -77,23 +77,6 @@ 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 the host this KVM is attached to, and is the key: one KVM per machine, shown
|
|
||||||
// on that machine's KVM tab. Empty means the hub itself. The list lives on the hub even
|
|
||||||
// for a node's KVM, because a KVM earns its keep when the machine is down and cannot
|
|
||||||
// answer for itself.
|
|
||||||
Machine string `json:"machine"`
|
|
||||||
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
|
||||||
@@ -103,7 +86,6 @@ 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"`
|
||||||
|
|
||||||
|
|||||||
@@ -68,13 +68,9 @@ type State struct {
|
|||||||
// Mounts remembers where a container's bind mounts pointed, so a stack deployed by an
|
// 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
|
// orchestrator that has since been removed can still be found on disk. See
|
||||||
// internal/api/stacks.go.
|
// internal/api/stacks.go.
|
||||||
Mounts []RememberedMount `json:"mounts,omitempty"`
|
Mounts []RememberedMount `json:"mounts,omitempty"`
|
||||||
// KVMCookies is each KVM's own login, as the cookies its page set in a browser: kept so
|
SamplerMs int `json:"sampler_ms,omitempty"` // how often this machine samples; the hub keeps every machine on the console's refresh rate
|
||||||
// the next browser — or this one after it has forgotten them — is signed into the device
|
HistorySec int `json:"history_sec,omitempty"` // how far back the graphs reach; the hub keeps every machine on the same one
|
||||||
// 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
|
|
||||||
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
// Transcode is where videos played in the Files viewer are made (internal/api/play.go):
|
||||||
// a machine and a device on it. nil is the machine the file is on, its best device.
|
// a machine and a device on it. nil is the machine the file is on, its best device.
|
||||||
Transcode *Transcode `json:"transcode,omitempty"`
|
Transcode *Transcode `json:"transcode,omitempty"`
|
||||||
|
|||||||
+58
-6
@@ -1,4 +1,4 @@
|
|||||||
import { DragEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
import { DragEvent, PointerEvent, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
import { api, FileEntry, FileInfo, TaskInfo, ThumbStatus } from './api'
|
||||||
import { fmtBytes } from './fmt'
|
import { fmtBytes } from './fmt'
|
||||||
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
import { Btn, Loading, Spinner, askText, askYes, setUnsaved, toast, useEscape } from './ui'
|
||||||
@@ -22,6 +22,7 @@ export function resolvePath(input: string, here: string): string {
|
|||||||
}
|
}
|
||||||
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) }
|
||||||
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n)
|
||||||
|
const isMedia = (e: FileEntry) => !!e.preview || isImage(e.name)
|
||||||
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
|
||||||
const MAX_EDIT = 2 * 1024 * 1024
|
const MAX_EDIT = 2 * 1024 * 1024
|
||||||
|
|
||||||
@@ -198,7 +199,9 @@ function ThumbNote({ st, sudo, onChanged }: { st: ThumbStatus | null; sudo: bool
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---- editor / viewer ----
|
// ---- editor / viewer ----
|
||||||
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
|
// steps: the files ← and → go through, in the list's order (only the pictures, videos and
|
||||||
|
// sound when one of those is open).
|
||||||
|
function Viewer({ path, sudo, steps, onStep, onClose, onChanged }: { path: string; sudo: boolean; steps: string[]; onStep: (p: string) => void; onClose: () => void; onChanged: () => void }) {
|
||||||
const [info, setInfo] = useState<FileInfo | null>(null)
|
const [info, setInfo] = useState<FileInfo | null>(null)
|
||||||
const [text, setText] = useState<string | null>(null)
|
const [text, setText] = useState<string | null>(null)
|
||||||
const [orig, setOrig] = useState('')
|
const [orig, setOrig] = useState('')
|
||||||
@@ -251,10 +254,49 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
}, [dirty, name])
|
}, [dirty, name])
|
||||||
const dl = api.fileDownloadURL(path, sudo)
|
const dl = api.fileDownloadURL(path, sudo)
|
||||||
|
|
||||||
|
// Previous and next: the buttons, ← and → (unless typing, or the player has the focus and
|
||||||
|
// seeks with them), and a sideways swipe on a picture.
|
||||||
|
const at = steps.indexOf(path)
|
||||||
|
const prev = at > 0 ? steps[at - 1] : null
|
||||||
|
const next = at >= 0 && at < steps.length - 1 ? steps[at + 1] : null
|
||||||
|
const step = useCallback(async (to: string | null) => {
|
||||||
|
if (!to) return
|
||||||
|
if (dirty && !await askYes('Discard unsaved changes?', 'Discard')) return
|
||||||
|
setUnsaved(''); onStep(to)
|
||||||
|
}, [dirty, onStep])
|
||||||
|
useEffect(() => {
|
||||||
|
const h = (e: KeyboardEvent) => {
|
||||||
|
if (e.defaultPrevented || e.altKey || e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||||
|
const t = e.target as HTMLElement | null
|
||||||
|
if (t?.closest('input, textarea, select, [contenteditable], .vp')) return
|
||||||
|
if (e.key === 'ArrowLeft' && prev) { e.preventDefault(); step(prev) }
|
||||||
|
else if (e.key === 'ArrowRight' && next) { e.preventDefault(); step(next) }
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', h)
|
||||||
|
return () => window.removeEventListener('keydown', h)
|
||||||
|
}, [prev, next, step])
|
||||||
|
// The next and previous pictures load now, so stepping onto them is instant.
|
||||||
|
useEffect(() => {
|
||||||
|
for (const p of [prev, next]) if (p && isImage(p)) new Image().src = api.fileReadURL(p, sudo)
|
||||||
|
}, [prev, next, sudo])
|
||||||
|
const swipe = useRef<{ x: number; y: number } | null>(null)
|
||||||
|
const swipeEnd = (e: PointerEvent) => {
|
||||||
|
const s = swipe.current
|
||||||
|
swipe.current = null
|
||||||
|
if (!s) return
|
||||||
|
const dx = e.clientX - s.x, dy = e.clientY - s.y
|
||||||
|
if (Math.abs(dx) > 50 && Math.abs(dx) > 1.5 * Math.abs(dy)) step(dx < 0 ? next : prev)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="viewer">
|
<div className="viewer">
|
||||||
<div className="viewer-bar">
|
<div className="viewer-bar">
|
||||||
<button className="btn" onClick={close}>‹ Back</button>
|
<button className="btn" onClick={close}>‹ Back</button>
|
||||||
|
{at >= 0 && steps.length > 1 && <>
|
||||||
|
<button className="btn step" disabled={!prev} title="previous (←)" onClick={() => step(prev)}>←</button>
|
||||||
|
<span className="muted num">{at + 1}/{steps.length}</span>
|
||||||
|
<button className="btn step" disabled={!next} title="next (→)" onClick={() => step(next)}>→</button>
|
||||||
|
</>}
|
||||||
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
|
||||||
<span className="spacer" />
|
<span className="spacer" />
|
||||||
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
|
||||||
@@ -266,11 +308,17 @@ function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolea
|
|||||||
{info && text !== null && (
|
{info && text !== null && (
|
||||||
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
<textarea className="editor file-editor" value={text} spellCheck={false} onChange={(e) => setText(e.target.value)} />
|
||||||
)}
|
)}
|
||||||
{info && text === null && isImage(name) && <div className="preview"><img src={api.fileReadURL(path, sudo)} alt={name} /></div>}
|
{info && text === null && isImage(name) && (
|
||||||
|
<div className="preview pic" onPointerDown={(e) => { if (e.pointerType !== 'mouse' && e.isPrimary) swipe.current = { x: e.clientX, y: e.clientY } }} onPointerUp={swipeEnd} onPointerCancel={() => { swipe.current = null }}>
|
||||||
|
<img src={api.fileReadURL(path, sudo)} alt={name} draggable={false} />
|
||||||
|
{prev && <button className="step-edge prev" title="previous (←)" onClick={() => step(prev)}>‹</button>}
|
||||||
|
{next && <button className="step-edge next" title="next (→)" onClick={() => step(next)}>›</button>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
{/* Video: the player (Auto, by hand, or the file as it is), made on the Transcode device. Audio as it is. */}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'video' && <Player path={path} sudo={sudo} name={name} />}
|
{info && text === null && !isImage(name) && info.preview === 'video' && <Player key={path} path={path} sudo={sudo} name={name} />}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
{info && text === null && !isImage(name) && info.preview === 'audio' && !cantPlay && (
|
||||||
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
<div className="preview audio"><div className="audio-box"><div className="ficon">🎵</div><div className="mono small">{name}</div><audio controls autoPlay preload="metadata" src={api.fileReadURL(path, sudo)} onError={() => setCantPlay(true)} /></div></div>
|
||||||
)}
|
)}
|
||||||
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
{info && text === null && !isImage(name) && info.preview === 'audio' && cantPlay && (
|
||||||
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
<div className="notice">This browser can't play {name} as it is — the codec isn't one it decodes. Download it instead.</div>
|
||||||
@@ -399,7 +447,11 @@ export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: s
|
|||||||
if (files.length) uploadFiles(files)
|
if (files.length) uploadFiles(files)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
if (open) {
|
||||||
|
const cur = shown.find((e) => join(path, e.name) === open)
|
||||||
|
const steps = shown.filter((e) => e.type !== 'dir' && e.type !== 'other' && (!cur || !isMedia(cur) || isMedia(e))).map((e) => join(path, e.name))
|
||||||
|
return <div className="files"><Viewer path={open} sudo={sudo} steps={steps} onStep={setOpen} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
|
||||||
|
|||||||
-187
@@ -1,187 +0,0 @@
|
|||||||
// The KVM of the machine you are looking at: the device's own web page, shown in the tab.
|
|
||||||
//
|
|
||||||
// One per machine, and it follows the machine switcher — switch to `grid` and you get
|
|
||||||
// grid's KVM. The address is held on the **hub**, not on the machine, because a KVM earns
|
|
||||||
// its keep exactly when that machine is down and cannot answer for itself; the hub proxies
|
|
||||||
// the device either way.
|
|
||||||
//
|
|
||||||
// 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, 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
|
|
||||||
|
|
||||||
// Fullscreen, across the three ways browsers spell it. Safari on a Mac wants the webkit
|
|
||||||
// prefix; Safari on iOS has no fullscreen for an ordinary element at all — only for a
|
|
||||||
// <video> — so there the page expands inside the viewport instead, which is the same thing
|
|
||||||
// minus the browser's own chrome.
|
|
||||||
type FSElement = HTMLElement & { webkitRequestFullscreen?: () => Promise<void> | void }
|
|
||||||
type FSDocument = Document & { webkitFullscreenElement?: Element | null; webkitExitFullscreen?: () => Promise<void> | void }
|
|
||||||
const nativeFullscreen = () => {
|
|
||||||
if (typeof document === 'undefined') return false
|
|
||||||
const el = document.documentElement as FSElement
|
|
||||||
return typeof el.requestFullscreen === 'function' || typeof el.webkitRequestFullscreen === 'function'
|
|
||||||
}
|
|
||||||
const fullscreenElement = () => (document as FSDocument).fullscreenElement ?? (document as FSDocument).webkitFullscreenElement ?? null
|
|
||||||
async function enterFullscreen(el: FSElement) {
|
|
||||||
if (typeof el.requestFullscreen === 'function') return el.requestFullscreen()
|
|
||||||
if (typeof el.webkitRequestFullscreen === 'function') return el.webkitRequestFullscreen()
|
|
||||||
throw new Error('this browser has no fullscreen for a page element')
|
|
||||||
}
|
|
||||||
function exitFullscreen() {
|
|
||||||
const d = document as FSDocument
|
|
||||||
if (typeof d.exitFullscreen === 'function') return d.exitFullscreen()
|
|
||||||
if (typeof d.webkitExitFullscreen === 'function') return d.webkitExitFullscreen()
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function KVM({ machine, title }: { machine: string | null; title: string }) {
|
|
||||||
const [kvm, setKvm] = useState<Device | null | undefined>(undefined)
|
|
||||||
const [adding, setAdding] = useState(false)
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
const [full, setFull] = useState(false) // native fullscreen is on
|
|
||||||
const [expanded, setExpanded] = useState(false) // the fallback: filling the viewport in-page
|
|
||||||
const box = useRef<HTMLDivElement>(null)
|
|
||||||
const frame = useRef<HTMLIFrameElement>(null)
|
|
||||||
const key = machine ?? '' // the hub is a machine too, and its key is the empty one
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
try { setKvm((await api.kvms()).find((k) => k.machine === key) ?? null) }
|
|
||||||
catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
|
|
||||||
}, [key])
|
|
||||||
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 = !!fullscreenElement()
|
|
||||||
setFull(on)
|
|
||||||
if (!on) keyboardAPI()?.unlock?.()
|
|
||||||
}
|
|
||||||
document.addEventListener('fullscreenchange', onChange)
|
|
||||||
document.addEventListener('webkitfullscreenchange', onChange)
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener('fullscreenchange', onChange)
|
|
||||||
document.removeEventListener('webkitfullscreenchange', onChange)
|
|
||||||
keyboardAPI()?.unlock?.()
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
// Expanding is in-page, so Escape is ours to handle.
|
|
||||||
useEscape(() => setExpanded(false), expanded)
|
|
||||||
|
|
||||||
const goBig = async () => {
|
|
||||||
if (!nativeFullscreen()) { setExpanded(true); frame.current?.focus(); return }
|
|
||||||
try {
|
|
||||||
if (box.current) await enterFullscreen(box.current)
|
|
||||||
await keyboardAPI()?.lock?.()
|
|
||||||
frame.current?.focus()
|
|
||||||
} catch {
|
|
||||||
setExpanded(true) // refused (an iframe policy, a permissions policy): fill the page instead
|
|
||||||
frame.current?.focus()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const goSmall = () => { setExpanded(false); if (fullscreenElement()) exitFullscreen() }
|
|
||||||
|
|
||||||
const remove = async () => {
|
|
||||||
if (!kvm || !await askYes(`Forget ${title}'s KVM? The device itself is not touched — this only removes its address.`, 'Remove')) return
|
|
||||||
try { await api.removeKVM(kvm.id); toast('removed'); await load() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err && kvm === undefined) return <div className="dash"><div className="error">{err}</div></div>
|
|
||||||
if (kvm === undefined) return <div className="dash"><Loading label="Looking…" /></div>
|
|
||||||
|
|
||||||
const locks = !!keyboardAPI()?.lock
|
|
||||||
const big = full || expanded
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="kvm">
|
|
||||||
<div className="kvm-bar">
|
|
||||||
<span className="dname">{title}</span>
|
|
||||||
{kvm
|
|
||||||
? <span className="muted small mono kvm-url" title={kvm.url}>{kvm.name !== kvm.url ? `${kvm.name} · ` : ''}{kvm.url}</span>
|
|
||||||
: <span className="muted small">no KVM</span>}
|
|
||||||
<span className="spacer" />
|
|
||||||
{kvm && <>
|
|
||||||
<Btn className="btn" onClick={goBig} title={!nativeFullscreen() ? 'fill the screen — this browser has no true fullscreen for a page element' : locks ? 'fullscreen, and the browser stops keeping Escape, Tab and Ctrl-W for itself' : 'fullscreen — this browser will still keep some keys for itself'}>{nativeFullscreen() ? 'Fullscreen' : 'Expand'}</Btn>
|
|
||||||
<a className="btn" href={kvm.url} target="_blank" rel="noreferrer" title="the device directly, outside Ham Lab — only works on the LAN">Open ↗</a>
|
|
||||||
<Btn className="btn" onClick={() => setAdding(true)}>Change</Btn>
|
|
||||||
<Btn className="btn danger" onClick={remove}>Remove</Btn>
|
|
||||||
</>}
|
|
||||||
{!kvm && <Btn className="btn on" onClick={() => setAdding(true)}>+ Add a KVM for {title}</Btn>}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{kvm ? (
|
|
||||||
<div className={'kvm-frame' + (full ? ' full' : '') + (expanded ? ' expanded' : '')} ref={box}>
|
|
||||||
<iframe ref={frame} key={kvm.id} src={`/kvm/${kvm.id}/`} title={kvm.name}
|
|
||||||
allow="fullscreen; clipboard-read; clipboard-write" />
|
|
||||||
{expanded && <button className="btn kvm-shrink" onClick={goSmall} title="back to the console">Done</button>}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="svc-empty muted kvm-none">
|
|
||||||
<div>
|
|
||||||
<p>No KVM for <b>{title}</b>.</p>
|
|
||||||
<p className="small">Add one with its address and its page appears here. The address is kept on the hub,
|
|
||||||
so it still works when {title} does not — which is when you will want it.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{kvm && !big && (
|
|
||||||
<div className="kvm-hint muted small">
|
|
||||||
Click the picture, then type — the keys go to {title}.
|
|
||||||
{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 machine={key} title={title} existing={kvm} onClose={() => setAdding(false)} onAdded={() => { setAdding(false); load() }} />}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function AddDialog({ machine, title, existing, onClose, onAdded }: { machine: string; title: string; existing: Device | null; onClose: () => void; onAdded: () => void }) {
|
|
||||||
const [url, setUrl] = useState(existing?.url ?? '')
|
|
||||||
const [name, setName] = useState(existing?.name ?? '')
|
|
||||||
const [err, setErr] = useState('')
|
|
||||||
const [busy, setBusy] = useState(false)
|
|
||||||
useEscape(onClose)
|
|
||||||
const save = async (e: FormEvent) => {
|
|
||||||
e.preventDefault(); setErr(''); setBusy(true)
|
|
||||||
try { await api.addKVM(name.trim(), url.trim(), machine); onAdded() }
|
|
||||||
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>{existing ? `Change ${title}'s KVM` : `A KVM for ${title}`}</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={`${title} KVM`} maxLength={64} />
|
|
||||||
</label>
|
|
||||||
<div className="muted small">
|
|
||||||
Ham Lab serves the device from its own address, so a KVM that speaks only plain HTTP
|
|
||||||
still works here, and it is behind this login rather than needing a second one. The
|
|
||||||
address is kept on the hub, so it still reaches the device when {title} is down. The
|
|
||||||
device itself is not changed and is never reachable from outside.
|
|
||||||
{existing && <> Saving replaces the one there now — a machine has one KVM.</>}
|
|
||||||
</div>
|
|
||||||
{err && <div className="error">{err}</div>}
|
|
||||||
<div className="svc-actions">
|
|
||||||
<button type="submit" className="btn on" disabled={busy || !url.trim()}>{existing ? 'Save' : 'Add'}</button>
|
|
||||||
<span className="spacer" />
|
|
||||||
<button type="button" className="btn" onClick={onClose}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+3
-6
@@ -15,7 +15,6 @@ import Hardware from './Hardware'
|
|||||||
import Network from './Network'
|
import Network from './Network'
|
||||||
import VPN from './VPN'
|
import VPN from './VPN'
|
||||||
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'
|
||||||
@@ -81,7 +80,6 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'usage', label: 'Usage', icon: '▧' },
|
{ id: 'usage', label: 'Usage', icon: '▧' },
|
||||||
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
{ id: 'zfs', label: 'ZFS', icon: '⛁' },
|
||||||
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
{ id: 'docker', label: 'Docker', icon: DockerIcon },
|
||||||
{ id: 'kvm', label: 'KVM', icon: '⌨' },
|
|
||||||
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
{ id: 'hardware', label: 'Hardware', icon: '⬡' },
|
||||||
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
{ id: 'updates', label: 'Updates', icon: '⇪' },
|
||||||
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
{ id: 'alerts', label: 'Alerts', icon: BellIcon },
|
||||||
@@ -91,8 +89,8 @@ const GROUPS: Group[] = [
|
|||||||
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
{ id: 'settings', label: 'Settings', icon: '⚙' },
|
||||||
]
|
]
|
||||||
|
|
||||||
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'kvm' | 'users' | 'settings'
|
type View = 'terminal' | 'widgets' | 'docker' | 'alerts' | 'services' | 'jobs' | 'updates' | 'disks' | 'hardware' | 'network' | 'vpn' | 'zfs' | 'usage' | 'files' | 'procs' | 'machines' | 'users' | 'settings'
|
||||||
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'kvm', 'users', 'settings']
|
const VIEWS: View[] = ['terminal', 'widgets', 'docker', 'alerts', 'services', 'jobs', 'updates', 'disks', 'hardware', 'network', 'vpn', 'zfs', 'usage', 'files', 'procs', 'machines', 'users', 'settings']
|
||||||
const HUB_ONLY: View[] = ['machines', 'users', 'settings', 'alerts']
|
const HUB_ONLY: View[] = ['machines', '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
|
||||||
@@ -324,7 +322,6 @@ export default function Shell({ me: hubMe, onLoggedOut }: { me: Me; onLoggedOut:
|
|||||||
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
<PageBoundary page={GROUPS.find((g) => g.id === view)?.label ?? view}>
|
||||||
{view === 'settings' && <Settings me={hubMe} />}
|
{view === 'settings' && <Settings me={hubMe} />}
|
||||||
{view === 'users' && <Users me={hubMe} />}
|
{view === 'users' && <Users me={hubMe} />}
|
||||||
{view === 'kvm' && <KVM key={machine ?? 'hub'} machine={machine} title={title} />}
|
|
||||||
{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 />}
|
||||||
@@ -366,7 +363,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 === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : 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>
|
<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 === 'hardware' ? 'Hardware' : view === 'network' ? 'Network' : view === 'vpn' ? 'VPN' : 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>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -185,7 +185,6 @@ 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 }
|
|
||||||
|
|
||||||
// ---- the Network tab ----
|
// ---- the Network tab ----
|
||||||
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
export interface IfAddr { ip: string; prefix: number; v6?: boolean }
|
||||||
@@ -360,10 +359,6 @@ 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}`),
|
||||||
// Always the hub's: it holds every machine's KVM, so the tab works when the machine does not.
|
|
||||||
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'),
|
||||||
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
zfsARC: () => req<ARCView>('GET', '/api/zfs/arc'),
|
||||||
zfsLanding: () => req<LandingCheck>('GET', '/api/zfs/landing'),
|
zfsLanding: () => req<LandingCheck>('GET', '/api/zfs/landing'),
|
||||||
|
|||||||
+4
-16
@@ -378,6 +378,10 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
|||||||
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
.preview { flex: 1; display: flex; align-items: center; justify-content: center; overflow: auto; padding: 12px; }
|
||||||
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
.preview img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
.preview video { max-width: 100%; max-height: 100%; background: #000; border-radius: 4px; outline: none; }
|
||||||
|
.preview.pic { position: relative; touch-action: pan-y pinch-zoom; }
|
||||||
|
.step-edge { position: absolute; top: 50%; transform: translateY(-50%); width: 44px; height: 72px; border: 0; border-radius: 6px; background: rgba(0, 0, 0, .35); color: #fff; font-size: 34px; line-height: 1; cursor: pointer; opacity: .55; }
|
||||||
|
.step-edge:hover { opacity: 1; } .step-edge.prev { left: 8px; } .step-edge.next { right: 8px; }
|
||||||
|
.btn.step:disabled { opacity: .4; }
|
||||||
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
/* the video player (Player.tsx): Ham Share's, in the viewer; full screen is the browser's, or
|
||||||
on an iPhone the whole window */
|
on an iPhone the whole window */
|
||||||
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
.vp { position: relative; flex: 1; min-height: 240px; background: #000; color: #fff; overflow: hidden; user-select: none; -webkit-user-select: none; outline: none; }
|
||||||
@@ -959,25 +963,9 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
|
|||||||
.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; } }
|
|
||||||
.kvm-none { text-align: center; } .kvm-none p { margin: 4px 0; }
|
|
||||||
/* No true fullscreen for a page element (Safari on iOS): fill the viewport in-page instead. */
|
|
||||||
.kvm-frame.expanded { position: fixed; inset: 0; z-index: 60; padding-top: calc(env(safe-area-inset-top) + var(--blur)); }
|
|
||||||
.kvm-frame.expanded iframe { height: 100%; }
|
|
||||||
.kvm-shrink { position: absolute; right: max(10px, env(safe-area-inset-right)); bottom: max(10px, env(safe-area-inset-bottom)); z-index: 61; background: var(--bg2); border: 1px solid var(--border); opacity: .75; }
|
|
||||||
.kvm-shrink:hover, .kvm-shrink:active { opacity: 1; }
|
|
||||||
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
.updot { margin-left: 6px; font-size: 11px; font-weight: 700; }
|
||||||
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
.updot.behind { color: var(--s2); } .updot.unknown { color: var(--muted); }
|
||||||
|
|
||||||
|
|
||||||
/* a phone: the time goes above the seek bar, so the buttons fit in one row */
|
/* a phone: the time goes above the seek bar, so the buttons fit in one row */
|
||||||
.vp-time.above { display: none; }
|
.vp-time.above { display: none; }
|
||||||
@media (max-width: 560px) {
|
@media (max-width: 560px) {
|
||||||
|
|||||||
Reference in New Issue
Block a user