fix(panel): mark platform system services read-only in the fleet table
login/lobby carry reserved names, so every per-server route rejects them — yet the cockpit offered claim/stop/wake and a console link on their rows, each answering 400 bad_name. The fleet view now marks them (system:true, shared naming.IsSystemServer) and the panel renders a plain label instead of dead actions.
This commit is contained in:
9 files changed
+99
-4
No files matched your search
@@ -2731,6 +2731,13 @@ paths:
|
|||||||
The owner's display identity (email, or username when
|
The owner's display identity (email, or username when
|
||||||
the address is absent). Absent for an unclaimed server
|
the address is absent). Absent for an unclaimed server
|
||||||
or when the best-effort owner lookup failed.
|
or when the best-effort owner lookup failed.
|
||||||
|
system:
|
||||||
|
type: boolean
|
||||||
|
description: >-
|
||||||
|
True for a platform-provisioned system service (the login
|
||||||
|
gate, the lobby). Their reserved names are rejected by
|
||||||
|
every per-server route, so the cockpit renders them
|
||||||
|
read-only instead of offering actions that would 400.
|
||||||
'401':
|
'401':
|
||||||
$ref: '#/components/responses/Unauthorized'
|
$ref: '#/components/responses/Unauthorized'
|
||||||
'403':
|
'403':
|
||||||
|
|||||||
@@ -1809,6 +1809,44 @@ func TestFleetAdminRead(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("system services are marked read-only", func(t *testing.T) {
|
||||||
|
// The login gate and the lobby carry reserved names, so every per-server
|
||||||
|
// route rejects them; the fleet row must say "system" so the cockpit
|
||||||
|
// renders them without actions that would 400.
|
||||||
|
sysCl := newFakeCluster()
|
||||||
|
sysCl.list = []ServerInfo{
|
||||||
|
{Name: "login", Phase: "Running", Ready: true},
|
||||||
|
{Name: "lobby", Phase: "Running", Ready: true},
|
||||||
|
{Name: "survival", Phase: "Stopped"},
|
||||||
|
}
|
||||||
|
api := newTestAPI(newFakeRepo(), sysCl)
|
||||||
|
api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
|
||||||
|
Role: "admin", ViaAdminAccess: true}}
|
||||||
|
w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var got struct {
|
||||||
|
Servers []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
System bool `json:"system"`
|
||||||
|
} `json:"servers"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||||
|
t.Fatalf("body not JSON: %v", err)
|
||||||
|
}
|
||||||
|
byName := map[string]bool{}
|
||||||
|
for _, r := range got.Servers {
|
||||||
|
byName[r.Name] = r.System
|
||||||
|
}
|
||||||
|
if !byName["login"] || !byName["lobby"] {
|
||||||
|
t.Errorf("system flags = %+v, want login+lobby marked", byName)
|
||||||
|
}
|
||||||
|
if byName["survival"] {
|
||||||
|
t.Errorf("survival marked system; only platform services are")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
|
t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
|
||||||
repo := newFakeRepo()
|
repo := newFakeRepo()
|
||||||
// Only "survival" is claimed; "creative"/"skyblock" stay unowned.
|
// Only "survival" is claimed; "creative"/"skyblock" stay unowned.
|
||||||
|
|||||||
@@ -259,7 +259,8 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
|
|||||||
owners, _ := a.Repo.ServerOwners(r.Context())
|
owners, _ := a.Repo.ServerOwners(r.Context())
|
||||||
views := make([]fleetServerView, len(servers))
|
views := make([]fleetServerView, len(servers))
|
||||||
for i, s := range servers {
|
for i, s := range servers {
|
||||||
views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name]}
|
views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name],
|
||||||
|
System: naming.IsSystemServer(s.Name)}
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
|
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
|
||||||
}
|
}
|
||||||
@@ -267,13 +268,18 @@ func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
|
|||||||
// fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD
|
// fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD
|
||||||
// lifecycle view (ServerInfo, §1 authority) with the owner's display identity
|
// lifecycle view (ServerInfo, §1 authority) with the owner's display identity
|
||||||
// joined alongside. The embed keeps every lifecycle field flat in the JSON so the
|
// joined alongside. The embed keeps every lifecycle field flat in the JSON so the
|
||||||
// shape is a strict superset of ServerInfo; Owner is the only addition.
|
// shape is a strict superset of ServerInfo.
|
||||||
type fleetServerView struct {
|
type fleetServerView struct {
|
||||||
ServerInfo
|
ServerInfo
|
||||||
// Owner is the claiming user's display identity (email, or username when the
|
// Owner is the claiming user's display identity (email, or username when the
|
||||||
// address is absent), or "" when the server is unclaimed or the best-effort
|
// address is absent), or "" when the server is unclaimed or the best-effort
|
||||||
// owner lookup failed — the cockpit renders "" as "unclaimed".
|
// owner lookup failed — the cockpit renders "" as "unclaimed".
|
||||||
Owner string `json:"owner,omitempty"`
|
Owner string `json:"owner,omitempty"`
|
||||||
|
// System marks a platform-provisioned system service (the login gate and the
|
||||||
|
// lobby, naming.IsSystemServer). Their names are reserved, so every per-server
|
||||||
|
// API route rejects them — the cockpit must render them read-only rather than
|
||||||
|
// offer claim/wake/stop/console actions that would answer 400.
|
||||||
|
System bool `json:"system,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// createServerRequest is the structured §15 create-server form. This is the
|
// createServerRequest is the structured §15 create-server form. This is the
|
||||||
|
|||||||
@@ -46,6 +46,15 @@ const (
|
|||||||
SystemLobbyServer = "lobby"
|
SystemLobbyServer = "lobby"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// IsSystemServer reports whether name is one of the platform-provisioned system
|
||||||
|
// services above. They carry reserved names on purpose, and the API's per-server
|
||||||
|
// routes reject those names outright (ValidateServerName) — so a caller that only
|
||||||
|
// DISPLAYS fleet rows uses this to mark them as not user-manageable instead of
|
||||||
|
// offering actions (claim/wake/stop/console) that would answer 400.
|
||||||
|
func IsSystemServer(name string) bool {
|
||||||
|
return name == SystemLoginServer || name == SystemLobbyServer
|
||||||
|
}
|
||||||
|
|
||||||
// ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer
|
// ServiceTokenSecretName / ServiceTokenSecretKey name the internal-API bearer
|
||||||
// credential Secret (spec §7). They are one source of truth shared across
|
// credential Secret (spec §7). They are one source of truth shared across
|
||||||
// subsystems: the platform renderer wires this Secret into the felis-api
|
// subsystems: the platform renderer wires this Secret into the felis-api
|
||||||
|
|||||||
@@ -42,6 +42,21 @@ func TestValidateServerName(t *testing.T) {
|
|||||||
// ValidateSystemServerName keeps the format rule but drops the reservation
|
// ValidateSystemServerName keeps the format rule but drops the reservation
|
||||||
// check, so the platform can provision the reserved system names (login, lobby)
|
// check, so the platform can provision the reserved system names (login, lobby)
|
||||||
// that ValidateServerName correctly refuses to hand to users.
|
// that ValidateServerName correctly refuses to hand to users.
|
||||||
|
func TestIsSystemServer(t *testing.T) {
|
||||||
|
// Exactly the platform's two system services answer true; a user server that
|
||||||
|
// merely sounds systemic does not.
|
||||||
|
for _, name := range []string{"login", "lobby"} {
|
||||||
|
if !naming.IsSystemServer(name) {
|
||||||
|
t.Errorf("IsSystemServer(%q) = false, want true", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, name := range []string{"survival", "admin", "login2", "", "lobby-"} {
|
||||||
|
if naming.IsSystemServer(name) {
|
||||||
|
t.Errorf("IsSystemServer(%q) = true, want false", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateSystemServerName(t *testing.T) {
|
func TestValidateSystemServerName(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
"backups_link_desc": "View and restore world backups for this server.",
|
"backups_link_desc": "View and restore world backups for this server.",
|
||||||
"files_link_title": "Server files",
|
"files_link_title": "Server files",
|
||||||
"files_link_desc": "Browse and repair files in the world volume.",
|
"files_link_desc": "Browse and repair files in the world volume.",
|
||||||
|
"system_service": "System service",
|
||||||
|
"system_service_hint": "Provisioned and managed by the platform (felis systemservers); it accepts no user operations.",
|
||||||
"players_back_to_console": "Back to console",
|
"players_back_to_console": "Back to console",
|
||||||
"players_not_yours_title": "Not your server",
|
"players_not_yours_title": "Not your server",
|
||||||
"players_not_yours_body": "Only the owner or an admin can manage this server's players.",
|
"players_not_yours_body": "Only the owner or an admin can manage this server's players.",
|
||||||
|
|||||||
@@ -51,6 +51,8 @@
|
|||||||
"backups_link_desc": "查看并恢复该服务器的世界备份。",
|
"backups_link_desc": "查看并恢复该服务器的世界备份。",
|
||||||
"files_link_title": "服务器文件",
|
"files_link_title": "服务器文件",
|
||||||
"files_link_desc": "浏览并修复世界卷中的文件。",
|
"files_link_desc": "浏览并修复世界卷中的文件。",
|
||||||
|
"system_service": "系统服务",
|
||||||
|
"system_service_hint": "由平台预置并管理(felis systemservers),不接受用户操作。",
|
||||||
"players_back_to_console": "返回控制台",
|
"players_back_to_console": "返回控制台",
|
||||||
"players_not_yours_title": "这不是你的服务器",
|
"players_not_yours_title": "这不是你的服务器",
|
||||||
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
|
"players_not_yours_body": "只有所有者或管理员才能管理此服务器的玩家。",
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ export interface FleetServer {
|
|||||||
* Empty/absent for an unclaimed server or when the best-effort owner lookup
|
* Empty/absent for an unclaimed server or when the best-effort owner lookup
|
||||||
* failed — the cockpit renders that as "unclaimed". */
|
* failed — the cockpit renders that as "unclaimed". */
|
||||||
owner?: string;
|
owner?: string;
|
||||||
|
/** True for a platform-provisioned system service (the login gate, the lobby).
|
||||||
|
* Their reserved names are rejected by every per-server route, so the cockpit
|
||||||
|
* renders them read-only instead of offering actions that would 400. */
|
||||||
|
system?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** BackupView is one row of GET /api/v1/backups (spec §7 backups). A backup is
|
/** BackupView is one row of GET /api/v1/backups (spec §7 backups). A backup is
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ interface UnifiedServer {
|
|||||||
endpointAddress?: string | null;
|
endpointAddress?: string | null;
|
||||||
claimable?: boolean;
|
claimable?: boolean;
|
||||||
owned?: boolean;
|
owned?: boolean;
|
||||||
|
system?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ServersPage() {
|
export function ServersPage() {
|
||||||
@@ -126,6 +127,7 @@ export function ServersPage() {
|
|||||||
endpointAddress: s.endpointAddress,
|
endpointAddress: s.endpointAddress,
|
||||||
claimable: !s.owner,
|
claimable: !s.owner,
|
||||||
owned: s.owner === identity?.email,
|
owned: s.owner === identity?.email,
|
||||||
|
system: s.system,
|
||||||
}));
|
}));
|
||||||
} else {
|
} else {
|
||||||
return (data as ServerInfo[]).map((s) => ({
|
return (data as ServerInfo[]).map((s) => ({
|
||||||
@@ -443,7 +445,11 @@ function ServerRow({
|
|||||||
</td>
|
</td>
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<td className="px-4 py-3 align-middle text-left">
|
<td className="px-4 py-3 align-middle text-left">
|
||||||
{server.owner ? (
|
{server.system ? (
|
||||||
|
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
||||||
|
{ts("system_service")}
|
||||||
|
</span>
|
||||||
|
) : server.owner ? (
|
||||||
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
|
<span className="inline-flex max-w-[16rem] items-center gap-1.5 truncate">
|
||||||
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
<UserRound className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
<span className="truncate" title={server.owner}>
|
<span className="truncate" title={server.owner}>
|
||||||
@@ -486,7 +492,13 @@ function ServerRow({
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 align-middle">
|
<td className="px-4 py-3 align-middle">
|
||||||
<div className="flex items-center justify-end gap-2">
|
<div className="flex items-center justify-end gap-2">
|
||||||
{server.claimable && !server.owned ? (
|
{server.system ? (
|
||||||
|
// A system service carries a reserved name that every per-server route
|
||||||
|
// rejects, so offer no actions — just the honest label.
|
||||||
|
<span className="text-xs text-muted-foreground/70" title={ts("system_service_hint")}>
|
||||||
|
{ts("system_service")}
|
||||||
|
</span>
|
||||||
|
) : server.claimable && !server.owned ? (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
Reference in new issue
Block a user