diff --git a/portals/ai-workspace/bff/internal/config/config.go b/portals/ai-workspace/bff/internal/config/config.go index 6d57f5ee6e..107d253a22 100644 --- a/portals/ai-workspace/bff/internal/config/config.go +++ b/portals/ai-workspace/bff/internal/config/config.go @@ -138,6 +138,13 @@ type ControlPlaneConfig struct { // TLSSkipVerify disables upstream certificate verification entirely. Last-resort // escape hatch for dev/demo only; prefer CAFile. TLSSkipVerify bool `koanf:"tls_skip_verify"` + // CloudURL is an optional second hop for Moesif analytics (wso2cloud platform-api). + // When set, /proxy/cloud/* is proxied there instead of the primary control + // plane. Include the /cloud path prefix (e.g. http://host:8081/cloud). + CloudURL string `koanf:"cloud_url"` + // CloudCAFile / CloudTLSSkipVerify apply only to CloudURL when that hop uses TLS. + CloudCAFile string `koanf:"cloud_ca_file"` + CloudTLSSkipVerify bool `koanf:"cloud_tls_skip_verify"` } // SessionConfig is [ai_workspace.session]: server-side session lifetime. @@ -334,6 +341,7 @@ func (c *Config) normalize() { c.Auth.Authorization.Mode = strings.ToLower(c.Auth.Authorization.Mode) c.ControlPlane.URL = strings.TrimRight(c.ControlPlane.URL, "/") + c.ControlPlane.CloudURL = strings.TrimRight(c.ControlPlane.CloudURL, "/") c.Auth.OIDC.Issuer = strings.TrimRight(c.Auth.OIDC.Issuer, "/") c.Cookie = CookieConfig{Name: cookieName, Secure: true, SameSite: "lax"} @@ -417,6 +425,22 @@ func (c *Config) validate() error { "Trust the upstream certificate with [control_plane] ca_file instead.") } + if c.ControlPlane.CloudURL != "" { + cu, err := url.Parse(c.ControlPlane.CloudURL) + if err != nil || (cu.Scheme != "http" && cu.Scheme != "https") || cu.Host == "" { + return fmt.Errorf("[control_plane] cloud_url must be an absolute http:// or https:// URL, got %q", c.ControlPlane.CloudURL) + } + if cu.Scheme == "http" { + if c.ControlPlane.CloudCAFile != "" || c.ControlPlane.CloudTLSSkipVerify { + return fmt.Errorf("[control_plane] cloud_ca_file / cloud_tls_skip_verify are set but cloud_url is http:// (no TLS on that hop)") + } + } + if cu.Scheme == "https" && c.ControlPlane.CloudTLSSkipVerify { + slog.Warn("[control_plane] cloud_tls_skip_verify = true — cloud upstream certificate verification is DISABLED. " + + "Trust the upstream certificate with [control_plane] cloud_ca_file instead.") + } + } + if c.Auth.OIDCEnabled() { if c.Auth.OIDC.Issuer == "" || c.Auth.OIDC.ClientID == "" || c.Auth.OIDC.ClientSecret == "" || c.Auth.OIDC.RedirectURL == "" { return fmt.Errorf("OIDC mode requires [auth.oidc] authority, client_id, client_secret and redirect_url") diff --git a/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go b/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go index 14baa64751..442ad8d3e1 100644 --- a/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go +++ b/portals/ai-workspace/bff/internal/proxy/reverse_proxy_test.go @@ -119,3 +119,29 @@ func TestReverseProxy_StripsBasePathAndPrefix(t *testing.T) { t.Errorf("upstream path = %q, want /api/v0.9/projects (base path + prefix not stripped)", gotPath) } } + +// Cloud analytics hop: browser calls /ai-workspace/proxy/cloud/analytics/id-token; +// CloudURL is http://host/cloud, so after stripping /proxy/cloud the upstream +// path must be /cloud/analytics/id-token. +func TestReverseProxy_CloudPrefixJoinsTargetPath(t *testing.T) { + var gotPath string + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.WriteHeader(http.StatusOK) + })) + defer backend.Close() + + target, _ := url.Parse(backend.URL + "/cloud") + rp := ReverseProxy(target, "/ai-workspace/proxy/cloud", backend.Client().Transport) + + req := httptest.NewRequest(http.MethodGet, "/ai-workspace/proxy/cloud/analytics/id-token", nil) + rec := httptest.NewRecorder() + rp.ServeHTTP(rec, WithToken(req, "tok")) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + if gotPath != "/cloud/analytics/id-token" { + t.Errorf("upstream path = %q, want /cloud/analytics/id-token", gotPath) + } +} diff --git a/portals/ai-workspace/bff/internal/server/handlers.go b/portals/ai-workspace/bff/internal/server/handlers.go index 7635021f4d..0af9256fe4 100644 --- a/portals/ai-workspace/bff/internal/server/handlers.go +++ b/portals/ai-workspace/bff/internal/server/handlers.go @@ -22,6 +22,7 @@ import ( "errors" "log/slog" "net/http" + "net/http/httputil" "net/url" "strings" "time" @@ -184,6 +185,21 @@ func (s *Server) handleOIDCCallback(w http.ResponseWriter, r *http.Request) { // it upstream. No server-side lookup is involved unless the token is an OIDC // access token that is near expiry and must be refreshed. func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { + s.serveProxy(s.proxy, w, r) +} + +// handleCloudProxy (/proxy/cloud/*) — same session cookie injection as +// handleProxy, but against the optional Moesif / cloud analytics upstream. +func (s *Server) handleCloudProxy(w http.ResponseWriter, r *http.Request) { + s.serveProxy(s.cloudProxy, w, r) +} + +func (s *Server) serveProxy(rp *httputil.ReverseProxy, w http.ResponseWriter, r *http.Request) { + if rp == nil { + http.NotFound(w, r) + return + } + jwt, ok := s.tokenFromCookie(r) if !ok { writeErrorJSON(w, http.StatusUnauthorized, "NOT_AUTHENTICATED", "not authenticated") @@ -209,7 +225,7 @@ func (s *Server) handleProxy(w http.ResponseWriter, r *http.Request) { } } - s.proxy.ServeHTTP(w, proxy.WithToken(r, jwt)) + rp.ServeHTTP(w, proxy.WithToken(r, jwt)) } // --------------------------------------------------------------------------- diff --git a/portals/ai-workspace/bff/internal/server/middleware.go b/portals/ai-workspace/bff/internal/server/middleware.go index 878ed46957..f55f314d37 100644 --- a/portals/ai-workspace/bff/internal/server/middleware.go +++ b/portals/ai-workspace/bff/internal/server/middleware.go @@ -35,6 +35,10 @@ func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler } // securityHeaders sets global response headers (ports the nginx index.html block). +// +// Referrer-Policy is strict-origin-when-cross-origin (not no-referrer) so Moesif +// wrap/basic embeds can read the parent console origin for embed allowlisting. +// Matches the Insights iframe referrerPolicy attribute. func securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() @@ -42,7 +46,7 @@ func securityHeaders(next http.Handler) http.Handler { h.Set("X-Frame-Options", "DENY") h.Set("Content-Security-Policy", "frame-ancestors 'self'") h.Set("X-Content-Type-Options", "nosniff") - h.Set("Referrer-Policy", "no-referrer") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") h.Set("X-Permitted-Cross-Domain-Policies", "none") next.ServeHTTP(w, r) }) diff --git a/portals/ai-workspace/bff/internal/server/routes.go b/portals/ai-workspace/bff/internal/server/routes.go index 6cd54cff23..602b8bbb02 100644 --- a/portals/ai-workspace/bff/internal/server/routes.go +++ b/portals/ai-workspace/bff/internal/server/routes.go @@ -68,9 +68,11 @@ func (s *Server) routes() http.Handler { mux.HandleFunc("POST "+s.path("/api/llm-providers"), s.handleCreateLLMProvider) mux.HandleFunc("POST "+s.path("/api/mcp-proxies"), s.handleCreateMCPServer) - // Same-origin reverse proxy to the Platform API. The proxy's Rewrite hook - // strips the base path and the proxy prefix before forwarding (see - // server.New), so we register the subtree directly. + // Same-origin reverse proxy to the Platform API. Optional cloud hop is more + // specific (/proxy/cloud/) and must be registered before the catch-all /proxy/. + if s.cloudProxy != nil { + mux.HandleFunc(s.path(paths.Proxy)+"/cloud/", s.handleCloudProxy) + } mux.HandleFunc(s.path(paths.Proxy)+"/", s.handleProxy) // SPA static files + client-side routing fallback (must be last). The prefix is diff --git a/portals/ai-workspace/bff/internal/server/server.go b/portals/ai-workspace/bff/internal/server/server.go index 1a45dc9b72..f4f64e6ed0 100644 --- a/portals/ai-workspace/bff/internal/server/server.go +++ b/portals/ai-workspace/bff/internal/server/server.go @@ -46,13 +46,14 @@ type refreshLock struct { // Server holds the BFF dependencies and HTTP handler. type Server struct { - cfg *config.Config - claims session.ClaimMapping - store session.Store - fileBased *auth.FileBased - oidc *auth.OIDC - proxy *httputil.ReverseProxy - handler http.Handler + cfg *config.Config + claims session.ClaimMapping + store session.Store + fileBased *auth.FileBased + oidc *auth.OIDC + proxy *httputil.ReverseProxy + cloudProxy *httputil.ReverseProxy + handler http.Handler refreshMu sync.Mutex refreshLocks map[string]*refreshLock @@ -95,6 +96,22 @@ func New(ctx context.Context, cfg *config.Config) (*Server, error) { refreshLocks: make(map[string]*refreshLock), } + if cfg.ControlPlane.CloudURL != "" { + cloudTarget, err := url.Parse(cfg.ControlPlane.CloudURL) + if err != nil { + return nil, err + } + cloudTransport, err := proxy.NewTransport(cfg.HTTPClient, proxy.TLSClientOptions{ + CAFile: cfg.ControlPlane.CloudCAFile, + SkipVerify: cfg.ControlPlane.CloudTLSSkipVerify, + }) + if err != nil { + return nil, err + } + // Strip /proxy/cloud so /analytics/id-token joins onto CloudURL's /cloud. + s.cloudProxy = proxy.ReverseProxy(cloudTarget, paths.Base+paths.Proxy+"/cloud", cloudTransport) + } + if cfg.Auth.OIDCEnabled() { // The session store exists only to hold OIDC refresh/id tokens for renewal. // File-based sessions are fully self-contained in the cookie JWT. diff --git a/portals/ai-workspace/src/App.tsx b/portals/ai-workspace/src/App.tsx index a7cfc692fb..1d81de756d 100644 --- a/portals/ai-workspace/src/App.tsx +++ b/portals/ai-workspace/src/App.tsx @@ -87,6 +87,7 @@ import { Box, Button, Stack, Typography } from '@wso2/oxygen-ui'; import OoopsImage from './assets/images/Ooops.svg'; import { AI_WORKSPACE_GATEWAYS_SLOT, + AI_WORKSPACE_INSIGHTS_SLOT, AI_WORKSPACE_SIDEBAR_SLOT, ExtensionsProvider, hiddenRegionsOf, @@ -97,6 +98,43 @@ import { import { Hideable, HiddenRegionsProvider, useSlot } from './slots'; import { usePort } from './hostPort'; +/** Moesif wrap hosts the cloud Insights embed trusts (keep in sync with the plugin allowlist). */ +const ALLOWED_MOESIF_ORIGINS = new Set([ + 'https://www.moesif.com', + 'https://web-dev.moesif.com', +]); + +/** + * Whether cloud Insights can embed Moesif. Kept in App (not imported from the + * plugin) so the OSS portal builds without `@wso2-enterprise/apip-cloud-ui-insights`. + * When false, InsightsRoute keeps the built-in page even if a cloud override is registered. + */ +function isCloudInsightsMoesifConfigured(): boolean { + const runtimeWindow = window as Window & { + __RUNTIME_CONFIG__?: Record; + config?: Record; + }; + const configured = + runtimeWindow.__RUNTIME_CONFIG__?.APIP_AIW_MOESIF_WEB_URL || + runtimeWindow.__RUNTIME_CONFIG__?.moesifAppUrl || + runtimeWindow.__RUNTIME_CONFIG__?.MOESIF_APP_URL || + runtimeWindow.config?.APIP_AIW_MOESIF_WEB_URL || + runtimeWindow.config?.moesifAppUrl || + import.meta.env.APIP_AIW_MOESIF_WEB_URL || + import.meta.env.VITE_MOESIF_APP_URL || + ''; + const trimmed = String(configured).trim(); + if (!trimmed) return false; + try { + const parsed = new URL(trimmed); + return ( + parsed.protocol === 'https:' && ALLOWED_MOESIF_ORIGINS.has(parsed.origin) + ); + } catch { + return false; + } +} + /** * Only allow same-origin relative paths as return URLs to prevent open redirects. * Rejects protocol-relative URLs (//evil.com) and absolute URLs. @@ -311,6 +349,23 @@ function GatewaysRoute() { ); } +// Same Slot/Hideable pattern as GatewaysRoute for the built-in Insights page +// (org + project `/insights`). Cloud registers embedProfile="ai-workspace". +// If the Moesif origin is not configured, keep the built-in page instead of +// replacing it with "Insights is not configured for this deployment." +function InsightsRoute() { + const port = usePort(); + const [override] = useSlot(AI_WORKSPACE_INSIGHTS_SLOT); + if (override && isCloudInsightsMoesifConfigured()) { + return <>{override.render(port)}; + } + return ( + + + + ); +} + export type AppProps = { extensions?: readonly AIWorkspaceCloudEntry[]; }; @@ -584,7 +639,7 @@ function WorkspaceRoutes({ extensions = [] }: AppProps) { path="insights" element={ - + } /> @@ -837,7 +892,7 @@ function WorkspaceRoutes({ extensions = [] }: AppProps) { path="insights" element={ - + } /> diff --git a/portals/ai-workspace/src/extensions.tsx b/portals/ai-workspace/src/extensions.tsx index cacd119244..ceb8717ade 100644 --- a/portals/ai-workspace/src/extensions.tsx +++ b/portals/ai-workspace/src/extensions.tsx @@ -57,6 +57,13 @@ export type AIWorkspaceExtension = SlotEntry & { */ export const AI_WORKSPACE_GATEWAYS_SLOT = 'page.gateways'; +/** + * Slot for overriding the built-in Insights page (org + project `/insights`) + * with the shared cloud Moesif embed. Same Slot/Hideable split as gateways — + * the built-in sidebar item and routes stay; only the page body changes. + */ +export const AI_WORKSPACE_INSIGHTS_SLOT = 'page.insights'; + /** * A host-injected replacement for a specific built-in page. Unlike * `AIWorkspaceExtension`, this isn't a new sidebar item — the built-in diff --git a/portals/ai-workspace/tsconfig.json b/portals/ai-workspace/tsconfig.json index 6111aa41a6..6a45794295 100644 --- a/portals/ai-workspace/tsconfig.json +++ b/portals/ai-workspace/tsconfig.json @@ -16,7 +16,13 @@ "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", - "types": ["vite/client", "node"] + "baseUrl": ".", + "types": ["vite/client", "node"], + "paths": { + "@wso2-enterprise/apip-cloud-ui-insights": [ + "../cloud-plugins/apip-cloud-ui-insights/src/index.ts" + ] + } }, "include": ["src"] } diff --git a/portals/ai-workspace/vite.config.ts b/portals/ai-workspace/vite.config.ts index 4781624006..5a0836e807 100644 --- a/portals/ai-workspace/vite.config.ts +++ b/portals/ai-workspace/vite.config.ts @@ -23,6 +23,7 @@ import basicSsl from '@vitejs/plugin-basic-ssl' // Pinned to 7.1.0: its peer range is vite >=4 <=7. Newer major releases (8.x, 9.x) // require vite >=7, incompatible with this project's vite@5.4.21. import istanbul from 'vite-plugin-istanbul' +import fs from 'node:fs' import path from 'path' import { fileURLToPath } from 'url' @@ -32,6 +33,17 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const repoRoot = path.resolve(__dirname, '../../..') +const cloudPluginsRoot = path.resolve(__dirname, '../cloud-plugins') +const insightsLocalEntry = path.resolve( + cloudPluginsRoot, + 'apip-cloud-ui-insights/src/index.ts' +) +// Monorepo: alias to the sibling plugin source. Docker SaaS images only copy +// portals/ai-workspace into /web and install Insights into node_modules — the +// sibling path is absent there, so skip the alias and let Node resolve the package. +const insightsAlias = fs.existsSync(insightsLocalEntry) + ? { '@wso2-enterprise/apip-cloud-ui-insights': insightsLocalEntry } + : {} const rushTemp = path.resolve(repoRoot, 'common/temp') const aiTemp = path.resolve(rushTemp, 'ai-workspace') const aiNodeModules = path.resolve(aiTemp, 'node_modules') @@ -147,12 +159,16 @@ export default defineConfig({ envPrefix: browserSafeEnvVars, resolve: { dedupe: ['react', 'react-dom', 'react/jsx-runtime', 'react/jsx-dev-runtime'], + alias: { + ...insightsAlias, + }, }, server: { port: 9643, fs: { allow: [ path.resolve(__dirname), + ...(fs.existsSync(cloudPluginsRoot) ? [cloudPluginsRoot] : []), repoRoot, rushTemp, aiTemp, diff --git a/portals/api-control-plane/bff/internal/config/config.go b/portals/api-control-plane/bff/internal/config/config.go index 68a129e11f..101c8dec6d 100644 --- a/portals/api-control-plane/bff/internal/config/config.go +++ b/portals/api-control-plane/bff/internal/config/config.go @@ -119,6 +119,9 @@ type ControlPlaneConfig struct { // Upstreams are additional named backends, each proxied same-origin at // {name-derived prefix}/*. Optional; empty for every standalone deployment. Upstreams []UpstreamConfig `koanf:"upstreams"` + // MoesifAppURL is the HTTPS origin of the Moesif wrap/basic host (e.g. + // https://www.moesif.com). Bridged to the SPA as moesifAppUrl when set. + MoesifAppURL string `koanf:"moesif_app_url"` } // UpstreamConfig is one [[api_control_plane.control_plane.upstreams]] entry: a diff --git a/portals/api-control-plane/bff/internal/config/runtime_config.go b/portals/api-control-plane/bff/internal/config/runtime_config.go index dc57648c10..635701134e 100644 --- a/portals/api-control-plane/bff/internal/config/runtime_config.go +++ b/portals/api-control-plane/bff/internal/config/runtime_config.go @@ -40,11 +40,19 @@ func buildRuntimeConfig(cfg *Config) map[string]string { // false client-side) for every deployment that doesn't configure one — // every standalone deployment today. for _, u := range cfg.ControlPlane.Upstreams { - if u.Name == "billing" { + switch u.Name { + case "billing": out["billingProxyEnabled"] = "true" - break + case "cloud": + out["cloudProxyEnabled"] = "true" } } + // Moesif wrap/basic iframe origin for cloud Insights embeds. Emitted + // explicitly so the SPA never guesses from environmentName. + if cfg.ControlPlane.MoesifAppURL != "" { + out["moesifAppUrl"] = cfg.ControlPlane.MoesifAppURL + } + return out } diff --git a/portals/api-control-plane/bff/internal/config/runtime_config_test.go b/portals/api-control-plane/bff/internal/config/runtime_config_test.go index 382e314466..104a14c451 100644 --- a/portals/api-control-plane/bff/internal/config/runtime_config_test.go +++ b/portals/api-control-plane/bff/internal/config/runtime_config_test.go @@ -51,6 +51,39 @@ func TestBuildRuntimeConfig_BillingProxyEnabledWhenUpstreamConfigured(t *testing } } +func TestBuildRuntimeConfig_CloudProxyEnabledWhenUpstreamConfigured(t *testing.T) { + cfg := &Config{ + Auth: AuthConfig{Mode: "basic"}, + ControlPlane: ControlPlaneConfig{ + ProxyPrefix: "/proxy", + Upstreams: []UpstreamConfig{{Name: "cloud", URL: "https://platform-api.example.com"}}, + }, + } + out := buildRuntimeConfig(cfg) + + if out["cloudProxyEnabled"] != "true" { + t.Errorf(`out["cloudProxyEnabled"] = %q, want "true"`, out["cloudProxyEnabled"]) + } + if _, present := out["billingProxyEnabled"]; present { + t.Error(`out["billingProxyEnabled"] should be absent when only "cloud" upstream is configured`) + } +} + +func TestBuildRuntimeConfig_MoesifAppUrlWhenConfigured(t *testing.T) { + cfg := &Config{ + Auth: AuthConfig{Mode: "basic"}, + ControlPlane: ControlPlaneConfig{ + ProxyPrefix: "/proxy", + MoesifAppURL: "https://www.moesif.com", + }, + } + out := buildRuntimeConfig(cfg) + + if out["moesifAppUrl"] != "https://www.moesif.com" { + t.Errorf(`out["moesifAppUrl"] = %q, want "https://www.moesif.com"`, out["moesifAppUrl"]) + } +} + func TestBuildRuntimeConfig_NeverEmitsClientSecretOrAuthority(t *testing.T) { // The BFF performs the whole OIDC handshake server-side — the SPA must // never receive the client identity or secret. diff --git a/portals/api-control-plane/bff/internal/server/middleware.go b/portals/api-control-plane/bff/internal/server/middleware.go index 705123163f..1b2ca7cbb8 100644 --- a/portals/api-control-plane/bff/internal/server/middleware.go +++ b/portals/api-control-plane/bff/internal/server/middleware.go @@ -38,6 +38,10 @@ func chain(h http.Handler, mws ...func(http.Handler) http.Handler) http.Handler // only sent when the deployment expects HTTPS (Session.Cookie.Secure) — an // unconditional HSTS header would pin a plain-HTTP local-dev origin to HTTPS // for a year, breaking the very deployment that set Secure=false to allow it. +// +// Referrer-Policy is strict-origin-when-cross-origin (not no-referrer) so Moesif +// wrap/basic embeds can read the parent console origin for embed allowlisting. +// Matches the Insights iframe referrerPolicy attribute. func (s *Server) securityHeaders(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := w.Header() @@ -47,7 +51,7 @@ func (s *Server) securityHeaders(next http.Handler) http.Handler { h.Set("X-Frame-Options", "DENY") h.Set("Content-Security-Policy", "frame-ancestors 'self'") h.Set("X-Content-Type-Options", "nosniff") - h.Set("Referrer-Policy", "no-referrer") + h.Set("Referrer-Policy", "strict-origin-when-cross-origin") h.Set("X-Permitted-Cross-Domain-Policies", "none") next.ServeHTTP(w, r) }) diff --git a/portals/api-control-plane/src/config/runtime.test.ts b/portals/api-control-plane/src/config/runtime.test.ts index d2f7df46ed..6dbef6864d 100644 --- a/portals/api-control-plane/src/config/runtime.test.ts +++ b/portals/api-control-plane/src/config/runtime.test.ts @@ -94,4 +94,22 @@ describe('runtimeConfig', () => { expect(runtimeConfig.billingProxyEnabled).toBe(false); }); + + it('cloudProxyEnabled defaults to false when absent', async () => { + const runtimeConfig = await loadRuntimeConfig(); + + expect(runtimeConfig.cloudProxyEnabled).toBe(false); + }); + + it('reads cloudProxyEnabled and moesifAppUrl from runtime config', async () => { + window.__RUNTIME_CONFIG__ = { + cloudProxyEnabled: 'true', + moesifAppUrl: 'https://www.moesif.com', + }; + + const runtimeConfig = await loadRuntimeConfig(); + + expect(runtimeConfig.cloudProxyEnabled).toBe(true); + expect(runtimeConfig.moesifAppUrl).toBe('https://www.moesif.com'); + }); }); diff --git a/portals/api-control-plane/src/config/runtime.ts b/portals/api-control-plane/src/config/runtime.ts index 8f4e6f3aa0..489c9f6678 100644 --- a/portals/api-control-plane/src/config/runtime.ts +++ b/portals/api-control-plane/src/config/runtime.ts @@ -41,6 +41,17 @@ export type RuntimeConfig = { * (/proxy/billing/...) — the browser never learns the real billing URL. */ billingProxyEnabled: boolean; + /** + * Set when the BFF has a "cloud" named upstream configured (cloud only). + * When true, cloud Insights extensions may call it via the same-origin + * proxy (/proxy/cloud/...) — the browser never learns the real cloud URL. + */ + cloudProxyEnabled: boolean; + /** + * Moesif wrap/basic iframe origin (HTTPS). Absent when Insights embed is + * not configured for this deployment. + */ + moesifAppUrl: string; /** * Same-origin path the BFF proxies to the Platform API (typically * "/proxy") — the browser only ever calls this BFF's own origin, which @@ -90,6 +101,10 @@ type LegacyWindowConfig = Partial<{ ORGANIZATION_API_URL: string; BILLING_PROXY_ENABLED: string; billingProxyEnabled: boolean | string; + CLOUD_PROXY_ENABLED: string; + cloudProxyEnabled: boolean | string; + MOESIF_APP_URL: string; + moesifAppUrl: string; DEFAULT_LOCALE: string; defaultLocale: string; PLATFORM_API_BASE_URL: string; @@ -205,6 +220,16 @@ export const runtimeConfig: RuntimeConfig = { fromWindow().billingProxyEnabled || import.meta.env.VITE_BILLING_PROXY_ENABLED, ), + cloudProxyEnabled: readBoolean( + fromWindow().CLOUD_PROXY_ENABLED || + fromWindow().cloudProxyEnabled || + import.meta.env.VITE_CLOUD_PROXY_ENABLED + ), + moesifAppUrl: + fromWindow().MOESIF_APP_URL || + fromWindow().moesifAppUrl || + import.meta.env.VITE_MOESIF_APP_URL || + '', platformApiBaseUrl: resolvedPlatformApiBaseUrl, platformApiVersion: fromWindow().PLATFORM_API_VERSION || diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx b/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx index 44256f61cf..18818fed01 100644 --- a/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx +++ b/portals/api-control-plane/src/navigation/useNavigationItems.test.tsx @@ -211,4 +211,109 @@ describe('host-injected sidebar extensions', () => { expect(items.find((entry) => entry.id === settingsTab.id)).toBeUndefined(); }); + + it('hides built-in Insights outside API scope when cloud Insights extensions load', () => { + const orgInsights: ApiControlPlaneExtension = { + id: 'organization-insights', + label: 'Insights', + level: 'organization', + order: 60, + group: 'api', + render: () =>
Cloud Insights
, + routePath: 'insights', + slot: 'sidebar.organization', + isVisible: (scope) => { + const typed = scope as { + isOrganizationScope?: boolean; + isProjectScope?: boolean; + isApiScope?: boolean; + }; + return ( + Boolean(typed.isOrganizationScope) && + !typed.isProjectScope && + !typed.isApiScope + ); + }, + }; + + const atOrg = () => + makeConsoleScope({ + isApiScope: false, + isProjectScope: false, + params: { orgHandle: ORG }, + project: undefined, + }); + + const items = itemsWithExtensions( + atOrg(), + `/organizations/${ORG}/home`, + [orgInsights] + ); + expect(items.find((entry) => entry.id === 'insights')).toBeUndefined(); + expect(items.find((entry) => entry.id === 'organization-insights')).toBeDefined(); + + const insightsIndex = items.findIndex( + (entry) => entry.id === 'organization-insights' + ); + const observabilityIndex = items.findIndex( + (entry) => entry.id === 'observability' + ); + expect(insightsIndex).toBeGreaterThan(-1); + expect(observabilityIndex).toBeGreaterThan(-1); + expect(insightsIndex).toBeLessThan(observabilityIndex); + }); + + it('keeps built-in Insights submenu in API scope with cloud extensions loaded', () => { + const cloudInsights: ApiControlPlaneExtension = { + id: 'organization-insights', + label: 'Insights', + level: 'organization', + order: 60, + group: 'api', + render: () =>
Cloud Insights
, + routePath: 'insights', + slot: 'sidebar.organization', + isVisible: (scope) => { + const typed = scope as { + isOrganizationScope?: boolean; + isProjectScope?: boolean; + isApiScope?: boolean; + }; + return ( + Boolean(typed.isOrganizationScope) && + !typed.isProjectScope && + !typed.isApiScope + ); + }, + }; + + const atApi = () => + makeConsoleScope({ + isApiScope: true, + isProjectScope: true, + params: { + apiHandler: API, + orgHandle: ORG, + projectHandler: PROJECT, + }, + component: COMPONENT, + }); + + const items = itemsWithExtensions( + atApi(), + `/organizations/${ORG}/projects/${PROJECT}/apis/${API}/insights/api`, + [cloudInsights] + ); + + expect(items.find((entry) => entry.id === 'insights')).toBeDefined(); + expect(items.find((entry) => entry.id === 'organization-insights')).toBeUndefined(); + }); + + it('keeps built-in Insights at org scope when no cloud Insights extensions are registered', () => { + const items = itemsAt(atOrg(), routes.organizationHome(ORG)); + + expect(items.find((entry) => entry.id === 'insights')).toBeDefined(); + expect(items.find((entry) => entry.id === 'organization-insights')).toBeUndefined(); + expect(items.find((entry) => entry.id === 'project-insights')).toBeUndefined(); + }); }); diff --git a/portals/api-control-plane/src/navigation/useNavigationItems.ts b/portals/api-control-plane/src/navigation/useNavigationItems.ts index b506c77c0f..71eb9757b4 100644 --- a/portals/api-control-plane/src/navigation/useNavigationItems.ts +++ b/portals/api-control-plane/src/navigation/useNavigationItems.ts @@ -29,6 +29,7 @@ import { isPageOverride, isSidebarExtension, useExtensions, + type ApiControlPlaneExtension, } from '../extensions'; import { navigationRegistry } from './navigationRegistry'; import { @@ -55,6 +56,29 @@ const isScopeSatisfied = ( return true; }; +const CLOUD_INSIGHTS_SIDEBAR_IDS = new Set([ + 'organization-insights', + 'project-insights', +]); + +const hasCloudInsightsSidebar = (extensions: readonly ApiControlPlaneExtension[]) => + extensions.some( + (extension) => + isSidebarExtension(extension) && + CLOUD_INSIGHTS_SIDEBAR_IDS.has(extension.id) + ); + +/** + * The built-in Insights submenu and the cloud org/project Insights extensions + * both link to Insights outside API scope — keep only the cloud entries then. + */ +const isBuiltinInsightsHiddenByCloudPlugin = ( + definition: NavigationDefinition, + scope: ConsoleScope, + cloudInsightsLoaded: boolean +) => + definition.id === 'insights' && cloudInsightsLoaded && !scope.isApiScope; + export const useNavigationItems = (): NavigationItem[] => { const scope = useConsoleScope(); const location = useLocation(); @@ -126,6 +150,7 @@ export const useNavigationItems = (): NavigationItem[] => { ? { ...definition, group: override.group ?? definition.group, order: override.order } : definition; }); + const cloudInsightsLoaded = hasCloudInsightsSidebar(extensions); const combinedRegistry = [...registryWithOverrides, ...extensionDefinitions]; // A definition becomes an item unless it has no target at all. Children go @@ -136,6 +161,15 @@ export const useNavigationItems = (): NavigationItem[] => { definition: NavigationDefinition ): NavigationItem | undefined => { if (!isFeatureEnabled(definition)) return undefined; + if ( + isBuiltinInsightsHiddenByCloudPlugin( + definition, + scope, + cloudInsightsLoaded + ) + ) { + return undefined; + } if (!(definition.isVisible?.(scope) ?? true)) return undefined; const to = definition.to(scope); diff --git a/portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx b/portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx index f9f65980b2..670e62946f 100644 --- a/portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx +++ b/portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx @@ -19,8 +19,11 @@ import { PageTitle } from '@wso2/oxygen-ui'; import { defineMessages, FormattedMessage } from 'react-intl'; +import { ComingSoon } from '@/components/ComingSoon'; import { ExternalToolPanel } from '@/components/common/ExternalToolPanel'; import { runtimeConfig } from '@/config/runtime'; +import { routes } from '@/routes/paths'; +import { ScopeGate } from '@/scope/ScopeGate'; const messages = defineMessages({ action: { @@ -29,6 +32,10 @@ const messages = defineMessages({ description: 'Button that opens the Moesif analytics console in a new tab. Moesif is a product name — leave it untranslated.', }, + cloudFeature: { + id: 'appShell.insightsPage.feature', + defaultMessage: 'API insights', + }, panelDescription: { id: 'apiControlPlane.pages.appShell.appShellPages.insights.InsightsPage.panelDescription', defaultMessage: @@ -49,6 +56,21 @@ const messages = defineMessages({ }); export function InsightsPage() { + // Cloud ships org/project Moesif embeds via the insights plugin; API-scoped + // analytics is not ready yet, so show Coming Soon when the cloud proxy is on + // (same signal that gates those sidebar extensions). + if (runtimeConfig.cloudProxyEnabled) { + return ( + + } /> + + ); + } + return ( <> diff --git a/portals/cloud-plugins/apip-cloud-ui-insights/README.md b/portals/cloud-plugins/apip-cloud-ui-insights/README.md new file mode 100644 index 0000000000..09df070851 --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-insights/README.md @@ -0,0 +1,43 @@ +# APIP Cloud UI — Insights + +Shared Moesif Insights embed for **API Control Plane** and **AI Workspace**. +The plugin is the same; each host registry picks an `embedProfile` so the +iframe *path* differs. The Moesif *origin* is runtime config; the path is not. + +## Layout + +``` +src/ + InsightsFeature.tsx # extension entry (scope resolution) + InsightsEmbed.tsx # Moesif wrap/basic iframe handshake + hostPort.ts # host port contract + types.ts # shared types (includes InsightsEmbedProfile) + api/ + analyticsApi.ts # WSO2 Cloud analytics endpoints + config/ + runtimeConfig.ts # BFF proxy + Moesif URL resolution + utils/ + moesifEmbed.ts # iframe URL builders + postMessage types + routeParams.ts # pathname → org/project handles + components/ + StateViews.tsx # loading / error states +overlays/ + api-control-plane/ + InsightsPage.tsx # cloud Docker overlay (API scope Coming soon) +``` + +## Embed modes (`embedProfile`) + +| Profile | Org iframe | Project iframe | +|---------|------------|----------------| +| `api-control-plane` (default) | `/wrap/basic#auth=post` | `/wrap/basic?project_id=…` (falls back to org if resolve fails) | +| `ai-workspace` | `/wrap/basic/ai-overview?embedded_ui=true&isolated_section=true#auth=post` | **same URL** (no project_id filter) | + +Do **not** put `/wrap/basic` or `/ai-overview` in env — only the Moesif origin. + +## Host registration + +- **API Control Plane** — org/project sidebar via `apip-cloud-ui/src/hosts/api-control-plane.tsx` +- **AI Workspace** — `page.insights` override (like gateways) via `hosts/ai-workspace.tsx`. + If Moesif origin is not in runtime config, `InsightsRoute` keeps the built-in + Insights page instead of showing a configuration error. diff --git a/portals/cloud-plugins/apip-cloud-ui-insights/overlays/api-control-plane/InsightsPage.tsx b/portals/cloud-plugins/apip-cloud-ui-insights/overlays/api-control-plane/InsightsPage.tsx new file mode 100644 index 0000000000..eb7b797060 --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-insights/overlays/api-control-plane/InsightsPage.tsx @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + * Cloud build overlay for API Control Plane Insights. Copy this file over + * `portals/api-control-plane/src/pages/appShell/appShellPages/insights/InsightsPage.tsx` + * when assembling a cloud deployment image. + */ + +import { FormattedMessage } from 'react-intl'; + +import { ComingSoon } from '../../../../components/ComingSoon'; +import { routes } from '../../../../routes/paths'; +import { ScopeGate } from '../../../../scope/ScopeGate'; + +export function InsightsPage() { + return ( + + + } + /> + + ); +} diff --git a/portals/cloud-plugins/apip-cloud-ui-insights/package.json b/portals/cloud-plugins/apip-cloud-ui-insights/package.json new file mode 100644 index 0000000000..8fbcd16b3a --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-insights/package.json @@ -0,0 +1,31 @@ +{ + "name": "@wso2-enterprise/apip-cloud-ui-insights", + "version": "0.1.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "scripts": { + "build": "tsc --noEmit", + "_phase:build": "tsc --noEmit", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@wso2/oxygen-ui": "0.5.0", + "@wso2/oxygen-ui-icons-react": "0.5.0", + "react": "19.2.3" + }, + "devDependencies": { + "@testing-library/dom": "10.4.1", + "@testing-library/jest-dom": "6.6.3", + "@testing-library/react": "16.3.0", + "@types/react": "19.2.17", + "@vitejs/plugin-react": "4.7.0", + "jsdom": "26.1.0", + "react-dom": "19.2.3", + "typescript": "5.9.3", + "vitest": "3.2.4" + } +} diff --git a/portals/cloud-plugins/apip-cloud-ui-insights/src/InsightsEmbed.test.tsx b/portals/cloud-plugins/apip-cloud-ui-insights/src/InsightsEmbed.test.tsx new file mode 100644 index 0000000000..44afcdb863 --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-insights/src/InsightsEmbed.test.tsx @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { act, render, screen, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { MOESIF_EMBEDDED_POST_MESSAGE_TYPES } from './utils/moesifEmbed'; + +const mockFetchViewerToken = vi.fn(); + +vi.mock('./api/analyticsApi', () => ({ + fetchViewerToken: () => mockFetchViewerToken(), +})); + +vi.mock('./config/runtimeConfig', () => ({ + insightsRuntimeConfig: { + moesifAppUrl: 'https://web-dev.moesif.com', + platformApiBaseUrl: '/proxy', + platformApiVersion: 'v0.9', + }, +})); + +vi.mock('./components/StateViews', () => ({ + LoadingState: ({ label }: { label?: string }) => ( +
{label}
+ ), + ErrorState: ({ title, message }: { title: string; message: string }) => ( +
+ {title}: {message} +
+ ), +})); + +vi.mock('@wso2/oxygen-ui', () => ({ + Box: ({ + children, + ...props + }: { + children?: ReactNode; + sx?: unknown; + }) =>
{children}
, + PageTitle: Object.assign( + ({ children }: { children?: ReactNode }) =>
{children}
, + { + Header: ({ children }: { children?: ReactNode }) =>

{children}

, + SubHeader: ({ children }: { children?: ReactNode }) =>

{children}

, + } + ), +})); + +import InsightsEmbed from './InsightsEmbed'; + +describe('InsightsEmbed', () => { + beforeEach(() => { + mockFetchViewerToken.mockReset(); + mockFetchViewerToken.mockResolvedValue('viewer-token'); + }); + + it('keeps the Moesif iframe hidden until the embed handshake completes', async () => { + render(); + + const iframe = await waitFor(() => { + const element = screen.getByTitle('Moesif Insights') as HTMLIFrameElement; + expect(element).toBeInTheDocument(); + return element; + }); + + expect(iframe.style.display).toBe('none'); + expect(screen.getByTestId('loading-state')).toBeInTheDocument(); + + act(() => { + window.dispatchEvent( + new MessageEvent('message', { + data: { + type: MOESIF_EMBEDDED_POST_MESSAGE_TYPES.SCHEMA_GEN_FINISHED, + }, + origin: 'https://web-dev.moesif.com', + // wrap/basic can post from nested frames. + source: null, + }) + ); + }); + + await waitFor(() => { + expect(iframe.style.display).toBe('block'); + }); + expect(screen.queryByTestId('loading-state')).not.toBeInTheDocument(); + }); +}); diff --git a/portals/cloud-plugins/apip-cloud-ui-insights/src/InsightsEmbed.tsx b/portals/cloud-plugins/apip-cloud-ui-insights/src/InsightsEmbed.tsx new file mode 100644 index 0000000000..a01bc3a28d --- /dev/null +++ b/portals/cloud-plugins/apip-cloud-ui-insights/src/InsightsEmbed.tsx @@ -0,0 +1,345 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, PageTitle } from '@wso2/oxygen-ui'; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type FC, +} from 'react'; + +import { fetchViewerToken } from './api/analyticsApi'; +import { ErrorState, LoadingState } from './components/StateViews'; +import { insightsRuntimeConfig } from './config/runtimeConfig'; +import type { InsightsEmbedProfile, InsightsEmbedScope } from './types'; +import { + ALLOWED_MOESIF_ORIGINS, + MOESIF_EMBEDDED_POST_MESSAGE_TYPES, + buildAiWorkspaceIframeSrc, + buildBasicIframeSrc, + buildBasicProjectIframeSrc, + resolveMoesifEmbeddingOrigin, +} from './utils/moesifEmbed'; + +/** Match choreo-console ProtectedRoute: refresh before Moesif viewer token expiry (~1h). */ +const VIEWER_TOKEN_REFRESH_INTERVAL_MS = 50 * 60 * 1000; +/** wrap/basic is a SPA; first iframe load may happen before SET_TOKEN listener binds. */ +const SET_TOKEN_RETRY_MS = 400; +/** Surface actionable UI if handshake never finishes. */ +const EMBED_HANDSHAKE_TIMEOUT_MS = 120_000; + +const isMoesifHandshakeDone = (type: unknown) => + type === MOESIF_EMBEDDED_POST_MESSAGE_TYPES.SCHEMA_GEN_FINISHED || + type === MOESIF_EMBEDDED_POST_MESSAGE_TYPES.ORG_LOAD_FINISHED; + +export type InsightsEmbedProps = { + scope: InsightsEmbedScope; + /** Host-chosen Moesif iframe path profile. Defaults to API Control Plane. */ + embedProfile?: InsightsEmbedProfile; +}; + +const InsightsEmbedConfigured: FC< + InsightsEmbedProps & { moesifAppUrl: string } +> = ({ moesifAppUrl, scope, embedProfile = 'api-control-plane' }) => { + const iframeRef = useRef(null); + const tokenRef = useRef(null); + const embeddingOrigin = resolveMoesifEmbeddingOrigin(moesifAppUrl); + + const iframeSrc = useMemo(() => { + if (embedProfile === 'ai-workspace') { + return buildAiWorkspaceIframeSrc(moesifAppUrl); + } + if (scope.level === 'project') { + return buildBasicProjectIframeSrc(moesifAppUrl, scope.projectId || ''); + } + return buildBasicIframeSrc(moesifAppUrl); + }, [embedProfile, moesifAppUrl, scope.level, scope.projectId]); + + const [viewerToken, setViewerToken] = useState(null); + const [tokenError, setTokenError] = useState(null); + const [tokenLoading, setTokenLoading] = useState(true); + const [isIframeDomLoaded, setIsIframeDomLoaded] = useState(false); + const [isEmbedReady, setIsEmbedReady] = useState(false); + const [handshakeError, setHandshakeError] = useState(null); + const [embedAttempt, setEmbedAttempt] = useState(0); + + const pageSubheader = useMemo(() => { + if (scope.level === 'project') { + return scope.projectName + ? `Explore usage analytics for ${scope.projectName} powered by Moesif.` + : 'Explore usage analytics and traffic insights powered by Moesif.'; + } + return 'Explore usage analytics and traffic insights powered by Moesif.'; + }, [scope.level, scope.projectName]); + + useEffect(() => { + tokenRef.current = viewerToken; + }, [viewerToken]); + + useEffect(() => { + setIsIframeDomLoaded(false); + setIsEmbedReady(false); + setHandshakeError(null); + }, [iframeSrc, scope.level]); + + const retryHandshake = useCallback(() => { + setHandshakeError(null); + setIsEmbedReady(false); + setIsIframeDomLoaded(false); + setEmbedAttempt((attempt) => attempt + 1); + }, []); + + const mintToken = useCallback(async () => fetchViewerToken(), []); + + useEffect(() => { + let cancelled = false; + setTokenLoading(true); + setTokenError(null); + + mintToken() + .then((token) => { + if (!cancelled) setViewerToken(token); + }) + .catch((err: unknown) => { + if (!cancelled) { + setTokenError(err instanceof Error ? err.message : String(err)); + } + }) + .finally(() => { + if (!cancelled) setTokenLoading(false); + }); + + return () => { + cancelled = true; + }; + }, [mintToken]); + + useEffect(() => { + if (!viewerToken) return; + + const intervalId = window.setInterval(() => { + mintToken() + .then((token) => setViewerToken(token)) + .catch(() => undefined); + }, VIEWER_TOKEN_REFRESH_INTERVAL_MS); + + return () => window.clearInterval(intervalId); + }, [mintToken, viewerToken]); + + const sendTokenToChild = useCallback(() => { + const child = iframeRef.current?.contentWindow; + if (!child || !viewerToken) return; + + child.postMessage( + { + type: MOESIF_EMBEDDED_POST_MESSAGE_TYPES.SET_TOKEN, + token: viewerToken, + }, + embeddingOrigin + ); + }, [embeddingOrigin, viewerToken]); + + useEffect(() => { + if (!isIframeDomLoaded || !viewerToken || isEmbedReady) return; + sendTokenToChild(); + const intervalId = window.setInterval(sendTokenToChild, SET_TOKEN_RETRY_MS); + return () => window.clearInterval(intervalId); + }, [isEmbedReady, isIframeDomLoaded, sendTokenToChild, viewerToken]); + + useEffect(() => { + if ( + !isIframeDomLoaded || + tokenLoading || + tokenError || + handshakeError || + !viewerToken || + !iframeSrc || + isEmbedReady + ) { + return; + } + + const timeoutId = window.setTimeout(() => { + setHandshakeError( + 'Moesif Insights did not finish loading in time. Please try again.' + ); + }, EMBED_HANDSHAKE_TIMEOUT_MS); + + return () => window.clearTimeout(timeoutId); + }, [ + handshakeError, + iframeSrc, + isEmbedReady, + isIframeDomLoaded, + tokenError, + tokenLoading, + viewerToken, + ]); + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + if (!ALLOWED_MOESIF_ORIGINS.has(event.origin)) return; + + const type = event.data?.type; + if (isMoesifHandshakeDone(type)) { + setHandshakeError(null); + setIsEmbedReady(true); + return; + } + if (type === MOESIF_EMBEDDED_POST_MESSAGE_TYPES.REFRESH_TOKEN) { + mintToken() + .then((token) => setViewerToken(token)) + .catch(() => undefined); + } + }; + + window.addEventListener('message', handleMessage); + return () => window.removeEventListener('message', handleMessage); + }, [mintToken]); + + const showLoader = + tokenLoading || + (!tokenError && + !handshakeError && + !isEmbedReady && + Boolean(viewerToken && iframeSrc)); + + // Hide the iframe until Moesif finishes its handshake so wrap/basic pre-auth UI + // (cookie banners, login shell) never flashes through the loader — same pattern + // as choreo-console Insights.tsx (display: none until SCHEMA_GEN_FINISHED). + const iframeStyle: CSSProperties = { + backgroundColor: 'transparent', + border: 'none', + display: isEmbedReady ? 'block' : 'none', + height: '100%', + width: '100%', + }; + + return ( + + + + Insights + {pageSubheader} + + + + {tokenError ? ( + + ) : handshakeError ? ( + + ) : ( + + {showLoader && ( + + + + )} + {viewerToken && iframeSrc && ( +