From 1c96a7123d7c9d2f0658bf12e9dd75de8ac50ae2 Mon Sep 17 00:00:00 2001 From: elijahbenizzy Date: Mon, 4 Mar 2024 11:58:25 -0800 Subject: [PATCH] Integrates tracing with frontend --- telemetry/ui/package-lock.json | 9 + telemetry/ui/package.json | 1 + telemetry/ui/src/api/core/ApiError.ts | 28 +- .../ui/src/api/core/ApiRequestOptions.ts | 22 +- telemetry/ui/src/api/core/ApiResult.ts | 10 +- .../ui/src/api/core/CancelablePromise.ts | 221 ++++---- telemetry/ui/src/api/core/OpenAPI.ts | 36 +- telemetry/ui/src/api/core/request.ts | 495 +++++++++--------- telemetry/ui/src/api/index.ts | 3 + telemetry/ui/src/api/models/ActionModel.ts | 11 +- .../ui/src/api/models/ApplicationLogs.ts | 9 +- .../ui/src/api/models/ApplicationModel.ts | 9 +- .../ui/src/api/models/ApplicationSummary.ts | 11 +- .../ui/src/api/models/BeginEntryModel.ts | 10 +- telemetry/ui/src/api/models/BeginSpanModel.ts | 17 + telemetry/ui/src/api/models/EndEntryModel.ts | 14 +- telemetry/ui/src/api/models/EndSpanModel.ts | 14 + .../ui/src/api/models/HTTPValidationError.ts | 3 +- telemetry/ui/src/api/models/Project.ts | 13 +- telemetry/ui/src/api/models/Span.ts | 15 + telemetry/ui/src/api/models/Step.ts | 11 +- .../ui/src/api/models/TransitionModel.ts | 9 +- .../ui/src/api/models/ValidationError.ts | 7 +- .../ui/src/api/services/DefaultService.ts | 175 ++++--- telemetry/ui/src/components/common/dates.tsx | 11 + .../ui/src/components/routes/app/AppView.tsx | 4 +- .../src/components/routes/app/GraphView.tsx | 3 +- .../ui/src/components/routes/app/StepList.tsx | 300 +++++++++-- 28 files changed, 885 insertions(+), 586 deletions(-) create mode 100644 telemetry/ui/src/api/models/BeginSpanModel.ts create mode 100644 telemetry/ui/src/api/models/EndSpanModel.ts create mode 100644 telemetry/ui/src/api/models/Span.ts diff --git a/telemetry/ui/package-lock.json b/telemetry/ui/package-lock.json index 1798f018b..6639ef90c 100644 --- a/telemetry/ui/package-lock.json +++ b/telemetry/ui/package-lock.json @@ -25,6 +25,7 @@ "heroicons": "^2.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.0.1", "react-query": "^3.39.3", "react-router-dom": "^6.22.1", "react-scripts": "5.0.1", @@ -17056,6 +17057,14 @@ "resolved": "https://registry.npmjs.org/react-error-overlay/-/react-error-overlay-6.0.11.tgz", "integrity": "sha512-/6UZ2qgEyH2aqzYZgQPxEnz33NJ2gNsnHA2o5+o4wW9bLM/JYQitNP9xPhsXwC08hMMovfGe/8retsdDsczPRg==" }, + "node_modules/react-icons": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.0.1.tgz", + "integrity": "sha512-WqLZJ4bLzlhmsvme6iFdgO8gfZP17rfjYEJ2m9RsZjZ+cc4k1hTzknEz63YS1MeT50kVzoa1Nz36f4BEx+Wigw==", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", diff --git a/telemetry/ui/package.json b/telemetry/ui/package.json index 228dc1b78..eb4a21573 100644 --- a/telemetry/ui/package.json +++ b/telemetry/ui/package.json @@ -20,6 +20,7 @@ "heroicons": "^2.1.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-icons": "^5.0.1", "react-query": "^3.39.3", "react-router-dom": "^6.22.1", "react-scripts": "5.0.1", diff --git a/telemetry/ui/src/api/core/ApiError.ts b/telemetry/ui/src/api/core/ApiError.ts index 81f32a17b..d6b8fcc3a 100644 --- a/telemetry/ui/src/api/core/ApiError.ts +++ b/telemetry/ui/src/api/core/ApiError.ts @@ -6,20 +6,20 @@ import type { ApiRequestOptions } from './ApiRequestOptions'; import type { ApiResult } from './ApiResult'; export class ApiError extends Error { - public readonly url: string; - public readonly status: number; - public readonly statusText: string; - public readonly body: any; - public readonly request: ApiRequestOptions; + public readonly url: string; + public readonly status: number; + public readonly statusText: string; + public readonly body: any; + public readonly request: ApiRequestOptions; - constructor(request: ApiRequestOptions, response: ApiResult, message: string) { - super(message); + constructor(request: ApiRequestOptions, response: ApiResult, message: string) { + super(message); - this.name = 'ApiError'; - this.url = response.url; - this.status = response.status; - this.statusText = response.statusText; - this.body = response.body; - this.request = request; - } + this.name = 'ApiError'; + this.url = response.url; + this.status = response.status; + this.statusText = response.statusText; + this.body = response.body; + this.request = request; + } } diff --git a/telemetry/ui/src/api/core/ApiRequestOptions.ts b/telemetry/ui/src/api/core/ApiRequestOptions.ts index 4d59ceda2..c19adcc94 100644 --- a/telemetry/ui/src/api/core/ApiRequestOptions.ts +++ b/telemetry/ui/src/api/core/ApiRequestOptions.ts @@ -3,15 +3,15 @@ /* tslint:disable */ /* eslint-disable */ export type ApiRequestOptions = { - readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; - readonly url: string; - readonly path?: Record; - readonly cookies?: Record; - readonly headers?: Record; - readonly query?: Record; - readonly formData?: Record; - readonly body?: any; - readonly mediaType?: string; - readonly responseHeader?: string; - readonly errors?: Record; + readonly method: 'GET' | 'PUT' | 'POST' | 'DELETE' | 'OPTIONS' | 'HEAD' | 'PATCH'; + readonly url: string; + readonly path?: Record; + readonly cookies?: Record; + readonly headers?: Record; + readonly query?: Record; + readonly formData?: Record; + readonly body?: any; + readonly mediaType?: string; + readonly responseHeader?: string; + readonly errors?: Record; }; diff --git a/telemetry/ui/src/api/core/ApiResult.ts b/telemetry/ui/src/api/core/ApiResult.ts index 63ed6c447..ad8fef2bc 100644 --- a/telemetry/ui/src/api/core/ApiResult.ts +++ b/telemetry/ui/src/api/core/ApiResult.ts @@ -3,9 +3,9 @@ /* tslint:disable */ /* eslint-disable */ export type ApiResult = { - readonly url: string; - readonly ok: boolean; - readonly status: number; - readonly statusText: string; - readonly body: any; + readonly url: string; + readonly ok: boolean; + readonly status: number; + readonly statusText: string; + readonly body: any; }; diff --git a/telemetry/ui/src/api/core/CancelablePromise.ts b/telemetry/ui/src/api/core/CancelablePromise.ts index d96101be4..eb02246c3 100644 --- a/telemetry/ui/src/api/core/CancelablePromise.ts +++ b/telemetry/ui/src/api/core/CancelablePromise.ts @@ -3,128 +3,129 @@ /* tslint:disable */ /* eslint-disable */ export class CancelError extends Error { - constructor(message: string) { - super(message); - this.name = 'CancelError'; - } - - public get isCancelled(): boolean { - return true; - } + + constructor(message: string) { + super(message); + this.name = 'CancelError'; + } + + public get isCancelled(): boolean { + return true; + } } export interface OnCancel { - readonly isResolved: boolean; - readonly isRejected: boolean; - readonly isCancelled: boolean; + readonly isResolved: boolean; + readonly isRejected: boolean; + readonly isCancelled: boolean; - (cancelHandler: () => void): void; + (cancelHandler: () => void): void; } export class CancelablePromise implements Promise { - #isResolved: boolean; - #isRejected: boolean; - #isCancelled: boolean; - readonly #cancelHandlers: (() => void)[]; - readonly #promise: Promise; - #resolve?: (value: T | PromiseLike) => void; - #reject?: (reason?: any) => void; - - constructor( - executor: ( - resolve: (value: T | PromiseLike) => void, - reject: (reason?: any) => void, - onCancel: OnCancel - ) => void - ) { - this.#isResolved = false; - this.#isRejected = false; - this.#isCancelled = false; - this.#cancelHandlers = []; - this.#promise = new Promise((resolve, reject) => { - this.#resolve = resolve; - this.#reject = reject; - - const onResolve = (value: T | PromiseLike): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isResolved = true; - if (this.#resolve) this.#resolve(value); - }; + #isResolved: boolean; + #isRejected: boolean; + #isCancelled: boolean; + readonly #cancelHandlers: (() => void)[]; + readonly #promise: Promise; + #resolve?: (value: T | PromiseLike) => void; + #reject?: (reason?: any) => void; + + constructor( + executor: ( + resolve: (value: T | PromiseLike) => void, + reject: (reason?: any) => void, + onCancel: OnCancel + ) => void + ) { + this.#isResolved = false; + this.#isRejected = false; + this.#isCancelled = false; + this.#cancelHandlers = []; + this.#promise = new Promise((resolve, reject) => { + this.#resolve = resolve; + this.#reject = reject; + + const onResolve = (value: T | PromiseLike): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isResolved = true; + if (this.#resolve) this.#resolve(value); + }; + + const onReject = (reason?: any): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#isRejected = true; + if (this.#reject) this.#reject(reason); + }; + + const onCancel = (cancelHandler: () => void): void => { + if (this.#isResolved || this.#isRejected || this.#isCancelled) { + return; + } + this.#cancelHandlers.push(cancelHandler); + }; + + Object.defineProperty(onCancel, 'isResolved', { + get: (): boolean => this.#isResolved, + }); + + Object.defineProperty(onCancel, 'isRejected', { + get: (): boolean => this.#isRejected, + }); + + Object.defineProperty(onCancel, 'isCancelled', { + get: (): boolean => this.#isCancelled, + }); + + return executor(onResolve, onReject, onCancel as OnCancel); + }); + } - const onReject = (reason?: any): void => { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isRejected = true; - if (this.#reject) this.#reject(reason); - }; + get [Symbol.toStringTag]() { + return "Cancellable Promise"; + } - const onCancel = (cancelHandler: () => void): void => { + public then( + onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, + onRejected?: ((reason: any) => TResult2 | PromiseLike) | null + ): Promise { + return this.#promise.then(onFulfilled, onRejected); + } + + public catch( + onRejected?: ((reason: any) => TResult | PromiseLike) | null + ): Promise { + return this.#promise.catch(onRejected); + } + + public finally(onFinally?: (() => void) | null): Promise { + return this.#promise.finally(onFinally); + } + + public cancel(): void { if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; + return; } - this.#cancelHandlers.push(cancelHandler); - }; - - Object.defineProperty(onCancel, 'isResolved', { - get: (): boolean => this.#isResolved - }); - - Object.defineProperty(onCancel, 'isRejected', { - get: (): boolean => this.#isRejected - }); - - Object.defineProperty(onCancel, 'isCancelled', { - get: (): boolean => this.#isCancelled - }); - - return executor(onResolve, onReject, onCancel as OnCancel); - }); - } - - get [Symbol.toStringTag]() { - return 'Cancellable Promise'; - } - - public then( - onFulfilled?: ((value: T) => TResult1 | PromiseLike) | null, - onRejected?: ((reason: any) => TResult2 | PromiseLike) | null - ): Promise { - return this.#promise.then(onFulfilled, onRejected); - } - - public catch( - onRejected?: ((reason: any) => TResult | PromiseLike) | null - ): Promise { - return this.#promise.catch(onRejected); - } - - public finally(onFinally?: (() => void) | null): Promise { - return this.#promise.finally(onFinally); - } - - public cancel(): void { - if (this.#isResolved || this.#isRejected || this.#isCancelled) { - return; - } - this.#isCancelled = true; - if (this.#cancelHandlers.length) { - try { - for (const cancelHandler of this.#cancelHandlers) { - cancelHandler(); + this.#isCancelled = true; + if (this.#cancelHandlers.length) { + try { + for (const cancelHandler of this.#cancelHandlers) { + cancelHandler(); + } + } catch (error) { + console.warn('Cancellation threw an error', error); + return; + } } - } catch (error) { - console.warn('Cancellation threw an error', error); - return; - } + this.#cancelHandlers.length = 0; + if (this.#reject) this.#reject(new CancelError('Request aborted')); } - this.#cancelHandlers.length = 0; - if (this.#reject) this.#reject(new CancelError('Request aborted')); - } - public get isCancelled(): boolean { - return this.#isCancelled; - } + public get isCancelled(): boolean { + return this.#isCancelled; + } } diff --git a/telemetry/ui/src/api/core/OpenAPI.ts b/telemetry/ui/src/api/core/OpenAPI.ts index 21267971b..e357bb22c 100644 --- a/telemetry/ui/src/api/core/OpenAPI.ts +++ b/telemetry/ui/src/api/core/OpenAPI.ts @@ -8,25 +8,25 @@ type Resolver = (options: ApiRequestOptions) => Promise; type Headers = Record; export type OpenAPIConfig = { - BASE: string; - VERSION: string; - WITH_CREDENTIALS: boolean; - CREDENTIALS: 'include' | 'omit' | 'same-origin'; - TOKEN?: string | Resolver | undefined; - USERNAME?: string | Resolver | undefined; - PASSWORD?: string | Resolver | undefined; - HEADERS?: Headers | Resolver | undefined; - ENCODE_PATH?: ((path: string) => string) | undefined; + BASE: string; + VERSION: string; + WITH_CREDENTIALS: boolean; + CREDENTIALS: 'include' | 'omit' | 'same-origin'; + TOKEN?: string | Resolver | undefined; + USERNAME?: string | Resolver | undefined; + PASSWORD?: string | Resolver | undefined; + HEADERS?: Headers | Resolver | undefined; + ENCODE_PATH?: ((path: string) => string) | undefined; }; export const OpenAPI: OpenAPIConfig = { - BASE: '', - VERSION: '0.1.0', - WITH_CREDENTIALS: false, - CREDENTIALS: 'include', - TOKEN: undefined, - USERNAME: undefined, - PASSWORD: undefined, - HEADERS: undefined, - ENCODE_PATH: undefined + BASE: '', + VERSION: '0.1.0', + WITH_CREDENTIALS: false, + CREDENTIALS: 'include', + TOKEN: undefined, + USERNAME: undefined, + PASSWORD: undefined, + HEADERS: undefined, + ENCODE_PATH: undefined, }; diff --git a/telemetry/ui/src/api/core/request.ts b/telemetry/ui/src/api/core/request.ts index 3a5d724e6..2201a0e69 100644 --- a/telemetry/ui/src/api/core/request.ts +++ b/telemetry/ui/src/api/core/request.ts @@ -9,294 +9,278 @@ import { CancelablePromise } from './CancelablePromise'; import type { OnCancel } from './CancelablePromise'; import type { OpenAPIConfig } from './OpenAPI'; -export const isDefined = ( - value: T | null | undefined -): value is Exclude => { - return value !== undefined && value !== null; +export const isDefined = (value: T | null | undefined): value is Exclude => { + return value !== undefined && value !== null; }; export const isString = (value: any): value is string => { - return typeof value === 'string'; + return typeof value === 'string'; }; export const isStringWithValue = (value: any): value is string => { - return isString(value) && value !== ''; + return isString(value) && value !== ''; }; export const isBlob = (value: any): value is Blob => { - return ( - typeof value === 'object' && - typeof value.type === 'string' && - typeof value.stream === 'function' && - typeof value.arrayBuffer === 'function' && - typeof value.constructor === 'function' && - typeof value.constructor.name === 'string' && - /^(Blob|File)$/.test(value.constructor.name) && - /^(Blob|File)$/.test(value[Symbol.toStringTag]) - ); + return ( + typeof value === 'object' && + typeof value.type === 'string' && + typeof value.stream === 'function' && + typeof value.arrayBuffer === 'function' && + typeof value.constructor === 'function' && + typeof value.constructor.name === 'string' && + /^(Blob|File)$/.test(value.constructor.name) && + /^(Blob|File)$/.test(value[Symbol.toStringTag]) + ); }; export const isFormData = (value: any): value is FormData => { - return value instanceof FormData; + return value instanceof FormData; }; export const base64 = (str: string): string => { - try { - return btoa(str); - } catch (err) { - // @ts-ignore - return Buffer.from(str).toString('base64'); - } + try { + return btoa(str); + } catch (err) { + // @ts-ignore + return Buffer.from(str).toString('base64'); + } }; export const getQueryString = (params: Record): string => { - const qs: string[] = []; + const qs: string[] = []; - const append = (key: string, value: any) => { - qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); - }; + const append = (key: string, value: any) => { + qs.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + }; - const process = (key: string, value: any) => { - if (isDefined(value)) { - if (Array.isArray(value)) { - value.forEach((v) => { - process(key, v); - }); - } else if (typeof value === 'object') { - Object.entries(value).forEach(([k, v]) => { - process(`${key}[${k}]`, v); - }); - } else { - append(key, value); - } - } - }; + const process = (key: string, value: any) => { + if (isDefined(value)) { + if (Array.isArray(value)) { + value.forEach(v => { + process(key, v); + }); + } else if (typeof value === 'object') { + Object.entries(value).forEach(([k, v]) => { + process(`${key}[${k}]`, v); + }); + } else { + append(key, value); + } + } + }; - Object.entries(params).forEach(([key, value]) => { - process(key, value); - }); + Object.entries(params).forEach(([key, value]) => { + process(key, value); + }); - if (qs.length > 0) { - return `?${qs.join('&')}`; - } + if (qs.length > 0) { + return `?${qs.join('&')}`; + } - return ''; + return ''; }; const getUrl = (config: OpenAPIConfig, options: ApiRequestOptions): string => { - const encoder = config.ENCODE_PATH || encodeURI; - - const path = options.url - .replace('{api-version}', config.VERSION) - .replace(/{(.*?)}/g, (substring: string, group: string) => { - if (options.path?.hasOwnProperty(group)) { - return encoder(String(options.path[group])); - } - return substring; - }); + const encoder = config.ENCODE_PATH || encodeURI; + + const path = options.url + .replace('{api-version}', config.VERSION) + .replace(/{(.*?)}/g, (substring: string, group: string) => { + if (options.path?.hasOwnProperty(group)) { + return encoder(String(options.path[group])); + } + return substring; + }); - const url = `${config.BASE}${path}`; - if (options.query) { - return `${url}${getQueryString(options.query)}`; - } - return url; + const url = `${config.BASE}${path}`; + if (options.query) { + return `${url}${getQueryString(options.query)}`; + } + return url; }; export const getFormData = (options: ApiRequestOptions): FormData | undefined => { - if (options.formData) { - const formData = new FormData(); - - const process = (key: string, value: any) => { - if (isString(value) || isBlob(value)) { - formData.append(key, value); - } else { - formData.append(key, JSON.stringify(value)); - } - }; - - Object.entries(options.formData) - .filter(([_, value]) => isDefined(value)) - .forEach(([key, value]) => { - if (Array.isArray(value)) { - value.forEach((v) => process(key, v)); - } else { - process(key, value); - } - }); + if (options.formData) { + const formData = new FormData(); + + const process = (key: string, value: any) => { + if (isString(value) || isBlob(value)) { + formData.append(key, value); + } else { + formData.append(key, JSON.stringify(value)); + } + }; - return formData; - } - return undefined; + Object.entries(options.formData) + .filter(([_, value]) => isDefined(value)) + .forEach(([key, value]) => { + if (Array.isArray(value)) { + value.forEach(v => process(key, v)); + } else { + process(key, value); + } + }); + + return formData; + } + return undefined; }; type Resolver = (options: ApiRequestOptions) => Promise; -export const resolve = async ( - options: ApiRequestOptions, - resolver?: T | Resolver -): Promise => { - if (typeof resolver === 'function') { - return (resolver as Resolver)(options); - } - return resolver; +export const resolve = async (options: ApiRequestOptions, resolver?: T | Resolver): Promise => { + if (typeof resolver === 'function') { + return (resolver as Resolver)(options); + } + return resolver; }; -export const getHeaders = async ( - config: OpenAPIConfig, - options: ApiRequestOptions -): Promise => { - const [token, username, password, additionalHeaders] = await Promise.all([ - resolve(options, config.TOKEN), - resolve(options, config.USERNAME), - resolve(options, config.PASSWORD), - resolve(options, config.HEADERS) - ]); - - const headers = Object.entries({ - Accept: 'application/json', - ...additionalHeaders, - ...options.headers - }) - .filter(([_, value]) => isDefined(value)) - .reduce( - (headers, [key, value]) => ({ - ...headers, - [key]: String(value) - }), - {} as Record - ); +export const getHeaders = async (config: OpenAPIConfig, options: ApiRequestOptions): Promise => { + const [token, username, password, additionalHeaders] = await Promise.all([ + resolve(options, config.TOKEN), + resolve(options, config.USERNAME), + resolve(options, config.PASSWORD), + resolve(options, config.HEADERS), + ]); + + const headers = Object.entries({ + Accept: 'application/json', + ...additionalHeaders, + ...options.headers, + }) + .filter(([_, value]) => isDefined(value)) + .reduce((headers, [key, value]) => ({ + ...headers, + [key]: String(value), + }), {} as Record); + + if (isStringWithValue(token)) { + headers['Authorization'] = `Bearer ${token}`; + } + + if (isStringWithValue(username) && isStringWithValue(password)) { + const credentials = base64(`${username}:${password}`); + headers['Authorization'] = `Basic ${credentials}`; + } - if (isStringWithValue(token)) { - headers['Authorization'] = `Bearer ${token}`; - } - - if (isStringWithValue(username) && isStringWithValue(password)) { - const credentials = base64(`${username}:${password}`); - headers['Authorization'] = `Basic ${credentials}`; - } - - if (options.body) { - if (options.mediaType) { - headers['Content-Type'] = options.mediaType; - } else if (isBlob(options.body)) { - headers['Content-Type'] = options.body.type || 'application/octet-stream'; - } else if (isString(options.body)) { - headers['Content-Type'] = 'text/plain'; - } else if (!isFormData(options.body)) { - headers['Content-Type'] = 'application/json'; + if (options.body) { + if (options.mediaType) { + headers['Content-Type'] = options.mediaType; + } else if (isBlob(options.body)) { + headers['Content-Type'] = options.body.type || 'application/octet-stream'; + } else if (isString(options.body)) { + headers['Content-Type'] = 'text/plain'; + } else if (!isFormData(options.body)) { + headers['Content-Type'] = 'application/json'; + } } - } - return new Headers(headers); + return new Headers(headers); }; export const getRequestBody = (options: ApiRequestOptions): any => { - if (options.body !== undefined) { - if (options.mediaType?.includes('/json')) { - return JSON.stringify(options.body); - } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { - return options.body; - } else { - return JSON.stringify(options.body); + if (options.body !== undefined) { + if (options.mediaType?.includes('/json')) { + return JSON.stringify(options.body) + } else if (isString(options.body) || isBlob(options.body) || isFormData(options.body)) { + return options.body; + } else { + return JSON.stringify(options.body); + } } - } - return undefined; + return undefined; }; export const sendRequest = async ( - config: OpenAPIConfig, - options: ApiRequestOptions, - url: string, - body: any, - formData: FormData | undefined, - headers: Headers, - onCancel: OnCancel + config: OpenAPIConfig, + options: ApiRequestOptions, + url: string, + body: any, + formData: FormData | undefined, + headers: Headers, + onCancel: OnCancel ): Promise => { - const controller = new AbortController(); + const controller = new AbortController(); - const request: RequestInit = { - headers, - body: body ?? formData, - method: options.method, - signal: controller.signal - }; + const request: RequestInit = { + headers, + body: body ?? formData, + method: options.method, + signal: controller.signal, + }; - if (config.WITH_CREDENTIALS) { - request.credentials = config.CREDENTIALS; - } + if (config.WITH_CREDENTIALS) { + request.credentials = config.CREDENTIALS; + } - onCancel(() => controller.abort()); + onCancel(() => controller.abort()); - return await fetch(url, request); + return await fetch(url, request); }; -export const getResponseHeader = ( - response: Response, - responseHeader?: string -): string | undefined => { - if (responseHeader) { - const content = response.headers.get(responseHeader); - if (isString(content)) { - return content; +export const getResponseHeader = (response: Response, responseHeader?: string): string | undefined => { + if (responseHeader) { + const content = response.headers.get(responseHeader); + if (isString(content)) { + return content; + } } - } - return undefined; + return undefined; }; export const getResponseBody = async (response: Response): Promise => { - if (response.status !== 204) { - try { - const contentType = response.headers.get('Content-Type'); - if (contentType) { - const jsonTypes = ['application/json', 'application/problem+json']; - const isJSON = jsonTypes.some((type) => contentType.toLowerCase().startsWith(type)); - if (isJSON) { - return await response.json(); - } else { - return await response.text(); + if (response.status !== 204) { + try { + const contentType = response.headers.get('Content-Type'); + if (contentType) { + const jsonTypes = ['application/json', 'application/problem+json'] + const isJSON = jsonTypes.some(type => contentType.toLowerCase().startsWith(type)); + if (isJSON) { + return await response.json(); + } else { + return await response.text(); + } + } + } catch (error) { + console.error(error); } - } - } catch (error) { - console.error(error); } - } - return undefined; + return undefined; }; export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): void => { - const errors: Record = { - 400: 'Bad Request', - 401: 'Unauthorized', - 403: 'Forbidden', - 404: 'Not Found', - 500: 'Internal Server Error', - 502: 'Bad Gateway', - 503: 'Service Unavailable', - ...options.errors - }; - - const error = errors[result.status]; - if (error) { - throw new ApiError(options, result, error); - } - - if (!result.ok) { - const errorStatus = result.status ?? 'unknown'; - const errorStatusText = result.statusText ?? 'unknown'; - const errorBody = (() => { - try { - return JSON.stringify(result.body, null, 2); - } catch (e) { - return undefined; - } - })(); - - throw new ApiError( - options, - result, - `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` - ); - } + const errors: Record = { + 400: 'Bad Request', + 401: 'Unauthorized', + 403: 'Forbidden', + 404: 'Not Found', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + ...options.errors, + } + + const error = errors[result.status]; + if (error) { + throw new ApiError(options, result, error); + } + + if (!result.ok) { + const errorStatus = result.status ?? 'unknown'; + const errorStatusText = result.statusText ?? 'unknown'; + const errorBody = (() => { + try { + return JSON.stringify(result.body, null, 2); + } catch (e) { + return undefined; + } + })(); + + throw new ApiError(options, result, + `Generic Error: status: ${errorStatus}; status text: ${errorStatusText}; body: ${errorBody}` + ); + } }; /** @@ -306,36 +290,33 @@ export const catchErrorCodes = (options: ApiRequestOptions, result: ApiResult): * @returns CancelablePromise * @throws ApiError */ -export const request = ( - config: OpenAPIConfig, - options: ApiRequestOptions -): CancelablePromise => { - return new CancelablePromise(async (resolve, reject, onCancel) => { - try { - const url = getUrl(config, options); - const formData = getFormData(options); - const body = getRequestBody(options); - const headers = await getHeaders(config, options); - - if (!onCancel.isCancelled) { - const response = await sendRequest(config, options, url, body, formData, headers, onCancel); - const responseBody = await getResponseBody(response); - const responseHeader = getResponseHeader(response, options.responseHeader); - - const result: ApiResult = { - url, - ok: response.ok, - status: response.status, - statusText: response.statusText, - body: responseHeader ?? responseBody - }; - - catchErrorCodes(options, result); - - resolve(result.body); - } - } catch (error) { - reject(error); - } - }); +export const request = (config: OpenAPIConfig, options: ApiRequestOptions): CancelablePromise => { + return new CancelablePromise(async (resolve, reject, onCancel) => { + try { + const url = getUrl(config, options); + const formData = getFormData(options); + const body = getRequestBody(options); + const headers = await getHeaders(config, options); + + if (!onCancel.isCancelled) { + const response = await sendRequest(config, options, url, body, formData, headers, onCancel); + const responseBody = await getResponseBody(response); + const responseHeader = getResponseHeader(response, options.responseHeader); + + const result: ApiResult = { + url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: responseHeader ?? responseBody, + }; + + catchErrorCodes(options, result); + + resolve(result.body); + } + } catch (error) { + reject(error); + } + }); }; diff --git a/telemetry/ui/src/api/index.ts b/telemetry/ui/src/api/index.ts index f52c5b295..31abb01ac 100644 --- a/telemetry/ui/src/api/index.ts +++ b/telemetry/ui/src/api/index.ts @@ -12,9 +12,12 @@ export type { ApplicationLogs } from './models/ApplicationLogs'; export type { ApplicationModel } from './models/ApplicationModel'; export type { ApplicationSummary } from './models/ApplicationSummary'; export type { BeginEntryModel } from './models/BeginEntryModel'; +export type { BeginSpanModel } from './models/BeginSpanModel'; export type { EndEntryModel } from './models/EndEntryModel'; +export type { EndSpanModel } from './models/EndSpanModel'; export type { HTTPValidationError } from './models/HTTPValidationError'; export type { Project } from './models/Project'; +export type { Span } from './models/Span'; export type { Step } from './models/Step'; export type { TransitionModel } from './models/TransitionModel'; export type { ValidationError } from './models/ValidationError'; diff --git a/telemetry/ui/src/api/models/ActionModel.ts b/telemetry/ui/src/api/models/ActionModel.ts index 1d59e86cd..83c470dc2 100644 --- a/telemetry/ui/src/api/models/ActionModel.ts +++ b/telemetry/ui/src/api/models/ActionModel.ts @@ -6,9 +6,10 @@ * Pydantic model that represents an action for storing/visualization in the UI */ export type ActionModel = { - type?: string; - name: string; - reads: Array; - writes: Array; - code: string; + type?: string; + name: string; + reads: Array; + writes: Array; + code: string; }; + diff --git a/telemetry/ui/src/api/models/ApplicationLogs.ts b/telemetry/ui/src/api/models/ApplicationLogs.ts index 4cb774b25..cf0ec200a 100644 --- a/telemetry/ui/src/api/models/ApplicationLogs.ts +++ b/telemetry/ui/src/api/models/ApplicationLogs.ts @@ -4,7 +4,12 @@ /* eslint-disable */ import type { ApplicationModel } from './ApplicationModel'; import type { Step } from './Step'; +/** + * Application logs are purely flat -- + * we will likely be rethinking this but for now this provides for easy parsing. + */ export type ApplicationLogs = { - application: ApplicationModel; - steps: Array; + application: ApplicationModel; + steps: Array; }; + diff --git a/telemetry/ui/src/api/models/ApplicationModel.ts b/telemetry/ui/src/api/models/ApplicationModel.ts index 64b043cf4..dab273394 100644 --- a/telemetry/ui/src/api/models/ApplicationModel.ts +++ b/telemetry/ui/src/api/models/ApplicationModel.ts @@ -8,8 +8,9 @@ import type { TransitionModel } from './TransitionModel'; * Pydantic model that represents an application for storing/visualization in the UI */ export type ApplicationModel = { - type?: string; - entrypoint: string; - actions: Array; - transitions: Array; + type?: string; + entrypoint: string; + actions: Array; + transitions: Array; }; + diff --git a/telemetry/ui/src/api/models/ApplicationSummary.ts b/telemetry/ui/src/api/models/ApplicationSummary.ts index 71f3eadcb..ad867485d 100644 --- a/telemetry/ui/src/api/models/ApplicationSummary.ts +++ b/telemetry/ui/src/api/models/ApplicationSummary.ts @@ -3,9 +3,10 @@ /* tslint:disable */ /* eslint-disable */ export type ApplicationSummary = { - app_id: string; - first_written: string; - last_written: string; - num_steps: number; - tags: Record; + app_id: string; + first_written: string; + last_written: string; + num_steps: number; + tags: Record; }; + diff --git a/telemetry/ui/src/api/models/BeginEntryModel.ts b/telemetry/ui/src/api/models/BeginEntryModel.ts index 4c8bfccea..35cb6f563 100644 --- a/telemetry/ui/src/api/models/BeginEntryModel.ts +++ b/telemetry/ui/src/api/models/BeginEntryModel.ts @@ -6,8 +6,10 @@ * Pydantic model that represents an entry for the beginning of a step */ export type BeginEntryModel = { - type?: string; - start_time: string; - action: string; - inputs: Record; + type?: string; + start_time: string; + action: string; + inputs: Record; + sequence_id: number; }; + diff --git a/telemetry/ui/src/api/models/BeginSpanModel.ts b/telemetry/ui/src/api/models/BeginSpanModel.ts new file mode 100644 index 000000000..feb063c51 --- /dev/null +++ b/telemetry/ui/src/api/models/BeginSpanModel.ts @@ -0,0 +1,17 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Pydantic model that represents an entry for the beginning of a span + */ +export type BeginSpanModel = { + type?: string; + start_time: string; + action_sequence_id: number; + span_id: string; + span_name: string; + parent_span_id: (string | null); + span_dependencies: Array; +}; + diff --git a/telemetry/ui/src/api/models/EndEntryModel.ts b/telemetry/ui/src/api/models/EndEntryModel.ts index 1afe71f57..fc16b4e8e 100644 --- a/telemetry/ui/src/api/models/EndEntryModel.ts +++ b/telemetry/ui/src/api/models/EndEntryModel.ts @@ -6,10 +6,12 @@ * Pydantic model that represents an entry for the end of a step */ export type EndEntryModel = { - type?: string; - end_time: string; - action: string; - result: Record | null; - exception: string | null; - state: Record; + type?: string; + end_time: string; + action: string; + result: (Record | null); + exception: (string | null); + state: Record; + sequence_id: number; }; + diff --git a/telemetry/ui/src/api/models/EndSpanModel.ts b/telemetry/ui/src/api/models/EndSpanModel.ts new file mode 100644 index 000000000..6aa9093b6 --- /dev/null +++ b/telemetry/ui/src/api/models/EndSpanModel.ts @@ -0,0 +1,14 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +/** + * Pydantic model that represents an entry for the end of a span + */ +export type EndSpanModel = { + type?: string; + end_time: string; + action_sequence_id: number; + span_id: string; +}; + diff --git a/telemetry/ui/src/api/models/HTTPValidationError.ts b/telemetry/ui/src/api/models/HTTPValidationError.ts index 7c4d06370..892e4257c 100644 --- a/telemetry/ui/src/api/models/HTTPValidationError.ts +++ b/telemetry/ui/src/api/models/HTTPValidationError.ts @@ -4,5 +4,6 @@ /* eslint-disable */ import type { ValidationError } from './ValidationError'; export type HTTPValidationError = { - detail?: Array; + detail?: Array; }; + diff --git a/telemetry/ui/src/api/models/Project.ts b/telemetry/ui/src/api/models/Project.ts index 39e84821a..11cf77730 100644 --- a/telemetry/ui/src/api/models/Project.ts +++ b/telemetry/ui/src/api/models/Project.ts @@ -3,10 +3,11 @@ /* tslint:disable */ /* eslint-disable */ export type Project = { - name: string; - id: string; - uri: string; - last_written: string; - created: string; - num_apps: number; + name: string; + id: string; + uri: string; + last_written: string; + created: string; + num_apps: number; }; + diff --git a/telemetry/ui/src/api/models/Span.ts b/telemetry/ui/src/api/models/Span.ts new file mode 100644 index 000000000..0e91276f2 --- /dev/null +++ b/telemetry/ui/src/api/models/Span.ts @@ -0,0 +1,15 @@ +/* generated using openapi-typescript-codegen -- do no edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { BeginSpanModel } from './BeginSpanModel'; +import type { EndSpanModel } from './EndSpanModel'; +/** + * Represents a span. These have action sequence IDs associated with + * them to put them in order. + */ +export type Span = { + begin_entry: BeginSpanModel; + end_entry: (EndSpanModel | null); +}; + diff --git a/telemetry/ui/src/api/models/Step.ts b/telemetry/ui/src/api/models/Step.ts index 31a7911ca..071d0843a 100644 --- a/telemetry/ui/src/api/models/Step.ts +++ b/telemetry/ui/src/api/models/Step.ts @@ -4,8 +4,13 @@ /* eslint-disable */ import type { BeginEntryModel } from './BeginEntryModel'; import type { EndEntryModel } from './EndEntryModel'; +import type { Span } from './Span'; +/** + * Log of astep -- has a start and an end. + */ export type Step = { - step_start_log: BeginEntryModel; - step_end_log: EndEntryModel | null; - step_sequence_id: number; + step_start_log: BeginEntryModel; + step_end_log: (EndEntryModel | null); + spans: Array; }; + diff --git a/telemetry/ui/src/api/models/TransitionModel.ts b/telemetry/ui/src/api/models/TransitionModel.ts index 57f5915cb..fb66ce4a8 100644 --- a/telemetry/ui/src/api/models/TransitionModel.ts +++ b/telemetry/ui/src/api/models/TransitionModel.ts @@ -6,8 +6,9 @@ * Pydantic model that represents a transition for storing/visualization in the UI */ export type TransitionModel = { - type?: string; - from_: string; - to: string; - condition: string; + type?: string; + from_: string; + to: string; + condition: string; }; + diff --git a/telemetry/ui/src/api/models/ValidationError.ts b/telemetry/ui/src/api/models/ValidationError.ts index aed8da766..f2ff49a2b 100644 --- a/telemetry/ui/src/api/models/ValidationError.ts +++ b/telemetry/ui/src/api/models/ValidationError.ts @@ -3,7 +3,8 @@ /* tslint:disable */ /* eslint-disable */ export type ValidationError = { - loc: Array; - msg: string; - type: string; + loc: Array<(string | number)>; + msg: string; + type: string; }; + diff --git a/telemetry/ui/src/api/services/DefaultService.ts b/telemetry/ui/src/api/services/DefaultService.ts index 48e030c16..937479f4d 100644 --- a/telemetry/ui/src/api/services/DefaultService.ts +++ b/telemetry/ui/src/api/services/DefaultService.ts @@ -9,75 +9,108 @@ import type { CancelablePromise } from '../core/CancelablePromise'; import { OpenAPI } from '../core/OpenAPI'; import { request as __request } from '../core/request'; export class DefaultService { - /** - * Get Projects - * Gets all projects visible by the user. - * - * :param request: FastAPI request - * :return: a list of projects visible by the user - * @returns Project Successful Response - * @throws ApiError - */ - public static getProjectsApiV0ProjectsGet(): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v0/projects' - }); - } - /** - * Get Apps - * Gets all apps visible by the user - * - * :param request: FastAPI request - * :param project_id: project name - * :return: a list of projects visible by the user - * @param projectId - * @returns ApplicationSummary Successful Response - * @throws ApiError - */ - public static getAppsApiV0ProjectIdAppsGet( - projectId: string - ): CancelablePromise> { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v0/{project_id}/apps', - path: { - project_id: projectId - }, - errors: { - 422: `Validation Error` - } - }); - } - /** - * Get Application Logs - * Lists steps for a given App. - * TODO: add streaming capabilities for bi-directional communication - * TODO: add pagination for quicker loading - * - * :param request: FastAPI - * :param project_id: ID of the project - * :param app_id: ID of the associated application - * :return: A list of steps with all associated step data - * @param projectId - * @param appId - * @returns ApplicationLogs Successful Response - * @throws ApiError - */ - public static getApplicationLogsApiV0ProjectIdAppIdAppsGet( - projectId: string, - appId: string - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/v0/{project_id}/{app_id}/apps', - path: { - project_id: projectId, - app_id: appId - }, - errors: { - 422: `Validation Error` - } - }); - } + /** + * Get Projects + * Gets all projects visible by the user. + * + * :param request: FastAPI request + * :return: a list of projects visible by the user + * @returns Project Successful Response + * @throws ApiError + */ + public static getProjectsApiV0ProjectsGet(): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v0/projects', + }); + } + /** + * Get Apps + * Gets all apps visible by the user + * + * :param request: FastAPI request + * :param project_id: project name + * :return: a list of projects visible by the user + * @param projectId + * @returns ApplicationSummary Successful Response + * @throws ApiError + */ + public static getAppsApiV0ProjectIdAppsGet( + projectId: string, + ): CancelablePromise> { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v0/{project_id}/apps', + path: { + 'project_id': projectId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Get Application Logs + * Lists steps for a given App. + * TODO: add streaming capabilities for bi-directional communication + * TODO: add pagination for quicker loading + * + * :param request: FastAPI + * :param project_id: ID of the project + * :param app_id: ID of the associated application + * :return: A list of steps with all associated step data + * @param projectId + * @param appId + * @returns ApplicationLogs Successful Response + * @throws ApiError + */ + public static getApplicationLogsApiV0ProjectIdAppIdAppsGet( + projectId: string, + appId: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v0/{project_id}/{app_id}/apps', + path: { + 'project_id': projectId, + 'app_id': appId, + }, + errors: { + 422: `Validation Error`, + }, + }); + } + /** + * Ready + * @returns boolean Successful Response + * @throws ApiError + */ + public static readyApiV0ReadyGet(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/api/v0/ready', + }); + } + /** + * React App + * Quick trick to server the react app + * Thanks to https://github.com/hop-along-polly/fastapi-webapp-react for the example/demo + * @param restOfPath + * @returns any Successful Response + * @throws ApiError + */ + public static reactAppRestOfPathGet( + restOfPath: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/{rest_of_path}', + path: { + 'rest_of_path': restOfPath, + }, + errors: { + 422: `Validation Error`, + }, + }); + } } diff --git a/telemetry/ui/src/components/common/dates.tsx b/telemetry/ui/src/components/common/dates.tsx index 19e442fe2..cb8c41684 100644 --- a/telemetry/ui/src/components/common/dates.tsx +++ b/telemetry/ui/src/components/common/dates.tsx @@ -28,6 +28,17 @@ export const DateTimeDisplay: React.FC<{ date: string; mode: 'short' | 'long' }> return {displayDateTime}; }; +export const TimeDisplay: React.FC<{ date: string }> = ({ date }) => { + const displayTime = new Date(date).toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + second: 'numeric', + hour12: true // Use AM/PM format. Set to false for 24-hour format. + }); + + return {displayTime}; +}; + /** * Formats a duration in a human-readable format. * Works for durations in milliseconds, seconds, and microseconds, diff --git a/telemetry/ui/src/components/routes/app/AppView.tsx b/telemetry/ui/src/components/routes/app/AppView.tsx index ac18bee74..87e161887 100644 --- a/telemetry/ui/src/components/routes/app/AppView.tsx +++ b/telemetry/ui/src/components/routes/app/AppView.tsx @@ -94,7 +94,7 @@ export const AppView = () => { const steps = data?.steps || []; const handleKeyDown = (event: KeyboardEvent) => { switch (event.key) { - case 'ArrowUp': + case 'ArrowDown': setCurrentActionIndex((prevIndex) => { if (prevIndex === undefined || prevIndex <= 0) { return 0; @@ -103,7 +103,7 @@ export const AppView = () => { } }); break; - case 'ArrowDown': + case 'ArrowUp': setCurrentActionIndex((prevIndex) => { if (prevIndex === undefined) { return 0; diff --git a/telemetry/ui/src/components/routes/app/GraphView.tsx b/telemetry/ui/src/components/routes/app/GraphView.tsx index aa4f4a71c..b62bf987b 100644 --- a/telemetry/ui/src/components/routes/app/GraphView.tsx +++ b/telemetry/ui/src/components/routes/app/GraphView.tsx @@ -60,8 +60,7 @@ const ActionNode = (props: { data: NodeData }) => { hoverAction, currentAction } = React.useContext(NodeStateProvider); - // const bgColor = highlightedActions?.includes(props.data.action.name) ? 'bg-dwlightblue' : ''; - const highlightedActions = [currentAction, ...(previousActions || [])]; + const highlightedActions = [currentAction, ...(previousActions || [])].reverse(); const indexOfAction = highlightedActions.findIndex( (step) => step?.step_start_log.action === props.data.action.name ); diff --git a/telemetry/ui/src/components/routes/app/StepList.tsx b/telemetry/ui/src/components/routes/app/StepList.tsx index 9b99653fb..d670da461 100644 --- a/telemetry/ui/src/components/routes/app/StepList.tsx +++ b/telemetry/ui/src/components/routes/app/StepList.tsx @@ -1,9 +1,13 @@ -import { Step } from '../../../api'; +import { Span, Step } from '../../../api'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '../../common/table'; -import { DateTimeDisplay, DurationDisplay } from '../../common/dates'; +import { DateTimeDisplay, DurationDisplay, TimeDisplay } from '../../common/dates'; import { backgroundColorsForIndex } from './AppView'; import { Status, getActionStatus } from '../../../utils'; import { Chip } from '../../common/chip'; +import { useState } from 'react'; +import { MinusCircleIcon, PlusCircleIcon } from '@heroicons/react/24/outline'; + +import { RiCornerDownRightLine } from 'react-icons/ri'; /** * Quick display to suggest that the action is still running @@ -47,9 +51,6 @@ const AutoRefreshSwitch = (props: {
); }; +/** + * Quick component to make the table row common between + * the action and span rows + * @param props + * @returns + */ +const CommonTableRow = (props: { + children: React.ReactNode; + sequenceID: number; + isHovered: boolean; + shouldBeHighlighted: boolean; + currentSelectedIndex: number | undefined; + step: Step; + setCurrentHoverIndex: (index?: number) => void; + setCurrentSelectedIndex: (index?: number) => void; +}) => { + return ( + { + props.setCurrentHoverIndex(props.sequenceID); + }} + onClick={() => { + if (props.currentSelectedIndex == props.sequenceID) { + props.setCurrentSelectedIndex(undefined); + } else { + props.setCurrentSelectedIndex(props.sequenceID); + } + }} + > + {props.children} + + ); +}; + +const ActionTableRow = (props: { + step: Step; + currentHoverIndex: number | undefined; + setCurrentHoverIndex: (index?: number) => void; + currentSelectedIndex: number | undefined; + setCurrentSelectedIndex: (index?: number) => void; + numPriorIndices: number; + isExpanded: boolean; + toggleExpanded: (index: number) => void; +}) => { + const sequenceID = props.step.step_start_log.sequence_id; + const isHovered = props.currentHoverIndex === sequenceID; + const spanCount = props.step.spans.length; + const shouldBeHighlighted = + props.currentSelectedIndex !== undefined && + sequenceID <= props.currentSelectedIndex && + sequenceID >= props.currentSelectedIndex - props.numPriorIndices; + const ExpandIcon = props.isExpanded ? MinusCircleIcon : PlusCircleIcon; + return ( + + {sequenceID} + {props.step.step_start_log.action} + +
+ +
+
+ + + + + {spanCount > 0 ? ( +
+ { + props.toggleExpanded(sequenceID); + e.stopPropagation(); + }} + /> + {spanCount} +
+ ) : ( + + )} +
+ +
+ +
+
+
+ ); +}; + +const TraceSubTable = (props: { + spans: Span[]; + step: Step; + currentHoverIndex: number | undefined; + setCurrentHoverIndex: (index?: number) => void; + currentSelectedIndex: number | undefined; + setCurrentSelectedIndex: (index?: number) => void; + numPriorIndices: number; +}) => { + return ( + <> + {props.spans.map((span) => { + // This is a quick implementation for prototyping -- we will likely switch this up + // This assumes that the span UID is of the form "actionID:spanID.spanID.spanID..." + // Which is currently the case + const spanIDUniqueToAction = span.begin_entry.span_id.split(':')[1]; + const depth = spanIDUniqueToAction.split('.').length; + const sequenceID = props.step.step_start_log.sequence_id; + const isHovered = props.currentHoverIndex === sequenceID; + const shouldBeHighlighted = + props.currentSelectedIndex !== undefined && + sequenceID <= props.currentSelectedIndex && + sequenceID >= props.currentSelectedIndex - props.numPriorIndices; + console.log(depth, Array(depth)); + return ( + + {spanIDUniqueToAction} + + +
+ {[...Array(depth).keys()].map((i) => ( + + ))} + {span.begin_entry.span_name} +
+
+ +
+ +
+
+ + + + + {/* + {span.begin_entry.span_name} + */} +
+ ); + })} + + ); +}; /** * Table with a list of steps. @@ -88,14 +265,54 @@ export const StepList = (props: { autoRefresh: boolean; setAutoRefresh: (b: boolean) => void; }) => { + // This is a quick way of expanding the actions all at once + const [expandedActions, setExpandedActions] = useState([]); + const toggleExpanded = (index: number) => { + if (expandedActions.includes(index)) { + setExpandedActions(expandedActions.filter((i) => i !== index)); + } else { + setExpandedActions([...expandedActions, index]); + } + }; + const [intentionExpandAll, setIntentionExpandAll] = useState(false); + const ExpandAllIcon = intentionExpandAll ? MinusCircleIcon : PlusCircleIcon; + const expandAll = () => { + const allIndices = props.steps.map((step) => step.step_start_log.sequence_id); + setExpandedActions(allIndices); + }; + const toggleExpandAll = () => { + if (intentionExpandAll) { + setExpandedActions([]); + } else { + expandAll(); + } + setIntentionExpandAll(!intentionExpandAll); + }; + const isExpanded = (index: number) => { + return expandedActions.includes(index); + }; return ( - + Action - Ran + + Ran + Duration + +
+ { + toggleExpandAll(); + e.stopPropagation(); + }} + /> + Spans +
+
- {props.steps.map((step, i) => { - const isHovered = i === props.currentHoverIndex; - const shouldBeHighlighted = - props.currentSelectedIndex !== undefined && - i >= props.currentSelectedIndex && - i <= props.currentSelectedIndex + props.numPriorIndices; - + {props.steps.map((step) => { return ( - { - props.setCurrentHoverIndex(i); - }} - onClick={() => { - if (props.currentSelectedIndex == i) { - props.setCurrentSelectedIndex(undefined); - } else { - props.setCurrentSelectedIndex(i); - } - }}> - {step.step_sequence_id} - - {step.step_start_log.action} - - - - - - + + {isExpanded(step.step_start_log.sequence_id) && ( + - - -
- -
-
-
+ )} + ); })}