Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement project configurator #4631

Merged
merged 1 commit into from
Jul 22, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions components/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"@gitpod/gitpod-protocol": "0.1.5",
"countries-list": "^2.6.1",
"moment": "^2.29.1",
"monaco-editor": "^0.25.2",
"react": "^17.0.1",
"react-dom": "^17.0.1",
"react-router-dom": "^5.2.0",
Expand Down
4 changes: 4 additions & 0 deletions components/dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ const NewTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/New
const JoinTeam = React.lazy(() => import(/* webpackPrefetch: true */ './teams/JoinTeam'));
const Members = React.lazy(() => import(/* webpackPrefetch: true */ './teams/Members'));
const NewProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/NewProject'));
const ConfigureProject = React.lazy(() => import(/* webpackPrefetch: true */ './projects/ConfigureProject'));
const Projects = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Projects'));
const Project = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Project'));
const Prebuilds = React.lazy(() => import(/* webpackPrefetch: true */ './projects/Prebuilds'));
Expand Down Expand Up @@ -206,6 +207,9 @@ function App() {
if (maybeProject === "members") {
return <Members />;
}
if (subResource === "configure") {
return <ConfigureProject />;
}
if (subResource === "prebuilds") {
return <Prebuilds />;
}
Expand Down
6 changes: 5 additions & 1 deletion components/dashboard/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ export default function Menu() {
})();

const userFullName = user?.fullName || user?.name || '...';
const showTeamsUI = user?.rolesOrPermissions?.includes('teams-and-projects') || window.location.hostname.endsWith('gitpod-dev.com') || window.location.hostname.endsWith('gitpod-io-dev.com');
const showTeamsUI = user?.rolesOrPermissions?.includes('teams-and-projects');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙏

const team = getCurrentTeam(location, teams);

// Hide most of the top menu when in a full-page form.
Expand Down Expand Up @@ -97,6 +97,10 @@ export default function Menu() {
{
title: 'Settings',
link: `/${team.slug}/${projectName}/settings`
},
{
title: 'Configure',
link: `/${team.slug}/${projectName}/configure`
}
] : [
{
Expand Down
44 changes: 44 additions & 0 deletions components/dashboard/src/components/MonacoEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import { useEffect, useRef } from "react";
import * as monaco from "monaco-editor";

export default function MonacoEditor(props: { classes: string, disabled?: boolean, language: string, value: string, onChange: (value: string) => void }) {
const containerRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<monaco.editor.IStandaloneCodeEditor>();

useEffect(() => {
if (containerRef.current) {
AlexTugarev marked this conversation as resolved.
Show resolved Hide resolved
editorRef.current = monaco.editor.create(containerRef.current, {
value: props.value,
language: props.language,
minimap: {
enabled: false,
},
renderLineHighlight: 'none',
});
editorRef.current.onDidChangeModelContent(() => {
props.onChange(editorRef.current!.getValue());
});
}
return () => editorRef.current?.dispose();
}, []);

useEffect(() => {
if (editorRef.current && editorRef.current.getValue() !== props.value) {
editorRef.current.setValue(props.value);
}
}, [ props.value ]);

useEffect(() => {
if (editorRef.current) {
editorRef.current.updateOptions({ readOnly: props.disabled });
}
}, [ props.disabled ]);

return <div className={props.classes} ref={containerRef} />;
}
205 changes: 205 additions & 0 deletions components/dashboard/src/components/PrebuildLogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import EventEmitter from "events";
import React, { Suspense, useEffect, useState } from "react";
import { Workspace, WorkspaceInstance, DisposableCollection, WorkspaceImageBuild, GitpodServer, HEADLESS_LOG_STREAM_STATUS_CODE_REGEX } from "@gitpod/gitpod-protocol";
import { getGitpodService } from "../service/service";

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

export default function PrebuildLogs(props: { workspaceId?: string }) {
const [ workspace, setWorkspace ] = useState<Workspace | undefined>();
const [ workspaceInstance, setWorkspaceInstance ] = useState<WorkspaceInstance | undefined>();
const [ error, setError ] = useState<Error | undefined>();
const logsEmitter = new EventEmitter();
const service = getGitpodService();

useEffect(() => {
const disposables = new DisposableCollection();
(async () => {
if (!props.workspaceId) {
return;
}
try {
const info = await service.server.getWorkspace(props.workspaceId);
if (info.latestInstance) {
setWorkspace(info.workspace);
setWorkspaceInstance(info.latestInstance);
}
disposables.push(service.registerClient({
onInstanceUpdate: setWorkspaceInstance,
onWorkspaceImageBuildLogs: (info: WorkspaceImageBuild.StateInfo, content?: WorkspaceImageBuild.LogContent) => {
if (!content) {
return;
}
logsEmitter.emit('logs', content.text);
},
}));
if (info.latestInstance) {
disposables.push(watchHeadlessLogs(service.server, info.latestInstance.id, chunk => {
logsEmitter.emit('logs', chunk);
}, async () => workspaceInstance?.status.phase === 'stopped'));
Comment on lines +43 to +45
Copy link
Contributor Author

@jankeromnes jankeromnes Jul 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@geropl Please double-check here: Is this really what the checkIfDone function is supposed to do?

(I'm not sure yet how watchHeadlessLogs works exactly.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jankeromnes Yes, if workspaceInstance is updated properly (I assume it is).

Ideally we'd have a API for "getPrebuildState" so we don't have to interpret this from the underlying instance, but the result should be the same.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! (In the future, I think it would be cool if watchHeadlessLogs can figure out on its own whenever the job it watches is done or not 😇)

}
} catch (err) {
console.error(err);
setError(err);
}
})();
return function cleanUp() {
disposables.dispose();
}
}, [ props.workspaceId ]);

useEffect(() => {
switch (workspaceInstance?.status.phase) {
// unknown indicates an issue within the system in that it cannot determine the actual phase of
// a workspace. This phase is usually accompanied by an error.
case "unknown":
break;

// Preparing means that we haven't actually started the workspace instance just yet, but rather
// are still preparing for launch. This means we're building the Docker image for the workspace.
case "preparing":
service.server.watchWorkspaceImageBuildLogs(workspace!.id);
break;

// Pending means the workspace does not yet consume resources in the cluster, but rather is looking for
// some space within the cluster. If for example the cluster needs to scale up to accomodate the
// workspace, the workspace will be in Pending state until that happened.
case "pending":
break;

// Creating means the workspace is currently being created. That includes downloading the images required
// to run the workspace over the network. The time spent in this phase varies widely and depends on the current
// network speed, image size and cache states.
case "creating":
break;

// Initializing is the phase in which the workspace is executing the appropriate workspace initializer (e.g. Git
// clone or backup download). After this phase one can expect the workspace to either be Running or Failed.
case "initializing":
break;

// Running means the workspace is able to actively perform work, either by serving a user through Theia,
// or as a headless workspace.
case "running":
break;

// Interrupted is an exceptional state where the container should be running but is temporarily unavailable.
// When in this state, we expect it to become running or stopping anytime soon.
case "interrupted":
break;

// Stopping means that the workspace is currently shutting down. It could go to stopped every moment.
case "stopping":
break;

// Stopped means the workspace ended regularly because it was shut down.
case "stopped":
service.server.watchWorkspaceImageBuildLogs(workspace!.id);
break;
}
if (workspaceInstance?.status.conditions.failed) {
setError(new Error(workspaceInstance.status.conditions.failed));
}
}, [ workspaceInstance?.status.phase ]);

return <>
<div className="capitalize">{workspaceInstance?.status.phase}</div>
<Suspense fallback={<div />}>
<WorkspaceLogs classes="h-64 w-full" logsEmitter={logsEmitter} errorMessage={error?.message} />
</Suspense>
<div className="mt-2 flex justify-center space-x-2">
{workspaceInstance?.status.phase === 'stopped'
? <a href={workspace?.contextURL ? '/#' + workspace.contextURL.replace(/^prebuild/, '') : undefined}><button>Open Workspace</button></a>
: <button className="secondary disabled" disabled={true}>Open Workspace</button> }
</div>
</>;
}

export function watchHeadlessLogs(server: GitpodServer, instanceId: string, onLog: (chunk: string) => void, checkIsDone: () => Promise<boolean>): DisposableCollection {
Copy link
Contributor Author

@jankeromnes jankeromnes Jul 21, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: I'm moving this function out of WorkspaceLogs.tsx and into the new component PrebuildLogs.tsx, because it is imported eagerly in various places.

However, we don't want to import WorkspaceLogs.tsx eagerly, because that file imports all of Xterm.js (you'll notice that WorkspaceLogs itself is always imported lazily -- same for MonacoEditor now).

const disposables = new DisposableCollection();

const startWatchingLogs = async () => {
if (await checkIsDone()) {
return;
}

const retry = async (reason: string, err?: Error) => {
console.debug("re-trying headless-logs because: " + reason, err);
await new Promise((resolve) => {
setTimeout(resolve, 2000);
});
startWatchingLogs().catch(console.error);
};

let response: Response | undefined = undefined;
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined = undefined;
try {
const logSources = await server.getHeadlessLog(instanceId);
// TODO(gpl) Only listening on first stream for now
const streamIds = Object.keys(logSources.streams);
if (streamIds.length < 1) {
await retry("no streams");
return;
}

const streamUrl = logSources.streams[streamIds[0]];
console.log("fetching from streamUrl: " + streamUrl);
response = await fetch(streamUrl, {
method: 'GET',
cache: 'no-cache',
credentials: 'include',
keepalive: true,
headers: {
'TE': 'trailers', // necessary to receive stream status code
},
});
reader = response.body?.getReader();
if (!reader) {
await retry("no reader");
return;
}
disposables.push({ dispose: () => reader?.cancel() });

const decoder = new TextDecoder('utf-8');
let chunk = await reader.read();
while (!chunk.done) {
const msg = decoder.decode(chunk.value, { stream: true });

// 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).
// So we resort to this hand-written solution:
const matches = msg.match(HEADLESS_LOG_STREAM_STATUS_CODE_REGEX);
if (matches) {
if (matches.length < 2) {
console.debug("error parsing log stream status code. msg: " + msg);
} else {
const streamStatusCode = matches[1];
if (streamStatusCode !== "200") {
throw new Error("received status code: " + streamStatusCode);
}
}
} else {
onLog(msg);
}

chunk = await reader.read();
}
reader.cancel()

if (await checkIsDone()) {
return;
}
} catch(err) {
reader?.cancel().catch(console.debug);
await retry("error while listening to stream", err);
}
};
startWatchingLogs().catch(console.error);

return disposables;
}
89 changes: 89 additions & 0 deletions components/dashboard/src/components/WorkspaceLogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* Copyright (c) 2021 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License-AGPL.txt in the project root for license information.
*/

import EventEmitter from 'events';
import React from 'react';
import { Terminal, ITerminalOptions, ITheme } from 'xterm';
import { FitAddon } from 'xterm-addon-fit'
import 'xterm/css/xterm.css';
import { DisposableCollection } from '@gitpod/gitpod-protocol';

export interface WorkspaceLogsProps {
logsEmitter: EventEmitter;
errorMessage?: string;
classes?: string;
}

export interface WorkspaceLogsState {
}

export default class WorkspaceLogs extends React.Component<WorkspaceLogsProps, WorkspaceLogsState> {
protected xTermParentRef: React.RefObject<HTMLDivElement>;
protected terminal: Terminal | undefined;
protected fitAddon: FitAddon | undefined;

constructor(props: WorkspaceLogsProps) {
super(props);
this.xTermParentRef = React.createRef();
}

private readonly toDispose = new DisposableCollection();
componentDidMount() {
const element = this.xTermParentRef.current;
if (element === null) {
return;
}
const theme: ITheme = {};
const options: ITerminalOptions = {
cursorBlink: false,
disableStdin: true,
fontSize: 14,
theme,
scrollback: 9999999,
};
this.terminal = new Terminal(options);
this.fitAddon = new FitAddon();
this.terminal.loadAddon(this.fitAddon);
this.terminal.open(element);
this.props.logsEmitter.on('logs', logs => {
if (this.fitAddon && this.terminal && logs) {
this.terminal.write(logs);
}
});
this.toDispose.push(this.terminal);
this.fitAddon.fit();

// Fit terminal on window resize (debounced)
let timeout: NodeJS.Timeout | undefined;
const onWindowResize = () => {
clearTimeout(timeout!);
timeout = setTimeout(() => this.fitAddon!.fit(), 20);
};
window.addEventListener('resize', onWindowResize);
this.toDispose.push({
dispose: () => {
clearTimeout(timeout!);
window.removeEventListener('resize', onWindowResize);
}
});
}

componentDidUpdate() {
if (this.terminal && this.props.errorMessage) {
this.terminal.write(`\n\u001b[38;5;196m${this.props.errorMessage}\u001b[0m`);
}
}

componentWillUnmount() {
this.toDispose.dispose();
}

render() {
return <div className={`mt-6 ${this.props.classes || 'h-72 w-11/12 lg:w-3/5'} rounded-xl bg-black relative`}>
<div className="absolute top-0 left-0 bottom-0 right-0 m-6" ref={this.xTermParentRef}></div>
</div>;
}
}
Loading