Stream live server logs over SSE
This commit is contained in:
@@ -95,6 +95,8 @@ Do not hardcode game-specific deployment behavior in run or platform services. V
|
|||||||
|
|
||||||
When a game needs "install if missing, update if present, then start" behavior, implement it as plugin-owned lifecycle actions. Platform may package and dispatch those actions, and run may execute them through generic capabilities, but neither platform nor run should special-case a game by name to perform those steps.
|
When a game needs "install if missing, update if present, then start" behavior, implement it as plugin-owned lifecycle actions. Platform may package and dispatch those actions, and run may execute them through generic capabilities, but neither platform nor run should special-case a game by name to perform those steps.
|
||||||
|
|
||||||
|
Realtime log collection must be plugin-declared and plugin-configured. For live terminal output, prefer the plugin-declared supervised process channels (`process.stdout` / `process.stderr`) for the process that run started, and treat game-specific file tails only as plugin-declared sources for history, fallback, or explicit backfill. Do not inspect or prioritize a game log file such as `SCUM.log` merely because it exists on disk; if a plugin needs a file tail, window/console capture, startup flag, hidden window behavior, or another live-log source, declare that behavior in the plugin manifest/action/config and keep run/platform generic.
|
||||||
|
|
||||||
Run-platform communication must remain channelized:
|
Run-platform communication must remain channelized:
|
||||||
|
|
||||||
- Control is lightweight and high priority.
|
- Control is lightweight and high priority.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## Overview
|
||||||
|
|
||||||
|
Realtime browser log display is a one-way stream, so the platform exposes Server-Sent Events instead of WebSocket for this change. SSE gives the browser one long-lived HTTP response, works with standard `EventSource`, carries same-origin HttpOnly session cookies, and only needs Nginx buffering disabled.
|
||||||
|
|
||||||
|
## Transport Boundary
|
||||||
|
|
||||||
|
Run continues to upload logs through `POST /api/v1/run/logs/batches`. That path remains durable and retryable: Run writes to local spool, sends bounded batches, receives sequence ACKs, and can retry without depending on browser presence.
|
||||||
|
|
||||||
|
Run log capture remains plugin-declared. For live terminal output, Run captures the supervised process channels declared by the plugin as `process.stdout` / `process.stderr`; game file tails such as `file.tail` are explicit plugin-declared history/backfill sources rather than a generic default. A managed process started by Run writes stdout/stderr into Run-owned capture files and Run tails those capture files into durable batch ingest, so restarting Run can resume transmission for an already-running supervised process without inspecting game-specific logs such as `SCUM.log`.
|
||||||
|
|
||||||
|
The browser subscribes to `GET /api/v1/server-instances/{id}/logs/events`. Platform authorizes the user session against the server instance, replays a bounded recent history per stream, then publishes newly ingested log entries from memory fan-out. `POST /api/v1/log-streams/query` stays as an explicit historical cursor API, not a realtime polling loop.
|
||||||
|
|
||||||
|
## Proxy Notes
|
||||||
|
|
||||||
|
SSE does not require `Upgrade` or `Connection: upgrade`. Reverse proxies must avoid buffering the stream and should keep the upstream read timeout long enough for idle log periods.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
The browser terminal and server log detail view were polling `POST /api/v1/log-streams/query` on a short interval, and terminal polling multiplied that request count by every candidate log stream. This wastes HTTP requests and can overload the platform or reverse proxy while still failing to feel truly realtime.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Add a platform-owned `GET /api/v1/server-instances/{id}/logs/events` Server-Sent Events stream for browser live logs.
|
||||||
|
- Keep Run-to-Platform log transport as durable signed HTTP batch ingest with local spool, sequence acknowledgement, and cursor query for history/reconnect repair.
|
||||||
|
- Switch the server detail log view, live log drawer, and management terminal to a single EventSource connection with bounded initial history instead of periodic `/log-streams/query` polling.
|
||||||
|
- Add Nginx proxy settings for unbuffered SSE forwarding.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects `platform/` API, DTO, service log ingest fan-out, tests, and protocol docs.
|
||||||
|
- Affects `platform_web/` API types/client, log UI components, tests, and Nginx config.
|
||||||
|
- Does not add WebSocket terminal transport, direct browser-to-Run connections, plugin-held platform keys, host paths, credentials, or game-specific log behavior in platform/run.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Browser live logs use a platform push stream
|
||||||
|
The Platform SHALL provide a server-scoped browser log event stream that sends safe log stream metadata and log entries over Server-Sent Events.
|
||||||
|
|
||||||
|
#### Scenario: Operator opens live logs
|
||||||
|
- **WHEN** an authorized operator opens a server log view or management terminal
|
||||||
|
- **THEN** platform_web opens `GET /api/v1/server-instances/{id}/logs/events` with `EventSource`
|
||||||
|
- **AND** the view does not start a periodic `/api/v1/log-streams/query` polling loop
|
||||||
|
|
||||||
|
#### Scenario: Initial history is replayed
|
||||||
|
- **WHEN** the browser opens the log event stream with a bounded `historyLimit`
|
||||||
|
- **THEN** Platform replays recent stored entries for the server's log streams before sending the ready event
|
||||||
|
- **AND** each event contains only safe stream metadata and log entry fields
|
||||||
|
|
||||||
|
### Requirement: Durable Run log ingest remains independent
|
||||||
|
Run-to-Platform log transfer SHALL remain durable HTTP batch ingest with local spool and sequence acknowledgement. Browser streaming MUST fan out only from platform-ingested log data.
|
||||||
|
|
||||||
|
#### Scenario: Run uploads a batch
|
||||||
|
- **WHEN** Run uploads a valid contiguous log batch
|
||||||
|
- **THEN** Platform stores it, updates the stream latest sequence, acknowledges the batch, and publishes the new entries to matching browser subscribers
|
||||||
|
- **AND** duplicate batch acknowledgements do not publish duplicate browser events
|
||||||
|
|
||||||
|
### Requirement: Run live logs follow plugin-declared process channels
|
||||||
|
Run live terminal output SHALL come from plugin-declared log sources and the supervised process that Run started. Game-specific file logs MUST NOT be used as the default live terminal source unless the plugin declares that file source for explicit history, fallback, or backfill.
|
||||||
|
|
||||||
|
#### Scenario: Run starts a supervised process
|
||||||
|
- **WHEN** Run executes a plugin lifecycle start action in supervised mode
|
||||||
|
- **THEN** Run captures the process stdout and stderr into Run-owned durable capture files
|
||||||
|
- **AND** Run tails those capture files into durable log batch ingest using the plugin-declared process stream keys
|
||||||
|
- **AND** Run hides the managed Windows process window when the OS supports hidden startup
|
||||||
|
|
||||||
|
#### Scenario: Run restarts while the game process remains alive
|
||||||
|
- **WHEN** Run restarts and reloads its persisted process journal for an already-running supervised process
|
||||||
|
- **THEN** Run resumes tailing the Run-owned stdout/stderr capture files from persisted offsets
|
||||||
|
- **AND** Run does not inspect game-specific logs such as `SCUM.log` to synthesize terminal output
|
||||||
|
|
||||||
|
### Requirement: Browser log streaming uses platform session authorization
|
||||||
|
The log event stream SHALL be authorized by the current platform user session and server access rules. Plugins and browser code MUST NOT receive Run session tokens, component keys, host paths, raw credentials, or direct Run socket information.
|
||||||
|
|
||||||
|
#### Scenario: Unauthorized user subscribes
|
||||||
|
- **WHEN** a user without access opens a server log event stream
|
||||||
|
- **THEN** Platform rejects the request using the existing authorization error behavior
|
||||||
|
- **AND** no log entries or stream metadata are sent
|
||||||
|
|
||||||
|
### Requirement: Reverse proxies forward log events without buffering
|
||||||
|
The deployed platform_web reverse proxy SHALL forward the log event route without response buffering and with a long read timeout so idle log periods do not force browser polling.
|
||||||
|
|
||||||
|
#### Scenario: Nginx proxies SSE
|
||||||
|
- **WHEN** Nginx forwards `/api/v1/server-instances/{id}/logs/events`
|
||||||
|
- **THEN** buffering is disabled for that location
|
||||||
|
- **AND** the route does not require WebSocket upgrade headers
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
## Prompt Boundaries
|
||||||
|
|
||||||
|
- Positive prompt (正向提示词): Realtime server logs and the management terminal must use one platform-owned push stream per open view, with bounded history replay and no periodic `/log-streams/query` polling.
|
||||||
|
- Directional prompt (方向提示词): Work inside `platform/`, `platform_web/`, and `run/`, preserve durable Run log ingest, plugin-declared log-source ownership, platform session authorization, existing UI controls, and Nginx proxying; verify with focused Go/frontend tests plus structure checks.
|
||||||
|
- Boundary prompt (任务边界): Do not add browser-to-Run sockets, raw credentials, host paths, plugin-owned auth keys, billing/cloud workflows, or game-specific SCUM paths/commands to platform/run code.
|
||||||
|
|
||||||
|
## 1. Platform SSE Contract
|
||||||
|
|
||||||
|
- [x] 1.1 Add safe log event DTOs and a server-scoped SSE route.
|
||||||
|
- [x] 1.2 Publish newly accepted log batch entries to non-blocking server-instance subscribers.
|
||||||
|
- [x] 1.3 Replay bounded recent history on stream open while keeping cursor query available for explicit history.
|
||||||
|
|
||||||
|
## 2. Frontend Realtime Logs
|
||||||
|
|
||||||
|
- [x] 2.1 Add EventSource client support for server log events.
|
||||||
|
- [x] 2.2 Replace live log drawer and management terminal `/log-streams/query` intervals with SSE.
|
||||||
|
- [x] 2.3 Replace server detail log polling with SSE history replay and live append.
|
||||||
|
|
||||||
|
## 3. Proxy And Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Disable Nginx buffering for the log events route.
|
||||||
|
- [x] 3.2 Add focused backend/frontend tests for SSE behavior and no terminal polling.
|
||||||
|
- [x] 3.3 Run final verification: platform Go tests, platform_web tests/typecheck, `scripts/check-structure.sh`, and strict OpenSpec validation.
|
||||||
|
|
||||||
|
## 4. Run Process Log Capture
|
||||||
|
|
||||||
|
- [x] 4.1 Capture supervised process stdout/stderr through Run-owned durable capture files instead of game-specific log inspection.
|
||||||
|
- [x] 4.2 Resume managed process log tailing after Run restart using the persisted process journal and capture offsets.
|
||||||
|
- [x] 4.3 Keep plugin-declared log source keys optional for runtime readiness; explicit file backfill still uses the requested plugin source.
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/dto"
|
||||||
|
"browser.local/platform/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultLogEventHistoryLimit = 100
|
||||||
|
maxLogEventHistoryLimit = 500
|
||||||
|
logEventHeartbeatInterval = 15 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// serverLogEvents godoc
|
||||||
|
// @Summary Stream server log events
|
||||||
|
// @Description Streams safe server log entries over Server-Sent Events. Durable cursor query remains available for history and reconnect repair.
|
||||||
|
// @Tags logs
|
||||||
|
// @Produce text/event-stream
|
||||||
|
// @Param id path string true "Server instance ID"
|
||||||
|
// @Param historyLimit query int false "Recent entries per stream to replay before live events"
|
||||||
|
// @Success 200 {string} string "event-stream"
|
||||||
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
|
// @Failure 404 {object} dto.ErrorResponse
|
||||||
|
// @Failure 405 {object} dto.ErrorResponse
|
||||||
|
// @Router /api/v1/server-instances/{id}/logs/events [get]
|
||||||
|
func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
writeMethodNotAllowed(w, http.MethodGet)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
instance, streams, subscription, err := h.openLogEventSubscription(r)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer subscription.Close()
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
writeServiceError(w, fmt.Errorf("streaming response unsupported"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
header := w.Header()
|
||||||
|
header.Set("Content-Type", "text/event-stream")
|
||||||
|
header.Set("Cache-Control", "no-cache, no-transform")
|
||||||
|
header.Set("Connection", "keep-alive")
|
||||||
|
header.Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
historyLimit := parseLogEventHistoryLimit(r.URL.Query().Get("historyLimit"))
|
||||||
|
for _, stream := range streams {
|
||||||
|
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if historyLimit > 0 {
|
||||||
|
if err := h.writeLogEventHistory(w, stream, historyLimit); err != nil {
|
||||||
|
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
|
||||||
|
flusher.Flush()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := writeSSEJSON(w, "ready", "", dto.LogStreamEventsReadyResponse{ServerInstanceID: instance.ID, StreamCount: len(streams), ServerTime: time.Now().UTC()}); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
|
||||||
|
heartbeat := time.NewTicker(logEventHeartbeatInterval)
|
||||||
|
defer heartbeat.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-r.Context().Done():
|
||||||
|
return
|
||||||
|
case event, ok := <-subscription.Events:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
case <-heartbeat.C:
|
||||||
|
if _, err := fmt.Fprintf(w, ": heartbeat %s\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerInstance, []domain.LogStream, service.LogEventSubscription, error) {
|
||||||
|
var instance domain.ServerInstance
|
||||||
|
var streams []domain.LogStream
|
||||||
|
var subscription service.LogEventSubscription
|
||||||
|
var err error
|
||||||
|
if h.enforceAuthorization {
|
||||||
|
sessionID := bearerToken(r)
|
||||||
|
instance, err = h.core.GetServerInstanceForSession(sessionID, r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
|
}
|
||||||
|
streams, err = h.core.ListLogStreamsForSession(sessionID, domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
|
}
|
||||||
|
subscription, err = h.core.SubscribeLogEventsForSession(sessionID, instance.ID)
|
||||||
|
} else {
|
||||||
|
instance, err = h.core.GetServerInstance(r.PathValue("id"))
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
|
}
|
||||||
|
streams, err = h.core.ListLogStreams(domain.LogStreamFilter{ServerInstanceID: instance.ID})
|
||||||
|
if err != nil {
|
||||||
|
return domain.ServerInstance{}, nil, subscription, err
|
||||||
|
}
|
||||||
|
subscription, err = h.core.SubscribeLogEvents(instance.ID)
|
||||||
|
}
|
||||||
|
return instance, streams, subscription, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *coreHandlers) writeLogEventHistory(w http.ResponseWriter, stream domain.LogStream, limit int) error {
|
||||||
|
afterSeq := uint64(0)
|
||||||
|
if stream.LatestSeq > uint64(limit) {
|
||||||
|
afterSeq = stream.LatestSeq - uint64(limit)
|
||||||
|
}
|
||||||
|
cursor, err := h.core.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: stream.ID, AfterSeq: afterSeq, Limit: limit})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, entry := range cursor.Entries {
|
||||||
|
event := domain.LogStreamEvent{ServerInstanceID: stream.ServerInstanceID, Stream: stream, Entry: entry, LatestSeq: cursor.LatestSeq}
|
||||||
|
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseLogEventHistoryLimit(value string) int {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return defaultLogEventHistoryLimit
|
||||||
|
}
|
||||||
|
limit, err := strconv.Atoi(value)
|
||||||
|
if err != nil || limit < 0 {
|
||||||
|
return defaultLogEventHistoryLimit
|
||||||
|
}
|
||||||
|
if limit > maxLogEventHistoryLimit {
|
||||||
|
return maxLogEventHistoryLimit
|
||||||
|
}
|
||||||
|
return limit
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSSEJSON(w http.ResponseWriter, eventName string, id string, value any) error {
|
||||||
|
payload, err := json.Marshal(value)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
if _, err := fmt.Fprintf(w, "id: %s\n", sanitizeSSEField(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := fmt.Fprintf(w, "event: %s\n", sanitizeSSEField(eventName)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = fmt.Fprintf(w, "data: %s\n\n", payload)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSSEField(value string) string {
|
||||||
|
value = strings.ReplaceAll(value, "\r", "")
|
||||||
|
value = strings.ReplaceAll(value, "\n", "")
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func logEventID(event domain.LogStreamEvent) string {
|
||||||
|
return fmt.Sprintf("%s:%d", sanitizeSSEField(event.Stream.ID), event.Entry.Seq)
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -35,6 +38,32 @@ func TestLogIngestAPIWorkflow(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLogEventsSSEReplaysHistory(t *testing.T) {
|
||||||
|
router := newTestRouter()
|
||||||
|
hello := createLogIngestAPIFixtures(t, router)
|
||||||
|
batch := validLogBatchRequest(t, hello.SessionToken, 1, 2)
|
||||||
|
assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", batch), http.StatusOK)
|
||||||
|
|
||||||
|
server := httptest.NewServer(router)
|
||||||
|
defer server.Close()
|
||||||
|
client := server.Client()
|
||||||
|
client.Timeout = 2 * time.Second
|
||||||
|
response, err := client.Get(server.URL + "/api/v1/server-instances/server-1/logs/events?historyLimit=2")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open log event stream: %v", err)
|
||||||
|
}
|
||||||
|
defer response.Body.Close()
|
||||||
|
if response.StatusCode != http.StatusOK || !strings.HasPrefix(response.Header.Get("Content-Type"), "text/event-stream") {
|
||||||
|
t.Fatalf("unexpected event stream response: status=%d content-type=%q", response.StatusCode, response.Header.Get("Content-Type"))
|
||||||
|
}
|
||||||
|
body := readSSEUntil(t, response, "event: ready")
|
||||||
|
for _, fragment := range []string{"event: stream", "event: log", `"streamId":"log-1"`, `"seq":1`, `"seq":2`} {
|
||||||
|
if !strings.Contains(body, fragment) {
|
||||||
|
t.Fatalf("expected SSE body to contain %q, got:\n%s", fragment, body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
|
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
hello := createLogIngestAPIFixtures(t, router)
|
hello := createLogIngestAPIFixtures(t, router)
|
||||||
@@ -77,6 +106,20 @@ func createLogIngestAPIFixtures(t *testing.T, router http.Handler) dto.RunContro
|
|||||||
return hello
|
return hello
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func readSSEUntil(t *testing.T, response *http.Response, marker string) string {
|
||||||
|
t.Helper()
|
||||||
|
reader := bufio.NewReader(response.Body)
|
||||||
|
var body strings.Builder
|
||||||
|
for !strings.Contains(body.String(), marker) {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read event stream: %v\n%s", err, body.String())
|
||||||
|
}
|
||||||
|
body.WriteString(line)
|
||||||
|
}
|
||||||
|
return body.String()
|
||||||
|
}
|
||||||
|
|
||||||
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
|
func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, lastSeq uint64) dto.LogBatchIngestRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
|
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ func (h *coreHandlers) register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies/install", h.serverDependenciesInstall)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
mux.HandleFunc("/api/v1/server-instances/{id}/dependencies", h.serverDependencies)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
|
mux.HandleFunc("/api/v1/server-instances/{id}/logs/live", h.serverLiveLogs)
|
||||||
|
mux.HandleFunc("/api/v1/server-instances/{id}/logs/events", h.serverLogEvents)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
|
mux.HandleFunc("/api/v1/server-instances/{id}/logs/backfill", h.serverLogsBackfill)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
|
mux.HandleFunc("/api/v1/server-instances/{id}/config/diff", h.serverInstanceConfigDiff)
|
||||||
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
|
mux.HandleFunc("/api/v1/server-instances/{id}/config/approve", h.serverInstanceConfigApprove)
|
||||||
|
|||||||
@@ -114,7 +114,18 @@ func TestCoreAPICreateListDetailWorkflows(t *testing.T) {
|
|||||||
}
|
}
|
||||||
getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1")
|
getJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams/log-1")
|
||||||
streams := getJSON[dto.LogStreamListResponse](t, router, "/api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout")
|
streams := getJSON[dto.LogStreamListResponse](t, router, "/api/v1/log-streams?serverInstanceId=server-1&streamKey=stdout")
|
||||||
assertListCount(t, streams.Count, 1)
|
if streams.Count < 1 {
|
||||||
|
t.Fatalf("expected stdout streams, got %+v", streams)
|
||||||
|
}
|
||||||
|
foundExplicitStream := false
|
||||||
|
for _, stream := range streams.Items {
|
||||||
|
if stream.ID == "log-1" {
|
||||||
|
foundExplicitStream = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundExplicitStream {
|
||||||
|
t.Fatalf("expected explicitly created stream in list, got %+v", streams)
|
||||||
|
}
|
||||||
|
|
||||||
auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{
|
auditResponse := postJSON[dto.AuditEventResponse](t, router, "/api/v1/audit-events", dto.AuditEventCreateRequest{
|
||||||
ID: "audit-1",
|
ID: "audit-1",
|
||||||
@@ -361,7 +372,13 @@ func TestCoreAPIServerRuntimeDistributionAndJobWorkflows(t *testing.T) {
|
|||||||
t.Fatalf("unexpected log backfill job: %+v", backfill)
|
t.Fatalf("unexpected log backfill job: %+v", backfill)
|
||||||
}
|
}
|
||||||
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
|
liveLogs := getJSONWithAuth[dto.LogStreamListResponse](t, router, "/api/v1/server-instances/"+serverID+"/logs/live", adminSession)
|
||||||
if liveLogs.Count != 1 || liveLogs.Items[0].StreamKey != "stdout" {
|
foundFileLog := false
|
||||||
|
for _, stream := range liveLogs.Items {
|
||||||
|
if stream.Source == domain.LogStreamSourceFile && stream.StreamKey == "latest-log" {
|
||||||
|
foundFileLog = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundFileLog {
|
||||||
t.Fatalf("unexpected live logs: %+v", liveLogs)
|
t.Fatalf("unexpected live logs: %+v", liveLogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1503,7 +1520,7 @@ func TestRuntimeBindingAPIIsAuthorizedValidatedAndRedacted(t *testing.T) {
|
|||||||
assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation)
|
assertErrorResponse(t, undeclared, http.StatusBadRequest, errorCodeValidation)
|
||||||
|
|
||||||
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
|
created := postOKJSONWithAuth[dto.ServerLifecycleResponse](t, router, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-complete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Complete", IdempotencyKey: "runtime-create-complete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-create-complete/rcon"}}, adminSession)
|
||||||
if created.Job.TargetKey != "local" {
|
if created.Job.TargetKey != "actions/install.json" {
|
||||||
t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job)
|
t.Fatalf("create workflow did not dispatch selected profile: %+v", created.Job)
|
||||||
}
|
}
|
||||||
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-incomplete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Incomplete", IdempotencyKey: "runtime-create-incomplete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
|
incompleteCreate := requestJSONWithAuth(t, router, http.MethodPost, "/api/v1/server-instances/workflows/create", dto.ServerLifecycleCreateRequest{ID: "runtime-create-incomplete", PluginID: registration.Manifest.ID, RunEndpointID: "run-local", Name: "Runtime Create Incomplete", IdempotencyKey: "runtime-create-incomplete", ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}, adminSession)
|
||||||
@@ -1858,6 +1875,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
|||||||
}
|
}
|
||||||
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
pluginRequest.RuntimeProfiles.DependencyProbes = []dto.RuntimeDependencyProbeBody{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||||
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
pluginRequest.RuntimeProfiles.InstallPlans = []dto.RuntimeInstallPlanBody{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []dto.RuntimeInstallStepBody{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||||
|
pluginRequest.RuntimeProfiles.LogSources = []dto.RuntimeLogSourceBody{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
||||||
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
pluginRequest.RuntimeProfiles.ClientManagers = []dto.RuntimeClientManagerProfileBody{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", Repository: dto.RuntimeRepositoryBody{URL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main"}, SupportedTargets: []dto.RuntimeTargetBody{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, Build: dto.RuntimeBuildBody{System: "go", EntryRef: "main.go"}, OutputArtifacts: []string{"scum_client.exe"}, Deployment: dto.RuntimeClientManagerDeploymentBody{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: dto.RuntimeClientManagerLifecycleBody{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: dto.RuntimeClientManagerHealthBody{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: dto.RuntimeClientManagerCompatibilityBody{MinimumVersion: "1.0.0"}, UpdatePolicy: dto.RuntimeClientManagerUpdatePolicyBody{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||||
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
postJSON[dto.GamePluginResponse](t, router, "/api/v1/game-plugins", pluginRequest)
|
||||||
|
|
||||||
@@ -1886,7 +1904,7 @@ func createRuntimeAPIFixtures(t *testing.T, router http.Handler, adminSession st
|
|||||||
Name: "Runtime API Server",
|
Name: "Runtime API Server",
|
||||||
State: domain.ServerInstanceStateReady,
|
State: domain.ServerInstanceStateReady,
|
||||||
}, adminSession)
|
}, adminSession)
|
||||||
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{}}, adminSession)
|
putJSONWithAuth[dto.RuntimeBindingResponse](t, router, "/api/v1/server-instances/"+server.ID+"/runtime-binding", dto.RuntimeBindingUpdateRequest{ProfileKey: "local", Bindings: map[string]string{"logs/latest": "runtime.logs.latest"}}, adminSession)
|
||||||
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{
|
||||||
ID: "log-runtime-api",
|
ID: "log-runtime-api",
|
||||||
ServerInstanceID: server.ID,
|
ServerInstanceID: server.ID,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ All routes use JSON request and response bodies. Collection routes support `GET`
|
|||||||
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
|
| Plugin marketplace | `GET /api/v1/plugin-marketplace/plugins` | `GET /api/v1/plugin-marketplace/plugins/{id}`, `POST /api/v1/plugin-marketplace/plugins/{id}/state` | `MarketplacePluginResponse`, `MarketplacePluginListResponse`, `MarketplacePluginStateRequest` |
|
||||||
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
|
| Plugin bridge | `POST /api/v1/plugin-bridge/authorize`, `POST /api/v1/plugin-bridge/execute` | n/a | `PluginBridgeAuthorizeRequest`, `PluginBridgeAuthorizeResponse`, `PluginBridgeExecuteRequest`, `PluginBridgeExecuteResponse` |
|
||||||
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
| Server instances | `GET /api/v1/server-instances`, `POST /api/v1/server-instances` | `GET /api/v1/server-instances/{id}`, `PUT /api/v1/server-instances/{id}`, `DELETE /api/v1/server-instances/{id}` | `ServerInstanceCreateRequest`, `ServerInstanceUpdateRequest`, `ServerInstanceResponse`, `ServerInstanceListResponse` |
|
||||||
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest` |
|
| Server runtime distribution | n/a | `GET /api/v1/server-instances/{id}/runtime/actions`, `POST /api/v1/server-instances/{id}/run/generate`, `POST /api/v1/server-instances/{id}/run/download`, `POST /api/v1/server-instances/{id}/run/key/reset`, `POST /api/v1/server-instances/{id}/run/update`, `GET /api/v1/server-instances/{id}/run/update`, `POST /api/v1/server-instances/{id}/client-managers/generate`, `POST /api/v1/server-instances/{id}/client-managers/download`, `POST /api/v1/server-instances/{id}/client-managers/key/reset`, `GET /api/v1/server-instances/{id}/dependencies`, `POST /api/v1/server-instances/{id}/dependencies/check`, `POST /api/v1/server-instances/{id}/dependencies/install`, `GET /api/v1/server-instances/{id}/logs/live`, `GET /api/v1/server-instances/{id}/logs/events`, `POST /api/v1/server-instances/{id}/logs/backfill` | `ServerRuntimeActionsResponse`, `RunDistributionGenerateRequest`, `RunDistributionResponse`, `RunUpdateRequest`, `RunUpdateJobResponse`/`RunUpdateJobListResponse`, `ClientManagerBuildRequest`, `ClientManagerDistributionResponse`, `ClientManagerDownloadRequest`, `ComponentKeyResetRequest`, `ComponentKeyResponse`, `DependencyCatalogResponse`, `DependencyJobRequest`, `LogBackfillRequest`, `LogStreamEventResponse` |
|
||||||
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
| Metrics | `GET /api/v1/metrics/platform`, `GET /api/v1/metrics/server-instances` | n/a | `PlatformResourceUsageResponse`, `ServerMetricsResponse`, `ServerMetricsListResponse` |
|
||||||
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
|
| Server config | n/a | `GET /api/v1/server-instances/{id}/config`, `POST /api/v1/server-instances/{id}/config/diff`, `POST /api/v1/server-instances/{id}/config/approve` | `ServerConfigResponse`, `ServerConfigDiffPreviewRequest`, `ServerConfigDiffPreviewResponse`, `ServerConfigWriteApprovalRequest`, `ServerConfigWriteDispatchResponse` |
|
||||||
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
| File operations | `POST /api/v1/file-operations/dispatch` | n/a | `FileOperationDispatchRequest`, `FileOperationDispatchResponse` |
|
||||||
@@ -146,7 +146,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
|||||||
## Implemented Runtime Distribution And Client Manager Actions
|
## Implemented Runtime Distribution And Client Manager Actions
|
||||||
|
|
||||||
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
|
- `GET /api/v1/server-instances/{id}/runtime-binding`: returns the visible server's selected profile and redacted logical binding readiness. Values are represented only by configured/secret-backed flags.
|
||||||
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs. Undeclared keys, unsafe paths/sockets/credentials, and changes to an existing active binding are rejected.
|
- `PUT /api/v1/server-instances/{id}/runtime-binding`: lets the server owner or a platform administrator select a declared profile and patch safe logical refs for non-deleted servers. Undeclared keys, unsafe paths/sockets/credentials, and plaintext secrets are rejected.
|
||||||
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
|
- `GET /api/v1/server-instances/{id}/runtime/actions`: returns the current user-visible runtime action matrix for the server, including run endpoint status, action availability, and safe unavailable reasons.
|
||||||
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, queues a platform-owned Docker build, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, build job ID, and redacted secret ref only.
|
- `POST /api/v1/server-instances/{id}/run/generate`: accepts `RunDistributionGenerateRequest`, queues a platform-owned Docker build, creates or reuses the server's current encrypted run key, writes that key into the secret-bearing generated package config, publishes an artifact, and returns `RunDistributionResponse` with checksum, key generation, artifact ID, build job ID, and redacted secret ref only.
|
||||||
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
|
- `POST /api/v1/server-instances/{id}/run/download`: opens the latest available run package through `ArtifactDownloadReferenceResponse` after server-scoped authorization.
|
||||||
@@ -160,6 +160,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins
|
|||||||
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
- `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key.
|
||||||
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
- `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation.
|
||||||
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
|
- `GET /api/v1/server-instances/{id}/logs/live`: returns safe live log stream metadata for the selected server using `LogStreamListResponse`.
|
||||||
|
- `GET /api/v1/server-instances/{id}/logs/events`: streams selected server log metadata and entries as `text/event-stream`; the optional `historyLimit` query replays recent stored entries before live push events.
|
||||||
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
|
- `POST /api/v1/server-instances/{id}/logs/backfill`: accepts `LogBackfillRequest`, queues a `logs.backfill` job with source key, checkpoint ref, limit, and idempotency metadata, and keeps log bodies out of job results.
|
||||||
|
|
||||||
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support. Responses and audit summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs.
|
Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings where required, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support. Responses and audit 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.
|
||||||
@@ -196,6 +197,7 @@ Job ack/progress/result/cancel/reconcile calls remain lightweight and independen
|
|||||||
|
|
||||||
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
|
- `POST /api/v1/run/logs/batches`: accept `LogBatchIngestRequest`, validate run session and stream metadata, store contiguous entries, update `LogStream.LatestSeq`, and return `LogBatchIngestResponse` with the acknowledged range.
|
||||||
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
|
- `POST /api/v1/log-streams/query`: accept `LogStreamCursorRequest` and return `LogStreamCursorResponse` with bounded ordered entries after a cursor.
|
||||||
|
- `GET /api/v1/server-instances/{id}/logs/events`: authorize the browser session for the server, replay bounded recent entries, and push newly ingested log entries over SSE without polling log stream queries.
|
||||||
|
|
||||||
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
Log ingest actions carry durable log metadata and bounded entries only: run endpoint ID, session token, stream identity, source, sequence range, compression metadata, checksum, entries, and cursor limits. They do not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
||||||
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||||
|
|||||||
@@ -324,6 +324,8 @@ type RunJobReconcileResult struct {
|
|||||||
|
|
||||||
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
|
func CopyRunJobAssignment(assignment RunJobAssignment) RunJobAssignment {
|
||||||
assignment.ExecutionInput.Inputs = CopyStringMap(assignment.ExecutionInput.Inputs)
|
assignment.ExecutionInput.Inputs = CopyStringMap(assignment.ExecutionInput.Inputs)
|
||||||
|
assignment.ExecutionInput.LogSource = CopyRuntimeLogSourcePtr(assignment.ExecutionInput.LogSource)
|
||||||
|
assignment.ExecutionInput.LogSources = CopyRuntimeLogSources(assignment.ExecutionInput.LogSources)
|
||||||
assignment.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), assignment.ExecutionInput.DLLExtensions...)
|
assignment.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), assignment.ExecutionInput.DLLExtensions...)
|
||||||
assignment.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(assignment.ExecutionInput.SourceRCON)
|
assignment.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(assignment.ExecutionInput.SourceRCON)
|
||||||
return assignment
|
return assignment
|
||||||
|
|||||||
@@ -48,6 +48,13 @@ type LogStreamCursorResult struct {
|
|||||||
LatestSeq uint64
|
LatestSeq uint64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogStreamEvent struct {
|
||||||
|
ServerInstanceID string
|
||||||
|
Stream LogStream
|
||||||
|
Entry LogEntry
|
||||||
|
LatestSeq uint64
|
||||||
|
}
|
||||||
|
|
||||||
type LogBatchRecord struct {
|
type LogBatchRecord struct {
|
||||||
Checksum string
|
Checksum string
|
||||||
FirstSeq uint64
|
FirstSeq uint64
|
||||||
@@ -87,6 +94,12 @@ func CopyLogStreamCursorResult(result LogStreamCursorResult) LogStreamCursorResu
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CopyLogStreamEvent(event LogStreamEvent) LogStreamEvent {
|
||||||
|
event.Stream = CopyLogStream(event.Stream)
|
||||||
|
event.Entry = CopyLogEntry(event.Entry)
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
func CopyLogBatchRecord(record LogBatchRecord) LogBatchRecord {
|
func CopyLogBatchRecord(record LogBatchRecord) LogBatchRecord {
|
||||||
record.Entries = CopyLogEntries(record.Entries)
|
record.Entries = CopyLogEntries(record.Entries)
|
||||||
return record
|
return record
|
||||||
|
|||||||
@@ -1109,6 +1109,8 @@ type JobExecutionInput struct {
|
|||||||
LifecycleOperation string
|
LifecycleOperation string
|
||||||
TargetVersion string
|
TargetVersion string
|
||||||
Inputs map[string]string
|
Inputs map[string]string
|
||||||
|
LogSource *RuntimeLogSource
|
||||||
|
LogSources []RuntimeLogSource
|
||||||
DLLExtensions []RuntimeDLLExtensionPlan
|
DLLExtensions []RuntimeDLLExtensionPlan
|
||||||
SourceRCON *RuntimeSourceRCONPlan
|
SourceRCON *RuntimeSourceRCONPlan
|
||||||
Deployment *ServerDeploymentDefinition
|
Deployment *ServerDeploymentDefinition
|
||||||
@@ -1988,6 +1990,8 @@ func CopyRunEndpoint(endpoint RunEndpoint) RunEndpoint {
|
|||||||
|
|
||||||
func CopyJob(job Job) Job {
|
func CopyJob(job Job) Job {
|
||||||
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
|
job.ExecutionInput.Inputs = CopyStringMap(job.ExecutionInput.Inputs)
|
||||||
|
job.ExecutionInput.LogSource = CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource)
|
||||||
|
job.ExecutionInput.LogSources = CopyRuntimeLogSources(job.ExecutionInput.LogSources)
|
||||||
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
|
job.ExecutionInput.DLLExtensions = append([]RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...)
|
||||||
job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)
|
job.ExecutionInput.SourceRCON = CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON)
|
||||||
job.ExecutionInput.ServerDeploymentPlan = CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)
|
job.ExecutionInput.ServerDeploymentPlan = CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)
|
||||||
@@ -2000,6 +2004,21 @@ func CopyJob(job Job) Job {
|
|||||||
return job
|
return job
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func CopyRuntimeLogSourcePtr(source *RuntimeLogSource) *RuntimeLogSource {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
copy := *source
|
||||||
|
return ©
|
||||||
|
}
|
||||||
|
|
||||||
|
func CopyRuntimeLogSources(sources []RuntimeLogSource) []RuntimeLogSource {
|
||||||
|
if sources == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return append([]RuntimeLogSource(nil), sources...)
|
||||||
|
}
|
||||||
|
|
||||||
func CopyRuntimeSourceRCONPlan(plan *RuntimeSourceRCONPlan) *RuntimeSourceRCONPlan {
|
func CopyRuntimeSourceRCONPlan(plan *RuntimeSourceRCONPlan) *RuntimeSourceRCONPlan {
|
||||||
if plan == nil {
|
if plan == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ type RunJobExecutionInputBody struct {
|
|||||||
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
|
LifecycleOperation string `json:"lifecycleOperation,omitempty"`
|
||||||
TargetVersion string `json:"targetVersion,omitempty"`
|
TargetVersion string `json:"targetVersion,omitempty"`
|
||||||
Inputs map[string]string `json:"inputs,omitempty"`
|
Inputs map[string]string `json:"inputs,omitempty"`
|
||||||
|
LogSource *RuntimeLogSourceBody `json:"logSource,omitempty"`
|
||||||
|
LogSources []RuntimeLogSourceBody `json:"logSources,omitempty"`
|
||||||
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
|
DLLExtensions []RuntimeDLLExtensionPlanBody `json:"dllExtensions,omitempty"`
|
||||||
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
|
SourceRCON *RuntimeSourceRCONPlanBody `json:"sourceRcon,omitempty"`
|
||||||
Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"`
|
Deployment *ServerDeploymentExecutionBody `json:"deployment,omitempty"`
|
||||||
@@ -661,7 +663,7 @@ func RunJobAssignmentFromDomain(assignment domain.RunJobAssignment) RunJobAssign
|
|||||||
State: assignment.State,
|
State: assignment.State,
|
||||||
Progress: progressReportFromDomain(assignment.Progress),
|
Progress: progressReportFromDomain(assignment.Progress),
|
||||||
ResultRef: assignment.ResultRef,
|
ResultRef: assignment.ResultRef,
|
||||||
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
|
ExecutionInput: RunJobExecutionInputBody{WorkspaceScope: assignment.ExecutionInput.WorkspaceScope, Content: assignment.ExecutionInput.Content, ExpectedVersion: assignment.ExecutionInput.ExpectedVersion, ExpectedChecksum: assignment.ExecutionInput.ExpectedChecksum, MaxReadBytes: assignment.ExecutionInput.MaxReadBytes, RemoteAdapterKey: assignment.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: assignment.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: assignment.ExecutionInput.TimeoutSeconds, PluginID: assignment.ExecutionInput.PluginID, LifecycleOperation: assignment.ExecutionInput.LifecycleOperation, TargetVersion: assignment.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(assignment.ExecutionInput.Inputs), LogSource: runtimeLogSourceFromDomain(assignment.ExecutionInput.LogSource), LogSources: runtimeLogSourcesFromDomain(assignment.ExecutionInput.LogSources), DLLExtensions: dllExtensionPlansFromDomain(assignment.ExecutionInput.DLLExtensions), SourceRCON: runtimeSourceRCONPlanFromDomain(assignment.ExecutionInput.SourceRCON), Deployment: deploymentExecutionFromDomain(assignment.ExecutionInput.Deployment), ServerDeploymentPlan: serverDeploymentPlanFromDomain(assignment.ExecutionInput.ServerDeploymentPlan)},
|
||||||
LeaseToken: assignment.LeaseToken,
|
LeaseToken: assignment.LeaseToken,
|
||||||
Attempt: assignment.Attempt,
|
Attempt: assignment.Attempt,
|
||||||
FencingToken: assignment.FencingToken,
|
FencingToken: assignment.FencingToken,
|
||||||
@@ -736,6 +738,24 @@ func runtimeSourceRCONPlanFromDomain(plan *domain.RuntimeSourceRCONPlan) *Runtim
|
|||||||
return &RuntimeSourceRCONPlanBody{Protocol: plan.Protocol, ExtensionKey: plan.ExtensionKey, ModKey: plan.ModKey, ConfigRef: plan.ConfigRef, DeploymentStateRef: plan.DeploymentStateRef, Port: plan.Port}
|
return &RuntimeSourceRCONPlanBody{Protocol: plan.Protocol, ExtensionKey: plan.ExtensionKey, ModKey: plan.ModKey, ConfigRef: plan.ConfigRef, DeploymentStateRef: plan.DeploymentStateRef, Port: plan.Port}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runtimeLogSourceFromDomain(source *domain.RuntimeLogSource) *RuntimeLogSourceBody {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &RuntimeLogSourceBody{Key: source.Key, Kind: source.Kind, TargetKey: source.TargetKey, StreamKey: source.StreamKey, CursorKind: source.CursorKind, RetentionDays: source.RetentionDays}
|
||||||
|
}
|
||||||
|
|
||||||
|
func runtimeLogSourcesFromDomain(sources []domain.RuntimeLogSource) []RuntimeLogSourceBody {
|
||||||
|
if len(sources) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]RuntimeLogSourceBody, 0, len(sources))
|
||||||
|
for _, source := range sources {
|
||||||
|
out = append(out, RuntimeLogSourceBody{Key: source.Key, Kind: source.Kind, TargetKey: source.TargetKey, StreamKey: source.StreamKey, CursorKind: source.CursorKind, RetentionDays: source.RetentionDays})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport {
|
func progressReportToDomain(progress JobProgressBody) domain.RunJobProgressReport {
|
||||||
return domain.RunJobProgressReport{
|
return domain.RunJobProgressReport{
|
||||||
Percent: progress.Percent,
|
Percent: progress.Percent,
|
||||||
|
|||||||
@@ -53,6 +53,21 @@ type LogStreamCursorResponse struct {
|
|||||||
LatestSeq uint64 `json:"latestSeq"`
|
LatestSeq uint64 `json:"latestSeq"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LogStreamEventResponse struct {
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
StreamID string `json:"streamId"`
|
||||||
|
Source domain.LogStreamSource `json:"source"`
|
||||||
|
StreamKey string `json:"streamKey"`
|
||||||
|
LatestSeq uint64 `json:"latestSeq"`
|
||||||
|
Entry LogEntryBody `json:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogStreamEventsReadyResponse struct {
|
||||||
|
ServerInstanceID string `json:"serverInstanceId"`
|
||||||
|
StreamCount int `json:"streamCount"`
|
||||||
|
ServerTime time.Time `json:"serverTime"`
|
||||||
|
}
|
||||||
|
|
||||||
func (request LogBatchIngestRequest) ToDomain() domain.LogBatchIngest {
|
func (request LogBatchIngestRequest) ToDomain() domain.LogBatchIngest {
|
||||||
return domain.LogBatchIngest{
|
return domain.LogBatchIngest{
|
||||||
RunEndpointID: request.RunEndpointID,
|
RunEndpointID: request.RunEndpointID,
|
||||||
@@ -99,6 +114,18 @@ func LogStreamCursorFromDomain(result domain.LogStreamCursorResult) LogStreamCur
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func LogStreamEventFromDomain(event domain.LogStreamEvent) LogStreamEventResponse {
|
||||||
|
event = domain.CopyLogStreamEvent(event)
|
||||||
|
return LogStreamEventResponse{
|
||||||
|
ServerInstanceID: event.ServerInstanceID,
|
||||||
|
StreamID: event.Stream.ID,
|
||||||
|
Source: event.Stream.Source,
|
||||||
|
StreamKey: event.Stream.StreamKey,
|
||||||
|
LatestSeq: event.LatestSeq,
|
||||||
|
Entry: logEntryFromDomain(event.Entry),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
|
func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -117,20 +144,24 @@ func logEntriesToDomain(entries []LogEntryBody) []domain.LogEntry {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func logEntryFromDomain(entry domain.LogEntry) LogEntryBody {
|
||||||
|
return LogEntryBody{
|
||||||
|
Seq: entry.Seq,
|
||||||
|
Timestamp: entry.Timestamp,
|
||||||
|
Level: entry.Level,
|
||||||
|
Line: entry.Line,
|
||||||
|
Fields: copyStringMap(entry.Fields),
|
||||||
|
Redacted: entry.Redacted,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func logEntriesFromDomain(entries []domain.LogEntry) []LogEntryBody {
|
func logEntriesFromDomain(entries []domain.LogEntry) []LogEntryBody {
|
||||||
if entries == nil {
|
if entries == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]LogEntryBody, len(entries))
|
out := make([]LogEntryBody, len(entries))
|
||||||
for i, entry := range entries {
|
for i, entry := range entries {
|
||||||
out[i] = LogEntryBody{
|
out[i] = logEntryFromDomain(entry)
|
||||||
Seq: entry.Seq,
|
|
||||||
Timestamp: entry.Timestamp,
|
|
||||||
Level: entry.Level,
|
|
||||||
Line: entry.Line,
|
|
||||||
Fields: copyStringMap(entry.Fields),
|
|
||||||
Redacted: entry.Redacted,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ Implemented HTTP JSON routes:
|
|||||||
|
|
||||||
- `POST /api/v1/run/logs/batches`
|
- `POST /api/v1/run/logs/batches`
|
||||||
- `POST /api/v1/log-streams/query`
|
- `POST /api/v1/log-streams/query`
|
||||||
|
- `GET /api/v1/server-instances/{id}/logs/events`
|
||||||
|
|
||||||
Named log DTOs:
|
Named log DTOs:
|
||||||
|
|
||||||
@@ -71,8 +72,9 @@ Named log DTOs:
|
|||||||
- `LogEntry`
|
- `LogEntry`
|
||||||
- `LogStreamCursorRequest`
|
- `LogStreamCursorRequest`
|
||||||
- `LogStreamCursorResponse`
|
- `LogStreamCursorResponse`
|
||||||
|
- `LogStreamEventResponse`
|
||||||
|
|
||||||
Log ingest supports bounded batches, sequence ranges, checksum validation, retry-safe duplicate acknowledgement, latest sequence tracking, and cursor query. Log payloads must not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
Log ingest supports bounded batches, sequence ranges, checksum validation, retry-safe duplicate acknowledgement, latest sequence tracking, cursor query, and browser SSE fan-out from already-ingested platform logs. Log payloads must not carry artifact chunks, host paths, raw credentials, direct sockets, or unbounded inline data.
|
||||||
|
|
||||||
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
Log ingest is durable and independently retried. Artifact/file transfer backlog must not prevent log batch acknowledgement, duplicate acknowledgement, cursor state updates, or spool cleanup.
|
||||||
|
|
||||||
|
|||||||
@@ -745,6 +745,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
|||||||
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: plugin capability is not declared")
|
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: plugin capability is not declared")
|
||||||
return domain.Job{}, ErrForbidden
|
return domain.Job{}, ErrForbidden
|
||||||
}
|
}
|
||||||
|
source, err := declaredFileLogSource(plugin, request.SourceKey)
|
||||||
|
if err != nil {
|
||||||
|
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: log source is not declared")
|
||||||
|
return domain.Job{}, err
|
||||||
|
}
|
||||||
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "logs.backfill.denied"); err != nil {
|
if err := svc.requireCompleteRuntimeBindings(user.ID, instance.ID, "logs.backfill.denied"); err != nil {
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
@@ -753,10 +758,11 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
|||||||
ServerInstanceID: instance.ID,
|
ServerInstanceID: instance.ID,
|
||||||
RunEndpointID: instance.RunEndpointID,
|
RunEndpointID: instance.RunEndpointID,
|
||||||
Capability: domain.JobCapabilityLogsBackfill,
|
Capability: domain.JobCapabilityLogsBackfill,
|
||||||
TargetKey: "logs/" + request.SourceKey,
|
TargetKey: "logs/" + source.Key,
|
||||||
InputRef: request.CheckpointRef,
|
InputRef: request.CheckpointRef,
|
||||||
IdempotencyKey: request.IdempotencyKey,
|
IdempotencyKey: request.IdempotencyKey,
|
||||||
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
|
Progress: domain.JobProgress{Percent: 0, Message: "historical log backfill queued"},
|
||||||
|
ExecutionInput: domain.JobExecutionInput{LogSource: &source},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
|
_ = svc.recordAuditEvent(user.ID, "logs.backfill.denied", "server-instance", instance.ID, domain.AuditResultDenied, "log backfill denied: endpoint unsupported or offline")
|
||||||
@@ -768,6 +774,20 @@ func (svc *CoreService) QueueLogBackfillForSession(sessionID string, request dom
|
|||||||
return domain.CopyJob(job), nil
|
return domain.CopyJob(job), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func declaredFileLogSource(plugin domain.GamePlugin, sourceKey string) (domain.RuntimeLogSource, error) {
|
||||||
|
for _, source := range plugin.RuntimeProfiles.LogSources {
|
||||||
|
if source.Key != sourceKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if source.Kind != "file.tail" || strings.TrimSpace(source.TargetKey) == "" || strings.TrimSpace(source.StreamKey) == "" {
|
||||||
|
return domain.RuntimeLogSource{}, validationError("log source must be a file.tail source with target and stream keys")
|
||||||
|
}
|
||||||
|
copy := source
|
||||||
|
return copy, nil
|
||||||
|
}
|
||||||
|
return domain.RuntimeLogSource{}, validationError("log source is not declared by plugin")
|
||||||
|
}
|
||||||
|
|
||||||
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
func (svc *CoreService) validateDistributionPluginPermission(actorID string, plugin domain.GamePlugin, serverInstanceID string, permission string, deniedAction string) error {
|
||||||
if plugin.Status != domain.GamePluginStatusInstalled {
|
if plugin.Status != domain.GamePluginStatusInstalled {
|
||||||
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
|
_ = svc.recordAuditEvent(actorID, deniedAction, "server-instance", serverInstanceID, domain.AuditResultDenied, "distribution operation denied: plugin is not installed")
|
||||||
|
|||||||
@@ -627,6 +627,7 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
|||||||
)
|
)
|
||||||
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
plugin.RuntimeProfiles.DependencyProbes = []domain.RuntimeDependencyProbe{{Key: "java-runtime", Kind: "command.version", TargetKey: "java", Platforms: []string{"linux"}}}
|
||||||
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
plugin.RuntimeProfiles.InstallPlans = []domain.RuntimeInstallPlan{{Key: "java-install", Title: "Install Java", Platforms: []string{"linux"}, Steps: []domain.RuntimeInstallStep{{Type: "package", TargetKey: "java", PackageManager: "apt", PackageName: "openjdk-21-jre"}}}}
|
||||||
|
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30}}
|
||||||
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", DisplayName: "SCUM Client Manager", Version: "1.0.0", RepositoryURL: "https://github.com/F88888/scum_client.git", RevisionPolicy: "branch", Branch: "main", SupportedTargets: []domain.RuntimeTarget{{OS: "windows", Arch: "amd64"}, {OS: "linux", Arch: "amd64"}}, BuildSystem: "go", EntryRef: "main.go", OutputArtifacts: []string{"scum_client.exe"}, Deployment: domain.RuntimeClientManagerDeployment{Mode: "run-supervised", ExecutableRef: "scum_client.exe", RequiredRunCapabilities: []string{domain.JobCapabilityClientManagerDeploy, domain.JobCapabilityClientManagerControl, domain.JobCapabilityClientManagerUpdate, domain.JobCapabilityClientManagerRollback, domain.JobCapabilityClientManagerUninstall}}, Lifecycle: domain.RuntimeClientManagerLifecycle{Actions: []string{"start", "stop", "restart", "status", "update", "rollback", "uninstall"}, StartupTimeoutSeconds: 60, StopTimeoutSeconds: 30}, Health: domain.RuntimeClientManagerHealth{Mode: "component-heartbeat", IntervalSeconds: 15, DegradedAfterSeconds: 45, OfflineAfterSeconds: 120, RequiredCapabilities: []string{"component.register", "component.heartbeat", "component.health"}}, Compatibility: domain.RuntimeClientManagerCompatibility{MinimumVersion: "1.0.0"}, UpdatePolicy: domain.RuntimeClientManagerUpdatePolicy{Strategy: "manual-staged", RequireApproval: true, HealthConfirmationSeconds: 60, RetainPrevious: true}}}
|
||||||
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
t.Fatalf("update plugin fixture: %v", err)
|
t.Fatalf("update plugin fixture: %v", err)
|
||||||
@@ -664,10 +665,35 @@ func newDistributionTestFixture(t *testing.T) (*CoreService, string, domain.Serv
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create distribution server: %v", err)
|
t.Fatalf("create distribution server: %v", err)
|
||||||
}
|
}
|
||||||
createCompleteRuntimeBinding(t, svc, instance, "local")
|
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"logs/latest": "runtime.logs.latest"}}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("build complete runtime binding: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.store.RuntimeBindings().Create(binding); err != nil {
|
||||||
|
t.Fatalf("create runtime binding: %v", err)
|
||||||
|
}
|
||||||
return svc, session, instance
|
return svc, session, instance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestQueueLogBackfillFreezesDeclaredFileLogSource(t *testing.T) {
|
||||||
|
svc, session, instance := newDistributionTestFixture(t)
|
||||||
|
|
||||||
|
job, err := svc.QueueLogBackfillForSession(session, domain.LogBackfillRequest{ServerInstanceID: instance.ID, SourceKey: "latest", CheckpointRef: "artifact://logs/checkpoint/1", IdempotencyKey: "log-source-freeze"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queue log backfill: %v", err)
|
||||||
|
}
|
||||||
|
if job.ExecutionInput.LogSource == nil || job.ExecutionInput.LogSource.Key != "latest" || job.ExecutionInput.LogSource.StreamKey != "latest-log" || job.ExecutionInput.LogSource.TargetKey != "logs/latest" {
|
||||||
|
t.Fatalf("expected frozen declared log source, got %+v", job.ExecutionInput.LogSource)
|
||||||
|
}
|
||||||
|
stream, err := svc.GetLogStream(jobLogStreamID(job.ID, "latest-log"))
|
||||||
|
if err != nil || stream.Source != domain.LogStreamSourceFile || stream.StreamKey != "latest-log" {
|
||||||
|
t.Fatalf("expected file log stream for backfill job, stream=%+v err=%v", stream, err)
|
||||||
|
}
|
||||||
|
if _, err := svc.QueueLogBackfillForSession(session, domain.LogBackfillRequest{ServerInstanceID: instance.ID, SourceKey: "missing", IdempotencyKey: "log-source-missing"}); err == nil || !strings.Contains(err.Error(), "not declared") {
|
||||||
|
t.Fatalf("expected undeclared source rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
|
func readGeneratedPackageConfig(t *testing.T, svc *CoreService, session string, artifactID string) generatedPackageConfig {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
_ = session
|
_ = session
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ func assignmentFromJob(job domain.Job, leaseToken string) domain.RunJobAssignmen
|
|||||||
State: job.State,
|
State: job.State,
|
||||||
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message},
|
Progress: domain.RunJobProgressReport{Percent: job.Progress.Percent, Phase: job.Progress.Phase, Message: job.Progress.Message},
|
||||||
ResultRef: job.ResultRef,
|
ResultRef: job.ResultRef,
|
||||||
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: job.ExecutionInput.WorkspaceScope, Content: job.ExecutionInput.Content, ExpectedVersion: job.ExecutionInput.ExpectedVersion, ExpectedChecksum: job.ExecutionInput.ExpectedChecksum, MaxReadBytes: job.ExecutionInput.MaxReadBytes, RemoteAdapterKey: job.ExecutionInput.RemoteAdapterKey, RemoteAdapterKind: job.ExecutionInput.RemoteAdapterKind, TimeoutSeconds: job.ExecutionInput.TimeoutSeconds, PluginID: job.ExecutionInput.PluginID, LifecycleOperation: job.ExecutionInput.LifecycleOperation, TargetVersion: job.ExecutionInput.TargetVersion, Inputs: domain.CopyStringMap(job.ExecutionInput.Inputs), LogSource: domain.CopyRuntimeLogSourcePtr(job.ExecutionInput.LogSource), LogSources: domain.CopyRuntimeLogSources(job.ExecutionInput.LogSources), DLLExtensions: append([]domain.RuntimeDLLExtensionPlan(nil), job.ExecutionInput.DLLExtensions...), SourceRCON: domain.CopyRuntimeSourceRCONPlan(job.ExecutionInput.SourceRCON), Deployment: deploymentPlanForDispatchValue(job.ExecutionInput.Deployment), ServerDeploymentPlan: domain.CopyServerDeploymentPlan(job.ExecutionInput.ServerDeploymentPlan)},
|
||||||
LeaseToken: leaseToken,
|
LeaseToken: leaseToken,
|
||||||
Attempt: job.Attempt,
|
Attempt: job.Attempt,
|
||||||
FencingToken: fencingToken,
|
FencingToken: fencingToken,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"browser.local/platform/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
const logEventSubscriberBuffer = 512
|
||||||
|
|
||||||
|
type LogEventSubscription struct {
|
||||||
|
Events <-chan domain.LogStreamEvent
|
||||||
|
Close func()
|
||||||
|
}
|
||||||
|
|
||||||
|
type logEventSubscriber struct {
|
||||||
|
serverInstanceID string
|
||||||
|
events chan domain.LogStreamEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) SubscribeLogEvents(serverInstanceID string) (LogEventSubscription, error) {
|
||||||
|
serverInstanceID = strings.TrimSpace(serverInstanceID)
|
||||||
|
if serverInstanceID == "" {
|
||||||
|
return LogEventSubscription{}, validationError("serverInstanceId is required")
|
||||||
|
}
|
||||||
|
if _, err := svc.store.ServerInstances().Get(serverInstanceID); err != nil {
|
||||||
|
return LogEventSubscription{}, err
|
||||||
|
}
|
||||||
|
events := make(chan domain.LogStreamEvent, logEventSubscriberBuffer)
|
||||||
|
svc.logEventMu.Lock()
|
||||||
|
svc.logEventSubscriberSeq++
|
||||||
|
id := svc.logEventSubscriberSeq
|
||||||
|
svc.logEventSubscribers[id] = logEventSubscriber{serverInstanceID: serverInstanceID, events: events}
|
||||||
|
svc.logEventMu.Unlock()
|
||||||
|
closeOnce := func() {
|
||||||
|
svc.logEventMu.Lock()
|
||||||
|
if subscriber, ok := svc.logEventSubscribers[id]; ok {
|
||||||
|
delete(svc.logEventSubscribers, id)
|
||||||
|
close(subscriber.events)
|
||||||
|
}
|
||||||
|
svc.logEventMu.Unlock()
|
||||||
|
}
|
||||||
|
return LogEventSubscription{Events: events, Close: closeOnce}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) SubscribeLogEventsForSession(sessionID string, serverInstanceID string) (LogEventSubscription, error) {
|
||||||
|
instance, err := svc.GetServerInstanceForSession(sessionID, serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return LogEventSubscription{}, err
|
||||||
|
}
|
||||||
|
return svc.SubscribeLogEvents(instance.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) publishLogEvents(stream domain.LogStream, entries []domain.LogEntry) {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
events := make([]domain.LogStreamEvent, len(entries))
|
||||||
|
for index, entry := range entries {
|
||||||
|
events[index] = domain.CopyLogStreamEvent(domain.LogStreamEvent{
|
||||||
|
ServerInstanceID: stream.ServerInstanceID,
|
||||||
|
Stream: stream,
|
||||||
|
Entry: entry,
|
||||||
|
LatestSeq: stream.LatestSeq,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
svc.logEventMu.Lock()
|
||||||
|
for id, subscriber := range svc.logEventSubscribers {
|
||||||
|
if subscriber.serverInstanceID != stream.ServerInstanceID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dropped := false
|
||||||
|
for _, event := range events {
|
||||||
|
select {
|
||||||
|
case subscriber.events <- event:
|
||||||
|
default:
|
||||||
|
delete(svc.logEventSubscribers, id)
|
||||||
|
close(subscriber.events)
|
||||||
|
dropped = true
|
||||||
|
}
|
||||||
|
if dropped {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svc.logEventMu.Unlock()
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
package service
|
package service
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"browser.local/platform/domain"
|
"browser.local/platform/domain"
|
||||||
|
"browser.local/platform/repo"
|
||||||
"browser.local/platform/validator"
|
"browser.local/platform/validator"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,6 +24,11 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
|||||||
|
|
||||||
stamp := svc.now()
|
stamp := svc.now()
|
||||||
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
stream, err := svc.store.LogStreams().Get(batch.LogStreamID)
|
||||||
|
if errors.Is(err, repo.ErrNotFound) {
|
||||||
|
if repairErr := svc.ensureJobLogStreamForBatch(batch, stamp); repairErr == nil {
|
||||||
|
stream, err = svc.store.LogStreams().Get(batch.LogStreamID)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.LogBatchIngestResult{}, err
|
return domain.LogBatchIngestResult{}, err
|
||||||
}
|
}
|
||||||
@@ -76,6 +86,7 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
|||||||
if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil {
|
if err := svc.projectGameMapTrajectoryEvents(projectionBatch); err != nil {
|
||||||
return domain.LogBatchIngestResult{}, err
|
return domain.LogBatchIngestResult{}, err
|
||||||
}
|
}
|
||||||
|
svc.publishLogEvents(stream, storedBatch.Entries)
|
||||||
return domain.LogBatchIngestResult{
|
return domain.LogBatchIngestResult{
|
||||||
Accepted: true,
|
Accepted: true,
|
||||||
LogStreamID: batch.LogStreamID,
|
LogStreamID: batch.LogStreamID,
|
||||||
@@ -86,6 +97,35 @@ func (svc *CoreService) IngestLogBatch(batch domain.LogBatchIngest) (domain.LogB
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) ensureJobLogStreamForBatch(batch domain.LogBatchIngest, stamp time.Time) error {
|
||||||
|
jobID, ok := jobIDFromLogBatch(batch)
|
||||||
|
if !ok {
|
||||||
|
return repo.ErrNotFound
|
||||||
|
}
|
||||||
|
job, err := svc.store.Jobs().Get(jobID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if job.ServerInstanceID != batch.ServerInstanceID || job.RunEndpointID != batch.RunEndpointID {
|
||||||
|
return validationError("log batch job scope does not match stream")
|
||||||
|
}
|
||||||
|
return svc.ensureJobLogStreams(job, stamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobIDFromLogBatch(batch domain.LogBatchIngest) (string, bool) {
|
||||||
|
streamKey := strings.TrimSpace(batch.StreamKey)
|
||||||
|
if streamKey == "" || !strings.HasPrefix(batch.LogStreamID, "job.") {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
suffix := "." + streamKey
|
||||||
|
body := strings.TrimPrefix(batch.LogStreamID, "job.")
|
||||||
|
if !strings.HasSuffix(body, suffix) {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
jobID := strings.TrimSuffix(body, suffix)
|
||||||
|
return jobID, strings.TrimSpace(jobID) != ""
|
||||||
|
}
|
||||||
|
|
||||||
func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIngest) bool {
|
func logBatchRecordMatches(record domain.LogBatchRecord, batch domain.LogBatchIngest) bool {
|
||||||
if record.Checksum == batch.Checksum {
|
if record.Checksum == batch.Checksum {
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -39,6 +39,38 @@ func TestCoreServiceIngestsLogBatchAndQueriesCursor(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreServicePublishesLogEventsForAcceptedBatch(t *testing.T) {
|
||||||
|
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||||
|
createLogStreamFixture(t, svc)
|
||||||
|
subscription, err := svc.SubscribeLogEvents("server-1")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("subscribe log events: %v", err)
|
||||||
|
}
|
||||||
|
defer subscription.Close()
|
||||||
|
|
||||||
|
batch := validLogBatch(t, sessionToken, 1, 1)
|
||||||
|
if _, err := svc.IngestLogBatch(batch); err != nil {
|
||||||
|
t.Fatalf("ingest log batch: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case event := <-subscription.Events:
|
||||||
|
if event.Stream.ID != "log-1" || event.Entry.Seq != 1 || event.LatestSeq != 1 {
|
||||||
|
t.Fatalf("unexpected log event: %+v", event)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("expected log event after accepted batch")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.IngestLogBatch(batch); err != nil {
|
||||||
|
t.Fatalf("ingest duplicate batch: %v", err)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case event := <-subscription.Events:
|
||||||
|
t.Fatalf("duplicate batch should not publish a second event: %+v", event)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
|
func TestCoreServiceLogBatchDuplicateAck(t *testing.T) {
|
||||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||||
createLogStreamFixture(t, svc)
|
createLogStreamFixture(t, svc)
|
||||||
@@ -114,6 +146,93 @@ func TestCoreServiceAcceptsAutoCreatedRunJobLogStreams(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceAcceptsPluginDeclaredProcessLogStreams(t *testing.T) {
|
||||||
|
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||||
|
job, err := svc.CreateJob(domain.Job{
|
||||||
|
ID: "job-declared-process-logs",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Capability: domain.LifecycleCapabilityStart,
|
||||||
|
IdempotencyKey: "job-declared-process-logs",
|
||||||
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "run-local", LifecycleOperation: "start", LogSources: []domain.RuntimeLogSource{
|
||||||
|
{Key: "console-out", Kind: "process.stdout", StreamKey: "scum.console.stdout", CursorKind: "sequence", RetentionDays: 30},
|
||||||
|
{Key: "console-err", Kind: "process.stderr", StreamKey: "scum.console.stderr", CursorKind: "sequence", RetentionDays: 30},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create job: %v", err)
|
||||||
|
}
|
||||||
|
streamID := jobLogStreamID(job.ID, "scum.console.stdout")
|
||||||
|
stream, err := svc.GetLogStream(streamID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get declared process log stream: %v", err)
|
||||||
|
}
|
||||||
|
if stream.StreamKey != "scum.console.stdout" || stream.Source != domain.LogStreamSourceProcess {
|
||||||
|
t.Fatalf("unexpected declared stream metadata: %+v", stream)
|
||||||
|
}
|
||||||
|
entry := domain.LogEntry{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "LogStreaming: Display: server ready"}
|
||||||
|
ack, err := svc.IngestLogBatch(domain.LogBatchIngest{
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
SessionToken: sessionToken,
|
||||||
|
LogStreamID: streamID,
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
StreamKey: "scum.console.stdout",
|
||||||
|
Source: domain.LogStreamSourceProcess,
|
||||||
|
FirstSeq: entry.Seq,
|
||||||
|
LastSeq: entry.Seq,
|
||||||
|
Compression: "none",
|
||||||
|
Checksum: validator.LogLineChecksum(entry.Line),
|
||||||
|
Entries: []domain.LogEntry{entry},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest declared process log batch: %v", err)
|
||||||
|
}
|
||||||
|
if !ack.Accepted || ack.LatestSeq != entry.Seq {
|
||||||
|
t.Fatalf("unexpected declared stream ack: %+v", ack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCoreServiceRepairsMissingDeclaredProcessLogStreamOnIngest(t *testing.T) {
|
||||||
|
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||||
|
job := domain.Job{
|
||||||
|
ID: "job-repaired-process-logs",
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
Capability: domain.LifecycleCapabilityStart,
|
||||||
|
IdempotencyKey: "job-repaired-process-logs",
|
||||||
|
ExecutionInput: domain.JobExecutionInput{WorkspaceScope: "run-local", LifecycleOperation: "start", LogSources: []domain.RuntimeLogSource{
|
||||||
|
{Key: "console-out", Kind: "process.stdout", StreamKey: "scum.console.stdout", CursorKind: "sequence", RetentionDays: 30},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
if err := svc.store.Jobs().Create(job); err != nil {
|
||||||
|
t.Fatalf("seed legacy job without streams: %v", err)
|
||||||
|
}
|
||||||
|
streamID := jobLogStreamID(job.ID, "scum.console.stdout")
|
||||||
|
if _, err := svc.GetLogStream(streamID); err == nil {
|
||||||
|
t.Fatal("expected declared stream to be missing before ingest repair")
|
||||||
|
}
|
||||||
|
entry := domain.LogEntry{Seq: 1, Timestamp: time.Date(2026, 7, 3, 12, 0, 1, 0, time.UTC), Level: "info", Line: "LogStreaming: Display: recovered from spool"}
|
||||||
|
ack, err := svc.IngestLogBatch(domain.LogBatchIngest{
|
||||||
|
RunEndpointID: "run-local",
|
||||||
|
SessionToken: sessionToken,
|
||||||
|
LogStreamID: streamID,
|
||||||
|
ServerInstanceID: "server-1",
|
||||||
|
StreamKey: "scum.console.stdout",
|
||||||
|
Source: domain.LogStreamSourceProcess,
|
||||||
|
FirstSeq: entry.Seq,
|
||||||
|
LastSeq: entry.Seq,
|
||||||
|
Compression: "none",
|
||||||
|
Checksum: validator.LogLineChecksum(entry.Line),
|
||||||
|
Entries: []domain.LogEntry{entry},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ingest repaired declared process log batch: %v", err)
|
||||||
|
}
|
||||||
|
if !ack.Accepted || ack.LatestSeq != entry.Seq {
|
||||||
|
t.Fatalf("unexpected repaired stream ack: %+v", ack)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
|
func TestCoreServiceRejectsOutOfOrderAndConflictingLogBatches(t *testing.T) {
|
||||||
svc, sessionToken := newRegisteredLogIngestService(t)
|
svc, sessionToken := newRegisteredLogIngestService(t)
|
||||||
createLogStreamFixture(t, svc)
|
createLogStreamFixture(t, svc)
|
||||||
|
|||||||
@@ -100,6 +100,19 @@ func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridge
|
|||||||
if jobID == "" {
|
if jobID == "" {
|
||||||
return validationError("protected request job binding is missing")
|
return validationError("protected request job binding is missing")
|
||||||
}
|
}
|
||||||
|
executionInput := domain.JobExecutionInput{
|
||||||
|
WorkspaceScope: command.ProfileKey,
|
||||||
|
RemoteAdapterKey: declaration.ProtectedRequest.TransportKey,
|
||||||
|
RemoteAdapterKind: adapterKind,
|
||||||
|
TimeoutSeconds: declaration.TimeoutSeconds,
|
||||||
|
PluginID: command.PluginID,
|
||||||
|
}
|
||||||
|
if declaration.ProtectedRequest.Kind == "rcon" {
|
||||||
|
if resolution, resolveErr := svc.resolveProtectedSourceRCONDispatch(command.ServerInstanceID, declaration.ProtectedRequest); resolveErr == nil {
|
||||||
|
executionInput.WorkspaceScope = resolution.binding.ProfileKey
|
||||||
|
executionInput.SourceRCON = resolution.plan
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := svc.protectedRequests.Put(jobID, protectedRequestPayload{commandID: command.ID, kind: declaration.ProtectedRequest.Kind, transportKey: declaration.ProtectedRequest.TransportKey, targetKey: declaration.ProtectedRequest.TargetKey, requestText: requestText, expiresAt: command.ExpiresAt}); err != nil {
|
if err := svc.protectedRequests.Put(jobID, protectedRequestPayload{commandID: command.ID, kind: declaration.ProtectedRequest.Kind, transportKey: declaration.ProtectedRequest.TransportKey, targetKey: declaration.ProtectedRequest.TargetKey, requestText: requestText, expiresAt: command.ExpiresAt}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -113,13 +126,7 @@ func (svc *CoreService) dispatchProtectedRequest(command domain.GameClientBridge
|
|||||||
IdempotencyKey: "protected-request:" + command.ID,
|
IdempotencyKey: "protected-request:" + command.ID,
|
||||||
Progress: domain.JobProgress{Percent: 0, Message: "protected request queued"},
|
Progress: domain.JobProgress{Percent: 0, Message: "protected request queued"},
|
||||||
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
|
RetryPolicy: domain.JobRetryPolicy{MaxAttempts: 1, InitialBackoffSeconds: 1, MaxBackoffSeconds: 1},
|
||||||
ExecutionInput: domain.JobExecutionInput{
|
ExecutionInput: executionInput,
|
||||||
WorkspaceScope: command.ProfileKey,
|
|
||||||
RemoteAdapterKey: declaration.ProtectedRequest.TransportKey,
|
|
||||||
RemoteAdapterKind: adapterKind,
|
|
||||||
TimeoutSeconds: declaration.TimeoutSeconds,
|
|
||||||
PluginID: command.PluginID,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
if job.RunEndpointID == "" {
|
if job.RunEndpointID == "" {
|
||||||
svc.protectedRequests.Delete(jobID)
|
svc.protectedRequests.Delete(jobID)
|
||||||
|
|||||||
@@ -202,6 +202,8 @@ type Core interface {
|
|||||||
GetLogStreamForSession(string, string) (domain.LogStream, error)
|
GetLogStreamForSession(string, string) (domain.LogStream, error)
|
||||||
ListLogStreamsForSession(string, domain.LogStreamFilter) ([]domain.LogStream, error)
|
ListLogStreamsForSession(string, domain.LogStreamFilter) ([]domain.LogStream, error)
|
||||||
QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
QueryLogStreamForSession(string, domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
||||||
|
SubscribeLogEvents(string) (LogEventSubscription, error)
|
||||||
|
SubscribeLogEventsForSession(string, string) (LogEventSubscription, error)
|
||||||
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
IngestLogBatch(domain.LogBatchIngest) (domain.LogBatchIngestResult, error)
|
||||||
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
QueryLogStream(domain.LogStreamCursorQuery) (domain.LogStreamCursorResult, error)
|
||||||
ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error)
|
ListGamePlayersForSession(string, domain.GamePlayerFilter) ([]domain.GamePlayer, error)
|
||||||
@@ -236,6 +238,9 @@ type CoreService struct {
|
|||||||
bridgeMu sync.Mutex
|
bridgeMu sync.Mutex
|
||||||
bridgeSeq uint64
|
bridgeSeq uint64
|
||||||
logStore LogBodyStore
|
logStore LogBodyStore
|
||||||
|
logEventMu sync.Mutex
|
||||||
|
logEventSubscribers map[uint64]logEventSubscriber
|
||||||
|
logEventSubscriberSeq uint64
|
||||||
artifactStore ArtifactBodyStore
|
artifactStore ArtifactBodyStore
|
||||||
artifactMu sync.Mutex
|
artifactMu sync.Mutex
|
||||||
artifactTransfers map[string]domain.ArtifactTransferSession
|
artifactTransfers map[string]domain.ArtifactTransferSession
|
||||||
@@ -279,6 +284,7 @@ func newCoreServiceWithLogStore(store repo.Store, logStore LogBodyStore, now fun
|
|||||||
authSessions: map[string]string{},
|
authSessions: map[string]string{},
|
||||||
runSessions: map[string]domain.RunControlSession{},
|
runSessions: map[string]domain.RunControlSession{},
|
||||||
logStore: logStore,
|
logStore: logStore,
|
||||||
|
logEventSubscribers: map[uint64]logEventSubscriber{},
|
||||||
artifactStore: artifactStore,
|
artifactStore: artifactStore,
|
||||||
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
artifactTransfers: map[string]domain.ArtifactTransferSession{},
|
||||||
artifactPayloads: map[string][]byte{},
|
artifactPayloads: map[string][]byte{},
|
||||||
@@ -2329,21 +2335,36 @@ func (svc *CoreService) ensureJobLogStreams(job domain.Job, stamp time.Time) err
|
|||||||
streams := []struct {
|
streams := []struct {
|
||||||
key string
|
key string
|
||||||
source domain.LogStreamSource
|
source domain.LogStreamSource
|
||||||
}{
|
}{}
|
||||||
{key: "stdout", source: domain.LogStreamSourceProcess},
|
addStream := func(key string, source domain.LogStreamSource) {
|
||||||
{key: "stderr", source: domain.LogStreamSourceProcess},
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, stream := range streams {
|
||||||
|
if stream.key == key {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
streams = append(streams, struct {
|
||||||
|
key string
|
||||||
|
source domain.LogStreamSource
|
||||||
|
}{key: key, source: source})
|
||||||
|
}
|
||||||
|
addStream("stdout", domain.LogStreamSourceProcess)
|
||||||
|
addStream("stderr", domain.LogStreamSourceProcess)
|
||||||
|
for _, source := range job.ExecutionInput.LogSources {
|
||||||
|
if source.Kind != "process.stdout" && source.Kind != "process.stderr" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
addStream(source.StreamKey, domain.LogStreamSourceProcess)
|
||||||
}
|
}
|
||||||
if job.Capability == domain.JobCapabilityRemoteRunProgram {
|
if job.Capability == domain.JobCapabilityRemoteRunProgram {
|
||||||
streams = append(streams,
|
addStream("management-program.stdout", domain.LogStreamSourceManagementProgram)
|
||||||
struct {
|
addStream("management-program.stderr", domain.LogStreamSourceManagementProgram)
|
||||||
key string
|
}
|
||||||
source domain.LogStreamSource
|
if job.Capability == domain.JobCapabilityLogsBackfill && job.ExecutionInput.LogSource != nil && strings.TrimSpace(job.ExecutionInput.LogSource.StreamKey) != "" {
|
||||||
}{key: "management-program.stdout", source: domain.LogStreamSourceManagementProgram},
|
addStream(job.ExecutionInput.LogSource.StreamKey, domain.LogStreamSourceFile)
|
||||||
struct {
|
|
||||||
key string
|
|
||||||
source domain.LogStreamSource
|
|
||||||
}{key: "management-program.stderr", source: domain.LogStreamSourceManagementProgram},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
for _, item := range streams {
|
for _, item := range streams {
|
||||||
stream := domain.LogStream{
|
stream := domain.LogStream{
|
||||||
|
|||||||
@@ -1695,7 +1695,20 @@ func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domai
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get plugin for runtime binding: %v", err)
|
t.Fatalf("get plugin for runtime binding: %v", err)
|
||||||
}
|
}
|
||||||
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: profileKey, Bindings: map[string]string{}}, true)
|
profile, ok := runtimeLifecycleProfile(plugin.RuntimeProfiles, profileKey)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("runtime profile %s missing", profileKey)
|
||||||
|
}
|
||||||
|
required, _ := runtimeBindingKeys(plugin.RuntimeProfiles, profile)
|
||||||
|
bindings := map[string]string{}
|
||||||
|
for _, key := range required {
|
||||||
|
if runtimeBindingTestKeyIsSensitive(key) {
|
||||||
|
bindings[key] = "secret://" + instance.ID + "/" + strings.ReplaceAll(key, "/", "-")
|
||||||
|
} else {
|
||||||
|
bindings[key] = "runtime." + strings.ReplaceAll(key, "/", ".")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: profileKey, Bindings: bindings}, true)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("build runtime binding: %v", err)
|
t.Fatalf("build runtime binding: %v", err)
|
||||||
}
|
}
|
||||||
@@ -1705,6 +1718,11 @@ func createCompleteRuntimeBinding(t *testing.T, svc *CoreService, instance domai
|
|||||||
return binding
|
return binding
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func runtimeBindingTestKeyIsSensitive(key string) bool {
|
||||||
|
normalized := strings.ToLower(key)
|
||||||
|
return strings.Contains(normalized, "password") || strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") || strings.Contains(normalized, "token") || strings.Contains(normalized, "dsn")
|
||||||
|
}
|
||||||
|
|
||||||
func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
func validPluginManifestRegistration() domain.GamePluginManifestRegistration {
|
||||||
return domain.GamePluginManifestRegistration{
|
return domain.GamePluginManifestRegistration{
|
||||||
ManifestRef: "artifact://manifests/game.example/0.1.0",
|
ManifestRef: "artifact://manifests/game.example/0.1.0",
|
||||||
|
|||||||
@@ -39,8 +39,8 @@ func (svc *CoreService) UpdateServerRuntimeBindingForSession(sessionID, serverIn
|
|||||||
if existingErr != nil && !errors.Is(existingErr, repo.ErrNotFound) {
|
if existingErr != nil && !errors.Is(existingErr, repo.ErrNotFound) {
|
||||||
return domain.RuntimeBindingView{}, existingErr
|
return domain.RuntimeBindingView{}, existingErr
|
||||||
}
|
}
|
||||||
if (instance.State == domain.ServerInstanceStateInstalling || instance.State == domain.ServerInstanceStateRunning) && existingErr == nil || instance.State == domain.ServerInstanceStateDeleted {
|
if instance.State == domain.ServerInstanceStateDeleted {
|
||||||
return domain.RuntimeBindingView{}, validationError("runtime binding cannot be changed while the server is active")
|
return domain.RuntimeBindingView{}, validationError("runtime binding cannot be changed after the server is deleted")
|
||||||
}
|
}
|
||||||
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -124,7 +124,7 @@ func runtimeBindingKeys(profiles domain.GamePluginRuntimeProfiles, profile domai
|
|||||||
add(probe.TargetKey, probe.Required)
|
add(probe.TargetKey, probe.Required)
|
||||||
}
|
}
|
||||||
for _, source := range profiles.LogSources {
|
for _, source := range profiles.LogSources {
|
||||||
add(source.TargetKey, source.TargetKey != "")
|
add(source.TargetKey, false)
|
||||||
}
|
}
|
||||||
for _, plan := range profiles.InstallPlans {
|
for _, plan := range profiles.InstallPlans {
|
||||||
for _, step := range plan.Steps {
|
for _, step := range plan.Steps {
|
||||||
|
|||||||
@@ -98,6 +98,52 @@ func TestRuntimeBindingValidationAndLifecycleGating(t *testing.T) {
|
|||||||
if err != nil || result.Job.TargetKey != "actions/start.json" || result.Job.ExecutionInput.WorkspaceScope != "local" {
|
if err != nil || result.Job.TargetKey != "actions/start.json" || result.Job.ExecutionInput.WorkspaceScope != "local" {
|
||||||
t.Fatalf("expected complete binding to permit start, result=%+v err=%v", result, err)
|
t.Fatalf("expected complete binding to permit start, result=%+v err=%v", result, err)
|
||||||
}
|
}
|
||||||
|
view, err = svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root-updated"}})
|
||||||
|
if err != nil || view.Status != domain.RuntimeBindingStatusComplete {
|
||||||
|
t.Fatalf("expected active server runtime binding edits to remain available, view=%+v err=%v", view, err)
|
||||||
|
}
|
||||||
|
instance.State = domain.ServerInstanceStateDeleted
|
||||||
|
if err := svc.store.ServerInstances().Update(instance); err != nil {
|
||||||
|
t.Fatalf("mark deleted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root"}}); err == nil || !strings.Contains(err.Error(), "deleted") {
|
||||||
|
t.Fatalf("expected deleted server runtime binding edit rejection, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuntimeBindingLogSourcesAreConfigurableButNotRequired(t *testing.T) {
|
||||||
|
svc := newTestCoreService()
|
||||||
|
plugin, endpoint := createPluginAndRunEndpoint(t, svc)
|
||||||
|
plugin.RuntimeProfiles = requiredRuntimeProfilesFixture()
|
||||||
|
plugin.RuntimeProfiles.LogSources = []domain.RuntimeLogSource{
|
||||||
|
{Key: "console-stdout", Kind: "process.stdout", TargetKey: "process/server", StreamKey: "console.stdout", CursorKind: "sequence", RetentionDays: 30},
|
||||||
|
{Key: "latest", Kind: "file.tail", TargetKey: "logs/latest", StreamKey: "latest-log", CursorKind: "offset", RetentionDays: 30},
|
||||||
|
}
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatalf("update plugin profiles: %v", err)
|
||||||
|
}
|
||||||
|
ownerSession := createServiceUserAndLogin(t, svc, domain.User{ID: "runtime-logs-owner", DisplayName: "Runtime Logs Owner", Email: "runtime-logs-owner@example.test", Roles: []string{"server-owner"}, PasswordHash: "secret-password"})
|
||||||
|
instance, err := svc.CreateServerInstanceForSession(ownerSession, domain.ServerInstance{ID: "runtime-log-sources", PluginID: plugin.ID, RunEndpointID: endpoint.ID, Name: "Runtime Log Sources", State: domain.ServerInstanceStateRunning})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create server: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
view, err := svc.UpdateServerRuntimeBindingForSession(ownerSession, instance.ID, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"server-root": "runtime.server-root", "rcon.password": "secret://runtime-log-sources/rcon"}})
|
||||||
|
if err != nil || view.Status != domain.RuntimeBindingStatusComplete || len(view.MissingKeys) != 0 {
|
||||||
|
t.Fatalf("log sources should not block runtime readiness: view=%+v err=%v", view, err)
|
||||||
|
}
|
||||||
|
seenLogs := map[string]domain.RuntimeBindingKeyView{}
|
||||||
|
for _, key := range view.Keys {
|
||||||
|
if strings.HasPrefix(key.Key, "logs/") || strings.HasPrefix(key.Key, "process/") {
|
||||||
|
seenLogs[key.Key] = key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, key := range []string{"logs/latest", "process/server"} {
|
||||||
|
item, ok := seenLogs[key]
|
||||||
|
if !ok || item.Required || item.Configured {
|
||||||
|
t.Fatalf("expected optional unconfigured log key %s, seen=%+v view=%+v", key, seenLogs, view)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requiredRuntimeProfilesFixture() domain.GamePluginRuntimeProfiles {
|
func requiredRuntimeProfilesFixture() domain.GamePluginRuntimeProfiles {
|
||||||
|
|||||||
@@ -310,6 +310,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
|||||||
return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
|
return domain.Job{}, validationError(fmt.Sprintf("plugin %s lifecycle action is required", action))
|
||||||
}
|
}
|
||||||
var dllExtensions []domain.RuntimeDLLExtensionPlan
|
var dllExtensions []domain.RuntimeDLLExtensionPlan
|
||||||
|
var logSources []domain.RuntimeLogSource
|
||||||
if action == domain.ServerLifecycleActionStart && hasProfile {
|
if action == domain.ServerLifecycleActionStart && hasProfile {
|
||||||
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -319,6 +320,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.Job{}, err
|
return domain.Job{}, err
|
||||||
}
|
}
|
||||||
|
logSources = lifecycleProcessLogSources(plugin.RuntimeProfiles)
|
||||||
}
|
}
|
||||||
job, err := svc.CreateJob(domain.Job{
|
job, err := svc.CreateJob(domain.Job{
|
||||||
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
|
ID: lifecycleJobID(instance.ID, action, idempotencyKey),
|
||||||
@@ -332,6 +334,7 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
|||||||
WorkspaceScope: profileKey,
|
WorkspaceScope: profileKey,
|
||||||
PluginID: plugin.ID,
|
PluginID: plugin.ID,
|
||||||
LifecycleOperation: lifecycleExecutionOperation(action),
|
LifecycleOperation: lifecycleExecutionOperation(action),
|
||||||
|
LogSources: logSources,
|
||||||
DLLExtensions: dllExtensions,
|
DLLExtensions: dllExtensions,
|
||||||
Deployment: deploymentPlanForDispatch(instance.Deployment),
|
Deployment: deploymentPlanForDispatch(instance.Deployment),
|
||||||
},
|
},
|
||||||
@@ -345,6 +348,16 @@ func (svc *CoreService) dispatchLifecycleJob(instance domain.ServerInstance, act
|
|||||||
return job, nil
|
return job, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lifecycleProcessLogSources(profiles domain.GamePluginRuntimeProfiles) []domain.RuntimeLogSource {
|
||||||
|
sources := []domain.RuntimeLogSource{}
|
||||||
|
for _, source := range profiles.LogSources {
|
||||||
|
if source.Kind == "process.stdout" || source.Kind == "process.stderr" {
|
||||||
|
sources = append(sources, source)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress {
|
func lifecycleJobProgress(deployment domain.ServerDeploymentDefinition) domain.JobProgress {
|
||||||
if deployment.Mode != "" {
|
if deployment.Mode != "" {
|
||||||
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}
|
return domain.JobProgress{Percent: 0, Phase: "queued", Message: "deployment queued; awaiting Run claim"}
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ func TestCoreServiceServerLifecycleWorkflows(t *testing.T) {
|
|||||||
if started.Job.ExecutionInput.PluginID != "server.scum" || started.Job.ExecutionInput.WorkspaceScope != "local" || started.Job.ExecutionInput.LifecycleOperation != "start" {
|
if started.Job.ExecutionInput.PluginID != "server.scum" || started.Job.ExecutionInput.WorkspaceScope != "local" || started.Job.ExecutionInput.LifecycleOperation != "start" {
|
||||||
t.Fatalf("expected start job to carry plugin/profile metadata, got %+v", started.Job.ExecutionInput)
|
t.Fatalf("expected start job to carry plugin/profile metadata, got %+v", started.Job.ExecutionInput)
|
||||||
}
|
}
|
||||||
|
if len(started.Job.ExecutionInput.LogSources) != 2 || started.Job.ExecutionInput.LogSources[0].StreamKey != "scum.console.stdout" || started.Job.ExecutionInput.LogSources[1].StreamKey != "scum.console.stderr" {
|
||||||
|
t.Fatalf("expected start job to carry plugin-declared process log sources, got %+v", started.Job.ExecutionInput.LogSources)
|
||||||
|
}
|
||||||
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
|
claimAndCompleteLifecycleJob(t, svc, sessionToken, domain.LifecycleCapabilityStart, domain.JobStateSucceeded)
|
||||||
running, err := svc.GetServerInstance("server-1")
|
running, err := svc.GetServerInstance("server-1")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -362,8 +365,11 @@ func createLifecyclePlugin(t *testing.T, svc *CoreService) domain.GamePlugin {
|
|||||||
Start: "actions/start.json",
|
Start: "actions/start.json",
|
||||||
Stop: "actions/stop.json",
|
Stop: "actions/stop.json",
|
||||||
},
|
},
|
||||||
Permissions: domain.PluginPermissions{Jobs: true, Logs: true},
|
Permissions: domain.PluginPermissions{Jobs: true, Logs: true},
|
||||||
RuntimeProfiles: domain.GamePluginRuntimeProfiles{LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}}},
|
RuntimeProfiles: domain.GamePluginRuntimeProfiles{
|
||||||
|
LifecycleProfiles: []domain.RuntimeLifecycleProfile{{Key: "local", Mode: "local-process", Capabilities: []string{domain.LifecycleCapabilityInstall, domain.LifecycleCapabilityStart, domain.LifecycleCapabilityStop}}},
|
||||||
|
LogSources: []domain.RuntimeLogSource{{Key: "scum-console-stdout", Kind: "process.stdout", TargetKey: "scum/server-process", StreamKey: "scum.console.stdout", CursorKind: "sequence", RetentionDays: 30}, {Key: "scum-console-stderr", Kind: "process.stderr", TargetKey: "scum/server-process", StreamKey: "scum.console.stderr", CursorKind: "sequence", RetentionDays: 30}},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("create lifecycle plugin: %v", err)
|
t.Fatalf("create lifecycle plugin: %v", err)
|
||||||
|
|||||||
@@ -212,9 +212,16 @@ func (svc *CoreService) resolveSourceRCONDispatch(instance domain.ServerInstance
|
|||||||
}
|
}
|
||||||
|
|
||||||
func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile) (domain.RuntimeTransportProfile, error) {
|
func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile) (domain.RuntimeTransportProfile, error) {
|
||||||
|
return sourceRCONTransportForCapability(profiles, profile, "", domain.JobCapabilityRemoteRunRCONCommand)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceRCONTransportForCapability(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, requiredKey string, capability string) (domain.RuntimeTransportProfile, error) {
|
||||||
var selected domain.RuntimeTransportProfile
|
var selected domain.RuntimeTransportProfile
|
||||||
for _, candidate := range profiles.TransportProfiles {
|
for _, candidate := range profiles.TransportProfiles {
|
||||||
if !containsString(profile.TransportKeys, candidate.Key) || candidate.Kind != "rcon" || !containsString(candidate.Capabilities, domain.JobCapabilityRemoteRunRCONCommand) {
|
if requiredKey != "" && candidate.Key != requiredKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !containsString(profile.TransportKeys, candidate.Key) || candidate.Kind != "rcon" || !containsString(candidate.Capabilities, capability) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if selected.Key != "" {
|
if selected.Key != "" {
|
||||||
@@ -228,6 +235,62 @@ func sourceRCONTransport(profiles domain.GamePluginRuntimeProfiles, profile doma
|
|||||||
return selected, nil
|
return selected, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (svc *CoreService) resolveProtectedSourceRCONDispatch(serverInstanceID string, request *domain.GameClientBridgeProtectedRequestDeclaration) (sourceRCONDispatchResolution, error) {
|
||||||
|
if request == nil || request.Kind != "rcon" {
|
||||||
|
return sourceRCONDispatchResolution{}, validationError("protected request is not RCON")
|
||||||
|
}
|
||||||
|
instance, err := svc.store.ServerInstances().Get(serverInstanceID)
|
||||||
|
if err != nil {
|
||||||
|
return sourceRCONDispatchResolution{}, err
|
||||||
|
}
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
return sourceRCONDispatchResolution{}, err
|
||||||
|
}
|
||||||
|
capability := domain.JobCapabilityRemoteRunProtectedRCON
|
||||||
|
if plugin.Status != domain.GamePluginStatusInstalled || plugin.Version != instance.PluginVersion || !plugin.Permissions.RemoteAccess || !plugin.RemoteAccess.RCON || !containsString(plugin.RequiredRunCapabilities, capability) || !containsString(plugin.RemoteAccess.RunCapabilities, capability) {
|
||||||
|
return sourceRCONDispatchResolution{}, forbiddenError("plugin does not declare protected SCUM RCON access")
|
||||||
|
}
|
||||||
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
return sourceRCONDispatchResolution{}, err
|
||||||
|
}
|
||||||
|
if err := svc.validateRunnableEndpoint(endpoint, capability); err != nil {
|
||||||
|
return sourceRCONDispatchResolution{}, err
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
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, capability) || !runtimePlatformsContain(profile.Platforms, "windows") {
|
||||||
|
return sourceRCONDispatchResolution{}, validationError("selected runtime profile does not support protected SCUM RCON")
|
||||||
|
}
|
||||||
|
transport, err := sourceRCONTransportForCapability(plugin.RuntimeProfiles, profile, request.TransportKey, capability)
|
||||||
|
if err != nil {
|
||||||
|
return sourceRCONDispatchResolution{}, err
|
||||||
|
}
|
||||||
|
if transport.TargetKey != request.TargetKey {
|
||||||
|
return sourceRCONDispatchResolution{}, validationError("protected RCON transport target is invalid")
|
||||||
|
}
|
||||||
|
extension, err := sourceRCONExtension(plugin.RuntimeProfiles, profile, endpoint)
|
||||||
|
if err != nil {
|
||||||
|
return sourceRCONDispatchResolution{}, err
|
||||||
|
}
|
||||||
|
plan := &domain.RuntimeSourceRCONPlan{Protocol: "source-rcon", ExtensionKey: extension.Key, ModKey: extension.ModKey, ConfigRef: "ue4ss/Mods/" + extension.ModKey + "/config.ini", DeploymentStateRef: "runtime/ue4ss-dll/" + extension.TargetKey + "/release.json", Port: extension.RCONPort}
|
||||||
|
return sourceRCONDispatchResolution{plugin: plugin, binding: binding, transport: transport, plan: plan}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func sourceRCONExtension(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) (domain.RuntimeDLLExtensionProfile, error) {
|
func sourceRCONExtension(profiles domain.GamePluginRuntimeProfiles, profile domain.RuntimeLifecycleProfile, endpoint domain.RunEndpoint) (domain.RuntimeDLLExtensionProfile, error) {
|
||||||
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
|
byKey := make(map[string]domain.RuntimeDLLExtensionProfile, len(profiles.DLLExtensions))
|
||||||
for _, extension := range profiles.DLLExtensions {
|
for _, extension := range profiles.DLLExtensions {
|
||||||
|
|||||||
@@ -85,6 +85,65 @@ func TestSourceRCONDispatchUsesOneTimeRedactedInput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestProtectedRCONBridgeDispatchCarriesSourceRCONPlan(t *testing.T) {
|
||||||
|
svc, _, _, instance := newSourceRCONFixture(t)
|
||||||
|
plugin, err := svc.store.GamePlugins().Get(instance.PluginID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
protectedCapability := domain.JobCapabilityRemoteRunProtectedRCON
|
||||||
|
plugin.RequiredRunCapabilities = append(plugin.RequiredRunCapabilities, protectedCapability)
|
||||||
|
plugin.RemoteAccess.RunCapabilities = append(plugin.RemoteAccess.RunCapabilities, protectedCapability)
|
||||||
|
plugin.RuntimeProfiles.ClientManagers = []domain.RuntimeClientManagerProfile{{Key: "scum-client-manager", Health: domain.RuntimeClientManagerHealth{RequiredCapabilities: []string{gameClientBridgeCapability}}}}
|
||||||
|
plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities = append(plugin.RuntimeProfiles.LifecycleProfiles[0].Capabilities, protectedCapability)
|
||||||
|
plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys = append(plugin.RuntimeProfiles.LifecycleProfiles[0].TransportKeys, "scum-management")
|
||||||
|
plugin.RuntimeProfiles.TransportProfiles = append(plugin.RuntimeProfiles.TransportProfiles, domain.RuntimeTransportProfile{Key: "scum-management", Kind: "rcon", TargetKey: "scum-management", Capabilities: []string{protectedCapability}})
|
||||||
|
plugin.GameClientBridge.Commands = append(plugin.GameClientBridge.Commands, domain.GameClientBridgeCommandDeclaration{Type: "management.rcon.request", ApprovalLevel: domain.GameClientBridgeApprovalLevelOperator, TimeoutSeconds: 120, MaxPayloadBytes: 8192, ProtectedRequest: &domain.GameClientBridgeProtectedRequestDeclaration{Kind: "rcon", TransportKey: "scum-management", TargetKey: "scum-management", TextField: "requestText", MaxTextBytes: 8192}})
|
||||||
|
if err := svc.store.GamePlugins().Update(plugin); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binding, err := svc.buildRuntimeBinding(instance, plugin, domain.RuntimeBindingUpdate{ProfileKey: "local", Bindings: map[string]string{"rcon": "runtime-rcon", "scum-management": "runtime-rcon"}}, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("refresh protected RCON binding: %v", err)
|
||||||
|
}
|
||||||
|
if err := svc.store.RuntimeBindings().Update(binding); err != nil {
|
||||||
|
t.Fatalf("store protected RCON binding: %v", err)
|
||||||
|
}
|
||||||
|
endpoint, err := svc.store.RunEndpoints().Get(instance.RunEndpointID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
endpoint.Capabilities = append(endpoint.Capabilities, protectedCapability)
|
||||||
|
if err := svc.store.RunEndpoints().Update(endpoint); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := svc.resolveProtectedSourceRCONDispatch(instance.ID, plugin.GameClientBridge.Commands[len(plugin.GameClientBridge.Commands)-1].ProtectedRequest); err != nil {
|
||||||
|
t.Fatalf("resolve protected Source RCON plan: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
command, err := svc.queueGameClientBridgeCommand("user-rcon-owner", domain.GameClientBridgeQueueRequest{ServerInstanceID: instance.ID, PluginID: plugin.ID, ProfileKey: "scum-client-manager", CommandType: "management.rcon.request", Payload: map[string]any{"requestText": "#ListPlayers"}, IdempotencyKey: "protected-rcon-1", ExpiresAt: fixedTime.Add(time.Minute)})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("queue protected RCON: %v", err)
|
||||||
|
}
|
||||||
|
job, err := svc.store.Jobs().Get(command.RunJobID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get protected RCON job: %v", err)
|
||||||
|
}
|
||||||
|
if job.Capability != protectedCapability || job.InputRef == "" || !strings.HasPrefix(job.InputRef, "input://protected-request/") || job.ExecutionInput.SourceRCON == nil {
|
||||||
|
t.Fatalf("expected protected RCON job with frozen Source RCON plan, got %+v", job)
|
||||||
|
}
|
||||||
|
if job.ExecutionInput.WorkspaceScope != "local" || job.ExecutionInput.RemoteAdapterKey != "scum-management" || job.ExecutionInput.RemoteAdapterKind != "protected-rcon" || job.ExecutionInput.SourceRCON.Port != 27015 {
|
||||||
|
t.Fatalf("protected RCON plan did not preserve logical runtime binding: %+v", job.ExecutionInput)
|
||||||
|
}
|
||||||
|
serialized, err := json.Marshal(job)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(string(serialized), "#ListPlayers") || strings.Contains(string(serialized), "password=") {
|
||||||
|
t.Fatalf("protected RCON job leaked transient input: %s", serialized)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) {
|
func TestSourceRCONDispatchRejectsUnsafeOrIncompatibleState(t *testing.T) {
|
||||||
svc, session, _, instance := newSourceRCONFixture(t)
|
svc, session, _, instance := newSourceRCONFixture(t)
|
||||||
unsafe := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "SetTime 12\nSpawnItem", IdempotencyKey: "rcon-unsafe"}
|
unsafe := domain.SourceRCONCommandRequest{ServerInstanceID: instance.ID, Kind: domain.SourceRCONCommandKindCommand, Command: "SetTime 12\nSpawnItem", IdempotencyKey: "rcon-unsafe"}
|
||||||
|
|||||||
@@ -1392,9 +1392,35 @@ func ValidateJob(job domain.Job) error {
|
|||||||
for i, plan := range job.ExecutionInput.DLLExtensions {
|
for i, plan := range job.ExecutionInput.DLLExtensions {
|
||||||
violations = append(violations, validateRuntimeDLLExtensionPlan(fmt.Sprintf("executionInput.dllExtensions[%d]", i), plan)...)
|
violations = append(violations, validateRuntimeDLLExtensionPlan(fmt.Sprintf("executionInput.dllExtensions[%d]", i), plan)...)
|
||||||
}
|
}
|
||||||
|
if job.ExecutionInput.LogSource != nil {
|
||||||
|
violations = append(violations, validateRuntimeLogSourcePlanForJob("executionInput.logSource", job.ExecutionInput.LogSource)...)
|
||||||
|
if job.Capability != domain.JobCapabilityLogsBackfill || job.ServerInstanceID == "" {
|
||||||
|
violations = append(violations, "executionInput.logSource is allowed only for logs.backfill jobs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(job.ExecutionInput.LogSources) > 0 {
|
||||||
|
if job.Capability != domain.LifecycleCapabilityStart || job.ExecutionInput.LifecycleOperation != "start" || job.ServerInstanceID == "" {
|
||||||
|
violations = append(violations, "executionInput.logSources are allowed only for scoped process.start jobs")
|
||||||
|
}
|
||||||
|
seenKinds := map[string]struct{}{}
|
||||||
|
for i, source := range job.ExecutionInput.LogSources {
|
||||||
|
prefix := fmt.Sprintf("executionInput.logSources[%d]", i)
|
||||||
|
violations = append(violations, validateRuntimeProcessLogSourcePlanForJob(prefix, source)...)
|
||||||
|
if _, exists := seenKinds[source.Kind]; exists {
|
||||||
|
violations = append(violations, "executionInput.logSources kind is duplicated")
|
||||||
|
}
|
||||||
|
seenKinds[source.Kind] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
if job.ExecutionInput.SourceRCON != nil {
|
if job.ExecutionInput.SourceRCON != nil {
|
||||||
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
violations = append(violations, validateRuntimeSourceRCONPlan("executionInput.sourceRcon", job.ExecutionInput.SourceRCON)...)
|
||||||
if job.Capability != domain.JobCapabilityRemoteRunRCONCommand || job.ExecutionInput.RemoteAdapterKind != "rcon" {
|
isSourceCommand := job.Capability == domain.JobCapabilityRemoteRunRCONCommand
|
||||||
|
isProtectedRCON := job.Capability == domain.JobCapabilityRemoteRunProtectedRCON
|
||||||
|
wantAdapterKind := "rcon"
|
||||||
|
if isProtectedRCON {
|
||||||
|
wantAdapterKind = "protected-rcon"
|
||||||
|
}
|
||||||
|
if (!isSourceCommand && !isProtectedRCON) || job.ExecutionInput.RemoteAdapterKind != wantAdapterKind {
|
||||||
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
violations = append(violations, "executionInput.sourceRcon is allowed only for rcon jobs")
|
||||||
}
|
}
|
||||||
if job.RetryPolicy.MaxAttempts != 1 {
|
if job.RetryPolicy.MaxAttempts != 1 {
|
||||||
@@ -1441,6 +1467,45 @@ func ValidateJob(job domain.Job) error {
|
|||||||
return finish(violations)
|
return finish(violations)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func validateRuntimeLogSourcePlanForJob(prefix string, source *domain.RuntimeLogSource) []string {
|
||||||
|
if source == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var violations []string
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
|
||||||
|
if source.Kind != "file.tail" {
|
||||||
|
violations = append(violations, prefix+".kind must be file.tail")
|
||||||
|
}
|
||||||
|
if source.CursorKind != "" && !oneOf(source.CursorKind, "offset", "fingerprint") {
|
||||||
|
violations = append(violations, prefix+".cursorKind is invalid")
|
||||||
|
}
|
||||||
|
if source.RetentionDays < 0 || source.RetentionDays > 365 {
|
||||||
|
violations = append(violations, prefix+".retentionDays is invalid")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateRuntimeProcessLogSourcePlanForJob(prefix string, source domain.RuntimeLogSource) []string {
|
||||||
|
var violations []string
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".key", source.Key)...)
|
||||||
|
if source.TargetKey != "" {
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".targetKey", source.TargetKey)...)
|
||||||
|
}
|
||||||
|
violations = append(violations, validateProfileKey(prefix+".streamKey", source.StreamKey)...)
|
||||||
|
if source.Kind != "process.stdout" && source.Kind != "process.stderr" {
|
||||||
|
violations = append(violations, prefix+".kind must be process.stdout or process.stderr")
|
||||||
|
}
|
||||||
|
if source.CursorKind != "" && source.CursorKind != "sequence" {
|
||||||
|
violations = append(violations, prefix+".cursorKind is invalid")
|
||||||
|
}
|
||||||
|
if source.RetentionDays < 0 || source.RetentionDays > 365 {
|
||||||
|
violations = append(violations, prefix+".retentionDays is invalid")
|
||||||
|
}
|
||||||
|
return violations
|
||||||
|
}
|
||||||
|
|
||||||
func ValidateArtifact(artifact domain.Artifact) error {
|
func ValidateArtifact(artifact domain.Artifact) error {
|
||||||
var violations []string
|
var violations []string
|
||||||
violations = appendRequired(violations, "id", artifact.ID)
|
violations = appendRequired(violations, "id", artifact.ID)
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import type {
|
|||||||
LlmConfigSuggestionRequest,
|
LlmConfigSuggestionRequest,
|
||||||
LlmConfigSuggestionResponse,
|
LlmConfigSuggestionResponse,
|
||||||
LogBackfillRequest,
|
LogBackfillRequest,
|
||||||
|
LogStreamEventOptions,
|
||||||
LogStreamCursorRequest,
|
LogStreamCursorRequest,
|
||||||
LogStreamCursorResponse,
|
LogStreamCursorResponse,
|
||||||
LogStreamListResponse,
|
LogStreamListResponse,
|
||||||
@@ -446,6 +447,17 @@ export class PlatformApiClient {
|
|||||||
return this.request<LogStreamListResponse>(`/server-instances/${encodeURIComponent(id)}/logs/live`);
|
return this.request<LogStreamListResponse>(`/server-instances/${encodeURIComponent(id)}/logs/live`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
openServerLogEvents(id: string, options: LogStreamEventOptions = {}): EventSource {
|
||||||
|
return new EventSource(this.serverLogEventsUrl(id, options), { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
serverLogEventsUrl(id: string, options: LogStreamEventOptions = {}): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (options.historyLimit !== undefined) params.set("historyLimit", String(options.historyLimit));
|
||||||
|
const query = params.toString();
|
||||||
|
return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events${query ? `?${query}` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
async requestLogBackfill(id: string, request: LogBackfillRequest): Promise<JobResponse> {
|
async requestLogBackfill(id: string, request: LogBackfillRequest): Promise<JobResponse> {
|
||||||
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, {
|
return this.request<JobResponse>(`/server-instances/${encodeURIComponent(id)}/logs/backfill`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -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}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
|
- Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, `POST /api/v1/jobs/{id}/cancel`, and `GET /api/v1/audit-events`; the frontend wraps these in one visible operation lifecycle per user intent.
|
||||||
|
|
||||||
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access.
|
||||||
- Log filtering by level/keyword/time/source is applied client-side over `POST /api/v1/log-streams/query` (`LogStreamCursorRequest`) results until the platform exposes server-side filters.
|
- Live log and management-terminal output uses `GET /api/v1/server-instances/{id}/logs/events` as a single `EventSource`/SSE stream with bounded initial history. `POST /api/v1/log-streams/query` remains available for explicit historical cursor reads and reconnect repair, not periodic browser polling.
|
||||||
# Client Manager API projection
|
# Client Manager API projection
|
||||||
|
|
||||||
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
|
`PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client.
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ export type DependencyState = "unknown" | "present" | "missing" | "installing" |
|
|||||||
export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requested" | "activating" | "succeeded" | "rolled-back" | "failed";
|
export type RunUpdatePhase = "queued" | "downloading" | "staged" | "restart-requested" | "activating" | "succeeded" | "rolled-back" | "failed";
|
||||||
export type ServerLifecycleAction = "create" | "start" | "stop" | "status";
|
export type ServerLifecycleAction = "create" | "start" | "stop" | "status";
|
||||||
|
|
||||||
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired";
|
export type GameClientBridgeCommandState = "pending" | "claimed" | "succeeded" | "failed" | "cancelled" | "expired" | "unknown";
|
||||||
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
|
export type GameClientBridgeApprovalState = "not_required" | "pending" | "approved" | "rejected";
|
||||||
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
|
export type GameClientBridgeApprovalLevel = "none" | "operator" | "platform-admin";
|
||||||
export type GameClientBridgeResultStatus = "succeeded" | "failed" | "cancelled";
|
export type GameClientBridgeResultStatus = "succeeded" | "failed" | "cancelled" | "unknown";
|
||||||
export type GameClientBridgeJsonValue = string | number | boolean | null | GameClientBridgeJsonValue[] | GameClientBridgeJsonObject;
|
export type GameClientBridgeJsonValue = string | number | boolean | null | GameClientBridgeJsonValue[] | GameClientBridgeJsonObject;
|
||||||
|
|
||||||
export interface GameClientBridgeJsonObject {
|
export interface GameClientBridgeJsonObject {
|
||||||
@@ -1494,6 +1494,25 @@ export interface LogStreamCursorResponse {
|
|||||||
latestSeq: number;
|
latestSeq: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LogStreamEventResponse {
|
||||||
|
serverInstanceId: string;
|
||||||
|
streamId: string;
|
||||||
|
source: string;
|
||||||
|
streamKey: string;
|
||||||
|
latestSeq: number;
|
||||||
|
entry: LogEntryBody;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogStreamEventsReadyResponse {
|
||||||
|
serverInstanceId: string;
|
||||||
|
streamCount: number;
|
||||||
|
serverTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogStreamEventOptions {
|
||||||
|
historyLimit?: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AuditEventResponse {
|
export interface AuditEventResponse {
|
||||||
id: string;
|
id: string;
|
||||||
actorId: string;
|
actorId: string;
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ export function ServerDeploymentWorkflow({ open, kind, plugins, endpoints, initi
|
|||||||
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
|
const protectedState = (nextValue: string, configured: boolean) => nextValue.trim() ? "将替换" : configured ? "保持已配置" : "未配置";
|
||||||
const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置";
|
const actionLabel = kind === "create" ? "创建服务器" : "保存部署设置";
|
||||||
|
|
||||||
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "先选择插件类型和服务器名称,再按部署方式填写启动项;不要求选择 Run 节点或部署目标。" : "仅停止中的服务器可以修改部署设置。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
|
return <ManagementDialog open={open} title={kind === "create" ? "创建服务器" : "编辑部署"} description={kind === "create" ? "先选择插件类型和服务器名称,再按部署方式填写启动项;不要求选择 Run 节点或部署目标。" : "任何运行状态都可以修改部署设置;这里只保存定义,不会直接重启进程。已保存的受保护路径和命令仅在本窗口内读取,关闭后清除。"} wide onClose={closeWorkflow}>
|
||||||
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
|
<form className="provider-form dialog-form server-deployment-workflow" onSubmit={(event) => void submit(event)} aria-label={kind === "create" ? "创建服务器部署向导" : "编辑服务器部署向导"}>
|
||||||
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
|
<ol className="deployment-workflow-steps" style={{ gridTemplateColumns: `repeat(${workflowSteps.length}, minmax(0, 1fr))` }} aria-label="部署步骤">{workflowSteps.map((item, index) => { const Icon = item.icon; return <li key={item.label} className={cx(index === step && "deployment-workflow-step-active", index < step && "deployment-workflow-step-complete")}><span>{index < step ? <CheckCircle2 size={15} /> : <Icon size={15} />}</span><strong>{index + 1}. {item.label}</strong></li>; })}</ol>
|
||||||
{step === pluginStep && <div className="deployment-workflow-body">
|
{step === pluginStep && <div className="deployment-workflow-body">
|
||||||
|
|||||||
@@ -1,30 +1,29 @@
|
|||||||
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
import { ListChecks, Pause, Play, RotateCw, Send, Sparkles, Terminal, Trash2, X } from "lucide-react";
|
||||||
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
import { type FormEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import type { LogEntryBody, LogStreamResponse } from "../api/types";
|
import type { GameClientBridgeCommandResponse, LogEntryBody, LogStreamResponse } from "../api/types";
|
||||||
import { sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
|
import { scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
|
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
|
||||||
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
import { EmptyState, ErrorState, LoadingState, ResultBadge } from "./StateViews";
|
||||||
|
|
||||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||||
type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
|
|
||||||
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
type TerminalLine = { id: string; tone: "input" | "info" | "success" | "warn" | "error"; text: string; at: string; sortKey: number; streamKey?: string; level?: string; seq?: number };
|
||||||
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
type TerminalQuickCommand = { label: string; command: string; hint: string };
|
||||||
|
|
||||||
const liveLogPollMs = 1000;
|
const terminalBridgeResultPollMs = 1000;
|
||||||
const terminalLogPollMs = 1000;
|
const terminalBridgeResultPollAttempts = 30;
|
||||||
const logStreamPollMs = 5000;
|
|
||||||
const liveLogHistoryWindow = 100;
|
const liveLogHistoryWindow = 100;
|
||||||
const terminalLogQueryLimit = 150;
|
|
||||||
const terminalHistoryWindow = 150;
|
const terminalHistoryWindow = 150;
|
||||||
const maxLogEntries = 500;
|
const maxLogEntries = 500;
|
||||||
const maxTerminalLines = 600;
|
const maxTerminalLines = 600;
|
||||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||||
"game.scum": [
|
"game.scum": [
|
||||||
{ label: "查询玩家", command: "ListPlayers", hint: "SCUM RCON" },
|
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" },
|
||||||
{ label: "服务器信息", command: "ServerInfo", hint: "SCUM RCON" },
|
{ label: "查询队伍", command: "#ListSquads 1", hint: "服务器队伍列表" },
|
||||||
{ label: "设为中午", command: "SetTime 12", hint: "SCUM RCON" }
|
{ label: "查询车辆", command: "#ListSpawnedVehicles true", hint: "已生成车辆列表" },
|
||||||
|
{ label: "设为中午", command: "#SetTime 12", hint: "设置游戏时间" }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,10 +84,15 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
|||||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||||
const [selectedStreamId, setSelectedStreamId] = useState("");
|
const [selectedStreamId, setSelectedStreamId] = useState("");
|
||||||
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
||||||
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
|
|
||||||
const [paused, setPaused] = useState(false);
|
const [paused, setPaused] = useState(false);
|
||||||
const [keyword, setKeyword] = useState("");
|
const [keyword, setKeyword] = useState("");
|
||||||
const [lastRefreshAt, setLastRefreshAt] = useState("");
|
const [lastRefreshAt, setLastRefreshAt] = useState("");
|
||||||
|
const [eventSourceKey, setEventSourceKey] = useState(0);
|
||||||
|
const pausedRef = useRef(paused);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pausedRef.current = paused;
|
||||||
|
}, [paused]);
|
||||||
|
|
||||||
const loadStreams = useCallback(async (showLoading = true) => {
|
const loadStreams = useCallback(async (showLoading = true) => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
@@ -105,40 +109,45 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setEntries([]);
|
setEntries([]);
|
||||||
setCursorByStream({});
|
|
||||||
setPaused(false);
|
setPaused(false);
|
||||||
|
setLastRefreshAt("");
|
||||||
void loadStreams();
|
void loadStreams();
|
||||||
}, [loadStreams, open]);
|
}, [loadStreams, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
|
let ready = false;
|
||||||
return () => window.clearInterval(timer);
|
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: liveLogHistoryWindow });
|
||||||
}, [loadStreams, open]);
|
events.addEventListener("open", () => setLastRefreshAt(new Date().toLocaleTimeString()));
|
||||||
|
events.addEventListener("stream", (event) => {
|
||||||
|
const stream = parseLogStreamEvent(event);
|
||||||
|
if (!stream) return;
|
||||||
|
ready = true;
|
||||||
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||||
|
setSelectedStreamId((current) => current || stream.id);
|
||||||
|
});
|
||||||
|
events.addEventListener("ready", () => {
|
||||||
|
ready = true;
|
||||||
|
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||||||
|
});
|
||||||
|
events.addEventListener("log", (event) => {
|
||||||
|
const payload = parseServerLogEvent(event);
|
||||||
|
if (!payload) return;
|
||||||
|
ready = true;
|
||||||
|
setLastRefreshAt(new Date().toLocaleTimeString());
|
||||||
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
|
||||||
|
setSelectedStreamId((current) => current || payload.streamId);
|
||||||
|
if (pausedRef.current) return;
|
||||||
|
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], maxLogEntries));
|
||||||
|
});
|
||||||
|
events.onerror = () => {
|
||||||
|
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||||
|
};
|
||||||
|
return () => events.close();
|
||||||
|
}, [eventSourceKey, open, serverId]);
|
||||||
|
|
||||||
const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined;
|
const selectedStream = streams.status === "ready" ? streams.data.find((stream) => stream.id === selectedStreamId) : undefined;
|
||||||
|
|
||||||
const tailSelectedStream = useCallback(async () => {
|
|
||||||
if (!open || !selectedStream) return;
|
|
||||||
const afterSeq = cursorByStream[selectedStream.id] ?? initialLogCursor(selectedStream, liveLogHistoryWindow);
|
|
||||||
const cursor = await platformApiClient.queryLogStream({ logStreamId: selectedStream.id, afterSeq, limit: 100 });
|
|
||||||
const nextSeq = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
|
|
||||||
setCursorByStream((current) => updateCursor(current, selectedStream.id, nextSeq));
|
|
||||||
setLastRefreshAt(new Date().toLocaleTimeString());
|
|
||||||
if (cursor.entries.length === 0) return;
|
|
||||||
setEntries((current) => [
|
|
||||||
...current,
|
|
||||||
...cursor.entries.map((entry) => ({ ...entry, source: selectedStream.source || selectedStream.streamKey, streamId: selectedStream.id, streamKey: selectedStream.streamKey }))
|
|
||||||
].slice(-maxLogEntries));
|
|
||||||
}, [cursorByStream, open, selectedStream]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open || paused || !selectedStream) return undefined;
|
|
||||||
void tailSelectedStream().catch(() => undefined);
|
|
||||||
const timer = window.setInterval(() => void tailSelectedStream().catch(() => undefined), liveLogPollMs);
|
|
||||||
return () => window.clearInterval(timer);
|
|
||||||
}, [open, paused, selectedStream, tailSelectedStream]);
|
|
||||||
|
|
||||||
const visibleEntries = useMemo(() => {
|
const visibleEntries = useMemo(() => {
|
||||||
const query = keyword.trim().toLowerCase();
|
const query = keyword.trim().toLowerCase();
|
||||||
return entries.filter((entry) => entry.streamId === selectedStreamId && (!query || entry.line.toLowerCase().includes(query) || (entry.level ?? "info").toLowerCase().includes(query)));
|
return entries.filter((entry) => entry.streamId === selectedStreamId && (!query || entry.line.toLowerCase().includes(query) || (entry.level ?? "info").toLowerCase().includes(query)));
|
||||||
@@ -146,35 +155,28 @@ export function ServerLiveLogDrawer({ open, serverId, serverName, onClose }: Ser
|
|||||||
|
|
||||||
function clearVisibleBuffer() {
|
function clearVisibleBuffer() {
|
||||||
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
|
setEntries((current) => current.filter((entry) => entry.streamId !== selectedStreamId));
|
||||||
if (selectedStream) setCursorByStream((current) => ({ ...current, [selectedStream.id]: selectedStream.latestSeq }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectLogStream(nextStreamId: string) {
|
function selectLogStream(nextStreamId: string) {
|
||||||
setSelectedStreamId(nextStreamId);
|
setSelectedStreamId(nextStreamId);
|
||||||
setEntries([]);
|
|
||||||
setCursorByStream((current) => {
|
|
||||||
const next = { ...current };
|
|
||||||
delete next[nextStreamId];
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · 每 ${liveLogPollMs / 1000} 秒刷新一次平台日志游标`} onClose={onClose}>
|
<LiveOperationDrawer open={open} title="实时日志" description={`${serverName} · SSE 实时推送平台日志`} onClose={onClose}>
|
||||||
<div className="log-filter-bar live-operation-toolbar">
|
<div className="log-filter-bar live-operation-toolbar">
|
||||||
<select value={selectedStreamId} aria-label="选择日志源" onChange={(event) => selectLogStream(event.target.value)}>
|
<select value={selectedStreamId} aria-label="选择日志源" onChange={(event) => selectLogStream(event.target.value)}>
|
||||||
{streams.status === "ready" && streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey || stream.source} · seq {stream.latestSeq}</option>)}
|
{streams.status === "ready" && streams.data.map((stream) => <option key={stream.id} value={stream.id}>{stream.streamKey || stream.source} · seq {stream.latestSeq}</option>)}
|
||||||
</select>
|
</select>
|
||||||
<input type="search" value={keyword} placeholder="过滤可见日志" aria-label="过滤可见日志" onChange={(event) => setKeyword(event.target.value)} />
|
<input type="search" value={keyword} placeholder="过滤可见日志" aria-label="过滤可见日志" onChange={(event) => setKeyword(event.target.value)} />
|
||||||
<button type="button" className="icon-command" onClick={() => setPaused((current) => !current)}>{paused ? <Play size={14} /> : <Pause size={14} />}<span>{paused ? "继续" : "暂停"}</span></button>
|
<button type="button" className="icon-command" onClick={() => setPaused((current) => !current)}>{paused ? <Play size={14} /> : <Pause size={14} />}<span>{paused ? "继续" : "暂停"}</span></button>
|
||||||
<button type="button" className="icon-command" onClick={() => void tailSelectedStream().catch(() => undefined)}><RotateCw size={14} /><span>刷新</span></button>
|
<button type="button" className="icon-command" onClick={() => setEventSourceKey((current) => current + 1)}><RotateCw size={14} /><span>重连</span></button>
|
||||||
<button type="button" className="icon-command" onClick={clearVisibleBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
<button type="button" className="icon-command" onClick={clearVisibleBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||||
</div>
|
</div>
|
||||||
<span className="page-status">状态:{paused ? "已暂停" : "自动刷新"} · 最新刷新 {lastRefreshAt || "等待"} · 游标 {selectedStream ? cursorByStream[selectedStream.id] ?? 0 : "--"}</span>
|
<span className="page-status">状态:{paused ? "已暂停" : "实时推送"} · 最新事件 {lastRefreshAt || "等待"} · 游标 {selectedStream ? selectedStream.latestSeq : "--"}</span>
|
||||||
{streams.status === "loading" && <LoadingState label="正在加载日志源…" compact />}
|
{streams.status === "loading" && <LoadingState label="正在加载日志源…" compact />}
|
||||||
{streams.status === "error" && <ErrorState title="实时日志不可用" reason={streams.reason} diagnosticId={`live-logs:${serverId}`} onRetry={() => void loadStreams()} compact />}
|
{streams.status === "error" && <ErrorState title="实时日志不可用" reason={streams.reason} diagnosticId={`live-logs:${serverId}`} onRetry={() => void loadStreams()} compact />}
|
||||||
{streams.status === "ready" && streams.data.length === 0 && <EmptyState title="暂无日志源" description="运行端还没有向平台登记该服务器的日志流。" />}
|
{streams.status === "ready" && streams.data.length === 0 && <EmptyState title="暂无日志源" description="运行端还没有向平台登记该服务器的日志流。" />}
|
||||||
{streams.status === "ready" && streams.data.length > 0 && visibleEntries.length === 0 && <EmptyState title="等待日志" description="没有新的匹配日志;保持窗口打开会继续按游标刷新。" />}
|
{streams.status === "ready" && streams.data.length > 0 && visibleEntries.length === 0 && <EmptyState title="等待日志" description="没有新的匹配日志;保持窗口打开会继续接收平台推送。" />}
|
||||||
{visibleEntries.length > 0 && (
|
{visibleEntries.length > 0 && (
|
||||||
<div className="log-list live-log-list" role="log" aria-live={paused ? "off" : "polite"}>
|
<div className="log-list live-log-list" role="log" aria-live={paused ? "off" : "polite"}>
|
||||||
{visibleEntries.map((entry) => (
|
{visibleEntries.map((entry) => (
|
||||||
@@ -204,7 +206,6 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [lines, setLines] = useState<TerminalLine[]>([]);
|
const [lines, setLines] = useState<TerminalLine[]>([]);
|
||||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||||
const [cursorByStream, setCursorByStream] = useState<Record<string, number>>({});
|
|
||||||
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
const [commandHistory, setCommandHistory] = useState<string[]>([]);
|
||||||
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
const [historyIndex, setHistoryIndex] = useState<number | null>(null);
|
||||||
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
const [result, setResult] = useState<{ status: "pending" | "succeeded" | "failed"; label: string } | null>(null);
|
||||||
@@ -228,57 +229,43 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
}
|
}
|
||||||
}, [open, serverId]);
|
}, [open, serverId]);
|
||||||
|
|
||||||
const tailTerminalLogs = useCallback(async (targetStreams = terminalStreams) => {
|
|
||||||
if (!open || targetStreams.length === 0) return;
|
|
||||||
const cursorUpdates: Record<string, number> = {};
|
|
||||||
const batches = await Promise.all(targetStreams.map(async (stream) => {
|
|
||||||
const afterSeq = cursorByStream[stream.id] ?? initialLogCursor(stream, terminalHistoryWindow);
|
|
||||||
const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq, limit: terminalLogQueryLimit });
|
|
||||||
cursorUpdates[stream.id] = nextCursorSeq(afterSeq, cursor.entries, cursor.nextSeq);
|
|
||||||
return cursor.entries.map((entry) => terminalLineFromLog(stream, entry));
|
|
||||||
}));
|
|
||||||
setCursorByStream((current) => {
|
|
||||||
let changed = false;
|
|
||||||
const next = { ...current };
|
|
||||||
for (const [streamId, cursor] of Object.entries(cursorUpdates)) {
|
|
||||||
const nextCursor = Math.max(next[streamId] ?? 0, cursor);
|
|
||||||
if (nextCursor !== next[streamId]) {
|
|
||||||
next[streamId] = nextCursor;
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return changed ? next : current;
|
|
||||||
});
|
|
||||||
appendLines(batches.flat().sort(compareTerminalLines));
|
|
||||||
}, [appendLines, cursorByStream, open, terminalStreams]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setCommand("");
|
setCommand("");
|
||||||
setPending(false);
|
setPending(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setCursorByStream({});
|
|
||||||
setHistoryIndex(null);
|
setHistoryIndex(null);
|
||||||
setLines([terminalSystemLine("info", supportsCommands ? "读取平台历史日志,后续按游标实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||||
void loadStreams();
|
void loadStreams();
|
||||||
}, [loadStreams, open, supportsCommands]);
|
}, [loadStreams, open, supportsCommands]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
const timer = window.setInterval(() => void loadStreams(false).catch(() => undefined), logStreamPollMs);
|
let ready = false;
|
||||||
return () => window.clearInterval(timer);
|
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalHistoryWindow });
|
||||||
}, [loadStreams, open]);
|
events.addEventListener("stream", (event) => {
|
||||||
|
const stream = parseLogStreamEvent(event);
|
||||||
useEffect(() => {
|
if (!stream) return;
|
||||||
if (!open || streams.status !== "ready") return;
|
ready = true;
|
||||||
void tailTerminalLogs(terminalRelevantStreams(streams.data)).catch((error) => appendLines([terminalSystemLine("error", error instanceof Error ? error.message : "历史日志读取失败", "LOGS")]));
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||||
}, [appendLines, open, streams]);
|
});
|
||||||
|
events.addEventListener("ready", () => {
|
||||||
useEffect(() => {
|
ready = true;
|
||||||
if (!open || terminalStreams.length === 0) return undefined;
|
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||||||
const timer = window.setInterval(() => void tailTerminalLogs().catch(() => undefined), terminalLogPollMs);
|
});
|
||||||
return () => window.clearInterval(timer);
|
events.addEventListener("log", (event) => {
|
||||||
}, [open, tailTerminalLogs, terminalStreams]);
|
const payload = parseServerLogEvent(event);
|
||||||
|
if (!payload) return;
|
||||||
|
ready = true;
|
||||||
|
const stream = streamFromServerLogEvent(payload);
|
||||||
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||||
|
appendLines([terminalLineFromLog(stream, payload.entry)]);
|
||||||
|
});
|
||||||
|
events.onerror = () => {
|
||||||
|
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||||
|
};
|
||||||
|
return () => events.close();
|
||||||
|
}, [appendLines, open, serverId]);
|
||||||
|
|
||||||
function selectQuickCommand(item: TerminalQuickCommand) {
|
function selectQuickCommand(item: TerminalQuickCommand) {
|
||||||
setCommand(item.command);
|
setCommand(item.command);
|
||||||
@@ -322,11 +309,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
setResult({ status: "pending", label: "正在提交命令" });
|
setResult({ status: "pending", label: "正在提交命令" });
|
||||||
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
appendLines([terminalSystemLine("input", `> ${submitted}`, "COMMAND")]);
|
||||||
try {
|
try {
|
||||||
const response = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, submitted));
|
const response = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, submitted));
|
||||||
const label = `已排队 · 任务 ${response.jobId}`;
|
const label = bridgeCommandDispatchLabel(response.state, response.id);
|
||||||
setResult({ status: "succeeded", label });
|
setResult({ status: "pending", label: `${label} · 等待 Run 返回结果` });
|
||||||
appendLines([terminalSystemLine("success", `${label} · ${response.message || response.status}`, "PLATFORM", `ok-${response.jobId}`)]);
|
appendLines([terminalSystemLine("success", `${label} · protected RCON`, "PLATFORM", `ok-${response.id}`)]);
|
||||||
void tailTerminalLogs().catch(() => undefined);
|
const finalCommand = await waitForBridgeCommandTerminal(response.id);
|
||||||
|
if (finalCommand) {
|
||||||
|
const outcome = terminalLineFromBridgeCommand(finalCommand);
|
||||||
|
setResult({ status: outcome.tone === "success" ? "succeeded" : "failed", label: outcome.text });
|
||||||
|
appendLines([outcome]);
|
||||||
|
} else {
|
||||||
|
const timeoutLine = terminalSystemLine("warn", `桥接命令 ${response.id} 已排队,但尚未返回终态;继续观察实时日志。`, "PLATFORM", `pending-${response.id}`);
|
||||||
|
setResult({ status: "pending", label: "等待 Run 返回结果" });
|
||||||
|
appendLines([timeoutLine]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const label = error instanceof Error ? error.message : "命令提交失败";
|
const label = error instanceof Error ? error.message : "命令提交失败";
|
||||||
setResult({ status: "failed", label });
|
setResult({ status: "failed", label });
|
||||||
@@ -336,13 +332,22 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForBridgeCommandTerminal(commandId: string): Promise<GameClientBridgeCommandResponse | null> {
|
||||||
|
for (let attempt = 0; attempt < terminalBridgeResultPollAttempts; attempt += 1) {
|
||||||
|
const current = await platformApiClient.getGameClientBridgeCommand(serverId, commandId);
|
||||||
|
if (isTerminalBridgeCommandState(current.state)) return current;
|
||||||
|
await delay(terminalBridgeResultPollMs);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LiveOperationDrawer open={open} title="管理终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
|
<LiveOperationDrawer open={open} title="管理终端" onClose={onClose} backdropClassName="terminal-drawer-backdrop" panelClassName="management-terminal-drawer" bodyClassName="management-terminal-body" hideHeader>
|
||||||
<section className="terminal-output-panel" aria-label="terminal output">
|
<section className="terminal-output-panel" aria-label="terminal output">
|
||||||
<div className="terminal-output-topbar">
|
<div className="terminal-output-topbar">
|
||||||
<div>
|
<div>
|
||||||
<strong>{serverName}</strong>
|
<strong>{serverName}</strong>
|
||||||
<span>最近历史 + {terminalLogPollMs / 1000}s 实时刷新 · 日志源 {logStreamPollMs / 1000}s 探测 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
|
<span>最近历史 + SSE 实时推送 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
<button type="button" className="terminal-output-action" onClick={clearTerminalBuffer}><Trash2 size={14} /><span>清屏</span></button>
|
||||||
@@ -386,39 +391,35 @@ function levelClass(level?: string): string {
|
|||||||
return "log-level-info";
|
return "log-level-info";
|
||||||
}
|
}
|
||||||
|
|
||||||
function initialLogCursor(stream: LogStreamResponse, historyWindow: number): number {
|
|
||||||
return Math.max(0, stream.latestSeq - historyWindow);
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextCursorSeq(afterSeq: number, entries: LogEntryBody[], nextSeq: number): number {
|
|
||||||
if (entries.length === 0) return Math.max(afterSeq, nextSeq);
|
|
||||||
return Math.max(afterSeq, nextSeq, entries[entries.length - 1]?.seq ?? afterSeq);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCursor(current: Record<string, number>, streamId: string, cursor: number): Record<string, number> {
|
|
||||||
const nextCursor = Math.max(current[streamId] ?? 0, cursor);
|
|
||||||
if (nextCursor === current[streamId]) return current;
|
|
||||||
return { ...current, [streamId]: nextCursor };
|
|
||||||
}
|
|
||||||
|
|
||||||
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
function terminalQuickCommandsForPlugin(pluginId: string): TerminalQuickCommand[] {
|
||||||
return terminalQuickCommandCatalog[pluginId] ?? [];
|
return terminalQuickCommandCatalog[pluginId] ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function bridgeCommandDispatchLabel(state: string, commandId: string): string {
|
||||||
|
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||||
|
}
|
||||||
|
|
||||||
function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] {
|
function terminalRelevantStreams(streams: LogStreamResponse[]): LogStreamResponse[] {
|
||||||
return [...streams].sort(compareTerminalStreams).slice(0, 8);
|
const active = streams.filter((stream) => stream.latestSeq > 0);
|
||||||
|
const candidates = active.length > 0 ? active : streams;
|
||||||
|
return [...candidates].sort(compareTerminalStreams).slice(0, 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
function compareTerminalStreams(a: LogStreamResponse, b: LogStreamResponse): number {
|
function compareTerminalStreams(a: LogStreamResponse, b: LogStreamResponse): number {
|
||||||
return terminalStreamRank(a) - terminalStreamRank(b) || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id);
|
const rank = terminalStreamRank(a) - terminalStreamRank(b);
|
||||||
|
if (rank !== 0) return rank;
|
||||||
|
const updated = (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0);
|
||||||
|
if (updated !== 0) return updated;
|
||||||
|
return b.latestSeq - a.latestSeq || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalStreamRank(stream: LogStreamResponse): number {
|
function terminalStreamRank(stream: LogStreamResponse): number {
|
||||||
const key = `${stream.source}:${stream.streamKey}`.toLowerCase();
|
const key = `${stream.source}:${stream.streamKey}`.toLowerCase();
|
||||||
if (key.includes("management-program")) return 0;
|
if (stream.source === "file" || key.includes("scum.")) return 0;
|
||||||
if (key.includes("stderr")) return 1;
|
if (key.includes("management-program")) return 1;
|
||||||
if (key.includes("stdout")) return 2;
|
if (key.includes("stderr")) return 2;
|
||||||
return 3;
|
if (key.includes("stdout")) return 3;
|
||||||
|
return 4;
|
||||||
}
|
}
|
||||||
|
|
||||||
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
function terminalLineFromLog(stream: LogStreamResponse, entry: LogEntryBody): TerminalLine {
|
||||||
@@ -439,6 +440,34 @@ function terminalSystemLine(tone: TerminalLine["tone"], text: string, streamKey:
|
|||||||
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
|
return { id, tone, text, at: new Date(now).toLocaleTimeString(), sortKey: now, streamKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
|
||||||
|
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalLineFromBridgeCommand(command: GameClientBridgeCommandResponse): TerminalLine {
|
||||||
|
const summary = command.result?.summary || command.resultSummary || command.cancellation?.reason || bridgeCommandStateLabel(command.state);
|
||||||
|
const completed = command.completedAt || command.result?.completedAt || command.cancellation?.cancelledAt || command.updatedAt;
|
||||||
|
const sortKey = Date.parse(completed) || Date.now();
|
||||||
|
const tone: TerminalLine["tone"] = command.state === "succeeded" ? "success" : command.state === "failed" ? "error" : "warn";
|
||||||
|
return { id: `bridge-${command.id}-${command.state}`, tone, text: `桥接命令 ${command.id} · ${bridgeCommandStateLabel(command.state)} · ${summary}`, at: new Date(sortKey).toLocaleTimeString(), sortKey, streamKey: "BRIDGE" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function bridgeCommandStateLabel(state: GameClientBridgeCommandResponse["state"]): string {
|
||||||
|
switch (state) {
|
||||||
|
case "succeeded": return "已成功";
|
||||||
|
case "failed": return "已失败";
|
||||||
|
case "cancelled": return "已取消";
|
||||||
|
case "expired": return "已过期";
|
||||||
|
case "unknown": return "状态未知";
|
||||||
|
case "claimed": return "Run 已领取";
|
||||||
|
case "pending": return "已排队";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
|
function terminalTone(entry: LogEntryBody): TerminalLine["tone"] {
|
||||||
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase();
|
const value = `${entry.level ?? ""} ${entry.line}`.toLowerCase();
|
||||||
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error";
|
if (/\b(error|fatal|panic|exception|failed|failure)\b/.test(value)) return "error";
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { describe, expect, it } from "vitest";
|
|||||||
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
|
import sourceRCONCommandPanelSource from "./SourceRCONCommandPanel.tsx?raw";
|
||||||
|
|
||||||
describe("SourceRCONCommandPanel", () => {
|
describe("SourceRCONCommandPanel", () => {
|
||||||
it("uses the typed dispatch API without confirmation, transcript, or connection fields", () => {
|
it("uses protected bridge dispatch without confirmation, transcript, or connection fields", () => {
|
||||||
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
|
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
|
||||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONChatRequest");
|
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
|
||||||
expect(sourceRCONCommandPanelSource).toContain("sourceRCONRawCommandRequest");
|
expect(sourceRCONCommandPanelSource).toContain("scumAnnouncementCommand");
|
||||||
|
expect(sourceRCONCommandPanelSource).not.toContain("sendSourceRCONCommand");
|
||||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||||
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
|
expect(sourceRCONCommandPanelSource).not.toContain("transcript");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { type FormEvent, useState } from "react";
|
import { type FormEvent, useState } from "react";
|
||||||
|
|
||||||
import { platformApiClient } from "../api/client";
|
import { platformApiClient } from "../api/client";
|
||||||
import { sourceRCONChatRequest, sourceRCONRawCommandRequest } from "../schemas/sourceRcon";
|
import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "../schemas/scumManagementRcon";
|
||||||
import { ResultBadge } from "./StateViews";
|
import { ResultBadge } from "./StateViews";
|
||||||
|
|
||||||
interface SourceRCONCommandPanelProps {
|
interface SourceRCONCommandPanelProps {
|
||||||
@@ -12,28 +12,25 @@ interface SourceRCONCommandPanelProps {
|
|||||||
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
|
type DispatchState = { status: "pending" | "succeeded" | "failed"; label: string } | null;
|
||||||
|
|
||||||
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
|
export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommandPanelProps) {
|
||||||
const [chatType, setChatType] = useState(4);
|
const [announcement, setAnnouncement] = useState("");
|
||||||
const [chatMessage, setChatMessage] = useState("");
|
|
||||||
const [targetSteamId, setTargetSteamId] = useState("");
|
|
||||||
const [rawCommand, setRawCommand] = useState("");
|
const [rawCommand, setRawCommand] = useState("");
|
||||||
const [pending, setPending] = useState<"chat" | "command" | null>(null);
|
const [pending, setPending] = useState<"announcement" | "command" | null>(null);
|
||||||
const [dispatch, setDispatch] = useState<DispatchState>(null);
|
const [dispatch, setDispatch] = useState<DispatchState>(null);
|
||||||
|
|
||||||
if (pluginId !== "game.scum") {
|
if (pluginId !== "game.scum") {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendChat(event: FormEvent<HTMLFormElement>) {
|
async function sendAnnouncement(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setPending("chat");
|
setPending("announcement");
|
||||||
setDispatch({ status: "pending", label: "正在提交聊天消息" });
|
setDispatch({ status: "pending", label: "正在提交服务器公告" });
|
||||||
try {
|
try {
|
||||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONChatRequest(serverId, { chatType, message: chatMessage, targetSteamId }));
|
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, scumAnnouncementCommand(announcement)));
|
||||||
setChatMessage("");
|
setAnnouncement("");
|
||||||
setTargetSteamId("");
|
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
|
||||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "聊天消息提交失败" });
|
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "服务器公告提交失败" });
|
||||||
} finally {
|
} finally {
|
||||||
setPending(null);
|
setPending(null);
|
||||||
}
|
}
|
||||||
@@ -44,9 +41,9 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
|||||||
setPending("command");
|
setPending("command");
|
||||||
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
|
setDispatch({ status: "pending", label: "正在提交原始管理员指令" });
|
||||||
try {
|
try {
|
||||||
const submitted = await platformApiClient.sendSourceRCONCommand(serverId, sourceRCONRawCommandRequest(serverId, rawCommand));
|
const submitted = await platformApiClient.queueGameClientBridgeCommand(serverId, scumManagementRCONCommandRequest(serverId, rawCommand));
|
||||||
setRawCommand("");
|
setRawCommand("");
|
||||||
setDispatch({ status: "succeeded", label: sourceRCONDispatchLabel(submitted.jobId, submitted.status) });
|
setDispatch({ status: "succeeded", label: protectedRCONDispatchLabel(submitted.id, submitted.state) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
|
setDispatch({ status: "failed", label: error instanceof Error ? error.message : "原始管理员指令提交失败" });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -55,35 +52,24 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="console-panel" aria-label="SCUM Source RCON controls">
|
<article className="console-panel" aria-label="SCUM protected RCON controls">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>SCUM 聊天与管理员指令</h2>
|
<h2>SCUM 公告与管理员指令</h2>
|
||||||
<p className="page-status">立即派发一次性任务;只显示安全状态,不保留聊天或指令记录。</p>
|
<p className="page-status">通过插件声明的 protected RCON 通道派发;只显示安全状态,不保留指令原文。</p>
|
||||||
</div>
|
</div>
|
||||||
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
|
{dispatch && <ResultBadge status={dispatch.status} label={dispatch.label} />}
|
||||||
</div>
|
</div>
|
||||||
<div className="operations-command-grid">
|
<div className="operations-command-grid">
|
||||||
<section className="console-module" aria-label="SCUM chat command">
|
<section className="console-module" aria-label="SCUM announcement command">
|
||||||
<div className="panel-header"><h2>发送聊天</h2></div>
|
<div className="panel-header"><h2>发送公告</h2></div>
|
||||||
<form className="provider-form" onSubmit={(event) => void sendChat(event)}>
|
<form className="provider-form" onSubmit={(event) => void sendAnnouncement(event)}>
|
||||||
<div className="form-grid">
|
|
||||||
<label>
|
|
||||||
聊天类型
|
|
||||||
<select value={chatType} onChange={(event) => setChatType(Number(event.target.value))} disabled={pending !== null}>
|
|
||||||
{[0, 1, 2, 3, 4, 5, 6, 7].map((value) => <option key={value} value={value}>类型 {value}</option>)}
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
目标 SteamID64(可选)
|
|
||||||
<input value={targetSteamId} inputMode="numeric" maxLength={17} onChange={(event) => setTargetSteamId(event.target.value)} disabled={pending !== null} placeholder="留空为广播" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<label>
|
<label>
|
||||||
聊天内容
|
公告内容
|
||||||
<textarea value={chatMessage} maxLength={1024} rows={3} onChange={(event) => setChatMessage(event.target.value)} disabled={pending !== null} placeholder="输入单行聊天内容" />
|
<textarea value={announcement} maxLength={1024} rows={3} onChange={(event) => setAnnouncement(event.target.value)} disabled={pending !== null} placeholder="输入单行公告内容" />
|
||||||
</label>
|
</label>
|
||||||
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !chatMessage.trim()}>{pending === "chat" ? "提交中…" : "发送聊天"}</button></div>
|
<p className="page-status">会生成 SCUM 管理命令:#Announce 公告内容。</p>
|
||||||
|
<div className="action-strip"><button type="submit" className="primary-command" disabled={pending !== null || !announcement.trim()}>{pending === "announcement" ? "提交中…" : "发送公告"}</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
<section className="console-module" aria-label="SCUM raw administrator command">
|
<section className="console-module" aria-label="SCUM raw administrator command">
|
||||||
@@ -91,9 +77,9 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
|||||||
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
|
<form className="provider-form" onSubmit={(event) => void sendRawCommand(event)}>
|
||||||
<label>
|
<label>
|
||||||
指令
|
指令
|
||||||
<textarea value={rawCommand} maxLength={4000} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 SetTime 12" />
|
<textarea value={rawCommand} maxLength={8192} rows={5} onChange={(event) => setRawCommand(event.target.value)} disabled={pending !== null} placeholder="例如 #ListPlayers 或 #SetTime 12" />
|
||||||
</label>
|
</label>
|
||||||
<p className="page-status">指令会直接交给当前运行中的 SCUM,不会显示执行回包。</p>
|
<p className="page-status">裸命令会交给当前运行中的 SCUM;执行结果以 Run 日志为准。</p>
|
||||||
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
|
<div className="action-strip"><button type="submit" className="icon-command" disabled={pending !== null || !rawCommand.trim()}>{pending === "command" ? "提交中…" : "发送指令"}</button></div>
|
||||||
</form>
|
</form>
|
||||||
</section>
|
</section>
|
||||||
@@ -102,6 +88,6 @@ export function SourceRCONCommandPanel({ serverId, pluginId }: SourceRCONCommand
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sourceRCONDispatchLabel(jobId: string, status: string): string {
|
function protectedRCONDispatchLabel(commandId: string, state: string): string {
|
||||||
return `已${status === "queued" ? "排队" : "提交"} · 任务 ${jobId}`;
|
return `已${state === "pending" ? "排队" : "提交"} · 桥接命令 ${commandId}`;
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-1
@@ -5,6 +5,20 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
location ~ ^/api/v1/server-instances/[^/]+/logs/events$ {
|
||||||
|
proxy_pass http://platform:8080;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_read_timeout 1h;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
add_header X-Accel-Buffering no;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/v1/ {
|
location /api/v1/ {
|
||||||
proxy_pass http://platform:8080/api/v1/;
|
proxy_pass http://platform:8080/api/v1/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -27,4 +41,3 @@ server {
|
|||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -308,21 +308,34 @@ describe("first-party console pages", () => {
|
|||||||
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
|
expect(serverLiveOperationsSource).toContain("terminal-command-dock");
|
||||||
expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog");
|
expect(serverLiveOperationsSource).toContain("terminalQuickCommandCatalog");
|
||||||
expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin");
|
expect(serverLiveOperationsSource).toContain("terminalQuickCommandsForPlugin");
|
||||||
|
expect(serverLiveOperationsSource).toContain("queueGameClientBridgeCommand");
|
||||||
|
expect(serverLiveOperationsSource).toContain("scumManagementRCONCommandRequest");
|
||||||
|
expect(serverLiveOperationsSource).toContain("#ListSquads");
|
||||||
|
expect(serverLiveOperationsSource).toContain("#ListSpawnedVehicles");
|
||||||
expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)");
|
expect(serverLiveOperationsSource).toContain("selectQuickCommand(item)");
|
||||||
expect(serverLiveOperationsSource).toContain("hideHeader");
|
expect(serverLiveOperationsSource).toContain("hideHeader");
|
||||||
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
|
expect(serverLiveOperationsSource).toContain("handleCommandKeyDown");
|
||||||
expect(serverLiveOperationsSource).toContain("commandHistory");
|
expect(serverLiveOperationsSource).toContain("commandHistory");
|
||||||
expect(serverLiveOperationsSource).toContain("ArrowUp");
|
expect(serverLiveOperationsSource).toContain("ArrowUp");
|
||||||
expect(serverLiveOperationsSource).toContain("listServerLiveLogs");
|
expect(serverLiveOperationsSource).toContain("listServerLiveLogs");
|
||||||
expect(serverLiveOperationsSource).toContain("queryLogStream");
|
expect(serverLiveOperationsSource).toContain("openServerLogEvents");
|
||||||
expect(serverLiveOperationsSource).toContain("terminalLogPollMs = 1000");
|
expect(serverLiveOperationsSource).toContain("getGameClientBridgeCommand");
|
||||||
expect(serverLiveOperationsSource).toContain("logStreamPollMs = 5000");
|
expect(serverLiveOperationsSource).toContain("terminalLineFromBridgeCommand");
|
||||||
|
expect(serverLiveOperationsSource).toContain("streams.filter((stream) => stream.latestSeq > 0)");
|
||||||
|
expect(serverLiveOperationsSource).toContain("SSE 实时推送");
|
||||||
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
||||||
expect(serverLiveOperationsSource).toContain("nextCursorSeq");
|
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
||||||
expect(serverLiveOperationsSource).toContain("initialLogCursor");
|
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
|
||||||
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
|
expect(serverLiveOperationsSource).not.toContain("SaveWorld");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses declared SCUM log source keys for log backfill defaults", () => {
|
||||||
|
expect(serversPageSource).toContain("scum-server-events");
|
||||||
|
expect(serverDetailPageSource).toContain("scum-server-events");
|
||||||
|
expect(serversPageSource).not.toContain("server-log");
|
||||||
|
expect(serverDetailPageSource).not.toContain("server-log");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders plugin catalog bridge readiness", () => {
|
it("renders plugin catalog bridge readiness", () => {
|
||||||
const html = renderToStaticMarkup(<PluginsPage />);
|
const html = renderToStaticMarkup(<PluginsPage />);
|
||||||
|
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
expect(serverDetailPageSource).toContain("resetClientManagerKey");
|
expect(serverDetailPageSource).toContain("resetClientManagerKey");
|
||||||
expect(serverDetailPageSource).toContain("checkDependencies");
|
expect(serverDetailPageSource).toContain("checkDependencies");
|
||||||
expect(serverDetailPageSource).toContain("installDependencies");
|
expect(serverDetailPageSource).toContain("installDependencies");
|
||||||
expect(serverDetailPageSource).toContain("listServerLiveLogs");
|
expect(serverDetailPageSource).toContain("openServerLogEvents");
|
||||||
expect(serverDetailPageSource).toContain("requestLogBackfill");
|
expect(serverDetailPageSource).toContain("requestLogBackfill");
|
||||||
expect(serverDetailPageSource).toContain("ClientManagerLifecyclePanel");
|
expect(serverDetailPageSource).toContain("ClientManagerLifecyclePanel");
|
||||||
expect(clientManagerLifecyclePanelSource).toContain("listClientManagerLifecycles");
|
expect(clientManagerLifecyclePanelSource).toContain("listClientManagerLifecycles");
|
||||||
@@ -119,11 +119,12 @@ describe("ServerDetailPage config write approval", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("adds direct SCUM chat and raw commands through the one-time typed RCON API", () => {
|
it("adds SCUM announcements and raw commands through protected RCON bridge jobs", () => {
|
||||||
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
|
expect(serverDetailPageSource).toContain("SourceRCONCommandPanel");
|
||||||
expect(sourceRCONCommandPanelSource).toContain("sendSourceRCONCommand");
|
expect(sourceRCONCommandPanelSource).toContain("queueGameClientBridgeCommand");
|
||||||
expect(sourceRCONCommandPanelSource).toContain("不保留聊天或指令记录");
|
expect(sourceRCONCommandPanelSource).toContain("scumManagementRCONCommandRequest");
|
||||||
expect(sourceRCONCommandPanelSource).toContain("不会显示执行回包");
|
expect(sourceRCONCommandPanelSource).toContain("不保留指令原文");
|
||||||
|
expect(sourceRCONCommandPanelSource).toContain("执行结果以 Run 日志为准");
|
||||||
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
expect(sourceRCONCommandPanelSource).not.toContain("ConfirmDialog");
|
||||||
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
expect(sourceRCONCommandPanelSource).not.toContain("operations.");
|
||||||
for (const forbidden of ["password", "host", "transcript", "history"]) {
|
for (const forbidden of ["password", "host", "transcript", "history"]) {
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type {
|
|||||||
DependencyCatalogResponse,
|
DependencyCatalogResponse,
|
||||||
GamePluginResponse,
|
GamePluginResponse,
|
||||||
JobResponse,
|
JobResponse,
|
||||||
LogEntryBody,
|
|
||||||
LogStreamResponse,
|
LogStreamResponse,
|
||||||
RunDistributionResponse,
|
RunDistributionResponse,
|
||||||
RunUpdateJobResponse,
|
RunUpdateJobResponse,
|
||||||
@@ -74,6 +73,7 @@ import { createPluginBridgeDispatcher, createPluginBridgeHostContext, parsePlugi
|
|||||||
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
|
import { downloadArtifactReference, safeArtifactError, safeArtifactFilename } from "../utils/artifactTransfer";
|
||||||
import { cx } from "../utils/classes";
|
import { cx } from "../utils/classes";
|
||||||
import { stateLabel, statusClass } from "./ServersPage";
|
import { stateLabel, statusClass } from "./ServersPage";
|
||||||
|
import { appendLiveLogEntries, entryFromServerLogEvent, mergeLogStreams, parseLogStreamEvent, parseServerLogEvent, streamFromServerLogEvent, type LiveLogEntry } from "../utils/logEvents";
|
||||||
|
|
||||||
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
type LoadState<T> = { status: "loading" } | { status: "error"; reason: string } | { status: "ready"; data: T };
|
||||||
|
|
||||||
@@ -1359,7 +1359,7 @@ function runtimeDefaultsForPlugin(pluginId: string) {
|
|||||||
sourceRevision: "main",
|
sourceRevision: "main",
|
||||||
probeKey: isScum ? "steamcmd" : "java-21",
|
probeKey: isScum ? "steamcmd" : "java-21",
|
||||||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||||||
logSourceKey: isScum ? "server-log" : "latest-log"
|
logSourceKey: isScum ? "scum-server-events" : "latest-log"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1394,40 +1394,41 @@ interface LogFilterState {
|
|||||||
|
|
||||||
function LogsSection({ serverId }: LogsSectionProps) {
|
function LogsSection({ serverId }: LogsSectionProps) {
|
||||||
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
const [streams, setStreams] = useState<LoadState<LogStreamResponse[]>>({ status: "loading" });
|
||||||
const [entries, setEntries] = useState<Array<LogEntryBody & { source: string }>>([]);
|
const [entries, setEntries] = useState<LiveLogEntry[]>([]);
|
||||||
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
|
const [filter, setFilter] = useState<LogFilterState>({ level: "all", keyword: "", source: "all", sinceMinutes: "all" });
|
||||||
const [selected, setSelected] = useState<(LogEntryBody & { source: string }) | null>(null);
|
const [selected, setSelected] = useState<LiveLogEntry | null>(null);
|
||||||
|
const [eventSourceKey, setEventSourceKey] = useState(0);
|
||||||
|
|
||||||
const refresh = useCallback(async (showLoading = true) => {
|
const refresh = useCallback(() => setEventSourceKey((current) => current + 1), []);
|
||||||
if (showLoading) setStreams({ status: "loading" });
|
|
||||||
try {
|
|
||||||
const response = await platformApiClient.listServerLiveLogs(serverId);
|
|
||||||
const serverStreams = response.items;
|
|
||||||
setStreams({ status: "ready", data: serverStreams });
|
|
||||||
const collected: Array<LogEntryBody & { source: string }> = [];
|
|
||||||
for (const stream of serverStreams) {
|
|
||||||
try {
|
|
||||||
const cursor = await platformApiClient.queryLogStream({ logStreamId: stream.id, afterSeq: 0, limit: 200 });
|
|
||||||
collected.push(...cursor.entries.map((entry) => ({ ...entry, source: stream.source || stream.streamKey })));
|
|
||||||
} catch {
|
|
||||||
// one unreadable stream should not blank the rest
|
|
||||||
}
|
|
||||||
}
|
|
||||||
collected.sort((a, b) => (a.timestamp < b.timestamp ? 1 : -1));
|
|
||||||
setEntries(collected);
|
|
||||||
} catch (error) {
|
|
||||||
setStreams({ status: "error", reason: error instanceof Error ? error.message : "加载失败" });
|
|
||||||
}
|
|
||||||
}, [serverId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refresh();
|
setStreams({ status: "loading" });
|
||||||
}, [refresh]);
|
setEntries([]);
|
||||||
|
setSelected(null);
|
||||||
useEffect(() => {
|
let ready = false;
|
||||||
const timer = window.setInterval(() => void refresh(false), 2000);
|
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: 200 });
|
||||||
return () => window.clearInterval(timer);
|
events.addEventListener("stream", (event) => {
|
||||||
}, [refresh]);
|
const stream = parseLogStreamEvent(event);
|
||||||
|
if (!stream) return;
|
||||||
|
ready = true;
|
||||||
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], stream) }));
|
||||||
|
});
|
||||||
|
events.addEventListener("ready", () => {
|
||||||
|
ready = true;
|
||||||
|
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||||||
|
});
|
||||||
|
events.addEventListener("log", (event) => {
|
||||||
|
const payload = parseServerLogEvent(event);
|
||||||
|
if (!payload) return;
|
||||||
|
ready = true;
|
||||||
|
setStreams((current) => ({ status: "ready", data: mergeLogStreams(current.status === "ready" ? current.data : [], streamFromServerLogEvent(payload)) }));
|
||||||
|
setEntries((current) => appendLiveLogEntries(current, [entryFromServerLogEvent(payload)], 1000));
|
||||||
|
});
|
||||||
|
events.onerror = () => {
|
||||||
|
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||||
|
};
|
||||||
|
return () => events.close();
|
||||||
|
}, [eventSourceKey, serverId]);
|
||||||
|
|
||||||
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
|
const sources = useMemo(() => [...new Set(entries.map((entry) => entry.source))], [entries]);
|
||||||
|
|
||||||
@@ -1448,16 +1449,16 @@ function LogsSection({ serverId }: LogsSectionProps) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
}).sort(compareLogEntriesDesc);
|
||||||
}, [entries, filter]);
|
}, [entries, filter]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<article className="console-panel" aria-label="server logs">
|
<article className="console-panel" aria-label="server logs">
|
||||||
<div className="panel-header">
|
<div className="panel-header">
|
||||||
<h2>日志</h2>
|
<h2>日志</h2>
|
||||||
<button type="button" className="icon-command" onClick={() => void refresh()}>
|
<button type="button" className="icon-command" onClick={refresh}>
|
||||||
<Sparkles size={14} />
|
<Sparkles size={14} />
|
||||||
<span>刷新</span>
|
<span>重连</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="log-filter-bar">
|
<div className="log-filter-bar">
|
||||||
@@ -1495,9 +1496,9 @@ function LogsSection({ serverId }: LogsSectionProps) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
|
{streams.status === "loading" && <LoadingState label="正在加载日志…" compact />}
|
||||||
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={() => void refresh()} compact />}
|
{streams.status === "error" && <ErrorState title="日志加载失败" reason={streams.reason} diagnosticId={`logs:${serverId}`} onRetry={refresh} compact />}
|
||||||
{streams.status === "ready" && entries.length === 0 && (
|
{streams.status === "ready" && entries.length === 0 && (
|
||||||
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="刷新" onAction={() => void refresh()} />
|
<EmptyState title="暂无日志" description="该服务器还没有已入库的日志流,或运行端尚未上报日志。" actionLabel="重连" onAction={refresh} />
|
||||||
)}
|
)}
|
||||||
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
|
{streams.status === "ready" && entries.length > 0 && visible.length === 0 && (
|
||||||
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
|
<EmptyState title="没有匹配的日志" description="调整级别、来源、时间范围或关键字后再试。" />
|
||||||
@@ -1505,7 +1506,7 @@ function LogsSection({ serverId }: LogsSectionProps) {
|
|||||||
{visible.length > 0 && (
|
{visible.length > 0 && (
|
||||||
<div className="log-list" role="list">
|
<div className="log-list" role="list">
|
||||||
{visible.map((entry) => (
|
{visible.map((entry) => (
|
||||||
<button key={`${entry.source}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
|
<button key={`${entry.streamId}-${entry.seq}`} type="button" className="log-line" role="listitem" onClick={() => setSelected(entry)}>
|
||||||
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
|
<time>{new Date(entry.timestamp).toLocaleTimeString()}</time>
|
||||||
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
|
<span className={cx("log-level", levelClass(entry.level))}>{(entry.level ?? "info").toUpperCase()}</span>
|
||||||
<span>{entry.line}</span>
|
<span>{entry.line}</span>
|
||||||
@@ -1574,6 +1575,12 @@ function levelClass(level?: string): string {
|
|||||||
return "log-level-info";
|
return "log-level-info";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareLogEntriesDesc(a: LiveLogEntry, b: LiveLogEntry): number {
|
||||||
|
const time = (Date.parse(b.timestamp) || 0) - (Date.parse(a.timestamp) || 0);
|
||||||
|
if (time !== 0) return time;
|
||||||
|
return b.seq - a.seq;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConfigSectionProps {
|
interface ConfigSectionProps {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
instance: ServerInstanceResponse;
|
instance: ServerInstanceResponse;
|
||||||
|
|||||||
@@ -949,7 +949,7 @@ function quickRuntimeDefaultsForPlugin(pluginId: string) {
|
|||||||
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
repositoryUrl: isScum ? "https://github.com/F88888/scum_client.git" : "https://github.com/example/client-manager.git",
|
||||||
probeKey: isScum ? "steamcmd" : "java-21",
|
probeKey: isScum ? "steamcmd" : "java-21",
|
||||||
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
installPlanKey: isScum ? "install-steamcmd-linux" : "install-java-linux",
|
||||||
logSourceKey: isScum ? "server-log" : "latest-log"
|
logSourceKey: isScum ? "scum-server-events" : "latest-log"
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ import type {
|
|||||||
GameClientBridgeStatusResponse
|
GameClientBridgeStatusResponse
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
|
|
||||||
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired"]);
|
const commandStates = new Set<GameClientBridgeCommandState>(["pending", "claimed", "succeeded", "failed", "cancelled", "expired", "unknown"]);
|
||||||
const approvalStates = new Set<GameClientBridgeApprovalState>(["not_required", "pending", "approved", "rejected"]);
|
const approvalStates = new Set<GameClientBridgeApprovalState>(["not_required", "pending", "approved", "rejected"]);
|
||||||
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled"]);
|
const resultStatuses = new Set<GameClientBridgeResultStatus>(["succeeded", "failed", "cancelled", "unknown"]);
|
||||||
const forbiddenKeys = new Set([
|
const forbiddenKeys = new Set([
|
||||||
"apikey",
|
"apikey",
|
||||||
"accesskey",
|
"accesskey",
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { scumAnnouncementCommand, scumManagementRCONCommandRequest } from "./scumManagementRcon";
|
||||||
|
|
||||||
|
describe("SCUM management RCON bridge schema", () => {
|
||||||
|
it("builds a protected bridge request without connection material", () => {
|
||||||
|
const stamp = Date.UTC(2026, 7, 3, 8, 0, 0);
|
||||||
|
|
||||||
|
expect(scumManagementRCONCommandRequest("server/unsafe", " ListPlayers ", stamp)).toEqual({
|
||||||
|
profileKey: "scum-client-manager",
|
||||||
|
commandType: "management.rcon.request",
|
||||||
|
payload: { requestText: "#ListPlayers" },
|
||||||
|
idempotencyKey: `web:scum-rcon:server-unsafe:${stamp}`,
|
||||||
|
priority: 20,
|
||||||
|
expiresAt: "2026-08-03T08:02:00.000Z"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats announcements and rejects framed command text", () => {
|
||||||
|
expect(scumAnnouncementCommand("Restart in ten minutes")).toBe("#Announce Restart in ten minutes");
|
||||||
|
expect(scumManagementRCONCommandRequest("server-1", "#SetTime 12").payload).toEqual({ requestText: "#SetTime 12" });
|
||||||
|
expect(() => scumManagementRCONCommandRequest("server-1", "ListPlayers\nSetTime 12")).toThrow("管理指令必须是受限的单行文本");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { GameClientBridgeQueueRequest } from "../api/types";
|
||||||
|
|
||||||
|
export const scumManagementRCONProfileKey = "scum-client-manager";
|
||||||
|
export const scumManagementRCONCommandType = "management.rcon.request";
|
||||||
|
|
||||||
|
const maxManagementCommandBytes = 8192;
|
||||||
|
const managementCommandTtlMs = 120_000;
|
||||||
|
|
||||||
|
export function scumManagementRCONCommandRequest(serverInstanceId: string, command: string, sequence = Date.now()): GameClientBridgeQueueRequest {
|
||||||
|
const stamp = Math.max(0, Math.floor(sequence));
|
||||||
|
return {
|
||||||
|
profileKey: scumManagementRCONProfileKey,
|
||||||
|
commandType: scumManagementRCONCommandType,
|
||||||
|
payload: { requestText: normalizeSCUMManagementCommand(command, "管理指令") },
|
||||||
|
idempotencyKey: `web:scum-rcon:${safeBridgeIdentifierPart(serverInstanceId)}:${stamp}`,
|
||||||
|
priority: 20,
|
||||||
|
expiresAt: new Date(stamp + managementCommandTtlMs).toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scumAnnouncementCommand(message: string): string {
|
||||||
|
return `#Announce ${validateSCUMManagementRCONText(message, "公告内容")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateSCUMManagementRCONText(value: string, label: string): string {
|
||||||
|
const normalized = value.trim();
|
||||||
|
if (!normalized || new TextEncoder().encode(normalized).byteLength > maxManagementCommandBytes || /[\u0000\r\n]/.test(normalized)) {
|
||||||
|
throw new Error(`${label}必须是受限的单行文本。`);
|
||||||
|
}
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSCUMManagementCommand(value: string, label: string): string {
|
||||||
|
const normalized = validateSCUMManagementRCONText(value, label);
|
||||||
|
return normalized.startsWith("#") ? normalized : `#${normalized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeBridgeIdentifierPart(value: string): string {
|
||||||
|
const normalized = value.trim().replace(/[^A-Za-z0-9._:-]+/g, "-").replace(/^[^A-Za-z0-9]+|[^A-Za-z0-9]+$/g, "").slice(0, 96);
|
||||||
|
return normalized || "server";
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import type { LogEntryBody, LogStreamEventResponse, LogStreamResponse } from "../api/types";
|
||||||
|
|
||||||
|
export type LiveLogEntry = LogEntryBody & { source: string; streamId: string; streamKey: string };
|
||||||
|
|
||||||
|
export function parseLogStreamEvent(event: MessageEvent): LogStreamResponse | null {
|
||||||
|
const value = parseEventData(event);
|
||||||
|
if (!isRecord(value) || typeof value.id !== "string" || typeof value.serverInstanceId !== "string") return null;
|
||||||
|
return value as unknown as LogStreamResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseServerLogEvent(event: MessageEvent): LogStreamEventResponse | null {
|
||||||
|
const value = parseEventData(event);
|
||||||
|
if (!isRecord(value) || typeof value.streamId !== "string" || !isRecord(value.entry)) return null;
|
||||||
|
return value as unknown as LogStreamEventResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function entryFromServerLogEvent(event: LogStreamEventResponse): LiveLogEntry {
|
||||||
|
return { ...event.entry, source: event.source || event.streamKey, streamId: event.streamId, streamKey: event.streamKey };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function streamFromServerLogEvent(event: LogStreamEventResponse): LogStreamResponse {
|
||||||
|
return {
|
||||||
|
id: event.streamId,
|
||||||
|
serverInstanceId: event.serverInstanceId,
|
||||||
|
source: event.source,
|
||||||
|
streamKey: event.streamKey,
|
||||||
|
latestSeq: event.latestSeq,
|
||||||
|
storageBackend: "",
|
||||||
|
retentionPolicy: "",
|
||||||
|
createdAt: "",
|
||||||
|
updatedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeLogStreams(current: LogStreamResponse[], incoming: LogStreamResponse): LogStreamResponse[] {
|
||||||
|
const index = current.findIndex((stream) => stream.id === incoming.id);
|
||||||
|
if (index === -1) return [...current, incoming].sort(compareLogStreams);
|
||||||
|
const next = [...current];
|
||||||
|
next[index] = { ...next[index], ...incoming, latestSeq: Math.max(next[index].latestSeq, incoming.latestSeq) };
|
||||||
|
return next.sort(compareLogStreams);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendLiveLogEntries(current: LiveLogEntry[], incoming: LiveLogEntry[], limit: number): LiveLogEntry[] {
|
||||||
|
const seen = new Set(current.map(logEntryKey));
|
||||||
|
const next = [...current];
|
||||||
|
for (const entry of incoming) {
|
||||||
|
const key = logEntryKey(entry);
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
next.push(entry);
|
||||||
|
}
|
||||||
|
return next.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareLogStreams(a: LogStreamResponse, b: LogStreamResponse): number {
|
||||||
|
const updated = (Date.parse(b.updatedAt) || 0) - (Date.parse(a.updatedAt) || 0);
|
||||||
|
if (updated !== 0) return updated;
|
||||||
|
return b.latestSeq - a.latestSeq || a.streamKey.localeCompare(b.streamKey) || a.id.localeCompare(b.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function logEntryKey(entry: LiveLogEntry): string {
|
||||||
|
return `${entry.streamId}:${entry.seq}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEventData(event: MessageEvent): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(String(event.data));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user