feat(servers): 新建服务器默认空闲 10 分钟自动停服,面板可调,converge 回填旧服,系统服不休眠
This commit is contained in:
23 files changed
+453
-18
No files matched your search
@@ -243,6 +243,19 @@ func buildMinecraftServerFromApplyRequest(req applyRequest, namespace string) (*
|
|||||||
AutostartPolicy: policy,
|
AutostartPolicy: policy,
|
||||||
Storage: v1alpha1.StorageSpec{Size: storageQ.String()},
|
Storage: v1alpha1.StorageSpec{Size: storageQ.String()},
|
||||||
Resources: corev1.ResourceRequirements{Limits: limits, Requests: requests},
|
Resources: corev1.ResourceRequirements{Limits: limits, Requests: requests},
|
||||||
|
// The rest matches what felis-api's create writes (K8sCluster.CreateServer):
|
||||||
|
// fall back to the login gate while stopped, RCON on (readiness, the
|
||||||
|
// player count and the console all ride it; the operator mints the
|
||||||
|
// password), and the default idle stop.
|
||||||
|
FallbackServer: naming.SystemLoginServer,
|
||||||
|
Rcon: v1alpha1.RconSpec{
|
||||||
|
Enabled: true,
|
||||||
|
SecretRef: v1alpha1.SecretKeyRef{
|
||||||
|
Name: naming.RconSecretName(req.Name),
|
||||||
|
Key: naming.RconSecretKey,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Idle: v1alpha1.DefaultIdle(),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
"felis.lolicon.best/internal/naming"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
"k8s.io/apimachinery/pkg/api/resource"
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
)
|
)
|
||||||
@@ -167,6 +168,18 @@ func TestBuildMinecraftServerFromApplyRequest_Valid(t *testing.T) {
|
|||||||
if ms.Spec.Storage.Size != "20Gi" {
|
if ms.Spec.Storage.Size != "20Gi" {
|
||||||
t.Errorf("Storage.Size = %q, want 20Gi", ms.Spec.Storage.Size)
|
t.Errorf("Storage.Size = %q, want 20Gi", ms.Spec.Storage.Size)
|
||||||
}
|
}
|
||||||
|
// Same operational defaults as the API create path: without RCON the server
|
||||||
|
// never reports players and the console answers 503; without spec.idle it
|
||||||
|
// never stops on its own.
|
||||||
|
if !ms.Spec.Rcon.Enabled || ms.Spec.Rcon.SecretRef.Name != naming.RconSecretName("test-server") {
|
||||||
|
t.Errorf("Rcon = %+v, want enabled with the operator-minted secret", ms.Spec.Rcon)
|
||||||
|
}
|
||||||
|
if ms.Spec.Idle != v1alpha1.DefaultIdle() {
|
||||||
|
t.Errorf("Idle = %+v, want the default %+v", ms.Spec.Idle, v1alpha1.DefaultIdle())
|
||||||
|
}
|
||||||
|
if ms.Spec.FallbackServer != naming.SystemLoginServer {
|
||||||
|
t.Errorf("FallbackServer = %q, want the login gate", ms.Spec.FallbackServer)
|
||||||
|
}
|
||||||
mem, ok := ms.Spec.Resources.Limits[corev1.ResourceMemory]
|
mem, ok := ms.Spec.Resources.Limits[corev1.ResourceMemory]
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("memory limit missing")
|
t.Fatal("memory limit missing")
|
||||||
|
|||||||
+34
-2
@@ -9,12 +9,14 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/config"
|
"felis.lolicon.best/internal/config"
|
||||||
"felis.lolicon.best/internal/platform"
|
"felis.lolicon.best/internal/platform"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
// cmdConverge is the explicit convergence pass over already-installed system
|
// cmdConverge is the explicit convergence pass over already-installed system
|
||||||
// servers (#1). Provisioning is create-if-absent, so a field the desired spec
|
// servers (#1), plus the idle-stop default for user servers that predate it. Provisioning is create-if-absent, so a field the desired spec
|
||||||
// gained after an install (spec.rcon, spec.startup.healthHTTPPort, a derived env
|
// gained after an install (spec.rcon, spec.startup.healthHTTPPort, a derived env
|
||||||
// key) never reaches the existing CR — and nothing says so. This command fills
|
// key) never reaches the existing CR — and nothing says so. This command fills
|
||||||
// exactly those zero-value fields; see convergeSystemServers for the full contract
|
// exactly those zero-value fields; see convergeSystemServers for the full contract
|
||||||
@@ -56,7 +58,9 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int {
|
|||||||
platform.InternalAPIBaseURL(controlNS), cfg.Server.RootDomain,
|
platform.InternalAPIBaseURL(controlNS), cfg.Server.RootDomain,
|
||||||
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname))
|
defaultPanelHostname(cfg.Server.RootDomain, cfg.Auth.PanelHostname))
|
||||||
|
|
||||||
fmt.Fprintln(stdout, "felis converge: filling fields an installed system server predates (operator-set values are never overwritten):")
|
outcomes = append(outcomes, convergeUserServerIdle(context.Background(), cl, cfg.K8s.Namespace)...)
|
||||||
|
|
||||||
|
fmt.Fprintln(stdout, "felis converge: filling fields an installed server predates (operator-set values are never overwritten):")
|
||||||
exit := 0
|
exit := 0
|
||||||
for _, o := range outcomes {
|
for _, o := range outcomes {
|
||||||
switch {
|
switch {
|
||||||
@@ -71,3 +75,31 @@ func cmdConverge(args []string, stdout, stderr io.Writer) int {
|
|||||||
}
|
}
|
||||||
return exit
|
return exit
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convergeUserServerIdle gives every user server that predates the idle default
|
||||||
|
// (spec.idle entirely unset) the default idle stop. A server whose idle stop was
|
||||||
|
// turned off keeps a duration on its spec, so it is not "unset" and is left
|
||||||
|
// alone; system servers never idle out and are skipped. Servers that already
|
||||||
|
// carry a value produce no line, so a converged fleet prints nothing here.
|
||||||
|
func convergeUserServerIdle(ctx context.Context, cl client.Client, namespace string) []systemServerOutcome {
|
||||||
|
var list v1alpha1.MinecraftServerList
|
||||||
|
if err := cl.List(ctx, &list, client.InNamespace(namespace)); err != nil {
|
||||||
|
return []systemServerOutcome{{name: "user servers", err: fmt.Errorf("list servers: %w", err)}}
|
||||||
|
}
|
||||||
|
var out []systemServerOutcome
|
||||||
|
for i := range list.Items {
|
||||||
|
ms := &list.Items[i]
|
||||||
|
if ms.Labels[v1alpha1.LabelSystemRole] != "" || ms.Spec.Idle != (v1alpha1.IdleSpec{}) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
patch := client.MergeFrom(ms.DeepCopy())
|
||||||
|
ms.Spec.Idle = v1alpha1.DefaultIdle()
|
||||||
|
if err := cl.Patch(ctx, ms, patch); err != nil {
|
||||||
|
out = append(out, systemServerOutcome{name: ms.Name, err: fmt.Errorf("converge %s: %w", ms.Name, err)})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, systemServerOutcome{name: ms.Name, available: true, updated: true,
|
||||||
|
changes: []string{fmt.Sprintf("spec.idle (stop after %ds empty)", v1alpha1.DefaultEmptySecondsBeforeStop)}})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -183,3 +183,49 @@ func TestConvergeSystemServersGuards(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestConvergeUserServerIdle fills the idle default only where spec.idle was
|
||||||
|
// never set: a server whose idle stop was turned off (duration kept), one with
|
||||||
|
// its own duration, and a system server all stay as they are.
|
||||||
|
func TestConvergeUserServerIdle(t *testing.T) {
|
||||||
|
scheme := newSystemServerScheme(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
mk := func(name string, idle v1alpha1.IdleSpec, role string) *v1alpha1.MinecraftServer {
|
||||||
|
ms := &v1alpha1.MinecraftServer{}
|
||||||
|
ms.Name, ms.Namespace = name, "minecraft"
|
||||||
|
ms.Spec.Idle = idle
|
||||||
|
if role != "" {
|
||||||
|
ms.Labels = map[string]string{v1alpha1.LabelSystemRole: role}
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
cl := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
|
||||||
|
mk("legacy", v1alpha1.IdleSpec{}, ""),
|
||||||
|
mk("off", v1alpha1.IdleSpec{EmptySecondsBeforeStop: 600}, ""),
|
||||||
|
mk("custom", v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 1800}, ""),
|
||||||
|
mk(naming.SystemLobbyServer, v1alpha1.IdleSpec{}, naming.SystemLobbyServer),
|
||||||
|
).Build()
|
||||||
|
|
||||||
|
outcomes := convergeUserServerIdle(ctx, cl, "minecraft")
|
||||||
|
if len(outcomes) != 1 || outcomes[0].name != "legacy" || outcomes[0].err != nil {
|
||||||
|
t.Fatalf("outcomes = %+v, want exactly one fill for legacy", outcomes)
|
||||||
|
}
|
||||||
|
want := map[string]v1alpha1.IdleSpec{
|
||||||
|
"legacy": v1alpha1.DefaultIdle(),
|
||||||
|
"off": {EmptySecondsBeforeStop: 600},
|
||||||
|
"custom": {AutoStopEnabled: true, EmptySecondsBeforeStop: 1800},
|
||||||
|
naming.SystemLobbyServer: {},
|
||||||
|
}
|
||||||
|
for name, idle := range want {
|
||||||
|
var ms v1alpha1.MinecraftServer
|
||||||
|
if err := cl.Get(ctx, client.ObjectKey{Namespace: "minecraft", Name: name}, &ms); err != nil {
|
||||||
|
t.Fatalf("get %s: %v", name, err)
|
||||||
|
}
|
||||||
|
if ms.Spec.Idle != idle {
|
||||||
|
t.Errorf("%s idle = %+v, want %+v", name, ms.Spec.Idle, idle)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if again := convergeUserServerIdle(ctx, cl, "minecraft"); len(again) != 0 {
|
||||||
|
t.Fatalf("second pass = %+v, want nothing to do", again)
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
-4
@@ -298,6 +298,18 @@ components:
|
|||||||
endpointAddress: { type: string }
|
endpointAddress: { type: string }
|
||||||
playersOnline: { type: integer, format: int32 }
|
playersOnline: { type: integer, format: int32 }
|
||||||
playersMax: { type: integer, format: int32 }
|
playersMax: { type: integer, format: int32 }
|
||||||
|
displayName: { type: string }
|
||||||
|
image: { type: string }
|
||||||
|
javaMemory: { type: string }
|
||||||
|
storageSize: { type: string }
|
||||||
|
cpu: { type: string }
|
||||||
|
idleStopSeconds:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: Seconds the server may sit empty before idle auto-stop scales it down; 0 when it never idles out (off, RCON disabled, or a system server).
|
||||||
|
playerCountUnknown:
|
||||||
|
type: boolean
|
||||||
|
description: Present and true while the operator cannot read the player count over RCON; idle auto-stop waits until it can.
|
||||||
|
|
||||||
MyServerView:
|
MyServerView:
|
||||||
type: object
|
type: object
|
||||||
@@ -4550,8 +4562,8 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
description: Only the supplied fields are patched; an empty patch is rejected.
|
description: Only the supplied fields are patched; an empty patch is rejected.
|
||||||
properties:
|
properties:
|
||||||
display_name: { type: string }
|
displayName: { type: string }
|
||||||
autostart_policy: { type: string }
|
autostartPolicy: { type: string }
|
||||||
image: { type: string }
|
image: { type: string }
|
||||||
memory: { type: string }
|
memory: { type: string }
|
||||||
storage:
|
storage:
|
||||||
@@ -4561,9 +4573,13 @@ paths:
|
|||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
cpu: { type: string }
|
cpu: { type: string }
|
||||||
cpu_request: { type: string }
|
cpuRequest: { type: string }
|
||||||
memory: { type: string }
|
memory: { type: string }
|
||||||
memory_request: { type: string }
|
memoryRequest: { type: string }
|
||||||
|
idleStopSeconds:
|
||||||
|
type: integer
|
||||||
|
format: int32
|
||||||
|
description: Idle auto-stop. 0 turns it off; otherwise the server stops after this many seconds with nobody online (60–86400, else 400 bad_idle_stop).
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: Patched.
|
description: Patched.
|
||||||
|
|||||||
+19
-2
@@ -709,8 +709,19 @@ resolver cannot fix:
|
|||||||
|
|
||||||
## 11. Idle auto-stop never fires; player count always shows 0
|
## 11. Idle auto-stop never fires; player count always shows 0
|
||||||
|
|
||||||
Both are implemented, and both hang off the same switch: **`spec.rcon.enabled`**.
|
Every server created from the panel or `felis apply` stops itself after 600 s
|
||||||
Check it first.
|
with nobody online (`spec.idle`), and the next join wakes it. An admin changes
|
||||||
|
or turns it off under **Edit server → Idle auto-stop**. A server created before
|
||||||
|
this default has no `spec.idle` and never stops; `sudo felis converge` gives it
|
||||||
|
the default (§12b). The login gate and the lobby never idle out, whatever their
|
||||||
|
spec says.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
kubectl get minecraftserver <name> -o jsonpath='{.spec.idle}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Both idle stop and the player count also hang off **`spec.rcon.enabled`**.
|
||||||
|
Check it next.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
kubectl get minecraftserver <name> -o jsonpath='{.spec.rcon.enabled}'
|
kubectl get minecraftserver <name> -o jsonpath='{.spec.rcon.enabled}'
|
||||||
@@ -824,6 +835,12 @@ gate, on a server whose image predates the listener would hold that server in
|
|||||||
this is a command you run rather than something setup does on every re-run.
|
this is a command you run rather than something setup does on every re-run.
|
||||||
System servers that are already current report `already converged`.
|
System servers that are already current report `already converged`.
|
||||||
|
|
||||||
|
The same pass gives every **user** server whose `spec.idle` was never set (one
|
||||||
|
created before idle stop became the default) the default: stop after 600 s
|
||||||
|
empty. A server whose idle stop an admin turned off keeps a duration on its
|
||||||
|
spec (`autoStopEnabled: false`, `emptySecondsBeforeStop` set), so converge
|
||||||
|
leaves it off; only servers it actually filled get a line.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. World PVC survives after I deleted the MinecraftServer
|
## 13. World PVC survives after I deleted the MinecraftServer
|
||||||
|
|||||||
@@ -1576,6 +1576,9 @@ func (c *fakeCluster) PatchServerSpec(_ context.Context, n string, p ServerSpecP
|
|||||||
if p.AutostartPolicy != nil {
|
if p.AutostartPolicy != nil {
|
||||||
info.AutostartPolicy = string(*p.AutostartPolicy)
|
info.AutostartPolicy = string(*p.AutostartPolicy)
|
||||||
}
|
}
|
||||||
|
if p.IdleStopSeconds != nil {
|
||||||
|
info.IdleStopSeconds = *p.IdleStopSeconds
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ type ServerInfo struct {
|
|||||||
JavaMemory string `json:"javaMemory,omitempty"`
|
JavaMemory string `json:"javaMemory,omitempty"`
|
||||||
StorageSize string `json:"storageSize,omitempty"`
|
StorageSize string `json:"storageSize,omitempty"`
|
||||||
CPU string `json:"cpu,omitempty"`
|
CPU string `json:"cpu,omitempty"`
|
||||||
|
// IdleStopSeconds is how long the server may sit empty before idle
|
||||||
|
// auto-stop scales it down; 0 means it never idles out.
|
||||||
|
IdleStopSeconds int32 `json:"idleStopSeconds"`
|
||||||
|
// PlayerCountUnknown is true while the operator cannot read the player
|
||||||
|
// count over RCON; idle auto-stop waits until it can.
|
||||||
|
PlayerCountUnknown bool `json:"playerCountUnknown,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServerInput is the validated, structured create-server form (spec §15).
|
// CreateServerInput is the validated, structured create-server form (spec §15).
|
||||||
@@ -67,6 +73,9 @@ type ServerSpecPatch struct {
|
|||||||
// (felis-api resolves both from the same form) or both stay nil.
|
// (felis-api resolves both from the same form) or both stay nil.
|
||||||
JavaMemory *string
|
JavaMemory *string
|
||||||
Resources *corev1.ResourceRequirements
|
Resources *corev1.ResourceRequirements
|
||||||
|
// IdleStopSeconds sets idle auto-stop: 0 turns it off, anything else is the
|
||||||
|
// empty duration before the stop (already range-checked).
|
||||||
|
IdleStopSeconds *int32
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cluster is the lifecycle-layer access the API depends on: reads of the
|
// Cluster is the lifecycle-layer access the API depends on: reads of the
|
||||||
|
|||||||
@@ -273,3 +273,39 @@ func TestPatchServerImageWithoutBuilderIs503(t *testing.T) {
|
|||||||
t.Error("no image may be patched without a Builder")
|
t.Error("no image may be patched without a Builder")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPatchServerIdleStop covers the idle auto-stop knob: 0 turns it off, a
|
||||||
|
// value inside the range is carried to the cluster, and one outside is refused
|
||||||
|
// before anything is written.
|
||||||
|
func TestPatchServerIdleStop(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
body string
|
||||||
|
wantCode int
|
||||||
|
want int32
|
||||||
|
}{
|
||||||
|
{`{"idleStopSeconds":0}`, http.StatusOK, 0},
|
||||||
|
{`{"idleStopSeconds":900}`, http.StatusOK, 900},
|
||||||
|
{`{"idleStopSeconds":59}`, http.StatusBadRequest, 0},
|
||||||
|
{`{"idleStopSeconds":86401}`, http.StatusBadRequest, 0},
|
||||||
|
{`{"idleStopSeconds":-5}`, http.StatusBadRequest, 0},
|
||||||
|
} {
|
||||||
|
api, _, cl, _ := newPatchAPI()
|
||||||
|
w := patchSurvival(api, tc.body)
|
||||||
|
if w.Code != tc.wantCode {
|
||||||
|
t.Fatalf("%s: code = %d, want %d (%s)", tc.body, w.Code, tc.wantCode, w.Body.String())
|
||||||
|
}
|
||||||
|
p, patched := cl.patched["survival"]
|
||||||
|
if tc.wantCode != http.StatusOK {
|
||||||
|
if patched {
|
||||||
|
t.Fatalf("%s: a refused value reached the cluster: %+v", tc.body, p)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !patched || p.IdleStopSeconds == nil || *p.IdleStopSeconds != tc.want {
|
||||||
|
t.Fatalf("%s: patched idle = %v, want %d", tc.body, p.IdleStopSeconds, tc.want)
|
||||||
|
}
|
||||||
|
if got := cl.byName["survival"].IdleStopSeconds; got != tc.want {
|
||||||
|
t.Fatalf("%s: view idleStopSeconds = %d, want %d", tc.body, got, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -618,6 +618,9 @@ type patchServerRequest struct {
|
|||||||
Image *string `json:"image,omitempty"`
|
Image *string `json:"image,omitempty"`
|
||||||
Memory *string `json:"memory,omitempty"`
|
Memory *string `json:"memory,omitempty"`
|
||||||
Resources *resourceRequest `json:"resources,omitempty"`
|
Resources *resourceRequest `json:"resources,omitempty"`
|
||||||
|
// IdleStopSeconds sets idle auto-stop: 0 turns it off, otherwise the server
|
||||||
|
// stops after that many seconds with nobody online (60 to 86400).
|
||||||
|
IdleStopSeconds *int32 `json:"idleStopSeconds,omitempty"`
|
||||||
// Storage is recognized only so the endpoint can reject it with a precise
|
// Storage is recognized only so the endpoint can reject it with a precise
|
||||||
// reason rather than an opaque "unknown field": a StatefulSet's PVC capacity
|
// reason rather than an opaque "unknown field": a StatefulSet's PVC capacity
|
||||||
// is immutable except for storage-class-gated expansion, which this build does
|
// is immutable except for storage-class-gated expansion, which this build does
|
||||||
@@ -626,6 +629,12 @@ type patchServerRequest struct {
|
|||||||
Storage *string `json:"storage,omitempty"`
|
Storage *string `json:"storage,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The idle auto-stop range an admin may pick through PATCH /servers/{name}.
|
||||||
|
const (
|
||||||
|
minIdleStopSeconds = 60
|
||||||
|
maxIdleStopSeconds = 86400
|
||||||
|
)
|
||||||
|
|
||||||
// handlePatchServer (spec §7 PATCH /servers/{name}) is the admin-tier spec
|
// handlePatchServer (spec §7 PATCH /servers/{name}) is the admin-tier spec
|
||||||
// mutation: it validates the structured form, re-admits any new image against the
|
// mutation: it validates the structured form, re-admits any new image against the
|
||||||
// whitelist, re-derives the §22 memory ceiling, and applies a merge patch to the
|
// whitelist, re-derives the §22 memory ceiling, and applies a merge patch to the
|
||||||
@@ -647,7 +656,7 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// An empty patch is a client mistake, not a no-op success.
|
// An empty patch is a client mistake, not a no-op success.
|
||||||
if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil &&
|
if body.DisplayName == nil && body.AutostartPolicy == nil && body.Image == nil &&
|
||||||
body.Memory == nil && body.Resources == nil && body.Storage == nil {
|
body.Memory == nil && body.Resources == nil && body.Storage == nil && body.IdleStopSeconds == nil {
|
||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
"patch must set at least one field"))
|
"patch must set at least one field"))
|
||||||
return
|
return
|
||||||
@@ -686,6 +695,19 @@ func (a *API) handlePatchServer(w http.ResponseWriter, r *http.Request) {
|
|||||||
changed = append(changed, "autostartPolicy")
|
changed = append(changed, "autostartPolicy")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if body.IdleStopSeconds != nil {
|
||||||
|
// A minute is the floor: below it a player who drops for a reconnect
|
||||||
|
// finds the server stopping under them. A day is the ceiling; longer is
|
||||||
|
// what "off" is for.
|
||||||
|
if s := *body.IdleStopSeconds; s != 0 && (s < minIdleStopSeconds || s > maxIdleStopSeconds) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_idle_stop",
|
||||||
|
"idleStopSeconds must be 0 (off) or between %d and %d", minIdleStopSeconds, maxIdleStopSeconds))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
patch.IdleStopSeconds = body.IdleStopSeconds
|
||||||
|
changed = append(changed, "idleStopSeconds")
|
||||||
|
}
|
||||||
|
|
||||||
if body.Image != nil {
|
if body.Image != nil {
|
||||||
// A new image must be re-admitted against the whitelist, exactly as create
|
// A new image must be re-admitted against the whitelist, exactly as create
|
||||||
// does — admission is the only source of a legal image. With no Builder
|
// does — admission is the only source of a legal image. With no Builder
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
batchv1 "k8s.io/api/batch/v1"
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
"k8s.io/apimachinery/pkg/types"
|
"k8s.io/apimachinery/pkg/types"
|
||||||
"k8s.io/client-go/util/retry"
|
"k8s.io/client-go/util/retry"
|
||||||
@@ -119,6 +120,8 @@ func (k *K8sCluster) CreateServer(ctx context.Context, in CreateServerInput) err
|
|||||||
FallbackServer: naming.SystemLoginServer,
|
FallbackServer: naming.SystemLoginServer,
|
||||||
Storage: v1alpha1.StorageSpec{Size: in.StorageSize},
|
Storage: v1alpha1.StorageSpec{Size: in.StorageSize},
|
||||||
Resources: in.Resources,
|
Resources: in.Resources,
|
||||||
|
// A server nobody plays on stops itself; the next join wakes it.
|
||||||
|
Idle: v1alpha1.DefaultIdle(),
|
||||||
// RCON is what makes a server manageable at all: the operator gates
|
// RCON is what makes a server manageable at all: the operator gates
|
||||||
// phase=Running on the probe and samples the player tally from it (spec
|
// phase=Running on the probe and samples the player tally from it (spec
|
||||||
// §5), and every write — console commands, the LuckPerms grants behind the
|
// §5), and every write — console commands, the LuckPerms grants behind the
|
||||||
@@ -328,6 +331,18 @@ func (k *K8sCluster) PatchServerSpec(ctx context.Context, name string, p ServerS
|
|||||||
if p.Resources != nil {
|
if p.Resources != nil {
|
||||||
ms.Spec.Resources = *p.Resources
|
ms.Spec.Resources = *p.Resources
|
||||||
}
|
}
|
||||||
|
if p.IdleStopSeconds != nil {
|
||||||
|
// Off keeps the duration (or the default) on the spec, which is what
|
||||||
|
// marks it as a choice: converge only fills a server with none at all.
|
||||||
|
if *p.IdleStopSeconds == 0 {
|
||||||
|
ms.Spec.Idle.AutoStopEnabled = false
|
||||||
|
if ms.Spec.Idle.EmptySecondsBeforeStop <= 0 {
|
||||||
|
ms.Spec.Idle.EmptySecondsBeforeStop = v1alpha1.DefaultEmptySecondsBeforeStop
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ms.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: *p.IdleStopSeconds}
|
||||||
|
}
|
||||||
|
}
|
||||||
return k.c.Patch(ctx, &ms, patch)
|
return k.c.Patch(ctx, &ms, patch)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,5 +371,19 @@ func serverInfo(ms *v1alpha1.MinecraftServer) *ServerInfo {
|
|||||||
JavaMemory: ms.Spec.JavaMemory,
|
JavaMemory: ms.Spec.JavaMemory,
|
||||||
StorageSize: ms.Spec.Storage.Size,
|
StorageSize: ms.Spec.Storage.Size,
|
||||||
CPU: cpuStr,
|
CPU: cpuStr,
|
||||||
|
IdleStopSeconds: idleStopSeconds(ms),
|
||||||
|
PlayerCountUnknown: ms.Status.Phase == v1alpha1.PhaseRunning &&
|
||||||
|
meta.IsStatusConditionFalse(ms.Status.Conditions, v1alpha1.ConditionPlayersCounted),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idleStopSeconds is the effective idle auto-stop duration, 0 when the server
|
||||||
|
// never idles out. It mirrors the operator's own rule: RCON must be on (the
|
||||||
|
// count comes from it) and system servers are exempt.
|
||||||
|
func idleStopSeconds(ms *v1alpha1.MinecraftServer) int32 {
|
||||||
|
if !ms.Spec.Rcon.Enabled || ms.Labels[v1alpha1.LabelSystemRole] != "" ||
|
||||||
|
!ms.Spec.Idle.AutoStopEnabled || ms.Spec.Idle.EmptySecondsBeforeStop <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return ms.Spec.Idle.EmptySecondsBeforeStop
|
||||||
|
}
|
||||||
@@ -63,3 +63,72 @@ func TestCreateServerEnablesRcon(t *testing.T) {
|
|||||||
t.Fatalf("rcon port = %d, want 0 so the operator default is the only copy", ms.Spec.Rcon.Port)
|
t.Fatalf("rcon port = %d, want 0 so the operator default is the only copy", ms.Spec.Rcon.Port)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestCreateServerDefaultsIdleStop pins the other half of "a server nobody plays
|
||||||
|
// on stops itself": the operator only idles out a server whose spec asks for
|
||||||
|
// it, so a create that leaves spec.idle empty ships a server that runs forever.
|
||||||
|
func TestCreateServerDefaultsIdleStop(t *testing.T) {
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := v1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatalf("scheme: %v", err)
|
||||||
|
}
|
||||||
|
c := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||||
|
k := NewK8sCluster(c, "minecraft")
|
||||||
|
if err := k.CreateServer(context.Background(), CreateServerInput{
|
||||||
|
Name: "survival", Subdomain: "survival", Image: "reg/paper:1", JavaMemory: "2G", StorageSize: "10Gi",
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateServer: %v", err)
|
||||||
|
}
|
||||||
|
info, err := k.GetServer(context.Background(), "survival")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetServer: %v", err)
|
||||||
|
}
|
||||||
|
if info.IdleStopSeconds != v1alpha1.DefaultEmptySecondsBeforeStop {
|
||||||
|
t.Fatalf("idleStopSeconds = %d, want the default %d", info.IdleStopSeconds, v1alpha1.DefaultEmptySecondsBeforeStop)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPatchIdleStopKeepsTheChoiceVisible: turning idle stop off must leave a
|
||||||
|
// duration on the spec, because a spec with none at all is what converge fills
|
||||||
|
// with the default. Off followed by a converge must stay off.
|
||||||
|
func TestPatchIdleStopKeepsTheChoiceVisible(t *testing.T) {
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
if err := v1alpha1.AddToScheme(scheme); err != nil {
|
||||||
|
t.Fatalf("scheme: %v", err)
|
||||||
|
}
|
||||||
|
legacy := &v1alpha1.MinecraftServer{}
|
||||||
|
legacy.Name, legacy.Namespace = "survival", "minecraft"
|
||||||
|
legacy.Spec.Rcon.Enabled = true
|
||||||
|
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(legacy).Build()
|
||||||
|
k := NewK8sCluster(c, "minecraft")
|
||||||
|
get := func() v1alpha1.IdleSpec {
|
||||||
|
var ms v1alpha1.MinecraftServer
|
||||||
|
if err := c.Get(context.Background(), types.NamespacedName{Namespace: "minecraft", Name: "survival"}, &ms); err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
return ms.Spec.Idle
|
||||||
|
}
|
||||||
|
|
||||||
|
off := int32(0)
|
||||||
|
if err := k.PatchServerSpec(context.Background(), "survival", ServerSpecPatch{IdleStopSeconds: &off}); err != nil {
|
||||||
|
t.Fatalf("patch off: %v", err)
|
||||||
|
}
|
||||||
|
if got := get(); got.AutoStopEnabled || got.EmptySecondsBeforeStop <= 0 {
|
||||||
|
t.Fatalf("idle after off = %+v, want disabled with a duration kept", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
thirty := int32(1800)
|
||||||
|
if err := k.PatchServerSpec(context.Background(), "survival", ServerSpecPatch{IdleStopSeconds: &thirty}); err != nil {
|
||||||
|
t.Fatalf("patch on: %v", err)
|
||||||
|
}
|
||||||
|
if got := get(); !got.AutoStopEnabled || got.EmptySecondsBeforeStop != 1800 {
|
||||||
|
t.Fatalf("idle after 1800 = %+v, want enabled at 1800", got)
|
||||||
|
}
|
||||||
|
info, err := k.GetServer(context.Background(), "survival")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetServer: %v", err)
|
||||||
|
}
|
||||||
|
if info.IdleStopSeconds != 1800 {
|
||||||
|
t.Fatalf("view idleStopSeconds = %d, want 1800", info.IdleStopSeconds)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -226,7 +226,23 @@ type StartupSpec struct {
|
|||||||
HealthHTTPPath string `json:"healthHTTPPath,omitempty"`
|
HealthHTTPPath string `json:"healthHTTPPath,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DefaultEmptySecondsBeforeStop is how long a user server may sit empty before
|
||||||
|
// idle auto-stop scales it down, when nobody chose another value (spec §8).
|
||||||
|
const DefaultEmptySecondsBeforeStop int32 = 600
|
||||||
|
|
||||||
|
// DefaultIdle is the idle policy every user server is created with: stop after
|
||||||
|
// DefaultEmptySecondsBeforeStop of an empty server. System servers (the login
|
||||||
|
// gate, the lobby) never take it; they must stay up.
|
||||||
|
func DefaultIdle() IdleSpec {
|
||||||
|
return IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: DefaultEmptySecondsBeforeStop}
|
||||||
|
}
|
||||||
|
|
||||||
// IdleSpec configures empty-server auto-stop (spec §8).
|
// IdleSpec configures empty-server auto-stop (spec §8).
|
||||||
|
//
|
||||||
|
// The two fields together tell a choice from its absence: turning auto-stop
|
||||||
|
// off keeps EmptySecondsBeforeStop set (AutoStopEnabled=false, seconds > 0),
|
||||||
|
// while a server that predates the default has both zero, and only that one
|
||||||
|
// is filled in by `felis converge`.
|
||||||
type IdleSpec struct {
|
type IdleSpec struct {
|
||||||
// AutoStopEnabled turns on idle auto-stop.
|
// AutoStopEnabled turns on idle auto-stop.
|
||||||
AutoStopEnabled bool `json:"autoStopEnabled,omitempty"`
|
AutoStopEnabled bool `json:"autoStopEnabled,omitempty"`
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
|
|||||||
// "empty" and stop a server full of people. It neither stamps nor clears
|
// "empty" and stop a server full of people. It neither stamps nor clears
|
||||||
// EmptySince, so a flaky read does not restart the countdown either; the
|
// EmptySince, so a flaky read does not restart the countdown either; the
|
||||||
// stop itself only ever follows a sample that really said zero.
|
// stop itself only ever follows a sample that really said zero.
|
||||||
if players.Known && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
|
if players.Known && idleStopApplies(server) {
|
||||||
if players.Online == 0 {
|
if players.Online == 0 {
|
||||||
if server.Status.EmptySince == nil {
|
if server.Status.EmptySince == nil {
|
||||||
t := r.now()
|
t := r.now()
|
||||||
@@ -271,7 +271,7 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
|
|||||||
// (observed live: the stamp sat unexamined for minutes). Wake at the exact
|
// (observed live: the stamp sat unexamined for minutes). Wake at the exact
|
||||||
// deadline while the tally says empty, or on a slow cadence while players
|
// deadline while the tally says empty, or on a slow cadence while players
|
||||||
// are online, to notice the moment the last one leaves.
|
// are online, to notice the moment the last one leaves.
|
||||||
if server.Spec.Rcon.Enabled && server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0 {
|
if server.Spec.Rcon.Enabled && idleStopApplies(server) {
|
||||||
if server.Status.EmptySince != nil {
|
if server.Status.EmptySince != nil {
|
||||||
deadline := server.Status.EmptySince.Time.Add(time.Duration(server.Spec.Idle.EmptySecondsBeforeStop) * time.Second)
|
deadline := server.Status.EmptySince.Time.Add(time.Duration(server.Spec.Idle.EmptySecondsBeforeStop) * time.Second)
|
||||||
if wait := deadline.Sub(r.now().Time); wait > 0 {
|
if wait := deadline.Sub(r.now().Time); wait > 0 {
|
||||||
@@ -283,6 +283,14 @@ func (r *Reconciler) reconcileRunning(ctx context.Context, server *v1alpha1.Mine
|
|||||||
return ctrl.Result{}, nil
|
return ctrl.Result{}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idleStopApplies reports whether idle auto-stop is configured for server. A
|
||||||
|
// system server (the login gate, the lobby) never idles out whatever its spec
|
||||||
|
// says: stopping the gate locks every player out, and nothing would wake it.
|
||||||
|
func idleStopApplies(server *v1alpha1.MinecraftServer) bool {
|
||||||
|
return server.Labels[v1alpha1.LabelSystemRole] == "" &&
|
||||||
|
server.Spec.Idle.AutoStopEnabled && server.Spec.Idle.EmptySecondsBeforeStop > 0
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) {
|
func (r *Reconciler) reconcileStopped(ctx context.Context, server *v1alpha1.MinecraftServer) (ctrl.Result, error) {
|
||||||
var sts appsv1.StatefulSet
|
var sts appsv1.StatefulSet
|
||||||
err := r.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: server.Name}, &sts)
|
err := r.Get(ctx, types.NamespacedName{Namespace: server.Namespace, Name: server.Name}, &sts)
|
||||||
|
|||||||
@@ -491,6 +491,34 @@ func TestIdleAutoStop_UnreadTallyNeverStops(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIdleAutoStop_SystemServerNeverIdles: the login gate and the lobby must
|
||||||
|
// stay up whatever their spec says. A stopped gate locks every player out, and
|
||||||
|
// nothing would wake it.
|
||||||
|
func TestIdleAutoStop_SystemServerNeverIdles(t *testing.T) {
|
||||||
|
srv := runningServer()
|
||||||
|
srv.Labels = map[string]string{v1alpha1.LabelSystemRole: "lobby"}
|
||||||
|
srv.Spec.Idle = v1alpha1.IdleSpec{AutoStopEnabled: true, EmptySecondsBeforeStop: 60}
|
||||||
|
r, c := newReconciler(t, fakeProber{players: operator.PlayerCount{Online: 0, Max: 20, Known: true}}, srv, rconSecret())
|
||||||
|
base := time.Date(2026, 7, 1, 10, 0, 0, 0, time.UTC)
|
||||||
|
clock := base
|
||||||
|
r.Now = func() metav1.Time { return metav1.NewTime(clock) }
|
||||||
|
|
||||||
|
reconcile(t, r, "survival")
|
||||||
|
markPodReady(t, c, "survival")
|
||||||
|
reconcile(t, r, "survival")
|
||||||
|
clock = base.Add(time.Hour)
|
||||||
|
res := reconcile(t, r, "survival")
|
||||||
|
|
||||||
|
server := getServer(t, c, "survival")
|
||||||
|
if server.Spec.DesiredState != v1alpha1.DesiredRunning || server.Status.EmptySince != nil {
|
||||||
|
t.Fatalf("system server: desiredState=%s emptySince=%v, want Running and no countdown",
|
||||||
|
server.Spec.DesiredState, server.Status.EmptySince)
|
||||||
|
}
|
||||||
|
if res.RequeueAfter != 0 {
|
||||||
|
t.Fatalf("RequeueAfter = %v, want none for a server that never idles", res.RequeueAfter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestIdleAutoStop_RequeuesUntilDeadline pins the self-driving requeue: an
|
// TestIdleAutoStop_RequeuesUntilDeadline pins the self-driving requeue: an
|
||||||
// empty Running server must wake the controller at the auto-stop deadline with
|
// empty Running server must wake the controller at the auto-stop deadline with
|
||||||
// no external event to lean on. Live, the EmptySince stamp sat unexamined for
|
// no external event to lean on. Live, the EmptySince stamp sat unexamined for
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Settings, ChevronRight } from "lucide-react";
|
import { Settings, ChevronRight, AlertTriangle } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
import { ConfirmFooter } from "@/components/ConfirmFooter";
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,17 @@ import type { AutostartPolicy } from "@/lib/types";
|
|||||||
|
|
||||||
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
const MEMORY_OPTIONS = ["2Gi", "4Gi", "6Gi", "8Gi"];
|
||||||
|
|
||||||
|
/** Idle auto-stop presets in seconds; "0" is Never. The server default is 600. */
|
||||||
|
const IDLE_OPTIONS = ["0", "300", "600", "900", "1800", "3600", "7200"];
|
||||||
|
|
||||||
|
/** idleLabel renders an idle-stop duration the way a person says it. */
|
||||||
|
function idleLabel(t: (key: string, opts?: Record<string, unknown>) => string, seconds: number): string {
|
||||||
|
if (seconds <= 0) return t("idle_stop_never");
|
||||||
|
if (seconds % 3600 === 0) return t("idle_stop_hours", { count: seconds / 3600 });
|
||||||
|
if (seconds % 60 === 0) return t("idle_stop_minutes", { count: seconds / 60 });
|
||||||
|
return t("idle_stop_seconds", { count: seconds });
|
||||||
|
}
|
||||||
|
|
||||||
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
function policyOptions(t: (key: string) => string): { value: AutostartPolicy; label: string }[] {
|
||||||
return [
|
return [
|
||||||
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
{ value: "ownerOnly", label: t("create_server_policy_owner") },
|
||||||
@@ -39,6 +50,8 @@ interface EditServerForm {
|
|||||||
image: string;
|
image: string;
|
||||||
memory: string;
|
memory: string;
|
||||||
cpu: string;
|
cpu: string;
|
||||||
|
/** Idle auto-stop seconds as a Select value; "0" is Never. */
|
||||||
|
idleStop: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -49,6 +62,9 @@ interface Props {
|
|||||||
currentMemory?: string;
|
currentMemory?: string;
|
||||||
currentStorage?: string;
|
currentStorage?: string;
|
||||||
currentCpu?: string;
|
currentCpu?: string;
|
||||||
|
currentIdleStopSeconds?: number;
|
||||||
|
/** The operator cannot read the player count, so idle stop is paused. */
|
||||||
|
playerCountUnknown?: boolean;
|
||||||
onUpdated: () => void;
|
onUpdated: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,8 +76,11 @@ export function EditServerDialog({
|
|||||||
currentMemory = "",
|
currentMemory = "",
|
||||||
currentStorage = "",
|
currentStorage = "",
|
||||||
currentCpu = "",
|
currentCpu = "",
|
||||||
|
currentIdleStopSeconds = 0,
|
||||||
|
playerCountUnknown = false,
|
||||||
onUpdated,
|
onUpdated,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const currentIdleStop = String(currentIdleStopSeconds);
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const images = useAsync(() => api.listImages(), []);
|
const images = useAsync(() => api.listImages(), []);
|
||||||
@@ -72,6 +91,7 @@ export function EditServerDialog({
|
|||||||
image: currentImage,
|
image: currentImage,
|
||||||
memory: currentMemory,
|
memory: currentMemory,
|
||||||
cpu: currentCpu,
|
cpu: currentCpu,
|
||||||
|
idleStop: currentIdleStop,
|
||||||
});
|
});
|
||||||
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -86,10 +106,11 @@ export function EditServerDialog({
|
|||||||
image: currentImage,
|
image: currentImage,
|
||||||
memory: currentMemory,
|
memory: currentMemory,
|
||||||
cpu: currentCpu,
|
cpu: currentCpu,
|
||||||
|
idleStop: currentIdleStop,
|
||||||
});
|
});
|
||||||
setError(null);
|
setError(null);
|
||||||
}
|
}
|
||||||
}, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu]);
|
}, [open, currentDisplayName, currentPolicy, currentImage, currentMemory, currentCpu, currentIdleStop]);
|
||||||
|
|
||||||
function set<K extends keyof EditServerForm>(k: K, v: EditServerForm[K]) {
|
function set<K extends keyof EditServerForm>(k: K, v: EditServerForm[K]) {
|
||||||
setForm((f) => ({ ...f, [k]: v }));
|
setForm((f) => ({ ...f, [k]: v }));
|
||||||
@@ -103,7 +124,8 @@ export function EditServerDialog({
|
|||||||
form.autostartPolicy !== currentPolicy ||
|
form.autostartPolicy !== currentPolicy ||
|
||||||
form.image !== currentImage ||
|
form.image !== currentImage ||
|
||||||
form.memory !== currentMemory ||
|
form.memory !== currentMemory ||
|
||||||
form.cpu !== currentCpu;
|
form.cpu !== currentCpu ||
|
||||||
|
form.idleStop !== currentIdleStop;
|
||||||
|
|
||||||
const canSubmit = hasChanges && !submitting;
|
const canSubmit = hasChanges && !submitting;
|
||||||
|
|
||||||
@@ -130,6 +152,9 @@ export function EditServerDialog({
|
|||||||
cpu: form.cpu.trim(),
|
cpu: form.cpu.trim(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
if (form.idleStop !== currentIdleStop) {
|
||||||
|
payload.idleStopSeconds = Number(form.idleStop);
|
||||||
|
}
|
||||||
|
|
||||||
await api.patchServer(serverName, payload);
|
await api.patchServer(serverName, payload);
|
||||||
setOpen(false);
|
setOpen(false);
|
||||||
@@ -265,6 +290,33 @@ export function EditServerDialog({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>{t("edit_server_idle")}</Label>
|
||||||
|
<Select value={form.idleStop} onValueChange={(v) => set("idleStop", v)}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{/* A duration set outside the presets (kubectl, converge) stays selectable. */}
|
||||||
|
{!IDLE_OPTIONS.includes(currentIdleStop) && (
|
||||||
|
<SelectItem value={currentIdleStop}>{idleLabel(t, currentIdleStopSeconds)}</SelectItem>
|
||||||
|
)}
|
||||||
|
{IDLE_OPTIONS.map((s) => (
|
||||||
|
<SelectItem key={s} value={s}>
|
||||||
|
{idleLabel(t, Number(s))}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("edit_server_idle_hint")}</p>
|
||||||
|
{playerCountUnknown && form.idleStop !== "0" && (
|
||||||
|
<p className="flex items-start gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||||
|
<AlertTriangle className="mt-px h-3.5 w-3.5 shrink-0" />
|
||||||
|
{t("edit_server_idle_unknown")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"bad_subdomain": "That subdomain is invalid — use 3–32 lowercase letters, digits or dashes, and avoid reserved names.",
|
"bad_subdomain": "That subdomain is invalid — use 3–32 lowercase letters, digits or dashes, and avoid reserved names.",
|
||||||
"at_capacity": "The cluster is at its running-server cap — try again once a server stops.",
|
"at_capacity": "The cluster is at its running-server cap — try again once a server stops.",
|
||||||
"storage_immutable": "Storage size can't be changed online.",
|
"storage_immutable": "Storage size can't be changed online.",
|
||||||
|
"bad_idle_stop": "Idle stop must be between 1 minute and 24 hours, or Never.",
|
||||||
"email_taken": "That email is already verified on another account — sign in with it or use another address.",
|
"email_taken": "That email is already verified on another account — sign in with it or use another address.",
|
||||||
"mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.",
|
"mail_undeliverable": "The verification email could not be delivered — try again later, or ask the operator to check the mail relay.",
|
||||||
"bad_path": "That path is invalid — use a relative path inside the world directory.",
|
"bad_path": "That path is invalid — use a relative path inside the world directory.",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
"create_server_cancel": "Cancel",
|
"create_server_cancel": "Cancel",
|
||||||
"create_server_submit": "Create",
|
"create_server_submit": "Create",
|
||||||
"edit_server_title": "Edit Server Config",
|
"edit_server_title": "Edit Server Config",
|
||||||
"edit_server_desc": "Configure display name, autostart policy, image, memory, and CPU",
|
"edit_server_desc": "Configure display name, autostart policy, image, memory, CPU, and idle stop",
|
||||||
"edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.",
|
"edit_server_desc_long": "Updating server spec. Fields left unchanged will retain their current values.",
|
||||||
"edit_server_submit": "Save Config",
|
"edit_server_submit": "Save Config",
|
||||||
"luckperms_group_name": "Group Name",
|
"luckperms_group_name": "Group Name",
|
||||||
@@ -167,5 +167,12 @@
|
|||||||
"luckperms_clear_history": "Clear history",
|
"luckperms_clear_history": "Clear history",
|
||||||
"edit_server_cpu": "CPU Limit",
|
"edit_server_cpu": "CPU Limit",
|
||||||
"edit_server_cpu_placeholder": "e.g. 1, 2, 500m",
|
"edit_server_cpu_placeholder": "e.g. 1, 2, 500m",
|
||||||
"owned_filter_mine": "me"
|
"owned_filter_mine": "me",
|
||||||
|
"edit_server_idle": "Idle auto-stop",
|
||||||
|
"edit_server_idle_hint": "Stops the server after this long with nobody online, freeing memory and CPU. The next player to join wakes it.",
|
||||||
|
"edit_server_idle_unknown": "The player count can’t be read right now, so idle stop is paused until it can.",
|
||||||
|
"idle_stop_never": "Never",
|
||||||
|
"idle_stop_seconds": "{{count}} s",
|
||||||
|
"idle_stop_minutes": "{{count}} min",
|
||||||
|
"idle_stop_hours": "{{count}} h"
|
||||||
}
|
}
|
||||||
@@ -35,6 +35,7 @@
|
|||||||
"bad_subdomain": "子域名不合法:需为 3–32 位小写字母、数字或连字符,且不能使用保留名。",
|
"bad_subdomain": "子域名不合法:需为 3–32 位小写字母、数字或连字符,且不能使用保留名。",
|
||||||
"at_capacity": "集群的在线服务器已达上限——等一台服务器停止后再试。",
|
"at_capacity": "集群的在线服务器已达上限——等一台服务器停止后再试。",
|
||||||
"storage_immutable": "存储容量不支持在线调整。",
|
"storage_immutable": "存储容量不支持在线调整。",
|
||||||
|
"bad_idle_stop": "空闲停服时长须在 1 分钟到 24 小时之间,或选择“从不”。",
|
||||||
"email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。",
|
"email_taken": "该邮箱已在其他账户上完成验证;请直接用该邮箱登录,或换一个地址。",
|
||||||
"mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。",
|
"mail_undeliverable": "验证码邮件发送失败——请稍后重试,或联系管理员检查邮件服务。",
|
||||||
"bad_path": "路径不合法——请使用世界目录内的相对路径。",
|
"bad_path": "路径不合法——请使用世界目录内的相对路径。",
|
||||||
|
|||||||
@@ -126,7 +126,7 @@
|
|||||||
"create_server_cancel": "取消",
|
"create_server_cancel": "取消",
|
||||||
"create_server_submit": "创建",
|
"create_server_submit": "创建",
|
||||||
"edit_server_title": "编辑服务器配置",
|
"edit_server_title": "编辑服务器配置",
|
||||||
"edit_server_desc": "配置显示名、自启策略、镜像、内存与CPU",
|
"edit_server_desc": "配置显示名、自启策略、镜像、内存、CPU 与空闲停服",
|
||||||
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
|
"edit_server_desc_long": "正在修改服务器的 spec 配置。未修改的项将保持原样。",
|
||||||
"edit_server_submit": "保存配置",
|
"edit_server_submit": "保存配置",
|
||||||
"luckperms_group_name": "用户组名称",
|
"luckperms_group_name": "用户组名称",
|
||||||
@@ -167,5 +167,12 @@
|
|||||||
"luckperms_clear_history": "清除历史记录",
|
"luckperms_clear_history": "清除历史记录",
|
||||||
"edit_server_cpu": "CPU 限制",
|
"edit_server_cpu": "CPU 限制",
|
||||||
"edit_server_cpu_placeholder": "例如 1, 2, 500m",
|
"edit_server_cpu_placeholder": "例如 1, 2, 500m",
|
||||||
"owned_filter_mine": "我"
|
"owned_filter_mine": "我",
|
||||||
|
"edit_server_idle": "空闲自动停服",
|
||||||
|
"edit_server_idle_hint": "没人在线达到这个时长就自动停服,省下内存和 CPU;玩家下次进服时自动唤醒。",
|
||||||
|
"edit_server_idle_unknown": "现在读不到在线人数,空闲停服暂停中,直到能读到为止。",
|
||||||
|
"idle_stop_never": "从不",
|
||||||
|
"idle_stop_seconds": "{{count}} 秒",
|
||||||
|
"idle_stop_minutes": "{{count}} 分钟",
|
||||||
|
"idle_stop_hours": "{{count}} 小时"
|
||||||
}
|
}
|
||||||
@@ -323,6 +323,8 @@ export const api = {
|
|||||||
autostartPolicy?: AutostartPolicy;
|
autostartPolicy?: AutostartPolicy;
|
||||||
image?: string;
|
image?: string;
|
||||||
memory?: string;
|
memory?: string;
|
||||||
|
/** Idle auto-stop: 0 turns it off, else seconds empty before the stop (60–86400). */
|
||||||
|
idleStopSeconds?: number;
|
||||||
resources?: {
|
resources?: {
|
||||||
cpu?: string;
|
cpu?: string;
|
||||||
cpuRequest?: string;
|
cpuRequest?: string;
|
||||||
@@ -738,6 +740,8 @@ export function humanizeError(e: unknown): string {
|
|||||||
return t("at_capacity");
|
return t("at_capacity");
|
||||||
case "storage_immutable":
|
case "storage_immutable":
|
||||||
return t("storage_immutable");
|
return t("storage_immutable");
|
||||||
|
case "bad_idle_stop":
|
||||||
|
return t("bad_idle_stop");
|
||||||
// Email identity: the verified-email uniqueness index (migration 0020) plus
|
// Email identity: the verified-email uniqueness index (migration 0020) plus
|
||||||
// VerifyEmailOTP's guard make a second verified holder impossible; the OTP
|
// VerifyEmailOTP's guard make a second verified holder impossible; the OTP
|
||||||
// relay can also refuse to deliver at all.
|
// relay can also refuse to deliver at all.
|
||||||
|
|||||||
@@ -36,6 +36,10 @@ export interface ServerInfo {
|
|||||||
javaMemory?: string;
|
javaMemory?: string;
|
||||||
storageSize?: string;
|
storageSize?: string;
|
||||||
cpu?: string;
|
cpu?: string;
|
||||||
|
/** Seconds empty before idle auto-stop; 0 when the server never idles out. */
|
||||||
|
idleStopSeconds?: number;
|
||||||
|
/** True while the operator cannot read the player count; idle stop waits. */
|
||||||
|
playerCountUnknown?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or
|
/** ServerStatus is GET /servers/{name}/status. It never carries `owned` or
|
||||||
|
|||||||
@@ -286,6 +286,8 @@ export function ServerConsole() {
|
|||||||
currentMemory={data.javaMemory}
|
currentMemory={data.javaMemory}
|
||||||
currentStorage={data.storageSize}
|
currentStorage={data.storageSize}
|
||||||
currentCpu={data.cpu}
|
currentCpu={data.cpu}
|
||||||
|
currentIdleStopSeconds={data.idleStopSeconds}
|
||||||
|
playerCountUnknown={data.playerCountUnknown}
|
||||||
onUpdated={reload}
|
onUpdated={reload}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in new issue
Block a user