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

Added support for showing and accessing custom helpers #920

Merged
merged 3 commits into from
Oct 16, 2023
Merged
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
89 changes: 89 additions & 0 deletions src/components/menus/ExtensionMenu.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
<svelte:options accessors={true} immutable={true} />

<script lang="ts">
import SettingsIcon from '@nasa-jpl/stellar/icons/settings.svg?component';
import { createEventDispatcher } from 'svelte';
import type { User } from '../../types/app';
import type { Extension } from '../../types/extension';
import { permissionHandler } from '../../utilities/permissionHandler';
import PlanNavButton from '../plan/PlanNavButton.svelte';
import MenuItem from './MenuItem.svelte';
export let extensions: Extension[];
export let title: string;
export let user: User | null;
const DESCRIPTION_MAX_LENGTH = 50;
const dispatch = createEventDispatcher();
function callExtension(extension: Extension) {
dispatch('callExtension', extension);
}
function formatDescription(description: string): string {
// Truncate the description at DESCRIPTION_MAX_LENGTH and add ellipsis.
if (description !== null && description.length > DESCRIPTION_MAX_LENGTH) {
return `${description.substring(0, Math.min(description.length, DESCRIPTION_MAX_LENGTH))}...`;
} else if (description?.length < DESCRIPTION_MAX_LENGTH) {
return description;
}
return '';
}
function hasExtensionPermission(extension: Extension): boolean {
for (const extensionRole of extension.extension_roles) {
if (user?.activeRole === extensionRole.role) {
return true;
}
}
return false;
}
</script>

{#if extensions.length > 0}
<div class="extension-menu st-typography-medium">
<PlanNavButton {title} menuTitle="Extensions">
<SettingsIcon />
<div class="st-typography-medium" slot="menu">
{#each extensions as extension}
<MenuItem
on:click={() => callExtension(extension)}
use={[
[
permissionHandler,
{
hasPermission: hasExtensionPermission(extension),
permissionError: 'You do not have permission to call this extension',
},
],
]}
>
<div class="extension-menu--menu-item">
{extension.label}
<span class="st-typography-label">{formatDescription(extension.description)}</span>
</div>
</MenuItem>
{/each}
</div>
</PlanNavButton>
</div>
{/if}

<style>
.extension-menu {
--aerie-menu-item-template-columns: auto;
align-items: center;
cursor: pointer;
display: grid;
height: inherit;
justify-content: center;
position: relative;
}
.extension-menu--menu-item {
display: flex;
flex-direction: column;
}
</style>
22 changes: 22 additions & 0 deletions src/routes/extensions/+server.ts
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions src/routes/plans/[id]/+page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -31,6 +32,7 @@
activityDirectivesMap,
resetActivityStores,
selectActivity,
selectedActivityDirectiveId,
} from '../../../stores/activities';
import { checkConstraintsStatus, constraintResults, resetConstraintStores } from '../../../stores/constraints';
import {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -338,6 +341,17 @@
}
}
async function onCallExtension(event: CustomEvent<Extension>) {
const payload = {
planId: $planId,
selectedActivityDirectiveId: $selectedActivityDirectiveId,
simulationDatasetId: $simulationDatasetId,
url: event.detail.url,
};
effects.callExtension(event.detail, payload, data.user);
}
async function onSaveView(event: CustomEvent<ViewSaveEvent>) {
const { detail } = event;
const { definition, id, name, owner } = detail;
Expand Down Expand Up @@ -516,6 +530,12 @@
>
<CalendarIcon />
</PlanNavButton>
<ExtensionMenu
extensions={data.extensions}
title={!compactNavMode ? 'Extensions' : ''}
user={data.user}
on:callExtension={onCallExtension}
/>
<ViewMenu
hasCreatePermission={hasCreateViewPermission}
hasUpdatePermission={hasUpdateViewPermission}
Expand Down
2 changes: 2 additions & 0 deletions src/routes/plans/[id]/+page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,10 @@ export const load: PageLoad = async ({ parent, params, url }) => {
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,
Expand Down
28 changes: 28 additions & 0 deletions src/types/extension.ts
Original file line number Diff line number Diff line change
@@ -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;
};
40 changes: 39 additions & 1 deletion src/utilities/effects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -234,6 +235,28 @@ const effects = {
}
},

async callExtension(
extension: Extension,
payload: ExtensionPayload & Record<'url', string>,
user: User | null,
): Promise<void> {
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<void> {
try {
if (!queryPermissions.UPDATE_SIMULATION_DATASET(user)) {
Expand Down Expand Up @@ -2446,6 +2469,21 @@ const effects = {
}
},

async getExtensions(user: User | null): Promise<Extension[]> {
try {
const data = await reqHasura<Extension[]>(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<ModelSlim[]> {
try {
const data = await reqHasura<ModelSlim[]>(gql.GET_MODELS, {}, user);
Expand Down
17 changes: 17 additions & 0 deletions src/utilities/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions src/utilities/requests.ts
Original file line number Diff line number Diff line change
@@ -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<ExtensionResponse> {
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.
*/
Expand Down
Loading