fix: bound management terminal logs
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
schema: spec-driven
|
||||||
|
created: 2026-08-07
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
## Context
|
||||||
|
|
||||||
|
The server log SSE route currently replays `historyLimit` entries independently for every stream, then sends live events. The management terminal stores a bounded client buffer but does not own a scroll container or follow state, so the browser viewport remains at its initial top position after history is appended. Durable log bodies are stored by the platform `LogBodyStore` as memory indexes backed by per-stream JSONL segment files; this change does not rewrite or delete that durable history.
|
||||||
|
|
||||||
|
## Goals / Non-Goals
|
||||||
|
|
||||||
|
**Goals:**
|
||||||
|
|
||||||
|
- Make initial server log replay bounded across all streams and limited to the newest entries.
|
||||||
|
- Keep replay chronological and preserve live SSE delivery.
|
||||||
|
- Keep at most 10,000 terminal lines in the browser and automatically follow the newest line until the operator scrolls away.
|
||||||
|
- Make returning to the bottom restore follow mode.
|
||||||
|
|
||||||
|
**Non-Goals:**
|
||||||
|
|
||||||
|
- No full-history fetch, log retention deletion, database migration, or change to Run ingest.
|
||||||
|
- No browser-to-Run transport or game-specific log selection.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
- Treat `historyLimit` as a server-wide budget for the SSE endpoint, with a platform cap of 10,000. This prevents stream-count multiplication while retaining the existing query parameter and compatibility for existing clients.
|
||||||
|
- Read bounded tails from each stream using its latest sequence, merge by timestamp/sequence/stream ID, and emit only the newest budgeted entries in chronological order. This keeps the UI output coherent without adding a new cross-stream database query API.
|
||||||
|
- Give the terminal output element a ref and track `followLatest` from scroll position. Initial history and live events call a bottom-scroll helper only while locked; a user scroll above a small bottom threshold unlocks, and a later scroll to the threshold locks again.
|
||||||
|
- Keep the rendered buffer capped at 10,000 through the existing merge helper. System and command-result lines use the same cap, so browser memory remains bounded even when the stream is noisy.
|
||||||
|
|
||||||
|
## Risks / Trade-offs
|
||||||
|
|
||||||
|
- [Risk] Reading a tail from every stream still does bounded work proportional to stream count. -> Mitigation: each stream read is capped by the global budget and the final response is capped at 10,000; existing per-stream cursor storage remains unchanged.
|
||||||
|
- [Risk] Timestamp ties across streams can reorder entries relative to ingest order. -> Mitigation: use sequence and stream ID tie-breakers and retain each stream's sequence ordering.
|
||||||
|
- [Risk] Scroll events can race with React rendering. -> Mitigation: defer bottom scrolling with `requestAnimationFrame` and re-check the element's current scroll metrics.
|
||||||
|
|
||||||
|
## Migration Plan
|
||||||
|
|
||||||
|
Deploy the backend and frontend together. Existing clients sending `historyLimit` continue to work, but receive a server-wide bounded replay. Rollback is code-only: reverting the endpoint selection and terminal follow logic restores the prior behavior without data migration.
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
## Why
|
||||||
|
|
||||||
|
Opening the management terminal currently places an operator at the top of the replayed history, so the oldest item in the selected window is visible instead of the newest runtime output. The SSE history limit is also applied independently to every log stream, allowing the initial response size to grow with stream count instead of remaining bounded for the server view.
|
||||||
|
|
||||||
|
## What Changes
|
||||||
|
|
||||||
|
- Bound SSE history replay by a server-wide recent-entry window, capped at 10,000 entries, rather than replaying the requested limit for each stream.
|
||||||
|
- Preserve chronological replay order so a browser can render the selected recent window and begin at its newest output.
|
||||||
|
- Make the management terminal retain at most 10,000 rendered lines, initially follow the bottom, pause auto-follow when an operator scrolls away, and resume it when the operator returns to the bottom.
|
||||||
|
- Keep `POST /api/v1/log-streams/query` as the bounded, explicit historical cursor API; do not add full-history browser loading.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
### New Capabilities
|
||||||
|
|
||||||
|
- `bounded-server-log-view`: Bounded server-wide SSE history replay and management-terminal auto-follow behavior for live server log views.
|
||||||
|
|
||||||
|
### Modified Capabilities
|
||||||
|
|
||||||
|
- None.
|
||||||
|
|
||||||
|
## Impact
|
||||||
|
|
||||||
|
- Affects the Platform server log SSE endpoint, its Go tests, and the persisted-log history selection path.
|
||||||
|
- Affects the platform_web log event client types and management terminal component/tests.
|
||||||
|
- Does not change Run-to-Platform durable batch ingest, browser authorization, plugin-declared log sources, or game-specific behavior.
|
||||||
+45
@@ -0,0 +1,45 @@
|
|||||||
|
## ADDED Requirements
|
||||||
|
|
||||||
|
### Requirement: Server SSE history is globally bounded
|
||||||
|
The Platform SHALL interpret the server log SSE `historyLimit` as a total recent-entry budget across all streams, default it to 100 entries, and cap it at 10,000 entries. It MUST NOT load or transmit the complete durable history when opening a browser stream.
|
||||||
|
|
||||||
|
#### Scenario: Terminal opens with the default window
|
||||||
|
- **WHEN** an authorized operator opens `/api/v1/server-instances/{id}/logs/events` without `historyLimit`
|
||||||
|
- **THEN** Platform replays no more than 100 recent entries total across that server's streams
|
||||||
|
- **AND** the replay contains the newest available entries rather than starting at sequence one
|
||||||
|
|
||||||
|
#### Scenario: Requested history is capped
|
||||||
|
- **WHEN** a client requests a `historyLimit` greater than 10,000
|
||||||
|
- **THEN** Platform limits the total replay to 10,000 entries
|
||||||
|
- **AND** it does not perform an unbounded history query
|
||||||
|
|
||||||
|
#### Scenario: Replay order is chronological
|
||||||
|
- **WHEN** multiple streams have entries in the bounded history window
|
||||||
|
- **THEN** Platform emits the selected entries in ascending timestamp, sequence, and stream-ID tie-break order
|
||||||
|
- **AND** emits `ready` only after the bounded replay completes
|
||||||
|
|
||||||
|
### Requirement: Management terminal follows the latest output
|
||||||
|
The management terminal SHALL begin with the output viewport at the newest rendered line and remain pinned to the bottom while follow mode is locked. User scrolling away from the bottom SHALL unlock follow mode, and scrolling back to the bottom SHALL lock it again.
|
||||||
|
|
||||||
|
#### Scenario: Initial history opens at the newest line
|
||||||
|
- **WHEN** the terminal receives its initial bounded history
|
||||||
|
- **THEN** the output viewport scrolls to the bottom after the lines render
|
||||||
|
- **AND** new log events continue to appear without moving the viewport away from the newest line
|
||||||
|
|
||||||
|
#### Scenario: Operator inspects older output
|
||||||
|
- **WHEN** the operator scrolls above the bottom threshold
|
||||||
|
- **THEN** follow mode unlocks
|
||||||
|
- **AND** incoming log events are retained in the buffer without forcing a scroll
|
||||||
|
|
||||||
|
#### Scenario: Operator returns to live output
|
||||||
|
- **WHEN** the operator scrolls back within the bottom threshold
|
||||||
|
- **THEN** follow mode locks again
|
||||||
|
- **AND** subsequent incoming log events keep the viewport at the bottom
|
||||||
|
|
||||||
|
### Requirement: Browser terminal history is retained in a fixed window
|
||||||
|
The management terminal SHALL retain and render at most 10,000 lines, evicting the oldest lines when new system, command, or log lines exceed that bound. Historical loading MUST use only the bounded SSE replay and MUST NOT issue a full-history request.
|
||||||
|
|
||||||
|
#### Scenario: Buffer exceeds the retention window
|
||||||
|
- **WHEN** more than 10,000 lines have been received or generated
|
||||||
|
- **THEN** the terminal removes the oldest lines
|
||||||
|
- **AND** the newest 10,000 lines remain available for display
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
## 1. Platform History Contract
|
||||||
|
|
||||||
|
- [x] 1.1 Change the server log SSE replay to apply one capped total history budget across all streams and emit the selected tail entries in chronological order.
|
||||||
|
- [x] 1.2 Add backend tests for default/capped totals, newest-tail selection, chronological ordering, and ready-event sequencing.
|
||||||
|
|
||||||
|
## 2. Management Terminal View
|
||||||
|
|
||||||
|
- [x] 2.1 Add a scroll container ref and follow-latest state to the management terminal, with initial/live bottom scrolling only while locked and unlock/relock detection at the bottom threshold.
|
||||||
|
- [x] 2.2 Increase the terminal retention buffer to 10,000 lines and keep oldest-line eviction for all incoming line types.
|
||||||
|
- [x] 2.3 Add focused frontend tests for initial bottom positioning, scroll unlock/relock, bounded buffer behavior, and bounded SSE history options.
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
|
||||||
|
- [x] 3.1 Run focused platform and platform_web tests, then `scripts/check-structure.sh`.
|
||||||
|
- [x] 3.2 Run `openspec validate improve-server-terminal-log-window --strict` and record verification evidence before marking tasks complete.
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -15,7 +16,7 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
defaultLogEventHistoryLimit = 100
|
defaultLogEventHistoryLimit = 100
|
||||||
maxLogEventHistoryLimit = 500
|
maxLogEventHistoryLimit = 10000
|
||||||
logEventHeartbeatInterval = 15 * time.Second
|
logEventHeartbeatInterval = 15 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ const (
|
|||||||
// @Tags logs
|
// @Tags logs
|
||||||
// @Produce text/event-stream
|
// @Produce text/event-stream
|
||||||
// @Param id path string true "Server instance ID"
|
// @Param id path string true "Server instance ID"
|
||||||
// @Param historyLimit query int false "Recent entries per stream to replay before live events"
|
// @Param historyLimit query int false "Total recent entries to replay across this server's streams"
|
||||||
// @Success 200 {string} string "event-stream"
|
// @Success 200 {string} string "event-stream"
|
||||||
// @Failure 401 {object} dto.ErrorResponse
|
// @Failure 401 {object} dto.ErrorResponse
|
||||||
// @Failure 403 {object} dto.ErrorResponse
|
// @Failure 403 {object} dto.ErrorResponse
|
||||||
@@ -61,12 +62,18 @@ func (h *coreHandlers) serverLogEvents(w http.ResponseWriter, r *http.Request) {
|
|||||||
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
|
if err := writeSSEJSON(w, "stream", "", dto.LogStreamFromDomain(stream)); err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}
|
||||||
if historyLimit > 0 {
|
if historyLimit > 0 {
|
||||||
if err := h.writeLogEventHistory(w, stream, historyLimit); err != nil {
|
history, err := h.loadLogEventHistory(streams, historyLimit)
|
||||||
|
if err != nil {
|
||||||
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
|
_ = writeSSEJSON(w, "error", "", map[string]string{"message": err.Error()})
|
||||||
flusher.Flush()
|
flusher.Flush()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for _, event := range history {
|
||||||
|
if err := writeSSEJSON(w, "log", logEventID(event), dto.LogStreamEventFromDomain(event)); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := writeSSEJSON(w, "ready", "", dto.LogStreamEventsReadyResponse{ServerInstanceID: instance.ID, StreamCount: len(streams), ServerTime: time.Now().UTC()}); err != nil {
|
if err := writeSSEJSON(w, "ready", "", dto.LogStreamEventsReadyResponse{ServerInstanceID: instance.ID, StreamCount: len(streams), ServerTime: time.Now().UTC()}); err != nil {
|
||||||
@@ -127,22 +134,35 @@ func (h *coreHandlers) openLogEventSubscription(r *http.Request) (domain.ServerI
|
|||||||
return instance, streams, subscription, err
|
return instance, streams, subscription, err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *coreHandlers) writeLogEventHistory(w http.ResponseWriter, stream domain.LogStream, limit int) error {
|
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)
|
afterSeq := uint64(0)
|
||||||
if stream.LatestSeq > uint64(limit) {
|
if stream.LatestSeq > uint64(limit) {
|
||||||
afterSeq = stream.LatestSeq - uint64(limit)
|
afterSeq = stream.LatestSeq - uint64(limit)
|
||||||
}
|
}
|
||||||
cursor, err := h.core.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: stream.ID, AfterSeq: afterSeq, Limit: limit})
|
cursor, err := h.core.QueryLogStream(domain.LogStreamCursorQuery{LogStreamID: stream.ID, AfterSeq: afterSeq, Limit: limit})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, entry := range cursor.Entries {
|
for _, entry := range cursor.Entries {
|
||||||
event := domain.LogStreamEvent{ServerInstanceID: stream.ServerInstanceID, Stream: stream, Entry: entry, LatestSeq: cursor.LatestSeq}
|
history = append(history, 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
|
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 {
|
func parseLogEventHistoryLimit(value string) int {
|
||||||
|
|||||||
@@ -64,6 +64,34 @@ func TestLogEventsSSEReplaysHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
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)
|
||||||
|
|
||||||
|
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()
|
||||||
|
body := readSSEUntil(t, response, "event: ready")
|
||||||
|
if strings.Contains(body, `"streamId":"log-1"`) {
|
||||||
|
t.Fatalf("expected no history entries from older stream, got:\n%s", body)
|
||||||
|
}
|
||||||
|
if strings.Count(body, `"streamId":"log-2"`) != 2 || !strings.Contains(body, `"seq":2`) {
|
||||||
|
t.Fatalf("expected newest two entries from stream log-2, got:\n%s", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
|
func TestLogIngestAPIDuplicateAndErrors(t *testing.T) {
|
||||||
router := newTestRouter()
|
router := newTestRouter()
|
||||||
hello := createLogIngestAPIFixtures(t, router)
|
hello := createLogIngestAPIFixtures(t, router)
|
||||||
@@ -121,11 +149,15 @@ func readSSEUntil(t *testing.T, response *http.Response, marker string) 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 {
|
||||||
|
return validLogBatchRequestForStream(t, sessionToken, "log-1", "stdout", firstSeq, lastSeq, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validLogBatchRequestForStream(t *testing.T, sessionToken string, streamID string, streamKey string, firstSeq uint64, lastSeq uint64, timestampOffset int) dto.LogBatchIngestRequest {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
|
entries := make([]dto.LogEntryBody, 0, lastSeq-firstSeq+1)
|
||||||
domainEntries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1)
|
domainEntries := make([]domain.LogEntry, 0, lastSeq-firstSeq+1)
|
||||||
for seq := firstSeq; seq <= lastSeq; seq++ {
|
for seq := firstSeq; seq <= lastSeq; seq++ {
|
||||||
entry := dto.LogEntryBody{Seq: seq, Timestamp: time.Date(2026, 7, 3, 12, 0, int(seq), 0, time.UTC), Level: "info", Line: "line"}
|
entry := dto.LogEntryBody{Seq: seq, Timestamp: time.Date(2026, 7, 3, 12, 0, timestampOffset+int(seq), 0, time.UTC), Level: "info", Line: "line"}
|
||||||
entries = append(entries, entry)
|
entries = append(entries, entry)
|
||||||
domainEntries = append(domainEntries, domain.LogEntry{Seq: entry.Seq, Timestamp: entry.Timestamp, Level: entry.Level, Line: entry.Line})
|
domainEntries = append(domainEntries, domain.LogEntry{Seq: entry.Seq, Timestamp: entry.Timestamp, Level: entry.Level, Line: entry.Line})
|
||||||
}
|
}
|
||||||
@@ -136,9 +168,9 @@ func validLogBatchRequest(t *testing.T, sessionToken string, firstSeq uint64, la
|
|||||||
return dto.LogBatchIngestRequest{
|
return dto.LogBatchIngestRequest{
|
||||||
RunEndpointID: "run-local",
|
RunEndpointID: "run-local",
|
||||||
SessionToken: sessionToken,
|
SessionToken: sessionToken,
|
||||||
LogStreamID: "log-1",
|
LogStreamID: streamID,
|
||||||
ServerInstanceID: "server-1",
|
ServerInstanceID: "server-1",
|
||||||
StreamKey: "stdout",
|
StreamKey: streamKey,
|
||||||
Source: domain.LogStreamSourceProcess,
|
Source: domain.LogStreamSourceProcess,
|
||||||
FirstSeq: firstSeq,
|
FirstSeq: firstSeq,
|
||||||
LastSeq: lastSeq,
|
LastSeq: lastSeq,
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
const (
|
const (
|
||||||
MaxLogBatchEntries = 512
|
MaxLogBatchEntries = 512
|
||||||
MaxLogLineLength = 8192
|
MaxLogLineLength = 8192
|
||||||
MaxLogQueryLimit = 500
|
MaxLogQueryLimit = 10000
|
||||||
)
|
)
|
||||||
|
|
||||||
func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
|
func ValidateLogBatchIngest(batch domain.LogBatchIngest) error {
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ type TerminalQuickCommand = { label: string; command: string; hint: string };
|
|||||||
const terminalBridgeResultPollMs = 1000;
|
const terminalBridgeResultPollMs = 1000;
|
||||||
const terminalBridgeResultPollAttempts = 30;
|
const terminalBridgeResultPollAttempts = 30;
|
||||||
const liveLogHistoryWindow = 100;
|
const liveLogHistoryWindow = 100;
|
||||||
const terminalHistoryWindow = 150;
|
const terminalHistoryWindow = 10000;
|
||||||
const maxLogEntries = 500;
|
const maxLogEntries = 500;
|
||||||
const maxTerminalLines = 600;
|
const maxTerminalLines = 10000;
|
||||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||||
"game.scum": [
|
"game.scum": [
|
||||||
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" },
|
{ label: "查询玩家", command: "#ListPlayers", hint: "在线玩家列表" },
|
||||||
@@ -209,6 +209,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
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);
|
||||||
|
const [followLatest, setFollowLatest] = useState(true);
|
||||||
|
const outputRef = useRef<HTMLDivElement>(null);
|
||||||
|
const followLatestRef = useRef(true);
|
||||||
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
|
const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]);
|
||||||
const supportsCommands = quickCommands.length > 0;
|
const supportsCommands = quickCommands.length > 0;
|
||||||
const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]);
|
const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]);
|
||||||
@@ -235,10 +238,21 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
setPending(false);
|
setPending(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setHistoryIndex(null);
|
setHistoryIndex(null);
|
||||||
|
followLatestRef.current = true;
|
||||||
|
setFollowLatest(true);
|
||||||
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||||
void loadStreams();
|
void loadStreams();
|
||||||
}, [loadStreams, open, supportsCommands]);
|
}, [loadStreams, open, supportsCommands]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !followLatestRef.current) return undefined;
|
||||||
|
const frame = window.requestAnimationFrame(() => {
|
||||||
|
const output = outputRef.current;
|
||||||
|
if (output) output.scrollTop = output.scrollHeight;
|
||||||
|
});
|
||||||
|
return () => window.cancelAnimationFrame(frame);
|
||||||
|
}, [lines, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
let ready = false;
|
let ready = false;
|
||||||
@@ -298,6 +312,14 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
setLines([]);
|
setLines([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleTerminalScroll() {
|
||||||
|
const output = outputRef.current;
|
||||||
|
if (!output) return;
|
||||||
|
const nextFollowLatest = output.scrollHeight - output.clientHeight - output.scrollTop <= 24;
|
||||||
|
followLatestRef.current = nextFollowLatest;
|
||||||
|
setFollowLatest(nextFollowLatest);
|
||||||
|
}
|
||||||
|
|
||||||
async function submitCommand(event: FormEvent<HTMLFormElement>) {
|
async function submitCommand(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!supportsCommands || !canManage || pending || !command.trim()) return;
|
if (!supportsCommands || !canManage || pending || !command.trim()) return;
|
||||||
@@ -347,14 +369,14 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
<div className="terminal-output-topbar">
|
<div className="terminal-output-topbar">
|
||||||
<div>
|
<div>
|
||||||
<strong>{serverName}</strong>
|
<strong>{serverName}</strong>
|
||||||
<span>最近历史 + SSE 实时推送 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"}</span>
|
<span>最近历史 + SSE 实时推送 · {streams.status === "ready" ? `${terminalStreams.length} 个日志源` : streams.status === "loading" ? "读取日志源" : "日志源异常"} · {followLatest ? "自动置底" : "已解锁滚动"}</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>
|
||||||
<button type="button" className="terminal-output-action" aria-label="关闭管理终端" onClick={onClose}><X size={14} /><span>关闭</span></button>
|
<button type="button" className="terminal-output-action" aria-label="关闭管理终端" onClick={onClose}><X size={14} /><span>关闭</span></button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="terminal-output" role="log" aria-live="polite">
|
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
|
||||||
{streams.status === "error" && <div className="terminal-line terminal-line-error"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">{streams.reason}</span></div>}
|
{streams.status === "error" && <div className="terminal-line terminal-line-error"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">{streams.reason}</span></div>}
|
||||||
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">暂无日志源。需要 Run 上报或历史日志回填后,这里才会持续追加。</span></div>}
|
{streams.status === "ready" && streams.data.length === 0 && <div className="terminal-line terminal-line-warn"><time>{new Date().toLocaleTimeString()}</time><span className="terminal-stream">LOGS</span><span className="terminal-text">暂无日志源。需要 Run 上报或历史日志回填后,这里才会持续追加。</span></div>}
|
||||||
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span className="terminal-stream">{line.streamKey || line.level || "LOG"}</span><span className="terminal-text">{line.text}</span></div>)}
|
{lines.map((line) => <div key={line.id} className={`terminal-line terminal-line-${line.tone}`}><time>{line.at}</time><span className="terminal-stream">{line.streamKey || line.level || "LOG"}</span><span className="terminal-text">{line.text}</span></div>)}
|
||||||
|
|||||||
@@ -340,6 +340,11 @@ describe("first-party console pages", () => {
|
|||||||
expect(serverLiveOperationsSource).toContain("streams.filter((stream) => stream.latestSeq > 0)");
|
expect(serverLiveOperationsSource).toContain("streams.filter((stream) => stream.latestSeq > 0)");
|
||||||
expect(serverLiveOperationsSource).toContain("SSE 实时推送");
|
expect(serverLiveOperationsSource).toContain("SSE 实时推送");
|
||||||
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
expect(serverLiveOperationsSource).toContain("mergeTerminalLines");
|
||||||
|
expect(serverLiveOperationsSource).toContain("terminalHistoryWindow = 10000");
|
||||||
|
expect(serverLiveOperationsSource).toContain("maxTerminalLines = 10000");
|
||||||
|
expect(serverLiveOperationsSource).toContain("followLatestRef");
|
||||||
|
expect(serverLiveOperationsSource).toContain("handleTerminalScroll");
|
||||||
|
expect(serverLiveOperationsSource).toContain("scrollHeight - output.clientHeight - output.scrollTop <= 24");
|
||||||
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
||||||
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
|
expect(serverLiveOperationsSource).not.toContain("logStreamPollMs = 5000");
|
||||||
expect(serverLiveOperationsSource).not.toContain("queryLogStream(");
|
expect(serverLiveOperationsSource).not.toContain("queryLogStream(");
|
||||||
|
|||||||
Reference in New Issue
Block a user