Repository: keephq/keep
Pull request #4105: fix: monaco from npm by default, CDN version in dev mode with turbopack
Author: login: Kiryous
Labels: name: UI
description: User interface related issues
name: javascript
description: Pull requests that update Javascript code
name: size:L
description: This PR changes 100-499 lines, ignoring generated files.
name: lgtm
description: This PR has been approved by a maintainer
Created: 2025-03-18T13:56:32Z | Merged: 2025-03-19T11:09:29Z

<!-- 
Thanks for creating this pull request 🤗

Please make sure that the pull request is limited to one type (docs, feature, etc.) and keep it as small as possible. You can open multiple prs instead of opening a huge one.
-->

<!-- If this pull request closes an issue, please mention the issue number below -->
Closes #4104 



Linked issue #4104: [🐛 Bug]: loading monaco-editor as npm package fails with turbopack
![Image](https://github.com/user-attachments/assets/c76c9888-e216-4fe6-a41c-2ac3a8ea790a)

Diff:
diff --git a/keep-ui/app/(keep)/alerts/ViewAlertModal.tsx b/keep-ui/app/(keep)/alerts/ViewAlertModal.tsx
index 6485f90145..441d6229b5 100644
--- a/keep-ui/app/(keep)/alerts/ViewAlertModal.tsx
+++ b/keep-ui/app/(keep)/alerts/ViewAlertModal.tsx
@@ -1,21 +1,14 @@
 import { AlertDto, Status, Severity } from "@/entities/alerts/model"; // Adjust the import path as needed
 import Modal from "@/components/ui/Modal"; // Ensure this path matches your project structure
-import { Button, Subtitle, Switch, Text, Callout } from "@tremor/react";
+import { Button, Switch, Text, Callout } from "@tremor/react";
 import { toast } from "react-toastify";
-import "./ViewAlertModal.css";
 import React, { useState, useRef, useEffect } from "react";
 import { useApi } from "@/shared/lib/hooks/useApi";
-import { showErrorToast } from "@/shared/ui";
-import Editor, { Monaco } from "@monaco-editor/react";
+import { MonacoEditor, showErrorToast } from "@/shared/ui";
+import { type Monaco } from "@monaco-editor/react";
 import { Lock, Unlock, Save, AlertTriangle, Copy, X } from "lucide-react";
-
-// Monaco Editor - do not load from CDN (to support on-prem)
-// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
-import * as monaco from "monaco-editor";
-import { loader } from "@monaco-editor/react";
-loader.config({ monaco });
-
-import * as monacoEditor from "monaco-editor/esm/vs/editor/editor.api";
+import { type editor } from "monaco-editor";
+import "./ViewAlertModal.css";
 
 interface ViewAlertModalProps {
   alert: AlertDto | null | undefined;
@@ -71,8 +64,8 @@ export const ViewAlertModal: React.FC<ViewAlertModalProps> = ({
     []
   );
   const api = useApi();
-  const editorRef = useRef<any>(null);
-  const monacoRef = useRef<typeof monacoEditor | null>(null);
+  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
+  const monacoRef = useRef<Monaco | null>(null);
   const decorationsRef = useRef<string[]>([]);
 
   // Initialize editor value when alert changes
@@ -198,7 +191,7 @@ export const ViewAlertModal: React.FC<ViewAlertModalProps> = ({
     }
   };
 
-  const setupMonacoCompletionProvider = (monaco: typeof monacoEditor) => {
+  const setupMonacoCompletionProvider = (monaco: Monaco) => {
     // Set up enum value suggestions
     monaco.languages.registerCompletionItemProvider("json", {
       triggerCharacters: ['"', ":", " ", ","],
@@ -270,7 +263,7 @@ export const ViewAlertModal: React.FC<ViewAlertModalProps> = ({
     });
   };
 
-  const handleEditorDidMount = (editor: any, monaco: typeof monacoEditor) => {
+  const handleEditorDidMount = (editor: any, monaco: Monaco) => {
     editorRef.current = editor;
     monacoRef.current = monaco;
 
@@ -378,10 +371,7 @@ export const ViewAlertModal: React.FC<ViewAlertModalProps> = ({
     }
   }, [showHighlightedOnly, isEditable]);
 
-  const applyReadOnlyDecorations = (
-    editor: any,
-    monaco: typeof monacoEditor
-  ) => {
+  const applyReadOnlyDecorations = (editor: any, monaco: Monaco) => {
     if (!editor || !monaco) return;
 
     const model = editor.getModel();
@@ -761,7 +751,7 @@ export const ViewAlertModal: React.FC<ViewAlertModalProps> = ({
                   {getErrorMessage()}
                 </div>
               )}
-              <Editor
+              <MonacoEditor
                 height="100%"
                 defaultLanguage="json"
                 value={editorValue}
diff --git a/keep-ui/app/(keep)/mapping/create-or-edit-mapping.tsx b/keep-ui/app/(keep)/mapping/create-or-edit-mapping.tsx
index 419c2c549d..6729698e6f 100644
--- a/keep-ui/app/(keep)/mapping/create-or-edit-mapping.tsx
+++ b/keep-ui/app/(keep)/mapping/create-or-edit-mapping.tsx
@@ -36,14 +36,8 @@ import { useTopology } from "@/app/(keep)/topology/model";
 import { useApi } from "@/shared/lib/hooks/useApi";
 import { showErrorToast, Input, KeepLoader } from "@/shared/ui";
 import { PlusIcon, MinusIcon } from "@heroicons/react/20/solid";
-import Editor from "@monaco-editor/react";
-
-// Monaco Editor - do not load from CDN (to support on-prem)
-// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
-import * as monaco from "monaco-editor";
-import { loader } from "@monaco-editor/react";
+import { MonacoEditor } from "@/shared/ui";
 import { useTenantConfiguration } from "@/utils/hooks/useTenantConfiguration";
-loader.config({ monaco });
 
 interface Props {
   editRuleId: number | null;
@@ -364,7 +358,7 @@ export default function CreateOrEditMapping({
                     </TabPanel>
                     <TabPanel>
                       <div className="flex flex-col gap-2">
-                        <Editor
+                        <MonacoEditor
                           height="200px"
                           defaultLanguage="csv"
                           value={csvText}
diff --git a/keep-ui/app/(keep)/settings/webhook-settings.tsx b/keep-ui/app/(keep)/settings/webhook-settings.tsx
index baa02f23c4..dd4e60f2b1 100644
--- a/keep-ui/app/(keep)/settings/webhook-settings.tsx
+++ b/keep-ui/app/(keep)/settings/webhook-settings.tsx
@@ -25,13 +25,7 @@ import { useApi } from "@/shared/lib/hooks/useApi";
 import { useConfig } from "@/utils/hooks/useConfig";
 import { PageSubtitle } from "@/shared/ui";
 import { PageTitle } from "@/shared/ui";
-import { Editor } from "@monaco-editor/react";
-
-// Monaco Editor - do not load from CDN (to support on-prem)
-// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
-import * as monaco from "monaco-editor";
-import { loader } from "@monaco-editor/react";
-loader.config({ monaco });
+import { MonacoEditor } from "@/shared/ui";
 
 interface Webhook {
   webhookApi: string;
@@ -230,7 +224,7 @@ req.end();
               {languages.map(({ title, language, code }) => (
                 <TabPanel key={title}>
                   <div className="h-[calc(100vh-20rem)]">
-                    <Editor
+                    <MonacoEditor
                       value={code}
                       language={language}
                       theme="vs-light"
diff --git a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-sync-status.tsx b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-sync-status.tsx
index 2e9712557f..4283e3566f 100644
--- a/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-sync-status.tsx
+++ b/keep-ui/app/(keep)/workflows/[workflow_id]/workflow-sync-status.tsx
@@ -2,19 +2,12 @@ import { useWorkflowStore } from "@/entities/workflows";
 import { CloudIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
 import { Tooltip } from "@/shared/ui";
 import { useEffect } from "react";
-import TimeAgo, { Formatter } from "react-timeago";
+import TimeAgo from "react-timeago";
+import { useWorkflowEditorChangesSaved } from "@/entities/workflows/model/workflow-store";
 
 export function WorkflowSyncStatus() {
-  const {
-    lastChangedAt,
-    lastDeployedAt,
-    isEditorSyncedWithNodes,
-    isInitialized,
-  } = useWorkflowStore();
-  const isChangesSaved =
-    isEditorSyncedWithNodes &&
-    lastDeployedAt === null &&
-    lastDeployedAt >= lastChangedAt;
+  const { isInitialized, lastDeployedAt } = useWorkflowStore();
+  const isChangesSaved = useWorkflowEditorChangesSaved();
 
   useEffect(() => {
     const handler = (e: BeforeUnloadEvent) => {
diff --git a/keep-ui/entities/workflows/model/types.ts b/keep-ui/entities/workflows/model/types.ts
index 01acfde467..b834ecbdf7 100644
--- a/keep-ui/entities/workflows/model/types.ts
+++ b/keep-ui/entities/workflows/model/types.ts
@@ -410,10 +410,11 @@ export interface WorkflowStateValues {
   canDeploy: boolean;
   isSaving: boolean;
   isLoading: boolean;
+  isDeployed: boolean;
   validationErrors: Record<string, string>;
 
-  lastChangedAt: number;
-  lastDeployedAt: number;
+  lastChangedAt: number | null;
+  lastDeployedAt: number | null;
 
   // UI
   editorOpen: boolean;
diff --git a/keep-ui/entities/workflows/model/workflow-store.ts b/keep-ui/entities/workflows/model/workflow-store.ts
index 65d10869e8..7266148577 100644
--- a/keep-ui/entities/workflows/model/workflow-store.ts
+++ b/keep-ui/entities/workflows/model/workflow-store.ts
@@ -285,14 +285,15 @@ const defaultState: WorkflowStateValues = {
   selectedEdge: null,
   changes: 0,
   isEditorSyncedWithNodes: true,
-  lastChangedAt: 0,
-  lastDeployedAt: 0,
+  lastChangedAt: null,
+  lastDeployedAt: null,
   canDeploy: false,
   saveRequestCount: 0,
   runRequestCount: 0,
   isSaving: false,
   definition: null,
   isLoading: false,
+  isDeployed: false,
   validationErrors: {},
 };
 
@@ -814,9 +815,25 @@ function initializeWorkflow(
     toolboxConfiguration,
     isLoading: false,
     isInitialized: true,
+    isDeployed: workflowId !== null,
     // If it's a new workflow (workflowId = null), we want to open the editor because metadata fields in there
     editorOpen: !workflowId,
   });
   get().onLayout({ direction: "DOWN" });
   get().updateDefinition();
 }
+
+export function useWorkflowEditorChangesSaved() {
+  const { lastChangedAt, lastDeployedAt, isEditorSyncedWithNodes, isDeployed } =
+    useWorkflowStore();
+  const isDeployedAndUntouched =
+    lastDeployedAt === null && lastChangedAt === null && isDeployed;
+  const isDeployedAndChangesSaved =
+    lastDeployedAt !== null &&
+    lastChangedAt !== null &&
+    lastDeployedAt >= lastChangedAt;
+  const isChangesSaved =
+    isEditorSyncedWithNodes &&
+    (isDeployedAndChangesSaved || isDeployedAndUntouched);
+  return isChangesSaved;
+}
diff --git a/keep-ui/features/workflow-execution-results/ui/WorkflowExecutionLogs.tsx b/keep-ui/features/workflow-execution-results/ui/WorkflowExecutionLogs.tsx
index 18f8d658a1..6c2bdae8e3 100644
--- a/keep-ui/features/workflow-execution-results/ui/WorkflowExecutionLogs.tsx
+++ b/keep-ui/features/workflow-execution-results/ui/WorkflowExecutionLogs.tsx
@@ -1,4 +1,3 @@
-import Loading from "@/app/(keep)/loading";
 import {
   LogEntry,
   WorkflowExecutionDetail,
@@ -18,13 +17,7 @@ import {
 import { parseISO, differenceInSeconds, formatDistance } from "date-fns";
 import Skeleton from "react-loading-skeleton";
 import "react-loading-skeleton/dist/skeleton.css";
-
-// Monaco Editor - do not load from CDN (to support on-prem)
-// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
-import * as monaco from "monaco-editor";
-import { loader } from "@monaco-editor/react";
-loader.config({ monaco });
-import Editor from "@monaco-editor/react";
+import { MonacoEditor } from "@/shared/ui";
 
 function getStepIcon(status: string) {
   switch (status) {
@@ -203,7 +196,7 @@ function LogGroupAccordion({
                       ),
                     }}
                   >
-                    <Editor
+                    <MonacoEditor
                       value={JSON.stringify(result, null, 2)}
                       language="json"
                       theme="vs-light"
diff --git a/keep-ui/features/workflows/ai-assistant/ui/StepPreview.tsx b/keep-ui/features/workflows/ai-assistant/ui/StepPreview.tsx
index 686e12de36..6b951209b8 100644
--- a/keep-ui/features/workflows/ai-assistant/ui/StepPreview.tsx
+++ b/keep-ui/features/workflows/ai-assistant/ui/StepPreview.tsx
@@ -3,17 +3,11 @@ import clsx from "clsx";
 import Image from "next/image";
 import { NodeTriggerIcon } from "@/entities/workflows/ui/NodeTriggerIcon";
 import { normalizeStepType } from "../../builder/lib/utils";
-// Monaco Editor - do not load from CDN (to support on-prem)
-// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
-import * as monaco from "monaco-editor";
-import { loader } from "@monaco-editor/react";
-loader.config({ monaco });
-import { Editor } from "@monaco-editor/react";
 import { stringify } from "yaml";
 import { getTriggerDescriptionFromStep } from "@/entities/workflows/lib/getTriggerDescription";
 import { getYamlFromStep } from "../lib/utils";
 import { WF_DEBUG_INFO } from "../../builder/ui/debug-settings";
-import { JsonCard } from "@/shared/ui";
+import { JsonCard, MonacoEditor } from "@/shared/ui";
 
 function getStepIconUrl(data: V2Step | V2StepTrigger) {
   const { type } = data || {};
@@ -72,7 +66,7 @@ export const StepPreview = ({
               height: Math.min(yaml?.split("\n").length * 20 + 8, 192),
             }}
           >
-            <Editor
+            <MonacoEditor
               value={yaml}
               language="yaml"
               theme="vs-light"
diff --git a/keep-ui/features/workflows/builder/ui/Editor/StepTest.tsx b/keep-ui/features/workflows/builder/ui/Editor/StepTest.tsx
index 8ca2060b5c..341721cb6b 100644
--- a/keep-ui/features/workflows/builder/ui/Editor/StepTest.tsx
+++ b/keep-ui/features/workflows/builder/ui/Editor/StepTest.tsx
@@ -1,15 +1,9 @@
 import { Button, TextInput } from "@/components/ui";
 import { useApi } from "@/shared/lib/hooks/useApi";
-import { JsonCard } from "@/shared/ui";
+import { JsonCard, MonacoEditor } from "@/shared/ui";
 import { Callout, Text } from "@tremor/react";
 import { useMemo, useState } from "react";
 import { EditorLayout } from "./StepEditor";
-// Monaco Editor - do not load from CDN (to support on-prem)
-// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
-import * as monaco from "monaco-editor";
-import { loader } from "@monaco-editor/react";
-loader.config({ monaco });
-import { Editor } from "@monaco-editor/react";
 import { SparklesIcon } from "@heroicons/react/24/outline";
 import { useCopilotChat } from "@copilotkit/react-core";
 import { Role } from "@copilotkit/runtime-client-gql";
@@ -150,8 +144,8 @@ export function TestRunStepForm({
               typeof value === "object"
                 ? JSON.parse(result)
                 : typeof value === "number"
-                ? Number(result)
-                : result,
+                  ? Number(result)
+                  : result,
             ];
           } catch {
             return [key, result];
@@ -257,7 +251,7 @@ export function TestRunStepForm({
                   ),
                 }}
               >
-                <Editor
+                <MonacoEditor
                   value={JSON.stringify(result, null, 2)}
                   language="json"
                   theme="vs-light"
diff --git a/keep-ui/next.config.js b/keep-ui/next.config.js
index 1e83c7ece8..d64c484de6 100644
--- a/keep-ui/next.config.js
+++ b/keep-ui/next.config.js
@@ -1,13 +1,29 @@
 const { withSentryConfig } = require("@sentry/nextjs");
 
-const isWithTurbo = process.env.NEXT_TURBO === "true";
 const isSentryDisabled =
   process.env.SENTRY_DISABLED === "true" ||
   process.env.NODE_ENV === "development";
 
+// Turbopack doesn't support dynamic imports yet, so we need to fallback to CDN for development
+// Checking NODE_ENV because in the future we may use turbopack in production as well
+const turbopackAliases =
+  process.env.NODE_ENV === "development"
+    ? {
+        "./MonacoEditor": "@/shared/ui/MonacoEditor/index.turbopack.ts",
+      }
+    : {};
+
 /** @type {import('next').NextConfig} */
 const nextConfig = {
   reactStrictMode: false,
+  devIndicators: {
+    position: "bottom-right",
+  },
+  experimental: {
+    turbo: {
+      resolveAlias: turbopackAliases,
+    },
+  },
   webpack: (
     config,
     { buildId, dev, isServer, defaultLoaders, nextRuntime, webpack }
@@ -50,6 +66,7 @@ const nextConfig = {
         message: /Critical dependency/,
       },
     ];
+
     return config;
   },
   // @auth/core is ESM-only and jest fails to transpile it.
@@ -83,12 +100,9 @@ const nextConfig = {
       },
     ],
   },
-  // compiler is not supported in turbo mode
-  compiler: isWithTurbo
-    ? undefined
-    : {
-        removeConsole: false,
-      },
+  compiler: {
+    removeConsole: false,
+  },
   output: "standalone",
   productionBrowserSourceMaps: !isSentryDisabled,
   async redirects() {
diff --git a/keep-ui/package.json b/keep-ui/package.json
index 9ab53d437e..750676e74c 100644
--- a/keep-ui/package.json
+++ b/keep-ui/package.json
@@ -6,7 +6,7 @@
   "scripts": {
     "build": "./next_build.sh",
     "dev": "next dev --turbopack -p 3000",
-    "dev:turbo": "NEXT_TURBO=true next dev -p 3000",
+    "dev:webpack": "next dev -p 3000",
     "lint": "next lint",
     "start": "next start",
     "test": "jest"
diff --git a/keep-ui/shared/lib/server/getConfig.ts b/keep-ui/shared/lib/server/getConfig.ts
index 5465a27042..e1aff195a6 100644
--- a/keep-ui/shared/lib/server/getConfig.ts
+++ b/keep-ui/shared/lib/server/getConfig.ts
@@ -33,6 +33,7 @@ export function getConfig(): InternalConfig {
   } else {
     API_URL_CLIENT = process.env.API_URL_CLIENT;
   }
+
   return {
     AUTH_TYPE: authType,
     PUSHER_DISABLED: process.env.PUSHER_DISABLED === "true",
@@ -56,11 +57,13 @@ export function getConfig(): InternalConfig {
     SENTRY_DISABLED: process.env.SENTRY_DISABLED,
     FRIGADE_DISABLED: process.env.FRIGADE_DISABLED,
     READ_ONLY: process.env.KEEP_READ_ONLY === "true",
-    OPEN_AI_API_KEY_SET: !!process.env.OPEN_AI_API_KEY || !!process.env.OPENAI_API_KEY,
+    OPEN_AI_API_KEY_SET:
+      !!process.env.OPEN_AI_API_KEY || !!process.env.OPENAI_API_KEY,
     // NOISY ALERTS DISABLED BY DEFAULT TO SPARE SPACE ON THE TABLE
     NOISY_ALERTS_ENABLED: process.env.NOISY_ALERTS_ENABLED === "true",
     // The URL of the documentation site
     KEEP_DOCS_URL: process.env.KEEP_DOCS_URL || "https://docs.keephq.dev",
-    KEEP_CONTACT_US_URL: process.env.KEEP_CONTACT_US_URL || "https://slack.keephq.dev/",
+    KEEP_CONTACT_US_URL:
+      process.env.KEEP_CONTACT_US_URL || "https://slack.keephq.dev/",
   };
 }
diff --git a/keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx b/keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx
index 72be78a4c7..4986a1d3cf 100644
--- a/keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx
+++ b/keep-ui/shared/ui/ErrorComponent/ErrorComponent.tsx
@@ -4,7 +4,7 @@
 // https://nextjs.org/docs/app/building-your-application/routing/error-handling#how-errorjs-works
 
 "use client";
-import { useEffect } from "react";
+import { useEffect, useMemo } from "react";
 import { Title, Subtitle } from "@tremor/react";
 import { Button, Text } from "@tremor/react";
 import { KeepApiError } from "@/shared/api";
@@ -17,11 +17,13 @@ import { useConfig } from "utils/hooks/useConfig";
 
 export function ErrorComponent({
   error: originalError,
+  defaultMessage = "An error occurred",
   description,
   reset,
 }: {
   error: Error | KeepApiError;
-  description?: string;
+  defaultMessage?: string;
+  description?: React.ReactNode;
   reset?: () => void;
 }) {
   const signOut = useSignOut();
@@ -35,7 +37,10 @@ export function ErrorComponent({
     Sentry.captureException(originalError);
   }, [originalError]);
 
-  const error = isHealthy ? originalError : new KeepApiHealthError();
+  const error = useMemo(() => {
+    return isHealthy ? originalError : new KeepApiHealthError();
+  }, [isHealthy, originalError]);
+
   const subtitle =
     error instanceof KeepApiError
       ? error.proposedResolution || description
@@ -46,11 +51,11 @@ export function ErrorComponent({
       <KeepLogoError />
       <div className="max-w-md">
         <Title className="text-xl font-bold text-tremor-content-strong dark:text-dark-tremor-content-strong">
-          {error.message || "An error occurred"}
+          {error.message || defaultMessage}
         </Title>
         {subtitle && <Subtitle>{subtitle}</Subtitle>}
       </div>
-      {error && (
+      {error && (error instanceof KeepApiError || error.stack) && (
         <code className="text-gray-600 text-left bg-gray-100 p-2 rounded-md">
           {error instanceof KeepApiError && (
             <>
diff --git a/keep-ui/shared/ui/MonacoEditor/MonacoEditorCDN.tsx b/keep-ui/shared/ui/MonacoEditor/MonacoEditorCDN.tsx
new file mode 100644
index 0000000000..72dd3c3494
--- /dev/null
+++ b/keep-ui/shared/ui/MonacoEditor/MonacoEditorCDN.tsx
@@ -0,0 +1,30 @@
+"use client";
+
+import { Editor, EditorProps, loader } from "@monaco-editor/react";
+import { KeepLoader } from "../KeepLoader/KeepLoader";
+import { useEffect, useState } from "react";
+import { ErrorComponent } from "../ErrorComponent/ErrorComponent";
+
+const Loader = <KeepLoader loadingText="Loading Code Editor ..." />;
+
+export function MonacoEditorCDN(props: EditorProps) {
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    loader.init().catch((error: Error) => {
+      setError(error);
+    });
+  }, []);
+
+  if (error) {
+    return (
+      <ErrorComponent
+        error={error}
+        defaultMessage={`Error loading Monaco Editor from CDN`}
+        description="Check your internet connection and try again"
+      />
+    );
+  }
+
+  return <Editor {...props} loading={Loader} />;
+}
diff --git a/keep-ui/shared/ui/MonacoEditor/MonacoEditorNPM.tsx b/keep-ui/shared/ui/MonacoEditor/MonacoEditorNPM.tsx
new file mode 100644
index 0000000000..eaee6a94a4
--- /dev/null
+++ b/keep-ui/shared/ui/MonacoEditor/MonacoEditorNPM.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { Editor, EditorProps, loader } from "@monaco-editor/react";
+import * as monaco from "monaco-editor";
+import { KeepLoader } from "../KeepLoader/KeepLoader";
+import { useConfig } from "@/utils/hooks/useConfig";
+import { useEffect, useState } from "react";
+import { ErrorComponent } from "../ErrorComponent/ErrorComponent";
+
+// Monaco Editor - imported as an npm package instead of loading from the CDN to support air-gapped environments
+// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
+loader.config({ monaco });
+
+const Loader = <KeepLoader loadingText="Loading Code Editor ..." />;
+
+export function MonacoEditorNPM(props: EditorProps) {
+  const { data: config } = useConfig();
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    loader.init().catch((error: Error) => {
+      setError(error);
+    });
+  }, []);
+
+  if (error) {
+    return (
+      <ErrorComponent
+        error={error}
+        defaultMessage="Error loading Monaco Editor from NPM"
+        description={
+          <>
+            This should not happen. Please contact us on Slack
+            <a href={config.KEEP_CONTACT_US_URL} target="_blank">
+              {config.KEEP_CONTACT_US_URL}
+            </a>
+          </>
+        }
+      />
+    );
+  }
+
+  return <Editor {...props} loading={Loader} />;
+}
diff --git a/keep-ui/shared/ui/MonacoEditor/index.ts b/keep-ui/shared/ui/MonacoEditor/index.ts
new file mode 100644
index 0000000000..a35450f90c
--- /dev/null
+++ b/keep-ui/shared/ui/MonacoEditor/index.ts
@@ -0,0 +1,12 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+const MonacoEditor = dynamic(
+  () => import("./MonacoEditorNPM").then((mod) => mod.MonacoEditorNPM),
+  {
+    ssr: false,
+  }
+);
+
+export { MonacoEditor };
diff --git a/keep-ui/shared/ui/MonacoEditor/index.turbopack.ts b/keep-ui/shared/ui/MonacoEditor/index.turbopack.ts
new file mode 100644
index 0000000000..84b401418e
--- /dev/null
+++ b/keep-ui/shared/ui/MonacoEditor/index.turbopack.ts
@@ -0,0 +1,3 @@
+// This file is used to replace the default export for the MonacoEditor component
+// when using Turbopack in development mode
+export { MonacoEditorCDN as MonacoEditor } from "./MonacoEditorCDN";
diff --git a/keep-ui/shared/ui/YAMLCodeblock/ui/MonacoYAMLEditor.tsx b/keep-ui/shared/ui/YAMLCodeblock/ui/MonacoYAMLEditor.tsx
index c2965f58fe..71acc90991 100644
--- a/keep-ui/shared/ui/YAMLCodeblock/ui/MonacoYAMLEditor.tsx
+++ b/keep-ui/shared/ui/YAMLCodeblock/ui/MonacoYAMLEditor.tsx
@@ -1,10 +1,4 @@
 import React, { useCallback, useEffect, useRef, useState } from "react";
-// Monaco Editor - do not load from CDN (to support on-prem)
-// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
-import * as monaco from "monaco-editor";
-import { loader } from "@monaco-editor/react";
-loader.config({ monaco });
-import Editor from "@monaco-editor/react";
 import { type editor } from "monaco-editor";
 import { Download, Copy, Check, Save } from "lucide-react";
 import { Button } from "@tremor/react";
@@ -12,8 +6,8 @@ import { LogEntry } from "@/shared/api/workflow-executions";
 import { getStepStatus } from "@/shared/lib/logs-utils";
 import { useWorkflowActions } from "@/entities/workflows/model/useWorkflowActions";
 import { getOrderedWorkflowYamlString } from "@/entities/workflows/lib/yaml-utils";
+import { MonacoEditor } from "@/shared/ui";
 import "./MonacoYAMLEditor.css";
-
 interface Props {
   workflowRaw: string;
   workflowId?: string;
@@ -489,7 +483,7 @@ const MonacoYAMLEditor = ({
             <Download className="h-4 w-4" />
           </Button>
         </div>
-        <Editor
+        <MonacoEditor
           wrapperProps={{ "data-testid": dataTestId }}
           height="100%"
           defaultLanguage="yaml"
diff --git a/keep-ui/shared/ui/index.ts b/keep-ui/shared/ui/index.ts
index 95e6e9c8b6..5ada205a6d 100644
--- a/keep-ui/shared/ui/index.ts
+++ b/keep-ui/shared/ui/index.ts
@@ -22,7 +22,7 @@ export { ResizableColumns } from "./ResizableColumns";
 export { KeepLoader } from "./KeepLoader/KeepLoader";
 export { PageTitle } from "./PageTitle";
 export { PageSubtitle } from "./PageSubtitle";
-
+export { MonacoEditor } from "./MonacoEditor";
 export { showErrorToast } from "./utils/showErrorToast";
 export { showSuccessToast } from "./utils/showSuccessToast";
 export { DebugJSON } from "./DebugJSON";
diff --git a/keep-ui/utils/hooks/useConfig.ts b/keep-ui/utils/hooks/useConfig.ts
index e6367aa20a..744f4fb66f 100644
--- a/keep-ui/utils/hooks/useConfig.ts
+++ b/keep-ui/utils/hooks/useConfig.ts
@@ -9,6 +9,10 @@ export const useConfig = () => {
     throw new Error("useConfig must be used within a ConfigProvider");
   }
 
+  if (context === null) {
+    throw new Error("config is not passed to ConfigProvider");
+  }
+
   return {
     data: context,
   };
diff --git a/tests/e2e_tests/test_end_to_end.py b/tests/e2e_tests/test_end_to_end.py
index 2575e8c682..e6b6f8c902 100644
--- a/tests/e2e_tests/test_end_to_end.py
+++ b/tests/e2e_tests/test_end_to_end.py
@@ -516,3 +516,24 @@ def test_provider_deletion(browser: Page):
     except Exception:
         save_failure_artifacts(browser, log_entries)
         raise
+
+def test_monaco_editor_npm(browser: Page):
+    log_entries = []
+    setup_console_listener(browser, log_entries)
+    try:
+        init_e2e_test(browser, next_url="/signin")
+        browser.route("**/*", lambda route, request: 
+            route.abort() if not request.url.startswith("http://localhost") else route.continue_()
+        )
+        browser.get_by_role("link", name="Workflows").click()
+        browser.get_by_role("button", name="Upload Workflows").click()
+        file_input = browser.locator("#workflowFile")
+        file_input.set_input_files("./tests/e2e_tests/workflow-sample.yaml")
+        browser.get_by_role("button", name="Upload")
+        browser.wait_for_url(re.compile("http://localhost:3000/workflows/.*"))
+        browser.get_by_role("tab", name="YAML Definition").click()
+        editor_container = browser.get_by_test_id("wf-detail-yaml-editor-container")
+        expect(editor_container).not_to_contain_text("Error loading Monaco Editor from CDN")
+    except Exception:
+        save_failure_artifacts(browser, log_entries)
+        raise


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR adds support for loading Monaco Editor from NPM packages instead of CDN to support air-gapped environments, with several key changes to the codebase.

- Added new `MonacoEditorDefault` and `MonacoEditorWithNpm` components in `/keep-ui/shared/ui/MonacoEditor/` to handle both CDN and NPM loading strategies
- Added `MONACO_EDITOR_NPM` configuration flag in `getConfig.ts` to control editor loading method
- Refactored workflow state management with new `isDeployed` flag and proper null handling for timestamps
- Replaced direct Monaco editor imports across multiple components with the new shared MonacoEditor component
- Modified `next.config.js` to support both webpack and turbo aliases for Monaco Editor NPM loading

<sub>21 file(s) reviewed, 6 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=keep_4105)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T08:20:53Z
user: login: shahargl
body: lgtm
state: APPROVED
author_association: MEMBER
submitted_at: 2025-03-19T11:07:55Z
user: login: skynetigor
body: LGTM
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-19T11:08:37Z

Review comments:
diff_hunk: @@ -72,7 +64,7 @@ export const ViewAlertModal: React.FC<ViewAlertModalProps> = ({
   );
   const api = useApi();
   const editorRef = useRef<any>(null);
-  const monacoRef = useRef<typeof monacoEditor | null>(null);
+  const monacoRef = useRef<Monaco | null>(null);
path: keep-ui/app/(keep)/alerts/ViewAlertModal.tsx
user: login: greptile-apps[bot]
body: style: editorRef type should be more specific than 'any' - consider using editor.IStandaloneCodeEditor from monaco-editor

```suggestion
  const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
```
created_at: 2025-03-19T08:17:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -6,7 +6,7 @@
   "scripts": {
     "build": "./next_build.sh",
     "dev": "next dev --turbopack -p 3000",
-    "dev:turbo": "NEXT_TURBO=true next dev -p 3000",
+    "dev:webpack": "next dev -p 3000",
path: keep-ui/package.json
user: login: greptile-apps[bot]
body: logic: Default dev script still uses turbopack while webpack alternative is provided. This could cause confusion since the issue occurs with turbopack.
created_at: 2025-03-19T08:18:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,42 @@
+"use client";
+
+import { Editor, EditorProps, loader } from "@monaco-editor/react";
+import { KeepLoader } from "../KeepLoader/KeepLoader";
+import { useEffect, useState } from "react";
+import { ErrorComponent } from "../ErrorComponent/ErrorComponent";
+
+const Loader = <KeepLoader loadingText="Loading Code Editor ..." />;
+
+export function MonacoEditorDefault(props: EditorProps) {
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    const loadMonaco = async () => {
+      loader.init().catch((error: any) => {
+        setError(error);
path: keep-ui/shared/ui/MonacoEditor/MonacoEditorDefault.tsx
user: login: greptile-apps[bot]
body: style: error type should be explicitly defined instead of using 'any' to ensure proper error handling

```suggestion
      loader.init().catch((error: Error) => {
        setError(error);
```
created_at: 2025-03-19T08:19:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,13 @@
+"use client";
+
+import dynamic from "next/dynamic";
+
+// this export is used to replace default export in index.ts if KEEP_MONACO_EDITOR_NPM=true
path: keep-ui/shared/ui/MonacoEditor/index.npm.ts
user: login: greptile-apps[bot]
body: logic: Environment variable name in comment (KEEP_MONACO_EDITOR_NPM) differs from what's mentioned in PR description (BUILD_KEEP_MONACO_AS_NPM). This inconsistency should be resolved.
created_at: 2025-03-19T08:19:25Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+"use client";
+
+import { Editor, EditorProps, loader } from "@monaco-editor/react";
+import * as monaco from "monaco-editor";
+import { KeepLoader } from "../KeepLoader/KeepLoader";
+import { useConfig } from "@/utils/hooks/useConfig";
+import { useEffect, useState } from "react";
+import { ErrorComponent } from "../ErrorComponent/ErrorComponent";
+
+// Monaco Editor - imported as an npm package instead of loading from the CDN to support air-gapped environments
+// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
+loader.config({ monaco });
+
+const Loader = <KeepLoader loadingText="Loading Code Editor ..." />;
+
+export function MonacoEditorWithNpm(props: EditorProps) {
+  const { data: config } = useConfig();
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    loader.init().catch((error: any) => {
+      setError(error);
path: keep-ui/shared/ui/MonacoEditor/MonacoEditorWithNpm.tsx
user: login: greptile-apps[bot]
body: style: error is typed as 'any'. Should explicitly type the error or use Error type for better type safety.

```suggestion
    loader.init().catch((error: Error) => {
      setError(error);
```
created_at: 2025-03-19T08:19:31Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,46 @@
+"use client";
+
+import { Editor, EditorProps, loader } from "@monaco-editor/react";
+import * as monaco from "monaco-editor";
+import { KeepLoader } from "../KeepLoader/KeepLoader";
+import { useConfig } from "@/utils/hooks/useConfig";
+import { useEffect, useState } from "react";
+import { ErrorComponent } from "../ErrorComponent/ErrorComponent";
+
+// Monaco Editor - imported as an npm package instead of loading from the CDN to support air-gapped environments
+// https://github.com/suren-atoyan/monaco-react?tab=readme-ov-file#use-monaco-editor-as-an-npm-package
+loader.config({ monaco });
+
+const Loader = <KeepLoader loadingText="Loading Code Editor ..." />;
+
+export function MonacoEditorWithNpm(props: EditorProps) {
+  const { data: config } = useConfig();
+  const [error, setError] = useState<Error | null>(null);
+
+  useEffect(() => {
+    loader.init().catch((error: any) => {
+      setError(error);
+    });
+  }, []);
+
+  if (error) {
+    return (
+      <ErrorComponent
+        error={error}
+        defaultMessage={`Error loading Monaco Editor from ${config.MONACO_EDITOR_NPM ? "NPM" : "CDN"}`}
+        description={
+          <>
+            Check internet connection. If you are using Keep in an air-gapped
+            environment, set
+            <code className="text-gray-600 text-left bg-gray-100 px-2 py-1 mx-2 rounded-md">
+              MONACO_EDITOR_NPM=true
+            </code>
+            in keep-frontend environment variables.
+          </>
+        }
+      />
+    );
+  }
+
+  return true ? <Editor {...props} loading={Loader} /> : Loader;
path: keep-ui/shared/ui/MonacoEditor/MonacoEditorWithNpm.tsx
user: login: greptile-apps[bot]
body: logic: ternary operator with hardcoded 'true' condition makes the Loader branch unreachable. Should use a loading state instead.

```suggestion
  return <Editor {...props} loading={Loader} />;
```
created_at: 2025-03-19T08:19:32Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-18T13:56:44Z
body: [vc]: #BiNhS/rTaoEqlKPc5c+jR19+oysEK9bpTeNMZrPNrSI=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJrZWVwIiwiaW5zcGVjdG9yVXJsIjoiaHR0cHM6Ly92ZXJjZWwuY29tL2tlZXBocS9rZWVwL0d5cUQ5NURUMXdxY3J2U2NKWFlMRFd6blNNZG0iLCJwcmV2aWV3VXJsIjoia2VlcC1naXQtZml4LTQxMDQtbW9uYWNvLWVkaXRvci1vcHRpb25hbC1ucG0tbG9hZGVyLWtlZXBocS52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwibGl2ZUZlZWRiYWNrIjp7InJlc29sdmVkIjowLCJ1bnJlc29sdmVkIjowLCJ0b3RhbCI6MCwibGluayI6ImtlZXAtZ2l0LWZpeC00MTA0LW1vbmFjby1lZGl0b3Itb3B0aW9uYWwtbnBtLWxvYWRlci1rZWVwaHEudmVyY2VsLmFwcCJ9LCJyb290RGlyZWN0b3J5Ijoia2VlcC11aSJ9XX0=
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **keep** | ✅ Ready ([Inspect](https://vercel.com/keephq/keep/GyqD95DT1wqcrvScJXYLDWznSMdm)) | [Visit Preview](https://keep-git-fix-4104-monaco-editor-optional-npm-loader-keephq.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/keep-git-fix-4104-monaco-editor-optional-npm-loader-keephq.vercel.app?via=pr-comment-feedback-link) | Mar 19, 2025 10:48am |


author_association: NONE
user: login: Kiryous
created_at: 2025-03-19T08:16:49Z
body: @greptileai

author_association: CONTRIBUTOR