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..629f870692 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,16 @@ 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..26f53b74f0 100644
--- a/apps/greenhouse/src/hooks/usePluginLoader.ts
+++ b/apps/greenhouse/src/hooks/usePluginLoader.ts
@@ -12,24 +12,43 @@ 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 +64,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 +93,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 +105,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 +115,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 +126,7 @@ export function usePluginLoader({
return () => {
cancelled = true
}
- }, [pluginName, cachedModule])
+ }, [pluginName, pluginPath, cachedModule])
// Mount the app once it's loaded
useEffect(() => {
@@ -107,20 +134,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..91c32dc507
--- /dev/null
+++ b/apps/template/docker/Dockerfile
@@ -0,0 +1,29 @@
+# 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="Juno Template plugin - ES module library for external plugin PoC"
+LABEL org.opencontainers.image.licenses=Apache-2.0
+
+WORKDIR /app
+
+ADD . .
+RUN npm i -g pnpm
+
+RUN pnpm install && pnpx turbo build --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 default nginx config (serves files from /usr/share/nginx/html/)
+# No custom config needed - default works fine
+
+EXPOSE 80
+
+CMD ["nginx", "-g", "daemon off;"]
diff --git a/apps/template/vite.config.ts b/apps/template/vite.config.ts
index 7c9f0e5888..3ebe9ecc34 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,14 @@ export default defineConfig(({ mode }: { mode: string }): UserConfig => {
formats: ["es"],
fileName: () => `index.js`,
},
+
+ // For PoC: Bundle everything including React
+ // Production: Should externalize and use import maps
+ rollupOptions: {
+ output: {
+ inlineDynamicImports: true,
+ },
+ },
},
}
})