feat: Add initial Fluxscape deploy popup (#6)

This commit is contained in:
Eric Tuvesson
2024-04-03 18:56:07 +02:00
committed by GitHub
parent 51224f9455
commit 9f85089bd6
8 changed files with 282 additions and 20 deletions

View File

@@ -0,0 +1,35 @@
import React, { createContext, useContext, useEffect } from 'react';
import { Slot } from '@noodl-core-ui/types/global';
import { commandEventHandler } from './iframe';
export interface PluginContext {}
const PluginContext = createContext<PluginContext>({});
export interface PluginContextProps {
children: Slot;
}
export function PluginContextProvider({ children }: PluginContextProps) {
// Listen for all the iframe commands
useEffect(() => {
window.addEventListener('message', commandEventHandler, false);
return function () {
window.removeEventListener('message', commandEventHandler, false);
};
}, []);
return <PluginContext.Provider value={{}}>{children}</PluginContext.Provider>;
}
export function usePluginContext() {
const context = useContext(PluginContext);
if (context === undefined) {
throw new Error('usePluginContext must be a child of PluginContextProvider');
}
return context;
}

View File

@@ -0,0 +1,24 @@
import { ToastLayer } from '../../../views/ToastLayer';
export type Command = {
kind: 'notify';
messageType: 'info' | 'success' | 'error';
message: string;
};
export async function execute(command: Command, _event: MessageEvent) {
switch (command.messageType || 'info') {
case 'info': {
ToastLayer.showInteraction(String(command.message));
break;
}
case 'success': {
ToastLayer.showSuccess(String(command.message));
break;
}
case 'error': {
ToastLayer.showError(String(command.message));
break;
}
}
}

View File

@@ -0,0 +1,169 @@
import s3 from 's3';
import { filesystem, platform } from '@noodl/platform';
import { Environment, EnvironmentDataFormat } from '@noodl-models/CloudServices';
import { ProjectModel } from '@noodl-models/projectmodel';
import { createEditorCompilation } from '@noodl-utils/compilation/compilation.editor';
import { guid } from '@noodl-utils/utils';
import { ToastLayer } from '../../../views/ToastLayer';
export type Command = {
kind: 'upload-aws-s3';
taskId: string;
cloudService: EnvironmentDataFormat;
s3: {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
region: string;
bucket: string;
prefix: string;
}
messages: {
progress: string;
success: string;
failure: string;
}
// Initial version to a more extendable system that can be used with these
// kind of events.
events: {
complete: {
kind: "fetch",
resource: string,
options: RequestInit
}[]
}
};
export async function execute(command: Command, event: MessageEvent) {
const project = ProjectModel.instance;
const taskId = command.taskId || guid();
// Setup the build steps
const compilation = createEditorCompilation(project).addProjectBuildScripts();
// Create a temp folder
const tempDir = platform.getTempPath() + '/upload-' + taskId;
// Deploy to temp folder
await compilation.deployToFolder(tempDir, {
environment: command.cloudService ? new Environment(command.cloudService) : undefined
});
// Upload to S3
try {
await uploadDirectory(
{
localDir: tempDir,
accessKeyId: command.s3.accessKeyId,
secretAccessKey: command.s3.secretAccessKey,
sessionToken: command.s3.sessionToken,
region: command.s3.region,
bucket: command.s3.bucket,
prefix: command.s3.prefix
},
({ progress, total }) => {
ToastLayer.showProgress(command.messages.progress, (progress / total) * 100, taskId);
}
);
ToastLayer.showSuccess(command.messages.success);
} catch (error) {
console.error(error);
ToastLayer.showError(command.messages.failure);
} finally {
ToastLayer.hideProgress(taskId);
}
// Clean up the temp folder
filesystem.removeDirRecursive(tempDir);
// NOTE: Would be nice to clean up the events in here, so "complete" is like
// "finally". And then also have "success" and "failure".
// Notify that the process is done
event.source.postMessage({
kind: "upload-aws-s3",
id: taskId,
status: "complete",
}, { targetOrigin: event.origin })
// Execute complete event
if (Array.isArray(command?.events?.complete)) {
for (const event of command.events.complete) {
if (event.kind === "fetch") {
await fetch(event.resource, event.options);
} else {
console.error("invalid event type", event);
}
}
}
}
function uploadDirectory(
options: {
localDir: string;
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
region: string;
bucket: string;
prefix: string;
},
progress: (args: { progress: number; total: number }) => void
) {
return new Promise<void>((resolve, reject) => {
// https://github.com/noodlapp/node-s3-client?tab=readme-ov-file#create-a-client
const client = s3.createClient({
maxAsyncS3: 20, // this is the default
s3RetryCount: 3, // this is the default
s3RetryDelay: 1000, // this is the default
multipartUploadThreshold: 20971520, // this is the default (20 MB)
multipartUploadSize: 15728640, // this is the default (15 MB)
s3Options: {
accessKeyId: options.accessKeyId,
secretAccessKey: options.secretAccessKey,
sessionToken: options.sessionToken,
region: options.region
}
});
function getFileCacheControl(fullPath: string) {
switch (fullPath.split('.').at(-1)) {
case 'html':
return 'no-cache';
default: {
const TIME_1 = 31536000; // 365 days
const TIME_2 = 10800; // 3 hours for files with hash
const maxAge = /-[a-f0-9]{16}\./.test(fullPath) ? TIME_1 : TIME_2;
return `public, max-age=${maxAge}`;
}
}
}
const uploader = client.uploadDir({
localDir: options.localDir,
deleteRemoved: true,
s3Params: {
Bucket: options.bucket,
Prefix: options.prefix
},
getS3Params(fullPath, _s3Object, callback) {
callback(null, {
CacheControl: getFileCacheControl(fullPath)
});
}
});
uploader.on('error', function (error) {
reject(error);
});
uploader.on('progress', function () {
progress({ progress: uploader.progressAmount, total: uploader.progressTotal });
});
uploader.on('end', function () {
resolve();
});
});
}

View File

@@ -0,0 +1,19 @@
import * as Notify from './commands/notify';
import * as UploadAwsS3 from './commands/upload-aws-s3';
type IFrameCommand = Notify.Command | UploadAwsS3.Command;
const commands: Record<IFrameCommand['kind'], (command: IFrameCommand, event: MessageEvent) => Promise<void>> = {
notify: Notify.execute,
'upload-aws-s3': UploadAwsS3.execute
};
export function commandEventHandler(event: MessageEvent) {
try {
const command = event.data;
const handler = commands[command.kind];
handler && handler(command, event);
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1 @@
export * from './PluginContext';

View File

@@ -3,7 +3,7 @@ import { JSONStorage } from '@noodl/platform';
import { CreateEnvironment, CreateEnvironmentRequest, UpdateEnvironmentRequest } from '@noodl-models/CloudServices';
/** The data format is separated from our internal model. */
type EnvironmentDataFormat = {
export type EnvironmentDataFormat = {
enabled: boolean;
id: string;
name: string;

View File

@@ -43,6 +43,7 @@ import { BaseWindow } from '../../views/windows/BaseWindow';
import { whatsnewRender } from '../../whats-new';
import { IRouteProps } from '../AppRoute';
import { useSetupSettings } from './useSetupSettings';
import { PluginContextProvider } from '@noodl-contexts/PluginContext';
// eslint-disable-next-line @typescript-eslint/no-var-requires
const ImportOverwritePopupTemplate = require('../../templates/importoverwritepopup.html');
@@ -223,24 +224,26 @@ export function EditorPage({ route }: EditorPageProps) {
return (
<NodeGraphContextProvider>
<ProjectDesignTokenContextProvider>
<BaseWindow>
{isLoading ? (
<ActivityIndicator />
) : (
<>
<FrameDivider
first={<SidePanel />}
second={<ErrorBoundary>{Boolean(Document) && <Document />}</ErrorBoundary>}
sizeMin={200}
size={frameDividerSize}
horizontal
onSizeChanged={setFrameDividerSize}
/>
<PluginContextProvider>
<BaseWindow>
{isLoading ? (
<ActivityIndicator />
) : (
<>
<FrameDivider
first={<SidePanel />}
second={<ErrorBoundary>{Boolean(Document) && <Document />}</ErrorBoundary>}
sizeMin={200}
size={frameDividerSize}
horizontal
onSizeChanged={setFrameDividerSize}
/>
{Boolean(lesson) && <Frame instance={lesson} isContentSize isFitWidth />}
</>
)}
</BaseWindow>
{Boolean(lesson) && <Frame instance={lesson} isContentSize isFitWidth />}
</>
)}
</BaseWindow>
</PluginContextProvider>
</ProjectDesignTokenContextProvider>
</NodeGraphContextProvider>
);

View File

@@ -1,4 +1,5 @@
import React, { RefObject } from 'react';
import { usePluginContext } from '@noodl-contexts/PluginContext';
import React, { RefObject, useEffect, useRef } from 'react';
import { ActivityIndicator } from '@noodl-core-ui/components/common/ActivityIndicator';
import { BaseDialog } from '@noodl-core-ui/components/layout/BaseDialog';
@@ -25,7 +26,12 @@ function DeployPopupChild() {
>
<PopupSection title="Deploy options" />
<Tabs tabs={[{ label: 'Self Hosting', content: <DeployToFolderTab />, testId: 'self-hosting-tab-button' }]} />
<Tabs
tabs={[
{ label: 'Fluxscape', content: <FluxscapeDeployTab />, testId: 'fluxscape-tab-button' },
{ label: 'Self Hosting', content: <DeployToFolderTab />, testId: 'self-hosting-tab-button' }
]}
/>
{hasActivity && <ActivityIndicator isOverlay />}
</div>
@@ -54,3 +60,8 @@ export function DeployPopup(props: DeployPopupProps) {
</DeployContextProvider>
);
}
function FluxscapeDeployTab() {
// Preview URL: 'http://192.168.0.33:8574/'
return <iframe src="https://portal.fluxscape.io" style={{ width: "100%", height: "50vh", borderStyle: "none" }} />;
}