diff --git a/src/components/menus/ExtensionMenu.svelte b/src/components/menus/ExtensionMenu.svelte new file mode 100644 index 0000000000..ad4f691e52 --- /dev/null +++ b/src/components/menus/ExtensionMenu.svelte @@ -0,0 +1,88 @@ + + + + +{#if extensions.length > 0} +
+ + +
+ {#each extensions as extension} + callExtension(extension)} + use={[ + [ + permissionHandler, + { + hasPermission: hasExtensionPermission(extension), + permissionError: 'You do not have permission to call this extension', + }, + ], + ]} + > +
+ {extension.label} + {formatDescription(extension.description)} +
+
+ {/each} +
+
+
+{/if} + + diff --git a/src/routes/extensions/+server.ts b/src/routes/extensions/+server.ts new file mode 100644 index 0000000000..cf47a7207b --- /dev/null +++ b/src/routes/extensions/+server.ts @@ -0,0 +1,22 @@ +import { json, type RequestHandler } from '@sveltejs/kit'; +import type { ExtensionResponse } from '../../types/extension'; +import { reqExtension } from '../../utilities/requests'; + +/** + * Used to proxy requests from the UI to an external extension. This avoids any CORS errors we might + * encounter by calling a tool that may or may not be external to Aerie. + */ +export const POST: RequestHandler = async event => { + const { url, ...body } = await event.request.json(); + const response = await reqExtension(url, body, event.locals.user); + + if (isExtensionResponse(response)) { + return json(response); + } + + return json({ message: response, success: false }); +}; + +function isExtensionResponse(result: any): result is ExtensionResponse { + return 'message' in result && 'success' in result; +} diff --git a/src/routes/plans/[id]/+page.svelte b/src/routes/plans/[id]/+page.svelte index df0a75c64b..716b4ad2ed 100644 --- a/src/routes/plans/[id]/+page.svelte +++ b/src/routes/plans/[id]/+page.svelte @@ -15,6 +15,7 @@ import Console from '../../../components/console/Console.svelte'; import ConsoleSection from '../../../components/console/ConsoleSection.svelte'; import ConsoleTab from '../../../components/console/ConsoleTab.svelte'; + import ExtensionMenu from '../../../components/menus/ExtensionMenu.svelte'; import PlanMenu from '../../../components/menus/PlanMenu.svelte'; import ViewMenu from '../../../components/menus/ViewMenu.svelte'; import PlanMergeRequestsStatusButton from '../../../components/plan/PlanMergeRequestsStatusButton.svelte'; @@ -31,6 +32,7 @@ activityDirectivesMap, resetActivityStores, selectActivity, + selectedActivityDirectiveId, } from '../../../stores/activities'; import { checkConstraintsStatus, constraintResults, resetConstraintStores } from '../../../stores/constraints'; import { @@ -88,6 +90,7 @@ viewUpdateGrid, } from '../../../stores/views'; import type { ActivityDirective } from '../../../types/activity'; + import type { Extension } from '../../../types/extension'; import type { PlanSnapshot } from '../../../types/plan-snapshot'; import type { ViewSaveEvent, ViewToggleEvent } from '../../../types/view'; import effects from '../../../utilities/effects'; @@ -313,6 +316,16 @@ clearSnapshot(); } + async function onCallExtension(event: CustomEvent) { + const payload = { + planId: $planId, + selectedActivityDirectiveId: $selectedActivityDirectiveId, + simulationDatasetId: $simulationDatasetId, + url: event.detail.url, + }; + effects.callExtension(event.detail, payload, data.user); + } + async function onSaveView(event: CustomEvent) { const { detail } = event; const { definition, id, name } = detail; @@ -491,6 +504,12 @@ > + { const initialPlanTags = await effects.getPlanTags(initialPlan.id, user); const initialView = await effects.getView(url.searchParams, user, initialActivityTypes, initialResourceTypes); const initialPlanSnapshotId = getSearchParameterNumber(SearchParameters.SNAPSHOT_ID, url.searchParams); + const extensions = await effects.getExtensions(user); return { + extensions, initialActivityTypes, initialPlan, initialPlanSnapshotId, diff --git a/src/types/extension.ts b/src/types/extension.ts new file mode 100644 index 0000000000..bf9b10ed5e --- /dev/null +++ b/src/types/extension.ts @@ -0,0 +1,28 @@ +export type Extension = { + description: string; + extension_roles: ExtensionRole[]; + id: number; + label: string; + updated_at: string; + url: string; +}; + +export type ExtensionPayload = { + gateway?: string; + hasura?: string; + planId: number; + selectedActivityDirectiveId: number | null; + simulationDatasetId: number | null; +}; + +export type ExtensionResponse = { + message: string; + success: boolean; + url: string; +}; + +export type ExtensionRole = { + extension_id: number; + id: number; + role: string; +}; diff --git a/src/utilities/effects.ts b/src/utilities/effects.ts index 90ef1cbe02..10bd50184e 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -47,6 +47,7 @@ import type { ExpansionSet, SeqId, } from '../types/expansion'; +import type { Extension, ExtensionPayload } from '../types/extension'; import type { Model, ModelInsertInput, ModelSlim } from '../types/model'; import type { DslTypeScriptResponse, TypeScriptFile } from '../types/monaco'; import type { @@ -130,7 +131,7 @@ import { showUploadViewModal, } from './modal'; import { queryPermissions } from './permissions'; -import { reqGateway, reqHasura } from './requests'; +import { reqExtension, reqGateway, reqHasura } from './requests'; import { sampleProfiles } from './resources'; import { Status } from './status'; import { getDoyTime, getDoyTimeFromInterval, getIntervalFromDoyRange } from './time'; @@ -225,6 +226,28 @@ const effects = { } }, + async callExtension( + extension: Extension, + payload: ExtensionPayload & Record<'url', string>, + user: User | null, + ): Promise { + try { + const response = await reqExtension(`${base}/extensions`, payload, user); + + if (response.success) { + showSuccessToast(response.message); + window.open(response.url, '_blank'); + } else { + throw new Error(response.message); + } + } catch (error: any) { + const failureMessage = `Extension: ${extension.label} was not executed successfully`; + + catchError(failureMessage, error as Error); + showFailureToast(failureMessage); + } + }, + async cancelPendingSimulation(simulationDatasetId: number, user: User | null): Promise { try { if (!queryPermissions.UPDATE_SIMULATION_DATASET(user)) { @@ -2352,6 +2375,21 @@ const effects = { } }, + async getExtensions(user: User | null): Promise { + try { + const data = await reqHasura(gql.GET_EXTENSIONS, {}, user); + const { extensions = [] } = data; + if (extensions != null) { + return extensions; + } else { + throw Error('Unable to retrieve extensions'); + } + } catch (e) { + catchError(e as Error); + return []; + } + }, + async getModels(user: User | null): Promise { try { const data = await reqHasura(gql.GET_MODELS, {}, user); diff --git a/src/utilities/gql.ts b/src/utilities/gql.ts index 7f601e4ff5..86fb2f3b4f 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -779,6 +779,23 @@ const gql = { } `, + GET_EXTENSIONS: `#graphql + query GetExtensions { + extensions { + description + extension_roles { + extension_id + id + role + } + id + label + updated_at + url + } + } + `, + GET_MODELS: `#graphql query GetModels { models: mission_model { diff --git a/src/utilities/requests.ts b/src/utilities/requests.ts index 4b7247edb8..b1ae04f08e 100644 --- a/src/utilities/requests.ts +++ b/src/utilities/requests.ts @@ -1,10 +1,50 @@ import { browser } from '$app/environment'; import { env } from '$env/dynamic/public'; import type { BaseUser, User } from '../types/app'; +import type { ExtensionPayload, ExtensionResponse } from '../types/extension'; import type { QueryVariables } from '../types/subscribable'; import { logout } from '../utilities/login'; import { INVALID_JWT } from '../utilities/permissions'; +/** + * Used to make calls to application external to Aerie. + * + * @param url The external URL to call. + * @param payload The JSON payload that is serialized as the body of the request. + * @param user The user information serialized as a bearer token. + * @returns + */ +export async function reqExtension( + url: string, + payload: ExtensionPayload | (ExtensionPayload & Record<'url', string>), + user: BaseUser | User | null, +): Promise { + const headers: HeadersInit = { + Authorization: `Bearer ${user?.token ?? ''}`, + ...{ 'Content-Type': 'application/json' }, + }; + const options: RequestInit = { + headers, + method: 'POST', + }; + + if (payload !== null) { + options.body = JSON.stringify({ + ...payload, + gateway: browser ? env.PUBLIC_GATEWAY_CLIENT_URL : env.PUBLIC_GATEWAY_SERVER_URL, + hasura: browser ? env.PUBLIC_HASURA_CLIENT_URL : env.PUBLIC_HASURA_SERVER_URL, + }); + } + + const response = await fetch(`${url}`, options); + + if (!response.ok) { + throw new Error(response.statusText); + } + + return await response.json(); +} + /** * Function to make HTTP requests to the Aerie Gateway. */