From cd4e28bba00abf04538a5afaf80e25cc714e3d46 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 28 Aug 2026 15:14:13 +0200 Subject: [PATCH 1/6] feat(greenhouse): add external plugins Signed-off-by: Arturo Reuschenbach Puncernau --- .../workflows/build-push-template-image.yaml | 103 ++++++++++++++++++ apps/greenhouse/src/components/Extension.tsx | 15 ++- apps/greenhouse/src/hooks/usePluginLoader.ts | 91 +++++++++++----- apps/greenhouse/src/routes/__root.tsx | 24 +++- apps/template/docker/Dockerfile | 35 ++++++ 5 files changed, 240 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/build-push-template-image.yaml create mode 100644 apps/template/docker/Dockerfile diff --git a/.github/workflows/build-push-template-image.yaml b/.github/workflows/build-push-template-image.yaml new file mode 100644 index 0000000000..1dcec0728e --- /dev/null +++ b/.github/workflows/build-push-template-image.yaml @@ -0,0 +1,103 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors +# SPDX-License-Identifier: Apache-2.0 + +name: Build Template Plugin 🔧 + +on: + workflow_dispatch: {} + pull_request: + paths: + - apps/template/** + - .github/workflows/build-push-template-image.yaml + +env: + REGISTRY: ghcr.io + IMAGE_NAME: "juno-plugin-template" + PACKAGE_PATH: "apps/template" + DESCRIPTION: "Juno Template Plugin - External plugin PoC for Greenhouse" + +jobs: + build-and-push: + name: Build and Push Template Plugin Image + runs-on: [ubuntu-latest] + permissions: + contents: read + packages: write + id-token: write + security-events: write + + steps: + - name: Checkout repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - name: Read version from package.json + id: read_version + working-directory: ${{ env.PACKAGE_PATH }} + run: | + TEMPLATE_VERSION=$(jq -r '.version' package.json) + echo "Template version is $TEMPLATE_VERSION" + echo "IMAGE_VERSION=$TEMPLATE_VERSION" >> $GITHUB_OUTPUT + + # Login against a Docker registry + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Check if the image with this SHA already exists + - name: Check if image exists in registry + id: check-image + continue-on-error: true + run: | + if docker pull ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:${{ github.sha }}; then + echo "Image with SHA ${{ github.sha }} already exists in the registry" + echo "exists=true" >> $GITHUB_OUTPUT + else + echo "exists=false" >> $GITHUB_OUTPUT + fi + + # Set up Docker Buildx + - name: Set up Docker Buildx + if: steps.check-image.outputs.exists != 'true' + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + with: + driver-opts: | + image=moby/buildkit:latest + + - name: Generate Docker metadata + id: meta + if: steps.check-image.outputs.exists != 'true' + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} + tags: | + type=semver,pattern={{major}}.{{minor}}.{{patch}},value=${{ steps.read_version.outputs.IMAGE_VERSION }} + type=sha,enable=true,format=short,prefix= + type=raw,value=${{ github.sha }} + type=ref,event=pr + labels: | + org.opencontainers.image.description=${{env.DESCRIPTION}} + org.opencontainers.image.title=Juno-Template-Plugin + org.opencontainers.image.url=https://github.com/cloudoperators/juno/tree/main/apps/template + org.opencontainers.image.source=https://github.com/cloudoperators/juno/tree/main/apps/template + annotations: | + manifest:org.opencontainers.image.description=${{env.DESCRIPTION}} + manifest:org.opencontainers.image.title=Juno-Template-Plugin + manifest:org.opencontainers.image.source=https://github.com/cloudoperators/juno/tree/main/apps/template + manifest:org.opencontainers.image.url=https://github.com/cloudoperators/juno/tree/main/apps/template + + # Build and push Docker image + - name: Build and push Docker image ${{ steps.meta.outputs.tags }} + id: build-image + if: steps.check-image.outputs.exists != 'true' + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + file: ${{ env.PACKAGE_PATH }}/docker/Dockerfile + provenance: false + diff --git a/apps/greenhouse/src/components/Extension.tsx b/apps/greenhouse/src/components/Extension.tsx index 015ddcb025..80d407ca73 100644 --- a/apps/greenhouse/src/components/Extension.tsx +++ b/apps/greenhouse/src/components/Extension.tsx @@ -16,11 +16,16 @@ type ExtensionProps = { } function Extension({ id, config, appProps, pluginAuth }: ExtensionProps) { - const { isLoading, containerRef } = usePluginLoader({ + // PoC: Hardcode template plugin path + // TODO: Replace with dynamic plugin registry (usePluginConfig hook) + const pluginPath = config.name === "template" ? "/plugins/template/index.js" : undefined + + const { isLoading, error, containerRef } = usePluginLoader({ pluginName: config.name, config, appProps, pluginAuth, + pluginPath, }) if (isLoading) { @@ -31,6 +36,14 @@ function Extension({ id, config, appProps, pluginAuth }: ExtensionProps) { ) } + if (error) { + return ( +
+
Error loading plugin {config.name}: {error.message}
+
+ ) + } + return
} diff --git a/apps/greenhouse/src/hooks/usePluginLoader.ts b/apps/greenhouse/src/hooks/usePluginLoader.ts index ecbb208c8c..4f77ab1b8b 100644 --- a/apps/greenhouse/src/hooks/usePluginLoader.ts +++ b/apps/greenhouse/src/hooks/usePluginLoader.ts @@ -12,24 +12,46 @@ import type { PluginModule } from "@cloudoperators/juno-app-supernova" // Cache loaded modules at the module level (persists across component mounts) const moduleCache = new Map() -const getApp = async (appName: string): Promise => { +const getApp = async ( + appName: string, + pluginPath?: string +): Promise => { // Return cached module immediately if available if (moduleCache.has(appName)) { return moduleCache.get(appName)! } - // Load the module let module: PluginModule | null = null - switch (appName) { - case "supernova": - module = await import("@cloudoperators/juno-app-supernova") - break - case "doop": - module = await import("@cloudoperators/juno-app-doop") - break - case "heureka": - module = await import("@cloudoperators/juno-app-heureka") - break + + // Try loading from plugin path (relative path = same origin via Ingress, no CORS!) + if (pluginPath) { + try { + console.log(`Loading plugin ${appName} from ${pluginPath}`) + // @vite-ignore allows dynamic import of paths/URLs + module = await import(/* @vite-ignore */ pluginPath) + console.log(`Successfully loaded plugin ${appName}`) + } catch (error) { + console.error(`Failed to load plugin ${appName} from ${pluginPath}:`, error) + // Continue to fallback logic + } + } + + // Fallback to bundled apps (backward compatibility) + if (!module) { + switch (appName) { + case "supernova": + module = await import("@cloudoperators/juno-app-supernova") + break + case "doop": + module = await import("@cloudoperators/juno-app-doop") + break + case "heureka": + module = await import("@cloudoperators/juno-app-heureka") + break + default: + console.warn(`Unknown plugin: ${appName}, no fallback available`) + return null + } } // Cache it for next time @@ -45,22 +67,27 @@ type UsePluginLoaderParams = { config: any appProps: AppProps pluginAuth: AuthStore + pluginPath?: string // NEW: Path to load plugin from (relative = same origin, absolute = external CDN) } type UsePluginLoaderResult = { isLoading: boolean + error: Error | null // NEW: Track loading errors containerRef: React.RefObject } /** * Custom hook to handle plugin loading and mounting * Loads plugins dynamically with caching and handles mount/unmount lifecycle + * Supports both bundled plugins and remote plugins + * Remote plugins use relative paths (same origin via Ingress) - no CORS needed! */ export function usePluginLoader({ pluginName, config, appProps, pluginAuth, + pluginPath, // NEW }: UsePluginLoaderParams): UsePluginLoaderResult { const router = useRouter() const containerRef = useRef(null) @@ -69,6 +96,7 @@ export function usePluginLoader({ const cachedModule = moduleCache.get(pluginName) const [app, setApp] = useState(cachedModule || null) const [isLoading, setIsLoading] = useState(!cachedModule) // Only show loading if not cached + const [error, setError] = useState(null) // NEW // Load the plugin module dynamically (only if not already loaded) useEffect(() => { @@ -80,8 +108,9 @@ export function usePluginLoader({ const loadApp = async () => { setIsLoading(true) + setError(null) try { - const appModule = await getApp(pluginName) + const appModule = await getApp(pluginName, pluginPath) // Pass pluginPath if (!cancelled) { setApp(appModule) setIsLoading(false) @@ -89,8 +118,9 @@ export function usePluginLoader({ } catch (error) { if (!cancelled) { setIsLoading(false) + setError(error instanceof Error ? error : new Error(String(error))) + console.error(`Error loading plugin ${pluginName}:`, error) } - throw error } } @@ -99,7 +129,7 @@ export function usePluginLoader({ return () => { cancelled = true } - }, [pluginName, cachedModule]) + }, [pluginName, pluginPath, cachedModule]) // Mount the app once it's loaded useEffect(() => { @@ -107,20 +137,29 @@ export function usePluginLoader({ return } - app.mount(containerRef.current, { - props: { - ...config.props, - embedded: true, - basePath: `${router.basepath === "/" ? "" : router.basepath}/${config.id}`, - enableHashedRouting: appProps?.enableHashedRouting || false, - auth: pluginAuth, - }, - }) + try { + app.mount(containerRef.current, { + props: { + ...config.props, + embedded: true, + basePath: `${router.basepath === "/" ? "" : router.basepath}/${config.id}`, + enableHashedRouting: appProps?.enableHashedRouting || false, + auth: pluginAuth, + }, + }) + } catch (error) { + console.error(`Error mounting plugin ${pluginName}:`, error) + setError(error instanceof Error ? error : new Error(String(error))) + } return () => { - app.unmount() + try { + app.unmount() + } catch (error) { + console.error(`Error unmounting plugin ${pluginName}:`, error) + } } }, [app, config, router, pluginAuth, appProps]) - return { isLoading, containerRef } + return { isLoading, error, containerRef } } diff --git a/apps/greenhouse/src/routes/__root.tsx b/apps/greenhouse/src/routes/__root.tsx index 456458d33b..fae640205e 100644 --- a/apps/greenhouse/src/routes/__root.tsx +++ b/apps/greenhouse/src/routes/__root.tsx @@ -41,7 +41,29 @@ function RootComponent() { // fetch configs from kubernetes getPluginConfigs() .then((kubernetesConfig: any) => { - receiveConfig(kubernetesConfig) + // PoC: Add hardcoded template plugin config + const templateConfig = { + id: "template", + name: "template", + displayName: "Template (PoC)", + core: false, + version: "latest", + url: null, + weight: 999, // Show at the end + navType: "app", + navigable: true, + props: { + id: "template", + }, + } + + // Merge with Kubernetes configs + const allConfigs = { + ...kubernetesConfig, + template: templateConfig, + } + + receiveConfig(allConfigs) }) .catch((error: any) => { // error fetching configs diff --git a/apps/template/docker/Dockerfile b/apps/template/docker/Dockerfile new file mode 100644 index 0000000000..7ccca0bdfc --- /dev/null +++ b/apps/template/docker/Dockerfile @@ -0,0 +1,35 @@ +# SPDX-FileCopyrightText: 2024 SAP SE or an SAP affiliate company and Juno contributors +# SPDX-License-Identifier: Apache-2.0 + +FROM node:22-alpine as build + +LABEL org.opencontainers.image.source=https://github.com/cloudoperators/juno/tree/main/apps/template +LABEL org.opencontainers.image.description="This image contains a standalone runnable version of the Juno Template app." +LABEL org.opencontainers.image.licenses=Apache-2.0 + +WORKDIR /app + +ADD . . +RUN npm i -g pnpm + +RUN pnpm install && pnpx turbo build:static --filter @cloudoperators/juno-app-template + +FROM nginx:alpine + +WORKDIR /usr/share/nginx/html + +# Copy built app +COPY --from=build /app/apps/template/build /usr/share/nginx/html/ + +# Use simple nginx config (no CORS needed - served through Ingress) +RUN echo 'server {' > /etc/nginx/conf.d/default.conf && \ + echo ' listen 80;' >> /etc/nginx/conf.d/default.conf && \ + echo ' location / {' >> /etc/nginx/conf.d/default.conf && \ + echo ' root /usr/share/nginx/html;' >> /etc/nginx/conf.d/default.conf && \ + echo ' try_files $uri $uri/ /index.html;' >> /etc/nginx/conf.d/default.conf && \ + echo ' }' >> /etc/nginx/conf.d/default.conf && \ + echo '}' >> /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] From d27d9752def933092b618971adb9cf4ff3b0bd4b Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 28 Aug 2026 15:15:26 +0200 Subject: [PATCH 2/6] chore(greenhouse): prettier Signed-off-by: Arturo Reuschenbach Puncernau --- apps/greenhouse/src/components/Extension.tsx | 4 +++- apps/greenhouse/src/hooks/usePluginLoader.ts | 5 +---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/greenhouse/src/components/Extension.tsx b/apps/greenhouse/src/components/Extension.tsx index 80d407ca73..629f870692 100644 --- a/apps/greenhouse/src/components/Extension.tsx +++ b/apps/greenhouse/src/components/Extension.tsx @@ -39,7 +39,9 @@ function Extension({ id, config, appProps, pluginAuth }: ExtensionProps) { if (error) { return (
-
Error loading plugin {config.name}: {error.message}
+
+ Error loading plugin {config.name}: {error.message} +
) } diff --git a/apps/greenhouse/src/hooks/usePluginLoader.ts b/apps/greenhouse/src/hooks/usePluginLoader.ts index 4f77ab1b8b..26f53b74f0 100644 --- a/apps/greenhouse/src/hooks/usePluginLoader.ts +++ b/apps/greenhouse/src/hooks/usePluginLoader.ts @@ -12,10 +12,7 @@ import type { PluginModule } from "@cloudoperators/juno-app-supernova" // Cache loaded modules at the module level (persists across component mounts) const moduleCache = new Map() -const getApp = async ( - appName: string, - pluginPath?: string -): Promise => { +const getApp = async (appName: string, pluginPath?: string): Promise => { // Return cached module immediately if available if (moduleCache.has(appName)) { return moduleCache.get(appName)! From bbaa99a10362050b2a8f9ebd9d0d3c4e4c0fef76 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 28 Aug 2026 15:58:08 +0200 Subject: [PATCH 3/6] chore(template): docker with no static build Signed-off-by: Arturo Reuschenbach Puncernau --- apps/template/docker/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/template/docker/Dockerfile b/apps/template/docker/Dockerfile index 7ccca0bdfc..1c4a090f8f 100644 --- a/apps/template/docker/Dockerfile +++ b/apps/template/docker/Dockerfile @@ -12,7 +12,7 @@ WORKDIR /app ADD . . RUN npm i -g pnpm -RUN pnpm install && pnpx turbo build:static --filter @cloudoperators/juno-app-template +RUN pnpm install && pnpx turbo build --filter @cloudoperators/juno-app-template FROM nginx:alpine From cd26c013d276a6022e3db64d736318c7c3e2f66d Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 28 Aug 2026 16:08:35 +0200 Subject: [PATCH 4/6] chore(template): default nginx Signed-off-by: Arturo Reuschenbach Puncernau --- apps/template/docker/Dockerfile | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/template/docker/Dockerfile b/apps/template/docker/Dockerfile index 1c4a090f8f..77636e9727 100644 --- a/apps/template/docker/Dockerfile +++ b/apps/template/docker/Dockerfile @@ -21,14 +21,8 @@ WORKDIR /usr/share/nginx/html # Copy built app COPY --from=build /app/apps/template/build /usr/share/nginx/html/ -# Use simple nginx config (no CORS needed - served through Ingress) -RUN echo 'server {' > /etc/nginx/conf.d/default.conf && \ - echo ' listen 80;' >> /etc/nginx/conf.d/default.conf && \ - echo ' location / {' >> /etc/nginx/conf.d/default.conf && \ - echo ' root /usr/share/nginx/html;' >> /etc/nginx/conf.d/default.conf && \ - echo ' try_files $uri $uri/ /index.html;' >> /etc/nginx/conf.d/default.conf && \ - echo ' }' >> /etc/nginx/conf.d/default.conf && \ - echo '}' >> /etc/nginx/conf.d/default.conf +# Use default nginx config (serves files from /usr/share/nginx/html/) +# No custom config needed - default works fine EXPOSE 80 From 4477f6e856965956e0e5b6f77cc019e9130aab26 Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 28 Aug 2026 16:27:25 +0200 Subject: [PATCH 5/6] fix(template): define process.env and externalize react Signed-off-by: Arturo Reuschenbach Puncernau --- apps/template/docker/Dockerfile | 2 +- apps/template/vite.config.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/template/docker/Dockerfile b/apps/template/docker/Dockerfile index 77636e9727..91c32dc507 100644 --- a/apps/template/docker/Dockerfile +++ b/apps/template/docker/Dockerfile @@ -4,7 +4,7 @@ FROM node:22-alpine as build LABEL org.opencontainers.image.source=https://github.com/cloudoperators/juno/tree/main/apps/template -LABEL org.opencontainers.image.description="This image contains a standalone runnable version of the Juno Template app." +LABEL org.opencontainers.image.description="Juno Template plugin - ES module library for external plugin PoC" LABEL org.opencontainers.image.licenses=Apache-2.0 WORKDIR /app diff --git a/apps/template/vite.config.ts b/apps/template/vite.config.ts index 7c9f0e5888..d59821a0cf 100644 --- a/apps/template/vite.config.ts +++ b/apps/template/vite.config.ts @@ -14,6 +14,7 @@ export default defineConfig(({ mode }: { mode: string }): UserConfig => { define: { "process.env": {}, + "process.env.NODE_ENV": JSON.stringify(mode === "production" ? "production" : "development"), }, plugins: [tailwindcss(), react(), tsconfigPaths()], @@ -45,6 +46,16 @@ export default defineConfig(({ mode }: { mode: string }): UserConfig => { formats: ["es"], fileName: () => `index.js`, }, + + rollupOptions: { + external: ["react", "react-dom"], + output: { + globals: { + react: "React", + "react-dom": "ReactDOM", + }, + }, + }, }, } }) From 6adb28cff0d88adf1c6b2b3e399cbee2a59515dd Mon Sep 17 00:00:00 2001 From: Arturo Reuschenbach Puncernau Date: Fri, 28 Aug 2026 16:34:26 +0200 Subject: [PATCH 6/6] fix(template): bundle React instead of externalizing Signed-off-by: Arturo Reuschenbach Puncernau --- apps/template/vite.config.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/apps/template/vite.config.ts b/apps/template/vite.config.ts index d59821a0cf..3ebe9ecc34 100644 --- a/apps/template/vite.config.ts +++ b/apps/template/vite.config.ts @@ -47,13 +47,11 @@ export default defineConfig(({ mode }: { mode: string }): UserConfig => { fileName: () => `index.js`, }, + // For PoC: Bundle everything including React + // Production: Should externalize and use import maps rollupOptions: { - external: ["react", "react-dom"], output: { - globals: { - react: "React", - "react-dom": "ReactDOM", - }, + inlineDynamicImports: true, }, }, },