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:
Lemon-miaow committed 2026-10-04 16:26:03 +08:00
1 parent e67896979e
commit cb3ed94026
34 files changed
+555 -103

No files matched your search

+49 -6
View File
@@ -66,7 +66,8 @@ info:
services through existing management routes, without player ownership rows. services through existing management routes, without player ownership rows.
Creating and claiming these reserved names remain prohibited. System patches Creating and claiming these reserved names remain prohibited. System patches
keep public autostart and idle stop disabled. Customization is persisted as 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 Control plane for the Felis Minecraft orchestration platform. The same binary
exposes an internal face (per-caller service tokens, for velocity / backend exposes an internal face (per-caller service tokens, for velocity / backend
@@ -2452,6 +2453,45 @@ paths:
'404': '404':
$ref: '#/components/responses/NotFound' $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: /api/v1/servers/{name}/claim:
post: post:
tags: [servers] tags: [servers]
@@ -4830,10 +4870,11 @@ paths:
get: get:
tags: [files] tags: [files]
operationId: readServerFile 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: >- description: >-
Returns one file's bytes, base64-encoded, from inside the server's world 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, 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 because a config editor that silently returned half a file would let a
subsequent save destroy the other half. subsequent save destroy the other half.
@@ -4886,7 +4927,7 @@ paths:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'409': '409':
description: Server is not stopped (its world PVC is still mounted). description: Server is not stopped (except startup-only system experience config).
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
@@ -4914,7 +4955,7 @@ paths:
put: put:
tags: [files] tags: [files]
operationId: writeServerFile 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: >- description: >-
Replaces a file's contents, creating the file if absent but never creating its 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) 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: 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 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 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-face: [external]
x-felis-tier: app x-felis-tier: app
security: [{ sessionCookie: [] }] security: [{ sessionCookie: [] }]
+1
View File
@@ -566,6 +566,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
// (handlers_retire.go); owner/admin-gated inside the handlers. // (handlers_retire.go); owner/admin-gated inside the handlers.
{Method: "PUT", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleRetire}, {Method: "PUT", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleRetire},
{Method: "DELETE", Pattern: "/api/v1/servers/{name}/retirement", h: a.handleCancelRetire}, {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}, {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 // 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 — // learn its own tier and decide which navigation surfaces to render. App-tier —
+30
View File
@@ -1935,6 +1935,36 @@ func (c *fakeCluster) RetryStart(ctx context.Context, n string) error {
c.retried = append(c.retried, n) c.retried = append(c.retried, n)
return nil 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 { func (c *fakeCluster) AcquireMaintenance(_ context.Context, n, kind string) error {
if err := c.maintErr[n]; err != nil { if err := c.maintErr[n]; err != nil {
return err return err
+3 -1
View File
@@ -137,8 +137,10 @@ type Cluster interface {
// also asks the operator to start it over with a fresh auto-restart budget // also asks the operator to start it over with a fresh auto-restart budget
// (v1alpha1.AnnotationStartRetry). Maintenance refuses it the same way. // (v1alpha1.AnnotationStartRetry). Maintenance refuses it the same way.
RetryStart(ctx context.Context, name string) error 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 // 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 // *MaintenanceBusyError while another operation holds the volume. The check
// and the lock are one atomic write against a concurrent wake. // and the lock are one atomic write against a concurrent wake.
AcquireMaintenance(ctx context.Context, name, kind string) error AcquireMaintenance(ctx context.Context, name, kind string) error
+19 -10
View File
@@ -15,6 +15,7 @@ import (
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance" "felis.lolicon.best/internal/maintenance"
"felis.lolicon.best/internal/naming"
) )
// FileEditor is the server-file-editor surface the API depends on: list a // 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 // 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 // show a file mid-change: the sha256 it returned then no longer matches, so a
// save built on it is refused with file_changed. // 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 { if !ok {
return return
} }
@@ -582,16 +587,12 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// ② ServerByName — an unknown server is 404 // ② ServerByName — an unknown server is 404
// ③ owner-or-admin, else 403. An unowned (released) server fails for everyone // ③ 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 // 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 // ④ stopped gate: world files must not change under a live game process.
// Job cannot mount it — refuse unless the server is fully stopped. Ready means // Only the system plugin's startup-only config may be read and saved live;
// it is up; any desiredState other than Stopped means it is up or coming up // its Job mounts the RWO volume on the same node as the game pod.
// and still owns the volume. This yields a specific 409 instead of a Job that
// silently fails to mount
// ⑤ the FileEditor must be wired, else 503 // ⑤ the FileEditor must be wired, else 503
// //
// Single-sourcing it is what keeps the handlers from drifting: a read path that // All file routes share this gate so the live-config exception stays narrow.
// forgot the stopped gate would not merely fail, it would hang waiting for a Pod
// that can never be scheduled.
// //
// It returns the validated server name and false if it has already written a // It returns the validated server name and false if it has already written a
// response. // response.
@@ -606,7 +607,7 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
a.writeLookupError(w, r, err) a.writeLookupError(w, r, err)
return "", false 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", writeError(w, r, newError(http.StatusConflict, "not_stopped",
"stop the server before working with its files")) "stop the server before working with its files"))
return "", false return "", false
@@ -636,6 +637,14 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
return name, true 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 // writeFileEditError maps executor errors onto HTTP status codes. The
// sentinels are caller-fault and get precise answers; a timeout is reported as 504 // 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 // so the caller knows to retry rather than believing the edit was rejected; and
+43
View File
@@ -163,6 +163,49 @@ func mkFiles(t *testing.T) (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
return api, repo, cl, files 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 // 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 // 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 // right beside a running server, and a write lands under a live world that the
+23
View File
@@ -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"}) 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 // 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). // server (spec §14: operating someone else's server is admin-tier).
func (a *API) handleStop(w http.ResponseWriter, r *http.Request) { func (a *API) handleStop(w http.ResponseWriter, r *http.Request) {
+27 -13
View File
@@ -3,6 +3,7 @@ package api
import ( import (
"context" "context"
"errors" "errors"
"net/http"
"strings" "strings"
"time" "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 // against, the same as AcquireMaintenance's: whichever of a racing wake and
// admission writes second gets a conflict, re-reads, and sees the other. // admission writes second gets a conflict, re-reads, and sees the other.
func (k *K8sCluster) start(ctx context.Context, name string) error { 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 // RetryStart starts a Failed server over: the same guarded write as start, plus
// v1alpha1.AnnotationStartRetry so the operator resets the restart budget and // v1alpha1.AnnotationStartRetry so the operator resets the restart budget and
// recreates the pod. // recreates the pod.
func (k *K8sCluster) RetryStart(ctx context.Context, name string) error { 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 { return retry.RetryOnConflict(retry.DefaultRetry, func() error {
var ms v1alpha1.MinecraftServer var ms v1alpha1.MinecraftServer
if err := k.getServer(ctx, name, &ms); err != nil { if err := k.getServer(ctx, name, &ms); err != nil {
return err 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 node := ms.Spec.NodeName
if node == "" { if node == "" {
node = ms.Status.NodeName 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 // A lock still on the object here no longer holds anything (Holder said
// so): drop it in the same write. // so): drop it in the same write.
delete(ms.Annotations, maintenance.Annotation) delete(ms.Annotations, maintenance.Annotation)
if retryFailed { if annotation != "" {
if ms.Annotations == nil { if ms.Annotations == nil {
ms.Annotations = map[string]string{} 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) 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 // AcquireMaintenance admits one world-volume operation of the given kind: the
// server must be fully stopped (desiredState Stopped, phase Stopped, and no game // 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 // pod left, so a pod still saving on its way down is waited out). System config
// may hold the volume. Admission writes the maintenance lock under the resourceVersion it // 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 // checked; the caller creates its Job and then calls ReleaseMaintenance, after
// which the Job itself is the lock. // which the Job itself is the lock.
func (k *K8sCluster) AcquireMaintenance(ctx context.Context, name, kind string) error { 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 == "" { if desired == "" {
desired = v1alpha1.DesiredStopped desired = v1alpha1.DesiredStopped
} }
if desired != v1alpha1.DesiredStopped || ms.Status.Ready || ms.Status.Phase != v1alpha1.PhaseStopped { if kind != maintenance.KindConfigWrite || !naming.IsSystemServer(name) {
return ErrNotStopped 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 { if ms.Annotations[v1alpha1.AnnotationRestart] != "" {
return err return ErrMaintenanceInProgress
} else if up {
return ErrNotStopped
} }
holder, held, err := k.maintenanceHolder(ctx, &ms) holder, held, err := k.maintenanceHolder(ctx, &ms)
if err != nil { 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) { func lockCluster(t *testing.T, objs ...client.Object) (*K8sCluster, client.Client) {
t.Helper() t.Helper()
scheme := runtime.NewScheme() scheme := runtime.NewScheme()
+2
View File
@@ -37,6 +37,8 @@ func maintenanceLabel(kind string) string {
return "a backup" return "a backup"
case maintenance.KindFileWrite: case maintenance.KindFileWrite:
return "a file write" return "a file write"
case maintenance.KindConfigWrite:
return "a settings save"
case maintenance.KindExport: case maintenance.KindExport:
return "a world export or file download" return "a world export or file download"
case maintenance.KindReap: case maintenance.KindReap:
@@ -39,6 +39,8 @@ const (
// the automatic restarts were spent. The operator takes the request once — // the automatic restarts were spent. The operator takes the request once —
// fresh restart budget, new start anchor, pod recreated — and removes it. // fresh restart budget, new start anchor, pod recreated — and removes it.
AnnotationStartRetry = GroupName + "/start-retry" 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. // ForwardingLegacy is the LabelForwarding value that selects legacy forwarding.
+3
View File
@@ -529,6 +529,9 @@ func write(r *os.Root, name string, content []byte, sum, expect string, createOn
if res.Code != "" { if res.Code != "" {
return res 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 expect != "" {
if res := checkUnchanged(r, name, target, expect); res.Code != "" { if res := checkUnchanged(r, name, target, expect); res.Code != "" {
return res return res
+15
View File
@@ -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}) 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 // TestExecuteContainment is the security test of this package. The world directory
// holds attacker-influenced content (players and plugins create files in it), so // 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 // each vector below is a path a caller could genuinely supply to try to leave the
+6 -5
View File
@@ -90,11 +90,12 @@ const (
// Kinds of holder. // Kinds of holder.
const ( const (
KindMigration = "migration" KindMigration = "migration"
KindRestore = "restore" KindRestore = "restore"
KindBackup = "backup" KindBackup = "backup"
KindFileWrite = "file-write" KindFileWrite = "file-write"
KindExport = "export" KindConfigWrite = "config-write"
KindExport = "export"
// KindReap is the reaper archiving an idle world and reclaiming its volume. // 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 // 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. // well inside Grace for as long as it works on the world.
+2
View File
@@ -46,6 +46,8 @@ const (
// reachable only after the login gate passes a player through, so it must // 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). // never be used as a fallback target (that would bypass the gate).
SystemLobbyServer = "lobby" 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 // IsSystemServer identifies platform services whose reserved names may be managed
+19 -13
View File
@@ -218,9 +218,11 @@ func (r *Reconciler) reconcile(ctx context.Context, req ctrl.Request) (ctrl.Resu
desired = v1alpha1.DesiredStopped desired = v1alpha1.DesiredStopped
} }
prevPhase, prevRestarts := server.Status.Phase, server.Status.AutoRestarts prevPhase, prevRestarts := server.Status.Phase, server.Status.AutoRestarts
if _, ok := server.Annotations[v1alpha1.AnnotationStartRetry]; ok { for _, annotation := range []string{v1alpha1.AnnotationStartRetry, v1alpha1.AnnotationRestart} {
if err := r.takeStartRetry(ctx, &server, desired); err != nil { if _, ok := server.Annotations[annotation]; ok {
return ctrl.Result{}, err if err := r.takeRestartRequest(ctx, &server, desired, annotation); err != nil {
return ctrl.Result{}, err
}
} }
} }
var res ctrl.Result 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)) r.event(server, eventType, reason, fmt.Sprintf("%s → %s: %s", from, phase, msg))
} }
// takeStartRetry answers felis-api's AnnotationStartRetry: a server still Failed // takeRestartRequest recreates a Failed pod for a start retry, or a Running pod
// and meant to run starts over as if freshly woken — pod recreated, restart // for an explicit restart. A request overtaken by a stop is only removed.
// budget back to zero, a new start anchor — and in any other state the request // The fresh status is written before the request is
// is stale and 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 // 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 // (at the cost of one more pod recreate), never a removed request with the old
// spent budget still in place. // spent budget still in place.
func (r *Reconciler) takeStartRetry(ctx context.Context, server *v1alpha1.MinecraftServer, desired v1alpha1.DesiredState) error { func (r *Reconciler) takeRestartRequest(ctx context.Context, server *v1alpha1.MinecraftServer, desired v1alpha1.DesiredState, annotation string) error {
if desired == v1alpha1.DesiredRunning && server.Status.Phase == v1alpha1.PhaseFailed { 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}} pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: server.Name + "-0", Namespace: server.Namespace}}
if err := r.Delete(ctx, pod); client.IgnoreNotFound(err) != nil { if err := r.Delete(ctx, pod); client.IgnoreNotFound(err) != nil {
return err return err
} }
server.Status.AutoRestarts = 0 server.Status.AutoRestarts = 0
server.Status.StartRequestedAt = nil 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 { if err := r.patchStatus(ctx, server); err != nil {
return err return err
} }
log.FromContext(ctx).Info("start retried") log.FromContext(ctx).Info(message)
r.event(server, corev1.EventTypeNormal, "StartRetried", "start requested again after it failed; recreated the pod") r.event(server, corev1.EventTypeNormal, reason, message)
} }
patch := client.MergeFrom(server.DeepCopy()) patch := client.MergeFrom(server.DeepCopy())
delete(server.Annotations, v1alpha1.AnnotationStartRetry) delete(server.Annotations, annotation)
return r.Patch(ctx, server, patch) return r.Patch(ctx, server, patch)
} }
+32
View File
@@ -37,6 +37,38 @@ func podPresent(t *testing.T, c client.Client) bool {
return err == nil 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 // 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 // with the whole budget back: the pod is recreated, the start re-anchored, and
// the next timeout is retried automatically again. // the next timeout is retried automatically again.
+3 -5
View File
@@ -90,8 +90,8 @@ func ControlPlaneRBAC(p Params) RBAC {
// claim. felis-api reads the fleet (velocity's pull, the fleet page, the wake // 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 // cap) from an informer cache of minecraftservers, hence watch on that one
// resource; everything else goes through a DIRECT client, so it needs no // 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, // list/watch beyond the explicit List calls. PVC list finds retained world
// mirroring that. // volumes before creating a server of the same name.
// //
// The read-side grant is deliberately minimal: pods:list + pods/log:get, NOT // 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 // 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{ rules := []rbacv1.PolicyRule{
rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "create", "patch"}), rule([]string{groupFelis}, []string{"minecraftservers"}, []string{"get", "list", "watch", "create", "patch"}),
rule([]string{groupCore}, []string{"secrets"}, []string{"get"}), rule([]string{groupCore}, []string{"secrets"}, []string{"get"}),
// get-only: WorldVolumeExists does a single direct Get of the world PVC; rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get", "list"}),
// nothing in felis-api lists or deletes PVCs.
rule([]string{groupCore}, []string{"persistentvolumeclaims"}, []string{"get"}),
// list backs GET /servers/{name}/jobs — the async status outlet reads the // list backs GET /servers/{name}/jobs — the async status outlet reads the
// backup/restore Jobs back by the server label — and finds the pending // backup/restore Jobs back by the server label — and finds the pending
// restore chains; patch settles a chain by relabelling its safety-snapshot // restore chains; patch settles a chain by relabelling its safety-snapshot
+6 -4
View File
@@ -127,13 +127,15 @@ func TestAPIRole_MinecraftPowersExact(t *testing.T) {
if hasRule(mc, groupCore, "secrets", "list") || hasRule(mc, groupCore, "secrets", "watch") { 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)") 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; // WorldVolumeExists reads a claim and lists retained claims on server creation.
// nothing in felis-api lists or deletes claims.
if !hasRule(mc, groupCore, "persistentvolumeclaims", "get") { if !hasRule(mc, groupCore, "persistentvolumeclaims", "get") {
t.Error("felis-api must get the world PVC (persistentvolumeclaims:get) for the backup/restore world-volume gate") 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") { if !hasRule(mc, groupCore, "persistentvolumeclaims", "list") {
t.Error("felis-api must NOT list or delete PVCs (the gate is a single direct Get)") 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 // Read-side console (spec §8 读=pods/log follow): list pods to find the
// running pod, then read its log subresource — and nothing wider. // running pod, then read its log subresource — and nothing wider.
+10 -4
View File
@@ -2267,7 +2267,7 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
streamConsole(ctx.req, ctx.res, serverInfo); streamConsole(ctx.req, ctx.res, serverInfo);
return true; 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)) { if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account"); sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true; 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"); sendError(ctx.res, 409, "server_retiring", "this server is being given up or deleted; cancel that first");
return true; 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"); setPhase(serverInfo, "Starting");
sendJSON(ctx.res, 200, { name: serverInfo.name, desiredState: "Running" }); sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" });
return true; return true;
} }
if (is("POST", ctx) && ctx.parts[4] === "stop") { 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"); sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true; 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"); sendError(ctx.res, 409, "not_stopped", "stop the server before editing its files");
return true; return true;
} }
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const tree = filesOf(ctx.state, serverInfo.name); const tree = filesOf(ctx.state, serverInfo.name);
const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files"); const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files");
if (p === null) return true; if (p === null) return true;
+19
View File
@@ -1,5 +1,24 @@
import { test, expect, t, expectFitsScreen } from "./fixtures"; 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 }) => { 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 page.goto("/servers");
await expect(page).toHaveURL(/\/login\?next=%2Fservers$/); await expect(page).toHaveURL(/\/login\?next=%2Fservers$/);
@@ -104,7 +104,7 @@ describe("WakeListSection", () => {
calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" }); calls.serverAllowlist.mockRejectedValue({ status: 500, code: "internal", message: "db down" });
render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />); render(<WakeListSection name="lobby" policy="allowlist" defaultOpen />);
expect((await screen.findByRole("alert")).textContent).toBe( 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.",
); );
}); });
+3 -1
View File
@@ -121,7 +121,9 @@
"auth_unavailable": "The sign-in service isn't available right now.", "auth_unavailable": "The sign-in service isn't available right now.",
"setup_token_invalid": "That setup token is invalid or expired.", "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.", "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.", "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.", "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.", "passkey_no_credential": "The browser returned no passkey. Try again.",
+8 -2
View File
@@ -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_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.", "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.", "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", "save": "Save settings",
"saving": "Saving…", "saving": "Saving…",
"invalid_values": "Check numeric ranges and selected options. Text is limited to 512 characters.", "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.", "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": "Saved settings apply when this space starts" "apply_on_start": "Save while running; settings take effect on the next start or restart"
} }
+3 -1
View File
@@ -121,7 +121,9 @@
"auth_unavailable": "登录服务当前不可用。", "auth_unavailable": "登录服务当前不可用。",
"setup_token_invalid": "初始化令牌无效或已过期。", "setup_token_invalid": "初始化令牌无效或已过期。",
"network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。", "network_error": "连不上 Felis:网络断开了,或者 Cloudflare Access 的登录已过期。刷新页面可以重新登录。",
"upstream_unavailable": "服务暂时不可用(可能正在重启或升级),请稍后重试。", "upstream_unavailable": "服务暂时不可用,请稍后重试。",
"internal_error": "操作失败,服务器出现内部错误。请联系管理员查看日志。",
"request_id": "请求编号:{{id}}",
"bad_path_param": "链接里的名称无效,请从列表重新打开。", "bad_path_param": "链接里的名称无效,请从列表重新打开。",
"payload_too_large": "内容超过了入口允许的大小,没有发送成功。", "payload_too_large": "内容超过了入口允许的大小,没有发送成功。",
"passkey_no_credential": "浏览器没有返回 Passkey,请重试。", "passkey_no_credential": "浏览器没有返回 Passkey,请重试。",
+8 -2
View File
@@ -13,7 +13,13 @@
"stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。", "stop_login": "停止登录空间后可读取和保存配置。维护期间新玩家无法进入;完成后请启动登录空间。",
"stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。", "stop_lobby": "停止大厅后可读取和保存配置。维护前可先备份地图,完成后请启动大厅。",
"read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。", "read_failed": "读取设置失败:{{reason}}。可在文件管理中检查 felis-experience.json。",
"saved": "已保存。启动该空间后生效。", "saved": "已保存,下次启动或重启后生效。",
"restart": "重启并应用",
"restarting": "正在重启…",
"restart_requested": "已发送重启请求,可在上方查看启动状态。",
"restart_title": "重启该空间?",
"restart_hint": "重启会暂时断开这里的玩家,重新启动后应用已保存的设置。",
"restart_required": "设置已保存,等待重启生效",
"save": "保存设置", "save": "保存设置",
"saving": "正在保存…", "saving": "正在保存…",
"invalid_values": "请检查数字范围和选项,文案最多 512 个字符。", "invalid_values": "请检查数字范围和选项,文案最多 512 个字符。",
@@ -88,5 +94,5 @@
"loginBook_description": "编辑玩家打开登录书时看到的引导内容。", "loginBook_description": "编辑玩家打开登录书时看到的引导内容。",
"content_tools": "内容与维护", "content_tools": "内容与维护",
"unsaved": "有未保存的更改", "unsaved": "有未保存的更改",
"apply_on_start": "保存的设置将在启动后生效" "apply_on_start": "可以在运行中保存;设置将在下次启动或重启后生效"
} }
+13 -1
View File
@@ -1102,9 +1102,21 @@ describe("responses that are not the API's JSON", () => {
}); });
it("maps an unknown 5xx code to the unavailable line", () => { 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); 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", () => { describe("session and connection signals", () => {
+7 -2
View File
@@ -56,7 +56,7 @@ import i18next from "i18next";
// the upstream Zero-Trust / Access cookie rides along; the panel never holds a // the upstream Zero-Trust / Access cookie rides along; the panel never holds a
// service token, and the RCON password is never requested (spec §8). // 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; if (typeof x !== "object" || x === null || !("error" in x)) return false;
const e = (x as { error: unknown }).error; const e = (x as { error: unknown }).error;
return typeof e === "object" && e !== null && typeof (e as { code?: unknown }).code === "string"; 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 */ /* no body, or not JSON: an ingress or tunnel answered */
} }
const err: ApiError = isApiError(parsed) 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, status,
code: status >= 500 ? "upstream_unavailable" : "error", code: status >= 500 ? "upstream_unavailable" : "error",
@@ -447,6 +447,9 @@ export const api = rejectingSync({
stop: (name: string) => stop: (name: string) =>
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/stop`), 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) => claim: (name: string) =>
request<{ name: string; claimed: boolean }>("POST", urlPath`/servers/${name}/claim`), 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"); return err.message ? t("bad_request", { detail: err.message }) : t("generic");
case "bad_schedule": case "bad_schedule":
return err.message ? t("bad_schedule", { detail: err.message }) : t("generic"); 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: default:
if (err.status === 401) return t("session_expired"); if (err.status === 401) return t("session_expired");
if (err.status === 403) return t("forbidden"); if (err.status === 403) return t("forbidden");
+64 -5
View File
@@ -580,6 +580,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/api/v1/servers/{name}/claim": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1495,13 +1515,13 @@ export interface paths {
cookie?: never; cookie?: never;
}; };
/** /**
* Read a file from a server's world volume (owner-or-admin; server must be stopped). * 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. 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. * @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"]; get: operations["readServerFile"];
/** /**
* Write a file in a server's world volume (owner-or-admin; server must be stopped). * 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. Audited as file.write. * @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"]; put: operations["writeServerFile"];
post?: never; post?: never;
@@ -4927,6 +4947,45 @@ export interface operations {
404: components["responses"]["NotFound"]; 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: { claim: {
parameters: { parameters: {
query?: never; query?: never;
@@ -7286,7 +7345,7 @@ export interface operations {
"application/json": components["schemas"]["Error"]; "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: { 409: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
+1
View File
@@ -391,6 +391,7 @@ export interface ApiError {
status: number; status: number;
code: string; code: string;
message: string; message: string;
request_id?: string;
} }
/** Identity mirrors GET /api/v1/me (app-tier — every authenticated principal may /** Identity mirrors GET /api/v1/me (app-tier — every authenticated principal may
@@ -156,7 +156,7 @@ describe("BuildScanPanel", () => {
calls.getBuildScan.mockResolvedValueOnce(BLOCKED); calls.getBuildScan.mockResolvedValueOnce(BLOCKED);
render(<BuildScanPanel build={FAILED} />); render(<BuildScanPanel build={FAILED} />);
expect((await screen.findByRole("alert")).textContent).toBe( 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" })); await userEvent.click(screen.getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("region", { name: "Security scan" })).toBeTruthy(); expect(await screen.findByRole("region", { name: "Security scan" })).toBeTruthy();
+35 -9
View File
@@ -1,12 +1,12 @@
// @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 } from "@testing-library/react"; import { 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";
const calls = vi.hoisted(() => ({ 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/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" }) })); 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.readServerFile.mockResolvedValue({ content: btoa(JSON.stringify({ menuTitleEn: "Old title", customPlugin: { enabled: true } })), sha256: "read-hash" });
calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" }); calls.writeServerFile.mockResolvedValue({ sha256: "saved-hash" });
calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" }); calls.createServerFile.mockResolvedValue({ sha256: "saved-hash" });
calls.restart.mockResolvedValue({ name: "lobby", desiredState: "Running" });
}); });
function page(space = "lobby") { 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", () => { describe("LobbyPage", () => {
@@ -29,7 +30,7 @@ describe("LobbyPage", () => {
page(); page();
fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } }); fireEvent.change(await screen.findByLabelText("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. 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]; const [name, path, content, hash] = calls.writeServerFile.mock.calls[0];
expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]); expect([name, path, hash]).toEqual(["lobby", "felis-experience.json", "read-hash"]);
expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } }); expect(JSON.parse(atob(content))).toEqual({ menuTitleEn: "My Network", customPlugin: { enabled: true } });
@@ -45,12 +46,37 @@ describe("LobbyPage", () => {
expect(calls.writeServerFile).not.toHaveBeenCalled(); expect(calls.writeServerFile).not.toHaveBeenCalled();
}); });
it("requires a stop before reading or writing a running space", async () => { it("opens the leftmost login space by default", async () => {
calls.status.mockResolvedValue({ name: "lobby", phase: "Running", desiredState: "Running", ready: true }); 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(); page();
await screen.findByText(/Stop the lobby to read and save settings/); fireEvent.change(await screen.findByLabelText("English menu title"), { target: { value: "My Network" } });
expect(calls.readServerFile).not.toHaveBeenCalled(); expect((screen.getByRole("button", { name: "Restart & apply" }) as HTMLButtonElement).disabled).toBe(true);
expect(screen.queryByRole("button", { name: "Save settings" })).toBeNull(); 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 () => { it("keeps the draft when another editor changed the file", async () => {
+48 -16
View File
@@ -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, 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 { useTranslation } from "react-i18next";
import { ConfirmDialog } from "@/components/ConfirmDialog"; import { ConfirmDialog } from "@/components/ConfirmDialog";
import { PageHeader } from "@/components/PageHeader"; import { PageHeader } from "@/components/PageHeader";
@@ -9,7 +9,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { PhaseBadge, shownPhase, startFailure } from "@/components/PhaseBadge"; 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 { CopyAddress } from "@/components/CopyAddress";
import { EditServerDialog } from "@/components/EditServerDialog"; import { EditServerDialog } from "@/components/EditServerDialog";
import { ErrorState, Loading } from "@/components/States"; import { ErrorState, Loading } from "@/components/States";
@@ -17,6 +17,7 @@ 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";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ServerStatus } from "@/lib/types";
import { STATUS_POLL_FAST_MS, useAsync, useConfig, usePolling, useUnsavedGuard } from "@/lib/hooks"; 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"; 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, 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 { t } = useTranslation("lobby");
const groups = name === "login" ? LOGIN_GROUPS : LOBBY_GROUPS; 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 [draft, setDraft] = useState<{ values: Experience; original: string; sha256: string } | null>(null);
const [saving, setSaving] = useState(false); 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); const [message, setMessage] = useState<{ kind: "error" | "success"; text: string } | null>(null);
useEffect(() => { useEffect(() => {
if (query.data && !draft) { if (query.data && !draft) {
@@ -44,12 +55,13 @@ function ExperienceSettings({ name, stopped, onDirtyChange }: { name: string; st
useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]); useEffect(() => onDirtyChange(dirty), [dirty, onDirtyChange]);
async function save() { async function save() {
if (!draft || !stopped || saving || !experienceValid(draft.values, groups)) return; if (!draft || saving || !experienceValid(draft.values, groups)) return;
setSaving(true); setSaving(true);
setMessage(null); setMessage(null);
try { try {
const sha256 = await writeExperience(name, draft.values, draft.sha256); const sha256 = await writeExperience(name, draft.values, draft.sha256);
setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 }); setDraft({ ...draft, original: JSON.stringify(draft.values), sha256 });
setNeedsRestart(true);
setMessage({ kind: "success", text: t("saved") }); setMessage({ kind: "success", text: t("saved") });
} catch (error) { } catch (error) {
setMessage({ kind: "error", text: humanizeError(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 ( return (
<div className="space-y-4"> <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>} {query.loading && !draft && <Loading />}
{stopped && query.loading && !draft && <Loading />}
{query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />} {query.error != null && <ErrorState error={t("read_failed", { reason: humanizeError(query.error) })} onRetry={query.reload} />}
{draft && groups.map((group) => { {draft && groups.map((group) => {
const Icon = GROUP_ICONS[group.key]; 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"> <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={!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>)} {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={!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> </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"> <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={!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 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>
@@ -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> <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} />}
{!experienceValid(draft.values, groups) && <InlineError message={t("invalid_values")} />} {!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"> <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="text-xs text-muted-foreground">{t(dirty ? "unsaved" : "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>
<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="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> </div>
</>} </>}
<ConfirmDialog open={confirmRestart} onOpenChange={setConfirmRestart} title={t("restart_title")} description={t("restart_hint")} confirmLabel={t("restart")} onConfirm={restart} />
</div> </div>
); );
} }
@@ -164,7 +196,7 @@ function BuilderAccess({ ready }: { ready: boolean }) {
export function LobbyPage() { export function LobbyPage() {
const { t } = useTranslation("lobby"); const { t } = useTranslation("lobby");
const [params, setParams] = useSearchParams(); 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 [dirty, setDirty] = useState(false);
const [nextSpace, setNextSpace] = useState<string | null>(null); const [nextSpace, setNextSpace] = useState<string | null>(null);
const cfg = useConfig(); 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} /> <PowerButton name={name} phase={data.phase} desiredState={data.desiredState} failed={startFailure(data) !== null} playersOnline={data.playersOnline} playerCountUnknown={data.playerCountUnknown} onChanged={status.reload} />
</div> </div>
<div className="grid items-start gap-5 lg:grid-cols-[minmax(0,2fr)_minmax(0,1fr)]"> <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"> <div className="space-y-4">
<Card className="overflow-hidden rounded-xl shadow-none"> <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> <CardHeader className="border-b border-border/60 bg-muted/20 py-4"><CardTitle className="text-sm">{t("content_tools")}</CardTitle></CardHeader>
+1 -1
View File
@@ -533,7 +533,7 @@ function ServerActions({
if (server.system) { if (server.system) {
// Staff customize system services in the dedicated lobby view. // 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 ( return (
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}> <span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
{ts("system_service")} {ts("system_service")}