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:
co-authored by
Claude Fable 5.1
parent
38d4ee09ae
commit
63b020d556
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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() }
|
||||||
@@ -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)}>>_ 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
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}`),
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user