fix(panel): diagnose runtime failures and add owner recovery controls
This commit is contained in:
47 files changed
+1587
-99
No files matched your search
@@ -331,6 +331,25 @@ components:
|
|||||||
description: Optional Yggdrasil API base for role lookup; empty infers it from the standard hasJoined suffix.
|
description: Optional Yggdrasil API base for role lookup; empty infers it from the standard hasJoined suffix.
|
||||||
enabled: { type: boolean }
|
enabled: { type: boolean }
|
||||||
|
|
||||||
|
StartupStatus:
|
||||||
|
type: object
|
||||||
|
required: [stage, logsAvailable]
|
||||||
|
properties:
|
||||||
|
stage: { type: string, enum: [creating, scheduling, preparing, booting, failed] }
|
||||||
|
reason: { type: string }
|
||||||
|
message: { type: string }
|
||||||
|
startedAt: { type: string, format: date-time }
|
||||||
|
logsAvailable: { type: boolean }
|
||||||
|
|
||||||
|
WakePolicySettings:
|
||||||
|
type: object
|
||||||
|
required: [maxRunningServers, wakeCooldownSeconds, revision, managed]
|
||||||
|
properties:
|
||||||
|
maxRunningServers: { type: integer, minimum: 0, maximum: 10000 }
|
||||||
|
wakeCooldownSeconds: { type: integer, minimum: 0, maximum: 3600 }
|
||||||
|
revision: { type: string }
|
||||||
|
managed: { type: boolean }
|
||||||
|
|
||||||
AuthSourcesSettings:
|
AuthSourcesSettings:
|
||||||
type: object
|
type: object
|
||||||
required: [sources, revision, managed]
|
required: [sources, revision, managed]
|
||||||
@@ -551,6 +570,7 @@ components:
|
|||||||
description: Status projection of one server (internal/api/cluster.go ServerInfo).
|
description: Status projection of one server (internal/api/cluster.go ServerInfo).
|
||||||
required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds]
|
required: [name, subdomain, phase, ready, playersOnline, playersMax, idleStopSeconds]
|
||||||
properties:
|
properties:
|
||||||
|
startup: { $ref: '#/components/schemas/StartupStatus', description: Private runtime and startup diagnostics for authorized server managers. }
|
||||||
nodeName: { type: string, description: Execution node; legacy servers report the observed node. }
|
nodeName: { type: string, description: Execution node; legacy servers report the observed node. }
|
||||||
name: { type: string }
|
name: { type: string }
|
||||||
subdomain: { type: string }
|
subdomain: { type: string }
|
||||||
@@ -2539,6 +2559,52 @@ paths:
|
|||||||
'404':
|
'404':
|
||||||
$ref: '#/components/responses/NotFound'
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
/api/v1/servers/{name}/emergency-stop:
|
||||||
|
post:
|
||||||
|
tags: [servers]
|
||||||
|
operationId: emergencyStop
|
||||||
|
summary: Scale an owned game workload to zero independently of Operator and RCON.
|
||||||
|
description: Owner only; fresh authentication and exact name confirmation required. A 202 acknowledges scaling, not process exit. Normal Pod termination grace is retained.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
parameters:
|
||||||
|
- { name: name, in: path, required: true, schema: { type: string } }
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
additionalProperties: false
|
||||||
|
required: [confirm]
|
||||||
|
properties:
|
||||||
|
confirm: { type: string }
|
||||||
|
responses:
|
||||||
|
'202':
|
||||||
|
description: Stop intent persisted and workload scaled to zero; await Pod termination.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name, desiredState]
|
||||||
|
properties:
|
||||||
|
name: { type: string }
|
||||||
|
desiredState: { type: string, const: Stopped }
|
||||||
|
'400':
|
||||||
|
$ref: '#/components/responses/BadRequest'
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
$ref: '#/components/responses/Forbidden'
|
||||||
|
'404':
|
||||||
|
$ref: '#/components/responses/NotFound'
|
||||||
|
|
||||||
|
'409':
|
||||||
|
$ref: '#/components/responses/Conflict'
|
||||||
|
'503':
|
||||||
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
|
|
||||||
/api/v1/servers/{name}/restart:
|
/api/v1/servers/{name}/restart:
|
||||||
post:
|
post:
|
||||||
tags: [servers]
|
tags: [servers]
|
||||||
@@ -4265,6 +4331,56 @@ paths:
|
|||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
|
||||||
|
/api/v1/settings/wake-policy:
|
||||||
|
get:
|
||||||
|
tags: [account]
|
||||||
|
operationId: getWakePolicy
|
||||||
|
summary: Read platform startup policy (Owner).
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Current startup policy and revision.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/WakePolicySettings' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
put:
|
||||||
|
tags: [account]
|
||||||
|
operationId: setWakePolicy
|
||||||
|
summary: Save platform startup policy (Owner, fresh reauthentication).
|
||||||
|
description: Atomically persists an override shared by all replicas. The admission limit applies to panel, in-game and scheduled starts without restarting. It does not stop existing servers or reserve memory. Wake cooldown applies to panel and in-game wakes.
|
||||||
|
x-felis-face: [external]
|
||||||
|
x-felis-tier: owner
|
||||||
|
security: [{ sessionCookie: [] }]
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [maxRunningServers, wakeCooldownSeconds, revision]
|
||||||
|
properties:
|
||||||
|
maxRunningServers: { type: integer, minimum: 0, maximum: 10000 }
|
||||||
|
wakeCooldownSeconds: { type: integer, minimum: 0, maximum: 3600 }
|
||||||
|
revision: { type: string }
|
||||||
|
responses:
|
||||||
|
'200':
|
||||||
|
description: Saved policy and new revision.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/WakePolicySettings' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'409':
|
||||||
|
description: The policy changed; reload before saving.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
|
||||||
/api/v1/settings/auth-sources:
|
/api/v1/settings/auth-sources:
|
||||||
get:
|
get:
|
||||||
tags: [account]
|
tags: [account]
|
||||||
|
|||||||
+35
-1
@@ -326,9 +326,23 @@ per-server cooldown → global running cap**. Map the API result:
|
|||||||
| `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` |
|
| `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` |
|
||||||
| `409` | `maintenance_in_progress` | A restore, backup or file change holds the server's world volume (§3b) | Wait for the Job to finish |
|
| `409` | `maintenance_in_progress` | A restore, backup or file change holds the server's world volume (§3b) | Wait for the Job to finish |
|
||||||
| `409` | `world_reclaiming` | The idle reaper is archiving the world (§3b item 3); afterwards the server is released with an empty world | Nothing to wait for; the old world stays in the archive |
|
| `409` | `world_reclaiming` | The idle reaper is archiving the world (§3b item 3); afterwards the server is released with an empty world | Nothing to wait for; the old world stays in the archive |
|
||||||
| `429` | (cooldown) | Wake retried within the 30s per-server `WakeCooldown` | Wait out the cooldown |
|
| `429` | (cooldown) | Wake retried within the configured per-server cooldown | Wait out the cooldown |
|
||||||
| `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap |
|
| `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap |
|
||||||
|
|
||||||
|
Owners can change the running-server limit and wake cooldown in **Platform
|
||||||
|
settings**. Saved values apply on all API replicas without a restart; the running
|
||||||
|
limit also applies to scheduled starts. Zero disables the corresponding control.
|
||||||
|
The running limit is an admission check, not an atomic reservation: concurrent
|
||||||
|
requests can briefly exceed it, and lowering it never stops existing servers.
|
||||||
|
|
||||||
|
A server marked Starting need not have launched Java. Its console now reads Pod
|
||||||
|
scheduling and container state: `Unschedulable` / insufficient memory means it is
|
||||||
|
waiting for resources, while image-pull failures and container exits show their
|
||||||
|
reasons. Logs attach once the container can produce them. A status request that
|
||||||
|
cannot complete within 12 seconds marks the last view as stale, shows its last
|
||||||
|
successful read time, and retries without overlapping polls. Do not infer game
|
||||||
|
readiness from an old Starting label or a disconnected log stream.
|
||||||
|
|
||||||
[GO-TESTED: `handlers_internal_wake_test.go`, cooldown, running-cap shape.] The
|
[GO-TESTED: `handlers_internal_wake_test.go`, cooldown, running-cap shape.] The
|
||||||
operator's RCON probe — **not** the wake call — is the authoritative readiness
|
operator's RCON probe — **not** the wake call — is the authoritative readiness
|
||||||
gate; the proxy polls `GET /api/v1/internal/servers/{name}/status` every ~2s and
|
gate; the proxy polls `GET /api/v1/internal/servers/{name}/status` every ~2s and
|
||||||
@@ -3406,3 +3420,23 @@ PG-TESTED: `TestScheduleStoreRunCAS`, `TestDueSchedules`, `TestSchedulesFollowTh
|
|||||||
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
|
| How long sessions, codes and audit rows are kept; export audit rows | §17 |
|
||||||
| Files page: a change, upload or unzip refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `upload_not_found`, `volume_full`, `archive_unsafe`, `job_failed`, `files_timeout`) | §18 |
|
| Files page: a change, upload or unzip refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `upload_not_found`, `volume_full`, `archive_unsafe`, `job_failed`, `files_timeout`) | §18 |
|
||||||
| A scheduled task shows `skipped`, `missed` or `failed`; a task switched itself off after an owner change | §19 |
|
| A scheduled task shows `skipped`, `missed` or `failed`; a task switched itself off after an owner change | §19 |
|
||||||
|
|
||||||
|
### 面板无法确认状态与应急停止
|
||||||
|
|
||||||
|
控制 API 超时或失联时,最后一次读取的状态仅作参考;“启动中”不能表示
|
||||||
|
游戏进程仍然健康。控制台保留已有日志,显示状态已过期和最后读取时间,
|
||||||
|
并自动重试。首次读取失败也提供 Owner 可见的主机侧恢复步骤。
|
||||||
|
|
||||||
|
普通停止由 Operator 执行。Owner 可以在服务器控制台的“更多操作”中选择
|
||||||
|
“应急停止”,输入服务器名并重新验证身份。它先持久保存 `desiredState=Stopped`,
|
||||||
|
再通过 Kubernetes 的 scale 子资源将该 MinecraftServer 自己拥有的 StatefulSet
|
||||||
|
缩到零,绕过游戏 RCON 和 Operator,但保留正常 Pod 终止时间,不删除世界。
|
||||||
|
如果缩容失败,已保存的停止意图不会撤销;界面明确提示结果未确认。
|
||||||
|
受理请求不等于游戏已停止:状态读取独立检查 StatefulSet 副本目标和游戏 Pod,
|
||||||
|
在副本目标仍非零或游戏 Pod 仍运行/退出时显示“停止中”。
|
||||||
|
|
||||||
|
控制 API 自己离线时,浏览器不能凭空执行集群操作。Owner 可展开主机侧恢复
|
||||||
|
步骤,在部署主机依次保存停止意图、缩容并检查 Pod(命名空间不是 `minecraft`
|
||||||
|
时需要替换)。节点离线时还需检查节点和容器运行时;强制删除 Pod 不是
|
||||||
|
进程已经退出的证据。不要靠强制删除世界、Pod 或 PVC 解决控制端失联。
|
||||||
|
应急停止入口的 Kubernetes 权限需要随平台更新应用新的 RBAC。
|
||||||
+9
-9
@@ -177,12 +177,9 @@ type API struct {
|
|||||||
SubmitCreateCooldown time.Duration
|
SubmitCreateCooldown time.Duration
|
||||||
SubmitUploadCooldown time.Duration
|
SubmitUploadCooldown time.Duration
|
||||||
|
|
||||||
// MaxRunningServers caps how many servers may be desired-Running cluster-wide
|
// MaxRunningServers is the default desired-Running admission limit (spec §9.1).
|
||||||
// (spec §9.1: the concurrency-上限 lever hanging on the same wake chokepoint as
|
// Zero disables it. A saved Owner wake policy overrides this and WakeCooldown;
|
||||||
// cooldown and autostartPolicy). Zero — the default — disables it: §9.2 wires
|
// panel, in-game and scheduled starts share withinRunningCap.
|
||||||
// only autostartPolicy + cooldown as active wake gates, so this lever ships
|
|
||||||
// inert, exactly like a zero WakeCooldown, and a deployment opts in by setting
|
|
||||||
// a positive value. Enforced via withinRunningCap on the wake path.
|
|
||||||
MaxRunningServers int
|
MaxRunningServers int
|
||||||
|
|
||||||
// MaxStreamsPerPrincipal caps how many concurrent Server-Sent Event streams
|
// MaxStreamsPerPrincipal caps how many concurrent Server-Sent Event streams
|
||||||
@@ -538,6 +535,7 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// App-auth tier: operations on your own servers (spec §14).
|
// App-auth tier: operations on your own servers (spec §14).
|
||||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/wake", h: a.handleWake},
|
||||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/stop", h: a.handleStop},
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/stop", h: a.handleStop},
|
||||||
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/emergency-stop", h: a.handleEmergencyStop, Admin: true, Owner: true},
|
||||||
{Method: "POST", Pattern: "/api/v1/servers/{name}/claim", h: a.handleClaim},
|
{Method: "POST", Pattern: "/api/v1/servers/{name}/claim", h: a.handleClaim},
|
||||||
// Console write (spec §8 写=RCON): owner/admin-gated inside the handler, so
|
// Console write (spec §8 写=RCON): owner/admin-gated inside the handler, so
|
||||||
// it sits in the app-tier block (操作自己服 → app 鉴权), not behind adminOnly.
|
// it sits in the app-tier block (操作自己服 → app 鉴权), not behind adminOnly.
|
||||||
@@ -652,6 +650,8 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
|||||||
// Staff can designate their own game identity after panel setup. Players
|
// Staff can designate their own game identity after panel setup. Players
|
||||||
// retain the in-game proof flow above.
|
// retain the in-game proof flow above.
|
||||||
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
|
{Method: "GET", Pattern: "/api/v1/account/link/sources", Admin: true, h: a.handleLinkSources},
|
||||||
|
{Method: "GET", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleGetWakePolicy},
|
||||||
|
{Method: "PUT", Pattern: "/api/v1/settings/wake-policy", Owner: true, Admin: true, h: a.handleSetWakePolicy},
|
||||||
{Method: "GET", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources},
|
{Method: "GET", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleGetAuthSources},
|
||||||
{Method: "PUT", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleSetAuthSources},
|
{Method: "PUT", Pattern: "/api/v1/settings/auth-sources", Owner: true, Admin: true, h: a.handleSetAuthSources},
|
||||||
{Method: "POST", Pattern: "/api/v1/settings/auth-sources/test", Owner: true, Admin: true, h: a.handleTestAuthSource},
|
{Method: "POST", Pattern: "/api/v1/settings/auth-sources/test", Owner: true, Admin: true, h: a.handleTestAuthSource},
|
||||||
@@ -1205,8 +1205,8 @@ func (l *streamLimiter) release(key string) {
|
|||||||
// can momentarily exceed the cap. That is acceptable because the operator
|
// can momentarily exceed the cap. That is acceptable because the operator
|
||||||
// reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state
|
// reconcile is idempotent and the §18 reaper / §9.3 quota bound steady-state
|
||||||
// load; the cap exists to refuse an obvious flood, not to hold a hard ceiling.
|
// load; the cap exists to refuse an obvious flood, not to hold a hard ceiling.
|
||||||
func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, error) {
|
func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo, cap int) (bool, error) {
|
||||||
if a.MaxRunningServers <= 0 || naming.IsSystemServer(info.Name) {
|
if cap <= 0 || naming.IsSystemServer(info.Name) {
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
if info.DesiredState == string(v1alpha1.DesiredRunning) {
|
if info.DesiredState == string(v1alpha1.DesiredRunning) {
|
||||||
@@ -1222,5 +1222,5 @@ func (a *API) withinRunningCap(ctx context.Context, info *ServerInfo) (bool, err
|
|||||||
running++
|
running++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return running < a.MaxRunningServers, nil
|
return running < cap, nil
|
||||||
}
|
}
|
||||||
@@ -57,6 +57,8 @@ type ServerInfo struct {
|
|||||||
// Resources is the spec's pod resource block. It stays off the wire; a spec
|
// Resources is the spec's pod resource block. It stays off the wire; a spec
|
||||||
// patch reads it so the fields the admin left out keep their values.
|
// patch reads it so the fields the admin left out keep their values.
|
||||||
Resources corev1.ResourceRequirements `json:"-"`
|
Resources corev1.ResourceRequirements `json:"-"`
|
||||||
|
// Startup explains scheduling and container state independently of the CR phase.
|
||||||
|
Startup *StartupStatus `json:"startup,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateServerInput is the validated, structured create-server form (spec §15).
|
// CreateServerInput is the validated, structured create-server form (spec §15).
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
autoscalingv1 "k8s.io/api/autoscaling/v1"
|
||||||
|
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"k8s.io/client-go/util/retry"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EmergencyStop records the stop intent before scaling the owned workload. It
|
||||||
|
// bypasses RCON and operator reconciliation, retaining normal Pod shutdown grace.
|
||||||
|
func (k *K8sCluster) EmergencyStop(ctx context.Context, name string) error {
|
||||||
|
if err := k.SetDesiredState(ctx, name, v1alpha1.DesiredStopped); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return retry.RetryOnConflict(retry.DefaultRetry, func() error {
|
||||||
|
var ms v1alpha1.MinecraftServer
|
||||||
|
if err := k.getServer(ctx, name, &ms); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if ms.Spec.DesiredState != v1alpha1.DesiredStopped {
|
||||||
|
return newError(http.StatusConflict, "conflict", "stop intent changed; retry emergency stop")
|
||||||
|
}
|
||||||
|
var sts appsv1.StatefulSet
|
||||||
|
if err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &sts); err != nil {
|
||||||
|
if apierrors.IsNotFound(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
owner := metav1.GetControllerOf(&sts)
|
||||||
|
if owner == nil || ms.UID == "" || owner.UID != ms.UID || owner.Kind != "MinecraftServer" || owner.APIVersion != v1alpha1.GroupVersion.String() {
|
||||||
|
return fmt.Errorf("stop intent saved, but workload ownership could not be verified")
|
||||||
|
}
|
||||||
|
scale := &autoscalingv1.Scale{ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: k.namespace, ResourceVersion: sts.ResourceVersion}, Spec: autoscalingv1.ScaleSpec{Replicas: 0}}
|
||||||
|
if err := k.c.SubResource("scale").Update(ctx, &sts, client.WithSubResourceBody(scale)); err != nil {
|
||||||
|
return fmt.Errorf("stop intent saved, but workload scale-down was not confirmed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleEmergencyStop(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireReauth(w, r, principalFromContext(r.Context())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if err := validateManagedServerName(r, name); err != nil {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_name", "invalid server name"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
Confirm string `json:"confirm"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Confirm != name {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "type the exact server name to confirm"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stopper, ok := a.Cluster.(interface {
|
||||||
|
EmergencyStop(context.Context, string) error
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
writeError(w, r, newError(http.StatusServiceUnavailable, "unavailable", "this cluster does not support direct emergency shutdown"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := stopper.EmergencyStop(ctx, name); err != nil {
|
||||||
|
a.audit(r, "emergency_stop.failed", name)
|
||||||
|
a.writeLookupError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, "emergency_stop", name)
|
||||||
|
writeJSON(w, http.StatusAccepted, map[string]any{"name": name, "desiredState": "Stopped"})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"k8s.io/apimachinery/pkg/types"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/interceptor"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestEmergencyStopIndependentOfOperator(t *testing.T) {
|
||||||
|
for _, mode := range []string{"success", "wrong owner", "scale unavailable", "missing workload"} {
|
||||||
|
t.Run(mode, func(t *testing.T) {
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
v1alpha1.AddToScheme(scheme)
|
||||||
|
appsv1.AddToScheme(scheme)
|
||||||
|
corev1.AddToScheme(scheme)
|
||||||
|
ms := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: "survival", Namespace: "minecraft", UID: "server-uid"}, Spec: v1alpha1.MinecraftServerSpec{DesiredState: v1alpha1.DesiredRunning}, Status: v1alpha1.MinecraftServerStatus{Phase: v1alpha1.PhaseRunning}}
|
||||||
|
replicas := int32(1)
|
||||||
|
sts := &appsv1.StatefulSet{ObjectMeta: metav1.ObjectMeta{Name: ms.Name, Namespace: ms.Namespace, OwnerReferences: []metav1.OwnerReference{*metav1.NewControllerRef(ms, v1alpha1.GroupVersion.WithKind("MinecraftServer"))}}, Spec: appsv1.StatefulSetSpec{Replicas: &replicas}}
|
||||||
|
if mode == "wrong owner" {
|
||||||
|
sts.OwnerReferences[0].UID = "unrelated"
|
||||||
|
}
|
||||||
|
builder := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ms)
|
||||||
|
if mode != "missing workload" {
|
||||||
|
builder = builder.WithObjects(sts)
|
||||||
|
}
|
||||||
|
if mode == "scale unavailable" {
|
||||||
|
builder = builder.WithInterceptorFuncs(interceptor.Funcs{SubResourceUpdate: func(context.Context, client.Client, string, client.Object, ...client.SubResourceUpdateOption) error {
|
||||||
|
return errors.New("kubernetes unavailable")
|
||||||
|
}})
|
||||||
|
}
|
||||||
|
c := builder.Build()
|
||||||
|
k := NewK8sCluster(c, "minecraft")
|
||||||
|
ctx := context.Background()
|
||||||
|
err := k.EmergencyStop(ctx, ms.Name)
|
||||||
|
if (err != nil) != (mode == "wrong owner" || mode == "scale unavailable") {
|
||||||
|
t.Fatal(mode, err)
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx, client.ObjectKeyFromObject(ms), ms); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ms.Spec.DesiredState != v1alpha1.DesiredStopped || ms.Status.Phase != v1alpha1.PhaseRunning {
|
||||||
|
t.Fatal("stop intent missing or status falsely rewritten", ms)
|
||||||
|
}
|
||||||
|
if mode != "success" && mode != "missing workload" {
|
||||||
|
if err := c.Get(ctx, client.ObjectKeyFromObject(sts), sts); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if *sts.Spec.Replicas != 1 {
|
||||||
|
t.Fatal("unsafe scaling")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if mode == "success" {
|
||||||
|
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: ms.Name + "-0", Namespace: ms.Namespace, Labels: map[string]string{v1alpha1.LabelServer: ms.Name, v1alpha1.LabelComponent: gamePodComponent}}, Status: corev1.PodStatus{Phase: corev1.PodRunning}}
|
||||||
|
if err := c.Create(ctx, pod); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
info, err := k.GetServer(ctx, ms.Name)
|
||||||
|
if err != nil || info.Phase != "Stopping" {
|
||||||
|
t.Fatal("running process falsely stopped", info, err)
|
||||||
|
}
|
||||||
|
if err := c.Delete(ctx, pod); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := c.Get(ctx, types.NamespacedName{Namespace: ms.Namespace, Name: ms.Name}, sts); err != nil || *sts.Spec.Replicas != 0 {
|
||||||
|
t.Fatal(sts, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info, err := k.GetServer(ctx, ms.Name)
|
||||||
|
if err != nil || info.Phase != "Stopped" {
|
||||||
|
t.Fatal("operator-independent confirmation missing", info, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type emergencyFakeCluster struct {
|
||||||
|
*fakeCluster
|
||||||
|
calls int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *emergencyFakeCluster) EmergencyStop(context.Context, string) error { c.calls++; return nil }
|
||||||
|
func TestEmergencyStopAuthorization(t *testing.T) {
|
||||||
|
cl := &emergencyFakeCluster{fakeCluster: newFakeCluster()}
|
||||||
|
a := newTestAPI(newFakeRepo(), cl)
|
||||||
|
path := "/api/v1/servers/survival/emergency-stop"
|
||||||
|
for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} {
|
||||||
|
a.External = staticExternal{p: p}
|
||||||
|
w := do(a.ExternalHandler(), "POST", path, `{"confirm":"survival"}`, jsonHeader)
|
||||||
|
if w.Code != 401 && w.Code != 403 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true, ViaSession: true, EmailVerified: true}
|
||||||
|
a.External = staticExternal{p: p}
|
||||||
|
a.Repo.(*fakeRepo).passkeyCreds["key"] = PasskeyCredential{ID: "key", UserID: p.UserID, UserVerified: true}
|
||||||
|
if w := do(a.ExternalHandler(), "POST", path, `{"confirm":"survival"}`, jsonHeader); w.Code != 403 || decodeErr(t, w) != "reauth_required" {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
p.ReauthAt = a.now()
|
||||||
|
if w := do(a.ExternalHandler(), "POST", path, `{"confirm":"wrong"}`, jsonHeader); w.Code != 400 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
if cl.calls != 0 {
|
||||||
|
t.Fatal("unconfirmed stop ran")
|
||||||
|
}
|
||||||
|
if w := do(a.ExternalHandler(), "POST", path, `{"confirm":"survival"}`, jsonHeader); w.Code != 202 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if cl.calls != 1 {
|
||||||
|
t.Fatal(cl.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,7 +173,12 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
|
|||||||
"the server failed to start and its automatic retries are spent; its owner can retry from the panel"))
|
"the server failed to start and its automatic retries are spent; its owner can retry from the panel"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.limiter().allowed(name, a.WakeCooldown) {
|
policy, _, err := a.readWakePolicy(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.limiter().allowed(name, policy.cooldown(a.WakeCooldown)) {
|
||||||
writeError(w, r, newError(http.StatusTooManyRequests, "cooldown", "wake is cooling down, retry shortly"))
|
writeError(w, r, newError(http.StatusTooManyRequests, "cooldown", "wake is cooling down, retry shortly"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -181,7 +186,7 @@ func (a *API) handleInternalWake(w http.ResponseWriter, r *http.Request) {
|
|||||||
// treats 503 at_capacity as "cluster full, tell the player to try later" and does
|
// treats 503 at_capacity as "cluster full, tell the player to try later" and does
|
||||||
// NOT enqueue them (nothing is coming up, so waiting would only strand them),
|
// NOT enqueue them (nothing is coming up, so waiting would only strand them),
|
||||||
// distinct from the 429 cooldown's "already waking, keep waiting".
|
// distinct from the 429 cooldown's "already waking, keep waiting".
|
||||||
ok, err := a.withinRunningCap(r.Context(), info)
|
ok, err := a.withinRunningCap(r.Context(), info, policy.MaxRunningServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -46,13 +46,18 @@ func (a *API) handleWake(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeError(w, r, errServerRetiring)
|
writeError(w, r, errServerRetiring)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !a.limiter().allowed(name, a.WakeCooldown) {
|
policy, _, err := a.readWakePolicy(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !a.limiter().allowed(name, policy.cooldown(a.WakeCooldown)) {
|
||||||
writeError(w, r, newError(http.StatusTooManyRequests, "cooldown", "wake is cooling down, retry shortly"))
|
writeError(w, r, newError(http.StatusTooManyRequests, "cooldown", "wake is cooling down, retry shortly"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Global running-server cap (spec §9.1). Distinct from the per-server cooldown:
|
// Global running-server cap (spec §9.1). Distinct from the per-server cooldown:
|
||||||
// 503 at_capacity means the cluster is full, not that this server is throttled.
|
// 503 at_capacity means the cluster is full, not that this server is throttled.
|
||||||
ok, err := a.withinRunningCap(r.Context(), info)
|
ok, err := a.withinRunningCap(r.Context(), info, policy.MaxRunningServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, r, err)
|
writeError(w, r, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wakePolicyKey = "wake_policy"
|
||||||
|
|
||||||
|
type wakePolicy struct {
|
||||||
|
MaxRunningServers int `json:"maxRunningServers"`
|
||||||
|
WakeCooldownSeconds int `json:"wakeCooldownSeconds"`
|
||||||
|
}
|
||||||
|
type wakePolicyView struct {
|
||||||
|
wakePolicy
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
Managed bool `json:"managed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) readWakePolicy(ctx context.Context) (wakePolicyView, []byte, error) {
|
||||||
|
view := wakePolicyView{wakePolicy: wakePolicy{a.MaxRunningServers, int(a.WakeCooldown / time.Second)}}
|
||||||
|
raw, err := a.Repo.GetSetting(ctx, wakePolicyKey)
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, ErrNotFound):
|
||||||
|
raw = nil
|
||||||
|
case err != nil:
|
||||||
|
return view, nil, err
|
||||||
|
default:
|
||||||
|
if err := json.Unmarshal(raw, &view.wakePolicy); err != nil {
|
||||||
|
return view, nil, err
|
||||||
|
}
|
||||||
|
if !view.wakePolicy.valid() {
|
||||||
|
return view, nil, errors.New("invalid persisted wake policy")
|
||||||
|
}
|
||||||
|
view.Managed = true
|
||||||
|
}
|
||||||
|
canonical, _ := json.Marshal(view.wakePolicy)
|
||||||
|
sum := sha256.Sum256(canonical)
|
||||||
|
view.Revision = hex.EncodeToString(sum[:])
|
||||||
|
return view, raw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleGetWakePolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
view, _, err := a.readWakePolicy(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) handleSetWakePolicy(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !a.requireReauth(w, r, principalFromContext(r.Context())) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := requireJSONContentType(r); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var body struct {
|
||||||
|
wakePolicy
|
||||||
|
Revision string `json:"revision"`
|
||||||
|
}
|
||||||
|
if err := decodeJSON(w, r, &body); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !body.wakePolicy.valid() {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "running limit must be 0–10000 and wake cooldown 0–3600 seconds"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current, expected, err := a.readWakePolicy(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if body.Revision != current.Revision {
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "conflict", "platform policy changed; reload before saving"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(body.wakePolicy)
|
||||||
|
if err := a.Repo.CompareAndSetSetting(r.Context(), wakePolicyKey, expected, raw); err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.audit(r, "platform.wake_policy", "platform")
|
||||||
|
view, _, err := a.readWakePolicy(r.Context())
|
||||||
|
if err != nil {
|
||||||
|
writeError(w, r, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, view)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p wakePolicyView) cooldown(fallback time.Duration) time.Duration {
|
||||||
|
if !p.Managed {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return time.Duration(p.WakeCooldownSeconds) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p wakePolicy) valid() bool {
|
||||||
|
return p.MaxRunningServers >= 0 && p.MaxRunningServers <= 10000 && p.WakeCooldownSeconds >= 0 && p.WakeCooldownSeconds <= 3600
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
)
|
||||||
|
|
||||||
|
const wakePolicyPath = "/api/v1/settings/wake-policy"
|
||||||
|
|
||||||
|
func TestWakePolicyPersistenceAndAdmission(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
cl := newFakeCluster()
|
||||||
|
cl.byName["survival"] = &ServerInfo{Name: "survival", AutostartPolicy: "public", Phase: "Stopped", DesiredState: "Stopped"}
|
||||||
|
cl.list = []ServerInfo{{Name: "other", DesiredState: "Running"}}
|
||||||
|
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner"}
|
||||||
|
a := newTestAPI(repo, cl)
|
||||||
|
a.External = staticExternal{p: &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true}}
|
||||||
|
view, _, err := a.readWakePolicy(context.Background())
|
||||||
|
if err != nil || view.Managed {
|
||||||
|
t.Fatal(view, err)
|
||||||
|
}
|
||||||
|
save := func(view wakePolicyView) int {
|
||||||
|
body, _ := json.Marshal(map[string]any{"maxRunningServers": view.MaxRunningServers, "wakeCooldownSeconds": view.WakeCooldownSeconds, "revision": view.Revision})
|
||||||
|
return do(a.ExternalHandler(), "PUT", wakePolicyPath, string(body), jsonHeader).Code
|
||||||
|
}
|
||||||
|
view.MaxRunningServers = 1
|
||||||
|
view.WakeCooldownSeconds = 60
|
||||||
|
if code := save(view); code != 200 {
|
||||||
|
t.Fatal("save", code)
|
||||||
|
}
|
||||||
|
if code := save(view); code != 409 {
|
||||||
|
t.Fatal("stale write", code)
|
||||||
|
}
|
||||||
|
replica := newTestAPI(repo, cl)
|
||||||
|
replica.External = a.External
|
||||||
|
persisted, _, err := replica.readWakePolicy(context.Background())
|
||||||
|
if err != nil || !persisted.Managed || persisted.MaxRunningServers != 1 || persisted.cooldown(0) != time.Minute {
|
||||||
|
t.Fatal(persisted, err)
|
||||||
|
}
|
||||||
|
if w := do(replica.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != 503 || decodeErr(t, w) != "at_capacity" {
|
||||||
|
t.Fatal("panel cap", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := internalWake(replica, `{"mc_uuid":"`+wakeUUID+`"}`); w.Code != 503 || decodeErr(t, w) != "at_capacity" {
|
||||||
|
t.Fatal("game cap", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if why, retry, err := replica.startScheduled(context.Background(), "survival"); err != nil || !retry || why == "" {
|
||||||
|
t.Fatal("scheduled cap", why, retry, err)
|
||||||
|
}
|
||||||
|
persisted.MaxRunningServers = 2
|
||||||
|
if code := save(persisted); code != 200 {
|
||||||
|
t.Fatal("increase cap", code)
|
||||||
|
}
|
||||||
|
if w := do(replica.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != 202 || cl.desired["survival"] != v1alpha1.DesiredRunning {
|
||||||
|
t.Fatal("wake", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := internalWake(replica, `{"mc_uuid":"`+wakeUUID+`"}`); w.Code != 429 {
|
||||||
|
t.Fatal("shared saved cooldown", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
repo.failGetSetting = errors.New("database down")
|
||||||
|
if w := do(replica.ExternalHandler(), "POST", "/api/v1/servers/survival/wake", "", nil); w.Code != 500 {
|
||||||
|
t.Fatal("outage failed open", w.Code)
|
||||||
|
}
|
||||||
|
repo.failGetSetting = nil
|
||||||
|
repo.settings[wakePolicyKey] = []byte(`{"maxRunningServers":-1}`)
|
||||||
|
if _, _, err := replica.readWakePolicy(context.Background()); err == nil {
|
||||||
|
t.Fatal("invalid persisted policy failed open")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWakePolicyAuthorizationAndValidation(t *testing.T) {
|
||||||
|
a := newTestAPI(newFakeRepo(), newFakeCluster())
|
||||||
|
for _, p := range []*Principal{nil, {UserID: "admin", Role: "admin", ViaAdminAccess: true}, {UserID: "owner", Role: "owner"}} {
|
||||||
|
a.External = staticExternal{p: p}
|
||||||
|
for _, method := range []string{"GET", "PUT"} {
|
||||||
|
if w := do(a.ExternalHandler(), method, wakePolicyPath, `{}`, jsonHeader); w.Code != 401 && w.Code != 403 {
|
||||||
|
t.Fatalf("unauthorized %+v: %d", p, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p := &Principal{UserID: "owner", Role: "owner", ViaAdminAccess: true, ViaSession: true, EmailVerified: true}
|
||||||
|
a.External = staticExternal{p: p}
|
||||||
|
a.Repo.(*fakeRepo).passkeyCreds["owner-key"] = PasskeyCredential{ID: "owner-key", UserID: p.UserID, UserVerified: true}
|
||||||
|
if w := do(a.ExternalHandler(), "PUT", wakePolicyPath, `{}`, jsonHeader); w.Code != 403 || decodeErr(t, w) != "reauth_required" {
|
||||||
|
t.Fatal("reauth not enforced", w.Code)
|
||||||
|
}
|
||||||
|
p.ReauthAt = a.now()
|
||||||
|
for _, body := range []string{`{"maxRunningServers":-1}`, `{"wakeCooldownSeconds":3601}`, `{"maxRunningServers":1.5}`} {
|
||||||
|
if w := do(a.ExternalHandler(), "PUT", wakePolicyPath, body, jsonHeader); w.Code != 400 {
|
||||||
|
t.Fatal("invalid policy", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"felis.lolicon.best/internal/maintenance"
|
"felis.lolicon.best/internal/maintenance"
|
||||||
"felis.lolicon.best/internal/naming"
|
"felis.lolicon.best/internal/naming"
|
||||||
"felis.lolicon.best/internal/placement"
|
"felis.lolicon.best/internal/placement"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
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"
|
||||||
@@ -98,7 +99,34 @@ func (k *K8sCluster) GetServer(ctx context.Context, name string) (*ServerInfo, e
|
|||||||
}
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return serverInfo(&ms), nil
|
info := serverInfo(&ms)
|
||||||
|
if ms.Spec.DesiredState != v1alpha1.DesiredStopped && ms.Status.Phase != v1alpha1.PhaseStarting && ms.Status.Phase != v1alpha1.PhaseFailed && ms.Status.Phase != v1alpha1.PhaseRunning {
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
var pods corev1.PodList
|
||||||
|
if err := k.c.List(ctx, &pods, client.InNamespace(k.namespace), client.MatchingLabels{v1alpha1.LabelServer: name, v1alpha1.LabelComponent: gamePodComponent}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if ms.Spec.DesiredState == v1alpha1.DesiredStopped {
|
||||||
|
info.Phase, info.Ready = string(v1alpha1.PhaseStopped), false
|
||||||
|
var sts appsv1.StatefulSet
|
||||||
|
err := k.c.Get(ctx, types.NamespacedName{Namespace: k.namespace, Name: name}, &sts)
|
||||||
|
if err != nil && !apierrors.IsNotFound(err) {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(pods.Items) > 0 || (err == nil && (sts.Spec.Replicas == nil || *sts.Spec.Replicas != 0)) {
|
||||||
|
info.Phase = string(v1alpha1.PhaseStopping)
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
|
}
|
||||||
|
diagnostic := startupStatus(&ms, pods.Items)
|
||||||
|
if ms.Status.Phase != v1alpha1.PhaseRunning || diagnostic.Stage == "failed" || diagnostic.Stage == "scheduling" || diagnostic.Stage == "creating" {
|
||||||
|
info.Startup = diagnostic
|
||||||
|
if ms.Status.Phase == v1alpha1.PhaseRunning {
|
||||||
|
info.Ready, info.Phase = false, string(v1alpha1.PhaseFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return info, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// WorldVolumeExists reads the world PVC the operator's StatefulSet
|
// WorldVolumeExists reads the world PVC the operator's StatefulSet
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/maintenance"
|
"felis.lolicon.best/internal/maintenance"
|
||||||
"felis.lolicon.best/internal/naming"
|
"felis.lolicon.best/internal/naming"
|
||||||
|
appsv1 "k8s.io/api/apps/v1"
|
||||||
batchv1 "k8s.io/api/batch/v1"
|
batchv1 "k8s.io/api/batch/v1"
|
||||||
corev1 "k8s.io/api/core/v1"
|
corev1 "k8s.io/api/core/v1"
|
||||||
"k8s.io/apimachinery/pkg/api/resource"
|
"k8s.io/apimachinery/pkg/api/resource"
|
||||||
@@ -114,6 +115,8 @@ func TestCreateServerDefaultsIdleStop(t *testing.T) {
|
|||||||
if err := v1alpha1.AddToScheme(scheme); err != nil {
|
if err := v1alpha1.AddToScheme(scheme); err != nil {
|
||||||
t.Fatalf("scheme: %v", err)
|
t.Fatalf("scheme: %v", err)
|
||||||
}
|
}
|
||||||
|
corev1.AddToScheme(scheme)
|
||||||
|
appsv1.AddToScheme(scheme)
|
||||||
c := fake.NewClientBuilder().WithScheme(scheme).Build()
|
c := fake.NewClientBuilder().WithScheme(scheme).Build()
|
||||||
k := NewK8sCluster(c, "minecraft")
|
k := NewK8sCluster(c, "minecraft")
|
||||||
if err := k.CreateServer(context.Background(), CreateServerInput{
|
if err := k.CreateServer(context.Background(), CreateServerInput{
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ func (k *K8sLogStreamer) StreamLogs(ctx context.Context, name string) (io.ReadCl
|
|||||||
// the <name>-0 pod naming. The name is DNS-1123-validated upstream, so it is a
|
// the <name>-0 pod naming. The name is DNS-1123-validated upstream, so it is a
|
||||||
// safe label-selector value.
|
// safe label-selector value.
|
||||||
pods, err := k.clientset.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{
|
pods, err := k.clientset.CoreV1().Pods(k.namespace).List(ctx, metav1.ListOptions{
|
||||||
LabelSelector: v1alpha1.LabelServer + "=" + name,
|
LabelSelector: v1alpha1.LabelServer + "=" + name + "," + v1alpha1.LabelComponent + "=" + gamePodComponent,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, ErrConsoleUnavailable
|
return nil, ErrConsoleUnavailable
|
||||||
|
|||||||
@@ -431,7 +431,11 @@ func (a *API) startScheduled(ctx context.Context, name string) (why string, retr
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
ok, err := a.withinRunningCap(ctx, info)
|
policy, _, err := a.readWakePolicy(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return "", false, err
|
||||||
|
}
|
||||||
|
ok, err := a.withinRunningCap(ctx, info, policy.MaxRunningServers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, err
|
return "", false, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/api/meta"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StartupStatus struct {
|
||||||
|
Stage string `json:"stage"`
|
||||||
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Message string `json:"message,omitempty"`
|
||||||
|
StartedAt string `json:"startedAt,omitempty"`
|
||||||
|
LogsAvailable bool `json:"logsAvailable"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func startupStatus(ms *v1alpha1.MinecraftServer, pods []corev1.Pod) *StartupStatus {
|
||||||
|
s := &StartupStatus{Stage: "creating"}
|
||||||
|
if ms.Status.Phase == v1alpha1.PhaseFailed {
|
||||||
|
s.Stage = "failed"
|
||||||
|
}
|
||||||
|
if ms.Status.StartRequestedAt != nil {
|
||||||
|
s.StartedAt = ms.Status.StartRequestedAt.UTC().Format("2006-01-02T15:04:05Z")
|
||||||
|
}
|
||||||
|
if c := meta.FindStatusCondition(ms.Status.Conditions, v1alpha1.ConditionReady); c != nil {
|
||||||
|
s.Reason, s.Message = c.Reason, c.Message
|
||||||
|
}
|
||||||
|
var p *corev1.Pod
|
||||||
|
for i := range pods {
|
||||||
|
if pods[i].DeletionTimestamp == nil && (p == nil || pods[i].CreationTimestamp.After(p.CreationTimestamp.Time)) {
|
||||||
|
p = &pods[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if p == nil {
|
||||||
|
if ms.Status.Phase == v1alpha1.PhaseRunning {
|
||||||
|
s.Stage, s.Reason, s.Message = "failed", "GamePodMissing", "game Pod is missing; recorded Running status is no longer confirmed"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
s.Stage = "preparing"
|
||||||
|
if p.Status.Phase == corev1.PodFailed {
|
||||||
|
s.Stage, s.Reason, s.Message = "failed", p.Status.Reason, p.Status.Message
|
||||||
|
}
|
||||||
|
for _, c := range p.Status.Conditions {
|
||||||
|
if c.Type == corev1.PodReady && (c.Status == corev1.ConditionUnknown || (ms.Status.Phase == v1alpha1.PhaseRunning && c.Status == corev1.ConditionFalse && p.Status.Phase == corev1.PodRunning)) {
|
||||||
|
s.Stage, s.Reason, s.Message = "failed", c.Reason, c.Message
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
if c.Type == corev1.PodScheduled && c.Status == corev1.ConditionFalse {
|
||||||
|
s.Stage, s.Reason, s.Message = "scheduling", c.Reason, c.Message
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.LogsAvailable = p.Status.Phase == corev1.PodRunning
|
||||||
|
for _, c := range slices.Concat(p.Status.InitContainerStatuses, p.Status.ContainerStatuses) {
|
||||||
|
if c.State.Waiting != nil {
|
||||||
|
switch c.State.Waiting.Reason {
|
||||||
|
case "CrashLoopBackOff", "ImagePullBackOff", "ErrImagePull", "CreateContainerConfigError", "RunContainerError":
|
||||||
|
s.Stage, s.Reason, s.Message = "failed", c.State.Waiting.Reason, c.State.Waiting.Message
|
||||||
|
default:
|
||||||
|
if s.Stage != "failed" {
|
||||||
|
s.Reason, s.Message = c.State.Waiting.Reason, c.State.Waiting.Message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.State.Terminated != nil && (c.State.Terminated.ExitCode != 0 || c.Name == serverLogContainer) {
|
||||||
|
s.Stage, s.Reason, s.Message = "failed", c.State.Terminated.Reason, c.State.Terminated.Message
|
||||||
|
if s.Message == "" {
|
||||||
|
s.Message = fmt.Sprintf("Container %s exited with code %d", c.Name, c.State.Terminated.ExitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if c.Name == serverLogContainer && c.State.Running != nil {
|
||||||
|
if s.Stage != "failed" {
|
||||||
|
s.Stage = "booting"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
|
corev1 "k8s.io/api/core/v1"
|
||||||
|
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||||
|
"k8s.io/apimachinery/pkg/runtime"
|
||||||
|
"sigs.k8s.io/controller-runtime/pkg/client/fake"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStartupDiagnostics(t *testing.T) {
|
||||||
|
started := metav1.NewTime(time.Now().UTC().Truncate(time.Second))
|
||||||
|
ms := &v1alpha1.MinecraftServer{ObjectMeta: metav1.ObjectMeta{Name: "survival", Namespace: "minecraft"}, Status: v1alpha1.MinecraftServerStatus{Phase: v1alpha1.PhaseStarting, StartRequestedAt: &started}}
|
||||||
|
cases := []struct {
|
||||||
|
name, stage, reason string
|
||||||
|
status corev1.PodStatus
|
||||||
|
logs bool
|
||||||
|
}{
|
||||||
|
{name: "scheduling memory", stage: "scheduling", reason: "Unschedulable", status: corev1.PodStatus{Conditions: []corev1.PodCondition{{Type: corev1.PodScheduled, Status: corev1.ConditionFalse, Reason: "Unschedulable", Message: "Insufficient memory"}}}},
|
||||||
|
{name: "image failure", stage: "failed", reason: "ImagePullBackOff", status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{Name: serverLogContainer, State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "ImagePullBackOff", Message: "image unavailable"}}}}}},
|
||||||
|
{name: "process crash", stage: "failed", reason: "OOMKilled", status: corev1.PodStatus{ContainerStatuses: []corev1.ContainerStatus{{Name: serverLogContainer, State: corev1.ContainerState{Terminated: &corev1.ContainerStateTerminated{Reason: "OOMKilled", ExitCode: 137}}}}}},
|
||||||
|
{name: "evicted", stage: "failed", reason: "Evicted", status: corev1.PodStatus{Phase: corev1.PodFailed, Reason: "Evicted", Message: "node memory pressure"}},
|
||||||
|
{name: "init failure", stage: "failed", reason: "CrashLoopBackOff", status: corev1.PodStatus{InitContainerStatuses: []corev1.ContainerStatus{{Name: "prepare", State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "CrashLoopBackOff", Message: "preparation failed"}}}}, ContainerStatuses: []corev1.ContainerStatus{{Name: serverLogContainer, State: corev1.ContainerState{Waiting: &corev1.ContainerStateWaiting{Reason: "PodInitializing"}}}}}},
|
||||||
|
{name: "node lost", stage: "failed", reason: "NodeNotReady", status: corev1.PodStatus{Phase: corev1.PodRunning, Conditions: []corev1.PodCondition{{Type: corev1.PodReady, Status: corev1.ConditionUnknown, Reason: "NodeNotReady", Message: "node heartbeat lost"}}}},
|
||||||
|
{name: "booting", stage: "booting", logs: true, status: corev1.PodStatus{Phase: corev1.PodRunning, ContainerStatuses: []corev1.ContainerStatus{{Name: serverLogContainer, State: corev1.ContainerState{Running: &corev1.ContainerStateRunning{}}}}}},
|
||||||
|
}
|
||||||
|
scheme := runtime.NewScheme()
|
||||||
|
v1alpha1.AddToScheme(scheme)
|
||||||
|
corev1.AddToScheme(scheme)
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pod := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "survival-0", Namespace: "minecraft", Labels: map[string]string{v1alpha1.LabelServer: "survival", v1alpha1.LabelComponent: gamePodComponent}}, Status: tc.status}
|
||||||
|
worker := &corev1.Pod{ObjectMeta: metav1.ObjectMeta{Name: "file-worker", Namespace: "minecraft", CreationTimestamp: started, Labels: map[string]string{v1alpha1.LabelServer: "survival", v1alpha1.LabelComponent: "file-op"}}, Status: corev1.PodStatus{Phase: corev1.PodRunning}}
|
||||||
|
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(ms, pod, worker).Build()
|
||||||
|
info, err := NewK8sCluster(c, "minecraft").GetServer(context.Background(), "survival")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := info.Startup
|
||||||
|
if s == nil || s.Stage != tc.stage || s.Reason != tc.reason || s.LogsAvailable != tc.logs || s.StartedAt != started.UTC().Format(time.RFC3339) {
|
||||||
|
t.Fatalf("diagnostics=%+v", s)
|
||||||
|
}
|
||||||
|
if tc.reason == "OOMKilled" && !strings.Contains(s.Message, "137") {
|
||||||
|
t.Fatal("missing exit code", s.Message)
|
||||||
|
}
|
||||||
|
if tc.stage == "failed" {
|
||||||
|
msRunning := ms.DeepCopy()
|
||||||
|
msRunning.Status.Phase = v1alpha1.PhaseRunning
|
||||||
|
cRunning := fake.NewClientBuilder().WithScheme(scheme).WithObjects(msRunning, pod).Build()
|
||||||
|
actual, err := NewK8sCluster(cRunning, "minecraft").GetServer(context.Background(), "survival")
|
||||||
|
if err != nil || actual.Phase != "Failed" || actual.Ready || actual.Startup == nil {
|
||||||
|
t.Fatal("stale operator Running concealed runtime failure", actual, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if publicServerInfo(info).Startup != nil {
|
||||||
|
t.Fatal("public status leaked diagnostics")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
running := ms.DeepCopy()
|
||||||
|
running.Status.Phase = v1alpha1.PhaseRunning
|
||||||
|
if s := startupStatus(running, nil); s.Stage != "failed" || s.Reason != "GamePodMissing" {
|
||||||
|
t.Fatal("missing game runtime concealed", s)
|
||||||
|
}
|
||||||
|
s := startupStatus(ms, nil)
|
||||||
|
if s.Stage != "creating" || s.LogsAvailable {
|
||||||
|
t.Fatalf("missing pod=%+v", s)
|
||||||
|
}
|
||||||
|
deleted := corev1.Pod{ObjectMeta: metav1.ObjectMeta{DeletionTimestamp: &started}, Status: cases[0].status}
|
||||||
|
if s := startupStatus(ms, []corev1.Pod{deleted}); s.Stage != "creating" {
|
||||||
|
t.Fatalf("terminating pod reused=%+v", s)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -109,6 +109,9 @@ func APIMinecraftRole(p Params) *rbacv1.Role {
|
|||||||
// restore chains; patch settles a chain by relabelling its safety-snapshot
|
// restore chains; patch settles a chain by relabelling its safety-snapshot
|
||||||
// Job (internal/api restorechain.go).
|
// Job (internal/api restorechain.go).
|
||||||
rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get", "delete", "list", "patch"}),
|
rule([]string{groupBatch}, []string{"jobs"}, []string{"create", "get", "delete", "list", "patch"}),
|
||||||
|
// Emergency shutdown bypasses the operator through the scale subresource.
|
||||||
|
rule([]string{groupApps}, []string{"statefulsets"}, []string{"get"}),
|
||||||
|
rule([]string{groupApps}, []string{"statefulsets/scale"}, []string{"update"}),
|
||||||
// Read-side console (spec §8 读=pods/log follow): list pods to find the
|
// Read-side console (spec §8 读=pods/log follow): list pods to find the
|
||||||
// server's running pod, then read its log subresource. Two separate rules so
|
// server's running pod, then read its log subresource. Two separate rules so
|
||||||
// the verbs stay tight — list on pods, get on pods/log, and nothing else.
|
// the verbs stay tight — list on pods, get on pods/log, and nothing else.
|
||||||
|
|||||||
@@ -127,6 +127,14 @@ func TestAPIRole_MinecraftPowersExact(t *testing.T) {
|
|||||||
if hasRule(mc, groupCore, "secrets", "list") || hasRule(mc, groupCore, "secrets", "watch") {
|
if hasRule(mc, groupCore, "secrets", "list") || hasRule(mc, groupCore, "secrets", "watch") {
|
||||||
t.Error("felis-api must NOT list or watch secrets (RCON reads are a direct Get by name)")
|
t.Error("felis-api must NOT list or watch secrets (RCON reads are a direct Get by name)")
|
||||||
}
|
}
|
||||||
|
if !hasRule(mc, groupApps, "statefulsets", "get") || !hasRule(mc, groupApps, "statefulsets/scale", "update") {
|
||||||
|
t.Error("API needs read and scale-only emergency shutdown grants")
|
||||||
|
}
|
||||||
|
for _, verb := range []string{"patch", "update", "delete"} {
|
||||||
|
if hasRule(mc, groupApps, "statefulsets", verb) {
|
||||||
|
t.Errorf("API must not mutate StatefulSets directly: %s", verb)
|
||||||
|
}
|
||||||
|
}
|
||||||
// WorldVolumeExists reads a claim and lists retained claims on server creation.
|
// WorldVolumeExists reads a claim and lists retained claims on server creation.
|
||||||
if !hasRule(mc, groupCore, "persistentvolumeclaims", "get") {
|
if !hasRule(mc, groupCore, "persistentvolumeclaims", "get") {
|
||||||
t.Error("felis-api must get the world PVC (persistentvolumeclaims:get) for the backup/restore world-volume gate")
|
t.Error("felis-api must get the world PVC (persistentvolumeclaims:get) for the backup/restore world-volume gate")
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { Plugin } from "vite";
|
|||||||
import type {
|
import type {
|
||||||
AuthSourceConfig,
|
AuthSourceConfig,
|
||||||
AuthSourcesSettings,
|
AuthSourcesSettings,
|
||||||
|
WakePolicySettings,
|
||||||
AutostartPolicy,
|
AutostartPolicy,
|
||||||
BackupView,
|
BackupView,
|
||||||
Build,
|
Build,
|
||||||
@@ -92,6 +93,7 @@ interface MockState {
|
|||||||
submissions: Submission[];
|
submissions: Submission[];
|
||||||
updateWindow: { start: string | null; end: string | null };
|
updateWindow: { start: string | null; end: string | null };
|
||||||
authSources: AuthSourcesSettings;
|
authSources: AuthSourcesSettings;
|
||||||
|
wakePolicy: WakePolicySettings;
|
||||||
// Each server's world volume, seeded on first visit.
|
// Each server's world volume, seeded on first visit.
|
||||||
files: Record<string, MockTree>;
|
files: Record<string, MockTree>;
|
||||||
}
|
}
|
||||||
@@ -455,6 +457,7 @@ function initialState(): MockState {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
updateWindow: { start: null, end: null },
|
updateWindow: { start: null, end: null },
|
||||||
|
wakePolicy: { maxRunningServers: 0, wakeCooldownSeconds: 30, revision: "initial", managed: false },
|
||||||
authSources: {
|
authSources: {
|
||||||
sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }],
|
sources: [{ tag: "littleskin", prefix: "LS", url: "https://littleskin.cn/api/yggdrasil/sessionserver/session/minecraft/hasJoined", api_url: "", enabled: true }],
|
||||||
revision: "installation-defaults",
|
revision: "installation-defaults",
|
||||||
@@ -1016,6 +1019,23 @@ async function handlePublic(ctx: RequestContext): Promise<boolean> {
|
|||||||
|
|
||||||
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
async function handleSession(ctx: SessionContext): Promise<boolean> {
|
||||||
switch (route(ctx)) {
|
switch (route(ctx)) {
|
||||||
|
case "GET settings/wake-policy":
|
||||||
|
case "PUT settings/wake-policy": {
|
||||||
|
if (!isOwner(ctx.account.role)) {
|
||||||
|
sendError(ctx.res, 403, "forbidden", "Owner account required");
|
||||||
|
} else if (is("GET", ctx)) {
|
||||||
|
sendJSON(ctx.res, 200, ctx.state.wakePolicy);
|
||||||
|
} else {
|
||||||
|
const body = await readJSON<Omit<WakePolicySettings, "managed">>(ctx.req);
|
||||||
|
if (body.revision !== ctx.state.wakePolicy.revision) sendError(ctx.res, 409, "conflict", "policy changed; reload before saving");
|
||||||
|
else if (!Number.isInteger(body.maxRunningServers) || body.maxRunningServers < 0 || body.maxRunningServers > 10000 || !Number.isInteger(body.wakeCooldownSeconds) || body.wakeCooldownSeconds < 0 || body.wakeCooldownSeconds > 3600) sendError(ctx.res, 400, "bad_request", "invalid wake policy");
|
||||||
|
else {
|
||||||
|
ctx.state.wakePolicy = { ...body, revision: createHash("sha256").update(JSON.stringify(body)).digest("hex"), managed: true };
|
||||||
|
sendJSON(ctx.res, 200, ctx.state.wakePolicy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
case "GET settings/auth-sources":
|
case "GET settings/auth-sources":
|
||||||
case "PUT settings/auth-sources":
|
case "PUT settings/auth-sources":
|
||||||
case "POST settings/auth-sources/test": {
|
case "POST settings/auth-sources/test": {
|
||||||
@@ -2324,6 +2344,14 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
|
|||||||
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" });
|
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Running" });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (is("POST", ctx) && ctx.parts[4] === "emergency-stop") {
|
||||||
|
if (!ctx.account || !isOwner(ctx.account.role)) { sendError(ctx.res, 403, "forbidden", "owner access required"); return true; }
|
||||||
|
const body = await readJSON(ctx.req) as { confirm?: string };
|
||||||
|
if (body.confirm !== serverInfo.name) { sendError(ctx.res, 400, "bad_request", "type the server name"); return true; }
|
||||||
|
setPhase(serverInfo, "Stopped");
|
||||||
|
sendJSON(ctx.res, 202, { name: serverInfo.name, desiredState: "Stopped" });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (is("POST", ctx) && ctx.parts[4] === "stop") {
|
if (is("POST", ctx) && ctx.parts[4] === "stop") {
|
||||||
if (!canManage(ctx.account, serverInfo)) {
|
if (!canManage(ctx.account, serverInfo)) {
|
||||||
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
|
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { test, expect, t, expectFitsScreen } from "./fixtures";
|
||||||
|
|
||||||
|
test("platform policy is an Owner entry and persists after reload", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/admin/platform");
|
||||||
|
await expect(page.getByRole("heading", { name: t("admin:platform_title"), exact: true })).toBeVisible();
|
||||||
|
const limit = page.getByLabel(t("admin:platform_limit"));
|
||||||
|
await expect(limit).toBeEnabled();
|
||||||
|
await limit.fill("3");
|
||||||
|
await page.getByLabel(t("admin:platform_cooldown")).fill("45");
|
||||||
|
await page.getByRole("button", { name: t("admin:platform_save") }).click();
|
||||||
|
await expect(page.getByText(t("admin:platform_saved"), { exact: true })).toBeVisible();
|
||||||
|
await page.reload();
|
||||||
|
await expect(limit).toHaveValue("3");
|
||||||
|
await expect(page.getByLabel(t("admin:platform_cooldown"))).toHaveValue("45");
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await signIn("linked");
|
||||||
|
await page.goto("/admin/platform");
|
||||||
|
await expect(page.getByRole("heading", { name: t("admin:platform_title"), exact: true })).toBeHidden();
|
||||||
|
expect((await page.request.get("/api/v1/settings/wake-policy")).status()).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("source disclosure does not scale on press and long pages keep bottom breathing room", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
for (const viewport of [{ width: 1440, height: 600 }, { width: 375, height: 812 }]) {
|
||||||
|
await page.setViewportSize(viewport);
|
||||||
|
await page.goto("/admin/auth-sources");
|
||||||
|
const disclosure = page.getByRole("button", { name: t("authSources:expand", { name: "littleskin" }) });
|
||||||
|
const box = await disclosure.boundingBox();
|
||||||
|
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
|
||||||
|
await page.mouse.down();
|
||||||
|
await expect.poll(() => disclosure.evaluate((el) => getComputedStyle(el).transform)).toMatch(/^(none|matrix\(1, 0, 0, 1, 0, 0\))$/);
|
||||||
|
await page.mouse.up();
|
||||||
|
await expect(page.getByRole("button", { name: t("authSources:collapse", { name: "littleskin" }) })).toHaveAttribute("aria-expanded", "true");
|
||||||
|
const main = page.getByRole("main");
|
||||||
|
await main.evaluate((el) => { el.scrollTop = el.scrollHeight; });
|
||||||
|
const save = page.getByRole("button", { name: t("authSources:save"), exact: true });
|
||||||
|
await expect(save).toBeInViewport();
|
||||||
|
const room = await save.evaluate((el) => el.closest("main")!.getBoundingClientRect().bottom - el.parentElement!.parentElement!.getBoundingClientRect().bottom);
|
||||||
|
expect(room).toBeGreaterThanOrEqual(23);
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("startup shows actual scheduling blockage, stale API state, then recovery", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
let state: "blocked" | "outage" | "running" = "blocked";
|
||||||
|
await page.route("**/api/v1/servers/survival/status", async (route) => {
|
||||||
|
if (state === "outage") { await route.fulfill({ status: 503, json: { error: { code: "upstream_unavailable", message: "test API outage" } } }); return; }
|
||||||
|
const original = await route.fetch();
|
||||||
|
const data = await original.json();
|
||||||
|
await route.fulfill({ json: { ...data, phase: state === "running" ? "Running" : "Starting", desiredState: "Running", ready: state === "running", startup: state === "blocked" ? { stage: "scheduling", reason: "Unschedulable", message: "0/1 nodes: Insufficient memory", startedAt: new Date(Date.now() - 60000).toISOString(), logsAvailable: false } : undefined } });
|
||||||
|
});
|
||||||
|
await page.goto("/servers/survival");
|
||||||
|
await expect(page.getByText(t("servers:startup_scheduling"), { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText(t("servers:startup_memory"), { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByText(t("servers:log_ended"), { exact: true })).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: t("servers:retire_delete"), exact: true })).toBeHidden();
|
||||||
|
state = "outage";
|
||||||
|
await expect(page.getByText(t("servers:status_stale"), { exact: true })).toBeVisible({ timeout: 7000 });
|
||||||
|
await expect(page.getByText(t("servers:startup_scheduling"), { exact: true })).toBeHidden();
|
||||||
|
await expect(page.getByRole("button", { name: t("servers:stop"), exact: true })).toBeHidden();
|
||||||
|
state = "running";
|
||||||
|
await expect(page.getByText(t("servers:status_stale"), { exact: true })).toBeHidden({ timeout: 7000 });
|
||||||
|
await page.getByRole("button", { name: t("servers:more_actions"), exact: true }).click();
|
||||||
|
await expect(page.getByRole("button", { name: t("servers:retire_delete"), exact: true })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Owner emergency stop stays secondary, confirms name, and distinguishes acceptance from shutdown", async ({ page, signIn }) => {
|
||||||
|
await signIn("owner");
|
||||||
|
await page.goto("/servers/survival");
|
||||||
|
await page.getByRole("button", { name: t("servers:more_actions"), exact: true }).click();
|
||||||
|
await page.getByRole("button", { name: t("servers:emergency_stop"), exact: true }).click();
|
||||||
|
const confirm = page.getByRole("button", { name: t("servers:emergency_stop"), exact: true });
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
await page.getByLabel(t("servers:retire_confirm_label"), { exact: false }).fill("survival");
|
||||||
|
await confirm.click();
|
||||||
|
await expect(page.getByText(t("servers:emergency_stop_accepted"), { exact: true })).toBeVisible();
|
||||||
|
await expect(confirm).toBeDisabled();
|
||||||
|
await page.getByRole("dialog").getByRole("button", { name: t("common:close_sr"), exact: true }).last().click();
|
||||||
|
await expectFitsScreen(page);
|
||||||
|
await page.goto("/admin/platform");
|
||||||
|
const ownerNav = page.getByRole("navigation").getByRole("link");
|
||||||
|
const links = await ownerNav.evaluateAll((elements) => elements.map((el) => el.getAttribute("href")));
|
||||||
|
expect(links.at(-1)).toBe("/admin/platform");
|
||||||
|
});
|
||||||
@@ -65,6 +65,7 @@ const LobbyPage = lazyWithReload(() =>
|
|||||||
const UpdatesPage = lazyWithReload(() =>
|
const UpdatesPage = lazyWithReload(() =>
|
||||||
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
|
import("@/pages/admin/UpdatesPage").then((m) => ({ default: m.UpdatesPage })),
|
||||||
);
|
);
|
||||||
|
const PlatformSettingsPage = lazyWithReload(() => import("@/pages/admin/PlatformSettingsPage").then((m) => ({ default: m.PlatformSettingsPage })));
|
||||||
const AuthSourcesPage = lazyWithReload(() => import("@/pages/admin/AuthSourcesPage").then((m) => ({ default: m.AuthSourcesPage })));
|
const AuthSourcesPage = lazyWithReload(() => import("@/pages/admin/AuthSourcesPage").then((m) => ({ default: m.AuthSourcesPage })));
|
||||||
|
|
||||||
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
// Three UX surfaces over two Zero-Trust tiers (DESIGN-WEB-3SIDES):
|
||||||
@@ -132,6 +133,7 @@ export default function App() {
|
|||||||
<Route path="updates" element={<UpdatesPage />} />
|
<Route path="updates" element={<UpdatesPage />} />
|
||||||
{/* Owner-gated platform settings and user management. */}
|
{/* Owner-gated platform settings and user management. */}
|
||||||
<Route element={<RequireOwner />}>
|
<Route element={<RequireOwner />}>
|
||||||
|
<Route path="platform" element={<PlatformSettingsPage />} />
|
||||||
<Route path="auth-sources" element={<AuthSourcesPage />} />
|
<Route path="auth-sources" element={<AuthSourcesPage />} />
|
||||||
<Route path="users" element={<UsersPage />} />
|
<Route path="users" element={<UsersPage />} />
|
||||||
<Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}>
|
<Route path="users/:id" element={<ValidParam param="id" pattern={USER_ID_PARAM} />}>
|
||||||
|
|||||||
@@ -309,8 +309,8 @@ export function AppShell() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div ref={setHeaderHost} className="shrink-0 border-b border-border bg-background p-4 empty:hidden md:p-6" />
|
<div ref={setHeaderHost} className="shrink-0 border-b border-border bg-background p-4 empty:hidden md:p-6" />
|
||||||
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto p-4 md:p-6">
|
<main className="flex min-h-0 flex-1 flex-col overflow-y-auto px-4 pt-4 md:px-6 md:pt-6">
|
||||||
<div className="mx-auto flex min-h-0 w-full max-w-8xl flex-1 flex-col gap-6">
|
<div className="mx-auto flex min-h-0 w-full max-w-8xl flex-1 flex-col gap-6 after:h-6 after:shrink-0 after:content-['']">
|
||||||
{/* A crash on one page leaves the navigation usable; moving to
|
{/* A crash on one page leaves the navigation usable; moving to
|
||||||
another route clears it. Pages load as their own chunks, so the
|
another route clears it. Pages load as their own chunks, so the
|
||||||
first visit to one shows a spinner here with the shell in place,
|
first visit to one shows a spinner here with the shell in place,
|
||||||
|
|||||||
@@ -104,3 +104,20 @@ describe("LogConsole", () => {
|
|||||||
expect(verdicts.filter((same) => same === false)).toHaveLength(1);
|
expect(verdicts.filter((same) => same === false)).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("startup log retries", () => {
|
||||||
|
it("recovers from a fatal early attach without requiring a manual reconnect", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const view = render(<LogConsole url="/startup" starting />);
|
||||||
|
try {
|
||||||
|
const original = FakeEventSource.last!;
|
||||||
|
act(() => { original.readyState = 2; original.onerror?.({}); });
|
||||||
|
expect(screen.getByText(i18next.t("servers:log_reconnecting"))).toBeTruthy();
|
||||||
|
expect(screen.getByText(i18next.t("servers:log_starting_wait"))).toBeTruthy();
|
||||||
|
await act(async () => vi.advanceTimersByTime(5000));
|
||||||
|
expect(FakeEventSource.last).not.toBe(original);
|
||||||
|
act(() => FakeEventSource.last!.onopen?.({}));
|
||||||
|
expect(screen.getByText(i18next.t("servers:log_live"))).toBeTruthy();
|
||||||
|
} finally { view.unmount(); vi.useRealTimers(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { memo, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
import { memo, useEffect, useCallback, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from "react";
|
||||||
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
|
import { ArrowDown, RotateCw, Trash2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -81,9 +81,14 @@ function StatusIndicator({ status }: { status: StreamStatus }) {
|
|||||||
* scrolling back to the bottom re-pins. The buffer is bounded by the controller,
|
* scrolling back to the bottom re-pins. The buffer is bounded by the controller,
|
||||||
* which also batches lines to one update per frame.
|
* which also batches lines to one update per frame.
|
||||||
*/
|
*/
|
||||||
export function LogConsole({ url, className }: { url: string; className?: string }) {
|
export function LogConsole({ url, className, starting = false }: { url: string; className?: string; starting?: boolean }) {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const { lines, status, clear, reconnect } = useLogStream(url);
|
const { lines, status, retryable, clear, reconnect } = useLogStream(url);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!starting || !retryable || status !== "ended") return;
|
||||||
|
const timer = window.setTimeout(reconnect, 5000);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [starting, retryable, status, reconnect]);
|
||||||
const chunks = useMemo(() => chunkLines(lines), [lines]);
|
const chunks = useMemo(() => chunkLines(lines), [lines]);
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
const [pinned, setPinned] = useState(true);
|
const [pinned, setPinned] = useState(true);
|
||||||
@@ -110,7 +115,7 @@ export function LogConsole({ url, className }: { url: string; className?: string
|
|||||||
<div className={cn("overflow-hidden rounded-md border border-border bg-black flex-1 flex flex-col min-h-0", className)}>
|
<div className={cn("overflow-hidden rounded-md border border-border bg-black flex-1 flex flex-col min-h-0", className)}>
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2 shrink-0">
|
<div className="flex items-center justify-between gap-2 border-b border-zinc-800 bg-zinc-900/60 px-3 py-2 shrink-0">
|
||||||
<StatusIndicator status={status} />
|
<StatusIndicator status={starting && retryable && status === "ended" ? "reconnecting" : status} />
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{status === "ended" && (
|
{status === "ended" && (
|
||||||
<Button
|
<Button
|
||||||
@@ -143,9 +148,9 @@ export function LogConsole({ url, className }: { url: string; className?: string
|
|||||||
>
|
>
|
||||||
{lines.length === 0 ? (
|
{lines.length === 0 ? (
|
||||||
<p className="select-none py-8 text-center text-zinc-600">
|
<p className="select-none py-8 text-center text-zinc-600">
|
||||||
{status === "ended"
|
{status === "ended" && (!starting || !retryable)
|
||||||
? t("log_ended_empty")
|
? t("log_ended_empty")
|
||||||
: t("log_waiting")}
|
: t(starting ? "log_starting_wait" : "log_waiting")}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />)
|
chunks.map((chunk) => <LogChunk key={chunk.key} lines={chunk.lines} />)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import i18next from "i18next";
|
import i18next from "i18next";
|
||||||
import { RetireCard, RetireNotice } from "./Retirement";
|
import { RetireCard, RetireNotice } from "./Retirement";
|
||||||
|
|
||||||
const calls = vi.hoisted(() => ({ retireServer: vi.fn(), cancelRetire: vi.fn() }));
|
const calls = vi.hoisted(() => ({ retireServer: vi.fn(), cancelRetire: vi.fn(), emergencyStop: vi.fn() }));
|
||||||
vi.mock("@/lib/api", async (importOriginal) => {
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("@/lib/api")>();
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
return { ...actual, api: { ...actual.api, ...calls } };
|
return { ...actual, api: { ...actual.api, ...calls } };
|
||||||
@@ -17,15 +17,42 @@ const hourAgo = () => new Date(Date.now() - 3600_000).toISOString();
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
calls.retireServer.mockReset();
|
calls.retireServer.mockReset();
|
||||||
calls.cancelRetire.mockReset();
|
calls.cancelRetire.mockReset();
|
||||||
|
calls.emergencyStop.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("RetireCard", () => {
|
describe("RetireCard", () => {
|
||||||
|
it("requires exact confirmation and reports acceptance without claiming shutdown", async () => {
|
||||||
|
calls.emergencyStop.mockResolvedValue({ desiredState: "Stopped" });
|
||||||
|
const onChanged = vi.fn();
|
||||||
|
render(<RetireCard name="survival" label="World" isAdmin isOwner onChanged={onChanged} />);
|
||||||
|
expect(screen.queryByRole("button", { name: t("servers:emergency_stop") })).toBeNull();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:emergency_stop") }));
|
||||||
|
const confirm = screen.getByRole("button", { name: t("servers:emergency_stop") });
|
||||||
|
expect(confirm).toHaveProperty("disabled", true);
|
||||||
|
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival");
|
||||||
|
await userEvent.click(confirm);
|
||||||
|
expect(calls.emergencyStop).toHaveBeenCalledExactlyOnceWith("survival", "survival");
|
||||||
|
expect(screen.getByText(t("servers:emergency_stop_accepted"))).toBeTruthy();
|
||||||
|
expect(confirm).toHaveProperty("disabled", true);
|
||||||
|
expect(onChanged).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps system services behind emergency controls without offering retirement", async () => {
|
||||||
|
render(<RetireCard name="login" label="Login" isAdmin isOwner canRetire={false} onChanged={vi.fn()} />);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||||
|
expect(screen.getByRole("button", { name: t("servers:emergency_stop") })).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: t("servers:retire_delete") })).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: t("servers:retire_release") })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("lets an owner give the server up once its name is typed out, and never offers a deletion", async () => {
|
it("lets an owner give the server up once its name is typed out, and never offers a deletion", async () => {
|
||||||
calls.retireServer.mockResolvedValue({ name: "survival", retiring: { requested_at: hourAgo(), delete: false } });
|
calls.retireServer.mockResolvedValue({ name: "survival", retiring: { requested_at: hourAgo(), delete: false } });
|
||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
render(<RetireCard name="survival" label="Survival World" isAdmin={false} onChanged={onChanged} />);
|
render(<RetireCard name="survival" label="Survival World" isAdmin={false} onChanged={onChanged} />);
|
||||||
|
|
||||||
expect(screen.queryByRole("button", { name: t("servers:retire_delete") })).toBeNull();
|
expect(screen.queryByRole("button", { name: t("servers:retire_delete") })).toBeNull();
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_release") }));
|
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_release") }));
|
||||||
|
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
@@ -57,6 +84,7 @@ describe("RetireCard", () => {
|
|||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
|
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
|
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
|
||||||
const dialog = screen.getByRole("dialog");
|
const dialog = screen.getByRole("dialog");
|
||||||
expect(dialog.textContent).toContain(t("servers:retire_step_delete"));
|
expect(dialog.textContent).toContain(t("servers:retire_step_delete"));
|
||||||
@@ -73,6 +101,7 @@ describe("RetireCard", () => {
|
|||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
|
render(<RetireCard name="survival" label="survival" isAdmin onChanged={onChanged} />);
|
||||||
|
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: t("servers:more_actions") }));
|
||||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
|
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_delete") }));
|
||||||
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival");
|
await userEvent.type(screen.getByLabelText(t("servers:retire_confirm_label"), { exact: false }), "survival");
|
||||||
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_confirm_delete") }));
|
await userEvent.click(screen.getByRole("button", { name: t("servers:retire_confirm_delete") }));
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Hourglass, Loader2, PackageX } from "lucide-react";
|
import { ChevronRight, Ellipsis, Hourglass, Loader2 } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -15,6 +15,7 @@ import { MessageLine } from "@/components/MessageLine";
|
|||||||
import { api, humanizeError } from "@/lib/api";
|
import { api, humanizeError } from "@/lib/api";
|
||||||
import { formatAbsolute, formatRelative } from "@/lib/format";
|
import { formatAbsolute, formatRelative } from "@/lib/format";
|
||||||
import type { RetireState } from "@/lib/types";
|
import type { RetireState } from "@/lib/types";
|
||||||
|
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
// A server leaves its owner, or the platform, through a retirement
|
// A server leaves its owner, or the platform, through a retirement
|
||||||
@@ -100,7 +101,7 @@ export function RetireNotice({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type Mode = "release" | "delete";
|
type Mode = "release" | "delete" | "stop";
|
||||||
|
|
||||||
/** RetireCard is the console sidebar's way to give a server up (its owner or an
|
/** RetireCard is the console sidebar's way to give a server up (its owner or an
|
||||||
* admin) or delete it (an admin). Either asks for the server's name typed out,
|
* admin) or delete it (an admin). Either asks for the server's name typed out,
|
||||||
@@ -109,43 +110,40 @@ export function RetireCard({
|
|||||||
name,
|
name,
|
||||||
label,
|
label,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
isOwner = false,
|
||||||
|
canRetire = true,
|
||||||
onChanged,
|
onChanged,
|
||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
/** What the dialog calls the server: its display name, else its name. */
|
/** What the dialog calls the server: its display name, else its name. */
|
||||||
label: string;
|
label: string;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
isOwner?: boolean;
|
||||||
|
canRetire?: boolean;
|
||||||
onChanged: () => void;
|
onChanged: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const [mode, setMode] = useState<Mode | null>(null);
|
const [mode, setMode] = useState<Mode | null>(null);
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 rounded-lg border border-destructive/30 bg-card p-4">
|
<div>
|
||||||
<div className="flex items-start gap-3">
|
<Button variant="outline" className="h-auto w-full justify-start gap-3 border-border bg-card p-4 text-left active:scale-100" onClick={() => setOpen(true)}>
|
||||||
<PackageX className="mt-0.5 h-5 w-5 shrink-0 text-destructive" />
|
<Ellipsis className="text-muted-foreground" /><span className="flex-1">{t("more_actions")}</span><ChevronRight className="text-muted-foreground" />
|
||||||
<div className="min-w-0 flex-1">
|
</Button>
|
||||||
<p className="text-sm font-medium">{isAdmin ? t("retire_card_title_admin") : t("retire_card_title")}</p>
|
<Dialog open={open && mode === null} onOpenChange={setOpen}>
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<DialogContent className="sm:max-w-md">
|
||||||
{isAdmin ? t("retire_card_desc_admin") : t("retire_card_desc")}
|
<DialogHeader>
|
||||||
</p>
|
<DialogTitle>{t("more_actions")}</DialogTitle>
|
||||||
</div>
|
<DialogDescription>{canRetire ? (isAdmin ? t("retire_card_desc_admin") : t("retire_card_desc")) : t("emergency_stop_warning")}</DialogDescription>
|
||||||
</div>
|
</DialogHeader>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button
|
{isOwner && <Button variant="outline" onClick={() => setMode("stop")}>{t("emergency_stop")}</Button>}
|
||||||
size="sm"
|
{canRetire && <Button variant="outline" onClick={() => setMode("release")}>{t("retire_release")}</Button>}
|
||||||
variant="outline"
|
{canRetire && isAdmin && <Button variant="destructive" onClick={() => setMode("delete")}>{t("retire_delete")}</Button>}
|
||||||
className="border-destructive/30 text-destructive hover:bg-destructive/10 hover:text-destructive"
|
|
||||||
onClick={() => setMode("release")}
|
|
||||||
>
|
|
||||||
{t("retire_release")}
|
|
||||||
</Button>
|
|
||||||
{isAdmin && (
|
|
||||||
<Button size="sm" variant="destructive" onClick={() => setMode("delete")}>
|
|
||||||
{t("retire_delete")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
{mode && (
|
{mode && (
|
||||||
<RetireDialog
|
<RetireDialog
|
||||||
name={name}
|
name={name}
|
||||||
@@ -153,7 +151,7 @@ export function RetireCard({
|
|||||||
mode={mode}
|
mode={mode}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
onClose={() => setMode(null)}
|
onClose={() => setMode(null)}
|
||||||
onDone={onChanged}
|
onDone={() => { setOpen(false); onChanged(); }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -180,40 +178,51 @@ function RetireDialog({
|
|||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const del = mode === "delete";
|
const del = mode === "delete";
|
||||||
|
const stopping = mode === "stop";
|
||||||
|
const reauth = useReauth();
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
|
|
||||||
function setOpen(open: boolean) {
|
function setOpen(open: boolean) {
|
||||||
if (!open && !busy) onClose();
|
if (!open && !busy) onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function confirm() {
|
async function confirm() {
|
||||||
if (typed !== name || busy) return;
|
if (typed !== name || busy || accepted) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
|
if (stopping) {
|
||||||
|
await reauth.guard(() => api.emergencyStop(name, typed));
|
||||||
|
setAccepted(true);
|
||||||
|
setBusy(false);
|
||||||
|
onDone();
|
||||||
|
return;
|
||||||
|
}
|
||||||
await api.retireServer(name, { confirm: typed, delete: del });
|
await api.retireServer(name, { confirm: typed, delete: del });
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
onClose();
|
onClose();
|
||||||
onDone();
|
onDone();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(humanizeError(e));
|
if (!isReauthCancelled(e)) setError(humanizeError(e));
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Dialog open onOpenChange={setOpen}>
|
<Dialog open onOpenChange={setOpen}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{del ? t("retire_delete_title", { name: label }) : t("retire_release_title", { name: label })}</DialogTitle>
|
<DialogTitle>{stopping ? t("emergency_stop") : del ? t("retire_delete_title", { name: label }) : t("retire_release_title", { name: label })}</DialogTitle>
|
||||||
<DialogDescription asChild>
|
{stopping ? <DialogDescription>{t("emergency_stop_warning")}</DialogDescription> : <DialogDescription asChild>
|
||||||
<ul className="list-disc space-y-1 pl-5 text-left">
|
<ul className="list-disc space-y-1 pl-5 text-left">
|
||||||
<li>{t("retire_step_stop")}</li>
|
<li>{t("retire_step_stop")}</li>
|
||||||
<li>{del ? t("retire_step_delete") : t("retire_step_release")}</li>
|
<li>{del ? t("retire_step_delete") : t("retire_step_release")}</li>
|
||||||
<li>{isAdmin ? t("retire_step_cancel_admin") : t("retire_step_cancel_owner")}</li>
|
<li>{isAdmin ? t("retire_step_cancel_admin") : t("retire_step_cancel_owner")}</li>
|
||||||
</ul>
|
</ul>
|
||||||
</DialogDescription>
|
</DialogDescription>}
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-2">
|
{accepted ? <MessageLine kind="success" message={t("emergency_stop_accepted")} /> : <div className="grid gap-2">
|
||||||
<label htmlFor="retire-confirm" className="text-sm">
|
<label htmlFor="retire-confirm" className="text-sm">
|
||||||
{t("retire_confirm_label")} <code className="rounded bg-muted px-1 font-mono text-xs">{name}</code>
|
{t("retire_confirm_label")} <code className="rounded bg-muted px-1 font-mono text-xs">{name}</code>
|
||||||
</label>
|
</label>
|
||||||
@@ -228,17 +237,20 @@ function RetireDialog({
|
|||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="font-mono"
|
className="font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>}
|
||||||
{error && <MessageLine kind="error" message={error} compact />}
|
{error && <MessageLine kind="error" message={error} compact />}
|
||||||
|
{stopping && error && <p className="text-sm text-muted-foreground">{t("emergency_stop_unknown")}</p>}
|
||||||
<ConfirmFooter
|
<ConfirmFooter
|
||||||
onCancel={() => setOpen(false)}
|
onCancel={() => setOpen(false)}
|
||||||
onConfirm={() => void confirm()}
|
onConfirm={() => void confirm()}
|
||||||
disabled={typed !== name}
|
disabled={typed !== name || accepted}
|
||||||
loading={busy}
|
loading={busy}
|
||||||
cancelLabel={t("access_cancel")}
|
cancelLabel={accepted ? t("common:close_sr") : t("access_cancel")}
|
||||||
confirmLabel={del ? t("retire_confirm_delete") : t("retire_confirm_release")}
|
confirmLabel={stopping ? t("emergency_stop") : del ? t("retire_confirm_delete") : t("retire_confirm_release")}
|
||||||
/>
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
{reauth.dialog}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -306,5 +306,20 @@
|
|||||||
"scan_download_report": "Trivy report",
|
"scan_download_report": "Trivy report",
|
||||||
"scan_download_sbom": "SBOM",
|
"scan_download_sbom": "SBOM",
|
||||||
"scan_not_kept": "Not kept with this build: the file was too large, or the step that writes it failed.",
|
"scan_not_kept": "Not kept with this build: the file was too large, or the step that writes it failed.",
|
||||||
"scan_download_failed": "Couldn't download: {{reason}}"
|
"scan_download_failed": "Couldn't download: {{reason}}",
|
||||||
|
"platform_title": "Platform settings",
|
||||||
|
"platform_subtitle": "Global controls for server startup and wake requests.",
|
||||||
|
"platform_reload": "Reload",
|
||||||
|
"platform_wake_title": "Startup and wake policy",
|
||||||
|
"platform_loading": "Loading the saved policy…",
|
||||||
|
"platform_limit": "Running server limit (0–10000)",
|
||||||
|
"platform_limit_hint": "0 means unlimited. New starts count servers already running or requested to run; system services can always start. Lowering the limit does not stop existing servers.",
|
||||||
|
"platform_cooldown": "Wake cooldown (0–3600 seconds)",
|
||||||
|
"platform_cooldown_hint": "Minimum interval between accepted wake requests for the same server. 0 disables the cooldown.",
|
||||||
|
"platform_scope": "The limit applies to panel starts, in-game wakes and scheduled starts. It is an admission check; available memory still determines whether Kubernetes can schedule a server. User quotas remain under Users.",
|
||||||
|
"platform_saved": "Saved. The new policy is active without a restart.",
|
||||||
|
"platform_unsaved": "Unsaved changes",
|
||||||
|
"platform_live": "Saved changes apply immediately on all API replicas.",
|
||||||
|
"platform_discard": "Discard changes",
|
||||||
|
"platform_save": "Save and apply"
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,6 @@
|
|||||||
"admin_submissions": "Submissions",
|
"admin_submissions": "Submissions",
|
||||||
"admin_updates": "Maintenance & Backups",
|
"admin_updates": "Maintenance & Backups",
|
||||||
"admin_lobby": "Login & lobby",
|
"admin_lobby": "Login & lobby",
|
||||||
"auth_sources": "Authentication sources"
|
"auth_sources": "Authentication sources",
|
||||||
|
"platform_settings": "Platform settings"
|
||||||
}
|
}
|
||||||
@@ -270,5 +270,29 @@
|
|||||||
"migration_state_succeeded": "Migration complete; start manually after inspection",
|
"migration_state_succeeded": "Migration complete; start manually after inspection",
|
||||||
"migration_state_failed": "Migration failed; server remains stopped",
|
"migration_state_failed": "Migration failed; server remains stopped",
|
||||||
"system_customize": "Customize space",
|
"system_customize": "Customize space",
|
||||||
"edit_system_desc": "Adjust the name, image and resources. A replacement must retain the Felis login or menu plugin and match the proxy protocol. System services do not stop when idle."
|
"edit_system_desc": "Adjust the name, image and resources. A replacement must retain the Felis login or menu plugin and match the proxy protocol. System services do not stop when idle.",
|
||||||
|
"more_actions": "More actions",
|
||||||
|
"log_starting_wait": "Waiting for startup logs. The connection retries automatically while the server is starting.",
|
||||||
|
"startup_creating": "Creating the server container",
|
||||||
|
"startup_scheduling": "Waiting for resources and scheduling",
|
||||||
|
"startup_preparing": "Preparing files and pulling images",
|
||||||
|
"startup_booting": "Starting Minecraft and checking readiness",
|
||||||
|
"startup_elapsed": "Startup requested {{seconds}} seconds ago",
|
||||||
|
"startup_memory": "Not enough schedulable memory. Reduce this server’s memory allocation, stop another server, or add node capacity. Java has not started yet.",
|
||||||
|
"startup_hint": "Startup status refreshes automatically. Scheduling, file preparation and Minecraft startup are separate stages; no log output does not mean the process has hung.",
|
||||||
|
"status_stale": "Status unavailable",
|
||||||
|
"status_stale_detail": "Cannot confirm the current server state. Last successful read: {{time}}. Retrying automatically. {{error}}",
|
||||||
|
"startup_failed": "Startup blocked or container failed",
|
||||||
|
"logs_unavailable_title": "Logs are not available yet",
|
||||||
|
"logs_unavailable_body": "The container has not started producing logs. Check the startup diagnostics above; logs attach automatically once available.",
|
||||||
|
"startup_problem": "Startup issue",
|
||||||
|
"startup_waiting_resources": "Waiting for resources",
|
||||||
|
"startup_failed_hint": "Check the image, configuration and memory using the reported error. Correct the problem before restarting the server.",
|
||||||
|
"emergency_stop": "Emergency stop",
|
||||||
|
"emergency_stop_warning": "Use when normal stop is unresponsive. Scale the workload to zero independently of the game process and Operator, retaining normal shutdown grace. Worlds are not deleted, but unsaved changes may be lost. Fresh authentication is required.",
|
||||||
|
"emergency_stop_accepted": "Stop intent saved and workload scaled to zero. Wait for the Pod to exit; status will continue checking. Accepted does not mean stopped.",
|
||||||
|
"emergency_stop_unknown": "The result is unconfirmed. Stop intent may already be saved. Refresh status; if the control API is unreachable, use the host recovery steps.",
|
||||||
|
"host_recovery": "Control API unreachable? Show host recovery steps",
|
||||||
|
"host_recovery_hint": "The panel cannot bypass an offline control API. Run these commands on the deployment host: save stop intent, scale to zero, then check game Pods.",
|
||||||
|
"host_recovery_verify": "A terminating Pod or unreachable node does not confirm that the process stopped. Check the node and container runtime; force-deleting a Pod is not proof of shutdown."
|
||||||
}
|
}
|
||||||
@@ -302,5 +302,20 @@
|
|||||||
"scan_download_report": "Trivy 报告",
|
"scan_download_report": "Trivy 报告",
|
||||||
"scan_download_sbom": "SBOM",
|
"scan_download_sbom": "SBOM",
|
||||||
"scan_not_kept": "这次构建未留存该文件:文件过大,或生成它的步骤失败。",
|
"scan_not_kept": "这次构建未留存该文件:文件过大,或生成它的步骤失败。",
|
||||||
"scan_download_failed": "下载失败:{{reason}}"
|
"scan_download_failed": "下载失败:{{reason}}",
|
||||||
|
"platform_title": "平台设置",
|
||||||
|
"platform_subtitle": "统一管理服务器启动与唤醒的全局策略。",
|
||||||
|
"platform_reload": "重新读取",
|
||||||
|
"platform_wake_title": "启动与唤醒策略",
|
||||||
|
"platform_loading": "正在读取已保存的策略…",
|
||||||
|
"platform_limit": "同时运行的服务器上限(0–10000)",
|
||||||
|
"platform_limit_hint": "0 表示不限制。启动时计入已运行和已请求运行的服务器;系统服务始终可以启动。降低上限不会自动停止现有服务器。",
|
||||||
|
"platform_cooldown": "唤醒冷却时间(0–3600 秒)",
|
||||||
|
"platform_cooldown_hint": "同一服务器两次成功接受唤醒请求的最短间隔。0 表示关闭冷却。",
|
||||||
|
"platform_scope": "运行上限统一用于面板启动、游戏内唤醒与计划任务。此上限用于启动准入,能否实际调度仍取决于节点可用内存。用户配额在“用户管理”中调整。",
|
||||||
|
"platform_saved": "已保存并立即生效,无需重启。",
|
||||||
|
"platform_unsaved": "有尚未保存的更改",
|
||||||
|
"platform_live": "保存后会立即在所有 API 实例上生效。",
|
||||||
|
"platform_discard": "放弃更改",
|
||||||
|
"platform_save": "保存并生效"
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,6 @@
|
|||||||
"admin_submissions": "审核提交",
|
"admin_submissions": "审核提交",
|
||||||
"admin_updates": "维护与备份",
|
"admin_updates": "维护与备份",
|
||||||
"admin_lobby": "登录与大厅",
|
"admin_lobby": "登录与大厅",
|
||||||
"auth_sources": "认证源"
|
"auth_sources": "认证源",
|
||||||
|
"platform_settings": "平台设置"
|
||||||
}
|
}
|
||||||
@@ -269,5 +269,29 @@
|
|||||||
"migration_state_succeeded": "迁移完成,等待手动启动",
|
"migration_state_succeeded": "迁移完成,等待手动启动",
|
||||||
"migration_state_failed": "迁移失败,服务器保持停服",
|
"migration_state_failed": "迁移失败,服务器保持停服",
|
||||||
"system_customize": "自定义大厅",
|
"system_customize": "自定义大厅",
|
||||||
"edit_system_desc": "调整名称、镜像与资源。更换镜像需保留 Felis 登录或菜单插件,并保持与代理的版本兼容。系统服务不自动休眠。"
|
"edit_system_desc": "调整名称、镜像与资源。更换镜像需保留 Felis 登录或菜单插件,并保持与代理的版本兼容。系统服务不自动休眠。",
|
||||||
|
"more_actions": "更多操作",
|
||||||
|
"log_starting_wait": "正在等待启动日志,启动期间会自动重试连接。",
|
||||||
|
"startup_creating": "正在创建服务器容器",
|
||||||
|
"startup_scheduling": "正在等待资源与调度",
|
||||||
|
"startup_preparing": "正在准备文件与拉取镜像",
|
||||||
|
"startup_booting": "正在启动 Minecraft 并检查就绪状态",
|
||||||
|
"startup_elapsed": "本次启动已等待 {{seconds}} 秒",
|
||||||
|
"startup_memory": "可调度内存不足。请降低此服务器的内存配置、停止其他服务器,或增加节点容量。Java 尚未启动。",
|
||||||
|
"startup_hint": "启动状态会自动刷新。资源调度、文件准备与游戏启动是不同阶段,没有日志输出并不代表程序卡死。",
|
||||||
|
"status_stale": "状态已过期",
|
||||||
|
"status_stale_detail": "无法确认服务器当前状态。最后成功读取:{{time}}。正在自动重试。{{error}}",
|
||||||
|
"startup_failed": "启动受阻或容器异常",
|
||||||
|
"logs_unavailable_title": "日志暂不可用",
|
||||||
|
"logs_unavailable_body": "容器尚未开始输出日志。请查看上方的启动状态与阻塞原因;日志可用后会自动连接。",
|
||||||
|
"startup_problem": "启动异常",
|
||||||
|
"startup_waiting_resources": "等待资源",
|
||||||
|
"startup_failed_hint": "请根据报错检查镜像、配置与内存。修正问题后再重新启动服务器。",
|
||||||
|
"emergency_stop": "应急停止",
|
||||||
|
"emergency_stop_warning": "普通停止无响应时使用。直接将工作负载缩到零,绕过游戏进程和 Operator,保留正常退出时间;不会删除世界,但无法保证未保存的内容。需要重新验证身份。",
|
||||||
|
"emergency_stop_accepted": "停止意图已保存,工作负载已缩到零。仍需等待 Pod 退出;上方状态会继续检查,不能将请求受理视为已经停止。",
|
||||||
|
"emergency_stop_unknown": "操作结果尚未确认。停止意图可能已经保存,请重新读取状态;若控制 API 不可达,请使用主机侧恢复步骤。",
|
||||||
|
"host_recovery": "控制 API 不可达?查看主机侧应急停止步骤",
|
||||||
|
"host_recovery_hint": "面板无法绕过已经离线的控制 API。仅在部署主机上执行下列命令:先保存停止意图,再缩到零,最后检查游戏 Pod。",
|
||||||
|
"host_recovery_verify": "如果 Pod 仍在退出或节点不可达,不能确认进程已停止。检查对应节点和容器运行时;不要强制删除 Pod 来冒充停止成功。"
|
||||||
}
|
}
|
||||||
@@ -1813,3 +1813,22 @@ describe("world export wire shapes", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("server status timeout", () => {
|
||||||
|
afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); });
|
||||||
|
it("bounds a stalled status request and reports an unavailable connection", async () => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = vi.spyOn(AbortSignal, "timeout").mockReturnValue(controller.signal);
|
||||||
|
vi.stubGlobal("fetch", vi.fn((_url: string, init: RequestInit) => new Promise((_resolve, reject) => {
|
||||||
|
init.signal!.addEventListener("abort", () => reject(init.signal!.reason));
|
||||||
|
})));
|
||||||
|
const request = api.status("survival");
|
||||||
|
const result = expect(request).rejects.toMatchObject({ code: "network_error", status: 0 });
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
controller.abort(new DOMException("Status request timed out", "TimeoutError"));
|
||||||
|
await result;
|
||||||
|
expect(timeout).toHaveBeenCalledWith(12_000);
|
||||||
|
expect(isConnectionLost()).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -23,6 +23,7 @@ import type {
|
|||||||
MinecraftAuthSource,
|
MinecraftAuthSource,
|
||||||
AuthSourceConfig,
|
AuthSourceConfig,
|
||||||
AuthSourcesSettings,
|
AuthSourcesSettings,
|
||||||
|
WakePolicySettings,
|
||||||
MinecraftProfile,
|
MinecraftProfile,
|
||||||
LinkStatus,
|
LinkStatus,
|
||||||
BindResult,
|
BindResult,
|
||||||
@@ -441,7 +442,8 @@ export const api = rejectingSync({
|
|||||||
fleet: () =>
|
fleet: () =>
|
||||||
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
|
request<{ servers: FleetServer[] }>("GET", "/fleet").then((r) => r.servers ?? []),
|
||||||
|
|
||||||
status: (name: string) => request<ServerStatus>("GET", urlPath`/servers/${name}/status`),
|
emergencyStop: (name: string, confirm: string) => send<{ name: string; desiredState: string }>(urlPath`/servers/${name}/emergency-stop`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ confirm }), signal: AbortSignal.timeout(20_000) }),
|
||||||
|
status: (name: string) => send<ServerStatus>(urlPath`/servers/${name}/status`, { method: "GET", signal: AbortSignal.timeout(12_000) }),
|
||||||
|
|
||||||
wake: (name: string) =>
|
wake: (name: string) =>
|
||||||
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/wake`),
|
request<{ name: string; desiredState: string }>("POST", urlPath`/servers/${name}/wake`),
|
||||||
@@ -952,6 +954,8 @@ export const api = rejectingSync({
|
|||||||
|
|
||||||
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
linkSources: () => request<{ sources: MinecraftAuthSource[] }>("GET", "/account/link/sources"),
|
||||||
|
|
||||||
|
getWakePolicy: () => request<WakePolicySettings>("GET", "/settings/wake-policy"),
|
||||||
|
setWakePolicy: (policy: Omit<WakePolicySettings, "managed">) => request<WakePolicySettings>("PUT", "/settings/wake-policy", policy),
|
||||||
getAuthSources: () => request<AuthSourcesSettings>("GET", "/settings/auth-sources"),
|
getAuthSources: () => request<AuthSourcesSettings>("GET", "/settings/auth-sources"),
|
||||||
setAuthSources: (sources: AuthSourceConfig[], revision: string) => request<AuthSourcesSettings>("PUT", "/settings/auth-sources", { sources, revision }),
|
setAuthSources: (sources: AuthSourceConfig[], revision: string) => request<AuthSourcesSettings>("PUT", "/settings/auth-sources", { sources, revision }),
|
||||||
testAuthSource: (source: AuthSourceConfig) => request<{ ok: boolean; status: number; elapsed_ms: number }>("POST", "/settings/auth-sources/test", source),
|
testAuthSource: (source: AuthSourceConfig) => request<{ ok: boolean; status: number; elapsed_ms: number }>("POST", "/settings/auth-sources/test", source),
|
||||||
|
|||||||
@@ -154,3 +154,30 @@ describe("usePolling", () => {
|
|||||||
expect(reload).toHaveBeenCalledTimes(2);
|
expect(reload).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("polling slow or unavailable backends", () => {
|
||||||
|
it("does not supersede an in-flight poll and keeps a failure visible until a successful read", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const req = requests();
|
||||||
|
const hook = renderHook(() => {
|
||||||
|
const query = useAsync(() => req.fetch("a"), [], { coalesce: true });
|
||||||
|
usePolling(query.reload, 4000);
|
||||||
|
return query;
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await req.settle(0, { ok: "Starting" });
|
||||||
|
const checked = hook.result.current.updatedAt;
|
||||||
|
act(() => vi.advanceTimersByTime(4000));
|
||||||
|
act(() => vi.advanceTimersByTime(12000));
|
||||||
|
expect(req.pending).toHaveLength(2);
|
||||||
|
await req.settle(1, { fail: new Error("backend timeout") });
|
||||||
|
expect(hook.result.current).toMatchObject({ data: "Starting", loading: false, updatedAt: checked });
|
||||||
|
act(() => vi.advanceTimersByTime(4000));
|
||||||
|
expect(hook.result.current.error).toBeInstanceOf(Error);
|
||||||
|
expect(req.pending).toHaveLength(3);
|
||||||
|
await req.settle(2, { ok: "Running" });
|
||||||
|
expect(hook.result.current).toMatchObject({ data: "Running", error: null, loading: false });
|
||||||
|
expect(hook.result.current.updatedAt).toBeGreaterThan(checked!);
|
||||||
|
} finally { hook.unmount(); vi.useRealTimers(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
+17
-5
@@ -21,6 +21,7 @@ export interface AsyncState<T> {
|
|||||||
data: T | null;
|
data: T | null;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
updatedAt: number | null;
|
||||||
/** Re-run the async fn (e.g. after a mutation). */
|
/** Re-run the async fn (e.g. after a mutation). */
|
||||||
reload: () => void;
|
reload: () => void;
|
||||||
}
|
}
|
||||||
@@ -30,6 +31,8 @@ export interface AsyncOptions {
|
|||||||
* are its filter or page: the old rows stay put (each still acts on its own
|
* are its filter or page: the old rows stay put (each still acts on its own
|
||||||
* item) instead of blanking to a spinner on every keystroke. */
|
* item) instead of blanking to a spinner on every keystroke. */
|
||||||
keepPrevious?: boolean;
|
keepPrevious?: boolean;
|
||||||
|
/** Polling must not supersede a request that is still awaiting its timeout. */
|
||||||
|
coalesce?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Settled<T> {
|
interface Settled<T> {
|
||||||
@@ -38,6 +41,7 @@ interface Settled<T> {
|
|||||||
data: T | null;
|
data: T | null;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
|
updatedAt: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** useAsync runs an async producer on mount and on demand, guarding against
|
/** useAsync runs an async producer on mount and on demand, guarding against
|
||||||
@@ -49,33 +53,39 @@ interface Settled<T> {
|
|||||||
export function useAsync<T>(
|
export function useAsync<T>(
|
||||||
fn: () => Promise<T>,
|
fn: () => Promise<T>,
|
||||||
deps: unknown[] = [],
|
deps: unknown[] = [],
|
||||||
{ keepPrevious = false }: AsyncOptions = {},
|
{ keepPrevious = false, coalesce = false }: AsyncOptions = {},
|
||||||
): AsyncState<T> {
|
): AsyncState<T> {
|
||||||
const [settled, setSettled] = useState<Settled<T> | null>(null);
|
const [settled, setSettled] = useState<Settled<T> | null>(null);
|
||||||
const seq = useRef(0);
|
const seq = useRef(0);
|
||||||
|
const inFlight = useRef<{ run: () => Promise<T>; ticket: number } | null>(null);
|
||||||
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const run = useCallback(fn, deps);
|
const run = useCallback(fn, deps);
|
||||||
|
|
||||||
const reload = useCallback(() => {
|
const reload = useCallback(() => {
|
||||||
|
if (coalesce && inFlight.current?.run === run) return;
|
||||||
const ticket = ++seq.current;
|
const ticket = ++seq.current;
|
||||||
|
inFlight.current = { run, ticket };
|
||||||
setSettled((s) => ({
|
setSettled((s) => ({
|
||||||
run,
|
run,
|
||||||
data: s?.run === run || keepPrevious ? (s?.data ?? null) : null,
|
data: s?.run === run || keepPrevious ? (s?.data ?? null) : null,
|
||||||
error: null,
|
error: s?.run === run ? s.error : null,
|
||||||
|
updatedAt: s?.run === run ? s.updatedAt : null,
|
||||||
loading: true,
|
loading: true,
|
||||||
}));
|
}));
|
||||||
run().then(
|
run().then(
|
||||||
(d) => {
|
(d) => {
|
||||||
if (ticket === seq.current) setSettled({ run, data: d, error: null, loading: false });
|
if (inFlight.current?.ticket === ticket) inFlight.current = null;
|
||||||
|
if (ticket === seq.current) setSettled({ run, data: d, error: null, loading: false, updatedAt: Date.now() });
|
||||||
},
|
},
|
||||||
(e) => {
|
(e) => {
|
||||||
|
if (inFlight.current?.ticket === ticket) inFlight.current = null;
|
||||||
if (ticket === seq.current) {
|
if (ticket === seq.current) {
|
||||||
setSettled((s) => ({ run, data: s?.data ?? null, error: e, loading: false }));
|
setSettled((s) => ({ run, data: s?.data ?? null, error: e, loading: false, updatedAt: s?.updatedAt ?? null }));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}, [run, keepPrevious]);
|
}, [run, keepPrevious, coalesce]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
reload();
|
reload();
|
||||||
@@ -83,6 +93,7 @@ export function useAsync<T>(
|
|||||||
const tickets = seq;
|
const tickets = seq;
|
||||||
return () => {
|
return () => {
|
||||||
tickets.current++;
|
tickets.current++;
|
||||||
|
inFlight.current = null;
|
||||||
};
|
};
|
||||||
}, [reload]);
|
}, [reload]);
|
||||||
|
|
||||||
@@ -94,6 +105,7 @@ export function useAsync<T>(
|
|||||||
error: current ? settled.error : null,
|
error: current ? settled.error : null,
|
||||||
loading: current ? settled.loading : true,
|
loading: current ? settled.loading : true,
|
||||||
reload,
|
reload,
|
||||||
|
updatedAt: current ? settled.updatedAt : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -228,6 +228,7 @@ describe("LogStreamController", () => {
|
|||||||
es.emitOpen();
|
es.emitOpen();
|
||||||
es.emitMessage("[12:00:00] [Server thread/INFO]: hello");
|
es.emitMessage("[12:00:00] [Server thread/INFO]: hello");
|
||||||
es.emitEvent("revoked"); // no frame ran: the status change carries the line
|
es.emitEvent("revoked"); // no frame ran: the status change carries the line
|
||||||
|
expect(ctrl.getSnapshot().retryable).toBe(false);
|
||||||
expect(ctrl.getSnapshot().status).toBe("ended");
|
expect(ctrl.getSnapshot().status).toBe("ended");
|
||||||
expect(es.closed).toBe(true);
|
expect(es.closed).toBe(true);
|
||||||
expect(ctrl.getSnapshot().lines).toHaveLength(1);
|
expect(ctrl.getSnapshot().lines).toHaveLength(1);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export interface LogLine {
|
|||||||
export interface LogStreamSnapshot {
|
export interface LogStreamSnapshot {
|
||||||
lines: LogLine[];
|
lines: LogLine[];
|
||||||
status: StreamStatus;
|
status: StreamStatus;
|
||||||
|
retryable: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventSource readyState constants. We avoid referencing the DOM EventSource
|
// EventSource readyState constants. We avoid referencing the DOM EventSource
|
||||||
@@ -158,13 +159,14 @@ export class LogStreamController {
|
|||||||
// reference between mutations — required by useSyncExternalStore to avoid an
|
// reference between mutations — required by useSyncExternalStore to avoid an
|
||||||
// infinite render loop.
|
// infinite render loop.
|
||||||
private snapshot: LogStreamSnapshot;
|
private snapshot: LogStreamSnapshot;
|
||||||
|
private retryable = true;
|
||||||
|
|
||||||
constructor(opts: LogStreamOptions) {
|
constructor(opts: LogStreamOptions) {
|
||||||
this.url = opts.url;
|
this.url = opts.url;
|
||||||
this.factory = opts.factory;
|
this.factory = opts.factory;
|
||||||
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
|
this.maxLines = opts.maxLines ?? DEFAULT_MAX_LINES;
|
||||||
this.frame = opts.frame ?? nextFrame;
|
this.frame = opts.frame ?? nextFrame;
|
||||||
this.snapshot = { lines: this.lines, status: this.status };
|
this.snapshot = { lines: this.lines, status: this.status, retryable: this.retryable };
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe = (fn: () => void): (() => void) => {
|
subscribe = (fn: () => void): (() => void) => {
|
||||||
@@ -201,6 +203,7 @@ export class LogStreamController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private connect(): void {
|
private connect(): void {
|
||||||
|
this.retryable = true;
|
||||||
this.setStatus("connecting");
|
this.setStatus("connecting");
|
||||||
const es = this.factory(this.url);
|
const es = this.factory(this.url);
|
||||||
this.es = es;
|
this.es = es;
|
||||||
@@ -212,6 +215,7 @@ export class LogStreamController {
|
|||||||
es.addEventListener("revoked", () => {
|
es.addEventListener("revoked", () => {
|
||||||
if (this.es !== es) return;
|
if (this.es !== es) return;
|
||||||
this.disconnect();
|
this.disconnect();
|
||||||
|
this.retryable = false;
|
||||||
this.setStatus("ended");
|
this.setStatus("ended");
|
||||||
});
|
});
|
||||||
es.onerror = () => {
|
es.onerror = () => {
|
||||||
@@ -281,7 +285,7 @@ export class LogStreamController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private commit(): void {
|
private commit(): void {
|
||||||
this.snapshot = { lines: this.lines, status: this.status };
|
this.snapshot = { lines: this.lines, status: this.status, retryable: this.retryable };
|
||||||
this.listeners.forEach((fn) => fn());
|
this.listeners.forEach((fn) => fn());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Upload,
|
Upload,
|
||||||
Clock,
|
Clock,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
|
Settings,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@@ -78,6 +79,7 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: "/admin/users", key: "admin_users", icon: Users },
|
{ to: "/admin/users", key: "admin_users", icon: Users },
|
||||||
{ to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck },
|
{ to: "/admin/auth-sources", key: "auth_sources", icon: ShieldCheck },
|
||||||
|
{ to: "/admin/platform", key: "platform_settings", icon: Settings },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -600,6 +600,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/servers/{name}/emergency-stop": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/**
|
||||||
|
* Scale an owned game workload to zero independently of Operator and RCON.
|
||||||
|
* @description Owner only; fresh authentication and exact name confirmation required. A 202 acknowledges scaling, not process exit. Normal Pod termination grace is retained.
|
||||||
|
*/
|
||||||
|
post: operations["emergencyStop"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/servers/{name}/restart": {
|
"/api/v1/servers/{name}/restart": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1243,6 +1263,27 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/v1/settings/wake-policy": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/** Read platform startup policy (Owner). */
|
||||||
|
get: operations["getWakePolicy"];
|
||||||
|
/**
|
||||||
|
* Save platform startup policy (Owner, fresh reauthentication).
|
||||||
|
* @description Atomically persists an override shared by all replicas. The admission limit applies to panel, in-game and scheduled starts without restarting. It does not stop existing servers or reserve memory. Wake cooldown applies to panel and in-game wakes.
|
||||||
|
*/
|
||||||
|
put: operations["setWakePolicy"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/v1/settings/auth-sources": {
|
"/api/v1/settings/auth-sources": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -2915,6 +2956,21 @@ export interface components {
|
|||||||
api_url: string;
|
api_url: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
};
|
};
|
||||||
|
StartupStatus: {
|
||||||
|
/** @enum {string} */
|
||||||
|
stage: "creating" | "scheduling" | "preparing" | "booting" | "failed";
|
||||||
|
reason?: string;
|
||||||
|
message?: string;
|
||||||
|
/** Format: date-time */
|
||||||
|
startedAt?: string;
|
||||||
|
logsAvailable: boolean;
|
||||||
|
};
|
||||||
|
WakePolicySettings: {
|
||||||
|
maxRunningServers: number;
|
||||||
|
wakeCooldownSeconds: number;
|
||||||
|
revision: string;
|
||||||
|
managed: boolean;
|
||||||
|
};
|
||||||
AuthSourcesSettings: {
|
AuthSourcesSettings: {
|
||||||
/** @description Third-party sources in priority order; built-in Mojang always precedes them and is immutable. */
|
/** @description Third-party sources in priority order; built-in Mojang always precedes them and is immutable. */
|
||||||
sources: components["schemas"]["AuthSourceConfig"][];
|
sources: components["schemas"]["AuthSourceConfig"][];
|
||||||
@@ -3074,6 +3130,8 @@ export interface components {
|
|||||||
};
|
};
|
||||||
/** @description Status projection of one server (internal/api/cluster.go ServerInfo). */
|
/** @description Status projection of one server (internal/api/cluster.go ServerInfo). */
|
||||||
ServerInfo: {
|
ServerInfo: {
|
||||||
|
/** @description Private runtime and startup diagnostics for authorized server managers. */
|
||||||
|
startup?: components["schemas"]["StartupStatus"];
|
||||||
/** @description Execution node; legacy servers report the observed node. */
|
/** @description Execution node; legacy servers report the observed node. */
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -5069,6 +5127,44 @@ export interface operations {
|
|||||||
404: components["responses"]["NotFound"];
|
404: components["responses"]["NotFound"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
emergencyStop: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
confirm: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Stop intent persisted and workload scaled to zero; await Pod termination. */
|
||||||
|
202: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
name: string;
|
||||||
|
/** @constant */
|
||||||
|
desiredState: "Stopped";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
404: components["responses"]["NotFound"];
|
||||||
|
409: components["responses"]["Conflict"];
|
||||||
|
503: components["responses"]["ServiceUnavailable"];
|
||||||
|
};
|
||||||
|
};
|
||||||
restart: {
|
restart: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -6729,6 +6825,68 @@ export interface operations {
|
|||||||
401: components["responses"]["Unauthorized"];
|
401: components["responses"]["Unauthorized"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
getWakePolicy: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description Current startup policy and revision. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["WakePolicySettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
setWakePolicy: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
maxRunningServers: number;
|
||||||
|
wakeCooldownSeconds: number;
|
||||||
|
revision: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Saved policy and new revision. */
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["WakePolicySettings"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
400: components["responses"]["BadRequest"];
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
403: components["responses"]["Forbidden"];
|
||||||
|
/** @description The policy changed; reload before saving. */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
getAuthSources: {
|
getAuthSources: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export interface MyServerView {
|
|||||||
* A caller who does not own the server gets the public subset, so everything
|
* A caller who does not own the server gets the public subset, so everything
|
||||||
* past the counts may be absent. */
|
* past the counts may be absent. */
|
||||||
export interface ServerStatus {
|
export interface ServerStatus {
|
||||||
|
startup?: { stage: "creating" | "scheduling" | "preparing" | "booting" | "failed"; reason?: string; message?: string; startedAt?: string; logsAvailable: boolean };
|
||||||
nodeName?: string;
|
nodeName?: string;
|
||||||
name: string;
|
name: string;
|
||||||
subdomain: string;
|
subdomain: string;
|
||||||
@@ -768,3 +769,10 @@ export interface WorldMigration {
|
|||||||
switched: boolean;
|
switched: boolean;
|
||||||
attempt: number;
|
attempt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WakePolicySettings {
|
||||||
|
maxRunningServers: number;
|
||||||
|
wakeCooldownSeconds: number;
|
||||||
|
revision: string;
|
||||||
|
managed: boolean;
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ const browserEventSource: EventSourceFactory = (url) =>
|
|||||||
export interface UseLogStream {
|
export interface UseLogStream {
|
||||||
lines: LogLine[];
|
lines: LogLine[];
|
||||||
status: StreamStatus;
|
status: StreamStatus;
|
||||||
|
retryable: boolean;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
reconnect: () => void;
|
reconnect: () => void;
|
||||||
}
|
}
|
||||||
@@ -58,5 +59,5 @@ export function useLogStream(url: string): UseLogStream {
|
|||||||
const clear = useCallback(() => controller.clear(), [controller]);
|
const clear = useCallback(() => controller.clear(), [controller]);
|
||||||
const reconnect = useCallback(() => controller.reconnect(), [controller]);
|
const reconnect = useCallback(() => controller.reconnect(), [controller]);
|
||||||
|
|
||||||
return { lines: snapshot.lines, status: snapshot.status, clear, reconnect };
|
return { lines: snapshot.lines, status: snapshot.status, retryable: snapshot.retryable, clear, reconnect };
|
||||||
}
|
}
|
||||||
@@ -31,6 +31,7 @@ vi.mock("@/components/LogConsole", () => ({ LogConsole: () => <div data-testid="
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tier.isAdmin = true;
|
tier.isAdmin = true;
|
||||||
|
tier.isOwner = false;
|
||||||
calls.status.mockReset();
|
calls.status.mockReset();
|
||||||
calls.myServers.mockReset();
|
calls.myServers.mockReset();
|
||||||
calls.myServers.mockResolvedValue([]);
|
calls.myServers.mockResolvedValue([]);
|
||||||
@@ -53,6 +54,18 @@ function status(over: Record<string, unknown>) {
|
|||||||
return { name: "survival", subdomain: "survival", phase: "Failed", ready: false, playersOnline: 0, playersMax: 20, ...over };
|
return { name: "survival", subdomain: "survival", phase: "Failed", ready: false, playersOnline: 0, playersMax: 20, ...over };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
describe("ServerConsole host recovery", () => {
|
||||||
|
it("offers host recovery even when the first API read fails", async () => {
|
||||||
|
tier.isOwner = true;
|
||||||
|
calls.status.mockRejectedValue(new Error("API offline"));
|
||||||
|
renderConsole();
|
||||||
|
expect(await screen.findByText("Control API unreachable? Show host recovery steps")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/kubectl patch minecraftserver survival/)).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||||
|
tier.isOwner = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("ServerConsole failed start", () => {
|
describe("ServerConsole failed start", () => {
|
||||||
it("shows the failed attempt's log under what the owner can do once retries are spent", async () => {
|
it("shows the failed attempt's log under what the owner can do once retries are spent", async () => {
|
||||||
calls.status.mockResolvedValue(status({ desiredState: "Running", startGaveUp: true, autoRestarts: 3 }));
|
calls.status.mockResolvedValue(status({ desiredState: "Running", startGaveUp: true, autoRestarts: 3 }));
|
||||||
@@ -157,10 +170,10 @@ describe("ServerConsole following the server", () => {
|
|||||||
|
|
||||||
calls.status.mockRejectedValue({ status: 409, code: "test_failure", message: "status backend down" });
|
calls.status.mockRejectedValue({ status: 409, code: "test_failure", message: "status backend down" });
|
||||||
await advance(STATUS_POLL_SLOW_MS);
|
await advance(STATUS_POLL_SLOW_MS);
|
||||||
await waitFor(() => expect(screen.getByText(/Couldn't refresh/)).toBeTruthy());
|
await waitFor(() => expect(screen.getByText(/Cannot confirm the current server state/)).toBeTruthy());
|
||||||
expect(screen.getByText(/status backend down/)).toBeTruthy();
|
expect(screen.getByText(/status backend down/)).toBeTruthy();
|
||||||
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
||||||
expect(screen.getByRole("button", { name: "Stop" })).toBeTruthy();
|
expect(screen.queryByRole("button", { name: "Stop" })).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -178,16 +191,21 @@ describe("ServerConsole retirement", () => {
|
|||||||
calls.status.mockResolvedValue(stopped());
|
calls.status.mockResolvedValue(stopped());
|
||||||
renderConsole();
|
renderConsole();
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "Give up" })).toBeTruthy();
|
const more = await screen.findByRole("button", { name: "More actions" });
|
||||||
|
expect(giveUp()).toBeNull();
|
||||||
|
await userEvent.click(more);
|
||||||
|
expect(screen.getByRole("button", { name: "Give up" })).toBeTruthy();
|
||||||
expect(del()).toBeNull();
|
expect(del()).toBeNull();
|
||||||
expect(screen.getByRole("button", { name: "Wake" })).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("offers an admin both", async () => {
|
it("offers an admin both", async () => {
|
||||||
calls.status.mockResolvedValue(stopped());
|
calls.status.mockResolvedValue(stopped());
|
||||||
renderConsole();
|
renderConsole();
|
||||||
|
|
||||||
expect(await screen.findByRole("button", { name: "Delete" })).toBeTruthy();
|
const more = await screen.findByRole("button", { name: "More actions" });
|
||||||
|
expect(del()).toBeNull();
|
||||||
|
await userEvent.click(more);
|
||||||
|
expect(screen.getByRole("button", { name: "Delete" })).toBeTruthy();
|
||||||
expect(giveUp()).toBeTruthy();
|
expect(giveUp()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,3 +342,42 @@ describe("ServerConsole command line", () => {
|
|||||||
expect(kept.slice(-2)).toEqual(["cmd 49", "list"]);
|
expect(kept.slice(-2)).toEqual(["cmd 49", "list"]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("startup diagnostics and stale state", () => {
|
||||||
|
it("shows a crashed container even while the operator phase still says Starting", async () => {
|
||||||
|
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running", startup: { stage: "failed", reason: "OOMKilled", message: "Container minecraft exited with code 137", logsAvailable: false } }));
|
||||||
|
renderConsole();
|
||||||
|
expect(await screen.findByText("Startup issue")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/OOMKilled.*137/)).toBeTruthy();
|
||||||
|
expect(screen.queryByText("Starting", { exact: true })).toBeNull();
|
||||||
|
expect(screen.getByText("Logs are not available yet")).toBeTruthy();
|
||||||
|
});
|
||||||
|
it("explains scheduling failure before logs exist", async () => {
|
||||||
|
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running", startup: { stage: "scheduling", reason: "Unschedulable", message: "0/1 nodes: Insufficient memory", startedAt: new Date().toISOString(), logsAvailable: false } }));
|
||||||
|
renderConsole();
|
||||||
|
expect(await screen.findByText("Waiting for resources and scheduling")).toBeTruthy();
|
||||||
|
expect(screen.getByText(/Not enough schedulable memory/)).toBeTruthy();
|
||||||
|
expect(screen.queryByTestId("log-stream")).toBeNull();
|
||||||
|
expect(screen.queryByRole("button", { name: "Delete server" })).toBeNull();
|
||||||
|
expect(screen.getByRole("button", { name: "More actions" })).toBeTruthy();
|
||||||
|
});
|
||||||
|
it("does not claim a live startup state when the API is unavailable, and recovers automatically", async () => {
|
||||||
|
calls.status.mockResolvedValue(status({ phase: "Starting", desiredState: "Running", startup: { stage: "booting", logsAvailable: true } }));
|
||||||
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||||
|
renderConsole();
|
||||||
|
await screen.findByText("Starting Minecraft and checking readiness");
|
||||||
|
try {
|
||||||
|
calls.status.mockRejectedValue({ status: 0, code: "network_error", message: "backend unavailable" });
|
||||||
|
await act(async () => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||||
|
expect(screen.getByText("Status unavailable")).toBeTruthy();
|
||||||
|
expect(screen.getByRole("alert").textContent).toContain("Last successful read");
|
||||||
|
expect(screen.queryByText("Starting Minecraft and checking readiness")).toBeNull();
|
||||||
|
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
||||||
|
expect(screen.queryByRole("button", { name: /^Stop/ })).toBeNull();
|
||||||
|
calls.status.mockResolvedValue(status({ phase: "Running", ready: true, desiredState: "Running" }));
|
||||||
|
await act(async () => vi.advanceTimersByTimeAsync(STATUS_POLL_FAST_MS));
|
||||||
|
expect(screen.queryByText("Status unavailable")).toBeNull();
|
||||||
|
expect(screen.getByTestId("log-stream")).toBeTruthy();
|
||||||
|
} finally { vi.useRealTimers(); }
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,12 +2,13 @@ import { useState, useRef, useCallback, useId, useLayoutEffect, type KeyboardEve
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, CalendarClock, ChevronRight, type LucideIcon } from "lucide-react";
|
import { Terminal, Moon, Shield, ShieldAlert, HelpCircle, Loader2, Users, Archive, FolderOpen, CalendarClock, ChevronRight, type LucideIcon } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { BackLink } from "@/components/BackLink";
|
import { BackLink } from "@/components/BackLink";
|
||||||
import { MAX_AUTO_RESTARTS, PhaseBadge, pendingPower, shownPhase, startFailure, type StartFailure } from "@/components/PhaseBadge";
|
import { MAX_AUTO_RESTARTS, PhaseBadge, pendingPower, shownPhase, startFailure, type StartFailure } from "@/components/PhaseBadge";
|
||||||
import { PageHeader } from "@/components/PageHeader";
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
import { LogConsole } from "@/components/LogConsole";
|
import { LogConsole } from "@/components/LogConsole";
|
||||||
import { Loading, ErrorState, RefreshError } from "@/components/States";
|
import { Loading, ErrorState } from "@/components/States";
|
||||||
import { api, consoleStreamURL, humanizeError } from "@/lib/api";
|
import { api, consoleStreamURL, humanizeError } from "@/lib/api";
|
||||||
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, useConfig, usePolling } from "@/lib/hooks";
|
import { STATUS_POLL_FAST_MS, STATUS_POLL_SLOW_MS, useAsync, useConfig, usePolling } from "@/lib/hooks";
|
||||||
import { useTier } from "@/lib/tier";
|
import { useTier } from "@/lib/tier";
|
||||||
@@ -19,15 +20,16 @@ import { EditServerDialog } from "@/components/EditServerDialog";
|
|||||||
import { PowerButton } from "@/components/PowerButton";
|
import { PowerButton } from "@/components/PowerButton";
|
||||||
import { RetireCard, RetireNotice } from "@/components/Retirement";
|
import { RetireCard, RetireNotice } from "@/components/Retirement";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { InlineError } from "@/components/MessageLine";
|
import { InlineError, MessageLine } from "@/components/MessageLine";
|
||||||
|
|
||||||
// notStreamingCopy explains why there is no live feed for a phase that has no
|
// notStreamingCopy explains why there is no live feed for a phase that has no
|
||||||
// streamable pod. The read path only has something to relay once a pod is up, so
|
// streamable pod. The read path only has something to relay once a pod is up, so
|
||||||
// Stopped/Failed/Stopping each get their own honest line rather than an empty
|
// Stopped/Failed/Stopping each get their own honest line rather than an empty
|
||||||
// console. `default` covers Unknown plus any future phase the backend may emit
|
// console. `default` covers Unknown plus any future phase the backend may emit
|
||||||
// that the panel hasn't modelled yet — the screen stays informative regardless.
|
// that the panel hasn't modelled yet — the screen stays informative regardless.
|
||||||
function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; body: string; spin?: boolean } {
|
function useNotStreamingCopy(phase: Phase, waitingForContainer: boolean): { icon: LucideIcon; title: string; body: string; spin?: boolean } {
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
|
if (waitingForContainer) return { icon: HelpCircle, title: t("logs_unavailable_title"), body: t("logs_unavailable_body") };
|
||||||
switch (phase) {
|
switch (phase) {
|
||||||
case "Starting":
|
case "Starting":
|
||||||
// Asked to start with no pod yet: the stream attaches once the pod is up.
|
// Asked to start with no pod yet: the stream attaches once the pod is up.
|
||||||
@@ -58,8 +60,8 @@ function useNotStreamingCopy(phase: Phase): { icon: LucideIcon; title: string; b
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function NotStreaming({ phase }: { phase: Phase }) {
|
function NotStreaming({ phase, waitingForContainer = false }: { phase: Phase; waitingForContainer?: boolean }) {
|
||||||
const { icon: Icon, title, body, spin } = useNotStreamingCopy(phase);
|
const { icon: Icon, title, body, spin } = useNotStreamingCopy(phase, waitingForContainer);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
|
<div className="flex items-start gap-3 rounded-md border border-dashed border-border bg-muted/30 p-6 text-sm text-muted-foreground">
|
||||||
<Icon className={cn("mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70", spin && "animate-spin")} />
|
<Icon className={cn("mt-0.5 h-5 w-5 shrink-0 text-muted-foreground/70", spin && "animate-spin")} />
|
||||||
@@ -229,11 +231,12 @@ function CommandInput({ name }: { name: string }) {
|
|||||||
export function ServerConsole() {
|
export function ServerConsole() {
|
||||||
const { name = "" } = useParams();
|
const { name = "" } = useParams();
|
||||||
const cfg = useConfig();
|
const cfg = useConfig();
|
||||||
const { isAdmin } = useTier();
|
const { isAdmin, isOwner } = useTier();
|
||||||
const { t } = useTranslation("servers");
|
const { t } = useTranslation("servers");
|
||||||
const { data, error, loading, reload } = useAsync(
|
const { data, error, loading, reload, updatedAt } = useAsync(
|
||||||
() => api.status(name),
|
() => api.status(name),
|
||||||
[name],
|
[name],
|
||||||
|
{ coalesce: true },
|
||||||
);
|
);
|
||||||
// The header, the power button and whether the stream attaches all follow the
|
// The header, the power button and whether the stream attaches all follow the
|
||||||
// status, so it is reread: fast while the server is on its way somewhere (a
|
// status, so it is reread: fast while the server is on its way somewhere (a
|
||||||
@@ -259,8 +262,10 @@ export function ServerConsole() {
|
|||||||
// read most wants them, so the gate streams for both, not Running alone. A
|
// read most wants them, so the gate streams for both, not Running alone. A
|
||||||
// Failed start usually leaves its pod behind, and that pod's log is how the
|
// Failed start usually leaves its pod behind, and that pod's log is how the
|
||||||
// owner finds out why it failed, so Failed streams too.
|
// owner finds out why it failed, so Failed streams too.
|
||||||
const streamable = data?.phase === "Running" || data?.phase === "Starting" || data?.phase === "Failed";
|
const streamable = data?.phase === "Running" || ((data?.phase === "Starting" || data?.phase === "Failed") && data.startup?.logsAvailable !== false);
|
||||||
const failure = data ? startFailure(data) : null;
|
const failure = data ? startFailure(data) : null;
|
||||||
|
const startup = data?.startup;
|
||||||
|
const startupBlocked = startup?.stage === "failed" || startup?.reason === "Unschedulable";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col lg:flex-1 lg:min-h-[35rem] gap-4 min-h-0">
|
<div className="flex flex-col lg:flex-1 lg:min-h-[35rem] gap-4 min-h-0">
|
||||||
@@ -271,18 +276,26 @@ export function ServerConsole() {
|
|||||||
{loading && !data ? (
|
{loading && !data ? (
|
||||||
<Loading />
|
<Loading />
|
||||||
) : error && !data ? (
|
) : error && !data ? (
|
||||||
<ErrorState error={error} onRetry={reload} />
|
<div className="space-y-3"><ErrorState error={error} onRetry={reload} />{isOwner && <HostRecovery name={name} />}</div>
|
||||||
) : data ? (
|
) : data ? (
|
||||||
<>
|
<>
|
||||||
{error && <RefreshError error={error} />}
|
{error && <div className="space-y-2"><MessageLine kind="error" message={t("status_stale_detail", { time: updatedAt ? new Date(updatedAt).toLocaleTimeString() : "—", error: humanizeError(error) })} />{isOwner && <HostRecovery name={name} />}</div>}
|
||||||
<PageHeader
|
<PageHeader
|
||||||
icon={Terminal}
|
icon={Terminal}
|
||||||
title={data.displayName || data.name}
|
title={data.displayName || data.name}
|
||||||
subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined}
|
subtitle={cfg ? <CopyAddress address={joinAddress(data.subdomain, cfg)} /> : undefined}
|
||||||
actions={
|
actions={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
{error ? (
|
||||||
|
<span role="status" className="text-sm font-medium text-destructive">{t("status_stale")}</span>
|
||||||
|
) : startupBlocked ? (
|
||||||
|
<Badge variant="outline" className="border-amber-500/30 text-amber-600 dark:text-amber-400">
|
||||||
|
{t(startup?.stage === "failed" ? "startup_problem" : "startup_waiting_resources")}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
<PhaseBadge phase={shownPhase(data)} failure={failure} autoRestarts={data.autoRestarts} />
|
<PhaseBadge phase={shownPhase(data)} failure={failure} autoRestarts={data.autoRestarts} />
|
||||||
<PowerButton
|
)}
|
||||||
|
{!error && <PowerButton
|
||||||
name={name}
|
name={name}
|
||||||
phase={data.phase}
|
phase={data.phase}
|
||||||
desiredState={data.desiredState}
|
desiredState={data.desiredState}
|
||||||
@@ -291,7 +304,7 @@ export function ServerConsole() {
|
|||||||
playerCountUnknown={data.playerCountUnknown}
|
playerCountUnknown={data.playerCountUnknown}
|
||||||
retiring={data.retiring}
|
retiring={data.retiring}
|
||||||
onChanged={reload}
|
onChanged={reload}
|
||||||
/>
|
/>}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
className="mb-6"
|
className="mb-6"
|
||||||
@@ -301,6 +314,14 @@ export function ServerConsole() {
|
|||||||
<RetireNotice name={name} retiring={data.retiring} isAdmin={isAdmin} onChanged={reload} />
|
<RetireNotice name={name} retiring={data.retiring} isAdmin={isAdmin} onChanged={reload} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!error && startup && (
|
||||||
|
<div role="status" className="shrink-0 space-y-2 rounded-lg border border-border bg-card p-4 text-sm">
|
||||||
|
<p className="flex items-center gap-2 font-medium">{startupBlocked ? <ShieldAlert className="h-4 w-4 text-amber-500" /> : <Loader2 className="h-4 w-4 animate-spin text-primary" />}{t(`startup_${startup.stage}`)}</p>
|
||||||
|
{startup.startedAt && <p className="text-xs text-muted-foreground">{t("startup_elapsed", { seconds: Math.max(0, Math.floor((Date.now() - new Date(startup.startedAt).getTime()) / 1000)) })}</p>}
|
||||||
|
{startup.reason && <p className="break-words text-muted-foreground">{startup.reason === "Unschedulable" && startup.message?.includes("Insufficient memory") ? t("startup_memory") : [startup.reason, startup.message].filter(Boolean).join(": ")}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">{t(startup.stage === "failed" ? "startup_failed_hint" : "startup_hint")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 lg:grid-rows-[minmax(0,1fr)] flex-1 lg:min-h-0 min-h-0">
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-4 lg:grid-rows-[minmax(0,1fr)] flex-1 lg:min-h-0 min-h-0">
|
||||||
{/* Left/Main column: Console */}
|
{/* Left/Main column: Console */}
|
||||||
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
|
<div className="lg:col-span-3 flex flex-col lg:min-h-0 min-h-0 h-full">
|
||||||
@@ -308,7 +329,7 @@ export function ServerConsole() {
|
|||||||
<CardContent className="p-0 flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
<CardContent className="p-0 flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
||||||
{!streamable ? (
|
{!streamable ? (
|
||||||
<div className="flex-1 flex flex-col justify-center p-6 bg-black">
|
<div className="flex-1 flex flex-col justify-center p-6 bg-black">
|
||||||
<NotStreaming phase={shownPhase(data)} />
|
<NotStreaming phase={error ? "Unknown" : shownPhase(data)} waitingForContainer={!error && data.startup?.logsAvailable === false} />
|
||||||
</div>
|
</div>
|
||||||
) : cfg ? (
|
) : cfg ? (
|
||||||
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
<div className="flex-1 flex flex-col lg:min-h-0 min-h-0 bg-black">
|
||||||
@@ -317,10 +338,11 @@ export function ServerConsole() {
|
|||||||
)}
|
)}
|
||||||
<LogConsole
|
<LogConsole
|
||||||
key={name}
|
key={name}
|
||||||
|
starting={!error && data.phase === "Starting"}
|
||||||
url={consoleStreamURL(cfg.apiBase, name)}
|
url={consoleStreamURL(cfg.apiBase, name)}
|
||||||
className="border-0 rounded-none bg-transparent"
|
className="border-0 rounded-none bg-transparent"
|
||||||
/>
|
/>
|
||||||
{data.phase === "Running" && (
|
{!error && data.phase === "Running" && (
|
||||||
<div className="p-3 bg-zinc-900/40 border-t border-zinc-800">
|
<div className="p-3 bg-zinc-900/40 border-t border-zinc-800">
|
||||||
<CommandInput name={name} />
|
<CommandInput name={name} />
|
||||||
</div>
|
</div>
|
||||||
@@ -430,8 +452,8 @@ export function ServerConsole() {
|
|||||||
{/* Giving the server up (owner) or deleting it (admin) closes the
|
{/* Giving the server up (owner) or deleting it (admin) closes the
|
||||||
sidebar. A system server is never retired, and one already on its
|
sidebar. A system server is never retired, and one already on its
|
||||||
way out shows the notice above with the way back instead. */}
|
way out shows the notice above with the way back instead. */}
|
||||||
{owned && !data.reaperExempt && !data.retiring && (
|
{owned && (isOwner || (!data.reaperExempt && !data.retiring)) && (
|
||||||
<RetireCard name={name} label={data.displayName || data.name} isAdmin={isAdmin} onChanged={reload} />
|
<RetireCard name={name} label={data.displayName || data.name} isAdmin={isAdmin} isOwner={isOwner} canRetire={!data.reaperExempt && !data.retiring} onChanged={reload} />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -440,3 +462,11 @@ export function ServerConsole() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function HostRecovery({ name }: { name: string }) {
|
||||||
|
const { t } = useTranslation("servers");
|
||||||
|
if (!/^[a-z0-9][a-z0-9-]{0,31}$/.test(name)) return null;
|
||||||
|
return <details className="rounded-lg border border-border bg-card p-3 text-sm"><summary className="cursor-pointer font-medium">{t("host_recovery")}</summary><p className="my-3 text-muted-foreground">{t("host_recovery_hint")}</p><pre className="overflow-x-auto whitespace-pre-wrap rounded bg-muted p-3 text-xs">{`kubectl patch minecraftserver ${name} -n minecraft --type=merge -p '{"spec":{"desiredState":"Stopped"}}'
|
||||||
|
kubectl scale statefulset ${name} -n minecraft --replicas=0
|
||||||
|
kubectl get pods -n minecraft -l felis.lolicon.best/server=${name},felis.lolicon.best/component=server -o wide`}</pre><p className="mt-3 text-muted-foreground">{t("host_recovery_verify")}</p></details>;
|
||||||
|
}
|
||||||
@@ -117,7 +117,7 @@ export function AuthSourcesPage() {
|
|||||||
return <Card key={index} className="overflow-hidden rounded-xl shadow-none">
|
return <Card key={index} className="overflow-hidden rounded-xl shadow-none">
|
||||||
<CardHeader className={cn("flex-row flex-wrap items-center justify-between gap-3 space-y-0 bg-muted/20 py-4", expanded && "border-b border-border/60")}>
|
<CardHeader className={cn("flex-row flex-wrap items-center justify-between gap-3 space-y-0 bg-muted/20 py-4", expanded && "border-b border-border/60")}>
|
||||||
<CardTitle className="min-w-0 flex-1 text-sm">
|
<CardTitle className="min-w-0 flex-1 text-sm">
|
||||||
<Button variant="ghost" className="h-auto w-full justify-start whitespace-normal p-0 text-left hover:bg-transparent" aria-expanded={expanded} aria-controls={`source-fields-${index}`} aria-label={t(expanded ? "collapse" : "expand", { name: source.tag || t("new_source") })} disabled={!draft} onClick={() => setDraft((current) => current && { ...current, sources: current.sources.map((s, i) => i === index ? { ...s, expanded: !expanded } : s) })}>
|
<Button variant="ghost" className="h-auto w-full justify-start whitespace-normal p-0 text-left hover:bg-transparent active:scale-100" aria-expanded={expanded} aria-controls={`source-fields-${index}`} aria-label={t(expanded ? "collapse" : "expand", { name: source.tag || t("new_source") })} disabled={!draft} onClick={() => setDraft((current) => current && { ...current, sources: current.sources.map((s, i) => i === index ? { ...s, expanded: !expanded } : s) })}>
|
||||||
<ChevronRight className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-90")} />
|
<ChevronRight className={cn("transition-transform motion-reduce:transition-none", expanded && "rotate-90")} />
|
||||||
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">{index + 2}</span>
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-xs font-semibold text-primary">{index + 2}</span>
|
||||||
<span className="break-all">{source.tag || t(draft ? "new_source" : "loading")}</span>
|
<span className="break-all">{source.tag || t(draft ? "new_source" : "loading")}</span>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
|
import { MemoryRouter } from "react-router-dom";
|
||||||
|
import { PlatformSettingsPage } from "./PlatformSettingsPage";
|
||||||
|
import type { WakePolicySettings } from "@/lib/types";
|
||||||
|
const calls = vi.hoisted(() => ({ getWakePolicy: vi.fn(), setWakePolicy: vi.fn() }));
|
||||||
|
vi.mock("@/lib/api", async (original) => ({ ...await original<typeof import("@/lib/api")>(), api: calls }));
|
||||||
|
const policy: WakePolicySettings = { maxRunningServers: 0, wakeCooldownSeconds: 0, revision: "initial", managed: false };
|
||||||
|
const limit = () => screen.getByRole("spinbutton", { name: "Running server limit (0–10000)" }) as HTMLInputElement;
|
||||||
|
const cooldown = () => screen.getByRole("spinbutton", { name: "Wake cooldown (0–3600 seconds)" });
|
||||||
|
function page() { render(<MemoryRouter><PlatformSettingsPage /></MemoryRouter>); }
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
calls.getWakePolicy.mockResolvedValue(policy);
|
||||||
|
calls.setWakePolicy.mockImplementation(async (body) => ({ ...body, revision: "saved", managed: true }));
|
||||||
|
});
|
||||||
|
describe("platform policy", () => {
|
||||||
|
it("keeps the form visible during loading without offering placeholder values as saved configuration", async () => {
|
||||||
|
let resolve!: (policy: WakePolicySettings) => void;
|
||||||
|
calls.getWakePolicy.mockReturnValue(new Promise((r) => { resolve = r; }));
|
||||||
|
page();
|
||||||
|
expect(limit().disabled).toBe(true);
|
||||||
|
expect(limit().value).toBe("");
|
||||||
|
expect(screen.getByRole("status").textContent).toContain("Loading the saved policy");
|
||||||
|
await act(async () => resolve(policy));
|
||||||
|
expect(limit().disabled).toBe(false);
|
||||||
|
expect(limit().value).toBe("0");
|
||||||
|
});
|
||||||
|
it("saves validated values with the read revision and preserves drafts on a conflict", async () => {
|
||||||
|
page(); await waitFor(() => expect(limit().disabled).toBe(false));
|
||||||
|
fireEvent.change(limit(), { target: { value: "2" } });
|
||||||
|
fireEvent.change(cooldown(), { target: { value: "30" } });
|
||||||
|
calls.setWakePolicy.mockRejectedValueOnce({ status: 409, code: "conflict", message: "changed" });
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save and apply" }));
|
||||||
|
await screen.findByRole("alert");
|
||||||
|
expect(limit().value).toBe("2");
|
||||||
|
expect(screen.getByRole("button", { name: "Reload" })).toHaveProperty("disabled", true);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Save and apply" }));
|
||||||
|
await screen.findByText("Saved. The new policy is active without a restart.");
|
||||||
|
expect(calls.setWakePolicy).toHaveBeenLastCalledWith({ maxRunningServers: 2, wakeCooldownSeconds: 30, revision: "initial" });
|
||||||
|
fireEvent.change(limit(), { target: { value: "1.5" } });
|
||||||
|
expect(screen.getByRole("button", { name: "Save and apply" })).toHaveProperty("disabled", true);
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Discard changes" }));
|
||||||
|
expect(limit().value).toBe("2");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Loader2, RefreshCw, Save, Settings } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { PageHeader } from "@/components/PageHeader";
|
||||||
|
import { MessageLine } from "@/components/MessageLine";
|
||||||
|
import { isReauthCancelled, useReauth } from "@/components/ReauthDialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api, humanizeError } from "@/lib/api";
|
||||||
|
import { useAsync, useUnsavedGuard } from "@/lib/hooks";
|
||||||
|
import type { WakePolicySettings } from "@/lib/types";
|
||||||
|
|
||||||
|
export function PlatformSettingsPage() {
|
||||||
|
const { t } = useTranslation("admin");
|
||||||
|
const query = useAsync(api.getWakePolicy, []);
|
||||||
|
const reauth = useReauth();
|
||||||
|
const [saved, setSaved] = useState<WakePolicySettings | null>(null);
|
||||||
|
const [limit, setLimit] = useState("");
|
||||||
|
const [cooldown, setCooldown] = useState("");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ kind: "success" | "error"; text: string } | null>(null);
|
||||||
|
const dirty = saved !== null && (limit !== String(saved.maxRunningServers) || cooldown !== String(saved.wakeCooldownSeconds));
|
||||||
|
useUnsavedGuard(dirty);
|
||||||
|
function load(policy: WakePolicySettings) {
|
||||||
|
setSaved(policy);
|
||||||
|
setLimit(String(policy.maxRunningServers));
|
||||||
|
setCooldown(String(policy.wakeCooldownSeconds));
|
||||||
|
}
|
||||||
|
useEffect(() => {
|
||||||
|
if (query.data) load(query.data);
|
||||||
|
}, [query.data]);
|
||||||
|
const busy = query.loading || saving;
|
||||||
|
const valid = /^\d+$/.test(limit) && Number(limit) <= 10000 && /^\d+$/.test(cooldown) && Number(cooldown) <= 3600;
|
||||||
|
async function save() {
|
||||||
|
if (!saved || !dirty || !valid || busy) return;
|
||||||
|
setSaving(true);
|
||||||
|
setMessage(null);
|
||||||
|
try {
|
||||||
|
const policy = await reauth.guard(() => api.setWakePolicy({ maxRunningServers: Number(limit), wakeCooldownSeconds: Number(cooldown), revision: saved.revision }));
|
||||||
|
load(policy);
|
||||||
|
setMessage({ kind: "success", text: t("platform_saved") });
|
||||||
|
} catch (error) {
|
||||||
|
if (!isReauthCancelled(error)) setMessage({ kind: "error", text: humanizeError(error) });
|
||||||
|
} finally { setSaving(false); }
|
||||||
|
}
|
||||||
|
return <div className="space-y-6">
|
||||||
|
<PageHeader icon={Settings} title={t("platform_title")} subtitle={t("platform_subtitle")} actions={<Button variant="outline" size="sm" disabled={dirty || busy} onClick={query.reload}><RefreshCw />{t("platform_reload")}</Button>} />
|
||||||
|
<Card><CardHeader><CardTitle>{t("platform_wake_title")}</CardTitle></CardHeader><CardContent className="space-y-6">
|
||||||
|
{query.loading && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="h-4 w-4 animate-spin" />{t("platform_loading")}</p>}
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2"><Label htmlFor="running-limit">{t("platform_limit")}</Label><Input id="running-limit" type="number" min={0} max={10000} step={1} value={limit} disabled={!saved || busy} onChange={(e) => { setLimit(e.target.value); setMessage(null); }} /><p className="text-xs leading-relaxed text-muted-foreground">{t("platform_limit_hint")}</p></div>
|
||||||
|
<div className="space-y-2"><Label htmlFor="wake-cooldown">{t("platform_cooldown")}</Label><Input id="wake-cooldown" type="number" min={0} max={3600} step={1} value={cooldown} disabled={!saved || busy} onChange={(e) => { setCooldown(e.target.value); setMessage(null); }} /><p className="text-xs leading-relaxed text-muted-foreground">{t("platform_cooldown_hint")}</p></div>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm leading-relaxed text-muted-foreground">{t("platform_scope")}</p>
|
||||||
|
</CardContent></Card>
|
||||||
|
{query.error != null && <MessageLine kind="error" message={humanizeError(query.error)} />}
|
||||||
|
{message && <MessageLine kind={message.kind} message={message.text} />}
|
||||||
|
<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 ? "platform_unsaved" : "platform_live")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{dirty && <Button variant="outline" disabled={busy} onClick={() => { if (saved) load(saved); setMessage(null); }}>{t("platform_discard")}</Button>}
|
||||||
|
<Button disabled={!dirty || !valid || busy} onClick={() => void save()}>{saving ? <Loader2 className="animate-spin" /> : <Save />}{t("platform_save")}</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{reauth.dialog}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
Reference in new issue
Block a user