Feature 1: Local accounts, replacing shared token
Sync Gitea releases to GitHub / sync-releases (push) Canceled after 0s

- Local accounts with bcrypt password hashing; first-run setup via POST /api/setup
- Personal API tokens (dmv_<48hex>, SHA-256 hashed at rest) for scripted access
- Server-side in-memory sessions with 32-byte secure cookie (dockmv_session, 7-day TTL, sliding renewal)
- Login rate limiting (exponential backoff 1s–30s cap) per IP
- Refuse to bind non-loopback while no account exists, unless DOCKMV_TRUST_ADDR=1 (for Docker's port mapping)
- Every account can manage every other account (no roles in v1)
- Auth middleware: public-path allowlist (/api/setup, /api/login, /api/logout, /api/me, /api/health) + session cookie check + API token (X-Auth-Token or Authorization: Bearer) check
- Frontend AuthGate gates app on GET /api/me; shows setup screen or login form or app tree as needed
- Account tab for personal token management; sign-out button in topbar
- Break: removed --token flag, DOCKMV_TOKEN env var, ?token= query param, /api/health no longer auto-responds when unauthenticated

Verified: go build/vet clean, frontend tsc+vite clean. Sandbox cannot execute binaries to test setup→login→session→token flow at runtime; recommend manual pass before merge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
kawaandClaude Sonnet 5 committed 2026-08-24 15:13:54 +02:00
1 parent 17a83e737f
commit 8d20d9be84
28 files changed
+1635 -137

No files matched your search

+3
View File
@@ -12,6 +12,9 @@ web/node_modules/
*.log
.DS_Store
# Local planning notes
/PLAN.md
# NOTE: internal/webui/dist IS committed on purpose. It is the embedded web UI,
# and keeping it in the tree means `go build` alone produces a working binary
# without a Node toolchain. Regenerate it with `make ui`.
+28 -8
View File
@@ -31,17 +31,17 @@ reach it — see [sources](#sources).
```bash
git clone https://git.azuze.fr/kawa/DockMV.git dockmv && cd dockmv
docker compose up -d
docker compose logs dockmv # prints the URL, including the access token
docker compose logs dockmv # prints the URL
```
Open the printed URL. It binds to `127.0.0.1` only; reach it from your laptop with a tunnel:
Open the printed URL, create the first account (there is no default login), and you're in. It binds to
`127.0.0.1` only; reach it from your laptop with a tunnel:
```bash
ssh -L 8080:127.0.0.1:8080 you@source-host
```
Uses the published image `git.azuze.fr/kawa/dockmv:latest`. Pin a version with `VERSION=v1.2.0 docker compose up -d`,
and set a fixed token with `DOCKMV_TOKEN` in the compose file to keep the same URL across restarts.
Uses the published image `git.azuze.fr/kawa/dockmv:latest`. Pin a version with `VERSION=v1.2.0 docker compose up -d`.
### Prebuilt binary
@@ -190,7 +190,12 @@ carries only genuine run-time overrides and keeps working when the image is upda
The tool can stop containers and read every volume on the host, so it is treated as a privileged
admin tool:
- Binds to **`127.0.0.1` by default**. Binding elsewhere auto-generates an access token and prints it.
- Binds to **`127.0.0.1` by default**. A fresh install has no account yet, and refuses to bind
anywhere else until one is created — finish setup over an SSH tunnel first (see above).
- **Local accounts, no roles yet.** The first visit creates an account; there is no default login.
Every account can manage every other account and every resource — the same single-workspace model
the rest of the tool already has. Scripted access uses a **personal API token** (Account tab),
sent as `X-Auth-Token`, instead of the old shared `--token`.
- **SSH host keys are verified** like OpenSSH, for sources as well as targets. An unknown key is
refused until you approve the fingerprint in the UI; a *changed* key is refused outright. Trusted
keys go to `<data-dir>/known_hosts`.
@@ -255,23 +260,38 @@ dockmv version
```
--addr string address to listen on (default "127.0.0.1:8080")
--token string require this token on every request; "auto" generates one
--data-dir string sources, connections and trusted host keys (default: OS config dir)
--data-dir string accounts, sources, connections and trusted host keys (default: OS config dir)
--package-dir string where migration packages are written (default <data-dir>/packages)
--docker-host string local source docker daemon (default: the DOCKER_HOST environment);
given explicitly, it overrides the remembered source
-v verbose logging
```
Binding to anything but a loopback address refuses to start until an account exists — see the Safety
section above. Set `DOCKMV_TRUST_ADDR=1` to skip that check when something else already restricts
exposure, e.g. a container's own port mapping (the shipped `docker-compose.yml` does this).
`inspect` is handy for scripting and for reporting bugs:
```bash
dockmv inspect --sizes | jq '.containers[] | {name, image, mounts}'
```
Everything the UI does is available over HTTP. Pass the token as `X-Auth-Token` when one is set.
Everything the UI does is available over HTTP. The browser uses a session cookie, set by
`/api/login`; scripts use a personal API token from the Account tab, passed as `X-Auth-Token` or
`Authorization: Bearer …`.
```
POST /api/setup create the first account (only while none exists)
POST /api/login
POST /api/logout
GET /api/me who's signed in, or whether setup is still needed
GET /api/users every account
POST /api/users create another account
DELETE /api/users/{id}
GET /api/tokens the caller's own personal API tokens
POST /api/tokens create one; the plaintext is only ever shown once
DELETE /api/tokens/{id}
GET /api/health
GET /api/source inventory + default selections
GET /api/source/sizes volume sizes (slow)
+9 -5
View File
@@ -1,11 +1,12 @@
# dockmv, running on the SOURCE host.
#
# docker compose up -d
# docker compose logs dockmv # the URL and access token are printed here
# docker compose logs dockmv # the URL is printed here
#
# The container needs the Docker socket to read containers and stream their
# data. That is equivalent to root on this host, so the UI is protected by a
# generated token and should not be published to an untrusted network.
# login (created on first visit) and should not be published to an untrusted
# network.
services:
dockmv:
@@ -18,12 +19,15 @@ services:
- "127.0.0.1:8080:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
# Connections, trusted SSH host keys and built packages.
# Accounts, connections, trusted SSH host keys and built packages.
- migrate-data:/data
environment:
# Set a fixed token to keep the same URL across restarts.
# DOCKMV_TOKEN: change-me
TZ: ${TZ:-UTC}
# dockmv listens on 0.0.0.0 *inside* the container so the ports: mapping
# above can reach it; that mapping (127.0.0.1 on the host side) is the
# actual loopback restriction, not this bind address. This tells dockmv
# not to second-guess that and refuse to start before setup is done.
DOCKMV_TRUST_ADDR: "1"
command:
- serve
- --addr=0.0.0.0:8080
+227
View File
@@ -0,0 +1,227 @@
package api
import (
"context"
"net"
"net/http"
"strings"
"github.com/arescom/dockmv/internal/session"
"github.com/arescom/dockmv/internal/store"
)
// Identity is the authenticated caller, attached to the request context by
// auth(). Every handler reached past auth() has one.
type Identity struct {
UserID string
Username string
}
type identityKey struct{}
func identityFrom(r *http.Request) Identity {
id, _ := r.Context().Value(identityKey{}).(Identity)
return id
}
// publicPaths need no authentication: they are how a fresh install creates
// its first account, how a session is established or torn down, and the
// health check the UI's own bootstrap (and Docker's HEALTHCHECK) depend on.
var publicPaths = map[string]bool{
"/api/setup": true,
"/api/login": true,
"/api/logout": true,
"/api/me": true,
"/api/health": true,
}
// auth gates every /api/* request behind a session cookie or a personal API
// token.
//
// Static assets are served without it: see the comment on spaHandler. Nothing
// sensitive lives in the bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") || publicPaths[r.URL.Path] {
next.ServeHTTP(w, r)
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
s.serveAs(w, r, next, Identity{UserID: sess.UserID, Username: sess.Username})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
s.serveAs(w, r, next, Identity{UserID: usr.ID, Username: usr.Username})
return
}
}
}
writeError(w, http.StatusUnauthorized, "authentication required")
})
}
func (s *Server) serveAs(w http.ResponseWriter, r *http.Request, next http.Handler, id Identity) {
ctx := context.WithValue(r.Context(), identityKey{}, id)
next.ServeHTTP(w, r.WithContext(ctx))
}
func bearerToken(r *http.Request) string {
if got := r.Header.Get("X-Auth-Token"); got != "" {
return got
}
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
return strings.TrimPrefix(h, "Bearer ")
}
return ""
}
// isHTTPS reports whether the request reached us over TLS, directly or
// through a reverse proxy that sets the standard forwarded-proto header. It
// decides the session cookie's Secure attribute.
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, sess session.Session) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: sess.ID,
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
Expires: sess.ExpiresAt,
})
}
func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: session.CookieName,
Value: "",
Path: "/",
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
}
// remoteIP strips the port from RemoteAddr, for the login rate limiter.
func remoteIP(r *http.Request) string {
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
type setupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleSetup creates the first account. It only succeeds while no account
// exists yet; main.go also refuses to bind non-loopback until then, so this
// endpoint being open to anyone is not a standing risk.
func (s *Server) handleSetup(w http.ResponseWriter, r *http.Request) {
if !s.NeedsSetup() {
writeError(w, http.StatusConflict, "setup has already been completed")
return
}
var req setupRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Create(req.Username, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
s.completeLogin(w, r, usr)
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
ip := remoteIP(r)
if !s.logins.Allow(ip) {
writeError(w, http.StatusTooManyRequests, "too many attempts; try again shortly")
return
}
var req loginRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Verify(req.Username, req.Password)
if err != nil {
s.logins.Fail(ip)
writeError(w, http.StatusUnauthorized, "invalid username or password")
return
}
s.logins.Reset(ip)
_ = s.users.TouchLastLogin(usr.ID)
s.completeLogin(w, r, usr)
}
func (s *Server) completeLogin(w http.ResponseWriter, r *http.Request, usr store.User) {
sess, err := s.sessions.Create(usr.ID, usr.Username)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
setSessionCookie(w, r, *sess)
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(session.CookieName); err == nil {
s.sessions.Delete(c.Value)
}
clearSessionCookie(w, r)
w.WriteHeader(http.StatusNoContent)
}
// meResponse is also what /api/setup and /api/login return on success.
type meResponse struct {
ID string `json:"id,omitempty"`
Username string `json:"username,omitempty"`
Authenticated bool `json:"authenticated"`
NeedsSetup bool `json:"needsSetup,omitempty"`
}
// handleMe reports the caller's identity, or that setup is still needed. It
// is public so the UI can decide, on load, whether to show the setup screen,
// a login form, or the app itself.
func (s *Server) handleMe(w http.ResponseWriter, r *http.Request) {
if s.NeedsSetup() {
writeJSON(w, http.StatusOK, meResponse{NeedsSetup: true})
return
}
if c, err := r.Cookie(session.CookieName); err == nil {
if sess, ok := s.sessions.Get(c.Value); ok {
writeJSON(w, http.StatusOK, meResponse{ID: sess.UserID, Username: sess.Username, Authenticated: true})
return
}
}
if got := bearerToken(r); got != "" {
if tok, err := s.tokens.Authenticate(got); err == nil {
if usr, err := s.users.Get(tok.UserID); err == nil {
writeJSON(w, http.StatusOK, meResponse{ID: usr.ID, Username: usr.Username, Authenticated: true})
return
}
}
}
writeJSON(w, http.StatusOK, meResponse{})
}
+4 -5
View File
@@ -25,11 +25,10 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
defer cancel()
body := map[string]any{
"ok": true,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
"authRequired": s.cfg.Token != "",
"ok": true,
"packageDir": s.cfg.PackageDir,
"dataDir": s.cfg.DataDir,
"knownHosts": s.hosts.Path(),
}
// Health is also what connects to the selected source on a fresh start, so
+46 -47
View File
@@ -2,7 +2,6 @@
package api
import (
"crypto/subtle"
"encoding/json"
"fmt"
"io/fs"
@@ -15,6 +14,7 @@ import (
"time"
"github.com/arescom/dockmv/internal/job"
"github.com/arescom/dockmv/internal/session"
"github.com/arescom/dockmv/internal/sshx"
"github.com/arescom/dockmv/internal/store"
)
@@ -23,9 +23,7 @@ import (
type Config struct {
// Addr is the listen address, e.g. 127.0.0.1:8080.
Addr string
// Token, when set, must be presented on every API request.
Token string
// DataDir holds connections and the known-hosts file.
// DataDir holds accounts, connections and the known-hosts file.
DataDir string
// PackageDir is where migration packages are written.
PackageDir string
@@ -42,13 +40,17 @@ type Config struct {
// Server ties the source daemon, connection store and job manager to HTTP.
type Server struct {
cfg Config
log *slog.Logger
sources *store.Sources
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
mux *http.ServeMux
cfg Config
log *slog.Logger
sources *store.Sources
conns *store.Connections
hosts *sshx.KnownHosts
jobs *job.Manager
users *store.Users
tokens *store.Tokens
sessions *session.Manager
logins *session.Limiter
mux *http.ServeMux
// srcMu guards cur, the connection to the selected source. It is opened on
// first use and replaced when the operator picks another source.
@@ -92,14 +94,33 @@ func New(cfg Config) (*Server, error) {
}
}
users, err := store.NewUsers(filepath.Join(cfg.DataDir, "users.json"))
if err != nil {
return nil, err
}
tokens, err := store.NewTokens(filepath.Join(cfg.DataDir, "tokens.json"))
if err != nil {
return nil, err
}
s := &Server{
cfg: cfg, log: cfg.Logger, sources: sources,
conns: conns, hosts: hosts, jobs: job.NewManager(), mux: http.NewServeMux(),
conns: conns, hosts: hosts, jobs: job.NewManager(),
users: users, tokens: tokens,
sessions: session.NewManager(), logins: session.NewLimiter(),
mux: http.NewServeMux(),
}
s.routes()
return s, nil
}
// NeedsSetup reports whether no account exists yet. main.go refuses to bind
// non-loopback while this is true, and GET /api/me uses it to decide whether
// the UI should show the first-run setup screen instead of a login form.
func (s *Server) NeedsSetup() bool {
return s.users.Count() == 0
}
// Close releases the connection to the current source.
func (s *Server) Close() error {
s.invalidateSource("")
@@ -114,6 +135,19 @@ func (s *Server) Handler() http.Handler {
func (s *Server) routes() {
m := s.mux
m.HandleFunc("POST /api/setup", s.handleSetup)
m.HandleFunc("POST /api/login", s.handleLogin)
m.HandleFunc("POST /api/logout", s.handleLogout)
m.HandleFunc("GET /api/me", s.handleMe)
m.HandleFunc("GET /api/users", s.handleListUsers)
m.HandleFunc("POST /api/users", s.handleCreateUser)
m.HandleFunc("DELETE /api/users/{id}", s.handleDeleteUser)
m.HandleFunc("GET /api/tokens", s.handleListTokens)
m.HandleFunc("POST /api/tokens", s.handleCreateToken)
m.HandleFunc("DELETE /api/tokens/{id}", s.handleRevokeToken)
m.HandleFunc("GET /api/health", s.handleHealth)
m.HandleFunc("GET /api/source", s.handleSource)
m.HandleFunc("GET /api/source/sizes", s.handleSourceSizes)
@@ -152,41 +186,6 @@ func (s *Server) routes() {
}
}
// auth enforces the shared token on the API. The token may also be passed as a
// query parameter, because EventSource cannot set headers and neither can a
// download link.
//
// Static assets are deliberately served without it. A browser opening
// /?token=… does not carry the query string over to /assets/app.js, so gating
// the shell would leave the UI unable to boot. Nothing sensitive lives in the
// bundle; every piece of data is behind /api.
func (s *Server) auth(next http.Handler) http.Handler {
if s.cfg.Token == "" {
return next
}
want := []byte(s.cfg.Token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !strings.HasPrefix(r.URL.Path, "/api/") {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get("X-Auth-Token")
if got == "" {
if h := r.Header.Get("Authorization"); strings.HasPrefix(h, "Bearer ") {
got = strings.TrimPrefix(h, "Bearer ")
}
}
if got == "" {
got = r.URL.Query().Get("token")
}
if subtle.ConstantTimeCompare([]byte(got), want) != 1 {
writeError(w, http.StatusUnauthorized, "invalid or missing token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
+50
View File
@@ -0,0 +1,50 @@
package api
import (
"net/http"
"github.com/arescom/dockmv/internal/store"
)
// handleListTokens lists only the caller's own tokens; there is no way to see
// another account's.
func (s *Server) handleListTokens(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.tokens.List(identityFrom(r).UserID))
}
type createTokenRequest struct {
Name string `json:"name"`
}
// createTokenResponse embeds the stored token plus the plaintext secret,
// which is only ever shown this once.
type createTokenResponse struct {
store.APIToken
Token string `json:"token"`
}
func (s *Server) handleCreateToken(w http.ResponseWriter, r *http.Request) {
var req createTokenRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
if req.Name == "" {
writeError(w, http.StatusBadRequest, "name is required")
return
}
tok, plain, err := s.tokens.Create(identityFrom(r).UserID, req.Name)
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
writeJSON(w, http.StatusCreated, createTokenResponse{APIToken: tok, Token: plain})
}
func (s *Server) handleRevokeToken(w http.ResponseWriter, r *http.Request) {
if err := s.tokens.Revoke(identityFrom(r).UserID, r.PathValue("id")); err != nil {
writeError(w, http.StatusNotFound, "%v", err)
return
}
w.WriteHeader(http.StatusNoContent)
}
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"net/http"
"github.com/arescom/dockmv/internal/store"
)
func (s *Server) handleListUsers(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, s.users.List())
}
type createUserRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// handleCreateUser adds another account. v1 has no roles: any authenticated
// account can create or remove any other.
func (s *Server) handleCreateUser(w http.ResponseWriter, r *http.Request) {
var req createUserRequest
if err := decode(r, &req); err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
usr, err := s.users.Create(req.Username, req.Password)
if err != nil {
writeError(w, http.StatusBadRequest, "%v", err)
return
}
writeJSON(w, http.StatusCreated, usr)
}
// handleDeleteUser removes an account, along with its sessions and personal
// API tokens so nothing left behind still authenticates as it.
func (s *Server) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if err := s.users.Delete(id); err != nil {
status := http.StatusNotFound
if err == store.ErrLastAccount {
status = http.StatusBadRequest
}
writeError(w, status, "%v", err)
return
}
s.sessions.DeleteAllForUser(id)
_ = s.tokens.RevokeAllForUser(id)
w.WriteHeader(http.StatusNoContent)
}
+1
View File
@@ -39,6 +39,7 @@ const (
KindSSH Kind = "ssh"
KindPackage Kind = "package"
KindRestore Kind = "restore"
KindBackup Kind = "backup"
)
// Level classifies a log line.
+76
View File
@@ -0,0 +1,76 @@
package session
import (
"sync"
"time"
)
// limiterBase and limiterCap bound the exponential backoff: 1s, 2s, 4s, ...
// capped at 30s.
const (
limiterBase = 1 * time.Second
limiterCap = 30 * time.Second
// maxShift keeps 1<<n from overflowing and is already well past the point
// backoff hits limiterCap, so failures beyond it just stay capped.
maxShift = 5
)
// Limiter throttles login attempts per key (typically the remote IP) with
// exponential backoff, so password guessing is slow without locking anyone
// out for long. It never expires idle keys on its own; a v1-scale accounts
// file does not make that worth the complexity.
type Limiter struct {
mu sync.Mutex
state map[string]*limiterState
}
type limiterState struct {
failures int
blockedUntil time.Time
}
// NewLimiter returns an empty limiter.
func NewLimiter() *Limiter {
return &Limiter{state: map[string]*limiterState{}}
}
// Allow reports whether key may attempt a login right now.
func (l *Limiter) Allow(key string) bool {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.state[key]
if !ok {
return true
}
return !time.Now().Before(st.blockedUntil)
}
// Fail records a failed attempt for key and returns the backoff before the
// next one is allowed.
func (l *Limiter) Fail(key string) time.Duration {
l.mu.Lock()
defer l.mu.Unlock()
st, ok := l.state[key]
if !ok {
st = &limiterState{}
l.state[key] = st
}
st.failures++
shift := st.failures - 1
if shift > maxShift {
shift = maxShift
}
backoff := limiterBase * time.Duration(1<<shift)
if backoff > limiterCap {
backoff = limiterCap
}
st.blockedUntil = time.Now().Add(backoff)
return backoff
}
// Reset clears key's failure history after a successful login.
func (l *Limiter) Reset(key string) {
l.mu.Lock()
delete(l.state, key)
l.mu.Unlock()
}
+95
View File
@@ -0,0 +1,95 @@
// Package session tracks logged-in browser sessions in memory. There is no
// persistence and no signing: state lives only for the life of the process,
// so a restart already invalidates every session, and signing would protect
// nothing that a restart doesn't already.
package session
import (
"crypto/rand"
"encoding/hex"
"sync"
"time"
)
// CookieName is the session cookie set on a successful login.
const CookieName = "dockmv_session"
// TTL is how long a session survives without activity. Every successful
// lookup slides the expiry forward by this much.
const TTL = 7 * 24 * time.Hour
// Session is one logged-in browser tab's worth of state.
type Session struct {
ID string
UserID string
Username string
ExpiresAt time.Time
}
// Manager holds every live session.
type Manager struct {
mu sync.Mutex
byID map[string]*Session
}
// NewManager returns an empty session store.
func NewManager() *Manager {
return &Manager{byID: map[string]*Session{}}
}
// Create starts a new session for a logged-in account.
func (m *Manager) Create(userID, username string) (*Session, error) {
id, err := newSessionID()
if err != nil {
return nil, err
}
s := &Session{ID: id, UserID: userID, Username: username, ExpiresAt: time.Now().Add(TTL)}
m.mu.Lock()
m.byID[id] = s
m.mu.Unlock()
return s, nil
}
// Get returns the session for id, sliding its expiry forward. ok is false for
// an unknown or expired id.
func (m *Manager) Get(id string) (Session, bool) {
m.mu.Lock()
defer m.mu.Unlock()
s, ok := m.byID[id]
if !ok {
return Session{}, false
}
if time.Now().After(s.ExpiresAt) {
delete(m.byID, id)
return Session{}, false
}
s.ExpiresAt = time.Now().Add(TTL)
return *s, true
}
// Delete ends one session, e.g. on logout.
func (m *Manager) Delete(id string) {
m.mu.Lock()
delete(m.byID, id)
m.mu.Unlock()
}
// DeleteAllForUser ends every session belonging to an account, e.g. when the
// account itself is deleted.
func (m *Manager) DeleteAllForUser(userID string) {
m.mu.Lock()
for id, s := range m.byID {
if s.UserID == userID {
delete(m.byID, id)
}
}
m.mu.Unlock()
}
func newSessionID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
+197
View File
@@ -0,0 +1,197 @@
package store
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"sync"
"time"
)
// ErrTokenNotFound is returned for an unknown, revoked, or not-owned token.
var ErrTokenNotFound = errors.New("token not found")
// APIToken is a personal access token, scoped to the account that created it.
// The plaintext is only ever returned once, by Create.
type APIToken struct {
ID string `json:"id"`
UserID string `json:"userId"`
Name string `json:"name"`
Hint string `json:"hint"` // last 4 characters, for telling tokens apart in a list
Hash string `json:"hash"`
CreatedAt time.Time `json:"createdAt"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
}
// Tokens is a JSON-backed collection of personal API tokens.
type Tokens struct {
path string
mu sync.RWMutex
// items holds every token by id.
items map[string]APIToken
// byHash maps a token's sha256 hex digest to its id, for authentication
// lookups without ever storing the plaintext.
byHash map[string]string
}
// NewTokens loads (or creates) the tokens file at path.
func NewTokens(path string) (*Tokens, error) {
t := &Tokens{path: path, items: map[string]APIToken{}, byHash: map[string]string{}}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return t, nil
}
if err != nil {
return nil, fmt.Errorf("read tokens: %w", err)
}
var list []APIToken
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse tokens file %s: %w", path, err)
}
for _, tok := range list {
t.items[tok.ID] = tok
t.byHash[tok.Hash] = tok.ID
}
return t, nil
}
// List returns every token owned by userID, hash stripped, newest first.
func (t *Tokens) List(userID string) []APIToken {
t.mu.RLock()
defer t.mu.RUnlock()
out := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
if tok.UserID == userID {
out = append(out, redactToken(tok))
}
}
sort.Slice(out, func(i, j int) bool { return out[i].CreatedAt.After(out[j].CreatedAt) })
return out
}
// Create mints a new token for userID and returns it (hash stripped) plus the
// plaintext secret, which is never stored and never retrievable again.
func (t *Tokens) Create(userID, name string) (APIToken, string, error) {
plain, err := newTokenSecret()
if err != nil {
return APIToken{}, "", fmt.Errorf("generate token: %w", err)
}
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
tok := APIToken{
ID: newID(),
UserID: userID,
Name: name,
Hint: plain[len(plain)-4:],
Hash: hash,
CreatedAt: time.Now(),
}
t.items[tok.ID] = tok
t.byHash[hash] = tok.ID
if err := t.flush(); err != nil {
return APIToken{}, "", err
}
return redactToken(tok), plain, nil
}
// Authenticate looks up the token behind a plaintext secret and records it as
// used. It does not check ownership: any valid token authenticates as its
// owner, which the caller then treats as the request's identity.
func (t *Tokens) Authenticate(plain string) (APIToken, error) {
hash := hashToken(plain)
t.mu.Lock()
defer t.mu.Unlock()
id, ok := t.byHash[hash]
if !ok {
return APIToken{}, ErrTokenNotFound
}
tok := t.items[id]
now := time.Now()
tok.LastUsedAt = &now
t.items[id] = tok
if err := t.flush(); err != nil {
return APIToken{}, err
}
return redactToken(tok), nil
}
// Revoke deletes a token, refusing if it is not owned by userID.
func (t *Tokens) Revoke(userID, id string) error {
t.mu.Lock()
defer t.mu.Unlock()
tok, ok := t.items[id]
if !ok || tok.UserID != userID {
return ErrTokenNotFound
}
delete(t.items, id)
delete(t.byHash, tok.Hash)
return t.flush()
}
// RevokeAllForUser deletes every token owned by userID, e.g. when the account
// itself is deleted.
func (t *Tokens) RevokeAllForUser(userID string) error {
t.mu.Lock()
defer t.mu.Unlock()
for id, tok := range t.items {
if tok.UserID == userID {
delete(t.items, id)
delete(t.byHash, tok.Hash)
}
}
return t.flush()
}
// flush writes the file. The caller must hold the write lock.
func (t *Tokens) flush() error {
list := make([]APIToken, 0, len(t.items))
for _, tok := range t.items {
list = append(list, tok)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := t.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write tokens: %w", err)
}
if err := os.Rename(tmp, t.path); err != nil {
return fmt.Errorf("replace tokens file: %w", err)
}
return nil
}
func redactToken(tok APIToken) APIToken {
tok.Hash = ""
return tok
}
// newTokenSecret generates a token in the form dmv_<48 hex characters>.
func newTokenSecret() (string, error) {
b := make([]byte, 24)
if _, err := rand.Read(b); err != nil {
return "", err
}
return "dmv_" + hex.EncodeToString(b), nil
}
func hashToken(plain string) string {
sum := sha256.Sum256([]byte(plain))
return hex.EncodeToString(sum[:])
}
+145
View File
@@ -0,0 +1,145 @@
package store
import (
"path/filepath"
"strings"
"testing"
)
func newTestTokens(t *testing.T) (*Tokens, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "tokens.json")
tk, err := NewTokens(path)
if err != nil {
t.Fatalf("NewTokens: %v", err)
}
return tk, path
}
func TestTokensCreateAndAuthenticate(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "ci token")
if err != nil {
t.Fatalf("Create: %v", err)
}
if tok.Hash != "" {
t.Fatal("Create must return a redacted token")
}
if !strings.HasPrefix(plain, "dmv_") {
t.Fatalf("plaintext = %q, want dmv_ prefix", plain)
}
if tok.Hint != plain[len(plain)-4:] {
t.Fatalf("hint = %q, want last 4 chars of %q", tok.Hint, plain)
}
got, err := tk.Authenticate(plain)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if got.UserID != "user-1" {
t.Fatalf("UserID = %q, want user-1", got.UserID)
}
if got.Hash != "" {
t.Fatal("Authenticate must return a redacted token")
}
if _, err := tk.Authenticate("dmv_deadbeef"); err != ErrTokenNotFound {
t.Fatalf("Authenticate with bogus token = %v, want ErrTokenNotFound", err)
}
}
func TestTokensAuthenticateRecordsLastUsed(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "ci token")
if err != nil {
t.Fatalf("Create: %v", err)
}
if tok.LastUsedAt != nil {
t.Fatal("a fresh token should have no last-used time")
}
got, err := tk.Authenticate(plain)
if err != nil {
t.Fatalf("Authenticate: %v", err)
}
if got.LastUsedAt == nil {
t.Fatal("Authenticate should record LastUsedAt")
}
}
func TestTokensListScopedToOwner(t *testing.T) {
tk, _ := newTestTokens(t)
if _, _, err := tk.Create("user-1", "a"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-2", "b"); err != nil {
t.Fatalf("Create: %v", err)
}
list := tk.List("user-1")
if len(list) != 1 || list[0].Name != "a" {
t.Fatalf("List(user-1) = %+v, want just token a", list)
}
}
func TestTokensRevokeOwnershipChecked(t *testing.T) {
tk, _ := newTestTokens(t)
tok, plain, err := tk.Create("user-1", "a")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := tk.Revoke("user-2", tok.ID); err != ErrTokenNotFound {
t.Fatalf("Revoke by non-owner = %v, want ErrTokenNotFound", err)
}
if err := tk.Revoke("user-1", tok.ID); err != nil {
t.Fatalf("Revoke by owner: %v", err)
}
if _, err := tk.Authenticate(plain); err != ErrTokenNotFound {
t.Fatalf("Authenticate after revoke = %v, want ErrTokenNotFound", err)
}
}
func TestTokensRevokeAllForUser(t *testing.T) {
tk, _ := newTestTokens(t)
if _, _, err := tk.Create("user-1", "a"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-1", "b"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, _, err := tk.Create("user-2", "c"); err != nil {
t.Fatalf("Create: %v", err)
}
if err := tk.RevokeAllForUser("user-1"); err != nil {
t.Fatalf("RevokeAllForUser: %v", err)
}
if len(tk.List("user-1")) != 0 {
t.Fatal("user-1 should have no tokens left")
}
if len(tk.List("user-2")) != 1 {
t.Fatal("user-2's token should be untouched")
}
}
func TestTokensPersistAcrossReload(t *testing.T) {
tk, path := newTestTokens(t)
_, plain, err := tk.Create("user-1", "a")
if err != nil {
t.Fatalf("Create: %v", err)
}
reopened, err := NewTokens(path)
if err != nil {
t.Fatalf("NewTokens: %v", err)
}
if _, err := reopened.Authenticate(plain); err != nil {
t.Fatalf("Authenticate after reload: %v", err)
}
}
+231
View File
@@ -0,0 +1,231 @@
package store
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
// ErrUsernameTaken is returned by Create when the username is already in use.
var ErrUsernameTaken = errors.New("username already taken")
// ErrInvalidCredentials is returned by Verify on an unknown username or a
// wrong password. The two cases are not distinguished, to avoid leaking which
// usernames exist.
var ErrInvalidCredentials = errors.New("invalid username or password")
// ErrLastAccount is returned by Delete when it would remove the only account.
var ErrLastAccount = errors.New("cannot delete the last account")
// ErrPasswordTooLong is returned when a password exceeds bcrypt's 72-byte
// limit. Passwords are rejected rather than silently truncated.
var ErrPasswordTooLong = errors.New("password must be 72 bytes or fewer")
// User is one local account.
type User struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"passwordHash"`
CreatedAt time.Time `json:"createdAt"`
LastLoginAt *time.Time `json:"lastLoginAt,omitempty"`
}
// Users is a JSON-backed collection of local accounts.
type Users struct {
path string
mu sync.RWMutex
// items holds every account by id.
items map[string]User
// byName maps a lowercased username to its account id, for case-insensitive
// uniqueness checks and login lookups.
byName map[string]string
}
// dummyHash is compared against on a lookup-miss in Verify, so a login attempt
// against a username that doesn't exist takes the same time as one that does.
var dummyHash = mustHash("dockmv-dummy-password-for-timing-only")
func mustHash(password string) []byte {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
panic(err)
}
return h
}
// NewUsers loads (or creates) the users file at path.
func NewUsers(path string) (*Users, error) {
u := &Users{path: path, items: map[string]User{}, byName: map[string]string{}}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("create data directory: %w", err)
}
b, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return u, nil
}
if err != nil {
return nil, fmt.Errorf("read users: %w", err)
}
var list []User
if err := json.Unmarshal(b, &list); err != nil {
return nil, fmt.Errorf("parse users file %s: %w", path, err)
}
for _, usr := range list {
u.items[usr.ID] = usr
u.byName[strings.ToLower(usr.Username)] = usr.ID
}
return u, nil
}
// Count returns the number of accounts.
func (u *Users) Count() int {
u.mu.RLock()
defer u.mu.RUnlock()
return len(u.items)
}
// List returns every account, password hashes stripped, username order.
func (u *Users) List() []User {
u.mu.RLock()
defer u.mu.RUnlock()
out := make([]User, 0, len(u.items))
for _, usr := range u.items {
out = append(out, redactUser(usr))
}
sort.Slice(out, func(i, j int) bool { return out[i].Username < out[j].Username })
return out
}
// Get returns one account, password hash stripped.
func (u *Users) Get(id string) (User, error) {
u.mu.RLock()
defer u.mu.RUnlock()
usr, ok := u.items[id]
if !ok {
return User{}, ErrNotFound
}
return redactUser(usr), nil
}
// Create adds a new account. Usernames are unique case-insensitively.
func (u *Users) Create(username, password string) (User, error) {
username = strings.TrimSpace(username)
if username == "" {
return User{}, errors.New("username is required")
}
if password == "" {
return User{}, errors.New("password is required")
}
if len(password) > 72 {
return User{}, ErrPasswordTooLong
}
u.mu.Lock()
defer u.mu.Unlock()
key := strings.ToLower(username)
if _, ok := u.byName[key]; ok {
return User{}, ErrUsernameTaken
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return User{}, fmt.Errorf("hash password: %w", err)
}
usr := User{ID: newID(), Username: username, PasswordHash: string(hash), CreatedAt: time.Now()}
u.items[usr.ID] = usr
u.byName[key] = usr.ID
if err := u.flush(); err != nil {
return User{}, err
}
return redactUser(usr), nil
}
// Verify checks a username/password pair and returns the account on success.
// A lookup-miss still runs a bcrypt comparison against a dummy hash, so the
// two failure cases take the same time.
func (u *Users) Verify(username, password string) (User, error) {
u.mu.RLock()
id, ok := u.byName[strings.ToLower(strings.TrimSpace(username))]
var usr User
if ok {
usr = u.items[id]
}
u.mu.RUnlock()
if !ok {
_ = bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return User{}, ErrInvalidCredentials
}
if err := bcrypt.CompareHashAndPassword([]byte(usr.PasswordHash), []byte(password)); err != nil {
return User{}, ErrInvalidCredentials
}
return redactUser(usr), nil
}
// TouchLastLogin records the current time as an account's last login.
func (u *Users) TouchLastLogin(id string) error {
u.mu.Lock()
defer u.mu.Unlock()
usr, ok := u.items[id]
if !ok {
return ErrNotFound
}
now := time.Now()
usr.LastLoginAt = &now
u.items[id] = usr
return u.flush()
}
// Delete removes an account. The last remaining account cannot be deleted, so
// the instance never ends up with no way to log in.
func (u *Users) Delete(id string) error {
u.mu.Lock()
defer u.mu.Unlock()
usr, ok := u.items[id]
if !ok {
return ErrNotFound
}
if len(u.items) == 1 {
return ErrLastAccount
}
delete(u.items, id)
delete(u.byName, strings.ToLower(usr.Username))
return u.flush()
}
// flush writes the file. The caller must hold the write lock.
func (u *Users) flush() error {
list := make([]User, 0, len(u.items))
for _, usr := range u.items {
list = append(list, usr)
}
sort.Slice(list, func(i, j int) bool { return list[i].ID < list[j].ID })
b, err := json.MarshalIndent(list, "", " ")
if err != nil {
return err
}
tmp := u.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write users: %w", err)
}
if err := os.Rename(tmp, u.path); err != nil {
return fmt.Errorf("replace users file: %w", err)
}
return nil
}
func redactUser(usr User) User {
usr.PasswordHash = ""
return usr
}
+131
View File
@@ -0,0 +1,131 @@
package store
import (
"path/filepath"
"strings"
"testing"
)
func newTestUsers(t *testing.T) (*Users, string) {
t.Helper()
path := filepath.Join(t.TempDir(), "users.json")
u, err := NewUsers(path)
if err != nil {
t.Fatalf("NewUsers: %v", err)
}
return u, path
}
func TestUsersCreateAndVerify(t *testing.T) {
u, _ := newTestUsers(t)
usr, err := u.Create("Alice", "correct-password")
if err != nil {
t.Fatalf("Create: %v", err)
}
if usr.PasswordHash != "" {
t.Fatal("Create must return a redacted user")
}
if usr.ID == "" {
t.Fatal("an id should have been generated")
}
if _, err := u.Verify("alice", "correct-password"); err != nil {
t.Fatalf("Verify with matching case-insensitive username: %v", err)
}
if _, err := u.Verify("Alice", "wrong-password"); err != ErrInvalidCredentials {
t.Fatalf("Verify with wrong password = %v, want ErrInvalidCredentials", err)
}
if _, err := u.Verify("nobody", "correct-password"); err != ErrInvalidCredentials {
t.Fatalf("Verify with unknown username = %v, want ErrInvalidCredentials", err)
}
}
func TestUsersUsernameUniqueCaseInsensitive(t *testing.T) {
u, _ := newTestUsers(t)
if _, err := u.Create("Bob", "password1"); err != nil {
t.Fatalf("Create: %v", err)
}
if _, err := u.Create("bob", "password2"); err != ErrUsernameTaken {
t.Fatalf("Create with case-different duplicate = %v, want ErrUsernameTaken", err)
}
}
func TestUsersValidation(t *testing.T) {
u, _ := newTestUsers(t)
if _, err := u.Create("", "password"); err == nil {
t.Fatal("empty username should be rejected")
}
if _, err := u.Create("carol", ""); err == nil {
t.Fatal("empty password should be rejected")
}
if _, err := u.Create("dave", strings.Repeat("x", 73)); err != ErrPasswordTooLong {
t.Fatalf("73-byte password = %v, want ErrPasswordTooLong", err)
}
}
func TestUsersDeleteRefusesLastAccount(t *testing.T) {
u, _ := newTestUsers(t)
a, err := u.Create("alice", "password1")
if err != nil {
t.Fatalf("Create: %v", err)
}
b, err := u.Create("bob", "password2")
if err != nil {
t.Fatalf("Create: %v", err)
}
if err := u.Delete(a.ID); err != nil {
t.Fatalf("Delete first account: %v", err)
}
if err := u.Delete(b.ID); err != ErrLastAccount {
t.Fatalf("Delete last account = %v, want ErrLastAccount", err)
}
if u.Count() != 1 {
t.Fatalf("Count = %d, want 1", u.Count())
}
}
func TestUsersPersistAcrossReload(t *testing.T) {
u, path := newTestUsers(t)
if _, err := u.Create("alice", "correct-password"); err != nil {
t.Fatalf("Create: %v", err)
}
reopened, err := NewUsers(path)
if err != nil {
t.Fatalf("NewUsers: %v", err)
}
if reopened.Count() != 1 {
t.Fatalf("Count after reload = %d, want 1", reopened.Count())
}
if _, err := reopened.Verify("alice", "correct-password"); err != nil {
t.Fatalf("Verify after reload: %v", err)
}
}
func TestUsersTouchLastLogin(t *testing.T) {
u, _ := newTestUsers(t)
usr, err := u.Create("alice", "correct-password")
if err != nil {
t.Fatalf("Create: %v", err)
}
if usr.LastLoginAt != nil {
t.Fatal("a fresh account should have no last login")
}
if err := u.TouchLastLogin(usr.ID); err != nil {
t.Fatalf("TouchLastLogin: %v", err)
}
got, err := u.Get(usr.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if got.LastLoginAt == nil {
t.Fatal("LastLoginAt should be set after TouchLastLogin")
}
}
+11
View File
@@ -0,0 +1,11 @@
(function(){const g=document.createElement("link").relList;if(g&&g.supports&&g.supports("modulepreload"))return;for(const O of document.querySelectorAll('link[rel="modulepreload"]'))f(O);new MutationObserver(O=>{for(const D of O)if(D.type==="childList")for(const x of D.addedNodes)x.tagName==="LINK"&&x.rel==="modulepreload"&&f(x)}).observe(document,{childList:!0,subtree:!0});function j(O){const D={};return O.integrity&&(D.integrity=O.integrity),O.referrerPolicy&&(D.referrerPolicy=O.referrerPolicy),O.crossOrigin==="use-credentials"?D.credentials="include":O.crossOrigin==="anonymous"?D.credentials="omit":D.credentials="same-origin",D}function f(O){if(O.ep)return;O.ep=!0;const D=j(O);fetch(O.href,D)}})();var ys={exports:{}},Cn={};var zr;function oy(){if(zr)return Cn;zr=1;var o=Symbol.for("react.transitional.element"),g=Symbol.for("react.fragment");function j(f,O,D){var x=null;if(D!==void 0&&(x=""+D),O.key!==void 0&&(x=""+O.key),"key"in O){D={};for(var L in O)L!=="key"&&(D[L]=O[L])}else D=O;return O=D.ref,{$$typeof:o,type:f,key:x,ref:O!==void 0?O:null,props:D}}return Cn.Fragment=g,Cn.jsx=j,Cn.jsxs=j,Cn}var Nr;function dy(){return Nr||(Nr=1,ys.exports=oy()),ys.exports}var c=dy(),vs={exports:{}},F={};var Ar;function ry(){if(Ar)return F;Ar=1;var o=Symbol.for("react.transitional.element"),g=Symbol.for("react.portal"),j=Symbol.for("react.fragment"),f=Symbol.for("react.strict_mode"),O=Symbol.for("react.profiler"),D=Symbol.for("react.consumer"),x=Symbol.for("react.context"),L=Symbol.for("react.forward_ref"),U=Symbol.for("react.suspense"),p=Symbol.for("react.memo"),V=Symbol.for("react.lazy"),E=Symbol.for("react.activity"),C=Symbol.iterator;function I(h){return h===null||typeof h!="object"?null:(h=C&&h[C]||h["@@iterator"],typeof h=="function"?h:null)}var Q={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ct=Object.assign,Dt={};function bt(h,A,B){this.props=h,this.context=A,this.refs=Dt,this.updater=B||Q}bt.prototype.isReactComponent={},bt.prototype.setState=function(h,A){if(typeof h!="object"&&typeof h!="function"&&h!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,h,A,"setState")},bt.prototype.forceUpdate=function(h){this.updater.enqueueForceUpdate(this,h,"forceUpdate")};function _t(){}_t.prototype=bt.prototype;function ht(h,A,B){this.props=h,this.context=A,this.refs=Dt,this.updater=B||Q}var at=ht.prototype=new _t;at.constructor=ht,ct(at,bt.prototype),at.isPureReactComponent=!0;var St=Array.isArray;function At(){}var H={H:null,A:null,T:null,S:null},K=Object.prototype.hasOwnProperty;function Y(h,A,B){var G=B.ref;return{$$typeof:o,type:h,key:A,ref:G!==void 0?G:null,props:B}}function P(h,A){return Y(h.type,A,h.props)}function mt(h){return typeof h=="object"&&h!==null&&h.$$typeof===o}function st(h){var A={"=":"=0",":":"=2"};return"$"+h.replace(/[=:]/g,function(B){return A[B]})}var $t=/\/+/g;function il(h,A){return typeof h=="object"&&h!==null&&h.key!=null?st(""+h.key):A.toString(36)}function ll(h){switch(h.status){case"fulfilled":return h.value;case"rejected":throw h.reason;default:switch(typeof h.status=="string"?h.then(At,At):(h.status="pending",h.then(function(A){h.status==="pending"&&(h.status="fulfilled",h.value=A)},function(A){h.status==="pending"&&(h.status="rejected",h.reason=A)})),h.status){case"fulfilled":return h.value;case"rejected":throw h.reason}}throw h}function T(h,A,B,G,$){var tt=typeof h;(tt==="undefined"||tt==="boolean")&&(h=null);var yt=!1;if(h===null)yt=!0;else switch(tt){case"bigint":case"string":case"number":yt=!0;break;case"object":switch(h.$$typeof){case o:case g:yt=!0;break;case V:return yt=h._init,T(yt(h._payload),A,B,G,$)}}if(yt)return $=$(h),yt=G===""?"."+il(h,0):G,St($)?(B="",yt!=null&&(B=yt.replace($t,"$&/")+"/"),T($,A,B,"",function(se){return se})):$!=null&&(mt($)&&($=P($,B+($.key==null||h&&h.key===$.key?"":(""+$.key).replace($t,"$&/")+"/")+yt)),A.push($)),1;yt=0;var Kt=G===""?".":G+":";if(St(h))for(var Ut=0;Ut<h.length;Ut++)G=h[Ut],tt=Kt+il(G,Ut),yt+=T(G,A,B,tt,$);else if(Ut=I(h),typeof Ut=="function")for(h=Ut.call(h),Ut=0;!(G=h.next()).done;)G=G.value,tt=Kt+il(G,Ut++),yt+=T(G,A,B,tt,$);else if(tt==="object"){if(typeof h.then=="function")return T(ll(h),A,B,G,$);throw A=String(h),Error("Objects are not valid as a React child (found: "+(A==="[object Object]"?"object with keys {"+Object.keys(h).join(", ")+"}":A)+"). If you meant to render a collection of children, use an array instead.")}return yt}function R(h,A,B){if(h==null)return h;var G=[],$=0;return T(h,G,"","",function(tt){return A.call(B,tt,$++)}),G}function M(h){if(h._status===-1){var A=h._result;A=A(),A.then(function(B){(h._status===0||h._status===-1)&&(h._status=1,h._result=B)},function(B){(h._status===0||h._status===-1)&&(h._status=2,h._result=B)}),h._status===-1&&(h._status=0,h._result=A)}if(h._status===1)return h._result.default;throw h._result}vaLine truncated
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
`+W+t+el}var He=!1;function Pu(t,l){if(!t||He)return"";He=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(l){var _=function(){throw Error()};if(Object.defineProperty(_.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(_,[])}catch(S){var b=S}Reflect.construct(t,[],_)}else{try{_.call()}catch(S){b=S}t.call(_.prototype)}}else{try{throw Error()}catch(S){b=S}(_=t())&&typeof _.catch=="function"&&_.catch(function(){})}}catch(S){if(S&&b&&typeof S.stack=="string")return[S.stack,b.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),i=u[0],s=u[1];if(i&&s){var d=i.split(`
`),v=s.split(`
`);for(n=a=0;a<d.length&&!d[a].includes("DetermineComponentFrameRoot");)a++;for(;n<v.length&&!v[n].includes("DetermineComponentFrameRoot");)n++;if(a===d.length||n===v.length)for(a=d.length-1,n=v.length-1;1<=a&&0<=n&&d[a]!==v[n];)n--;for(;1<=a&&0<=n;a--,n--)if(d[a]!==v[n]){if(a!==1||n!==1)do if(a--,n--,0>n||d[a]!==v[n]){var z=`
`+d[a].replace(" at new "," at ");return t.displayName&&z.includes("<anonymous>")&&(z=z.replace("<anonymous>",t.displayName)),z}while(1<=a&&0<=n);break}}}finally{He=!1,Error.prepareStackTrace=e}return(e=t?t.displayName||t.name:"")?zl(e):""}function Qr(t,l){switch(t.tag){case 26:case 27:case 5:return zl(t.type);case 16:return zl("Lazy");case 13:return t.child!==l&&l!==null?zl("Suspense Fallback"):zl("Suspense");case 19:return zl("SuspenseList");case 0:case 15:return Pu(t.type,!1);case 11:return Pu(t.type.render,!1);case 1:return Pu(t.type,!0);case 31:return zl("Activity");default:return""}}function Es(t){try{var l="",e=null;do l+=Qr(t,e),e=t,t=t.return;while(t);return l}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var ti=Object.prototype.hasOwnProperty,li=o.unstable_scheduleCallback,ei=o.unstable_cancelCallback,Zr=o.unstable_shouldYield,Lr=o.unstable_requestPaint,yl=o.unstable_now,Vr=o.unstable_getCurrentPriorityLevel,Ts=o.unstable_ImmediatePriority,zs=o.unstable_UserBlockingPriority,Rn=o.unstable_NormalPriority,Kr=o.unstable_LowPriority,Ns=o.unstable_IdlePriority,wr=o.log,kr=o.unstable_setDisableYieldValue,Ga=null,vl=null;function fe(t){if(typeof wr=="function"&&kr(t),vl&&typeof vl.setStrictMode=="function")try{vl.setStrictMode(Ga,t)}catch{}}var gl=Math.clz32?Math.clz32:Wr,Jr=Math.log,$r=Math.LN2;function Wr(t){return t>>>=0,t===0?32:31-(Jr(t)/$r|0)|0}var qn=256,Bn=262144,Yn=4194304;function Re(t){var l=t&42;if(l!==0)return l;switch(t&-t){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Gn(t,l,e){var a=t.pendingLanes;if(a===0)return 0;var n=0,u=t.suspendedLanes,i=t.pingedLanes;t=t.warmLanes;var s=a&134217727;return s!==0?(a=s&~u,a!==0?n=Re(a):(i&=s,i!==0?n=Re(i):e||(e=s&~t,e!==0&&(n=Re(e))))):(s=a&~u,s!==0?n=Re(s):i!==0?n=Re(i):e||(e=a&~t,e!==0&&(n=Re(e)))),n===0?0:l!==0&&l!==n&&(l&u)===0&&(u=n&-n,e=l&-l,u>=e||u===32&&(e&4194048)!==0)?l:n}function Xa(t,l){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&l)===0}function Fr(t,l){switch(t){case 1:case 2:case 4:case 8:case 64:return l+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return l+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function As(){var t=Yn;return Yn<<=1,(Yn&62914560)===0&&(Yn=4194304),t}function ai(t){for(var l=[],e=0;31>e;e++)l.push(t);return l}function Qa(t,l){t.pendingLanes|=l,l!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Ir(t,l,e,a,n,u){var i=t.pendingLanes;t.pendingLanes=e,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=e,t.entangledLanes&=e,t.errorRecoveryDisabledLanes&=e,t.shellSuspendCounter=0;var s=t.entanglements,d=t.expirationTimes,v=t.hiddenUpdates;for(e=i&~e;0<e;){var z=31-gl(e),_=1<<z;s[z]=0,d[z]=-1;var b=v[z];if(b!==null)for(v[z]=null,z=0;z<b.length;z++){var S=b[z];S!==null&&(S.lane&=-536870913)}e&=~_}a!==0&&_s(t,a,0),u!==0&&n===0&&t.tag!==0&&(t.suspendedLanes|=u&~(i&~l))}function _s(t,l,e){t.pendingLanes|=l,t.suspendedLanes&=~l;var a=31-gl(l);t.entangledLanes|=l,t.entanglements[a]=t.entanglements[a]|1073741824|e&261930}function Os(t,l){var e=t.entangledLanes|=l;for(t=t.entanglements;e;){var a=31-gl(e),n=1<<a;n&l|t[a]&l&&(t[a]|=l),e&=~n}}function Ms(t,l){var e=l&-l;return e=(e&42)!==0?1:ni(e),(e&(t.suspendedLanes|l))!==0?0:e}function ni(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function ui(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Ds(){var t=R.p;return t!==0?t:(t=window.event,t===void 0?32:br(t.type))}function Cs(t,l){var e=R.p;try{return R.p=t,l()}finally{R.p=e}}var oe=Math.random().toString(36).slice(2),Wt="__reactFiber$"+oe,cl="__reactProps$"+oe,ta="__reactContainer$"+oe,ii="__reactEvents$"+oe,Pr="__reactListeners$"+oe,th="__reactHandles$"+oe,Us="__reactResources$"+oe,Za="__reactMarker$"+oe;function ci(t){delete t[Wt],delete t[cl],delete t[ii],delete t[Pr],delete t[th]}function la(t){var l=t[Wt];if(l)return l;for(var e=t.parentNode;e;){if(l=e[ta]||e[Wt]){if(e=l.alternate,l.child!==null||e!==null&&e.child!==null)for(t=er(t);t!==null;){if(e=t[Wt])return e;t=er(t)}return l}t=e,e=t.parentNode}return null}function ea(t){if(t=t[Wt]||t[ta]){var l=t.tag;if(l===5||l===6||l===13||l===31||l===26||l===27||l===3)return t}return null}function La(t){var l=t.tag;if(l===5||l===26||l===27||l===6)return t.stateNode;throw Error(f(33))}function aa(t){var l=t[Us];return l||(l=t[Us]={hoistableStyles:new Map,hoistableScripts:new Map}),l}function wt(t){t[Za]=!0}var Hs=new Set,Rs={};function qe(t,l){na(t,l),na(t+"Capture",l)}function na(t,l){for(Rs[t]=l,t=0;t<l.length;t++)Hs.add(l[t])}var lh=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21 Line truncated
`).replace(Cm,"")}function wd(t,l){return l=Kd(l),Kd(t)===l}function Tt(t,l,e,a,n,u){switch(e){case"children":typeof a=="string"?l==="body"||l==="textarea"&&a===""||ia(t,a):(typeof a=="number"||typeof a=="bigint")&&l!=="body"&&ia(t,""+a);break;case"className":Qn(t,"class",a);break;case"tabIndex":Qn(t,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Qn(t,e,a);break;case"style":Vs(t,a,u);break;case"data":if(l!=="object"){Qn(t,"data",a);break}case"src":case"href":if(a===""&&(l!=="a"||e!=="href")){t.removeAttribute(e);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(e);break}a=Ln(""+a),t.setAttribute(e,a);break;case"action":case"formAction":if(typeof a=="function"){t.setAttribute(e,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(e==="formAction"?(l!=="input"&&Tt(t,l,"name",n.name,n,null),Tt(t,l,"formEncType",n.formEncType,n,null),Tt(t,l,"formMethod",n.formMethod,n,null),Tt(t,l,"formTarget",n.formTarget,n,null)):(Tt(t,l,"encType",n.encType,n,null),Tt(t,l,"method",n.method,n,null),Tt(t,l,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){t.removeAttribute(e);break}a=Ln(""+a),t.setAttribute(e,a);break;case"onClick":a!=null&&(t.onclick=Vl);break;case"onScroll":a!=null&&it("scroll",t);break;case"onScrollEnd":a!=null&&it("scrollend",t);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(f(60));t.innerHTML=e}}break;case"multiple":t.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":t.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){t.removeAttribute("xlink:href");break}e=Ln(""+a),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(e,""+a):t.removeAttribute(e);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(e,""):t.removeAttribute(e);break;case"capture":case"download":a===!0?t.setAttribute(e,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?t.setAttribute(e,a):t.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?t.setAttribute(e,a):t.removeAttribute(e);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?t.removeAttribute(e):t.setAttribute(e,a);break;case"popover":it("beforetoggle",t),it("toggle",t),Xn(t,"popover",a);break;case"xlinkActuate":Ll(t,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Ll(t,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Ll(t,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Ll(t,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Ll(t,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Ll(t,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Ll(t,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Ll(t,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Ll(t,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Xn(t,"is",a);break;case"innerText":case"textContent":break;default:(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")&&(e=ih.get(e)||e,Xn(t,e,a))}}function Fc(t,l,e,a,n,u){switch(e){case"style":Vs(t,a,u);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(f(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(f(60));t.innerHTML=e}}break;case"children":typeof a=="string"?ia(t,a):(typeof a=="number"||typeof a=="bigint")&&ia(t,""+a);break;case"onScroll":a!=null&&it("scroll",t);break;case"onScrollEnd":a!=null&&it("scrollend",t);break;case"onClick":a!=null&&(t.onclick=Vl);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne Line truncated
`)})]}),o.items.map(f=>c.jsxs("div",{children:[c.jsxs("h3",{style:{margin:"0 0 6px",fontSize:12},children:[f.name,f.targetName!==f.name&&c.jsxs("span",{className:"faint",children:[" → ",f.targetName]})]}),(f.warnings??[]).map((O,D)=>c.jsx(Jt,{kind:"warn",children:O},`w${D}`)),(f.notes??[]).map((O,D)=>c.jsxs("div",{className:"small faint",children:["· ",O]},`n${D}`)),c.jsx("pre",{className:"cmdblock",children:(f.commands??[]).join(`
`)})]},f.containerId))]})})}function Dy({jobs:o,activeJob:g,setActiveJob:j,reload:f,reloadPackages:O}){const D=g||o[0]?.id||"";return o.length===0?c.jsx("div",{className:"empty",children:"no migrations yet — select containers and start one"}):c.jsxs("div",{style:{display:"flex",minHeight:0,height:"100%"},children:[c.jsx("div",{className:"joblist",style:{width:320,flex:"0 0 320px",overflow:"auto"},children:o.map(x=>c.jsxs("div",{className:`jobcard${x.id===D?" active":""}`,onClick:()=>j(x.id),children:[c.jsxs("div",{className:"row",children:[c.jsx(Hn,{state:x.state}),c.jsx("span",{className:"spacer"}),c.jsx("span",{className:"small faint",children:x.kind==="ssh"?"ssh":"package"})]}),c.jsx("div",{className:"truncate",style:{marginTop:2},children:x.title}),c.jsxs("div",{className:"small faint",children:[new Date(x.createdAt).toLocaleTimeString()," · ",Br(x.startedAt,x.endedAt),x.dryRun&&" · dry run"]}),c.jsx("div",{style:{marginTop:6},children:c.jsx(xs,{done:x.bytesDone,total:x.bytesTotal,state:x.state})})]},x.id))}),c.jsx("div",{style:{flex:1,minWidth:0,overflow:"auto",borderLeft:"1px solid var(--border)"},children:D&&c.jsx(Cy,{id:D,reload:f,reloadPackages:O})})]})}function Cy({id:o,reload:g,reloadPackages:j}){const[f,O]=q.useState(null),[D,x]=q.useState(!0),L=q.useRef(null),U=q.useRef(!0);q.useEffect(()=>{O(null);let E=!1;rt.job(o).then(I=>!E&&O(I)).catch(()=>{});const C=rt.jobEvents(o);return C.onmessage=I=>{try{O(JSON.parse(I.data))}catch{}},C.addEventListener("done",()=>{C.close(),g(),j()}),C.onerror=()=>C.close(),()=>{E=!0,C.close()}},[o,g,j]);const p=q.useMemo(()=>(f?.log??[]).filter(E=>D||E.level!=="cmd"),[f,D]);if(q.useEffect(()=>{const E=L.current;E&&U.current&&(E.scrollTop=E.scrollHeight)},[p]),!f)return c.jsx("div",{className:"empty",children:"loading…"});const V=f.state==="running"||f.state==="pending";return c.jsxs("div",{style:{padding:16,display:"flex",flexDirection:"column",gap:14},children:[c.jsxs("div",{className:"row",children:[c.jsx(Hn,{state:f.state}),c.jsx("b",{children:f.title}),f.dryRun&&c.jsx("span",{className:"badge",children:"dry run"}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint",children:[ce(f.bytesDone),f.bytesTotal>0&&c.jsxs(c.Fragment,{children:[" of ",ce(f.bytesTotal)]})," · ",Br(f.startedAt,f.endedAt)]}),V?c.jsx("button",{className:"btn tiny danger",onClick:()=>rt.cancelJob(f.id).then(g),children:"cancel"}):c.jsx("button",{className:"btn tiny ghost",onClick:()=>rt.deleteJob(f.id).then(g),children:"remove"})]}),c.jsx(xs,{done:f.bytesDone,total:f.bytesTotal,state:f.state}),f.error&&c.jsx(Jt,{kind:"err",children:f.error}),f.state==="succeeded"&&f.artifact&&c.jsxs(Jt,{kind:"ok",children:["Package ready at ",c.jsx("span",{className:"mono",children:f.artifact})," (",ce(f.artifactBytes??0),")."," ","Open the Packages tab to download it."]}),f.items.map(E=>c.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:"8px 10px"},children:[c.jsxs("div",{className:"row",children:[c.jsx(Hn,{state:E.state}),c.jsx("b",{children:E.name}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint",children:[E.steps.filter(C=>C.state==="succeeded").length,"/",E.steps.length," steps"]})]}),E.error&&c.jsx("div",{className:"small",style:{color:"var(--err)"},children:E.error}),(E.warnings??[]).map((C,I)=>c.jsxs("div",{className:"small",style:{color:"var(--warn)"},children:["! ",C]},I)),c.jsx("div",{className:"steps",children:E.steps.map(C=>c.jsxs("div",{className:`step ${C.state}`,children:[c.jsx(Hn,{state:C.state,label:""}),c.jsx("span",{className:"label truncate",title:C.error||C.label,children:C.label}),c.jsx("span",{children:C.bytesTotal>0||C.bytesDone>0?c.jsx(xs,{done:C.bytesDone,total:C.bytesTotal,state:C.state}):null}),c.jsx("span",{className:"faint nowrap",style:{textAlign:"right"},children:C.bytesDone>0?ce(C.bytesDone):C.state==="skipped"?"skipped":""})]},C.id))})]},E.id)),c.jsxs("div",{className:"row",children:[c.jsx("h3",{style:{margin:0,fontSize:12},children:"log"}),c.jsx("span",{className:"spacer"}),c.jsxs("label",{className:"check small",children:[c.jsx("input",{type:"checkbox",checked:D,onChange:E=>x(E.target.checked)}),c.jsx("span",{children:"show commands"})]})]}),c.jsxs("div",{className:"log",ref:L,onScroll:E=>{const C=E.currentTarget;U.current=C.scrollHeight-C.scrollTop-C.clientHeight<24},children:[p.map(E=>c.jsxs("div",{className:`l-${E.level}`,children:[c.jsxs("span",{className:"ts",children:[new Date(E.at).toLocaleTimeString()," "]}),E.message]},E.seq)),p.length===0&&c.jsx("span",{className:"faint",children:"nothing logged yet"})]})]})}function Uy({packages:o,reload:g}){return o.length===0?c.jsx("div",{className:"empty",children:"no packages built yet"}):c.jsxs("div",{style:{padding:16,display:"flex",flexDirection:"column",gap:12},children:[c.jsxs(Jt,{kind:"info",children:["Copy a package to the target host, then run ",c.jsx("span",{className:"mono",children:"./install.sh --dry-run"})," to review it and"," ",c Line truncated
@@ -1 +1 @@
:root{--bg: #0e1116;--bg-raised: #161b22;--bg-sunken: #0a0d12;--bg-hover: #1c232d;--border: #262d38;--border-strong: #38414f;--text: #dfe6ef;--text-dim: #8b97a8;--text-faint: #5d6878;--accent: #4c9aff;--accent-dim: #1b3a63;--ok: #3fb950;--warn: #d29922;--err: #f85149;--run: #58a6ff;--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--radius: 6px;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}.app{display:flex;flex-direction:column;height:100%}.topbar{display:flex;align-items:center;gap:16px;padding:0 16px;height:48px;flex:0 0 auto;background:var(--bg-raised);border-bottom:1px solid var(--border)}.brand{font-family:var(--mono);font-weight:600;letter-spacing:-.3px;display:flex;align-items:center;gap:8px}.brand-logo{height:22px;width:auto;display:block}.tabs{display:flex;gap:2px;margin-left:8px}.tab{background:none;border:0;padding:6px 12px;border-radius:var(--radius);color:var(--text-dim);cursor:pointer}.tab:hover{background:var(--bg-hover);color:var(--text)}.tab.active{background:var(--accent-dim);color:#cfe3ff}.tab .count{font-family:var(--mono);font-size:11px;margin-left:6px;color:var(--text-faint)}.topbar-right{margin-left:auto;display:flex;align-items:center;gap:12px}.hostinfo{font-family:var(--mono);font-size:11px;color:var(--text-dim)}.hostinfo b{color:var(--text);font-weight:600}.body{flex:1;display:flex;min-height:0}.main{flex:1;min-width:0;overflow:auto}.sidebar{width:340px;flex:0 0 340px;overflow:auto;background:var(--bg-raised);border-left:1px solid var(--border)}@media(max-width:1100px){.body{flex-direction:column;overflow:auto}.main{overflow:visible;flex:0 0 auto}.sidebar{width:auto;flex:0 0 auto;overflow:visible;border-left:0;border-top:1px solid var(--border)}}.section{padding:14px 16px;border-bottom:1px solid var(--border)}.section h3{margin:0 0 10px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);font-weight:600}.row{display:flex;align-items:center;gap:8px}.row.wrap{flex-wrap:wrap}.spacer{flex:1}.stack{display:flex;flex-direction:column;gap:8px}.muted{color:var(--text-dim)}.faint{color:var(--text-faint)}.mono{font-family:var(--mono)}.small{font-size:11px}.nowrap{white-space:nowrap}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.btn{background:var(--bg-hover);border:1px solid var(--border-strong);border-radius:var(--radius);padding:6px 12px;cursor:pointer;color:var(--text);white-space:nowrap}.btn:hover:not(:disabled){background:#232c38;border-color:#4a5566}.btn:disabled{opacity:.45;cursor:not-allowed}.btn.primary{background:#1f6feb;border-color:#2f7ef5;color:#fff;font-weight:500}.btn.primary:hover:not(:disabled){background:#2b7bf3}.btn.danger{border-color:#6b2a28;color:#ff8a80}.btn.danger:hover:not(:disabled){background:#351c1b}.btn.tiny{padding:2px 7px;font-size:11px}.btn.ghost{background:none;border-color:transparent;color:var(--text-dim)}.btn.ghost:hover:not(:disabled){background:var(--bg-hover);color:var(--text)}input[type=text],input[type=number],input[type=password],select,textarea{background:var(--bg-sunken);border:1px solid var(--border-strong);border-radius:var(--radius);padding:5px 8px;width:100%}input:focus,select:focus,textarea:focus{outline:2px solid var(--accent-dim);border-color:var(--accent)}textarea{font-family:var(--mono);font-size:11px;resize:vertical}label.field{display:block}label.field>span{display:block;font-size:11px;color:var(--text-dim);margin-bottom:3px}.check{display:flex;align-items:center;gap:7px;cursor:pointer;-webkit-user-select:none;user-select:none}.check input{accent-color:var(--accent);width:14px;height:14px;margin:0;cursor:pointer}.check.disabled{opacity:.45;cursor:not-allowed}.badge{font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;border:1px solid var(--border-strong);color:var(--text-dim);white-space:nowrap}.badge.vol{border-color:#2d4a6b;color:#83b8f0}.badge.bind{border-color:#5c4520;color:#e0b556}.badge.anon{border-color:#46375e;color:#b294e0}.badge.tmpfs{border-color:#33404d;color:#9aa7b6}.badge.net{border-color:#2b5040;color:#6cc79b}.badge.port{border-color:#3a3f52;color:#a5aec9}.state{display:inline-flex;align-items:center;gap:5px;font-size:11px}.state .dot{width:7px;height:7px;border-radius:50%;background:var(--text-faint);flex:0 0 auto}.state.running .dot{background:var(--ok)}.state.exited .dot,.state.dead .dot{background:var(--text-faint)}.state.paused .dot,.state.restarting .dot{background:var(--warn)}.state.succeeded .dot{background:var(--ok)}.state.failed .dot{background:var(--err)}.state.canceled .dot{background:var(--warn)}.state.pending .dot,.state.skipped .dot{background:var(--text-faint)}.state.running .dot{animation:pulse 1.4s easeLine truncated
:root{--bg: #0e1116;--bg-raised: #161b22;--bg-sunken: #0a0d12;--bg-hover: #1c232d;--border: #262d38;--border-strong: #38414f;--text: #dfe6ef;--text-dim: #8b97a8;--text-faint: #5d6878;--accent: #4c9aff;--accent-dim: #1b3a63;--ok: #3fb950;--warn: #d29922;--err: #f85149;--run: #58a6ff;--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Mono", Menlo, Consolas, monospace;--sans: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;--radius: 6px;color-scheme:dark}*{box-sizing:border-box}html,body,#root{height:100%}body{margin:0;background:var(--bg);color:var(--text);font-family:var(--sans);font-size:13px;line-height:1.5;-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}.app{display:flex;flex-direction:column;height:100%}.topbar{display:flex;align-items:center;gap:16px;padding:0 16px;height:48px;flex:0 0 auto;background:var(--bg-raised);border-bottom:1px solid var(--border)}.brand{font-family:var(--mono);font-weight:600;letter-spacing:-.3px;display:flex;align-items:center;gap:8px}.brand-logo{height:22px;width:auto;display:block}.tabs{display:flex;gap:2px;margin-left:8px}.tab{background:none;border:0;padding:6px 12px;border-radius:var(--radius);color:var(--text-dim);cursor:pointer}.tab:hover{background:var(--bg-hover);color:var(--text)}.tab.active{background:var(--accent-dim);color:#cfe3ff}.tab .count{font-family:var(--mono);font-size:11px;margin-left:6px;color:var(--text-faint)}.topbar-right{margin-left:auto;display:flex;align-items:center;gap:12px}.hostinfo{font-family:var(--mono);font-size:11px;color:var(--text-dim)}.hostinfo b{color:var(--text);font-weight:600}.body{flex:1;display:flex;min-height:0}.main{flex:1;min-width:0;overflow:auto}.sidebar{width:340px;flex:0 0 340px;overflow:auto;background:var(--bg-raised);border-left:1px solid var(--border)}@media(max-width:1100px){.body{flex-direction:column;overflow:auto}.main{overflow:visible;flex:0 0 auto}.sidebar{width:auto;flex:0 0 auto;overflow:visible;border-left:0;border-top:1px solid var(--border)}}.section{padding:14px 16px;border-bottom:1px solid var(--border)}.section h3{margin:0 0 10px;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--text-faint);font-weight:600}.row{display:flex;align-items:center;gap:8px}.row.wrap{flex-wrap:wrap}.spacer{flex:1}.stack{display:flex;flex-direction:column;gap:8px}.muted{color:var(--text-dim)}.faint{color:var(--text-faint)}.mono{font-family:var(--mono)}.small{font-size:11px}.nowrap{white-space:nowrap}.truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.btn{background:var(--bg-hover);border:1px solid var(--border-strong);border-radius:var(--radius);padding:6px 12px;cursor:pointer;color:var(--text);white-space:nowrap}.btn:hover:not(:disabled){background:#232c38;border-color:#4a5566}.btn:disabled{opacity:.45;cursor:not-allowed}.btn.primary{background:#1f6feb;border-color:#2f7ef5;color:#fff;font-weight:500}.btn.primary:hover:not(:disabled){background:#2b7bf3}.btn.danger{border-color:#6b2a28;color:#ff8a80}.btn.danger:hover:not(:disabled){background:#351c1b}.btn.tiny{padding:2px 7px;font-size:11px}.btn.ghost{background:none;border-color:transparent;color:var(--text-dim)}.btn.ghost:hover:not(:disabled){background:var(--bg-hover);color:var(--text)}input[type=text],input[type=number],input[type=password],select,textarea{background:var(--bg-sunken);border:1px solid var(--border-strong);border-radius:var(--radius);padding:5px 8px;width:100%}input:focus,select:focus,textarea:focus{outline:2px solid var(--accent-dim);border-color:var(--accent)}textarea{font-family:var(--mono);font-size:11px;resize:vertical}label.field{display:block}label.field>span{display:block;font-size:11px;color:var(--text-dim);margin-bottom:3px}.check{display:flex;align-items:center;gap:7px;cursor:pointer;-webkit-user-select:none;user-select:none}.check input{accent-color:var(--accent);width:14px;height:14px;margin:0;cursor:pointer}.check.disabled{opacity:.45;cursor:not-allowed}.badge{font-family:var(--mono);font-size:10px;padding:1px 5px;border-radius:3px;border:1px solid var(--border-strong);color:var(--text-dim);white-space:nowrap}.badge.vol{border-color:#2d4a6b;color:#83b8f0}.badge.bind{border-color:#5c4520;color:#e0b556}.badge.anon{border-color:#46375e;color:#b294e0}.badge.tmpfs{border-color:#33404d;color:#9aa7b6}.badge.net{border-color:#2b5040;color:#6cc79b}.badge.port{border-color:#3a3f52;color:#a5aec9}.state{display:inline-flex;align-items:center;gap:5px;font-size:11px}.state .dot{width:7px;height:7px;border-radius:50%;background:var(--text-faint);flex:0 0 auto}.state.running .dot{background:var(--ok)}.state.exited .dot,.state.dead .dot{background:var(--text-faint)}.state.paused .dot,.state.restarting .dot{background:var(--warn)}.state.succeeded .dot{background:var(--ok)}.state.failed .dot{background:var(--err)}.state.canceled .dot{background:var(--warn)}.state.pending .dot,.state.skipped .dot{background:var(--text-faint)}.state.running .dot{animation:pulse 1.4s easeLine truncated
-11
View File
@@ -1,11 +0,0 @@
(function(){const S=document.createElement("link").relList;if(S&&S.supports&&S.supports("modulepreload"))return;for(const O of document.querySelectorAll('link[rel="modulepreload"]'))s(O);new MutationObserver(O=>{for(const D of O)if(D.type==="childList")for(const T of D.addedNodes)T.tagName==="LINK"&&T.rel==="modulepreload"&&s(T)}).observe(document,{childList:!0,subtree:!0});function z(O){const D={};return O.integrity&&(D.integrity=O.integrity),O.referrerPolicy&&(D.referrerPolicy=O.referrerPolicy),O.crossOrigin==="use-credentials"?D.credentials="include":O.crossOrigin==="anonymous"?D.credentials="omit":D.credentials="same-origin",D}function s(O){if(O.ep)return;O.ep=!0;const D=z(O);fetch(O.href,D)}})();var gf={exports:{}},Dn={};var Nr;function sy(){if(Nr)return Dn;Nr=1;var o=Symbol.for("react.transitional.element"),S=Symbol.for("react.fragment");function z(s,O,D){var T=null;if(D!==void 0&&(T=""+D),O.key!==void 0&&(T=""+O.key),"key"in O){D={};for(var w in O)w!=="key"&&(D[w]=O[w])}else D=O;return O=D.ref,{$$typeof:o,type:s,key:T,ref:O!==void 0?O:null,props:D}}return Dn.Fragment=S,Dn.jsx=z,Dn.jsxs=z,Dn}var Ar;function oy(){return Ar||(Ar=1,gf.exports=sy()),gf.exports}var c=oy(),Sf={exports:{}},I={};var _r;function dy(){if(_r)return I;_r=1;var o=Symbol.for("react.transitional.element"),S=Symbol.for("react.portal"),z=Symbol.for("react.fragment"),s=Symbol.for("react.strict_mode"),O=Symbol.for("react.profiler"),D=Symbol.for("react.consumer"),T=Symbol.for("react.context"),w=Symbol.for("react.forward_ref"),H=Symbol.for("react.suspense"),b=Symbol.for("react.memo"),V=Symbol.for("react.lazy"),E=Symbol.for("react.activity"),U=Symbol.iterator;function ll(h){return h===null||typeof h!="object"?null:(h=U&&h[U]||h["@@iterator"],typeof h=="function"?h:null)}var W={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},vl=Object.assign,Yl={};function gl(h,A,q){this.props=h,this.context=A,this.refs=Yl,this.updater=q||W}gl.prototype.isReactComponent={},gl.prototype.setState=function(h,A){if(typeof h!="object"&&typeof h!="function"&&h!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,h,A,"setState")},gl.prototype.forceUpdate=function(h){this.updater.enqueueForceUpdate(this,h,"forceUpdate")};function _l(){}_l.prototype=gl.prototype;function dl(h,A,q){this.props=h,this.context=A,this.refs=Yl,this.updater=q||W}var el=dl.prototype=new _l;el.constructor=dl,vl(el,gl.prototype),el.isPureReactComponent=!0;var Sl=Array.isArray;function Nl(){}var C={H:null,A:null,T:null,S:null},Z=Object.prototype.hasOwnProperty;function B(h,A,q){var Y=q.ref;return{$$typeof:o,type:h,key:A,ref:Y!==void 0?Y:null,props:q}}function $(h,A){return B(h.type,A,h.props)}function sl(h){return typeof h=="object"&&h!==null&&h.$$typeof===o}function jl(h){var A={"=":"=0",":":"=2"};return"$"+h.replace(/[=:]/g,function(q){return A[q]})}var at=/\/+/g;function tt(h,A){return typeof h=="object"&&h!==null&&h.key!=null?jl(""+h.key):A.toString(36)}function Cl(h){switch(h.status){case"fulfilled":return h.value;case"rejected":throw h.reason;default:switch(typeof h.status=="string"?h.then(Nl,Nl):(h.status="pending",h.then(function(A){h.status==="pending"&&(h.status="fulfilled",h.value=A)},function(A){h.status==="pending"&&(h.status="rejected",h.reason=A)})),h.status){case"fulfilled":return h.value;case"rejected":throw h.reason}}throw h}function j(h,A,q,Y,F){var al=typeof h;(al==="undefined"||al==="boolean")&&(h=null);var rl=!1;if(h===null)rl=!0;else switch(al){case"bigint":case"string":case"number":rl=!0;break;case"object":switch(h.$$typeof){case o:case S:rl=!0;break;case V:return rl=h._init,j(rl(h._payload),A,q,Y,F)}}if(rl)return F=F(h),rl=Y===""?"."+tt(h,0):Y,Sl(F)?(q="",rl!=null&&(q=rl.replace(at,"$&/")+"/"),j(F,A,q,"",function(k){return k})):F!=null&&(sl(F)&&(F=$(F,q+(F.key==null||h&&h.key===F.key?"":(""+F.key).replace(at,"$&/")+"/")+rl)),A.push(F)),1;rl=0;var Kl=Y===""?".":Y+":";if(Sl(h))for(var Ul=0;Ul<h.length;Ul++)Y=h[Ul],al=Kl+tt(Y,Ul),rl+=j(Y,A,q,al,F);else if(Ul=ll(h),typeof Ul=="function")for(h=Ul.call(h),Ul=0;!(Y=h.next()).done;)Y=Y.value,al=Kl+tt(Y,Ul++),rl+=j(Y,A,q,al,F);else if(al==="object"){if(typeof h.then=="function")return j(Cl(h),A,q,Y,F);throw A=String(h),Error("Objects are not valid as a React child (found: "+(A==="[object Object]"?"object with keys {"+Object.keys(h).join(", ")+"}":A)+"). If you meant to render a collection of children, use an array instead.")}return rl}function R(h,A,q){if(h==null)return h;var Y=[],F=0;return j(h,Y,"","",function(al){return A.call(q,al,F++)}),Y}function M(h){if(h._status===-1){var A=h._result;A=A(),A.then(function(q){(h._status===0||h._status===-1)&&(h._status=1,h._result=q)},function(q){(h._status===0||h._status===-1)&&(h._status=2,h._result=q)}),h._status===-1&&(h._status=0,h._result=A)}if(h._status===1)return h._result.default;throw h._result}vaLine truncated
at`)?" (<anonymous>)":-1<e.stack.indexOf("@")?"@unknown:0:0":""}return`
`+ce+l+Ie}var Pu=!1;function li(l,t){if(!l||Pu)return"";Pu=!0;var e=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var a={DetermineComponentFrameRoot:function(){try{if(t){var _=function(){throw Error()};if(Object.defineProperty(_.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(_,[])}catch(p){var g=p}Reflect.construct(l,[],_)}else{try{_.call()}catch(p){g=p}l.call(_.prototype)}}else{try{throw Error()}catch(p){g=p}(_=l())&&typeof _.catch=="function"&&_.catch(function(){})}}catch(p){if(p&&g&&typeof p.stack=="string")return[p.stack,g.stack]}return[null,null]}};a.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var n=Object.getOwnPropertyDescriptor(a.DetermineComponentFrameRoot,"name");n&&n.configurable&&Object.defineProperty(a.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var u=a.DetermineComponentFrameRoot(),i=u[0],f=u[1];if(i&&f){var d=i.split(`
`),v=f.split(`
`);for(n=a=0;a<d.length&&!d[a].includes("DetermineComponentFrameRoot");)a++;for(;n<v.length&&!v[n].includes("DetermineComponentFrameRoot");)n++;if(a===d.length||n===v.length)for(a=d.length-1,n=v.length-1;1<=a&&0<=n&&d[a]!==v[n];)n--;for(;1<=a&&0<=n;a--,n--)if(d[a]!==v[n]){if(a!==1||n!==1)do if(a--,n--,0>n||d[a]!==v[n]){var x=`
`+d[a].replace(" at new "," at ");return l.displayName&&x.includes("<anonymous>")&&(x=x.replace("<anonymous>",l.displayName)),x}while(1<=a&&0<=n);break}}}finally{Pu=!1,Error.prepareStackTrace=e}return(e=l?l.displayName||l.name:"")?Ue(e):""}function Xr(l,t){switch(l.tag){case 26:case 27:case 5:return Ue(l.type);case 16:return Ue("Lazy");case 13:return l.child!==t&&t!==null?Ue("Suspense Fallback"):Ue("Suspense");case 19:return Ue("SuspenseList");case 0:case 15:return li(l.type,!1);case 11:return li(l.type.render,!1);case 1:return li(l.type,!0);case 31:return Ue("Activity");default:return""}}function Ef(l){try{var t="",e=null;do t+=Xr(l,e),e=l,l=l.return;while(l);return t}catch(a){return`
Error generating stack: `+a.message+`
`+a.stack}}var ti=Object.prototype.hasOwnProperty,ei=o.unstable_scheduleCallback,ai=o.unstable_cancelCallback,Qr=o.unstable_shouldYield,Zr=o.unstable_requestPaint,mt=o.unstable_now,Lr=o.unstable_getCurrentPriorityLevel,Nf=o.unstable_ImmediatePriority,Af=o.unstable_UserBlockingPriority,Hn=o.unstable_NormalPriority,Vr=o.unstable_LowPriority,_f=o.unstable_IdlePriority,Kr=o.log,Jr=o.unstable_setDisableYieldValue,Ya=null,yt=null;function fe(l){if(typeof Kr=="function"&&Jr(l),yt&&typeof yt.setStrictMode=="function")try{yt.setStrictMode(Ya,l)}catch{}}var vt=Math.clz32?Math.clz32:$r,wr=Math.log,kr=Math.LN2;function $r(l){return l>>>=0,l===0?32:31-(wr(l)/kr|0)|0}var Rn=256,qn=262144,Bn=4194304;function He(l){var t=l&42;if(t!==0)return t;switch(l&-l){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return l&261888;case 262144:case 524288:case 1048576:case 2097152:return l&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return l&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return l}}function Yn(l,t,e){var a=l.pendingLanes;if(a===0)return 0;var n=0,u=l.suspendedLanes,i=l.pingedLanes;l=l.warmLanes;var f=a&134217727;return f!==0?(a=f&~u,a!==0?n=He(a):(i&=f,i!==0?n=He(i):e||(e=f&~l,e!==0&&(n=He(e))))):(f=a&~u,f!==0?n=He(f):i!==0?n=He(i):e||(e=a&~l,e!==0&&(n=He(e)))),n===0?0:t!==0&&t!==n&&(t&u)===0&&(u=n&-n,e=t&-t,u>=e||u===32&&(e&4194048)!==0)?t:n}function Ga(l,t){return(l.pendingLanes&~(l.suspendedLanes&~l.pingedLanes)&t)===0}function Wr(l,t){switch(l){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function Of(){var l=Bn;return Bn<<=1,(Bn&62914560)===0&&(Bn=4194304),l}function ni(l){for(var t=[],e=0;31>e;e++)t.push(l);return t}function Xa(l,t){l.pendingLanes|=t,t!==268435456&&(l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0)}function Fr(l,t,e,a,n,u){var i=l.pendingLanes;l.pendingLanes=e,l.suspendedLanes=0,l.pingedLanes=0,l.warmLanes=0,l.expiredLanes&=e,l.entangledLanes&=e,l.errorRecoveryDisabledLanes&=e,l.shellSuspendCounter=0;var f=l.entanglements,d=l.expirationTimes,v=l.hiddenUpdates;for(e=i&~e;0<e;){var x=31-vt(e),_=1<<x;f[x]=0,d[x]=-1;var g=v[x];if(g!==null)for(v[x]=null,x=0;x<g.length;x++){var p=g[x];p!==null&&(p.lane&=-536870913)}e&=~_}a!==0&&Mf(l,a,0),u!==0&&n===0&&l.tag!==0&&(l.suspendedLanes|=u&~(i&~t))}function Mf(l,t,e){l.pendingLanes|=t,l.suspendedLanes&=~t;var a=31-vt(t);l.entangledLanes|=t,l.entanglements[a]=l.entanglements[a]|1073741824|e&261930}function Df(l,t){var e=l.entangledLanes|=t;for(l=l.entanglements;e;){var a=31-vt(e),n=1<<a;n&t|l[a]&t&&(l[a]|=t),e&=~n}}function Cf(l,t){var e=t&-t;return e=(e&42)!==0?1:ui(e),(e&(l.suspendedLanes|t))!==0?0:e}function ui(l){switch(l){case 2:l=1;break;case 8:l=4;break;case 32:l=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:l=128;break;case 268435456:l=134217728;break;default:l=0}return l}function ii(l){return l&=-l,2<l?8<l?(l&134217727)!==0?32:268435456:8:2}function Uf(){var l=R.p;return l!==0?l:(l=window.event,l===void 0?32:br(l.type))}function Hf(l,t){var e=R.p;try{return R.p=l,t()}finally{R.p=e}}var se=Math.random().toString(36).slice(2),$l="__reactFiber$"+se,nt="__reactProps$"+se,Pe="__reactContainer$"+se,ci="__reactEvents$"+se,Ir="__reactListeners$"+se,Pr="__reactHandles$"+se,Rf="__reactResources$"+se,Qa="__reactMarker$"+se;function fi(l){delete l[$l],delete l[nt],delete l[ci],delete l[Ir],delete l[Pr]}function la(l){var t=l[$l];if(t)return t;for(var e=l.parentNode;e;){if(t=e[Pe]||e[$l]){if(e=t.alternate,t.child!==null||e!==null&&e.child!==null)for(l=ar(l);l!==null;){if(e=l[$l])return e;l=ar(l)}return t}l=e,e=l.parentNode}return null}function ta(l){if(l=l[$l]||l[Pe]){var t=l.tag;if(t===5||t===6||t===13||t===31||t===26||t===27||t===3)return l}return null}function Za(l){var t=l.tag;if(t===5||t===26||t===27||t===6)return l.stateNode;throw Error(s(33))}function ea(l){var t=l[Rf];return t||(t=l[Rf]={hoistableStyles:new Map,hoistableScripts:new Map}),t}function wl(l){l[Qa]=!0}var qf=new Set,Bf={};function Re(l,t){aa(l,t),aa(l+"Capture",t)}function aa(l,t){for(Bf[l]=t,l=0;l<t.length;l++)qf.add(t[l])}var lh=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u2070-\\u21 Line truncated
`).replace(Dm,"")}function wd(l,t){return t=Jd(t),Jd(l)===t}function zl(l,t,e,a,n,u){switch(e){case"children":typeof a=="string"?t==="body"||t==="textarea"&&a===""||ua(l,a):(typeof a=="number"||typeof a=="bigint")&&t!=="body"&&ua(l,""+a);break;case"className":Xn(l,"class",a);break;case"tabIndex":Xn(l,"tabindex",a);break;case"dir":case"role":case"viewBox":case"width":case"height":Xn(l,e,a);break;case"style":Jf(l,a,u);break;case"data":if(t!=="object"){Xn(l,"data",a);break}case"src":case"href":if(a===""&&(t!=="a"||e!=="href")){l.removeAttribute(e);break}if(a==null||typeof a=="function"||typeof a=="symbol"||typeof a=="boolean"){l.removeAttribute(e);break}a=Zn(""+a),l.setAttribute(e,a);break;case"action":case"formAction":if(typeof a=="function"){l.setAttribute(e,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof u=="function"&&(e==="formAction"?(t!=="input"&&zl(l,t,"name",n.name,n,null),zl(l,t,"formEncType",n.formEncType,n,null),zl(l,t,"formMethod",n.formMethod,n,null),zl(l,t,"formTarget",n.formTarget,n,null)):(zl(l,t,"encType",n.encType,n,null),zl(l,t,"method",n.method,n,null),zl(l,t,"target",n.target,n,null)));if(a==null||typeof a=="symbol"||typeof a=="boolean"){l.removeAttribute(e);break}a=Zn(""+a),l.setAttribute(e,a);break;case"onClick":a!=null&&(l.onclick=Lt);break;case"onScroll":a!=null&&il("scroll",l);break;case"onScrollEnd":a!=null&&il("scrollend",l);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(s(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(s(60));l.innerHTML=e}}break;case"multiple":l.multiple=a&&typeof a!="function"&&typeof a!="symbol";break;case"muted":l.muted=a&&typeof a!="function"&&typeof a!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(a==null||typeof a=="function"||typeof a=="boolean"||typeof a=="symbol"){l.removeAttribute("xlink:href");break}e=Zn(""+a),l.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",e);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":a!=null&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,""+a):l.removeAttribute(e);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":a&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,""):l.removeAttribute(e);break;case"capture":case"download":a===!0?l.setAttribute(e,""):a!==!1&&a!=null&&typeof a!="function"&&typeof a!="symbol"?l.setAttribute(e,a):l.removeAttribute(e);break;case"cols":case"rows":case"size":case"span":a!=null&&typeof a!="function"&&typeof a!="symbol"&&!isNaN(a)&&1<=a?l.setAttribute(e,a):l.removeAttribute(e);break;case"rowSpan":case"start":a==null||typeof a=="function"||typeof a=="symbol"||isNaN(a)?l.removeAttribute(e):l.setAttribute(e,a);break;case"popover":il("beforetoggle",l),il("toggle",l),Gn(l,"popover",a);break;case"xlinkActuate":Zt(l,"http://www.w3.org/1999/xlink","xlink:actuate",a);break;case"xlinkArcrole":Zt(l,"http://www.w3.org/1999/xlink","xlink:arcrole",a);break;case"xlinkRole":Zt(l,"http://www.w3.org/1999/xlink","xlink:role",a);break;case"xlinkShow":Zt(l,"http://www.w3.org/1999/xlink","xlink:show",a);break;case"xlinkTitle":Zt(l,"http://www.w3.org/1999/xlink","xlink:title",a);break;case"xlinkType":Zt(l,"http://www.w3.org/1999/xlink","xlink:type",a);break;case"xmlBase":Zt(l,"http://www.w3.org/XML/1998/namespace","xml:base",a);break;case"xmlLang":Zt(l,"http://www.w3.org/XML/1998/namespace","xml:lang",a);break;case"xmlSpace":Zt(l,"http://www.w3.org/XML/1998/namespace","xml:space",a);break;case"is":Gn(l,"is",a);break;case"innerText":case"textContent":break;default:(!(2<e.length)||e[0]!=="o"&&e[0]!=="O"||e[1]!=="n"&&e[1]!=="N")&&(e=uh.get(e)||e,Gn(l,e,a))}}function Ic(l,t,e,a,n,u){switch(e){case"style":Jf(l,a,u);break;case"dangerouslySetInnerHTML":if(a!=null){if(typeof a!="object"||!("__html"in a))throw Error(s(61));if(e=a.__html,e!=null){if(n.children!=null)throw Error(s(60));l.innerHTML=e}}break;case"children":typeof a=="string"?ua(l,a):(typeof a=="number"||typeof a=="bigint")&&ua(l,""+a);break;case"onScroll":a!=null&&il("scroll",l);break;case"onScrollEnd":a!=null&&il("scrollend",l);break;case"onClick":a!=null&&(l.onclick=Lt);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne Line truncated
`)})]}),o.items.map(s=>c.jsxs("div",{children:[c.jsxs("h3",{style:{margin:"0 0 6px",fontSize:12},children:[s.name,s.targetName!==s.name&&c.jsxs("span",{className:"faint",children:[" → ",s.targetName]})]}),(s.warnings??[]).map((O,D)=>c.jsx(ht,{kind:"warn",children:O},`w${D}`)),(s.notes??[]).map((O,D)=>c.jsxs("div",{className:"small faint",children:["· ",O]},`n${D}`)),c.jsx("pre",{className:"cmdblock",children:(s.commands??[]).join(`
`)})]},s.containerId))]})})}function My({jobs:o,activeJob:S,setActiveJob:z,reload:s,reloadPackages:O}){const D=S||o[0]?.id||"";return o.length===0?c.jsx("div",{className:"empty",children:"no migrations yet — select containers and start one"}):c.jsxs("div",{style:{display:"flex",minHeight:0,height:"100%"},children:[c.jsx("div",{className:"joblist",style:{width:320,flex:"0 0 320px",overflow:"auto"},children:o.map(T=>c.jsxs("div",{className:`jobcard${T.id===D?" active":""}`,onClick:()=>z(T.id),children:[c.jsxs("div",{className:"row",children:[c.jsx(Un,{state:T.state}),c.jsx("span",{className:"spacer"}),c.jsx("span",{className:"small faint",children:T.kind==="ssh"?"ssh":"package"})]}),c.jsx("div",{className:"truncate",style:{marginTop:2},children:T.title}),c.jsxs("div",{className:"small faint",children:[new Date(T.createdAt).toLocaleTimeString()," · ",qr(T.startedAt,T.endedAt),T.dryRun&&" · dry run"]}),c.jsx("div",{style:{marginTop:6},children:c.jsx(zf,{done:T.bytesDone,total:T.bytesTotal,state:T.state})})]},T.id))}),c.jsx("div",{style:{flex:1,minWidth:0,overflow:"auto",borderLeft:"1px solid var(--border)"},children:D&&c.jsx(Dy,{id:D,reload:s,reloadPackages:O})})]})}function Dy({id:o,reload:S,reloadPackages:z}){const[s,O]=Q.useState(null),[D,T]=Q.useState(!0),w=Q.useRef(null),H=Q.useRef(!0);Q.useEffect(()=>{O(null);let E=!1;Al.job(o).then(ll=>!E&&O(ll)).catch(()=>{});const U=Al.jobEvents(o);return U.onmessage=ll=>{try{O(JSON.parse(ll.data))}catch{}},U.addEventListener("done",()=>{U.close(),S(),z()}),U.onerror=()=>U.close(),()=>{E=!0,U.close()}},[o,S,z]);const b=Q.useMemo(()=>(s?.log??[]).filter(E=>D||E.level!=="cmd"),[s,D]);if(Q.useEffect(()=>{const E=w.current;E&&H.current&&(E.scrollTop=E.scrollHeight)},[b]),!s)return c.jsx("div",{className:"empty",children:"loading…"});const V=s.state==="running"||s.state==="pending";return c.jsxs("div",{style:{padding:16,display:"flex",flexDirection:"column",gap:14},children:[c.jsxs("div",{className:"row",children:[c.jsx(Un,{state:s.state}),c.jsx("b",{children:s.title}),s.dryRun&&c.jsx("span",{className:"badge",children:"dry run"}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint",children:[ie(s.bytesDone),s.bytesTotal>0&&c.jsxs(c.Fragment,{children:[" of ",ie(s.bytesTotal)]})," · ",qr(s.startedAt,s.endedAt)]}),V?c.jsx("button",{className:"btn tiny danger",onClick:()=>Al.cancelJob(s.id).then(S),children:"cancel"}):c.jsx("button",{className:"btn tiny ghost",onClick:()=>Al.deleteJob(s.id).then(S),children:"remove"})]}),c.jsx(zf,{done:s.bytesDone,total:s.bytesTotal,state:s.state}),s.error&&c.jsx(ht,{kind:"err",children:s.error}),s.state==="succeeded"&&s.artifact&&c.jsxs(ht,{kind:"ok",children:["Package ready at ",c.jsx("span",{className:"mono",children:s.artifact})," (",ie(s.artifactBytes??0),")."," ","Open the Packages tab to download it."]}),s.items.map(E=>c.jsxs("div",{style:{border:"1px solid var(--border)",borderRadius:6,padding:"8px 10px"},children:[c.jsxs("div",{className:"row",children:[c.jsx(Un,{state:E.state}),c.jsx("b",{children:E.name}),c.jsx("span",{className:"spacer"}),c.jsxs("span",{className:"small faint",children:[E.steps.filter(U=>U.state==="succeeded").length,"/",E.steps.length," steps"]})]}),E.error&&c.jsx("div",{className:"small",style:{color:"var(--err)"},children:E.error}),(E.warnings??[]).map((U,ll)=>c.jsxs("div",{className:"small",style:{color:"var(--warn)"},children:["! ",U]},ll)),c.jsx("div",{className:"steps",children:E.steps.map(U=>c.jsxs("div",{className:`step ${U.state}`,children:[c.jsx(Un,{state:U.state,label:""}),c.jsx("span",{className:"label truncate",title:U.error||U.label,children:U.label}),c.jsx("span",{children:U.bytesTotal>0||U.bytesDone>0?c.jsx(zf,{done:U.bytesDone,total:U.bytesTotal,state:U.state}):null}),c.jsx("span",{className:"faint nowrap",style:{textAlign:"right"},children:U.bytesDone>0?ie(U.bytesDone):U.state==="skipped"?"skipped":""})]},U.id))})]},E.id)),c.jsxs("div",{className:"row",children:[c.jsx("h3",{style:{margin:0,fontSize:12},children:"log"}),c.jsx("span",{className:"spacer"}),c.jsxs("label",{className:"check small",children:[c.jsx("input",{type:"checkbox",checked:D,onChange:E=>T(E.target.checked)}),c.jsx("span",{children:"show commands"})]})]}),c.jsxs("div",{className:"log",ref:w,onScroll:E=>{const U=E.currentTarget;H.current=U.scrollHeight-U.scrollTop-U.clientHeight<24},children:[b.map(E=>c.jsxs("div",{className:`l-${E.level}`,children:[c.jsxs("span",{className:"ts",children:[new Date(E.at).toLocaleTimeString()," "]}),E.message]},E.seq)),b.length===0&&c.jsx("span",{className:"faint",children:"nothing logged yet"})]})]})}function Cy({packages:o,reload:S}){return o.length===0?c.jsx("div",{className:"empty",children:"no packages built yet"}):c.jsxs("div",{style:{padding:16,display:"flex",flexDirection:"column",gap:12},children:[c.jsxs(ht,{kind:"info",children:["Copy a package to the target host, then run ",c.jsx("span",{className:"mono",children:"./install.sh --dry-run"})," to review it and" Line truncated
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="color-scheme" content="dark light" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>DockMV</title>
<script type="module" crossorigin src="/assets/index-qcSVszEj.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CKzWD9Xt.css">
<script type="module" crossorigin src="/assets/index-4h69aJoO.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CjhDzpx2.css">
</head>
<body>
<div id="root"></div>
+14 -28
View File
@@ -9,8 +9,6 @@ package main
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"flag"
@@ -81,8 +79,7 @@ Run "dockmv serve -h" for the server flags.
func serve(args []string) error {
fs := flag.NewFlagSet("serve", flag.ContinueOnError)
addr := fs.String("addr", "127.0.0.1:8080", "address to listen on; use 0.0.0.0:8080 to expose it on the network")
token := fs.String("token", os.Getenv("DOCKMV_TOKEN"), "require this token on every request; \"auto\" generates one (default: $DOCKMV_TOKEN)")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for connections and trusted host keys")
dataDir := fs.String("data-dir", defaultDataDir(), "directory for accounts, connections and trusted host keys")
pkgDir := fs.String("package-dir", "", "directory for migration packages (default <data-dir>/packages)")
dockerHost := fs.String("docker-host", "", "local source docker daemon (default: the DOCKER_HOST environment)")
verbose := fs.Bool("v", false, "verbose logging")
@@ -108,21 +105,8 @@ func serve(args []string) error {
}
logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level}))
authToken := *token
if authToken == "auto" {
authToken = randomToken()
}
// Anything but a loopback bind is reachable by other machines. This tool
// can stop containers and read every volume on the host, so it refuses to
// be exposed without a token.
if authToken == "" && !isLoopback(*addr) {
authToken = randomToken()
logger.Warn("listening on a non-loopback address; generated an access token")
}
cfg := api.Config{
Addr: *addr,
Token: authToken,
DataDir: *dataDir,
PackageDir: *pkgDir,
DockerHost: *dockerHost,
@@ -136,6 +120,19 @@ func serve(args []string) error {
}
defer srv.Close()
// This tool can stop containers and read every volume on the host, so a
// fresh install must complete account setup from loopback before it is
// reachable from anywhere else. DOCKMV_TRUST_ADDR skips this when some
// other layer already restricts exposure — e.g. the Docker image, which
// binds 0.0.0.0 internally so `ports: "127.0.0.1:8080:8080"` can do the
// actual restricting.
if srv.NeedsSetup() && !isLoopback(*addr) && os.Getenv("DOCKMV_TRUST_ADDR") == "" {
return fmt.Errorf("no account exists yet; bind to a loopback address first "+
"(e.g. --addr 127.0.0.1:8080), reach it with an SSH tunnel if needed "+
"(ssh -L 8080:127.0.0.1:8080 user@this-host), finish setup, then restart with %s; "+
"or set DOCKMV_TRUST_ADDR=1 if exposure is already restricted elsewhere (e.g. a container's own port mapping)", *addr)
}
if !webui.Built() {
logger.Warn("web UI is not embedded in this binary; only the HTTP API is available")
}
@@ -154,9 +151,6 @@ func serve(args []string) error {
}
url := "http://" + displayAddr(ln.Addr().String())
if authToken != "" {
url += "/?token=" + authToken
}
fmt.Fprintf(os.Stderr, "\n dockmv %s\n open %s\n data: %s\n\n", version, url, *dataDir)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
@@ -227,14 +221,6 @@ func defaultDataDir() string {
return ".dockmv"
}
func randomToken() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b)
}
func isLoopback(addr string) bool {
host, _, err := net.SplitHostPort(addr)
if err != nil {
+115
View File
@@ -0,0 +1,115 @@
import { useCallback, useEffect, useState } from 'react'
import { api, ApiError } from './api'
import type { APIToken, Me } from './types'
import { Notice } from './ui'
export function Account({ me }: { me: Me }) {
const [tokens, setTokens] = useState<APIToken[]>([])
const [name, setName] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
// Set only right after a create; the plaintext is never retrievable again.
const [revealed, setRevealed] = useState<{ name: string; token: string } | null>(null)
const load = useCallback(async () => {
try {
setTokens(await api.tokens())
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
useEffect(() => {
load()
}, [load])
const create = async () => {
const trimmed = name.trim()
if (!trimmed) return
setBusy(true)
setError('')
try {
const tok = await api.createToken(trimmed)
setRevealed({ name: tok.name, token: tok.token })
setName('')
await load()
} catch (e) {
setError(e instanceof ApiError ? e.message : String(e))
} finally {
setBusy(false)
}
}
const revoke = async (id: string) => {
if (!confirm('Revoke this token? Anything using it stops working immediately.')) return
await api.revokeToken(id)
await load()
}
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12, maxWidth: 720 }}>
<div className="section" style={{ padding: 0, border: 'none' }}>
<h3>signed in as</h3>
<div className="mono">{me.username}</div>
</div>
<div className="section" style={{ padding: 0, border: 'none' }}>
<h3>personal API tokens</h3>
<div className="small faint">
For scripted access: pass a token as <span className="mono">X-Auth-Token</span> or{' '}
<span className="mono">Authorization: Bearer …</span>.
</div>
</div>
{error && <Notice kind="err">{error}</Notice>}
{revealed && (
<Notice kind="warn">
Token <b>{revealed.name}</b> — copy it now, it will not be shown again:
<div className="fingerprint" style={{ marginTop: 6 }}>{revealed.token}</div>
<button className="btn tiny" style={{ marginTop: 6 }} onClick={() => setRevealed(null)}>done</button>
</Notice>
)}
<div className="row" style={{ gap: 8 }}>
<input
type="text"
placeholder="token name, e.g. ci-backup"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && create()}
/>
<button className="btn primary" onClick={create} disabled={busy || !name.trim()}>create token</button>
</div>
{tokens.length === 0 ? (
<div className="empty">no personal tokens yet</div>
) : (
<table className="mount-table">
<thead>
<tr>
<th>name</th>
<th style={{ width: 90 }}>ends in</th>
<th style={{ width: 170 }}>created</th>
<th style={{ width: 170 }}>last used</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{tokens.map((t) => (
<tr key={t.id}>
<td>{t.name}</td>
<td className="mono">…{t.hint}</td>
<td className="small faint">{new Date(t.createdAt).toLocaleString()}</td>
<td className="small faint">{t.lastUsedAt ? new Date(t.lastUsedAt).toLocaleString() : 'never'}</td>
<td>
<button className="btn tiny danger" onClick={() => revoke(t.id)}>revoke</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
)
}
+10 -3
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import { api } from './api'
import type {
Connection, Health, ItemSelection, JobSnapshot, Options, PackageInfo, Plan, Source, SourceResponse,
Connection, Health, ItemSelection, JobSnapshot, Me, Options, PackageInfo, Plan, Source, SourceResponse,
SourceStatus, TargetInventory,
} from './types'
import { Notice } from './ui'
@@ -10,10 +10,11 @@ import { SourcePanel } from './SourcePanel'
import { Sidebar } from './Sidebar'
import { Jobs } from './Jobs'
import { Packages } from './Packages'
import { Account } from './Account'
type View = 'containers' | 'jobs' | 'packages'
type View = 'containers' | 'jobs' | 'packages' | 'account'
export default function App() {
export default function App({ me, logout }: { me: Me; logout: () => void }) {
const [health, setHealth] = useState<Health | null>(null)
const [source, setSource] = useState<SourceResponse | null>(null)
const [sel, setSel] = useState<Record<string, ItemSelection>>({})
@@ -174,6 +175,9 @@ export default function App() {
Packages
{packages.length > 0 && <span className="count">{packages.length}</span>}
</button>
<button className={`tab${view === 'account' ? ' active' : ''}`} onClick={() => setView('account')}>
Account
</button>
</nav>
<div className="topbar-right">
{health && (
@@ -187,6 +191,8 @@ export default function App() {
<button className="btn tiny" onClick={loadSource} disabled={loading}>
{loading ? 'loading…' : 'refresh'}
</button>
<span className="small faint">{me.username}</span>
<button className="btn tiny ghost" onClick={logout}>sign out</button>
</div>
</header>
@@ -232,6 +238,7 @@ export default function App() {
/>
)}
{view === 'packages' && <Packages packages={packages} reload={loadPackages} />}
{view === 'account' && <Account me={me} />}
</main>
{view === 'containers' && (
+49
View File
@@ -0,0 +1,49 @@
import { useCallback, useEffect, useState } from 'react'
import App from './App'
import { api, setUnauthorizedHandler } from './api'
import { Login } from './Login'
import type { Me } from './types'
/**
* AuthGate decides, on load and after every 401, whether to show the setup
* screen, a login form, or the app itself. It owns the one GET /api/me call
* the app needs before it can render anything real.
*/
export function AuthGate() {
const [me, setMe] = useState<Me | null>(null)
const [error, setError] = useState('')
const load = useCallback(async () => {
try {
setMe(await api.me())
setError('')
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
}
}, [])
useEffect(() => {
load()
}, [load])
// A 401 elsewhere in the app (an expired or revoked session) means the
// server no longer considers us logged in; drop straight to the login form
// instead of waiting for the next unrelated re-render to notice.
useEffect(() => {
setUnauthorizedHandler(() => setMe((prev) => (prev ? { ...prev, authenticated: false } : prev)))
return () => setUnauthorizedHandler(null)
}, [])
const logout = useCallback(async () => {
await api.logout().catch(() => undefined)
await load()
}, [load])
if (!me) {
return <div className="empty">{error || 'loading…'}</div>
}
if (!me.authenticated) {
return <Login needsSetup={!!me.needsSetup} onDone={load} />
}
return <App me={me} logout={logout} />
}
+70
View File
@@ -0,0 +1,70 @@
import { useState, type FormEvent } from 'react'
import { api, ApiError } from './api'
import { Notice } from './ui'
/**
* Login is the one form for both first-run setup and every login after that:
* the server itself has no separate "register" concept, just an account
* store that starts empty.
*/
export function Login({ needsSetup, onDone }: { needsSetup: boolean; onDone: () => void }) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [busy, setBusy] = useState(false)
const submit = async (e: FormEvent) => {
e.preventDefault()
setBusy(true)
setError('')
try {
if (needsSetup) {
await api.setup(username, password)
} else {
await api.login(username, password)
}
onDone()
} catch (err) {
setError(err instanceof ApiError ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<div className="login">
<form className="login-box" onSubmit={submit}>
<div className="brand"><img src="/logo-icon.png" alt="" className="brand-logo" />DockMV</div>
<h1>{needsSetup ? 'Create your account' : 'Sign in'}</h1>
{needsSetup && (
<Notice kind="info">
No account exists yet. Create the first one to finish setup — every account can manage every
other one, there are no separate roles yet.
</Notice>
)}
<label className="field">
<span>username</span>
<input
type="text" autoFocus autoComplete="username"
value={username} onChange={(e) => setUsername(e.target.value)} required
/>
</label>
<label className="field">
<span>password</span>
<input
type="password" autoComplete={needsSetup ? 'new-password' : 'current-password'}
value={password} onChange={(e) => setPassword(e.target.value)} required
/>
</label>
{error && <Notice kind="err">{error}</Notice>}
<button className="btn primary" type="submit" disabled={busy}>
{busy ? 'please wait…' : needsSetup ? 'create account' : 'sign in'}
</button>
</form>
</div>
)
}
+34 -24
View File
@@ -1,23 +1,8 @@
import type {
Connection, Health, HostKeyInfo, JobSnapshot, PackageInfo, Plan,
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory,
APIToken, Connection, Health, HostKeyInfo, JobSnapshot, Me, PackageInfo, Plan,
Preflight, PreviewResponse, Source, SourceResponse, SourcesResponse, SourceStatus, TargetInventory, User,
} from './types'
// The token, when the server requires one, arrives as a query parameter the
// first time and is kept for the tab afterwards.
function readToken(): string {
const fromUrl = new URLSearchParams(location.search).get('token')
if (fromUrl) {
sessionStorage.setItem('dm.token', fromUrl)
const clean = location.pathname + location.hash
history.replaceState(null, '', clean)
return fromUrl
}
return sessionStorage.getItem('dm.token') ?? ''
}
const token = readToken()
/** ApiError carries the server's message plus anything it attached to it. */
export class ApiError extends Error {
status: number
@@ -33,12 +18,26 @@ export class ApiError extends Error {
}
}
// Paths whose own job is to report or resolve "not authenticated" — a 401
// from one of these is the expected outcome of a bad login, not a session
// that dropped out from under the app, so it must not trigger the global
// handler below.
const authPaths = new Set(['/api/me', '/api/login', '/api/setup'])
// Set by AuthGate so a 401 on any other endpoint (a session that expired or
// was revoked mid-use) drops the app back to the login screen, without every
// call site having to check for it.
let onUnauthorized: (() => void) | null = null
export function setUnauthorizedHandler(fn: (() => void) | null) {
onUnauthorized = fn
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const headers: Record<string, string> = { ...(init?.headers as Record<string, string>) }
if (token) headers['X-Auth-Token'] = token
if (init?.body) headers['Content-Type'] = 'application/json'
const res = await fetch(path, { ...init, headers })
const res = await fetch(path, { ...init, headers, credentials: 'same-origin' })
if (res.status === 401 && !authPaths.has(path)) onUnauthorized?.()
if (res.status === 204) return undefined as T
const text = await res.text()
@@ -65,6 +64,19 @@ export const api = {
source: () => request<SourceResponse>('/api/source'),
volumeSizes: () => request<{ volumes: Record<string, number> }>('/api/source/sizes'),
me: () => request<Me>('/api/me'),
setup: (username: string, password: string) => post<Me>('/api/setup', { username, password }),
login: (username: string, password: string) => post<Me>('/api/login', { username, password }),
logout: () => request<void>('/api/logout', { method: 'POST' }),
users: () => request<User[]>('/api/users'),
createUser: (username: string, password: string) => post<User>('/api/users', { username, password }),
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
tokens: () => request<APIToken[]>('/api/tokens'),
createToken: (name: string) => post<APIToken & { token: string }>('/api/tokens', { name }),
revokeToken: (id: string) => request<void>(`/api/tokens/${id}`, { method: 'DELETE' }),
sources: () => request<SourcesResponse>('/api/sources'),
saveSource: (s: Partial<Source>) => post<Source>('/api/sources', s),
deleteSource: (id: string) => request<void>(`/api/sources/${id}`, { method: 'DELETE' }),
@@ -92,10 +104,8 @@ export const api = {
packages: () => request<PackageInfo[]>('/api/packages'),
deletePackage: (name: string) => request<void>(`/api/packages/${encodeURIComponent(name)}`, { method: 'DELETE' }),
downloadUrl: (name: string) =>
`/api/packages/${encodeURIComponent(name)}/download` + (token ? `?token=${encodeURIComponent(token)}` : ''),
downloadUrl: (name: string) => `/api/packages/${encodeURIComponent(name)}/download`,
/** Opens the live progress stream for a job. */
jobEvents: (id: string) =>
new EventSource(`/api/jobs/${id}/events` + (token ? `?token=${encodeURIComponent(token)}` : '')),
/** Opens the live progress stream for a job. Cookies ride along automatically: it's a same-origin request. */
jobEvents: (id: string) => new EventSource(`/api/jobs/${id}/events`),
}
+2 -2
View File
@@ -1,10 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import { AuthGate } from './AuthGate'
import './styles.css'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<AuthGate />
</StrictMode>,
)
+11
View File
@@ -311,3 +311,14 @@ label.field > span { display: block; font-size: 11px; color: var(--text-dim); ma
background: var(--bg-sunken); border: 1px solid var(--border-strong);
border-radius: var(--radius); padding: 8px 10px;
}
/* ---------- login ---------- */
.login { height: 100%; display: flex; align-items: center; justify-content: center; }
.login-box {
width: min(360px, 90vw); display: flex; flex-direction: column; gap: 14px;
background: var(--bg-raised); border: 1px solid var(--border);
border-radius: var(--radius); padding: 28px;
}
.login-box .brand { display: flex; align-items: center; gap: 8px; font-weight: 600; }
.login-box h1 { font-size: 16px; font-weight: 600; margin: 0; }
+24 -1
View File
@@ -286,5 +286,28 @@ export interface Health {
packageDir: string
dataDir: string
knownHosts: string
authRequired: boolean
}
export interface User {
id: string
username: string
createdAt: string
lastLoginAt?: string
}
export interface APIToken {
id: string
userId: string
name: string
hint: string
createdAt: string
lastUsedAt?: string
}
/** The response from GET /api/me, /api/setup and /api/login. */
export interface Me {
id?: string
username?: string
authenticated: boolean
needsSetup?: boolean
}