Files tab: browser with breadcrumbs/sort/filter, text editor, image preview, streamed upload with progress, download, mkdir/touch/rename/move/chmod/delete, open-in-terminal; root toggle via sudo-reexecuted 'hamlab fs' helper

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 05:39:57 -07:00
co-authored by Claude Fable 5.1
parent 38d4ee09ae
commit 63b020d556
9 changed files with 1094 additions and 9 deletions
+12 -1
View File
@@ -182,7 +182,18 @@ something long in one, switch away and back, refresh — it's all still there.
MVP URL space so `host` can be inserted later without breaking clients. MVP URL space so `host` can be inserted later without breaking clients.
- **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session - **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session
list + revoke, optional NPM access list restricting to WireGuard/LAN. list + revoke, optional NPM access list restricting to WireGuard/LAN.
- **Files** — browse/upload/download/edit under `/`, with a sudo toggle. - **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from
`/` with breadcrumbs, an editable path box, sort, hidden-file toggle and a filter;
rows show type icon, size, modified, perms and owner, link targets and broken links.
Text files (≤ 2 MiB, sniffed) open in an editor with Ctrl+S / Save; images preview;
everything else downloads. Upload via drag-and-drop or picker with per-file progress
(streamed, no size limit). New folder / file, rename, move, chmod, copy path, delete
(folders need the name typed back), and "open in terminal", which `cd`s the active
shell (or a fresh one) to the folder. **Sudo toggle**: root access re-executes the
same binary as `hamlab fs <op>` under `sudo -n`, streaming bodies over stdin/stdout,
so both modes share one implementation and one set of rules (absolute paths only;
`/`, `/etc`, `/usr`, … are never deleted or moved whole). Every write is logged with
the caller's IP.
- **Docker read-only view** — `docker ps`/logs; dockhand stays the source of truth. - **Docker read-only view** — `docker ps`/logs; dockhand stays the source of truth.
- **Notifications** — push on service failure / SMART warning / UPS event. - **Notifications** — push on service failure / SMART warning / UPS event.
- **Terminal extras** — search, copy-mode, font/theme, split panes, upload via - **Terminal extras** — search, copy-mode, font/theme, split panes, upload via
+11 -2
View File
@@ -22,6 +22,7 @@ import (
"hamlab/internal/auth" "hamlab/internal/auth"
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/storage" "hamlab/internal/storage"
@@ -42,7 +43,7 @@ func main() {
} }
cfgPath := flag.String("config", defaultCfg, "path to config.json") cfgPath := flag.String("config", defaultCfg, "path to config.json")
flag.Usage = func() { flag.Usage = func() {
fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version]\n") fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|fs …]\n")
flag.PrintDefaults() flag.PrintDefaults()
} }
flag.Parse() flag.Parse()
@@ -51,6 +52,9 @@ func main() {
if flag.NArg() > 0 { if flag.NArg() > 0 {
cmd = flag.Arg(0) cmd = flag.Arg(0)
} }
if cmd == "fs" { // privileged helper for the Files tab (run via sudo by the daemon)
os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr))
}
cfg, err := config.Load(*cfgPath) cfg, err := config.Load(*cfgPath)
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
@@ -151,7 +155,12 @@ func serve(cfg *config.Config) error {
tm := tasks.New() tm := tasks.New()
up := updates.New(svc.Sudo(), tm) up := updates.New(svc.Sudo(), tm)
sto := storage.New(svc.Sudo()) sto := storage.New(svc.Sudo())
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, web.FS(), version) self, _ := os.Executable()
var sudoFS files.FS
if svc.Sudo() && self != "" {
sudoFS = files.Sudo{Self: self}
}
srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, web.FS(), version)
hs := &http.Server{ hs := &http.Server{
Addr: cfg.Listen, Addr: cfg.Listen,
Handler: srv.Handler(), Handler: srv.Handler(),
+252 -2
View File
@@ -8,9 +8,12 @@ import (
"io" "io"
"io/fs" "io/fs"
"log" "log"
"mime"
"net/http" "net/http"
"net/url"
"os" "os"
"os/user" "os/user"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -20,6 +23,7 @@ import (
"hamlab/internal/auth" "hamlab/internal/auth"
"hamlab/internal/config" "hamlab/internal/config"
"hamlab/internal/cron" "hamlab/internal/cron"
"hamlab/internal/files"
"hamlab/internal/metrics" "hamlab/internal/metrics"
"hamlab/internal/services" "hamlab/internal/services"
"hamlab/internal/storage" "hamlab/internal/storage"
@@ -43,6 +47,8 @@ type Server struct {
tasks *tasks.Manager tasks *tasks.Manager
updates *updates.Manager updates *updates.Manager
storage *storage.Manager storage *storage.Manager
fsLocal files.FS
fsSudo files.FS // nil without passwordless sudo
version string version string
started time.Time started time.Time
user string user string
@@ -51,9 +57,9 @@ type Server struct {
mux *http.ServeMux mux *http.ServeMux
} }
func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, ui fs.FS, version string) *Server { func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, ui fs.FS, version string) *Server {
host, _ := os.Hostname() host, _ := os.Hostname()
s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, ui: ui, version: version, s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, ui: ui, version: version,
started: time.Now(), host: host, user: os.Getenv("USER")} started: time.Now(), host: host, user: os.Getenv("USER")}
if u, err := user.Current(); err == nil && u.Username != "" { if u, err := user.Current(); err == nil && u.Username != "" {
s.user = u.Username s.user = u.Username
@@ -102,6 +108,17 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me
m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog)) m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog))
m.HandleFunc("POST /api/power/{action}", s.auth(s.power)) m.HandleFunc("POST /api/power/{action}", s.auth(s.power))
m.HandleFunc("POST /api/updates/install", s.auth(s.updatesInstall)) m.HandleFunc("POST /api/updates/install", s.auth(s.updatesInstall))
m.HandleFunc("GET /api/files", s.auth(s.filesList))
m.HandleFunc("GET /api/files/stat", s.auth(s.filesStat))
m.HandleFunc("GET /api/files/read", s.auth(s.filesRead))
m.HandleFunc("GET /api/files/download", s.auth(s.filesRead))
m.HandleFunc("PUT /api/files/write", s.auth(s.filesWrite))
m.HandleFunc("PUT /api/files/upload", s.auth(s.filesUpload))
m.HandleFunc("POST /api/files/mkdir", s.auth(s.filesMkdir))
m.HandleFunc("POST /api/files/touch", s.auth(s.filesTouch))
m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename))
m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete))
m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod))
m.HandleFunc("GET /api/storage", s.auth(s.storageOverview)) m.HandleFunc("GET /api/storage", s.auth(s.storageOverview))
m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart)) m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart))
m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest)) m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest))
@@ -597,6 +614,239 @@ func (s *Server) power(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(204) w.WriteHeader(204)
} }
// ---- files ----
// fsFor picks the backend: ?sudo=1 asks for root, which needs passwordless sudo.
func (s *Server) fsFor(w http.ResponseWriter, r *http.Request) (files.FS, bool) {
if r.URL.Query().Get("sudo") == "1" {
if s.fsSudo == nil {
jsonErr(w, 403, "root access needs passwordless sudo on the host")
return nil, false
}
return s.fsSudo, true
}
return s.fsLocal, true
}
func fsErr(w http.ResponseWriter, err error) {
code := 400
switch {
case errors.Is(err, os.ErrNotExist):
code = 404
case errors.Is(err, os.ErrPermission), strings.Contains(err.Error(), "permission denied"):
code = 403
}
msg := err.Error()
if pe, ok := err.(*os.PathError); ok { // "open /x: permission denied" -> "permission denied"
msg = pe.Err.Error()
}
jsonErr(w, code, msg)
}
func cleanQuery(w http.ResponseWriter, r *http.Request, key string) (string, bool) {
p, err := files.Clean(r.URL.Query().Get(key))
if err != nil {
jsonErr(w, 400, err.Error())
return "", false
}
return p, true
}
func (s *Server) filesList(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
p, ok := cleanQuery(w, r, "path")
if !ok {
return
}
l, err := fsys.List(r.Context(), p)
if err != nil {
fsErr(w, err)
return
}
jsonOK(w, 200, l)
}
func (s *Server) filesStat(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
p, ok := cleanQuery(w, r, "path")
if !ok {
return
}
in, err := fsys.Stat(r.Context(), p)
if err != nil {
fsErr(w, err)
return
}
jsonOK(w, 200, in)
}
// filesRead streams a file. /download adds an attachment header; ?max= caps the bytes.
func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
p, ok := cleanQuery(w, r, "path")
if !ok {
return
}
in, err := fsys.Stat(r.Context(), p)
if err != nil {
fsErr(w, err)
return
}
if in.Type == "dir" {
jsonErr(w, 400, "is a directory")
return
}
if mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64); mx > 0 && in.Size > mx {
jsonErr(w, 413, "file too large")
return
}
f, err := fsys.Open(r.Context(), p)
if err != nil {
fsErr(w, err)
return
}
defer f.Close()
name := filepath.Base(p)
ct := mime.TypeByExtension(filepath.Ext(name))
if ct == "" {
if in.Text {
ct = "text/plain; charset=utf-8"
} else {
ct = "application/octet-stream"
}
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("X-Content-Type-Options", "nosniff")
if strings.HasSuffix(r.URL.Path, "/download") {
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(name))
} else {
w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name))
}
if in.Size > 0 {
w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10))
}
_, _ = io.Copy(w, f)
}
func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
p, ok := cleanQuery(w, r, "path")
if !ok {
return
}
if err := fsys.Write(r.Context(), p, io.LimitReader(r.Body, 8<<20)); err != nil {
fsErr(w, err)
return
}
log.Printf("files: wrote %s (from %s)", p, clientIP(r))
w.WriteHeader(204)
}
// filesUpload streams the body into <path>/<name>.
func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
dir, ok := cleanQuery(w, r, "path")
if !ok {
return
}
name := r.URL.Query().Get("name")
if name == "" || name != filepath.Base(name) || name == "." || name == ".." {
jsonErr(w, 400, "bad file name")
return
}
dst := filepath.Join(dir, name)
if err := fsys.Write(r.Context(), dst, r.Body); err != nil {
fsErr(w, err)
return
}
log.Printf("files: uploaded %s (from %s)", dst, clientIP(r))
w.WriteHeader(201)
}
func (s *Server) filesOp(w http.ResponseWriter, r *http.Request, fn func(fsys files.FS, body map[string]any, p string) error) {
fsys, ok := s.fsFor(w, r)
if !ok {
return
}
var body map[string]any
if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil {
jsonErr(w, 400, "bad request")
return
}
p, err := files.Clean(str(body["path"]))
if err != nil {
jsonErr(w, 400, err.Error())
return
}
if err := fn(fsys, body, p); err != nil {
fsErr(w, err)
return
}
w.WriteHeader(204)
}
func str(v any) string { s, _ := v.(string); return s }
func (s *Server) filesMkdir(w http.ResponseWriter, r *http.Request) {
s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error {
log.Printf("files: mkdir %s (from %s)", p, clientIP(r))
return fsys.Mkdir(r.Context(), p)
})
}
func (s *Server) filesTouch(w http.ResponseWriter, r *http.Request) {
s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error {
log.Printf("files: create %s (from %s)", p, clientIP(r))
return fsys.Touch(r.Context(), p)
})
}
func (s *Server) filesRename(w http.ResponseWriter, r *http.Request) {
s.filesOp(w, r, func(fsys files.FS, body map[string]any, from string) error {
to, err := files.Clean(str(body["to"]))
if err != nil {
return err
}
log.Printf("files: rename %s -> %s (from %s)", from, to, clientIP(r))
return fsys.Rename(r.Context(), from, to)
})
}
func (s *Server) filesDelete(w http.ResponseWriter, r *http.Request) {
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
rec, _ := body["recursive"].(bool)
log.Printf("files: delete %s recursive=%v (from %s)", p, rec, clientIP(r))
return fsys.Delete(r.Context(), p, rec)
})
}
func (s *Server) filesChmod(w http.ResponseWriter, r *http.Request) {
s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error {
perm, err := files.ParsePerm(str(body["mode"]))
if err != nil {
return err
}
log.Printf("files: chmod %04o %s (from %s)", perm, p, clientIP(r))
return fsys.Chmod(r.Context(), p, perm)
})
}
// ---- storage (Disks tab) ---- // ---- storage (Disks tab) ----
func (s *Server) updatesInstall(w http.ResponseWriter, r *http.Request) { func (s *Server) updatesInstall(w http.ResponseWriter, r *http.Request) {
+496
View File
@@ -0,0 +1,496 @@
// Package files is the Files tab: browse, read, write, upload, download, mkdir,
// rename, chmod and delete anywhere on the host.
//
// Two backends share one interface. Local runs in-process as the daemon's user. Sudo
// re-executes this same binary as root (`sudo -n hamlab fs …`) for one operation at a
// time, streaming file bodies over its stdin/stdout, so root access uses exactly the
// same code and the same validation as the unprivileged path.
package files
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"os/user"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
"time"
)
type Entry struct {
Name string `json:"name"`
Type string `json:"type"` // dir | file | link | other
Size int64 `json:"size"`
Mode string `json:"mode"` // "-rw-r--r--"
Perm string `json:"perm"` // "0644"
Mtime int64 `json:"mtime"`
User string `json:"user"`
Group string `json:"group"`
Target string `json:"target,omitempty"` // link target
Broken bool `json:"broken,omitempty"` // dangling link
}
type Listing struct {
Path string `json:"path"`
Parent string `json:"parent,omitempty"`
Entries []Entry `json:"entries"`
}
type Info struct {
Entry
Path string `json:"path"`
Text bool `json:"text"` // looks like text (for the editor)
}
// FS is what the API talks to.
type FS interface {
List(ctx context.Context, path string) (Listing, error)
Stat(ctx context.Context, path string) (Info, error)
Open(ctx context.Context, path string) (io.ReadCloser, error)
Write(ctx context.Context, path string, r io.Reader) error
Mkdir(ctx context.Context, path string) error
Touch(ctx context.Context, path string) error
Rename(ctx context.Context, from, to string) error
Delete(ctx context.Context, path string, recursive bool) error
Chmod(ctx context.Context, path string, perm os.FileMode) error
}
// ---- path rules ----
// Clean rejects relative paths and normalises the rest. Everything else on the host is
// reachable on purpose: this is a root-capable console, the gate is the login.
func Clean(p string) (string, error) {
if p == "" {
return "", errors.New("path required")
}
if !strings.HasPrefix(p, "/") {
return "", errors.New("path must be absolute")
}
c := filepath.Clean(p)
if strings.ContainsRune(c, 0) {
return "", errors.New("bad path")
}
return c, nil
}
// protected are never deleted or renamed as a whole, no matter who asks.
var protected = map[string]bool{"/": true, "/bin": true, "/boot": true, "/dev": true, "/etc": true, "/home": true, "/lib": true, "/lib64": true,
"/opt": true, "/proc": true, "/root": true, "/run": true, "/sbin": true, "/srv": true, "/sys": true, "/tmp": true, "/usr": true, "/var": true}
func ParsePerm(s string) (os.FileMode, error) {
v, err := strconv.ParseUint(strings.TrimPrefix(s, "0"), 8, 32)
if err != nil || v > 0o7777 {
return 0, errors.New("mode must be octal like 0644")
}
return os.FileMode(v), nil
}
// ---- Local ----
type Local struct{}
var uidCache, gidCache = map[uint32]string{}, map[uint32]string{}
func owner(st os.FileInfo) (string, string) {
sys, ok := st.Sys().(*syscall.Stat_t)
if !ok {
return "", ""
}
u, ok := uidCache[sys.Uid]
if !ok {
u = strconv.Itoa(int(sys.Uid))
if x, err := user.LookupId(u); err == nil {
u = x.Username
}
uidCache[sys.Uid] = u
}
g, ok := gidCache[sys.Gid]
if !ok {
g = strconv.Itoa(int(sys.Gid))
if x, err := user.LookupGroupId(g); err == nil {
g = x.Name
}
gidCache[sys.Gid] = g
}
return u, g
}
func entryOf(dir, name string, lst os.FileInfo) Entry {
e := Entry{Name: name, Size: lst.Size(), Mode: lst.Mode().String(), Perm: fmt.Sprintf("%04o", lst.Mode().Perm()), Mtime: lst.ModTime().UnixMilli()}
e.User, e.Group = owner(lst)
switch {
case lst.Mode()&os.ModeSymlink != 0:
e.Type = "link"
full := filepath.Join(dir, name)
e.Target, _ = os.Readlink(full)
if st, err := os.Stat(full); err != nil {
e.Broken = true
} else if st.IsDir() {
e.Type = "dir" // keep dir semantics for navigation; Target says it's a link
} else {
e.Size = st.Size()
}
case lst.IsDir():
e.Type = "dir"
case lst.Mode().IsRegular():
e.Type = "file"
default:
e.Type = "other"
}
return e
}
func (Local) List(_ context.Context, path string) (Listing, error) {
ents, err := os.ReadDir(path)
if err != nil {
return Listing{}, err
}
l := Listing{Path: path, Entries: make([]Entry, 0, len(ents))}
if path != "/" {
l.Parent = filepath.Dir(path)
}
for _, d := range ents {
lst, err := d.Info() // Lstat
if err != nil {
continue
}
l.Entries = append(l.Entries, entryOf(path, d.Name(), lst))
}
sort.Slice(l.Entries, func(i, j int) bool {
a, b := l.Entries[i], l.Entries[j]
if (a.Type == "dir") != (b.Type == "dir") {
return a.Type == "dir"
}
return strings.ToLower(a.Name) < strings.ToLower(b.Name)
})
return l, nil
}
func (Local) Stat(_ context.Context, path string) (Info, error) {
lst, err := os.Lstat(path)
if err != nil {
return Info{}, err
}
in := Info{Entry: entryOf(filepath.Dir(path), filepath.Base(path), lst), Path: path}
if in.Type == "file" && in.Size <= 8<<20 {
if f, err := os.Open(path); err == nil {
buf := make([]byte, 4096)
n, _ := f.Read(buf)
f.Close()
in.Text = looksText(buf[:n])
}
}
return in, nil
}
func looksText(b []byte) bool {
if len(b) == 0 {
return true
}
ctrl := 0
for _, c := range b {
if c == 0 {
return false
}
if c < 32 && c != '\n' && c != '\r' && c != '\t' && c != '\f' && c != 0x1b {
ctrl++
}
}
return ctrl*20 < len(b)
}
func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) {
st, err := os.Stat(path)
if err != nil {
return nil, err
}
if st.IsDir() {
return nil, errors.New("is a directory")
}
return os.Open(path)
}
// Write replaces the file's contents, keeping its mode; new files get 0644 (umask).
func (Local) Write(_ context.Context, path string, r io.Reader) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
return err
}
if _, err := io.Copy(f, r); err != nil {
f.Close()
return err
}
return f.Close()
}
func (Local) Mkdir(_ context.Context, path string) error { return os.Mkdir(path, 0o755) }
func (Local) Touch(_ context.Context, path string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644)
if err != nil {
return err
}
return f.Close()
}
func (Local) Rename(_ context.Context, from, to string) error {
if protected[from] {
return errors.New("refusing to move " + from)
}
if _, err := os.Lstat(to); err == nil {
return errors.New("destination exists")
}
return os.Rename(from, to)
}
func (Local) Delete(_ context.Context, path string, recursive bool) error {
if protected[path] {
return errors.New("refusing to delete " + path)
}
lst, err := os.Lstat(path)
if err != nil {
return err
}
if lst.IsDir() && recursive {
return os.RemoveAll(path)
}
return os.Remove(path) // fails on a non-empty dir, by design
}
func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error {
return os.Chmod(path, perm)
}
// ---- Sudo: the same operations, run as root in a child hamlab ----
type Sudo struct{ Self string }
func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd {
full := append([]string{"sudo", "-n", s.Self, "fs"}, args...)
return exec.CommandContext(ctx, full[0], full[1:]...)
}
func (s Sudo) runJSON(ctx context.Context, out any, args ...string) error {
c := s.cmd(ctx, args...)
var errb strings.Builder
c.Stderr = &errb
b, err := c.Output()
if err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
if out == nil {
return nil
}
return json.Unmarshal(b, out)
}
func (s Sudo) List(ctx context.Context, path string) (Listing, error) {
var l Listing
err := s.runJSON(ctx, &l, "list", path)
return l, err
}
func (s Sudo) Stat(ctx context.Context, path string) (Info, error) {
var in Info
err := s.runJSON(ctx, &in, "stat", path)
return in, err
}
type cmdReader struct {
io.ReadCloser
cmd *exec.Cmd
errb *strings.Builder
}
func (r *cmdReader) Close() error {
r.ReadCloser.Close()
err := r.cmd.Wait()
if err != nil && r.errb.Len() > 0 {
return errors.New(strings.TrimSpace(r.errb.String()))
}
return err
}
func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) {
c := s.cmd(ctx, "read", path)
errb := &strings.Builder{}
c.Stderr = errb
out, err := c.StdoutPipe()
if err != nil {
return nil, err
}
if err := c.Start(); err != nil {
return nil, err
}
// Fail early on "no such file" etc.: the helper stats before writing anything.
head := make([]byte, 1)
n, rerr := out.Read(head)
if n == 0 && rerr != nil {
_ = c.Wait()
if msg := strings.TrimSpace(errb.String()); msg != "" {
return nil, errors.New(msg)
}
if rerr != io.EOF {
return nil, rerr
}
}
return &cmdReader{ReadCloser: struct {
io.Reader
io.Closer
}{io.MultiReader(strings.NewReader(string(head[:n])), out), out}, cmd: c, errb: errb}, nil
}
func (s Sudo) Write(ctx context.Context, path string, r io.Reader) error {
c := s.cmd(ctx, "write", path)
c.Stdin = r
var errb strings.Builder
c.Stderr = &errb
if err := c.Run(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
func (s Sudo) Mkdir(ctx context.Context, path string) error {
return s.runJSON(ctx, nil, "mkdir", path)
}
func (s Sudo) Touch(ctx context.Context, path string) error {
return s.runJSON(ctx, nil, "touch", path)
}
func (s Sudo) Rename(ctx context.Context, from, to string) error {
return s.runJSON(ctx, nil, "rename", from, to)
}
func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error {
if recursive {
return s.runJSON(ctx, nil, "delete", "-r", path)
}
return s.runJSON(ctx, nil, "delete", path)
}
func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error {
return s.runJSON(ctx, nil, "chmod", fmt.Sprintf("%04o", perm), path)
}
// Helper implements `hamlab fs <op> …` for the Sudo backend. Returns the exit code.
func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int {
fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 }
if len(args) < 2 {
return fail(errors.New("usage: hamlab fs <list|stat|read|write|mkdir|touch|rename|delete|chmod> …"))
}
ctx := context.Background()
var l Local
op := args[0]
clean := func(i int) (string, error) {
if i >= len(args) {
return "", errors.New("path required")
}
return Clean(args[i])
}
switch op {
case "list", "stat", "read", "write", "mkdir", "touch":
p, err := clean(1)
if err != nil {
return fail(err)
}
switch op {
case "list":
out, err := l.List(ctx, p)
if err != nil {
return fail(err)
}
return emit(stdout, out, stderr)
case "stat":
out, err := l.Stat(ctx, p)
if err != nil {
return fail(err)
}
return emit(stdout, out, stderr)
case "read":
f, err := l.Open(ctx, p)
if err != nil {
return fail(err)
}
defer f.Close()
if _, err := io.Copy(stdout, f); err != nil {
return fail(err)
}
return 0
case "write":
if err := l.Write(ctx, p, stdin); err != nil {
return fail(err)
}
case "mkdir":
if err := l.Mkdir(ctx, p); err != nil {
return fail(err)
}
case "touch":
if err := l.Touch(ctx, p); err != nil {
return fail(err)
}
}
case "rename":
from, err := clean(1)
if err != nil {
return fail(err)
}
to, err := clean(2)
if err != nil {
return fail(err)
}
if err := l.Rename(ctx, from, to); err != nil {
return fail(err)
}
case "delete":
rec := false
i := 1
if args[1] == "-r" {
rec, i = true, 2
}
p, err := clean(i)
if err != nil {
return fail(err)
}
if err := l.Delete(ctx, p, rec); err != nil {
return fail(err)
}
case "chmod":
perm, err := ParsePerm(args[1])
if err != nil {
return fail(err)
}
p, err := clean(2)
if err != nil {
return fail(err)
}
if err := l.Chmod(ctx, p, perm); err != nil {
return fail(err)
}
default:
return fail(errors.New("unknown op " + op))
}
fmt.Fprintln(stdout, "{}")
return 0
}
func emit(w io.Writer, v any, stderr io.Writer) int {
if err := json.NewEncoder(w).Encode(v); err != nil {
fmt.Fprintln(stderr, err.Error())
return 1
}
return 0
}
// ModTime helper for handlers.
func Now() time.Time { return time.Now() }
+233
View File
@@ -0,0 +1,233 @@
import { DragEvent, useCallback, useEffect, useRef, useState } from 'react'
import { api, FileEntry, FileInfo } from './api'
import { fmtBytes } from './Dashboard'
import { Btn, Loading, Spinner, toast } from './ui'
const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name)
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 shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'"
const MAX_EDIT = 2 * 1024 * 1024
function icon(e: FileEntry): string {
if (e.type === 'dir') return e.target ? '🔗' : '📁'
if (e.type === 'link') return e.broken ? '⛓️‍💥' : '🔗'
if (e.type === 'other') return '⚙️'
if (isImage(e.name)) return '🖼️'
if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️'
if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜'
if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄'
return '📄'
}
type Sort = 'name' | 'size' | 'mtime'
// ---- editor / viewer ----
function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) {
const [info, setInfo] = useState<FileInfo | null>(null)
const [text, setText] = useState<string | null>(null)
const [orig, setOrig] = useState('')
const [err, setErr] = useState('')
const [saving, setSaving] = useState(false)
const name = path.split('/').pop() ?? path
const dirty = text !== null && text !== orig
useEffect(() => {
setInfo(null); setText(null); setErr('')
let stop = false
;(async () => {
try {
const i = await api.fileStat(path, sudo)
if (stop) return
setInfo(i)
if (i.text && i.size <= MAX_EDIT && !isImage(name)) {
const t = await api.fileRead(path, sudo)
if (stop) return
setText(t); setOrig(t)
}
} catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') }
})()
return () => { stop = true }
}, [path, sudo, name])
const save = useCallback(async () => {
if (text === null || !dirty) return
setSaving(true)
try { await api.fileWrite(path, text, sudo); setOrig(text); toast(`${name} saved`); onChanged() }
catch (e) { toast(`save failed — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
finally { setSaving(false) }
}, [text, dirty, path, sudo, name, onChanged])
useEffect(() => {
const h = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save() } }
window.addEventListener('keydown', h)
return () => window.removeEventListener('keydown', h)
}, [save])
const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; onClose() }
const dl = api.fileDownloadURL(path, sudo)
return (
<div className="viewer">
<div className="viewer-bar">
<button className="btn" onClick={close}>‹ Back</button>
<span className="vname mono" title={path}>{name}{dirty && <span className="dirty"> •</span>}</span>
<span className="spacer" />
{info && <span className="muted num">{fmtBytes(info.size)} · {info.perm} · {info.user}</span>}
<a className="btn" href={dl} download={name}>Download</a>
{text !== null && <Btn className="btn on" disabled={!dirty || saving} onClick={save}>Save</Btn>}
</div>
{err && <div className="error">{err}</div>}
{!info && !err && <Loading label="Opening…" />}
{info && text !== null && (
<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) && !err && (
<div className="notice">{info.type === 'dir' ? 'This is a directory.' : info.size > MAX_EDIT ? `Too large to edit here (${fmtBytes(info.size)}). Download it instead.` : 'Binary file. Download it instead.'}</div>
)}
</div>
)
}
// ---- page ----
export default function Files({ onOpenInTerminal }: { onOpenInTerminal?: (cmd: string) => void }) {
const [path, setPath] = useState<string>(() => sessionStorage.getItem('hamlab.files.path') || '/')
const [sudo, setSudo] = useState<boolean>(() => sessionStorage.getItem('hamlab.files.sudo') === '1')
const [entries, setEntries] = useState<FileEntry[] | null>(null)
const [err, setErr] = useState('')
const [hidden, setHidden] = useState(false)
const [sort, setSort] = useState<Sort>('name')
const [q, setQ] = useState('')
const [pathInput, setPathInput] = useState(path)
const [open, setOpen] = useState<string | null>(null)
const [menu, setMenu] = useState<string | null>(null)
const [upload, setUpload] = useState<{ name: string; pct: number; n: number; of: number } | null>(null)
const [drag, setDrag] = useState(false)
const fileInput = useRef<HTMLInputElement>(null)
const canSudo = useRef(true)
const load = useCallback(async (p = path, s = sudo) => {
try {
const l = await api.files(p, s)
setEntries(l.entries); setErr('')
canSudo.current = true
} catch (e) {
const msg = e instanceof Error ? e.message : 'failed'
setErr(msg); setEntries([])
}
}, [path, sudo])
useEffect(() => { setEntries(null); setPathInput(path); setMenu(null); load(path, sudo); sessionStorage.setItem('hamlab.files.path', path); sessionStorage.setItem('hamlab.files.sudo', sudo ? '1' : '0') }, [path, sudo, load])
useEffect(() => { const h = () => setMenu(null); window.addEventListener('click', h); return () => window.removeEventListener('click', h) }, [])
const go = (p: string) => { setOpen(null); setPath(p || '/') }
const crumbs = path === '/' ? [] : path.slice(1).split('/')
const shown = (entries ?? []).filter((e) => (hidden || !e.name.startsWith('.')) && (!q || e.name.toLowerCase().includes(q.toLowerCase())))
.sort((a, b) => {
if ((a.type === 'dir') !== (b.type === 'dir')) return a.type === 'dir' ? -1 : 1
if (sort === 'size') return b.size - a.size
if (sort === 'mtime') return b.mtime - a.mtime
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' })
})
// ---- actions ----
const act = async (label: string, fn: () => Promise<unknown>) => {
try { await fn(); toast(label); await load() } catch (e) { toast(`${label} — ${e instanceof Error ? e.message : 'failed'}`, 'err') }
}
const mkdir = () => { const n = window.prompt('New folder name'); if (n?.trim()) act(`created ${n.trim()}`, () => api.fileMkdir(join(path, n.trim()), sudo)) }
const touch = () => { const n = window.prompt('New file name'); if (n?.trim()) act(`created ${n.trim()}`, () => api.fileTouch(join(path, n.trim()), sudo)).then(() => setOpen(join(path, n.trim()))) }
const rename = (e: FileEntry) => { const n = window.prompt('Rename to', e.name); if (n?.trim() && n.trim() !== e.name) act(`renamed to ${n.trim()}`, () => api.fileRename(join(path, e.name), join(path, n.trim()), sudo)) }
const move = (e: FileEntry) => { const n = window.prompt('Move to (absolute path)', join(path, e.name)); if (n?.trim() && n.trim() !== join(path, e.name)) act(`moved to ${n.trim()}`, () => api.fileRename(join(path, e.name), n.trim(), sudo)) }
const chmod = (e: FileEntry) => { const n = window.prompt(`Permissions for ${e.name} (octal)`, e.perm); if (n?.trim() && n.trim() !== e.perm) act(`chmod ${n.trim()} ${e.name}`, () => api.fileChmod(join(path, e.name), n.trim(), sudo)) }
const del = (e: FileEntry) => {
const full = join(path, e.name)
if (e.type === 'dir' && !e.target) {
const typed = window.prompt(`Delete the folder "${e.name}" and everything in it?\nType its name to confirm.`)
if (typed !== e.name) return
act(`deleted ${e.name}`, () => api.fileDelete(full, true, sudo))
} else if (window.confirm(`Delete ${e.name}?`)) act(`deleted ${e.name}`, () => api.fileDelete(full, false, sudo))
}
const copyPath = async (e: FileEntry) => { try { await navigator.clipboard.writeText(join(path, e.name)); toast('path copied') } catch { window.prompt('Path', join(path, e.name)) } }
const openTerm = (p: string) => { onOpenInTerminal?.(`cd ${shellQuote(p)}`); }
// ---- upload ----
const uploadFiles = async (files: FileList | File[]) => {
const list = Array.from(files)
for (let i = 0; i < list.length; i++) {
const f = list[i]
setUpload({ name: f.name, pct: 0, n: i + 1, of: list.length })
try {
await api.fileUpload(path, f, sudo, (pct) => setUpload({ name: f.name, pct, n: i + 1, of: list.length }))
} catch (e) { toast(`upload ${f.name} — ${e instanceof Error ? e.message : 'failed'}`, 'err'); break }
}
setUpload(null)
toast(list.length === 1 ? `uploaded ${list[0].name}` : `uploaded ${list.length} files`)
load()
}
const onDrop = (e: DragEvent) => { e.preventDefault(); setDrag(false); if (e.dataTransfer.files.length) uploadFiles(e.dataTransfer.files) }
if (open) return <div className="files"><Viewer path={open} sudo={sudo} onClose={() => setOpen(null)} onChanged={() => load()} /></div>
return (
<div className={'files' + (drag ? ' drag' : '')} onDragOver={(e) => { e.preventDefault(); setDrag(true) }} onDragLeave={() => setDrag(false)} onDrop={onDrop}>
<div className="files-bar">
<form className="crumbs" onSubmit={(e) => { e.preventDefault(); go(pathInput.trim()) }}>
<button type="button" className="crumb" onClick={() => go('/')}>/</button>
{crumbs.map((c, i) => <button type="button" key={i} className="crumb" onClick={() => go('/' + crumbs.slice(0, i + 1).join('/'))}>{c}</button>)}
<input className="path-input" value={pathInput} onChange={(e) => setPathInput(e.target.value)} onBlur={() => setPathInput(path)} spellCheck={false} />
</form>
<div className="files-tools">
<button className={'btn' + (sudo ? ' on' : '')} title="browse as root" onClick={() => setSudo(!sudo)}>{sudo ? 'root' : 'sudo'}</button>
<button className={'btn' + (hidden ? ' on' : '')} title="show hidden files" onClick={() => setHidden(!hidden)}>.hidden</button>
<select className="btn" value={sort} onChange={(e) => setSort(e.target.value as Sort)}><option value="name">name</option><option value="size">size</option><option value="mtime">modified</option></select>
<input className="filter" placeholder="filter" value={q} onChange={(e) => setQ(e.target.value)} />
<button className="btn" onClick={mkdir}>+ folder</button>
<button className="btn" onClick={touch}>+ file</button>
<button className="btn" onClick={() => fileInput.current?.click()}>Upload</button>
<input ref={fileInput} type="file" multiple style={{ display: 'none' }} onChange={(e) => { if (e.target.files?.length) uploadFiles(e.target.files); e.target.value = '' }} />
{onOpenInTerminal && <button className="btn" title="cd here in the active terminal" onClick={() => openTerm(path)}>&gt;_ here</button>}
<Btn className="btn" title="reload" onClick={() => load()}>↻</Btn>
</div>
</div>
{upload && <div className="upload-bar"><Spinner /> {upload.of > 1 ? `${upload.n}/${upload.of} · ` : ''}{upload.name} · {upload.pct}%<div className="meter"><div className="fill" style={{ width: `${upload.pct}%` }} /></div></div>}
{err && <div className="error" style={{ padding: '6px 12px' }}>{err}{/permission denied/i.test(err) && !sudo && <> · <button className="link" onClick={() => setSudo(true)}>retry as root</button></>}</div>}
<div className="file-list">
{entries === null && <Loading label="Listing…" />}
{path !== '/' && entries !== null && (
<div className="file-row up" onClick={() => go(path.slice(0, path.lastIndexOf('/')) || '/')}><span className="ficon">↩</span><span className="fname">..</span></div>
)}
{shown.map((e) => {
const full = join(path, e.name)
const isDir = e.type === 'dir'
return (
<div key={e.name} className={'file-row' + (e.broken ? ' broken' : '')} onClick={() => (isDir ? go(full) : setOpen(full))}>
<span className="ficon">{icon(e)}</span>
<span className="fname" title={e.target ? `→ ${e.target}` : e.name}>{e.name}{e.target && <span className="muted"> → {e.target}</span>}</span>
<span className="fmeta num">{isDir ? '' : fmtBytes(e.size)}</span>
<span className="fmeta num">{fmtTime(e.mtime)}</span>
<span className="fmeta num perm">{e.perm} {e.user}</span>
<button className="icon-btn more" onClick={(ev) => { ev.stopPropagation(); setMenu(menu === e.name ? null : e.name) }}>⋯</button>
{menu === e.name && (
<div className="ctx" onClick={(ev) => ev.stopPropagation()}>
{!isDir && <a href={api.fileDownloadURL(full, sudo)} download={e.name} onClick={() => setMenu(null)}>Download</a>}
{isDir && onOpenInTerminal && <button onClick={() => { setMenu(null); openTerm(full) }}>Open in terminal</button>}
<button onClick={() => { setMenu(null); rename(e) }}>Rename</button>
<button onClick={() => { setMenu(null); move(e) }}>Move…</button>
<button onClick={() => { setMenu(null); chmod(e) }}>Permissions ({e.perm})</button>
<button onClick={() => { setMenu(null); copyPath(e) }}>Copy path</button>
<button className="danger" onClick={() => { setMenu(null); del(e) }}>Delete</button>
</div>
)}
</div>
)
})}
{entries !== null && shown.length === 0 && !err && <div className="muted" style={{ padding: 12 }}>{q ? 'Nothing matches.' : 'Empty folder.'}{!hidden && entries.some((e) => e.name.startsWith('.')) ? ' (hidden files not shown)' : ''}</div>}
</div>
{drag && <div className="drop-hint">Drop to upload into {path}</div>}
</div>
)
}
+16 -2
View File
@@ -8,6 +8,7 @@ import Services from './Services'
import Cron from './Cron' import Cron from './Cron'
import Updates from './Updates' import Updates from './Updates'
import Disks from './Disks' import Disks from './Disks'
import Files from './Files'
import Power from './Power' import Power from './Power'
import { Btn, NetBar, Toasts, toast } from './ui' import { Btn, NetBar, Toasts, toast } from './ui'
@@ -18,9 +19,10 @@ const GROUPS: Group[] = [
{ id: 'cron', label: 'Cron', icon: '⏱', page: true }, { id: 'cron', label: 'Cron', icon: '⏱', page: true },
{ id: 'updates', label: 'Updates', icon: '⇪', page: true }, { id: 'updates', label: 'Updates', icon: '⇪', page: true },
{ id: 'disks', label: 'Disks', icon: '▤', page: true }, { id: 'disks', label: 'Disks', icon: '▤', page: true },
{ id: 'files', label: 'Files', icon: '🗀', page: true },
] ]
type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' type View = 'terminal' | 'dashboard' | 'services' | 'cron' | 'updates' | 'disks' | 'files'
function fmtUptime(s: number): string { function fmtUptime(s: number): string {
const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60) const d = Math.floor(s / 86400), h = Math.floor((s % 86400) / 3600), m = Math.floor((s % 3600) / 60)
@@ -68,6 +70,17 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
} }
async function logout() { await api.logout(); onLoggedOut() } async function logout() { await api.logout(); onLoggedOut() }
// Files → "open in terminal": type into the active shell (creating one if needed) and switch to it.
async function runInTerminal(cmd: string) {
let id = active
if (!id || !terms.some((t) => t.id === id && t.running)) {
try { const t = await api.createTerminal(); await reload(); id = t.id; setActive(t.id) } catch (e) { toast(`terminal — ${e instanceof Error ? e.message : 'failed'}`, 'err'); return }
}
setView('terminal'); setDrawer(false)
const send = (tries: number) => { const h = handles.current[id!]; if (h) h.input(cmd + '\n'); else if (tries > 0) window.setTimeout(() => send(tries - 1), 100) }
window.setTimeout(() => send(20), 50)
}
const activeTerm = terms.find((t) => t.id === active) const activeTerm = terms.find((t) => t.id === active)
const termView = view === 'terminal' const termView = view === 'terminal'
@@ -99,6 +112,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
{view === 'cron' && <Cron />} {view === 'cron' && <Cron />}
{view === 'updates' && <Updates />} {view === 'updates' && <Updates />}
{view === 'disks' && <Disks />} {view === 'disks' && <Disks />}
{view === 'files' && <Files onOpenInTerminal={runInTerminal} />}
{termView && terms.length === 0 && ( {termView && terms.length === 0 && (
<div className="center muted"> <div className="center muted">
<div>No terminals.</div> <div>No terminals.</div>
@@ -121,7 +135,7 @@ export default function Shell({ me, onLoggedOut }: { me: Me; onLoggedOut: () =>
<span>{me.version.startsWith('v') ? me.version : 'v' + me.version}</span> <span>{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 === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : activeTerm ? activeTerm.name : ''}</span> <span className="muted">{view === 'dashboard' ? 'Dashboard' : view === 'services' ? 'Services' : view === 'cron' ? 'Cron' : view === 'updates' ? 'Updates' : view === 'disks' ? 'Disks' : view === 'files' ? 'Files' : activeTerm ? activeTerm.name : ''}</span>
</footer> </footer>
</div> </div>
) )
+3 -2
View File
@@ -51,7 +51,8 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
let lastSize = '' let lastSize = ''
const enc = new TextEncoder() const enc = new TextEncoder()
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)) } const queued: string[] = []
const send = (s: string) => { if (ws && ws.readyState === WebSocket.OPEN) ws.send(enc.encode(s)); else queued.push(s) }
const sendResize = () => { const sendResize = () => {
const key = `${term.cols}x${term.rows}` const key = `${term.cols}x${term.rows}`
if (key === lastSize) return if (key === lastSize) return
@@ -69,7 +70,7 @@ export default function TerminalPane({ id, active, register, onStatus, onExit }:
const sock = new WebSocket(wsURL(id)) const sock = new WebSocket(wsURL(id))
sock.binaryType = 'arraybuffer' sock.binaryType = 'arraybuffer'
ws = sock ws = sock
sock.onopen = () => { retry = 0; setConn('open'); cbs.current.onStatus(true); lastSize = ''; doFit() } sock.onopen = () => { retry = 0; setConn('open'); cbs.current.onStatus(true); lastSize = ''; doFit(); while (queued.length) sock.send(enc.encode(queued.shift()!)) }
sock.onmessage = (ev) => { sock.onmessage = (ev) => {
if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return } if (ev.data instanceof ArrayBuffer) { term.write(new Uint8Array(ev.data)); return }
try { try {
+33
View File
@@ -76,7 +76,40 @@ export interface SmartInfo { device: string; available: boolean; supported: bool
export interface ZFSSnapshot { name: string; used: number; created: number } export interface ZFSSnapshot { name: string; used: number; created: number }
export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] } export interface ZFSInfo { available: boolean; message?: string; pools: { name: string; size: number; alloc: number; free: number; frag: number; cap: number; health: string; dedup: string; scan?: string; errors?: string; config?: string }[]; datasets: { name: string; type: string; used: number; avail: number; refer: number; used_by_snapshots: number; mount?: string; compressratio: string; snapshots: number }[] }
export interface FileEntry { name: string; type: 'dir' | 'file' | 'link' | 'other'; size: number; mode: string; perm: string; mtime: number; user: string; group: string; target?: string; broken?: boolean }
export interface FileInfo extends FileEntry { path: string; text: boolean }
const q = (path: string, sudo: boolean, extra = '') => `path=${encodeURIComponent(path)}${sudo ? '&sudo=1' : ''}${extra}`
export const api = { export const api = {
files: (path: string, sudo: boolean) => req<{ path: string; parent?: string; entries: FileEntry[] }>('GET', `/api/files?${q(path, sudo)}`),
fileStat: (path: string, sudo: boolean) => req<FileInfo>('GET', `/api/files/stat?${q(path, sudo)}`),
fileRead: async (path: string, sudo: boolean): Promise<string> => {
const r = await fetch(`/api/files/read?${q(path, sudo, '&max=2097152')}`, { credentials: 'same-origin' })
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
return r.text()
},
fileReadURL: (path: string, sudo: boolean) => `/api/files/read?${q(path, sudo)}`,
fileDownloadURL: (path: string, sudo: boolean) => `/api/files/download?${q(path, sudo)}`,
fileWrite: async (path: string, text: string, sudo: boolean) => {
const r = await fetch(`/api/files/write?${q(path, sudo)}`, { method: 'PUT', credentials: 'same-origin', body: text, headers: { 'Content-Type': 'application/octet-stream' } })
if (r.status === 401) throw new Unauthenticated()
if (!r.ok) { let m = r.statusText; try { m = (await r.json()).error ?? m } catch { /* */ } throw new Error(m) }
},
fileUpload: (dir: string, file: File, sudo: boolean, onProgress: (pct: number) => void) => new Promise<void>((resolve, reject) => {
const x = new XMLHttpRequest()
x.open('PUT', `/api/files/upload?${q(dir, sudo)}&name=${encodeURIComponent(file.name)}`)
x.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) }
x.onload = () => { if (x.status >= 200 && x.status < 300) resolve(); else { let m = x.statusText; try { m = JSON.parse(x.responseText).error ?? m } catch { /* */ } reject(new Error(m)) } }
x.onerror = () => reject(new Error('network error'))
x.send(file)
}),
fileMkdir: (path: string, sudo: boolean) => req<void>('POST', `/api/files/mkdir${sudo ? '?sudo=1' : ''}`, { path }),
fileTouch: (path: string, sudo: boolean) => req<void>('POST', `/api/files/touch${sudo ? '?sudo=1' : ''}`, { path }),
fileRename: (path: string, to: string, sudo: boolean) => req<void>('POST', `/api/files/rename${sudo ? '?sudo=1' : ''}`, { path, to }),
fileDelete: (path: string, recursive: boolean, sudo: boolean) => req<void>('POST', `/api/files/delete${sudo ? '?sudo=1' : ''}`, { path, recursive }),
fileChmod: (path: string, mode: string, sudo: boolean) => req<void>('POST', `/api/files/chmod${sudo ? '?sudo=1' : ''}`, { path, mode }),
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}`),
+38
View File
@@ -249,3 +249,41 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
.ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; } .ds-snaps { padding: 8px 12px 10px 40px; border-bottom: 1px solid var(--border); background: var(--bg); display: flex; flex-direction: column; gap: 6px; }
.snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; } .snap-list { display: flex; flex-direction: column; gap: 2px; max-height: 40vh; overflow-y: auto; }
.snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; } .snap { display: flex; justify-content: space-between; gap: 12px; font-size: 12px; }
/* files */
.files { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--bg); }
.files.drag { outline: 2px dashed var(--accent2); outline-offset: -4px; }
.files-bar { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; background: var(--bg2); border-bottom: 1px solid var(--border); }
.crumbs { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
.crumb { padding: 3px 6px; border-radius: 4px; font-family: ui-monospace, monospace; font-size: 13px; color: var(--accent2); }
.crumb:hover { background: var(--bg3); } .crumb + .crumb::before { content: '/'; color: var(--muted); margin-right: 4px; }
.path-input { flex: 1; min-width: 160px; margin-left: 8px; padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--muted); font: 12px ui-monospace, monospace; }
.path-input:focus { color: var(--fg); border-color: var(--accent2); outline: none; }
.files-tools { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
.files-tools .filter { width: 110px; padding: 5px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--fg); font-size: 13px; }
.file-list { flex: 1; overflow-y: auto; }
.file-row { display: grid; grid-template-columns: 28px 1fr 80px 80px 130px 32px; gap: 8px; align-items: center; padding: 7px 10px; border-bottom: 1px solid var(--border); cursor: pointer; position: relative; }
.file-row:hover { background: var(--bg3); }
.file-row.up { color: var(--muted); }
.file-row.broken .fname { color: var(--bad); }
.ficon { text-align: center; font-size: 15px; }
.fname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.fmeta { color: var(--muted); text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.more { justify-self: end; }
.ctx { position: absolute; right: 36px; top: 4px; z-index: 5; background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; box-shadow: 0 6px 20px rgba(0,0,0,.5); display: flex; flex-direction: column; min-width: 180px; overflow: hidden; }
.ctx button, .ctx a { padding: 9px 14px; text-align: left; color: var(--fg); text-decoration: none; font-size: 13px; }
.ctx button:hover, .ctx a:hover { background: var(--bg3); } .ctx .danger { color: var(--bad); }
.upload-bar { padding: 6px 12px; font-size: 12px; color: var(--muted); border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 8px; }
.upload-bar .meter { flex: 1; }
.drop-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(17,20,24,.7); color: var(--fg); font-size: 16px; pointer-events: none; }
.viewer { position: absolute; inset: 0; display: flex; flex-direction: column; }
.viewer-bar { display: flex; align-items: center; gap: 8px; padding: 8px 10px; background: var(--bg2); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
.vname { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 50vw; } .dirty { color: var(--accent); }
.file-editor { flex: 1; min-height: 0; border: 0; border-radius: 0; resize: none; font-size: 13px; }
.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; }
@media (max-width: 800px) {
.file-row { grid-template-columns: 24px 1fr 70px 32px; } .file-row .fmeta:nth-child(4), .file-row .perm { display: none; }
.path-input { width: 100%; margin: 4px 0 0; }
.files-tools .filter { width: 80px; }
}