diff --git a/explore/src/components/ExploreManager/ExploreManager.test.tsx b/explore/src/components/ExploreManager/ExploreManager.test.tsx
new file mode 100644
index 00000000..0f9baa0c
--- /dev/null
+++ b/explore/src/components/ExploreManager/ExploreManager.test.tsx
@@ -0,0 +1,62 @@
+// Copyright The Perses Authors
+// Licensed 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 { render, waitFor } from '@testing-library/react';
+
+import { ExploreManager } from './ExploreManager';
+import { ExplorerManagerProvider } from './ExplorerManagerProvider';
+
+const pluginLoaderComponent = vi.fn<(props: unknown) => null>(() => null);
+const listPluginMetadata = vi.fn();
+
+vi.mock('@perses-dev/plugin-system', () => ({
+ PluginLoaderComponent: (props: unknown): null => pluginLoaderComponent(props),
+ useListPluginMetadata: (): unknown => listPluginMetadata(),
+}));
+
+vi.mock('../ExploreToolbar', () => ({
+ ExploreToolbar: (): null => null,
+}));
+
+describe('ExploreManager', () => {
+ it('should load the explorer plugin with its module version and registry', async () => {
+ listPluginMetadata.mockReturnValue({
+ data: [
+ {
+ kind: 'Explore',
+ spec: { name: 'TempoExplorer', display: { name: 'Tempo' } },
+ module: { name: 'Tempo', version: '0.59.0', registry: 'perses' },
+ },
+ ],
+ });
+
+ render(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(pluginLoaderComponent).toHaveBeenCalledWith(
+ expect.objectContaining({
+ plugin: {
+ name: 'TempoExplorer',
+ moduleName: 'Tempo',
+ version: '0.59.0',
+ registry: 'perses',
+ },
+ }),
+ );
+ });
+ });
+});
diff --git a/explore/src/components/ExploreManager/ExploreManager.tsx b/explore/src/components/ExploreManager/ExploreManager.tsx
index 98cecb55..b724fcc7 100644
--- a/explore/src/components/ExploreManager/ExploreManager.tsx
+++ b/explore/src/components/ExploreManager/ExploreManager.tsx
@@ -118,6 +118,8 @@ export function ExploreManager(props: ExploreManagerProps): ReactElement {
plugin={{
name: currentPlugin.spec.name,
moduleName: currentPlugin.module.name,
+ version: currentPlugin.module.version,
+ registry: currentPlugin.module.registry,
}}
/>
)}
diff --git a/plugin-system/src/remote/PluginRuntime.test.ts b/plugin-system/src/remote/PluginRuntime.test.ts
new file mode 100644
index 00000000..c940ac06
--- /dev/null
+++ b/plugin-system/src/remote/PluginRuntime.test.ts
@@ -0,0 +1,63 @@
+// Copyright The Perses Authors
+// Licensed 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 { loadPlugin } from './PluginRuntime';
+import { remotePluginLoader } from './remotePluginLoader';
+
+const registerRemotes = vi.fn();
+const loadRemote = vi.fn().mockResolvedValue({});
+
+vi.mock('@module-federation/enhanced/runtime', () => ({
+ createInstance: vi.fn(() => ({ options: { remotes: [] }, registerRemotes, loadRemote })),
+}));
+
+describe('loadPlugin', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should use the plugin baseURL when provided', async () => {
+ remotePluginLoader({ baseURL: '/perses', apiPrefix: '/perses' });
+
+ await loadPlugin({
+ moduleName: 'Tempo',
+ pluginName: 'TempoExplorer',
+ version: '0.59.0',
+ baseURL: 'https://cdn.example.com/plugins',
+ });
+
+ expect(registerRemotes).toHaveBeenCalledWith([
+ expect.objectContaining({ entry: 'https://cdn.example.com/plugins/Tempo~0.59.0/mf-manifest.json' }),
+ ]);
+ });
+
+ it('should fall back to /plugins when the loader has no base URL', async () => {
+ remotePluginLoader();
+
+ await loadPlugin({ moduleName: 'Tempo', pluginName: 'TempoExplorer', version: '0.59.0' });
+
+ expect(registerRemotes).toHaveBeenCalledWith([
+ expect.objectContaining({ entry: '/plugins/Tempo~0.59.0/mf-manifest.json' }),
+ ]);
+ });
+
+ it('should fall back to the base URL configured on the loader when the plugin has none', async () => {
+ remotePluginLoader({ baseURL: '/perses', apiPrefix: '/perses' });
+
+ await loadPlugin({ moduleName: 'Tempo', pluginName: 'TempoExplorer', version: '0.59.0' });
+
+ expect(registerRemotes).toHaveBeenCalledWith([
+ expect.objectContaining({ entry: '/perses/plugins/Tempo~0.59.0/mf-manifest.json' }),
+ ]);
+ });
+});
diff --git a/plugin-system/src/remote/PluginRuntime.tsx b/plugin-system/src/remote/PluginRuntime.tsx
index f3b9e2cc..9592d12e 100644
--- a/plugin-system/src/remote/PluginRuntime.tsx
+++ b/plugin-system/src/remote/PluginRuntime.tsx
@@ -25,6 +25,16 @@ import type { PersesPlugin, RemotePluginModule } from './PersesPlugin.types';
let instance: ModuleFederation | null = null;
+let pluginsAssetsBaseURL = '/plugins';
+
+/**
+ * Sets the base URL used to resolve plugin assets for plugins that don't carry their own `baseURL`. The plugin loader
+ * registers the path it was configured with, so plugins keep resolving when Perses is served behind a sub-path.
+ */
+export function setPluginsAssetsBaseURL(baseURL: string): void {
+ pluginsAssetsBaseURL = baseURL;
+}
+
function createSharedModuleLoader(loadModule: () => Promise): () => Promise<() => TModule> {
return async () => {
const module = await loadModule();
@@ -263,7 +273,7 @@ const registerRemote = (name: string, registry?: string, version?: string, baseU
const existingRemote = pluginRuntime.options.remotes.find((remote) => remote.name === registryName);
if (!existingRemote) {
const nameVersionRegistry = [name, version, registry].filter(Boolean).join('~');
- const prefix = baseURL || '/plugins';
+ const prefix = baseURL || pluginsAssetsBaseURL;
const remoteEntryURL = `${prefix}/${nameVersionRegistry}/mf-manifest.json`;
pluginRuntime.registerRemotes([
diff --git a/plugin-system/src/remote/remotePluginLoader.test.ts b/plugin-system/src/remote/remotePluginLoader.test.ts
index fff5cbb6..9e86da70 100644
--- a/plugin-system/src/remote/remotePluginLoader.test.ts
+++ b/plugin-system/src/remote/remotePluginLoader.test.ts
@@ -27,6 +27,7 @@ import { remotePluginLoader } from './remotePluginLoader';
// Mock the loadPlugin function
vi.mock('./PluginRuntime', () => ({
loadPlugin: vi.fn(),
+ setPluginsAssetsBaseURL: vi.fn(),
}));
const mockLoadPlugin = vi.mocked(loadPlugin);
diff --git a/plugin-system/src/remote/remotePluginLoader.ts b/plugin-system/src/remote/remotePluginLoader.ts
index a3500fc1..92693327 100644
--- a/plugin-system/src/remote/remotePluginLoader.ts
+++ b/plugin-system/src/remote/remotePluginLoader.ts
@@ -17,7 +17,7 @@ import type { PluginLoader, PluginMetadata, PluginModuleResource, PluginType } f
import { getPluginModuleCompoundKey } from '@perses-dev/plugin-system';
import type { RemotePluginModule } from './PersesPlugin.types';
-import { loadPlugin } from './PluginRuntime';
+import { loadPlugin, setPluginsAssetsBaseURL } from './PluginRuntime';
const isPluginMetadata = (plugin: unknown): plugin is PluginMetadata => {
return (
@@ -92,6 +92,8 @@ export function remotePluginLoader(options?: RemotePluginLoaderOptions): PluginL
const { pluginsApiPath, pluginsAssetsPath } = paramToOptions(options);
const fetchFn = options?.fetchFn ?? defaultFetch;
+ setPluginsAssetsBaseURL(pluginsAssetsPath);
+
return {
getInstalledPlugins: async (): Promise => {
const pluginsResponse = await fetchFn(pluginsApiPath);