Skip to content

Commit 71cc5eb

Browse files
committed
[server, dashboard] Use workspace log endpoint in frontend
1 parent 5f01d9c commit 71cc5eb

File tree

3 files changed

+117
-59
lines changed

3 files changed

+117
-59
lines changed

components/dashboard/src/start/CreateWorkspace.tsx

Lines changed: 31 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import StartWorkspace from "./StartWorkspace";
1616
import { openAuthorizeWindow } from "../provider-utils";
1717
import { SelectAccountPayload } from "@gitpod/gitpod-protocol/lib/auth";
1818
import { SelectAccountModal } from "../settings/SelectAccountModal";
19+
import { watchHeadlessLogs } from "./WorkspaceLogs";
1920

2021
const WorkspaceLogs = React.lazy(() => import('./WorkspaceLogs'));
2122

@@ -68,27 +69,27 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
6869

6970
async tryAuthorize(host: string, scopes?: string[]) {
7071
try {
71-
await openAuthorizeWindow({
72-
host,
73-
scopes,
74-
onSuccess: () => {
75-
window.location.reload();
76-
},
77-
onError: (error) => {
78-
if (typeof error === "string") {
79-
try {
80-
const payload = JSON.parse(error);
81-
if (SelectAccountPayload.is(payload)) {
82-
this.setState({ selectAccountError: payload });
83-
}
84-
} catch (error) {
85-
console.log(error);
86-
}
87-
}
72+
await openAuthorizeWindow({
73+
host,
74+
scopes,
75+
onSuccess: () => {
76+
window.location.reload();
77+
},
78+
onError: (error) => {
79+
if (typeof error === "string") {
80+
try {
81+
const payload = JSON.parse(error);
82+
if (SelectAccountPayload.is(payload)) {
83+
this.setState({ selectAccountError: payload });
84+
}
85+
} catch (error) {
86+
console.log(error);
8887
}
89-
});
88+
}
89+
}
90+
});
9091
} catch (error) {
91-
console.log(error)
92+
console.log(error)
9293
}
9394
};
9495

@@ -100,7 +101,7 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
100101
window.location.href = gitpodHostUrl.asAccessControl().toString();
101102
}} />
102103
</div>
103-
</StartPage>);
104+
</StartPage>);
104105
}
105106

106107
let phase = StartPhase.Checking;
@@ -130,19 +131,19 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
130131
// HACK: Hide the error (behind the modal)
131132
error = undefined;
132133
phase = StartPhase.Stopped;
133-
statusMessage = <LimitReachedPrivateRepoModal/>;
134+
statusMessage = <LimitReachedPrivateRepoModal />;
134135
break;
135136
case ErrorCodes.TOO_MANY_RUNNING_WORKSPACES:
136137
// HACK: Hide the error (behind the modal)
137138
error = undefined;
138139
phase = StartPhase.Stopped;
139-
statusMessage = <LimitReachedParallelWorkspacesModal/>;
140+
statusMessage = <LimitReachedParallelWorkspacesModal />;
140141
break;
141142
case ErrorCodes.NOT_ENOUGH_CREDIT:
142143
// HACK: Hide the error (behind the modal)
143144
error = undefined;
144145
phase = StartPhase.Stopped;
145-
statusMessage = <LimitReachedOutOfHours/>;
146+
statusMessage = <LimitReachedOutOfHours />;
146147
break;
147148
default:
148149
statusMessage = <p className="text-base text-gitpod-red w-96">Unknown Error: {JSON.stringify(this.state?.error, null, 2)}</p>;
@@ -156,7 +157,7 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
156157
}
157158

158159
else if (result?.existingWorkspaces) {
159-
statusMessage = <Modal visible={true} closeable={false} onClose={()=>{}}>
160+
statusMessage = <Modal visible={true} closeable={false} onClose={() => { }}>
160161
<h3>Running Workspaces</h3>
161162
<div className="border-t border-b border-gray-200 dark:border-gray-800 mt-4 -mx-6 px-6 py-2">
162163
<p className="mt-1 mb-2 text-base">You already have running workspaces with the same context. You can open an existing one or open a new workspace.</p>
@@ -203,7 +204,7 @@ export default class CreateWorkspace extends React.Component<CreateWorkspaceProp
203204

204205
function LimitReachedModal(p: { children: React.ReactNode }) {
205206
const { user } = useContext(UserContext);
206-
return <Modal visible={true} closeable={false} onClose={()=>{}}>
207+
return <Modal visible={true} closeable={false} onClose={() => { }}>
207208
<h3 className="flex">
208209
<span className="flex-grow">Limit Reached</span>
209210
<img className="rounded-full w-8 h-8" src={user?.avatarUrl || ''} alt={user?.name || 'Anonymous'} />
@@ -237,7 +238,7 @@ function LimitReachedOutOfHours() {
237238
}
238239

239240
function RepositoryNotFoundView(p: { error: StartWorkspaceError }) {
240-
const [ statusMessage, setStatusMessage ] = useState<React.ReactNode>();
241+
const [statusMessage, setStatusMessage] = useState<React.ReactNode>();
241242
useEffect(() => {
242243
(async () => {
243244
const service = getGitpodService();
@@ -250,7 +251,7 @@ function RepositoryNotFoundView(p: { error: StartWorkspaceError }) {
250251
console.log('lastUpdate', lastUpdate);
251252

252253
if ((await service.server.mayAccessPrivateRepo()) === false) {
253-
setStatusMessage(<LimitReachedPrivateRepoModal/>);
254+
setStatusMessage(<LimitReachedPrivateRepoModal />);
254255
return;
255256
}
256257

@@ -317,6 +318,7 @@ interface RunningPrebuildViewProps {
317318
runningPrebuild: {
318319
prebuildID: string
319320
workspaceID: string
321+
instanceID: string
320322
starting: RunningWorkspacePrebuildStarting
321323
sameCluster: boolean
322324
};
@@ -339,25 +341,11 @@ function RunningPrebuildView(props: RunningPrebuildViewProps) {
339341
}
340342
pollTimeout = setTimeout(pollIsPrebuildDone, 10000);
341343
};
342-
const watchPrebuild = () => {
343-
service.server.watchHeadlessWorkspaceLogs(props.runningPrebuild.workspaceID);
344-
pollIsPrebuildDone();
345-
};
346-
watchPrebuild();
347-
348-
const toDispose = service.registerClient({
349-
notifyDidOpenConnection: () => watchPrebuild(),
350-
onHeadlessWorkspaceLogs: event => {
351-
if (event.workspaceID !== props.runningPrebuild.workspaceID) {
352-
return;
353-
}
354-
logsEmitter.emit('logs', event.text);
355-
},
356-
});
357344

345+
const disposables = watchHeadlessLogs(service.server, props.runningPrebuild.instanceID, (chunk) => logsEmitter.emit('logs', chunk), pollIsPrebuildDone);
358346
return function cleanup() {
359347
clearTimeout(pollTimeout!);
360-
toDispose.dispose();
348+
disposables.dispose();
361349
};
362350
}, []);
363351

components/dashboard/src/start/StartWorkspace.tsx

Lines changed: 6 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@
77
import EventEmitter from "events";
88
import React, { useEffect, Suspense } from "react";
99
import { DisposableCollection, WorkspaceInstance, WorkspaceImageBuild, Workspace, WithPrebuild } from "@gitpod/gitpod-protocol";
10-
import { HeadlessLogEvent } from "@gitpod/gitpod-protocol/lib/headless-workspace-log";
1110
import { ErrorCodes } from "@gitpod/gitpod-protocol/lib/messaging/error";
1211
import PendingChangesDropdown from "../components/PendingChangesDropdown";
1312
import { getGitpodService, gitpodHostUrl } from "../service/service";
1413
import { StartPage, StartPhase, StartWorkspaceError } from "./StartPage";
14+
import { watchHeadlessLogs } from "./WorkspaceLogs";
1515

1616
const WorkspaceLogs = React.lazy(() => import('./WorkspaceLogs'));
1717

@@ -230,7 +230,7 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
230230
// or as a headless workspace.
231231
case "running":
232232
if (isHeadless) {
233-
return <HeadlessWorkspaceView workspaceId={this.state.workspaceInstance.workspaceId} />;
233+
return <HeadlessWorkspaceView instanceId={this.state.workspaceInstance.id} />;
234234
}
235235
phase = StartPhase.Running;
236236
statusMessage = <p className="text-base text-gray-400">Opening IDE …</p>;
@@ -246,7 +246,7 @@ export default class StartWorkspace extends React.Component<StartWorkspaceProps,
246246
// Stopping means that the workspace is currently shutting down. It could go to stopped every moment.
247247
case "stopping":
248248
if (isHeadless) {
249-
return <HeadlessWorkspaceView workspaceId={this.state.workspaceInstance.workspaceId} />;
249+
return <HeadlessWorkspaceView instanceId={this.state.workspaceInstance.id} />;
250250
}
251251
phase = StartPhase.Stopping;
252252
statusMessage = <div>
@@ -337,23 +337,14 @@ function ImageBuildView(props: ImageBuildViewProps) {
337337
</StartPage>;
338338
}
339339

340-
function HeadlessWorkspaceView(props: { workspaceId: string }) {
340+
function HeadlessWorkspaceView(props: { instanceId: string }) {
341341
const logsEmitter = new EventEmitter();
342342

343343
useEffect(() => {
344344
const service = getGitpodService();
345-
const watchHeadlessWorkspace = () => service.server.watchHeadlessWorkspaceLogs(props.workspaceId);;
346-
watchHeadlessWorkspace();
347-
348-
const toDispose = service.registerClient({
349-
notifyDidOpenConnection: () => watchHeadlessWorkspace(),
350-
onHeadlessWorkspaceLogs(event: HeadlessLogEvent): void {
351-
logsEmitter.emit('logs', event.text);
352-
},
353-
});
354-
345+
const disposables = watchHeadlessLogs(service.server, props.instanceId, (chunk) => logsEmitter.emit('logs', chunk), () => {});
355346
return function cleanup() {
356-
toDispose.dispose();
347+
disposables.dispose();
357348
};
358349
}, []);
359350

components/dashboard/src/start/WorkspaceLogs.tsx

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import React from 'react';
99
import { Terminal, ITerminalOptions, ITheme } from 'xterm';
1010
import { FitAddon } from 'xterm-addon-fit'
1111
import 'xterm/css/xterm.css';
12-
import { DisposableCollection } from '@gitpod/gitpod-protocol';
12+
import { DisposableCollection, GitpodServer, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from '@gitpod/gitpod-protocol';
1313

1414
export interface WorkspaceLogsProps {
1515
logsEmitter: EventEmitter;
@@ -85,4 +85,83 @@ export default class WorkspaceLogs extends React.Component<WorkspaceLogsProps, W
8585
<div className="h-full w-full" ref={this.xTermParentRef}></div>
8686
</div>;
8787
}
88+
}
89+
90+
export function watchHeadlessLogs(server: GitpodServer, instanceId: string, onLog: (chunk: string) => void, checkIsDone: () => Promise<void> | void): DisposableCollection {
91+
const disposables = new DisposableCollection();
92+
93+
const startWatchingLogs = async () => {
94+
await checkIsDone();
95+
96+
const retry = async (reason: string, err?: Error) => {
97+
console.debug("re-trying headless-logs because: " + reason, err);
98+
await new Promise((resolve) => {
99+
setTimeout(resolve, 2000);
100+
});
101+
startWatchingLogs().catch(console.error);
102+
};
103+
104+
let response: Response | undefined = undefined;
105+
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined = undefined;
106+
try {
107+
const logSources = await server.getHeadlessLog(instanceId);
108+
// TODO(gpl) Only listening on first stream for now
109+
const streamIds = Object.keys(logSources.streams);
110+
if (streamIds.length < 1) {
111+
await retry("no streams");
112+
return;
113+
}
114+
115+
const streamUrl = logSources.streams[streamIds[0]];
116+
console.log("fetching from streamUrl: " + streamUrl);
117+
response = await fetch(streamUrl, {
118+
method: 'GET',
119+
cache: 'no-cache',
120+
credentials: 'include',
121+
keepalive: true,
122+
headers: {
123+
'TE': 'trailers', // necessary to receive stream status code
124+
},
125+
});
126+
reader = response.body?.getReader();
127+
if (!reader) {
128+
await retry("no reader");
129+
return;
130+
}
131+
disposables.push({ dispose: () => reader?.cancel() });
132+
133+
const decoder = new TextDecoder('utf-8');
134+
let chunk = await reader.read();
135+
while (!chunk.done) {
136+
const msg = decoder.decode(chunk.value, { stream: true });
137+
138+
// In an ideal world, we'd use res.addTrailers()/response.trailer here. But despite being introduced with HTTP/1.1 in 1999, trailers are not supported by popular proxies (nginx, for example).
139+
// So we resort to this hand-written solution:
140+
const matches = msg.match(HEADLESS_LOG_STREAM_STATUS_CODE_REGEX);
141+
if (matches) {
142+
if (matches.length < 2) {
143+
console.debug("error parsing log stream status code. msg: " + msg);
144+
} else {
145+
const streamStatusCode = matches[1];
146+
if (streamStatusCode !== "200") {
147+
throw new Error("received status code: " + streamStatusCode);
148+
}
149+
}
150+
} else {
151+
onLog(msg);
152+
}
153+
154+
chunk = await reader.read();
155+
}
156+
reader.cancel()
157+
158+
await checkIsDone();
159+
} catch(err) {
160+
reader?.cancel().catch(console.debug);
161+
await retry("error while listening to stream", err);
162+
}
163+
};
164+
startWatchingLogs().catch(console.error);
165+
166+
return disposables;
88167
}

0 commit comments

Comments
 (0)