Compare commits
10
Commits
504387c657
...
725b71b6b6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
725b71b6b6 | ||
|
|
199fa8b41d | ||
|
|
7e10c5685a | ||
|
|
7e15cb09b4 | ||
|
|
d4e3f68032 | ||
|
|
3005e0510c | ||
|
|
49b90ebd29 | ||
|
|
3521a7f579 | ||
|
|
d6ee482957 | ||
|
|
3e00737267 |
@@ -15,13 +15,14 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
defaultLogEventHistoryLimit = 100
|
||||
defaultLogEventHistoryLimit = 0
|
||||
maxLogEventHistoryLimit = 10000
|
||||
logEventHeartbeatInterval = 15 * time.Second
|
||||
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) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w, http.MethodGet)
|
||||
@@ -221,6 +222,9 @@ func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInsta
|
||||
return nil, err
|
||||
}
|
||||
for _, stream := range active.streams {
|
||||
if historyLimit == 0 {
|
||||
emittedThrough[stream.ID] = stream.LatestSeq
|
||||
}
|
||||
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
router := newTestRouter()
|
||||
hello := createLogIngestAPIFixtures(t, router)
|
||||
|
||||
@@ -1290,7 +1290,7 @@ func (h *coreHandlers) serverInstanceConfigApprove(w http.ResponseWriter, r *htt
|
||||
|
||||
// serverFilesWorkspace godoc
|
||||
// @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
|
||||
// @Produce json
|
||||
// @Param id path string true "Server instance ID"
|
||||
|
||||
@@ -151,7 +151,6 @@ func TestMetricsAndConfigReadAPIAreSafeAndRoleScoped(t *testing.T) {
|
||||
|
||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", validGamePluginRequest())
|
||||
endpointRequest := validRunEndpointRequest()
|
||||
endpointRequest.Capabilities = append(endpointRequest.Capabilities, domain.JobCapabilityFilesList)
|
||||
postJSON[dto.RunEndpointResponse](t, router, "/api/v1/run/endpoints", endpointRequest)
|
||||
instance := postJSONWithAuth[dto.ServerInstanceResponse](t, router, "/api/v1/server-instances", dto.ServerInstanceCreateRequest{
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
list := getJSONWithAuth[dto.ServerFileListResponse](t, router, "/api/v1/server-instances/"+instance.ID+"/files/list?directoryKey=scum-config", adminSession)
|
||||
foundSettings := false
|
||||
for _, entry := range list.Entries {
|
||||
if entry.LogicalKey == "scum-server-settings" && entry.Editable && entry.Downloadable {
|
||||
foundSettings = true
|
||||
if list.State != "declared" || list.DirectoryKey != "scum-config" || len(list.Entries) != 2 || list.Entries[1].Name != "ServerSettings.ini" || !strings.Contains(list.Reason, "服务器文件缓存") {
|
||||
t.Fatalf("expected declared SCUM file list, got %+v", list)
|
||||
}
|
||||
}
|
||||
if list.State != "declared" || !foundSettings {
|
||||
t.Fatalf("expected declared file list, got %+v", list)
|
||||
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)
|
||||
assertStatus(t, refreshRecorder, http.StatusAccepted)
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
releaseBuilds := make(chan struct{})
|
||||
t.Cleanup(func() { close(releaseBuilds) })
|
||||
|
||||
@@ -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.
|
||||
- `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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -1212,7 +1212,9 @@ const (
|
||||
JobCapabilityRemoteRunProcessStart = "remote.run.process.start"
|
||||
JobCapabilityRemoteRunProcessStop = "remote.run.process.stop"
|
||||
JobCapabilityRemoteRunDBMySQLQuery = "remote.run.db.mysql.query"
|
||||
JobCapabilityRemoteRunDBMySQLExecute = "remote.run.db.mysql.execute"
|
||||
JobCapabilityRemoteRunDBSQLiteQuery = "remote.run.db.sqlite.query"
|
||||
JobCapabilityRemoteRunDBSQLiteExecute = "remote.run.db.sqlite.execute"
|
||||
JobCapabilityRemoteRunLogsTransfer = "remote.run.logs.transfer"
|
||||
JobCapabilityRemoteRunRCONCommand = "remote.run.rcon.command"
|
||||
JobCapabilityRemoteRunProgram = "remote.run.program.command"
|
||||
|
||||
@@ -43,9 +43,6 @@ func (svc *CoreService) ClaimRunJob(claim domain.RunJobClaim) (domain.RunJobClai
|
||||
if claim.Capacity.MaxJobs > 0 && claim.Capacity.RunningJobs >= claim.Capacity.MaxJobs {
|
||||
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})
|
||||
if err != nil {
|
||||
return domain.RunJobClaimResult{}, err
|
||||
@@ -614,7 +611,7 @@ func firstEligibleSupportedJob(jobs []domain.Job, capabilities []string, stamp t
|
||||
if !eligible || !job.CancelRequestedAt.IsZero() {
|
||||
continue
|
||||
}
|
||||
if len(capabilitySet) > 0 {
|
||||
if len(capabilitySet) > 0 && !isServerFileCapability(job.Capability) {
|
||||
if _, supported := capabilitySet[job.Capability]; !supported {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
svc, sessionToken := newRegisteredRunJobService(t)
|
||||
createQueuedRunJob(t, svc, "job-1", "idem-1")
|
||||
|
||||
@@ -119,7 +119,7 @@ func isRemoteAdapterCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
|
||||
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand:
|
||||
return true
|
||||
default:
|
||||
@@ -156,7 +156,7 @@ func remoteAdapterKindForCapability(capability string) domain.RemoteAdapterKind
|
||||
return domain.RemoteAdapterRunFile
|
||||
case domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop:
|
||||
return domain.RemoteAdapterRunProcess
|
||||
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery:
|
||||
case domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute:
|
||||
return domain.RemoteAdapterDatabase
|
||||
case domain.JobCapabilityRemoteRunRCONCommand:
|
||||
return domain.RemoteAdapterRCON
|
||||
|
||||
@@ -1935,6 +1935,9 @@ func (svc *CoreService) GetServerConfigForSession(sessionID string, serverInstan
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
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")) {
|
||||
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})
|
||||
if err != nil {
|
||||
return domain.DeclaredFileReadSnapshot{}, err
|
||||
@@ -1958,7 +1957,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
||||
var pending *domain.Job
|
||||
for i := range jobs {
|
||||
job := jobs[i]
|
||||
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != file.Key {
|
||||
if job.Capability != domain.JobCapabilityFilesRead || job.TargetKey != fileKey {
|
||||
continue
|
||||
}
|
||||
if job.State == domain.JobStateSucceeded && job.ExecutionResult.Kind == "file.read" {
|
||||
@@ -1973,7 +1972,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
||||
pending = ©
|
||||
}
|
||||
}
|
||||
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 {
|
||||
return domain.DeclaredFileReadSnapshot{
|
||||
ServerInstanceID: base.ServerInstanceID,
|
||||
@@ -1995,7 +1994,7 @@ func (svc *CoreService) GetDeclaredFileReadSnapshotForSession(sessionID string,
|
||||
return base, nil
|
||||
}
|
||||
base.State = "not-read"
|
||||
base.Reason = "尚未读取此声明文件。"
|
||||
base.Reason = "尚未读取此文件。"
|
||||
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") {
|
||||
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
|
||||
message := "file read queued"
|
||||
@@ -2761,6 +2754,9 @@ func (svc *CoreService) validateRunnableEndpoint(endpoint domain.RunEndpoint, ca
|
||||
if !svc.runEndpointHeartbeatCurrent(endpoint) {
|
||||
return validationError("run endpoint heartbeat is stale")
|
||||
}
|
||||
if isServerFileCapability(capability) {
|
||||
return nil
|
||||
}
|
||||
if len(validator.MissingCapabilities(endpoint.Capabilities, []string{capability})) > 0 {
|
||||
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 {
|
||||
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 nil
|
||||
|
||||
@@ -907,7 +907,7 @@ func TestCoreServiceConfigWriteAndFileDispatchAreScoped(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
|
||||
func TestPluginFileWorkspaceDoesNotConstrainServerFileDispatch(t *testing.T) {
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
plugin.FileWorkspace = scumTestFileWorkspace()
|
||||
@@ -946,16 +946,17 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
|
||||
if allowed.Job.TargetKey != "scum-server-settings" || allowed.Job.Capability != domain.JobCapabilityFilesRead {
|
||||
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,
|
||||
PluginID: plugin.ID,
|
||||
Operation: domain.FileOperationRead,
|
||||
Key: "logs/latest.log",
|
||||
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,
|
||||
PluginID: plugin.ID,
|
||||
Operation: domain.FileOperationWrite,
|
||||
@@ -963,8 +964,72 @@ func TestDeclaredPluginFileWorkspaceConstrainsFileDispatch(t *testing.T) {
|
||||
InputRef: "input://file-workspace/update",
|
||||
Content: "line",
|
||||
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") {
|
||||
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") {
|
||||
t.Fatalf("expected unknown logical key rejection, got %v", err)
|
||||
unknownSnapshot, err := svc.GetDeclaredFileReadSnapshotForSession(ownerSession, instance.ID, "logs/latest.log")
|
||||
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) {
|
||||
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)
|
||||
|
||||
result, err := svc.ExecutePluginBridgeAction(session, domain.PluginBridgeExecuteRequest{
|
||||
RequestID: "query-template-sql-rejected-1",
|
||||
RequestID: "sqlite-execute-1",
|
||||
PluginID: plugin.ID,
|
||||
RouteKey: "remote",
|
||||
ServerInstanceID: instance.ID,
|
||||
Action: domain.PluginBridgeActionRemoteAccessRequest,
|
||||
Payload: map[string]string{
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
"capability": domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
"declarationKey": "scum-db-read",
|
||||
"targetKey": "scum-db.player-lookup",
|
||||
"idempotencyKey": "query-template-sql-rejected-1",
|
||||
"input.templateKey": "players.by-id",
|
||||
"input.sqlText": "SELECT * FROM users",
|
||||
"idempotencyKey": "sqlite-execute-1",
|
||||
"input.mode": "execute",
|
||||
"input.sqlText": "UPDATE prisoner SET stamina = 855 WHERE id = 'steam-123';",
|
||||
},
|
||||
})
|
||||
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") {
|
||||
t.Fatalf("expected arbitrary SQL input rejection, got %+v", result)
|
||||
if result.Status != "queued" || result.Result["jobId"] == "" {
|
||||
t.Fatalf("expected queued sqlite execute job, got %+v", result)
|
||||
}
|
||||
jobs, listErr := svc.ListJobs(domain.JobFilter{ServerInstanceID: instance.ID})
|
||||
if listErr != nil {
|
||||
t.Fatalf("list jobs after arbitrary SQL rejection: %v", listErr)
|
||||
job, getErr := svc.store.Jobs().Get(result.Result["jobId"])
|
||||
if getErr != nil {
|
||||
t.Fatalf("get sqlite execute job: %v", getErr)
|
||||
}
|
||||
if len(jobs) != 0 {
|
||||
t.Fatalf("arbitrary SQL rejection created jobs: %+v", jobs)
|
||||
if job.Capability != domain.JobCapabilityRemoteRunDBSQLiteExecute || job.ExecutionInput.Inputs["sqlText"] == "" || job.ExecutionInput.Inputs["mode"] != "execute" {
|
||||
t.Fatalf("expected sqlite execute inputs, got %#v", job)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1895,7 +1961,8 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
svc := newTestCoreService()
|
||||
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||
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.Permissions.RemoteAccess = true
|
||||
plugin.BridgeActions = append(plugin.BridgeActions, string(domain.PluginBridgeActionRemoteAccessRequest))
|
||||
@@ -1908,14 +1975,14 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
})
|
||||
plugin.RemoteAccess = domain.GamePluginRemoteAccess{
|
||||
Methods: []string{"run"},
|
||||
RunCapabilities: []string{capability},
|
||||
RunCapabilities: []string{capability, executeCapability},
|
||||
DatabaseEngines: []string{"sqlite"},
|
||||
}
|
||||
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{
|
||||
Key: "scum-db-read",
|
||||
Kind: "sqlite",
|
||||
TargetKey: "scum-db.player-lookup",
|
||||
Capabilities: []string{capability},
|
||||
Capabilities: []string{capability, executeCapability},
|
||||
})
|
||||
plugin.GameClientBridge = domain.GameClientBridgeManifest{
|
||||
QueryTemplates: []domain.GameClientBridgeQueryTemplateDeclaration{
|
||||
@@ -1941,7 +2008,7 @@ func createSQLiteQueryBridgeFixture(t *testing.T) (*CoreService, domain.GamePlug
|
||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||
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 {
|
||||
t.Fatalf("update sqlite query endpoint fixture: %v", err)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
const (
|
||||
serverFileTransferChannel = "run-file-transfer"
|
||||
serverFileMaxInlineEditBytes = 64 * 1024
|
||||
serverFileDefaultDirectoryKey = "server-root"
|
||||
)
|
||||
|
||||
type serverFileContext struct {
|
||||
@@ -25,6 +26,7 @@ type serverFileContext struct {
|
||||
Instance domain.ServerInstance
|
||||
Plugin domain.GamePlugin
|
||||
Directory domain.PluginLogicalDirectory
|
||||
Workspace domain.PluginFileWorkspace
|
||||
Scope string
|
||||
}
|
||||
|
||||
@@ -54,10 +56,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve
|
||||
if err != nil {
|
||||
return domain.ServerFileWorkspaceView{}, err
|
||||
}
|
||||
workspace := domain.CopyPluginFileWorkspace(ctx.Plugin.FileWorkspace)
|
||||
if workspace.DefaultDirectoryKey == "" && len(workspace.Directories) > 0 {
|
||||
workspace.DefaultDirectoryKey = workspace.Directories[0].Key
|
||||
}
|
||||
workspace := domain.CopyPluginFileWorkspace(ctx.Workspace)
|
||||
view := domain.ServerFileWorkspaceView{
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
PluginID: ctx.Plugin.ID,
|
||||
@@ -65,7 +64,7 @@ func (svc *CoreService) GetServerFileWorkspaceForSession(sessionID string, serve
|
||||
Directories: workspace.Directories,
|
||||
Files: workspace.Files,
|
||||
ConfigFields: workspace.ConfigFields,
|
||||
DeclaredOnly: true,
|
||||
DeclaredOnly: false,
|
||||
RuntimeWorkspaceScope: svc.runtimeProfileScope(ctx.Instance.ID),
|
||||
Transfer: domain.ServerFileTransferPolicy{
|
||||
Channel: serverFileTransferChannel,
|
||||
@@ -100,17 +99,45 @@ func (svc *CoreService) ListServerFilesForSession(sessionID string, request doma
|
||||
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
|
||||
}
|
||||
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"
|
||||
reason := "展示插件声明的逻辑文件;点击刷新可请求 Run 返回实时目录。"
|
||||
if hasLatest && !isTerminalJobState(latest.State) {
|
||||
reason := "展示服务器文件缓存;打开文件标签时会自动读取 Run 实时目录。"
|
||||
if hasLatest {
|
||||
if !isTerminalJobState(latest.State) {
|
||||
state = "pending"
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
request = normalizeServerFileListRequest(request)
|
||||
if request.IdempotencyKey == "" {
|
||||
@@ -123,6 +150,22 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques
|
||||
if err != nil {
|
||||
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{
|
||||
ID: jobIDFromParts("job-file-list", request.ServerInstanceID, request.IdempotencyKey),
|
||||
ServerInstanceID: ctx.Instance.ID,
|
||||
@@ -142,7 +185,6 @@ func (svc *CoreService) RefreshServerFileListForSession(sessionID string, reques
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -172,9 +214,6 @@ func (svc *CoreService) UploadServerFileForSession(sessionID string, request dom
|
||||
if err != nil {
|
||||
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))
|
||||
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()}
|
||||
@@ -213,7 +252,7 @@ func (svc *CoreService) PrepareServerFileDownloadForSession(sessionID string, re
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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") {
|
||||
return serverFileContext{}, ErrForbidden
|
||||
}
|
||||
workspace := effectiveServerFileWorkspace(plugin)
|
||||
directory := domain.PluginLogicalDirectory{}
|
||||
if directoryKey != "" {
|
||||
var found bool
|
||||
for _, candidate := range plugin.FileWorkspace.Directories {
|
||||
for _, candidate := range workspace.Directories {
|
||||
if candidate.Key == directoryKey {
|
||||
directory = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found && len(plugin.FileWorkspace.Directories) > 0 {
|
||||
return serverFileContext{}, validationError("directoryKey must reference a plugin-declared directory")
|
||||
if !found && len(workspace.Directories) > 0 {
|
||||
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 {
|
||||
@@ -353,13 +408,13 @@ func serverFileEntriesFromDeclaredWorkspace(workspace domain.PluginFileWorkspace
|
||||
if directoryKey == "" || directory.Key == directoryKey {
|
||||
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 {
|
||||
if directoryKey != "" && file.DirectoryKey != directoryKey {
|
||||
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 {
|
||||
if entries[i].Kind == entries[j].Kind {
|
||||
@@ -390,10 +445,14 @@ func serverFileEntriesFromRunList(content string, fallbackDirectoryKey string, f
|
||||
directoryKey = fallbackDirectoryKey
|
||||
}
|
||||
relativePath := cleanServerFileRelativePath(entry.RelativePath)
|
||||
if relativePath == "" {
|
||||
if relativePath == "" && entry.Name != "" {
|
||||
relativePath = cleanServerFileRelativePath(path.Join(fallbackPath, entry.Name))
|
||||
} else if relativePath == "" {
|
||||
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
|
||||
}
|
||||
@@ -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) {
|
||||
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) {
|
||||
@@ -491,7 +577,7 @@ func fileRemark(file domain.PluginLogicalFile) string {
|
||||
if file.Editable {
|
||||
return "可编辑配置"
|
||||
}
|
||||
return "插件声明文件"
|
||||
return "文件"
|
||||
}
|
||||
|
||||
func serverFileDisplayName(workspace domain.PluginFileWorkspace, key string) string {
|
||||
|
||||
@@ -177,21 +177,10 @@ func (svc *CoreService) resolveSourceRCONDispatch(instance domain.ServerInstance
|
||||
if !strings.EqualFold(endpoint.Platform, "windows") || !strings.EqualFold(endpoint.Architecture, "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 {
|
||||
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)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
return sourceRCONTransportForCapability(profiles, profile, "", domain.JobCapabilityRemoteRunRCONCommand)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
stamp := fixedTime
|
||||
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) {
|
||||
return newSourceRCONFixtureWithRuntimeBinding(t, true)
|
||||
}
|
||||
|
||||
func newSourceRCONFixtureWithRuntimeBinding(t *testing.T, createBinding bool) (*CoreService, string, string, domain.ServerInstance) {
|
||||
t.Helper()
|
||||
svc := newCoreService(repo.NewMemoryStore(), func() time.Time { return fixedTime })
|
||||
capability := domain.JobCapabilityRemoteRunRCONCommand
|
||||
@@ -177,6 +240,7 @@ func newSourceRCONFixture(t *testing.T) (*CoreService, string, string, domain.Se
|
||||
if err != nil {
|
||||
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)
|
||||
if err != nil {
|
||||
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 {
|
||||
t.Fatalf("store RCON binding: %v", err)
|
||||
}
|
||||
}
|
||||
helloRequest := validRunControlHello()
|
||||
helloRequest.CapabilityReport.Capabilities = []string{capability}
|
||||
helloRequest.CapabilityReport.Fingerprint = "cap-source-rcon"
|
||||
|
||||
@@ -109,10 +109,14 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
|
||||
}
|
||||
var violations []string
|
||||
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")
|
||||
}
|
||||
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")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(value) {
|
||||
@@ -121,3 +125,20 @@ func validateRemoteAdapterInputs(field string, inputs map[string]string) []strin
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
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 }
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"browser.local/platform/domain"
|
||||
@@ -20,11 +21,11 @@ const (
|
||||
maxPluginDescriptionLength = 240
|
||||
maxPluginPageTitleLength = 40
|
||||
maxPluginBridgePayloadKeys = 16
|
||||
maxPluginBridgePayloadSize = 4096
|
||||
maxPluginBridgePayloadSize = 16 * 1024
|
||||
maxProgressMessageLength = 256
|
||||
maxServerConfigContentSize = 64 * 1024
|
||||
maxJobExecutionContentSize = 64 * 1024
|
||||
maxLogicalFileKeyLength = 160
|
||||
maxLogicalFileKeyLength = 1024
|
||||
maxProductionMessageLength = 320
|
||||
)
|
||||
|
||||
@@ -970,7 +971,11 @@ func ValidatePluginBridgeExecuteRequest(request domain.PluginBridgeExecuteReques
|
||||
if strings.TrimSpace(key) == "" || strings.TrimSpace(key) != key || len([]rune(key)) > 80 {
|
||||
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")
|
||||
}
|
||||
for _, reason := range unsafePluginStringReasons(key) {
|
||||
@@ -1265,6 +1270,13 @@ func ValidateServerInstanceDependenciesForCapabilities(instance domain.ServerIns
|
||||
violations = append(violations, "run endpoint must be online or degraded")
|
||||
}
|
||||
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 {
|
||||
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})
|
||||
}
|
||||
|
||||
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 {
|
||||
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")
|
||||
}
|
||||
for _, engine := range remote.DatabaseEngines {
|
||||
required := domain.JobCapabilityRemoteRunDBMySQLQuery
|
||||
queryCapability := domain.JobCapabilityRemoteRunDBMySQLQuery
|
||||
executeCapability := domain.JobCapabilityRemoteRunDBMySQLExecute
|
||||
if engine == "sqlite" {
|
||||
required = domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
queryCapability = domain.JobCapabilityRemoteRunDBSQLiteQuery
|
||||
executeCapability = domain.JobCapabilityRemoteRunDBSQLiteExecute
|
||||
}
|
||||
if !containsString(remote.RunCapabilities, required) {
|
||||
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s", field, required))
|
||||
if !containsString(remote.RunCapabilities, queryCapability) && !containsString(remote.RunCapabilities, executeCapability) {
|
||||
violations = append(violations, fmt.Sprintf("%s.databaseEngines requires %s or %s", field, queryCapability, executeCapability))
|
||||
}
|
||||
}
|
||||
return violations
|
||||
@@ -2341,7 +2365,7 @@ func validPluginRunCapability(capability string) bool {
|
||||
domain.JobCapabilityRemoteRsyncRead, domain.JobCapabilityRemoteRsyncWrite,
|
||||
domain.JobCapabilityRemoteRunFilesRead, domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunProcessStart, domain.JobCapabilityRemoteRunProcessStop,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery, domain.JobCapabilityRemoteRunDBMySQLExecute, domain.JobCapabilityRemoteRunDBSQLiteQuery, domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
domain.JobCapabilityRemoteRunLogsTransfer, domain.JobCapabilityRemoteRunRCONCommand,
|
||||
domain.JobCapabilityRemoteRunProgram,
|
||||
domain.JobCapabilityRunSelfUpdate, domain.JobCapabilityDependenciesCheck, domain.JobCapabilityDependenciesInstall,
|
||||
@@ -2375,7 +2399,9 @@ func remoteCapabilityRequiresInputRef(capability string) bool {
|
||||
domain.JobCapabilityRemoteRsyncWrite,
|
||||
domain.JobCapabilityRemoteRunFilesWrite,
|
||||
domain.JobCapabilityRemoteRunDBMySQLQuery,
|
||||
domain.JobCapabilityRemoteRunDBMySQLExecute,
|
||||
domain.JobCapabilityRemoteRunDBSQLiteQuery,
|
||||
domain.JobCapabilityRemoteRunDBSQLiteExecute,
|
||||
domain.JobCapabilityRemoteRunRCONCommand, domain.JobCapabilityRemoteRunProgram:
|
||||
return true
|
||||
default:
|
||||
@@ -2412,15 +2438,14 @@ func validFileOperationKind(operation domain.FileOperationKind) bool {
|
||||
|
||||
func validUploadFilename(name string) bool {
|
||||
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
|
||||
}
|
||||
for _, char := range name {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == ' ' || char == '(' || char == ')' {
|
||||
continue
|
||||
}
|
||||
if unicode.IsControl(char) || char == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -2438,15 +2463,19 @@ func validLogicalFileKey(key string) bool {
|
||||
if trimmed == "" || trimmed != key || len([]rune(key)) > maxLogicalFileKeyLength {
|
||||
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
|
||||
}
|
||||
for _, segment := range strings.Split(key, "/") {
|
||||
if segment == "" || segment == "." || segment == ".." {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, char := range key {
|
||||
if (char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || char == '_' || char == '-' || char == '.' || char == '/' {
|
||||
continue
|
||||
}
|
||||
if unicode.IsControl(char) || char == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -806,6 +806,7 @@ describe("PlatformApiClient AI providers", () => {
|
||||
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-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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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.
|
||||
- `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 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.
|
||||
- `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.
|
||||
@@ -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/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/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.
|
||||
- `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.
|
||||
@@ -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.
|
||||
|
||||
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
|
||||
|
||||
`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 type { JobResponse, LogEntryBody, LogStreamResponse, SourceRCONCommandResponse } from "../api/types";
|
||||
import { formatTerminalServerTime } from "../utils/logTime";
|
||||
import { ServerManagementTerminalDrawer } from "./ServerManagementTerminalDrawer";
|
||||
|
||||
const apiMocks = vi.hoisted(() => ({
|
||||
@@ -68,7 +69,7 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
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 emitSession("session-a");
|
||||
@@ -88,6 +89,16 @@ describe("ServerManagementTerminalDrawer", () => {
|
||||
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 () => {
|
||||
await renderDrawer();
|
||||
|
||||
@@ -200,11 +211,11 @@ async function renderDrawer() {
|
||||
await act(async () => {
|
||||
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) {
|
||||
await act(async () => eventStream.emit("session", { serverInstanceId: "server-1", logSessionId, streamCount: logSessionId ? 1 : 0, serverTime: "2026-08-14T00:00:00Z" }));
|
||||
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 }));
|
||||
}
|
||||
|
||||
async function emitStream(stream: LogStreamResponse) {
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { JobResponse, LogEntryBody, LogStreamResponse } from "../api/types"
|
||||
import { scumSourceRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||
import { cx } from "../utils/classes";
|
||||
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";
|
||||
|
||||
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 terminalJobResultPollAttempts = 30;
|
||||
const terminalInitialHistoryWindow = 500;
|
||||
const terminalLiveReplayWindow = 0;
|
||||
const terminalHistoryWindow = 500;
|
||||
const maxTerminalLines = 10000;
|
||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||
"game.scum": [
|
||||
@@ -98,6 +99,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
const [selectedHistoryStreamId, setSelectedHistoryStreamId] = useState("");
|
||||
const outputRef = useRef<HTMLDivElement>(null);
|
||||
const followLatestRef = useRef(true);
|
||||
const serverTimeRef = useRef<string | undefined>(undefined);
|
||||
const initialHistoryPendingRef = useRef(false);
|
||||
const liveSessionRef = useRef<string | null | undefined>(undefined);
|
||||
const historyRequestRef = useRef(0);
|
||||
@@ -136,11 +138,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setHistoryLines({ status: "idle" });
|
||||
setSelectedHistoryStreamId("");
|
||||
liveSessionRef.current = undefined;
|
||||
serverTimeRef.current = undefined;
|
||||
historyRequestRef.current += 1;
|
||||
initialHistoryPendingRef.current = true;
|
||||
followLatestRef.current = true;
|
||||
setFollowLatest(true);
|
||||
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM")]);
|
||||
setLines([terminalSystemLine("info", supportsCommands ? "正在连接当前受管进程输出。" : "该插件暂未声明可用的终端命令通道。", "SYSTEM", undefined, serverTimeRef.current)]);
|
||||
}, [open, supportsCommands]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -155,11 +158,12 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
let ready = false;
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow });
|
||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalLiveReplayWindow });
|
||||
events.addEventListener("session", (event) => {
|
||||
const session = parseLogSessionEvent(event);
|
||||
if (!session) return;
|
||||
ready = true;
|
||||
serverTimeRef.current = session.serverTime;
|
||||
const nextSessionId = normalizeLogSessionId(session.logSessionId);
|
||||
const previousSessionId = liveSessionRef.current;
|
||||
liveSessionRef.current = nextSessionId;
|
||||
@@ -167,8 +171,8 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
if (previousSessionId === nextSessionId) return;
|
||||
setStreams({ status: "ready", data: [] });
|
||||
setLines(nextSessionId
|
||||
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`)]
|
||||
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty")]);
|
||||
? [terminalSystemLine("info", previousSessionId === undefined ? "已跟随当前受管进程输出会话。" : "Run 已切换到新的受管进程输出会话。", "SYSTEM", `session-${nextSessionId}`, serverTimeRef.current)]
|
||||
: [terminalSystemLine("warn", "当前没有可跟随的受管进程输出;旧日志可从历史查看。", "SYSTEM", "session-empty", serverTimeRef.current)]);
|
||||
lockTerminalFollow();
|
||||
});
|
||||
events.addEventListener("stream", (event) => {
|
||||
@@ -216,7 +220,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setSelectedHistoryStreamId(streamId);
|
||||
setHistoryLines({ status: "loading" });
|
||||
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;
|
||||
setHistoryLines({ status: "ready", data: response.entries.map((entry) => terminalLineFromLog(stream, entry)) });
|
||||
} catch (error) {
|
||||
@@ -284,26 +288,26 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
setCommandHistory((current) => [...current.filter((item) => item !== submitted), submitted].slice(-50));
|
||||
setPending(true);
|
||||
setResult({ status: "pending", label: "正在提交命令" });
|
||||
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
||||
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND", undefined, serverTimeRef.current)]);
|
||||
try {
|
||||
const response = await platformApiClient.dispatchSourceRCONCommand(serverId, scumSourceRCONCommandRequest(serverId, submitted));
|
||||
const label = rconJobDispatchLabel(response.status, response.jobId);
|
||||
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);
|
||||
if (finalJob) {
|
||||
const outcome = terminalLineFromJob(finalJob);
|
||||
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
||||
appendLines([outcome]);
|
||||
} 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 返回结果" });
|
||||
appendLines([timeoutLine]);
|
||||
}
|
||||
} catch (error) {
|
||||
const label = error instanceof Error ? error.message : "命令提交失败";
|
||||
setResult({ status: "failed", label });
|
||||
appendLines([terminalSystemLine("error", label, "ERROR")]);
|
||||
appendLines([terminalSystemLine("error", label, "ERROR", undefined, serverTimeRef.current)]);
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
@@ -333,9 +337,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
||||
</div>
|
||||
</div>
|
||||
<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 && <HistoryLogView streams={historyStreams} lines={historyLines} selectedStreamId={selectedHistoryStreamId} onSelect={selectHistoryStream} />}
|
||||
{!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 === "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} serverTime={serverTimeRef.current} />}
|
||||
{!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>)}
|
||||
</div>
|
||||
</section>
|
||||
@@ -368,12 +372,13 @@ interface HistoryLogViewProps {
|
||||
lines: HistoryLineState;
|
||||
selectedStreamId: string;
|
||||
onSelect: (streamId: string) => Promise<void>;
|
||||
serverTime?: string;
|
||||
}
|
||||
|
||||
function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryLogViewProps) {
|
||||
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" />;
|
||||
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} />;
|
||||
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" />;
|
||||
function HistoryLogView({ streams, lines, selectedStreamId, onSelect, serverTime }: HistoryLogViewProps) {
|
||||
if (streams.status === "loading") return <TerminalStatusLine tone="info" label="正在加载历史日志列表。" serverTime={serverTime} />;
|
||||
if (streams.status === "error") return <TerminalStatusLine tone="error" label={streams.reason} serverTime={serverTime} />;
|
||||
if (streams.data.length === 0) return <TerminalStatusLine tone="warn" label="暂无可查看的历史日志流。" serverTime={serverTime} />;
|
||||
return (
|
||||
<>
|
||||
<div className="terminal-line terminal-line-info terminal-source-system">
|
||||
@@ -385,17 +390,17 @@ function HistoryLogView({ streams, lines, selectedStreamId, onSelect }: HistoryL
|
||||
</select>
|
||||
</span>
|
||||
</div>
|
||||
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" />}
|
||||
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" />}
|
||||
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} />}
|
||||
{lines.status === "ready" && lines.data.length === 0 && <TerminalStatusLine tone="warn" label="所选历史日志流暂无保留内容。" />}
|
||||
{lines.status === "idle" && <TerminalStatusLine tone="info" label="请选择一个历史日志流。" serverTime={serverTime} />}
|
||||
{lines.status === "loading" && <TerminalStatusLine tone="info" label="正在读取所选历史日志。" serverTime={serverTime} />}
|
||||
{lines.status === "error" && <TerminalStatusLine tone="error" label={lines.reason} serverTime={serverTime} />}
|
||||
{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>)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function TerminalStatusLine({ tone, label }: { tone: "info" | "warn" | "error"; label: string }) {
|
||||
return <div className={`terminal-line terminal-line-${tone} terminal-source-system`}><time>{new Date().toLocaleTimeString()}</time><span className="terminal-text">{label}</span></div>;
|
||||
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>{formatTerminalServerTime(serverTime)}</time><span className="terminal-text">{label}</span></div>;
|
||||
}
|
||||
|
||||
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 {
|
||||
const now = Date.now();
|
||||
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
|
||||
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 sortKey = serverTime ? Date.parse(serverTime) : 0;
|
||||
return { id, tone, text, at: formatTerminalServerTime(serverTime), sortKey, streamKey };
|
||||
}
|
||||
|
||||
function terminalLineClassName(line: TerminalLine): string {
|
||||
@@ -457,7 +462,7 @@ function terminalLineFromJob(job: JobResponse): TerminalLine {
|
||||
const completed = job.updatedAt;
|
||||
const sortKey = Date.parse(completed) || Date.now();
|
||||
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 {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { configDiffViewFromPreview } from "./ServerDetailPage";
|
||||
import serversPageSource from "./ServersPage.tsx?raw";
|
||||
import serverManagementTerminalSource from "../components/ServerManagementTerminalDrawer.tsx?raw";
|
||||
import serverDetailPageSource from "./ServerDetailPage.tsx?raw";
|
||||
import type { ServerConfigDiffPreviewResponse } from "../api/types";
|
||||
@@ -22,6 +23,15 @@ const preview: ServerConfigDiffPreviewResponse = {
|
||||
};
|
||||
|
||||
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", () => {
|
||||
const view = configDiffViewFromPreview(preview);
|
||||
|
||||
@@ -75,6 +85,21 @@ describe("ServerDetailPage config write approval", () => {
|
||||
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", () => {
|
||||
expect(serverDetailPageSource).not.toContain('id="run-builder"');
|
||||
expect(serverDetailPageSource).not.toContain("getServerRuntimeActions");
|
||||
|
||||
@@ -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 { type ChangeEvent, type FormEvent, useCallback, useEffect, useMemo, useState } from "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, useRef, useState } from "react";
|
||||
|
||||
import { platformApiClient } from "../api/client";
|
||||
import type {
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
JobResponse,
|
||||
ServerInstanceResponse,
|
||||
ServerMemberResponse,
|
||||
ServerMetricsResponse,
|
||||
ServerDeploymentResponse,
|
||||
ServerConfigDiffPreviewResponse,
|
||||
RunEndpointResponse,
|
||||
@@ -17,14 +16,13 @@ import type {
|
||||
ServerFileListResponse,
|
||||
ServerFileWorkspaceResponse
|
||||
} from "../api/types";
|
||||
import { ConfirmDialog, UsageMeter } from "../components/OperationControls";
|
||||
import { ConfirmDialog } from "../components/OperationControls";
|
||||
import { ServerManagementTerminalDrawer } from "../components/ServerManagementTerminalDrawer";
|
||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "../components/StateViews";
|
||||
import type { PageComponentProps } from "../contracts/page";
|
||||
import { canStartServer, canStopServer, runtimeObservationFreshness, serverMetadataFormFromInstance, type ServerMetadataFormState } from "../contracts/serverManagement";
|
||||
import {
|
||||
serverDetailSections,
|
||||
serverIsOnline,
|
||||
type ConfigDiffView,
|
||||
type LlmSuggestionView,
|
||||
type ServerDetailSection
|
||||
@@ -41,14 +39,12 @@ import { PluginPageHostPage } from "./PluginPageHostPage";
|
||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||
|
||||
const serverDetailRefreshMs = 5000;
|
||||
const serverMetricFreshMs = 30000;
|
||||
|
||||
export function ServerDetailPage(props: PageComponentProps) {
|
||||
const { session, params, operations, onNavigate } = props;
|
||||
const serverId = params.serverId ?? "";
|
||||
const [section, setSection] = useState<ServerDetailSection>("manage");
|
||||
const [instance, setInstance] = useState<LoadState<ServerInstanceResponse>>({ status: "loading" });
|
||||
const [metrics, setMetrics] = useState<ServerMetricsResponse | null>(null);
|
||||
const [plugins, setPlugins] = useState<GamePluginResponse[]>([]);
|
||||
const [jobs, setJobs] = useState<JobResponse[]>([]);
|
||||
const [runEndpoint, setRunEndpoint] = useState<RunEndpointResponse | undefined>();
|
||||
@@ -84,12 +80,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
setRunEndpoint(undefined);
|
||||
setDeployment({ status: "error", reason: "部署定义加载失败" });
|
||||
}
|
||||
try {
|
||||
const metricsResponse = await platformApiClient.listServerMetrics();
|
||||
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
|
||||
} catch {
|
||||
setMetrics(null);
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -99,18 +89,16 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
const refreshOperationalState = useCallback(async () => {
|
||||
if (!serverId) return;
|
||||
try {
|
||||
const [detail, jobResponse, metricsResponse, endpointResponse] = await Promise.all([
|
||||
const [detail, jobResponse, endpointResponse] = await Promise.all([
|
||||
platformApiClient.getServerInstance(serverId),
|
||||
platformApiClient.listJobs(serverId),
|
||||
platformApiClient.listServerMetrics(),
|
||||
platformApiClient.listRunEndpoints()
|
||||
]);
|
||||
setInstance({ status: "ready", data: detail });
|
||||
setJobs(jobResponse.items);
|
||||
setMetrics(metricsResponse.items.find((item) => item.serverInstanceId === serverId) ?? null);
|
||||
setRunEndpoint(endpointResponse.items.find((endpoint) => endpoint.id === detail.runEndpointId));
|
||||
} catch {
|
||||
setMetrics(null);
|
||||
setRunEndpoint(undefined);
|
||||
}
|
||||
}, [serverId]);
|
||||
|
||||
@@ -124,7 +112,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
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 detailSections = useMemo(() => serverDetailSectionEntries(readyPlugin), [readyPlugin]);
|
||||
const metricsWaiting = metrics?.source === "run-metrics-pending";
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.routeKey || !readyPlugin?.pages.some((page) => page.key === params.routeKey)) return;
|
||||
@@ -247,18 +234,6 @@ export function ServerDetailPage(props: PageComponentProps) {
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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 [uploadBusy, setUploadBusy] = useState(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 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 loadWorkspace = useCallback(async () => {
|
||||
initialRuntimeLoadRef.current = false;
|
||||
setWorkspace({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.getServerFileWorkspace(instance.id);
|
||||
setWorkspace({ status: "ready", data: response });
|
||||
const nextDirectoryKey = response.defaultDirectoryKey || response.directories[0]?.key || "";
|
||||
setDirectoryKey((current) => current || nextDirectoryKey);
|
||||
if (!nextDirectoryKey) {
|
||||
setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "插件尚未声明文件工作区;需要在插件 manifest 中添加 fileWorkspace。" } });
|
||||
}
|
||||
if (!nextDirectoryKey) setList({ status: "ready", data: { serverInstanceId: response.serverInstanceId, pluginId: response.pluginId, directoryKey: "", state: "declared", entries: [], reason: "尚未获得服务器文件入口。" } });
|
||||
} catch (error) {
|
||||
setWorkspace({ status: "error", reason: error instanceof Error ? error.message : "文件工作区加载失败" });
|
||||
setList({ status: "error", reason: "文件工作区不可用" });
|
||||
}
|
||||
}, [instance.id]);
|
||||
|
||||
const loadList = useCallback(async () => {
|
||||
const loadList = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||
if (!directoryKey) return;
|
||||
setList({ status: "loading" });
|
||||
if (!options.silent) setList({ status: "loading" });
|
||||
try {
|
||||
const response = await platformApiClient.listServerFiles(instance.id, { directoryKey, path: relativePath || undefined, query: searchQuery || undefined, recursive });
|
||||
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) {
|
||||
setList({ status: "error", reason: error instanceof Error ? error.message : "文件列表加载失败" });
|
||||
}
|
||||
@@ -630,26 +608,37 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
void loadWorkspace();
|
||||
}, [loadWorkspace]);
|
||||
|
||||
useEffect(() => {
|
||||
if (workspace.status !== "ready" || !directoryKey) return;
|
||||
void loadList();
|
||||
}, [directoryKey, loadList, workspace.status]);
|
||||
|
||||
async function refreshRuntimeList() {
|
||||
const refreshRuntimeList = useCallback(async (options: { silent?: boolean } = {}) => {
|
||||
if (!directoryKey) return;
|
||||
const operationId = operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" });
|
||||
const operationId = options.silent ? "" : operations.begin({ intent: "刷新文件目录", targetKind: "server", targetId: instance.id, requester: session.displayName });
|
||||
if (!options.silent) setPanelResult({ status: "pending", label: "正在向 Run 请求实时目录…" });
|
||||
try {
|
||||
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 });
|
||||
operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
|
||||
setPanelResult({ status: "pending", label: response.reason ?? "目录刷新任务已派发,稍后可再次刷新查看实时结果。" });
|
||||
if (operationId) operations.succeed(operationId, `目录刷新任务 ${response.job?.id ?? "已派发"}`, response.job);
|
||||
setPanelResult({ status: serverFileListResultStatus(response), label: serverFileListResultLabel(response) });
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : "目录刷新失败";
|
||||
operations.fail(operationId, reason, operationId);
|
||||
if (operationId) operations.fail(operationId, reason, operationId);
|
||||
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) {
|
||||
if (entry.kind === "directory") {
|
||||
@@ -664,7 +653,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
async function openEditor(entry: ServerFileEntryResponse) {
|
||||
const key = serverFileEntryKey(entry);
|
||||
if (!key) {
|
||||
setPanelResult({ status: "failed", label: "该文件缺少插件声明的逻辑 key,不能读取。" });
|
||||
setPanelResult({ status: "failed", label: "该文件缺少路径 key,不能读取。" });
|
||||
return;
|
||||
}
|
||||
setEditor({ entry, key, draft: "", loading: true, saving: false, message: "正在读取最近快照…" });
|
||||
@@ -687,7 +676,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
}
|
||||
|
||||
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 });
|
||||
setEditor((current) => ({ ...current, saving: true, error: undefined, message: "正在派发写入任务…" }));
|
||||
try {
|
||||
@@ -706,7 +695,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
|
||||
async function downloadEntry(entry: ServerFileEntryResponse) {
|
||||
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 });
|
||||
setPanelResult({ status: "pending", label: "正在准备文件下载…" });
|
||||
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 === "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 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>
|
||||
<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>)}
|
||||
</div>
|
||||
<div className="server-file-toolbar">
|
||||
<div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
|
||||
{workspace.data.directories.length === 0 && <span className="provider-id">插件尚未声明可浏览目录</span>}
|
||||
<div className={cx("server-file-toolbar", workspace.data.directories.length > 1 && "server-file-toolbar-has-tabs")}>
|
||||
{workspace.data.directories.length > 1 && <div className="server-file-directory-tabs" role="tablist" aria-label="文件目录">
|
||||
{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(""); }}>
|
||||
{directory.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
<form className="server-file-search" onSubmit={submitSearch}>
|
||||
<Search size={14} />
|
||||
<input type="search" value={searchDraft} placeholder="搜索文件/目录" onChange={(event) => setSearchDraft(event.target.value)} />
|
||||
@@ -792,7 +784,7 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
</form>
|
||||
<div className="action-strip server-file-actions">
|
||||
<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)} />
|
||||
</label>
|
||||
</div>
|
||||
@@ -801,28 +793,25 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
{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 === "ready" && (
|
||||
<div className="server-file-layout">
|
||||
<div className="resource-table-wrap server-file-table-wrap">
|
||||
<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>
|
||||
{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) => (
|
||||
<tr key={serverFileEntryRowKey(entry)} className={entry.kind === "directory" ? "server-file-directory-row" : undefined}>
|
||||
<td><input type="checkbox" disabled /></td>
|
||||
<td>
|
||||
<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>
|
||||
</button>
|
||||
<span className="provider-id">{entry.logicalKey || entry.relativePath || entry.directoryKey}</span>
|
||||
</td>
|
||||
<td>{entry.kind === "directory" ? "计算" : formatBytes(entry.sizeBytes)}</td>
|
||||
<td>{entry.kind === "directory" ? "--" : formatBytes(entry.sizeBytes)}</td>
|
||||
<td>{formatDateTime(entry.modifiedAt)}</td>
|
||||
<td>{entry.remark || entry.scope || "--"}</td>
|
||||
<td>
|
||||
<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 === "file" && <button type="button" title="下载" disabled={!entry.downloadable} onClick={() => void downloadEntry(entry)}><Download 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="下载" onClick={() => void downloadEntry(entry)}><Download size={14} /><span>下载</span></button>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -830,9 +819,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
</tbody>
|
||||
</table>
|
||||
</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 && <p className="section-copy">选择一个插件声明为可编辑的文件后,这里会显示最近读取快照;保存会派发 Run 侧写入任务。</p>}
|
||||
)}
|
||||
{editor.entry && (
|
||||
<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.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>}
|
||||
@@ -844,11 +835,11 @@ function ServerFilesSection({ instance, session, operations }: ServerFilesSectio
|
||||
)}
|
||||
{editor.entry && (
|
||||
<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="icon-command" disabled={!editor.entry.downloadable} onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span>下载</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" onClick={() => void downloadEntry(editor.entry!)}><Download size={14} /><span>下载</span></button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
@@ -863,6 +854,52 @@ function serverFileEntryRowKey(entry: ServerFileEntryResponse): string {
|
||||
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 {
|
||||
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();
|
||||
}
|
||||
|
||||
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 {
|
||||
serverId: string;
|
||||
instance: ServerInstanceResponse;
|
||||
|
||||
@@ -455,7 +455,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.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-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-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}
|
||||
@@ -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 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-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: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-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-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-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-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{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-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}
|
||||
@@ -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>div:first-child{min-width:0}
|
||||
.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-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}
|
||||
@@ -722,14 +720,13 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
||||
.page-title{font-size:24px}
|
||||
.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}
|
||||
.server-detail-stat-strip{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.server-detail-meter-strip,.terminal-command-form{grid-template-columns:1fr}
|
||||
.terminal-command-form{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))}
|
||||
.section-tabs{overflow-x:auto;flex-wrap:nowrap;padding-bottom:4px}
|
||||
.server-toolbar{align-items:stretch}
|
||||
.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}
|
||||
.server-card-head,.server-detail-title-row{display:grid}
|
||||
.server-detail-title-row .action-strip{align-items:stretch}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { formatTerminalLogTime } from "./logTime";
|
||||
import { formatTerminalLogTime, formatTerminalServerTime } from "./logTime";
|
||||
|
||||
describe("formatTerminalLogTime", () => {
|
||||
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", () => {
|
||||
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"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,3 +9,7 @@ export function formatTerminalLogTime(timestamp: string, line?: string): string
|
||||
const date = new Date(timestamp);
|
||||
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) : "时间同步中";
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
|
||||
const plugin: PluginBridgeManifestContract = {
|
||||
id: "game.example",
|
||||
declaredPermissions: ["server.read", "server.logs.read", "server.files.read", "server.artifacts.read", "ai.invoke"],
|
||||
bridgeActions: ["server.instances.read", "logs.query", "files.request", "artifacts.open", "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", "remote.access.request", "ai.invoke"],
|
||||
pages: [
|
||||
{
|
||||
key: "logs",
|
||||
@@ -20,6 +20,13 @@ const plugin: PluginBridgeManifestContract = {
|
||||
path: "/logs",
|
||||
permissions: ["server.logs.read", "server.files.read", "server.artifacts.read", "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"]
|
||||
@@ -110,6 +117,11 @@ describe("plugin bridge host utilities", () => {
|
||||
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 () => {
|
||||
const context = createPluginBridgeHostContext({
|
||||
plugin,
|
||||
|
||||
@@ -110,7 +110,7 @@ export function validateBridgeExecutionRequest(context: PluginBridgeHostContext,
|
||||
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);
|
||||
if (encodedSize > 4096) {
|
||||
if (encodedSize > 16 * 1024) {
|
||||
return { code: "payload_too_large", message: "bridge payload is too large" };
|
||||
}
|
||||
for (const [key, value] of Object.entries(payload)) {
|
||||
|
||||
@@ -17,6 +17,69 @@ export type PluginGameClientQueueRequest = {
|
||||
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 = {
|
||||
pluginData?: PluginDataActions;
|
||||
gameClient?: {
|
||||
@@ -25,6 +88,7 @@ export type SCUMWorkspaceActions = {
|
||||
list: (filter?: { profileKey?: string; state?: string; commandType?: string }) => 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 = {
|
||||
@@ -80,7 +144,7 @@ type SurfaceKey = keyof SCUMSurfaceData;
|
||||
type PageKey = "players" | "squads" | "live-map" | "gifts" | "workflows";
|
||||
|
||||
const pageCollections: Record<PageKey, SurfaceKey[]> = {
|
||||
players: ["players", "members"],
|
||||
players: ["players", "members", "activityEvents", "giftClaims", "pendingGifts", "giftDeliveries"],
|
||||
squads: ["squads", "members", "flags"],
|
||||
"live-map": ["mapPoints", "mapRegions", "mapSettings", "players", "vehicles", "flags"],
|
||||
gifts: ["gifts", "giftClaims", "pendingGifts", "giftDeliveries", "timedGiftEvents", "players"],
|
||||
@@ -292,6 +356,23 @@ function requirePluginData(actions: SCUMWorkspaceActions): PluginDataActions {
|
||||
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 {
|
||||
const result = textValue(value[key]);
|
||||
if (!result) throw new Error(`${label}不能为空。`);
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
loadSCUMSurface,
|
||||
parseGiftItems,
|
||||
parseGiftCommands,
|
||||
playerAttributeDrafts,
|
||||
playerAttributeSqlPreview,
|
||||
queuePlayerAttributePatch,
|
||||
queueGiftDelivery,
|
||||
resetGiftClaim,
|
||||
resetPendingGift,
|
||||
@@ -21,9 +24,12 @@ import {
|
||||
} from "./page-data.js";
|
||||
|
||||
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 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;
|
||||
|
||||
export type ReactLike = {
|
||||
@@ -51,6 +57,8 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
const [action, setAction] = usePluginState<ActionState>(react, { status: "idle" });
|
||||
const [playerSearch, setPlayerSearch] = usePluginState(react, "");
|
||||
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 [selectedSquadId, setSelectedSquadId] = usePluginState(react, "");
|
||||
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;
|
||||
return e("section", { className: "console-panel", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
|
||||
e("div", { className: "panel-header" },
|
||||
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")
|
||||
)
|
||||
e("div", null, e("h2", null, input.pageTitle ?? surfaceTitle(pageKey)), e("p", { className: "provider-id" }, surfaceSummary(pageKey)))
|
||||
),
|
||||
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 === "error" ? e("p", { className: "page-status", "data-state": "error" }, state.reason) : null,
|
||||
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,
|
||||
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,
|
||||
@@ -139,6 +144,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
|
||||
|
||||
type ViewState = {
|
||||
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>;
|
||||
activityStatus: string; setActivityStatus: StateSetter<string>; giftTab: GiftTab; setGiftTab: StateSetter<GiftTab>;
|
||||
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) {
|
||||
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 "live-map": return mapSurface(e, data, input, view);
|
||||
case "gifts": return giftsSurface(e, data, input, view);
|
||||
case "workflows":
|
||||
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 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" },
|
||||
statsStrip(e, [["用户", data.players.length], ["在线", data.players.filter(playerOnline).length], ["筛选结果", players.length], ["队伍成员", data.members.length]]),
|
||||
e("div", { className: "console-row-actions" },
|
||||
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" }, "离线/未知"))
|
||||
),
|
||||
players.length ? players.slice(0, 120).map((player, index) => e("article", { key: idOf(player, `player-${index}`), className: "console-record" },
|
||||
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("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("span", { className: "provider-id" }, `Fame ${numField(player, "famePoints")} · Cash ${numField(player, "normalBalance", "moneyBalance")} · Gold ${numField(player, "goldBalance")} · ${coords(positionOf(player))}`)
|
||||
)) : e("p", { className: "page-status" }, "没有符合筛选条件的真实用户记录。")
|
||||
e("div", { className: "provider-table-wrap" },
|
||||
e("table", { className: "resource-table scum-user-table", style: { minWidth: "1180px" } },
|
||||
e("caption", { className: "provider-id" }, "SCUM 用户真实记录"),
|
||||
e("thead", null, e("tr", null, ["用户名", "Steam", "队伍", "状态", "上次登录", "登录 IP", "最后活动", "概况", "操作"].map((label) => e("th", { key: label, scope: "col" }, label)))),
|
||||
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.execute;SQL 文本会随任务进入 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) {
|
||||
const search = view.squadSearch.trim().toLowerCase();
|
||||
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" }
|
||||
];
|
||||
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 function supportsStateField(field: string): boolean { return stateFieldCatalog.some((candidate) => candidate.key === field); }
|
||||
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 /^[A-Za-z0-9_.:-]{1,120}$/.test(field); }
|
||||
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 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; }
|
||||
|
||||
@@ -71,6 +71,7 @@
|
||||
"remote.run.files.read",
|
||||
"remote.run.files.write",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.db.sqlite.execute",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer",
|
||||
@@ -95,6 +96,7 @@
|
||||
"remote.run.files.read",
|
||||
"remote.run.files.write",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.db.sqlite.execute",
|
||||
"remote.run.process.start",
|
||||
"remote.run.process.stop",
|
||||
"remote.run.logs.transfer",
|
||||
@@ -193,15 +195,6 @@
|
||||
"resultSchemaRef": "schemas/bridge/maintenance-prepare.result.schema.json",
|
||||
"timeoutSeconds": 120,
|
||||
"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": [
|
||||
@@ -485,14 +478,6 @@
|
||||
"player.notify"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "state.patch",
|
||||
"title": "SCUM player state patch",
|
||||
"permission": "server.game-client.maintenance",
|
||||
"requiredHandlers": [
|
||||
"game-state.patch"
|
||||
]
|
||||
},
|
||||
{
|
||||
"key": "vehicle.spawn",
|
||||
"title": "SCUM catalogued vehicle spawn",
|
||||
@@ -522,8 +507,7 @@
|
||||
"scum.positions"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"state.patch"
|
||||
"player.intelligence"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -898,8 +882,7 @@
|
||||
"remote.access.request"
|
||||
],
|
||||
"featureKeys": [
|
||||
"player.intelligence",
|
||||
"state.patch"
|
||||
"player.intelligence"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -1357,7 +1340,8 @@
|
||||
"kind": "sqlite",
|
||||
"targetKey": "scum-database",
|
||||
"capabilities": [
|
||||
"remote.run.db.sqlite.query"
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.db.sqlite.execute"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
+1
-1
@@ -9,6 +9,6 @@
|
||||
"expectedStateVersion": { "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 },
|
||||
"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" } } } }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
"properties": {
|
||||
"status": { "enum": ["confirmed", "rejected", "failed"] },
|
||||
"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 }
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -10,6 +10,6 @@
|
||||
"safetyWindow": { "type": "string", "maxLength": 96, "pattern": "^[A-Za-z0-9_.:-]{1,96}$" },
|
||||
"maintenanceVerified": { "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.stop",
|
||||
"remote.run.db.mysql.query",
|
||||
"remote.run.db.mysql.execute",
|
||||
"remote.run.db.sqlite.query",
|
||||
"remote.run.db.sqlite.execute",
|
||||
"remote.run.logs.transfer",
|
||||
"remote.run.rcon.command",
|
||||
"remote.run.program.command",
|
||||
|
||||
@@ -43,7 +43,9 @@ export type RunCapability =
|
||||
| "remote.run.process.start"
|
||||
| "remote.run.process.stop"
|
||||
| "remote.run.db.mysql.query"
|
||||
| "remote.run.db.mysql.execute"
|
||||
| "remote.run.db.sqlite.query"
|
||||
| "remote.run.db.sqlite.execute"
|
||||
| "remote.run.logs.transfer"
|
||||
| "remote.run.rcon.command"
|
||||
| "remote.run.program.command"
|
||||
|
||||
@@ -275,7 +275,7 @@ describe("plugin manifest validation", () => {
|
||||
expect(local?.capabilities).toContain("remote.run.rcon.command");
|
||||
expect(local?.transportKeys).toContain("scum-management");
|
||||
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-program", kind: "program", capabilities: ["remote.run.program.command"] })
|
||||
]));
|
||||
@@ -493,8 +493,7 @@ describe("plugin manifest validation", () => {
|
||||
"vehicle.spawn",
|
||||
"event.start",
|
||||
"restart.prepare",
|
||||
"maintenance.prepare",
|
||||
"game-state.patch"
|
||||
"maintenance.prepare"
|
||||
]));
|
||||
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"]));
|
||||
@@ -533,8 +532,7 @@ describe("plugin manifest validation", () => {
|
||||
"vehicle.spawn": { permission: "server.game-client.command" },
|
||||
"event.start": { permission: "server.game-client.command" },
|
||||
"restart.prepare": { permission: "server.game-client.maintenance" },
|
||||
"maintenance.prepare": { permission: "server.game-client.maintenance" },
|
||||
"game-state.patch": { permission: "server.game-client.maintenance" }
|
||||
"maintenance.prepare": { permission: "server.game-client.maintenance" }
|
||||
} as const;
|
||||
|
||||
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 {
|
||||
permissions: string[];
|
||||
capabilities: string[];
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[] };
|
||||
remoteAccess?: { runCapabilities?: string[]; databaseEngines?: string[]; rcon?: boolean };
|
||||
gameClientBridge: {
|
||||
queryTemplates: Array<{
|
||||
key: string;
|
||||
@@ -664,12 +662,14 @@ describe("plugin manifest validation", () => {
|
||||
const templatesByKey = new Map(manifest.gameClientBridge.queryTemplates.map((template) => [template.key, template]));
|
||||
expect([...templatesByKey.keys()]).toEqual(expect.arrayContaining(expectedKeys));
|
||||
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.execute");
|
||||
expect(manifest.remoteAccess?.databaseEngines).toContain("sqlite");
|
||||
expect(manifest.remoteAccess?.rcon).toBe(true);
|
||||
const sqliteTransport = manifest.runtimeProfiles?.transportProfiles?.find((profile) => profile.key === "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) {
|
||||
const template = templatesByKey.get(key)!;
|
||||
expect(template.engine).toBe("sqlite");
|
||||
|
||||
@@ -4,7 +4,7 @@ import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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 { renderPluginPage } from "../examples/scum-server-plugin/page-bundle/index.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(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: "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(validateVehicleSpawn({ vehicleCode: "BPC_Laika_C" })).toBeNull();
|
||||
expect(validateVehicleSpawn({ vehicleCode: "#spawnvehicle BPC_Laika_C" })).toContain("格式无效");
|
||||
@@ -176,14 +176,27 @@ describe("SCUM plugin feature module", () => {
|
||||
const view = renderAndCollect();
|
||||
expect(view.nodes).toContain("section:用户管理");
|
||||
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)).toEqual(expect.arrayContaining(["编辑属性", "礼包状态", "他的物品", "登录历史", "用户轨迹"]));
|
||||
expect(view.inputs.map((input) => input.label)).toContain("搜索用户");
|
||||
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");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const view = renderAndCollect({ data: { ...surfaceData, players: [] } });
|
||||
expect(view.texts.join("\n")).toContain("没有符合筛选条件的真实用户记录");
|
||||
@@ -252,11 +265,11 @@ describe("SCUM plugin feature module", () => {
|
||||
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}`;
|
||||
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).not.toContain("remote.access.request");
|
||||
expect(source).toContain("remote.access.request");
|
||||
expect(source).not.toContain("input.templateKey");
|
||||
expect(source).not.toContain("requestSCUMPageQueries");
|
||||
expect(pageSource).toContain("setInterval(refresh, 3000)");
|
||||
@@ -309,7 +322,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
|
||||
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, {
|
||||
page: { key: options.pageKey ?? "players", title: options.pageTitle ?? "用户管理" },
|
||||
context: { serverInstanceId: "server-1", permissions: options.permissions ?? ["server.read", "server.remote.access"] },
|
||||
|
||||
Reference in New Issue
Block a user