Fix terminal log follow and wrapping
This commit is contained in:
@@ -802,6 +802,28 @@ describe("PlatformApiClient AI providers", () => {
|
|||||||
stream.close();
|
stream.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reconnects terminal log SSE after the fetch stream ends", async () => {
|
||||||
|
const payloads = [
|
||||||
|
"id: log-1:3\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":3,\"line\":\"first live line\"}}\n\n",
|
||||||
|
"id: log-1:4\nevent: log\ndata: {\"streamId\":\"log-1\",\"entry\":{\"seq\":4,\"line\":\"second live line\"}}\n\n"
|
||||||
|
];
|
||||||
|
const fetchMock = vi.fn(async (_input: RequestInfo | URL, _init?: RequestInit) => new Response(new ReadableStream<Uint8Array>({ start(controller) { controller.enqueue(new TextEncoder().encode(payloads[Math.min(fetchMock.mock.calls.length - 1, payloads.length - 1)])); controller.close(); } }), { status: 200, headers: { "Content-Type": "text/event-stream" } }));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const client = new PlatformApiClient("/api/v1", () => "terminal-session");
|
||||||
|
const events: string[] = [];
|
||||||
|
const stream = client.openServerLogEvents("server-1");
|
||||||
|
stream.addEventListener("log", (event) => events.push(event.data));
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(events).toContain('{"streamId":"log-1","entry":{"seq":3,"line":"first live line"}}'));
|
||||||
|
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2), { timeout: 1500 });
|
||||||
|
expect(new Headers(fetchMock.mock.calls[1]?.[1]?.headers).get("Last-Event-ID")).toBe("log-1:3");
|
||||||
|
await vi.waitFor(() => expect(events).toEqual([
|
||||||
|
'{"streamId":"log-1","entry":{"seq":3,"line":"first live line"}}',
|
||||||
|
'{"streamId":"log-1","entry":{"seq":4,"line":"second live line"}}'
|
||||||
|
]), { timeout: 1500 });
|
||||||
|
stream.close();
|
||||||
|
});
|
||||||
|
|
||||||
it("surfaces password confirmation denials without exposing generic forbidden text", async () => {
|
it("surfaces password confirmation denials without exposing generic forbidden text", async () => {
|
||||||
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
||||||
code: "forbidden",
|
code: "forbidden",
|
||||||
|
|||||||
+38
-17
@@ -791,11 +791,12 @@ async function responseError(response: Response): Promise<PlatformApiError> {
|
|||||||
|
|
||||||
class FetchServerSentEventStream implements PlatformEventStream {
|
class FetchServerSentEventStream implements PlatformEventStream {
|
||||||
onerror: ((event: Event) => void) | null = null;
|
onerror: ((event: Event) => void) | null = null;
|
||||||
|
private lastEventId = "";
|
||||||
private readonly controller = new AbortController();
|
private readonly controller = new AbortController();
|
||||||
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
|
private readonly listeners = new Map<string, Set<(event: MessageEvent) => void>>();
|
||||||
|
|
||||||
constructor(private readonly url: string, private readonly sessionToken: string) {
|
constructor(private readonly url: string, private readonly sessionToken: string) {
|
||||||
void this.connect();
|
void this.connectLoop();
|
||||||
}
|
}
|
||||||
|
|
||||||
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
addEventListener(type: string, listener: (event: MessageEvent) => void): void {
|
||||||
@@ -813,17 +814,21 @@ class FetchServerSentEventStream implements PlatformEventStream {
|
|||||||
this.listeners.clear();
|
this.listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async connect(): Promise<void> {
|
private async connectLoop(): Promise<void> {
|
||||||
try {
|
while (!this.controller.signal.aborted) {
|
||||||
const response = await fetch(this.url, { method: "GET", credentials: "include", headers: { Accept: "text/event-stream", Authorization: `Bearer ${this.sessionToken}` }, signal: this.controller.signal });
|
try {
|
||||||
if (!response.ok || !response.body) {
|
const headers = new Headers({ Accept: "text/event-stream", Authorization: `Bearer ${this.sessionToken}` });
|
||||||
throw new Error(`event stream failed: ${response.status}`);
|
if (this.lastEventId) headers.set("Last-Event-ID", this.lastEventId);
|
||||||
}
|
const response = await fetch(this.url, { method: "GET", credentials: "include", headers, signal: this.controller.signal });
|
||||||
await this.read(response.body);
|
if (!response.ok || !response.body) {
|
||||||
} catch {
|
throw new Error(`event stream failed: ${response.status}`);
|
||||||
if (!this.controller.signal.aborted) {
|
}
|
||||||
|
await this.read(response.body);
|
||||||
|
} catch {
|
||||||
|
if (this.controller.signal.aborted) return;
|
||||||
this.onerror?.(new Event("error"));
|
this.onerror?.(new Event("error"));
|
||||||
}
|
}
|
||||||
|
await this.reconnectPause();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -853,23 +858,39 @@ class FetchServerSentEventStream implements PlatformEventStream {
|
|||||||
if (field === "id") eventId = value;
|
if (field === "id") eventId = value;
|
||||||
if (field === "data") dataLines.push(value);
|
if (field === "data") dataLines.push(value);
|
||||||
};
|
};
|
||||||
while (!this.controller.signal.aborted) {
|
try {
|
||||||
const { value, done } = await reader.read();
|
while (!this.controller.signal.aborted) {
|
||||||
if (done) break;
|
const { value, done } = await reader.read();
|
||||||
buffer += decoder.decode(value, { stream: true });
|
if (done) break;
|
||||||
const lines = buffer.split("\n");
|
buffer += decoder.decode(value, { stream: true });
|
||||||
buffer = lines.pop() ?? "";
|
const lines = buffer.split("\n");
|
||||||
lines.forEach(processLine);
|
buffer = lines.pop() ?? "";
|
||||||
|
lines.forEach(processLine);
|
||||||
|
}
|
||||||
|
buffer += decoder.decode();
|
||||||
|
if (buffer) processLine(buffer);
|
||||||
|
processLine("");
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private dispatch(type: string, data: string, lastEventId: string): void {
|
private dispatch(type: string, data: string, lastEventId: string): void {
|
||||||
|
if (lastEventId) this.lastEventId = lastEventId;
|
||||||
const event = new MessageEvent(type, { data, lastEventId });
|
const event = new MessageEvent(type, { data, lastEventId });
|
||||||
this.listeners.get(type)?.forEach((listener) => listener(event));
|
this.listeners.get(type)?.forEach((listener) => listener(event));
|
||||||
if (type !== "message") {
|
if (type !== "message") {
|
||||||
this.listeners.get("message")?.forEach((listener) => listener(event));
|
this.listeners.get("message")?.forEach((listener) => listener(event));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private reconnectPause(): Promise<void> {
|
||||||
|
if (this.controller.signal.aborted) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timeout = globalThis.setTimeout(resolve, 1000);
|
||||||
|
this.controller.signal.addEventListener("abort", () => { globalThis.clearTimeout(timeout); resolve(); }, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function safeValidationMessage(apiError?: ApiErrorResponse | null): string {
|
function safeValidationMessage(apiError?: ApiErrorResponse | null): string {
|
||||||
|
|||||||
@@ -261,9 +261,9 @@ export function ServerManagementTerminalDrawer({ open, serverId, serverName, plu
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ref={outputRef} className="terminal-output" role="log" aria-live="polite" onScroll={handleTerminalScroll}>
|
<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 terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><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 terminal-source-system"><time>{new Date().toLocaleTimeString()}</time><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={terminalLineClassName(line)}><time>{line.at}</time><span className="terminal-text">{line.text}</span></div>)}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section className="terminal-command-dock" aria-label="terminal command controls">
|
<section className="terminal-command-dock" aria-label="terminal command controls">
|
||||||
@@ -316,6 +316,21 @@ 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 terminalLineClassName(line: TerminalLine): string {
|
||||||
|
return `terminal-line terminal-line-${line.tone} terminal-source-${terminalSourceClass(line.streamKey || line.level)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function terminalSourceClass(value?: string): string {
|
||||||
|
const key = (value ?? "").toLowerCase();
|
||||||
|
if (key.includes("stderr") || key === "error") return "stderr";
|
||||||
|
if (key.includes("stdout")) return "stdout";
|
||||||
|
if (key.includes("command")) return "command";
|
||||||
|
if (key.includes("platform")) return "platform";
|
||||||
|
if (key.includes("bridge")) return "bridge";
|
||||||
|
if (key.includes("system")) return "system";
|
||||||
|
return "log";
|
||||||
|
}
|
||||||
|
|
||||||
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
|
function isTerminalBridgeCommandState(state: GameClientBridgeCommandResponse["state"]): boolean {
|
||||||
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
|
return state === "succeeded" || state === "failed" || state === "cancelled" || state === "expired" || state === "unknown";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -184,10 +184,11 @@ describe("platform web shared theme CSS", () => {
|
|||||||
expect(css).toContain(".management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr)auto");
|
expect(css).toContain(".management-terminal-body{height:100%;grid-template-rows:minmax(0,1fr)auto");
|
||||||
expect(css).toContain(".terminal-output-panel{display:grid;grid-template-rows:autominmax(0,1fr);min-height:0;border:1pxsolid#222;border-radius:8px;background:#000");
|
expect(css).toContain(".terminal-output-panel{display:grid;grid-template-rows:autominmax(0,1fr);min-height:0;border:1pxsolid#222;border-radius:8px;background:#000");
|
||||||
expect(css).toContain(".terminal-output-topbar{display:flex;align-items:center;justify-content:space-between");
|
expect(css).toContain(".terminal-output-topbar{display:flex;align-items:center;justify-content:space-between");
|
||||||
expect(css).toContain(".terminal-line{display:grid;grid-template-columns:76px148pxminmax(0,1fr)");
|
expect(css).toContain(".terminal-output{display:block;min-height:260px");
|
||||||
expect(css).toContain(".terminal-line-success.terminal-stream,.terminal-line-success.terminal-text{color:#7ee787}");
|
expect(css).toContain(".terminal-line{display:grid;grid-template-columns:74pxminmax(0,1fr)");
|
||||||
expect(css).toContain(".terminal-line-warn.terminal-stream,.terminal-line-warn.terminal-text{color:#d29922}");
|
expect(css).toContain(".terminal-text{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word");
|
||||||
expect(css).toContain(".terminal-line-error.terminal-stream,.terminal-line-error.terminal-text{color:#ff7b72}");
|
expect(css).toContain(".terminal-source-system.terminal-text,.terminal-source-platform.terminal-text,.terminal-source-bridge.terminal-text{color:#d2a8ff}");
|
||||||
|
expect(css).toContain(".terminal-line-error.terminal-text,.terminal-source-stderr.terminal-text,.terminal-source-stderrtime{color:#ff7b72}");
|
||||||
expect(css).toContain(".terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr))");
|
expect(css).toContain(".terminal-quick-command-list{display:grid;grid-template-columns:repeat(4,minmax(0,1fr))");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -506,11 +506,11 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.terminal-output-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:44px;padding:8px 10px;border-bottom:1px solid #1f2933;background:#000;color:#dbeafe}
|
.terminal-output-topbar{display:flex;align-items:center;justify-content:space-between;gap:12px;min-height:44px;padding:8px 10px;border-bottom:1px solid #1f2933;background:#000;color:#dbeafe}
|
||||||
.terminal-output-topbar>div{display:flex;align-items:center;gap:8px;min-width:0}.terminal-output-topbar>div:first-child{display:grid;gap:2px}.terminal-output-topbar strong{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-output-topbar span{font-size:11px;color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
.terminal-output-topbar>div{display:flex;align-items:center;gap:8px;min-width:0}.terminal-output-topbar>div:first-child{display:grid;gap:2px}.terminal-output-topbar strong{font-size:13px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-output-topbar span{font-size:11px;color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
.terminal-output-action{min-height:30px;display:inline-flex;align-items:center;gap:5px;padding:0 9px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#c9d1d9;cursor:pointer;font:inherit;font-size:12px}.terminal-output-action:hover,.terminal-output-action:focus-visible{border-color:#58a6ff;color:#fff;outline:0}
|
.terminal-output-action{min-height:30px;display:inline-flex;align-items:center;gap:5px;padding:0 9px;border:1px solid #30363d;border-radius:6px;background:#0d1117;color:#c9d1d9;cursor:pointer;font:inherit;font-size:12px}.terminal-output-action:hover,.terminal-output-action:focus-visible{border-color:#58a6ff;color:#fff;outline:0}
|
||||||
.terminal-output{display:grid;align-content:start;gap:2px;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:10px;border-radius:0;background:#000;font-family:var(--font-mono);font-size:12.5px;color:#c9d1d9;box-shadow:none}
|
.terminal-output{display:block;min-height:260px;max-height:min(54dvh,520px);overflow:auto;padding:10px;border-radius:0;background:#000;font-family:var(--font-mono);font-size:12.5px;color:#c9d1d9;box-shadow:none}
|
||||||
.management-terminal-body .terminal-output{height:100%;min-height:0;max-height:none}
|
.management-terminal-body .terminal-output{height:100%;min-height:0;max-height:none}
|
||||||
.terminal-line{display:grid;grid-template-columns:76px 148px minmax(0,1fr);gap:10px;align-items:start;min-height:20px;padding:1px 0}
|
.terminal-line{display:grid;grid-template-columns:74px minmax(0,1fr);gap:12px;align-items:start;min-height:auto;padding:1px 0;line-height:1.42}.terminal-line+.terminal-line{margin-top:2px}
|
||||||
.terminal-line time{color:#6e7681}.terminal-stream{color:#8b949e;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.terminal-text{overflow-wrap:anywhere;color:#c9d1d9}
|
.terminal-line time{color:#6e7681}.terminal-text{min-width:0;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;color:#c9d1d9}
|
||||||
.terminal-line-input .terminal-stream,.terminal-line-input .terminal-text{color:#79c0ff}.terminal-line-success .terminal-stream,.terminal-line-success .terminal-text{color:#7ee787}.terminal-line-warn .terminal-stream,.terminal-line-warn .terminal-text{color:#d29922}.terminal-line-error .terminal-stream,.terminal-line-error .terminal-text{color:#ff7b72}
|
.terminal-source-system .terminal-text,.terminal-source-platform .terminal-text,.terminal-source-bridge .terminal-text{color:#d2a8ff}.terminal-source-command .terminal-text,.terminal-line-input .terminal-text{color:#79c0ff}.terminal-line-success .terminal-text{color:#7ee787}.terminal-line-warn .terminal-text{color:#d29922}.terminal-line-error .terminal-text,.terminal-source-stderr .terminal-text,.terminal-source-stderr time{color:#ff7b72}
|
||||||
.terminal-command-dock{display:grid;gap:10px;padding:10px;border:1px solid #222;border-radius:8px;background:#000;box-shadow:none}
|
.terminal-command-dock{display:grid;gap:10px;padding:10px;border:1px solid #222;border-radius:8px;background:#000;box-shadow:none}
|
||||||
.terminal-command-dock-header{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
.terminal-command-dock-header{display:flex;align-items:center;justify-content:space-between;gap:10px}
|
||||||
.terminal-command-dock-header>span{display:inline-flex;align-items:center;gap:6px;color:#f0f6fc;font-weight:800}
|
.terminal-command-dock-header>span{display:inline-flex;align-items:center;gap:6px;color:#f0f6fc;font-weight:800}
|
||||||
@@ -719,7 +719,7 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
|
|||||||
.provider-table,.resource-table{min-width:680px}
|
.provider-table,.resource-table{min-width:680px}
|
||||||
.log-line{grid-template-columns:minmax(0,1fr);gap:2px}
|
.log-line{grid-template-columns:minmax(0,1fr);gap:2px}
|
||||||
.terminal-quick-command-list{grid-template-columns:repeat(2,minmax(0,1fr))}
|
.terminal-quick-command-list{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
.terminal-line{grid-template-columns:64px minmax(0,1fr);gap:5px 8px}.terminal-stream,.terminal-text{grid-column:2}.terminal-output-topbar{align-items:stretch}.terminal-output-topbar,.terminal-output-topbar>div{display:grid}.terminal-output-topbar>div:last-child{grid-template-columns:repeat(2,minmax(0,1fr))}
|
.terminal-line{grid-template-columns:64px minmax(0,1fr);gap:5px 8px}.terminal-output-topbar{align-items:stretch}.terminal-output-topbar,.terminal-output-topbar>div{display:grid}.terminal-output-topbar>div:last-child{grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||||
.management-terminal-drawer{height:92dvh;max-height:92dvh}
|
.management-terminal-drawer{height:92dvh;max-height:92dvh}
|
||||||
.ai-provider-toolbar{align-items:stretch}
|
.ai-provider-toolbar{align-items:stretch}
|
||||||
.icon-command,.segmented-button{flex:1 1 auto}
|
.icon-command,.segmented-button{flex:1 1 auto}
|
||||||
|
|||||||
Reference in New Issue
Block a user