Refine terminal history scrolling and job failure summaries
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## Risks / Trade-offs
|
||||||
|
|||||||
+1
@@ -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
|
- **WHEN** the terminal receives its initial bounded history
|
||||||
- **THEN** the output viewport scrolls to the bottom after the lines render
|
- **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** 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
|
#### Scenario: Operator inspects older output
|
||||||
- **WHEN** the operator scrolls above the bottom threshold
|
- **WHEN** the operator scrolls above the bottom threshold
|
||||||
|
|||||||
@@ -5,11 +5,11 @@
|
|||||||
|
|
||||||
## 2. Management Terminal View
|
## 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.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
|
## 3. Verification
|
||||||
|
|
||||||
- [x] 3.1 Run focused platform and platform_web tests, then `scripts/check-structure.sh`.
|
- [ ] 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.2 Run `openspec validate improve-server-terminal-log-window --strict` and record verification evidence before marking tasks complete.
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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 = 10000;
|
const terminalInitialHistoryWindow = 500;
|
||||||
const maxLogEntries = 500;
|
const maxLogEntries = 500;
|
||||||
const maxTerminalLines = 10000;
|
const maxTerminalLines = 10000;
|
||||||
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
const terminalQuickCommandCatalog: Record<string, TerminalQuickCommand[]> = {
|
||||||
@@ -212,6 +212,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
const [followLatest, setFollowLatest] = useState(true);
|
const [followLatest, setFollowLatest] = useState(true);
|
||||||
const outputRef = useRef<HTMLDivElement>(null);
|
const outputRef = useRef<HTMLDivElement>(null);
|
||||||
const followLatestRef = useRef(true);
|
const followLatestRef = useRef(true);
|
||||||
|
const initialHistoryPendingRef = useRef(false);
|
||||||
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]);
|
||||||
@@ -221,6 +222,20 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
setLines((current) => mergeTerminalLines(current, incoming));
|
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) => {
|
const loadStreams = useCallback(async (showLoading = true) => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
if (showLoading) setStreams({ status: "loading" });
|
if (showLoading) setStreams({ status: "loading" });
|
||||||
@@ -238,6 +253,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
setPending(false);
|
setPending(false);
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setHistoryIndex(null);
|
setHistoryIndex(null);
|
||||||
|
initialHistoryPendingRef.current = true;
|
||||||
followLatestRef.current = true;
|
followLatestRef.current = true;
|
||||||
setFollowLatest(true);
|
setFollowLatest(true);
|
||||||
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
setLines([terminalSystemLine("info", supportsCommands ? "连接平台日志推送,先补最近历史再实时追加。" : "该插件暂未声明可用的管理终端命令通道。", "SYSTEM")]);
|
||||||
@@ -245,7 +261,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
}, [loadStreams, open, supportsCommands]);
|
}, [loadStreams, open, supportsCommands]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !followLatestRef.current) return undefined;
|
if (!open || initialHistoryPendingRef.current || !followLatestRef.current) return undefined;
|
||||||
const frame = window.requestAnimationFrame(() => {
|
const frame = window.requestAnimationFrame(() => {
|
||||||
const output = outputRef.current;
|
const output = outputRef.current;
|
||||||
if (output) output.scrollTop = output.scrollHeight;
|
if (output) output.scrollTop = output.scrollHeight;
|
||||||
@@ -256,7 +272,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return undefined;
|
if (!open) return undefined;
|
||||||
let ready = false;
|
let ready = false;
|
||||||
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalHistoryWindow });
|
const events = platformApiClient.openServerLogEvents(serverId, { historyLimit: terminalInitialHistoryWindow });
|
||||||
events.addEventListener("stream", (event) => {
|
events.addEventListener("stream", (event) => {
|
||||||
const stream = parseLogStreamEvent(event);
|
const stream = parseLogStreamEvent(event);
|
||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
@@ -266,6 +282,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
events.addEventListener("ready", () => {
|
events.addEventListener("ready", () => {
|
||||||
ready = true;
|
ready = true;
|
||||||
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
setStreams((current) => current.status === "ready" ? current : { status: "ready", data: [] });
|
||||||
|
lockTerminalFollow();
|
||||||
});
|
});
|
||||||
events.addEventListener("log", (event) => {
|
events.addEventListener("log", (event) => {
|
||||||
const payload = parseServerLogEvent(event);
|
const payload = parseServerLogEvent(event);
|
||||||
@@ -279,7 +296,7 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
if (!ready) setStreams({ status: "error", reason: "实时日志推送连接失败" });
|
||||||
};
|
};
|
||||||
return () => events.close();
|
return () => events.close();
|
||||||
}, [appendLines, open, serverId]);
|
}, [appendLines, lockTerminalFollow, open, serverId]);
|
||||||
|
|
||||||
function selectQuickCommand(item: TerminalQuickCommand) {
|
function selectQuickCommand(item: TerminalQuickCommand) {
|
||||||
setCommand(item.command);
|
setCommand(item.command);
|
||||||
|
|||||||
@@ -67,6 +67,20 @@ describe("operations console contracts", () => {
|
|||||||
expect(summaries.find((item) => item.instance.id === "job-failed")?.failedJobs).toBe(1);
|
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", () => {
|
it("summarizes only endpoint-safe capacity and status values", () => {
|
||||||
const endpoints: RunEndpointResponse[] = [
|
const endpoints: RunEndpointResponse[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -85,13 +85,22 @@ export function summarizeServerOperations(
|
|||||||
instance,
|
instance,
|
||||||
metrics: metrics.get(instance.id),
|
metrics: metrics.get(instance.id),
|
||||||
activeJobs: serverJobs.filter(isActiveJob).length,
|
activeJobs: serverJobs.filter(isActiveJob).length,
|
||||||
failedJobs: serverJobs.filter((job) => job.state === "failed").length,
|
failedJobs: currentFailedJobsForServer(instance, serverJobs).length,
|
||||||
latestJob: serverJobs[0]
|
latestJob: serverJobs[0]
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort(compareServerOperations);
|
.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 {
|
export function summarizeEndpointOperations(endpoints: RunEndpointResponse[]): EndpointOperationsSummary {
|
||||||
return endpoints.reduce<EndpointOperationsSummary>(
|
return endpoints.reduce<EndpointOperationsSummary>(
|
||||||
(summary, endpoint) => ({
|
(summary, endpoint) => ({
|
||||||
|
|||||||
@@ -340,9 +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("terminalInitialHistoryWindow = 500");
|
||||||
expect(serverLiveOperationsSource).toContain("maxTerminalLines = 10000");
|
expect(serverLiveOperationsSource).toContain("maxTerminalLines = 10000");
|
||||||
expect(serverLiveOperationsSource).toContain("followLatestRef");
|
expect(serverLiveOperationsSource).toContain("followLatestRef");
|
||||||
|
expect(serverLiveOperationsSource).toContain("initialHistoryPendingRef");
|
||||||
|
expect(serverLiveOperationsSource).toContain("lockTerminalFollow");
|
||||||
expect(serverLiveOperationsSource).toContain("handleTerminalScroll");
|
expect(serverLiveOperationsSource).toContain("handleTerminalScroll");
|
||||||
expect(serverLiveOperationsSource).toContain("scrollHeight - output.clientHeight - output.scrollTop <= 24");
|
expect(serverLiveOperationsSource).toContain("scrollHeight - output.clientHeight - output.scrollTop <= 24");
|
||||||
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
expect(serverLiveOperationsSource).not.toContain("terminalLogPollMs = 1000");
|
||||||
|
|||||||
Reference in New Issue
Block a user