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

feat: Web UI migration to REST API #1950

Open
wants to merge 13 commits into
base: main
Choose a base branch
from
Open
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
2 changes: 2 additions & 0 deletions enclave-manager/web/packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
"js-cookie": "^3.0.5",
"kurtosis-cloud-indexer-sdk": "^0.0.2",
"kurtosis-ui-components": "0.86.2",
"openapi-fetch": "^0.8.2",
"openapi-typescript-helpers": "^0.0.5",
"react-error-boundary": "^4.0.11",
"react-hook-form": "^7.47.0",
"yaml": "^2.3.4"
Expand Down
8 changes: 8 additions & 0 deletions enclave-manager/web/packages/app/src/client/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,11 @@ export const KURTOSIS_DEFAULT_EM_API_PORT = isDefined(process.env.REACT_APP_KURT
: 8081;
export const KURTOSIS_EM_API_DEFAULT_URL =
process.env.REACT_APP_KURTOSIS_DEFAULT_URL || `http://${KURTOSIS_EM_DEFAULT_HOST}:${KURTOSIS_DEFAULT_EM_API_PORT}`;

// REST API
export const KURTOSIS_REST_API_DEFAULT_HOST = "engine." + (process.env.REACT_APP_KURTOSIS_DEFAULT_HOST || "localhost");
export const KURTOSIS_DEFAULT_REST_API_PORT = isDefined(process.env.REACT_APP_KURTOSIS_DEFAULT_REST_API_PORT)
? parseInt(process.env.REACT_APP_KURTOSIS_DEFAULT_REST_API_PORT)
: 9779;
export const KURTOSIS_REST_API_DEFAULT_URL = `http://${KURTOSIS_REST_API_DEFAULT_HOST}:${KURTOSIS_DEFAULT_REST_API_PORT}/api`;
export const KURTOSIS_WEBSOCKET_API_DEFAULT_URL = KURTOSIS_REST_API_DEFAULT_URL.replace(/^http/, "ws");
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import { createPromiseClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { KurtosisEnclaveManagerServer } from "enclave-manager-sdk/build/kurtosis_enclave_manager_api_connect";
import { paths } from "kurtosis-sdk/src/engine/rest_api_bindings/types";
import { DateTime } from "luxon";
import { KURTOSIS_CLOUD_EM_URL, KURTOSIS_CLOUD_UI_URL, KURTOSIS_DEFAULT_EM_API_PORT } from "../constants";
import createClient from "openapi-fetch";
import {
KURTOSIS_CLOUD_EM_URL,
KURTOSIS_CLOUD_UI_URL,
KURTOSIS_DEFAULT_EM_API_PORT,
KURTOSIS_DEFAULT_REST_API_PORT,
} from "../constants";
import { KurtosisClient } from "./KurtosisClient";
import { createWSClient } from "./websocketClient/WebSocketClient";

function constructGatewayURL(remoteHost: string): string {
return `${KURTOSIS_CLOUD_UI_URL}/gateway/ips/${remoteHost}/ports/${KURTOSIS_DEFAULT_EM_API_PORT}`;
}

function constructRESTGatewayURL(remoteHost: string): string {
return `${KURTOSIS_CLOUD_UI_URL}/gateway/ips/${remoteHost}/ports/${KURTOSIS_DEFAULT_REST_API_PORT}`;
}

function constructWSGatewayURL(remoteHost: string): string {
return constructRESTGatewayURL(remoteHost).replace(/^http/, "ws");
}

export class AuthenticatedKurtosisClient extends KurtosisClient {
private readonly token: string;
private readonly tokenExpiry: DateTime;
Expand All @@ -19,6 +35,8 @@ export class AuthenticatedKurtosisClient extends KurtosisClient {
KurtosisEnclaveManagerServer,
createConnectTransport({ baseUrl: constructGatewayURL(gatewayHost) }),
),
createClient<paths>({ baseUrl: constructRESTGatewayURL(gatewayHost) }),
createWSClient<paths>({ baseUrl: constructWSGatewayURL(gatewayHost) }),
parentUrl,
childUrl,
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,20 @@ import {
InspectFilesArtifactContentsRequest,
RunStarlarkPackageRequest,
} from "enclave-manager-sdk/build/kurtosis_enclave_manager_api_pb";
import { components, paths } from "kurtosis-sdk/src/engine/rest_api_bindings/types";
import { assertDefined, asyncResult, isDefined, RemoveFunctions } from "kurtosis-ui-components";
import createClient from "openapi-fetch";
import { EnclaveFullInfo } from "../../emui/enclaves/types";
import { createWSClient } from "./websocketClient/WebSocketClient";

type KurtosisRestClient = ReturnType<typeof createClient<paths>>;
type KurtosisWebsocketClient = ReturnType<typeof createWSClient<paths>>;
export type KurtosisRestApiTypes = components["schemas"];

export abstract class KurtosisClient {
protected readonly client: PromiseClient<typeof KurtosisEnclaveManagerServer>;
protected readonly restClient: KurtosisRestClient;
protected readonly websocketClient: KurtosisWebsocketClient;

/* Full URL of the browser containing the EM UI covering two use cases:
* In local-mode this is: http://localhost:9711, http://localhost:3000 (with `yarn start` / dev mode)
Expand All @@ -45,8 +54,16 @@ export abstract class KurtosisClient {
* */
protected readonly baseApplicationUrl: URL;

constructor(client: PromiseClient<typeof KurtosisEnclaveManagerServer>, parentUrl: URL, childUrl: URL) {
constructor(
client: PromiseClient<typeof KurtosisEnclaveManagerServer>,
restClient: KurtosisRestClient,
websocketClient: KurtosisWebsocketClient,
parentUrl: URL,
childUrl: URL,
) {
this.client = client;
this.restClient = restClient;
this.websocketClient = websocketClient;
this.cloudUrl = parentUrl;
this.baseApplicationUrl = childUrl;
this.getParentRequestedRoute();
Expand All @@ -73,9 +90,47 @@ export abstract class KurtosisClient {
}

async checkHealth() {
console.log(await this.restClient.GET("/engine/info"));
return asyncResult(this.client.check({}, this.getHeaderOptions()));
}

async *getServiceLogsWS(
abortController: AbortController,
enclave: RemoveFunctions<EnclaveFullInfo>,
serviceUUID: string,
followLogs?: boolean,
numLogLines?: number,
returnAllLogs?: boolean,
conjunctiveFilters?: LogLineFilter[],
): AsyncGenerator<KurtosisRestApiTypes["ServiceLogs"]> {
// TODO (edgar) do proper filter conversion
// const filters: KurtosisRestApiTypes["LogLineFilter"][] = conjunctiveFilters!.map(x => {return {operator: x.operator, text_pattern: x.textPattern};});
const logs = this.websocketClient.WS("/enclaves/{enclave_identifier}/services/{service_identifier}/logs", {
params: {
path: {
enclave_identifier: enclave.enclaveUuid,
service_identifier: serviceUUID,
},
query: {
follow_logs: followLogs,
num_log_lines: numLogLines,
return_all_logs: returnAllLogs,
// conjunctive_filters: filters
},
},
abortSignal: abortController.signal,
});

for await (const lineGroup of logs) {
if (lineGroup.error) {
return;
}
if (lineGroup.data) {
yield lineGroup.data;
}
}
}

async getEnclaves() {
return asyncResult(this.client.getEnclaves({}, this.getHeaderOptions()), "KurtosisClient could not getEnclaves");
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { createPromiseClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { KurtosisEnclaveManagerServer } from "enclave-manager-sdk/build/kurtosis_enclave_manager_api_connect";
import { KURTOSIS_EM_API_DEFAULT_URL } from "../constants";
import { paths } from "kurtosis-sdk/src/engine/rest_api_bindings/types";
import createClient from "openapi-fetch";
import {
KURTOSIS_EM_API_DEFAULT_URL,
KURTOSIS_REST_API_DEFAULT_URL,
KURTOSIS_WEBSOCKET_API_DEFAULT_URL,
} from "../constants";
import { KurtosisClient } from "./KurtosisClient";
import { createWSClient } from "./websocketClient/WebSocketClient";

export class LocalKurtosisClient extends KurtosisClient {
constructor() {
Expand All @@ -12,6 +19,8 @@ export class LocalKurtosisClient extends KurtosisClient {
KurtosisEnclaveManagerServer,
createConnectTransport({ baseUrl: KURTOSIS_EM_API_DEFAULT_URL }),
),
createClient<paths>({ baseUrl: KURTOSIS_REST_API_DEFAULT_URL }),
createWSClient<paths>({ baseUrl: KURTOSIS_WEBSOCKET_API_DEFAULT_URL }),
defaultUrl,
defaultUrl,
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import type {
ErrorResponse,
FilterKeys,
MediaType,
PathsWithMethod,
ResponseObjectMap,
SuccessResponse,
} from "openapi-typescript-helpers";

import type { ParamsOption, QuerySerializer } from "openapi-fetch";

import { createFinalURL, defaultQuerySerializer } from "openapi-fetch";

export interface ClientOptions {
baseUrl?: string;
}

export type RequestOptions<T> = ParamsOption<T> & {
querySerializer?: QuerySerializer<T>;
abortSignal?: AbortSignal;
};

type ReturnType<Paths extends {}, P extends PathsWithMethod<Paths, "get">> = "get" extends infer T
? T extends "get"
? T extends keyof Paths[P]
? Paths[P][T]
: unknown
: never
: never;

type ParamsType<Paths extends {}, P extends PathsWithMethod<Paths, "get">> = RequestOptions<
FilterKeys<Paths[P], "get">
>;

export type MessageResponse<T> =
| {
data: FilterKeys<SuccessResponse<ResponseObjectMap<T>>, MediaType>;
error?: never;
message: MessageEvent<any>;
}
| {
data?: never;
error: FilterKeys<ErrorResponse<ResponseObjectMap<T>>, MediaType>;
message: MessageEvent<any>;
};

// This implementation is based on the http version of the lib `openapi-fetch`
// https://github.com/drwpow/openapi-typescript/blob/main/packages/openapi-fetch/src/index.d.ts
export function createWSClient<Paths extends {}>(
clientOptions?: ClientOptions,
): {
WS: <P extends PathsWithMethod<Paths, "get">>(
url: P,
...init: ParamsType<Paths, P>[]
) => AsyncGenerator<MessageResponse<ReturnType<Paths, P>>>;
} {
var baseUrl = clientOptions?.baseUrl ?? "";
if (baseUrl.endsWith("/")) {
baseUrl = baseUrl.slice(0, -1); // remove trailing slash
}

return {
/** Call a WS endpoint */
WS: async function* (url, fetchOptions) {
const { params = {}, querySerializer = defaultQuerySerializer, abortSignal } = fetchOptions || {};

// build full URL
const finalURL = createFinalURL(url.toString(), {
baseUrl,
params,
querySerializer,
});

var socket: WebSocket;
var isWSPaused = false;
var controller = new AbortController();

// Create and wait for the WebSocket connection to be open
async function wsConnect(): Promise<WebSocket> {
return new Promise((resolve, reject) => {
try {
let newSocket = new WebSocket(finalURL);
newSocket.addEventListener("open", () => resolve(newSocket));
} catch (error) {
reject(error);
}
});
}

// Start the connection for the first time
socket = await wsConnect();

// Handle client side request to abort (via abort signal)
if (abortSignal) {
// already aborted, fail immediately
if (abortSignal.aborted) {
console.warn(`Websocket on ${finalURL} got aborted before using. Closing it.`);
socket.close();
}

// close later if aborted
abortSignal.addEventListener("abort", () => {
console.warn(`Websocket on ${finalURL} has been asked to abort. Closing it.`);
socket.close();
});
}

let reconnectHandler = async () => {
if (!abortSignal?.aborted) {
console.warn("Websocket connection unexpectedly closed, reconnecting");
await wsConnect().then((ws) => {
socket = ws;
});
controller.abort();
controller = new AbortController();
isWSPaused = false;
}
};

// reconnect on unexpected close (i.e. abortSignal not aborted)
socket.addEventListener("close", reconnectHandler);

// // Pause and resume WS connection when
// document.addEventListener("visibilitychange", async () => {
// if (document.hidden) {
// console.debug("Lost focus on web UI. Closing Websocket connection to save resources, resuming later");
// isWSPaused = true;
// socket.close();
// } else {
// console.debug("Resuming Websocket connection");
// reconnectHandler();
// }
// });

// Abortable message listener used to exit the message promise in case the connection
// in broken and we replace with a new connection
function waitForMsg(signal: AbortSignal): Promise<MessageEvent<any>> {
if (signal.aborted) {
return Promise.reject("Signal already aborted");
}
return new Promise((resolve, reject) => {
socket.addEventListener("message", (event: MessageEvent<any>) => resolve(event));
// Listen for abort event on signal
signal.addEventListener("abort", () => {
reject("Received signal to aborted");
});
});
}

// the async loop generator
try {
let message: MessageEvent<any> | undefined;
// the isWSPaused keep the loop active even if the connection got closed. The loop
// only exit if it's not paused and the connection is closed.
while (isWSPaused || socket.readyState === WebSocket.OPEN) {
// Wait for the next message and skip if gets an undefined message (i.e. aborted message await)
message = await waitForMsg(controller.signal).catch((_) => undefined);
if (message === undefined) {
continue;
}
// Yield the received message
yield { error: undefined, data: JSON.parse(message.data), message: message };
}
} catch (error) {
console.error(`Received an unexpected message from the channel on ${finalURL}:`);
console.error(error);
} finally {
// Final close. But let's remove the reconnection handler first
socket.removeEventListener("close", reconnectHandler);
socket.close();
}
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -43,19 +43,19 @@ export const ServiceLogs = ({ enclave, service }: ServiceLogsProps) => {
const handleGetAllLogs = useCallback(
async function* () {
const abortController = new AbortController();
const logs = await kurtosisClient.getServiceLogs(abortController, enclave, [service], false, 0, true);
const logs = kurtosisClient.getServiceLogsWS(abortController, enclave, service.serviceUuid, false, 0, true);
try {
for await (const lineGroup of logs) {
const lineGroupForService = lineGroup.serviceLogsByServiceUuid[service.serviceUuid];
const lineGroupForService = lineGroup.service_logs_by_service_uuid![service.serviceUuid];
assertDefined(
lineGroupForService,
`Log line response included a line group withouth service ${
service.serviceUuid
}: ${lineGroup.toJsonString()}`,
`Log line response included a line group without service ${service.serviceUuid}: ${JSON.stringify(
lineGroup,
)}`,
);
const parsedLogLines = serviceLogLineToLogLineMessage(
lineGroupForService.line,
lineGroupForService.timestamp,
Timestamp.fromDate(new Date(lineGroupForService.timestamp)),
);
yield parsedLogLines.map((line) => line.message || "").join("\n");
}
Expand All @@ -75,11 +75,19 @@ export const ServiceLogs = ({ enclave, service }: ServiceLogsProps) => {
if (isRetry) setLogLines([]);
console.info("Created a new logging stream");
try {
for await (const lineGroup of await kurtosisClient.getServiceLogs(abortController, enclave, [service])) {
for await (const lineGroup of kurtosisClient.getServiceLogsWS(
abortController,
enclave,
service.serviceUuid,
true,
)) {
if (canceled) return;
const lineGroupForService = lineGroup.serviceLogsByServiceUuid[service.serviceUuid];
const lineGroupForService = lineGroup.service_logs_by_service_uuid![service.serviceUuid];
if (!isDefined(lineGroupForService)) continue;
const parsedLines = serviceLogLineToLogLineMessage(lineGroupForService.line, lineGroupForService.timestamp);
const parsedLines = serviceLogLineToLogLineMessage(
lineGroupForService.line,
Timestamp.fromDate(new Date(lineGroupForService.timestamp)),
);
setLogLines((logLines) => [...logLines, ...parsedLines]);
}
} catch (error: any) {
Expand Down
Loading
Loading