From 9b91a7182ae87c1167184b36fe735ea002917dea Mon Sep 17 00:00:00 2001 From: Cody Hansen Date: Wed, 4 Oct 2023 05:55:34 -1000 Subject: [PATCH 1/3] Added support for showing and accessing custom helpers --- src/components/menus/ExtensionMenu.svelte | 88 +++++++++++++++++++++++ src/routes/extensions/+server.ts | 22 ++++++ src/routes/plans/[id]/+page.svelte | 19 +++++ src/routes/plans/[id]/+page.ts | 2 + src/types/extension.ts | 28 ++++++++ src/utilities/effects.ts | 40 ++++++++++- src/utilities/gql.ts | 17 +++++ src/utilities/requests.ts | 40 +++++++++++ 8 files changed, 255 insertions(+), 1 deletion(-) create mode 100644 src/components/menus/ExtensionMenu.svelte create mode 100644 src/routes/extensions/+server.ts create mode 100644 src/types/extension.ts 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 b79080d9d8..7d5c9b13ac 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 { @@ -87,6 +89,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 { View, ViewSaveEvent, ViewToggleEvent } from '../../../types/view'; import effects from '../../../utilities/effects'; @@ -338,6 +341,16 @@ } } + 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, owner } = detail; @@ -516,6 +529,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 98df9bdd2f..25fa4e2e4c 100644 --- a/src/utilities/effects.ts +++ b/src/utilities/effects.ts @@ -49,6 +49,7 @@ import type { ExpansionSet, SeqId, } from '../types/expansion'; +import type { Extension, ExtensionPayload } from '../types/extension'; import type { Model, ModelInsertInput, ModelSchema, ModelSlim } from '../types/model'; import type { DslTypeScriptResponse, TypeScriptFile } from '../types/monaco'; import type { @@ -138,7 +139,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'; @@ -234,6 +235,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)) { @@ -2446,6 +2469,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 c298c2e445..0a91ecdea1 100644 --- a/src/utilities/gql.ts +++ b/src/utilities/gql.ts @@ -798,6 +798,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. */ From 86b4b95462cca3742361ce306bc2a9aa2549100c Mon Sep 17 00:00:00 2001 From: Cody Hansen Date: Wed, 4 Oct 2023 12:39:47 -1000 Subject: [PATCH 2/3] Some misc changes for displaying extensions --- src/components/menus/ExtensionMenu.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/menus/ExtensionMenu.svelte b/src/components/menus/ExtensionMenu.svelte index ad4f691e52..5ebb730400 100644 --- a/src/components/menus/ExtensionMenu.svelte +++ b/src/components/menus/ExtensionMenu.svelte @@ -27,6 +27,7 @@ } else if (description?.length < DESCRIPTION_MAX_LENGTH) { return description; } + return ''; } From 8b739349428e5f54c48af345270121d8676f5d0d Mon Sep 17 00:00:00 2001 From: Cody Hansen Date: Thu, 5 Oct 2023 09:53:42 -1000 Subject: [PATCH 3/3] Moved extension call + toast logic into effects --- src/routes/plans/[id]/+page.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/plans/[id]/+page.svelte b/src/routes/plans/[id]/+page.svelte index 7d5c9b13ac..fec7b8afc8 100644 --- a/src/routes/plans/[id]/+page.svelte +++ b/src/routes/plans/[id]/+page.svelte @@ -348,6 +348,7 @@ simulationDatasetId: $simulationDatasetId, url: event.detail.url, }; + effects.callExtension(event.detail, payload, data.user); }