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.
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: [] }]
+1
View File
@@ -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 —
+30
View File
@@ -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
+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
// (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
+19 -10
View File
@@ -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
+43
View File
@@ -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
+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"})
}
// 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
View File
@@ -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()
+2
View File
@@ -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.
+3
View File
@@ -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
+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})
}
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
+6 -5
View File
@@ -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.
+2
View File
@@ -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
+19 -13
View File
@@ -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)
}
+32
View File
@@ -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.
+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
// 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
+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") {
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
View File
@@ -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;
+19
View File
@@ -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.",
);
});
+3 -1
View File
@@ -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.",
+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_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"
}
+3 -1
View File
@@ -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,请重试。",
+8 -2
View File
@@ -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": "可以在运行中保存;设置将在下次启动或重启后生效"
}
+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", () => {
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", () => {
+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
// 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");
+64 -5
View File
@@ -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;
+1
View File
@@ -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();
+35 -9
View File
@@ -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 () => {
+48 -16
View File
@@ -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>
+1 -1
View File
@@ -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")}