diff --git a/ui/.env b/ui/.env index 93492c75..0978bf23 100644 --- a/ui/.env +++ b/ui/.env @@ -1,3 +1,4 @@ VITE_FLOW_TRANSLATOR_BASE_URL=http://localhost:8080 VITE_KEIP_ASSISTANT_DOCS_URL=https://github.com/codice/keip-canvas/blob/main/assistant/README.md -VITE_KEIP_ASSISTANT_OLLAMA_URL=http://localhost:11434 \ No newline at end of file +VITE_KEIP_ASSISTANT_OLLAMA_URL=http://localhost:11434 +VITE_KEIP_K8S_CLUSTER_URL=http://localhost:7080 \ No newline at end of file diff --git a/ui/package-lock.json b/ui/package-lock.json index 8350498e..fd98fc46 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -50,7 +50,7 @@ "sass": "^1.89.2", "typescript": "~5.6.3", "typescript-eslint": "^8.34.0", - "vite": "^6.3.5", + "vite": "^6.3.6", "vitest": "^3.2.3" }, "engines": { @@ -7636,9 +7636,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.6.tgz", + "integrity": "sha512-0msEVHJEScQbhkbVTb/4iHZdJ6SXp/AvxL2sjwYQFfBqleHtnCqv1J3sa9zbWz/6kW1m9Tfzn92vW+kZ1WV6QA==", "dev": true, "dependencies": { "esbuild": "^0.25.0", diff --git a/ui/package.json b/ui/package.json index d9b0e61e..71027d9d 100644 --- a/ui/package.json +++ b/ui/package.json @@ -61,7 +61,7 @@ "sass": "^1.89.2", "typescript": "~5.6.3", "typescript-eslint": "^8.34.0", - "vite": "^6.3.5", + "vite": "^6.3.6", "vitest": "^3.2.3" }, "overrides": { diff --git a/ui/src/components/endpoints/deploy/keipClient.ts b/ui/src/components/endpoints/deploy/keipClient.ts new file mode 100644 index 00000000..beac3cb9 --- /dev/null +++ b/ui/src/components/endpoints/deploy/keipClient.ts @@ -0,0 +1,53 @@ +import { K8S_CLUSTER_URL } from "../../../singletons/externalEndpoints" +import fetchWithTimeout from "../../../utils/fetch/fetchWithTimeout" + +interface Route { + name: string + namespace: string + xml: string +} + +interface Resource { + name: string + status: "created" | "deleted" | "updated" | "recreated" +} + +interface RouteDeployRequest { + routes: Route[] +} + +type RouteDeployResponse = Resource[] + +class KeipClient { + public serverBaseUrl = K8S_CLUSTER_URL + + public ping(): Promise { + const status = fetchWithTimeout(`${this.serverBaseUrl}/status`, { + timeout: 5000, + }) + .then((res) => res.ok) + .catch(() => false) + return status + } + + public async deploy( + request: RouteDeployRequest + ): Promise { + const response = await fetchWithTimeout(`${this.serverBaseUrl}/route`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(request), + timeout: 10000, + }) + + if (!response.ok) { + throw new Error(`Failed to deploy route: ${response.status}`) + } + + return (await response.json()) as RouteDeployResponse + } +} + +export const keipClient = new KeipClient() diff --git a/ui/src/components/endpoints/deploy/keipClientStatusHook.ts b/ui/src/components/endpoints/deploy/keipClientStatusHook.ts new file mode 100644 index 00000000..926f8f4d --- /dev/null +++ b/ui/src/components/endpoints/deploy/keipClientStatusHook.ts @@ -0,0 +1,28 @@ +import { useEffect, useState } from "react" +import { keipClient } from "./keipClient" + +const logKeipClientStatus = (available: boolean) => { + if (available) { + console.log(`A KEIP Controller is available at ${keipClient.serverBaseUrl}`) + } else { + console.log( + `Could not connect to a KEIP Controller at ${keipClient.serverBaseUrl}` + ) + } +} + +const useKeipClientStatus = () => { + const [isAvailable, setIsAvailable] = useState(false) + + useEffect(() => { + void (async () => { + const status = await keipClient.ping() + logKeipClientStatus(status) + setIsAvailable(status) + })() + }, []) + + return isAvailable +} + +export default useKeipClientStatus diff --git a/ui/src/components/endpoints/translation/translateFlowToXml.ts b/ui/src/components/endpoints/translation/translateFlowToXml.ts new file mode 100644 index 00000000..98f7d813 --- /dev/null +++ b/ui/src/components/endpoints/translation/translateFlowToXml.ts @@ -0,0 +1,44 @@ +import { EipFlow } from "../../../api/generated/eipFlow" + +// highlight.js theme +import "highlight.js/styles/intellij-light.css" +import { FLOW_TRANSLATOR_BASE_URL } from "../../../singletons/externalEndpoints" +import fetchWithTimeout from "../../../utils/fetch/fetchWithTimeout" + +interface FlowTranslationResponse { + data?: string + error?: { + message: string + type: string + details: object[] + } +} + +// TODO: Add client-side caching (might make sense to use a data fetching library) +export const fetchXmlTranslation = async ( + flow: EipFlow, + abortCtrl: AbortController +) => { + const queryStr = new URLSearchParams({ prettyPrint: "true" }).toString() + const response = await fetchWithTimeout( + `${FLOW_TRANSLATOR_BASE_URL}/translation/toSpringXml?` + queryStr, + { + method: "POST", + body: JSON.stringify(flow), + headers: { + "Content-Type": "application/json", + }, + timeout: 20000, + abortCtrl, + } + ) + + const { data, error } = (await response.json()) as FlowTranslationResponse + + if (!response.ok) { + console.error("Failed to convert diagram to XML:", error) + throw new Error(JSON.stringify(error)) + } + + return data! +} diff --git a/ui/src/components/toolbar/xml/translatorStatusHook.ts b/ui/src/components/endpoints/translation/translatorStatusHook.ts similarity index 100% rename from ui/src/components/toolbar/xml/translatorStatusHook.ts rename to ui/src/components/endpoints/translation/translatorStatusHook.ts diff --git a/ui/src/components/options-menu/OptionsMenu.tsx b/ui/src/components/options-menu/OptionsMenu.tsx index a60e144d..dd067585 100644 --- a/ui/src/components/options-menu/OptionsMenu.tsx +++ b/ui/src/components/options-menu/OptionsMenu.tsx @@ -1,12 +1,18 @@ import { OverflowMenu, OverflowMenuItem } from "@carbon/react" import { Menu } from "@carbon/react/icons" import { useState } from "react" +import useKeipClientStatus from "../endpoints/deploy/keipClientStatusHook" +import useTranslationServerStatus from "../endpoints/translation/translatorStatusHook" import ExportPng from "./ExportPng" import SaveDiagram from "./SaveDiagram" +import { DeployRouteModal } from "./modals/DeployRouteModal" import { ImportFlowModal } from "./modals/ImportFlowModal" const OptionsMenu = () => { const [importFlowModalOpen, setImportFlowModalOpen] = useState(false) + const [deployModalOpen, setDeployModalOpen] = useState(false) + const isKeipWebhookAvailable = useKeipClientStatus() + const isTranslationServerAvailable = useTranslationServerStatus() return ( <> @@ -26,6 +32,13 @@ const OptionsMenu = () => { itemText="Import Flow JSON" onClick={() => setImportFlowModalOpen(true)} /> + + {/* Route deployment requires both the flow translator and Keip controller endpoints to be available */} + setDeployModalOpen(true)} + disabled={!isKeipWebhookAvailable || !isTranslationServerAvailable} + /> {/* Modals */} @@ -33,6 +46,7 @@ const OptionsMenu = () => { open={importFlowModalOpen} setOpen={setImportFlowModalOpen} /> + ) } diff --git a/ui/src/components/options-menu/modals/DeployRouteModal.tsx b/ui/src/components/options-menu/modals/DeployRouteModal.tsx new file mode 100644 index 00000000..6f94b5c7 --- /dev/null +++ b/ui/src/components/options-menu/modals/DeployRouteModal.tsx @@ -0,0 +1,106 @@ +import { Modal, Stack, TextInput } from "@carbon/react" +import { InlineLoadingStatus } from "@carbon/react/lib/components/InlineLoading/InlineLoading" +import { useState } from "react" +import { createPortal } from "react-dom" +import { getEipFlow } from "../../../singletons/store/storeViews" +import { keipClient } from "../../endpoints/deploy/keipClient" +import { fetchXmlTranslation } from "../../endpoints/translation/translateFlowToXml" + +interface DeployRouteModalProps { + open: boolean + setOpen: (open: boolean) => void +} + +const getLoadingDescription = (status: InlineLoadingStatus) => { + switch (status) { + case "active": + return "Deploying Route" + case "error": + return "Deploy Failed" + case "finished": + return "Deployed" + case "inactive": + return "" + } +} + +// TODO: Display status for deployed routes once Keip controller endpoint adds support for status checks. +// TODO: Store deployed route name and namespace in the AppStore to survive refreshes. +export const DeployRouteModal = ({ open, setOpen }: DeployRouteModalProps) => { + const [loadingStatus, setLoadingStatus] = + useState("inactive") + + const [name, setName] = useState("") + const [namespace, setNamespace] = useState("default") + + const onClose = () => { + setOpen(false) + loadingStatus !== "active" && setLoadingStatus("inactive") + } + + const handleDeploy = () => { + setLoadingStatus("active") + deployDiagram() + .then(() => setLoadingStatus("finished")) + .catch((err) => { + console.error(err) + setLoadingStatus("error") + }) + } + + const deployDiagram = async () => { + const flow = getEipFlow() + + if (flow.nodes?.length === 0) { + throw new Error("Failed to deploy - diagram is empty") + } + + const xml = await fetchXmlTranslation(flow, new AbortController()) + + if (!xml) { + throw new Error("Failed to deploy - translated XML is empty") + } + + const request = { + routes: [ + { + name, + namespace, + xml, + }, + ], + } + + await keipClient.deploy(request) + } + + return createPortal( + + + setName(e.target.value)} + /> + setNamespace(e.target.value)} + /> + + , + document.body + ) +} diff --git a/ui/src/components/toolbar/ToolbarMenu.tsx b/ui/src/components/toolbar/ToolbarMenu.tsx index b07a2cd7..d3fb4b1a 100644 --- a/ui/src/components/toolbar/ToolbarMenu.tsx +++ b/ui/src/components/toolbar/ToolbarMenu.tsx @@ -5,7 +5,7 @@ import { KEIP_ASSISTANT_DOCS_URL } from "../../singletons/externalEndpoints" import AssistantChatPanel from "./assistant/AssistantChatPanel" import { useLlmServerStatus } from "./assistant/llmStatusHook" import XmlPanel from "./xml/XmlPanel" -import useTranslationServerStatus from "./xml/translatorStatusHook" +import useTranslationServerStatus from "../endpoints/translation/translatorStatusHook" interface PanelButtonProps { name: string diff --git a/ui/src/components/toolbar/xml/XmlPanel.tsx b/ui/src/components/toolbar/xml/XmlPanel.tsx index 6f16d68c..8fb98db2 100644 --- a/ui/src/components/toolbar/xml/XmlPanel.tsx +++ b/ui/src/components/toolbar/xml/XmlPanel.tsx @@ -3,27 +3,16 @@ import hljs from "highlight.js/lib/core" import xml from "highlight.js/lib/languages/xml" import { useEffect, useState } from "react" import Editor from "react-simple-code-editor" -import { EipFlow } from "../../../api/generated/eipFlow" import { useEipFlow } from "../../../singletons/store/diagramToEipFlow" // highlight.js theme import "highlight.js/styles/intellij-light.css" -import { FLOW_TRANSLATOR_BASE_URL } from "../../../singletons/externalEndpoints" -import fetchWithTimeout from "../../../utils/fetch/fetchWithTimeout" +import { fetchXmlTranslation } from "../../endpoints/translation/translateFlowToXml" const UNMOUNT_SIGNAL = "unmount" hljs.registerLanguage("xml", xml) -interface FlowTranslationResponse { - data?: string - error?: { - message: string - type: string - details: object[] - } -} - type InlineLoadingProps = React.ComponentProps const getLoadingStatus = ( @@ -38,35 +27,6 @@ const getLoadingStatus = ( : { status: "finished", description: "synced" } } -// TODO: Add client-side caching (might make sense to use a data fetching library) -const fetchXmlTranslation = async ( - flow: EipFlow, - abortCtrl: AbortController -) => { - const queryStr = new URLSearchParams({ prettyPrint: "true" }).toString() - const response = await fetchWithTimeout( - `${FLOW_TRANSLATOR_BASE_URL}/translation/toSpringXml?` + queryStr, - { - method: "POST", - body: JSON.stringify(flow), - headers: { - "Content-Type": "application/json", - }, - timeout: 20000, - abortCtrl, - } - ) - - const { data, error } = (await response.json()) as FlowTranslationResponse - - if (!response.ok) { - console.error("Failed to convert diagram to XML:", error) - throw new Error(JSON.stringify(error)) - } - - return data! -} - const XmlPanel = () => { const [content, setContent] = useState("") const [isLoading, setLoading] = useState(false) diff --git a/ui/src/singletons/externalEndpoints.ts b/ui/src/singletons/externalEndpoints.ts index 5a4226e1..53c89a88 100644 --- a/ui/src/singletons/externalEndpoints.ts +++ b/ui/src/singletons/externalEndpoints.ts @@ -6,3 +6,5 @@ export const KEIP_ASSISTANT_DOCS_URL = import.meta.env export const KEIP_ASSISTANT_OLLAMA_URL = import.meta.env .VITE_KEIP_ASSISTANT_OLLAMA_URL + +export const K8S_CLUSTER_URL = import.meta.env.VITE_KEIP_K8S_CLUSTER_URL diff --git a/ui/src/singletons/store/diagramToEipFlow.ts b/ui/src/singletons/store/diagramToEipFlow.ts index f1af52cd..2c93b780 100644 --- a/ui/src/singletons/store/diagramToEipFlow.ts +++ b/ui/src/singletons/store/diagramToEipFlow.ts @@ -59,7 +59,7 @@ export const useEipFlow = () => * @param state the AppStore's current state object * @returns An EipFlow */ -const diagramToEipFlow = (state: AppStore): EipFlow => { +export const diagramToEipFlow = (state: AppStore): EipFlow => { const nodeLookup = createNodeLookupMap(state.nodes) const routerChildMap = new Map() diff --git a/ui/src/singletons/store/storeViews.ts b/ui/src/singletons/store/storeViews.ts index 3d1f1a22..c2f50f74 100644 --- a/ui/src/singletons/store/storeViews.ts +++ b/ui/src/singletons/store/storeViews.ts @@ -1,6 +1,7 @@ import { CustomEdge, CustomNode, Layout } from "../../api/flow" -import { EipId } from "../../api/generated/eipFlow" +import { EipFlow, EipId } from "../../api/generated/eipFlow" import { useAppStore } from "./appStore" +import { diagramToEipFlow } from "./diagramToEipFlow" interface ChildTraversalItem { id: string @@ -34,6 +35,9 @@ export const getCustomEntityContent = ( ): Readonly | undefined => useAppStore.getState().customEntities[entityId] +export const getEipFlow = (): Readonly => + diagramToEipFlow(useAppStore.getState()) + export const childrenBreadthTraversal = function* ( rootId: string ): Generator { diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts index e82486d0..3ff9d328 100644 --- a/ui/src/vite-env.d.ts +++ b/ui/src/vite-env.d.ts @@ -4,6 +4,7 @@ interface ImportMetaEnv { readonly VITE_FLOW_TRANSLATOR_BASE_URL: string readonly VITE_KEIP_ASSISTANT_DOCS_URL: string readonly VITE_KEIP_ASSISTANT_OLLAMA_URL: string + readonly VITE_KEIP_K8S_CLUSTER_URL: string } interface ImportMeta {