diff --git a/openspec/changes/improve-server-terminal-log-window/design.md b/openspec/changes/improve-server-terminal-log-window/design.md index fe77b86..b09564a 100644 --- a/openspec/changes/improve-server-terminal-log-window/design.md +++ b/openspec/changes/improve-server-terminal-log-window/design.md @@ -20,7 +20,7 @@ The server log SSE route currently replays `historyLimit` entries independently - 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. +- Give the terminal output element a ref and track `followLatest` from scroll position. The terminal requests only a 500-entry recent replay, then locks to the bottom after `ready` with two animation frames; layout-driven scroll events during replay cannot unlock follow mode. After initialization, 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 diff --git a/openspec/changes/improve-server-terminal-log-window/specs/bounded-server-log-view/spec.md b/openspec/changes/improve-server-terminal-log-window/specs/bounded-server-log-view/spec.md index b6dc334..5657e37 100644 --- a/openspec/changes/improve-server-terminal-log-window/specs/bounded-server-log-view/spec.md +++ b/openspec/changes/improve-server-terminal-log-window/specs/bounded-server-log-view/spec.md @@ -25,6 +25,7 @@ The management terminal SHALL begin with the output viewport at the newest rende - **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 +- **AND** the terminal requests no more than 500 initial history entries while retaining up to 10,000 rendered lines as live output arrives #### Scenario: Operator inspects older output - **WHEN** the operator scrolls above the bottom threshold diff --git a/openspec/changes/improve-server-terminal-log-window/tasks.md b/openspec/changes/improve-server-terminal-log-window/tasks.md index 60f9430..708ecd9 100644 --- a/openspec/changes/improve-server-terminal-log-window/tasks.md +++ b/openspec/changes/improve-server-terminal-log-window/tasks.md @@ -5,11 +5,11 @@ ## 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. +- [ ] 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. +- [ ] 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. +- [ ] 3.1 Run focused platform and platform_web tests, then `scripts/check-structure.sh`. +- [ ] 3.2 Run `openspec validate improve-server-terminal-log-window --strict` and record verification evidence before marking tasks complete. diff --git a/platform_web/components/ServerLiveOperations.tsx b/platform_web/components/ServerLiveOperations.tsx index c23692c..4cc9d71 100644 --- a/platform_web/components/ServerLiveOperations.tsx +++ b/platform_web/components/ServerLiveOperations.tsx @@ -15,7 +15,7 @@ type TerminalQuickCommand = { label: string; command: string; hint: string }; const terminalBridgeResultPollMs = 1000; const terminalBridgeResultPollAttempts = 30; const liveLogHistoryWindow = 100; -const terminalHistoryWindow = 10000; +const terminalInitialHistoryWindow = 500; const maxLogEntries = 500; const maxTerminalLines = 10000; const terminalQuickCommandCatalog: Record = { @@ -212,6 +212,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu const [followLatest, setFollowLatest] = useState(true); const outputRef = useRef(null); const followLatestRef = useRef(true); + const initialHistoryPendingRef = useRef(false); const quickCommands = useMemo(() => terminalQuickCommandsForPlugin(pluginId), [pluginId]); const supportsCommands = quickCommands.length > 0; const terminalStreams = useMemo(() => streams.status === "ready" ? terminalRelevantStreams(streams.data) : [], [streams]); @@ -221,6 +222,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu setLines((current) => mergeTerminalLines(current, incoming)); }, []); + const lockTerminalFollow = useCallback(() => { + followLatestRef.current = true; + setFollowLatest(true); + window.requestAnimationFrame(() => { + const output = outputRef.current; + if (output) output.scrollTop = output.scrollHeight; + window.requestAnimationFrame(() => { + const output = outputRef.current; + if (output) output.scrollTop = output.scrollHeight; + initialHistoryPendingRef.current = false; + }); + }); + }, []); + const loadStreams = useCallback(async (showLoading = true) => { if (!open) return; if (showLoading) setStreams({ status: "loading" }); @@ -238,6 +253,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu setPending(false); setResult(null); setHistoryIndex(null); + initialHistoryPendingRef.current = true; followLatestRef.current = true; setFollowLatest(true); setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]); @@ -245,7 +261,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu }, [loadStreams, open, supportsCommands]); useEffect(() => { - if (!open || !followLatestRef.current) return undefined; + if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined; const frame = window.requestAnimationFrame(() => { const output = outputRef.current; if (output) output.scrollTop = output.scrollHeight; @@ -256,7 +272,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu useEffect(() => { if (!open) return undefined; let ready = false; - const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalHistoryWindow }); + const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow }); events.addEventListener("stream", (event) => { const stream = parseLogStreamEvent(event); if (!stream) return; @@ -266,6 +282,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu events.addEventListener("ready", () => { ready = true; setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] }); + lockTerminalFollow(); }); events.addEventListener("log", (event) => { const payload = parseServerLogEvent(event); @@ -279,7 +296,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" }); }; return () => events.close(); - }, [appendLines, open, serverId]); + }, [appendLines, lockTerminalFollow, open, serverId]); function selectQuickCommand(item: TerminalQuickCommand) { setCommand(item.command); diff --git a/platform_web/contracts/operationsConsole.test.ts b/platform_web/contracts/operationsConsole.test.ts index 644d431..f242ac9 100644 --- a/platform_web/contracts/operationsConsole.test.ts +++ b/platform_web/contracts/operationsConsole.test.ts @@ -67,6 +67,20 @@ describe("operations console contracts", () => { expect(summaries.find((item) => item.instance.id === "job-failed")?.failedJobs).toBe(1); }); + it("does not count failed jobs older than the latest healthy server projection", () => { + const summaries = summarizeServerOperations( + [server("recovered", "running", "2026-07-18T10:10:00Z")], + new Map(), + [ + job("old-failed-start", "failed", "2026-07-18T10:03:00Z", "recovered"), + job("healthy-start", "succeeded", "2026-07-18T10:10:00Z", "recovered") + ] + ); + + expect(summaries[0]?.failedJobs).toBe(0); + expect(summaries[0]?.latestJob?.id).toBe("healthy-start"); + }); + it("summarizes only endpoint-safe capacity and status values", () => { const endpoints: RunEndpointResponse[] = [ { diff --git a/platform_web/contracts/operationsConsole.ts b/platform_web/contracts/operationsConsole.ts index 2fb95ba..0290815 100644 --- a/platform_web/contracts/operationsConsole.ts +++ b/platform_web/contracts/operationsConsole.ts @@ -85,13 +85,22 @@ export function summarizeServerOperations( instance, metrics: metrics.get(instance.id), activeJobs: serverJobs.filter(isActiveJob).length, - failedJobs: serverJobs.filter((job) => job.state === "failed").length, + failedJobs: currentFailedJobsForServer(instance, serverJobs).length, latestJob: serverJobs[0] }; }) .sort(compareServerOperations); } +function currentFailedJobsForServer(instance: ServerInstanceResponse, jobs: JobResponse[]): JobResponse[] { + const failedJobs = jobs.filter((job) => job.state === "failed"); + if (instance.state === "failed") { + return failedJobs; + } + const instanceUpdatedAt = timestamp(instance.updatedAt); + return failedJobs.filter((job) => timestamp(job.terminalAt ?? job.updatedAt) >= instanceUpdatedAt); +} + export function summarizeEndpointOperations(endpoints: RunEndpointResponse[]): EndpointOperationsSummary { return endpoints.reduce( (summary, endpoint) => ({ diff --git a/platform_web/pages/ConsolePages.test.tsx b/platform_web/pages/ConsolePages.test.tsx index 39a0916..914686f 100644 --- a/platform_web/pages/ConsolePages.test.tsx +++ b/platform_web/pages/ConsolePages.test.tsx @@ -340,9 +340,11 @@ describe("first-party console pages", () => { expect(serverLiveOperationsSource).toContain("streams.filter((stream) => stream.latestSeq > 0)"); expect(serverLiveOperationsSource).toContain("SSE 实时推送"); expect(serverLiveOperationsSource).toContain("mergeTerminalLines"); - expect(serverLiveOperationsSource).toContain("terminalHistoryWindow = 10000"); + expect(serverLiveOperationsSource).toContain("terminalInitialHistoryWindow = 500"); expect(serverLiveOperationsSource).toContain("maxTerminalLines = 10000"); expect(serverLiveOperationsSource).toContain("followLatestRef"); + expect(serverLiveOperationsSource).toContain("initialHistoryPendingRef"); + expect(serverLiveOperationsSource).toContain("lockTerminalFollow"); expect(serverLiveOperationsSource).toContain("handleTerminalScroll"); expect(serverLiveOperationsSource).toContain("scrollHeight - output.clientHeight - output.scrollTop <= 24"); expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");