fix(panel): streamline system settings and server creation
Use shared action buttons and default to the login space. Allow staff to save startup-only experience settings while running and request a durable restart through the existing operator flow. Grant the API PVC list permission needed to detect retained worlds before server creation, and show internal failures with a request ID. Cover permission, maintenance, restart and UI behavior with regression checks.
This commit is contained in:
34 files changed
+555
-103
No files matched your search
+49
-6
@@ -66,7 +66,8 @@ info:
|
||||
services through existing management routes, without player ownership rows.
|
||||
Creating and claiming these reserved names remain prohibited. System patches
|
||||
keep public autostart and idle stop disabled. Customization is persisted as
|
||||
felis-experience.json using the existing stopped-server file API.
|
||||
felis-experience.json using the existing file API. Staff may read and save
|
||||
this startup-only config while system services run; restart to apply it.
|
||||
|
||||
Control plane for the Felis Minecraft orchestration platform. The same binary
|
||||
exposes an internal face (per-caller service tokens, for velocity / backend
|
||||
@@ -2452,6 +2453,45 @@ paths:
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
|
||||
/api/v1/servers/{name}/restart:
|
||||
post:
|
||||
tags: [servers]
|
||||
operationId: restart
|
||||
summary: Restart your own running server, or a system service as staff.
|
||||
description: >-
|
||||
Records a durable request for the operator to gracefully recreate the
|
||||
game pod. Desired state remains Running. A concurrent stop supersedes
|
||||
the request; maintenance blocks admission.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: app
|
||||
security: [{ sessionCookie: [] }]
|
||||
parameters:
|
||||
- { name: name, in: path, required: true, schema: { type: string } }
|
||||
responses:
|
||||
'202':
|
||||
description: Restart accepted.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name, desiredState]
|
||||
properties:
|
||||
name: { type: string }
|
||||
desiredState: { type: string, const: Running }
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
'404':
|
||||
$ref: '#/components/responses/NotFound'
|
||||
'409':
|
||||
description: Server is not running, is retiring, or maintenance is in progress.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
|
||||
/api/v1/servers/{name}/claim:
|
||||
post:
|
||||
tags: [servers]
|
||||
@@ -4830,10 +4870,11 @@ paths:
|
||||
get:
|
||||
tags: [files]
|
||||
operationId: readServerFile
|
||||
summary: Read a file from a server's world volume (owner-or-admin; server must be stopped).
|
||||
summary: Read a file from a server's world volume (owner-or-admin).
|
||||
description: >-
|
||||
Returns one file's bytes, base64-encoded, from inside the server's world
|
||||
volume. Same stopped-gate and os.Root containment as the directory listing.
|
||||
volume. Same stopped-gate and os.Root containment as the directory listing,
|
||||
except staff may read login/lobby's felis-experience.json while running.
|
||||
Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read,
|
||||
because a config editor that silently returned half a file would let a
|
||||
subsequent save destroy the other half.
|
||||
@@ -4886,7 +4927,7 @@ paths:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
'409':
|
||||
description: Server is not stopped (its world PVC is still mounted).
|
||||
description: Server is not stopped (except startup-only system experience config).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: '#/components/schemas/Error' }
|
||||
@@ -4914,7 +4955,7 @@ paths:
|
||||
put:
|
||||
tags: [files]
|
||||
operationId: writeServerFile
|
||||
summary: Write a file in a server's world volume (owner-or-admin; server must be stopped).
|
||||
summary: Write a file in a server's world volume (owner-or-admin).
|
||||
description: >-
|
||||
Replaces a file's contents, creating the file if absent but never creating its
|
||||
parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM)
|
||||
@@ -4927,7 +4968,9 @@ paths:
|
||||
otherwise 409 file_changed. content_sha256 is the SHA-256 of the content:
|
||||
content that hashes otherwise changed on the way and is refused (400
|
||||
digest_mismatch) before a Job starts, and the Job checks the bytes it received
|
||||
the same way before writing. Audited as file.write.
|
||||
the same way before writing. Staff may save login/lobby's startup-only
|
||||
felis-experience.json while running; restart to apply. That config cannot
|
||||
be a symlink. Audited as file.write.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: app
|
||||
security: [{ sessionCookie: [] }]
|
||||
|
||||
@@ -566,6 +566,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
// (handlers_retire.go); owner/admin-gated inside the handlers.
|
||||
{Method: "PUT", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleRetire},
|
||||
{Method: "DELETE", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleCancelRetire},
|
||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/restart", h: a.handleRestart},
|
||||
{Method: "GET", Pattern: "/api/v1/servers/{name}/status", h: a.handleStatus},
|
||||
// Identity self-read (spec §14 tiering): the panel reads this once at boot to
|
||||
// learn its own tier and decide which navigation surfaces to render. App-tier —
|
||||
|
||||
@@ -1935,6 +1935,36 @@ func (c *fakeCluster) RetryStart(ctx context.Context, n string) error {
|
||||
c.retried = append(c.retried, n)
|
||||
return nil
|
||||
}
|
||||
func (c *fakeCluster) RestartServer(ctx context.Context, n string) error {
|
||||
return c.RetryStart(ctx, n)
|
||||
}
|
||||
|
||||
func TestRestartAuthorization(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, server, role, user string
|
||||
staff bool
|
||||
status int
|
||||
}{
|
||||
{"owner of player server", "survival", "user", "owner1", false, 202},
|
||||
{"other player", "survival", "user", "stranger", false, 403},
|
||||
{"Owner console system service", "lobby", "owner", "owner1", true, 202},
|
||||
{"player system service", "lobby", "user", "owner1", false, 400},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
a, _, cl, _ := mkFiles(t)
|
||||
cl.byName[tc.server] = &ServerInfo{Name: tc.server, Phase: "Running", DesiredState: "Running", Ready: true}
|
||||
a.External = staticExternal{p: &Principal{UserID: tc.user, Role: tc.role, ViaAdminAccess: tc.staff}}
|
||||
w := do(a.ExternalHandler(), "POST", "/api/v1/servers/"+tc.server+"/restart", "", nil)
|
||||
if w.Code != tc.status {
|
||||
t.Fatalf("status=%d want=%d body=%s", w.Code, tc.status, w.Body.String())
|
||||
}
|
||||
if (len(cl.retried) == 1) != (tc.status == 202) {
|
||||
t.Fatalf("unauthorized restart or missing request: %v", cl.retried)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fakeCluster) AcquireMaintenance(_ context.Context, n, kind string) error {
|
||||
if err := c.maintErr[n]; err != nil {
|
||||
return err
|
||||
|
||||
@@ -137,8 +137,10 @@ type Cluster interface {
|
||||
// also asks the operator to start it over with a fresh auto-restart budget
|
||||
// (v1alpha1.AnnotationStartRetry). Maintenance refuses it the same way.
|
||||
RetryStart(ctx context.Context, name string) error
|
||||
// RestartServer requests a pod restart without changing desired state.
|
||||
RestartServer(ctx context.Context, name string) error
|
||||
// AcquireMaintenance admits one world-volume operation (internal/maintenance
|
||||
// kind): ErrNotStopped unless the server is fully stopped, a
|
||||
// kind): ErrNotStopped unless fully stopped (except system config writes), a
|
||||
// *MaintenanceBusyError while another operation holds the volume. The check
|
||||
// and the lock are one atomic write against a concurrent wake.
|
||||
AcquireMaintenance(ctx context.Context, name, kind string) error
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||
"felis.lolicon.best/internal/fileedit"
|
||||
"felis.lolicon.best/internal/maintenance"
|
||||
"felis.lolicon.best/internal/naming"
|
||||
)
|
||||
|
||||
// FileEditor is the server-file-editor surface the API depends on: list a
|
||||
@@ -233,7 +234,11 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
|
||||
// beside it, and a read that overlaps a restore or another change can at worst
|
||||
// show a file mid-change: the sha256 it returned then no longer matches, so a
|
||||
// save built on it is refused with file_changed.
|
||||
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
|
||||
kind := maintenance.KindFileWrite
|
||||
if liveExperienceFile(r, name) {
|
||||
kind = maintenance.KindConfigWrite
|
||||
}
|
||||
release, ok := a.acquireWorld(w, r, name, kind, "stop the server before editing its files")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
@@ -582,16 +587,12 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
|
||||
// ② ServerByName — an unknown server is 404
|
||||
// ③ owner-or-admin, else 403. An unowned (released) server fails for everyone
|
||||
// but admin, which is the same "must re-claim first" rule restore enforces
|
||||
// ④ stopped gate: the world PVC is RWO and held by a running server, so a file
|
||||
// Job cannot mount it — refuse unless the server is fully stopped. Ready means
|
||||
// it is up; any desiredState other than Stopped means it is up or coming up
|
||||
// and still owns the volume. This yields a specific 409 instead of a Job that
|
||||
// silently fails to mount
|
||||
// ④ stopped gate: world files must not change under a live game process.
|
||||
// Only the system plugin's startup-only config may be read and saved live;
|
||||
// its Job mounts the RWO volume on the same node as the game pod.
|
||||
// ⑤ the FileEditor must be wired, else 503
|
||||
//
|
||||
// Single-sourcing it is what keeps the handlers from drifting: a read path that
|
||||
// forgot the stopped gate would not merely fail, it would hang waiting for a Pod
|
||||
// that can never be scheduled.
|
||||
// All file routes share this gate so the live-config exception stays narrow.
|
||||
//
|
||||
// It returns the validated server name and false if it has already written a
|
||||
// response.
|
||||
@@ -606,7 +607,7 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
|
||||
a.writeLookupError(w, r, err)
|
||||
return "", false
|
||||
}
|
||||
if info.Ready || info.DesiredState != string(v1alpha1.DesiredStopped) {
|
||||
if !liveExperienceFile(r, name) && (info.Ready || info.DesiredState != string(v1alpha1.DesiredStopped)) {
|
||||
writeError(w, r, newError(http.StatusConflict, "not_stopped",
|
||||
"stop the server before working with its files"))
|
||||
return "", false
|
||||
@@ -636,6 +637,14 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
|
||||
return name, true
|
||||
}
|
||||
|
||||
// Only the startup-only system plugin settings may be edited while running.
|
||||
// World files, uploads, deletes and plugin binaries keep the stopped gate.
|
||||
func liveExperienceFile(r *http.Request, name string) bool {
|
||||
return naming.IsSystemServer(name) && strings.HasSuffix(r.URL.Path, "/file") &&
|
||||
(r.Method == http.MethodGet || r.Method == http.MethodPut) &&
|
||||
r.URL.Query().Get("path") == naming.ExperienceConfigFile
|
||||
}
|
||||
|
||||
// writeFileEditError maps executor errors onto HTTP status codes. The
|
||||
// sentinels are caller-fault and get precise answers; a timeout is reported as 504
|
||||
// so the caller knows to retry rather than believing the edit was rejected; and
|
||||
|
||||
@@ -163,6 +163,49 @@ func mkFiles(t *testing.T) (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
|
||||
return api, repo, cl, files
|
||||
}
|
||||
|
||||
func TestRunningSystemExperienceFile(t *testing.T) {
|
||||
for _, name := range []string{"login", "lobby"} {
|
||||
for _, tc := range []struct {
|
||||
method, suffix, body string
|
||||
allowed bool
|
||||
}{
|
||||
{"GET", "/file?path=felis-experience.json", "", true},
|
||||
{"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, true},
|
||||
{"PUT", "/file?path=felis-experience.json", `{"content":"aGk=","content_sha256":"` + hiSum + `","create_only":true}`, true},
|
||||
{"PUT", "/file?path=world/level.dat", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, false},
|
||||
{"GET", "/file?path=./felis-experience.json", "", false},
|
||||
{"DELETE", "/file?path=felis-experience.json", "", false},
|
||||
{"GET", "/files", "", false},
|
||||
} {
|
||||
t.Run(name+" "+tc.method+tc.suffix, func(t *testing.T) {
|
||||
a, _, cl, files := mkFiles(t)
|
||||
cl.byName[name] = &ServerInfo{Name: name, Phase: "Running", Ready: true, DesiredState: "Running"}
|
||||
a.External = staticExternal{p: &Principal{UserID: "owner1", Role: "owner", ViaAdminAccess: true}}
|
||||
w := do(a.ExternalHandler(), tc.method, "/api/v1/servers/"+name+tc.suffix, tc.body, jsonHeader)
|
||||
if tc.allowed {
|
||||
if w.Code != http.StatusOK || files.calls != 1 {
|
||||
t.Fatalf("live config: status=%d calls=%d body=%s", w.Code, files.calls, w.Body.String())
|
||||
}
|
||||
if tc.method == "PUT" && (len(cl.acquired) != 1 || cl.acquired[0] != name+":"+maintenance.KindConfigWrite) {
|
||||
t.Fatalf("wrong lock: %v", cl.acquired)
|
||||
}
|
||||
} else if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" || files.calls != 0 {
|
||||
t.Fatalf("world gate: status=%d calls=%d body=%s", w.Code, files.calls, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
t.Run("player cannot edit system config", func(t *testing.T) {
|
||||
a, _, cl, files := mkFiles(t)
|
||||
cl.byName["lobby"] = &ServerInfo{Name: "lobby", Phase: "Running", Ready: true, DesiredState: "Running"}
|
||||
a.External = staticExternal{p: &Principal{UserID: "owner1", Role: "user"}}
|
||||
w := do(a.ExternalHandler(), "GET", "/api/v1/servers/lobby/file?path=felis-experience.json", "", nil)
|
||||
if w.Code < 400 || files.calls != 0 {
|
||||
t.Fatalf("player reached config: status=%d calls=%d", w.Code, files.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestFileEditorStoppedGate is the gate this whole subsystem hinges on. The world
|
||||
// PVC is ReadWriteOnce, but RWO is per node: on a single node a file Job mounts it
|
||||
// right beside a running server, and a write lands under a live world that the
|
||||
|
||||
@@ -92,6 +92,29 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{"name": name, "desiredState": "Running"})
|
||||
}
|
||||
|
||||
// handleRestart records a restart request for the operator to consume once.
|
||||
func (a *API) handleRestart(w http.ResponseWriter, r *http.Request) {
|
||||
name, ok := a.authorizeServerFiles(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rec, err := a.managedServerRecord(r.Context(), name)
|
||||
if err != nil {
|
||||
a.writeLookupError(w, r, err)
|
||||
return
|
||||
}
|
||||
if rec.Retire != nil {
|
||||
writeError(w, r, errServerRetiring)
|
||||
return
|
||||
}
|
||||
if err := a.Cluster.RestartServer(r.Context(), rec.Name); err != nil {
|
||||
a.writeLookupError(w, r, maintenanceError(err, "wait for maintenance to finish before restarting"))
|
||||
return
|
||||
}
|
||||
a.audit(r, "server.restart", rec.Name)
|
||||
writeJSON(w, http.StatusAccepted, map[string]string{"name": rec.Name, "desiredState": "Running"})
|
||||
}
|
||||
|
||||
// handleStop flips desiredState to Stopped. Only the owner or an admin may stop a
|
||||
// server (spec §14: operating someone else's server is admin-tier).
|
||||
func (a *API) handleStop(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
+27
-13
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -264,22 +265,29 @@ func (k *K8sCluster) SetDesiredState(ctx context.Context, name string, state v1a
|
||||
// against, the same as AcquireMaintenance's: whichever of a racing wake and
|
||||
// admission writes second gets a conflict, re-reads, and sees the other.
|
||||
func (k *K8sCluster) start(ctx context.Context, name string) error {
|
||||
return k.startWith(ctx, name, false)
|
||||
return k.startWith(ctx, name, "")
|
||||
}
|
||||
|
||||
// RetryStart starts a Failed server over: the same guarded write as start, plus
|
||||
// v1alpha1.AnnotationStartRetry so the operator resets the restart budget and
|
||||
// recreates the pod.
|
||||
func (k *K8sCluster) RetryStart(ctx context.Context, name string) error {
|
||||
return k.startWith(ctx, name, true)
|
||||
return k.startWith(ctx, name, v1alpha1.AnnotationStartRetry)
|
||||
}
|
||||
|
||||
func (k *K8sCluster) startWith(ctx context.Context, name string, retryFailed bool) error {
|
||||
func (k *K8sCluster) RestartServer(ctx context.Context, name string) error {
|
||||
return k.startWith(ctx, name, v1alpha1.AnnotationRestart)
|
||||
}
|
||||
|
||||
func (k *K8sCluster) startWith(ctx context.Context, name, annotation string) error {
|
||||
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||
var ms v1alpha1.MinecraftServer
|
||||
if err := k.getServer(ctx, name, &ms); err != nil {
|
||||
return err
|
||||
}
|
||||
if annotation == v1alpha1.AnnotationRestart && (ms.Spec.DesiredState != v1alpha1.DesiredRunning || ms.Status.Phase != v1alpha1.PhaseRunning) {
|
||||
return newError(http.StatusConflict, "not_running", "start the server before restarting it")
|
||||
}
|
||||
node := ms.Spec.NodeName
|
||||
if node == "" {
|
||||
node = ms.Status.NodeName
|
||||
@@ -308,11 +316,11 @@ func (k *K8sCluster) startWith(ctx context.Context, name string, retryFailed boo
|
||||
// A lock still on the object here no longer holds anything (Holder said
|
||||
// so): drop it in the same write.
|
||||
delete(ms.Annotations, maintenance.Annotation)
|
||||
if retryFailed {
|
||||
if annotation != "" {
|
||||
if ms.Annotations == nil {
|
||||
ms.Annotations = map[string]string{}
|
||||
}
|
||||
ms.Annotations[v1alpha1.AnnotationStartRetry] = k.clock().UTC().Format(time.RFC3339)
|
||||
ms.Annotations[annotation] = k.clock().UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
return k.c.Patch(ctx, &ms, patch)
|
||||
})
|
||||
@@ -320,8 +328,9 @@ func (k *K8sCluster) startWith(ctx context.Context, name string, retryFailed boo
|
||||
|
||||
// AcquireMaintenance admits one world-volume operation of the given kind: the
|
||||
// server must be fully stopped (desiredState Stopped, phase Stopped, and no game
|
||||
// pod left, so a pod still saving on its way down is waited out) and nothing else
|
||||
// may hold the volume. Admission writes the maintenance lock under the resourceVersion it
|
||||
// pod left, so a pod still saving on its way down is waited out). System config
|
||||
// writes may run beside the game pod, but not during a pending restart. Nothing
|
||||
// else may hold the volume. Admission writes the lock under the resourceVersion it
|
||||
// checked; the caller creates its Job and then calls ReleaseMaintenance, after
|
||||
// which the Job itself is the lock.
|
||||
func (k *K8sCluster) AcquireMaintenance(ctx context.Context, name, kind string) error {
|
||||
@@ -334,13 +343,18 @@ func (k *K8sCluster) AcquireMaintenance(ctx context.Context, name, kind string)
|
||||
if desired == "" {
|
||||
desired = v1alpha1.DesiredStopped
|
||||
}
|
||||
if desired != v1alpha1.DesiredStopped || ms.Status.Ready || ms.Status.Phase != v1alpha1.PhaseStopped {
|
||||
return ErrNotStopped
|
||||
if kind != maintenance.KindConfigWrite || !naming.IsSystemServer(name) {
|
||||
if desired != v1alpha1.DesiredStopped || ms.Status.Ready || ms.Status.Phase != v1alpha1.PhaseStopped {
|
||||
return ErrNotStopped
|
||||
}
|
||||
if up, err := k.gamePodExists(ctx, name); err != nil {
|
||||
return err
|
||||
} else if up {
|
||||
return ErrNotStopped
|
||||
}
|
||||
}
|
||||
if up, err := k.gamePodExists(ctx, name); err != nil {
|
||||
return err
|
||||
} else if up {
|
||||
return ErrNotStopped
|
||||
if ms.Annotations[v1alpha1.AnnotationRestart] != "" {
|
||||
return ErrMaintenanceInProgress
|
||||
}
|
||||
holder, held, err := k.maintenanceHolder(ctx, &ms)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,6 +35,54 @@ func stoppedServer() *v1alpha1.MinecraftServer {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemConfigSaveAndRestartAdmission(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
ms := stoppedServer()
|
||||
ms.Name = "lobby"
|
||||
ms.Spec.DesiredState = v1alpha1.DesiredRunning
|
||||
ms.Status.Phase, ms.Status.Ready = v1alpha1.PhaseRunning, true
|
||||
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "lobby-0", Namespace: "minecraft",
|
||||
Labels: map[string]string{v1alpha1.LabelServer: "lobby", v1alpha1.LabelComponent: gamePodComponent}}}
|
||||
k, c := lockCluster(t, ms, pod)
|
||||
if err := k.AcquireMaintenance(ctx, "lobby", maintenance.KindConfigWrite); err != nil {
|
||||
t.Fatalf("save beside running pod: %v", err)
|
||||
}
|
||||
if err := k.RestartServer(ctx, "lobby"); !errors.Is(err, ErrMaintenanceInProgress) {
|
||||
t.Fatalf("restart during save: %v", err)
|
||||
}
|
||||
if err := k.ReleaseMaintenance(ctx, "lobby"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := k.RestartServer(ctx, "lobby"); err != nil {
|
||||
t.Fatalf("restart after save: %v", err)
|
||||
}
|
||||
var got v1alpha1.MinecraftServer
|
||||
if err := c.Get(ctx, client.ObjectKeyFromObject(ms), &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Annotations[v1alpha1.AnnotationRestart] == "" || got.Spec.DesiredState != v1alpha1.DesiredRunning {
|
||||
t.Fatalf("restart not recorded durably: %+v", got)
|
||||
}
|
||||
if err := c.Get(ctx, client.ObjectKeyFromObject(pod), &corev1.Pod{}); err != nil {
|
||||
t.Fatalf("API must leave pod deletion to operator: %v", err)
|
||||
}
|
||||
if err := k.AcquireMaintenance(ctx, "lobby", maintenance.KindConfigWrite); !errors.Is(err, ErrMaintenanceInProgress) {
|
||||
t.Fatalf("save racing a pending restart: %v", err)
|
||||
}
|
||||
|
||||
userServer := stoppedServer()
|
||||
userServer.Spec.DesiredState = v1alpha1.DesiredRunning
|
||||
userServer.Status.Phase = v1alpha1.PhaseRunning
|
||||
k, _ = lockCluster(t, userServer)
|
||||
if err := k.AcquireMaintenance(ctx, "survival", maintenance.KindConfigWrite); !errors.Is(err, ErrNotStopped) {
|
||||
t.Fatalf("live config exception reached a player world: %v", err)
|
||||
}
|
||||
k, _ = lockCluster(t, stoppedServer())
|
||||
if err := k.RestartServer(ctx, "survival"); err == nil {
|
||||
t.Fatal("restart admitted a stopped server")
|
||||
}
|
||||
}
|
||||
|
||||
func lockCluster(t *testing.T, objs ...client.Object) (*K8sCluster, client.Client) {
|
||||
t.Helper()
|
||||
scheme := runtime.NewScheme()
|
||||
|
||||
@@ -37,6 +37,8 @@ func maintenanceLabel(kind string) string {
|
||||
return "a backup"
|
||||
case maintenance.KindFileWrite:
|
||||
return "a file write"
|
||||
case maintenance.KindConfigWrite:
|
||||
return "a settings save"
|
||||
case maintenance.KindExport:
|
||||
return "a world export or file download"
|
||||
case maintenance.KindReap:
|
||||
|
||||
@@ -39,6 +39,8 @@ const (
|
||||
// the automatic restarts were spent. The operator takes the request once —
|
||||
// fresh restart budget, new start anchor, pod recreated — and removes it.
|
||||
AnnotationStartRetry = GroupName + "/start-retry"
|
||||
// AnnotationRestart requests one pod recreation while keeping desiredState Running.
|
||||
AnnotationRestart = GroupName + "/restart"
|
||||
)
|
||||
|
||||
// ForwardingLegacy is the LabelForwarding value that selects legacy forwarding.
|
||||
|
||||
@@ -529,6 +529,9 @@ func write(r *os.Root, name string, content []byte, sum, expect string, createOn
|
||||
if res.Code != "" {
|
||||
return res
|
||||
}
|
||||
if name == naming.ExperienceConfigFile && target != name {
|
||||
return Result{Code: CodeBadPath, Error: "the experience config must be a regular file, not a symlink"}
|
||||
}
|
||||
if expect != "" {
|
||||
if res := checkUnchanged(r, name, target, expect); res.Code != "" {
|
||||
return res
|
||||
|
||||
@@ -45,6 +45,21 @@ func run(root, op, path string, content []byte, expect string) (Result, error) {
|
||||
return Execute(root, Request{Op: op, Path: path, Content: content, Expect: expect})
|
||||
}
|
||||
|
||||
func TestExperienceConfigCannotWriteThroughSymlink(t *testing.T) {
|
||||
root, _ := worldRoot(t)
|
||||
if err := os.Symlink("server.properties", filepath.Join(root, "felis-experience.json")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res, err := run(root, OpWrite, "felis-experience.json", []byte("{}"), "")
|
||||
if err != nil || res.Code != CodeBadPath {
|
||||
t.Fatalf("symlink write: result=%+v err=%v", res, err)
|
||||
}
|
||||
content, err := os.ReadFile(filepath.Join(root, "server.properties"))
|
||||
if err != nil || string(content) != "motd=hello\n" {
|
||||
t.Fatalf("live world file was changed: content=%q err=%v", content, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteContainment is the security test of this package. The world directory
|
||||
// holds attacker-influenced content (players and plugins create files in it), so
|
||||
// each vector below is a path a caller could genuinely supply to try to leave the
|
||||
|
||||
@@ -90,11 +90,12 @@ const (
|
||||
|
||||
// Kinds of holder.
|
||||
const (
|
||||
KindMigration = "migration"
|
||||
KindRestore = "restore"
|
||||
KindBackup = "backup"
|
||||
KindFileWrite = "file-write"
|
||||
KindExport = "export"
|
||||
KindMigration = "migration"
|
||||
KindRestore = "restore"
|
||||
KindBackup = "backup"
|
||||
KindFileWrite = "file-write"
|
||||
KindConfigWrite = "config-write"
|
||||
KindExport = "export"
|
||||
// KindReap is the reaper archiving an idle world and reclaiming its volume.
|
||||
// It runs no Job: the reaper holds the Annotation itself and rewrites it
|
||||
// well inside Grace for as long as it works on the world.
|
||||
|
||||
@@ -46,6 +46,8 @@ const (
|
||||
// reachable only after the login gate passes a player through, so it must
|
||||
// never be used as a fallback target (that would bypass the gate).
|
||||
SystemLobbyServer = "lobby"
|
||||
// ExperienceConfigFile is read by the system plugins only at startup.
|
||||
ExperienceConfigFile = "felis-experience.json"
|
||||
)
|
||||
|
||||
// IsSystemServer identifies platform services whose reserved names may be managed
|
||||
|
||||
@@ -218,9 +218,11 @@ func (r *Reconciler) reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
|
||||
desired = v1alpha1.DesiredStopped
|
||||
}
|
||||
prevPhase, prevRestarts := server.Status.Phase, server.Status.AutoRestarts
|
||||
if _, ok := server.Annotations[v1alpha1.AnnotationStartRetry]; ok {
|
||||
if err := r.takeStartRetry(ctx, &server, desired); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
for _, annotation := range []string{v1alpha1.AnnotationStartRetry, v1alpha1.AnnotationRestart} {
|
||||
if _, ok := server.Annotations[annotation]; ok {
|
||||
if err := r.takeRestartRequest(ctx, &server, desired, annotation); err != nil {
|
||||
return ctrl.Result{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
var res ctrl.Result
|
||||
@@ -263,30 +265,34 @@ func (r *Reconciler) recordTransition(ctx context.Context, server *v1alpha1.Mine
|
||||
r.event(server, eventType, reason, fmt.Sprintf("%s → %s: %s", from, phase, msg))
|
||||
}
|
||||
|
||||
// takeStartRetry answers felis-api's AnnotationStartRetry: a server still Failed
|
||||
// and meant to run starts over as if freshly woken — pod recreated, restart
|
||||
// budget back to zero, a new start anchor — and in any other state the request
|
||||
// is stale and only removed. The fresh status is written before the request is
|
||||
// takeRestartRequest recreates a Failed pod for a start retry, or a Running pod
|
||||
// for an explicit restart. A request overtaken by a stop is only removed.
|
||||
// The fresh status is written before the request is
|
||||
// removed, so a pass that fails in between leaves the request to be taken again
|
||||
// (at the cost of one more pod recreate), never a removed request with the old
|
||||
// spent budget still in place.
|
||||
func (r *Reconciler) takeStartRetry(ctx context.Context, server *v1alpha1.MinecraftServer, desired v1alpha1.DesiredState) error {
|
||||
if desired == v1alpha1.DesiredRunning && server.Status.Phase == v1alpha1.PhaseFailed {
|
||||
func (r *Reconciler) takeRestartRequest(ctx context.Context, server *v1alpha1.MinecraftServer, desired v1alpha1.DesiredState, annotation string) error {
|
||||
explicit := annotation == v1alpha1.AnnotationRestart
|
||||
if desired == v1alpha1.DesiredRunning && (server.Status.Phase == v1alpha1.PhaseFailed || (explicit && server.Status.Phase == v1alpha1.PhaseRunning)) {
|
||||
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: server.Name + "-0", Namespace: server.Namespace}}
|
||||
if err := r.Delete(ctx, pod); client.IgnoreNotFound(err) != nil {
|
||||
return err
|
||||
}
|
||||
server.Status.AutoRestarts = 0
|
||||
server.Status.StartRequestedAt = nil
|
||||
r.markStarting(server, "StartRetried", "start requested again after it failed; recreated the pod")
|
||||
reason, message := "StartRetried", "start requested again after it failed; recreated the pod"
|
||||
if explicit {
|
||||
reason, message = "RestartRequested", "restart requested; recreated the pod"
|
||||
}
|
||||
r.markStarting(server, reason, message)
|
||||
if err := r.patchStatus(ctx, server); err != nil {
|
||||
return err
|
||||
}
|
||||
log.FromContext(ctx).Info("start retried")
|
||||
r.event(server, corev1.EventTypeNormal, "StartRetried", "start requested again after it failed; recreated the pod")
|
||||
log.FromContext(ctx).Info(message)
|
||||
r.event(server, corev1.EventTypeNormal, reason, message)
|
||||
}
|
||||
patch := client.MergeFrom(server.DeepCopy())
|
||||
delete(server.Annotations, v1alpha1.AnnotationStartRetry)
|
||||
delete(server.Annotations, annotation)
|
||||
return r.Patch(ctx, server, patch)
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,38 @@ func podPresent(t *testing.T, c client.Client) bool {
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TestExplicitRestartIsConsumedOnce(t *testing.T) {
|
||||
srv := runningServer()
|
||||
srv.Status.Phase = v1alpha1.PhaseRunning
|
||||
srv.Annotations = map[string]string{v1alpha1.AnnotationRestart: "2026-07-01T12:00:00Z"}
|
||||
r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod())
|
||||
reconcile(t, r, "survival")
|
||||
s := getServer(t, c, "survival")
|
||||
if s.Annotations[v1alpha1.AnnotationRestart] != "" || s.Spec.DesiredState != v1alpha1.DesiredRunning || s.Status.Phase != v1alpha1.PhaseStarting || podPresent(t, c) {
|
||||
t.Fatalf("restart: desired=%s phase=%s request=%s pod=%v", s.Spec.DesiredState, s.Status.Phase, s.Annotations[v1alpha1.AnnotationRestart], podPresent(t, c))
|
||||
}
|
||||
if err := c.Create(context.Background(), gamePod()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconcile(t, r, "survival")
|
||||
if !podPresent(t, c) {
|
||||
t.Fatal("consumed request deleted the replacement pod")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopSupersedesExplicitRestart(t *testing.T) {
|
||||
srv := runningServer()
|
||||
srv.Spec.DesiredState = v1alpha1.DesiredStopped
|
||||
srv.Status.Phase = v1alpha1.PhaseRunning
|
||||
srv.Annotations = map[string]string{v1alpha1.AnnotationRestart: "2026-07-01T12:00:00Z"}
|
||||
r, c := newReconciler(t, fakeProber{}, srv, rconSecret(), gamePod())
|
||||
reconcile(t, r, "survival")
|
||||
s := getServer(t, c, "survival")
|
||||
if s.Annotations[v1alpha1.AnnotationRestart] != "" || s.Spec.DesiredState != v1alpha1.DesiredStopped || s.Status.Phase == v1alpha1.PhaseStarting {
|
||||
t.Fatalf("restart overrode stop: desired=%s phase=%s annotations=%v", s.Spec.DesiredState, s.Status.Phase, s.Annotations)
|
||||
}
|
||||
}
|
||||
|
||||
// Once the automatic restarts are spent, a retry request starts the server over
|
||||
// with the whole budget back: the pod is recreated, the start re-anchored, and
|
||||
// the next timeout is retried automatically again.
|
||||
|
||||
@@ -90,8 +90,8 @@ func ControlPlaneRBAC(p Params) RBAC {
|
||||
// claim. felis-api reads the fleet (velocity's pull, the fleet page, the wake
|
||||
// cap) from an informer cache of minecraftservers, hence watch on that one
|
||||
// resource; everything else goes through a DIRECT client, so it needs no
|
||||
// list/watch beyond the explicit List calls — and the PVC grant is get-only,
|
||||
// mirroring that.
|
||||
// list/watch beyond the explicit List calls. PVC list finds retained world
|
||||
// volumes before creating a server of the same name.
|
||||
//
|
||||
// The read-side grant is deliberately minimal: pods:list + pods/log:get, NOT
|
||||
// pods:get — the streamer lists pods by the server label then reads the chosen
|
||||
@@ -103,9 +103,7 @@ func APIMinecraftRole(p Params) *rbacv1.Role {
|
||||
rules := []rbacv1.PolicyRule{
|
||||
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "create", "patch"}),
|
||||
rule([]string{groupCore}, []string{"secrets"}, []string{"get"}),
|
||||
// get-only: WorldVolumeExists does a single direct Get of the world PVC;
|
||||
// nothing in felis-api lists or deletes PVCs.
|
||||
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get"}),
|
||||
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "list"}),
|
||||
// list backs GET /servers/{name}/jobs — the async status outlet reads the
|
||||
// backup/restore Jobs back by the server label — and finds the pending
|
||||
// restore chains; patch settles a chain by relabelling its safety-snapshot
|
||||
|
||||
@@ -127,13 +127,15 @@ func TestAPIRole_MinecraftPowersExact(t *testing.T) {
|
||||
if hasRule(mc, groupCore, "secrets", "list") || hasRule(mc, groupCore, "secrets", "watch") {
|
||||
t.Error("felis-api must NOT list or watch secrets (RCON reads are a direct Get by name)")
|
||||
}
|
||||
// WorldVolumeExists (backup/restore pre-gate) does a single direct PVC Get;
|
||||
// nothing in felis-api lists or deletes claims.
|
||||
// WorldVolumeExists reads a claim and lists retained claims on server creation.
|
||||
if !hasRule(mc, groupCore, "persistentvolumeclaims", "get") {
|
||||
t.Error("felis-api must get the world PVC (persistentvolumeclaims:get) for the backup/restore world-volume gate")
|
||||
}
|
||||
if hasRule(mc, groupCore, "persistentvolumeclaims", "list") || hasRule(mc, groupCore, "persistentvolumeclaims", "delete") {
|
||||
t.Error("felis-api must NOT list or delete PVCs (the gate is a single direct Get)")
|
||||
if !hasRule(mc, groupCore, "persistentvolumeclaims", "list") {
|
||||
t.Error("felis-api must list retained PVCs before creating a server")
|
||||
}
|
||||
if hasRule(mc, groupCore, "persistentvolumeclaims", "watch") || hasRule(mc, groupCore, "persistentvolumeclaims", "delete") {
|
||||
t.Error("felis-api must NOT watch or delete PVCs")
|
||||
}
|
||||
// Read-side console (spec §8 读=pods/log follow): list pods to find the
|
||||
// running pod, then read its log subresource — and nothing wider.
|
||||
|
||||
+10
-4
@@ -2267,7 +2267,7 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
|
||||
streamConsole(ctx.req, ctx.res, serverInfo);
|
||||
return true;
|
||||
}
|
||||
if (is("POST", ctx) && ctx.parts[4] === "wake") {
|
||||
if (is("POST", ctx) && (ctx.parts[4] === "wake" || ctx.parts[4] === "restart")) {
|
||||
if (!canManage(ctx.account, serverInfo)) {
|
||||
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
|
||||
return true;
|
||||
@@ -2276,8 +2276,12 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
|
||||
sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first");
|
||||
return true;
|
||||
}
|
||||
if (ctx.parts[4] === "restart" && serverInfo.phase !== "Running") {
|
||||
sendError(ctx.res, 409, "not_running", "start the server before restarting it");
|
||||
return true;
|
||||
}
|
||||
setPhase(serverInfo, "Starting");
|
||||
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" });
|
||||
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" });
|
||||
return true;
|
||||
}
|
||||
if (is("POST", ctx) && ctx.parts[4] === "stop") {
|
||||
@@ -2454,11 +2458,13 @@ async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Pro
|
||||
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
|
||||
return true;
|
||||
}
|
||||
if (serverInfo.phase !== "Stopped") {
|
||||
const url = new URL(ctx.req.url ?? "/", "http://localhost");
|
||||
const liveConfig = ["login", "lobby"].includes(serverInfo.name) &&
|
||||
["GET file", "PUT file"].includes(key) && url.searchParams.get("path") === "felis-experience.json";
|
||||
if (!liveConfig && serverInfo.phase !== "Stopped") {
|
||||
sendError(ctx.res, 409, "not_stopped", "stop the server before editing its files");
|
||||
return true;
|
||||
}
|
||||
const url = new URL(ctx.req.url ?? "/", "http://localhost");
|
||||
const tree = filesOf(ctx.state, serverInfo.name);
|
||||
const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files");
|
||||
if (p === null) return true;
|
||||
|
||||
@@ -1,5 +1,24 @@
|
||||
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||
|
||||
test("login is the default space; live lobby settings save before a confirmed restart", async ({ page, signIn }) => {
|
||||
await signIn("owner");
|
||||
await page.goto("/admin/lobby");
|
||||
await expect(page.getByRole("button", { name: t("lobby:login"), exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(page.getByLabel(t("lobby:bookTitle"))).toBeVisible();
|
||||
await page.getByRole("button", { name: t("lobby:lobby"), exact: true }).click();
|
||||
await page.getByLabel(t("lobby:menuTitleEn")).fill("Our Network");
|
||||
await expect(page.getByRole("button", { name: t("lobby:restart"), exact: true })).toBeDisabled();
|
||||
await page.getByRole("button", { name: t("lobby:save"), exact: true }).click();
|
||||
await expect(page.getByText(t("lobby:saved"), { exact: true })).toBeVisible();
|
||||
await expectFitsScreen(page);
|
||||
await page.getByRole("button", { name: t("lobby:restart"), exact: true }).click();
|
||||
const dialog = page.getByRole("dialog", { name: t("lobby:restart_title") });
|
||||
await expect(dialog).toBeVisible();
|
||||
await dialog.getByRole("button", { name: t("lobby:restart"), exact: true }).click();
|
||||
await expect(dialog).toHaveCount(0);
|
||||
await expect(page.getByText(t("lobby:restart_requested"), { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a signed-out visit signs in by email code and returns to the page it asked for", async ({ page }) => {
|
||||
await page.goto("/servers");
|
||||
await expect(page).toHaveURL(/\/login\?next=%2Fservers$/);
|
||||
|
||||
@@ -104,7 +104,7 @@ describe("WakeListSection", () => {
|
||||
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
|
||||
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||
"Couldn't load the wake list. The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
|
||||
"Couldn't load the wake list. The operation failed because of an internal server error. Ask an admin to check the logs.",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -121,7 +121,9 @@
|
||||
"auth_unavailable": "The sign-in service isn't available right now.",
|
||||
"setup_token_invalid": "That setup token is invalid or expired.",
|
||||
"network_error": "Can't reach Felis: the network is down, or your Cloudflare Access sign-in expired. Reload the page to sign in again.",
|
||||
"upstream_unavailable": "The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
|
||||
"upstream_unavailable": "The service is unavailable right now. Try again shortly.",
|
||||
"internal_error": "The operation failed because of an internal server error. Ask an admin to check the logs.",
|
||||
"request_id": "Request ID: {{id}}",
|
||||
"bad_path_param": "The name in this link is not valid. Open it again from the list.",
|
||||
"payload_too_large": "That is larger than the entry proxy accepts, so it was not sent.",
|
||||
"passkey_no_credential": "The browser returned no passkey. Try again.",
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
"stop_login": "Stop the login space to read and save settings. New players cannot join during maintenance; start it again when finished.",
|
||||
"stop_lobby": "Stop the lobby to read and save settings. Back up its map before maintenance, then start it again when finished.",
|
||||
"read_failed": "Could not read settings: {{reason}}. Check felis-experience.json in the file manager.",
|
||||
"saved": "Saved. Start this space to apply the settings.",
|
||||
"saved": "Saved. Settings apply on the next start or restart.",
|
||||
"restart": "Restart & apply",
|
||||
"restarting": "Restarting…",
|
||||
"restart_requested": "Restart requested. Follow the startup status above.",
|
||||
"restart_title": "Restart this space?",
|
||||
"restart_hint": "Players here will be disconnected briefly. Saved settings take effect when this space starts again.",
|
||||
"restart_required": "Settings saved; restart to apply",
|
||||
"save": "Save settings",
|
||||
"saving": "Saving…",
|
||||
"invalid_values": "Check numeric ranges and selected options. Text is limited to 512 characters.",
|
||||
@@ -88,5 +94,5 @@
|
||||
"loginBook_description": "Edit the guidance players see in the login book.",
|
||||
"content_tools": "Content & maintenance",
|
||||
"unsaved": "You have unsaved changes",
|
||||
"apply_on_start": "Saved settings apply when this space starts"
|
||||
"apply_on_start": "Save while running; settings take effect on the next start or restart"
|
||||
}
|
||||
@@ -121,7 +121,9 @@
|
||||
"auth_unavailable": "登录服务当前不可用。",
|
||||
"setup_token_invalid": "初始化令牌无效或已过期。",
|
||||
"network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。",
|
||||
"upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。",
|
||||
"upstream_unavailable": "服务暂时不可用,请稍后重试。",
|
||||
"internal_error": "操作失败,服务器出现内部错误。请联系管理员查看日志。",
|
||||
"request_id": "请求编号:{{id}}",
|
||||
"bad_path_param": "链接里的名称无效,请从列表重新打开。",
|
||||
"payload_too_large": "内容超过了入口允许的大小,没有发送成功。",
|
||||
"passkey_no_credential": "浏览器没有返回 Passkey,请重试。",
|
||||
|
||||
@@ -13,7 +13,13 @@
|
||||
"stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。",
|
||||
"stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。",
|
||||
"read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。",
|
||||
"saved": "已保存。启动该空间后生效。",
|
||||
"saved": "已保存,下次启动或重启后生效。",
|
||||
"restart": "重启并应用",
|
||||
"restarting": "正在重启…",
|
||||
"restart_requested": "已发送重启请求,可在上方查看启动状态。",
|
||||
"restart_title": "重启该空间?",
|
||||
"restart_hint": "重启会暂时断开这里的玩家,重新启动后应用已保存的设置。",
|
||||
"restart_required": "设置已保存,等待重启生效",
|
||||
"save": "保存设置",
|
||||
"saving": "正在保存…",
|
||||
"invalid_values": "请检查数字范围和选项,文案最多 512 个字符。",
|
||||
@@ -88,5 +94,5 @@
|
||||
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
|
||||
"content_tools": "内容与维护",
|
||||
"unsaved": "有未保存的更改",
|
||||
"apply_on_start": "保存的设置将在启动后生效"
|
||||
"apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效"
|
||||
}
|
||||
@@ -1102,9 +1102,21 @@ describe("responses that are not the API's JSON", () => {
|
||||
});
|
||||
|
||||
it("maps an unknown 5xx code to the unavailable line", () => {
|
||||
expect(humanizeError({ status: 500, code: "internal", message: "internal error" })).toMatch(/unavailable/i);
|
||||
expect(humanizeError({ status: 500, code: "unknown", message: "internal error" })).toMatch(/unavailable/i);
|
||||
expect(humanizeError({ status: 413, code: "error", message: "Payload Too Large" })).toMatch(/larger/i);
|
||||
});
|
||||
|
||||
it("keeps an internal error's request ID without exposing backend details", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async () => ({
|
||||
ok: false, status: 500,
|
||||
text: async () => JSON.stringify({ error: { code: "internal", message: "database credentials rejected", request_id: "req-123" } }),
|
||||
})));
|
||||
const err = await api.status("lobby").catch((e) => e);
|
||||
expect(err.request_id).toBe("req-123");
|
||||
expect(humanizeError(err)).toMatch(/internal server error/);
|
||||
expect(humanizeError(err)).toContain("req-123");
|
||||
expect(humanizeError(err)).not.toContain("credentials");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session and connection signals", () => {
|
||||
|
||||
@@ -56,7 +56,7 @@ import i18next from "i18next";
|
||||
// the upstream Zero-Trust / Access cookie rides along; the panel never holds a
|
||||
// service token, and the RCON password is never requested (spec §8).
|
||||
|
||||
function isApiError(x: unknown): x is { error: { code: string; message: string } } {
|
||||
function isApiError(x: unknown): x is { error: { code: string; message: string; request_id?: string } } {
|
||||
if (typeof x !== "object" || x === null || !("error" in x)) return false;
|
||||
const e = (x as { error: unknown }).error;
|
||||
return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string";
|
||||
@@ -193,7 +193,7 @@ function failed(path: string, status: number, statusText: string, text: string):
|
||||
/* no body, or not JSON: an ingress or tunnel answered */
|
||||
}
|
||||
const err: ApiError = isApiError(parsed)
|
||||
? { status, code: parsed.error.code, message: parsed.error.message }
|
||||
? { status, code: parsed.error.code, message: parsed.error.message, ...(typeof parsed.error.request_id === "string" && { request_id: parsed.error.request_id }) }
|
||||
: {
|
||||
status,
|
||||
code: status >= 500 ? "upstream_unavailable" : "error",
|
||||
@@ -447,6 +447,9 @@ export const api = rejectingSync({
|
||||
stop: (name: string) =>
|
||||
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`),
|
||||
|
||||
restart: (name: string) =>
|
||||
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/restart`),
|
||||
|
||||
claim: (name: string) =>
|
||||
request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`),
|
||||
|
||||
@@ -1567,6 +1570,8 @@ export function humanizeError(e: unknown): string {
|
||||
return err.message ? t("bad_request", { detail: err.message }) : t("generic");
|
||||
case "bad_schedule":
|
||||
return err.message ? t("bad_schedule", { detail: err.message }) : t("generic");
|
||||
case "internal":
|
||||
return t("internal_error") + (err.request_id ? " " + t("request_id", { id: err.request_id }) : "");
|
||||
default:
|
||||
if (err.status === 401) return t("session_expired");
|
||||
if (err.status === 403) return t("forbidden");
|
||||
|
||||
@@ -580,6 +580,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/restart": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Restart your own running server, or a system service as staff.
|
||||
* @description Records a durable request for the operator to gracefully recreate the game pod. Desired state remains Running. A concurrent stop supersedes the request; maintenance blocks admission.
|
||||
*/
|
||||
post: operations["restart"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/servers/{name}/claim": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -1495,13 +1515,13 @@ export interface paths {
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Read a file from a server's world volume (owner-or-admin; server must be stopped).
|
||||
* @description Returns one file's bytes, base64-encoded, from inside the server's world volume. Same stopped-gate and os.Root containment as the directory listing. Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read, because a config editor that silently returned half a file would let a subsequent save destroy the other half.
|
||||
* Read a file from a server's world volume (owner-or-admin).
|
||||
* @description Returns one file's bytes, base64-encoded, from inside the server's world volume. Same stopped-gate and os.Root containment as the directory listing, except staff may read login/lobby's felis-experience.json while running. Reads are capped at 1 MiB; a larger file is 413 rather than a truncated read, because a config editor that silently returned half a file would let a subsequent save destroy the other half.
|
||||
*/
|
||||
get: operations["readServerFile"];
|
||||
/**
|
||||
* Write a file in a server's world volume (owner-or-admin; server must be stopped).
|
||||
* @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Audited as file.write.
|
||||
* Write a file in a server's world volume (owner-or-admin).
|
||||
* @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Staff may save login/lobby's startup-only felis-experience.json while running; restart to apply. That config cannot be a symlink. Audited as file.write.
|
||||
*/
|
||||
put: operations["writeServerFile"];
|
||||
post?: never;
|
||||
@@ -4927,6 +4947,45 @@ export interface operations {
|
||||
404: components["responses"]["NotFound"];
|
||||
};
|
||||
};
|
||||
restart: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
name: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Restart accepted. */
|
||||
202: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": {
|
||||
name: string;
|
||||
/** @constant */
|
||||
desiredState: "Running";
|
||||
};
|
||||
};
|
||||
};
|
||||
400: components["responses"]["BadRequest"];
|
||||
401: components["responses"]["Unauthorized"];
|
||||
403: components["responses"]["Forbidden"];
|
||||
404: components["responses"]["NotFound"];
|
||||
/** @description Server is not running, is retiring, or maintenance is in progress. */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
claim: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -7286,7 +7345,7 @@ export interface operations {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description Server is not stopped (its world PVC is still mounted). */
|
||||
/** @description Server is not stopped (except startup-only system experience config). */
|
||||
409: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
|
||||
@@ -391,6 +391,7 @@ export interface ApiError {
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
request_id?: string;
|
||||
}
|
||||
|
||||
/** Identity mirrors GET /api/v1/me (app-tier — every authenticated principal may
|
||||
|
||||
@@ -156,7 +156,7 @@ describe("BuildScanPanel", () => {
|
||||
calls.getBuildScan.mockResolvedValueOnce(BLOCKED);
|
||||
render(<BuildScanPanel build={FAILED} />);
|
||||
expect((await screen.findByRole("alert")).textContent).toBe(
|
||||
"Couldn't load the security scan: The service is unavailable right now (it may be restarting or upgrading). Try again shortly.",
|
||||
"Couldn't load the security scan: The operation failed because of an internal server error. Ask an admin to check the logs.",
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Try again" }));
|
||||
expect(await screen.findByRole("region", { name: "Security scan" })).toBeTruthy();
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { LobbyPage } from "./LobbyPage";
|
||||
|
||||
const calls = vi.hoisted(() => ({
|
||||
status: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(),
|
||||
status: vi.fn(), restart: vi.fn(), listImages: vi.fn(), readServerFile: vi.fn(), writeServerFile: vi.fn(), createServerFile: vi.fn(), accessPermission: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
|
||||
vi.mock("@/lib/config", async (original) => ({ ...await original<typeof import("@/lib/config")>(), loadConfig: () => Promise.resolve({ apiBase: "/api/v1", rootDomain: "example.test", gameVersion: "26.3" }) }));
|
||||
@@ -18,10 +18,11 @@ beforeEach(() => {
|
||||
calls.readServerFile.mockResolvedValue({ content: btoa(JSON.stringify({ menuTitleEn: "Old title", customPlugin: { enabled: true } })), sha256: "read-hash" });
|
||||
calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" });
|
||||
calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" });
|
||||
calls.restart.mockResolvedValue({ name: "lobby", desiredState: "Running" });
|
||||
});
|
||||
|
||||
function page(space = "lobby") {
|
||||
render(<MemoryRouter initialEntries={[`/admin/lobby?space=${space}`]}><LobbyPage /></MemoryRouter>);
|
||||
render(<MemoryRouter initialEntries={[`/admin/lobby${space ? `?space=${space}` : ""}`]}><LobbyPage /></MemoryRouter>);
|
||||
}
|
||||
|
||||
describe("LobbyPage", () => {
|
||||
@@ -29,7 +30,7 @@ describe("LobbyPage", () => {
|
||||
page();
|
||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
await screen.findByText("Saved. Start this space to apply the settings.");
|
||||
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
||||
const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
|
||||
expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]);
|
||||
expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } });
|
||||
@@ -45,12 +46,37 @@ describe("LobbyPage", () => {
|
||||
expect(calls.writeServerFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a stop before reading or writing a running space", async () => {
|
||||
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true });
|
||||
it("opens the leftmost login space by default", async () => {
|
||||
page("");
|
||||
await screen.findByLabelText("Login book title");
|
||||
expect(calls.status).toHaveBeenCalledWith("login");
|
||||
expect(screen.getByRole("button", { name: "Login space" }).getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("saves while running and restarts only when requested", async () => {
|
||||
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 0 });
|
||||
page();
|
||||
await screen.findByText(/Stop the lobby to read and save settings/);
|
||||
expect(calls.readServerFile).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull();
|
||||
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
|
||||
expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save settings" }));
|
||||
await screen.findByText("Saved. Settings apply on the next start or restart.");
|
||||
expect(calls.restart).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restart & apply" }));
|
||||
await waitFor(() => expect(calls.restart).toHaveBeenCalledWith("lobby"));
|
||||
});
|
||||
|
||||
it("confirms a restart when players are online and keeps failures visible", async () => {
|
||||
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true, playersOnline: 2 });
|
||||
calls.restart.mockRejectedValue({ status: 409, code: "maintenance_in_progress", message: "busy" });
|
||||
page();
|
||||
await screen.findByLabelText("English menu title");
|
||||
await userEvent.click(screen.getByRole("button", { name: "Restart & apply" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "Restart this space?" });
|
||||
expect(calls.restart).not.toHaveBeenCalled();
|
||||
await userEvent.click(within(dialog).getByRole("button", { name: "Restart & apply" }));
|
||||
await waitFor(() => expect(calls.restart).toHaveBeenCalledWith("lobby"));
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByRole("dialog", { name: "Restart this space?" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the draft when another editor changed the file", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
|
||||
import { BookOpen, ChevronRight, DoorOpen, FolderOpen, Globe, Map, MessageSquare, Package, RotateCw, Save, Shield, Sun, Terminal, type LucideIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmDialog } from "@/components/ConfirmDialog";
|
||||
import { PageHeader } from "@/components/PageHeader";
|
||||
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge";
|
||||
import { PowerButton } from "@/components/PowerButton";
|
||||
import { PowerButton, SUBMITTED_HOLD_MS } from "@/components/PowerButton";
|
||||
import { CopyAddress } from "@/components/CopyAddress";
|
||||
import { EditServerDialog } from "@/components/EditServerDialog";
|
||||
import { ErrorState, Loading } from "@/components/States";
|
||||
@@ -17,6 +17,7 @@ import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||
import { api, humanizeError } from "@/lib/api";
|
||||
import { joinAddress } from "@/lib/config";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ServerStatus } from "@/lib/types";
|
||||
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks";
|
||||
import { experienceValid, LOGIN_GROUPS, LOBBY_GROUPS, readExperience, writeExperience, type Experience } from "@/lib/experience";
|
||||
|
||||
@@ -27,12 +28,22 @@ const GROUP_ICONS: Record<string, LucideIcon> = {
|
||||
loginBook: BookOpen,
|
||||
};
|
||||
|
||||
function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; stopped: boolean; onDirtyChange: (dirty: boolean) => void }) {
|
||||
function ExperienceSettings({ name, server, onDirtyChange, onChanged }: { name: string; server: ServerStatus; onDirtyChange: (dirty: boolean) => void; onChanged: () => void }) {
|
||||
const { t } = useTranslation("lobby");
|
||||
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS;
|
||||
const query = useAsync(() => stopped ? readExperience(name, groups) : Promise.resolve(null), [name, stopped]);
|
||||
const query = useAsync(() => readExperience(name, groups), [name]);
|
||||
const [draft, setDraft] = useState<{ values: Experience; original: string; sha256: string } | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [needsRestart, setNeedsRestart] = useState(false);
|
||||
const [restarting, setRestarting] = useState(false);
|
||||
const [confirmRestart, setConfirmRestart] = useState(false);
|
||||
const running = server.phase === "Running" && server.desiredState === "Running";
|
||||
useEffect(() => {
|
||||
if (!restarting) return;
|
||||
if (!running) { setRestarting(false); return; }
|
||||
const hold = window.setTimeout(() => setRestarting(false), SUBMITTED_HOLD_MS);
|
||||
return () => window.clearTimeout(hold);
|
||||
}, [running, restarting]);
|
||||
const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
|
||||
useEffect(() => {
|
||||
if (query.data && !draft) {
|
||||
@@ -44,12 +55,13 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
|
||||
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
|
||||
|
||||
async function save() {
|
||||
if (!draft || !stopped || saving || !experienceValid(draft.values, groups)) return;
|
||||
if (!draft || saving || !experienceValid(draft.values, groups)) return;
|
||||
setSaving(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const sha256 = await writeExperience(name, draft.values, draft.sha256);
|
||||
setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 });
|
||||
setNeedsRestart(true);
|
||||
setMessage({ kind: "success", text: t("saved") });
|
||||
} catch (error) {
|
||||
setMessage({ kind: "error", text: humanizeError(error) });
|
||||
@@ -58,10 +70,23 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
|
||||
}
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
setRestarting(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await api.restart(name);
|
||||
setNeedsRestart(false);
|
||||
setMessage({ kind: "success", text: t("restart_requested") });
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
setRestarting(false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{!stopped && <p className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4 text-sm">{t(name === "login" ? "stop_login" : "stop_lobby")}</p>}
|
||||
{stopped && query.loading && !draft && <Loading />}
|
||||
{query.loading && !draft && <Loading />}
|
||||
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
|
||||
{draft && groups.map((group) => {
|
||||
const Icon = GROUP_ICONS[group.key];
|
||||
@@ -86,13 +111,13 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
|
||||
<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>
|
||||
{field.choices ? (
|
||||
<select id={id} value={value as string} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} 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) => 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">
|
||||
{field.choices.map((choice) => <option key={choice} value={choice}>{t(choice)}</option>)}
|
||||
</select>
|
||||
) : field.multiline ? (
|
||||
<textarea id={id} value={value as string} maxLength={512} onChange={(e) => set(e.target.value)} disabled={!stopped || saving} 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} 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" />
|
||||
) : (
|
||||
<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={!stopped || saving} className="h-10 rounded-lg bg-background shadow-none" />
|
||||
<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" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -103,7 +128,7 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
|
||||
<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 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={!stopped || saving} className="peer sr-only" />
|
||||
<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" />
|
||||
<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>
|
||||
@@ -119,11 +144,18 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
|
||||
<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} />}
|
||||
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />}
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border bg-card p-3">
|
||||
<p className="text-xs text-muted-foreground">{t(dirty ? "unsaved" : "apply_on_start")}</p>
|
||||
<Button onClick={() => void save()} disabled={!stopped || !dirty || saving || !experienceValid(draft.values, groups)}><Save className="h-4 w-4" />{t(saving ? "saving" : "save")}</Button>
|
||||
<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>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" disabled={!running || dirty || saving || restarting} onClick={() => {
|
||||
if ((server.playersOnline ?? 0) > 0 || server.playerCountUnknown) setConfirmRestart(true);
|
||||
else void restart().catch((error) => setMessage({ kind: "error", text: humanizeError(error) }));
|
||||
}}><RotateCw className={cn(restarting && "animate-spin")} />{t(restarting ? "restarting" : "restart")}</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>
|
||||
</>}
|
||||
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -164,7 +196,7 @@ function BuilderAccess({ ready }: { ready: boolean }) {
|
||||
export function LobbyPage() {
|
||||
const { t } = useTranslation("lobby");
|
||||
const [params, setParams] = useSearchParams();
|
||||
const name = params.get("space") === "login" ? "login" : "lobby";
|
||||
const name = params.get("space") === "lobby" ? "lobby" : "login";
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [nextSpace, setNextSpace] = useState<string | null>(null);
|
||||
const cfg = useConfig();
|
||||
@@ -212,7 +244,7 @@ export function LobbyPage() {
|
||||
<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} onDirtyChange={setDirty} stopped={data.phase === "Stopped" && data.desiredState === "Stopped"} />
|
||||
<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>
|
||||
|
||||
@@ -533,7 +533,7 @@ function ServerActions({
|
||||
|
||||
if (server.system) {
|
||||
// Staff customize system services in the dedicated lobby view.
|
||||
if (isAdmin) return <Link className="text-sm text-primary hover:underline" to={`/admin/lobby?space=${server.name}`}>{ts("system_customize")}</Link>;
|
||||
if (isAdmin) return <Button asChild variant="outline" size="sm"><Link to={`/admin/lobby?space=${server.name}`}>{ts("system_customize")}</Link></Button>;
|
||||
return (
|
||||
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
||||
{ts("system_service")}
|
||||
|
||||
Reference in new issue
Block a user