From 7ebe3bb3dd00997c26467d5cf7175a8fa0953709 Mon Sep 17 00:00:00 2001 From: npc0-hue Date: Tue, 25 Aug 2026 22:25:09 +0800 Subject: [PATCH] Remove terminal log replay --- platform/api/log_events_handlers.go | 84 ++----------------- platform/api/log_ingest_handlers_test.go | 50 ++++------- platform/api/routes.md | 2 +- platform_web/api/client.test.ts | 7 +- platform_web/api/client.ts | 12 +-- platform_web/api/contracts.md | 2 +- platform_web/api/types.ts | 4 - .../ServerManagementTerminalDrawer.test.tsx | 12 +-- .../ServerManagementTerminalDrawer.tsx | 3 +- 9 files changed, 40 insertions(+), 136 deletions(-) diff --git a/platform/api/log_events_handlers.go b/platform/api/log_events_handlers.go index 73a7299..91e5279 100644 --- a/platform/api/log_events_handlers.go +++ b/platform/api/log_events_handlers.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "net/http" - "sort" - "strconv" "strings" "time" @@ -15,14 +13,11 @@ import ( ) const ( - defaultLogEventHistoryLimit = 0 - maxLogEventHistoryLimit = 10000 - logEventHeartbeatInterval = 15 * time.Second - managedLogSessionIDPrefix = "log-session:" + logEventHeartbeatInterval = 15 * time.Second + managedLogSessionIDPrefix = "log-session:" ) // serverLogEvents streams platform-accepted live append events for the terminal drawer. -// Callers can opt into a bounded current-session replay with historyLimit. func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w, http.MethodGet) @@ -47,12 +42,11 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { header.Set("X-Accel-Buffering", "no") w.WriteHeader(http.StatusOK) - historyLimit := parseLogEventHistoryLimit(r.URL.Query().Get("historyLimit")) active := supervisedLogSession{} if liveEligible { active = activeSupervisedLogSession(streams) } - emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active, historyLimit) + emittedThrough, err := h.writeCurrentLogSession(w, instance.ID, active) if err != nil { _ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()}) flusher.Flush() @@ -83,7 +77,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { continue } active = supervisedLogSession{} - emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit) + emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active) if err != nil { return } @@ -102,7 +96,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { continue } active = next - emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit) + emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active) if err != nil { return } @@ -125,7 +119,7 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) { } if !sameSupervisedLogSession(active, next) { active = next - emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active, historyLimit) + emittedThrough, err = h.writeCurrentLogSession(w, instance.ID, active) if err != nil { return } @@ -216,34 +210,17 @@ func (session supervisedLogSession) hasStream(streamID string) bool { return false } -func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInstanceID string, active supervisedLogSession, historyLimit int) (map[string]uint64, error) { +func (h *coreHandlers) writeCurrentLogSession(w http.ResponseWriter, serverInstanceID string, active supervisedLogSession) (map[string]uint64, error) { emittedThrough := make(map[string]uint64, len(active.streams)) if err := writeSSEJSON(w, "session", "", dto.LogStreamEventsSessionResponse{ServerInstanceID: serverInstanceID, LogSessionID: active.sessionID, SessionStartedAt: active.startedAt, StreamCount: len(active.streams), ServerTime: time.Now().UTC()}); err != nil { return nil, err } for _, stream := range active.streams { - if historyLimit == 0 { - emittedThrough[stream.ID] = stream.LatestSeq - } + emittedThrough[stream.ID] = stream.LatestSeq if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil { return nil, err } } - if historyLimit == 0 || len(active.streams) == 0 { - return emittedThrough, nil - } - history, err := h.loadLogEventHistory(active.streams, historyLimit) - if err != nil { - return nil, err - } - for _, event := range history { - if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil { - return nil, err - } - if event.Entry.Seq > emittedThrough[event.Stream.ID] { - emittedThrough[event.Stream.ID] = event.Entry.Seq - } - } return emittedThrough, nil } @@ -314,51 +291,6 @@ func (h *coreHandlers) loadLiveLogSnapshot(serverInstanceID string) ([]domain.Lo return current, true, nil } -func (h *coreHandlers) loadLogEventHistory(streams []domain.LogStream, limit int) ([]domain.LogStreamEvent, error) { - history := make([]domain.LogStreamEvent, 0, limit) - for _, stream := range streams { - 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 nil, err - } - for _, entry := range cursor.Entries { - history = append(history, domain.LogStreamEvent{ServerInstanceID: stream.ServerInstanceID, Stream: stream, Entry: entry, LatestSeq: cursor.LatestSeq}) - } - } - sort.SliceStable(history, func(i, j int) bool { - left, right := history[i], history[j] - if !left.Entry.Timestamp.Equal(right.Entry.Timestamp) { - return left.Entry.Timestamp.Before(right.Entry.Timestamp) - } - if left.Entry.Seq != right.Entry.Seq { - return left.Entry.Seq < right.Entry.Seq - } - return left.Stream.ID < right.Stream.ID - }) - if len(history) > limit { - history = history[len(history)-limit:] - } - return history, 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 { diff --git a/platform/api/log_ingest_handlers_test.go b/platform/api/log_ingest_handlers_test.go index 6145210..2113404 100644 --- a/platform/api/log_ingest_handlers_test.go +++ b/platform/api/log_ingest_handlers_test.go @@ -81,17 +81,17 @@ func TestLogIngestAPIWorkflow(t *testing.T) { } } -func TestLogEventsSSEReplaysHistory(t *testing.T) { +func TestLogEventsSSEDoesNotReplayHistory(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) - recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=2") + recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events") assertStatus(t, recorder, http.StatusOK) body := recorder.Body.String() - if !strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") || !strings.Contains(body, "event: stream") || !strings.Contains(body, "event: log") || !strings.Contains(body, "event: ready") || !strings.Contains(body, `"seq":1`) || !strings.Contains(body, `"seq":2`) { - t.Fatalf("expected stream, history log, and ready SSE events, headers=%v body=%s", recorder.Header(), body) + if !strings.Contains(recorder.Header().Get("Content-Type"), "text/event-stream") || !strings.Contains(body, "event: stream") || !strings.Contains(body, "event: ready") || strings.Contains(body, "event: log") || strings.Contains(body, `"seq":1`) || strings.Contains(body, `"seq":2`) { + t.Fatalf("expected live-only SSE events without history replay, headers=%v body=%s", recorder.Header(), body) } } @@ -122,7 +122,7 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) { hookResult <- err }} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=0", nil).WithContext(ctx) + request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx) streamWriter, streamReader := newSSEPipeResponseWriter() done := make(chan struct{}) go func() { @@ -152,25 +152,6 @@ func TestLogEventsSSELiveOnlyStartsAfterSnapshotTail(t *testing.T) { assertSSEEvent(t, reader, "log", `"seq":2`) } -func TestLogEventsSSEUsesServerWideNewestHistory(t *testing.T) { - router := newTestRouter() - hello := createLogIngestAPIFixtures(t, router) - postJSON[dto.LogStreamResponse](t, router, "/api/v1/log-streams", dto.LogStreamCreateRequest{ - ID: "log-2", ServerInstanceID: "server-1", Source: domain.LogStreamSourceProcess, StreamKey: "stderr", - LogSessionID: "session-current", SessionStartedAt: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), - StorageBackend: domain.LogStorageBackendLocalSegments, RetentionPolicy: "default", - }) - assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-1", "stdout", 1, 2, 0)), http.StatusOK) - assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequestForStream(t, hello.SessionToken, "log-2", "stderr", 1, 2, 10)), http.StatusOK) - - recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=2") - assertStatus(t, recorder, http.StatusOK) - body := recorder.Body.String() - if strings.Count(body, "event: log") != 2 || !strings.Contains(body, `"streamId":"log-2"`) || strings.Contains(body, `"streamId":"log-1"`) { - t.Fatalf("expected server-wide newest history across streams, body=%s", body) - } -} - func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) { router := newTestRouter() hello := createLogIngestAPIFixtures(t, router) @@ -182,7 +163,7 @@ func TestLogEventsSSEExcludesOlderSupervisedSessions(t *testing.T) { assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", oldBatch), http.StatusOK) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) - recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10") + recorder := performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events") body := recorder.Body.String() if !strings.Contains(body, `"logSessionId":"session-current"`) || strings.Contains(body, `"streamId":"log-old"`) { t.Fatalf("expected only current session in live SSE, body=%s", body) @@ -196,7 +177,7 @@ func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing. stopped := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", stopped), http.StatusOK) - assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")) + assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")) running := stopped running.ManagedProcessID = "log-session:session-missing" @@ -204,9 +185,9 @@ func TestLogEventsSSEInitialSnapshotRequiresRunningOnlineProcessFact(t *testing. running.ObservedAt = time.Date(2026, 7, 3, 13, 0, 0, 0, time.UTC) running.ExecutionResult = dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "running"} assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", running), http.StatusOK) - assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")) + assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")) assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/control/heartbeat", dto.RunControlHeartbeatRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, Version: "0.1.0", Status: domain.RunEndpointStatusOffline, CapabilityFingerprint: "cap-logs", Capacity: dto.RunCapacityResponse{MaxJobs: 1}}), http.StatusOK) - assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events?historyLimit=10")) + assertEmptyInitialLogSession(t, performCancelledSSE(t, router, "/api/v1/server-instances/server-1/logs/events")) } func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconnect(t *testing.T) { @@ -215,7 +196,7 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", validLogBatchRequest(t, hello.SessionToken, 1, 1)), http.StatusOK) ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", nil).WithContext(ctx) + request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx) streamWriter, streamReader := newSSEPipeResponseWriter() done := make(chan struct{}) go func() { @@ -234,7 +215,6 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne reader := bufio.NewReader(streamReader) assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`) assertSSEEvent(t, reader, "stream", `"id":"log-1"`) - assertSSEEvent(t, reader, "log", `"seq":1`) assertSSEEvent(t, reader, "ready", `"streamCount":1`) statusReport := dto.RunLifecycleReportRequest{RunEndpointID: "run-local", SessionToken: hello.SessionToken, ServerInstanceID: "server-1", Capability: domain.LifecycleCapabilityStatus, State: domain.JobStateSucceeded, Progress: dto.JobProgressBody{Percent: 100}, ExecutionResult: dto.RunJobExecutionResultBody{Kind: "process", ProcessState: "stopped", ExitClassification: "requested-stop"}} @@ -257,10 +237,14 @@ func TestLogEventsSSEClearsStoppedSessionAndRestoresRunningSessionWithoutReconne assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/lifecycle/report", statusReport), http.StatusOK) assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) + nextLive := validLogBatchRequestForStream(t, hello.SessionToken, next.LogStreamID, "stdout", 2, 2, 21) + nextLive.LogSessionID = "session-next" + nextLive.SessionStartedAt = nextStartedAt + assertStatus(t, performJSON(t, router, http.MethodPost, "/api/v1/run/logs/batches", nextLive), http.StatusOK) assertSSEEvent(t, reader, "log", `"streamId":"run.run-local.server-1.session-next.stdout"`) } -func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicates(t *testing.T) { +func TestLogEventsSSEOrdersSessionSwitchAndAdditionalStreamWithoutDuplicates(t *testing.T) { core := service.NewCoreService(repo.NewMemoryStore()) if err := core.SeedLocalPlatformAdmin(); err != nil { t.Fatalf("seed platform admin: %v", err) @@ -274,7 +258,7 @@ func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicat hookResult <- err }} ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events?historyLimit=10", nil).WithContext(ctx) + request := httptest.NewRequest(http.MethodGet, "/api/v1/server-instances/server-1/logs/events", nil).WithContext(ctx) streamWriter, streamReader := newSSEPipeResponseWriter() done := make(chan struct{}) go func() { @@ -293,7 +277,6 @@ func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicat reader := bufio.NewReader(streamReader) assertSSEEvent(t, reader, "session", `"logSessionId":"session-current"`) assertSSEEvent(t, reader, "stream", `"id":"log-1"`) - assertSSEEvent(t, reader, "log", `"seq":1`) assertSSEEvent(t, reader, "ready", `"streamCount":1`) if err := <-hookResult; err != nil { t.Fatalf("ingest during stream snapshot: %v", err) @@ -311,7 +294,6 @@ func TestLogEventsSSEOrdersReplaySessionSwitchAndAdditionalStreamWithoutDuplicat } assertSSEEvent(t, reader, "session", `"logSessionId":"session-next"`) assertSSEEvent(t, reader, "stream", `"id":"run.run-local.server-1.session-next.stdout"`) - assertSSEEvent(t, reader, "log", `"seq":1`) stderr := validLogBatchRequestForStream(t, hello.SessionToken, "run.run-local.server-1.session-next.stderr", "stderr", 1, 1, 21) stderr.LogSessionID = "session-next" diff --git a/platform/api/routes.md b/platform/api/routes.md index 6f80643..e6c9d21 100644 --- a/platform/api/routes.md +++ b/platform/api/routes.md @@ -154,7 +154,7 @@ Lifecycle workflow responses include accepted status, action, bounded server ins - `GET /api/v1/server-instances/{id}/dependencies`: returns the target-matched plugin/profile dependency catalog, current safe probe status/evidence, typed plan summaries, and deterministic immutable plan digests. - `POST /api/v1/server-instances/{id}/dependencies/check`: accepts `DependencyJobRequest` and queues a `dependencies.check` run job for a declared logical probe key. - `POST /api/v1/server-instances/{id}/dependencies/install`: accepts `DependencyJobRequest` with an install plan key and the exact catalog `planDigest`; stale/missing digests are denied before job creation. -Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events by default. A caller can opt into bounded current-session replay with `historyLimit`; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows. +Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is registered for the server detail terminal drawer and emits platform-accepted live log SSE events only. It does not replay retained log entries; the raw log list/backfill routes (`logs/live` and `logs/backfill`) remain unavailable as product APIs, and internal log ingest and cursor query remain available for run/platform maintenance flows. Runtime distribution and client-manager APIs require the current bearer session, server visibility, plugin-declared permissions, complete runtime bindings only for actions that truly depend on external logical bindings, and platform-builder readiness. Run-side lifecycle commands separately require run endpoint capability support and use plugin-declared lifecycle actions without making manual runtime-profile binding a user prerequisite. Responses and summaries expose artifact IDs, job IDs, checksums, key generations, fingerprints, status, and redacted `secret://runtime-keys/.../current` refs only. They do not expose raw run keys, client-manager keys, FTP passwords, database DSNs, RCON passwords, host paths, direct sockets, run endpoint private addresses, build workspace paths, or large inline logs. diff --git a/platform_web/api/client.test.ts b/platform_web/api/client.test.ts index 7f12484..92573ad 100644 --- a/platform_web/api/client.test.ts +++ b/platform_web/api/client.test.ts @@ -805,8 +805,7 @@ describe("PlatformApiClient AI providers", () => { it("builds encoded server log event stream URLs for the terminal drawer", () => { const client = new PlatformApiClient("/api/v1"); - expect(client.serverLogEventsUrl("server/scum 1", { historyLimit: 500 })).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events?historyLimit=500"); - expect(client.serverLogEventsUrl("server-1", { historyLimit: 0 })).toBe("/api/v1/server-instances/server-1/logs/events?historyLimit=0"); + expect(client.serverLogEventsUrl("server/scum 1")).toBe("/api/v1/server-instances/server%2Fscum%201/logs/events"); expect(client.serverLogEventsUrl("server-1")).toBe("/api/v1/server-instances/server-1/logs/events"); }); @@ -830,7 +829,7 @@ describe("PlatformApiClient AI providers", () => { vi.stubGlobal("fetch", fetchMock); const client = new PlatformApiClient("/api/v1", () => "terminal-session"); const events: string[] = []; - const stream = client.openServerLogEvents("server-1", { historyLimit: 2 }); + const stream = client.openServerLogEvents("server-1"); stream.addEventListener("session", (event) => events.push(`session:${event.data}`)); stream.addEventListener("stream", (event) => events.push(`stream:${event.data}`)); stream.addEventListener("log", (event) => events.push(`log:${event.data}`)); @@ -842,7 +841,7 @@ describe("PlatformApiClient AI providers", () => { 'log:{"streamId":"log-1","entry":{"seq":3,"line":"live line"}}', 'ready:{"serverInstanceId":"server-1"}' ])); - expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server-1/logs/events?historyLimit=2"); + expect(String(fetchMock.mock.calls[0]?.[0])).toBe("/api/v1/server-instances/server-1/logs/events"); stream.close(); }); diff --git a/platform_web/api/client.ts b/platform_web/api/client.ts index 026bf66..f5415f1 100644 --- a/platform_web/api/client.ts +++ b/platform_web/api/client.ts @@ -53,7 +53,6 @@ import type { LlmConfigSuggestionResponse, LogStreamCursorRequest, LogStreamCursorResponse, - LogStreamEventOptions, LogStreamListResponse, LoginRequest, MarketplacePluginFilterRequest, @@ -634,8 +633,8 @@ export class PlatformApiClient { return this.request(`/log-streams${query}`); } - openServerLogEvents(id: string, options: LogStreamEventOptions = {}): PlatformEventStream { - const url = this.serverLogEventsUrl(id, options); + openServerLogEvents(id: string): PlatformEventStream { + const url = this.serverLogEventsUrl(id); const sessionToken = this.sessionTokenProvider(); if (!sessionToken) { return new EventSource(url, { withCredentials: true }); @@ -643,11 +642,8 @@ export class PlatformApiClient { return new FetchServerSentEventStream(url, sessionToken); } - 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}` : ""}`; + serverLogEventsUrl(id: string): string { + return `${this.baseUrl}/server-instances/${encodeURIComponent(id)}/logs/events`; } async queryLogStream(request: LogStreamCursorRequest): Promise { diff --git a/platform_web/api/contracts.md b/platform_web/api/contracts.md index 2132aff..7f4d606 100644 --- a/platform_web/api/contracts.md +++ b/platform_web/api/contracts.md @@ -60,7 +60,7 @@ Existing platform APIs already cover server lifecycle, jobs, log stream metadata - Operation/job traceability reuses `GET /api/v1/jobs`, `GET /api/v1/jobs/{id}`, and `POST /api/v1/jobs/{id}/cancel`; the frontend wraps these in one visible operation lifecycle per user intent. Browser Job contracts explicitly exclude raw or hashed lease tokens, Run session tokens/generations, secret refs, host paths, sockets, and credentials. The safe schema rejects those keys, and existing API client 401/403 behavior remains authoritative for expired sessions and cross-owner access. -- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output by default, with bounded current-session replay only when the caller explicitly sends `historyLimit`. Raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients; internal log ingest and cursor query remain available to platform services and maintenance/debug flows. +- Server-scoped terminal log streaming (`GET /api/v1/server-instances/{id}/logs/events`) is used by the server detail terminal drawer for platform-accepted live SSE output only. It does not replay retained log entries; raw log list/backfill routes (`GET .../logs/live`, `POST .../logs/backfill`) and direct management-terminal/RCON input routes remain removed from product clients, and internal log ingest and cursor query remain available to platform services and maintenance/debug flows. # Client Manager API projection `PlatformApiClient` exposes list/detail and typed deploy, control, update, retry, revoke-session, and uninstall methods. `schemas/clientManagerLifecycle.ts` validates status/action/job/health fields and rejects forbidden machine or credential fields before rendering. Lifecycle commands carry profile, distribution, expected deployment generation, approval/confirmation, and idempotency only. Artifact bytes remain in the platform-owned artifact transfer client. diff --git a/platform_web/api/types.ts b/platform_web/api/types.ts index 180eba2..24818a7 100644 --- a/platform_web/api/types.ts +++ b/platform_web/api/types.ts @@ -1631,10 +1631,6 @@ export interface LogStreamEventsSessionResponse { serverTime: string; } -export interface LogStreamEventOptions { - historyLimit?: number; -} - export interface JobCreateRequest { id: string; serverInstanceId?: string; diff --git a/platform_web/components/ServerManagementTerminalDrawer.test.tsx b/platform_web/components/ServerManagementTerminalDrawer.test.tsx index adbf520..c45cacc 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.test.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.test.tsx @@ -74,16 +74,16 @@ describe("ServerManagementTerminalDrawer", () => { await emitSession("session-a"); await emitStream(logStream("stdout-a", "session-a", "process.stdout")); - await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay")); + await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A live output")); await emitReady(); - expect(container?.textContent).toContain("generation A current replay"); + expect(container?.textContent).toContain("generation A live output"); expect(container?.textContent).toContain("当前受管进程会话 · SSE 实时推送"); await emitSession("session-a"); - await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A current replay")); - expect(container?.textContent).toContain("generation A current replay"); - expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A current replay")).toHaveLength(1); + await emitLog("stdout-a", "session-a", "process.stdout", logEntry(1, "generation A live output")); + expect(container?.textContent).toContain("generation A live output"); + expect(Array.from(container?.querySelectorAll(".terminal-text") ?? []).filter((node) => node.textContent === "generation A live output")).toHaveLength(1); expect(apiMocks.openServerLogEvents).toHaveBeenCalledTimes(1); expect(apiMocks.listLogStreams).not.toHaveBeenCalled(); expect(apiMocks.dispatchSourceRCONCommand).not.toHaveBeenCalled(); @@ -211,7 +211,7 @@ async function renderDrawer() { await act(async () => { root?.render( undefined} />); }); - expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1", { historyLimit: 0 }); + expect(apiMocks.openServerLogEvents).toHaveBeenCalledWith("server-1"); } async function emitSession(logSessionId?: string, serverTime = "2026-08-14T00:00:00Z") { diff --git a/platform_web/components/ServerManagementTerminalDrawer.tsx b/platform_web/components/ServerManagementTerminalDrawer.tsx index 7dfc56c..adb1980 100644 --- a/platform_web/components/ServerManagementTerminalDrawer.tsx +++ b/platform_web/components/ServerManagementTerminalDrawer.tsx @@ -16,7 +16,6 @@ type TerminalQuickCommand = { label: string; command: string; hint: string }; const terminalJobResultPollMs = 1000; const terminalJobResultPollAttempts = 30; -const terminalLiveReplayWindow = 0; const terminalHistoryWindow = 500; const maxTerminalLines = 10000; const terminalQuickCommandCatalog: Record = { @@ -158,7 +157,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu useEffect(() => { if (!open) return undefined; let ready = false; - const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalLiveReplayWindow }); + const events = platformApiClient.openServerLogEvents(serverId); events.addEventListener("session", (event) => { const session = parseLogSessionEvent(event); if (!session) return;