perf(panel): speed up file browsing and preserve loading forms
This commit is contained in:
27 files changed
+1387
-145
No files matched your search
+6
-1
@@ -342,11 +342,15 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
// orphaned (the index is in memory), so the stage starts empty.
|
// orphaned (the index is in memory), so the stage starts empty.
|
||||||
var files api.FileEditor
|
var files api.FileEditor
|
||||||
var fileStage *fileedit.Stage
|
var fileStage *fileedit.Stage
|
||||||
|
var fileBrowser *fileedit.Browser
|
||||||
if felisImage != "" {
|
if felisImage != "" {
|
||||||
fcfg := fileEditConfig(cfg, felisImage)
|
fcfg := fileEditConfig(cfg, felisImage)
|
||||||
fcfg.ResolveWorld = worldResolver
|
fcfg.ResolveWorld = worldResolver
|
||||||
|
fileBrowser = &fileedit.Browser{BaseURL: internalAPIBaseURL()}
|
||||||
|
runner := fileedit.NewK8sRunner(clientset)
|
||||||
|
runner.Browser = fileBrowser
|
||||||
files = &fileedit.Editor{
|
files = &fileedit.Editor{
|
||||||
Runner: fileedit.NewK8sRunner(clientset),
|
Runner: runner,
|
||||||
Config: fcfg,
|
Config: fcfg,
|
||||||
}
|
}
|
||||||
fileStage = &fileedit.Stage{Dir: fileStagingDir()}
|
fileStage = &fileedit.Stage{Dir: fileStagingDir()}
|
||||||
@@ -416,6 +420,7 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
|
|||||||
RestoreChains: jobStatus,
|
RestoreChains: jobStatus,
|
||||||
Files: files,
|
Files: files,
|
||||||
FileStage: fileStage,
|
FileStage: fileStage,
|
||||||
|
FileBrowser: fileBrowser,
|
||||||
// The file Job fetches an upload from here; it runs in the minecraft
|
// The file Job fetches an upload from here; it runs in the minecraft
|
||||||
// namespace, where the internal face is reachable like it is for the login
|
// namespace, where the internal face is reachable like it is for the login
|
||||||
// gate.
|
// gate.
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
|||||||
fs := flag.NewFlagSet("files", flag.ContinueOnError)
|
fs := flag.NewFlagSet("files", flag.ContinueOnError)
|
||||||
fs.SetOutput(stderr)
|
fs.SetOutput(stderr)
|
||||||
op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip")
|
op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename, upload or unzip")
|
||||||
|
browseURL := fs.String("browse-url", "", "internal command channel for a read-only file browser")
|
||||||
path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)")
|
path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)")
|
||||||
worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it")
|
worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it")
|
||||||
expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this")
|
expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this")
|
||||||
@@ -53,6 +54,16 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *browseURL != "" {
|
||||||
|
limitHeapToCgroup()
|
||||||
|
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||||
|
defer stop()
|
||||||
|
if err := fileedit.Browse(ctx, *worldsRoot, *browseURL, os.Getenv(fileedit.BrowserTokenEnv)); err != nil {
|
||||||
|
fmt.Fprintf(stderr, "felis files: %v\n", err)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
if *op == "" {
|
if *op == "" {
|
||||||
fmt.Fprintln(stderr, "felis files: --op is required")
|
fmt.Fprintln(stderr, "felis files: --op is required")
|
||||||
return 2
|
return 2
|
||||||
|
|||||||
@@ -1625,6 +1625,56 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/api/v1/internal/file-browser/{id}:
|
||||||
|
post:
|
||||||
|
tags: [files]
|
||||||
|
operationId: internalFileBrowser
|
||||||
|
summary: Exchange a read-only file Job's result for its next command.
|
||||||
|
description: >-
|
||||||
|
Internal face only. A random bearer token scopes the worker to one
|
||||||
|
world and a four-minute session; idle workers exit after 45 seconds.
|
||||||
|
The first request sends an empty object. Later requests return the
|
||||||
|
previous command's id and result or error. This endpoint dispatches
|
||||||
|
only reads already authorized by the external file API.
|
||||||
|
x-felis-face: [internal]
|
||||||
|
x-felis-tier: public
|
||||||
|
security: []
|
||||||
|
parameters:
|
||||||
|
- { name: id, in: path, required: true, schema: { type: string } }
|
||||||
|
- { name: Authorization, in: header, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
result: { type: object }
|
||||||
|
error: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: The next authorized read command.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [id, op, path]
|
||||||
|
properties:
|
||||||
|
id: { type: string }
|
||||||
|
op: { type: string, enum: [list, read] }
|
||||||
|
path: { type: string }
|
||||||
|
'204':
|
||||||
|
$ref: '#/components/responses/NoContent'
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
/api/v1/internal/exports/{id}:
|
/api/v1/internal/exports/{id}:
|
||||||
put:
|
put:
|
||||||
tags: [backups]
|
tags: [backups]
|
||||||
|
|||||||
+12
-2
@@ -3170,8 +3170,18 @@ for 10 seconds (the Free plan's limits).
|
|||||||
## 18. Server files: a change or an upload is refused
|
## 18. Server files: a change or an upload is refused
|
||||||
|
|
||||||
The panel's Files page is for the server's owner or an admin, and only while
|
The panel's Files page is for the server's owner or an admin, and only while
|
||||||
the server is fully stopped. Each call runs a one-shot `felis files` Job in the
|
the server is fully stopped. Reads reuse a read-only `felis files` Job for the
|
||||||
`minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and
|
same world, avoiding Pod startup for every folder and file. The worker pulls
|
||||||
|
commands from the existing internal API with a random, world-scoped token;
|
||||||
|
each request still passes the owner/admin and stopped gates. It exits after
|
||||||
|
45 idle seconds or four minutes total; at most four workers exist per API
|
||||||
|
process, with one-shot reads used at capacity. No file contents are cached.
|
||||||
|
The panel caches directory listings for 30 seconds; Refresh, a write, upload
|
||||||
|
or restore invalidates them. The text editor highlights common config formats
|
||||||
|
and preserves CRLF; binary and oversized files offer download instead.
|
||||||
|
|
||||||
|
Changes still run one-shot Jobs in the `minecraft` namespace, labelled
|
||||||
|
`app.kubernetes.io/managed-by=felis-files` and
|
||||||
`felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload|unzip>`.
|
`felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload|unzip>`.
|
||||||
A listing or a read holds nothing. Every change (a save, a new file or folder,
|
A listing or a read holds nothing. Every change (a save, a new file or folder,
|
||||||
a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a
|
a rename, a delete, an upload, an unzip) holds the world for its Job (§3b), so a wake or a
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ type API struct {
|
|||||||
// InternalBaseURL is where that Job reaches felis-api's internal face to do so
|
// InternalBaseURL is where that Job reaches felis-api's internal face to do so
|
||||||
// (handleUploadFile). Uploads report 503 unless both are set.
|
// (handleUploadFile). Uploads report 503 unless both are set.
|
||||||
FileStage *fileedit.Stage
|
FileStage *fileedit.Stage
|
||||||
|
FileBrowser *fileedit.Browser
|
||||||
InternalBaseURL string
|
InternalBaseURL string
|
||||||
|
|
||||||
// Exporter starts the Job behind a world or backup download (exports.go),
|
// Exporter starts the Job behind a world or backup download (exports.go),
|
||||||
@@ -476,6 +477,8 @@ func (a *API) internalAPIRoutes() []apiRoute {
|
|||||||
// with the upload is the check (handlers_files.go).
|
// with the upload is the check (handlers_files.go).
|
||||||
{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
|
{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
|
||||||
{Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded},
|
{Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded},
|
||||||
|
// Read-only file Jobs pull commands with their own scoped bearer token.
|
||||||
|
{Method: "POST", Pattern: "/api/v1/internal/file-browser/{id}", Public: true, h: a.handleInternalFileBrowser},
|
||||||
|
|
||||||
// An export Job's archive, held open until the owner's browser downloads
|
// An export Job's archive, held open until the owner's browser downloads
|
||||||
// it. Public for the same reason as file uploads: the Job holds no service
|
// it. Public for the same reason as file uploads: the Job holds no service
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ import (
|
|||||||
// internal/fileedit, which explains why that transport needs no RBAC felis-api
|
// internal/fileedit, which explains why that transport needs no RBAC felis-api
|
||||||
// does not already hold. Unlike Restorer and Backuper these calls are
|
// does not already hold. Unlike Restorer and Backuper these calls are
|
||||||
// SYNCHRONOUS: the caller wants the listing or the bytes, so the handler blocks on
|
// SYNCHRONOUS: the caller wants the listing or the bytes, so the handler blocks on
|
||||||
// the Job (seconds, dominated by Pod scheduling) rather than answering 202.
|
// the result rather than answering 202.
|
||||||
//
|
//
|
||||||
// It is an interface so the handlers are unit-tested against a fake; the
|
// It is an interface so the handlers are unit-tested against a fake; the
|
||||||
// production implementation is *fileedit.Editor. Using fileedit.Entry directly
|
// production implementation is *fileedit.Editor. Using fileedit.Entry directly
|
||||||
@@ -61,6 +61,14 @@ type FileEditor interface {
|
|||||||
Ops(ctx context.Context, server string) ([]fileedit.OpState, error)
|
Ops(ctx context.Context, server string) ([]fileedit.OpState, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *API) handleInternalFileBrowser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if a.FileBrowser == nil {
|
||||||
|
writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable", "file browser is not configured"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.FileBrowser.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
// writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so
|
// writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so
|
||||||
// encoding/json requires it to be base64 — which is what makes the write path
|
// encoding/json requires it to be base64 — which is what makes the write path
|
||||||
// binary-safe: a config file with CRLF line endings, a UTF-8 BOM, or a stray
|
// binary-safe: a config file with CRLF line endings, a UTF-8 BOM, or a stray
|
||||||
|
|||||||
@@ -53,6 +53,24 @@ type fakeFileEditor struct {
|
|||||||
ops []fileedit.OpState
|
ops []fileedit.OpState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFileBrowserStaysOnInternalFaceWithScopedAuthentication(t *testing.T) {
|
||||||
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
a.Internal = CallerTokens{CallerVelocity: "service-token"}
|
||||||
|
a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}}
|
||||||
|
a.FileBrowser = &fileedit.Browser{}
|
||||||
|
for _, h := range []http.Handler{a.InternalHandler(), a.ExternalHandler()} {
|
||||||
|
w := do(h, "POST", fileedit.BrowserRoute+"unknown", `{}`, map[string]string{"Authorization": "Bearer service-token"})
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("service token opened a browser: status %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
a.FileBrowser = nil
|
||||||
|
w := do(a.InternalHandler(), "POST", fileedit.BrowserRoute+"unknown", `{}`, nil)
|
||||||
|
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" {
|
||||||
|
t.Fatalf("unconfigured browser: status %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeFileEditor) List(_ context.Context, server, path string) (fileedit.Listing, error) {
|
func (f *fakeFileEditor) List(_ context.Context, server, path string) (fileedit.Listing, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
f.gotServer, f.gotPath = server, path
|
f.gotServer, f.gotPath = server, path
|
||||||
|
|||||||
@@ -0,0 +1,266 @@
|
|||||||
|
package fileedit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Browse runs inside the read-only Job and reuses Execute for every command.
|
||||||
|
// Refuse write commands even if the other end violates the protocol.
|
||||||
|
func Browse(ctx context.Context, worldRoot, url, token string) error {
|
||||||
|
if token == "" {
|
||||||
|
return fmt.Errorf("fileedit: browser token is required")
|
||||||
|
}
|
||||||
|
client := &http.Client{Timeout: browserIdle + 30*time.Second,
|
||||||
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||||
|
answer := BrowseAnswer{}
|
||||||
|
for {
|
||||||
|
body, err := json.Marshal(answer)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if resp.StatusCode == http.StatusNoContent {
|
||||||
|
resp.Body.Close()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
resp.Body.Close()
|
||||||
|
return fmt.Errorf("fileedit: browser exchange returned %s", resp.Status)
|
||||||
|
}
|
||||||
|
var command BrowseCommand
|
||||||
|
err = json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&command)
|
||||||
|
resp.Body.Close()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if command.Op != OpList && command.Op != OpRead {
|
||||||
|
return fmt.Errorf("fileedit: browser refused operation %q", command.Op)
|
||||||
|
}
|
||||||
|
res, err := Execute(worldRoot, Request{Op: command.Op, Path: command.Path})
|
||||||
|
answer = BrowseAnswer{ID: command.ID}
|
||||||
|
if err != nil {
|
||||||
|
answer.Error = err.Error()
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
answer.Result, err = json.Marshal(res)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
BrowserTokenEnv = "FELIS_FILE_BROWSER_TOKEN"
|
||||||
|
BrowserRoute = "/api/v1/internal/file-browser/"
|
||||||
|
browserIdle = 45 * time.Second
|
||||||
|
browserLifetime = 4 * time.Minute
|
||||||
|
maxBrowsers = 4
|
||||||
|
)
|
||||||
|
|
||||||
|
var errBrowserFull = errors.New("fileedit: browser capacity reached")
|
||||||
|
|
||||||
|
// Browser reuses a short-lived, read-only Job per world. Jobs pull commands from
|
||||||
|
// the existing internal API face: no inbound Pod port or new RBAC is needed.
|
||||||
|
// Every browser request is still authorized by the normal file API handlers.
|
||||||
|
type Browser struct {
|
||||||
|
BaseURL string
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[string]*browseSession
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseCommand struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Op string `json:"op"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type BrowseAnswer struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Result json.RawMessage `json:"result,omitempty"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type browseSession struct {
|
||||||
|
id, key string
|
||||||
|
tokenHash [sha256.Size]byte
|
||||||
|
commands chan BrowseCommand
|
||||||
|
answers chan BrowseAnswer
|
||||||
|
serial chan struct{}
|
||||||
|
done chan struct{}
|
||||||
|
once sync.Once
|
||||||
|
timer *time.Timer
|
||||||
|
mu sync.Mutex
|
||||||
|
connected bool
|
||||||
|
pending string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) close(s *browseSession) {
|
||||||
|
b.mu.Lock()
|
||||||
|
if b.sessions[s.key] == s {
|
||||||
|
delete(b.sessions, s.key)
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
s.once.Do(func() {
|
||||||
|
if s.timer != nil {
|
||||||
|
s.timer.Stop()
|
||||||
|
}
|
||||||
|
close(s.done)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) session(ctx context.Context, p JobParams, start func(context.Context, JobParams) error) (*browseSession, error) {
|
||||||
|
key := strings.Join([]string{p.Namespace, p.Server, p.WorldPVC, p.NodeName, p.Image}, "\x00")
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
if s := b.sessions[key]; s != nil {
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
if len(b.sessions) >= maxBrowsers {
|
||||||
|
return nil, errBrowserFull
|
||||||
|
}
|
||||||
|
token, err := randomHex(32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &browseSession{id: p.OpID, key: key, tokenHash: sha256.Sum256([]byte(token)),
|
||||||
|
commands: make(chan BrowseCommand, 1), answers: make(chan BrowseAnswer, 1), serial: make(chan struct{}, 1), done: make(chan struct{})}
|
||||||
|
if b.sessions == nil {
|
||||||
|
b.sessions = make(map[string]*browseSession)
|
||||||
|
}
|
||||||
|
b.sessions[key] = s
|
||||||
|
p.BrowserURL, p.BrowserToken = strings.TrimRight(b.BaseURL, "/")+BrowserRoute+s.id, token
|
||||||
|
p.Deadline = browserLifetime
|
||||||
|
if err := start(ctx, p); err != nil {
|
||||||
|
delete(b.sessions, key)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
delete(b.sessions, key)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.timer = time.AfterFunc(browserLifetime, func() { b.close(s) })
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *Browser) Run(ctx context.Context, p JobParams, start func(context.Context, JobParams) error) ([]byte, error) {
|
||||||
|
s, err := b.session(ctx, p, start)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case s.serial <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-s.done:
|
||||||
|
return nil, fmt.Errorf("fileedit: browser closed")
|
||||||
|
}
|
||||||
|
defer func() { <-s.serial }()
|
||||||
|
s.mu.Lock()
|
||||||
|
s.pending = p.OpID
|
||||||
|
s.mu.Unlock()
|
||||||
|
select {
|
||||||
|
case s.commands <- BrowseCommand{ID: p.OpID, Op: p.Op, Path: p.Path}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
b.close(s)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-s.done:
|
||||||
|
return nil, fmt.Errorf("fileedit: browser closed")
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case answer := <-s.answers:
|
||||||
|
if answer.Error != "" {
|
||||||
|
return nil, fmt.Errorf("fileedit: browser read: %s", answer.Error)
|
||||||
|
}
|
||||||
|
return answer.Result, nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
b.close(s)
|
||||||
|
return nil, ctx.Err()
|
||||||
|
case <-s.done:
|
||||||
|
return nil, fmt.Errorf("fileedit: browser closed before returning a result")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP accepts one worker's result and long-polls its next command. The
|
||||||
|
// random token opens only this world/session, and lives only in the Job's env.
|
||||||
|
func (b *Browser) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := strings.TrimPrefix(r.URL.Path, BrowserRoute)
|
||||||
|
b.mu.Lock()
|
||||||
|
var s *browseSession
|
||||||
|
for _, candidate := range b.sessions {
|
||||||
|
if candidate.id == id {
|
||||||
|
s = candidate
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
b.mu.Unlock()
|
||||||
|
token, bearer := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
|
||||||
|
sum := sha256.Sum256([]byte(token))
|
||||||
|
if s == nil || !bearer || subtle.ConstantTimeCompare(sum[:], s.tokenHash[:]) != 1 {
|
||||||
|
http.Error(w, "unknown browser", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var answer BrowseAnswer
|
||||||
|
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxLogBytes))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&answer); err != nil {
|
||||||
|
http.Error(w, "invalid result", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mu.Lock()
|
||||||
|
valid := !s.connected && answer.ID == "" || s.connected && answer.ID != "" && answer.ID == s.pending
|
||||||
|
if valid {
|
||||||
|
s.connected = true
|
||||||
|
if answer.ID != "" {
|
||||||
|
s.pending = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if !valid {
|
||||||
|
http.Error(w, "unexpected result", http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if answer.ID != "" {
|
||||||
|
select {
|
||||||
|
case s.answers <- answer:
|
||||||
|
case <-s.done:
|
||||||
|
w.WriteHeader(http.StatusGone)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
timer := time.NewTimer(browserIdle)
|
||||||
|
defer timer.Stop()
|
||||||
|
select {
|
||||||
|
case command := <-s.commands:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err := json.NewEncoder(w).Encode(command); err != nil {
|
||||||
|
b.close(s)
|
||||||
|
}
|
||||||
|
case <-timer.C:
|
||||||
|
b.close(s)
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case <-s.done:
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
case <-r.Context().Done():
|
||||||
|
b.close(s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package fileedit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestBrowserReusesWorkerAndReadsCurrentBytes(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte("enabled: true\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
b := &Browser{}
|
||||||
|
srv := httptest.NewServer(b)
|
||||||
|
b.BaseURL = srv.URL
|
||||||
|
defer srv.Close()
|
||||||
|
defer cancel()
|
||||||
|
var workers atomic.Int32
|
||||||
|
start := func(_ context.Context, p JobParams) error {
|
||||||
|
workers.Add(1)
|
||||||
|
job, err := FilesJob(p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pod := job.Spec.Template.Spec
|
||||||
|
if pod.AutomountServiceAccountToken == nil || *pod.AutomountServiceAccountToken || len(pod.Volumes) != 1 || !pod.Containers[0].VolumeMounts[0].ReadOnly {
|
||||||
|
t.Error("browser weakened the file Job's isolation")
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
if err := Browse(ctx, root, p.BrowserURL, p.BrowserToken); err != nil && ctx.Err() == nil {
|
||||||
|
t.Errorf("worker: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
read := func(op, path string) Result {
|
||||||
|
p := testParams(op)
|
||||||
|
p.Path = path
|
||||||
|
p.OpID, _ = newOpID()
|
||||||
|
payload, err := b.Run(ctx, p, start)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var result Result
|
||||||
|
if err := json.Unmarshal(payload, &result); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if got := read(OpRead, "config.yml"); string(got.Content) != "enabled: true\n" {
|
||||||
|
t.Fatalf("first read: %+v", got)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte("enabled: false\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := read(OpRead, "config.yml"); string(got.Content) != "enabled: false\n" {
|
||||||
|
t.Fatalf("stale read: %+v", got)
|
||||||
|
}
|
||||||
|
if got := read(OpList, ""); len(got.Entries) != 1 {
|
||||||
|
t.Fatalf("listing: %+v", got)
|
||||||
|
}
|
||||||
|
if got := read(OpRead, "../outside"); got.Code != CodeBadPath {
|
||||||
|
t.Fatalf("containment: %+v", got)
|
||||||
|
}
|
||||||
|
if workers.Load() != 1 {
|
||||||
|
t.Fatalf("started %d workers for repeated reads", workers.Load())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simultaneous readers still receive their own result, never the other path's.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for _, path := range []string{"config.yml", "missing.yml"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
p := testParams(OpRead)
|
||||||
|
p.Path = path
|
||||||
|
p.OpID, _ = newOpID()
|
||||||
|
payload, err := b.Run(ctx, p, start)
|
||||||
|
if err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var got Result
|
||||||
|
json.Unmarshal(payload, &got)
|
||||||
|
if path == "missing.yml" && got.Code != CodeNotFound || path == "config.yml" && string(got.Content) != "enabled: false\n" {
|
||||||
|
t.Errorf("%s: %+v", path, got)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowserCancellationRevokesToken(t *testing.T) {
|
||||||
|
b := &Browser{BaseURL: "http://internal"}
|
||||||
|
var started JobParams
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
p := testParams(OpRead)
|
||||||
|
_, err := b.Run(ctx, p, func(_ context.Context, p JobParams) error { started = p; cancel(); return nil })
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("cancelled read succeeded")
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest(http.MethodPost, started.BrowserURL, strings.NewReader(`{}`))
|
||||||
|
r.Header.Set("Authorization", "Bearer "+started.BrowserToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
b.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("cancelled token returned %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowserRejectsWrongTokenAndStaleResult(t *testing.T) {
|
||||||
|
b := &Browser{BaseURL: "http://internal"}
|
||||||
|
p := testParams(OpRead)
|
||||||
|
var started JobParams
|
||||||
|
s, err := b.session(context.Background(), p, func(_ context.Context, p JobParams) error { started = p; return nil })
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer b.close(s)
|
||||||
|
for _, header := range []string{"", "Bearer wrong", started.BrowserToken} {
|
||||||
|
r := httptest.NewRequest(http.MethodPost, started.BrowserURL, strings.NewReader(`{}`))
|
||||||
|
r.Header.Set("Authorization", header)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
b.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("wrong token returned %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest(http.MethodPost, started.BrowserURL, strings.NewReader(`{"id":"another-read","result":{}}`))
|
||||||
|
r.Header.Set("Authorization", "Bearer "+started.BrowserToken)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
b.ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("stale result returned %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowserCapacityReleasedAfterClosingSession(t *testing.T) {
|
||||||
|
b := &Browser{BaseURL: "http://internal"}
|
||||||
|
defer func() {
|
||||||
|
for _, s := range b.sessions {
|
||||||
|
b.close(s)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
started := 0
|
||||||
|
start := func(context.Context, JobParams) error { started++; return nil }
|
||||||
|
for i := 0; i < maxBrowsers; i++ {
|
||||||
|
p := testParams(OpList)
|
||||||
|
p.Server = string(rune('a' + i))
|
||||||
|
p.OpID = p.Server
|
||||||
|
if _, err := b.session(context.Background(), p, start); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := testParams(OpRead)
|
||||||
|
if _, err := b.session(context.Background(), p, start); err != errBrowserFull || started != maxBrowsers {
|
||||||
|
t.Fatalf("capacity: err=%v, started=%d", err, started)
|
||||||
|
}
|
||||||
|
for _, s := range b.sessions {
|
||||||
|
b.close(s)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if _, err := b.session(context.Background(), p, start); err != nil || started != maxBrowsers+1 {
|
||||||
|
t.Fatalf("reopen: err=%v, started=%d", err, started)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBrowseRefusesMutation(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
path := filepath.Join(root, "keep.txt")
|
||||||
|
os.WriteFile(path, []byte("keep"), 0600)
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
json.NewEncoder(w).Encode(BrowseCommand{ID: "x", Op: OpDelete, Path: "keep.txt"})
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
if err := Browse(context.Background(), root, srv.URL, "token"); err == nil {
|
||||||
|
t.Fatal("worker accepted a write command")
|
||||||
|
}
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
if err != nil || string(got) != "keep" {
|
||||||
|
t.Fatalf("file changed: %q, %v", got, err)
|
||||||
|
}
|
||||||
|
p := testParams(OpWrite)
|
||||||
|
p.BrowserURL = srv.URL
|
||||||
|
p.BrowserToken = "token"
|
||||||
|
if _, err := FilesJob(p); err == nil {
|
||||||
|
t.Fatal("writable browser Job was allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,14 +32,14 @@
|
|||||||
// upload fetched by the Job from felis-api's internal face (see Stage), because a
|
// upload fetched by the Job from felis-api's internal face (see Stage), because a
|
||||||
// 64 MiB jar fits in neither a Job spec nor an environment.
|
// 64 MiB jar fits in neither a Job spec nor an environment.
|
||||||
//
|
//
|
||||||
// The price is latency: every operation is a Pod schedule + image pull, so a
|
// Interactive reads reuse a bounded, short-lived, read-only Job through Browser.
|
||||||
// listing takes seconds rather than milliseconds. That is inherent to RWO plus a
|
// Its command channel uses the existing internal API face. At capacity, reads
|
||||||
// stopped server, not a property of this transport: the file manager works on a
|
// use one-shot Jobs and stream results before the Pod's terminal phase. Changes
|
||||||
// stopped server, one operation per Job.
|
// still wait for termination to preserve the world lock's ordering.
|
||||||
//
|
//
|
||||||
// The Editor depends on the Runner interface, so the orchestration and the error
|
// The Editor depends on the Runner interface, so the orchestration and the error
|
||||||
// mapping are unit-tested against an in-memory fake; the client-go implementation
|
// mapping are unit-tested against an in-memory fake; the client-go transport
|
||||||
// (k8sjobs.go) compiles here but is exercised only against a live cluster.
|
// (k8sjobs.go) is tested against the Kubernetes HTTP API shape.
|
||||||
package fileedit
|
package fileedit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
|||||||
@@ -69,8 +69,10 @@ type JobParams struct {
|
|||||||
// Async marks a Job felis-api does not wait on: it carries LabelAsync and
|
// Async marks a Job felis-api does not wait on: it carries LabelAsync and
|
||||||
// AnnotationPath, which Ops reads it back by. Only an upload or an unzip
|
// AnnotationPath, which Ops reads it back by. Only an upload or an unzip
|
||||||
// runs so.
|
// runs so.
|
||||||
Async bool
|
Async bool
|
||||||
WorldPVC string
|
WorldPVC string
|
||||||
|
BrowserURL string
|
||||||
|
BrowserToken string
|
||||||
|
|
||||||
Namespace string
|
Namespace string
|
||||||
ServiceAccount string
|
ServiceAccount string
|
||||||
@@ -128,7 +130,7 @@ func filesLabels(p JobParams) map[string]string {
|
|||||||
// - mounts EXACTLY ONE volume — the world PVC — and NO Secret, NO ConfigMap, and
|
// - mounts EXACTLY ONE volume — the world PVC — and NO Secret, NO ConfigMap, and
|
||||||
// NO backup PVC. It is therefore strictly blinder than the backup Pod, which
|
// NO backup PVC. It is therefore strictly blinder than the backup Pod, which
|
||||||
// mounts the config Secret to self-record its row: a file-editor Pod has nothing
|
// mounts the config Secret to self-record its row: a file-editor Pod has nothing
|
||||||
// to record, so it is handed no database URL and no credential of any kind (the
|
// to record, so it is handed no database URL or platform credential (the
|
||||||
// four-power red line, spec §22);
|
// four-power red line, spec §22);
|
||||||
// - mounts that one volume READ-ONLY for list and read (see mutates), so the
|
// - mounts that one volume READ-ONLY for list and read (see mutates), so the
|
||||||
// two operations that only look physically cannot change anything — the
|
// two operations that only look physically cannot change anything — the
|
||||||
@@ -147,8 +149,12 @@ func filesLabels(p JobParams) map[string]string {
|
|||||||
//
|
//
|
||||||
// The container runs `/usr/local/bin/felis files` (cmd/felis), which performs the
|
// The container runs `/usr/local/bin/felis files` (cmd/felis), which performs the
|
||||||
// operation under os.Root containment and prints the marked JSON Result line that
|
// operation under os.Root containment and prints the marked JSON Result line that
|
||||||
// felis-api reads back through pods/log.
|
// felis-api reads back through pods/log. A browser instead pulls read commands
|
||||||
|
// from the internal API and posts each result there using a scoped token.
|
||||||
func FilesJob(p JobParams) (*batchv1.Job, error) {
|
func FilesJob(p JobParams) (*batchv1.Job, error) {
|
||||||
|
if p.BrowserURL != "" && (mutates(p.Op) || p.BrowserToken == "" || p.Async) {
|
||||||
|
return nil, fmt.Errorf("fileedit: a browser must be read-only and have a token")
|
||||||
|
}
|
||||||
if p.Image == "" {
|
if p.Image == "" {
|
||||||
return nil, fmt.Errorf("fileedit: image is empty")
|
return nil, fmt.Errorf("fileedit: image is empty")
|
||||||
}
|
}
|
||||||
@@ -246,7 +252,7 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
|
|||||||
// so the spec is the sole channel into the Pod; base64 keeps arbitrary bytes —
|
// so the spec is the sole channel into the Pod; base64 keeps arbitrary bytes —
|
||||||
// CRLF line endings, a UTF-8 BOM, a binary blob — intact through a field that
|
// CRLF line endings, a UTF-8 BOM, a binary blob — intact through a field that
|
||||||
// must be a valid string. Content is set ONLY for a write and the token ONLY
|
// must be a valid string. Content is set ONLY for a write and the token ONLY
|
||||||
// for an upload, so no other Job spec carries either.
|
// for an upload or read-only browser, so no other Job spec carries either.
|
||||||
switch p.Op {
|
switch p.Op {
|
||||||
case OpWrite:
|
case OpWrite:
|
||||||
parts := splitContent(p.Content)
|
parts := splitContent(p.Content)
|
||||||
@@ -257,6 +263,11 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
|
|||||||
case OpUpload:
|
case OpUpload:
|
||||||
container.Env = []corev1.EnvVar{{Name: UploadTokenEnv, Value: p.UploadToken}}
|
container.Env = []corev1.EnvVar{{Name: UploadTokenEnv, Value: p.UploadToken}}
|
||||||
}
|
}
|
||||||
|
if p.BrowserURL != "" {
|
||||||
|
container.Args = []string{"--browse-url", p.BrowserURL, "--worlds-root", p.WorldsRoot}
|
||||||
|
container.Env = []corev1.EnvVar{{Name: BrowserTokenEnv, Value: p.BrowserToken}}
|
||||||
|
container.Resources.Requests = corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("10m"), corev1.ResourceMemory: resource.MustParse("32Mi")}
|
||||||
|
}
|
||||||
|
|
||||||
job := &batchv1.Job{
|
job := &batchv1.Job{
|
||||||
ObjectMeta: metav1.ObjectMeta{
|
ObjectMeta: metav1.ObjectMeta{
|
||||||
|
|||||||
@@ -18,12 +18,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// pollInterval is how often the runner re-Lists Pods while waiting for the file
|
// pollInterval is how often the runner re-Lists Pods while waiting for the file
|
||||||
// Job to finish. It is a POLL rather than a Watch because felis-api holds
|
// container to start. It is a POLL rather than a Watch because felis-api holds
|
||||||
// pods:list and NOT pods:watch (internal/platform.APIMinecraftRole) — establishing
|
// pods:list and NOT pods:watch (internal/platform.APIMinecraftRole) — establishing
|
||||||
// a watch would need a permission this design exists to avoid. Half a second is
|
// a watch would need a permission this design exists to avoid.
|
||||||
// well inside the human-perceptible floor for an operation already dominated by
|
const pollInterval = 150 * time.Millisecond
|
||||||
// Pod scheduling, while keeping the request count on a slow image pull modest.
|
|
||||||
const pollInterval = 500 * time.Millisecond
|
|
||||||
|
|
||||||
// maxLogBytes bounds what the runner will buffer from a Pod's log. The payload is
|
// maxLogBytes bounds what the runner will buffer from a Pod's log. The payload is
|
||||||
// at most a base64-encoded MaxReadBytes (≈4/3 of 1 MiB) plus the JSON envelope, so
|
// at most a base64-encoded MaxReadBytes (≈4/3 of 1 MiB) plus the JSON envelope, so
|
||||||
@@ -45,12 +43,11 @@ const maxLogBytes = 4 << 20
|
|||||||
// the Job create is available on both — so one client covers all three calls
|
// the Job create is available on both — so one client covers all three calls
|
||||||
// instead of the binding carrying two.
|
// instead of the binding carrying two.
|
||||||
//
|
//
|
||||||
// INTEGRATION-ONLY: like K8sLogStreamer and K8sCluster this needs a live cluster;
|
// The transport is tested against the Kubernetes HTTP API shape; the Editor
|
||||||
// it compiles here but is exercised only against one, never by the hermetic test
|
// tests separately verify orchestration and error mapping against a fake Runner.
|
||||||
// suite. The Oracle verifies the layer above it (Editor orchestration and error
|
|
||||||
// mapping) against a fake Runner, and the Job shape via the pure jobspec.
|
|
||||||
type K8sRunner struct {
|
type K8sRunner struct {
|
||||||
cs kubernetes.Interface
|
cs kubernetes.Interface
|
||||||
|
Browser *Browser
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewK8sRunner builds a Runner over cs. Every per-operation parameter — the
|
// NewK8sRunner builds a Runner over cs. Every per-operation parameter — the
|
||||||
@@ -60,14 +57,23 @@ func NewK8sRunner(cs kubernetes.Interface) *K8sRunner {
|
|||||||
return &K8sRunner{cs: cs}
|
return &K8sRunner{cs: cs}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run creates the file Job, waits for its Pod to reach a terminal phase, and
|
// Run streams read results as soon as they are printed. Mutations still wait
|
||||||
// returns the JSON payload from the ResultPrefix line of that Pod's log.
|
// for termination so returning success does not leave the next write racing
|
||||||
|
// the cluster-side world lock.
|
||||||
//
|
//
|
||||||
// The Job name carries a fresh random OpID (FilesJobName), so a create collision is
|
// The Job name carries a fresh random OpID (FilesJobName), so a create collision is
|
||||||
// not an expected condition the way it is for restore — an AlreadyExists here means
|
// not an expected condition the way it is for restore — an AlreadyExists here means
|
||||||
// a 64-bit collision inside one TTL window and is reported rather than absorbed,
|
// a 64-bit collision inside one TTL window and is reported rather than absorbed,
|
||||||
// because absorbing it would mean returning ANOTHER operation's output.
|
// because absorbing it would mean returning ANOTHER operation's output.
|
||||||
func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) {
|
func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) {
|
||||||
|
if k.Browser != nil && !mutates(p.Op) {
|
||||||
|
payload, err := k.Browser.Run(ctx, p, k.Start)
|
||||||
|
// At capacity, retain the bounded one-shot path instead of starting
|
||||||
|
// more idle workers. All reads still use the same containment checks.
|
||||||
|
if !errors.Is(err, errBrowserFull) {
|
||||||
|
return payload, err
|
||||||
|
}
|
||||||
|
}
|
||||||
job, err := FilesJob(p)
|
job, err := FilesJob(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -81,24 +87,30 @@ func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
log, err := k.podLog(ctx, p.Namespace, pod.Name)
|
stream, err := k.cs.CoreV1().Pods(p.Namespace).GetLogs(pod.Name, &corev1.PodLogOptions{
|
||||||
|
Container: containerName, Follow: true,
|
||||||
|
}).Stream(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, fmt.Errorf("fileedit: read file job log: %w", err)
|
||||||
}
|
}
|
||||||
|
defer stream.Close()
|
||||||
payload, ok := extractResult(log)
|
sc := bufio.NewScanner(io.LimitReader(stream, maxLogBytes))
|
||||||
if !ok {
|
sc.Buffer(make([]byte, 0, 64*1024), maxLogBytes)
|
||||||
// No marked line: the entrypoint died before printing (an unmountable volume,
|
var last string
|
||||||
// an OOM kill, a deadline). The log tail travels in the error for the operator's
|
for sc.Scan() {
|
||||||
// benefit — this error reaches felis-api's logs, while the caller gets the
|
if payload, ok := strings.CutPrefix(sc.Text(), ResultPrefix); ok {
|
||||||
// generic 500 writeError produces, so no node detail leaks to the browser.
|
return []byte(payload), nil
|
||||||
return nil, fmt.Errorf("fileedit: file job %s produced no result (phase %s): %s",
|
}
|
||||||
job.Name, pod.Status.Phase, tail(log))
|
last = sc.Text()
|
||||||
}
|
}
|
||||||
return payload, nil
|
if err := sc.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("fileedit: read file job result: %w", err)
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("fileedit: file job %s produced no result (phase %s): %s",
|
||||||
|
job.Name, pod.Status.Phase, tail(last))
|
||||||
}
|
}
|
||||||
|
|
||||||
// awaitPod polls until the operation's Pod reaches a terminal phase. It selects by
|
// awaitPod polls until the file container has started (or failed). It selects by
|
||||||
// the per-invocation LabelOpID, so it can never observe a different operation's Pod
|
// the per-invocation LabelOpID, so it can never observe a different operation's Pod
|
||||||
// — the reason that label exists.
|
// — the reason that label exists.
|
||||||
//
|
//
|
||||||
@@ -107,7 +119,7 @@ func (k *K8sRunner) Run(ctx context.Context, p JobParams) ([]byte, error) {
|
|||||||
// is too large) is printed and then exited on cleanly, and even a genuinely failed
|
// is too large) is printed and then exited on cleanly, and even a genuinely failed
|
||||||
// Pod may have printed a diagnosable result first. Deciding what the outcome MEANS
|
// Pod may have printed a diagnosable result first. Deciding what the outcome MEANS
|
||||||
// is the caller's job (Run reads the printed result); this function only decides
|
// is the caller's job (Run reads the printed result); this function only decides
|
||||||
// when there is nothing left to wait for.
|
// when its log can be opened without a ContainerCreating refusal.
|
||||||
func (k *K8sRunner) awaitPod(ctx context.Context, p JobParams) (*corev1.Pod, error) {
|
func (k *K8sRunner) awaitPod(ctx context.Context, p JobParams) (*corev1.Pod, error) {
|
||||||
ticker := time.NewTicker(pollInterval)
|
ticker := time.NewTicker(pollInterval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -120,9 +132,17 @@ func (k *K8sRunner) awaitPod(ctx context.Context, p JobParams) (*corev1.Pod, err
|
|||||||
return nil, fmt.Errorf("fileedit: find file job pod: %w", err)
|
return nil, fmt.Errorf("fileedit: find file job pod: %w", err)
|
||||||
}
|
}
|
||||||
for i := range pods.Items {
|
for i := range pods.Items {
|
||||||
switch pods.Items[i].Status.Phase {
|
pod := &pods.Items[i]
|
||||||
|
if !mutates(p.Op) {
|
||||||
|
for _, c := range pod.Status.ContainerStatuses {
|
||||||
|
if c.Name == containerName && (c.State.Running != nil || c.State.Terminated != nil) {
|
||||||
|
return pod, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch pod.Status.Phase {
|
||||||
case corev1.PodSucceeded, corev1.PodFailed:
|
case corev1.PodSucceeded, corev1.PodFailed:
|
||||||
return &pods.Items[i], nil
|
return pod, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,10 @@ package fileedit
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -10,10 +14,73 @@ import (
|
|||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/runtime"
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/client-go/kubernetes"
|
||||||
"k8s.io/client-go/kubernetes/fake"
|
"k8s.io/client-go/kubernetes/fake"
|
||||||
|
"k8s.io/client-go/rest"
|
||||||
k8stesting "k8s.io/client-go/testing"
|
k8stesting "k8s.io/client-go/testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestRunReadsResultBeforePodTermination(t *testing.T) {
|
||||||
|
for _, op := range []string{OpList, OpRead} {
|
||||||
|
t.Run(op, func(t *testing.T) {
|
||||||
|
closed := make(chan struct{})
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch {
|
||||||
|
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/jobs"):
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
io.WriteString(w, `{"apiVersion":"batch/v1","kind":"Job","metadata":{"name":"files-test"}}`)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/pods"):
|
||||||
|
if !strings.Contains(r.URL.Query().Get("labelSelector"), LabelOpID+"=") {
|
||||||
|
t.Error("pod lookup did not select this operation")
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
io.WriteString(w, `{"apiVersion":"v1","kind":"PodList","items":[{"metadata":{"name":"file-pod"},"status":{"phase":"Running","containerStatuses":[{"name":"files","state":{"running":{}}}]}}]}`)
|
||||||
|
case strings.HasSuffix(r.URL.Path, "/log"):
|
||||||
|
if r.URL.Query().Get("follow") != "true" {
|
||||||
|
t.Error("result log was not streamed")
|
||||||
|
}
|
||||||
|
io.WriteString(w, "diagnostic line\n"+ResultPrefix+`{"entries":[]}`+"\n")
|
||||||
|
w.(http.Flusher).Flush()
|
||||||
|
// The log stays open and the Pod stays Running. Run must return
|
||||||
|
// on the result line and close the stream, not await either EOF.
|
||||||
|
<-r.Context().Done()
|
||||||
|
close(closed)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected cluster call: %s %s", r.Method, r.URL)
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
cs, err := kubernetes.NewForConfig(&rest.Config{Host: srv.URL})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
got, err := NewK8sRunner(cs).Run(ctx, testParams(op))
|
||||||
|
if err != nil || string(got) != `{"entries":[]}` {
|
||||||
|
t.Fatalf("Run = %s, %v", got, err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-closed:
|
||||||
|
case <-ctx.Done():
|
||||||
|
t.Fatal("result stream was not closed")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAwaitPodKeepsWritesWaitingForTermination(t *testing.T) {
|
||||||
|
p := testParams(OpWrite)
|
||||||
|
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "file-pod", Namespace: p.Namespace, Labels: filesLabels(p)},
|
||||||
|
Status: corev1.PodStatus{Phase: corev1.PodRunning, ContainerStatuses: []corev1.ContainerStatus{{Name: containerName, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}}}}
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if got, err := NewK8sRunner(fake.NewSimpleClientset(pod)).awaitPod(ctx, p); err == nil || got != nil {
|
||||||
|
t.Fatalf("a running writer was treated as finished: %v, %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
opCreated = time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC)
|
opCreated = time.Date(2026, 9, 28, 10, 0, 0, 0, time.UTC)
|
||||||
opEnded = opCreated.Add(3 * time.Minute)
|
opEnded = opCreated.Add(3 * time.Minute)
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
test("file editor highlights configuration, preserves CRLF, undoes edits and saves by shortcut", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
await page.request.post("/api/v1/servers/lobby/stop");
|
||||||
|
const original = "groups:\r\n admin:\r\n enabled: true\r\n permissions:\r\n - limboserver.stop\r\n - limboserver.kick\r\n\r\nplayers:\r\n LOOHP:\r\n - admin\r\n";
|
||||||
|
const created = await page.request.put("/api/v1/servers/lobby/file?path=permission.yml", { data: {
|
||||||
|
content: Buffer.from(original).toString("base64"), content_sha256: createHash("sha256").update(original).digest("hex"), create_only: true,
|
||||||
|
} });
|
||||||
|
expect(created.ok()).toBe(true);
|
||||||
|
await page.goto("/servers/lobby/files");
|
||||||
|
await page.getByRole("button", { name: t("files:open_file", { name: "permission.yml" }), exact: true }).click();
|
||||||
|
const editor = page.getByRole("textbox", { name: t("files:file_content") });
|
||||||
|
await expect(editor).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: t("files:save"), exact: true })).toBeDisabled();
|
||||||
|
await expect(page.locator(".cm-lineNumbers .cm-gutterElement")).not.toHaveCount(0);
|
||||||
|
await expect(page.locator(".cm-line span")).not.toHaveCount(0);
|
||||||
|
await editor.press("ControlOrMeta+End");
|
||||||
|
await page.keyboard.type("# edited");
|
||||||
|
await editor.press("ControlOrMeta+z");
|
||||||
|
await expect(editor).not.toContainText("# edited");
|
||||||
|
await page.keyboard.type("# edited");
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await page.screenshot({ path: "/tmp/felis-files-editor.png", fullPage: true });
|
||||||
|
const saved = page.waitForRequest((r) => r.method() === "PUT" && r.url().includes("/file?path="));
|
||||||
|
await editor.press("ControlOrMeta+s");
|
||||||
|
const body = (await saved).postDataJSON();
|
||||||
|
expect(Buffer.from(body.content, "base64").toString()).toBe(original + "# edited");
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("binary files show an explanation and download action without requesting text", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
await page.request.post("/api/v1/servers/lobby/stop");
|
||||||
|
let reads = 0;
|
||||||
|
page.on("request", (r) => { if (r.url().includes("/file?path=")) reads++; });
|
||||||
|
await page.goto("/servers/lobby/files");
|
||||||
|
await page.getByRole("button", { name: t("files:open_folder", { name: "plugins" }), exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: t("files:open_file", { name: "LuckPerms-Bukkit-5.4.141.jar" }), exact: true }).click();
|
||||||
|
await expect(page.getByText(t("files:binary_title"), { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: t("files:download_file"), exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("textbox")).toHaveCount(0);
|
||||||
|
expect(reads).toBe(0);
|
||||||
|
await page.screenshot({ path: "/tmp/felis-files-binary.png", fullPage: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("opening a slow file shows immediate progress and closing it ignores the delayed answer", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
await page.request.post("/api/v1/servers/lobby/stop");
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
await page.route("**/api/v1/servers/lobby/file?path=*", async (route) => { await pending; await route.continue(); });
|
||||||
|
await page.goto("/servers/lobby/files");
|
||||||
|
await page.getByRole("button", { name: t("files:open_file", { name: "server.properties" }), exact: true }).click();
|
||||||
|
await expect(page.getByText(t("files:opening_file"), { exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: "/tmp/felis-files-loading.png", fullPage: true });
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
const answer = page.waitForResponse((r) => r.url().includes("/file?path="));
|
||||||
|
release();
|
||||||
|
await answer;
|
||||||
|
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||||
|
});
|
||||||
@@ -1,5 +1,27 @@
|
|||||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||||
|
|
||||||
|
test("space settings keep the form visible while reading configuration", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.emulateMedia({ colorScheme: "dark" });
|
||||||
|
let release!: () => void;
|
||||||
|
const pending = new Promise<void>((resolve) => { release = resolve; });
|
||||||
|
await page.route("**/api/v1/servers/login/file?path=felis-experience.json", async (route) => { await pending; await route.continue(); });
|
||||||
|
await page.goto("/admin/lobby");
|
||||||
|
const title = page.getByLabel(t("lobby:bookTitle"));
|
||||||
|
await expect(title).toBeVisible();
|
||||||
|
await expect(title).toBeDisabled();
|
||||||
|
await expect(title).toHaveValue("");
|
||||||
|
await expect(page.getByText(t("lobby:loading_settings"), { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: t("lobby:save"), exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole("button", { name: t("lobby:save"), exact: true })).toBeDisabled();
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await page.screenshot({ path: "/tmp/felis-lobby-loading.png", fullPage: true });
|
||||||
|
release();
|
||||||
|
await expect(title).toBeEnabled();
|
||||||
|
await expect(title).toHaveValue("Felis Login");
|
||||||
|
await expect(page.getByText(t("lobby:loading_settings"), { exact: true })).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("login is the default space; live lobby settings save before a confirmed restart", async ({ page, signIn }) => {
|
test("login is the default space; live lobby settings save before a confirmed restart", async ({ page, signIn }) => {
|
||||||
await signIn("owner");
|
await signIn("owner");
|
||||||
await page.goto("/admin/lobby");
|
await page.goto("/admin/lobby");
|
||||||
|
|||||||
Generated
+159
@@ -8,12 +8,18 @@
|
|||||||
"name": "felis-panel",
|
"name": "felis-panel",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/legacy-modes": "^6.5.4",
|
||||||
|
"@codemirror/state": "^6.7.6",
|
||||||
|
"@codemirror/view": "^6.43.13",
|
||||||
|
"@lezer/highlight": "^1.2.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"i18next": "^26.3.3",
|
"i18next": "^26.3.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
@@ -6508,6 +6514,159 @@
|
|||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"zod": "^3.25.0 || ^4.0.0"
|
"zod": "^3.25.0 || ^4.0.0"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-4UeRVFO0lsBaM7GA32zOqbmqNPme9jedo8Ey+5c5656MU3MLitMLIA8aCZTYjUMEpEsuSeXEm78Uea6Iq3bvSg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/legacy-modes": {
|
||||||
|
"version": "6.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.4.tgz",
|
||||||
|
"integrity": "sha512-/cZr6qZyl08iYNLGsJ862CXXNI51LryRFRE40ejgoIjXZz0C1rGkD3/Ek5jM/8w1ceRjqtt4qx/KLMh4zBTgew==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.12.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz",
|
||||||
|
"integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0",
|
||||||
|
"@lezer/common": "^1.5.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/autocomplete": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.43.13",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.13.tgz",
|
||||||
|
"integrity": "sha512-sihaFrUzAsYBQsL9J2t69y8nfMQGwcYmggAZsk+kjPbjYZMyuf2hU8tUNTZ+P+isb6XRr8JE22TZlJxBoVdH1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4",
|
||||||
|
"@codemirror/state": "^6.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||||
|
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-H0iErY4e43LpXbYDyBci5W4v/RwTgGV3YzYOlJPiGZ8RY8w51kVE+xLn1tg3Z6UmSfyETvtsUK3r7YsgDQEI7Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz",
|
||||||
|
"integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.20.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz",
|
||||||
|
"integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.7.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||||
|
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.5",
|
||||||
|
"@codemirror/view": "^6.37.0",
|
||||||
|
"@codemirror/state": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.11.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||||
|
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.1.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@codemirror/state": "^6.7.0",
|
||||||
|
"@codemirror/language": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.9.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||||
|
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"crelt": "^1.0.5",
|
||||||
|
"@codemirror/view": "^6.42.0",
|
||||||
|
"@codemirror/state": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.7.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||||
|
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16,12 +16,18 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.12.4",
|
||||||
|
"@codemirror/legacy-modes": "^6.5.4",
|
||||||
|
"@codemirror/state": "^6.7.6",
|
||||||
|
"@codemirror/view": "^6.43.13",
|
||||||
|
"@lezer/highlight": "^1.2.4",
|
||||||
"@radix-ui/react-dialog": "^1.1.6",
|
"@radix-ui/react-dialog": "^1.1.6",
|
||||||
"@radix-ui/react-label": "^2.1.2",
|
"@radix-ui/react-label": "^2.1.2",
|
||||||
"@radix-ui/react-select": "^2.1.6",
|
"@radix-ui/react-select": "^2.1.6",
|
||||||
"@radix-ui/react-slot": "^1.1.2",
|
"@radix-ui/react-slot": "^1.1.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"codemirror": "^6.0.2",
|
||||||
"i18next": "^26.3.3",
|
"i18next": "^26.3.3",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import { basicSetup } from "codemirror";
|
||||||
|
import { EditorView, keymap } from "@codemirror/view";
|
||||||
|
import { EditorState, Compartment } from "@codemirror/state";
|
||||||
|
import { StreamLanguage, syntaxHighlighting, HighlightStyle } from "@codemirror/language";
|
||||||
|
import { tags } from "@lezer/highlight";
|
||||||
|
import { yaml } from "@codemirror/legacy-modes/mode/yaml";
|
||||||
|
import { properties } from "@codemirror/legacy-modes/mode/properties";
|
||||||
|
import { toml } from "@codemirror/legacy-modes/mode/toml";
|
||||||
|
import { json, javascript } from "@codemirror/legacy-modes/mode/javascript";
|
||||||
|
import { xml } from "@codemirror/legacy-modes/mode/xml";
|
||||||
|
import { shell } from "@codemirror/legacy-modes/mode/shell";
|
||||||
|
import { useTheme } from "@/lib/theme";
|
||||||
|
|
||||||
|
const languages = { yaml, yml: yaml, properties, toml, json, js: javascript, xml, sh: shell };
|
||||||
|
|
||||||
|
export function TextFileEditor({ path, value, onChange, onSave, label }: {
|
||||||
|
path: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const host = useRef<HTMLDivElement>(null);
|
||||||
|
const view = useRef<EditorView | null>(null);
|
||||||
|
const themeConfig = useRef(new Compartment());
|
||||||
|
const callbacks = useRef({ onChange, onSave });
|
||||||
|
const { theme } = useTheme();
|
||||||
|
useEffect(() => { callbacks.current = { onChange, onSave }; }, [onChange, onSave]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const extension = path.split(".").pop()?.toLowerCase() ?? "";
|
||||||
|
const language = languages[extension as keyof typeof languages];
|
||||||
|
const lineBreak = value.includes("\r\n") ? "\r\n" : value.includes("\r") && !value.includes("\n") ? "\r" : "\n";
|
||||||
|
const editor = new EditorView({
|
||||||
|
parent: host.current!,
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: value,
|
||||||
|
extensions: [
|
||||||
|
basicSetup,
|
||||||
|
language ? StreamLanguage.define(language) : [],
|
||||||
|
EditorState.tabSize.of(2),
|
||||||
|
EditorState.lineSeparator.of(lineBreak),
|
||||||
|
EditorView.contentAttributes.of({ "aria-label": label }),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) callbacks.current.onChange(update.state.sliceDoc());
|
||||||
|
}),
|
||||||
|
keymap.of([{ key: "Mod-s", run: () => { callbacks.current.onSave(); return true; } }]),
|
||||||
|
themeConfig.current.of(editorTheme(theme === "dark")),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
view.current = editor;
|
||||||
|
editor.focus();
|
||||||
|
return () => { editor.destroy(); view.current = null; };
|
||||||
|
// A file gets one editor; text and theme changes update it without losing history.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [path, label]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const editor = view.current;
|
||||||
|
if (editor && editor.state.sliceDoc() !== value) {
|
||||||
|
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: value } });
|
||||||
|
}
|
||||||
|
}, [value]);
|
||||||
|
useEffect(() => {
|
||||||
|
view.current?.dispatch({ effects: themeConfig.current.reconfigure(editorTheme(theme === "dark")) });
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return <div ref={host} className="min-w-0 overflow-hidden rounded-md border border-input" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editorTheme(dark: boolean) {
|
||||||
|
return [EditorView.theme({
|
||||||
|
"&": { height: "50vh", color: "hsl(var(--foreground))", backgroundColor: "hsl(var(--background))", fontSize: "13px" },
|
||||||
|
"&.cm-focused": { outline: "2px solid hsl(var(--ring) / .5)", outlineOffset: "-2px" },
|
||||||
|
".cm-scroller": { fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", lineHeight: "1.7" },
|
||||||
|
".cm-content": { padding: "12px 0", caretColor: "hsl(var(--foreground))" },
|
||||||
|
".cm-gutters": { backgroundColor: "hsl(var(--card))", color: "hsl(var(--muted-foreground))", borderColor: "hsl(var(--border))" },
|
||||||
|
".cm-activeLine, .cm-activeLineGutter": { backgroundColor: "hsl(var(--muted) / .5)" },
|
||||||
|
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground": { backgroundColor: "hsl(var(--primary) / .2)" },
|
||||||
|
}, { dark }), syntaxHighlighting(HighlightStyle.define([
|
||||||
|
{ tag: [tags.keyword, tags.bool, tags.null], color: dark ? "#c4b5fd" : "#7c3aed" },
|
||||||
|
{ tag: [tags.propertyName, tags.variableName, tags.atom, tags.meta], color: dark ? "#7dd3fc" : "#0369a1" },
|
||||||
|
{ tag: [tags.string, tags.quote], color: dark ? "#86efac" : "#15803d" },
|
||||||
|
{ tag: tags.number, color: dark ? "#fdba74" : "#c2410c" },
|
||||||
|
{ tag: tags.comment, color: dark ? "#94a3b8" : "#64748b", fontStyle: "italic" },
|
||||||
|
]))];
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.",
|
"stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.",
|
||||||
"not_yours_title": "Not your server",
|
"not_yours_title": "Not your server",
|
||||||
"not_yours_body": "Only the owner or an admin can edit this server's files.",
|
"not_yours_body": "Only the owner or an admin can edit this server's files.",
|
||||||
"binary_hint": "Binary file — opened read-only; saving non-text bytes through an editor could corrupt it.",
|
"binary_hint": "Editing this file as text would damage it. Download it to open with the appropriate application, then upload your changes.",
|
||||||
"saved": "Saved {{path}}",
|
"saved": "Saved {{path}}",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
@@ -129,5 +129,12 @@
|
|||||||
"job_failed": "The background task ended without saying why. Refresh the list to check, then try again.",
|
"job_failed": "The background task ended without saying why. Refresh the list to check, then try again.",
|
||||||
"job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again.",
|
"job_timed_out": "The background task did not finish within 2 hours and was stopped. Refresh the list to check, then try again.",
|
||||||
"job_out_of_memory": "The background task ran out of memory and the system stopped it. The files on the server were not changed. Try again.",
|
"job_out_of_memory": "The background task ran out of memory and the system stopped it. The files on the server were not changed. Try again.",
|
||||||
"job_out_of_memory_unzip": "The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one."
|
"job_out_of_memory_unzip": "The archive holds more files than the extraction task has memory to list, so the system stopped it. The files on the server were not changed. Split it into several smaller zips and extract each one.",
|
||||||
|
"binary_title": "This is a binary file",
|
||||||
|
"preview_large_title": "File exceeds the preview limit",
|
||||||
|
"preview_large_hint": "The editor reads files up to {{limit}}. Download it to edit, then upload your changes.",
|
||||||
|
"download_file": "Download file",
|
||||||
|
"opening_file": "Reading file…",
|
||||||
|
"loading_folder": "Opening {{path}}…",
|
||||||
|
"file_content": "File contents"
|
||||||
}
|
}
|
||||||
@@ -94,5 +94,6 @@
|
|||||||
"loginBook_description": "Edit the guidance players see in the login book.",
|
"loginBook_description": "Edit the guidance players see in the login book.",
|
||||||
"content_tools": "Content & maintenance",
|
"content_tools": "Content & maintenance",
|
||||||
"unsaved": "You have unsaved changes",
|
"unsaved": "You have unsaved changes",
|
||||||
"apply_on_start": "Save while running; settings take effect on the next start or restart"
|
"apply_on_start": "Save while running; settings take effect on the next start or restart",
|
||||||
|
"loading_settings": "Loading settings. Editing will be available once loaded."
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。",
|
"stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。",
|
||||||
"not_yours_title": "这不是你的服务器",
|
"not_yours_title": "这不是你的服务器",
|
||||||
"not_yours_body": "只有服务器所有者或管理员才能编辑其文件。",
|
"not_yours_body": "只有服务器所有者或管理员才能编辑其文件。",
|
||||||
"binary_hint": "二进制文件——以只读方式打开;通过编辑器保存非文本字节可能损坏该文件。",
|
"binary_hint": "这是二进制文件,文本编辑会损坏它。请下载后使用对应的软件打开,修改后重新上传。",
|
||||||
"saved": "已保存 {{path}}",
|
"saved": "已保存 {{path}}",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
@@ -125,5 +125,12 @@
|
|||||||
"job_failed": "后台任务没说明原因就结束了。刷新列表确认一下,再试一次。",
|
"job_failed": "后台任务没说明原因就结束了。刷新列表确认一下,再试一次。",
|
||||||
"job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。",
|
"job_timed_out": "后台任务 2 小时还没做完,被停下了。刷新列表确认一下,再试一次。",
|
||||||
"job_out_of_memory": "后台任务用完了内存,被系统停掉了。服务器上的文件没有被改动,再试一次。",
|
"job_out_of_memory": "后台任务用完了内存,被系统停掉了。服务器上的文件没有被改动,再试一次。",
|
||||||
"job_out_of_memory_unzip": "压缩包里的文件太多,解压任务的内存装不下它的文件清单,被系统停掉了。服务器上的文件没有被改动。把它拆成几个小一些的 zip,分别上传解压。"
|
"job_out_of_memory_unzip": "压缩包里的文件太多,解压任务的内存装不下它的文件清单,被系统停掉了。服务器上的文件没有被改动。把它拆成几个小一些的 zip,分别上传解压。",
|
||||||
|
"binary_title": "此文件不能作为文本编辑",
|
||||||
|
"preview_large_title": "文件超出在线预览上限",
|
||||||
|
"preview_large_hint": "在线编辑器最多读取 {{limit}}。请下载后编辑,再重新上传。",
|
||||||
|
"download_file": "下载文件",
|
||||||
|
"opening_file": "正在读取文件…",
|
||||||
|
"loading_folder": "正在打开 {{path}}…",
|
||||||
|
"file_content": "文件内容"
|
||||||
}
|
}
|
||||||
@@ -94,5 +94,6 @@
|
|||||||
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
|
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
|
||||||
"content_tools": "内容与维护",
|
"content_tools": "内容与维护",
|
||||||
"unsaved": "有未保存的更改",
|
"unsaved": "有未保存的更改",
|
||||||
"apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效"
|
"apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效",
|
||||||
|
"loading_settings": "正在加载配置,读取完成后即可编辑。"
|
||||||
}
|
}
|
||||||
@@ -232,6 +232,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/internal/file-browser/{id}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Exchange a read-only file Job's result for its next command.
|
||||||
|
* @description Internal face only. A random bearer token scopes the worker to one world and a four-minute session; idle workers exit after 45 seconds. The first request sends an empty object. Later requests return the previous command's id and result or error. This endpoint dispatches only reads already authorized by the external file API.
|
||||||
|
*/
|
||||||
|
post: operations["internalFileBrowser"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/internal/exports/{id}": {
|
"/api/v1/internal/exports/{id}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -4179,6 +4199,48 @@ export interface operations {
|
|||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
internalFileBrowser: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header: {
|
||||||
|
Authorization: string;
|
||||||
|
};
|
||||||
|
path: {
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
id?: string;
|
||||||
|
result?: Record<string, never>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description The next authorized read command. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
id: string;
|
||||||
|
/** @enum {string} */
|
||||||
|
op: "list" | "read";
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
204: components["responses"]["NoContent"];
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
409: components["responses"]["Conflict"];
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
internalExportUpload: {
|
internalExportUpload: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -68,6 +68,12 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) }));
|
vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }) }));
|
||||||
|
// Editor interactions are exercised in the browser; page tests focus on reads,
|
||||||
|
// writes, conflict protection and the dialog lifecycle.
|
||||||
|
vi.mock("@/components/files/TextFileEditor", () => ({
|
||||||
|
TextFileEditor: ({ value, onChange, label }: { value: string; onChange: (v: string) => void; label: string }) =>
|
||||||
|
<textarea aria-label={label} value={value} onChange={(e) => onChange(e.target.value)} />,
|
||||||
|
}));
|
||||||
|
|
||||||
const t = (key: string) => i18next.t(key);
|
const t = (key: string) => i18next.t(key);
|
||||||
|
|
||||||
@@ -85,7 +91,7 @@ async function openEditor() {
|
|||||||
renderFiles();
|
renderFiles();
|
||||||
await userEvent.click(await screen.findByText("server.properties"));
|
await userEvent.click(await screen.findByText("server.properties"));
|
||||||
const dialog = await screen.findByRole("dialog");
|
const dialog = await screen.findByRole("dialog");
|
||||||
return { dialog, editor: within(dialog).getByRole("textbox") as HTMLTextAreaElement };
|
return { dialog, editor: await within(dialog).findByRole("textbox") as HTMLTextAreaElement };
|
||||||
}
|
}
|
||||||
|
|
||||||
let opsNow: FileOp[] = [];
|
let opsNow: FileOp[] = [];
|
||||||
@@ -304,6 +310,56 @@ describe("ServerFiles from the keyboard", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("ServerFiles opening feedback", () => {
|
||||||
|
it("shows the selected file and loading dialog immediately, and ignores a cancelled read", async () => {
|
||||||
|
let resolve!: (value: { content: string; sha256: string }) => void;
|
||||||
|
mocks.readServerFile.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||||
|
renderFiles();
|
||||||
|
await userEvent.click(await screen.findByText("server.properties"));
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(within(dialog).getByText(t("files:opening_file"))).toBeTruthy();
|
||||||
|
expect(screen.getByRole("row", { selected: true, hidden: true }).textContent).toContain("server.properties");
|
||||||
|
await userEvent.keyboard("{Escape}");
|
||||||
|
await act(async () => resolve({ content: btoa("late file"), sha256: "abc" }));
|
||||||
|
expect(screen.queryByRole("dialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
["Limbo.jar", 8 * 1024 * 1024, "binary_title"],
|
||||||
|
["spawn.schem", 6000, "binary_title"],
|
||||||
|
["large.log", 2 * 1024 * 1024, "preview_large_title"],
|
||||||
|
])("explains why %s cannot be edited without starting a read Job", async (name, size, title) => {
|
||||||
|
mocks.listServerFiles.mockResolvedValue({ entries: [{ name, size, is_dir: false, mod_time: "" }] });
|
||||||
|
renderFiles();
|
||||||
|
await userEvent.click(await screen.findByText(name));
|
||||||
|
const dialog = screen.getByRole("dialog");
|
||||||
|
expect(within(dialog).getByText(t(`files:${title}`))).toBeTruthy();
|
||||||
|
expect(within(dialog).getByRole("button", { name: t("files:download_file") })).toBeTruthy();
|
||||||
|
expect(within(dialog).queryByRole("textbox")).toBeNull();
|
||||||
|
expect(within(dialog).queryByRole("button", { name: t("files:save") })).toBeNull();
|
||||||
|
expect(mocks.readServerFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("detects binary bytes even with an unfamiliar extension", async () => {
|
||||||
|
mocks.readServerFile.mockResolvedValue({ content: btoa("a\0b"), sha256: "abc" });
|
||||||
|
renderFiles();
|
||||||
|
await userEvent.click(await screen.findByText("server.properties"));
|
||||||
|
expect(await screen.findByText(t("files:binary_title"))).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("textbox")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses recent directories, but an explicit refresh invalidates them", async () => {
|
||||||
|
renderFiles();
|
||||||
|
await userEvent.click(await screen.findByText("world"));
|
||||||
|
await screen.findByRole("button", { name: "world" });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("files:up") }));
|
||||||
|
expect(mocks.listServerFiles.mock.calls).toEqual([["lobby", ""], ["lobby", "world"]]);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("files:refresh") }));
|
||||||
|
await userEvent.click(await screen.findByText("world"));
|
||||||
|
expect(mocks.listServerFiles.mock.calls).toEqual([["lobby", ""], ["lobby", "world"], ["lobby", ""], ["lobby", "world"]]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ServerFiles folder answers arriving out of order", () => {
|
describe("ServerFiles folder answers arriving out of order", () => {
|
||||||
type Listing = { path: string; truncated: boolean; entries: { name: string; size: number; is_dir: boolean; mod_time: string }[] };
|
type Listing = { path: string; truncated: boolean; entries: { name: string; size: number; is_dir: boolean; mod_time: string }[] };
|
||||||
function deferred() {
|
function deferred() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useParams, useSearchParams } from "react-router-dom";
|
import { useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -65,6 +65,9 @@ import { cn } from "@/lib/utils";
|
|||||||
* are capped at 1 MiB server-side; a larger file is refused there with 413, so
|
* are capped at 1 MiB server-side; a larger file is refused there with 413, so
|
||||||
* this only gates the save button to keep the common case honest up front. */
|
* this only gates the save button to keep the common case honest up front. */
|
||||||
const MAX_WRITE_BYTES = 256 * 1024;
|
const MAX_WRITE_BYTES = 256 * 1024;
|
||||||
|
const MAX_READ_BYTES = 1024 * 1024;
|
||||||
|
const TextFileEditor = lazy(() => import("@/components/files/TextFileEditor").then((m) => ({ default: m.TextFileEditor })));
|
||||||
|
const BINARY_EXTENSION = /\.(jar|zip|gz|tar|7z|rar|dat|mca|mcr|schem|schematic|nbt|db|sqlite|png|jpe?g|gif|webp|ico|ogg|mp[34]|pdf|class|exe|dll|so|bin)$/i;
|
||||||
|
|
||||||
/** The []byte wire codec: Go's encoding/json renders []byte as base64, so the
|
/** The []byte wire codec: Go's encoding/json renders []byte as base64, so the
|
||||||
* editor must speak it explicitly in both directions. Chunked so a ~256 KiB
|
* editor must speak it explicitly in both directions. Chunked so a ~256 KiB
|
||||||
@@ -134,6 +137,9 @@ export function ServerFiles() {
|
|||||||
const [truncated, setTruncated] = useState(false);
|
const [truncated, setTruncated] = useState(false);
|
||||||
const [listErr, setListErr] = useState<unknown>(null);
|
const [listErr, setListErr] = useState<unknown>(null);
|
||||||
const [listLoading, setListLoading] = useState(false);
|
const [listLoading, setListLoading] = useState(false);
|
||||||
|
const [listTarget, setListTarget] = useState("");
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
const folders = useRef(new Map<string, { at: number; listing: Awaited<ReturnType<typeof api.listServerFiles>> }>());
|
||||||
// Bytes free on the world volume by the latest listing; null while unknown
|
// Bytes free on the world volume by the latest listing; null while unknown
|
||||||
// (the listing says null when the Job could not tell; 0 is a full volume).
|
// (the listing says null when the Job could not tell; 0 is a full volume).
|
||||||
const [free, setFree] = useState<number | null>(null);
|
const [free, setFree] = useState<number | null>(null);
|
||||||
@@ -144,13 +150,21 @@ export function ServerFiles() {
|
|||||||
// folder already left behind would otherwise replace the one clicked after it.
|
// folder already left behind would otherwise replace the one clicked after it.
|
||||||
const loadSeq = useRef(0);
|
const loadSeq = useRef(0);
|
||||||
const load = useCallback(
|
const load = useCallback(
|
||||||
async (p: string) => {
|
async (p: string, cached = false) => {
|
||||||
const ticket = ++loadSeq.current;
|
const ticket = ++loadSeq.current;
|
||||||
|
if (!cached) folders.current.clear();
|
||||||
|
const previous = folders.current.get(`${name}:${p}`);
|
||||||
setListLoading(true);
|
setListLoading(true);
|
||||||
|
setListTarget(p);
|
||||||
setListErr(null);
|
setListErr(null);
|
||||||
try {
|
try {
|
||||||
const r = await api.listServerFiles(name, p);
|
const fresh = cached && previous && Date.now() - previous.at < 30_000;
|
||||||
|
const r = fresh
|
||||||
|
? previous.listing
|
||||||
|
: await api.listServerFiles(name, p);
|
||||||
if (ticket !== loadSeq.current) return;
|
if (ticket !== loadSeq.current) return;
|
||||||
|
if (folders.current.size >= 20) folders.current.delete(folders.current.keys().next().value!);
|
||||||
|
folders.current.set(`${name}:${p}`, { at: fresh ? previous.at : Date.now(), listing: r });
|
||||||
setEntries(sortEntries(r.entries ?? []));
|
setEntries(sortEntries(r.entries ?? []));
|
||||||
setTruncated(r.truncated === true);
|
setTruncated(r.truncated === true);
|
||||||
setFree(r.free_bytes ?? null);
|
setFree(r.free_bytes ?? null);
|
||||||
@@ -174,6 +188,9 @@ export function ServerFiles() {
|
|||||||
if (owned && stopped) void load(dir);
|
if (owned && stopped) void load(dir);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [owned, stopped, load]);
|
}, [owned, stopped, load]);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!stopped) folders.current.clear();
|
||||||
|
}, [stopped]);
|
||||||
|
|
||||||
// While the server is not stopped, poll the phase so the first successful
|
// While the server is not stopped, poll the phase so the first successful
|
||||||
// stop flips the page from the notice to the listing without a manual reload.
|
// stop flips the page from the notice to the listing without a manual reload.
|
||||||
@@ -192,8 +209,12 @@ export function ServerFiles() {
|
|||||||
sha256: string;
|
sha256: string;
|
||||||
conflict: boolean;
|
conflict: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
preview?: "binary" | "large";
|
||||||
|
size?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [opening, setOpening] = useState<string | null>(null);
|
const [opening, setOpening] = useState<string | null>(null);
|
||||||
|
const openingSeq = useRef(0);
|
||||||
|
useEffect(() => () => { ++openingSeq.current; ++loadSeq.current; }, []);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [reloading, setReloading] = useState(false);
|
const [reloading, setReloading] = useState(false);
|
||||||
// Closing an editor with unsaved text (Esc, the overlay, ✕, Cancel) asks
|
// Closing an editor with unsaved text (Esc, the overlay, ✕, Cancel) asks
|
||||||
@@ -208,6 +229,8 @@ export function ServerFiles() {
|
|||||||
setConfirmDiscard(true);
|
setConfirmDiscard(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
++openingSeq.current;
|
||||||
|
setOpening(null);
|
||||||
setOpen(null);
|
setOpen(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,9 +239,11 @@ export function ServerFiles() {
|
|||||||
setOpen(null);
|
setOpen(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function readInto(p: string) {
|
async function readInto(p: string, ticket = openingSeq.current) {
|
||||||
const r = await api.readServerFile(name, p);
|
const r = await api.readServerFile(name, p);
|
||||||
const text = decodeText(base64ToBytes(r.content ?? ""));
|
if (ticket !== openingSeq.current) return;
|
||||||
|
const bytes = base64ToBytes(r.content ?? "");
|
||||||
|
const text = decodeText(bytes);
|
||||||
setConfirmDiscard(false);
|
setConfirmDiscard(false);
|
||||||
setOpen({
|
setOpen({
|
||||||
path: p,
|
path: p,
|
||||||
@@ -228,25 +253,36 @@ export function ServerFiles() {
|
|||||||
sha256: r.sha256 ?? "",
|
sha256: r.sha256 ?? "",
|
||||||
conflict: false,
|
conflict: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
preview: text === null ? "binary" : undefined,
|
||||||
|
size: bytes.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openFile(entry: ServerFileEntry) {
|
async function openFile(entry: ServerFileEntry) {
|
||||||
|
if (opening !== null) return;
|
||||||
const p = joinPath(dir, entry.name);
|
const p = joinPath(dir, entry.name);
|
||||||
|
setSelected(p);
|
||||||
|
setMsg(null);
|
||||||
// The read path refuses this one outright; saying why beats a Job that
|
// The read path refuses this one outright; saying why beats a Job that
|
||||||
// answers "invalid path".
|
// answers "invalid path".
|
||||||
if (p === SECRET_CONFIG_PATH) {
|
if (p === SECRET_CONFIG_PATH) {
|
||||||
setMsg({ kind: "error", text: t("secret_config_unreadable") });
|
setMsg({ kind: "error", text: t("secret_config_unreadable") });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const preview = BINARY_EXTENSION.test(entry.name) ? "binary" : entry.size > MAX_READ_BYTES ? "large" : undefined;
|
||||||
|
if (preview) {
|
||||||
|
setOpen({ path: p, text: "", original: "", editable: false, sha256: "", conflict: false, error: null, preview, size: entry.size });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ticket = ++openingSeq.current;
|
||||||
setOpening(p);
|
setOpening(p);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
await readInto(p);
|
await readInto(p, ticket);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg({ kind: "error", text: humanizeError(e) });
|
if (ticket === openingSeq.current) setMsg({ kind: "error", text: humanizeError(e) });
|
||||||
} finally {
|
} finally {
|
||||||
setOpening(null);
|
if (ticket === openingSeq.current) setOpening(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +302,7 @@ export function ServerFiles() {
|
|||||||
// handleSave writes the editor's text. `overwrite` drops the precondition,
|
// handleSave writes the editor's text. `overwrite` drops the precondition,
|
||||||
// which is what "Overwrite anyway" on a conflict means.
|
// which is what "Overwrite anyway" on a conflict means.
|
||||||
async function handleSave(overwrite = false) {
|
async function handleSave(overwrite = false) {
|
||||||
if (!open || saving) return;
|
if (!open?.editable || saving || (!overwrite && open.conflict) || !dirty || tooLarge) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
setOpen({ ...open, error: null });
|
setOpen({ ...open, error: null });
|
||||||
@@ -314,6 +350,9 @@ export function ServerFiles() {
|
|||||||
// whichever tab or person started them. A restore replaces the files listed.
|
// whichever tab or person started them. A restore replaces the files listed.
|
||||||
const worldJobs = useWorldJobs(name, owned && stopped, () => void load(dir));
|
const worldJobs = useWorldJobs(name, owned && stopped, () => void load(dir));
|
||||||
const held = worldJobs.holder;
|
const held = worldJobs.holder;
|
||||||
|
useEffect(() => {
|
||||||
|
if (held !== null) folders.current.clear();
|
||||||
|
}, [held]);
|
||||||
|
|
||||||
// A download holds the world while felis-api gets it ready, and a change
|
// A download holds the world while felis-api gets it ready, and a change
|
||||||
// sent meanwhile could only be refused.
|
// sent meanwhile could only be refused.
|
||||||
@@ -663,7 +702,7 @@ export function ServerFiles() {
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => void load(parentOf(dir))}
|
onClick={() => void load(parentOf(dir), true)}
|
||||||
disabled={dir === "" || listLoading}
|
disabled={dir === "" || listLoading}
|
||||||
>
|
>
|
||||||
<ArrowUp className="h-4 w-4" />
|
<ArrowUp className="h-4 w-4" />
|
||||||
@@ -672,7 +711,7 @@ export function ServerFiles() {
|
|||||||
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
|
<nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void load("")}
|
onClick={() => void load("", true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded px-1.5 py-0.5 hover:bg-muted",
|
"rounded px-1.5 py-0.5 hover:bg-muted",
|
||||||
dir === "" ? "font-medium text-foreground" : "text-muted-foreground",
|
dir === "" ? "font-medium text-foreground" : "text-muted-foreground",
|
||||||
@@ -688,7 +727,7 @@ export function ServerFiles() {
|
|||||||
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => void load(target)}
|
onClick={() => void load(target, true)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"rounded px-1.5 py-0.5 font-mono text-xs hover:bg-muted",
|
"rounded px-1.5 py-0.5 font-mono text-xs hover:bg-muted",
|
||||||
last ? "font-medium text-foreground" : "text-muted-foreground",
|
last ? "font-medium text-foreground" : "text-muted-foreground",
|
||||||
@@ -760,6 +799,13 @@ export function ServerFiles() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{listLoading && (
|
||||||
|
<div role="status" className="flex items-center gap-2 border-b border-border bg-primary/5 px-4 py-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
||||||
|
{t("loading_folder", { path: listTarget || t("root") })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{held !== null && (
|
{held !== null && (
|
||||||
<section role="status" className="border-b border-border bg-muted/20 px-4 py-2.5">
|
<section role="status" className="border-b border-border bg-muted/20 px-4 py-2.5">
|
||||||
<p className="flex items-start gap-2.5 text-sm">
|
<p className="flex items-start gap-2.5 text-sm">
|
||||||
@@ -811,8 +857,13 @@ export function ServerFiles() {
|
|||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={e.name}
|
key={e.name}
|
||||||
onClick={() => (e.is_dir ? void load(p) : void openFile(e))}
|
onClick={() => {
|
||||||
className="cursor-pointer transition-colors hover:bg-muted/30"
|
setSelected(p);
|
||||||
|
if (e.is_dir) void load(p, true);
|
||||||
|
else void openFile(e);
|
||||||
|
}}
|
||||||
|
aria-selected={selected === p}
|
||||||
|
className={cn("cursor-pointer transition-colors hover:bg-muted/30", selected === p && "bg-primary/10 ring-1 ring-inset ring-primary/30")}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
{/* The name is a button so the keyboard and a screen
|
{/* The name is a button so the keyboard and a screen
|
||||||
@@ -829,7 +880,7 @@ export function ServerFiles() {
|
|||||||
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
<span className="truncate font-mono text-xs">{e.name}</span>
|
<span className="truncate font-mono text-xs">{e.name}</span>
|
||||||
{opening === p && (
|
{(opening === p || (listLoading && listTarget === p)) && (
|
||||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
@@ -940,16 +991,18 @@ export function ServerFiles() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File editor dialog */}
|
{/* File editor dialog */}
|
||||||
<Dialog open={open !== null} onOpenChange={(v) => !v && requestClose()}>
|
<Dialog open={open !== null || opening !== null} onOpenChange={(v) => !v && requestClose()}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-4xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="break-all font-mono text-sm">
|
<DialogTitle className="break-all font-mono text-sm">
|
||||||
{open?.path}
|
{opening ?? open?.path}
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
{open && !open.editable && (
|
|
||||||
<p className="text-xs text-muted-foreground">{t("binary_hint")}</p>
|
|
||||||
)}
|
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
{opening !== null && (
|
||||||
|
<div role="status" className="flex h-[30vh] items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin text-primary" />{t("opening_file")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
{open.conflict && (
|
{open.conflict && (
|
||||||
@@ -997,18 +1050,29 @@ export function ServerFiles() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{open.error && <MessageLine kind="error" message={open.error} />}
|
{open.error && <MessageLine kind="error" message={open.error} />}
|
||||||
<textarea
|
{open.editable ? (
|
||||||
value={open.text}
|
<Suspense fallback={<Loading />}>
|
||||||
onChange={(e) => setOpen({ ...open, text: e.target.value })}
|
<TextFileEditor path={open.path} value={open.text} label={t("file_content")}
|
||||||
readOnly={!open.editable}
|
onChange={(text) => setOpen({ ...open, text })} onSave={() => void handleSave()} />
|
||||||
spellCheck={false}
|
</Suspense>
|
||||||
className="h-[50vh] w-full resize-none rounded-md border border-input bg-background p-3 font-mono text-xs leading-relaxed focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
) : (
|
||||||
/>
|
<div className="flex min-h-52 flex-col items-center justify-center gap-3 rounded-md border border-border bg-muted/20 px-6 py-8 text-center">
|
||||||
|
<FileArchive className="h-9 w-9 text-muted-foreground" />
|
||||||
|
<p className="font-medium">{t(open.preview === "large" ? "preview_large_title" : "binary_title")}</p>
|
||||||
|
<p className="max-w-md text-sm text-muted-foreground">{t(open.preview === "large" ? "preview_large_hint" : "binary_hint", { limit: formatBytes(MAX_READ_BYTES) })}</p>
|
||||||
|
<Button variant="outline" disabled={changing}
|
||||||
|
onClick={() => void download({ name: open.path.slice(dir.length ? dir.length + 1 : 0), is_dir: false, size: open.size ?? 0, mod_time: "" })}>
|
||||||
|
{downloading === open.path ? <Loader2 className="h-4 w-4 animate-spin" /> : <Download className="h-4 w-4" />}
|
||||||
|
{t("download_file")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!open.editable && msg && <MessageLine kind={msg.kind} message={msg.text} />}
|
||||||
<DialogFooter className="items-center gap-2 sm:justify-between">
|
<DialogFooter className="items-center gap-2 sm:justify-between">
|
||||||
<span className={cn("text-xs", tooLarge ? "text-destructive" : "text-muted-foreground")}>
|
<span className={cn("text-xs", tooLarge ? "text-destructive" : "text-muted-foreground")}>
|
||||||
{tooLarge
|
{tooLarge
|
||||||
? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) })
|
? t("too_large", { limit: formatBytes(MAX_WRITE_BYTES) })
|
||||||
: formatBytes(dirtyBytes)}
|
: `${formatBytes(open.editable ? dirtyBytes : open.size ?? 0)}${open.editable ? " · UTF-8 · Ctrl/⌘ S" : ""}`}
|
||||||
</span>
|
</span>
|
||||||
{confirmDiscard ? (
|
{confirmDiscard ? (
|
||||||
<div role="alert" className="flex flex-wrap items-center justify-end gap-2">
|
<div role="alert" className="flex flex-wrap items-center justify-end gap-2">
|
||||||
@@ -1033,7 +1097,7 @@ export function ServerFiles() {
|
|||||||
>
|
>
|
||||||
{t("common:cancel")}
|
{t("common:cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{open.editable && <Button
|
||||||
onClick={() => void handleSave()}
|
onClick={() => void handleSave()}
|
||||||
disabled={saving || reloading || open.conflict || !open.editable || !dirty || tooLarge}
|
disabled={saving || reloading || open.conflict || !open.editable || !dirty || tooLarge}
|
||||||
>
|
>
|
||||||
@@ -1043,7 +1107,7 @@ export function ServerFiles() {
|
|||||||
<Save className="h-4 w-4" />
|
<Save className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
{saving ? t("saving") : t("save")}
|
{saving ? t("saving") : t("save")}
|
||||||
</Button>
|
</Button>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { LobbyPage } from "./LobbyPage";
|
import { LobbyPage } from "./LobbyPage";
|
||||||
@@ -25,10 +25,39 @@ function page(space = "lobby") {
|
|||||||
render(<MemoryRouter initialEntries={[`/admin/lobby${space ? `?space=${space}` : ""}`]}><LobbyPage /></MemoryRouter>);
|
render(<MemoryRouter initialEntries={[`/admin/lobby${space ? `?space=${space}` : ""}`]}><LobbyPage /></MemoryRouter>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function readyField(label: string) {
|
||||||
|
const field = await screen.findByLabelText(label) as HTMLInputElement;
|
||||||
|
await waitFor(() => expect(field.disabled).toBe(false));
|
||||||
|
return field;
|
||||||
|
}
|
||||||
|
|
||||||
describe("LobbyPage", () => {
|
describe("LobbyPage", () => {
|
||||||
|
it.each([["login", "Login book title"], ["lobby", "English menu title"]])("shows %s fields before either status or settings finish loading", async (space, label) => {
|
||||||
|
let resolveRead!: (value: { content: string; sha256: string }) => void;
|
||||||
|
let resolveStatus!: (value: object) => void;
|
||||||
|
calls.readServerFile.mockReturnValue(new Promise((resolve) => { resolveRead = resolve; }));
|
||||||
|
calls.status.mockReturnValue(new Promise((resolve) => { resolveStatus = resolve; }));
|
||||||
|
page(space);
|
||||||
|
const field = screen.getByLabelText(label) as HTMLInputElement;
|
||||||
|
expect(field.value).toBe("");
|
||||||
|
expect(field.disabled).toBe(true);
|
||||||
|
expect(screen.getByText("Loading settings. Editing will be available once loaded.")).toBeTruthy();
|
||||||
|
expect((screen.getByRole("button", { name: "Save settings" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
expect(calls.readServerFile).toHaveBeenCalledWith(space, "felis-experience.json");
|
||||||
|
await act(async () => resolveRead({ content: btoa(JSON.stringify({ bookTitle: "My Login", menuTitleEn: "My Lobby" })), sha256: "read-hash" }));
|
||||||
|
await readyField(label);
|
||||||
|
expect(field.value).toBe(space === "login" ? "My Login" : "My Lobby");
|
||||||
|
expect(screen.queryByText("Loading settings. Editing will be available once loaded.")).toBeNull();
|
||||||
|
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
|
await act(async () => resolveStatus({ name: space, phase: "Running", desiredState: "Running", ready: true }));
|
||||||
|
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(false);
|
||||||
|
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("saves with the read hash and preserves unknown plugin settings", async () => {
|
it("saves with the read hash and preserves unknown plugin settings", async () => {
|
||||||
page();
|
page();
|
||||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
||||||
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
|
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
|
||||||
@@ -39,7 +68,7 @@ describe("LobbyPage", () => {
|
|||||||
it("uses defaults for a missing file and creates without overwriting a concurrent file", async () => {
|
it("uses defaults for a missing file and creates without overwriting a concurrent file", async () => {
|
||||||
calls.readServerFile.mockRejectedValue({ status: 404, code: "not_found" });
|
calls.readServerFile.mockRejectedValue({ status: 404, code: "not_found" });
|
||||||
page("login");
|
page("login");
|
||||||
fireEvent.change(await screen.findByLabelText("Login timeout (30–3600 seconds)"), { target: { value: "900" } });
|
fireEvent.change(await readyField("Login timeout (30–3600 seconds)"), { target: { value: "900" } });
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
await waitFor(() => expect(calls.createServerFile).toHaveBeenCalled());
|
await waitFor(() => expect(calls.createServerFile).toHaveBeenCalled());
|
||||||
expect(calls.createServerFile.mock.calls[0].slice(0, 2)).toEqual(["login", "felis-experience.json"]);
|
expect(calls.createServerFile.mock.calls[0].slice(0, 2)).toEqual(["login", "felis-experience.json"]);
|
||||||
@@ -56,7 +85,7 @@ describe("LobbyPage", () => {
|
|||||||
it("saves while running and restarts only when requested", async () => {
|
it("saves while running and restarts only when requested", async () => {
|
||||||
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 0 });
|
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 0 });
|
||||||
page();
|
page();
|
||||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||||
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
||||||
@@ -69,7 +98,7 @@ describe("LobbyPage", () => {
|
|||||||
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 });
|
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 });
|
||||||
calls.restart.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "busy" });
|
calls.restart.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "busy" });
|
||||||
page();
|
page();
|
||||||
await screen.findByLabelText("English menu title");
|
await readyField("English menu title");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Restart & apply" }));
|
await userEvent.click(screen.getByRole("button", { name: "Restart & apply" }));
|
||||||
const dialog = await screen.findByRole("dialog", { name: "Restart this space?" });
|
const dialog = await screen.findByRole("dialog", { name: "Restart this space?" });
|
||||||
expect(calls.restart).not.toHaveBeenCalled();
|
expect(calls.restart).not.toHaveBeenCalled();
|
||||||
@@ -82,7 +111,7 @@ describe("LobbyPage", () => {
|
|||||||
it("keeps the draft when another editor changed the file", async () => {
|
it("keeps the draft when another editor changed the file", async () => {
|
||||||
calls.writeServerFile.mockRejectedValue({ status: 409, code: "file_changed" });
|
calls.writeServerFile.mockRejectedValue({ status: 409, code: "file_changed" });
|
||||||
page();
|
page();
|
||||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
await screen.findByRole("alert");
|
await screen.findByRole("alert");
|
||||||
expect((screen.getByLabelText("English menu title") as HTMLInputElement).value).toBe("My Network");
|
expect((screen.getByLabelText("English menu title") as HTMLInputElement).value).toBe("My Network");
|
||||||
@@ -91,7 +120,7 @@ describe("LobbyPage", () => {
|
|||||||
|
|
||||||
it("confirms a switch with unsaved edits", async () => {
|
it("confirms a switch with unsaved edits", async () => {
|
||||||
page();
|
page();
|
||||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
fireEvent.change(await readyField("English menu title"), { target: { value: "My Network" } });
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Login space" }));
|
await userEvent.click(screen.getByRole("button", { name: "Login space" }));
|
||||||
await screen.findByRole("dialog", { name: "Discard unsaved settings?" });
|
await screen.findByRole("dialog", { name: "Discard unsaved settings?" });
|
||||||
expect(calls.status).not.toHaveBeenCalledWith("login");
|
expect(calls.status).not.toHaveBeenCalledWith("login");
|
||||||
@@ -100,10 +129,16 @@ describe("LobbyPage", () => {
|
|||||||
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not offer to save malformed JSON", async () => {
|
it("keeps the form disabled after malformed JSON and enables it after retry", async () => {
|
||||||
calls.readServerFile.mockResolvedValue({ content: btoa("{broken"), sha256: "hash" });
|
calls.readServerFile.mockResolvedValue({ content: btoa("{broken"), sha256: "hash" });
|
||||||
page();
|
page();
|
||||||
await screen.findByRole("alert");
|
await screen.findByRole("alert");
|
||||||
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
|
expect((screen.getByLabelText("English menu title") as HTMLInputElement).disabled).toBe(true);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||||
|
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||||
|
calls.readServerFile.mockResolvedValue({ content: btoa('{"menuTitleEn":"Recovered title"}'), sha256: "hash" });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||||
|
expect((await readyField("English menu title")).value).toBe("Recovered title");
|
||||||
|
expect(screen.queryByRole("alert")).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useSearchParams } from "react-router-dom";
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, RotateCw, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
|
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Loader2, Map, MessageSquare, Package, RotateCw, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
@@ -12,7 +12,7 @@ import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
|
|||||||
import { PowerButton, SUBMITTED_HOLD_MS } from "@/components/PowerButton";
|
import { PowerButton, SUBMITTED_HOLD_MS } from "@/components/PowerButton";
|
||||||
import { CopyAddress } from "@/components/CopyAddress";
|
import { CopyAddress } from "@/components/CopyAddress";
|
||||||
import { EditServerDialog } from "@/components/EditServerDialog";
|
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||||
import { ErrorState, Loading } from "@/components/States";
|
import { ErrorState } from "@/components/States";
|
||||||
import { InlineError, MessageLine } from "@/components/MessageLine";
|
import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { joinAddress } from "@/lib/config";
|
import { joinAddress } from "@/lib/config";
|
||||||
@@ -28,7 +28,7 @@ const GROUP_ICONS: Record<string, LucideIcon> = {
|
|||||||
loginBook: BookOpen,
|
loginBook: BookOpen,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name: string; server: ServerStatus; onDirtyChange: (dirty: boolean) => void; onChanged: () => void }) {
|
function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name: string; server: ServerStatus | null; onDirtyChange: (dirty: boolean) => void; onChanged: () => void }) {
|
||||||
const { t } = useTranslation("lobby");
|
const { t } = useTranslation("lobby");
|
||||||
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
|
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
|
||||||
const query = useAsync(() => readExperience(name, groups), [name]);
|
const query = useAsync(() => readExperience(name, groups), [name]);
|
||||||
@@ -37,7 +37,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
|||||||
const [needsRestart, setNeedsRestart] = useState(false);
|
const [needsRestart, setNeedsRestart] = useState(false);
|
||||||
const [restarting, setRestarting] = useState(false);
|
const [restarting, setRestarting] = useState(false);
|
||||||
const [confirmRestart, setConfirmRestart] = useState(false);
|
const [confirmRestart, setConfirmRestart] = useState(false);
|
||||||
const running = server.phase === "Running" && server.desiredState === "Running";
|
const running = server?.phase === "Running" && server.desiredState === "Running";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!restarting) return;
|
if (!restarting) return;
|
||||||
if (!running) { setRestarting(false); return; }
|
if (!running) { setRestarting(false); return; }
|
||||||
@@ -54,6 +54,10 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
|||||||
useUnsavedGuard(dirty);
|
useUnsavedGuard(dirty);
|
||||||
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
|
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
|
||||||
|
|
||||||
|
function setValue(key: string, value: unknown) {
|
||||||
|
setDraft((current) => current && { ...current, values: { ...current.values, [key]: value } });
|
||||||
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
if (!draft || saving || !experienceValid(draft.values, groups)) return;
|
if (!draft || saving || !experienceValid(draft.values, groups)) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
@@ -85,10 +89,10 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div aria-busy={query.loading} className="space-y-4">
|
||||||
{query.loading && !draft && <Loading />}
|
{query.loading && <p role="status" className="flex items-center gap-2 rounded-lg border border-border bg-muted/20 px-3 py-2.5 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 shrink-0 animate-spin" />{t("loading_settings")}</p>}
|
||||||
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
|
{query.error != null && <div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 p-3"><InlineError message={t("read_failed", { reason: humanizeError(query.error) })} /><Button variant="outline" size="sm" onClick={query.reload}>{t("common:try_again")}</Button></div>}
|
||||||
{draft && groups.map((group) => {
|
{groups.map((group) => {
|
||||||
const Icon = GROUP_ICONS[group.key];
|
const Icon = GROUP_ICONS[group.key];
|
||||||
const fields = group.fields.filter((field) => typeof field.value !== "boolean");
|
const fields = group.fields.filter((field) => typeof field.value !== "boolean");
|
||||||
const switches = group.fields.filter((field) => typeof field.value === "boolean");
|
const switches = group.fields.filter((field) => typeof field.value === "boolean");
|
||||||
@@ -104,20 +108,20 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
|||||||
<CardContent className="space-y-5 pt-5">
|
<CardContent className="space-y-5 pt-5">
|
||||||
<div className="grid items-start gap-x-5 gap-y-4 sm:grid-cols-2">
|
<div className="grid items-start gap-x-5 gap-y-4 sm:grid-cols-2">
|
||||||
{fields.map((field) => {
|
{fields.map((field) => {
|
||||||
const value = draft.values[field.key] ?? field.value;
|
const value = draft ? draft.values[field.key] ?? field.value : "";
|
||||||
const id = `experience-${field.key}`;
|
const id = `experience-${field.key}`;
|
||||||
const set = (next: unknown) => setDraft({ ...draft, values: { ...draft.values, [field.key]: next } });
|
|
||||||
return (
|
return (
|
||||||
<div key={id} className="min-w-0 space-y-2">
|
<div key={id} className="min-w-0 space-y-2">
|
||||||
<Label htmlFor={id} className="block text-xs font-medium text-muted-foreground">{t(field.key)}</Label>
|
<Label htmlFor={id} className="block text-xs font-medium text-muted-foreground">{t(field.key)}</Label>
|
||||||
{field.choices ? (
|
{field.choices ? (
|
||||||
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={saving || restarting} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
<select id={id} value={value as string} onChange={(e) => setValue(field.key, e.target.value)} disabled={!draft || saving || restarting} className="h-10 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50">
|
||||||
|
{!draft && <option value="">{query.loading ? t("common:loading") : "—"}</option>}
|
||||||
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
|
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
|
||||||
</select>
|
</select>
|
||||||
) : field.multiline ? (
|
) : field.multiline ? (
|
||||||
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={saving || restarting} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
|
<textarea id={id} value={value as string} placeholder={!draft && query.loading ? t("common:loading") : undefined} maxLength={512} onChange={(e) => setValue(field.key, e.target.value)} disabled={!draft || saving || restarting} className="block h-32 w-full resize-y rounded-lg border border-input bg-background px-3 py-2.5 text-sm leading-6 outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50" />
|
||||||
) : (
|
) : (
|
||||||
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => set(typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={saving || restarting} className="h-10 rounded-lg bg-background shadow-none" />
|
<Input id={id} type={typeof field.value === "number" ? "number" : "text"} value={value as string | number} placeholder={!draft && query.loading ? t("common:loading") : undefined} min={field.min} max={field.max} step={1} maxLength={512} onChange={(e) => setValue(field.key, typeof field.value === "number" ? (e.target.value === "" ? "" : Number(e.target.value)) : e.target.value)} disabled={!draft || saving || restarting} className="h-10 rounded-lg bg-background shadow-none" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -128,7 +132,7 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
|||||||
<label key={field.key} htmlFor={`experience-${field.key}`} className="flex min-h-12 cursor-pointer items-center justify-between gap-4 py-3 text-sm">
|
<label key={field.key} htmlFor={`experience-${field.key}`} className="flex min-h-12 cursor-pointer items-center justify-between gap-4 py-3 text-sm">
|
||||||
<span>{t(field.key)}</span>
|
<span>{t(field.key)}</span>
|
||||||
<span className="relative shrink-0">
|
<span className="relative shrink-0">
|
||||||
<input id={`experience-${field.key}`} type="checkbox" role="switch" checked={(draft.values[field.key] ?? field.value) as boolean} onChange={(e) => setDraft({ ...draft, values: { ...draft.values, [field.key]: e.target.checked } })} disabled={saving || restarting} className="peer sr-only" />
|
<input id={`experience-${field.key}`} type="checkbox" role="switch" checked={draft ? (draft.values[field.key] ?? field.value) as boolean : false} onChange={(e) => setValue(field.key, e.target.checked)} disabled={!draft || saving || restarting} className="peer sr-only" />
|
||||||
<span aria-hidden="true" className="block h-5 w-9 rounded-full bg-muted-foreground/25 p-0.5 transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 peer-checked:[&>span]:translate-x-4">
|
<span aria-hidden="true" className="block h-5 w-9 rounded-full bg-muted-foreground/25 p-0.5 transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2 peer-disabled:opacity-50 peer-checked:[&>span]:translate-x-4">
|
||||||
<span className="block h-4 w-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none" />
|
<span className="block h-4 w-4 rounded-full bg-white shadow-sm transition-transform motion-reduce:transition-none" />
|
||||||
</span>
|
</span>
|
||||||
@@ -140,21 +144,19 @@ function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name:
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{draft && <>
|
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
|
||||||
<p className="text-sm text-muted-foreground">{t(name === "login" ? "login_settings_hint" : "lobby_settings_hint")}</p>
|
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||||
{message && <MessageLine kind={message.kind} message={message.text} />}
|
{draft && !experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
|
||||||
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
|
<div className={cn("flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-3", needsRestart ? "border-amber-500/30 bg-amber-500/5" : "border-border")}>
|
||||||
<div className={cn("flex flex-wrap items-center justify-between gap-3 rounded-xl border bg-card p-3", needsRestart ? "border-amber-500/30 bg-amber-500/5" : "border-border")}>
|
<p className={cn("text-xs", needsRestart ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground")}>{t(dirty ? "unsaved" : needsRestart ? "restart_required" : "apply_on_start")}</p>
|
||||||
<p className={cn("text-xs", needsRestart ? "text-amber-700 dark:text-amber-400" : "text-muted-foreground")}>{t(dirty ? "unsaved" : needsRestart ? "restart_required" : "apply_on_start")}</p>
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="flex flex-wrap gap-2">
|
<Button variant="outline" disabled={!draft || !running || dirty || saving || restarting} onClick={() => {
|
||||||
<Button variant="outline" disabled={!running || dirty || saving || restarting} onClick={() => {
|
if ((server?.playersOnline ?? 0) > 0 || server?.playerCountUnknown) setConfirmRestart(true);
|
||||||
if ((server.playersOnline ?? 0) > 0 || server.playerCountUnknown) setConfirmRestart(true);
|
else void restart().catch((error) => setMessage({ kind: "error", text: humanizeError(error) }));
|
||||||
else void restart().catch((error) => setMessage({ kind: "error", text: humanizeError(error) }));
|
}}><RotateCw className={cn(restarting && "animate-spin")} />{t(restarting ? "restarting" : "restart")}</Button>
|
||||||
}}><RotateCw className={cn(restarting && "animate-spin")} />{t(restarting ? "restarting" : "restart")}</Button>
|
<Button onClick={() => void save()} disabled={!draft || !dirty || saving || restarting || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
|
||||||
<Button onClick={() => void save()} disabled={!dirty || saving || restarting || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</>}
|
</div>
|
||||||
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
|
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -236,35 +238,32 @@ export function LobbyPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
|
<p className="text-sm text-muted-foreground">{t(`${name}_description`)}</p>
|
||||||
{status.loading && !data && <Loading />}
|
|
||||||
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
|
{status.error != null && <ErrorState error={humanizeError(status.error)} onRetry={status.reload} />}
|
||||||
{data && <>
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-4">
|
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data?.displayName || t(name)}</h2>{data ? <PhaseBadge phase={shownPhase(data)} /> : status.loading && <span role="status" className="flex items-center gap-2 text-xs text-muted-foreground"><Loader2 className="h-3.5 w-3.5 animate-spin" />{t("common:loading")}</span>}</div><p className="break-all text-xs text-muted-foreground">{data?.image || "\u00a0"}</p></div>
|
||||||
<div className="min-w-0 space-y-2"><div className="flex flex-wrap items-center gap-3"><h2 className="font-semibold">{data.displayName || t(name)}</h2><PhaseBadge phase={shownPhase(data)} /></div><p className="break-all text-xs text-muted-foreground">{data.image}</p></div>
|
{data && <PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />}
|
||||||
<PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
|
</div>
|
||||||
|
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
||||||
|
<ExperienceSettings key={name} name={name} server={data} onDirtyChange={setDirty} onChanged={status.reload} />
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="overflow-hidden rounded-xl shadow-none">
|
||||||
|
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
|
||||||
|
<CardContent className="divide-y divide-border/60 p-0">
|
||||||
|
{links.map(({ icon: Icon, title, hint, to }) => (
|
||||||
|
<Link key={title} to={to} className="group flex gap-3 p-4 transition-colors hover:bg-muted/40">
|
||||||
|
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex items-center justify-between gap-2 text-sm font-medium">{t(title)}<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" /></p>
|
||||||
|
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{t(hint)}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{data && <EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />}
|
||||||
|
{name === "lobby" && <BuilderAccess ready={data?.ready ?? false} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]">
|
</div>
|
||||||
<ExperienceSettings key={name} name={name} server={data} onDirtyChange={setDirty} onChanged={status.reload} />
|
|
||||||
<div className="space-y-4">
|
|
||||||
<Card className="overflow-hidden rounded-xl shadow-none">
|
|
||||||
<CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
|
|
||||||
<CardContent className="divide-y divide-border/60 p-0">
|
|
||||||
{links.map(({ icon: Icon, title, hint, to }) => (
|
|
||||||
<Link key={title} to={to} className="group flex gap-3 p-4 transition-colors hover:bg-muted/40">
|
|
||||||
<Icon className="mt-0.5 h-4 w-4 shrink-0 text-primary" />
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<p className="flex items-center justify-between gap-2 text-sm font-medium">{t(title)}<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform group-hover:translate-x-0.5" /></p>
|
|
||||||
<p className="mt-1.5 text-xs leading-relaxed text-muted-foreground">{t(hint)}</p>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<EditServerDialog serverName={name} systemService currentDisplayName={data.displayName} currentPolicy={data.autostartPolicy} currentImage={data.image} currentMemory={data.memory} currentStorage={data.storageSize} currentCpu={data.cpu} currentIdleStopSeconds={0} onUpdated={status.reload} />
|
|
||||||
{name === "lobby" && <BuilderAccess ready={data.ready} />}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>}
|
|
||||||
<ConfirmDialog open={nextSpace !== null} onOpenChange={(open) => { if (!open) setNextSpace(null); }} title={t("discard_title")} description={t("discard_hint")} confirmLabel={t("discard")} onConfirm={async () => { if (nextSpace) { setParams({ space: nextSpace }); setDirty(false); setNextSpace(null); } }} />
|
<ConfirmDialog open={nextSpace !== null} onOpenChange={(open) => { if (!open) setNextSpace(null); }} title={t("discard_title")} description={t("discard_hint")} confirmLabel={t("discard")} onConfirm={async () => { if (nextSpace) { setParams({ space: nextSpace }); setDirty(false); setNextSpace(null); } }} />
|
||||||
</div>;
|
</div>;
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user