Compare commits

...
10 Commits
39 changed files with 1049 additions and 345 deletions
+6 -2
View File
@@ -15,13 +15,14 @@ import (
) )
const ( const (
defaultLogEventHistoryLimit = 100 defaultLogEventHistoryLimit = 0
maxLogEventHistoryLimit = 10000 maxLogEventHistoryLimit = 10000
logEventHeartbeatInterval = 15 * time.Second logEventHeartbeatInterval = 15 * time.Second
managedLogSessionIDPrefix = "log-session:" managedLogSessionIDPrefix = "log-session:"
) )
// serverLogEvents streams platform-accepted server log history and live append events for the terminal drawer. // serverLogEvents streams platform-accepted live append events for the terminal drawer.
// Callers can opt into a bounded current-session replay with historyLimit.
func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
writeMethodNotAllowed(w, http.MethodGet) writeMethodNotAllowed(w, http.MethodGet)
@@ -221,6 +222,9 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
return nil, err return nil, err
} }
for _, stream := range active.streams { for _, stream := range active.streams {
if historyLimit == 0 {
emittedThrough[stream.ID] = stream.LatestSeq
}
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil { if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
return nil, err return nil, err
} }
+57
View File
@@ -95,6 +95,63 @@ func TestLogEventsSSEReplaysHistory(t *testing.T) {
} }
} }
func TestLogEventsSSEDefaultsToLiveOnly(t *testing.T) {
router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router)
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 2)), http.StatusOK)
recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")
assertStatus(t, recorder, http.StatusOK)
body := recorder.Body.String()
if !strings.Contains(body, "event: stream") || !strings.Contains(body, "event: ready") || strings.Contains(body, "event: log") {
t.Fatalf("expected live-only SSE snapshot without history logs, body=%s", body)
}
}
func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) {
core := service.NewCoreService(repo.NewMemoryStore())
if err := core.SeedLocalPlatformAdmin(); err != nil {
t.Fatalf("seed platform admin: %v", err)
}
setupRouter := NewTestRouterWithCore(core)
hello := createLogIngestAPIFixtures(t, setupRouter)
initial := validLogBatchRequest(t, hello.SessionToken, 1, 1)
hookResult := make(chan error, 1)
hooked := &logStreamListHookCore{Core: core, hook: func() {
_, err := core.IngestLogBatch(initial.ToDomain())
hookResult <- err
}}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=0", nil).WithContext(ctx)
streamWriter, streamReader := newSSEPipeResponseWriter()
done := make(chan struct{})
go func() {
NewTestRouterWithCore(hooked).ServeHTTP(streamWriter, request)
_ = streamWriter.Close()
close(done)
}()
t.Cleanup(func() {
cancel()
_ = streamReader.Close()
<-done
})
if status := <-streamWriter.status; status != http.StatusOK {
t.Fatalf("unexpected SSE status: %d", status)
}
reader := bufio.NewReader(streamReader)
assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`)
assertSSEEvent(t, reader, "stream", `"id":"log-1"`)
assertSSEEvent(t, reader, "ready", `"streamCount":1`)
if err := <-hookResult; err != nil {
t.Fatalf("ingest during stream snapshot: %v", err)
}
next := validLogBatchRequest(t, hello.SessionToken, 2, 2)
if _, err := core.IngestLogBatch(next.ToDomain()); err != nil {
t.Fatalf("ingest next live batch: %v", err)
}
assertSSEEvent(t, reader, "log", `"seq":2`)
}
func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) { func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) {
router := newTestRouter() router := newTestRouter()
hello := createLogIngestAPIFixtures(t, router) hello := createLogIngestAPIFixtures(t, router)
+1 -1
View File
@@ -1290,7 +1290,7 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt
// serverFilesWorkspace godoc // serverFilesWorkspace godoc
// @Summary Read server file workspace // @Summary Read server file workspace
// @Description Returns plugin-declared logical directories and transfer policy without exposing host paths. // @Description Returns the generic server file manager root and transfer policy without exposing host paths.
// @Tags server-files // @Tags server-files
// @Produce json // @Produce json
// @Param id path string true "Server instance ID" // @Param id path string true "Server instance ID"
+31 -9
View File
@@ -151,7 +151,6 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest()) postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
endpointRequest := validRunEndpointRequest() endpointRequest := validRunEndpointRequest()
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest) postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
ID: "server-metrics-api", ID: "server-metrics-api",
@@ -260,18 +259,18 @@ func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession) putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession) workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || len(workspace.Files) != 2 { if workspace.DefaultDirectoryKey != "scum-config" || workspace.Transfer.Channel != "run-file-transfer" || workspace.DeclaredOnly || len(workspace.Directories) != 2 || workspace.Directories[0].Label != "服务器配置" || len(workspace.Files) != 2 {
t.Fatalf("unexpected workspace: %+v", workspace) t.Fatalf("unexpected workspace: %+v", workspace)
} }
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession) list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession)
foundSettings := false if list.State != "declared" || list.DirectoryKey != "scum-config" || len(list.Entries) != 2 || list.Entries[1].Name != "ServerSettings.ini" || !strings.Contains(list.Reason, "服务器文件缓存") {
for _, entry := range list.Entries { t.Fatalf("expected declared SCUM file list, got %+v", list)
if entry.LogicalKey == "scum-server-settings" && entry.Editable && entry.Downloadable {
foundSettings = true
} }
} refreshRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/refresh", dto.ServerFileListRequest{DirectoryKey: "scum-config", IdempotencyKey: "api-file-list-refresh"}, adminSession)
if list.State != "declared" || !foundSettings { assertStatus(t, refreshRecorder, http.StatusAccepted)
t.Fatalf("expected declared file list, got %+v", list) refresh := decodeBody[dto.ServerFileListResponse](t, refreshRecorder)
if refresh.State != "pending" || refresh.Job == nil || refresh.Job.Capability != domain.JobCapabilityFilesList {
t.Fatalf("expected file list refresh without endpoint declaration gate, got %+v", refresh)
} }
readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession) readRecorder := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/"+instance.ID+"/files/read", dto.ServerFileReadRequest{PluginID: "server.scum", Key: "scum-server-settings", IdempotencyKey: "api-file-read"}, adminSession)
assertStatus(t, readRecorder, http.StatusAccepted) assertStatus(t, readRecorder, http.StatusAccepted)
@@ -286,6 +285,29 @@ func TestCoreAPIServerFileWorkspaceRoutesAreScoped(t *testing.T) {
assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden) assertErrorResponse(t, requestWithAuth(t, router, http.MethodGet, "/api/v1/server-instances/"+instance.ID+"/files/workspace", "", otherSession), http.StatusForbidden, errorCodeForbidden)
} }
func TestCoreAPIServerFileWorkspaceSynthesizesDefaultDirectoryForLegacyPlugin(t *testing.T) {
router := newTestRouter()
adminSession := createAdminSession(t, router)
pluginRequest := validGamePluginRequest()
pluginRequest.RequiredRunCapabilities = append(pluginRequest.RequiredRunCapabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
pluginRequest.DeclaredPermissions = []string{"server.files.read", "server.files.write"}
pluginRequest.Permissions.Files = true
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
endpointRequest := validRunEndpointRequest()
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite)
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{ID: "server-file-legacy-api", PluginID: "server.scum", RunEndpointID: "run-local", Name: "Legacy File API Server", State: domain.ServerInstanceStateRunning}, adminSession)
workspace := getJSONWithAuth[dto.ServerFileWorkspaceResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/workspace", adminSession)
if workspace.DefaultDirectoryKey != "server-root" || workspace.DeclaredOnly || len(workspace.Directories) != 1 || workspace.Directories[0].Label != "服务器根目录" || workspace.Directories == nil || workspace.Files == nil || workspace.ConfigFields == nil {
t.Fatalf("expected synthesized non-null workspace, got %+v", workspace)
}
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list", adminSession)
if list.DirectoryKey != "server-root" || list.Entries == nil || !strings.Contains(list.Reason, "服务器文件缓存") {
t.Fatalf("expected default file list, got %+v", list)
}
}
func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) { func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
releaseBuilds := make(chan struct{}) releaseBuilds := make(chan struct{})
t.Cleanup(func() { close(releaseBuilds) }) t.Cleanup(func() { close(releaseBuilds) })
+1 -1
View File
@@ -154,7 +154,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
- `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests. - `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests.
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key. - `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation. - `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted log SSE history/live events only. The raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs; internal log ingest and cursor query remain available for run/platform maintenance flows. Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events by default. A caller can opt into bounded current-session replay with `historyLimit`; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows.
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
+2
View File
@@ -1212,7 +1212,9 @@ const (
JobCapabilityRemoteRunProcessStart = "remote.run.process.start" JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop" JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query" JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
JobCapabilityRemoteRunDBMySQLExecute = "remote.run.db.mysql.execute"
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query" JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
JobCapabilityRemoteRunDBSQLiteExecute = "remote.run.db.sqlite.execute"
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer" JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command" JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
JobCapabilityRemoteRunProgram = "remote.run.program.command" JobCapabilityRemoteRunProgram = "remote.run.program.command"
+1 -4
View File
@@ -43,9 +43,6 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs { if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
return emptyJobClaim(claim.RunEndpointID, stamp), nil return emptyJobClaim(claim.RunEndpointID, stamp), nil
} }
if session.RequireSignedRequests && len(claim.Capabilities) == 0 {
return emptyJobClaim(claim.RunEndpointID, stamp), nil
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID}) jobs, err := svc.store.Jobs().List(domain.JobFilter{RunEndpointID: claim.RunEndpointID})
if err != nil { if err != nil {
return domain.RunJobClaimResult{}, err return domain.RunJobClaimResult{}, err
@@ -614,7 +611,7 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t
if !eligible || !job.CancelRequestedAt.IsZero() { if !eligible || !job.CancelRequestedAt.IsZero() {
continue continue
} }
if len(capabilitySet) > 0 { if len(capabilitySet) > 0 && !isServerFileCapability(job.Capability) {
if _, supported := capabilitySet[job.Capability]; !supported { if _, supported := capabilitySet[job.Capability]; !supported {
continue continue
} }
+46
View File
@@ -98,6 +98,52 @@ func TestCoreServiceRunJobClaimNoJob(t *testing.T) {
} }
} }
func TestCoreServiceRunJobClaimAllowsServerFileCapabilityWithoutDeclaration(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
instance, err := svc.CreateServerInstance(domain.ServerInstance{
ID: "server-file-claim",
PluginID: plugin.ID,
RunEndpointID: endpoint.ID,
Name: "File Claim Server",
State: domain.ServerInstanceStateRunning,
})
if err != nil {
t.Fatalf("create server instance: %v", err)
}
helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = []string{"control.hello", "control.heartbeat", "process.start"}
helloRequest.CapabilityReport.Fingerprint = "cap-file-claim-no-list"
hello, err := svc.RegisterRunHello(helloRequest)
if err != nil {
t.Fatalf("register run hello: %v", err)
}
_, err = svc.CreateJob(domain.Job{
ID: "job-file-list",
ServerInstanceID: instance.ID,
RunEndpointID: endpoint.ID,
Capability: domain.JobCapabilityFilesList,
TargetKey: "server-root",
IdempotencyKey: "idem-file-list",
})
if err != nil {
t.Fatalf("create file list job: %v", err)
}
claim, err := svc.ClaimRunJob(domain.RunJobClaim{
RunEndpointID: endpoint.ID,
SessionToken: hello.SessionToken,
Capabilities: []string{"process.start"},
Capacity: domain.RunCapacity{MaxJobs: 4},
})
if err != nil {
t.Fatalf("claim file list job: %v", err)
}
if !claim.Accepted || !claim.HasJob || claim.Job.JobID != "job-file-list" || claim.Job.Capability != domain.JobCapabilityFilesList {
t.Fatalf("expected file job claim without files.list declaration, got %+v", claim)
}
}
func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) { func TestCoreServiceRunJobRejectsInvalidSessionAndLease(t *testing.T) {
svc, sessionToken := newRegisteredRunJobService(t) svc, sessionToken := newRegisteredRunJobService(t)
createQueuedRunJob(t, svc, "job-1", "idem-1") createQueuedRunJob(t, svc, "job-1", "idem-1")
+2 -2
View File
@@ -119,7 +119,7 @@ func isRemoteAdapterCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand: domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand:
return true return true
default: default:
@@ -156,7 +156,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind
return domain.RemoteAdapterRunFile return domain.RemoteAdapterRunFile
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop: case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
return domain.RemoteAdapterRunProcess return domain.RemoteAdapterRunProcess
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery: case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute:
return domain.RemoteAdapterDatabase return domain.RemoteAdapterDatabase
case domain.JobCapabilityRemoteRunRCONCommand: case domain.JobCapabilityRemoteRunRCONCommand:
return domain.RemoteAdapterRCON return domain.RemoteAdapterRCON
+10 -14
View File
@@ -1935,6 +1935,9 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
} }
func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) { func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string, serverInstanceID string, fileKey string) (domain.DeclaredFileReadSnapshot, error) {
if err := validator.ValidateServerFileReadSnapshotRequest(serverInstanceID, fileKey); err != nil {
return domain.DeclaredFileReadSnapshot{}, err
}
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID) instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
if err != nil { if err != nil {
return domain.DeclaredFileReadSnapshot{}, err return domain.DeclaredFileReadSnapshot{}, err
@@ -1946,10 +1949,6 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) { if plugin.Status != domain.GamePluginStatusInstalled || (!plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read")) {
return domain.DeclaredFileReadSnapshot{}, ErrForbidden return domain.DeclaredFileReadSnapshot{}, ErrForbidden
} }
file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, domain.FileOperationDispatchRequest{Operation: domain.FileOperationRead, Key: fileKey})
if !constrained || !allowed || file.Key == "" {
return domain.DeclaredFileReadSnapshot{}, validationError("file key must reference a plugin-declared file")
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID}) jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil { if err != nil {
return domain.DeclaredFileReadSnapshot{}, err return domain.DeclaredFileReadSnapshot{}, err
@@ -1958,7 +1957,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
var pending *domain.Job var pending *domain.Job
for i := range jobs { for i := range jobs {
job := jobs[i] job := jobs[i]
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key { if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != fileKey {
continue continue
} }
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" { if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
@@ -1973,7 +1972,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
pending = &copy pending = &copy
} }
} }
base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: file.Key} base := domain.DeclaredFileReadSnapshot{ServerInstanceID: instance.ID, PluginID: plugin.ID, Key: fileKey}
if completed != nil { if completed != nil {
return domain.DeclaredFileReadSnapshot{ return domain.DeclaredFileReadSnapshot{
ServerInstanceID: base.ServerInstanceID, ServerInstanceID: base.ServerInstanceID,
@@ -1995,7 +1994,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
return base, nil return base, nil
} }
base.State = "not-read" base.State = "not-read"
base.Reason = "尚未读取此声明文件。" base.Reason = "尚未读取此文件。"
return base, nil return base, nil
} }
@@ -2182,12 +2181,6 @@ func (svc *CoreService) DispatchFileOperationForSession(sessionID string, reques
if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") { if request.Operation == domain.FileOperationWrite && !containsString(plugin.DeclaredPermissions, "server.files.write") {
return domain.FileOperationDispatchResult{}, ErrForbidden return domain.FileOperationDispatchResult{}, ErrForbidden
} }
if file, constrained, allowed := declaredPluginFileRequest(plugin.FileWorkspace, request); constrained && !allowed {
if file.Key == "" {
return domain.FileOperationDispatchResult{}, validationError("file key must reference a plugin-declared file")
}
return domain.FileOperationDispatchResult{}, validationError("file key is not writable by plugin declaration")
}
} }
capability := domain.JobCapabilityFilesRead capability := domain.JobCapabilityFilesRead
message := "file read queued" message := "file read queued"
@@ -2761,6 +2754,9 @@ func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, ca
if !svc.runEndpointHeartbeatCurrent(endpoint) { if !svc.runEndpointHeartbeatCurrent(endpoint) {
return validationError("run endpoint heartbeat is stale") return validationError("run endpoint heartbeat is stale")
} }
if isServerFileCapability(capability) {
return nil
}
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 { if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 {
return validationError("run endpoint missing required capability: " + capability) return validationError("run endpoint missing required capability: " + capability)
} }
@@ -2789,7 +2785,7 @@ func validateJobServerTarget(job domain.Job, instance domain.ServerInstance, plu
if plugin.ID != instance.PluginID { if plugin.ID != instance.PluginID {
return validationError("job plugin must match server instance") return validationError("job plugin must match server instance")
} }
if job.Capability != domain.JobCapabilityDistributionBuild && !containsString(plugin.RequiredRunCapabilities, job.Capability) { if job.Capability != domain.JobCapabilityDistributionBuild && !isServerFileCapability(job.Capability) && !containsString(plugin.RequiredRunCapabilities, job.Capability) {
return validationError("plugin missing required capability: " + job.Capability) return validationError("plugin missing required capability: " + job.Capability)
} }
return nil return nil
+94 -27
View File
@@ -907,7 +907,7 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
} }
} }
func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) { func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) {
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace() plugin.FileWorkspace = scumTestFileWorkspace()
@@ -946,16 +946,17 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead { if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead {
t.Fatalf("unexpected declared file dispatch: %+v", allowed) t.Fatalf("unexpected declared file dispatch: %+v", allowed)
} }
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ unknown, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
PluginID: plugin.ID, PluginID: plugin.ID,
Operation: domain.FileOperationRead, Operation: domain.FileOperationRead,
Key: "logs/latest.log", Key: "logs/latest.log",
IdempotencyKey: "idem-file-workspace-unknown", IdempotencyKey: "idem-file-workspace-unknown",
}); err == nil || !strings.Contains(err.Error(), "plugin-declared file") { })
t.Fatalf("expected undeclared file key rejection, got %v", err) if err != nil || unknown.Job.TargetKey != "logs/latest.log" || unknown.Job.Capability != domain.JobCapabilityFilesRead {
t.Fatalf("expected undeclared file read to queue, dispatch=%+v err=%v", unknown, err)
} }
if _, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{ written, err := svc.DispatchFileOperationForSession(ownerSession, domain.FileOperationDispatchRequest{
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
PluginID: plugin.ID, PluginID: plugin.ID,
Operation: domain.FileOperationWrite, Operation: domain.FileOperationWrite,
@@ -963,8 +964,72 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
InputRef: "input://file-workspace/update", InputRef: "input://file-workspace/update",
Content: "line", Content: "line",
IdempotencyKey: "idem-file-workspace-log-write", IdempotencyKey: "idem-file-workspace-log-write",
}); err == nil || !strings.Contains(err.Error(), "not writable") { })
t.Fatalf("expected log write rejection, got %v", err) if err != nil || written.Job.TargetKey != "scum-chat-log" || written.Job.Capability != domain.JobCapabilityFilesWrite {
t.Fatalf("expected declared log write to queue, dispatch=%+v err=%v", written, err)
}
}
func TestServerFileListReportsFailedRuntimeRefresh(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
endpoint.Capabilities = append(endpoint.Capabilities, domain.JobCapabilityFilesList)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update file list capability: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-list-failure", DisplayName: "File List Failure", Email: "file-list-failure@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-list-failure", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File List Failure Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
refresh, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root", IdempotencyKey: "idem-file-list-failure"})
if err != nil || refresh.State != "pending" || refresh.Job.ID == "" {
t.Fatalf("refresh file list: result=%+v err=%v", refresh, err)
}
failedJob := refresh.Job
failedJob.State = domain.JobStateFailed
failedJob.Progress = domain.JobProgress{Percent: 100, Message: "executor does not support files.list"}
failedJob.TerminalAt = fixedTime.Add(2 * time.Minute)
failedJob.UpdatedAt = failedJob.TerminalAt
if err := svc.store.Jobs().Update(failedJob); err != nil {
t.Fatalf("update failed file list job: %v", err)
}
list, err := svc.ListServerFilesForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "server-root"})
if err != nil || list.State != "failed" || list.Job.ID != failedJob.ID || !strings.Contains(list.Reason, "executor does not support files.list") {
t.Fatalf("expected failed file list state, list=%+v err=%v", list, err)
}
}
func TestServerFileListFallsBackToPluginWorkspaceWithoutRunListCapability(t *testing.T) {
svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
plugin.FileWorkspace = scumTestFileWorkspace()
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin workspace: %v", err)
}
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "user-file-list-fallback", DisplayName: "File List Fallback", Email: "file-list-fallback@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "server-file-list-fallback", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "File List Fallback Server", State: domain.ServerInstanceStateRunning})
if err != nil {
t.Fatalf("create server: %v", err)
}
createCompleteRuntimeBinding(t, svc, instance, "local")
refresh, err := svc.RefreshServerFileListForSession(ownerSession, domain.ServerFileListRequest{ServerInstanceID: instance.ID, DirectoryKey: "scum-config", IdempotencyKey: "idem-file-list-fallback"})
if err != nil {
t.Fatalf("refresh file list fallback: %v", err)
}
if refresh.State != "ready" || refresh.Job.ID != "" || len(refresh.Entries) != 2 || refresh.Entries[1].LogicalKey != "scum-server-settings" || !strings.Contains(refresh.Reason, "未声明 files.list") {
t.Fatalf("expected plugin workspace fallback, got %+v", refresh)
}
jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: instance.ID})
if err != nil {
t.Fatalf("list fallback jobs: %v", err)
}
if len(jobs) != 0 {
t.Fatalf("unsupported Run capability must not create a failed refresh job, got %+v", jobs)
} }
} }
@@ -1013,8 +1078,9 @@ func TestDeclaredFileReadSnapshotProjectionStatesAndRedaction(t *testing.T) {
if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") { if err != nil || snapshot.JobID != "job-file-snapshot-success-new" || !strings.Contains(snapshot.Content, "ServerName=New") || strings.Contains(snapshot.Content, "secret") {
t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err) t.Fatalf("expected newest successful redacted result, snapshot=%+v err=%v", snapshot, err)
} }
if _, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log"); err == nil || !strings.Contains(err.Error(), "plugin-declared file") { unknownSnapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log")
t.Fatalf("expected unknown logical key rejection, got %v", err) if err != nil || unknownSnapshot.State != "not-read" {
t.Fatalf("expected unknown logical key to report not-read, snapshot=%+v err=%v", unknownSnapshot, err)
} }
if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) { if _, err := svc.GetDeclaredFileReadSnapshotForSession(otherSession, instance.ID, "scum-server-settings"); !errors.Is(err, ErrForbidden) {
t.Fatalf("expected unrelated session forbidden, got %v", err) t.Fatalf("expected unrelated session forbidden, got %v", err)
@@ -1621,36 +1687,36 @@ func TestFindBridgeQueryTemplateRequiresPagePermissionAndRemoteAction(t *testing
} }
} }
func TestCoreServiceRejectsArbitrarySQLBridgeInputBeforeJob(t *testing.T) { func TestCoreServiceDispatchesSQLiteExecuteSQLText(t *testing.T) {
svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t) svc, plugin, _, session, instance := createSQLiteQueryBridgeFixture(t)
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{ result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
RequestID: "query-template-sql-rejected-1", RequestID: "sqlite-execute-1",
PluginID: plugin.ID, PluginID: plugin.ID,
RouteKey: "remote", RouteKey: "remote",
ServerInstanceID: instance.ID, ServerInstanceID: instance.ID,
Action: domain.PluginBridgeActionRemoteAccessRequest, Action: domain.PluginBridgeActionRemoteAccessRequest,
Payload: map[string]string{ Payload: map[string]string{
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery, "capability": domain.JobCapabilityRemoteRunDBSQLiteExecute,
"declarationKey": "scum-db-read", "declarationKey": "scum-db-read",
"targetKey": "scum-db.player-lookup", "targetKey": "scum-db.player-lookup",
"idempotencyKey": "query-template-sql-rejected-1", "idempotencyKey": "sqlite-execute-1",
"input.templateKey": "players.by-id", "input.mode": "execute",
"input.sqlText": "SELECT * FROM users", "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';",
}, },
}) })
if err != nil { if err != nil {
t.Fatalf("execute arbitrary SQL bridge input: %v", err) t.Fatalf("execute sqlite SQL bridge input: %v", err)
} }
if result.Status != "error" || result.Error == nil || !strings.Contains(strings.ToLower(result.Error.Message), "unsafe") { if result.Status != "queued" || result.Result["jobId"] == "" {
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result) t.Fatalf("expected queued sqlite execute job, got %+v", result)
} }
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID}) job, getErr := svc.store.Jobs().Get(result.Result["jobId"])
if listErr != nil { if getErr != nil {
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr) t.Fatalf("get sqlite execute job: %v", getErr)
} }
if len(jobs) != 0 { if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteExecute || job.ExecutionInput.Inputs["sqlText"] == "" || job.ExecutionInput.Inputs["mode"] != "execute" {
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs) t.Fatalf("expected sqlite execute inputs, got %#v", job)
} }
} }
@@ -1895,7 +1961,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
svc := newTestCoreService() svc := newTestCoreService()
plugin, endpoint := createPluginAndRunEndpoint(t, svc) plugin, endpoint := createPluginAndRunEndpoint(t, svc)
capability := domain.JobCapabilityRemoteRunDBSQLiteQuery capability := domain.JobCapabilityRemoteRunDBSQLiteQuery
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability) executeCapability := domain.JobCapabilityRemoteRunDBSQLiteExecute
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, capability, executeCapability)
plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access") plugin.DeclaredPermissions = append(plugin.DeclaredPermissions, "server.remote.access")
plugin.Permissions.RemoteAccess = true plugin.Permissions.RemoteAccess = true
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest)) plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
@@ -1908,14 +1975,14 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
}) })
plugin.RemoteAccess = domain.GamePluginRemoteAccess{ plugin.RemoteAccess = domain.GamePluginRemoteAccess{
Methods: []string{"run"}, Methods: []string{"run"},
RunCapabilities: []string{capability}, RunCapabilities: []string{capability, executeCapability},
DatabaseEngines: []string{"sqlite"}, DatabaseEngines: []string{"sqlite"},
} }
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{ plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
Key: "scum-db-read", Key: "scum-db-read",
Kind: "sqlite", Kind: "sqlite",
TargetKey: "scum-db.player-lookup", TargetKey: "scum-db.player-lookup",
Capabilities: []string{capability}, Capabilities: []string{capability, executeCapability},
}) })
plugin.GameClientBridge = domain.GameClientBridgeManifest{ plugin.GameClientBridge = domain.GameClientBridgeManifest{
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{ QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
@@ -1941,7 +2008,7 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
if err := svc.store.GamePlugins().Update(plugin); err != nil { if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update sqlite query plugin fixture: %v", err) t.Fatalf("update sqlite query plugin fixture: %v", err)
} }
endpoint.Capabilities = append(endpoint.Capabilities, capability) endpoint.Capabilities = append(endpoint.Capabilities, capability, executeCapability)
if err := svc.store.RunEndpoints().Update(endpoint); err != nil { if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
t.Fatalf("update sqlite query endpoint fixture: %v", err) t.Fatalf("update sqlite query endpoint fixture: %v", err)
} }
+109 -23
View File
@@ -18,6 +18,7 @@ import (
const ( const (
serverFileTransferChannel = "run-file-transfer" serverFileTransferChannel = "run-file-transfer"
serverFileMaxInlineEditBytes = 64 * 1024 serverFileMaxInlineEditBytes = 64 * 1024
serverFileDefaultDirectoryKey = "server-root"
) )
type serverFileContext struct { type serverFileContext struct {
@@ -25,6 +26,7 @@ type serverFileContext struct {
Instance domain.ServerInstance Instance domain.ServerInstance
Plugin domain.GamePlugin Plugin domain.GamePlugin
Directory domain.PluginLogicalDirectory Directory domain.PluginLogicalDirectory
Workspace domain.PluginFileWorkspace
Scope string Scope string
} }
@@ -54,10 +56,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve
if err != nil { if err != nil {
return domain.ServerFileWorkspaceView{}, err return domain.ServerFileWorkspaceView{}, err
} }
workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace) workspace := domain.CopyPluginFileWorkspace(ctx.Workspace)
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
}
view := domain.ServerFileWorkspaceView{ view := domain.ServerFileWorkspaceView{
ServerInstanceID: ctx.Instance.ID, ServerInstanceID: ctx.Instance.ID,
PluginID: ctx.Plugin.ID, PluginID: ctx.Plugin.ID,
@@ -65,7 +64,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve
Directories: workspace.Directories, Directories: workspace.Directories,
Files: workspace.Files, Files: workspace.Files,
ConfigFields: workspace.ConfigFields, ConfigFields: workspace.ConfigFields,
DeclaredOnly: true, DeclaredOnly: false,
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID), RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
Transfer: domain.ServerFileTransferPolicy{ Transfer: domain.ServerFileTransferPolicy{
Channel: serverFileTransferChannel, Channel: serverFileTransferChannel,
@@ -100,17 +99,45 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma
if parseErr == nil { if parseErr == nil {
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "ready", Entries: filterServerFileEntries(entries, request.Query), Job: latest, RefreshedAt: latest.TerminalAt}), nil
} }
entries = filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "failed", Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: "Run 返回的文件列表无法解析。"}), nil
} }
state := "declared" state := "declared"
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。" reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
if hasLatest && !isTerminalJobState(latest.State) { if hasLatest {
if !isTerminalJobState(latest.State) {
state = "pending" state = "pending"
reason = "Run 正在刷新目录。" reason = "Run 正在刷新目录。"
} else if latest.State == domain.JobStateFailed || latest.State == domain.JobStateCancelled {
state = "failed"
reason = serverFileListJobFailureReason(latest)
}
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
if state == "declared" {
reason = "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
} }
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: state, Entries: entries, Job: latest, RefreshedAt: latest.TerminalAt, Reason: reason}), nil
} }
func serverFileListJobFailureReason(job domain.Job) string {
prefix := "Run 文件目录刷新失败"
if job.State == domain.JobStateCancelled {
prefix = "Run 文件目录刷新已取消"
}
detail := strings.TrimSpace(job.CancelReason)
if detail == "" {
detail = strings.TrimSpace(job.Progress.Message)
}
if detail == "" {
detail = strings.TrimSpace(job.ExecutionResult.Summary)
}
if detail == "" {
return prefix + "。"
}
return prefix + "" + detail
}
func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) { func (svc *CoreService) RefreshServerFileListForSession(sessionID string, request domain.ServerFileListRequest) (domain.ServerFileListResult, error) {
request = normalizeServerFileListRequest(request) request = normalizeServerFileListRequest(request)
if request.IdempotencyKey == "" { if request.IdempotencyKey == "" {
@@ -123,6 +150,22 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques
if err != nil { if err != nil {
return domain.ServerFileListResult{}, err return domain.ServerFileListResult{}, err
} }
endpoint, err := svc.GetRunEndpoint(ctx.Instance.RunEndpointID)
if err != nil {
return domain.ServerFileListResult{}, err
}
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Workspace, request.DirectoryKey), request.Query)
if !containsString(endpoint.Capabilities, domain.JobCapabilityFilesList) {
return domain.CopyServerFileListResult(domain.ServerFileListResult{
ServerInstanceID: ctx.Instance.ID,
PluginID: ctx.Plugin.ID,
DirectoryKey: request.DirectoryKey,
Path: request.Path,
State: "ready",
Entries: entries,
Reason: "当前 Run 未声明 files.list,已展示插件声明的逻辑文件。",
}), nil
}
job, err := svc.CreateJob(domain.Job{ job, err := svc.CreateJob(domain.Job{
ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey), ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey),
ServerInstanceID: ctx.Instance.ID, ServerInstanceID: ctx.Instance.ID,
@@ -142,7 +185,6 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques
if err != nil { if err != nil {
return domain.ServerFileListResult{}, err return domain.ServerFileListResult{}, err
} }
entries := filterServerFileEntries(serverFileEntriesFromDeclaredWorkspace(ctx.Plugin.FileWorkspace, request.DirectoryKey), request.Query)
return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil return domain.CopyServerFileListResult(domain.ServerFileListResult{ServerInstanceID: ctx.Instance.ID, PluginID: ctx.Plugin.ID, DirectoryKey: request.DirectoryKey, Path: request.Path, State: "pending", Entries: entries, Job: job, Reason: "目录刷新任务已派发到 Run。"}), nil
} }
@@ -172,9 +214,6 @@ func (svc *CoreService) UploadServerFileForSession(sessionID string, request dom
if err != nil { if err != nil {
return domain.ServerFileUploadDispatch{}, err return domain.ServerFileUploadDispatch{}, err
} }
if strings.EqualFold(ctx.Directory.Scope, "logs") {
return domain.ServerFileUploadDispatch{}, validationError("log directories are read-only")
}
relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename)) relativePath := cleanServerFileRelativePath(path.Join(request.RelativePath, request.Filename))
artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath) artifactID := serverFileUploadArtifactID(request.ServerInstanceID, request.IdempotencyKey, relativePath)
artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()} artifact := domain.Artifact{ID: artifactID, OwnerKind: domain.ArtifactOwnerKindServerInstance, OwnerID: ctx.Instance.ID, SizeBytes: int64(len(request.Payload)), Checksum: request.Checksum, State: domain.ArtifactStateAvailable, CreatedAt: svc.now(), UpdatedAt: svc.now()}
@@ -213,7 +252,7 @@ func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, re
if err != nil { if err != nil {
return domain.ServerFileDownloadResult{}, err return domain.ServerFileDownloadResult{}, err
} }
filename := serverFileDisplayName(ctx.Plugin.FileWorkspace, request.Key) filename := serverFileDisplayName(ctx.Workspace, request.Key)
job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key) job, hasJob, err := svc.latestFileReadJob(ctx.Instance.ID, request.Key)
if err != nil { if err != nil {
return domain.ServerFileDownloadResult{}, err return domain.ServerFileDownloadResult{}, err
@@ -323,21 +362,37 @@ func (svc *CoreService) serverFileContextForSession(sessionID string, serverInst
} else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") { } else if !plugin.Permissions.Files && !containsString(plugin.DeclaredPermissions, "server.files.read") {
return serverFileContext{}, ErrForbidden return serverFileContext{}, ErrForbidden
} }
workspace := effectiveServerFileWorkspace(plugin)
directory := domain.PluginLogicalDirectory{} directory := domain.PluginLogicalDirectory{}
if directoryKey != "" { if directoryKey != "" {
var found bool var found bool
for _, candidate := range plugin.FileWorkspace.Directories { for _, candidate := range workspace.Directories {
if candidate.Key == directoryKey { if candidate.Key == directoryKey {
directory = candidate directory = candidate
found = true found = true
break break
} }
} }
if !found && len(plugin.FileWorkspace.Directories) > 0 { if !found && len(workspace.Directories) > 0 {
return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory") return serverFileContext{}, validationError("directoryKey must reference an available logical directory")
} }
} }
return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Scope: svc.runtimeProfileScope(instance.ID)}, nil return serverFileContext{User: user, Instance: instance, Plugin: plugin, Directory: directory, Workspace: workspace, Scope: svc.runtimeProfileScope(instance.ID)}, nil
}
func effectiveServerFileWorkspace(plugin domain.GamePlugin) domain.PluginFileWorkspace {
workspace := domain.CopyPluginFileWorkspace(plugin.FileWorkspace)
if serverFileWorkspaceIsPluginDeclared(workspace) {
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
}
return workspace
}
return domain.PluginFileWorkspace{DefaultDirectoryKey: serverFileDefaultDirectoryKey, Directories: []domain.PluginLogicalDirectory{{Key: serverFileDefaultDirectoryKey, Label: "服务器根目录", Scope: "config"}}, Files: []domain.PluginLogicalFile{}, ConfigFields: []domain.PluginConfigField{}}
}
func serverFileWorkspaceIsPluginDeclared(workspace domain.PluginFileWorkspace) bool {
return workspace.DefaultDirectoryKey != "" || len(workspace.Directories) > 0 || len(workspace.Files) > 0 || len(workspace.ConfigFields) > 0
} }
func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest { func normalizeServerFileListRequest(request domain.ServerFileListRequest) domain.ServerFileListRequest {
@@ -353,13 +408,13 @@ func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace
if directoryKey == "" || directory.Key == directoryKey { if directoryKey == "" || directory.Key == directoryKey {
continue continue
} }
entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: false, Downloadable: false, Remark: "插件声明目录"}) entries = append(entries, domain.ServerFileEntry{Name: directory.Label, Kind: domain.ServerFileEntryDirectory, DirectoryKey: directory.Key, RelativePath: "", Scope: directory.Scope, Editable: true, Downloadable: true, Remark: "目录"})
} }
for _, file := range workspace.Files { for _, file := range workspace.Files {
if directoryKey != "" && file.DirectoryKey != directoryKey { if directoryKey != "" && file.DirectoryKey != directoryKey {
continue continue
} }
entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: file.Editable, Downloadable: true, Remark: fileRemark(file)}) entries = append(entries, domain.ServerFileEntry{Name: file.Label, Kind: domain.ServerFileEntryFile, DirectoryKey: file.DirectoryKey, RelativePath: file.Key, LogicalKey: file.Key, Scope: file.Kind, Editable: true, Downloadable: true, Remark: fileRemark(file)})
} }
sort.SliceStable(entries, func(i int, j int) bool { sort.SliceStable(entries, func(i int, j int) bool {
if entries[i].Kind == entries[j].Kind { if entries[i].Kind == entries[j].Kind {
@@ -390,10 +445,14 @@ func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, f
directoryKey = fallbackDirectoryKey directoryKey = fallbackDirectoryKey
} }
relativePath := cleanServerFileRelativePath(entry.RelativePath) relativePath := cleanServerFileRelativePath(entry.RelativePath)
if relativePath == "" { if relativePath == "" && entry.Name != "" {
relativePath = cleanServerFileRelativePath(path.Join(fallbackPath, entry.Name))
} else if relativePath == "" {
relativePath = fallbackPath relativePath = fallbackPath
} }
entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: entry.Editable, Downloadable: entry.Downloadable, Remark: entry.Remark}) editable := kind == domain.ServerFileEntryFile
downloadable := kind == domain.ServerFileEntryFile
entries = append(entries, domain.ServerFileEntry{Name: entry.Name, Kind: kind, DirectoryKey: directoryKey, RelativePath: relativePath, LogicalKey: entry.LogicalKey, Scope: entry.Scope, SizeBytes: entry.SizeBytes, ModifiedAt: modifiedAt, Checksum: entry.Checksum, Editable: editable, Downloadable: downloadable, Remark: entry.Remark})
} }
return entries, nil return entries, nil
} }
@@ -413,7 +472,34 @@ func filterServerFileEntries(entries []domain.ServerFileEntry, query string) []d
} }
func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) { func (svc *CoreService) latestFileListJob(serverInstanceID string, directoryKey string, relativePath string) (domain.Job, bool, error) {
return svc.latestServerFileJob(serverInstanceID, domain.JobCapabilityFilesList, directoryKey) jobs, err := svc.store.Jobs().List(domain.JobFilter{ServerInstanceID: serverInstanceID})
if err != nil {
return domain.Job{}, false, err
}
var latest domain.Job
found := false
for _, job := range jobs {
if job.Capability != domain.JobCapabilityFilesList || job.TargetKey != directoryKey {
continue
}
if job.ExecutionInput.Inputs["path"] != relativePath {
continue
}
if !found || job.UpdatedAt.After(latest.UpdatedAt) || job.CreatedAt.After(latest.CreatedAt) {
latest = job
found = true
}
}
return domain.CopyJob(latest), found, nil
}
func isServerFileCapability(capability string) bool {
switch capability {
case domain.JobCapabilityFilesList, domain.JobCapabilityFilesRead, domain.JobCapabilityFilesWrite:
return true
default:
return false
}
} }
func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) { func (svc *CoreService) latestFileReadJob(serverInstanceID string, key string) (domain.Job, bool, error) {
@@ -491,7 +577,7 @@ func fileRemark(file domain.PluginLogicalFile) string {
if file.Editable { if file.Editable {
return "可编辑配置" return "可编辑配置"
} }
return "插件声明文件" return "文件"
} }
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string { func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
+51 -12
View File
@@ -177,21 +177,10 @@ func (svc *CoreService) resolveSourceRCONDispatch(instance domain.ServerInstance
if !strings.EqualFold(endpoint.Platform, "windows") || !strings.EqualFold(endpoint.Architecture, "amd64") { if !strings.EqualFold(endpoint.Platform, "windows") || !strings.EqualFold(endpoint.Architecture, "amd64") {
return sourceRCONDispatchResolution{}, validationError("unsupported_extension_platform: SCUM Source RCON requires windows/amd64") return sourceRCONDispatchResolution{}, validationError("unsupported_extension_platform: SCUM Source RCON requires windows/amd64")
} }
binding, err := svc.runtimeBindingForServer(instance.ID) binding, profile, err := svc.sourceRCONRuntimeProfile(instance, plugin, endpoint)
if err != nil { if err != nil {
return sourceRCONDispatchResolution{}, err return sourceRCONDispatchResolution{}, err
} }
binding, err = normalizeRuntimeBinding(plugin, binding)
if err != nil {
return sourceRCONDispatchResolution{}, err
}
if binding.Status != domain.RuntimeBindingStatusComplete || binding.PluginVersion != plugin.Version {
return sourceRCONDispatchResolution{}, validationError("runtime binding is incomplete or stale")
}
profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, binding.ProfileKey)
if !exists || !containsString(profile.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) || !runtimePlatformsContain(profile.Platforms, "windows") {
return sourceRCONDispatchResolution{}, validationError("selected runtime profile does not support SCUM RCON")
}
transport, err := sourceRCONTransport(plugin.RuntimeProfiles, profile) transport, err := sourceRCONTransport(plugin.RuntimeProfiles, profile)
if err != nil { if err != nil {
return sourceRCONDispatchResolution{}, err return sourceRCONDispatchResolution{}, err
@@ -211,6 +200,56 @@ func (svc *CoreService) resolveSourceRCONDispatch(instance domain.ServerInstance
return sourceRCONDispatchResolution{plugin: plugin, binding: binding, transport: transport, plan: plan}, nil return sourceRCONDispatchResolution{plugin: plugin, binding: binding, transport: transport, plan: plan}, nil
} }
func (svc *CoreService) sourceRCONRuntimeProfile(instance domain.ServerInstance, plugin domain.GamePlugin, endpoint domain.RunEndpoint) (domain.RuntimeBinding, domain.RuntimeLifecycleProfile, error) {
if binding, err := svc.runtimeBindingForServer(instance.ID); err == nil {
if normalized, normalizeErr := normalizeRuntimeBinding(plugin, binding); normalizeErr == nil {
if profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, normalized.ProfileKey); exists && sourceRCONProfileSupports(plugin.RuntimeProfiles, profile, endpoint) {
return normalized, profile, nil
}
}
} else if !errors.Is(err, repo.ErrNotFound) {
return domain.RuntimeBinding{}, domain.RuntimeLifecycleProfile{}, err
}
if key := strings.TrimSpace(instance.Deployment.ProfileKey); key != "" {
if profile, exists := runtimeLifecycleProfileForKey(plugin.RuntimeProfiles, key); exists && sourceRCONProfileSupports(plugin.RuntimeProfiles, profile, endpoint) {
return transientSourceRCONBinding(instance, plugin, profile), profile, nil
}
}
var selected domain.RuntimeLifecycleProfile
for _, profile := range plugin.RuntimeProfiles.LifecycleProfiles {
if !sourceRCONProfileSupports(plugin.RuntimeProfiles, profile, endpoint) {
continue
}
if selected.Key != "" && selected.Key != profile.Key {
return domain.RuntimeBinding{}, domain.RuntimeLifecycleProfile{}, validationError("selected runtime profile has multiple SCUM RCON candidates")
}
selected = profile
}
if selected.Key == "" {
return domain.RuntimeBinding{}, domain.RuntimeLifecycleProfile{}, validationError("selected runtime profile does not support SCUM RCON")
}
return transientSourceRCONBinding(instance, plugin, selected), selected, nil
}
func sourceRCONProfileSupports(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) bool {
if !containsString(profile.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) || !runtimePlatformsContain(profile.Platforms, "windows") {
return false
}
if _, err := sourceRCONTransport(profiles, profile); err != nil {
return false
}
if _, err := sourceRCONExtension(profiles, profile, endpoint); err != nil {
return false
}
return true
}
func transientSourceRCONBinding(instance domain.ServerInstance, plugin domain.GamePlugin, profile domain.RuntimeLifecycleProfile) domain.RuntimeBinding {
return domain.RuntimeBinding{ID: "runtime-binding-" + instance.ID, ServerInstanceID: instance.ID, PluginID: plugin.ID, PluginVersion: plugin.Version, ProfileKey: profile.Key, Mode: profile.Mode, Status: domain.RuntimeBindingStatusComplete}
}
func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile) (domain.RuntimeTransportProfile, error) { func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile) (domain.RuntimeTransportProfile, error) {
return sourceRCONTransportForCapability(profiles, profile, "", domain.JobCapabilityRemoteRunRCONCommand) return sourceRCONTransportForCapability(profiles, profile, "", domain.JobCapabilityRemoteRunRCONCommand)
} }
+65
View File
@@ -126,6 +126,65 @@ func TestSourceRCONDispatchCanonicalizesLegacyLocalBinding(t *testing.T) {
} }
} }
func TestSourceRCONDispatchSelectsDeclaredProfileWithoutManualBinding(t *testing.T) {
svc, session, _, instance := newSourceRCONFixtureWithRuntimeBinding(t, false)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.RuntimeProfiles.LifecycleProfiles[0].Key = "run-local"
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update source RCON profile key: %v", err)
}
dispatch, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "#ListPlayers", IdempotencyKey: "rcon-without-binding"})
if err != nil {
t.Fatalf("dispatch RCON without manual binding: %v", err)
}
job, err := svc.store.Jobs().Get(dispatch.JobID)
if err != nil {
t.Fatalf("get RCON job: %v", err)
}
if job.ExecutionInput.WorkspaceScope != "run-local" || job.TargetKey != "rcon" || job.ExecutionInput.SourceRCON == nil {
t.Fatalf("expected declared source RCON profile to drive job, got %+v", job)
}
}
func TestSourceRCONDispatchFallsBackFromCustomClientBinding(t *testing.T) {
svc, session, _, instance := newSourceRCONFixture(t)
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
if err != nil {
t.Fatal(err)
}
plugin.RuntimeProfiles.LifecycleProfiles[0].Key = "run-local"
plugin.RuntimeProfiles.LifecycleProfiles = append(plugin.RuntimeProfiles.LifecycleProfiles, domain.RuntimeLifecycleProfile{Key: "scum-client", Mode: "custom-client", Capabilities: []string{"client-manager.deploy", "client-manager.control", "logs.read"}, ClientManagerRef: "scum-client-manager", Platforms: []string{"windows"}})
if err := svc.store.GamePlugins().Update(plugin); err != nil {
t.Fatalf("update plugin profiles: %v", err)
}
binding, err := svc.store.RuntimeBindings().Get("runtime-binding-" + instance.ID)
if err != nil {
t.Fatalf("get runtime binding: %v", err)
}
binding.ProfileKey = "scum-client"
binding.Mode = "custom-client"
binding.Bindings = map[string]string{"scum-client-manager": "runtime-client-manager"}
if err := svc.store.RuntimeBindings().Update(binding); err != nil {
t.Fatalf("point binding at custom client profile: %v", err)
}
dispatch, err := svc.DispatchSourceRCONCommandForSession(session, domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "#ListPlayers", IdempotencyKey: "rcon-custom-client-binding"})
if err != nil {
t.Fatalf("dispatch RCON with custom-client binding fallback: %v", err)
}
job, err := svc.store.Jobs().Get(dispatch.JobID)
if err != nil {
t.Fatalf("get RCON job: %v", err)
}
if job.ExecutionInput.WorkspaceScope != "run-local" || job.TargetKey != "rcon" || job.ExecutionInput.RemoteAdapterKey != "rcon" {
t.Fatalf("expected source RCON to use run-local despite custom-client binding, got %+v", job)
}
}
func TestSourceRCONBrokerExpiresWithoutReplay(t *testing.T) { func TestSourceRCONBrokerExpiresWithoutReplay(t *testing.T) {
stamp := fixedTime stamp := fixedTime
broker := newSourceRCONCommandBroker(func() time.Time { return stamp }) broker := newSourceRCONCommandBroker(func() time.Time { return stamp })
@@ -139,6 +198,10 @@ func TestSourceRCONBrokerExpiresWithoutReplay(t *testing.T) {
} }
func newSourceRCONFixture(t *testing.T) (*CoreService, string, string, domain.ServerInstance) { func newSourceRCONFixture(t *testing.T) (*CoreService, string, string, domain.ServerInstance) {
return newSourceRCONFixtureWithRuntimeBinding(t, true)
}
func newSourceRCONFixtureWithRuntimeBinding(t *testing.T, createBinding bool) (*CoreService, string, string, domain.ServerInstance) {
t.Helper() t.Helper()
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime }) svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
capability := domain.JobCapabilityRemoteRunRCONCommand capability := domain.JobCapabilityRemoteRunRCONCommand
@@ -177,6 +240,7 @@ func newSourceRCONFixture(t *testing.T) (*CoreService, string, string, domain.Se
if err != nil { if err != nil {
t.Fatalf("create RCON server: %v", err) t.Fatalf("create RCON server: %v", err)
} }
if createBinding {
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon"}}, true) binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon"}}, true)
if err != nil { if err != nil {
t.Fatalf("create RCON binding: %v", err) t.Fatalf("create RCON binding: %v", err)
@@ -184,6 +248,7 @@ func newSourceRCONFixture(t *testing.T) (*CoreService, string, string, domain.Se
if err := svc.store.RuntimeBindings().Create(binding); err != nil { if err := svc.store.RuntimeBindings().Create(binding); err != nil {
t.Fatalf("store RCON binding: %v", err) t.Fatalf("store RCON binding: %v", err)
} }
}
helloRequest := validRunControlHello() helloRequest := validRunControlHello()
helloRequest.CapabilityReport.Capabilities = []string{capability} helloRequest.CapabilityReport.Capabilities = []string{capability}
helloRequest.CapabilityReport.Fingerprint = "cap-source-rcon" helloRequest.CapabilityReport.Fingerprint = "cap-source-rcon"
+23 -2
View File
@@ -109,10 +109,14 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
} }
var violations []string var violations []string
for key, value := range inputs { for key, value := range inputs {
if !clientManagerIdentifierPattern.MatchString(key) || unsafeGameClientBridgePayloadKey(key) { if !clientManagerIdentifierPattern.MatchString(key) || unsafeRemoteAdapterInputKey(key) {
violations = append(violations, field+" key is invalid or unsafe") violations = append(violations, field+" key is invalid or unsafe")
} }
if len([]rune(value)) > 2048 { limit := 2048
if remoteAdapterSQLInputKey(key) {
limit = 16 * 1024
}
if len([]rune(value)) > limit {
violations = append(violations, field+"."+key+" is too long") violations = append(violations, field+"."+key+" is too long")
} }
for _, reason := range unsafePluginStringReasons(value) { for _, reason := range unsafePluginStringReasons(value) {
@@ -121,3 +125,20 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
} }
return violations return violations
} }
func unsafeRemoteAdapterInputKey(key string) bool {
if remoteAdapterSQLInputKey(key) {
return false
}
return unsafeGameClientBridgePayloadKey(key)
}
func remoteAdapterSQLInputKey(key string) bool {
normalized := strings.ToLower(strings.NewReplacer(".", "", "_", "", "-", "", ":", "", "/", "").Replace(key))
switch normalized {
case "sql", "sqltext", "sqlstatement", "sqlquery", "rawsql", "rawquery", "statement":
return true
default:
return false
}
}
+3
View File
@@ -18,6 +18,9 @@ func TestObservabilityValidatorsBoundMetricsBackupsAndRemoteTargets(t *testing.T
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil { if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "ftp", TargetKey: "tcp://host", Capability: "remote.ftp.read", IdempotencyKey: "request-1"}); err == nil {
t.Fatal("expected unsafe remote target rejection") t.Fatal("expected unsafe remote target rejection")
} }
if err := ValidateRemoteAdapterRequest(domain.RemoteAdapterRequest{ServerInstanceID: "server-1", DeclarationKey: "sqlite-db", TargetKey: "scum-db", Capability: domain.JobCapabilityRemoteRunDBSQLiteExecute, IdempotencyKey: "sql-execute-1", Inputs: map[string]string{"mode": "execute", "sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';"}}); err != nil {
t.Fatalf("expected SQL text input to validate: %v", err)
}
} }
func floatPtr(value float64) *float64 { return &value } func floatPtr(value float64) *float64 { return &value }
+45 -16
View File
@@ -7,6 +7,7 @@ import (
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
"unicode"
"unicode/utf8" "unicode/utf8"
"browser.local/platform/domain" "browser.local/platform/domain"
@@ -20,11 +21,11 @@ const (
maxPluginDescriptionLength = 240 maxPluginDescriptionLength = 240
maxPluginPageTitleLength = 40 maxPluginPageTitleLength = 40
maxPluginBridgePayloadKeys = 16 maxPluginBridgePayloadKeys = 16
maxPluginBridgePayloadSize = 4096 maxPluginBridgePayloadSize = 16 * 1024
maxProgressMessageLength = 256 maxProgressMessageLength = 256
maxServerConfigContentSize = 64 * 1024 maxServerConfigContentSize = 64 * 1024
maxJobExecutionContentSize = 64 * 1024 maxJobExecutionContentSize = 64 * 1024
maxLogicalFileKeyLength = 160 maxLogicalFileKeyLength = 1024
maxProductionMessageLength = 320 maxProductionMessageLength = 320
) )
@@ -970,7 +971,11 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 { if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 {
violations = append(violations, "payload key is invalid") violations = append(violations, "payload key is invalid")
} }
if len([]rune(value)) > 1024 { valueLimit := 1024
if remoteAdapterSQLInputKey(strings.TrimPrefix(key, "input.")) {
valueLimit = 16 * 1024
}
if len([]rune(value)) > valueLimit {
violations = append(violations, "payload value is too long") violations = append(violations, "payload value is too long")
} }
for _, reason := range unsafePluginStringReasons(key) { for _, reason := range unsafePluginStringReasons(key) {
@@ -1265,6 +1270,13 @@ func ValidateServerInstanceDependenciesForCapabilities(instance domain.ServerIns
violations = append(violations, "run endpoint must be online or degraded") violations = append(violations, "run endpoint must be online or degraded")
} }
missing := MissingCapabilities(endpoint.Capabilities, requiredRunCapabilities) missing := MissingCapabilities(endpoint.Capabilities, requiredRunCapabilities)
fileCapabilityMissing := missing[:0]
for _, capability := range missing {
if capability != "files.list" && capability != "files.read" && capability != "files.write" {
fileCapabilityMissing = append(fileCapabilityMissing, capability)
}
}
missing = fileCapabilityMissing
if len(missing) > 0 { if len(missing) > 0 {
violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", ")) violations = append(violations, "run endpoint missing required capabilities: "+strings.Join(missing, ", "))
} }
@@ -1444,6 +1456,16 @@ func ValidateServerFileReadRequest(request domain.ServerFileReadRequest) error {
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey}) return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationRead, Key: request.Key, IdempotencyKey: request.IdempotencyKey})
} }
func ValidateServerFileReadSnapshotRequest(serverInstanceID string, key string) error {
var violations []string
violations = appendRequired(violations, "serverInstanceId", serverInstanceID)
violations = appendRequired(violations, "key", key)
if !validLogicalFileKey(key) {
violations = append(violations, "key is not allowed")
}
return finish(violations)
}
func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error { func ValidateServerFileWriteRequest(request domain.ServerFileWriteRequest) error {
return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey}) return ValidateFileOperationDispatchRequest(domain.FileOperationDispatchRequest{ServerInstanceID: request.ServerInstanceID, PluginID: request.PluginID, Operation: domain.FileOperationWrite, Key: request.Key, InputRef: request.InputRef, Content: request.Content, ExpectedConfigVersion: request.ExpectedVersion, ExpectedChecksum: request.ExpectedChecksum, IdempotencyKey: request.IdempotencyKey})
} }
@@ -2042,12 +2064,14 @@ func validateRemoteAccess(field string, remote domain.GamePluginRemoteAccess, de
violations = append(violations, field+".logTransfer requires remote.run.logs.transfer") violations = append(violations, field+".logTransfer requires remote.run.logs.transfer")
} }
for _, engine := range remote.DatabaseEngines { for _, engine := range remote.DatabaseEngines {
required := domain.JobCapabilityRemoteRunDBMySQLQuery queryCapability := domain.JobCapabilityRemoteRunDBMySQLQuery
executeCapability := domain.JobCapabilityRemoteRunDBMySQLExecute
if engine == "sqlite" { if engine == "sqlite" {
required = domain.JobCapabilityRemoteRunDBSQLiteQuery queryCapability = domain.JobCapabilityRemoteRunDBSQLiteQuery
executeCapability = domain.JobCapabilityRemoteRunDBSQLiteExecute
} }
if !containsString(remote.RunCapabilities, required) { if !containsString(remote.RunCapabilities, queryCapability) && !containsString(remote.RunCapabilities, executeCapability) {
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required)) violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s or %s", field, queryCapability, executeCapability))
} }
} }
return violations return violations
@@ -2341,7 +2365,7 @@ func validPluginRunCapability(capability string) bool {
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop, domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
domain.JobCapabilityRemoteRunProgram, domain.JobCapabilityRemoteRunProgram,
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall, domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
@@ -2375,7 +2399,9 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
domain.JobCapabilityRemoteRsyncWrite, domain.JobCapabilityRemoteRsyncWrite,
domain.JobCapabilityRemoteRunFilesWrite, domain.JobCapabilityRemoteRunFilesWrite,
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLQuery,
domain.JobCapabilityRemoteRunDBMySQLExecute,
domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
domain.JobCapabilityRemoteRunDBSQLiteExecute,
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram: domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram:
return true return true
default: default:
@@ -2412,15 +2438,14 @@ func validFileOperationKind(operation domain.FileOperationKind) bool {
func validUploadFilename(name string) bool { func validUploadFilename(name string) bool {
trimmed := strings.TrimSpace(name) trimmed := strings.TrimSpace(name)
if trimmed == "" || trimmed != name || len([]rune(name)) > 120 || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, "..") || strings.Contains(name, "://") || looksLikeRawHostPath(name) || containsUnsafeRuntimeSecret(name) { if trimmed == "" || trimmed != name || name == "." || name == ".." || len([]rune(name)) > 255 || !utf8.ValidString(name) || strings.Contains(name, "/") || strings.Contains(name, `\`) || strings.Contains(name, ":") || strings.Contains(name, "://") || looksLikeRawHostPath(name) {
return false return false
} }
for _, char := range name { for _, char := range name {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ' ' || char == '(' || char == ')' { if unicode.IsControl(char) || char == 0 {
continue
}
return false return false
} }
}
return true return true
} }
@@ -2438,15 +2463,19 @@ func validLogicalFileKey(key string) bool {
if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength { if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength {
return false return false
} }
if strings.HasPrefix(key, "/") || strings.Contains(key, "..") || strings.Contains(key, `\`) || strings.Contains(key, "://") || looksLikeRawHostPath(key) || containsUnsafeRuntimeSecret(key) { if !utf8.ValidString(key) || strings.HasPrefix(key, "/") || strings.Contains(key, `\`) || strings.Contains(key, ":") || strings.Contains(key, "://") || looksLikeRawHostPath(key) {
return false return false
} }
for _, segment := range strings.Split(key, "/") {
if segment == "" || segment == "." || segment == ".." {
return false
}
}
for _, char := range key { for _, char := range key {
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' { if unicode.IsControl(char) || char == 0 {
continue
}
return false return false
} }
}
return true return true
} }
+1
View File
@@ -806,6 +806,7 @@ describe("PlatformApiClient AI providers", () => {
const client = new PlatformApiClient("/api/v1"); const client = new PlatformApiClient("/api/v1");
expect(client.serverLogEventsUrl("server/scum 1", { historyLimit: 500 })).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events?historyLimit=500"); expect(client.serverLogEventsUrl("server/scum 1", { historyLimit: 500 })).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events?historyLimit=500");
expect(client.serverLogEventsUrl("server-1", { historyLimit: 0 })).toBe("/api/v1/server-instances/server-1/logs/events?historyLimit=0");
expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events"); expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events");
}); });
+4 -4
View File
@@ -27,8 +27,8 @@ Normal browser login uses the platform's HttpOnly SameSite cookie and `credentia
- `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response. - `startServerInstance` and `stopServerInstance` post `ServerLifecycleCommandRequest` with the current config version and receive the lifecycle job response.
- `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators. - `listServerAdministratorCandidates`, `addServerAdministrator`, and `removeServerAdministrator` call server membership endpoints so server owners can invite or remove active non-platform-admin server administrators.
- Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients. - Game-specific pages use the scoped `plugin-data` collection API and declared plugin bridge machine actions; Platform does not expose game-specific projection or workflow clients.
- `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for plugin-declared file work. - `dispatchFileOperation` posts `FileOperationDispatchRequest` to `/file-operations/dispatch` using logical file keys and scoped refs rather than raw host paths; it remains the low-level compatibility dispatch for file work.
- `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders plugin-declared logical directories, requests live listings through `files.list`, reads editable snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly. - `getServerFileWorkspace`, `listServerFiles`, `refreshServerFiles`, `readServerFile`, `getServerFileReadSnapshot`, `writeServerFile`, `uploadServerFile`, and `prepareServerFileDownload` power the first-party server-detail file manager. The page renders a generic server-root entry, requests live listings through `files.list`, reads snapshots through `files.read`, saves through `files.write`, and stages browser uploads as server-instance artifacts before Run pulls input chunks on the dedicated file-transfer channel. Server detail may call only these server-file APIs plus the encapsulated download helper; it must not call raw artifact-transfer methods directly.
- `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior. - `listArtifacts`, `openArtifactDownload`, and `readArtifactContent` use platform artifact routes for available job/server artifacts. Browser reads are chunked through `/artifacts/{id}/content` and must render only safe filenames, checksums, progress, and platform storage behavior.
- `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions. - `authorizePluginBridge` posts `PluginBridgeAuthorizeRequest` to `/plugin-bridge/authorize` for preflight decisions.
- `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential. - `executePluginBridge` posts `PluginBridgeExecuteRequest` to `/plugin-bridge/execute` from host-owned bridge dispatch utilities only. Plugin pages receive typed `PluginBridgeExecuteResponse` envelopes and never receive the platform API client, bearer token, raw provider key, run socket, host path, or storage credential.
@@ -52,7 +52,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
- `PUT /api/v1/users/current/profile` (`UserProfileUpdateRequest`/`CurrentUserResponse`): implemented current-user profile updates such as display name, avatar reference, phone, QQ, and bounded contact fields. - `PUT /api/v1/users/current/profile` (`UserProfileUpdateRequest`/`CurrentUserResponse`): implemented current-user profile updates such as display name, avatar reference, phone, QQ, and bounded contact fields.
- `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference. - `PUT /api/v1/users/current/theme` (`UserThemePreferenceRequest`/`UserThemePreferenceResponse`): implemented per-user theme preferences, including selected palette IDs such as `mecha-black` or `magical-girl`, uploaded background reference or safe persisted data URL metadata, and readable overlay preference.
- `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen. - `GET /api/v1/metrics/platform` (`PlatformResourceUsageResponse`): implemented platform-level CPU/memory/disk usage and LLM connectivity summary for the overview first screen.
- `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards and the server detail header. - `GET /api/v1/metrics/server-instances` (`ServerMetricsListResponse`): implemented per-server online state, player count, TPS, latency, CPU/memory/disk for server cards on the server list.
- Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` plus reviewable AI config-diff approval APIs; plugin pages do not receive raw config text. - Server-scoped raw config routes (`GET /api/v1/server-instances/{id}/config`, `POST .../config/diff`, `POST .../config/approve`) are removed from the product API. AI configuration assistance uses `/api/v1/ai/invocations` plus reviewable AI config-diff approval APIs; plugin pages do not receive raw config text.
- `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only. - `POST /api/v1/file-operations/dispatch` (`FileOperationDispatchRequest`/`FileOperationDispatchResponse`): implemented scoped file operation dispatch using logical keys and refs only.
- `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets. - `POST /api/v1/ai/config-suggestions` (`LlmConfigSuggestionRequest`/`LlmConfigSuggestionResponse`) and `POST /api/v1/ai/invocations` (`AIInvocationRequest`/`AIInvocationResponse`): platform-mediated AI recommendation or diff scoped to one server. Provider keys stay in `platform/`; responses carry only recommendation text, usage metadata, and reviewable suggestions, never keys or provider secrets.
@@ -60,7 +60,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent. - Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent.
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access. Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted SSE history/live output. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows. - Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output by default, with bounded current-session replay only when the caller explicitly sends `historyLimit`. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows.
# Client Manager API projection # Client Manager API projection
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client. `PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
@@ -5,6 +5,7 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types"; import type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types";
import { formatTerminalServerTime } from "../utils/logTime";
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer"; import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
const apiMocks = vi.hoisted(() => ({ const apiMocks = vi.hoisted(() => ({
@@ -68,7 +69,7 @@ afterEach(async () => {
}); });
describe("ServerManagementTerminalDrawer", () => { describe("ServerManagementTerminalDrawer", () => {
it("shows current-session replay and keeps it on a repeated boundary for the same session", async () => { it("shows live current-session output and keeps it on a repeated boundary for the same session", async () => {
await renderDrawer(); await renderDrawer();
await emitSession("session-a"); await emitSession("session-a");
@@ -88,6 +89,16 @@ describe("ServerManagementTerminalDrawer", () => {
expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled();
}); });
it("uses the server-provided clock for terminal system lines", async () => {
const serverTime = "2001-02-03T04:05:06Z";
await renderDrawer();
expect(container?.textContent).toContain("时间同步中");
await emitSession("session-server-clock", serverTime);
const systemLine = Array.from(container?.querySelectorAll<HTMLDivElement>(".terminal-line") ?? []).find((line) => line.textContent?.includes("已跟随当前受管进程输出会话"));
expect(systemLine?.querySelector("time")?.textContent).toBe(formatTerminalServerTime(serverTime));
});
it("renders an empty current session without accepting unrelated or sessionless logs", async () => { it("renders an empty current session without accepting unrelated or sessionless logs", async () => {
await renderDrawer(); await renderDrawer();
@@ -200,11 +211,11 @@ async function renderDrawer() {
await act(async () => { await act(async () => {
root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />); root?.render(<ServerManagementTerminalDrawer open serverId="server-1" serverName="SCUM Alpha" pluginId="game.scum" canManage onClose={() => undefined} />);
}); });
expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 500 }); expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 0 });
} }
async function emitSession(logSessionId?: string) { async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") {
await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime: "2026-08-14T00:00:00Z" })); await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime }));
} }
async function emitStream(stream: LogStreamResponse) { async function emitStream(stream: LogStreamResponse) {
@@ -6,7 +6,7 @@ import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types"
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon"; import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
import { cx } from "../utils/classes"; import { cx } from "../utils/classes";
import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents"; import { mergeLogStreams, parseLogSessionEvent, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent } from "../utils/logEvents";
import { formatTerminalLogTime } from "../utils/logTime"; import { formatTerminalLogTime, formatTerminalServerTime } from "../utils/logTime";
import { EmptyState, ResultBadge } from "./StateViews"; import { EmptyState, ResultBadge } from "./StateViews";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
@@ -16,7 +16,8 @@ type TerminalQuickCommand = { label: string; command: string; hint: string };
const terminalJobResultPollMs = 1000; const terminalJobResultPollMs = 1000;
const terminalJobResultPollAttempts = 30; const terminalJobResultPollAttempts = 30;
const terminalInitialHistoryWindow = 500; const terminalLiveReplayWindow = 0;
const terminalHistoryWindow = 500;
const maxTerminalLines = 10000; const maxTerminalLines = 10000;
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = { const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
"game.scum": [ "game.scum": [
@@ -98,6 +99,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState(""); const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
const outputRef = useRef<HTMLDivElement>(null); const outputRef = useRef<HTMLDivElement>(null);
const followLatestRef = useRef(true); const followLatestRef = useRef(true);
const serverTimeRef = useRef<string | undefined>(undefined);
const initialHistoryPendingRef = useRef(false); const initialHistoryPendingRef = useRef(false);
const liveSessionRef = useRef<string | null | undefined>(undefined); const liveSessionRef = useRef<string | null | undefined>(undefined);
const historyRequestRef = useRef(0); const historyRequestRef = useRef(0);
@@ -136,11 +138,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setHistoryLines({ status: "idle" }); setHistoryLines({ status: "idle" });
setSelectedHistoryStreamId(""); setSelectedHistoryStreamId("");
liveSessionRef.current = undefined; liveSessionRef.current = undefined;
serverTimeRef.current = undefined;
historyRequestRef.current += 1; historyRequestRef.current += 1;
initialHistoryPendingRef.current = true; initialHistoryPendingRef.current = true;
followLatestRef.current = true; followLatestRef.current = true;
setFollowLatest(true); setFollowLatest(true);
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]); setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM", undefined, serverTimeRef.current)]);
}, [open, supportsCommands]); }, [open, supportsCommands]);
useEffect(() => { useEffect(() => {
@@ -155,11 +158,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
useEffect(() => { useEffect(() => {
if (!open) return undefined; if (!open) return undefined;
let ready = false; let ready = false;
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow }); const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalLiveReplayWindow });
events.addEventListener("session", (event) => { events.addEventListener("session", (event) => {
const session = parseLogSessionEvent(event); const session = parseLogSessionEvent(event);
if (!session) return; if (!session) return;
ready = true; ready = true;
serverTimeRef.current = session.serverTime;
const nextSessionId = normalizeLogSessionId(session.logSessionId); const nextSessionId = normalizeLogSessionId(session.logSessionId);
const previousSessionId = liveSessionRef.current; const previousSessionId = liveSessionRef.current;
liveSessionRef.current = nextSessionId; liveSessionRef.current = nextSessionId;
@@ -167,8 +171,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
if (previousSessionId === nextSessionId) return; if (previousSessionId === nextSessionId) return;
setStreams({ status: "ready", data: [] }); setStreams({ status: "ready", data: [] });
setLines(nextSessionId setLines(nextSessionId
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`)] ? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)]
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty")]); : [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty", serverTimeRef.current)]);
lockTerminalFollow(); lockTerminalFollow();
}); });
events.addEventListener("stream", (event) => { events.addEventListener("stream", (event) => {
@@ -216,7 +220,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setSelectedHistoryStreamId(streamId); setSelectedHistoryStreamId(streamId);
setHistoryLines({ status: "loading" }); setHistoryLines({ status: "loading" });
try { try {
const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalInitialHistoryWindow), limit: terminalInitialHistoryWindow }); const response = await platformApiClient.queryLogStream({ logStreamId: streamId, afterSeq: Math.max(0, stream.latestSeq - terminalHistoryWindow), limit: terminalHistoryWindow });
if (historyRequestRef.current !== requestId) return; if (historyRequestRef.current !== requestId) return;
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) }); setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
} catch (error) { } catch (error) {
@@ -284,26 +288,26 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50)); setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50));
setPending(true); setPending(true);
setResult({ status: "pending", label: "正在提交命令" }); setResult({ status: "pending", label: "正在提交命令" });
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]); appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND", undefined, serverTimeRef.current)]);
try { try {
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted)); const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
const label = rconJobDispatchLabel(response.status, response.jobId); const label = rconJobDispatchLabel(response.status, response.jobId);
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` }); setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`)]); appendLines([terminalSystemLine("success", `${label} · Source RCON`, "PLATFORM", `ok-${response.jobId}`, serverTimeRef.current)]);
const finalJob = await waitForRCONJobTerminal(response.jobId); const finalJob = await waitForRCONJobTerminal(response.jobId);
if (finalJob) { if (finalJob) {
const outcome = terminalLineFromJob(finalJob); const outcome = terminalLineFromJob(finalJob);
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text }); setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
appendLines([outcome]); appendLines([outcome]);
} else { } else {
const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`); const timeoutLine = terminalSystemLine("warn", `RCON 任务 ${response.jobId} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.jobId}`, serverTimeRef.current);
setResult({ status: "pending", label: "等待 Run 返回结果" }); setResult({ status: "pending", label: "等待 Run 返回结果" });
appendLines([timeoutLine]); appendLines([timeoutLine]);
} }
} catch (error) { } catch (error) {
const label = error instanceof Error ? error.message : "命令提交失败"; const label = error instanceof Error ? error.message : "命令提交失败";
setResult({ status: "failed", label }); setResult({ status: "failed", label });
appendLines([terminalSystemLine("error", label, "ERROR")]); appendLines([terminalSystemLine("error", label, "ERROR", undefined, serverTimeRef.current)]);
} finally { } finally {
setPending(false); setPending(false);
} }
@@ -333,9 +337,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
</div> </div>
</div> </div>
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}> <div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
{!historyOpen && streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{streams.reason}</span></div>} {!historyOpen && streams.status === "error" && <div className="terminal-line terminal-line-error terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{streams.reason}</span></div>}
{historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} />} {historyOpen && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} serverTime={serverTimeRef.current} />}
{!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>} {!historyOpen && streams.status === "ready" && lines.length === 0 && <div className="terminal-line terminal-line-warn terminal-source-system"><time>{formatTerminalServerTime(serverTimeRef.current)}</time><span className="terminal-text">{liveSessionId ? "当前受管进程会话暂无输出,后续输出会自动追加。" : "当前没有可跟随的受管进程输出;旧日志可从历史查看。"}</span></div>}
{!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)} {!historyOpen && lines.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
</div> </div>
</section> </section>
@@ -368,12 +372,13 @@ interface HistoryLogViewProps {
lines: HistoryLineState; lines: HistoryLineState;
selectedStreamId: string; selectedStreamId: string;
onSelect: (streamId: string) => Promise<void>; onSelect: (streamId: string) => Promise<void>;
serverTime?: string;
} }
function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryLogViewProps) { function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) {
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" />; if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" serverTime={serverTime} />;
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} />; if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} serverTime={serverTime} />;
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" />; if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" serverTime={serverTime} />;
return ( return (
<> <>
<div className="terminal-line terminal-line-info terminal-source-system"> <div className="terminal-line terminal-line-info terminal-source-system">
@@ -385,17 +390,17 @@ function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryL
</select> </select>
</span> </span>
</div> </div>
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" />} {lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" serverTime={serverTime} />}
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" />} {lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" serverTime={serverTime} />}
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} />} {lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} serverTime={serverTime} />}
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" />} {lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" serverTime={serverTime} />}
{lines.status === "ready" && lines.data.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)} {lines.status === "ready" && lines.data.map((line) => <div key={line.id} className={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
</> </>
); );
} }
function TerminalStatusLine({ tone, label }: { tone: "info" | "warn" | "error"; label: string }) { function TerminalStatusLine({ tone, label, serverTime }: { tone: "info" | "warn" | "error"; label: string; serverTime?: string }) {
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{label}</span></div>; return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{formatTerminalServerTime(serverTime)}</time><span className="terminal-text">{label}</span></div>;
} }
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] { function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
@@ -419,9 +424,9 @@ function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): Te
}; };
} }
function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`): TerminalLine { function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey: string, id = `${streamKey.toLowerCase()}-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, serverTime?: string): TerminalLine {
const now = Date.now(); const sortKey = serverTime ? Date.parse(serverTime) : 0;
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey }; return { id, tone, text, at: formatTerminalServerTime(serverTime), sortKey, streamKey };
} }
function terminalLineClassName(line: TerminalLine): string { function terminalLineClassName(line: TerminalLine): string {
@@ -457,7 +462,7 @@ function terminalLineFromJob(job: JobResponse): TerminalLine {
const completed = job.updatedAt; const completed = job.updatedAt;
const sortKey = Date.parse(completed) || Date.now(); const sortKey = Date.parse(completed) || Date.now();
const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn"; const tone: TerminalLine["tone"] = job.state === "succeeded" ? "success" : job.state === "failed" ? "error" : "warn";
return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "PLATFORM" }; return { id: `rcon-job-${job.id}-${job.state}`, tone, text: `RCON 任务 ${job.id} · ${jobStateLabel(job.state)} · ${summary}`, at: formatTerminalServerTime(job.updatedAt), sortKey, streamKey: "PLATFORM" };
} }
function jobStateLabel(state: JobResponse["state"]): string { function jobStateLabel(state: JobResponse["state"]): string {
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { configDiffViewFromPreview } from "./ServerDetailPage"; import { configDiffViewFromPreview } from "./ServerDetailPage";
import serversPageSource from "./ServersPage.tsx?raw";
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw"; import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
import serverDetailPageSource from "./ServerDetailPage.tsx?raw"; import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
import type { ServerConfigDiffPreviewResponse } from "../api/types"; import type { ServerConfigDiffPreviewResponse } from "../api/types";
@@ -22,6 +23,15 @@ const preview: ServerConfigDiffPreviewResponse = {
}; };
describe("ServerDetailPage config write approval", () => { describe("ServerDetailPage config write approval", () => {
it("keeps server overview metrics on the server list instead of the detail header", () => {
expect(serverDetailPageSource).not.toContain("listServerMetrics");
expect(serverDetailPageSource).not.toContain("server-detail-stat-strip");
expect(serverDetailPageSource).not.toContain("server-detail-meter-strip");
expect(serverDetailPageSource).not.toContain("<UsageMeter");
expect(serversPageSource).toContain("listServerMetrics");
expect(serversPageSource).toContain("<UsageMeter");
});
it("maps platform diff preview responses into the display diff without losing approval metadata", () => { it("maps platform diff preview responses into the display diff without losing approval metadata", () => {
const view = configDiffViewFromPreview(preview); const view = configDiffViewFromPreview(preview);
@@ -75,6 +85,21 @@ describe("ServerDetailPage config write approval", () => {
expect(serverDetailPageSource).not.toContain("Bearer "); expect(serverDetailPageSource).not.toContain("Bearer ");
}); });
it("keeps the server file manager list-first without plugin declaration gates", () => {
expect(serverDetailPageSource).toContain("server-file-editor-overlay");
expect(serverDetailPageSource).toContain("refreshRuntimeList({ silent: true })");
expect(serverDetailPageSource).toContain("window.setInterval(() => void loadList({ silent: true }), 2000)");
expect(serverDetailPageSource).toContain("if (!options.silent) setList({ status: \"loading\" })");
expect(serverDetailPageSource).toContain("serverFileListPendingLabel");
expect(serverDetailPageSource).not.toContain("server-file-layout");
expect(serverDetailPageSource).not.toContain("未声明目录");
expect(serverDetailPageSource).not.toContain("插件尚未声明");
expect(serverDetailPageSource).not.toContain("插件声明为可编辑");
expect(serverDetailPageSource).not.toContain("entry.editable");
expect(serverDetailPageSource).not.toContain("entry.downloadable");
expect(serverDetailPageSource).not.toContain("当前目录暂无缓存结果;点击刷新目录读取实时文件。");
});
it("keeps run distribution and client-manager workflows out of server detail tabs", () => { it("keeps run distribution and client-manager workflows out of server detail tabs", () => {
expect(serverDetailPageSource).not.toContain('id="run-builder"'); expect(serverDetailPageSource).not.toContain('id="run-builder"');
expect(serverDetailPageSource).not.toContain("getServerRuntimeActions"); expect(serverDetailPageSource).not.toContain("getServerRuntimeActions");
+107 -86
View File
@@ -1,5 +1,5 @@
import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles } from "lucide-react"; import { ChevronRight, Download, Eye, FileText, Folder, MoonStar, PackageOpen, Pencil, RefreshCw, Save, Search, ShieldCheck, Sparkles, Square, Terminal, Upload, UserRoundMinus, UserRoundPlus, WandSparkles, X } from "lucide-react";
import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "react"; import { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { platformApiClient } from "../api/client"; import { platformApiClient } from "../api/client";
import type { import type {
@@ -9,7 +9,6 @@ import type {
JobResponse, JobResponse,
ServerInstanceResponse, ServerInstanceResponse,
ServerMemberResponse, ServerMemberResponse,
ServerMetricsResponse,
ServerDeploymentResponse, ServerDeploymentResponse,
ServerConfigDiffPreviewResponse, ServerConfigDiffPreviewResponse,
RunEndpointResponse, RunEndpointResponse,
@@ -17,14 +16,13 @@ import type {
ServerFileListResponse, ServerFileListResponse,
ServerFileWorkspaceResponse ServerFileWorkspaceResponse
} from "../api/types"; } from "../api/types";
import { ConfirmDialog, UsageMeter } from "../components/OperationControls"; import { ConfirmDialog } from "../components/OperationControls";
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer"; import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews"; import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
import type { PageComponentProps } from "../contracts/page"; import type { PageComponentProps } from "../contracts/page";
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement"; import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
import { import {
serverDetailSections, serverDetailSections,
serverIsOnline,
type ConfigDiffView, type ConfigDiffView,
type LlmSuggestionView, type LlmSuggestionView,
type ServerDetailSection type ServerDetailSection
@@ -41,14 +39,12 @@ import { PluginPageHostPage } from "./PluginPageHostPage";
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T }; type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
const serverDetailRefreshMs = 5000; const serverDetailRefreshMs = 5000;
const serverMetricFreshMs = 30000;
export function ServerDetailPage(props: PageComponentProps) { export function ServerDetailPage(props: PageComponentProps) {
const { session, params, operations, onNavigate } = props; const { session, params, operations, onNavigate } = props;
const serverId = params.serverId ?? ""; const serverId = params.serverId ?? "";
const [section, setSection] = useState<ServerDetailSection>("manage"); const [section, setSection] = useState<ServerDetailSection>("manage");
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" }); const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]); const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
const [jobs, setJobs] = useState<JobResponse[]>([]); const [jobs, setJobs] = useState<JobResponse[]>([]);
const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>(); const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
@@ -84,12 +80,6 @@ export function ServerDetailPage(props: PageComponentProps) {
setRunEndpoint(undefined); setRunEndpoint(undefined);
setDeployment({ status: "error", reason: "部署定义加载失败" }); setDeployment({ status: "error", reason: "部署定义加载失败" });
} }
try {
const metricsResponse = await platformApiClient.listServerMetrics();
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
} catch {
setMetrics(null);
}
}, [serverId]); }, [serverId]);
useEffect(() => { useEffect(() => {
@@ -99,18 +89,16 @@ export function ServerDetailPage(props: PageComponentProps) {
const refreshOperationalState = useCallback(async () => { const refreshOperationalState = useCallback(async () => {
if (!serverId) return; if (!serverId) return;
try { try {
const [detail, jobResponse, metricsResponse, endpointResponse] = await Promise.all([ const [detail, jobResponse, endpointResponse] = await Promise.all([
platformApiClient.getServerInstance(serverId), platformApiClient.getServerInstance(serverId),
platformApiClient.listJobs(serverId), platformApiClient.listJobs(serverId),
platformApiClient.listServerMetrics(),
platformApiClient.listRunEndpoints() platformApiClient.listRunEndpoints()
]); ]);
setInstance({ status: "ready", data: detail }); setInstance({ status: "ready", data: detail });
setJobs(jobResponse.items); setJobs(jobResponse.items);
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId)); setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
} catch { } catch {
setMetrics(null); setRunEndpoint(undefined);
} }
}, [serverId]); }, [serverId]);
@@ -124,7 +112,6 @@ export function ServerDetailPage(props: PageComponentProps) {
const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified"; const detailFreshness = instance.status === "ready" ? runtimeObservationFreshness(instance.data, runEndpoint) : "unverified";
const detailStateText = instance.status === "ready" && detailFreshness === "fresh" ? stateLabel(instance.data.state) : instance.status === "ready" ? `最后观测:${stateLabel(instance.data.state)}Run 未验证)` : "未验证"; const detailStateText = instance.status === "ready" && detailFreshness === "fresh" ? stateLabel(instance.data.state) : instance.status === "ready" ? `最后观测:${stateLabel(instance.data.state)}Run 未验证)` : "未验证";
const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]); const detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]);
const metricsWaiting = metrics?.source === "run-metrics-pending";
useEffect(() => { useEffect(() => {
if (!params.routeKey || !readyPlugin?.pages.some((page) => page.key === params.routeKey)) return; if (!params.routeKey || !readyPlugin?.pages.some((page) => page.key === params.routeKey)) return;
@@ -247,18 +234,6 @@ export function ServerDetailPage(props: PageComponentProps) {
</button> </button>
</div> </div>
</div> </div>
<div className="server-detail-stat-strip">
<HeaderStat label="状态" value={detailFreshness === "fresh" ? (serverIsOnline(instance.data.state) ? "在线" : "离线") : "未验证"} />
<HeaderStat label="玩家" value={metrics?.playerCount !== undefined ? `${metrics.playerCount}${metrics.maxPlayers ? `/${metrics.maxPlayers}` : ""}` : "--"} />
<HeaderStat label="TPS" value={metrics?.tps !== undefined ? metrics.tps.toFixed(1) : "--"} />
<HeaderStat label="延迟" value={metrics?.latencyMs !== undefined ? `${Math.round(metrics.latencyMs)}ms` : "--"} />
<HeaderStat label="指标" value={metricFreshnessLabel(metrics)} />
</div>
<div className="server-detail-meter-strip">
<UsageMeter label="CPU" percent={metrics?.cpuPercent} pending={metricsWaiting} />
<UsageMeter label="内存" percent={metrics?.memoryPercent} pending={metricsWaiting} />
<UsageMeter label="磁盘" percent={metrics?.diskPercent} pending={metricsWaiting} />
</div>
</header> </header>
<nav className="section-tabs" aria-label="server sections"> <nav className="section-tabs" aria-label="server sections">
@@ -594,33 +569,36 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null); const [panelResult, setPanelResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
const [uploadBusy, setUploadBusy] = useState(false); const [uploadBusy, setUploadBusy] = useState(false);
const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false }); const [editor, setEditor] = useState<ServerFileEditorState>({ entry: null, key: "", draft: "", loading: false, saving: false });
const initialRuntimeLoadRef = useRef(false);
const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined; const activeDirectory = workspace.status === "ready" ? workspace.data.directories.find((item) => item.key === directoryKey) : undefined;
const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && activeDirectory?.scope !== "logs" && !uploadBusy; const canUpload = workspace.status === "ready" && Boolean(activeDirectory) && !uploadBusy;
const entries = list.status === "ready" ? list.data.entries : []; const entries = list.status === "ready" ? list.data.entries : [];
const loadWorkspace = useCallback(async () => { const loadWorkspace = useCallback(async () => {
initialRuntimeLoadRef.current = false;
setWorkspace({ status: "loading" }); setWorkspace({ status: "loading" });
try { try {
const response = await platformApiClient.getServerFileWorkspace(instance.id); const response = await platformApiClient.getServerFileWorkspace(instance.id);
setWorkspace({ status: "ready", data: response }); setWorkspace({ status: "ready", data: response });
const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || ""; const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || "";
setDirectoryKey((current) => current || nextDirectoryKey); setDirectoryKey((current) => current || nextDirectoryKey);
if (!nextDirectoryKey) { if (!nextDirectoryKey) setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "尚未获得服务器文件入口。" } });
setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "插件尚未声明文件工作区;需要在插件 manifest 中添加 fileWorkspace。" } });
}
} catch (error) { } catch (error) {
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" }); setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
setList({ status: "error", reason: "文件工作区不可用" }); setList({ status: "error", reason: "文件工作区不可用" });
} }
}, [instance.id]); }, [instance.id]);
const loadList = useCallback(async () => { const loadList = useCallback(async (options: { silent?: boolean } = {}) => {
if (!directoryKey) return; if (!directoryKey) return;
setList({ status: "loading" }); if (!options.silent) setList({ status: "loading" });
try { try {
const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive }); const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive });
setList({ status: "ready", data: response }); setList({ status: "ready", data: response });
if (options.silent && response.state === "ready") setPanelResult({ status: "succeeded", label: `目录已更新:${response.entries.length}` });
if (options.silent && response.state === "pending") setPanelResult({ status: "pending", label: serverFileListPendingLabel(response) });
if (options.silent && response.state === "failed") setPanelResult({ status: "failed", label: response.reason ?? "目录刷新失败" });
} catch (error) { } catch (error) {
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" }); setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" });
} }
@@ -630,26 +608,37 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
void loadWorkspace(); void loadWorkspace();
}, [loadWorkspace]); }, [loadWorkspace]);
useEffect(() => { const refreshRuntimeList = useCallback(async (options: { silent?: boolean } = {}) => {
if (workspace.status !== "ready" || !directoryKey) return;
void loadList();
}, [directoryKey, loadList, workspace.status]);
async function refreshRuntimeList() {
if (!directoryKey) return; if (!directoryKey) return;
const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName }); const operationId = options.silent ? "" : operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" }); if (!options.silent) setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" });
try { try {
const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) }); const response = await platformApiClient.refreshServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive, idempotencyKey: serverFileIdempotency("list", instance.id, directoryKey) });
setList({ status: "ready", data: response }); setList({ status: "ready", data: response });
operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job); if (operationId) operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" }); setPanelResult({ status: serverFileListResultStatus(response), label: serverFileListResultLabel(response) });
} catch (error) { } catch (error) {
const reason = error instanceof Error ? error.message : "目录刷新失败"; const reason = error instanceof Error ? error.message : "目录刷新失败";
operations.fail(operationId, reason, operationId); if (operationId) operations.fail(operationId, reason, operationId);
setPanelResult({ status: "failed", label: reason }); setPanelResult({ status: "failed", label: reason });
} }
}, [directoryKey, instance.id, operations, relativePath, recursive, searchQuery, session.displayName]);
useEffect(() => {
if (workspace.status !== "ready" || !directoryKey) return;
if (!initialRuntimeLoadRef.current) {
initialRuntimeLoadRef.current = true;
void refreshRuntimeList({ silent: true });
return;
} }
void loadList();
}, [directoryKey, loadList, refreshRuntimeList, workspace.status]);
useEffect(() => {
if (workspace.status !== "ready" || !directoryKey || list.status !== "ready" || list.data.state !== "pending") return;
const timer = window.setInterval(() => void loadList({ silent: true }), 2000);
return () => window.clearInterval(timer);
}, [directoryKey, list, loadList, workspace.status]);
async function openEntry(entry: ServerFileEntryResponse) { async function openEntry(entry: ServerFileEntryResponse) {
if (entry.kind === "directory") { if (entry.kind === "directory") {
@@ -664,7 +653,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
async function openEditor(entry: ServerFileEntryResponse) { async function openEditor(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry); const key = serverFileEntryKey(entry);
if (!key) { if (!key) {
setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" }); setPanelResult({ status: "failed", label: "该文件缺少路径 key,不能读取。" });
return; return;
} }
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" }); setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
@@ -687,7 +676,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
} }
async function saveEditor() { async function saveEditor() {
if (!editor.entry || !editor.key || editor.saving || !editor.entry.editable) return; if (!editor.entry || !editor.key || editor.saving) return;
const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); const operationId = operations.begin({ intent: "保存文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" })); setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
try { try {
@@ -706,7 +695,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
async function downloadEntry(entry: ServerFileEntryResponse) { async function downloadEntry(entry: ServerFileEntryResponse) {
const key = serverFileEntryKey(entry); const key = serverFileEntryKey(entry);
if (!key || !entry.downloadable) return; if (!key) return;
const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName }); const operationId = operations.begin({ intent: "下载文件", targetKind: "server", targetId: instance.id, requester: session.displayName });
setPanelResult({ status: "pending", label: "正在准备文件下载…" }); setPanelResult({ status: "pending", label: "正在准备文件下载…" });
try { try {
@@ -761,6 +750,10 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
} }
} }
function closeEditor() {
setEditor({ entry: null, key: "", draft: "", loading: false, saving: false });
}
if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />; if (workspace.status === "loading") return <LoadingState label="正在加载文件工作区…" compact />;
if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />; if (workspace.status === "error") return <ErrorState title="文件工作区不可用" reason={workspace.reason} diagnosticId={`server-files:${instance.id}`} onRetry={() => void loadWorkspace()} compact />;
@@ -772,18 +765,17 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
</div> </div>
<div className="server-file-pathbar" aria-label="当前文件路径"> <div className="server-file-pathbar" aria-label="当前文件路径">
<button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span></span></button> <button type="button" className="icon-command" onClick={goUp} disabled={!relativePath && directoryKey === workspace.data.defaultDirectoryKey}><ChevronRight size={14} className="server-file-back-icon" /><span></span></button>
<span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "未声明目录")}</span> <span className="server-file-path-chip">{activeDirectory?.label ?? (directoryKey || "服务器根目录")}</span>
{relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)} {relativePath.split("/").filter(Boolean).map((part) => <span key={part} className="server-file-path-chip server-file-path-child"><ChevronRight size={12} />{part}</span>)}
</div> </div>
<div className="server-file-toolbar"> <div className={cx("server-file-toolbar", workspace.data.directories.length > 1 && "server-file-toolbar-has-tabs")}>
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录"> {workspace.data.directories.length > 1 && <div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
{workspace.data.directories.length === 0 && <span className="provider-id"></span>}
{workspace.data.directories.map((directory) => ( {workspace.data.directories.map((directory) => (
<button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}> <button key={directory.key} type="button" className={cx("segmented-button", directory.key === directoryKey && "segmented-button-active")} onClick={() => { setDirectoryKey(directory.key); setRelativePath(""); }}>
{directory.label} {directory.label}
</button> </button>
))} ))}
</div> </div>}
<form className="server-file-search" onSubmit={submitSearch}> <form className="server-file-search" onSubmit={submitSearch}>
<Search size={14} /> <Search size={14} />
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} /> <input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
@@ -792,7 +784,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
</form> </form>
<div className="action-strip server-file-actions"> <div className="action-strip server-file-actions">
<button type="button" className="icon-command" onClick={() => void refreshRuntimeList()} disabled={!directoryKey}><RefreshCw size={14} /><span></span></button> <button type="button" className="icon-command" onClick={() => void refreshRuntimeList()} disabled={!directoryKey}><RefreshCw size={14} /><span></span></button>
<label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前逻辑目录" : "当前目录不可上传或正在上传"}> <label className={cx("server-file-upload-control", !canUpload && "server-file-upload-disabled")} title={canUpload ? "上传到当前目录" : "当前目录不可上传或正在上传"}>
<Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} /> <Upload size={14} /><span>{uploadBusy ? "上传中…" : "上传"}</span><input type="file" disabled={!canUpload} onChange={(event) => void uploadFile(event)} />
</label> </label>
</div> </div>
@@ -801,28 +793,25 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
{list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />} {list.status === "loading" && <LoadingState label="正在加载文件列表…" compact />}
{list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void loadList()} compact />} {list.status === "error" && <ErrorState title="文件列表不可用" reason={list.reason} diagnosticId={`server-file-list:${instance.id}:${directoryKey}`} onRetry={() => void loadList()} compact />}
{list.status === "ready" && ( {list.status === "ready" && (
<div className="server-file-layout">
<div className="resource-table-wrap server-file-table-wrap"> <div className="resource-table-wrap server-file-table-wrap">
<table className="resource-table server-file-table"> <table className="resource-table server-file-table">
<thead><tr><th aria-label="选择"><input type="checkbox" disabled /></th><th></th><th></th><th></th><th></th><th></th></tr></thead> <thead><tr><th></th><th></th><th></th><th></th><th></th></tr></thead>
<tbody> <tbody>
{entries.length === 0 && <tr><td colSpan={6}><span className="provider-id"></span></td></tr>} {entries.length === 0 && <tr><td colSpan={5}><span className="provider-id">{serverFileListEmptyLabel(list.data)}</span></td></tr>}
{entries.map((entry) => ( {entries.map((entry) => (
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}> <tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
<td><input type="checkbox" disabled /></td>
<td> <td>
<button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}> <button type="button" className="table-link-button server-file-name-button" onClick={() => void openEntry(entry)}>
{entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span> {entry.kind === "directory" ? <Folder size={16} /> : <FileText size={16} />}<span>{entry.name}</span>
</button> </button>
<span className="provider-id">{entry.logicalKey || entry.relativePath || entry.directoryKey}</span>
</td> </td>
<td>{entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}</td> <td>{entry.kind === "directory" ? "--" : formatBytes(entry.sizeBytes)}</td>
<td>{formatDateTime(entry.modifiedAt)}</td> <td>{formatDateTime(entry.modifiedAt)}</td>
<td>{entry.remark || entry.scope || "--"}</td> <td>{entry.remark || entry.scope || "--"}</td>
<td> <td>
<div className="row-actions human-row-actions"> <div className="row-actions human-row-actions">
{entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span></span></button> : <button type="button" title="读取/编辑" disabled={!entry.editable} onClick={() => void openEditor(entry)}><Pencil size={14} /><span></span></button>} {entry.kind === "directory" ? <button type="button" title="打开目录" onClick={() => void openEntry(entry)}><Eye size={14} /><span></span></button> : <button type="button" title="读取/编辑" onClick={() => void openEditor(entry)}><Pencil size={14} /><span></span></button>}
{entry.kind === "file" && <button type="button" title="下载" disabled={!entry.downloadable} onClick={() => void downloadEntry(entry)}><Download size={14} /><span></span></button>} {entry.kind === "file" && <button type="button" title="下载" onClick={() => void downloadEntry(entry)}><Download size={14} /><span></span></button>}
</div> </div>
</td> </td>
</tr> </tr>
@@ -830,9 +819,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
</tbody> </tbody>
</table> </table>
</div> </div>
<aside className="server-file-editor" aria-label="file editor"> )}
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> </h3>{editor.entry && <span className="page-status">{editor.entry.name}</span>}</div> {editor.entry && (
{!editor.entry && <p className="section-copy"> Run </p>} <div className="server-file-editor-overlay" role="dialog" aria-modal="true" aria-label="file editor">
<section className="server-file-editor">
<div className="panel-header"><h3><FileText size={15} style={{ verticalAlign: "-2px" }} /> </h3><span className="page-status">{editor.entry.name}</span><button type="button" className="icon-command" onClick={closeEditor}><X size={14} /><span></span></button></div>
{editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />} {editor.entry && editor.loading && <LoadingState label="正在读取文件快照…" compact />}
{editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />} {editor.entry && editor.error && <ErrorState title="文件编辑不可用" reason={editor.error} diagnosticId={`server-file-edit:${instance.id}:${editor.key}`} compact />}
{editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>} {editor.entry && editor.message && !editor.error && <span className="provider-id">{editor.message}</span>}
@@ -844,11 +835,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
)} )}
{editor.entry && ( {editor.entry && (
<div className="action-strip server-file-editor-actions"> <div className="action-strip server-file-editor-actions">
<button type="button" className="primary-command" disabled={!editor.entry.editable || editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button> <button type="button" className="primary-command" disabled={editor.loading || editor.saving || editor.snapshot?.content === undefined} onClick={() => void saveEditor()}><Save size={14} /><span>{editor.saving ? "保存中…" : "保存"}</span></button>
<button type="button" className="icon-command" disabled={!editor.entry.downloadable} onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span></span></button> <button type="button" className="icon-command" onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span></span></button>
</div> </div>
)} )}
</aside> </section>
</div> </div>
)} )}
</article> </article>
@@ -863,6 +854,52 @@ function serverFileEntryRowKey(entry: ServerFileEntryResponse): string {
return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`; return `${entry.kind}:${entry.directoryKey}:${entry.relativePath ?? ""}:${entry.logicalKey ?? ""}:${entry.name}`;
} }
function serverFileListResultStatus(response: ServerFileListResponse): "pending" | "succeeded" | "failed" {
if (response.state === "ready") return "succeeded";
if (response.state === "failed") return "failed";
return "pending";
}
function serverFileListResultLabel(response: ServerFileListResponse): string {
if (response.state === "ready") return `目录已更新:${response.entries.length}`;
if (response.state === "failed") return response.reason ?? "目录刷新失败";
return serverFileListPendingLabel(response);
}
function serverFileListEmptyLabel(response: ServerFileListResponse): string {
if (response.state === "pending") return serverFileListPendingLabel(response);
if (response.state === "failed") return response.reason ?? "目录刷新失败";
return "当前目录暂无文件。";
}
function serverFileListPendingLabel(response: ServerFileListResponse): string {
const job = response.job;
if (!job) return response.reason ?? "正在读取当前目录,Run 返回后会自动更新。";
const progress = job.progress?.message?.trim();
const attempt = job.attempt > 0 ? ` · 第 ${job.attempt} 次尝试` : "";
const nextAttempt = job.state === "retrying" && job.nextAttemptAt ? ` · 下次 ${formatDateTime(job.nextAttemptAt)}` : "";
return `文件刷新任务 ${serverFileJobStateLabel(job.state)}${attempt}${nextAttempt}${progress ? ` · ${progress}` : ""}`;
}
function serverFileJobStateLabel(state: JobResponse["state"]): string {
switch (state) {
case "queued":
return "已排队,等待 Run 领取";
case "accepted":
return "Run 已领取,等待确认";
case "running":
return "运行中";
case "retrying":
return "等待重试";
case "succeeded":
return "已完成";
case "cancelled":
return "已取消";
default:
return "已失败";
}
}
function serverFileIdempotency(prefix: string, serverId: string, key: string): string { function serverFileIdempotency(prefix: string, serverId: string, key: string): string {
return `web-file-${prefix}-${serverId}-${String(key).replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 40)}-${Date.now()}`; return `web-file-${prefix}-${serverId}-${String(key).replace(/[^a-zA-Z0-9_.-]+/g, "-").slice(0, 40)}-${Date.now()}`;
} }
@@ -886,22 +923,6 @@ function formatDateTime(value?: string): string {
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString(); return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
} }
function HeaderStat({ label, value }: { label: string; value: string }) {
return (
<span className="server-card-stat">
<span>{label}</span>
<strong>{value}</strong>
</span>
);
}
function metricFreshnessLabel(metrics: ServerMetricsResponse | null): string {
if (!metrics || metrics.source === "run-metrics-pending") return "等待上报";
const collectedAt = new Date(metrics.collectedAt).getTime();
if (Number.isFinite(collectedAt) && Date.now() - collectedAt > serverMetricFreshMs) return "指标过期";
return new Date(metrics.collectedAt).toLocaleTimeString();
}
interface LlmSectionProps { interface LlmSectionProps {
serverId: string; serverId: string;
instance: ServerInstanceResponse; instance: ServerInstanceResponse;
+7 -10
View File
@@ -455,7 +455,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-file-back-icon{transform:rotate(180deg)} .server-file-back-icon{transform:rotate(180deg)}
.server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap} .server-file-path-chip{display:inline-flex;align-items:center;gap:4px;min-height:30px;padding:0 10px;border:1px solid var(--line);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);font-size:12px;font-weight:800;white-space:nowrap}
.server-file-path-child{color:var(--ink-faint)} .server-file-path-child{color:var(--ink-faint)}
.server-file-toolbar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(260px,1.2fr) auto;gap:8px;align-items:center;min-width:0} .server-file-toolbar{display:grid;grid-template-columns:minmax(320px,1fr) auto;gap:8px;align-items:center;min-width:0}.server-file-toolbar-has-tabs{grid-template-columns:minmax(160px,.8fr) minmax(320px,1.2fr) auto}
.server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px} .server-file-directory-tabs{display:flex;gap:6px;overflow:auto;min-width:0;padding-bottom:2px}
.server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)} .server-file-search{display:flex;align-items:center;gap:7px;min-width:0;min-height:38px;padding:0 8px;border:1px solid var(--line);border-radius:8px;background:var(--control-surface);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0} .server-file-search input[type=search]{min-width:120px;flex:1 1 auto;border:0;background:transparent;color:var(--ink);font:inherit;outline:0}
@@ -464,11 +464,11 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap} .server-file-upload-control{min-height:36px;display:inline-flex;align-items:center;justify-content:center;gap:8px;border:1px solid var(--line-strong);border-radius:8px;padding:0 12px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;box-shadow:inset 0 1px 0 var(--crystal-rim),0 8px 18px var(--glass-shadow);font-weight:700;white-space:nowrap}
.server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)} .server-file-upload-control input{display:none}.server-file-upload-control:hover,.server-file-upload-control:focus-within{border-color:var(--accent);color:var(--ink);box-shadow:inset 0 1px 0 var(--crystal-rim),0 0 0 2px var(--accent-soft),0 12px 26px var(--glass-shadow)}
.server-file-upload-disabled{opacity:.55;cursor:not-allowed} .server-file-upload-disabled{opacity:.55;cursor:not-allowed}
.server-file-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(280px,360px);gap:12px;align-items:start;min-width:0} .server-file-table-wrap{max-height:min(68vh,760px)}.server-file-table{min-width:760px}.server-file-table th:last-child,.server-file-table td:last-child{width:190px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
.server-file-table-wrap{max-height:560px}.server-file-table{min-width:860px}.server-file-table td:first-child,.server-file-table th:first-child{width:44px}.server-file-directory-row{background:color-mix(in srgb,var(--accent-soft) 54%,transparent)}
.server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)} .server-file-name-button{display:inline-flex;align-items:center;gap:8px;color:var(--ink);font-weight:850}.server-file-name-button svg{color:var(--accent-deep)}
.server-file-editor{display:grid;gap:10px;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 14px 30px var(--glass-shadow);min-width:0} .server-file-editor-overlay{position:fixed;inset:0;z-index:70;display:grid;place-items:center;padding:24px;background:rgba(0,0,0,.42);backdrop-filter:blur(10px)}
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)} .server-file-editor{display:grid;gap:10px;width:min(960px,calc(100vw - 48px));max-height:calc(100dvh - 48px);overflow:auto;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--glass-wash),var(--glass-tint),var(--surface);box-shadow:inset 0 1px 0 var(--crystal-rim),0 24px 70px var(--glass-shadow);min-width:0}
.server-file-editor-field{display:grid;gap:6px;color:var(--ink-soft);font-size:13px;font-weight:800}.server-file-editor-field textarea{min-height:320px;height:min(62vh,560px);width:100%;border:1px solid var(--line-strong);border-radius:8px;padding:10px;background:var(--surface-solid);color:var(--ink);font:13px/1.45 var(--font-mono);resize:vertical}.server-file-editor-field textarea:focus{border-color:var(--accent);outline:2px solid var(--accent-soft)}
.server-file-editor-actions .primary-command{width:auto} .server-file-editor-actions .primary-command{width:auto}
.server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px} .server-card-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,420px),1fr));gap:16px}
.server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0} .server-card{display:grid;gap:12px;padding:16px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),var(--glass-tint),var(--surface);backdrop-filter:blur(22px) saturate(1.28);text-align:left;transition:transform 120ms ease,border-color 120ms ease;box-shadow:var(--jelly-inset),inset 0 0 0 1px var(--diamond-line),0 18px 42px var(--glass-shadow),0 0 28px rgba(255,255,255,.2);position:relative;overflow:hidden;min-width:0}
@@ -489,8 +489,6 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.server-detail-title-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap} .server-detail-title-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.server-detail-title-row>div:first-child{min-width:0} .server-detail-title-row>div:first-child{min-width:0}
.server-detail-title-row h1{margin:0;font-size:24px;color:var(--ink);overflow-wrap:anywhere} .server-detail-title-row h1{margin:0;font-size:24px;color:var(--ink);overflow-wrap:anywhere}
.server-detail-stat-strip{display:grid;grid-template-columns:repeat(7,minmax(0,1fr));gap:8px}
.server-detail-meter-strip{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.section-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 72%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)} .section-tabs{display:flex;gap:6px;flex-wrap:wrap;padding:8px;border:1px solid var(--line);border-radius:8px;background:var(--frosted-surface),color-mix(in srgb,var(--surface) 72%,transparent);box-shadow:inset 0 1px 0 var(--crystal-rim)}
.section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap} .section-tab{display:inline-flex;align-items:center;gap:6px;min-height:38px;padding:0 14px;border:1px solid var(--line-strong);border-radius:999px;background:var(--control-surface);color:var(--ink-soft);cursor:pointer;white-space:nowrap}
.section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0} .section-tab:focus-visible,.section-tab:hover{border-color:var(--accent);outline:0}
@@ -722,14 +720,13 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.page-title{font-size:24px} .page-title{font-size:24px}
.catalog-grid,.console-grid,.metric-grid,.overview-two-col,.resource-list-item,.server-card-grid{grid-template-columns:1fr} .catalog-grid,.console-grid,.metric-grid,.overview-two-col,.resource-list-item,.server-card-grid{grid-template-columns:1fr}
.form-grid,.server-metrics,.server-workspace{grid-template-columns:1fr} .form-grid,.server-metrics,.server-workspace{grid-template-columns:1fr}
.server-detail-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))} .terminal-command-form{grid-template-columns:1fr}
.server-detail-meter-strip,.terminal-command-form{grid-template-columns:1fr}
.client-manager-command-grid,.client-manager-version-grid{grid-template-columns:1fr} .client-manager-command-grid,.client-manager-version-grid{grid-template-columns:1fr}
.server-card-stats{grid-template-columns:repeat(2,minmax(0,1fr))} .server-card-stats{grid-template-columns:repeat(2,minmax(0,1fr))}
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px} .section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
.server-toolbar{align-items:stretch} .server-toolbar{align-items:stretch}
.server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0} .server-toolbar input[type=search],.server-toolbar select{flex:1 1 100%;min-width:0}
.server-file-toolbar,.server-file-layout{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto} .server-file-toolbar{grid-template-columns:1fr}.server-file-search,.server-file-actions,.server-file-pathbar{align-items:stretch;flex-wrap:wrap}.server-file-search input[type=search]{min-width:0}.server-file-actions{justify-content:stretch}.server-file-actions>*{flex:1 1 auto}
.plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch} .plugin-control-row,.server-card-head,.server-detail-title-row{grid-template-columns:1fr;align-items:stretch}
.server-card-head,.server-detail-title-row{display:grid} .server-card-head,.server-detail-title-row{display:grid}
.server-detail-title-row .action-strip{align-items:stretch} .server-detail-title-row .action-strip{align-items:stretch}
+6 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { formatTerminalLogTime } from "./logTime"; import { formatTerminalLogTime, formatTerminalServerTime } from "./logTime";
describe("formatTerminalLogTime", () => { describe("formatTerminalLogTime", () => {
it("uses the SCUM log clock instead of the browser timezone", () => { it("uses the SCUM log clock instead of the browser timezone", () => {
@@ -11,4 +11,9 @@ describe("formatTerminalLogTime", () => {
it("falls back to the event timestamp for generic logs", () => { it("falls back to the event timestamp for generic logs", () => {
expect(formatTerminalLogTime("not-a-timestamp")).toBe("时间未知"); expect(formatTerminalLogTime("not-a-timestamp")).toBe("时间未知");
}); });
it("does not use the browser clock before the server clock is synchronized", () => {
expect(formatTerminalServerTime()).toBe("时间同步中");
expect(formatTerminalServerTime("2026-08-14T00:00:00Z")).toBe(formatTerminalLogTime("2026-08-14T00:00:00Z"));
});
}); });
+4
View File
@@ -9,3 +9,7 @@ export function formatTerminalLogTime(timestamp: string, line?: string): string
const date = new Date(timestamp); const date = new Date(timestamp);
return Number.isNaN(date.getTime()) ? "时间未知" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" }); return Number.isNaN(date.getTime()) ? "时间未知" : date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
} }
export function formatTerminalServerTime(timestamp?: string): string {
return timestamp ? formatTerminalLogTime(timestamp) : "时间同步中";
}
+14 -2
View File
@@ -11,8 +11,8 @@ import {
const plugin: PluginBridgeManifestContract = { const plugin: PluginBridgeManifestContract = {
id: "game.example", id: "game.example",
declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"], declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "server.remote.access", "ai.invoke"],
bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "ai.invoke"], bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "remote.access.request", "ai.invoke"],
pages: [ pages: [
{ {
key: "logs", key: "logs",
@@ -20,6 +20,13 @@ const plugin: PluginBridgeManifestContract = {
path: "/logs", path: "/logs",
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"], permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"] bridgeActions: ["logs.query", "files.request", "artifacts.open", "ai.invoke"]
},
{
key: "remote",
title: "Remote",
path: "/remote",
permissions: ["server.remote.access"],
bridgeActions: ["remote.access.request"]
} }
], ],
aiPurposes: ["logs.diagnose"] aiPurposes: ["logs.diagnose"]
@@ -110,6 +117,11 @@ describe("plugin bridge host utilities", () => {
expect(client.executePluginBridge).not.toHaveBeenCalled(); expect(client.executePluginBridge).not.toHaveBeenCalled();
}); });
it("allows mediated remote SQL execute payloads without direct connection material", () => {
const context = createPluginBridgeHostContext({ plugin, routeKey: "remote", serverInstanceId: "server-1", themeTokens: { colorScheme: "dark", accentColor: "#22c55e" } });
expect(validateBridgeExecutionRequest(context, { requestId: "sql-1", action: "remote.access.request", payload: { capability: "remote.run.db.sqlite.execute", declarationKey: "sqlite-db", targetKey: "scum-db", idempotencyKey: "sql-1", "input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';" } })).toBeNull();
});
it("dispatches mediated AI requests without provider configuration", async () => { it("dispatches mediated AI requests without provider configuration", async () => {
const context = createPluginBridgeHostContext({ const context = createPluginBridgeHostContext({
plugin, plugin,
+1 -1
View File
@@ -110,7 +110,7 @@ export function validateBridgeExecutionRequest(context: PluginBridgeHostContext,
return { code: "payload_too_large", message: "bridge payload has too many keys" }; return { code: "payload_too_large", message: "bridge payload has too many keys" };
} }
const encodedSize = Object.entries(payload).reduce((sum, [key, value]) => sum + key.length + value.length, 0); const encodedSize = Object.entries(payload).reduce((sum, [key, value]) => sum + key.length + value.length, 0);
if (encodedSize > 4096) { if (encodedSize > 16 * 1024) {
return { code: "payload_too_large", message: "bridge payload is too large" }; return { code: "payload_too_large", message: "bridge payload is too large" };
} }
for (const [key, value] of Object.entries(payload)) { for (const [key, value] of Object.entries(payload)) {
@@ -17,6 +17,69 @@ export type PluginGameClientQueueRequest = {
expiresAt: string; expiresAt: string;
}; };
export type PluginBridgeExecuteEnvelope = { requestId: string; action: string; payload?: Record<string, string> };
export type PluginBridgeExecutionResult = { status?: string; result?: Record<string, string>; error?: { message?: string } };
export const playerAttributeCatalog = [
{ key: "stamina", label: "体力", column: "stamina", sourceKeys: ["attributes.stamina", "stamina", "体力"] },
{ key: "dexterity", label: "敏捷", column: "dexterity", sourceKeys: ["attributes.dexterity", "dexterity", "敏捷"] },
{ key: "intelligence", label: "智力", column: "intelligence", sourceKeys: ["attributes.intelligence", "intelligence", "智力"] }
] as const;
export type PlayerAttributeDraft = { fieldKey: string; label: string; before: string; after: string };
export function playerAttributeDrafts(player: RecordMap): PlayerAttributeDraft[] {
return playerAttributeCatalog.map((field) => ({ fieldKey: field.key, label: field.label, before: firstText(player, ...field.sourceKeys), after: firstText(player, ...field.sourceKeys) }));
}
export function playerAttributeSqlPreview(drafts: PlayerAttributeDraft[]): string {
const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim());
if (!changes.length) return "等待输入要提交的属性变更。";
return buildPlayerAttributeSqlText({ gamePlayerId: ":playerId" }, changes.flatMap((draft) => {
const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey);
const after = Number(draft.after);
return definition && Number.isFinite(after) ? [{ column: definition.column, after }] : [];
}));
}
export function buildPlayerAttributeMutation(player: RecordMap, drafts: PlayerAttributeDraft[]): RecordMap {
const playerId = firstText(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id");
if (!playerId) throw new Error("用户没有可用的 Steam ID 或游戏用户编号。");
const changes = drafts.filter((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()).map((draft) => {
const definition = playerAttributeCatalog.find((candidate) => candidate.key === draft.fieldKey);
const before = draft.before.trim() ? Number(draft.before) : Number.NaN;
const after = Number(draft.after);
if (!definition || !Number.isFinite(after)) throw new Error(`${draft.label}目标值必须是数字。`);
return { fieldKey: draft.fieldKey, label: draft.label, column: definition.column, before: Number.isFinite(before) ? before : null, after };
});
if (!changes.length) throw new Error("至少填写一项与当前值不同的属性。");
const idempotencyKey = safeCommandId(`player-attributes:${playerId}:${changes.map((change) => `${change.fieldKey}:${change.after}`).join(",")}:${Date.now()}`);
return { playerId, reason: "管理员在 SCUM 用户管理中编辑属性", sqlText: buildPlayerAttributeSqlText(player, changes), changes, idempotencyKey };
}
export async function queuePlayerAttributePatch(actions: SCUMWorkspaceActions, player: RecordMap, drafts: PlayerAttributeDraft[]): Promise<unknown> {
if (!actions.dispatch) throw new Error("通用 remote.access.request 能力不可用,无法提交 SQL 执行任务。");
const mutation = buildPlayerAttributeMutation(player, drafts);
const idempotencyKey = textValue(mutation.idempotencyKey);
const result = await actions.dispatch({
requestId: idempotencyKey,
action: "remote.access.request",
payload: {
capability: "remote.run.db.sqlite.execute",
declarationKey: "scum-database",
targetKey: "scum-database",
idempotencyKey,
timeoutSeconds: "60",
maxAttempts: "1",
"input.mode": "execute",
"input.sqlText": textValue(mutation.sqlText),
"input.reason": textValue(mutation.reason)
}
});
if (result?.status && !["queued", "ok"].includes(result.status)) throw new Error(result.error?.message || "SQL 执行任务未进入 Run 队列。");
return result;
}
export type SCUMWorkspaceActions = { export type SCUMWorkspaceActions = {
pluginData?: PluginDataActions; pluginData?: PluginDataActions;
gameClient?: { gameClient?: {
@@ -25,6 +88,7 @@ export type SCUMWorkspaceActions = {
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>; list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => Promise<unknown>;
snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>; snapshots: (query?: { profileKey?: string; type?: string; streamKey?: string; observedAfter?: string; limit?: number }) => Promise<unknown>;
}; };
dispatch?: (envelope: PluginBridgeExecuteEnvelope, signal?: AbortSignal) => Promise<PluginBridgeExecutionResult>;
}; };
export type SCUMSurfaceData = { export type SCUMSurfaceData = {
@@ -80,7 +144,7 @@ type SurfaceKey = keyof SCUMSurfaceData;
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows"; type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
const pageCollections: Record<PageKey, SurfaceKey[]> = { const pageCollections: Record<PageKey, SurfaceKey[]> = {
players: ["players", "members"], players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
squads: ["squads", "members", "flags"], squads: ["squads", "members", "flags"],
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"], "live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"],
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"], gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"],
@@ -292,6 +356,23 @@ function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions {
return actions.pluginData; return actions.pluginData;
} }
function buildPlayerAttributeSqlText(player: RecordMap, changes: Array<{ column: string; after: number }>): string {
const playerId = firstText(player, "gamePlayerId", "playerId", "id");
const profileId = firstText(player, "userProfileId", "profileId");
const steamId = firstText(player, "steamId", "providerId");
const where = playerId && playerId !== ":playerId"
? `id = ${sqlLiteral(playerId)}`
: profileId
? `id = (SELECT prisoner_id FROM user_profile WHERE CAST(id AS TEXT) = ${sqlLiteral(profileId)} LIMIT 1)`
: steamId
? `id = (SELECT profile.prisoner_id FROM user_profile profile WHERE profile.user_id = ${sqlLiteral(steamId)} LIMIT 1)`
: "id = :playerId";
return changes.map((change) => `UPDATE prisoner SET ${change.column} = ${sqlNumber(change.after)} WHERE ${where};`).join("\n");
}
function sqlLiteral(value: string): string { return value === ":playerId" ? value : `'${value.replace(/'/g, "''")}'`; }
function sqlNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(value); }
function requiredKey(value: RecordMap, key: string, label: string): string { function requiredKey(value: RecordMap, key: string, label: string): string {
const result = textValue(value[key]); const result = textValue(value[key]);
if (!result) throw new Error(`${label}不能为空。`); if (!result) throw new Error(`${label}不能为空。`);
@@ -6,6 +6,9 @@ import {
loadSCUMSurface, loadSCUMSurface,
parseGiftItems, parseGiftItems,
parseGiftCommands, parseGiftCommands,
playerAttributeDrafts,
playerAttributeSqlPreview,
queuePlayerAttributePatch,
queueGiftDelivery, queueGiftDelivery,
resetGiftClaim, resetGiftClaim,
resetPendingGift, resetPendingGift,
@@ -21,9 +24,12 @@ import {
} from "./page-data.js"; } from "./page-data.js";
type StateSetter<T> = (next: T | ((previous: T) => T)) => void; type StateSetter<T> = (next: T | ((previous: T) => T)) => void;
type InputEvent = { target?: { value?: string; checked?: boolean } }; type InputEvent = { target?: { value?: string; checked?: boolean }; stopPropagation?: () => void };
type GiftTab = "definitions" | "claims" | "deliveries" | "timed"; type GiftTab = "definitions" | "claims" | "deliveries" | "timed";
type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other"; type MapLayer = "players" | "vehicles" | "flags" | "regions" | "other";
type PlayerPanelKind = "closed" | "attributes" | "gifts" | "items" | "history" | "trajectory";
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
type AttributeDraft = { fieldKey: string; label: string; before: string; after: string };
const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href; const scumMapBackground = new URL("../assets/map/scum-map-overview.jpg", import.meta.url).href;
export type ReactLike = { export type ReactLike = {
@@ -51,6 +57,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" }); const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
const [playerSearch, setPlayerSearch] = usePluginState(react, ""); const [playerSearch, setPlayerSearch] = usePluginState(react, "");
const [playerStatus, setPlayerStatus] = usePluginState(react, "all"); const [playerStatus, setPlayerStatus] = usePluginState(react, "all");
const [playerPanel, setPlayerPanel] = usePluginState<PlayerPanelState>(react, { kind: "closed", playerId: "" });
const [attributeDrafts, setAttributeDrafts] = usePluginState<AttributeDraft[]>(react, []);
const [squadSearch, setSquadSearch] = usePluginState(react, ""); const [squadSearch, setSquadSearch] = usePluginState(react, "");
const [selectedSquadId, setSelectedSquadId] = usePluginState(react, ""); const [selectedSquadId, setSelectedSquadId] = usePluginState(react, "");
const [activityStatus, setActivityStatus] = usePluginState(react, "all"); const [activityStatus, setActivityStatus] = usePluginState(react, "all");
@@ -116,16 +124,13 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData; const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) }, return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
e("div", { className: "panel-header" }, e("div", { className: "panel-header" },
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey))), e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey)))
e("div", { className: "console-row-actions" },
e("span", { className: "page-status" }, input.availability.available ? "通用数据/机器动作可用" : input.availability.reason ?? "等待 Run/Companion")
)
), ),
action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null, action.status !== "idle" ? e("p", { className: "page-status", "data-state": action.status }, action.message) : null,
state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null, state.status === "loading" ? e("p", { className: "page-status" }, "正在读取插件自有 SCUM 集合…") : null,
state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null, state.status === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, { state.status === "ready" ? renderSurfaceBody(e, pageKey, data, input, {
playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId, playerSearch, setPlayerSearch, playerStatus, setPlayerStatus, playerPanel, setPlayerPanel, attributeDrafts, setAttributeDrafts, squadSearch, setSquadSearch, selectedSquadId, setSelectedSquadId,
activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule, activityStatus, setActivityStatus, eventId, setEventId, eventName, setEventName, eventType, setEventType, eventSchedule, setEventSchedule,
eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal,
produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ, produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ,
@@ -139,6 +144,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
type ViewState = { type ViewState = {
playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>; playerSearch: string; setPlayerSearch: StateSetter<string>; playerStatus: string; setPlayerStatus: StateSetter<string>;
playerPanel: PlayerPanelState; setPlayerPanel: StateSetter<PlayerPanelState>; attributeDrafts: AttributeDraft[]; setAttributeDrafts: StateSetter<AttributeDraft[]>;
squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>; squadSearch: string; setSquadSearch: StateSetter<string>; selectedSquadId: string; setSelectedSquadId: StateSetter<string>;
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>; activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>; eventId: string; setEventId: StateSetter<string>; eventName: string; setEventName: StateSetter<string>;
@@ -158,33 +164,134 @@ type ViewState = {
function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) { function renderSurfaceBody(e: ReactLike["createElement"], pageKey: string, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
switch (pageKey) { switch (pageKey) {
case "players": return playersSurface(e, data, view); case "players": return playersSurface(e, data, input, view);
case "squads": return squadsSurface(e, data, view); case "squads": return squadsSurface(e, data, view);
case "live-map": return mapSurface(e, data, input, view); case "live-map": return mapSurface(e, data, input, view);
case "gifts": return giftsSurface(e, data, input, view); case "gifts": return giftsSurface(e, data, input, view);
case "workflows": case "workflows":
case "activity": return activitiesSurface(e, data, input, view); case "activity": return activitiesSurface(e, data, input, view);
default: return playersSurface(e, data, view); default: return playersSurface(e, data, input, view);
} }
} }
function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) { function playersSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const search = view.playerSearch.trim().toLowerCase(); const search = view.playerSearch.trim().toLowerCase();
const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "steamId", "squadName") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player))); const players = data.players.filter((player) => matchesText(player, search, "displayName", "playerName", "gamePlayerId", "playerId", "steamId", "squadName") && (view.playerStatus === "all" || (view.playerStatus === "online") === playerOnline(player)));
const selectedPlayer = data.players.find((player) => playerKey(player) === view.playerPanel.playerId);
return e("div", { className: "console-record-list" }, return e("div", { className: "console-record-list" },
statsStrip(e, [["用户", data.players.length], ["在线", data.players.filter(playerOnline).length], ["筛选结果", players.length], ["队伍成员", data.members.length]]),
e("div", { className: "console-row-actions" }, e("div", { className: "console-row-actions" },
e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }), e("input", { value: view.playerSearch, "aria-label": "搜索用户", placeholder: "名称 / Steam ID / 队伍", onChange: (event: InputEvent) => view.setPlayerSearch(inputValue(event)) }),
e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知")) e("select", { value: view.playerStatus, "aria-label": "在线状态", onChange: (event: InputEvent) => view.setPlayerStatus(inputValue(event)) }, e("option", { value: "all" }, "全部状态"), e("option", { value: "online" }, "在线"), e("option", { value: "offline" }, "离线/未知"))
), ),
players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" }, e("div", { className: "provider-table-wrap" },
e("div", { className: "console-record-head" }, e("strong", null, textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户"), e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知")), e("table", { className: "resource-table scum-user-table", style: { minWidth: "1180px" } },
e("div", { className: "console-record-meta" }, e("span", null, `Steam ${textField(player, "steamId", "providerId") || "unknown"}`), e("span", null, `Profile ${textField(player, "userProfileId", "profileId") || "unknown"}`), e("span", null, `队伍 ${textField(player, "squadName", "squadId") || "未加入"}`), e("span", null, freshness(player))), e("caption", { className: "provider-id" }, "SCUM 用户真实记录"),
e("span", { className: "provider-id" }, `Fame ${numField(player, "famePoints")} · Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")} · ${coords(positionOf(player))}`) e("thead", null, e("tr", null, ["用户名", "Steam", "队伍", "状态", "上次登录", "登录 IP", "最后活动", "概况", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
)) : e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。") e("tbody", null, players.length ? players.slice(0, 500).map((player, index) => playerTableRow(e, player, index, data, input, view)) : e("tr", null, e("td", { colSpan: 9 }, e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。"))))
)
),
selectedPlayer && view.playerPanel.kind !== "closed" ? playerDrawer(e, selectedPlayer, data, input, view) : null
); );
} }
function playerTableRow(e: ReactLike["createElement"], player: RecordMap, index: number, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const name = textField(player, "displayName", "playerName", "name") || textField(player, "gamePlayerId", "playerId", "steamId") || "未知用户";
const steamId = textField(player, "steamId", "providerId") || "未同步";
const open = (kind: PlayerPanelKind) => { view.setPlayerPanel({ kind, playerId: playerKey(player) }); if (kind === "attributes") view.setAttributeDrafts(playerAttributeDrafts(player)); };
return e("tr", { key: idOf(player, `player-${index}`) },
e("td", null, e("strong", null, name), e("span", { className: "provider-id" }, `Profile ${textField(player, "userProfileId", "profileId") || "未同步"}`)),
e("td", null, e("code", null, steamId)),
e("td", null, e("span", null, textField(player, "squadName", "squadId") || "未加入"), e("span", { className: "provider-id" }, textField(player, "squadId") ? `ID ${textField(player, "squadId")}` : "")),
e("td", null, e("span", { className: `status-pill ${playerOnline(player) ? "status-active" : "status-disabled"}` }, playerOnline(player) ? "在线" : "离线/未知"), e("span", { className: "provider-id" }, numField(player, "pingMs") === "--" ? "" : `${numField(player, "pingMs")} ms`)),
e("td", null, userDateField(player, "lastLoginTime", "lastLoginAt", "lastLoginObservedAt")),
e("td", null, e("span", null, textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") || "未同步"), e("span", { className: "provider-id" }, textField(player, "lastLoginIp", "loginIp", "ipAddress", "lastIp") ? "日志同步" : "等待日志字段")),
e("td", null, userDateField(player, "lastSeenAt", "lastLoginObservedAt", "onlineObservedAt", "updatedAt")),
e("td", null, e("span", null, `Fame ${numField(player, "famePoints")}`), e("span", { className: "provider-id" }, `Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")}`)),
e("td", { className: "provider-actions-cell" }, playerActionMenu(e, player, view, open))
);
}
function playerActionMenu(e: ReactLike["createElement"], player: RecordMap, view: ViewState, open: (kind: PlayerPanelKind) => void) {
return e("details", { className: "provider-actions-cell" },
e("summary", { className: "icon-command", "aria-label": `打开${textField(player, "displayName", "playerName") || "用户"}操作菜单` }, "操作"),
e("div", { className: "inline-action-menu", role: "menu", "aria-label": "用户操作" },
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("attributes") }, "编辑属性"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("gifts") }, "礼包状态"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("items") }, "他的物品"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("history") }, "登录历史"),
e("button", { type: "button", className: "runtime-action-item", role: "menuitem", onClick: () => open("trajectory") }, "用户轨迹")
)
);
}
function playerDrawer(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const kind = view.playerPanel.kind;
const name = textField(player, "displayName", "playerName", "name") || playerKey(player);
const close = () => view.setPlayerPanel({ kind: "closed", playerId: "" });
const content = kind === "attributes" ? playerAttributesPanel(e, player, input, view) : kind === "gifts" ? playerGiftPanel(e, player, data, input, view) : kind === "items" ? playerItemsPanel(e, player) : kind === "history" ? playerHistoryPanel(e, player, data) : playerTrajectoryPanel(e, player, data);
const title = kind === "attributes" ? "编辑属性" : kind === "gifts" ? "礼包状态" : kind === "items" ? "他的物品" : kind === "history" ? "登录历史" : "用户轨迹";
return e("div", { className: "confirm-backdrop", role: "presentation", onClick: close },
e("aside", { className: "drawer-panel", role: "dialog", "aria-modal": "true", "aria-label": `${name} / ${title}`, onClick: (event: InputEvent) => event.stopPropagation?.() },
e("div", { className: "panel-header" }, e("div", null, e("h2", null, title), e("span", { className: "provider-id" }, `${name} · Steam ${textField(player, "steamId", "providerId") || "未同步"}`)), e("button", { type: "button", className: "drawer-close", onClick: close }, "关闭")),
content
)
);
}
function playerAttributesPanel(e: ReactLike["createElement"], player: RecordMap, input: SCUMPageContext, view: ViewState) {
const preview = playerAttributeSqlPreview(view.attributeDrafts);
const canSubmit = view.attributeDrafts.some((draft) => draft.after.trim() && draft.after.trim() !== draft.before.trim()) && view.attributeDrafts.every((draft) => !draft.after.trim() || Number.isFinite(Number(draft.after)));
const save = () => runAction(view.setAction, "正在生成 SQL 并提交到 Run…", async () => { await queuePlayerAttributePatch(input.workspaceActions ?? {}, player, view.attributeDrafts); view.refresh(); return "SQL 已进入平台到 Run 的执行队列。"; });
return e("div", { className: "console-record-list" },
e("p", { className: "dialog-description" }, "快捷项会生成 SCUM.db SQL,并通过平台 remote.access.request 提交给 Run 执行;当前值未同步也可以直接提交。"),
e("div", { className: "provider-form" },
e("div", { className: "form-grid" }, view.attributeDrafts.map((draft) => e("label", { key: draft.fieldKey }, `${draft.label}(当前 ${draft.before || "未同步"}`, e("input", { type: "number", value: draft.after, "aria-label": `${draft.label}目标值`, onChange: (event: InputEvent) => view.setAttributeDrafts((previous) => previous.map((candidate) => candidate.fieldKey === draft.fieldKey ? { ...candidate, after: inputValue(event) } : candidate)) })))
)
),
e("div", { className: "diff-view", "aria-label": "SQL 预览" }, e("strong", null, "SQL 预览"), e("code", null, preview)),
e("p", { className: "field-help" }, "执行任务使用 remote.run.db.sqlite.executeSQL 文本会随任务进入 Run 队列。"),
e("div", { className: "confirm-actions" }, e("button", { type: "button", className: "drawer-close", onClick: () => view.setPlayerPanel({ kind: "closed", playerId: "" }) }, "取消"), e("button", { type: "button", className: "primary-command", disabled: !canSubmit || !input.workspaceActions?.dispatch, onClick: save }, "生成并执行"))
);
}
function playerGiftPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData, input: SCUMPageContext, view: ViewState) {
const claims = playerRecords(data.giftClaims, player);
const pending = playerRecords(data.pendingGifts, player);
const deliveries = playerRecords(data.giftDeliveries, player);
const reset = (row: RecordMap, mode: "claim" | "pending") => runAction(view.setAction, "正在重置礼包状态…", async () => { if (mode === "claim") await resetGiftClaim(input.workspaceActions ?? {}, row); else await resetPendingGift(input.workspaceActions ?? {}, row); view.refresh(); return "礼包状态已重置。"; });
return e("div", { className: "console-record-list" },
e("p", { className: "dialog-description" }, "状态来自插件礼包集合。重置会让该记录回到可领取状态,不会生成礼包定义或样例记录。"),
giftStatusRecord(e, "已领取", claims, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "claim") }, "重置状态")),
giftStatusRecord(e, "待领取", pending, (row) => e("button", { type: "button", className: "runtime-action-item", disabled: !input.workspaceActions?.pluginData, onClick: () => reset(row, "pending") }, "重置状态")),
giftStatusRecord(e, "发放记录", deliveries)
);
}
function giftStatusRecord(e: ReactLike["createElement"], title: string, rows: RecordMap[], action?: (row: RecordMap) => unknown) {
return e("article", { className: "console-module" }, e("div", { className: "panel-header" }, e("h2", null, title), e("span", { className: "page-status" }, `${rows.length}`)), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `${title}-${index}`), className: "console-row" }, e("span", null, textField(row, "giftName", "giftCode", "giftType") || "礼包未命名"), e("strong", null, textField(row, "status") || "未知"), e("strong", null, dateField(row, "claimedAt", "receivedAt", "deliveredAt", "createdAt")), action ? e("span", { className: "console-row-actions" }, action(row)) : null)) : e("p", { className: "page-status" }, "暂无该用户的真实礼包记录。")));
}
function playerItemsPanel(e: ReactLike["createElement"], player: RecordMap) {
const items = field(player, "items", "inventory", "inventoryItems");
const rows = Array.isArray(items) ? items : [];
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "物品清单只展示插件已同步到用户记录的内容。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((item, index) => e("div", { key: `item-${index}`, className: "console-row" }, e("span", null, isRecord(item) ? textField(item, "name", "label", "itemId", "className") || "未命名物品" : String(item)), e("strong", null, isRecord(item) ? `× ${numField(item, "quantity", "count")}` : ""))) : e("p", { className: "page-status" }, "没有该用户的真实物品记录,等待插件同步。")));
}
function playerHistoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
const rows = playerRecords(data.activityEvents, player).filter((row) => ["login", "logout", "scum.login", "scum.logout"].includes(textField(row, "eventType", "type").toLowerCase()));
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "登录历史来自插件日志同步事件;网络信息按插件声明字段展示。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `history-${index}`), className: "console-row" }, e("span", null, textField(row, "eventType", "type") || "登录事件"), e("strong", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, textField(row, "loginIp", "ipAddress", "lastIp") || "IP 未同步"))) : e("p", { className: "page-status" }, "没有该用户的真实登录历史。")));
}
function playerTrajectoryPanel(e: ReactLike["createElement"], player: RecordMap, data: SCUMSurfaceData) {
const rows = playerRecords(data.activityEvents, player).filter((row) => hasCoordinates(positionOf(row)));
return e("div", { className: "console-record-list" }, e("p", { className: "dialog-description" }, "用户轨迹只展示插件声明并已同步的位置事件,不从机器文件或 SCUM.db 外部猜测。"), e("div", { className: "console-row-list" }, rows.length ? rows.map((row, index) => e("div", { key: idOf(row, `trajectory-${index}`), className: "console-row" }, e("span", null, dateField(row, "occurredAt", "observedAt", "createdAt")), e("strong", null, coords(positionOf(row))), e("strong", null, textField(row, "source") || "plugin log"))) : e("p", { className: "page-status" }, "没有该用户的真实轨迹记录。")));
}
function playerRecords(rows: RecordMap[], player: RecordMap): RecordMap[] { const identities = playerIdentities(player); return rows.filter((row) => identities.includes(textField(row, "steamId", "playerId", "gamePlayerId", "userProfileId", "profileId"))); }
function playerIdentities(player: RecordMap): string[] { return ["steamId", "gamePlayerId", "playerId", "userProfileId", "profileId", "id"].map((key) => textField(player, key)).filter(Boolean); }
function playerKey(player: RecordMap): string { return textField(player, "steamId", "gamePlayerId", "playerId", "userProfileId", "id", "_recordKey"); }
function userDateField(row: RecordMap | undefined, ...keys: string[]): string { return textField(row, ...keys) ? dateField(row, ...keys) : "未同步"; }
function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) { function squadsSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, view: ViewState) {
const search = view.squadSearch.trim().toLowerCase(); const search = view.squadSearch.trim().toLowerCase();
const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId")); const squads = data.squads.filter((squad) => matchesText(squad, search, "name", "squadId", "leaderProfileId"));
@@ -10,9 +10,9 @@ export const configurationCatalog: readonly SCUMConfigField[] = [
{ key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" } { key: "welcome-message", fileKey: "scum-server-settings", configKey: "WelcomeMessage", label: "欢迎消息", description: "登录成功后由已声明的服务器扩展显示给玩家。", control: "text", defaultValue: "", restartImpact: "none" }
]; ];
export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }]; export const vehicleSpawnCatalog: readonly SCUMVehicleSpawnOption[] = [{ code: "BPC_Laika_C", label: "Laika" }, { code: "BPC_WolfsWagen_C", label: "WolfsWagen" }];
export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }]; export const stateFieldCatalog: readonly Omit<SCUMStateField, "value" | "editable" | "reason">[] = [{ key: "skills.running", label: "跑步技能", minimum: 0, maximum: 1000000 }, { key: "attributes.strength", label: "力量属性", minimum: 1, maximum: 8 }, { key: "attributes.stamina", label: "体力", minimum: 0, maximum: 100000 }, { key: "attributes.dexterity", label: "敏捷", minimum: 0, maximum: 100000 }, { key: "attributes.intelligence", label: "智力", minimum: 0, maximum: 100000 }];
export function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); } export function supportsStateField(field: string): boolean { return /^[A-Za-z0-9_.:-]{1,120}$/.test(field); }
export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; } export function featureUnavailable(reason: string): SCUMFeatureAvailability { return { feature: "configuration", available: false, reason }; }
export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在插件目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; } export function validateConfigPatch(patch: SCUMConfigPatch): string | null { if (!patch.idempotencyKey.trim() || !patch.reason.trim() || !patch.changes.length) return "配置修改必须包含原因、幂等键和至少一项变更。"; for (const change of patch.changes) { const field = configurationCatalog.find((candidate) => candidate.key === change.key); if (!field) return `字段 ${change.key} 不在插件目录中。`; if (!change.value.trim()) return `字段 ${field.label} 不能为空。`; if ((field.control === "number" || field.control === "port") && (!Number.isInteger(Number(change.value)) || (field.minimum !== undefined && Number(change.value) < field.minimum) || (field.maximum !== undefined && Number(change.value) > field.maximum))) return `字段 ${field.label} 超出允许范围。`; } return null; }
export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { const definition = stateFieldCatalog.find((candidate) => candidate.key === field.fieldKey); if (!definition) return `字段 ${field.fieldKey} 未在插件运行时目录中声明`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after) || field.after < definition.minimum || field.after > definition.maximum) return `字段 ${definition.label} 超出允许范围`; } return null; } export function validateStatePatch(fields: Array<{ fieldKey: string; before: number; after: number }>): string | null { if (!fields.length) return "状态修改至少需要一个字段。"; for (const field of fields) { if (!supportsStateField(field.fieldKey)) return `字段 ${field.fieldKey} 格式无效`; if (!Number.isFinite(field.before) || !Number.isFinite(field.after)) return `字段 ${field.fieldKey} 必须是数字`; } return null; }
export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在插件目录中声明。"; return null; } export function validateVehicleSpawn(spawn: SCUMVehicleSpawn): string | null { if (!/^[A-Za-z][A-Za-z0-9_]{2,63}$/.test(spawn.vehicleCode)) return "载具代码格式无效。"; if (!vehicleSpawnCatalog.some((candidate) => candidate.code === spawn.vehicleCode)) return "载具代码未在插件目录中声明。"; return null; }
@@ -71,6 +71,7 @@
"remote.run.files.read", "remote.run.files.read",
"remote.run.files.write", "remote.run.files.write",
"remote.run.db.sqlite.query", "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute",
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.logs.transfer", "remote.run.logs.transfer",
@@ -95,6 +96,7 @@
"remote.run.files.read", "remote.run.files.read",
"remote.run.files.write", "remote.run.files.write",
"remote.run.db.sqlite.query", "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute",
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.logs.transfer", "remote.run.logs.transfer",
@@ -193,15 +195,6 @@
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json", "resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
"timeoutSeconds": 120, "timeoutSeconds": 120,
"maxPayloadBytes": 4096 "maxPayloadBytes": 4096
},
{
"type": "game-state.patch",
"title": "Patch SCUM player state",
"permission": "server.game-client.maintenance",
"payloadSchemaRef": "schemas/bridge/game-state-patch.payload.schema.json",
"resultSchemaRef": "schemas/bridge/game-state-patch.result.schema.json",
"timeoutSeconds": 120,
"maxPayloadBytes": 4096
} }
], ],
"snapshots": [ "snapshots": [
@@ -485,14 +478,6 @@
"player.notify" "player.notify"
] ]
}, },
{
"key": "state.patch",
"title": "SCUM player state patch",
"permission": "server.game-client.maintenance",
"requiredHandlers": [
"game-state.patch"
]
},
{ {
"key": "vehicle.spawn", "key": "vehicle.spawn",
"title": "SCUM catalogued vehicle spawn", "title": "SCUM catalogued vehicle spawn",
@@ -522,8 +507,7 @@
"scum.positions" "scum.positions"
], ],
"featureKeys": [ "featureKeys": [
"player.intelligence", "player.intelligence"
"state.patch"
] ]
}, },
{ {
@@ -898,8 +882,7 @@
"remote.access.request" "remote.access.request"
], ],
"featureKeys": [ "featureKeys": [
"player.intelligence", "player.intelligence"
"state.patch"
] ]
}, },
{ {
@@ -1357,7 +1340,8 @@
"kind": "sqlite", "kind": "sqlite",
"targetKey": "scum-database", "targetKey": "scum-database",
"capabilities": [ "capabilities": [
"remote.run.db.sqlite.query" "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute"
] ]
}, },
{ {
@@ -9,6 +9,6 @@
"expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "expectedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"reason": { "type": "string", "minLength": 4, "maxLength": 240 }, "reason": { "type": "string", "minLength": 4, "maxLength": 240 },
"changes": { "type": "array", "minItems": 1, "maxItems": 8, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["fieldKey", "before", "after"], "properties": { "fieldKey": { "enum": ["skills.running", "attributes.strength"] }, "before": { "type": "number", "minimum": 0, "maximum": 10 }, "after": { "type": "number", "minimum": 0, "maximum": 10 } } } } "changes": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "type": "object", "additionalProperties": false, "required": ["fieldKey", "before", "after"], "properties": { "fieldKey": { "type": "string", "minLength": 1, "maxLength": 120, "pattern": "^[A-Za-z0-9_.:-]+$" }, "before": { "type": "number" }, "after": { "type": "number" } } } }
} }
} }
@@ -7,7 +7,7 @@
"properties": { "properties": {
"status": { "enum": ["confirmed", "rejected", "failed"] }, "status": { "enum": ["confirmed", "rejected", "failed"] },
"confirmedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "confirmedStateVersion": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"confirmedFields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } }, "confirmedFields": { "type": "object", "additionalProperties": false, "patternProperties": { "^[A-Za-z0-9_.:-]+$": { "type": "number" } } },
"message": { "type": "string", "maxLength": 200 } "message": { "type": "string", "maxLength": 200 }
} }
} }
@@ -10,6 +10,6 @@
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" }, "safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
"maintenanceVerified": { "type": "boolean" }, "maintenanceVerified": { "type": "boolean" },
"playerOnline": { "type": "boolean" }, "playerOnline": { "type": "boolean" },
"fields": { "type": "object", "additionalProperties": false, "required": ["skills.running", "attributes.strength"], "properties": { "skills.running": { "type": "number", "minimum": 0, "maximum": 10 }, "attributes.strength": { "type": "number", "minimum": 0, "maximum": 10 } } } "fields": { "type": "object", "additionalProperties": false, "patternProperties": { "^[A-Za-z0-9_.:-]+$": { "type": "number" } } }
} }
} }
@@ -449,7 +449,9 @@
"remote.run.process.start", "remote.run.process.start",
"remote.run.process.stop", "remote.run.process.stop",
"remote.run.db.mysql.query", "remote.run.db.mysql.query",
"remote.run.db.mysql.execute",
"remote.run.db.sqlite.query", "remote.run.db.sqlite.query",
"remote.run.db.sqlite.execute",
"remote.run.logs.transfer", "remote.run.logs.transfer",
"remote.run.rcon.command", "remote.run.rcon.command",
"remote.run.program.command", "remote.run.program.command",
+2
View File
@@ -43,7 +43,9 @@ export type RunCapability =
| "remote.run.process.start" | "remote.run.process.start"
| "remote.run.process.stop" | "remote.run.process.stop"
| "remote.run.db.mysql.query" | "remote.run.db.mysql.query"
| "remote.run.db.mysql.execute"
| "remote.run.db.sqlite.query" | "remote.run.db.sqlite.query"
| "remote.run.db.sqlite.execute"
| "remote.run.logs.transfer" | "remote.run.logs.transfer"
| "remote.run.rcon.command" | "remote.run.rcon.command"
| "remote.run.program.command" | "remote.run.program.command"
+7 -7
View File
@@ -275,7 +275,7 @@ describe("plugin manifest validation", () => {
expect(local?.capabilities).toContain("remote.run.rcon.command"); expect(local?.capabilities).toContain("remote.run.rcon.command");
expect(local?.transportKeys).toContain("scum-management"); expect(local?.transportKeys).toContain("scum-management");
expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([ expect(manifest.runtimeProfiles?.transportProfiles).toEqual(expect.arrayContaining([
expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query"]) }), expect.objectContaining({ key: "scum-database", kind: "sqlite", capabilities: expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]) }),
expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }), expect.objectContaining({ key: "scum-management", kind: "rcon", capabilities: ["remote.run.rcon.command"] }),
expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] }) expect.objectContaining({ key: "scum-program", kind: "program", capabilities: ["remote.run.program.command"] })
])); ]));
@@ -493,8 +493,7 @@ describe("plugin manifest validation", () => {
"vehicle.spawn", "vehicle.spawn",
"event.start", "event.start",
"restart.prepare", "restart.prepare",
"maintenance.prepare", "maintenance.prepare"
"game-state.patch"
])); ]));
expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"])); expect(manifest.gameClientBridge.snapshots.map((snapshot) => snapshot.type)).toEqual(expect.arrayContaining(["companion.health", "online.sessions", "players", "squads", "vehicles", "flags"]));
expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"])); expect(manifest.gameClientBridge.pages.map((page) => page.pageKey)).toEqual(expect.arrayContaining(["players", "squads", "live-map", "gifts", "workflows"]));
@@ -533,8 +532,7 @@ describe("plugin manifest validation", () => {
"vehicle.spawn": { permission: "server.game-client.command" }, "vehicle.spawn": { permission: "server.game-client.command" },
"event.start": { permission: "server.game-client.command" }, "event.start": { permission: "server.game-client.command" },
"restart.prepare": { permission: "server.game-client.maintenance" }, "restart.prepare": { permission: "server.game-client.maintenance" },
"maintenance.prepare": { permission: "server.game-client.maintenance" }, "maintenance.prepare": { permission: "server.game-client.maintenance" }
"game-state.patch": { permission: "server.game-client.maintenance" }
} as const; } as const;
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected))); expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining(Object.keys(expected)));
@@ -627,7 +625,7 @@ describe("plugin manifest validation", () => {
const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as { const manifest = JSON.parse(fs.readFileSync(path.join(pluginDir, "manifest.json"), "utf8")) as {
permissions: string[]; permissions: string[];
capabilities: string[]; capabilities: string[];
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] }; remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[]; rcon?: boolean };
gameClientBridge: { gameClientBridge: {
queryTemplates: Array<{ queryTemplates: Array<{
key: string; key: string;
@@ -664,12 +662,14 @@ describe("plugin manifest validation", () => {
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template])); const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys)); expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
expect(manifest.capabilities).toContain("remote.run.db.sqlite.query"); expect(manifest.capabilities).toContain("remote.run.db.sqlite.query");
expect(manifest.capabilities).toContain("remote.run.db.sqlite.execute");
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query"); expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.query");
expect(manifest.remoteAccess?.runCapabilities).toContain("remote.run.db.sqlite.execute");
expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite"); expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite");
expect(manifest.remoteAccess?.rcon).toBe(true); expect(manifest.remoteAccess?.rcon).toBe(true);
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database"); const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "scum-database");
expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" }); expect(sqliteTransport).toMatchObject({ kind: "sqlite", targetKey: "scum-database" });
expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query"])); expect(sqliteTransport?.capabilities).toEqual(expect.arrayContaining(["remote.run.db.sqlite.query", "remote.run.db.sqlite.execute"]));
for (const key of expectedKeys) { for (const key of expectedKeys) {
const template = templatesByKey.get(key)!; const template = templatesByKey.get(key)!;
expect(template.engine).toBe("sqlite"); expect(template.engine).toBe("sqlite");
+21 -8
View File
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js"; import { migrateConfigurationRecord, migrateGiftGrantRecord, migratePlayerProfileRecord, migratePlayerRecord, migrateStatePatchRecord, migrateTrajectoryHistoryRecord, migrateTrajectoryRecord, migrationStatus } from "../examples/scum-server-plugin/features/migration.js";
import { createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, queueGiftDelivery, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js"; import { buildPlayerAttributeMutation, createGiftDelivery, deleteGiftDefinition, loadSCUMSurface, mergePlayerSnapshots, parseGiftCommands, parseGiftItems, playerAttributeDrafts, playerAttributeSqlPreview, queueGiftDelivery, queuePlayerAttributePatch, resetGiftClaim, resetPendingGift, resolveMapBounds, saveEventProduce, saveGiftDefinition, saveMapSettings, scumCollections, startEvent, type RecordMap, type SCUMSurfaceData, type SCUMWorkspaceActions } from "../examples/scum-server-plugin/features/page-data.js";
import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js"; import { collectMapPoints, mapPointStyle } from "../examples/scum-server-plugin/features/page.js";
import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js"; import { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.js";
import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js"; import { configurationCatalog, validateConfigPatch, validateStatePatch, validateVehicleSpawn, vehicleSpawnCatalog } from "../examples/scum-server-plugin/features/schemas.js";
@@ -41,7 +41,7 @@ describe("SCUM plugin feature module", () => {
expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message"); expect(configurationCatalog.map((field) => field.key)).toContain("welcome-message");
expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围"); expect(validateConfigPatch({ reason: "adjust capacity", idempotencyKey: "cfg-1", changes: [{ key: "max-players", value: "129" }] })).toContain("超出允许范围");
expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull(); expect(validateStatePatch([{ fieldKey: "skills.running", before: 1, after: 2 }])).toBeNull();
expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toContain("插件运行时目录"); expect(validateStatePatch([{ fieldKey: "unknown", before: 1, after: 2 }])).toBeNull();
expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]); expect(vehicleSpawnCatalog.map((vehicle) => vehicle.code)).toEqual(["BPC_Laika_C", "BPC_WolfsWagen_C"]);
expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull(); expect(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效"); expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
@@ -176,14 +176,27 @@ describe("SCUM plugin feature module", () => {
const view = renderAndCollect(); const view = renderAndCollect();
expect(view.nodes).toContain("section:用户管理"); expect(view.nodes).toContain("section:用户管理");
expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步"); expect(view.texts.join("\n")).toContain("插件声明的 SCUM.db 查询与日志同步");
expect(view.texts).toContain("通用数据/机器动作可用"); expect(view.texts).toEqual(expect.arrayContaining(["用户名", "Steam", "队伍", "上次登录", "登录 IP", "操作"]));
expect(view.texts).not.toContain("筛选结果");
expect(view.texts).not.toContain("通用数据/机器动作可用");
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"])); expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["同步 SCUM.db", "重新读取"]));
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["编辑属性", "礼包状态", "他的物品", "登录历史", "用户轨迹"]));
expect(view.inputs.map((input) => input.label)).toContain("搜索用户"); expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
expect(view.texts).toContain("Mira"); expect(view.texts).toContain("Mira");
expect(view.texts.join("\n")).toContain("Steam 76561198000000001"); expect(view.texts).toContain("76561198000000001");
expect(view.texts.join("\n")).toContain("Fame 42"); expect(view.texts.join("\n")).toContain("Fame 42");
}); });
it("prepares player attribute SQL execution through platform-to-Run remote access", async () => {
const player = { steamId: "76561198000000001", displayName: "Mira", stateVersion: "state-1", stamina: 12, dexterity: 4, intelligence: 8 };
const drafts = playerAttributeDrafts(player).map((draft) => draft.fieldKey === "stamina" ? { ...draft, after: "855" } : draft);
expect(playerAttributeSqlPreview(drafts)).toContain("UPDATE prisoner SET stamina = 855 WHERE id = :playerId;");
expect(buildPlayerAttributeMutation(player, drafts)).toMatchObject({ playerId: "76561198000000001", sqlText: expect.stringContaining("UPDATE prisoner SET stamina = 855"), changes: [{ fieldKey: "stamina", before: 12, after: 855 }] });
const actions = { dispatch: vi.fn<NonNullable<SCUMWorkspaceActions["dispatch"]>>(async () => ({ status: "queued", result: { jobId: "job-1" } })) };
await queuePlayerAttributePatch(actions, player, drafts);
expect(actions.dispatch).toHaveBeenCalledWith(expect.objectContaining({ action: "remote.access.request", payload: expect.objectContaining({ capability: "remote.run.db.sqlite.execute", declarationKey: "scum-database", "input.sqlText": expect.stringContaining("UPDATE prisoner SET stamina = 855") }) }));
});
it("does not invent users when the collection is empty", () => { it("does not invent users when the collection is empty", () => {
const view = renderAndCollect({ data: { ...surfaceData, players: [] } }); const view = renderAndCollect({ data: { ...surfaceData, players: [] } });
expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录"); expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录");
@@ -252,11 +265,11 @@ describe("SCUM plugin feature module", () => {
expect(pageSource).not.toContain("visible.slice(0, 240)"); expect(pageSource).not.toContain("visible.slice(0, 240)");
}); });
it("contains no specialized host callbacks, raw SQL, machine paths, or fake-data branches", () => { it("contains no specialized host callbacks, machine paths, or fake-data branches", () => {
const source = `${pageSource}\n${dataClientSource}`; const source = `${pageSource}\n${dataClientSource}`;
for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "SELECT ", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden); for (const forbidden of ["listSCUM", "gameGift", "createSCUMOperation", "createSCUMWorkflow", "C:/", "/Users/", "hostPath", "sampleCoordinates", "samplePlayers"]) expect(source).not.toContain(forbidden);
expect(source).toContain("pluginData"); expect(source).toContain("pluginData");
expect(source).not.toContain("remote.access.request"); expect(source).toContain("remote.access.request");
expect(source).not.toContain("input.templateKey"); expect(source).not.toContain("input.templateKey");
expect(source).not.toContain("requestSCUMPageQueries"); expect(source).not.toContain("requestSCUMPageQueries");
expect(pageSource).toContain("setInterval(refresh, 3000)"); expect(pageSource).toContain("setInterval(refresh, 3000)");
@@ -309,7 +322,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
return [value, () => undefined]; return [value, () => undefined];
} }
}; };
const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions() }; const actions: SCUMWorkspaceActions = { pluginData: pluginDataActions(), gameClient: gameClientActions(), dispatch: async () => ({ status: "queued", result: { jobId: "job-1" } }) };
renderPluginPage(react, { renderPluginPage(react, {
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" }, page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] }, context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },