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}
+
+{/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.
*/