Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion dashboards/src/components/Panel/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ export const Panel = memo(function Panel(props: PanelProps) {
}

try {
const plugin = await getPlugin('Panel', panelPluginKind);
const plugin = await getPlugin({ kind: 'Panel', name: panelPluginKind });

// More defensive checking for plugin and actions
if (
Expand Down
5 changes: 4 additions & 1 deletion dashboards/src/context/DatasourceStoreProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,10 @@ export function DatasourceStoreProvider(props: DatasourceStoreProviderProps): Re
const getDatasourceClient = useCallback(
async function getClient<Client extends DatasourceClient>(selector: DatasourceSelector): Promise<Client> {
const { kind } = selector;
const [{ spec, proxyUrl }, plugin] = await Promise.all([findDatasource(selector), getPlugin('Datasource', kind)]);
const [{ spec, proxyUrl }, plugin] = await Promise.all([
findDatasource(selector),
getPlugin({ kind: 'Datasource', name: kind }),
]);

// allows extending client
const client = plugin.createClient(spec.plugin.spec, { proxyUrl }) as Client;
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

81 changes: 67 additions & 14 deletions plugin-system/src/components/PluginRegistry/PluginRegistry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import {
} from '../../model';
import { PluginRegistryContext } from '../../runtime';
import { useEvent } from '../../utils';
import { usePluginIndexes, getTypeAndKindKey } from './plugin-indexes';
import { usePluginIndexes, PluginCompoundKey } from './plugin-indexes';

export type PluginPreference = { kind: PluginType; name: string; version: string; registry: string };
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this type is internal only from what I can see.

Suggested change
export type PluginPreference = { kind: PluginType; name: string; version: string; registry: string };
type PluginPreference = { kind: PluginType; name: string; version: string; registry: string };


export interface PluginRegistryProps {
pluginLoader: PluginLoader;
Expand Down Expand Up @@ -62,28 +64,79 @@ export function PluginRegistry(props: PluginRegistryProps): ReactElement {
});

const getPlugin = useCallback(
async <T extends PluginType>(kind: T, name: string): Promise<PluginImplementation<T>> => {
// Get the indexes of the installed plugins
async <T extends PluginType>(compoundKeyObj: PluginCompoundKey<T>): Promise<PluginImplementation<T>> => {
const pluginIndexes = await getPluginIndexes();
let resource: PluginModuleResource | undefined = undefined;
let pluginModule: Record<string, Plugin<UnknownSpec>> | undefined;
const { kind, name, version, registry } = compoundKeyObj;

/**
* The logic has been developed based on the following discussion:
* https://github.com/perses/shared/pull/128#discussion_r3200721179
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe is better to add the rationale rather than a link to a discussion.

*/

// Figure out what module the plugin is in by looking in the index
const typeAndKindKey = getTypeAndKindKey(kind, name);
const resource = pluginIndexes.pluginResourcesByNameAndKind.get(typeAndKindKey);
if (resource === undefined) {
if (registry || version) {
let compoundKey = '';
/**
* likely keys are
* 1- kind:name:registry:version
* 2- kind:name::version
* 3- kind:name:registry:
* The last one should be looked up with startsWith
* Because there might be 2 different versions from the same registry
*/
compoundKey = `${kind}:${name}:${registry}:${version}`;
resource =
pluginIndexes.pluginResourcesByNameAndKind.get(compoundKey) ||
pluginIndexes.pluginResourcesByNameAndKind.get(
Array.from(pluginIndexes.pluginResourcesByNameAndKind.keys()).find((key) =>
key.startsWith(compoundKey, 0)
) ?? ''
);

if (resource) {
pluginModule = (await loadPluginModule(resource)) as Record<string, Plugin<UnknownSpec>>;
const plugin = pluginModule?.[`${name}:${registry ?? ''}:${version ?? ''}`];
if (!plugin) {
throw new Error(
`The ${name} plugin for kind '${kind}' is missing from the ${resource!.metadata.name} plugin module`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
`The ${name} plugin for kind '${kind}' is missing from the ${resource!.metadata.name} plugin module`
`The ${name} plugin for kind '${kind}' is missing from the ${resource.metadata.name} plugin module`

);
}
return plugin as PluginImplementation<T>;
}
}
/**
* Here we need to CRAFT a resource from an EXISTING ONE with empty version and registry
* This way we can rely on the backend to get the latest version
* Remember, an EXISTING ONE is coming from getInstalledPlugins, so it includes the spec.plugins (which is used in subsequent steps)
* To craft a resource:
* 1- we search through the pluginIndexes (which is provided from installedPlugins) to find a resource which starts with kind:name
* 2- in metadata we set the version to empty_string and registry to undefined
* 3- rest of the structure should remain intact
* 4- we pass this crafted resource to loadPluginModule
* 5- since the version and registry are empty the url will be baseUrl/moduleName///mf-manifest.json
* 6- the backend handle the empty version and registry and returns the latest
*/
const resourceKey = Array.from(pluginIndexes.pluginResourcesByNameAndKind.keys()).find((k) =>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this search is non deterministic, it might find an older version rather than the latest which should be the default

Copy link
Copy Markdown
Contributor Author

@shahrokni shahrokni May 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't really matter if it finds an old one or new one.
We just need a found object of PluginModuleResource which starts with the kind:name: pattern.
As you can see we are crafting our own object by setting both version and registry to empty and undefined respectively. We use this object as a kind of payload to get to a point to see those information are not available.
Since they are not available, the fallback mechanism works and returns the default one.

Why do we need it anyway? Because of the list of plugins it provides. There is a step which loops through the list of plugins to register them individually per module

     const resourceKey = Array.from(pluginIndexes.pluginResourcesByNameAndKind.keys()).find((k) =>
        k.startsWith(`${kind}:${name}:`, 0)
      );
      resource = pluginIndexes.pluginResourcesByNameAndKind.get(resourceKey || '');

      if (!resource) {
        throw new Error(`A ${name} plugin for kind '${kind}' is not installed`);
      }

      const unversionedResource: PluginModuleResource = {
        ...resource,
        metadata: { ...resource.metadata, version: '', registry: undefined },
      };

Again, I would like to draw your attention to how the legacy code gets the default plugin for the very first time
#128 (comment)

k.startsWith(`${kind}:${name}:`, 0)
);
resource = pluginIndexes.pluginResourcesByNameAndKind.get(resourceKey || '');

if (!resource) {
throw new Error(`A ${name} plugin for kind '${kind}' is not installed`);
}

// Treat the plugin module as a bunch of named exports that have plugins
const pluginModule = (await loadPluginModule(resource)) as Record<string, Plugin<UnknownSpec>>;
resource.metadata.registry = undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of mutating directly we should probably load from a copy.

resource.metadata.version = '';

pluginModule = (await loadPluginModule(resource)) as Record<string, Plugin<UnknownSpec>>;

// We currently assume that plugin modules will have named exports that match the kinds they handle
const plugin = pluginModule[name];
if (plugin === undefined) {
const plugin = pluginModule?.[`${name}:${registry ?? ''}:${version ?? ''}`];
if (!plugin) {
throw new Error(
`The ${name} plugin for kind '${kind}' is missing from the ${resource.metadata.name} plugin module`
`The ${name} plugin for kind '${kind}' is missing from the ${resource!.metadata.name} plugin module`
);
}

return plugin as PluginImplementation<T>;
},
[getPluginIndexes, loadPluginModule]
Expand Down
24 changes: 18 additions & 6 deletions plugin-system/src/components/PluginRegistry/plugin-indexes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,20 @@ export function usePluginIndexes(
const pluginMetadataByKind = new Map<PluginType, PluginMetadataWithModule[]>();

for (const resource of installedPlugins) {
const {
metadata: { version, registry },
} = resource;
for (const pluginMetadata of resource.spec.plugins) {
const {
kind,
spec: { name },
} = pluginMetadata;

// Index the plugin by type and kind to point at the module that contains it
const key = getTypeAndKindKey(kind, name);
const key = getPluginModuleCompoundKey({ kind, name, registry, version });
if (pluginResourcesByNameAndKind.has(key)) {
console.warn(`Got more than one ${kind} plugin for kind ${name}`);
console.warn(
`Got more than one ${kind} plugin for kind ${name}, registry '${registry || 'undefined'}', and version '${version || 'undefined'}'`
);
}
pluginResourcesByNameAndKind.set(key, resource);

Expand Down Expand Up @@ -84,9 +88,17 @@ export function usePluginIndexes(
return getPluginIndexes;
}

export type PluginCompoundKey<T extends PluginType> = {
kind: T;
name: string;
registry?: string;
version?: string;
};

/**
* Gets a unique key for a plugin type/kind that can be used as a cache key.
* Gets a unique key for a plugin type/kind/version/registry that can be used as a cache key.
*/
export function getTypeAndKindKey(kind: PluginType, name: string): string {
return `${kind}:${name}`;
export function getPluginModuleCompoundKey(compoundKey: PluginCompoundKey<PluginType>): string {
const { kind, name, registry, version } = compoundKey;
return `${kind}:${name}:${registry ?? ''}:${version ?? ''}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ export function PluginSpecEditor(props: PluginSpecEditorProps): ReactElement | n
...others
} = props;
const { data: plugin, isLoading, error } = usePlugin(pluginType, pluginKind);

if (error) {
return <ErrorAlert error={error} />;
}
Expand Down
29 changes: 21 additions & 8 deletions plugin-system/src/model/plugin-loading.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,20 +35,33 @@ export interface DynamicImportPlugin {
* the plugin itself via a dynamic `import()` statement.
*/
export function dynamicImportPluginLoader(plugins: DynamicImportPlugin[]): PluginLoader {
const importMap: Map<PluginModuleResource, DynamicImportPlugin['importPlugin']> = new Map(
plugins.map((plugin) => [plugin.resource, plugin.importPlugin])
);

const importMap: Map<string, { resource: PluginModuleResource; importPlugin: DynamicImportPlugin['importPlugin'] }> =
new Map();
for (const p of plugins) {
const {
resource,
resource: {
kind,
metadata: { name, registry, version },
},
importPlugin,
} = p;
importMap.set(`${kind}:${name}:${registry ?? ''}:${version ?? ''}`, { resource, importPlugin });
}
return {
async getInstalledPlugins(): Promise<PluginModuleResource[]> {
return Promise.resolve(Array.from(importMap.keys()));
return Promise.resolve(Array.from(importMap.values()).map((v) => v.resource));
},
importPluginModule(resource): Promise<unknown> {
const importFn = importMap.get(resource);
if (importFn === undefined) {
const {
kind,
metadata: { name, version, registry },
} = resource;
const { importPlugin } = importMap.get(`${kind}:${name}:${registry ?? ''}:${version ?? ''}`) || {};
if (importPlugin === undefined) {
throw new Error('Plugin not found');
}
return importFn();
return importPlugin();
},
};
}
3 changes: 3 additions & 0 deletions plugin-system/src/model/plugins.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ export interface PluginMetadata {
kind: PluginType;
spec: {
name: string;
version?: string;
registry?: string;
display: {
name: string;
description?: string;
Expand All @@ -50,6 +52,7 @@ export interface PluginMetadata {
export interface PluginModuleMetadata {
name: string;
version: string;
registry?: string;
}

/**
Expand Down
2 changes: 2 additions & 0 deletions plugin-system/src/remote/PersesPlugin.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@

export interface PersesPlugin {
name: string;
version?: string;
registry?: string;
moduleName: string;
baseURL?: string;
}
Expand Down
38 changes: 25 additions & 13 deletions plugin-system/src/remote/PluginRuntime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,31 +214,40 @@ const getPluginRuntime = (): ModuleFederation => {
return instance;
};

const registerRemote = (name: string, baseURL?: string): void => {
const registerRemote = (name: string, registry?: string, version?: string, baseURL?: string): void => {
const pluginRuntime = getPluginRuntime();

const existingRemote = pluginRuntime.options.remotes.find((remote) => remote.name === name);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are breaking the cache as the remotes is now tracked by registryName

if (!existingRemote) {
const remoteEntryURL = baseURL ? `${baseURL}/${name}/mf-manifest.json` : `/plugins/${name}/mf-manifest.json`;
const segments = [registry, version].filter(Boolean);
const suffix = segments.length ? `/${segments.join('/')}` : '';
const prefix = baseURL || '/plugins';
const remoteEntryURL = `${prefix}/${name}${suffix}/mf-manifest.json`.replace(/\/+/g, '/');

const registryName = `${name}:${registry ?? ''}:${version ?? ''}`;
pluginRuntime.registerRemotes([
{
name,
name: registryName,
entry: remoteEntryURL,
alias: name,
alias: registryName,
},
]);
}
};

export const loadPlugin = async (
moduleName: string,
pluginName: string,
baseURL?: string
): Promise<RemotePluginModule | null> => {
registerRemote(moduleName, baseURL);
export const loadPlugin = async (target: {
moduleName: string;
pluginName: string;
registry?: string;
version?: string;
baseURL?: string;
}): Promise<RemotePluginModule | null> => {
const { moduleName, pluginName, registry, version, baseURL } = target;
registerRemote(moduleName, registry, version, baseURL);

const pluginRuntime = getPluginRuntime();

return pluginRuntime.loadRemote<RemotePluginModule>(`${moduleName}/${pluginName}`);
const registryName = `${moduleName}:${registry ?? ''}:${version ?? ''}`;
return pluginRuntime.loadRemote<RemotePluginModule>(`${registryName}/${pluginName}`);
};

export function usePluginRuntime({ plugin }: { plugin: PersesPlugin }): {
Expand All @@ -247,6 +256,9 @@ export function usePluginRuntime({ plugin }: { plugin: PersesPlugin }): {
} {
return {
pluginRuntime: getPluginRuntime(),
loadPlugin: () => loadPlugin(plugin.moduleName, plugin.name, plugin.baseURL),
loadPlugin: (): Promise<RemotePluginModule | null> => {
const { moduleName, name: pluginName, registry, version, baseURL } = plugin;
return loadPlugin({ moduleName, pluginName, registry, version, baseURL });
},
};
}
Loading
Loading